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
|
||||||
|
|
||||||
The Biergarten App is a full-stack directory and discovery platform for
|
The Biergarten App is a full-stack directory and discovery platform for
|
||||||
breweries. It features a robust user authentication system, a searchable
|
breweries. It provides:
|
||||||
database of brewery locations, and a custom offline data-generation pipeline
|
|
||||||
that uses LLMs (Llama.cpp) and Wikipedia to synthesize realistic seed data.
|
|
||||||
|
|
||||||
It features:
|
- JWT-based user authentication, with email confirmation and refresh-token
|
||||||
|
rotation
|
||||||
|
- A searchable database of brewery locations
|
||||||
|
- An offline data-generation pipeline that uses large language models (LLMs)
|
||||||
|
via Llama.cpp and Wikipedia to synthesize realistic seed data
|
||||||
|
|
||||||
|
The repository is organized into:
|
||||||
|
|
||||||
- A .NET backend (Web API + database migrations/seed) under `web/backend/`
|
- A .NET backend (Web API + database migrations/seed) under `web/backend/`
|
||||||
- A server-rendered React website (React Router + Vite) under `web/frontend/`
|
- A server-rendered React website (React Router + Vite) under `web/frontend/`
|
||||||
- A C++20 “pipeline” CLI for generating seed data under `tooling/pipeline/`
|
- A C++20 data-generation CLI under `tooling/pipeline/`
|
||||||
|
|
||||||
Specialized documentation (setup, architecture, docker, testing, diagrams, and
|
Specialized documentation (setup, architecture, docker, testing, diagrams, and
|
||||||
pipeline notes) lives under `docs/`.
|
pipeline notes) lives under `docs/`.
|
||||||
|
|
||||||
## Documentation (Start Here)
|
## Documentation
|
||||||
|
|
||||||
Website + backend (active stack):
|
Website + backend (active stack):
|
||||||
|
|
||||||
@@ -37,7 +41,7 @@ Data generation pipeline (C++):
|
|||||||
- [Authentication Flow](docs/website/diagrams-out/authentication-flow.svg)
|
- [Authentication Flow](docs/website/diagrams-out/authentication-flow.svg)
|
||||||
- [Database Schema](docs/website/diagrams-out/database-schema.svg)
|
- [Database Schema](docs/website/diagrams-out/database-schema.svg)
|
||||||
|
|
||||||
## Current Status
|
## Current status
|
||||||
|
|
||||||
Active areas in the repository:
|
Active areas in the repository:
|
||||||
|
|
||||||
@@ -52,18 +56,27 @@ Archived/reference areas:
|
|||||||
- `archive/next-js-web-app/` contains an older Next.js frontend retained for
|
- `archive/next-js-web-app/` contains an older Next.js frontend retained for
|
||||||
reference
|
reference
|
||||||
|
|
||||||
## Tech Stack
|
## Tech stack
|
||||||
|
|
||||||
- **Backend**: .NET 10, ASP.NET Core, SQL Server 2022, DbUp
|
- **Backend**: .NET 10, ASP.NET Core, SQL Server 2022, DbUp
|
||||||
- **Frontend**: React 19, React Router 7, Vite 7, Tailwind CSS 4, DaisyUI 5
|
- **Frontend**: React 19, React Router 7, Vite 7, Tailwind CSS 4, DaisyUI 5
|
||||||
- **UI Documentation**: Storybook 10, Vitest browser mode, Playwright
|
- **UI Documentation**: Storybook 10, Vitest browser mode, Playwright
|
||||||
- **Testing**: xUnit, Reqnroll (BDD), FluentAssertions, Moq
|
- **Testing**: xUnit, Reqnroll (BDD), FluentAssertions, Moq
|
||||||
- **Infrastructure**: Docker, Docker Compose
|
- **Infrastructure**: Docker, Docker Compose
|
||||||
- **Security**: Argon2id password hashing, JWT access/refresh/confirmation
|
- **Security**: Argon2id password hashing, JSON Web Tokens (JWT) for
|
||||||
tokens
|
access/refresh/confirmation
|
||||||
- **Data Pipeline**: C++20, CMake, Boost, libcurl, SQLite, llama.cpp
|
- **Data Pipeline**: C++20, CMake, Boost, libcurl, SQLite, llama.cpp
|
||||||
|
|
||||||
## Quick Start
|
## Requirements
|
||||||
|
|
||||||
|
- .NET SDK 10 or later
|
||||||
|
- Node.js 18 or later
|
||||||
|
- Docker Desktop, or an equivalent Docker Engine setup
|
||||||
|
|
||||||
|
See [Getting Started](docs/website/getting-started.md) for the full
|
||||||
|
prerequisite list, including the pipeline's C++ toolchain.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
For full setup details, use [Getting Started](docs/website/getting-started.md).
|
For full setup details, use [Getting Started](docs/website/getting-started.md).
|
||||||
This section is the shortest path to a working dev environment.
|
This section is the shortest path to a working dev environment.
|
||||||
@@ -101,7 +114,7 @@ npm run test:storybook
|
|||||||
npm run test:storybook:playwright
|
npm run test:storybook:playwright
|
||||||
```
|
```
|
||||||
|
|
||||||
## Repository Structure
|
## Repository structure
|
||||||
|
|
||||||
```text
|
```text
|
||||||
web/
|
web/
|
||||||
@@ -123,9 +136,10 @@ archive/
|
|||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Run the backend test stack with Docker:
|
Create a test environment file, then run the backend test stack with Docker:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
cp web/.env.example web/.env.test
|
||||||
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml up --abort-on-container-exit
|
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml up --abort-on-container-exit
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -147,5 +161,16 @@ See [Environment Variables](docs/website/environment-variables.md) for details.
|
|||||||
1. Fork the repository
|
1. Fork the repository
|
||||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||||
3. Commit your changes (`git commit -m 'Add amazing feature'`)
|
3. Commit your changes (`git commit -m 'Add amazing feature'`)
|
||||||
4. Push to the branch (`git push origin feature/amazing-feature`)
|
4. Run the checks in [Testing](docs/website/testing.md) (backend tests,
|
||||||
5. Open a Pull Request
|
`npm run lint`, `npm run typecheck`)
|
||||||
|
5. Push to the branch (`git push origin feature/amazing-feature`)
|
||||||
|
6. Open a Pull Request
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
Report bugs and request features via
|
||||||
|
[GitHub Issues](https://github.com/aaronpo97/the-biergarten-app/issues).
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
GPL-3.0. See [LICENSE.md](LICENSE.md).
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
This document describes the active architecture of The Biergarten App.
|
This document describes the active architecture of The Biergarten App.
|
||||||
|
|
||||||
## High-Level Overview
|
## High-level overview
|
||||||
|
|
||||||
The Biergarten App is a monorepo with a clear split between the backend and the
|
The Biergarten App is a monorepo with a clear split between the backend and the
|
||||||
active website:
|
active website:
|
||||||
@@ -29,9 +29,9 @@ For visual representations, see:
|
|||||||
- [database-schema.svg](website/diagrams-out/database-schema.svg) - Database
|
- [database-schema.svg](website/diagrams-out/database-schema.svg) - Database
|
||||||
relationships
|
relationships
|
||||||
|
|
||||||
## Backend Architecture
|
## Backend architecture
|
||||||
|
|
||||||
### Vertical Slice Architecture Pattern
|
### Vertical-slice architecture pattern
|
||||||
|
|
||||||
The backend organizes business capabilities as feature slices instead of
|
The backend organizes business capabilities as feature slices instead of
|
||||||
technical layers. Each feature (`Features.Auth`, `Features.Breweries`,
|
technical layers. Each feature (`Features.Auth`, `Features.Breweries`,
|
||||||
@@ -80,9 +80,9 @@ interaction (`Features.Auth` triggering a confirmation email handled by
|
|||||||
whose contract lives in `Shared.Application`, so neither slice takes a
|
whose contract lives in `Shared.Application`, so neither slice takes a
|
||||||
project reference on the other.
|
project reference on the other.
|
||||||
|
|
||||||
### Layer Responsibilities
|
### Layer responsibilities
|
||||||
|
|
||||||
#### API Layer (`API.Core`)
|
#### API layer (`API.Core`)
|
||||||
|
|
||||||
**Purpose**: Thin ASP.NET Core host: no business logic, no controllers of
|
**Purpose**: Thin ASP.NET Core host: no business logic, no controllers of
|
||||||
its own
|
its own
|
||||||
@@ -108,7 +108,7 @@ its own
|
|||||||
- No controllers, no business logic, no feature-specific contracts
|
- No controllers, no business logic, no feature-specific contracts
|
||||||
- Exists purely to host and wire up the feature slices
|
- Exists purely to host and wire up the feature slices
|
||||||
|
|
||||||
#### Feature Slices (`Features.Auth`, `Features.Breweries`, `Features.UserManagement`, `Features.Emails`)
|
#### Feature slices (`Features.Auth`, `Features.Breweries`, `Features.UserManagement`, `Features.Emails`)
|
||||||
|
|
||||||
**Purpose**: Each slice is the complete vertical for one business capability
|
**Purpose**: Each slice is the complete vertical for one business capability
|
||||||
|
|
||||||
@@ -146,7 +146,7 @@ MediatR commands sent from other slices, never over HTTP.
|
|||||||
MediatR
|
MediatR
|
||||||
- Read endpoints return a dedicated `Dto`, never the domain entity directly
|
- Read endpoints return a dedicated `Dto`, never the domain entity directly
|
||||||
|
|
||||||
#### Shared Projects (`Shared.Contracts`, `Shared.Application`)
|
#### Shared projects (`Shared.Contracts`, `Shared.Application`)
|
||||||
|
|
||||||
**Purpose**: The minimum cross-slice surface area required because every
|
**Purpose**: The minimum cross-slice surface area required because every
|
||||||
slice needs it, or because duplicating it four times would be worse than
|
slice needs it, or because duplicating it four times would be worse than
|
||||||
@@ -166,7 +166,7 @@ sharing it
|
|||||||
- Kept deliberately small: this is the exception to "no slice depends on
|
- Kept deliberately small: this is the exception to "no slice depends on
|
||||||
another slice," not a general-purpose dumping ground
|
another slice," not a general-purpose dumping ground
|
||||||
|
|
||||||
#### Infrastructure Layer
|
#### Infrastructure layer
|
||||||
|
|
||||||
**Purpose**: Technical capabilities and external integrations, shared by
|
**Purpose**: Technical capabilities and external integrations, shared by
|
||||||
whichever slices need them
|
whichever slices need them
|
||||||
@@ -185,14 +185,14 @@ whichever slices need them
|
|||||||
**Dependencies**:
|
**Dependencies**:
|
||||||
|
|
||||||
- Domain entities
|
- Domain entities
|
||||||
- External libraries (ADO.NET, JWT, Argon2, MailKit, etc.)
|
- External libraries (ADO.NET, JWT, Argon2, MailKit, and so on)
|
||||||
|
|
||||||
**Rules**:
|
**Rules**:
|
||||||
|
|
||||||
- Implements technical concerns only, no business logic
|
- Implements technical concerns only, no business logic
|
||||||
- Reusable across slices
|
- Reusable across slices
|
||||||
|
|
||||||
#### Domain Layer (`Domain.Entities`)
|
#### Domain layer (`Domain.Entities`)
|
||||||
|
|
||||||
**Purpose**: Core business entities and models
|
**Purpose**: Core business entities and models
|
||||||
|
|
||||||
@@ -213,9 +213,9 @@ whichever slices need them
|
|||||||
- No infrastructure references
|
- No infrastructure references
|
||||||
- Represents business concepts
|
- Represents business concepts
|
||||||
|
|
||||||
### Design Patterns
|
### Design patterns
|
||||||
|
|
||||||
#### Vertical Slice + MediatR
|
#### Vertical slice + MediatR
|
||||||
|
|
||||||
**Purpose**: Organize code by feature instead of by technical layer, so
|
**Purpose**: Organize code by feature instead of by technical layer, so
|
||||||
everything needed to understand or change one capability lives in one
|
everything needed to understand or change one capability lives in one
|
||||||
@@ -223,9 +223,9 @@ project
|
|||||||
|
|
||||||
**Implementation**:
|
**Implementation**:
|
||||||
|
|
||||||
- Each HTTP write operation is a `Command` (e.g. `CreateBreweryCommand` in
|
- Each HTTP write operation is a `Command` (for example, `CreateBreweryCommand`
|
||||||
`Features.Breweries/Commands/CreateBrewery/`); each read operation is a
|
in `Features.Breweries/Commands/CreateBrewery/`); each read operation is a
|
||||||
`Query` (e.g. `GetBreweryByIdQuery`)
|
`Query` (for example, `GetBreweryByIdQuery`)
|
||||||
- Controllers bind directly to the Command/Query as the request body;
|
- Controllers bind directly to the Command/Query as the request body;
|
||||||
there is no separate request DTO + mapping step for writes
|
there is no separate request DTO + mapping step for writes
|
||||||
- A single shared `ValidationBehavior<TRequest,TResponse>`
|
- A single shared `ValidationBehavior<TRequest,TResponse>`
|
||||||
@@ -246,7 +246,7 @@ public class CreateBreweryHandler(IBreweryRepository repository) : IRequestHandl
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Repository Pattern
|
#### Repository pattern
|
||||||
|
|
||||||
**Purpose**: Abstract database access behind interfaces
|
**Purpose**: Abstract database access behind interfaces
|
||||||
|
|
||||||
@@ -275,7 +275,7 @@ public interface IAuthRepository
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Dependency Injection
|
#### Dependency injection
|
||||||
|
|
||||||
**Purpose**: Loose coupling and testability
|
**Purpose**: Loose coupling and testability
|
||||||
|
|
||||||
@@ -289,7 +289,7 @@ extension method that registers its repository and slice-internal services
|
|||||||
- Singleton: `ISqlConnectionFactory`
|
- Singleton: `ISqlConnectionFactory`
|
||||||
- Transient: Utilities, helpers
|
- Transient: Utilities, helpers
|
||||||
|
|
||||||
#### SQL-First Approach
|
#### SQL-first approach
|
||||||
|
|
||||||
**Purpose**: Push complex logic into the database
|
**Purpose**: Push complex logic into the database
|
||||||
|
|
||||||
@@ -306,9 +306,9 @@ extension method that registers its repository and slice-internal services
|
|||||||
- `USP_GetUserAccountByUsername` - User lookup
|
- `USP_GetUserAccountByUsername` - User lookup
|
||||||
- `USP_RotateUserCredential` - Password update
|
- `USP_RotateUserCredential` - Password update
|
||||||
|
|
||||||
## Frontend Architecture
|
## Frontend architecture
|
||||||
|
|
||||||
### Active Website (`web/frontend`)
|
### Active website (`web/frontend`)
|
||||||
|
|
||||||
The current website is a React Router 7 application with server-side rendering
|
The current website is a React Router 7 application with server-side rendering
|
||||||
enabled.
|
enabled.
|
||||||
@@ -327,7 +327,7 @@ web/frontend/
|
|||||||
└── package.json Frontend scripts and dependencies
|
└── package.json Frontend scripts and dependencies
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend Responsibilities
|
### Frontend responsibilities
|
||||||
|
|
||||||
- Render the auth demo and theme guide routes
|
- Render the auth demo and theme guide routes
|
||||||
- Manage cookie-backed website session state
|
- Manage cookie-backed website session state
|
||||||
@@ -335,7 +335,7 @@ web/frontend/
|
|||||||
- Provide shared UI building blocks for forms, navigation, themes, and toasts
|
- Provide shared UI building blocks for forms, navigation, themes, and toasts
|
||||||
- Supply Storybook documentation and browser-based component verification
|
- Supply Storybook documentation and browser-based component verification
|
||||||
|
|
||||||
### Theme System
|
### Theme system
|
||||||
|
|
||||||
The active website uses semantic DaisyUI theme tokens backed by four Biergarten
|
The active website uses semantic DaisyUI theme tokens backed by four Biergarten
|
||||||
themes:
|
themes:
|
||||||
@@ -348,15 +348,15 @@ themes:
|
|||||||
All component styling should prefer semantic tokens such as `primary`,
|
All component styling should prefer semantic tokens such as `primary`,
|
||||||
`success`, `surface`, and `highlight` instead of hard-coded color values.
|
`success`, `surface`, and `highlight` instead of hard-coded color values.
|
||||||
|
|
||||||
### Legacy Frontend
|
### Legacy frontend
|
||||||
|
|
||||||
The previous Next.js frontend has been archived at `archive/next-js-web-app/`
|
The previous Next.js frontend has been archived at `archive/next-js-web-app/`
|
||||||
for reference only. Active product and engineering documentation should point
|
for reference only. Active product and engineering documentation should point
|
||||||
to `web/frontend`.
|
to `web/frontend`.
|
||||||
|
|
||||||
## Security Architecture
|
## Security architecture
|
||||||
|
|
||||||
### Authentication Flow
|
### Authentication flow
|
||||||
|
|
||||||
1. **Registration**:
|
1. **Registration**:
|
||||||
- User submits credentials
|
- User submits credentials
|
||||||
@@ -375,7 +375,7 @@ to `web/frontend`.
|
|||||||
- Middleware validates token
|
- Middleware validates token
|
||||||
- Request proceeds if valid
|
- Request proceeds if valid
|
||||||
|
|
||||||
### Password Security
|
### Password security
|
||||||
|
|
||||||
**Algorithm**: Argon2id
|
**Algorithm**: Argon2id
|
||||||
|
|
||||||
@@ -385,7 +385,7 @@ to `web/frontend`.
|
|||||||
- Salt: 128-bit (16 bytes)
|
- Salt: 128-bit (16 bytes)
|
||||||
- Hash: 256-bit (32 bytes)
|
- Hash: 256-bit (32 bytes)
|
||||||
|
|
||||||
### JWT Tokens
|
### JWT tokens
|
||||||
|
|
||||||
**Algorithm**: HS256 (HMAC-SHA256)
|
**Algorithm**: HS256 (HMAC-SHA256)
|
||||||
|
|
||||||
@@ -409,9 +409,9 @@ to `web/frontend`.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Database Architecture
|
## Database architecture
|
||||||
|
|
||||||
### SQL-First Philosophy
|
### SQL-first philosophy
|
||||||
|
|
||||||
**Principles**:
|
**Principles**:
|
||||||
|
|
||||||
@@ -427,7 +427,7 @@ to `web/frontend`.
|
|||||||
- Version-controlled schema (migrations)
|
- Version-controlled schema (migrations)
|
||||||
- Easier query profiling and tuning
|
- Easier query profiling and tuning
|
||||||
|
|
||||||
### Migration Strategy
|
### Migration strategy
|
||||||
|
|
||||||
**Tool**: DbUp
|
**Tool**: DbUp
|
||||||
|
|
||||||
@@ -448,7 +448,7 @@ scripts/
|
|||||||
└── ...
|
└── ...
|
||||||
```
|
```
|
||||||
|
|
||||||
### Data Seeding
|
### Data seeding
|
||||||
|
|
||||||
**Purpose**: Populate development/test databases
|
**Purpose**: Populate development/test databases
|
||||||
|
|
||||||
@@ -460,9 +460,9 @@ scripts/
|
|||||||
- Test user accounts
|
- Test user accounts
|
||||||
- Sample breweries (future)
|
- Sample breweries (future)
|
||||||
|
|
||||||
## Deployment Architecture
|
## Deployment architecture
|
||||||
|
|
||||||
### Docker Containerization
|
### Docker containerization
|
||||||
|
|
||||||
**Container Structure**:
|
**Container Structure**:
|
||||||
|
|
||||||
@@ -479,10 +479,10 @@ scripts/
|
|||||||
|
|
||||||
For details, see [Docker Guide](website/docker.md).
|
For details, see [Docker Guide](website/docker.md).
|
||||||
|
|
||||||
### Health Checks
|
### Health checks
|
||||||
|
|
||||||
**SQL Server**: Validates database connectivity **API**: Checks service health
|
- **SQL Server**: Validates database connectivity
|
||||||
and dependencies
|
- **API**: Checks service health and dependencies
|
||||||
|
|
||||||
**Configuration**:
|
**Configuration**:
|
||||||
|
|
||||||
@@ -494,9 +494,9 @@ healthcheck:
|
|||||||
start_period: 30s
|
start_period: 30s
|
||||||
```
|
```
|
||||||
|
|
||||||
## Testing Architecture
|
## Testing architecture
|
||||||
|
|
||||||
### Test Pyramid
|
### Test pyramid
|
||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────┐
|
┌──────────────┐
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
# Ethics, Bias, and Known Issues
|
# Ethics, bias, and known issues
|
||||||
|
|
||||||
This document covers the ethical context of the Biergarten Pipeline's output,
|
This document covers the ethical context of the Biergarten Pipeline's output,
|
||||||
the model's biases, and known issues including hallucinated brewing science and
|
the model's biases, and known issues including hallucinated brewing science and
|
||||||
low-resource language failures.
|
low-resource language failures.
|
||||||
|
|
||||||
> Note that all testing was used using `google_gemma-4-E4B-it-Q6_K.gguf`.
|
> All testing described below used `google_gemma-4-E4B-it-Q6_K.gguf`.
|
||||||
|
|
||||||
## Table of Contents
|
## Table of contents
|
||||||
|
|
||||||
- [What This Dataset Is](#what-this-dataset-is)
|
- [What this dataset is](#what-this-dataset-is)
|
||||||
- [What This Dataset Is Not](#what-this-dataset-is-not)
|
- [What this dataset is not](#what-this-dataset-is-not)
|
||||||
- [Model Bias and Language Quality](#model-bias-and-language-quality)
|
- [Model bias and language quality](#model-bias-and-language-quality)
|
||||||
- [Western and Eurocentric Lens](#western-and-eurocentric-lens)
|
- [Western and Eurocentric lens](#western-and-eurocentric-lens)
|
||||||
- [Wikipedia Enrichment](#wikipedia-enrichment)
|
- [Wikipedia enrichment](#wikipedia-enrichment)
|
||||||
- [Names-by-Country Dataset](#names-by-country-dataset)
|
- [Names-by-country dataset](#names-by-country-dataset)
|
||||||
- [The "Avoid AI Phrases" Prompt Instruction](#the-avoid-ai-phrases-prompt-instruction)
|
- [The "avoid AI phrases" prompt instruction](#the-avoid-ai-phrases-prompt-instruction)
|
||||||
- [Known Issues](#known-issues)
|
- [Known issues](#known-issues)
|
||||||
- [Hallucinated Brewing Techniques](#hallucinated-brewing-techniques)
|
- [Hallucinated brewing techniques](#hallucinated-brewing-techniques)
|
||||||
- [Low-Resource Language Hallucination](#low-resource-language-hallucination)
|
- [Low-resource language hallucination](#low-resource-language-hallucination)
|
||||||
- [Synthetic Postal Codes](#synthetic-postal-codes)
|
- [Synthetic postal codes](#synthetic-postal-codes)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What This Dataset Is
|
## What this dataset is
|
||||||
|
|
||||||
This is AI-generated fixture data for a proof-of-concept version of The
|
This is AI-generated fixture data for a proof-of-concept version of The
|
||||||
Biergarten App. Anyone who interacts with an application seeded from this
|
Biergarten App. Anyone who interacts with an application seeded from this
|
||||||
@@ -30,29 +30,29 @@ pipeline must be told upfront that the content is AI-generated.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What This Dataset Is Not
|
## What this dataset is not
|
||||||
|
|
||||||
The pipeline is not intended to produce accurate brewing science, faithful
|
The pipeline is not intended to produce accurate brewing science, faithful
|
||||||
cultural representation, or reliable local-language text. Hallucinations such as
|
cultural representation, or reliable local-language text. Hallucinations such as
|
||||||
invented fermentation techniques, or incoherent local-language prose, are
|
invented fermentation techniques, or incoherent local-language prose, are
|
||||||
expected, observed, and partially documented in [Known Issues](#known-issues)
|
expected, observed, and partially documented in [Known issues](#known-issues)
|
||||||
below.
|
below.
|
||||||
|
|
||||||
Human control sits at the context layer (i.e. prompt design, Wikipedia
|
Human control sits at the context layer (that is, prompt design, Wikipedia
|
||||||
enrichment). Statistical output shapes in future pipeline stages (check-in
|
enrichment). Statistical output shapes in future pipeline stages (check-in
|
||||||
distributions, rating skews, activity profiles) will be handled the same way.
|
distributions, rating skews, activity profiles) will be handled the same way.
|
||||||
|
|
||||||
**Treat this data as an exercise in prompt engineering and model behaviour, not
|
**Treat this data as an exercise in prompt engineering and model behaviour, not
|
||||||
as a source of truth for brewing techniques or cultural representation.**
|
as a source of truth for brewing techniques or cultural representation.**
|
||||||
|
|
||||||
**Natural language processing, although a powerful tool for data analysis and
|
**Natural language processing is a powerful tool for data analysis and
|
||||||
generation is to be taken with scrutiny. Human language is not simply just data
|
generation, but its output should be treated with scrutiny. Human language is
|
||||||
points to be analyzed, but it also carries deep cultural and human meaning that
|
not simply data points to be analyzed — it carries cultural and human meaning
|
||||||
artificial intelligence is incapable of.**
|
that artificial intelligence cannot capture.**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Model Bias and Language Quality
|
## Model bias and language quality
|
||||||
|
|
||||||
The underlying model's training biases surface within this pipeline. Output
|
The underlying model's training biases surface within this pipeline. Output
|
||||||
quality tracks with how well a language is represented in the training corpus:
|
quality tracks with how well a language is represented in the training corpus:
|
||||||
@@ -67,9 +67,9 @@ large language models trained predominantly on English-language
|
|||||||
material.[^llm-bias]
|
material.[^llm-bias]
|
||||||
|
|
||||||
Mitigations are documented in
|
Mitigations are documented in
|
||||||
[Known Issues: Low-Resource Language Hallucination](#low-resource-language-hallucination).
|
[Known issues: Low-resource language hallucination](#low-resource-language-hallucination).
|
||||||
|
|
||||||
### Western and Eurocentric Lens
|
### Western and Eurocentric lens
|
||||||
|
|
||||||
The model's training data skews heavily Western and North American. When
|
The model's training data skews heavily Western and North American. When
|
||||||
generating brewery descriptions for Kinshasa, Abidjan, or Osaka, for example, it
|
generating brewery descriptions for Kinshasa, Abidjan, or Osaka, for example, it
|
||||||
@@ -81,19 +81,19 @@ some generation in city-specific material, but it does not eliminate the skew.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Wikipedia Enrichment
|
## Wikipedia enrichment
|
||||||
|
|
||||||
City and beer context is fetched from the Wikipedia API. Wikipedia text is
|
City and beer context is fetched from the Wikipedia API. Wikipedia text is
|
||||||
co-licensed under the **Creative Commons Attribution-ShareAlike 4.0
|
co-licensed under the **Creative Commons Attribution-ShareAlike 4.0
|
||||||
International License (CC BY-SA 4.0)** and the **GNU Free Documentation License
|
International License (CC BY-SA 4.0)**[^cc-sa] and the **GNU Free Documentation
|
||||||
(GFDL)**.[^wp-license]
|
License (GFDL)**.[^wp-license]
|
||||||
|
|
||||||
Wikipedia's own accuracy limitations and editorial biases can propagate into
|
Wikipedia's own accuracy limitations and editorial biases can propagate into
|
||||||
generated descriptions.
|
generated descriptions.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Names-by-Country Dataset
|
## Names-by-country dataset
|
||||||
|
|
||||||
`tooling/pipeline/forenames-by-country.json` and `surnames-by-country.json`
|
`tooling/pipeline/forenames-by-country.json` and `surnames-by-country.json`
|
||||||
(used to sample a `Name` per ISO 3166-1 country code for user generation) are
|
(used to sample a `Name` per ISO 3166-1 country code for user generation) are
|
||||||
@@ -118,7 +118,7 @@ same way brewery generation skips cities whose enrichment lookup fails.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## The "Avoid AI Phrases" Prompt Instruction
|
## The "avoid AI phrases" prompt instruction
|
||||||
|
|
||||||
The system prompt instructs the model to avoid common AI-generated phrasing
|
The system prompt instructs the model to avoid common AI-generated phrasing
|
||||||
patterns. This is a prompt engineering experiment:
|
patterns. This is a prompt engineering experiment:
|
||||||
@@ -130,14 +130,14 @@ consumers are informed of the AI-generated origin before engagement.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Known Issues
|
## Known issues
|
||||||
|
|
||||||
### Hallucinated Brewing Techniques
|
### Hallucinated brewing techniques
|
||||||
|
|
||||||
When forced by the system prompt to generate a "highly specialized technical
|
When forced by the system prompt to generate a "highly specialized technical
|
||||||
brewing detail," the model frequently hallucinates fermentation science and
|
brewing detail," the model frequently hallucinates fermentation science and
|
||||||
brewing chemistry. While the resulting sounds confident, it will be nonsensical
|
brewing chemistry. While the resulting text sounds confident, it is
|
||||||
to reader with brewing and/or scientific expertise.
|
nonsensical to a reader with brewing or scientific expertise.
|
||||||
|
|
||||||
Small-parameter models such as Gemma 4 E4B cannot reliably satisfy prompts
|
Small-parameter models such as Gemma 4 E4B cannot reliably satisfy prompts
|
||||||
calling for specialist brewing detail. This is consistent with the **CHOKE**
|
calling for specialist brewing detail. This is consistent with the **CHOKE**
|
||||||
@@ -155,9 +155,9 @@ and factually wrong output.
|
|||||||
[2026-04-21 15:04:40.258] [info] brewery_description_local="早朝の、たこ焼きや香ばしいイカ焼きの湿った匂いは、いつも乾燥小屋の外にある使用済み麦芽の金属的な匂いと奇妙に混ざり合って近隣に漂います。私たちはこの場所に店を構えることを決めたのです。なぜなら、大阪は決して止まることのない都市であり、商品と味が絶え間なく交換されることで築かれた場所だからです。地元の多くの方々は、信頼できる大規模な淡麗ラガーの味が習慣になっていますが、私たちは発酵の、より深く、より複雑な表現を探求することに関心があります。私たちのベースモルトは、山田錦のような地元の米の品種を意図的に組み込んで作られています。この米を酒ではなく、麦芽として、仕込みの最中にその複雑でバターのような風味を引き出すために使用しています。この組み合わせを、ゆっくりとした嫌気的な熟成プロセスに続けることで、私たちのビールは、慌ただしい市場のスタイルとは一線を画す、深みのある、満足感のある複雑さを持っています。オリジナルの倉庫の重く深紅のレンガ壁は、関西特有の湿気と季節の雨を何十年も吸収し、この地区の絶え間ない動きの正確な物語を語るような古色を帯びています。私たちはこの構造物を、その魅力のためではなく、その回復力とモルタルに込められた地域の歴史の密度ゆえに選びました。私たちの目標は、ただこの力強い交易都市に値する飲み物を提供することだけです。もしメインの通りから離れた静かな場所をお探しなら、新世界近くの脇道にある私たちを探してください。"
|
[2026-04-21 15:04:40.258] [info] brewery_description_local="早朝の、たこ焼きや香ばしいイカ焼きの湿った匂いは、いつも乾燥小屋の外にある使用済み麦芽の金属的な匂いと奇妙に混ざり合って近隣に漂います。私たちはこの場所に店を構えることを決めたのです。なぜなら、大阪は決して止まることのない都市であり、商品と味が絶え間なく交換されることで築かれた場所だからです。地元の多くの方々は、信頼できる大規模な淡麗ラガーの味が習慣になっていますが、私たちは発酵の、より深く、より複雑な表現を探求することに関心があります。私たちのベースモルトは、山田錦のような地元の米の品種を意図的に組み込んで作られています。この米を酒ではなく、麦芽として、仕込みの最中にその複雑でバターのような風味を引き出すために使用しています。この組み合わせを、ゆっくりとした嫌気的な熟成プロセスに続けることで、私たちのビールは、慌ただしい市場のスタイルとは一線を画す、深みのある、満足感のある複雑さを持っています。オリジナルの倉庫の重く深紅のレンガ壁は、関西特有の湿気と季節の雨を何十年も吸収し、この地区の絶え間ない動きの正確な物語を語るような古色を帯びています。私たちはこの構造物を、その魅力のためではなく、その回復力とモルタルに込められた地域の歴史の密度ゆえに選びました。私たちの目標は、ただこの力強い交易都市に値する飲み物を提供することだけです。もしメインの通りから離れた静かな場所をお探しなら、新世界近くの脇道にある私たちを探してください。"
|
||||||
```
|
```
|
||||||
|
|
||||||
A review of the following text for brewing techniques reveals several
|
A review of the following text for brewing techniques reveals three
|
||||||
inaccuracies, and no comments could be made on the local-language version due to
|
inaccuracies. No comments are made on the local-language version, since its
|
||||||
my own lack of proficiency in Japanese:
|
reviewer does not read Japanese:
|
||||||
|
|
||||||
#### 1. "Buttery flavours" framed as a desirable malt-derived flavour
|
#### 1. "Buttery flavours" framed as a desirable malt-derived flavour
|
||||||
|
|
||||||
@@ -191,7 +191,7 @@ fermentable sugars.[^yn-sakeonline]
|
|||||||
|
|
||||||
**Misleading**
|
**Misleading**
|
||||||
|
|
||||||
Anaerobic conditions during packaging and aging are not differentiating
|
Anaerobic conditions during packaging and aging are not a differentiating
|
||||||
technique. Anaerobic conditions are the standard baseline for all commercial
|
technique. Anaerobic conditions are the standard baseline for all commercial
|
||||||
beer production. Breweries exclude oxygen as a top priority for packaging and
|
beer production. Breweries exclude oxygen as a top priority for packaging and
|
||||||
shelf stability; published research in _Microbiology Spectrum_ confirms that
|
shelf stability; published research in _Microbiology Spectrum_ confirms that
|
||||||
@@ -200,7 +200,7 @@ Professional packaging lines use CO_2 purges and closed transfers specifically
|
|||||||
to maintain this state.[^packaging] Framing anaerobic aging as a distinctive
|
to maintain this state.[^packaging] Framing anaerobic aging as a distinctive
|
||||||
practice is misleading and suggests hallucinated output.
|
practice is misleading and suggests hallucinated output.
|
||||||
|
|
||||||
### Low-Resource Language Hallucination
|
### Low-resource language hallucination
|
||||||
|
|
||||||
The generation pipeline passes local language codes to the model to retrieve a
|
The generation pipeline passes local language codes to the model to retrieve a
|
||||||
translated `description_local`. Output quality is reliable for high-resource
|
translated `description_local`. Output quality is reliable for high-resource
|
||||||
@@ -262,9 +262,9 @@ idiomatic phrasing.
|
|||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
This dataset, when fed into the pipeline will often times reason that a local
|
When fed into the pipeline, this dataset often causes the model to reason that
|
||||||
variant of French is needed, but will often times just default to a standardized
|
a local variant of French is needed, but it often defaults to a standardized
|
||||||
dialect of French, devoid of any cultural or linguistic nuance.
|
dialect of French, without cultural or linguistic nuance.
|
||||||
|
|
||||||
For languages such as Welsh (Wales), Māori (Aotearoa/New Zealand), or Sicilian
|
For languages such as Welsh (Wales), Māori (Aotearoa/New Zealand), or Sicilian
|
||||||
(Sicily, Italy), the model can generate text that looks syntactically plausible
|
(Sicily, Italy), the model can generate text that looks syntactically plausible
|
||||||
@@ -273,7 +273,7 @@ rather than prompt engineering.
|
|||||||
|
|
||||||
Output sample: [./french-cities.example](french-cities.example)
|
Output sample: [./french-cities.example](french-cities.example)
|
||||||
|
|
||||||
#### Proposed Mitigations
|
#### Proposed mitigations
|
||||||
|
|
||||||
- **Prevention via allowlist:** introduce a high-resource language allowlist. If
|
- **Prevention via allowlist:** introduce a high-resource language allowlist. If
|
||||||
a location's code is unlisted, skip `description_local` generation and fall
|
a location's code is unlisted, skip `description_local` generation and fall
|
||||||
@@ -284,7 +284,7 @@ Output sample: [./french-cities.example](french-cities.example)
|
|||||||
SQLite schema so downstream applications can filter or flag potentially
|
SQLite schema so downstream applications can filter or flag potentially
|
||||||
hallucinated text by language tier.
|
hallucinated text by language tier.
|
||||||
|
|
||||||
### Synthetic Postal Codes
|
### Synthetic postal codes
|
||||||
|
|
||||||
When the pipeline is run without `--mocked`, `XegerPostalCodeService` generates
|
When the pipeline is run without `--mocked`, `XegerPostalCodeService` generates
|
||||||
a fresh postal code per brewery/user address by reverse-matching one of the
|
a fresh postal code per brewery/user address by reverse-matching one of the
|
||||||
@@ -312,7 +312,7 @@ not a source of truth.
|
|||||||
Simhi, Itay Itzhak, Fazl Barez, Gabriel Stanovsky, Yonatan Belinkov.
|
Simhi, Itay Itzhak, Fazl Barez, Gabriel Stanovsky, Yonatan Belinkov.
|
||||||
|
|
||||||
[^llm-bias]:
|
[^llm-bias]:
|
||||||
e.g., Blasi et al. (2022), "Systematic Inequalities in Language Technology
|
For example, Blasi et al. (2022), "Systematic Inequalities in Language Technology
|
||||||
Performance across the World's Languages," _ACL Anthology_. The pattern is
|
Performance across the World's Languages," _ACL Anthology_. The pattern is
|
||||||
consistent with models trained predominantly on English-language web
|
consistent with models trained predominantly on English-language web
|
||||||
corpora.
|
corpora.
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ deterministic mock.
|
|||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
- [How It Fits The Main App](#how-it-fits-the-main-app)
|
- [How it fits the main app](#how-it-fits-the-main-app)
|
||||||
- [Quick Start](#quick-start)
|
- [Quick start](#quick-start)
|
||||||
- [Build](#build)
|
- [Build](#build)
|
||||||
- [Model](#model)
|
- [Model](#model)
|
||||||
- [Run](#run)
|
- [Run](#run)
|
||||||
@@ -24,17 +24,17 @@ deterministic mock.
|
|||||||
- [Pipeline Stages](#pipeline-stages)
|
- [Pipeline Stages](#pipeline-stages)
|
||||||
- [Key Components](#key-components)
|
- [Key Components](#key-components)
|
||||||
- [Runtime Behaviour](#runtime-behaviour)
|
- [Runtime Behaviour](#runtime-behaviour)
|
||||||
- [Generated Output](#generated-output)
|
- [Generated output](#generated-output)
|
||||||
- [Tech Stack](#tech-stack)
|
- [Tech stack](#tech-stack)
|
||||||
- [Tested Hardware](#tested-hardware)
|
- [Tested hardware](#tested-hardware)
|
||||||
- [Fixture Strategy](#fixture-strategy)
|
- [Fixture strategy](#fixture-strategy)
|
||||||
- [Repo Layout](#repo-layout)
|
- [Repo layout](#repo-layout)
|
||||||
- [Code Tour](#code-tour)
|
- [Code tour](#code-tour)
|
||||||
- [Next Steps](#next-steps)
|
- [Next steps](#next-steps)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## How It Fits The Main App
|
## How it fits the main app
|
||||||
|
|
||||||
The pipeline is a data ingestion layer. It sits outside the web app runtime and
|
The pipeline is a data ingestion layer. It sits outside the web app runtime and
|
||||||
produces seed records the app imports at startup or during a dedicated seed
|
produces seed records the app imports at startup or during a dedicated seed
|
||||||
@@ -50,7 +50,7 @@ step.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start
|
## Quick start
|
||||||
|
|
||||||
### Build
|
### Build
|
||||||
|
|
||||||
@@ -99,13 +99,13 @@ Each run writes a fresh dated SQLite file such as
|
|||||||
--temperature 1.0 --top-p 0.95 --top-k 64 --n-ctx 8192 --seed -1
|
--temperature 1.0 --top-p 0.95 --top-k 64 --n-ctx 8192 --seed -1
|
||||||
```
|
```
|
||||||
|
|
||||||
#### CLI Flags
|
#### CLI flags
|
||||||
|
|
||||||
| Flag | Purpose |
|
| Flag | Purpose |
|
||||||
| ------------------ | ---------------------------------------------------------------------------------------------------- |
|
| ------------------ | ---------------------------------------------------------------------------------------------------- |
|
||||||
| `--mocked` | Deterministic mock generator, no model required. |
|
| `--mocked` | Deterministic mock generator, no model required. |
|
||||||
| `--model, -m` | Path to a GGUF file. Required unless `--mocked` is set. |
|
| `--model, -m` | Path to a GGUF file. Required unless `--mocked` is set. |
|
||||||
| `--prompt-dir` | Directory containing prompt files (e.g. `BREWERY_GENERATION.md`). Required unless `--mocked` is set. |
|
| `--prompt-dir` | Directory containing prompt files (for example, `BREWERY_GENERATION.md`). Required unless `--mocked` is set. |
|
||||||
| `--output, -o` | Directory for generated SQLite artifacts. Default: `output`. |
|
| `--output, -o` | Directory for generated SQLite artifacts. Default: `output`. |
|
||||||
| `--log-path` | Path for application logs. Default: `pipeline.log`. |
|
| `--log-path` | Path for application logs. Default: `pipeline.log`. |
|
||||||
| `--location-count` | Number of cities to sample from `locations.json` per run. Default: `10`. |
|
| `--location-count` | Number of cities to sample from `locations.json` per run. Default: `10`. |
|
||||||
@@ -176,7 +176,7 @@ output to confirm the fast path was taken.
|
|||||||
### Run the container
|
### Run the container
|
||||||
|
|
||||||
The container always runs the model-backed path; there is no `--mocked`
|
The container always runs the model-backed path; there is no `--mocked`
|
||||||
container mode (use a native build for that — see [Quick Start](#quick-start)).
|
container mode (use a native build for that — see [Quick start](#quick-start)).
|
||||||
The entrypoint, `runpod/start.sh`, downloads the GGUF model automatically if it
|
The entrypoint, `runpod/start.sh`, downloads the GGUF model automatically if it
|
||||||
is not already present at the configured path.
|
is not already present at the configured path.
|
||||||
|
|
||||||
@@ -218,7 +218,7 @@ environment variables listed above to match your run.
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### Pipeline Stages
|
### Pipeline stages
|
||||||
|
|
||||||
| Stage | Implementation |
|
| Stage | Implementation |
|
||||||
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
@@ -234,7 +234,7 @@ If name sampling, enrichment, or generation fails for a city, that city is
|
|||||||
skipped and the pipeline continues. `GenerateUsers()` runs before
|
skipped and the pipeline continues. `GenerateUsers()` runs before
|
||||||
`GenerateBreweries()` in `BiergartenPipelineOrchestrator::Run()`.
|
`GenerateBreweries()` in `BiergartenPipelineOrchestrator::Run()`.
|
||||||
|
|
||||||
### Key Components
|
### Key components
|
||||||
|
|
||||||
- `src/main.cc` — argument parsing and Boost.DI composition root.
|
- `src/main.cc` — argument parsing and Boost.DI composition root.
|
||||||
- `CuratedJsonDataService` — implements `ICuratedDataService`; takes a
|
- `CuratedJsonDataService` — implements `ICuratedDataService`; takes a
|
||||||
@@ -260,7 +260,7 @@ skipped and the pipeline continues. `GenerateUsers()` runs before
|
|||||||
LLM-generated username/bio/activity weight, and a programmatically generated
|
LLM-generated username/bio/activity weight, and a programmatically generated
|
||||||
(not LLM-authored) unique email and date of birth.
|
(not LLM-authored) unique email and date of birth.
|
||||||
|
|
||||||
### Runtime Behaviour
|
### Runtime behaviour
|
||||||
|
|
||||||
`WikipediaEnrichmentService` fetches two Wikipedia extracts per city: a generic
|
`WikipediaEnrichmentService` fetches two Wikipedia extracts per city: a generic
|
||||||
"brewing" extract and a "beer in `{country}`" extract. It does not currently
|
"brewing" extract and a "beer in `{country}`" extract. It does not currently
|
||||||
@@ -290,17 +290,17 @@ absent from either name fixture (currently `KE`, `SE`, `SG`, `TH`, `VN`, `ZA`)
|
|||||||
are skipped, the same way a failed enrichment or generation call skips a city —
|
are skipped, the same way a failed enrichment or generation call skips a city —
|
||||||
see ETHICS-AND-KNOWN-ISSUES.md's Names-by-Country Dataset section.
|
see ETHICS-AND-KNOWN-ISSUES.md's Names-by-Country Dataset section.
|
||||||
|
|
||||||
### Process Flow - Activity Diagram
|
### Process flow - activity diagram
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
### 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
|
Each successful run stores a `BreweryRecord` pair with the source location and a
|
||||||
`BreweryResult` payload, and a `UserRecord` pair with the source location and a
|
`BreweryResult` payload, and a `UserRecord` pair with the source location and a
|
||||||
@@ -328,7 +328,7 @@ SQLite export file named with the current UTC timestamp.
|
|||||||
The log dump also includes city, country, state or province, ISO subdivision
|
The log dump also includes city, country, state or province, ISO subdivision
|
||||||
code, latitude, and longitude for each entry.
|
code, latitude, and longitude for each entry.
|
||||||
|
|
||||||
### Consumer Data Shape
|
### Consumer data shape
|
||||||
|
|
||||||
| Field | Why it matters |
|
| Field | Why it matters |
|
||||||
| ----------------------------------- | ------------------------------------------------ |
|
| ----------------------------------- | ------------------------------------------------ |
|
||||||
@@ -341,7 +341,7 @@ code, latitude, and longitude for each entry.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tech Stack
|
## Tech stack
|
||||||
|
|
||||||
- C++20
|
- C++20
|
||||||
- CMake 3.31+
|
- CMake 3.31+
|
||||||
@@ -363,7 +363,7 @@ Silicon; CUDA or HIP/ROCm is detected on Linux when the toolkit is present.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tested Hardware
|
## Tested hardware
|
||||||
|
|
||||||
### ARM macOS — M1 Pro
|
### ARM macOS — M1 Pro
|
||||||
|
|
||||||
@@ -398,7 +398,7 @@ Silicon; CUDA or HIP/ROCm is detected on Linux when the toolkit is present.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Fixture Strategy
|
## Fixture strategy
|
||||||
|
|
||||||
- `--mocked` for stable fixtures, repeatable screenshots, and Storybook runs.
|
- `--mocked` for stable fixtures, repeatable screenshots, and Storybook runs.
|
||||||
`MockCuratedDataService` swaps in for `CuratedJsonDataService`, so no fixture
|
`MockCuratedDataService` swaps in for `CuratedJsonDataService`, so no fixture
|
||||||
@@ -414,7 +414,7 @@ Silicon; CUDA or HIP/ROCm is detected on Linux when the toolkit is present.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Repo Layout
|
## Repo layout
|
||||||
|
|
||||||
| Path | Purpose |
|
| Path | Purpose |
|
||||||
| -------------------------------------------- | ----------------------------------------------------------- |
|
| -------------------------------------------- | ----------------------------------------------------------- |
|
||||||
@@ -431,7 +431,7 @@ Silicon; CUDA or HIP/ROCm is detected on Linux when the toolkit is present.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Code Tour
|
## Code tour
|
||||||
|
|
||||||
Paths below are relative to `tooling/pipeline/`.
|
Paths below are relative to `tooling/pipeline/`.
|
||||||
|
|
||||||
@@ -450,7 +450,7 @@ Paths below are relative to `tooling/pipeline/`.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Next Steps
|
## Next steps
|
||||||
|
|
||||||
The pipeline currently produces city-aware brewery and user records and dated
|
The pipeline currently produces city-aware brewery and user records and dated
|
||||||
SQLite exports. The next passes add additional fixture types so the app can
|
SQLite exports. The next passes add additional fixture types so the app can
|
||||||
@@ -458,7 +458,7 @@ exercise the full brewery and social domains without live data. For the detailed
|
|||||||
engineering breakdown of what's needed to reach the architecture in
|
engineering breakdown of what's needed to reach the architecture in
|
||||||
[`diagrams/planned/`](./diagrams/planned/), see [ROADMAP.md](./ROADMAP.md).
|
[`diagrams/planned/`](./diagrams/planned/), see [ROADMAP.md](./ROADMAP.md).
|
||||||
|
|
||||||
### Testing — Very High Priority
|
### Testing — very high priority
|
||||||
|
|
||||||
- Unit test JSON validation and retry logic against malformed, truncated, and
|
- Unit test JSON validation and retry logic against malformed, truncated, and
|
||||||
empty model outputs.
|
empty model outputs.
|
||||||
@@ -472,19 +472,19 @@ engineering breakdown of what's needed to reach the architecture in
|
|||||||
- Confirm the retry path is reachable when the reasoning block consumes
|
- Confirm the retry path is reachable when the reasoning block consumes
|
||||||
available token budget.
|
available token budget.
|
||||||
|
|
||||||
### Beer Generation
|
### Beer generation
|
||||||
|
|
||||||
Generate catalog entries with style, ABV, IBU, color, aroma notes, and food
|
Generate catalog entries with style, ABV, IBU, color, aroma notes, and food
|
||||||
pairing hints. Link beers back to breweries and cities. Keep style coverage wide
|
pairing hints. Link beers back to breweries and cities. Keep style coverage wide
|
||||||
enough to exercise search, sort, and category filters.
|
enough to exercise search, sort, and category filters.
|
||||||
|
|
||||||
### Check-In System
|
### Check-in system
|
||||||
|
|
||||||
Produce timestamped check-in events between users and breweries. Use a J-curve
|
Produce timestamped check-in events between users and breweries. Use a J-curve
|
||||||
activity profile — a small set of users accounts for most check-ins, the rest
|
activity profile — a small set of users accounts for most check-ins, the rest
|
||||||
appear occasionally. Add bursty behaviour around weekends and travel periods.
|
appear occasionally. Add bursty behaviour around weekends and travel periods.
|
||||||
|
|
||||||
### Beer Ratings
|
### Beer ratings
|
||||||
|
|
||||||
Generate rating events with a strong positive skew and a long tail of lower
|
Generate rating events with a strong positive skew and a long tail of lower
|
||||||
scores. Avoid uniform distributions. Attach timestamps and user IDs so the app
|
scores. Avoid uniform distributions. Attach timestamps and user IDs so the app
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Docker Guide
|
# Docker guide
|
||||||
|
|
||||||
This document covers Docker deployment, configuration, and troubleshooting for
|
This document covers Docker deployment, configuration, and troubleshooting for
|
||||||
The Biergarten App.
|
The Biergarten App.
|
||||||
@@ -16,7 +16,7 @@ The project uses Docker Compose to orchestrate multiple services:
|
|||||||
See the [deployment diagram](diagrams-out/deployment.svg) for visual
|
See the [deployment diagram](diagrams-out/deployment.svg) for visual
|
||||||
representation.
|
representation.
|
||||||
|
|
||||||
## Docker Compose Environments
|
## Docker Compose environments
|
||||||
|
|
||||||
### 1. Development (`docker-compose.dev.yaml`)
|
### 1. Development (`docker-compose.dev.yaml`)
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down -v
|
|||||||
|
|
||||||
### 2. Testing (`docker-compose.test.yaml`)
|
### 2. Testing (`docker-compose.test.yaml`)
|
||||||
|
|
||||||
**Purpose**: Automated CI/CD testing in isolated environment
|
**Purpose**: Automated CI/CD testing in an isolated environment
|
||||||
|
|
||||||
**Features**:
|
**Features**:
|
||||||
|
|
||||||
@@ -128,18 +128,15 @@ api.core # Production API
|
|||||||
docker compose --env-file web/.env.prod -f web/docker-compose.prod.yaml up -d
|
docker compose --env-file web/.env.prod -f web/docker-compose.prod.yaml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
## Service Dependencies
|
## Service dependencies
|
||||||
|
|
||||||
Docker Compose manages startup order using health checks:
|
Docker Compose manages startup order using health checks:
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
sqlserver (health check)
|
graph TD
|
||||||
↓
|
A[sqlserver: health check passes] --> B[database.migrations: completes successfully]
|
||||||
database.migrations (completes successfully)
|
B --> C[database.seed: completes successfully]
|
||||||
↓
|
C --> D[api.core / tests: start when ready]
|
||||||
database.seed (completes successfully)
|
|
||||||
↓
|
|
||||||
api.core / tests (start when ready)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Health Check Example** (SQL Server):
|
**Health Check Example** (SQL Server):
|
||||||
@@ -168,7 +165,7 @@ api.core:
|
|||||||
|
|
||||||
## Volumes
|
## Volumes
|
||||||
|
|
||||||
### Persistent Volumes
|
### Persistent volumes
|
||||||
|
|
||||||
**Development**:
|
**Development**:
|
||||||
|
|
||||||
@@ -184,7 +181,7 @@ api.core:
|
|||||||
- `sqlserverdata-prod` - Production database files
|
- `sqlserverdata-prod` - Production database files
|
||||||
- `nuget-cache-prod` - Production NuGet cache
|
- `nuget-cache-prod` - Production NuGet cache
|
||||||
|
|
||||||
### Mounted Volumes
|
### Mounted volumes
|
||||||
|
|
||||||
**Test Results**:
|
**Test Results**:
|
||||||
|
|
||||||
@@ -203,7 +200,7 @@ Each environment uses isolated bridge networks:
|
|||||||
- `testnet` - Testing network (fully isolated)
|
- `testnet` - Testing network (fully isolated)
|
||||||
- `prodnet` - Production network
|
- `prodnet` - Production network
|
||||||
|
|
||||||
## Environment Variables
|
## Environment variables
|
||||||
|
|
||||||
All containers are configured via environment variables from `.env` files:
|
All containers are configured via environment variables from `.env` files:
|
||||||
|
|
||||||
@@ -224,9 +221,9 @@ environment:
|
|||||||
|
|
||||||
For complete list, see [Environment Variables](environment-variables.md).
|
For complete list, see [Environment Variables](environment-variables.md).
|
||||||
|
|
||||||
## Common Commands
|
## Common commands
|
||||||
|
|
||||||
### View Services
|
### View services
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Running services
|
# Running services
|
||||||
@@ -236,7 +233,7 @@ docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml ps
|
|||||||
docker ps -a
|
docker ps -a
|
||||||
```
|
```
|
||||||
|
|
||||||
### View Logs
|
### View logs
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# All services
|
# All services
|
||||||
@@ -249,17 +246,17 @@ docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml logs -f ap
|
|||||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml logs --tail=100 api.core
|
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml logs --tail=100 api.core
|
||||||
```
|
```
|
||||||
|
|
||||||
### Execute Commands in Container
|
### Execute commands in container
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Interactive shell
|
# Interactive shell
|
||||||
docker exec -it dev-env-api-core bash
|
docker exec -it dev-env-api-core bash
|
||||||
|
|
||||||
# Run command
|
# Run command (replace <db-password> with the value of DB_PASSWORD from .env.dev)
|
||||||
docker exec dev-env-sqlserver /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'password' -C
|
docker exec dev-env-sqlserver /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '<db-password>' -C
|
||||||
```
|
```
|
||||||
|
|
||||||
### Restart Services
|
### Restart services
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Restart all services
|
# Restart all services
|
||||||
@@ -272,7 +269,7 @@ docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml restart ap
|
|||||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d --build api.core
|
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d --build api.core
|
||||||
```
|
```
|
||||||
|
|
||||||
### Build Images
|
### Build images
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build all images
|
# Build all images
|
||||||
@@ -285,7 +282,7 @@ docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml build api.
|
|||||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml build --no-cache
|
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml build --no-cache
|
||||||
```
|
```
|
||||||
|
|
||||||
### Clean Up
|
### Clean up
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Stop and remove containers
|
# Stop and remove containers
|
||||||
@@ -301,9 +298,9 @@ docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down -v --
|
|||||||
docker system prune -af --volumes
|
docker system prune -af --volumes
|
||||||
```
|
```
|
||||||
|
|
||||||
## Dockerfile Structure
|
## Dockerfile structure
|
||||||
|
|
||||||
### Multi-Stage Build
|
### Multi-stage build
|
||||||
|
|
||||||
```dockerfile
|
```dockerfile
|
||||||
# Stage 1: Build
|
# Stage 1: Build
|
||||||
@@ -321,7 +318,7 @@ COPY --from=build /app/build .
|
|||||||
ENTRYPOINT ["dotnet", "Project.dll"]
|
ENTRYPOINT ["dotnet", "Project.dll"]
|
||||||
```
|
```
|
||||||
|
|
||||||
## Additional Resources
|
## Additional resources
|
||||||
|
|
||||||
- [Docker Compose Documentation](https://docs.docker.com/compose/)
|
- [Docker Compose Documentation](https://docs.docker.com/compose/)
|
||||||
- [.NET Docker Images](https://hub.docker.com/_/microsoft-dotnet)
|
- [.NET Docker Images](https://hub.docker.com/_/microsoft-dotnet)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Environment Variables
|
# Environment variables
|
||||||
|
|
||||||
This document covers the active environment variables used by the current
|
This document covers the active environment variables used by the current
|
||||||
Biergarten stack.
|
Biergarten stack.
|
||||||
@@ -11,7 +11,7 @@ The application uses environment variables for:
|
|||||||
- **React Router website** - API base URL and session signing
|
- **React Router website** - API base URL and session signing
|
||||||
- **Docker containers** - environment-specific orchestration
|
- **Docker containers** - environment-specific orchestration
|
||||||
|
|
||||||
## Configuration Patterns
|
## Configuration patterns
|
||||||
|
|
||||||
### Backend (.NET API)
|
### Backend (.NET API)
|
||||||
|
|
||||||
@@ -30,9 +30,9 @@ Environment-specific `.env` files loaded via `env_file:` in docker-compose.yaml:
|
|||||||
- `.env.test` - Testing
|
- `.env.test` - Testing
|
||||||
- `.env.prod` - Production
|
- `.env.prod` - Production
|
||||||
|
|
||||||
## Backend Variables (.NET API)
|
## Backend variables (.NET API)
|
||||||
|
|
||||||
### Database Connection
|
### Database connection
|
||||||
|
|
||||||
**Option 1: Component-Based (Recommended for Docker)**
|
**Option 1: Component-Based (Recommended for Docker)**
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ string is built from components.
|
|||||||
|
|
||||||
**Implementation**: See `DefaultSqlConnectionFactory.cs`
|
**Implementation**: See `DefaultSqlConnectionFactory.cs`
|
||||||
|
|
||||||
### JWT Authentication Secrets (Backend)
|
### JSON Web Token (JWT) authentication secrets (backend)
|
||||||
|
|
||||||
The backend uses separate secrets for different token types to enable
|
The backend uses separate secrets for different token types to enable
|
||||||
independent key rotation and validation isolation.
|
independent key rotation and validation isolation.
|
||||||
@@ -80,9 +80,9 @@ WEBSITE_BASE_URL=https://thebiergarten.app # Base URL for the website
|
|||||||
|
|
||||||
**Security Requirements**:
|
**Security Requirements**:
|
||||||
|
|
||||||
- Each secret should be minimum 32 characters
|
- Use at least 32 characters per secret
|
||||||
- Recommend 127+ characters for production
|
- Use 127 or more characters in production
|
||||||
- Generate using cryptographically secure random functions
|
- Generate secrets with a cryptographically secure random function
|
||||||
- Never reuse secrets across token types or environments
|
- Never reuse secrets across token types or environments
|
||||||
- Rotate secrets periodically in production
|
- Rotate secrets periodically in production
|
||||||
|
|
||||||
@@ -110,19 +110,20 @@ openssl rand -base64 127
|
|||||||
- **Handler**: Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler
|
- **Handler**: Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler
|
||||||
- **Validation**: Token signature, expiration, and malformed token checks
|
- **Validation**: Token signature, expiration, and malformed token checks
|
||||||
|
|
||||||
### Migration Control
|
### Migration control
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
CLEAR_DATABASE=true
|
CLEAR_DATABASE=true
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Required**: No
|
- **Required**: No
|
||||||
- **Default**: false
|
- **Default**: `false`
|
||||||
- **Effect**: If "true", drops and recreates database during migrations
|
- **Effect**: If set to `true`, drops and recreates the database during
|
||||||
- **Usage**: Development and testing environments ONLY
|
migrations
|
||||||
- **Warning**: NEVER use in production
|
- **Usage**: Development and testing environments only
|
||||||
|
- **Warning**: Never use in production
|
||||||
|
|
||||||
### ASP.NET Core Configuration
|
### ASP.NET Core configuration
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ASPNETCORE_ENVIRONMENT=Development # Development, Production, Staging
|
ASPNETCORE_ENVIRONMENT=Development # Development, Production, Staging
|
||||||
@@ -130,7 +131,7 @@ ASPNETCORE_URLS=http://0.0.0.0:8080 # Binding address and port
|
|||||||
DOTNET_RUNNING_IN_CONTAINER=true # Flag for container execution
|
DOTNET_RUNNING_IN_CONTAINER=true # Flag for container execution
|
||||||
```
|
```
|
||||||
|
|
||||||
## Frontend Variables (`web/frontend`)
|
## Frontend variables (`web/frontend`)
|
||||||
|
|
||||||
The active website does not use the old Next.js/Prisma environment model. Its
|
The active website does not use the old Next.js/Prisma environment model. Its
|
||||||
core runtime variables are:
|
core runtime variables are:
|
||||||
@@ -141,7 +142,7 @@ SESSION_SECRET=<generated-secret> # Cookie session signing secret
|
|||||||
NODE_ENV=development # Standard Node runtime mode
|
NODE_ENV=development # Standard Node runtime mode
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend Variable Details
|
### Frontend variable details
|
||||||
|
|
||||||
#### `API_BASE_URL`
|
#### `API_BASE_URL`
|
||||||
|
|
||||||
@@ -163,7 +164,7 @@ NODE_ENV=development # Standard Node runtime mode
|
|||||||
- **Typical values**: `development`, `production`, `test`
|
- **Typical values**: `development`, `production`, `test`
|
||||||
- **Purpose**: Controls secure cookie behavior and runtime mode
|
- **Purpose**: Controls secure cookie behavior and runtime mode
|
||||||
|
|
||||||
### SMTP Configuration (Backend)
|
### SMTP configuration (backend)
|
||||||
|
|
||||||
Read by `Infrastructure.Email/SmtpEmailProvider.cs` for sending confirmation
|
Read by `Infrastructure.Email/SmtpEmailProvider.cs` for sending confirmation
|
||||||
and account emails.
|
and account emails.
|
||||||
@@ -183,11 +184,11 @@ SMTP_FROM_NAME=The Biergarten App # Optional, defaults to "The Biergarten
|
|||||||
- **Local dev**: point at the `mailpit` Docker service (SMTP on port 1025, web
|
- **Local dev**: point at the `mailpit` Docker service (SMTP on port 1025, web
|
||||||
UI on http://localhost:8025)
|
UI on http://localhost:8025)
|
||||||
- **Production**: point at a real provider (SendGrid, Mailgun, Amazon SES,
|
- **Production**: point at a real provider (SendGrid, Mailgun, Amazon SES,
|
||||||
etc.)
|
and so on)
|
||||||
|
|
||||||
## Docker-Specific Variables
|
## Docker-specific variables
|
||||||
|
|
||||||
### SQL Server Container
|
### SQL Server container
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
SA_PASSWORD=YourStrong!Passw0rd # SQL Server SA password
|
SA_PASSWORD=YourStrong!Passw0rd # SQL Server SA password
|
||||||
@@ -201,9 +202,9 @@ MSSQL_PID=Express # SQL Server edition (Express, Developer, Ente
|
|||||||
- Uppercase, lowercase, digits, and special characters
|
- Uppercase, lowercase, digits, and special characters
|
||||||
- Maps to `DB_PASSWORD` for application containers
|
- Maps to `DB_PASSWORD` for application containers
|
||||||
|
|
||||||
## Environment File Structure
|
## Environment file structure
|
||||||
|
|
||||||
### Backend/Docker (`web/` Directory)
|
### Backend/Docker (`web/` directory)
|
||||||
|
|
||||||
```
|
```
|
||||||
web/.env.example # Template (tracked in Git)
|
web/.env.example # Template (tracked in Git)
|
||||||
@@ -219,7 +220,7 @@ cp web/.env.example web/.env.dev
|
|||||||
# Edit web/.env.dev with your values
|
# Edit web/.env.dev with your values
|
||||||
```
|
```
|
||||||
|
|
||||||
## Legacy Frontend Variables
|
## Legacy frontend variables
|
||||||
|
|
||||||
Variables for the archived Next.js frontend (`archive/next-js-web-app/`) have
|
Variables for the archived Next.js frontend (`archive/next-js-web-app/`) have
|
||||||
been removed from this active reference, since that app is retained for
|
been removed from this active reference, since that app is retained for
|
||||||
@@ -231,7 +232,7 @@ reference only and is not run as part of the active stack.
|
|||||||
- `web/docker-compose.test.yaml` → `web/.env.test`
|
- `web/docker-compose.test.yaml` → `web/.env.test`
|
||||||
- `web/docker-compose.prod.yaml` → `web/.env.prod`
|
- `web/docker-compose.prod.yaml` → `web/.env.prod`
|
||||||
|
|
||||||
## Variable Reference Table
|
## Variable reference table
|
||||||
|
|
||||||
| Variable | Backend | Frontend | Docker | Required | Notes |
|
| Variable | Backend | Frontend | Docker | Required | Notes |
|
||||||
| ----------------------------- | :-----: | :------: | :----: | :------: | -------------------------- |
|
| ----------------------------- | :-----: | :------: | :----: | :------: | -------------------------- |
|
||||||
@@ -268,7 +269,7 @@ reference only and is not run as part of the active stack.
|
|||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
|
|
||||||
### Backend Validation
|
### Backend validation
|
||||||
|
|
||||||
Variables are validated at startup:
|
Variables are validated at startup:
|
||||||
|
|
||||||
@@ -279,7 +280,7 @@ Variables are validated at startup:
|
|||||||
"minimum 32 characters" guidance above is a recommendation, not an
|
"minimum 32 characters" guidance above is a recommendation, not an
|
||||||
enforced check
|
enforced check
|
||||||
|
|
||||||
### Frontend Validation
|
### Frontend validation
|
||||||
|
|
||||||
The active website relies on runtime defaults for local development and the
|
The active website relies on runtime defaults for local development and the
|
||||||
surrounding server environment in deployed environments.
|
surrounding server environment in deployed environments.
|
||||||
@@ -288,9 +289,9 @@ surrounding server environment in deployed environments.
|
|||||||
- `SESSION_SECRET` falls back to a development-only local secret
|
- `SESSION_SECRET` falls back to a development-only local secret
|
||||||
- `NODE_ENV` controls secure cookie behavior
|
- `NODE_ENV` controls secure cookie behavior
|
||||||
|
|
||||||
## Example Configuration Files
|
## Example configuration files
|
||||||
|
|
||||||
### `.env.dev` (Backend/Docker)
|
### `.env.dev` (backend/Docker)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Database
|
# Database
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Getting Started
|
# Getting started
|
||||||
|
|
||||||
This guide covers local setup for the current Biergarten stack: the .NET backend
|
This guide covers local setup for the current Biergarten stack: the .NET backend
|
||||||
in `web/backend` and the active React Router frontend in `web/frontend`.
|
in `web/backend` and the active React Router frontend in `web/frontend`.
|
||||||
@@ -10,16 +10,16 @@ in `web/backend` and the active React Router frontend in `web/frontend`.
|
|||||||
- **Docker Desktop** or equivalent Docker Engine setup
|
- **Docker Desktop** or equivalent Docker Engine setup
|
||||||
- **Java 8+** if you want to regenerate PlantUML diagrams
|
- **Java 8+** if you want to regenerate PlantUML diagrams
|
||||||
|
|
||||||
## Recommended Path: Docker for Backend, Node for Frontend
|
## Recommended path: Docker for backend, Node for frontend
|
||||||
|
|
||||||
### 1. Clone the Repository
|
### 1. Clone the repository
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone <repository-url>
|
git clone https://github.com/aaronpo97/the-biergarten-app
|
||||||
cd the-biergarten-app
|
cd the-biergarten-app
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Configure Backend Environment Variables
|
### 2. Configure backend environment variables
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp web/.env.example web/.env.dev
|
cp web/.env.example web/.env.dev
|
||||||
@@ -40,7 +40,7 @@ WEBSITE_BASE_URL=http://localhost:3000
|
|||||||
|
|
||||||
See [Environment Variables](environment-variables.md) for the full list.
|
See [Environment Variables](environment-variables.md) for the full list.
|
||||||
|
|
||||||
### 3. Start the Backend Stack
|
### 3. Start the backend stack
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d
|
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d
|
||||||
@@ -54,7 +54,7 @@ Available endpoints:
|
|||||||
- Health Check: http://localhost:8080/health
|
- Health Check: http://localhost:8080/health
|
||||||
- Mailpit UI: http://localhost:8025
|
- Mailpit UI: http://localhost:8025
|
||||||
|
|
||||||
### 4. Start the Active Frontend
|
### 4. Start the active frontend
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd web/frontend
|
cd web/frontend
|
||||||
@@ -69,7 +69,7 @@ Required frontend runtime variables for local work:
|
|||||||
- `API_BASE_URL` - Base URL for the .NET API
|
- `API_BASE_URL` - Base URL for the .NET API
|
||||||
- `SESSION_SECRET` - Cookie session signing secret for the website server
|
- `SESSION_SECRET` - Cookie session signing secret for the website server
|
||||||
|
|
||||||
### 5. Optional: Run Storybook
|
### 5. Optional: run Storybook
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd web/frontend
|
cd web/frontend
|
||||||
@@ -78,7 +78,7 @@ npm run storybook
|
|||||||
|
|
||||||
Storybook runs at http://localhost:6006 by default.
|
Storybook runs at http://localhost:6006 by default.
|
||||||
|
|
||||||
## Useful Commands
|
## Useful commands
|
||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
|
|
||||||
@@ -99,11 +99,11 @@ npm run test:storybook
|
|||||||
npm run test:storybook:playwright
|
npm run test:storybook:playwright
|
||||||
```
|
```
|
||||||
|
|
||||||
## Manual Backend Setup
|
## Manual backend setup
|
||||||
|
|
||||||
If you do not want to use Docker, you can run the backend locally.
|
If you do not want to use Docker, you can run the backend locally.
|
||||||
|
|
||||||
### 1. Set Environment Variables
|
### 1. Set environment variables
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export DB_CONNECTION_STRING="Server=localhost,1433;Database=Biergarten;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True;"
|
export DB_CONNECTION_STRING="Server=localhost,1433;Database=Biergarten;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True;"
|
||||||
@@ -113,7 +113,7 @@ export CONFIRMATION_TOKEN_SECRET="<generated>"
|
|||||||
export WEBSITE_BASE_URL="http://localhost:3000"
|
export WEBSITE_BASE_URL="http://localhost:3000"
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Run Migrations and Seed
|
### 2. Run migrations and seed
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd web/backend
|
cd web/backend
|
||||||
@@ -127,12 +127,12 @@ dotnet run --project Database/Database.Seed/Database.Seed.csproj
|
|||||||
dotnet run --project API/API.Core/API.Core.csproj
|
dotnet run --project API/API.Core/API.Core.csproj
|
||||||
```
|
```
|
||||||
|
|
||||||
## Legacy Frontend Note
|
## Legacy frontend note
|
||||||
|
|
||||||
The previous Next.js frontend lives in `archive/next-js-web-app/` for reference
|
The previous Next.js frontend lives in `archive/next-js-web-app/` for reference
|
||||||
only and is not the active website.
|
only and is not the active website.
|
||||||
|
|
||||||
## Next Steps
|
## Next steps
|
||||||
|
|
||||||
- Review [Architecture](../architecture.md)
|
- Review [Architecture](../architecture.md)
|
||||||
- Run backend and frontend checks from [Testing](testing.md)
|
- Run backend and frontend checks from [Testing](testing.md)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ The project uses a multi-layered testing approach across backend and frontend:
|
|||||||
- **Storybook Playwright suite** - Browser checks against Storybook-rendered
|
- **Storybook Playwright suite** - Browser checks against Storybook-rendered
|
||||||
components
|
components
|
||||||
|
|
||||||
## Running Tests with Docker (Recommended)
|
## Running tests with Docker (recommended)
|
||||||
|
|
||||||
The easiest way to run all tests is using Docker Compose, which sets up an
|
The easiest way to run all tests is using Docker Compose, which sets up an
|
||||||
isolated test environment:
|
isolated test environment:
|
||||||
@@ -37,7 +37,7 @@ This command:
|
|||||||
5. Exports results to `./test-results/`
|
5. Exports results to `./test-results/`
|
||||||
6. Exits when tests complete
|
6. Exits when tests complete
|
||||||
|
|
||||||
### View Test Results
|
### View test results
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List test result files
|
# List test result files
|
||||||
@@ -51,18 +51,18 @@ cat test-results/Features.UserManagement.Tests.trx
|
|||||||
cat test-results/Features.Emails.Tests.trx
|
cat test-results/Features.Emails.Tests.trx
|
||||||
```
|
```
|
||||||
|
|
||||||
### Clean Up
|
### Clean up
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Remove test containers and volumes
|
# Remove test containers and volumes
|
||||||
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
|
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
|
||||||
```
|
```
|
||||||
|
|
||||||
## Running Tests Locally
|
## Running tests locally
|
||||||
|
|
||||||
You can run individual test projects locally without Docker:
|
You can run individual test projects locally without Docker:
|
||||||
|
|
||||||
### Integration Tests (API.Specs)
|
### Integration tests (API.Specs)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd web/backend
|
cd web/backend
|
||||||
@@ -75,7 +75,7 @@ dotnet test API/API.Specs/API.Specs.csproj
|
|||||||
- Database migrated and seeded
|
- Database migrated and seeded
|
||||||
- Environment variables set (DB connection, JWT secret)
|
- Environment variables set (DB connection, JWT secret)
|
||||||
|
|
||||||
### Feature Slice Unit Tests
|
### Feature slice unit tests
|
||||||
|
|
||||||
Each feature slice has its own test project, covering its command/query
|
Each feature slice has its own test project, covering its command/query
|
||||||
handlers and repository:
|
handlers and repository:
|
||||||
@@ -96,7 +96,7 @@ for proj in Features/*.Tests; do dotnet test "$proj"; done
|
|||||||
- No database required (handlers use Moq; repository tests use DbMocker to
|
- No database required (handlers use Moq; repository tests use DbMocker to
|
||||||
simulate SQL Server responses)
|
simulate SQL Server responses)
|
||||||
|
|
||||||
### Frontend Storybook Tests
|
### Frontend Storybook tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd web/frontend
|
cd web/frontend
|
||||||
@@ -110,7 +110,7 @@ npm run test:storybook
|
|||||||
toasts, and the theme gallery
|
toasts, and the theme gallery
|
||||||
- Runs in browser mode via Vitest and Storybook integration
|
- Runs in browser mode via Vitest and Storybook integration
|
||||||
|
|
||||||
### Frontend Playwright Storybook Tests
|
### Frontend Playwright Storybook tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd web/frontend
|
cd web/frontend
|
||||||
@@ -125,9 +125,9 @@ npm run test:storybook:playwright
|
|||||||
- The command will start or reuse the Storybook server defined in
|
- The command will start or reuse the Storybook server defined in
|
||||||
`playwright.storybook.config.ts`
|
`playwright.storybook.config.ts`
|
||||||
|
|
||||||
## Test Coverage
|
## Test coverage
|
||||||
|
|
||||||
### Current Coverage
|
### Current coverage
|
||||||
|
|
||||||
**Features.Auth.Tests**:
|
**Features.Auth.Tests**:
|
||||||
|
|
||||||
@@ -160,7 +160,7 @@ npm run test:storybook:playwright
|
|||||||
- Theme gallery rendering across Biergarten themes
|
- Theme gallery rendering across Biergarten themes
|
||||||
- Toast interactions and themed notification display
|
- Toast interactions and themed notification display
|
||||||
|
|
||||||
### Planned Coverage
|
### Planned coverage
|
||||||
|
|
||||||
- [ ] Password reset functionality
|
- [ ] Password reset functionality
|
||||||
- [ ] Beer post operations
|
- [ ] Beer post operations
|
||||||
@@ -168,7 +168,7 @@ npm run test:storybook:playwright
|
|||||||
- [ ] Image upload service
|
- [ ] Image upload service
|
||||||
- [ ] Frontend route integration coverage beyond Storybook stories
|
- [ ] Frontend route integration coverage beyond Storybook stories
|
||||||
|
|
||||||
## Testing Frameworks & Tools
|
## Testing frameworks and tools
|
||||||
|
|
||||||
### xUnit
|
### xUnit
|
||||||
|
|
||||||
@@ -200,9 +200,9 @@ npm run test:storybook:playwright
|
|||||||
- Simulates SQL Server responses
|
- Simulates SQL Server responses
|
||||||
- No real database required for unit tests
|
- No real database required for unit tests
|
||||||
|
|
||||||
## Test Structure
|
## Test structure
|
||||||
|
|
||||||
### API.Specs (Integration Tests)
|
### API.Specs (integration tests)
|
||||||
|
|
||||||
```
|
```
|
||||||
API.Specs/
|
API.Specs/
|
||||||
@@ -260,9 +260,9 @@ Each of the other three slices (`Features.Breweries.Tests`,
|
|||||||
`Features.UserManagement.Tests`, `Features.Emails.Tests`) follows the same
|
`Features.UserManagement.Tests`, `Features.Emails.Tests`) follows the same
|
||||||
shape: a `Commands/`/`Queries/` folder with one test file per handler.
|
shape: a `Commands/`/`Queries/` folder with one test file per handler.
|
||||||
|
|
||||||
## Writing Tests
|
## Writing tests
|
||||||
|
|
||||||
### Unit Test Example (xUnit)
|
### Unit test example (xUnit)
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public class LoginHandlerTests
|
public class LoginHandlerTests
|
||||||
@@ -287,7 +287,7 @@ public class LoginHandlerTests
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Integration Test Example (Reqnroll)
|
### Integration test example (Reqnroll)
|
||||||
|
|
||||||
```gherkin
|
```gherkin
|
||||||
Scenario: User login with valid credentials
|
Scenario: User login with valid credentials
|
||||||
@@ -297,7 +297,7 @@ Scenario: User login with valid credentials
|
|||||||
And the response should contain a JWT token
|
And the response should contain a JWT token
|
||||||
```
|
```
|
||||||
|
|
||||||
## Continuous Integration
|
## Continuous integration
|
||||||
|
|
||||||
Tests run automatically in CI/CD pipelines using the test Docker Compose
|
Tests run automatically in CI/CD pipelines using the test Docker Compose
|
||||||
configuration:
|
configuration:
|
||||||
@@ -326,7 +326,7 @@ npm run test:storybook:playwright
|
|||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Tests Failing Due to Database Connection
|
### Tests failing due to database connection
|
||||||
|
|
||||||
Ensure SQL Server is running and environment variables are set:
|
Ensure SQL Server is running and environment variables are set:
|
||||||
|
|
||||||
@@ -334,12 +334,12 @@ Ensure SQL Server is running and environment variables are set:
|
|||||||
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml ps
|
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml ps
|
||||||
```
|
```
|
||||||
|
|
||||||
### Port Conflicts
|
### Port conflicts
|
||||||
|
|
||||||
If port 1433 is in use, stop other SQL Server instances or modify the port in
|
If port 1433 is in use, stop other SQL Server instances or modify the port in
|
||||||
`docker-compose.test.yaml`.
|
`docker-compose.test.yaml`.
|
||||||
|
|
||||||
### Stale Test Data
|
### Stale test data
|
||||||
|
|
||||||
Clean up test database:
|
Clean up test database:
|
||||||
|
|
||||||
@@ -347,13 +347,13 @@ Clean up test database:
|
|||||||
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
|
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
|
||||||
```
|
```
|
||||||
|
|
||||||
### View Container Logs
|
### View container logs
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml logs <service-name>
|
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml logs <service-name>
|
||||||
```
|
```
|
||||||
|
|
||||||
## Best Practices
|
## Best practices
|
||||||
|
|
||||||
1. **Isolation**: Each test should be independent and not rely on other tests
|
1. **Isolation**: Each test should be independent and not rely on other tests
|
||||||
2. **Cleanup**: Use fixtures and dispose patterns for resource cleanup
|
2. **Cleanup**: Use fixtures and dispose patterns for resource cleanup
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Token Validation Architecture
|
# Token validation architecture
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The Core project validates JWTs across three token types:
|
The Core project validates JSON Web Tokens (JWTs) across three token types:
|
||||||
|
|
||||||
- **Access Tokens**: Short-lived (1 hour) tokens for API authentication
|
- **Access Tokens**: Short-lived (1 hour) tokens for API authentication
|
||||||
- **Refresh Tokens**: Long-lived (21 days) tokens for obtaining new access
|
- **Refresh Tokens**: Long-lived (21 days) tokens for obtaining new access
|
||||||
@@ -12,7 +12,7 @@ The Core project validates JWTs across three token types:
|
|||||||
|
|
||||||
## Components
|
## Components
|
||||||
|
|
||||||
### Infrastructure Layer
|
### Infrastructure layer
|
||||||
|
|
||||||
#### [ITokenInfrastructure](../../web/backend/Infrastructure/Infrastructure.Jwt/ITokenInfrastructure.cs)
|
#### [ITokenInfrastructure](../../web/backend/Infrastructure/Infrastructure.Jwt/ITokenInfrastructure.cs)
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ Low-level JWT operations.
|
|||||||
- Algorithm: HS256 (HMAC-SHA256)
|
- Algorithm: HS256 (HMAC-SHA256)
|
||||||
- Validates token lifetime, signature, and well-formedness
|
- Validates token lifetime, signature, and well-formedness
|
||||||
|
|
||||||
### Features.Auth Slice
|
### Features.Auth slice
|
||||||
|
|
||||||
#### [ITokenService](../../web/backend/Features/Features.Auth/Services/ITokenService.cs)
|
#### [ITokenService](../../web/backend/Features/Features.Auth/Services/ITokenService.cs)
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ registered by the host (`API.Core/Program.cs`) since
|
|||||||
`JwtAuthenticationHandler` (host-level auth middleware) also depends on
|
`JwtAuthenticationHandler` (host-level auth middleware) also depends on
|
||||||
it directly.
|
it directly.
|
||||||
|
|
||||||
### Integration Points
|
### Integration points
|
||||||
|
|
||||||
#### [ConfirmUserHandler](../../web/backend/Features/Features.Auth/Commands/ConfirmUser/ConfirmUserHandler.cs)
|
#### [ConfirmUserHandler](../../web/backend/Features/Features.Auth/Commands/ConfirmUser/ConfirmUserHandler.cs)
|
||||||
|
|
||||||
@@ -112,9 +112,9 @@ it directly.
|
|||||||
- `POST /api/auth/confirm/resend?userId=...` - Resend the confirmation email
|
- `POST /api/auth/confirm/resend?userId=...` - Resend the confirmation email
|
||||||
- `POST /api/auth/refresh` - Refresh access token
|
- `POST /api/auth/refresh` - Refresh access token
|
||||||
|
|
||||||
## Validation Security
|
## Validation security
|
||||||
|
|
||||||
### Token Secrets
|
### Token secrets
|
||||||
|
|
||||||
Three independent secrets enable:
|
Three independent secrets enable:
|
||||||
|
|
||||||
@@ -130,7 +130,7 @@ REFRESH_TOKEN_SECRET=... # Signs 21-day refresh tokens
|
|||||||
CONFIRMATION_TOKEN_SECRET=... # Signs 30-minute confirmation tokens
|
CONFIRMATION_TOKEN_SECRET=... # Signs 30-minute confirmation tokens
|
||||||
```
|
```
|
||||||
|
|
||||||
### Validation Checks
|
### Validation checks
|
||||||
|
|
||||||
Each token is validated for:
|
Each token is validated for:
|
||||||
|
|
||||||
@@ -139,7 +139,7 @@ Each token is validated for:
|
|||||||
3. **Claims Presence** - Required claims (Sub, UniqueName) must be present
|
3. **Claims Presence** - Required claims (Sub, UniqueName) must be present
|
||||||
4. **Claims Format** - UserId claim must be a valid GUID
|
4. **Claims Format** - UserId claim must be a valid GUID
|
||||||
|
|
||||||
### Error Handling
|
### Error handling
|
||||||
|
|
||||||
Validation failures return HTTP 401 Unauthorized:
|
Validation failures return HTTP 401 Unauthorized:
|
||||||
|
|
||||||
@@ -148,9 +148,9 @@ Validation failures return HTTP 401 Unauthorized:
|
|||||||
- Missing claims → "Invalid token"
|
- Missing claims → "Invalid token"
|
||||||
- Malformed claims → "Invalid token"
|
- Malformed claims → "Invalid token"
|
||||||
|
|
||||||
## Token Lifecycle
|
## Token lifecycle
|
||||||
|
|
||||||
### Access Token Lifecycle
|
### Access token lifecycle
|
||||||
|
|
||||||
1. **Generation**: During login (1-hour validity)
|
1. **Generation**: During login (1-hour validity)
|
||||||
2. **Usage**: Included in Authorization header on API requests
|
2. **Usage**: Included in Authorization header on API requests
|
||||||
@@ -158,16 +158,16 @@ Validation failures return HTTP 401 Unauthorized:
|
|||||||
4. **Expiration**: Token becomes invalid after 1 hour
|
4. **Expiration**: Token becomes invalid after 1 hour
|
||||||
5. **Refresh**: Use refresh token to obtain new access token
|
5. **Refresh**: Use refresh token to obtain new access token
|
||||||
|
|
||||||
### Refresh Token Lifecycle
|
### Refresh token lifecycle
|
||||||
|
|
||||||
1. **Generation**: During login (21-day validity)
|
1. **Generation**: During login (21-day validity)
|
||||||
2. **Storage**: Client-side (secure storage)
|
2. **Storage**: Client-side (secure storage)
|
||||||
3. **Usage**: Posted to `/api/auth/refresh` endpoint
|
3. **Usage**: Posted to `/api/auth/refresh` endpoint
|
||||||
4. **Validation**: Validated by RefreshTokenService
|
4. **Validation**: Validated by `ITokenService.RefreshTokenAsync()`
|
||||||
5. **Rotation**: New refresh token issued on successful refresh
|
5. **Rotation**: New refresh token issued on successful refresh
|
||||||
6. **Expiration**: Token becomes invalid after 21 days
|
6. **Expiration**: Token becomes invalid after 21 days
|
||||||
|
|
||||||
### Confirmation Token Lifecycle
|
### Confirmation token lifecycle
|
||||||
|
|
||||||
1. **Generation**: During user registration (30-minute validity)
|
1. **Generation**: During user registration (30-minute validity)
|
||||||
2. **Delivery**: Emailed to user in confirmation link
|
2. **Delivery**: Emailed to user in confirmation link
|
||||||
@@ -180,7 +180,7 @@ Validation failures return HTTP 401 Unauthorized:
|
|||||||
|
|
||||||
All of the following live in `Features.Auth.Tests`.
|
All of the following live in `Features.Auth.Tests`.
|
||||||
|
|
||||||
### Unit Tests
|
### Unit tests
|
||||||
|
|
||||||
**Services/TokenServiceValidationTests.cs**
|
**Services/TokenServiceValidationTests.cs**
|
||||||
|
|
||||||
@@ -210,7 +210,7 @@ All of the following live in `Features.Auth.Tests`.
|
|||||||
- Sends a fresh confirmation email when the user exists and is unverified
|
- Sends a fresh confirmation email when the user exists and is unverified
|
||||||
- No-ops when the user doesn't exist or is already verified
|
- No-ops when the user doesn't exist or is already verified
|
||||||
|
|
||||||
### BDD Tests (Reqnroll)
|
### BDD tests (Reqnroll)
|
||||||
|
|
||||||
**TokenRefresh.feature**
|
**TokenRefresh.feature**
|
||||||
|
|
||||||
@@ -230,19 +230,19 @@ All of the following live in `Features.Auth.Tests`.
|
|||||||
- Invalid/expired access token rejection
|
- Invalid/expired access token rejection
|
||||||
- Token type mismatch (refresh used as access token)
|
- Token type mismatch (refresh used as access token)
|
||||||
|
|
||||||
## Future Enhancements
|
## Future enhancements
|
||||||
|
|
||||||
### Stretch Goals
|
### Stretch goals
|
||||||
|
|
||||||
1. **Middleware for Access Token Validation**
|
1. **Middleware for Access Token Validation**
|
||||||
- Automatically validate access tokens on protected routes
|
- Automatically validate access tokens on protected routes
|
||||||
- Populate HttpContext.User from token claims
|
- Populate HttpContext.User from token claims
|
||||||
- Return 401 for invalid/missing tokens
|
- Return 401 for invalid/missing tokens
|
||||||
|
|
||||||
2. **Token Blacklisting**
|
2. **Token Denylisting**
|
||||||
- Implement token revocation (e.g., on logout)
|
- Implement token revocation (for example, on logout)
|
||||||
- Store blacklisted tokens in cache/database
|
- Store denylisted tokens in cache/database
|
||||||
- Check blacklist during validation
|
- Check the denylist during validation
|
||||||
|
|
||||||
3. **Refresh Token Rotation Strategy**
|
3. **Refresh Token Rotation Strategy**
|
||||||
- Detect token reuse (replay attacks)
|
- Detect token reuse (replay attacks)
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -111,8 +111,9 @@ if(NOT BIERGARTEN_MOCK_ONLY)
|
|||||||
)
|
)
|
||||||
FetchContent_MakeAvailable(llama-cpp)
|
FetchContent_MakeAvailable(llama-cpp)
|
||||||
endif()
|
endif()
|
||||||
|
if (NOT APPLE)
|
||||||
find_package(OpenMP REQUIRED)
|
find_package(OpenMP REQUIRED)
|
||||||
|
endif()
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
# spdlog
|
# spdlog
|
||||||
@@ -249,9 +250,10 @@ target_include_directories(${PROJECT_NAME} PRIVATE
|
|||||||
includes
|
includes
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
target_link_libraries(${PROJECT_NAME} PRIVATE
|
target_link_libraries(${PROJECT_NAME} PRIVATE
|
||||||
$<$<NOT:$<BOOL:${BIERGARTEN_MOCK_ONLY}>>:llama>
|
$<$<NOT:$<BOOL:${BIERGARTEN_MOCK_ONLY}>>:llama>
|
||||||
$<$<NOT:$<BOOL:${BIERGARTEN_MOCK_ONLY}>>:OpenMP::OpenMP_CXX>
|
$<TARGET_NAME_IF_EXISTS:OpenMP::OpenMP_CXX>
|
||||||
boost::di
|
boost::di
|
||||||
Boost::json
|
Boost::json
|
||||||
Boost::program_options
|
Boost::program_options
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
```bash
|
# RunPod container
|
||||||
touch runpod/start.sh
|
|
||||||
docker build \
|
Dockerfile, launcher (`start.sh`), and pod template for running the
|
||||||
--progress=plain \
|
Biergarten pipeline on a GPU host. See
|
||||||
-t biergarten-pipeline:latest \
|
[Docker / RunPod](../../../docs/pipeline/README.md#docker--runpod) in the
|
||||||
-f runpod/Dockerfile \
|
pipeline README for the full build and run instructions, environment
|
||||||
. 2>&1 | tee build.log
|
variable reference, and RunPod deployment notes.
|
||||||
```
|
|
||||||
|
|||||||
@@ -54,11 +54,11 @@ void XegerGenerator::Visit(const Alternation& node) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void XegerGenerator::Visit(const Repeat& node) {
|
void XegerGenerator::Visit(const Repeat& node) {
|
||||||
const int lo = node.min;
|
const unsigned int low = node.min;
|
||||||
const int hi = (node.max < 0) ? node.min + unbounded_cap_ : node.max;
|
const unsigned int high = node.max.value_or(node.min + unbounded_cap_);
|
||||||
std::uniform_int_distribution<int> dist(lo, hi);
|
std::uniform_int_distribution<unsigned int> dist(low, high);
|
||||||
const int count = dist(rng_);
|
const unsigned int count = dist(rng_);
|
||||||
for (int i = 0; i < count; ++i) {
|
for (unsigned int i = 0; i < count; ++i) {
|
||||||
node.child->Accept(*this);
|
node.child->Accept(*this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
/// Custom ASP.NET Core authentication handler that validates bearer JWT access tokens
|
||||||
/// supplied in the <c>Authorization</c> header against the <c>JWT</c> authentication scheme.
|
/// supplied in the <c>Authorization</c> header against the <c>JWT</c> authentication scheme.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="options">The monitored options for the <c>JWT</c> authentication scheme.</param>
|
|
||||||
/// <param name="logger">The logger factory used by the base <see cref="AuthenticationHandler{TOptions}" />.</param>
|
|
||||||
/// <param name="encoder">The URL encoder used by the base <see cref="AuthenticationHandler{TOptions}" />.</param>
|
|
||||||
/// <param name="tokenInfrastructure">The infrastructure service used to validate JWT access tokens.</param>
|
|
||||||
/// <param name="configuration">The application configuration, used as a fallback source for the JWT secret key.</param>
|
|
||||||
public class JwtAuthenticationHandler(
|
public class JwtAuthenticationHandler(
|
||||||
IOptionsMonitor<JwtAuthenticationOptions> options,
|
IOptionsMonitor<JwtAuthenticationOptions> options,
|
||||||
ILoggerFactory logger,
|
ILoggerFactory logger,
|
||||||
@@ -31,15 +26,9 @@ public class JwtAuthenticationHandler(
|
|||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The signing secret is resolved first from the <c>ACCESS_TOKEN_SECRET</c> environment variable, falling
|
/// The signing secret is resolved first from the <c>ACCESS_TOKEN_SECRET</c> environment variable, falling
|
||||||
/// back to the <c>Jwt:SecretKey</c> configuration value, to stay consistent with the secret source used
|
/// back to the <c>Jwt:SecretKey</c> configuration value, to stay consistent with the secret source used
|
||||||
/// when tokens are issued. Authentication fails if the secret is not configured, the
|
/// when tokens are issued. Fails if the secret is not configured, the <c>Authorization</c> header is
|
||||||
/// <c>Authorization</c> header is missing, the header does not use the <c>Bearer</c> scheme, or the
|
/// missing or not <c>Bearer</c>-scheme, or the token fails validation.
|
||||||
/// token fails validation.
|
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <returns>
|
|
||||||
/// A successful <see cref="AuthenticateResult" /> containing the validated
|
|
||||||
/// <see cref="System.Security.Claims.ClaimsPrincipal" />
|
|
||||||
/// on success, or a failure result describing why authentication could not be completed.
|
|
||||||
/// </returns>
|
|
||||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||||
{
|
{
|
||||||
// Use the same access-token secret source as TokenService to avoid mismatched validation.
|
// Use the same access-token secret source as TokenService to avoid mismatched validation.
|
||||||
@@ -78,8 +67,6 @@ public class JwtAuthenticationHandler(
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Writes a JSON 401 Unauthorized response when authentication fails or is required but not supplied.
|
/// Writes a JSON 401 Unauthorized response when authentication fails or is required but not supplied.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="properties">The authentication properties associated with the challenge.</param>
|
|
||||||
/// <returns>A task that completes once the response body has been written.</returns>
|
|
||||||
protected override async Task HandleChallengeAsync(AuthenticationProperties properties)
|
protected override async Task HandleChallengeAsync(AuthenticationProperties properties)
|
||||||
{
|
{
|
||||||
Response.ContentType = "application/json";
|
Response.ContentType = "application/json";
|
||||||
@@ -96,5 +83,4 @@ public class JwtAuthenticationHandler(
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Options for the <c>JWT</c> authentication scheme handled by <see cref="JwtAuthenticationHandler" />.
|
/// Options for the <c>JWT</c> authentication scheme handled by <see cref="JwtAuthenticationHandler" />.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>No additional options are defined beyond those provided by <see cref="AuthenticationSchemeOptions" />.</remarks>
|
|
||||||
public class JwtAuthenticationOptions : AuthenticationSchemeOptions { }
|
public class JwtAuthenticationOptions : AuthenticationSchemeOptions { }
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ namespace API.Core.Controllers;
|
|||||||
/// Handles requests that do not match any other route, used as the application's fallback controller.
|
/// Handles requests that do not match any other route, used as the application's fallback controller.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Excluded from API explorer/Swagger output via <c>[ApiExplorerSettings(IgnoreApi = true)]</c>.
|
|
||||||
/// Wired up as the fallback target via <c>app.MapFallbackToController("Handle404", "NotFound")</c> in
|
/// Wired up as the fallback target via <c>app.MapFallbackToController("Handle404", "NotFound")</c> in
|
||||||
/// <c>Program.cs</c>.
|
/// <c>Program.cs</c>.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
@@ -15,10 +14,6 @@ namespace API.Core.Controllers;
|
|||||||
[Route("error")] // required
|
[Route("error")] // required
|
||||||
public class NotFoundController : ControllerBase
|
public class NotFoundController : ControllerBase
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// Returns a generic 404 response for any request that did not match a defined route.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>A <c>404 Not Found</c> result with a JSON body containing a "Route not found." message.</returns>
|
|
||||||
[HttpGet("404")] //required
|
[HttpGet("404")] //required
|
||||||
public IActionResult Handle404()
|
public IActionResult Handle404()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,13 +14,9 @@ namespace API.Core.Controllers;
|
|||||||
public class ProtectedController : ControllerBase
|
public class ProtectedController : ControllerBase
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the authenticated caller's user ID and username, extracted from the JWT claims.
|
/// Returns the authenticated caller's user ID and username extracted from the JWT claims.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>
|
/// <returns><c>userId</c> and <c>username</c> may be <c>null</c> if not present in the token.</returns>
|
||||||
/// A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> whose payload contains the
|
|
||||||
/// caller's <c>userId</c> (from <see cref="ClaimTypes.NameIdentifier" />) and <c>username</c>
|
|
||||||
/// (from <see cref="ClaimTypes.Name" />), either of which may be <c>null</c> if not present in the token.
|
|
||||||
/// </returns>
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public ActionResult<ResponseBody<object>> Get()
|
public ActionResult<ResponseBody<object>> Get()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,67 +13,17 @@ namespace API.Core;
|
|||||||
/// MVC exception filter that converts unhandled exceptions raised by controller actions into
|
/// MVC exception filter that converts unhandled exceptions raised by controller actions into
|
||||||
/// consistent JSON error responses with appropriate HTTP status codes.
|
/// consistent JSON error responses with appropriate HTTP status codes.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="logger">Logger used to record unhandled exceptions before they are translated into a response.</param>
|
|
||||||
public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger) : IExceptionFilter
|
public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger) : IExceptionFilter
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Logs the exception and sets <see cref="ExceptionContext.Result" /> to an appropriate error response,
|
/// Logs the exception and sets <see cref="ExceptionContext.Result" /> to an appropriate error response.
|
||||||
/// marking the exception as handled.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Maps exception types to responses as follows:
|
/// Maps <see cref="FluentValidation.ValidationException" /> to 400, <see cref="ConflictException" /> to 409,
|
||||||
/// <list type="bullet">
|
/// <see cref="NotFoundException" /> to 404, <see cref="UnauthorizedException" /> to 401,
|
||||||
/// <item>
|
/// <see cref="ForbiddenException" /> to 403, <see cref="SqlException" /> to 503,
|
||||||
/// <description>
|
/// <see cref="Domain.Exceptions.ValidationException" /> to 400, and anything else to 500.
|
||||||
/// <see cref="FluentValidation.ValidationException" /> → <c>400 Bad Request</c> with per-property
|
|
||||||
/// validation error messages.
|
|
||||||
/// </description>
|
|
||||||
/// </item>
|
|
||||||
/// <item>
|
|
||||||
/// <description>
|
|
||||||
/// <see cref="ConflictException" /> → <c>409 Conflict</c> with a <see cref="ResponseBody" />
|
|
||||||
/// message.
|
|
||||||
/// </description>
|
|
||||||
/// </item>
|
|
||||||
/// <item>
|
|
||||||
/// <description>
|
|
||||||
/// <see cref="NotFoundException" /> → <c>404 Not Found</c> with a <see cref="ResponseBody" />
|
|
||||||
/// message.
|
|
||||||
/// </description>
|
|
||||||
/// </item>
|
|
||||||
/// <item>
|
|
||||||
/// <description>
|
|
||||||
/// <see cref="UnauthorizedException" /> → <c>401 Unauthorized</c> with a
|
|
||||||
/// <see cref="ResponseBody" /> message.
|
|
||||||
/// </description>
|
|
||||||
/// </item>
|
|
||||||
/// <item>
|
|
||||||
/// <description>
|
|
||||||
/// <see cref="ForbiddenException" /> → <c>403 Forbidden</c> with a <see cref="ResponseBody" />
|
|
||||||
/// message.
|
|
||||||
/// </description>
|
|
||||||
/// </item>
|
|
||||||
/// <item>
|
|
||||||
/// <description>
|
|
||||||
/// <see cref="SqlException" /> → <c>503 Service Unavailable</c> with a generic database error
|
|
||||||
/// message.
|
|
||||||
/// </description>
|
|
||||||
/// </item>
|
|
||||||
/// <item>
|
|
||||||
/// <description>
|
|
||||||
/// <see cref="Domain.Exceptions.ValidationException" /> → <c>400 Bad Request</c> with a
|
|
||||||
/// <see cref="ResponseBody" /> message.
|
|
||||||
/// </description>
|
|
||||||
/// </item>
|
|
||||||
/// <item>
|
|
||||||
/// <description>Any other exception → <c>500 Internal Server Error</c> with a generic error message.</description>
|
|
||||||
/// </item>
|
|
||||||
/// </list>
|
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="context">
|
|
||||||
/// The context for the unhandled exception, including the exception itself and the result to
|
|
||||||
/// populate.
|
|
||||||
/// </param>
|
|
||||||
public void OnException(ExceptionContext context)
|
public void OnException(ExceptionContext context)
|
||||||
{
|
{
|
||||||
logger.LogError(context.Exception, "Unhandled exception occurred");
|
logger.LogError(context.Exception, "Unhandled exception occurred");
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ using Infrastructure.Email;
|
|||||||
namespace API.Specs.Mocks;
|
namespace API.Specs.Mocks;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Mock email provider for testing that doesn't actually send emails.
|
/// Test double for <see cref="IEmailProvider" /> that records sent emails instead of delivering them.
|
||||||
/// Tracks sent emails for verification in tests if needed.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class MockEmailProvider : IEmailProvider
|
public class MockEmailProvider : IEmailProvider
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,37 +1,35 @@
|
|||||||
<Solution>
|
<Solution>
|
||||||
<Folder Name="/API/">
|
<Folder Name="/API/">
|
||||||
<Project Path="API/API.Core/API.Core.csproj"/>
|
<Project Path="API/API.Core/API.Core.csproj" />
|
||||||
<Project Path="API/API.Specs/API.Specs.csproj"/>
|
<Project Path="API/API.Specs/API.Specs.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/Database/">
|
<Folder Name="/Database/">
|
||||||
<Project Path="Database/Database.Migrations/Database.Migrations.csproj"/>
|
<Project Path="Database/Database.Migrations/Database.Migrations.csproj" />
|
||||||
<Project Path="Database/Database.Seed/Database.Seed.csproj"/>
|
<Project Path="Database/Database.Seed/Database.Seed.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/Domain/">
|
<Folder Name="/Domain/">
|
||||||
<Project Path="Domain/Domain.Entities/Domain.Entities.csproj"/>
|
<Project Path="Domain/Domain.Entities/Domain.Entities.csproj" />
|
||||||
<Project Path="Domain/Domain.Exceptions/Domain.Exceptions.csproj"/>
|
<Project Path="Domain/Domain.Exceptions/Domain.Exceptions.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/Infrastructure/">
|
<Folder Name="/Infrastructure/">
|
||||||
<Project Path="Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj"/>
|
<Project Path="Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj" />
|
||||||
<Project
|
<Project Path="Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj" />
|
||||||
Path="Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj"/>
|
<Project Path="Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj" />
|
||||||
<Project Path="Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj"/>
|
<Project Path="Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj" />
|
||||||
<Project
|
<Project Path="Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj" />
|
||||||
Path="Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj"/>
|
|
||||||
<Project Path="Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj"/>
|
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/Features/">
|
<Folder Name="/Features/">
|
||||||
<Project Path="Features/Features.Breweries/Features.Breweries.csproj"/>
|
<Project Path="Features/Features.Breweries/Features.Breweries.csproj" />
|
||||||
<Project Path="Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj"/>
|
<Project Path="Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj" />
|
||||||
<Project Path="Features/Features.UserManagement/Features.UserManagement.csproj"/>
|
<Project Path="Features/Features.UserManagement/Features.UserManagement.csproj" />
|
||||||
<Project Path="Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj"/>
|
<Project Path="Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj" />
|
||||||
<Project Path="Features/Features.Auth/Features.Auth.csproj"/>
|
<Project Path="Features/Features.Auth/Features.Auth.csproj" />
|
||||||
<Project Path="Features/Features.Auth.Tests/Features.Auth.Tests.csproj"/>
|
<Project Path="Features/Features.Auth.Tests/Features.Auth.Tests.csproj" />
|
||||||
<Project Path="Features/Features.Emails/Features.Emails.csproj"/>
|
<Project Path="Features/Features.Emails/Features.Emails.csproj" />
|
||||||
<Project Path="Features/Features.Emails.Tests/Features.Emails.Tests.csproj"/>
|
<Project Path="Features/Features.Emails.Tests/Features.Emails.Tests.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/Shared/">
|
<Folder Name="/Shared/">
|
||||||
<Project Path="Shared/Shared.Contracts/Shared.Contracts.csproj"/>
|
<Project Path="Shared/Shared.Contracts/Shared.Contracts.csproj" />
|
||||||
<Project Path="Shared/Shared.Application/Shared.Application.csproj"/>
|
<Project Path="Shared/Shared.Application/Shared.Application.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
@@ -7,215 +7,210 @@ using Microsoft.Data.SqlClient;
|
|||||||
namespace Database.Migrations;
|
namespace Database.Migrations;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Entry point for the database migration runner. Reads connection details from
|
/// Entry point for the database migration runner. Reads connection details from environment
|
||||||
/// environment variables, optionally clears and recreates the target database, and
|
/// variables, optionally drops and recreates the target database, and applies all SQL
|
||||||
/// applies all SQL migration scripts embedded in this assembly using DbUp.
|
/// migration scripts embedded in this assembly using DbUp.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class Program
|
public static class Program
|
||||||
{
|
{
|
||||||
/// <summary>The connection string for the target application database (<c>DB_NAME</c>).</summary>
|
/// <summary>
|
||||||
private static readonly string connectionString = BuildConnectionString();
|
/// Migration runner entry point. Drops the target database when <c>CLEAR_DATABASE</c> is
|
||||||
|
/// set to <c>true</c>, ensures the database exists, then deploys all pending migrations.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>
|
||||||
|
/// <c>0</c> if migrations completed successfully; <c>1</c> if they failed or an error occurred.
|
||||||
|
/// </returns>
|
||||||
|
public static async Task<int> Main()
|
||||||
|
{
|
||||||
|
Console.WriteLine("Starting database migrations...");
|
||||||
|
|
||||||
/// <summary>The connection string for the <c>master</c> database, used for create/drop operations.</summary>
|
try
|
||||||
private static readonly string masterConnectionString = BuildConnectionString("master");
|
{
|
||||||
|
DatabaseSettings settings = DatabaseSettings.FromEnvironment();
|
||||||
|
|
||||||
|
if (settings.ClearDatabase)
|
||||||
|
{
|
||||||
|
Console.WriteLine(
|
||||||
|
$"CLEAR_DATABASE is enabled. Dropping database '{settings.DatabaseName}'..."
|
||||||
|
);
|
||||||
|
await DropDatabaseIfExistsAsync(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
await CreateDatabaseIfNotExistsAsync(settings);
|
||||||
|
|
||||||
|
return DeployMigrations(settings.ConnectionString) ? 0 : 1;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is InvalidOperationException or SqlException)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("An error occurred during database migrations:");
|
||||||
|
Console.Error.WriteLine(ex.Message);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds a SQL Server connection string from the <c>DB_SERVER</c>, <c>DB_NAME</c>,
|
/// Drops the target database if it exists, first forcing it into single-user mode with
|
||||||
/// <c>DB_USER</c>, <c>DB_PASSWORD</c>, and <c>DB_TRUST_SERVER_CERTIFICATE</c>
|
/// rollback immediate so that any existing connections are terminated.
|
||||||
/// environment variables.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="databaseName">
|
/// <param name="settings">The resolved database settings.</param>
|
||||||
/// The database (initial catalog) to connect to. When <c>null</c>, falls back to the
|
/// <exception cref="SqlException">Thrown if the connection or the drop operation fails.</exception>
|
||||||
/// <c>DB_NAME</c> environment variable.
|
private static async Task DropDatabaseIfExistsAsync(DatabaseSettings settings)
|
||||||
/// </param>
|
|
||||||
/// <returns>A fully built SQL Server connection string.</returns>
|
|
||||||
/// <exception cref="InvalidOperationException">
|
|
||||||
/// Thrown when <c>DB_SERVER</c>, <c>DB_USER</c>, <c>DB_PASSWORD</c>, or (when
|
|
||||||
/// <paramref name="databaseName" /> is <c>null</c>) <c>DB_NAME</c> is not set.
|
|
||||||
/// </exception>
|
|
||||||
private static string BuildConnectionString(string? databaseName = null)
|
|
||||||
{
|
{
|
||||||
string server =
|
const string sql = """
|
||||||
Environment.GetEnvironmentVariable("DB_SERVER")
|
DECLARE @statement nvarchar(max);
|
||||||
?? throw new InvalidOperationException("DB_SERVER environment variable is not set");
|
|
||||||
|
|
||||||
string dbName =
|
IF EXISTS (SELECT 1 FROM sys.databases WHERE name = @databaseName)
|
||||||
databaseName
|
BEGIN
|
||||||
?? Environment.GetEnvironmentVariable("DB_NAME")
|
SET @statement =
|
||||||
?? throw new InvalidOperationException("DB_NAME environment variable is not set");
|
N'ALTER DATABASE ' + QUOTENAME(@databaseName)
|
||||||
|
+ N' SET SINGLE_USER WITH ROLLBACK IMMEDIATE;';
|
||||||
|
EXEC sys.sp_executesql @statement;
|
||||||
|
|
||||||
string user =
|
SET @statement = N'DROP DATABASE ' + QUOTENAME(@databaseName) + N';';
|
||||||
Environment.GetEnvironmentVariable("DB_USER")
|
EXEC sys.sp_executesql @statement;
|
||||||
?? throw new InvalidOperationException("DB_USER environment variable is not set");
|
END
|
||||||
|
""";
|
||||||
|
|
||||||
string password =
|
await ExecuteNonQueryAsync(settings.MasterConnectionString, sql, settings.DatabaseName);
|
||||||
Environment.GetEnvironmentVariable("DB_PASSWORD")
|
Console.WriteLine($"Database '{settings.DatabaseName}' dropped, or did not exist.");
|
||||||
?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set");
|
}
|
||||||
|
|
||||||
string trustServerCertificate =
|
/// <summary>
|
||||||
Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE") ?? "True";
|
/// Creates the target database on the <c>master</c> connection if it does not already exist.
|
||||||
|
/// </summary>
|
||||||
SqlConnectionStringBuilder builder = new()
|
/// <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,
|
const string sql = """
|
||||||
InitialCatalog = dbName,
|
DECLARE @statement nvarchar(max);
|
||||||
UserID = user,
|
|
||||||
Password = password,
|
|
||||||
TrustServerCertificate = bool.Parse(trustServerCertificate),
|
|
||||||
Encrypt = true,
|
|
||||||
};
|
|
||||||
|
|
||||||
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>
|
/// <summary>
|
||||||
/// Applies all pending SQL migration scripts embedded in this assembly to the target
|
/// Applies all pending SQL migration scripts embedded in this assembly to the target
|
||||||
/// database using DbUp, logging progress to the console.
|
/// database using DbUp, logging progress to the console.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="connectionString">The connection string for the target application database.</param>
|
||||||
/// <returns><c>true</c> if the upgrade completed successfully; otherwise <c>false</c>.</returns>
|
/// <returns><c>true</c> if the upgrade completed successfully; otherwise <c>false</c>.</returns>
|
||||||
private static bool DeployMigrations()
|
private static bool DeployMigrations(string connectionString)
|
||||||
{
|
{
|
||||||
UpgradeEngine? upgrader = DeployChanges
|
UpgradeEngine upgrader = DeployChanges
|
||||||
.To.SqlDatabase(connectionString)
|
.To.SqlDatabase(connectionString)
|
||||||
.WithScriptsEmbeddedInAssembly(Assembly.GetExecutingAssembly())
|
.WithScriptsEmbeddedInAssembly(Assembly.GetExecutingAssembly())
|
||||||
|
.WithTransactionPerScript()
|
||||||
.LogToConsole()
|
.LogToConsole()
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
DatabaseUpgradeResult? result = upgrader.PerformUpgrade();
|
DatabaseUpgradeResult result = upgrader.PerformUpgrade();
|
||||||
return result.Successful;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
if (result.Successful)
|
||||||
/// 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)
|
|
||||||
{
|
{
|
||||||
Console.WriteLine("Database migrations completed successfully.");
|
Console.WriteLine("Database migrations completed successfully.");
|
||||||
return 0;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Console.WriteLine("Database migrations failed.");
|
Console.Error.WriteLine($"Database migrations failed: {result.Error}");
|
||||||
return 1;
|
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:");
|
/// <summary>
|
||||||
Console.WriteLine(ex.Message);
|
/// Builds the settings from the <c>DB_SERVER</c>, <c>DB_NAME</c>, <c>DB_USER</c>,
|
||||||
return 1;
|
/// <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,18 +1,15 @@
|
|||||||
CREATE
|
CREATE OR ALTER FUNCTION dbo.UDF_GetCountryIdByCode(
|
||||||
OR
|
|
||||||
ALTER FUNCTION dbo.UDF_GetCountryIdByCode
|
|
||||||
(
|
|
||||||
@CountryCode NVARCHAR(2)
|
@CountryCode NVARCHAR(2)
|
||||||
)
|
)
|
||||||
RETURNS UNIQUEIDENTIFIER
|
RETURNS UNIQUEIDENTIFIER
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
DECLARE
|
DECLARE
|
||||||
@CountryId UNIQUEIDENTIFIER;
|
@CountryId UNIQUEIDENTIFIER;
|
||||||
|
|
||||||
SELECT @CountryId = CountryID
|
SELECT @CountryId = CountryID
|
||||||
FROM dbo.Country
|
FROM dbo.Country
|
||||||
WHERE ISO3166_1 = @CountryCode;
|
WHERE ISO3166_1 = @CountryCode;
|
||||||
|
|
||||||
RETURN @CountryId;
|
RETURN @CountryId;
|
||||||
END;
|
END;
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
CREATE
|
CREATE OR ALTER FUNCTION dbo.UDF_GetStateProvinceIdByCode(
|
||||||
OR
|
|
||||||
ALTER FUNCTION dbo.UDF_GetStateProvinceIdByCode
|
|
||||||
(
|
|
||||||
@StateProvinceCode NVARCHAR(6)
|
@StateProvinceCode NVARCHAR(6)
|
||||||
)
|
)
|
||||||
RETURNS UNIQUEIDENTIFIER
|
RETURNS UNIQUEIDENTIFIER
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
DECLARE
|
DECLARE
|
||||||
@StateProvinceId UNIQUEIDENTIFIER;
|
@StateProvinceId UNIQUEIDENTIFIER;
|
||||||
SELECT @StateProvinceId = StateProvinceID
|
SELECT @StateProvinceId = StateProvinceID
|
||||||
FROM dbo.StateProvince
|
FROM dbo.StateProvince
|
||||||
WHERE ISO3166_2 = @StateProvinceCode;
|
WHERE ISO3166_2 = @StateProvinceCode;
|
||||||
RETURN @StateProvinceId;
|
RETURN @StateProvinceId;
|
||||||
END;
|
END;
|
||||||
|
|||||||
@@ -1,34 +1,31 @@
|
|||||||
CREATE
|
CREATE OR ALTER PROCEDURE usp_CreateUserAccount(
|
||||||
OR
|
|
||||||
ALTER PROCEDURE usp_CreateUserAccount
|
|
||||||
(
|
|
||||||
@UserAccountId UNIQUEIDENTIFIER OUTPUT,
|
@UserAccountId UNIQUEIDENTIFIER OUTPUT,
|
||||||
@Username VARCHAR (64),
|
@Username VARCHAR(64),
|
||||||
@FirstName NVARCHAR(128),
|
@FirstName NVARCHAR(128),
|
||||||
@LastName NVARCHAR(128),
|
@LastName NVARCHAR(128),
|
||||||
@DateOfBirth DATETIME,
|
@DateOfBirth DATETIME,
|
||||||
@Email VARCHAR (128)
|
@Email VARCHAR(128)
|
||||||
)
|
)
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET
|
SET
|
||||||
NOCOUNT ON;
|
NOCOUNT ON;
|
||||||
|
|
||||||
DECLARE
|
DECLARE
|
||||||
@Inserted TABLE (UserAccountID UNIQUEIDENTIFIER);
|
@Inserted TABLE
|
||||||
|
(
|
||||||
|
UserAccountID UNIQUEIDENTIFIER
|
||||||
|
);
|
||||||
|
|
||||||
INSERT INTO UserAccount
|
INSERT INTO UserAccount
|
||||||
(Username,
|
(Username,
|
||||||
FirstName,
|
FirstName,
|
||||||
LastName,
|
LastName,
|
||||||
DateOfBirth,
|
DateOfBirth,
|
||||||
Email)
|
Email)
|
||||||
OUTPUT INSERTED.UserAccountID INTO @Inserted
|
OUTPUT INSERTED.UserAccountID INTO @Inserted
|
||||||
VALUES
|
VALUES (@Username, @FirstName, @LastName, @DateOfBirth, @Email);
|
||||||
(
|
|
||||||
@Username, @FirstName, @LastName, @DateOfBirth, @Email
|
|
||||||
);
|
|
||||||
|
|
||||||
SELECT @UserAccountId = UserAccountID
|
SELECT @UserAccountId = UserAccountID
|
||||||
FROM @Inserted;
|
FROM @Inserted;
|
||||||
END;
|
END;
|
||||||
|
|||||||
@@ -1,24 +1,20 @@
|
|||||||
CREATE
|
CREATE OR ALTER PROCEDURE usp_DeleteUserAccount(
|
||||||
OR
|
|
||||||
ALTER PROCEDURE usp_DeleteUserAccount
|
|
||||||
(
|
|
||||||
@UserAccountId UNIQUEIDENTIFIER
|
@UserAccountId UNIQUEIDENTIFIER
|
||||||
)
|
)
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET
|
SET NOCOUNT ON
|
||||||
NOCOUNT ON
|
|
||||||
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM UserAccount WHERE UserAccountId = @UserAccountId)
|
IF NOT EXISTS (SELECT 1 FROM UserAccount WHERE UserAccountId = @UserAccountId)
|
||||||
BEGIN
|
BEGIN
|
||||||
RAISERROR
|
RAISERROR
|
||||||
('UserAccount with the specified ID does not exist.', 16,
|
('UserAccount with the specified ID does not exist.', 16,
|
||||||
1);
|
1);
|
||||||
ROLLBACK TRANSACTION
|
ROLLBACK TRANSACTION
|
||||||
RETURN
|
RETURN
|
||||||
END
|
END
|
||||||
|
|
||||||
DELETE
|
DELETE
|
||||||
FROM UserAccount
|
FROM UserAccount
|
||||||
WHERE UserAccountId = @UserAccountId;
|
WHERE UserAccountId = @UserAccountId;
|
||||||
END;
|
END;
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
CREATE
|
CREATE OR ALTER PROCEDURE usp_GetAllUserAccounts
|
||||||
OR
|
AS
|
||||||
ALTER PROCEDURE usp_GetAllUserAccounts
|
|
||||||
AS
|
|
||||||
BEGIN
|
BEGIN
|
||||||
SET
|
SET NOCOUNT ON;
|
||||||
NOCOUNT ON;
|
SELECT UserAccountID,
|
||||||
|
|
||||||
SELECT UserAccountID,
|
|
||||||
Username,
|
Username,
|
||||||
FirstName,
|
FirstName,
|
||||||
LastName,
|
LastName,
|
||||||
@@ -15,5 +11,5 @@ SELECT UserAccountID,
|
|||||||
UpdatedAt,
|
UpdatedAt,
|
||||||
DateOfBirth,
|
DateOfBirth,
|
||||||
Timer
|
Timer
|
||||||
FROM dbo.UserAccount;
|
FROM dbo.UserAccount;
|
||||||
END;
|
END;
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
CREATE
|
CREATE OR ALTER PROCEDURE usp_GetUserAccountByEmail(
|
||||||
OR
|
@Email VARCHAR(128)
|
||||||
ALTER PROCEDURE usp_GetUserAccountByEmail(
|
)
|
||||||
@Email VARCHAR (128)
|
AS
|
||||||
)
|
|
||||||
AS
|
|
||||||
BEGIN
|
BEGIN
|
||||||
SET
|
SET
|
||||||
NOCOUNT ON;
|
NOCOUNT ON;
|
||||||
|
|
||||||
SELECT UserAccountID,
|
SELECT UserAccountID,
|
||||||
Username,
|
Username,
|
||||||
FirstName,
|
FirstName,
|
||||||
LastName,
|
LastName,
|
||||||
@@ -17,6 +15,6 @@ SELECT UserAccountID,
|
|||||||
UpdatedAt,
|
UpdatedAt,
|
||||||
DateOfBirth,
|
DateOfBirth,
|
||||||
Timer
|
Timer
|
||||||
FROM dbo.UserAccount
|
FROM dbo.UserAccount
|
||||||
WHERE Email = @Email;
|
WHERE Email = @Email;
|
||||||
END;
|
END;
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
CREATE
|
CREATE OR ALTER PROCEDURE USP_GetUserAccountById(
|
||||||
OR
|
|
||||||
ALTER PROCEDURE USP_GetUserAccountById(
|
|
||||||
@UserAccountId UNIQUEIDENTIFIER
|
@UserAccountId UNIQUEIDENTIFIER
|
||||||
)
|
)
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET
|
SET
|
||||||
NOCOUNT ON;
|
NOCOUNT ON;
|
||||||
|
|
||||||
SELECT UserAccountID,
|
SELECT UserAccountID,
|
||||||
Username,
|
Username,
|
||||||
FirstName,
|
FirstName,
|
||||||
LastName,
|
LastName,
|
||||||
@@ -17,6 +15,6 @@ SELECT UserAccountID,
|
|||||||
UpdatedAt,
|
UpdatedAt,
|
||||||
DateOfBirth,
|
DateOfBirth,
|
||||||
Timer
|
Timer
|
||||||
FROM dbo.UserAccount
|
FROM dbo.UserAccount
|
||||||
WHERE UserAccountID = @UserAccountId;
|
WHERE UserAccountID = @UserAccountId;
|
||||||
END
|
END
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
CREATE
|
CREATE OR ALTER PROCEDURE usp_GetUserAccountByUsername(
|
||||||
OR
|
@Username VARCHAR(64)
|
||||||
ALTER PROCEDURE usp_GetUserAccountByUsername(
|
)
|
||||||
@Username VARCHAR (64)
|
AS
|
||||||
)
|
|
||||||
AS
|
|
||||||
BEGIN
|
BEGIN
|
||||||
SET
|
SET
|
||||||
NOCOUNT ON;
|
NOCOUNT ON;
|
||||||
|
|
||||||
SELECT UserAccountID,
|
SELECT UserAccountID,
|
||||||
Username,
|
Username,
|
||||||
FirstName,
|
FirstName,
|
||||||
LastName,
|
LastName,
|
||||||
@@ -17,6 +15,6 @@ SELECT UserAccountID,
|
|||||||
UpdatedAt,
|
UpdatedAt,
|
||||||
DateOfBirth,
|
DateOfBirth,
|
||||||
Timer
|
Timer
|
||||||
FROM dbo.UserAccount
|
FROM dbo.UserAccount
|
||||||
WHERE Username = @Username;
|
WHERE Username = @Username;
|
||||||
END;
|
END;
|
||||||
|
|||||||
@@ -1,30 +1,28 @@
|
|||||||
CREATE
|
CREATE OR ALTER PROCEDURE usp_UpdateUserAccount(
|
||||||
OR
|
@Username VARCHAR(64),
|
||||||
ALTER PROCEDURE usp_UpdateUserAccount(
|
|
||||||
@Username VARCHAR (64),
|
|
||||||
@FirstName NVARCHAR(128),
|
@FirstName NVARCHAR(128),
|
||||||
@LastName NVARCHAR(128),
|
@LastName NVARCHAR(128),
|
||||||
@DateOfBirth DATETIME,
|
@DateOfBirth DATETIME,
|
||||||
@Email VARCHAR (128),
|
@Email VARCHAR(128),
|
||||||
@UserAccountId UNIQUEIDENTIFIER
|
@UserAccountId UNIQUEIDENTIFIER
|
||||||
)
|
)
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET
|
SET
|
||||||
NOCOUNT ON;
|
NOCOUNT ON;
|
||||||
|
|
||||||
UPDATE UserAccount
|
UPDATE UserAccount
|
||||||
SET Username = @Username,
|
SET Username = @Username,
|
||||||
FirstName = @FirstName,
|
FirstName = @FirstName,
|
||||||
LastName = @LastName,
|
LastName = @LastName,
|
||||||
DateOfBirth = @DateOfBirth,
|
DateOfBirth = @DateOfBirth,
|
||||||
Email = @Email
|
Email = @Email
|
||||||
WHERE UserAccountId = @UserAccountId;
|
WHERE UserAccountId = @UserAccountId;
|
||||||
|
|
||||||
IF
|
IF
|
||||||
@@ROWCOUNT = 0
|
@@ROWCOUNT = 0
|
||||||
BEGIN
|
BEGIN
|
||||||
THROW
|
THROW
|
||||||
50001, 'UserAccount with the specified ID does not exist.', 1;
|
50001, 'UserAccount with the specified ID does not exist.', 1;
|
||||||
END
|
END
|
||||||
END;
|
END;
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
CREATE
|
CREATE OR ALTER PROCEDURE dbo.USP_GetActiveUserCredentialByUserAccountId(
|
||||||
OR
|
|
||||||
ALTER PROCEDURE dbo.USP_GetActiveUserCredentialByUserAccountId(
|
|
||||||
@UserAccountId UNIQUEIDENTIFIER
|
@UserAccountId UNIQUEIDENTIFIER
|
||||||
)
|
)
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET
|
SET
|
||||||
NOCOUNT ON;
|
NOCOUNT ON;
|
||||||
|
|
||||||
SELECT UserCredentialId,
|
SELECT UserCredentialId,
|
||||||
UserAccountId,
|
UserAccountId,
|
||||||
Hash,
|
Hash,
|
||||||
IsRevoked,
|
IsRevoked,
|
||||||
CreatedAt,
|
CreatedAt,
|
||||||
RevokedAt
|
RevokedAt
|
||||||
FROM dbo.UserCredential
|
FROM dbo.UserCredential
|
||||||
WHERE UserAccountId = @UserAccountId
|
WHERE UserAccountId = @UserAccountId
|
||||||
AND IsRevoked = 0;
|
AND IsRevoked = 0;
|
||||||
END;
|
END;
|
||||||
@@ -1,30 +1,28 @@
|
|||||||
CREATE
|
CREATE OR ALTER PROCEDURE dbo.USP_InvalidateUserCredential(
|
||||||
OR
|
|
||||||
ALTER PROCEDURE dbo.USP_InvalidateUserCredential(
|
|
||||||
@UserAccountId_ UNIQUEIDENTIFIER
|
@UserAccountId_ UNIQUEIDENTIFIER
|
||||||
)
|
)
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET
|
SET
|
||||||
NOCOUNT ON;
|
NOCOUNT ON;
|
||||||
SET
|
SET
|
||||||
XACT_ABORT ON;
|
XACT_ABORT ON;
|
||||||
|
|
||||||
BEGIN
|
BEGIN
|
||||||
TRANSACTION;
|
TRANSACTION;
|
||||||
|
|
||||||
EXEC dbo.USP_GetUserAccountByID @UserAccountId = @UserAccountId_;
|
EXEC dbo.USP_GetUserAccountByID @UserAccountId = @UserAccountId_;
|
||||||
IF
|
IF
|
||||||
@@ROWCOUNT = 0
|
@@ROWCOUNT = 0
|
||||||
THROW 50001, 'User account not found', 1;
|
THROW 50001, 'User account not found', 1;
|
||||||
|
|
||||||
-- invalidate all other credentials by setting them to revoked
|
-- invalidate all other credentials by setting them to revoked
|
||||||
UPDATE dbo.UserCredential
|
UPDATE dbo.UserCredential
|
||||||
SET IsRevoked = 1,
|
SET IsRevoked = 1,
|
||||||
RevokedAt = GETDATE()
|
RevokedAt = GETDATE()
|
||||||
WHERE UserAccountId = @UserAccountId_
|
WHERE UserAccountId = @UserAccountId_
|
||||||
AND IsRevoked != 1;
|
AND IsRevoked != 1;
|
||||||
|
|
||||||
|
|
||||||
COMMIT TRANSACTION;
|
COMMIT TRANSACTION;
|
||||||
END;
|
END;
|
||||||
@@ -1,27 +1,25 @@
|
|||||||
CREATE
|
CREATE OR ALTER PROCEDURE dbo.USP_RegisterUser(
|
||||||
OR
|
@Username VARCHAR(64),
|
||||||
ALTER PROCEDURE dbo.USP_RegisterUser(
|
|
||||||
@Username VARCHAR (64),
|
|
||||||
@FirstName NVARCHAR(128),
|
@FirstName NVARCHAR(128),
|
||||||
@LastName NVARCHAR(128),
|
@LastName NVARCHAR(128),
|
||||||
@DateOfBirth DATETIME,
|
@DateOfBirth DATETIME,
|
||||||
@Email VARCHAR (128),
|
@Email VARCHAR(128),
|
||||||
@Hash NVARCHAR(MAX)
|
@Hash NVARCHAR(MAX)
|
||||||
)
|
)
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET
|
SET
|
||||||
NOCOUNT ON;
|
NOCOUNT ON;
|
||||||
SET
|
SET
|
||||||
XACT_ABORT ON;
|
XACT_ABORT ON;
|
||||||
|
|
||||||
DECLARE
|
DECLARE
|
||||||
@UserAccountId_ UNIQUEIDENTIFIER;
|
@UserAccountId_ UNIQUEIDENTIFIER;
|
||||||
|
|
||||||
BEGIN
|
BEGIN
|
||||||
TRANSACTION;
|
TRANSACTION;
|
||||||
|
|
||||||
EXEC usp_CreateUserAccount
|
EXEC usp_CreateUserAccount
|
||||||
@UserAccountId = @UserAccountId_ OUTPUT,
|
@UserAccountId = @UserAccountId_ OUTPUT,
|
||||||
@Username = @Username,
|
@Username = @Username,
|
||||||
@FirstName = @FirstName,
|
@FirstName = @FirstName,
|
||||||
@@ -30,23 +28,23 @@ EXEC usp_CreateUserAccount
|
|||||||
@Email = @Email;
|
@Email = @Email;
|
||||||
|
|
||||||
IF
|
IF
|
||||||
@UserAccountId_ IS NULL
|
@UserAccountId_ IS NULL
|
||||||
BEGIN
|
BEGIN
|
||||||
THROW
|
THROW
|
||||||
50000, 'Failed to create user account.', 1;
|
50000, 'Failed to create user account.', 1;
|
||||||
END
|
END
|
||||||
|
|
||||||
INSERT INTO dbo.UserCredential
|
INSERT INTO dbo.UserCredential
|
||||||
(UserAccountId, Hash)
|
(UserAccountId, Hash)
|
||||||
VALUES (@UserAccountId_, @Hash);
|
VALUES (@UserAccountId_, @Hash);
|
||||||
|
|
||||||
IF
|
IF
|
||||||
@@ROWCOUNT = 0
|
@@ROWCOUNT = 0
|
||||||
BEGIN
|
BEGIN
|
||||||
THROW
|
THROW
|
||||||
50002, 'Failed to create user credential.', 1;
|
50002, 'Failed to create user credential.', 1;
|
||||||
END
|
END
|
||||||
COMMIT TRANSACTION;
|
COMMIT TRANSACTION;
|
||||||
|
|
||||||
SELECT @UserAccountId_ AS UserAccountId;
|
SELECT @UserAccountId_ AS UserAccountId;
|
||||||
END
|
END
|
||||||
|
|||||||
@@ -1,33 +1,31 @@
|
|||||||
CREATE
|
CREATE OR ALTER PROCEDURE dbo.USP_RotateUserCredential(
|
||||||
OR
|
|
||||||
ALTER PROCEDURE dbo.USP_RotateUserCredential(
|
|
||||||
@UserAccountId_ UNIQUEIDENTIFIER,
|
@UserAccountId_ UNIQUEIDENTIFIER,
|
||||||
@Hash NVARCHAR(MAX)
|
@Hash NVARCHAR(MAX)
|
||||||
)
|
)
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET
|
SET
|
||||||
NOCOUNT ON;
|
NOCOUNT ON;
|
||||||
SET
|
SET
|
||||||
XACT_ABORT ON;
|
XACT_ABORT ON;
|
||||||
BEGIN
|
BEGIN
|
||||||
TRANSACTION;
|
TRANSACTION;
|
||||||
|
|
||||||
EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountId_
|
EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountId_
|
||||||
|
|
||||||
IF @@ROWCOUNT = 0
|
IF @@ROWCOUNT = 0
|
||||||
THROW 50001, 'User account not found', 1;
|
THROW 50001, 'User account not found', 1;
|
||||||
|
|
||||||
|
|
||||||
-- invalidate all other credentials -- set them to revoked
|
-- invalidate all other credentials -- set them to revoked
|
||||||
UPDATE dbo.UserCredential
|
UPDATE dbo.UserCredential
|
||||||
SET IsRevoked = 1,
|
SET IsRevoked = 1,
|
||||||
RevokedAt = GETDATE()
|
RevokedAt = GETDATE()
|
||||||
WHERE UserAccountId = @UserAccountId_;
|
WHERE UserAccountId = @UserAccountId_;
|
||||||
|
|
||||||
INSERT INTO dbo.UserCredential
|
INSERT INTO dbo.UserCredential
|
||||||
(UserAccountId, Hash)
|
(UserAccountId, Hash)
|
||||||
VALUES (@UserAccountId_, @Hash);
|
VALUES (@UserAccountId_, @Hash);
|
||||||
|
|
||||||
|
|
||||||
END;
|
END;
|
||||||
@@ -1,29 +1,27 @@
|
|||||||
CREATE
|
CREATE OR ALTER PROCEDURE dbo.USP_CreateUserVerification @UserAccountID_ UNIQUEIDENTIFIER,
|
||||||
OR
|
|
||||||
ALTER PROCEDURE dbo.USP_CreateUserVerification @UserAccountID_ UNIQUEIDENTIFIER,
|
|
||||||
@VerificationDateTime DATETIME = NULL
|
@VerificationDateTime DATETIME = NULL
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET
|
SET
|
||||||
NOCOUNT ON;
|
NOCOUNT ON;
|
||||||
SET
|
SET
|
||||||
XACT_ABORT ON;
|
XACT_ABORT ON;
|
||||||
|
|
||||||
IF
|
IF
|
||||||
@VerificationDateTime IS NULL
|
@VerificationDateTime IS NULL
|
||||||
SET @VerificationDateTime = GETDATE();
|
SET @VerificationDateTime = GETDATE();
|
||||||
|
|
||||||
BEGIN
|
BEGIN
|
||||||
TRANSACTION;
|
TRANSACTION;
|
||||||
|
|
||||||
EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountID_;
|
EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountID_;
|
||||||
IF
|
IF
|
||||||
@@ROWCOUNT = 0
|
@@ROWCOUNT = 0
|
||||||
THROW 50001, 'Could not find a user with that id', 1;
|
THROW 50001, 'Could not find a user with that id', 1;
|
||||||
|
|
||||||
INSERT INTO dbo.UserVerification
|
INSERT INTO dbo.UserVerification
|
||||||
(UserAccountId, VerificationDateTime)
|
(UserAccountId, VerificationDateTime)
|
||||||
VALUES (@UserAccountID_, @VerificationDateTime);
|
VALUES (@UserAccountID_, @VerificationDateTime);
|
||||||
|
|
||||||
COMMIT TRANSACTION;
|
COMMIT TRANSACTION;
|
||||||
END
|
END
|
||||||
|
|||||||
@@ -1,40 +1,38 @@
|
|||||||
CREATE
|
CREATE OR ALTER PROCEDURE dbo.USP_CreateCity(
|
||||||
OR
|
|
||||||
ALTER PROCEDURE dbo.USP_CreateCity(
|
|
||||||
@CityName NVARCHAR(100),
|
@CityName NVARCHAR(100),
|
||||||
@StateProvinceCode NVARCHAR(6)
|
@StateProvinceCode NVARCHAR(6)
|
||||||
)
|
)
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET
|
SET
|
||||||
NOCOUNT ON;
|
NOCOUNT ON;
|
||||||
SET
|
SET
|
||||||
XACT_ABORT ON;
|
XACT_ABORT ON;
|
||||||
|
|
||||||
BEGIN
|
BEGIN
|
||||||
TRANSACTION
|
TRANSACTION
|
||||||
DECLARE
|
DECLARE
|
||||||
@StateProvinceId UNIQUEIDENTIFIER = dbo.UDF_GetStateProvinceIdByCode(@StateProvinceCode);
|
@StateProvinceId UNIQUEIDENTIFIER = dbo.UDF_GetStateProvinceIdByCode(@StateProvinceCode);
|
||||||
IF
|
IF
|
||||||
@StateProvinceId IS NULL
|
@StateProvinceId IS NULL
|
||||||
BEGIN
|
BEGIN
|
||||||
THROW
|
THROW
|
||||||
50001, 'State/province does not exist', 1;
|
50001, 'State/province does not exist', 1;
|
||||||
END
|
END
|
||||||
|
|
||||||
IF
|
IF
|
||||||
EXISTS (SELECT 1
|
EXISTS (SELECT 1
|
||||||
FROM dbo.City
|
FROM dbo.City
|
||||||
WHERE CityName = @CityName
|
WHERE CityName = @CityName
|
||||||
AND StateProvinceID = @StateProvinceId)
|
AND StateProvinceID = @StateProvinceId)
|
||||||
BEGIN
|
BEGIN
|
||||||
|
|
||||||
THROW
|
THROW
|
||||||
50002, 'City already exists.', 1;
|
50002, 'City already exists.', 1;
|
||||||
END
|
END
|
||||||
|
|
||||||
INSERT INTO dbo.City
|
INSERT INTO dbo.City
|
||||||
(StateProvinceID, CityName)
|
(StateProvinceID, CityName)
|
||||||
VALUES (@StateProvinceId, @CityName);
|
VALUES (@StateProvinceId, @CityName);
|
||||||
COMMIT TRANSACTION
|
COMMIT TRANSACTION
|
||||||
END;
|
END;
|
||||||
|
|||||||
@@ -1,26 +1,24 @@
|
|||||||
CREATE
|
CREATE OR ALTER PROCEDURE dbo.USP_CreateCountry(
|
||||||
OR
|
|
||||||
ALTER PROCEDURE dbo.USP_CreateCountry(
|
|
||||||
@CountryName NVARCHAR(100),
|
@CountryName NVARCHAR(100),
|
||||||
@ISO3166_1 NVARCHAR(2)
|
@ISO3166_1 NVARCHAR(2)
|
||||||
)
|
)
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET
|
SET
|
||||||
NOCOUNT ON;
|
NOCOUNT ON;
|
||||||
SET
|
SET
|
||||||
XACT_ABORT ON;
|
XACT_ABORT ON;
|
||||||
BEGIN
|
BEGIN
|
||||||
TRANSACTION;
|
TRANSACTION;
|
||||||
|
|
||||||
IF
|
IF
|
||||||
EXISTS (SELECT 1
|
EXISTS (SELECT 1
|
||||||
FROM dbo.Country
|
FROM dbo.Country
|
||||||
WHERE ISO3166_1 = @ISO3166_1)
|
WHERE ISO3166_1 = @ISO3166_1)
|
||||||
THROW 50001, 'Country already exists', 1;
|
THROW 50001, 'Country already exists', 1;
|
||||||
|
|
||||||
INSERT INTO dbo.Country
|
INSERT INTO dbo.Country
|
||||||
(CountryName, ISO3166_1)
|
(CountryName, ISO3166_1)
|
||||||
VALUES (@CountryName, @ISO3166_1);
|
VALUES (@CountryName, @ISO3166_1);
|
||||||
COMMIT TRANSACTION;
|
COMMIT TRANSACTION;
|
||||||
END;
|
END;
|
||||||
|
|||||||
@@ -1,34 +1,32 @@
|
|||||||
CREATE
|
CREATE OR ALTER PROCEDURE dbo.USP_CreateStateProvince(
|
||||||
OR
|
|
||||||
ALTER PROCEDURE dbo.USP_CreateStateProvince(
|
|
||||||
@StateProvinceName NVARCHAR(100),
|
@StateProvinceName NVARCHAR(100),
|
||||||
@ISO3166_2 NVARCHAR(6),
|
@ISO3166_2 NVARCHAR(6),
|
||||||
@CountryCode NVARCHAR(2)
|
@CountryCode NVARCHAR(2)
|
||||||
)
|
)
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET
|
SET
|
||||||
NOCOUNT ON;
|
NOCOUNT ON;
|
||||||
SET
|
SET
|
||||||
XACT_ABORT ON;
|
XACT_ABORT ON;
|
||||||
|
|
||||||
IF
|
IF
|
||||||
EXISTS (SELECT 1
|
EXISTS (SELECT 1
|
||||||
FROM dbo.StateProvince
|
FROM dbo.StateProvince
|
||||||
WHERE ISO3166_2 = @ISO3166_2)
|
WHERE ISO3166_2 = @ISO3166_2)
|
||||||
RETURN;
|
RETURN;
|
||||||
|
|
||||||
DECLARE
|
DECLARE
|
||||||
@CountryId UNIQUEIDENTIFIER = dbo.UDF_GetCountryIdByCode(@CountryCode);
|
@CountryId UNIQUEIDENTIFIER = dbo.UDF_GetCountryIdByCode(@CountryCode);
|
||||||
IF
|
IF
|
||||||
@CountryId IS NULL
|
@CountryId IS NULL
|
||||||
BEGIN
|
BEGIN
|
||||||
THROW
|
THROW
|
||||||
50001, 'Country does not exist', 1;
|
50001, 'Country does not exist', 1;
|
||||||
|
|
||||||
END
|
END
|
||||||
|
|
||||||
INSERT INTO dbo.StateProvince
|
INSERT INTO dbo.StateProvince
|
||||||
(StateProvinceName, ISO3166_2, CountryID)
|
(StateProvinceName, ISO3166_2, CountryID)
|
||||||
VALUES (@StateProvinceName, @ISO3166_2, @CountryId);
|
VALUES (@StateProvinceName, @ISO3166_2, @CountryId);
|
||||||
END;
|
END;
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
CREATE
|
CREATE OR ALTER PROCEDURE dbo.USP_CreateBrewery(
|
||||||
OR
|
|
||||||
ALTER PROCEDURE dbo.USP_CreateBrewery(
|
|
||||||
@BreweryName NVARCHAR(256),
|
@BreweryName NVARCHAR(256),
|
||||||
@Description NVARCHAR(512),
|
@Description NVARCHAR(512),
|
||||||
@PostedByID UNIQUEIDENTIFIER,
|
@PostedByID UNIQUEIDENTIFIER,
|
||||||
@@ -9,53 +7,53 @@ ALTER PROCEDURE dbo.USP_CreateBrewery(
|
|||||||
@AddressLine2 NVARCHAR(256) = NULL,
|
@AddressLine2 NVARCHAR(256) = NULL,
|
||||||
@PostalCode NVARCHAR(20),
|
@PostalCode NVARCHAR(20),
|
||||||
@Coordinates GEOGRAPHY = NULL
|
@Coordinates GEOGRAPHY = NULL
|
||||||
)
|
)
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET
|
SET
|
||||||
NOCOUNT ON;
|
NOCOUNT ON;
|
||||||
SET
|
SET
|
||||||
XACT_ABORT ON;
|
XACT_ABORT ON;
|
||||||
|
|
||||||
IF
|
IF
|
||||||
@BreweryName IS NULL
|
@BreweryName IS NULL
|
||||||
THROW 50001, 'Brewery name cannot be null.', 1;
|
THROW 50001, 'Brewery name cannot be null.', 1;
|
||||||
|
|
||||||
IF
|
IF
|
||||||
@Description IS NULL
|
@Description IS NULL
|
||||||
THROW 50002, 'Brewery description cannot be null.', 1;
|
THROW 50002, 'Brewery description cannot be null.', 1;
|
||||||
|
|
||||||
IF
|
IF
|
||||||
NOT EXISTS (SELECT 1
|
NOT EXISTS (SELECT 1
|
||||||
FROM dbo.UserAccount
|
FROM dbo.UserAccount
|
||||||
WHERE UserAccountID = @PostedByID)
|
WHERE UserAccountID = @PostedByID)
|
||||||
THROW 50404, 'User not found.', 1;
|
THROW 50404, 'User not found.', 1;
|
||||||
|
|
||||||
IF
|
IF
|
||||||
NOT EXISTS (SELECT 1
|
NOT EXISTS (SELECT 1
|
||||||
FROM dbo.City
|
FROM dbo.City
|
||||||
WHERE CityID = @CityID)
|
WHERE CityID = @CityID)
|
||||||
THROW 50404, 'City not found.', 1;
|
THROW 50404, 'City not found.', 1;
|
||||||
|
|
||||||
DECLARE
|
DECLARE
|
||||||
@NewBreweryID UNIQUEIDENTIFIER = NEWID();
|
@NewBreweryID UNIQUEIDENTIFIER = NEWID();
|
||||||
DECLARE
|
DECLARE
|
||||||
@NewBrewerLocationID UNIQUEIDENTIFIER = NEWID();
|
@NewBrewerLocationID UNIQUEIDENTIFIER = NEWID();
|
||||||
|
|
||||||
BEGIN
|
BEGIN
|
||||||
TRANSACTION;
|
TRANSACTION;
|
||||||
|
|
||||||
INSERT INTO dbo.BreweryPost
|
INSERT INTO dbo.BreweryPost
|
||||||
(BreweryPostID, BreweryName, Description, PostedByID)
|
(BreweryPostID, BreweryName, Description, PostedByID)
|
||||||
VALUES (@NewBreweryID, @BreweryName, @Description, @PostedByID);
|
VALUES (@NewBreweryID, @BreweryName, @Description, @PostedByID);
|
||||||
|
|
||||||
INSERT INTO dbo.BreweryPostLocation
|
INSERT INTO dbo.BreweryPostLocation
|
||||||
(BreweryPostLocationID, BreweryPostID, CityID, AddressLine1, AddressLine2, PostalCode, Coordinates)
|
(BreweryPostLocationID, BreweryPostID, CityID, AddressLine1, AddressLine2, PostalCode, Coordinates)
|
||||||
VALUES (@NewBrewerLocationID, @NewBreweryID, @CityID, @AddressLine1, @AddressLine2, @PostalCode, @Coordinates);
|
VALUES (@NewBrewerLocationID, @NewBreweryID, @CityID, @AddressLine1, @AddressLine2, @PostalCode, @Coordinates);
|
||||||
|
|
||||||
COMMIT TRANSACTION;
|
COMMIT TRANSACTION;
|
||||||
|
|
||||||
SELECT @NewBreweryID AS BreweryPostID,
|
SELECT @NewBreweryID AS BreweryPostID,
|
||||||
@NewBrewerLocationID AS BreweryPostLocationID;
|
@NewBrewerLocationID AS BreweryPostLocationID;
|
||||||
|
|
||||||
END
|
END
|
||||||
|
|||||||
@@ -1,20 +1,17 @@
|
|||||||
CREATE
|
CREATE OR ALTER PROCEDURE dbo.USP_DeleteBrewery @BreweryPostID UNIQUEIDENTIFIER
|
||||||
OR
|
AS
|
||||||
ALTER PROCEDURE dbo.USP_DeleteBrewery
|
|
||||||
@BreweryPostID UNIQUEIDENTIFIER
|
|
||||||
AS
|
|
||||||
BEGIN
|
BEGIN
|
||||||
SET
|
SET
|
||||||
NOCOUNT ON;
|
NOCOUNT ON;
|
||||||
|
|
||||||
IF
|
IF
|
||||||
NOT EXISTS (SELECT 1
|
NOT EXISTS (SELECT 1
|
||||||
FROM dbo.BreweryPost
|
FROM dbo.BreweryPost
|
||||||
WHERE BreweryPostID = @BreweryPostID)
|
WHERE BreweryPostID = @BreweryPostID)
|
||||||
THROW 50404, 'Brewery not found.', 1;
|
THROW 50404, 'Brewery not found.', 1;
|
||||||
|
|
||||||
-- BreweryPostLocation and BreweryPostPhoto cascade-delete with their parent BreweryPost.
|
-- BreweryPostLocation and BreweryPostPhoto cascade-delete with their parent BreweryPost.
|
||||||
DELETE
|
DELETE
|
||||||
FROM dbo.BreweryPost
|
FROM dbo.BreweryPost
|
||||||
WHERE BreweryPostID = @BreweryPostID;
|
WHERE BreweryPostID = @BreweryPostID;
|
||||||
END
|
END
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
CREATE
|
CREATE OR ALTER PROCEDURE dbo.USP_GetAllBreweries(
|
||||||
OR
|
|
||||||
ALTER PROCEDURE dbo.USP_GetAllBreweries(
|
|
||||||
@Limit INT = NULL,
|
@Limit INT = NULL,
|
||||||
@Offset INT = NULL
|
@Offset INT = NULL
|
||||||
)
|
)
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET
|
SET
|
||||||
NOCOUNT ON;
|
NOCOUNT ON;
|
||||||
|
|
||||||
SELECT bp.BreweryPostID,
|
SELECT bp.BreweryPostID,
|
||||||
bp.PostedByID,
|
bp.PostedByID,
|
||||||
bp.BreweryName,
|
bp.BreweryName,
|
||||||
bp.Description,
|
bp.Description,
|
||||||
@@ -22,9 +20,9 @@ SELECT bp.BreweryPostID,
|
|||||||
bpl.AddressLine2,
|
bpl.AddressLine2,
|
||||||
bpl.PostalCode,
|
bpl.PostalCode,
|
||||||
bpl.Coordinates
|
bpl.Coordinates
|
||||||
FROM dbo.BreweryPost bp
|
FROM dbo.BreweryPost bp
|
||||||
LEFT JOIN dbo.BreweryPostLocation bpl
|
LEFT JOIN dbo.BreweryPostLocation bpl
|
||||||
ON bp.BreweryPostID = bpl.BreweryPostID
|
ON bp.BreweryPostID = bpl.BreweryPostID
|
||||||
ORDER BY bp.CreatedAt DESC
|
ORDER BY bp.CreatedAt DESC
|
||||||
OFFSET ISNULL(@Offset, 0) ROWS FETCH NEXT ISNULL(@Limit, 2147483647) ROWS ONLY;
|
OFFSET ISNULL(@Offset, 0) ROWS FETCH NEXT ISNULL(@Limit, 2147483647) ROWS ONLY;
|
||||||
END
|
END
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
CREATE
|
CREATE OR ALTER PROCEDURE dbo.USP_GetBreweryById @BreweryPostID UNIQUEIDENTIFIER
|
||||||
OR
|
AS
|
||||||
ALTER PROCEDURE dbo.USP_GetBreweryById @BreweryPostID UNIQUEIDENTIFIER
|
|
||||||
AS
|
|
||||||
BEGIN
|
BEGIN
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM BreweryPost bp
|
FROM BreweryPost bp
|
||||||
INNER JOIN BreweryPostLocation bpl
|
INNER JOIN BreweryPostLocation bpl
|
||||||
ON bp.BreweryPostID = bpl.BreweryPostID
|
ON bp.BreweryPostID = bpl.BreweryPostID
|
||||||
WHERE bp.BreweryPostID = @BreweryPostID;
|
WHERE bp.BreweryPostID = @BreweryPostID;
|
||||||
END
|
END
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
CREATE
|
CREATE OR ALTER PROCEDURE dbo.USP_UpdateBrewery(
|
||||||
OR
|
|
||||||
ALTER PROCEDURE dbo.USP_UpdateBrewery(
|
|
||||||
@BreweryPostID UNIQUEIDENTIFIER,
|
@BreweryPostID UNIQUEIDENTIFIER,
|
||||||
@BreweryName NVARCHAR(256),
|
@BreweryName NVARCHAR(256),
|
||||||
@Description NVARCHAR(512),
|
@Description NVARCHAR(512),
|
||||||
@@ -10,70 +8,70 @@ ALTER PROCEDURE dbo.USP_UpdateBrewery(
|
|||||||
@AddressLine2 NVARCHAR(256) = NULL,
|
@AddressLine2 NVARCHAR(256) = NULL,
|
||||||
@PostalCode NVARCHAR(20) = NULL,
|
@PostalCode NVARCHAR(20) = NULL,
|
||||||
@Coordinates GEOGRAPHY = NULL
|
@Coordinates GEOGRAPHY = NULL
|
||||||
)
|
)
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET
|
SET
|
||||||
NOCOUNT ON;
|
NOCOUNT ON;
|
||||||
SET
|
SET
|
||||||
XACT_ABORT ON;
|
XACT_ABORT ON;
|
||||||
|
|
||||||
IF
|
IF
|
||||||
@BreweryName IS NULL
|
@BreweryName IS NULL
|
||||||
THROW 50001, 'Brewery name cannot be null.', 1;
|
THROW 50001, 'Brewery name cannot be null.', 1;
|
||||||
|
|
||||||
IF
|
IF
|
||||||
@Description IS NULL
|
@Description IS NULL
|
||||||
THROW 50002, 'Brewery description cannot be null.', 1;
|
THROW 50002, 'Brewery description cannot be null.', 1;
|
||||||
|
|
||||||
IF
|
IF
|
||||||
NOT EXISTS (SELECT 1
|
NOT EXISTS (SELECT 1
|
||||||
FROM dbo.BreweryPost
|
FROM dbo.BreweryPost
|
||||||
WHERE BreweryPostID = @BreweryPostID)
|
WHERE BreweryPostID = @BreweryPostID)
|
||||||
THROW 50404, 'Brewery not found.', 1;
|
THROW 50404, 'Brewery not found.', 1;
|
||||||
|
|
||||||
IF
|
IF
|
||||||
@CityID IS NOT NULL AND NOT EXISTS (SELECT 1
|
@CityID IS NOT NULL AND NOT EXISTS (SELECT 1
|
||||||
FROM dbo.City
|
FROM dbo.City
|
||||||
WHERE CityID = @CityID)
|
WHERE CityID = @CityID)
|
||||||
THROW 50404, 'City not found.', 1;
|
THROW 50404, 'City not found.', 1;
|
||||||
|
|
||||||
BEGIN
|
BEGIN
|
||||||
TRANSACTION;
|
TRANSACTION;
|
||||||
|
|
||||||
UPDATE dbo.BreweryPost
|
UPDATE dbo.BreweryPost
|
||||||
SET BreweryName = @BreweryName,
|
SET BreweryName = @BreweryName,
|
||||||
Description = @Description,
|
Description = @Description,
|
||||||
UpdatedAt = GETDATE()
|
UpdatedAt = GETDATE()
|
||||||
WHERE BreweryPostID = @BreweryPostID;
|
WHERE BreweryPostID = @BreweryPostID;
|
||||||
|
|
||||||
IF
|
IF
|
||||||
@CityID IS NULL
|
@CityID IS NULL
|
||||||
BEGIN
|
BEGIN
|
||||||
-- No location supplied: clear any existing location for this brewery.
|
-- No location supplied: clear any existing location for this brewery.
|
||||||
DELETE
|
DELETE
|
||||||
FROM dbo.BreweryPostLocation
|
FROM dbo.BreweryPostLocation
|
||||||
WHERE BreweryPostID = @BreweryPostID;
|
WHERE BreweryPostID = @BreweryPostID;
|
||||||
END
|
END
|
||||||
ELSE IF EXISTS (SELECT 1
|
ELSE
|
||||||
|
IF EXISTS (SELECT 1
|
||||||
FROM dbo.BreweryPostLocation
|
FROM dbo.BreweryPostLocation
|
||||||
WHERE BreweryPostID = @BreweryPostID)
|
WHERE BreweryPostID = @BreweryPostID)
|
||||||
BEGIN
|
BEGIN
|
||||||
UPDATE dbo.BreweryPostLocation
|
UPDATE dbo.BreweryPostLocation
|
||||||
SET CityID = @CityID,
|
SET CityID = @CityID,
|
||||||
AddressLine1 = @AddressLine1,
|
AddressLine1 = @AddressLine1,
|
||||||
AddressLine2 = @AddressLine2,
|
AddressLine2 = @AddressLine2,
|
||||||
PostalCode = @PostalCode,
|
PostalCode = @PostalCode,
|
||||||
Coordinates = @Coordinates
|
Coordinates = @Coordinates
|
||||||
WHERE BreweryPostID = @BreweryPostID;
|
WHERE BreweryPostID = @BreweryPostID;
|
||||||
END
|
END
|
||||||
ELSE
|
ELSE
|
||||||
BEGIN
|
BEGIN
|
||||||
INSERT INTO dbo.BreweryPostLocation
|
INSERT INTO dbo.BreweryPostLocation
|
||||||
(BreweryPostLocationID, BreweryPostID, CityID, AddressLine1, AddressLine2, PostalCode, Coordinates)
|
(BreweryPostLocationID, BreweryPostID, CityID, AddressLine1, AddressLine2, PostalCode, Coordinates)
|
||||||
VALUES (ISNULL(@BreweryPostLocationID, NEWID()), @BreweryPostID, @CityID, @AddressLine1, @AddressLine2,
|
VALUES (ISNULL(@BreweryPostLocationID, NEWID()), @BreweryPostID, @CityID, @AddressLine1, @AddressLine2,
|
||||||
@PostalCode, @Coordinates);
|
@PostalCode, @Coordinates);
|
||||||
END
|
END
|
||||||
|
COMMIT TRANSACTION;
|
||||||
COMMIT TRANSACTION;
|
|
||||||
END
|
END
|
||||||
|
|||||||
@@ -8,20 +8,30 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="idunno.Password.Generator" Version="1.0.1" />
|
<PackageReference Include="idunno.Password.Generator" Version="2.0.0" />
|
||||||
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
|
|
||||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
|
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
|
||||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.9" />
|
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.0" />
|
||||||
<PackageReference Include="dbup" Version="5.0.41" />
|
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
remove this override once microsoft ships official fix to vulnerability
|
remove this override once microsoft ships official fix to vulnerability
|
||||||
https://github.com/advisories/GHSA-2m69-gcr7-jv3q
|
https:
|
||||||
-->
|
-->
|
||||||
<PackageReference Include="SQLitePCLRaw.lib.e_sqlite3" Version="3.50.3" />
|
<PackageReference Include="SQLitePCLRaw.lib.e_sqlite3" Version="3.50.3" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Features\Features.Breweries\Features.Breweries.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Features\Features.Auth\Features.Auth.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Pipeline-generated seed data, copied to the output directory so it can be opened via
|
||||||
|
ConnectionStrings.SqliteConnectionString at runtime. -->
|
||||||
|
<None
|
||||||
|
CopyToOutputDirectory="PreserveNewest"
|
||||||
|
Include="SeedData\biergarten_seed_2026-07-20T05-46-02.993511Z.sqlite"
|
||||||
|
/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
using Microsoft.Data.SqlClient;
|
#region
|
||||||
|
|
||||||
namespace Database.Seed;
|
using Microsoft.Data.SqlClient;
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
namespace Database.Seed.DatabaseHelpers;
|
||||||
|
|
||||||
public class ConnectionStrings
|
public class ConnectionStrings
|
||||||
{
|
{
|
||||||
/// <summary>
|
public static string SqlServerConnectionString => GetSqlServerConnectionString();
|
||||||
/// Builds a SQL Server connection string from the <c>DB_SERVER</c>, <c>DB_NAME</c>,
|
public static string SqliteConnectionString =>
|
||||||
/// <c>DB_USER</c>, <c>DB_PASSWORD</c>, and <c>DB_TRUST_SERVER_CERTIFICATE</c>
|
"Data Source=SeedData/biergarten_seed_2026-07-20T05-46-02.993511Z.sqlite";
|
||||||
/// environment variables.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>A fully built SQL Server connection string.</returns>
|
|
||||||
/// <exception cref="InvalidOperationException">
|
|
||||||
/// Thrown when <c>DB_SERVER</c>, <c>DB_NAME</c>, <c>DB_USER</c>, or <c>DB_PASSWORD</c>
|
|
||||||
/// is not set.
|
|
||||||
/// </exception>
|
|
||||||
private static string GetSqlServerConnectionString()
|
private static string GetSqlServerConnectionString()
|
||||||
{
|
{
|
||||||
string server =
|
string server =
|
||||||
@@ -47,8 +45,4 @@ public class ConnectionStrings
|
|||||||
|
|
||||||
return builder.ConnectionString;
|
return builder.ConnectionString;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public static string SqlServerConnectionString => GetSqlServerConnectionString();
|
|
||||||
public static string SqliteConnectionString => "Data Source=seed.sqlite";
|
|
||||||
}
|
}
|
||||||
@@ -10,7 +10,7 @@ FROM build AS publish
|
|||||||
ARG BUILD_CONFIGURATION=Release
|
ARG BUILD_CONFIGURATION=Release
|
||||||
RUN dotnet publish "./Database/Database.Seed/Database.Seed.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
RUN dotnet publish "./Database/Database.Seed/Database.Seed.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||||
|
|
||||||
FROM mcr.microsoft.com/dotnet/runtime:10.0 AS final
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=publish /app/publish .
|
COPY --from=publish /app/publish .
|
||||||
ENTRYPOINT ["dotnet", "Database.Seed.dll"]
|
ENTRYPOINT ["dotnet", "Database.Seed.dll"]
|
||||||
|
|||||||
@@ -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;
|
namespace Database.Seed;
|
||||||
|
|
||||||
|
|
||||||
public interface IExportDatabase
|
public interface IExportDatabase
|
||||||
{
|
{
|
||||||
void SeedLocations(IEnumerable<Location> locations);
|
void SeedLocations(IEnumerable<Location> locations);
|
||||||
|
|||||||
@@ -1,50 +1,14 @@
|
|||||||
namespace Domain.Entities;
|
namespace Domain.Entities;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Represents a user-submitted post about a brewery. Maps to the <c>BreweryPost</c> table.
|
|
||||||
/// A user account cannot be deleted while it has an associated brewery post.
|
|
||||||
/// </summary>
|
|
||||||
public class BreweryPost
|
public class BreweryPost
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// Primary key identifying this brewery post. Maps to <c>BreweryPostID</c>.
|
|
||||||
/// </summary>
|
|
||||||
public Guid BreweryPostId { get; set; }
|
public Guid BreweryPostId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Foreign key referencing the <see cref="UserAccount" /> that authored this post. Maps to <c>PostedByID</c>.
|
|
||||||
/// </summary>
|
|
||||||
public Guid PostedById { get; set; }
|
public Guid PostedById { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The name of the brewery being posted about.
|
|
||||||
/// </summary>
|
|
||||||
public string BreweryName { get; set; } = string.Empty;
|
public string BreweryName { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Free-text description of the brewery.
|
|
||||||
/// </summary>
|
|
||||||
public string Description { get; set; } = string.Empty;
|
public string Description { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The date and time the post was created.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The date and time the post was last updated, or <c>null</c> if it has never been updated.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? UpdatedAt { get; set; }
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has
|
|
||||||
/// been read from the database.
|
|
||||||
/// </summary>
|
|
||||||
public byte[]? Timer { get; set; }
|
public byte[]? Timer { get; set; }
|
||||||
|
public UserAccount PostedBy { get; set; } = null!;
|
||||||
/// <summary>
|
|
||||||
/// The associated <see cref="BreweryPostLocation" /> for this post, if one has been set. This is a one-to-one
|
|
||||||
/// navigation property.
|
|
||||||
/// </summary>
|
|
||||||
public BreweryPostLocation? Location { get; set; }
|
public BreweryPostLocation? Location { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +1,14 @@
|
|||||||
namespace Domain.Entities;
|
namespace Domain.Entities;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Represents the physical location of a <see cref="BreweryPost" />. Maps to the <c>BreweryPostLocation</c> table.
|
|
||||||
/// Each brewery post has at most one location, and the location is deleted automatically when its parent post is
|
|
||||||
/// deleted.
|
|
||||||
/// </summary>
|
|
||||||
public class BreweryPostLocation
|
public class BreweryPostLocation
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// Primary key identifying this location. Maps to <c>BreweryPostLocationID</c>.
|
|
||||||
/// </summary>
|
|
||||||
public Guid BreweryPostLocationId { get; set; }
|
public Guid BreweryPostLocationId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Foreign key referencing the owning <see cref="BreweryPost" />. Maps to <c>BreweryPostID</c>; unique, enforcing a
|
|
||||||
/// one-to-one relationship.
|
|
||||||
/// </summary>
|
|
||||||
public Guid BreweryPostId { get; set; }
|
public Guid BreweryPostId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The primary street address line.
|
|
||||||
/// </summary>
|
|
||||||
public string AddressLine1 { get; set; } = string.Empty;
|
public string AddressLine1 { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// An optional secondary address line (e.g., suite or unit number).
|
|
||||||
/// </summary>
|
|
||||||
public string? AddressLine2 { get; set; }
|
public string? AddressLine2 { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The postal/ZIP code of the location.
|
|
||||||
/// </summary>
|
|
||||||
public string PostalCode { get; set; } = string.Empty;
|
public string PostalCode { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Foreign key referencing the city in which the brewery is located. Maps to <c>CityID</c>.
|
|
||||||
/// </summary>
|
|
||||||
public Guid CityId { get; set; }
|
public Guid CityId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Serialized SQL Server <c>GEOGRAPHY</c> value representing the geographic coordinates of the location, or
|
|
||||||
/// <c>null</c> if not set.
|
|
||||||
/// </summary>
|
|
||||||
public byte[]? Coordinates { get; set; }
|
public byte[]? Coordinates { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has
|
|
||||||
/// been read from the database.
|
|
||||||
/// </summary>
|
|
||||||
public byte[]? Timer { get; set; }
|
public byte[]? Timer { get; set; }
|
||||||
|
public BreweryPost BreweryPost { get; set; } = null!;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,55 +1,17 @@
|
|||||||
namespace Domain.Entities;
|
namespace Domain.Entities;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Represents a registered user of the application. Maps to the <c>UserAccount</c> table, the root entity
|
|
||||||
/// referenced by <see cref="UserCredential" />, <see cref="UserVerification" />, brewery posts, and other user-owned
|
|
||||||
/// data.
|
|
||||||
/// </summary>
|
|
||||||
public class UserAccount
|
public class UserAccount
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// Primary key identifying this user account. Maps to <c>UserAccountID</c>.
|
|
||||||
/// </summary>
|
|
||||||
public Guid UserAccountId { get; set; }
|
public Guid UserAccountId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The user's unique username, used for login and display. Enforced unique at the database level.
|
|
||||||
/// </summary>
|
|
||||||
public string Username { get; set; } = string.Empty;
|
public string Username { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The user's first name.
|
|
||||||
/// </summary>
|
|
||||||
public string FirstName { get; set; } = string.Empty;
|
public string FirstName { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The user's last name.
|
|
||||||
/// </summary>
|
|
||||||
public string LastName { get; set; } = string.Empty;
|
public string LastName { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The user's email address. Enforced unique at the database level.
|
|
||||||
/// </summary>
|
|
||||||
public string Email { get; set; } = string.Empty;
|
public string Email { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The date and time the account was created.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The date and time the account was last updated, or <c>null</c> if it has never been updated.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? UpdatedAt { get; set; }
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The user's date of birth.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime DateOfBirth { get; set; }
|
public DateTime DateOfBirth { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has
|
|
||||||
/// been read from the database.
|
|
||||||
/// </summary>
|
|
||||||
public byte[]? Timer { get; set; }
|
public byte[]? Timer { get; set; }
|
||||||
|
public ICollection<BreweryPost> BreweryPosts { get; set; } = [];
|
||||||
|
public ICollection<UserCredential> UserCredentials { get; set; } = [];
|
||||||
|
public UserVerification? Verification { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,12 @@
|
|||||||
namespace Domain.Entities;
|
namespace Domain.Entities;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Represents an issued authentication credential (refresh token) for a <see cref="UserAccount" />.
|
|
||||||
/// Maps to the <c>UserCredential</c> table. Credentials are deleted automatically when the owning user account is
|
|
||||||
/// deleted.
|
|
||||||
/// </summary>
|
|
||||||
public class UserCredential
|
public class UserCredential
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// Primary key identifying this credential. Maps to <c>UserCredentialID</c>.
|
|
||||||
/// </summary>
|
|
||||||
public Guid UserCredentialId { get; set; }
|
public Guid UserCredentialId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Foreign key referencing the owning <see cref="UserAccount" />. Maps to <c>UserAccountID</c>.
|
|
||||||
/// </summary>
|
|
||||||
public Guid UserAccountId { get; set; }
|
public Guid UserAccountId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The date and time the credential was issued.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The date and time after which the credential is no longer valid.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime Expiry { get; set; }
|
public DateTime Expiry { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The Argon2 hash of the credential's secret value. The plaintext secret is never persisted.
|
|
||||||
/// </summary>
|
|
||||||
public string Hash { get; set; } = string.Empty;
|
public string Hash { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has
|
|
||||||
/// been read from the database.
|
|
||||||
/// </summary>
|
|
||||||
public byte[]? Timer { get; set; }
|
public byte[]? Timer { get; set; }
|
||||||
|
public UserAccount UserAccount { get; set; } = null!;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,10 @@
|
|||||||
namespace Domain.Entities;
|
namespace Domain.Entities;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Records that a <see cref="UserAccount" /> has completed verification (e.g., email confirmation).
|
|
||||||
/// Maps to the <c>UserVerification</c> table. A user account has at most one verification record,
|
|
||||||
/// and it is deleted automatically when the owning user account is deleted.
|
|
||||||
/// </summary>
|
|
||||||
public class UserVerification
|
public class UserVerification
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// Primary key identifying this verification record. Maps to <c>UserVerificationID</c>.
|
|
||||||
/// </summary>
|
|
||||||
public Guid UserVerificationId { get; set; }
|
public Guid UserVerificationId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Foreign key referencing the verified <see cref="UserAccount" />. Maps to <c>UserAccountID</c>; unique, enforcing a
|
|
||||||
/// one-to-one relationship.
|
|
||||||
/// </summary>
|
|
||||||
public Guid UserAccountId { get; set; }
|
public Guid UserAccountId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The date and time at which the user account was verified.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime VerificationDateTime { get; set; }
|
public DateTime VerificationDateTime { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has
|
|
||||||
/// been read from the database.
|
|
||||||
/// </summary>
|
|
||||||
public byte[]? Timer { get; set; }
|
public byte[]? Timer { get; set; }
|
||||||
|
public UserAccount UserAccount { get; set; } = null!;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,31 @@
|
|||||||
namespace Domain.Exceptions;
|
namespace Domain.Exceptions;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Exception thrown when a resource conflict occurs (e.g., duplicate username, email already in use).
|
/// The exception that is thrown when a resource conflict occurs (e.g. duplicate username, email already
|
||||||
/// Maps to HTTP 409 Conflict.
|
/// in use). Maps to HTTP 409 Conflict.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ConflictException(string message) : Exception(message);
|
public class ConflictException(string message) : Exception(message);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Exception thrown when a requested resource is not found.
|
/// The exception that is thrown when a requested resource is not found. Maps to HTTP 404 Not Found.
|
||||||
/// Maps to HTTP 404 Not Found.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class NotFoundException(string message) : Exception(message);
|
public class NotFoundException(string message) : Exception(message);
|
||||||
|
|
||||||
// Domain.Exceptions/UnauthorizedException.cs
|
// Domain.Exceptions/UnauthorizedException.cs
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Exception thrown when authentication fails or is required.
|
/// The exception that is thrown when authentication fails or is required. Maps to HTTP 401 Unauthorized.
|
||||||
/// Maps to HTTP 401 Unauthorized.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class UnauthorizedException(string message) : Exception(message);
|
public class UnauthorizedException(string message) : Exception(message);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Exception thrown when a user is authenticated but lacks permission to access a resource.
|
/// The exception that is thrown when an authenticated user lacks permission to access a resource. Maps to
|
||||||
/// Maps to HTTP 403 Forbidden.
|
/// HTTP 403 Forbidden.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ForbiddenException(string message) : Exception(message);
|
public class ForbiddenException(string message) : Exception(message);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Exception thrown when business rule validation fails (distinct from FluentValidation).
|
/// The exception that is thrown when business rule validation fails (distinct from FluentValidation's
|
||||||
/// Maps to HTTP 400 Bad Request.
|
/// <see cref="FluentValidation.ValidationException" />). Maps to HTTP 400 Bad Request.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ValidationException(string message) : Exception(message);
|
public class ValidationException(string message) : Exception(message);
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ public class RegisterUserHandlerTests
|
|||||||
_authRepoMock
|
_authRepoMock
|
||||||
.Setup(x =>
|
.Setup(x =>
|
||||||
x.RegisterUserAsync(
|
x.RegisterUserAsync(
|
||||||
|
new UserRegistrationDto(
|
||||||
command.Username,
|
command.Username,
|
||||||
command.FirstName,
|
command.FirstName,
|
||||||
command.LastName,
|
command.LastName,
|
||||||
@@ -76,6 +77,7 @@ public class RegisterUserHandlerTests
|
|||||||
hashedPassword
|
hashedPassword
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
)
|
||||||
.ReturnsAsync(
|
.ReturnsAsync(
|
||||||
new UserAccount
|
new UserAccount
|
||||||
{
|
{
|
||||||
@@ -139,15 +141,7 @@ public class RegisterUserHandlerTests
|
|||||||
.WithMessage("Username or email already exists");
|
.WithMessage("Username or email already exists");
|
||||||
|
|
||||||
_authRepoMock.Verify(
|
_authRepoMock.Verify(
|
||||||
x =>
|
x => x.RegisterUserAsync(It.IsAny<UserRegistrationDto>()),
|
||||||
x.RegisterUserAsync(
|
|
||||||
It.IsAny<string>(),
|
|
||||||
It.IsAny<string>(),
|
|
||||||
It.IsAny<string>(),
|
|
||||||
It.IsAny<string>(),
|
|
||||||
It.IsAny<DateTime>(),
|
|
||||||
It.IsAny<string>()
|
|
||||||
),
|
|
||||||
Times.Never
|
Times.Never
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -192,12 +186,7 @@ public class RegisterUserHandlerTests
|
|||||||
_authRepoMock
|
_authRepoMock
|
||||||
.Setup(x =>
|
.Setup(x =>
|
||||||
x.RegisterUserAsync(
|
x.RegisterUserAsync(
|
||||||
It.IsAny<string>(),
|
It.Is<UserRegistrationDto>(dto => dto.PasswordHash == hashedPassword)
|
||||||
It.IsAny<string>(),
|
|
||||||
It.IsAny<string>(),
|
|
||||||
It.IsAny<string>(),
|
|
||||||
It.IsAny<DateTime>(),
|
|
||||||
hashedPassword
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid() });
|
.ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid() });
|
||||||
@@ -215,12 +204,14 @@ public class RegisterUserHandlerTests
|
|||||||
_authRepoMock.Verify(
|
_authRepoMock.Verify(
|
||||||
x =>
|
x =>
|
||||||
x.RegisterUserAsync(
|
x.RegisterUserAsync(
|
||||||
|
new UserRegistrationDto(
|
||||||
command.Username,
|
command.Username,
|
||||||
command.FirstName,
|
command.FirstName,
|
||||||
command.LastName,
|
command.LastName,
|
||||||
command.Email,
|
command.Email,
|
||||||
command.DateOfBirth,
|
command.DateOfBirth,
|
||||||
hashedPassword
|
hashedPassword
|
||||||
|
)
|
||||||
),
|
),
|
||||||
Times.Once
|
Times.Once
|
||||||
);
|
);
|
||||||
@@ -239,16 +230,7 @@ public class RegisterUserHandlerTests
|
|||||||
.ReturnsAsync((UserAccount?)null);
|
.ReturnsAsync((UserAccount?)null);
|
||||||
_passwordInfraMock.Setup(x => x.Hash(It.IsAny<string>())).Returns("hashed");
|
_passwordInfraMock.Setup(x => x.Hash(It.IsAny<string>())).Returns("hashed");
|
||||||
_authRepoMock
|
_authRepoMock
|
||||||
.Setup(x =>
|
.Setup(x => x.RegisterUserAsync(It.IsAny<UserRegistrationDto>()))
|
||||||
x.RegisterUserAsync(
|
|
||||||
It.IsAny<string>(),
|
|
||||||
It.IsAny<string>(),
|
|
||||||
It.IsAny<string>(),
|
|
||||||
It.IsAny<string>(),
|
|
||||||
It.IsAny<DateTime>(),
|
|
||||||
It.IsAny<string>()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.ReturnsAsync(
|
.ReturnsAsync(
|
||||||
new UserAccount
|
new UserAccount
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Apps72.Dev.Data.DbMocker;
|
using Apps72.Dev.Data.DbMocker;
|
||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
|
using Features.Auth.Dtos;
|
||||||
using Features.Auth.Repository;
|
using Features.Auth.Repository;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
|
|
||||||
@@ -50,12 +51,14 @@ public class AuthRepositoryTests
|
|||||||
|
|
||||||
AuthRepository repo = CreateRepo(conn);
|
AuthRepository repo = CreateRepo(conn);
|
||||||
UserAccount result = await repo.RegisterUserAsync(
|
UserAccount result = await repo.RegisterUserAsync(
|
||||||
|
new UserRegistrationDto(
|
||||||
"testuser",
|
"testuser",
|
||||||
"Test",
|
"Test",
|
||||||
"User",
|
"User",
|
||||||
"test@example.com",
|
"test@example.com",
|
||||||
new DateTime(1990, 1, 1),
|
new DateTime(1990, 1, 1),
|
||||||
"hashedpassword123"
|
"hashedpassword123"
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
result.Should().NotBeNull();
|
result.Should().NotBeNull();
|
||||||
|
|||||||
@@ -3,8 +3,5 @@ using MediatR;
|
|||||||
|
|
||||||
namespace Features.Auth.Commands.ConfirmUser;
|
namespace Features.Auth.Commands.ConfirmUser;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Validates a confirmation token and confirms the corresponding user account.</summary>
|
||||||
/// Validates a confirmation token and confirms the corresponding user account.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="Token">The confirmation token issued to the user, typically delivered via email.</param>
|
|
||||||
public record ConfirmUserCommand(string Token) : IRequest<ConfirmationPayload>;
|
public record ConfirmUserCommand(string Token) : IRequest<ConfirmationPayload>;
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ namespace Features.Auth.Commands.ConfirmUser;
|
|||||||
/// Handles <see cref="ConfirmUserCommand" /> by validating the confirmation token and marking the
|
/// Handles <see cref="ConfirmUserCommand" /> by validating the confirmation token and marking the
|
||||||
/// corresponding user account as confirmed.
|
/// corresponding user account as confirmed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="authRepository">Repository used to look up and confirm user accounts.</param>
|
|
||||||
/// <param name="tokenService">Service used to validate the confirmation token.</param>
|
|
||||||
public class ConfirmUserHandler(IAuthRepository authRepository, ITokenService tokenService)
|
public class ConfirmUserHandler(IAuthRepository authRepository, ITokenService tokenService)
|
||||||
: IRequestHandler<ConfirmUserCommand, ConfirmationPayload>
|
: IRequestHandler<ConfirmUserCommand, ConfirmationPayload>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ namespace Features.Auth.Commands.RefreshToken;
|
|||||||
/// Handles <see cref="RefreshTokenCommand" /> by validating the refresh token and issuing a new
|
/// Handles <see cref="RefreshTokenCommand" /> by validating the refresh token and issuing a new
|
||||||
/// access/refresh token pair.
|
/// access/refresh token pair.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="tokenService">Service used to validate and exchange the refresh token.</param>
|
|
||||||
public class RefreshTokenHandler(ITokenService tokenService)
|
public class RefreshTokenHandler(ITokenService tokenService)
|
||||||
: IRequestHandler<RefreshTokenCommand, LoginPayload>
|
: IRequestHandler<RefreshTokenCommand, LoginPayload>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,14 +2,8 @@ using FluentValidation;
|
|||||||
|
|
||||||
namespace Features.Auth.Commands.RefreshToken;
|
namespace Features.Auth.Commands.RefreshToken;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Validates <see cref="RefreshTokenCommand" /> instances before they are processed.
|
|
||||||
/// </summary>
|
|
||||||
public class RefreshTokenValidator : AbstractValidator<RefreshTokenCommand>
|
public class RefreshTokenValidator : AbstractValidator<RefreshTokenCommand>
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// Configures a validation rule requiring <see cref="RefreshTokenCommand.RefreshToken" /> to be non-empty.
|
|
||||||
/// </summary>
|
|
||||||
public RefreshTokenValidator()
|
public RefreshTokenValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.RefreshToken).NotEmpty().WithMessage("Refresh token is required");
|
RuleFor(x => x.RefreshToken).NotEmpty().WithMessage("Refresh token is required");
|
||||||
|
|||||||
@@ -14,10 +14,6 @@ namespace Features.Auth.Commands.RegisterUser;
|
|||||||
/// account, issues access/refresh/confirmation tokens, and attempts to send the registration
|
/// account, issues access/refresh/confirmation tokens, and attempts to send the registration
|
||||||
/// confirmation email via Features.Emails.
|
/// confirmation email via Features.Emails.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="authRepo">Repository used to check for existing users and persist the new account.</param>
|
|
||||||
/// <param name="passwordInfrastructure">Infrastructure component used to hash the user's plain-text password.</param>
|
|
||||||
/// <param name="tokenService">Service used to generate access, refresh, and confirmation tokens.</param>
|
|
||||||
/// <param name="mediator">Used to send the cross-slice command that triggers the confirmation email.</param>
|
|
||||||
public class RegisterUserHandler(
|
public class RegisterUserHandler(
|
||||||
IAuthRepository authRepo,
|
IAuthRepository authRepo,
|
||||||
IPasswordInfrastructure passwordInfrastructure,
|
IPasswordInfrastructure passwordInfrastructure,
|
||||||
@@ -38,12 +34,14 @@ public class RegisterUserHandler(
|
|||||||
string hashed = passwordInfrastructure.Hash(request.Password);
|
string hashed = passwordInfrastructure.Hash(request.Password);
|
||||||
|
|
||||||
UserAccount createdUser = await authRepo.RegisterUserAsync(
|
UserAccount createdUser = await authRepo.RegisterUserAsync(
|
||||||
|
new UserRegistrationDto(
|
||||||
request.Username,
|
request.Username,
|
||||||
request.FirstName,
|
request.FirstName,
|
||||||
request.LastName,
|
request.LastName,
|
||||||
request.Email,
|
request.Email,
|
||||||
request.DateOfBirth,
|
request.DateOfBirth,
|
||||||
hashed
|
hashed
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
string accessToken = tokenService.GenerateAccessToken(createdUser);
|
string accessToken = tokenService.GenerateAccessToken(createdUser);
|
||||||
|
|||||||
@@ -2,15 +2,8 @@ using FluentValidation;
|
|||||||
|
|
||||||
namespace Features.Auth.Commands.RegisterUser;
|
namespace Features.Auth.Commands.RegisterUser;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Validates <see cref="RegisterUserCommand" /> instances before they are processed.
|
|
||||||
/// </summary>
|
|
||||||
public class RegisterUserValidator : AbstractValidator<RegisterUserCommand>
|
public class RegisterUserValidator : AbstractValidator<RegisterUserCommand>
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// Configures validation rules for username format and length, first/last name length, email format and
|
|
||||||
/// length, minimum age based on date of birth, and password strength requirements.
|
|
||||||
/// </summary>
|
|
||||||
public RegisterUserValidator()
|
public RegisterUserValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.Username)
|
RuleFor(x => x.Username)
|
||||||
|
|||||||
@@ -2,8 +2,5 @@ using MediatR;
|
|||||||
|
|
||||||
namespace Features.Auth.Commands.ResendConfirmationEmail;
|
namespace Features.Auth.Commands.ResendConfirmationEmail;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Resends the account confirmation email to a user, generating a fresh confirmation token.</summary>
|
||||||
/// Resends the account confirmation email to a user, generating a fresh confirmation token.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="UserId">The unique identifier of the user requesting the resend.</param>
|
|
||||||
public record ResendConfirmationEmailCommand(Guid UserId) : IRequest;
|
public record ResendConfirmationEmailCommand(Guid UserId) : IRequest;
|
||||||
|
|||||||
@@ -10,9 +10,6 @@ namespace Features.Auth.Commands.ResendConfirmationEmail;
|
|||||||
/// Handles <see cref="ResendConfirmationEmailCommand" /> by generating a fresh confirmation token and
|
/// Handles <see cref="ResendConfirmationEmailCommand" /> by generating a fresh confirmation token and
|
||||||
/// sending it via Features.Emails.
|
/// sending it via Features.Emails.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="authRepository">Repository used to look up the user and check verification status.</param>
|
|
||||||
/// <param name="tokenService">Service used to generate the confirmation token.</param>
|
|
||||||
/// <param name="mediator">Used to send the cross-slice command that triggers the email.</param>
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Returns silently without sending an email if the user does not exist (to prevent user enumeration)
|
/// Returns silently without sending an email if the user does not exist (to prevent user enumeration)
|
||||||
/// or if the user's account is already verified.
|
/// or if the user's account is already verified.
|
||||||
|
|||||||
@@ -18,21 +18,11 @@ namespace Features.Auth.Controllers;
|
|||||||
/// The controller is decorated with <c>[Authorize(AuthenticationSchemes = "JWT")]</c> by default, but most
|
/// The controller is decorated with <c>[Authorize(AuthenticationSchemes = "JWT")]</c> by default, but most
|
||||||
/// actions opt out via <c>[AllowAnonymous]</c> since they are entry points used before a caller holds a token.
|
/// actions opt out via <c>[AllowAnonymous]</c> since they are entry points used before a caller holds a token.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="mediator">Used to dispatch auth commands and queries to their handlers.</param>
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
[Authorize(AuthenticationSchemes = "JWT")]
|
[Authorize(AuthenticationSchemes = "JWT")]
|
||||||
public class AuthController(IMediator mediator) : ControllerBase
|
public class AuthController(IMediator mediator) : ControllerBase
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// Registers a new user account.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Allows anonymous access. On success, responds with <c>201 Created</c> containing the new user's ID,
|
|
||||||
/// username, issued refresh/access tokens, and whether a confirmation email was sent.
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="command">The registration details, including username, name, email, date of birth, and password.</param>
|
|
||||||
/// <returns>A <c>201 Created</c> result wrapping a <see cref="ResponseBody{T}" /> of <see cref="RegistrationPayload" />.</returns>
|
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[HttpPost("register")]
|
[HttpPost("register")]
|
||||||
public async Task<ActionResult<ResponseBody<RegistrationPayload>>> Register(
|
public async Task<ActionResult<ResponseBody<RegistrationPayload>>> Register(
|
||||||
@@ -50,15 +40,6 @@ public class AuthController(IMediator mediator) : ControllerBase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Authenticates a user with a username and password.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Allows anonymous access. On success, responds with <c>200 OK</c> containing the user's ID,
|
|
||||||
/// username, and a newly issued refresh/access token pair.
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="query">The login credentials (username and password).</param>
|
|
||||||
/// <returns>An <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> of <see cref="LoginPayload" />.</returns>
|
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[HttpPost("login")]
|
[HttpPost("login")]
|
||||||
public async Task<ActionResult<ResponseBody<LoginPayload>>> Login([FromBody] LoginQuery query)
|
public async Task<ActionResult<ResponseBody<LoginPayload>>> Login([FromBody] LoginQuery query)
|
||||||
@@ -73,15 +54,6 @@ public class AuthController(IMediator mediator) : ControllerBase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Confirms a user account using a confirmation token.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Requires JWT authentication. On success, responds with <c>200 OK</c> containing the confirmed
|
|
||||||
/// user's ID and the confirmation timestamp.
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="token">The confirmation token supplied via the confirmation email link.</param>
|
|
||||||
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> of <see cref="ConfirmationPayload" />.</returns>
|
|
||||||
[HttpPost("confirm")]
|
[HttpPost("confirm")]
|
||||||
public async Task<ActionResult<ResponseBody<ConfirmationPayload>>> Confirm(
|
public async Task<ActionResult<ResponseBody<ConfirmationPayload>>> Confirm(
|
||||||
[FromQuery] string token
|
[FromQuery] string token
|
||||||
@@ -97,14 +69,6 @@ public class AuthController(IMediator mediator) : ControllerBase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Resends the account confirmation email for the specified user.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Requires JWT authentication. On success, responds with <c>200 OK</c>.
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="userId">The unique identifier of the user account to resend the confirmation email for.</param>
|
|
||||||
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody" /> confirming the email was resent.</returns>
|
|
||||||
[HttpPost("confirm/resend")]
|
[HttpPost("confirm/resend")]
|
||||||
public async Task<ActionResult<ResponseBody>> ResendConfirmation([FromQuery] Guid userId)
|
public async Task<ActionResult<ResponseBody>> ResendConfirmation([FromQuery] Guid userId)
|
||||||
{
|
{
|
||||||
@@ -112,15 +76,6 @@ public class AuthController(IMediator mediator) : ControllerBase
|
|||||||
return Ok(new ResponseBody { Message = "confirmation email has been resent" });
|
return Ok(new ResponseBody { Message = "confirmation email has been resent" });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Exchanges a valid refresh token for a new access/refresh token pair.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Allows anonymous access. On success, responds with <c>200 OK</c> containing the user's ID,
|
|
||||||
/// username, and the newly issued refresh/access token pair.
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="command">The request containing the refresh token to exchange.</param>
|
|
||||||
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> of <see cref="LoginPayload" />.</returns>
|
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[HttpPost("refresh")]
|
[HttpPost("refresh")]
|
||||||
public async Task<ActionResult<ResponseBody<LoginPayload>>> Refresh(
|
public async Task<ActionResult<ResponseBody<LoginPayload>>> Refresh(
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
|
|
||||||
namespace Features.Auth.DependencyInjection;
|
namespace Features.Auth.DependencyInjection;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Registers the services owned by the Auth feature slice.
|
|
||||||
/// </summary>
|
|
||||||
public static class FeaturesAuthServiceCollectionExtensions
|
public static class FeaturesAuthServiceCollectionExtensions
|
||||||
{
|
{
|
||||||
public static IServiceCollection AddFeaturesAuth(this IServiceCollection services)
|
public static IServiceCollection AddFeaturesAuth(this IServiceCollection services)
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
namespace Features.Auth.Dtos;
|
namespace Features.Auth.Dtos;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Payload returned to the client after a successful login or token refresh.</summary>
|
||||||
/// Payload returned to the client after a successful login or token refresh.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="UserAccountId">The unique identifier of the authenticated user account.</param>
|
|
||||||
/// <param name="Username">The username of the authenticated user account.</param>
|
|
||||||
/// <param name="RefreshToken">The newly issued refresh token used to obtain new access tokens.</param>
|
|
||||||
/// <param name="AccessToken">The newly issued JWT access token used to authorize subsequent requests.</param>
|
|
||||||
public record LoginPayload(
|
public record LoginPayload(
|
||||||
Guid UserAccountId,
|
Guid UserAccountId,
|
||||||
string Username,
|
string Username,
|
||||||
@@ -14,14 +8,7 @@ public record LoginPayload(
|
|||||||
string AccessToken
|
string AccessToken
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Payload returned to the client after a successful registration.</summary>
|
||||||
/// Payload returned to the client after a successful registration.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="UserAccountId">The unique identifier of the newly created user account.</param>
|
|
||||||
/// <param name="Username">The username of the newly created user account.</param>
|
|
||||||
/// <param name="RefreshToken">The refresh token issued for the new account.</param>
|
|
||||||
/// <param name="AccessToken">The JWT access token issued for the new account.</param>
|
|
||||||
/// <param name="ConfirmationEmailSent">Whether a confirmation email was successfully sent to the new user.</param>
|
|
||||||
public record RegistrationPayload(
|
public record RegistrationPayload(
|
||||||
Guid UserAccountId,
|
Guid UserAccountId,
|
||||||
string Username,
|
string Username,
|
||||||
@@ -30,9 +17,5 @@ public record RegistrationPayload(
|
|||||||
bool ConfirmationEmailSent
|
bool ConfirmationEmailSent
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Payload returned to the client after a user account's email has been confirmed.</summary>
|
||||||
/// Payload returned to the client after a user account's email has been confirmed.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="UserAccountId">The unique identifier of the user account that was confirmed.</param>
|
|
||||||
/// <param name="ConfirmedDate">The date and time at which the account was confirmed.</param>
|
|
||||||
public record ConfirmationPayload(Guid UserAccountId, DateTime ConfirmedDate);
|
public record ConfirmationPayload(Guid UserAccountId, DateTime ConfirmedDate);
|
||||||
|
|||||||
@@ -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>
|
/// <summary>
|
||||||
/// Handles <see cref="LoginQuery" /> by verifying credentials and issuing access/refresh tokens.
|
/// Handles <see cref="LoginQuery" /> by verifying credentials and issuing access/refresh tokens.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="authRepo">Repository used to look up the user account and its active credential.</param>
|
|
||||||
/// <param name="passwordInfrastructure">
|
|
||||||
/// Infrastructure component used to verify a plain-text password against a stored
|
|
||||||
/// hash.
|
|
||||||
/// </param>
|
|
||||||
/// <param name="tokenService">Service used to generate access and refresh tokens for the authenticated user.</param>
|
|
||||||
public class LoginHandler(
|
public class LoginHandler(
|
||||||
IAuthRepository authRepo,
|
IAuthRepository authRepo,
|
||||||
IPasswordInfrastructure passwordInfrastructure,
|
IPasswordInfrastructure passwordInfrastructure,
|
||||||
|
|||||||
@@ -2,15 +2,8 @@ using FluentValidation;
|
|||||||
|
|
||||||
namespace Features.Auth.Queries.Login;
|
namespace Features.Auth.Queries.Login;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Validates <see cref="LoginQuery" /> instances before they are processed.
|
|
||||||
/// </summary>
|
|
||||||
public class LoginValidator : AbstractValidator<LoginQuery>
|
public class LoginValidator : AbstractValidator<LoginQuery>
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// Configures validation rules requiring both <see cref="LoginQuery.Username" /> and
|
|
||||||
/// <see cref="LoginQuery.Password" /> to be non-empty.
|
|
||||||
/// </summary>
|
|
||||||
public LoginValidator()
|
public LoginValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.Username).NotEmpty().WithMessage("Username is required");
|
RuleFor(x => x.Username).NotEmpty().WithMessage("Username is required");
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Data;
|
using System.Data;
|
||||||
using System.Data.Common;
|
using System.Data.Common;
|
||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
|
using Features.Auth.Dtos;
|
||||||
using Infrastructure.Sql;
|
using Infrastructure.Sql;
|
||||||
using Microsoft.Data.SqlClient;
|
using Microsoft.Data.SqlClient;
|
||||||
|
|
||||||
@@ -10,7 +11,6 @@ namespace Features.Auth.Repository;
|
|||||||
/// ADO.NET-based implementation of <see cref="IAuthRepository" /> backed by SQL Server stored procedures,
|
/// ADO.NET-based implementation of <see cref="IAuthRepository" /> backed by SQL Server stored procedures,
|
||||||
/// handling user registration, credential lookup/rotation, and account verification.
|
/// handling user registration, credential lookup/rotation, and account verification.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="connectionFactory">The factory used to create database connections.</param>
|
|
||||||
public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||||
: Repository<UserAccount>(connectionFactory),
|
: Repository<UserAccount>(connectionFactory),
|
||||||
IAuthRepository
|
IAuthRepository
|
||||||
@@ -25,23 +25,8 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
/// If the result cannot be interpreted, <see cref="Guid.Empty" /> is used, which will cause the
|
/// If the result cannot be interpreted, <see cref="Guid.Empty" /> is used, which will cause the
|
||||||
/// subsequent lookup to fail.
|
/// subsequent lookup to fail.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="username">Unique username for the user</param>
|
|
||||||
/// <param name="firstName">User's first name</param>
|
|
||||||
/// <param name="lastName">User's last name</param>
|
|
||||||
/// <param name="email">User's email address</param>
|
|
||||||
/// <param name="dateOfBirth">User's date of birth</param>
|
|
||||||
/// <param name="passwordHash">Hashed password</param>
|
|
||||||
/// <returns>The newly created UserAccount with generated ID</returns>
|
|
||||||
/// <exception cref="Exception">Thrown when the newly registered user cannot be retrieved after registration.</exception>
|
/// <exception cref="Exception">Thrown when the newly registered user cannot be retrieved after registration.</exception>
|
||||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
public async Task<UserAccount> RegisterUserAsync(UserRegistrationDto userRegistrationDto)
|
||||||
public async Task<UserAccount> RegisterUserAsync(
|
|
||||||
string username,
|
|
||||||
string firstName,
|
|
||||||
string lastName,
|
|
||||||
string email,
|
|
||||||
DateTime dateOfBirth,
|
|
||||||
string passwordHash
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
await using DbConnection connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
await using DbCommand command = connection.CreateCommand();
|
await using DbCommand command = connection.CreateCommand();
|
||||||
@@ -49,6 +34,8 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
command.CommandText = "USP_RegisterUser";
|
command.CommandText = "USP_RegisterUser";
|
||||||
command.CommandType = CommandType.StoredProcedure;
|
command.CommandType = CommandType.StoredProcedure;
|
||||||
|
|
||||||
|
var (username, firstName, lastName, email, dateOfBirth, passwordHash) = userRegistrationDto;
|
||||||
|
|
||||||
AddParameter(command, "@Username", username);
|
AddParameter(command, "@Username", username);
|
||||||
AddParameter(command, "@FirstName", firstName);
|
AddParameter(command, "@FirstName", firstName);
|
||||||
AddParameter(command, "@LastName", lastName);
|
AddParameter(command, "@LastName", lastName);
|
||||||
@@ -85,13 +72,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
?? throw new Exception("Failed to retrieve newly registered user.");
|
?? throw new Exception("Failed to retrieve newly registered user.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Retrieves a user account by email using the <c>usp_GetUserAccountByEmail</c> stored procedure.</summary>
|
||||||
/// Retrieves a user account by email address (typically used for login) using the
|
|
||||||
/// <c>usp_GetUserAccountByEmail</c> stored procedure.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="email">Email address to search for</param>
|
|
||||||
/// <returns>UserAccount if found, null otherwise</returns>
|
|
||||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
|
||||||
public async Task<UserAccount?> GetUserByEmailAsync(string email)
|
public async Task<UserAccount?> GetUserByEmailAsync(string email)
|
||||||
{
|
{
|
||||||
await using DbConnection connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
@@ -105,13 +86,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
return await reader.ReadAsync() ? MapToEntity(reader) : null;
|
return await reader.ReadAsync() ? MapToEntity(reader) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Retrieves a user account by username using the <c>usp_GetUserAccountByUsername</c> stored procedure.</summary>
|
||||||
/// Retrieves a user account by username (typically used for login) using the
|
|
||||||
/// <c>usp_GetUserAccountByUsername</c> stored procedure.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="username">Username to search for</param>
|
|
||||||
/// <returns>UserAccount if found, null otherwise</returns>
|
|
||||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
|
||||||
public async Task<UserAccount?> GetUserByUsernameAsync(string username)
|
public async Task<UserAccount?> GetUserByUsernameAsync(string username)
|
||||||
{
|
{
|
||||||
await using DbConnection connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
@@ -129,9 +104,6 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
/// Retrieves the active (non-revoked) credential for a user account using the
|
/// Retrieves the active (non-revoked) credential for a user account using the
|
||||||
/// <c>USP_GetActiveUserCredentialByUserAccountId</c> stored procedure.
|
/// <c>USP_GetActiveUserCredentialByUserAccountId</c> stored procedure.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="userAccountId">ID of the user account</param>
|
|
||||||
/// <returns>Active UserCredential if found, null otherwise</returns>
|
|
||||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
|
||||||
public async Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId)
|
public async Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId)
|
||||||
{
|
{
|
||||||
await using DbConnection connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
@@ -149,9 +121,6 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
/// Rotates a user's credential by invalidating all existing credentials and creating a new one,
|
/// Rotates a user's credential by invalidating all existing credentials and creating a new one,
|
||||||
/// using the <c>USP_RotateUserCredential</c> stored procedure.
|
/// using the <c>USP_RotateUserCredential</c> stored procedure.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="userAccountId">ID of the user account</param>
|
|
||||||
/// <param name="newPasswordHash">New hashed password</param>
|
|
||||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
|
||||||
public async Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash)
|
public async Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash)
|
||||||
{
|
{
|
||||||
await using DbConnection connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
@@ -165,12 +134,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
await command.ExecuteNonQueryAsync();
|
await command.ExecuteNonQueryAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Retrieves a user account by ID using the <c>usp_GetUserAccountById</c> stored procedure.</summary>
|
||||||
/// Retrieves a user account by ID using the <c>usp_GetUserAccountById</c> stored procedure.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userAccountId">ID of the user account</param>
|
|
||||||
/// <returns>UserAccount if found, null otherwise</returns>
|
|
||||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
|
||||||
public async Task<UserAccount?> GetUserByIdAsync(Guid userAccountId)
|
public async Task<UserAccount?> GetUserByIdAsync(Guid userAccountId)
|
||||||
{
|
{
|
||||||
await using DbConnection connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
@@ -190,8 +154,6 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
/// no-op and the existing user is returned (idempotent). If a concurrent request verifies the user
|
/// no-op and the existing user is returned (idempotent). If a concurrent request verifies the user
|
||||||
/// first, the resulting duplicate-key SQL exception (error 2601/2627) is swallowed.
|
/// first, the resulting duplicate-key SQL exception (error 2601/2627) is swallowed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="userAccountId">ID of the user account to confirm</param>
|
|
||||||
/// <returns>The confirmed <see cref="Domain.Entities.UserAccount" />, or <c>null</c> if the user account does not exist.</returns>
|
|
||||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">
|
/// <exception cref="Microsoft.Data.SqlClient.SqlException">
|
||||||
/// Thrown when the database command fails for a reason other than
|
/// Thrown when the database command fails for a reason other than
|
||||||
/// a duplicate verification record.
|
/// a duplicate verification record.
|
||||||
@@ -226,13 +188,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
return await GetUserByIdAsync(userAccountId);
|
return await GetUserByIdAsync(userAccountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Checks whether a user account has been verified by querying the <c>dbo.UserVerification</c> table.</summary>
|
||||||
/// Checks whether a user account has been verified by querying the
|
|
||||||
/// <c>dbo.UserVerification</c> table for a matching record.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userAccountId">ID of the user account</param>
|
|
||||||
/// <returns>True if the user has a verification record, false otherwise</returns>
|
|
||||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
|
||||||
public async Task<bool> IsUserVerifiedAsync(Guid userAccountId)
|
public async Task<bool> IsUserVerifiedAsync(Guid userAccountId)
|
||||||
{
|
{
|
||||||
await using DbConnection connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
@@ -247,23 +203,13 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
return result != null && result != DBNull.Value;
|
return result != null && result != DBNull.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Determines whether a <see cref="SqlException" /> is a duplicate key violation (SQL Server error 2601 or 2627).</summary>
|
||||||
/// Determines whether a <see cref="SqlException" /> represents a duplicate key violation
|
|
||||||
/// (SQL Server error 2601 or 2627), used to detect a concurrent duplicate verification insert.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="ex">The SQL exception to inspect.</param>
|
|
||||||
/// <returns>True if the exception represents a duplicate key violation, false otherwise.</returns>
|
|
||||||
private static bool IsDuplicateVerificationViolation(SqlException ex)
|
private static bool IsDuplicateVerificationViolation(SqlException ex)
|
||||||
{
|
{
|
||||||
// 2601/2627 are duplicate key violations in SQL Server.
|
// 2601/2627 are duplicate key violations in SQL Server.
|
||||||
return ex.Number == 2601 || ex.Number == 2627;
|
return ex.Number == 2601 || ex.Number == 2627;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Maps a data reader row to a UserAccount entity.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="reader">The data reader positioned on the row to map.</param>
|
|
||||||
/// <returns>The mapped <see cref="Domain.Entities.UserAccount" /> instance.</returns>
|
|
||||||
protected override UserAccount MapToEntity(DbDataReader reader)
|
protected override UserAccount MapToEntity(DbDataReader reader)
|
||||||
{
|
{
|
||||||
return new UserAccount
|
return new UserAccount
|
||||||
@@ -286,8 +232,6 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
/// Maps a data reader row to a UserCredential entity. The <c>Timer</c> column is mapped only if
|
/// Maps a data reader row to a UserCredential entity. The <c>Timer</c> column is mapped only if
|
||||||
/// present in the reader's schema, allowing this method to support result sets that omit it.
|
/// present in the reader's schema, allowing this method to support result sets that omit it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="reader">The data reader positioned on the row to map.</param>
|
|
||||||
/// <returns>The mapped <see cref="UserCredential" /> instance.</returns>
|
|
||||||
private static UserCredential MapToCredentialEntity(DbDataReader reader)
|
private static UserCredential MapToCredentialEntity(DbDataReader reader)
|
||||||
{
|
{
|
||||||
UserCredential entity = new()
|
UserCredential entity = new()
|
||||||
@@ -320,13 +264,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Adds a parameter to a database command, converting <c>null</c> values to <see cref="DBNull.Value" />.</summary>
|
||||||
/// Helper method to add a parameter to a database command, converting <c>null</c> values to
|
|
||||||
/// <see cref="DBNull.Value" />.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="command">The command to add the parameter to.</param>
|
|
||||||
/// <param name="name">The parameter name (including any prefix, e.g. "@Username").</param>
|
|
||||||
/// <param name="value">The parameter value, or <c>null</c> to bind <see cref="DBNull.Value" />.</param>
|
|
||||||
private static void AddParameter(DbCommand command, string name, object? value)
|
private static void AddParameter(DbCommand command, string name, object? value)
|
||||||
{
|
{
|
||||||
DbParameter p = command.CreateParameter();
|
DbParameter p = command.CreateParameter();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
|
using Features.Auth.Dtos;
|
||||||
|
|
||||||
namespace Features.Auth.Repository;
|
namespace Features.Auth.Repository;
|
||||||
|
|
||||||
@@ -7,76 +8,21 @@ namespace Features.Auth.Repository;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IAuthRepository
|
public interface IAuthRepository
|
||||||
{
|
{
|
||||||
/// <summary>
|
Task<UserAccount> RegisterUserAsync(UserRegistrationDto userRegistrationDto);
|
||||||
/// Registers a new user with account details and initial credential.
|
|
||||||
/// Uses stored procedure: USP_RegisterUser
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="username">Unique username for the user</param>
|
|
||||||
/// <param name="firstName">User's first name</param>
|
|
||||||
/// <param name="lastName">User's last name</param>
|
|
||||||
/// <param name="email">User's email address</param>
|
|
||||||
/// <param name="dateOfBirth">User's date of birth</param>
|
|
||||||
/// <param name="passwordHash">Hashed password</param>
|
|
||||||
/// <returns>The newly created UserAccount with generated ID</returns>
|
|
||||||
Task<UserAccount> RegisterUserAsync(
|
|
||||||
string username,
|
|
||||||
string firstName,
|
|
||||||
string lastName,
|
|
||||||
string email,
|
|
||||||
DateTime dateOfBirth,
|
|
||||||
string passwordHash
|
|
||||||
);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Retrieves a user account by email address (typically used for login).
|
|
||||||
/// Uses stored procedure: usp_GetUserAccountByEmail
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="email">Email address to search for</param>
|
|
||||||
/// <returns>UserAccount if found, null otherwise</returns>
|
|
||||||
Task<UserAccount?> GetUserByEmailAsync(string email);
|
Task<UserAccount?> GetUserByEmailAsync(string email);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Retrieves a user account by username (typically used for login).
|
|
||||||
/// Uses stored procedure: usp_GetUserAccountByUsername
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="username">Username to search for</param>
|
|
||||||
/// <returns>UserAccount if found, null otherwise</returns>
|
|
||||||
Task<UserAccount?> GetUserByUsernameAsync(string username);
|
Task<UserAccount?> GetUserByUsernameAsync(string username);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Retrieves the active (non-revoked) credential for a user account.
|
|
||||||
/// Uses stored procedure: USP_GetActiveUserCredentialByUserAccountId
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userAccountId">ID of the user account</param>
|
|
||||||
/// <returns>Active UserCredential if found, null otherwise</returns>
|
|
||||||
Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId);
|
Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Rotates a user's credential by invalidating all existing credentials and creating a new one.</summary>
|
||||||
/// Rotates a user's credential by invalidating all existing credentials and creating a new one.
|
|
||||||
/// Uses stored procedure: USP_RotateUserCredential
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userAccountId">ID of the user account</param>
|
|
||||||
/// <param name="newPasswordHash">New hashed password</param>
|
|
||||||
Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash);
|
Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Marks a user account as confirmed. Idempotent if the account is already verified.</summary>
|
||||||
/// Marks a user account as confirmed.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userAccountId">ID of the user account to confirm</param>
|
|
||||||
/// <returns>The confirmed UserAccount entity, or null if the user account does not exist</returns>
|
|
||||||
Task<UserAccount?> ConfirmUserAccountAsync(Guid userAccountId);
|
Task<UserAccount?> ConfirmUserAccountAsync(Guid userAccountId);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Retrieves a user account by ID.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userAccountId">ID of the user account</param>
|
|
||||||
/// <returns>UserAccount if found, null otherwise</returns>
|
|
||||||
Task<UserAccount?> GetUserByIdAsync(Guid userAccountId);
|
Task<UserAccount?> GetUserByIdAsync(Guid userAccountId);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks whether a user account has been verified.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="userAccountId">ID of the user account</param>
|
|
||||||
/// <returns>True if the user has a verification record, false otherwise</returns>
|
|
||||||
Task<bool> IsUserVerifiedAsync(Guid userAccountId);
|
Task<bool> IsUserVerifiedAsync(Guid userAccountId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,20 +18,10 @@ public enum TokenType
|
|||||||
ConfirmationToken,
|
ConfirmationToken,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Represents the result of successfully validating a token.</summary>
|
||||||
/// Represents the result of successfully validating a token.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="UserId">The unique identifier of the user the token belongs to.</param>
|
|
||||||
/// <param name="Username">The username extracted from the token's claims.</param>
|
|
||||||
/// <param name="Principal">The <see cref="ClaimsPrincipal" /> produced from the validated token.</param>
|
|
||||||
public record ValidatedToken(Guid UserId, string Username, ClaimsPrincipal Principal);
|
public record ValidatedToken(Guid UserId, string Username, ClaimsPrincipal Principal);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Represents the result of refreshing a user's session.</summary>
|
||||||
/// Represents the result of refreshing a user's session.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="UserAccount">The user account associated with the refreshed session.</param>
|
|
||||||
/// <param name="RefreshToken">The newly issued refresh token.</param>
|
|
||||||
/// <param name="AccessToken">The newly issued access token.</param>
|
|
||||||
public record RefreshTokenResult(UserAccount UserAccount, string RefreshToken, string AccessToken);
|
public record RefreshTokenResult(UserAccount UserAccount, string RefreshToken, string AccessToken);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -39,14 +29,13 @@ public record RefreshTokenResult(UserAccount UserAccount, string RefreshToken, s
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class TokenServiceExpirationHours
|
public static class TokenServiceExpirationHours
|
||||||
{
|
{
|
||||||
/// <summary>The expiration window, in hours, for access tokens.</summary>
|
|
||||||
public const double AccessTokenHours = 1;
|
public const double AccessTokenHours = 1;
|
||||||
|
|
||||||
/// <summary>The expiration window, in hours, for refresh tokens (21 days).</summary>
|
/// <summary>21 days.</summary>
|
||||||
public const double RefreshTokenHours = 504; // 21 days
|
public const double RefreshTokenHours = 504;
|
||||||
|
|
||||||
/// <summary>The expiration window, in hours, for confirmation tokens (30 minutes).</summary>
|
/// <summary>30 minutes.</summary>
|
||||||
public const double ConfirmationTokenHours = 0.5; // 30 minutes
|
public const double ConfirmationTokenHours = 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -54,61 +43,23 @@ public static class TokenServiceExpirationHours
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public interface ITokenService
|
public interface ITokenService
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// Generates a new access token for the given user.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="user">The user account to generate the token for.</param>
|
|
||||||
/// <returns>The signed access token string.</returns>
|
|
||||||
string GenerateAccessToken(UserAccount user);
|
string GenerateAccessToken(UserAccount user);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates a new refresh token for the given user.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="user">The user account to generate the token for.</param>
|
|
||||||
/// <returns>The signed refresh token string.</returns>
|
|
||||||
string GenerateRefreshToken(UserAccount user);
|
string GenerateRefreshToken(UserAccount user);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates a new confirmation token for the given user.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="user">The user account to generate the token for.</param>
|
|
||||||
/// <returns>The signed confirmation token string.</returns>
|
|
||||||
string GenerateConfirmationToken(UserAccount user);
|
string GenerateConfirmationToken(UserAccount user);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Generates a token of the type specified by <typeparamref name="T" />.</summary>
|
||||||
/// Generates a token of the type specified by <typeparamref name="T" />, which must be <see cref="TokenType" />.
|
/// <typeparam name="T">Must be <see cref="TokenType" />.</typeparam>
|
||||||
/// </summary>
|
|
||||||
/// <typeparam name="T">The enum type identifying which kind of token to generate. Must be <see cref="TokenType" />.</typeparam>
|
|
||||||
/// <param name="user">The user account to generate the token for.</param>
|
|
||||||
/// <returns>The signed token string corresponding to the requested token type.</returns>
|
|
||||||
string GenerateToken<T>(UserAccount user)
|
string GenerateToken<T>(UserAccount user)
|
||||||
where T : struct, Enum;
|
where T : struct, Enum;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Validates an access token.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="token">The access token string to validate.</param>
|
|
||||||
/// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
|
|
||||||
Task<ValidatedToken> ValidateAccessTokenAsync(string token);
|
Task<ValidatedToken> ValidateAccessTokenAsync(string token);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Validates a refresh token.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="token">The refresh token string to validate.</param>
|
|
||||||
/// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
|
|
||||||
Task<ValidatedToken> ValidateRefreshTokenAsync(string token);
|
Task<ValidatedToken> ValidateRefreshTokenAsync(string token);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Validates a confirmation token.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="token">The confirmation token string to validate.</param>
|
|
||||||
/// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
|
|
||||||
Task<ValidatedToken> ValidateConfirmationTokenAsync(string token);
|
Task<ValidatedToken> ValidateConfirmationTokenAsync(string token);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Validates a refresh token and issues a new access/refresh token pair for the associated user.</summary>
|
||||||
/// Validates a refresh token and issues a new access/refresh token pair for the associated user.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="refreshTokenString">The refresh token string to validate and exchange.</param>
|
|
||||||
/// <returns>A <see cref="RefreshTokenResult" /> containing the user and newly issued tokens.</returns>
|
|
||||||
Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString);
|
Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,12 +19,7 @@ public class TokenService : ITokenService
|
|||||||
private readonly string _refreshTokenSecret;
|
private readonly string _refreshTokenSecret;
|
||||||
private readonly ITokenInfrastructure _tokenInfrastructure;
|
private readonly ITokenInfrastructure _tokenInfrastructure;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Initializes a new instance of the <see cref="TokenService" /> class.</summary>
|
||||||
/// Initializes a new instance of <see cref="TokenService" />, loading the access, refresh, and
|
|
||||||
/// confirmation token signing secrets from environment variables.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="tokenInfrastructure">The infrastructure component used to generate and validate JWTs.</param>
|
|
||||||
/// <param name="authRepository">Repository used to look up user accounts during token refresh.</param>
|
|
||||||
/// <exception cref="InvalidOperationException">
|
/// <exception cref="InvalidOperationException">
|
||||||
/// Thrown when any of the <c>ACCESS_TOKEN_SECRET</c>, <c>REFRESH_TOKEN_SECRET</c>, or
|
/// Thrown when any of the <c>ACCESS_TOKEN_SECRET</c>, <c>REFRESH_TOKEN_SECRET</c>, or
|
||||||
/// <c>CONFIRMATION_TOKEN_SECRET</c> environment variables are not set.
|
/// <c>CONFIRMATION_TOKEN_SECRET</c> environment variables are not set.
|
||||||
@@ -53,12 +48,6 @@ public class TokenService : ITokenService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates an access token for the given user, signed with the access token secret and
|
|
||||||
/// expiring after <see cref="TokenServiceExpirationHours.AccessTokenHours" /> hours.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="user">The user account to generate the token for.</param>
|
|
||||||
/// <returns>The signed access token string.</returns>
|
|
||||||
public string GenerateAccessToken(UserAccount user)
|
public string GenerateAccessToken(UserAccount user)
|
||||||
{
|
{
|
||||||
DateTime expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.AccessTokenHours);
|
DateTime expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.AccessTokenHours);
|
||||||
@@ -70,12 +59,6 @@ public class TokenService : ITokenService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates a refresh token for the given user, signed with the refresh token secret and
|
|
||||||
/// expiring after <see cref="TokenServiceExpirationHours.RefreshTokenHours" /> hours.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="user">The user account to generate the token for.</param>
|
|
||||||
/// <returns>The signed refresh token string.</returns>
|
|
||||||
public string GenerateRefreshToken(UserAccount user)
|
public string GenerateRefreshToken(UserAccount user)
|
||||||
{
|
{
|
||||||
DateTime expiresAt = DateTime.UtcNow.AddHours(
|
DateTime expiresAt = DateTime.UtcNow.AddHours(
|
||||||
@@ -89,12 +72,6 @@ public class TokenService : ITokenService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates a confirmation token for the given user, signed with the confirmation token secret and
|
|
||||||
/// expiring after <see cref="TokenServiceExpirationHours.ConfirmationTokenHours" /> hours.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="user">The user account to generate the token for.</param>
|
|
||||||
/// <returns>The signed confirmation token string.</returns>
|
|
||||||
public string GenerateConfirmationToken(UserAccount user)
|
public string GenerateConfirmationToken(UserAccount user)
|
||||||
{
|
{
|
||||||
DateTime expiresAt = DateTime.UtcNow.AddHours(
|
DateTime expiresAt = DateTime.UtcNow.AddHours(
|
||||||
@@ -111,11 +88,9 @@ public class TokenService : ITokenService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Generates a token of the kind specified by <typeparamref name="T" />, dispatching to
|
/// Generates a token of the kind specified by <typeparamref name="T" />, dispatching to
|
||||||
/// <see cref="GenerateAccessToken" />, <see cref="GenerateRefreshToken" />, or
|
/// <see cref="GenerateAccessToken" />, <see cref="GenerateRefreshToken" />, or
|
||||||
/// <see cref="GenerateConfirmationToken" /> based on the corresponding <see cref="TokenType" /> value.
|
/// <see cref="GenerateConfirmationToken" />.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <typeparam name="T">The enum type identifying which kind of token to generate. Must be <see cref="TokenType" />.</typeparam>
|
/// <typeparam name="T">Must be <see cref="TokenType" />.</typeparam>
|
||||||
/// <param name="user">The user account to generate the token for.</param>
|
|
||||||
/// <returns>The signed token string corresponding to the requested token type.</returns>
|
|
||||||
/// <exception cref="InvalidOperationException">
|
/// <exception cref="InvalidOperationException">
|
||||||
/// Thrown when <typeparamref name="T" /> is not <see cref="TokenType" /> or does not resolve to a known token type.
|
/// Thrown when <typeparamref name="T" /> is not <see cref="TokenType" /> or does not resolve to a known token type.
|
||||||
/// </exception>
|
/// </exception>
|
||||||
@@ -139,11 +114,6 @@ public class TokenService : ITokenService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Validates an access token against the access token secret.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="token">The access token string to validate.</param>
|
|
||||||
/// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
|
|
||||||
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||||
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
||||||
/// </exception>
|
/// </exception>
|
||||||
@@ -152,11 +122,6 @@ public class TokenService : ITokenService
|
|||||||
return await ValidateTokenInternalAsync(token, _accessTokenSecret, "access");
|
return await ValidateTokenInternalAsync(token, _accessTokenSecret, "access");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Validates a refresh token against the refresh token secret.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="token">The refresh token string to validate.</param>
|
|
||||||
/// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
|
|
||||||
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||||
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
||||||
/// </exception>
|
/// </exception>
|
||||||
@@ -165,11 +130,6 @@ public class TokenService : ITokenService
|
|||||||
return await ValidateTokenInternalAsync(token, _refreshTokenSecret, "refresh");
|
return await ValidateTokenInternalAsync(token, _refreshTokenSecret, "refresh");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Validates a confirmation token against the confirmation token secret.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="token">The confirmation token string to validate.</param>
|
|
||||||
/// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
|
|
||||||
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||||
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
||||||
/// </exception>
|
/// </exception>
|
||||||
@@ -178,12 +138,6 @@ public class TokenService : ITokenService
|
|||||||
return await ValidateTokenInternalAsync(token, _confirmationTokenSecret, "confirmation");
|
return await ValidateTokenInternalAsync(token, _confirmationTokenSecret, "confirmation");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Validates the given refresh token, looks up the associated user, and issues a fresh
|
|
||||||
/// access/refresh token pair.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="refreshTokenString">The refresh token string to validate and exchange.</param>
|
|
||||||
/// <returns>A <see cref="RefreshTokenResult" /> containing the user and newly issued tokens.</returns>
|
|
||||||
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||||
/// Thrown when the refresh token is invalid, or when the user account it refers to no longer exists.
|
/// Thrown when the refresh token is invalid, or when the user account it refers to no longer exists.
|
||||||
/// </exception>
|
/// </exception>
|
||||||
@@ -204,10 +158,7 @@ public class TokenService : ITokenService
|
|||||||
/// Performs the shared validation logic for access, refresh, and confirmation tokens:
|
/// Performs the shared validation logic for access, refresh, and confirmation tokens:
|
||||||
/// validates the JWT signature/expiration, then extracts and parses the user ID and username claims.
|
/// validates the JWT signature/expiration, then extracts and parses the user ID and username claims.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="token">The token string to validate.</param>
|
|
||||||
/// <param name="secret">The secret key to validate the token's signature against.</param>
|
|
||||||
/// <param name="tokenType">A human-readable label (e.g. "access", "refresh", "confirmation") used in error messages.</param>
|
/// <param name="tokenType">A human-readable label (e.g. "access", "refresh", "confirmation") used in error messages.</param>
|
||||||
/// <returns>A <see cref="ValidatedToken" /> describing the token's claims.</returns>
|
|
||||||
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||||
/// Thrown when required claims are missing, the user ID claim is not a valid <see cref="Guid" />,
|
/// Thrown when required claims are missing, the user ID claim is not a valid <see cref="Guid" />,
|
||||||
/// or the underlying token validation fails for any other reason (e.g. expired or invalid signature).
|
/// or the underlying token validation fails for any other reason (e.g. expired or invalid signature).
|
||||||
|
|||||||
@@ -3,9 +3,6 @@ using MediatR;
|
|||||||
|
|
||||||
namespace Features.Breweries.Commands.CreateBrewery;
|
namespace Features.Breweries.Commands.CreateBrewery;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Location data required to create a new brewery post, supplied as part of <see cref="CreateBreweryCommand" />.
|
|
||||||
/// </summary>
|
|
||||||
public record CreateBreweryLocation(
|
public record CreateBreweryLocation(
|
||||||
Guid CityId,
|
Guid CityId,
|
||||||
string AddressLine1,
|
string AddressLine1,
|
||||||
|
|||||||
@@ -5,19 +5,10 @@ using MediatR;
|
|||||||
|
|
||||||
namespace Features.Breweries.Commands.CreateBrewery;
|
namespace Features.Breweries.Commands.CreateBrewery;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Handles <see cref="CreateBreweryCommand" /> by persisting a new brewery post and its location.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="repository">Repository used to persist the new brewery post.</param>
|
|
||||||
public class CreateBreweryHandler(IBreweryRepository repository)
|
public class CreateBreweryHandler(IBreweryRepository repository)
|
||||||
: IRequestHandler<CreateBreweryCommand, BreweryDto>
|
: IRequestHandler<CreateBreweryCommand, BreweryDto>
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>Creates a new brewery post, generating new identifiers for the post and its location.</summary>
|
||||||
/// Creates a new brewery post, generating new identifiers for the post and its location.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">The details of the brewery post to create.</param>
|
|
||||||
/// <param name="cancellationToken">A token to observe for cancellation requests.</param>
|
|
||||||
/// <returns>The newly created brewery post.</returns>
|
|
||||||
public async Task<BreweryDto> Handle(
|
public async Task<BreweryDto> Handle(
|
||||||
CreateBreweryCommand request,
|
CreateBreweryCommand request,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
|
|||||||
@@ -2,17 +2,8 @@ using FluentValidation;
|
|||||||
|
|
||||||
namespace Features.Breweries.Commands.CreateBrewery;
|
namespace Features.Breweries.Commands.CreateBrewery;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Validates <see cref="CreateBreweryCommand" /> instances before they are processed.
|
|
||||||
/// </summary>
|
|
||||||
public class CreateBreweryValidator : AbstractValidator<CreateBreweryCommand>
|
public class CreateBreweryValidator : AbstractValidator<CreateBreweryCommand>
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// Configures validation rules requiring <see cref="CreateBreweryCommand.PostedById" />,
|
|
||||||
/// <see cref="CreateBreweryCommand.BreweryName" />, <see cref="CreateBreweryCommand.Description" />, and
|
|
||||||
/// <see cref="CreateBreweryCommand.Location" /> to be present, with length limits on the name, description,
|
|
||||||
/// address line 1, and postal code fields.
|
|
||||||
/// </summary>
|
|
||||||
public CreateBreweryValidator()
|
public CreateBreweryValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.PostedById).NotEmpty().WithMessage("PostedById is required.");
|
RuleFor(x => x.PostedById).NotEmpty().WithMessage("PostedById is required.");
|
||||||
|
|||||||
@@ -2,7 +2,4 @@ using MediatR;
|
|||||||
|
|
||||||
namespace Features.Breweries.Commands.DeleteBrewery;
|
namespace Features.Breweries.Commands.DeleteBrewery;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deletes a brewery post by its unique identifier.
|
|
||||||
/// </summary>
|
|
||||||
public record DeleteBreweryCommand(Guid BreweryPostId) : IRequest;
|
public record DeleteBreweryCommand(Guid BreweryPostId) : IRequest;
|
||||||
|
|||||||
@@ -3,10 +3,6 @@ using MediatR;
|
|||||||
|
|
||||||
namespace Features.Breweries.Commands.DeleteBrewery;
|
namespace Features.Breweries.Commands.DeleteBrewery;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Handles <see cref="DeleteBreweryCommand" /> by deleting the matching brewery post.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="repository">Repository used to delete the brewery post.</param>
|
|
||||||
public class DeleteBreweryHandler(IBreweryRepository repository)
|
public class DeleteBreweryHandler(IBreweryRepository repository)
|
||||||
: IRequestHandler<DeleteBreweryCommand>
|
: IRequestHandler<DeleteBreweryCommand>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,9 +3,6 @@ using MediatR;
|
|||||||
|
|
||||||
namespace Features.Breweries.Commands.UpdateBrewery;
|
namespace Features.Breweries.Commands.UpdateBrewery;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Location data for an existing brewery post, supplied as part of <see cref="UpdateBreweryCommand" />.
|
|
||||||
/// </summary>
|
|
||||||
public record UpdateBreweryLocation(
|
public record UpdateBreweryLocation(
|
||||||
Guid BreweryPostLocationId,
|
Guid BreweryPostLocationId,
|
||||||
Guid CityId,
|
Guid CityId,
|
||||||
|
|||||||
@@ -5,10 +5,6 @@ using MediatR;
|
|||||||
|
|
||||||
namespace Features.Breweries.Commands.UpdateBrewery;
|
namespace Features.Breweries.Commands.UpdateBrewery;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Handles <see cref="UpdateBreweryCommand" /> by persisting changes to an existing brewery post.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="repository">Repository used to persist the updated brewery post.</param>
|
|
||||||
public class UpdateBreweryHandler(IBreweryRepository repository)
|
public class UpdateBreweryHandler(IBreweryRepository repository)
|
||||||
: IRequestHandler<UpdateBreweryCommand, BreweryDto>
|
: IRequestHandler<UpdateBreweryCommand, BreweryDto>
|
||||||
{
|
{
|
||||||
@@ -16,9 +12,6 @@ public class UpdateBreweryHandler(IBreweryRepository repository)
|
|||||||
/// Updates an existing brewery post. If <paramref name="request" /> has no <c>Location</c>,
|
/// Updates an existing brewery post. If <paramref name="request" /> has no <c>Location</c>,
|
||||||
/// the brewery's location is cleared.
|
/// the brewery's location is cleared.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="request">The updated details of the brewery post.</param>
|
|
||||||
/// <param name="cancellationToken">A token to observe for cancellation requests.</param>
|
|
||||||
/// <returns>The updated brewery post.</returns>
|
|
||||||
public async Task<BreweryDto> Handle(
|
public async Task<BreweryDto> Handle(
|
||||||
UpdateBreweryCommand request,
|
UpdateBreweryCommand request,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
|
|||||||
@@ -11,28 +11,17 @@ using Shared.Contracts;
|
|||||||
|
|
||||||
namespace Features.Breweries.Controllers;
|
namespace Features.Breweries.Controllers;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Provides CRUD endpoints for managing brewery posts.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The controller is decorated with <c>[Authorize(AuthenticationSchemes = "JWT")]</c> by default; read endpoints
|
/// Requires JWT authentication by default; <see cref="GetById" /> and <see cref="GetAll" /> opt out via
|
||||||
/// (<see cref="GetById" /> and <see cref="GetAll" />) opt out via <c>[AllowAnonymous]</c>.
|
/// <c>[AllowAnonymous]</c>.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="mediator">Used to dispatch brewery commands and queries to their handlers.</param>
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
[Authorize(AuthenticationSchemes = "JWT")]
|
[Authorize(AuthenticationSchemes = "JWT")]
|
||||||
public class BreweryController(IMediator mediator) : ControllerBase
|
public class BreweryController(IMediator mediator) : ControllerBase
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// Retrieves a single brewery post by its unique identifier.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>Allows anonymous access.</remarks>
|
/// <remarks>Allows anonymous access.</remarks>
|
||||||
/// <param name="id">The unique identifier of the brewery post to retrieve.</param>
|
/// <returns><c>200 OK</c> with the brewery if found; otherwise <c>404 Not Found</c>.</returns>
|
||||||
/// <returns>
|
|
||||||
/// A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> of <see cref="BreweryDto" /> if found;
|
|
||||||
/// otherwise a <c>404 Not Found</c> result wrapping a <see cref="ResponseBody" /> error message.
|
|
||||||
/// </returns>
|
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
public async Task<ActionResult<ResponseBody<BreweryDto>>> GetById(Guid id)
|
public async Task<ActionResult<ResponseBody<BreweryDto>>> GetById(Guid id)
|
||||||
@@ -50,13 +39,7 @@ public class BreweryController(IMediator mediator) : ControllerBase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Retrieves a paginated list of brewery posts.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>Allows anonymous access.</remarks>
|
/// <remarks>Allows anonymous access.</remarks>
|
||||||
/// <param name="limit">The maximum number of brewery posts to return, or <c>null</c> for no limit.</param>
|
|
||||||
/// <param name="offset">The number of brewery posts to skip before returning results, or <c>null</c> for no offset.</param>
|
|
||||||
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> of a collection of <see cref="BreweryDto" />.</returns>
|
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<ActionResult<ResponseBody<IEnumerable<BreweryDto>>>> GetAll(
|
public async Task<ActionResult<ResponseBody<IEnumerable<BreweryDto>>>> GetAll(
|
||||||
@@ -76,14 +59,7 @@ public class BreweryController(IMediator mediator) : ControllerBase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <returns><c>201 Created</c> with the newly created brewery.</returns>
|
||||||
/// Creates a new brewery post.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="command">The brewery details to create, including the posting user, name, description, and location.</param>
|
|
||||||
/// <returns>
|
|
||||||
/// A <c>201 Created</c> result wrapping a <see cref="ResponseBody{T}" /> of the newly created
|
|
||||||
/// <see cref="BreweryDto" />.
|
|
||||||
/// </returns>
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<ActionResult<ResponseBody<BreweryDto>>> Create(
|
public async Task<ActionResult<ResponseBody<BreweryDto>>> Create(
|
||||||
[FromBody] CreateBreweryCommand command
|
[FromBody] CreateBreweryCommand command
|
||||||
@@ -100,22 +76,8 @@ public class BreweryController(IMediator mediator) : ControllerBase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <param name="id">Must match <paramref name="command" />'s <c>BreweryPostId</c>.</param>
|
||||||
/// Updates an existing brewery post.
|
/// <returns><c>200 OK</c> with the updated brewery; or <c>400 Bad Request</c> if the route ID does not match the payload ID.</returns>
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">
|
|
||||||
/// The unique identifier of the brewery post from the route, which must match <paramref name="command" />
|
|
||||||
/// 's ID.
|
|
||||||
/// </param>
|
|
||||||
/// <param name="command">
|
|
||||||
/// The updated brewery details, including the posting user, name, description, and optional
|
|
||||||
/// location.
|
|
||||||
/// </param>
|
|
||||||
/// <returns>
|
|
||||||
/// A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> of the updated <see cref="BreweryDto" /> if the
|
|
||||||
/// update succeeds; otherwise a <c>400 Bad Request</c> result wrapping a <see cref="ResponseBody" /> error message
|
|
||||||
/// when the route ID does not match the payload ID.
|
|
||||||
/// </returns>
|
|
||||||
[HttpPut("{id:guid}")]
|
[HttpPut("{id:guid}")]
|
||||||
public async Task<ActionResult<ResponseBody<BreweryDto>>> Update(
|
public async Task<ActionResult<ResponseBody<BreweryDto>>> Update(
|
||||||
Guid id,
|
Guid id,
|
||||||
@@ -135,11 +97,7 @@ public class BreweryController(IMediator mediator) : ControllerBase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <returns><c>200 OK</c> confirming the deletion.</returns>
|
||||||
/// Deletes a brewery post.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The unique identifier of the brewery post to delete.</param>
|
|
||||||
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody" /> confirming the deletion.</returns>
|
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
public async Task<ActionResult<ResponseBody>> Delete(Guid id)
|
public async Task<ActionResult<ResponseBody>> Delete(Guid id)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,89 +1,39 @@
|
|||||||
namespace Features.Breweries.Dtos;
|
namespace Features.Breweries.Dtos;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Represents the location details of an existing brewery, as returned in <see cref="BreweryDto" />.
|
|
||||||
/// </summary>
|
|
||||||
public class BreweryLocationDto
|
public class BreweryLocationDto
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// The unique identifier of the brewery's location record.
|
|
||||||
/// </summary>
|
|
||||||
public Guid BreweryPostLocationId { get; set; }
|
public Guid BreweryPostLocationId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The unique identifier of the brewery post that this location belongs to.
|
|
||||||
/// </summary>
|
|
||||||
public Guid BreweryPostId { get; set; }
|
public Guid BreweryPostId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The unique identifier of the city in which the brewery is located.
|
|
||||||
/// </summary>
|
|
||||||
public Guid CityId { get; set; }
|
public Guid CityId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The primary street address line of the brewery.
|
|
||||||
/// </summary>
|
|
||||||
public string AddressLine1 { get; set; } = string.Empty;
|
public string AddressLine1 { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// An optional secondary address line (e.g. suite or unit number).
|
|
||||||
/// </summary>
|
|
||||||
public string? AddressLine2 { get; set; }
|
public string? AddressLine2 { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The postal/ZIP code of the brewery's address.
|
|
||||||
/// </summary>
|
|
||||||
public string PostalCode { get; set; } = string.Empty;
|
public string PostalCode { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Gets or sets the brewery's geographic coordinates, in a raw binary representation.</summary>
|
||||||
/// The optional geographic coordinates of the brewery, in a raw binary representation.
|
|
||||||
/// </summary>
|
|
||||||
public byte[]? Coordinates { get; set; }
|
public byte[]? Coordinates { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Represents a brewery post as returned by the brewery endpoints, including its metadata and
|
|
||||||
/// optional location.
|
|
||||||
/// </summary>
|
|
||||||
public class BreweryDto
|
public class BreweryDto
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// The unique identifier of the brewery post.
|
|
||||||
/// </summary>
|
|
||||||
public Guid BreweryPostId { get; set; }
|
public Guid BreweryPostId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The unique identifier of the user account that created the brewery post.
|
|
||||||
/// </summary>
|
|
||||||
public Guid PostedById { get; set; }
|
public Guid PostedById { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The name of the brewery.
|
|
||||||
/// </summary>
|
|
||||||
public string BreweryName { get; set; } = string.Empty;
|
public string BreweryName { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A description of the brewery.
|
|
||||||
/// </summary>
|
|
||||||
public string Description { get; set; } = string.Empty;
|
public string Description { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The date and time at which the brewery post was created.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The date and time at which the brewery post was last updated, or <c>null</c> if it has never been updated.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? UpdatedAt { get; set; }
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Gets or sets the row-version/concurrency token used to detect conflicting concurrent updates.</summary>
|
||||||
/// A row-version/concurrency token used to detect conflicting concurrent updates to the brewery post.
|
|
||||||
/// </summary>
|
|
||||||
public byte[]? Timer { get; set; }
|
public byte[]? Timer { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The location details of the brewery, or <c>null</c> if no location is associated with it.
|
|
||||||
/// </summary>
|
|
||||||
public BreweryLocationDto? Location { get; set; }
|
public BreweryLocationDto? Location { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,8 @@ using Domain.Entities;
|
|||||||
|
|
||||||
namespace Features.Breweries.Dtos;
|
namespace Features.Breweries.Dtos;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Maps <see cref="BreweryPost" /> domain entities to their <see cref="BreweryDto" /> wire representation.
|
|
||||||
/// </summary>
|
|
||||||
public static class BreweryDtoMapper
|
public static class BreweryDtoMapper
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// Maps a <see cref="BreweryPost" /> domain entity to its <see cref="BreweryDto" /> representation,
|
|
||||||
/// including its location, if present.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="brewery">The brewery post entity to map.</param>
|
|
||||||
/// <returns>The mapped <see cref="BreweryDto" />.</returns>
|
|
||||||
public static BreweryDto ToDto(this BreweryPost brewery)
|
public static BreweryDto ToDto(this BreweryPost brewery)
|
||||||
{
|
{
|
||||||
return new BreweryDto
|
return new BreweryDto
|
||||||
|
|||||||
@@ -5,10 +5,6 @@ using MediatR;
|
|||||||
|
|
||||||
namespace Features.Breweries.Queries.GetAllBreweries;
|
namespace Features.Breweries.Queries.GetAllBreweries;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Handles <see cref="GetAllBreweriesQuery" /> by retrieving a paginated list of brewery posts.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="repository">Repository used to query brewery post data.</param>
|
|
||||||
public class GetAllBreweriesHandler(IBreweryRepository repository)
|
public class GetAllBreweriesHandler(IBreweryRepository repository)
|
||||||
: IRequestHandler<GetAllBreweriesQuery, IEnumerable<BreweryDto>>
|
: IRequestHandler<GetAllBreweriesQuery, IEnumerable<BreweryDto>>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,10 +5,6 @@ using MediatR;
|
|||||||
|
|
||||||
namespace Features.Breweries.Queries.GetBreweryById;
|
namespace Features.Breweries.Queries.GetBreweryById;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Handles <see cref="GetBreweryByIdQuery" /> by looking up the matching brewery post.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="repository">Repository used to query brewery post data.</param>
|
|
||||||
public class GetBreweryByIdHandler(IBreweryRepository repository)
|
public class GetBreweryByIdHandler(IBreweryRepository repository)
|
||||||
: IRequestHandler<GetBreweryByIdQuery, BreweryDto?>
|
: IRequestHandler<GetBreweryByIdQuery, BreweryDto?>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,7 +3,4 @@ using MediatR;
|
|||||||
|
|
||||||
namespace Features.Breweries.Queries.GetBreweryById;
|
namespace Features.Breweries.Queries.GetBreweryById;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Retrieves a single brewery post by its unique identifier.
|
|
||||||
/// </summary>
|
|
||||||
public record GetBreweryByIdQuery(Guid BreweryPostId) : IRequest<BreweryDto?>;
|
public record GetBreweryByIdQuery(Guid BreweryPostId) : IRequest<BreweryDto?>;
|
||||||
|
|||||||
@@ -9,17 +9,11 @@ namespace Features.Breweries.Repository;
|
|||||||
/// ADO.NET-based implementation of <see cref="IBreweryRepository" /> backed by SQL Server stored
|
/// ADO.NET-based implementation of <see cref="IBreweryRepository" /> backed by SQL Server stored
|
||||||
/// procedures.
|
/// procedures.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="connectionFactory">The factory used to create database connections.</param>
|
|
||||||
public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||||
: Repository<BreweryPost>(connectionFactory),
|
: Repository<BreweryPost>(connectionFactory),
|
||||||
IBreweryRepository
|
IBreweryRepository
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>Retrieves a brewery post by ID using the <c>USP_GetBreweryById</c> stored procedure.</summary>
|
||||||
/// Retrieves a brewery post by ID using the <c>USP_GetBreweryById</c> stored procedure.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The unique identifier of the brewery post.</param>
|
|
||||||
/// <returns>The matching <see cref="BreweryPost" />, or <c>null</c> if not found.</returns>
|
|
||||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
|
||||||
public async Task<BreweryPost?> GetByIdAsync(Guid id)
|
public async Task<BreweryPost?> GetByIdAsync(Guid id)
|
||||||
{
|
{
|
||||||
await using DbConnection connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
@@ -40,10 +34,6 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
/// stored procedure. The <c>@Limit</c> and <c>@Offset</c> parameters are only added when their
|
/// stored procedure. The <c>@Limit</c> and <c>@Offset</c> parameters are only added when their
|
||||||
/// corresponding argument has a value.
|
/// corresponding argument has a value.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="limit">The maximum number of records to return, or <c>null</c> for no limit.</param>
|
|
||||||
/// <param name="offset">The number of records to skip, or <c>null</c> for no offset.</param>
|
|
||||||
/// <returns>The collection of matching <see cref="BreweryPost" /> records.</returns>
|
|
||||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
|
||||||
public async Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset)
|
public async Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset)
|
||||||
{
|
{
|
||||||
await using DbConnection connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
@@ -71,8 +61,6 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
/// <c>USP_UpdateBrewery</c> stored procedure. When <paramref name="brewery" />.<c>Location</c> is
|
/// <c>USP_UpdateBrewery</c> stored procedure. When <paramref name="brewery" />.<c>Location</c> is
|
||||||
/// <c>null</c>, any existing location for the brewery is removed.
|
/// <c>null</c>, any existing location for the brewery is removed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="brewery">The brewery post containing updated values.</param>
|
|
||||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
|
||||||
public async Task UpdateAsync(BreweryPost brewery)
|
public async Task UpdateAsync(BreweryPost brewery)
|
||||||
{
|
{
|
||||||
await using DbConnection connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
@@ -97,8 +85,6 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
/// Deletes a brewery post by ID using the <c>USP_DeleteBrewery</c> stored procedure. Its location
|
/// Deletes a brewery post by ID using the <c>USP_DeleteBrewery</c> stored procedure. Its location
|
||||||
/// and photos are removed via cascading foreign keys.
|
/// and photos are removed via cascading foreign keys.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="id">The unique identifier of the brewery post to delete.</param>
|
|
||||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
|
||||||
public async Task DeleteAsync(Guid id)
|
public async Task DeleteAsync(Guid id)
|
||||||
{
|
{
|
||||||
await using DbConnection connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
@@ -110,12 +96,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
await command.ExecuteNonQueryAsync();
|
await command.ExecuteNonQueryAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Creates a new brewery post and its location using the <c>USP_CreateBrewery</c> stored procedure.</summary>
|
||||||
/// Creates a new brewery post and its location using the <c>USP_CreateBrewery</c> stored procedure.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="brewery">The brewery post to create. Must have a non-null <c>Location</c>.</param>
|
|
||||||
/// <exception cref="ArgumentException">Thrown when <paramref name="brewery" />.<c>Location</c> is <c>null</c>.</exception>
|
/// <exception cref="ArgumentException">Thrown when <paramref name="brewery" />.<c>Location</c> is <c>null</c>.</exception>
|
||||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
|
||||||
public async Task CreateAsync(BreweryPost brewery)
|
public async Task CreateAsync(BreweryPost brewery)
|
||||||
{
|
{
|
||||||
await using DbConnection connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
@@ -139,12 +121,9 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Maps the current row of a data reader to a <see cref="BreweryPost" /> entity, including its
|
/// Maps the current row of <paramref name="reader" /> to a <see cref="BreweryPost" />, including its
|
||||||
/// rowversion <c>Timer</c> field and, if location columns are present in the result set, its
|
/// associated <see cref="BreweryPostLocation" /> if location columns are present in the result set.
|
||||||
/// associated <see cref="BreweryPostLocation" />.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="reader">The data reader positioned on the row to map.</param>
|
|
||||||
/// <returns>The mapped <see cref="BreweryPost" /> instance.</returns>
|
|
||||||
protected override BreweryPost MapToEntity(DbDataReader reader)
|
protected override BreweryPost MapToEntity(DbDataReader reader)
|
||||||
{
|
{
|
||||||
BreweryPost brewery = new();
|
BreweryPost brewery = new();
|
||||||
@@ -212,13 +191,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
return brewery;
|
return brewery;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Adds a parameter to <paramref name="command" />, converting <c>null</c> to <see cref="DBNull.Value" />.</summary>
|
||||||
/// Helper method to add a parameter to a database command, converting <c>null</c> values to
|
|
||||||
/// <see cref="DBNull.Value" />.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="command">The command to add the parameter to.</param>
|
|
||||||
/// <param name="name">The parameter name (including any prefix, e.g. "@BreweryName").</param>
|
|
||||||
/// <param name="value">The parameter value, or <c>null</c> to bind <see cref="DBNull.Value" />.</param>
|
|
||||||
private static void AddParameter(DbCommand command, string name, object? value)
|
private static void AddParameter(DbCommand command, string name, object? value)
|
||||||
{
|
{
|
||||||
DbParameter p = command.CreateParameter();
|
DbParameter p = command.CreateParameter();
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user