mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 01:54:43 +00:00
Replace stored-procedure repositories with Dapper-based repositories
Removes the USP_* stored procedures for Auth, Location, and Brewery data access in favour of services built with Dapper. Adds a new Features.Locations slice and optimistic concurrency support for the Brewery slice.
This commit is contained in:
@@ -13,8 +13,8 @@ active website:
|
||||
- **Architecture Style**: Vertical-slice backend plus server-rendered React
|
||||
frontend
|
||||
|
||||
The legacy Next.js frontend has been retained in `archive/next-js-web-app/`
|
||||
for reference only.
|
||||
The legacy Next.js frontend has been retained in `archive/next-js-web-app/` for
|
||||
reference only.
|
||||
|
||||
## Diagrams
|
||||
|
||||
@@ -35,9 +35,9 @@ For visual representations, see:
|
||||
|
||||
The backend organizes business capabilities as feature slices instead of
|
||||
technical layers. Each feature (`Features.Auth`, `Features.Breweries`,
|
||||
`Features.UserManagement`, `Features.Emails`) is a single project that owns
|
||||
its own controller, MediatR commands/queries/handlers, validators, and
|
||||
repository, end to end:
|
||||
`Features.UserManagement`, `Features.Emails`) is a single project that owns its
|
||||
own controller, MediatR commands/queries/handlers, validators, and repository,
|
||||
end to end:
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────────────────┐
|
||||
@@ -74,18 +74,18 @@ repository, end to end:
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Slices never reference each other's project. The one cross-slice
|
||||
interaction (`Features.Auth` triggering a confirmation email handled by
|
||||
`Features.Emails`) goes through a MediatR command (`SendRegistrationEmailCommand`)
|
||||
whose contract lives in `Shared.Application`, so neither slice takes a
|
||||
project reference on the other.
|
||||
Slices never reference each other's project. The one cross-slice interaction
|
||||
(`Features.Auth` triggering a confirmation email handled by `Features.Emails`)
|
||||
goes through a MediatR command (`SendRegistrationEmailCommand`) whose contract
|
||||
lives in `Shared.Application`, so neither slice takes a project reference on the
|
||||
other.
|
||||
|
||||
### Layer responsibilities
|
||||
|
||||
#### API layer (`API.Core`)
|
||||
|
||||
**Purpose**: Thin ASP.NET Core host: no business logic, no controllers of
|
||||
its own
|
||||
**Purpose**: Thin ASP.NET Core host: no business logic, no controllers of its
|
||||
own
|
||||
|
||||
**Components**:
|
||||
|
||||
@@ -114,26 +114,25 @@ its own
|
||||
|
||||
**Components** (per slice):
|
||||
|
||||
- `Controllers/`: HTTP endpoints, binding directly to Command/Query types
|
||||
as the request contract
|
||||
- `Commands/<Operation>/` and `Queries/<Operation>/`: one folder per
|
||||
operation, each containing the Command/Query record, its
|
||||
`IRequestHandler`, and (for commands) a FluentValidation validator
|
||||
- `Repository/`: the slice's own stored-procedure repository
|
||||
implementation
|
||||
- `Dtos/`: response shapes returned by query handlers (never the raw
|
||||
domain entity)
|
||||
- `DependencyInjection/`: an `AddFeaturesX()` extension method registering
|
||||
the slice's repository/services
|
||||
- `Controllers/`: HTTP endpoints, binding directly to Command/Query types as the
|
||||
request contract
|
||||
- `Commands/<Operation>/` and `Queries/<Operation>/`: one folder per operation,
|
||||
each containing the Command/Query record, its `IRequestHandler`, and (for
|
||||
commands) a FluentValidation validator
|
||||
- `Repository/`: the slice's own stored-procedure repository implementation
|
||||
- `Dtos/`: response shapes returned by query handlers (never the raw domain
|
||||
entity)
|
||||
- `DependencyInjection/`: an `AddFeaturesX()` extension method registering the
|
||||
slice's repository/services
|
||||
|
||||
`Features.Emails` has no `Controllers/` folder. It's invoked only via
|
||||
MediatR commands sent from other slices, never over HTTP.
|
||||
`Features.Emails` has no `Controllers/` folder. It's invoked only via MediatR
|
||||
commands sent from other slices, never over HTTP.
|
||||
|
||||
**Dependencies**:
|
||||
|
||||
- `Domain.Entities`, `Domain.Exceptions`
|
||||
- `Infrastructure.Sql` (generic ADO.NET plumbing) plus whichever
|
||||
infrastructure project the slice needs (`Infrastructure.Jwt`/
|
||||
- `Infrastructure.Sql` (generic ADO.NET plumbing) plus whichever infrastructure
|
||||
project the slice needs (`Infrastructure.Jwt`/
|
||||
`Infrastructure.PasswordHashing` for Auth, `Infrastructure.Email`/
|
||||
`Infrastructure.Email.Templates` for Emails)
|
||||
- `Shared.Contracts`, `Shared.Application`
|
||||
@@ -142,29 +141,27 @@ MediatR commands sent from other slices, never over HTTP.
|
||||
**Rules**:
|
||||
|
||||
- All business logic for that feature lives in its command/query handlers
|
||||
- No direct controller-to-repository calls; everything flows through
|
||||
MediatR
|
||||
- No direct controller-to-repository calls; everything flows through MediatR
|
||||
- Read endpoints return a dedicated `Dto`, never the domain entity directly
|
||||
|
||||
#### Shared projects (`Shared.Contracts`, `Shared.Application`)
|
||||
|
||||
**Purpose**: The minimum cross-slice surface area required because every
|
||||
slice needs it, or because duplicating it four times would be worse than
|
||||
sharing it
|
||||
**Purpose**: The minimum cross-slice surface area required because every slice
|
||||
needs it, or because duplicating it four times would be worse than sharing it
|
||||
|
||||
**Components**:
|
||||
|
||||
- `Shared.Contracts`: `ResponseBody<T>`/`ResponseBody`, the API response
|
||||
envelope every controller returns
|
||||
- `Shared.Application`: `ValidationBehavior<TRequest,TResponse>` (the
|
||||
MediatR pipeline behavior that runs FluentValidation before a handler
|
||||
executes) and the cross-slice email commands
|
||||
(`SendRegistrationEmailCommand`, `SendResendConfirmationEmailCommand`)
|
||||
- `Shared.Application`: `ValidationBehavior<TRequest,TResponse>` (the MediatR
|
||||
pipeline behavior that runs FluentValidation before a handler executes) and
|
||||
the cross-slice email commands (`SendRegistrationEmailCommand`,
|
||||
`SendResendConfirmationEmailCommand`)
|
||||
|
||||
**Rules**:
|
||||
|
||||
- Kept deliberately small: this is the exception to "no slice depends on
|
||||
another slice," not a general-purpose dumping ground
|
||||
- Kept deliberately small: this is the exception to "no slice depends on another
|
||||
slice," not a general-purpose dumping ground
|
||||
|
||||
#### Infrastructure layer
|
||||
|
||||
@@ -218,16 +215,15 @@ whichever slices need them
|
||||
#### Vertical slice + MediatR
|
||||
|
||||
**Purpose**: Organize code by feature instead of by technical layer, so
|
||||
everything needed to understand or change one capability lives in one
|
||||
project
|
||||
everything needed to understand or change one capability lives in one project
|
||||
|
||||
**Implementation**:
|
||||
|
||||
- Each HTTP write operation is a `Command` (for example, `CreateBreweryCommand`
|
||||
in `Features.Breweries/Commands/CreateBrewery/`); each read operation is a
|
||||
`Query` (for example, `GetBreweryByIdQuery`)
|
||||
- Controllers bind directly to the Command/Query as the request body;
|
||||
there is no separate request DTO + mapping step for writes
|
||||
- Controllers bind directly to the Command/Query as the request body; there is
|
||||
no separate request DTO + mapping step for writes
|
||||
- A single shared `ValidationBehavior<TRequest,TResponse>`
|
||||
(`Shared.Application/Behaviors/`) runs FluentValidation validators in the
|
||||
MediatR pipeline before any handler executes
|
||||
@@ -250,14 +246,14 @@ public class CreateBreweryHandler(IBreweryRepository repository) : IRequestHandl
|
||||
|
||||
**Purpose**: Abstract database access behind interfaces
|
||||
|
||||
**Implementation**: each slice owns its own repository, scoped to that
|
||||
feature only:
|
||||
**Implementation**: each slice owns its own repository, scoped to that feature
|
||||
only:
|
||||
|
||||
- `Features.Auth/Repository/IAuthRepository.cs`
|
||||
- `Features.Breweries/Repository/IBreweryRepository.cs`
|
||||
- `Features.UserManagement/Repository/IUserAccountRepository.cs`
|
||||
- `Infrastructure.Sql/DefaultSqlConnectionFactory.cs`: the generic
|
||||
connection factory every slice's repository builds on
|
||||
- `Infrastructure.Sql/DefaultSqlConnectionFactory.cs`: the generic connection
|
||||
factory every slice's repository builds on
|
||||
|
||||
**Benefits**:
|
||||
|
||||
@@ -279,9 +275,9 @@ public interface IAuthRepository
|
||||
|
||||
**Purpose**: Loose coupling and testability
|
||||
|
||||
**Configuration**: `Program.cs` wires up MediatR/FluentValidation across
|
||||
every `Features.*` assembly; each slice exposes its own `AddFeaturesX()`
|
||||
extension method that registers its repository and slice-internal services
|
||||
**Configuration**: `Program.cs` wires up MediatR/FluentValidation across every
|
||||
`Features.*` assembly; each slice exposes its own `AddFeaturesX()` extension
|
||||
method that registers its repository and slice-internal services
|
||||
|
||||
**Lifetimes**:
|
||||
|
||||
@@ -351,8 +347,8 @@ All component styling should prefer semantic tokens such as `primary`,
|
||||
### Legacy frontend
|
||||
|
||||
The previous Next.js frontend has been archived at `archive/next-js-web-app/`
|
||||
for reference only. Active product and engineering documentation should point
|
||||
to `web/frontend`.
|
||||
for reference only. Active product and engineering documentation should point to
|
||||
`web/frontend`.
|
||||
|
||||
## Security architecture
|
||||
|
||||
@@ -411,6 +407,10 @@ to `web/frontend`.
|
||||
|
||||
## Database architecture
|
||||
|
||||
For the table-by-table schema, the stored procedure reference (parameters,
|
||||
behavior, and exactly what each one throws), and the custom SQL error code
|
||||
scheme, see [Database](website/database.md).
|
||||
|
||||
### SQL-first philosophy
|
||||
|
||||
**Principles**:
|
||||
|
||||
@@ -136,8 +136,8 @@ consumers are informed of the AI-generated origin before engagement.
|
||||
|
||||
When forced by the system prompt to generate a "highly specialized technical
|
||||
brewing detail," the model frequently hallucinates fermentation science and
|
||||
brewing chemistry. While the resulting text sounds confident, it is
|
||||
nonsensical to a reader with brewing or scientific expertise.
|
||||
brewing chemistry. While the resulting text sounds confident, it is nonsensical
|
||||
to a reader with brewing or scientific expertise.
|
||||
|
||||
Small-parameter models such as Gemma 4 E4B cannot reliably satisfy prompts
|
||||
calling for specialist brewing detail. This is consistent with the **CHOKE**
|
||||
@@ -262,8 +262,8 @@ idiomatic phrasing.
|
||||
]
|
||||
```
|
||||
|
||||
When fed into the pipeline, this dataset often causes the model to reason that
|
||||
a local variant of French is needed, but it often defaults to a standardized
|
||||
When fed into the pipeline, this dataset often causes the model to reason that a
|
||||
local variant of French is needed, but it often defaults to a standardized
|
||||
dialect of French, without cultural or linguistic nuance.
|
||||
|
||||
For languages such as Welsh (Wales), Māori (Aotearoa/New Zealand), or Sicilian
|
||||
@@ -312,10 +312,10 @@ not a source of truth.
|
||||
Simhi, Itay Itzhak, Fazl Barez, Gabriel Stanovsky, Yonatan Belinkov.
|
||||
|
||||
[^llm-bias]:
|
||||
For example, Blasi et al. (2022), "Systematic Inequalities in Language Technology
|
||||
Performance across the World's Languages," _ACL Anthology_. The pattern is
|
||||
consistent with models trained predominantly on English-language web
|
||||
corpora.
|
||||
For example, Blasi et al. (2022), "Systematic Inequalities in Language
|
||||
Technology Performance across the World's Languages," _ACL Anthology_. The
|
||||
pattern is consistent with models trained predominantly on English-language
|
||||
web corpora.
|
||||
|
||||
[^wp-license]:
|
||||
Source:
|
||||
|
||||
@@ -101,21 +101,21 @@ Each run writes a fresh dated SQLite file such as
|
||||
|
||||
#### CLI flags
|
||||
|
||||
| Flag | Purpose |
|
||||
| ------------------ | ---------------------------------------------------------------------------------------------------- |
|
||||
| `--mocked` | Deterministic mock generator, no model required. |
|
||||
| `--model, -m` | Path to a GGUF file. Required unless `--mocked` is set. |
|
||||
| Flag | Purpose |
|
||||
| ------------------ | ------------------------------------------------------------------------------------------------------------ |
|
||||
| `--mocked` | Deterministic mock generator, no model required. |
|
||||
| `--model, -m` | Path to a GGUF file. Required unless `--mocked` is set. |
|
||||
| `--prompt-dir` | Directory containing prompt files (for example, `BREWERY_GENERATION.md`). Required unless `--mocked` is set. |
|
||||
| `--output, -o` | Directory for generated SQLite artifacts. Default: `output`. |
|
||||
| `--log-path` | Path for application logs. Default: `pipeline.log`. |
|
||||
| `--location-count` | Number of cities to sample from `locations.json` per run. Default: `10`. |
|
||||
| `--temperature` | Sampling temperature. Default: `1.0`. |
|
||||
| `--top-p` | Nucleus sampling. Default: `0.95`. |
|
||||
| `--top-k` | Top-k sampling. Default: `64`. |
|
||||
| `--n-ctx` | Context window size. Default: `8192`. |
|
||||
| `--seed` | Random seed. Default: `-1` (random at runtime). |
|
||||
| `--n-gpu-layers` | Number of model layers to offload to GPU. Default: `0`. |
|
||||
| `--help, -h` | Print usage and exit. |
|
||||
| `--output, -o` | Directory for generated SQLite artifacts. Default: `output`. |
|
||||
| `--log-path` | Path for application logs. Default: `pipeline.log`. |
|
||||
| `--location-count` | Number of cities to sample from `locations.json` per run. Default: `10`. |
|
||||
| `--temperature` | Sampling temperature. Default: `1.0`. |
|
||||
| `--top-p` | Nucleus sampling. Default: `0.95`. |
|
||||
| `--top-k` | Top-k sampling. Default: `64`. |
|
||||
| `--n-ctx` | Context window size. Default: `8192`. |
|
||||
| `--seed` | Random seed. Default: `-1` (random at runtime). |
|
||||
| `--n-gpu-layers` | Number of model layers to offload to GPU. Default: `0`. |
|
||||
| `--help, -h` | Print usage and exit. |
|
||||
|
||||
`--mocked` and `--model` are mutually exclusive. Omitting both exits with an
|
||||
error before the pipeline starts. Sampling flags are ignored when `--mocked` is
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 63 KiB |
@@ -3,102 +3,207 @@
|
||||
skinparam backgroundColor #FFFFFF
|
||||
skinparam defaultFontName Arial
|
||||
skinparam linetype ortho
|
||||
skinparam packageStyle rectangle
|
||||
|
||||
title Key Database Schema - User & Authentication
|
||||
title Database Schema
|
||||
caption Every table also has a Timer ROWVERSION column (optimistic concurrency), omitted below for brevity.\nTables marked (*) have no CRUD stored procedures yet.
|
||||
|
||||
entity "UserAccount" as User {
|
||||
* UserAccountId: INT <<PK>>
|
||||
--
|
||||
* Username: NVARCHAR(30) <<UNIQUE>>
|
||||
* Email: NVARCHAR(255) <<UNIQUE>>
|
||||
* FirstName: NVARCHAR(50)
|
||||
* LastName: NVARCHAR(50)
|
||||
Bio: NVARCHAR(500)
|
||||
CreatedAt: DATETIME2
|
||||
UpdatedAt: DATETIME2
|
||||
LastLoginAt: DATETIME2
|
||||
package "User & Auth" {
|
||||
entity "UserAccount" as User {
|
||||
* UserAccountID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* Username: VARCHAR(64) <<UNIQUE>>
|
||||
* FirstName: NVARCHAR(128)
|
||||
* LastName: NVARCHAR(128)
|
||||
* Email: VARCHAR(128) <<UNIQUE>>
|
||||
* CreatedAt: DATETIME
|
||||
UpdatedAt: DATETIME
|
||||
* DateOfBirth: DATE
|
||||
}
|
||||
|
||||
entity "UserCredential" as Cred {
|
||||
* UserCredentialID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* UserAccountID: UNIQUEIDENTIFIER <<FK>>
|
||||
* CreatedAt: DATETIME
|
||||
* Expiry: DATETIME
|
||||
* Hash: NVARCHAR(256)
|
||||
* IsRevoked: BIT
|
||||
RevokedAt: DATETIME
|
||||
}
|
||||
|
||||
entity "UserVerification" as Verify {
|
||||
* UserVerificationID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* UserAccountID: UNIQUEIDENTIFIER <<FK, UNIQUE>>
|
||||
* VerificationDateTime: DATETIME
|
||||
}
|
||||
|
||||
entity "UserAvatar (*)" as Avatar {
|
||||
* UserAvatarID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* UserAccountID: UNIQUEIDENTIFIER <<FK, UNIQUE>>
|
||||
* PhotoID: UNIQUEIDENTIFIER <<FK>>
|
||||
}
|
||||
|
||||
entity "UserFollow (*)" as Follow {
|
||||
* UserFollowID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* UserAccountID: UNIQUEIDENTIFIER <<FK>>
|
||||
* FollowingID: UNIQUEIDENTIFIER <<FK>>
|
||||
* CreatedAt: DATETIME
|
||||
}
|
||||
|
||||
entity "Photo (*)" as Photo {
|
||||
* PhotoID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
Hyperlink: NVARCHAR(256)
|
||||
* UploadedByID: UNIQUEIDENTIFIER <<FK>>
|
||||
* UploadedAt: DATETIME
|
||||
}
|
||||
}
|
||||
|
||||
entity "UserCredential" as Cred {
|
||||
* UserCredentialId: INT <<PK>>
|
||||
--
|
||||
* UserAccountId: INT <<FK>>
|
||||
* PasswordHash: VARBINARY(32)
|
||||
* PasswordSalt: VARBINARY(16)
|
||||
CredentialRotatedAt: DATETIME2
|
||||
CredentialExpiresAt: DATETIME2
|
||||
CredentialRevokedAt: DATETIME2
|
||||
* IsActive: BIT
|
||||
CreatedAt: DATETIME2
|
||||
package "Location" {
|
||||
entity "Country" as Country {
|
||||
* CountryID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* CountryName: NVARCHAR(100)
|
||||
* ISO3166_1: CHAR(2) <<UNIQUE>>
|
||||
}
|
||||
|
||||
entity "StateProvince" as StateProvince {
|
||||
* StateProvinceID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* StateProvinceName: NVARCHAR(100)
|
||||
* ISO3166_2: CHAR(6) <<UNIQUE>>
|
||||
* CountryID: UNIQUEIDENTIFIER <<FK>>
|
||||
}
|
||||
|
||||
entity "City" as City {
|
||||
* CityID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* CityName: NVARCHAR(100)
|
||||
* StateProvinceID: UNIQUEIDENTIFIER <<FK>>
|
||||
}
|
||||
}
|
||||
|
||||
entity "UserVerification" as Verify {
|
||||
* UserVerificationId: INT <<PK>>
|
||||
--
|
||||
* UserAccountId: INT <<FK>>
|
||||
* IsVerified: BIT
|
||||
VerifiedAt: DATETIME2
|
||||
VerificationToken: NVARCHAR(255)
|
||||
TokenExpiresAt: DATETIME2
|
||||
package "Brewery" {
|
||||
entity "BreweryPost" as Brewery {
|
||||
* BreweryPostID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* BreweryName: NVARCHAR(256)
|
||||
* PostedByID: UNIQUEIDENTIFIER <<FK>>
|
||||
* Description: NVARCHAR(512)
|
||||
* CreatedAt: DATETIME
|
||||
UpdatedAt: DATETIME
|
||||
}
|
||||
|
||||
entity "BreweryPostLocation" as BreweryLoc {
|
||||
* BreweryPostLocationID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* BreweryPostID: UNIQUEIDENTIFIER <<FK, UNIQUE>>
|
||||
* AddressLine1: NVARCHAR(256)
|
||||
AddressLine2: NVARCHAR(256)
|
||||
* PostalCode: NVARCHAR(20)
|
||||
* CityID: UNIQUEIDENTIFIER <<FK>>
|
||||
Coordinates: GEOGRAPHY
|
||||
}
|
||||
|
||||
entity "BreweryPostPhoto (*)" as BreweryPhoto {
|
||||
* BreweryPostPhotoID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* BreweryPostID: UNIQUEIDENTIFIER <<FK>>
|
||||
* PhotoID: UNIQUEIDENTIFIER <<FK>>
|
||||
* LinkedAt: DATETIME
|
||||
}
|
||||
}
|
||||
|
||||
entity "UserAvatar" as Avatar {
|
||||
* UserAvatarId: INT <<PK>>
|
||||
--
|
||||
* UserAccountId: INT <<FK>>
|
||||
PhotoId: INT <<FK>>
|
||||
* IsActive: BIT
|
||||
CreatedAt: DATETIME2
|
||||
package "Beer (*)" {
|
||||
entity "BeerStyle (*)" as BeerStyle {
|
||||
* BeerStyleID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* StyleName: NVARCHAR(100) <<UNIQUE>>
|
||||
Description: NVARCHAR(MAX)
|
||||
}
|
||||
|
||||
entity "BeerPost (*)" as Beer {
|
||||
* BeerPostID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* Name: NVARCHAR(100)
|
||||
* Description: NVARCHAR(MAX)
|
||||
* ABV: DECIMAL(4,2)
|
||||
* IBU: INT
|
||||
* PostedByID: UNIQUEIDENTIFIER <<FK>>
|
||||
* BeerStyleID: UNIQUEIDENTIFIER <<FK>>
|
||||
* BrewedByID: UNIQUEIDENTIFIER <<FK>>
|
||||
* CreatedAt: DATETIME
|
||||
UpdatedAt: DATETIME
|
||||
}
|
||||
|
||||
entity "BeerPostPhoto (*)" as BeerPhoto {
|
||||
* BeerPostPhotoID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* BeerPostID: UNIQUEIDENTIFIER <<FK>>
|
||||
* PhotoID: UNIQUEIDENTIFIER <<FK>>
|
||||
* LinkedAt: DATETIME
|
||||
}
|
||||
|
||||
entity "BeerPostComment (*)" as BeerComment {
|
||||
* BeerPostCommentID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* Comment: NVARCHAR(250)
|
||||
* BeerPostID: UNIQUEIDENTIFIER <<FK>>
|
||||
* CommentedByID: UNIQUEIDENTIFIER <<FK>>
|
||||
* Rating: INT
|
||||
* CreatedAt: DATETIME
|
||||
UpdatedAt: DATETIME
|
||||
}
|
||||
}
|
||||
|
||||
entity "UserFollow" as Follow {
|
||||
* UserFollowId: INT <<PK>>
|
||||
--
|
||||
* FollowerUserId: INT <<FK>>
|
||||
* FollowedUserId: INT <<FK>>
|
||||
CreatedAt: DATETIME2
|
||||
}
|
||||
|
||||
entity "Photo" as Photo {
|
||||
* PhotoId: INT <<PK>>
|
||||
--
|
||||
* Url: NVARCHAR(500)
|
||||
* CloudinaryPublicId: NVARCHAR(255)
|
||||
Width: INT
|
||||
Height: INT
|
||||
Format: NVARCHAR(10)
|
||||
CreatedAt: DATETIME2
|
||||
}
|
||||
|
||||
' Relationships
|
||||
' Relationships: User & Auth
|
||||
User ||--o{ Cred : "has"
|
||||
User ||--o| Verify : "has"
|
||||
User ||--o{ Avatar : "has"
|
||||
User ||--o{ Follow : "follows"
|
||||
User ||--o{ Follow : "followed by"
|
||||
User ||--o| Avatar : "has"
|
||||
Avatar }o--|| Photo : "refers to"
|
||||
User ||--o{ Follow : "follows (UserAccountID)"
|
||||
User ||--o{ Follow : "followed by (FollowingID)"
|
||||
User ||--o{ Photo : "uploaded"
|
||||
|
||||
note right of Cred
|
||||
Password hashing:
|
||||
- Algorithm: Argon2id
|
||||
- Memory: 64MB
|
||||
- Iterations: 4
|
||||
- Salt: 128-bit
|
||||
- Hash: 256-bit
|
||||
end note
|
||||
' Relationships: Location
|
||||
Country ||--o{ StateProvince : "has"
|
||||
StateProvince ||--o{ City : "has"
|
||||
|
||||
note right of Verify
|
||||
Account verification
|
||||
via email token
|
||||
with expiry
|
||||
end note
|
||||
' Relationships: Brewery
|
||||
User ||--o{ Brewery : "posted"
|
||||
Brewery ||--o| BreweryLoc : "has"
|
||||
City ||--o{ BreweryLoc : "located in"
|
||||
Brewery ||--o{ BreweryPhoto : "has"
|
||||
Photo ||--o{ BreweryPhoto : "linked to"
|
||||
|
||||
' Relationships: Beer
|
||||
User ||--o{ Beer : "posted"
|
||||
BeerStyle ||--o{ Beer : "categorizes"
|
||||
Brewery ||--o{ Beer : "brewed"
|
||||
Beer ||--o{ BeerPhoto : "has"
|
||||
Photo ||--o{ BeerPhoto : "linked to"
|
||||
Beer ||--o{ BeerComment : "has"
|
||||
User ||--o{ BeerComment : "commented"
|
||||
|
||||
note bottom of User
|
||||
Core stored procedures:
|
||||
- USP_RegisterUser
|
||||
- USP_GetUserAccountByUsername
|
||||
- usp_UpdateUserAccount
|
||||
- usp_DeleteUserAccount
|
||||
- USP_RotateUserCredential
|
||||
- USP_UpdateUserAccount
|
||||
- USP_CreateUserVerification
|
||||
end note
|
||||
|
||||
note bottom of Brewery
|
||||
Core stored procedures:
|
||||
- USP_CreateBrewery
|
||||
- USP_UpdateBrewery
|
||||
- USP_DeleteBrewery
|
||||
(optimistic concurrency via Timer)
|
||||
end note
|
||||
|
||||
@enduml
|
||||
|
||||
@@ -166,8 +166,8 @@ NODE_ENV=development # Standard Node runtime mode
|
||||
|
||||
### SMTP configuration (backend)
|
||||
|
||||
Read by `Infrastructure.Email/SmtpEmailProvider.cs` for sending confirmation
|
||||
and account emails.
|
||||
Read by `Infrastructure.Email/SmtpEmailProvider.cs` for sending confirmation and
|
||||
account emails.
|
||||
|
||||
```bash
|
||||
SMTP_HOST=mailpit # Required, no default
|
||||
@@ -183,8 +183,8 @@ SMTP_FROM_NAME=The Biergarten App # Optional, defaults to "The Biergarten
|
||||
startup if `SMTP_HOST` or `SMTP_FROM_EMAIL` is missing
|
||||
- **Local dev**: point at the `mailpit` Docker service (SMTP on port 1025, web
|
||||
UI on http://localhost:8025)
|
||||
- **Production**: point at a real provider (SendGrid, Mailgun, Amazon SES,
|
||||
and so on)
|
||||
- **Production**: point at a real provider (SendGrid, Mailgun, Amazon SES, and
|
||||
so on)
|
||||
|
||||
## Docker-specific variables
|
||||
|
||||
@@ -234,35 +234,35 @@ reference only and is not run as part of the active stack.
|
||||
|
||||
## Variable reference table
|
||||
|
||||
| Variable | Backend | Frontend | Docker | Required | Notes |
|
||||
| ----------------------------- | :-----: | :------: | :----: | :------: | -------------------------- |
|
||||
| `DB_SERVER` | ✓ | | ✓ | Yes\* | SQL Server address |
|
||||
| `DB_NAME` | ✓ | | ✓ | Yes\* | Database name |
|
||||
| `DB_USER` | ✓ | | ✓ | Yes\* | SQL username |
|
||||
| `DB_PASSWORD` | ✓ | | ✓ | Yes\* | SQL password |
|
||||
| `DB_CONNECTION_STRING` | ✓ | | | Yes\* | Alternative to components |
|
||||
| `DB_TRUST_SERVER_CERTIFICATE` | ✓ | | ✓ | No | Defaults to `True` |
|
||||
| `ACCESS_TOKEN_SECRET` | ✓ | | ✓ | Yes | Access token signing |
|
||||
| `REFRESH_TOKEN_SECRET` | ✓ | | ✓ | Yes | Refresh token signing |
|
||||
| `CONFIRMATION_TOKEN_SECRET` | ✓ | | ✓ | Yes | Confirmation token signing |
|
||||
| `WEBSITE_BASE_URL` | ✓ | | | Yes | Website URL for emails |
|
||||
| `SMTP_HOST` | ✓ | | ✓ | Yes | SMTP server host |
|
||||
| `SMTP_PORT` | ✓ | | ✓ | No | Defaults to `587` |
|
||||
| `SMTP_USERNAME` | ✓ | | ✓ | No | SMTP auth username |
|
||||
| `SMTP_PASSWORD` | ✓ | | ✓ | No | SMTP auth password |
|
||||
| `SMTP_USE_SSL` | ✓ | | ✓ | No | Defaults to `true` |
|
||||
| `SMTP_FROM_EMAIL` | ✓ | | ✓ | Yes | Email sender address |
|
||||
| `SMTP_FROM_NAME` | ✓ | | ✓ | No | Defaults to `The Biergarten` |
|
||||
| `API_BASE_URL` | | ✓ | | Yes | Website-to-API base URL |
|
||||
| `SESSION_SECRET` | | ✓ | | Yes | Website session signing |
|
||||
| `NODE_ENV` | | ✓ | | No | Runtime mode |
|
||||
| `CLEAR_DATABASE` | ✓ | | ✓ | No | Dev/test reset flag |
|
||||
| `ASPNETCORE_ENVIRONMENT` | ✓ | | ✓ | Yes | ASP.NET environment |
|
||||
| `ASPNETCORE_URLS` | ✓ | | ✓ | Yes | API binding address |
|
||||
| `SA_PASSWORD` | | | ✓ | Yes | SQL Server container |
|
||||
| `ACCEPT_EULA` | | | ✓ | Yes | SQL Server EULA |
|
||||
| `MSSQL_PID` | | | ✓ | No | SQL Server edition |
|
||||
| `DOTNET_RUNNING_IN_CONTAINER` | ✓ | | ✓ | No | Container flag |
|
||||
| Variable | Backend | Frontend | Docker | Required | Notes |
|
||||
| ----------------------------- | :-----: | :------: | :----: | :------: | ---------------------------- |
|
||||
| `DB_SERVER` | ✓ | | ✓ | Yes\* | SQL Server address |
|
||||
| `DB_NAME` | ✓ | | ✓ | Yes\* | Database name |
|
||||
| `DB_USER` | ✓ | | ✓ | Yes\* | SQL username |
|
||||
| `DB_PASSWORD` | ✓ | | ✓ | Yes\* | SQL password |
|
||||
| `DB_CONNECTION_STRING` | ✓ | | | Yes\* | Alternative to components |
|
||||
| `DB_TRUST_SERVER_CERTIFICATE` | ✓ | | ✓ | No | Defaults to `True` |
|
||||
| `ACCESS_TOKEN_SECRET` | ✓ | | ✓ | Yes | Access token signing |
|
||||
| `REFRESH_TOKEN_SECRET` | ✓ | | ✓ | Yes | Refresh token signing |
|
||||
| `CONFIRMATION_TOKEN_SECRET` | ✓ | | ✓ | Yes | Confirmation token signing |
|
||||
| `WEBSITE_BASE_URL` | ✓ | | | Yes | Website URL for emails |
|
||||
| `SMTP_HOST` | ✓ | | ✓ | Yes | SMTP server host |
|
||||
| `SMTP_PORT` | ✓ | | ✓ | No | Defaults to `587` |
|
||||
| `SMTP_USERNAME` | ✓ | | ✓ | No | SMTP auth username |
|
||||
| `SMTP_PASSWORD` | ✓ | | ✓ | No | SMTP auth password |
|
||||
| `SMTP_USE_SSL` | ✓ | | ✓ | No | Defaults to `true` |
|
||||
| `SMTP_FROM_EMAIL` | ✓ | | ✓ | Yes | Email sender address |
|
||||
| `SMTP_FROM_NAME` | ✓ | | ✓ | No | Defaults to `The Biergarten` |
|
||||
| `API_BASE_URL` | | ✓ | | Yes | Website-to-API base URL |
|
||||
| `SESSION_SECRET` | | ✓ | | Yes | Website session signing |
|
||||
| `NODE_ENV` | | ✓ | | No | Runtime mode |
|
||||
| `CLEAR_DATABASE` | ✓ | | ✓ | No | Dev/test reset flag |
|
||||
| `ASPNETCORE_ENVIRONMENT` | ✓ | | ✓ | Yes | ASP.NET environment |
|
||||
| `ASPNETCORE_URLS` | ✓ | | ✓ | Yes | API binding address |
|
||||
| `SA_PASSWORD` | | | ✓ | Yes | SQL Server container |
|
||||
| `ACCEPT_EULA` | | | ✓ | Yes | SQL Server EULA |
|
||||
| `MSSQL_PID` | | | ✓ | No | SQL Server edition |
|
||||
| `DOTNET_RUNNING_IN_CONTAINER` | ✓ | | ✓ | No | Container flag |
|
||||
|
||||
\* Either `DB_CONNECTION_STRING` OR the component variables (`DB_SERVER`,
|
||||
`DB_NAME`, `DB_USER`, `DB_PASSWORD`) must be provided.
|
||||
@@ -276,9 +276,8 @@ Variables are validated at startup:
|
||||
- Missing required variables (`ACCESS_TOKEN_SECRET`, `REFRESH_TOKEN_SECRET`,
|
||||
`CONFIRMATION_TOKEN_SECRET`, `SMTP_HOST`, `SMTP_FROM_EMAIL`, DB connection
|
||||
values) cause the application to fail with an `InvalidOperationException`
|
||||
- No minimum length is enforced on the token secrets in code; the
|
||||
"minimum 32 characters" guidance above is a recommendation, not an
|
||||
enforced check
|
||||
- No minimum length is enforced on the token secrets in code; the "minimum 32
|
||||
characters" guidance above is a recommendation, not an enforced check
|
||||
|
||||
### Frontend validation
|
||||
|
||||
|
||||
@@ -46,7 +46,8 @@ See [Environment Variables](environment-variables.md) for the full list.
|
||||
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d
|
||||
```
|
||||
|
||||
This starts SQL Server, migrations, seeding, the API, and Mailpit (local dev SMTP).
|
||||
This starts SQL Server, migrations, seeding, the API, and Mailpit (local dev
|
||||
SMTP).
|
||||
|
||||
Available endpoints:
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ Biergarten App.
|
||||
|
||||
The project uses a multi-layered testing approach across backend and frontend:
|
||||
|
||||
- **API.Specs** - BDD integration tests using Reqnroll (Gherkin), run against
|
||||
a live, seeded database
|
||||
- **API.Specs** - BDD integration tests using Reqnroll (Gherkin), run against a
|
||||
live, seeded database
|
||||
- **Features.\*.Tests** - One unit test project per backend feature slice
|
||||
(`Features.Auth.Tests`, `Features.Breweries.Tests`,
|
||||
`Features.UserManagement.Tests`, `Features.Emails.Tests`), covering that
|
||||
@@ -77,8 +77,8 @@ dotnet test API/API.Specs/API.Specs.csproj
|
||||
|
||||
### Feature slice unit tests
|
||||
|
||||
Each feature slice has its own test project, covering its command/query
|
||||
handlers and repository:
|
||||
Each feature slice has its own test project, covering its command/query handlers
|
||||
and repository:
|
||||
|
||||
```bash
|
||||
cd web/backend
|
||||
|
||||
@@ -34,8 +34,8 @@ Low-level JWT operations.
|
||||
|
||||
#### [ITokenService](../../web/backend/Features/Features.Auth/Services/ITokenService.cs)
|
||||
|
||||
Both token generation and validation live on the same slice-internal
|
||||
service (there is no separate validation service/class).
|
||||
Both token generation and validation live on the same slice-internal service
|
||||
(there is no separate validation service/class).
|
||||
|
||||
**Generation methods:**
|
||||
|
||||
@@ -56,18 +56,18 @@ service (there is no separate validation service/class).
|
||||
- `Username` (string)
|
||||
- `Principal` (ClaimsPrincipal) - Full JWT claims
|
||||
|
||||
**Implementation:** [TokenService.cs](../../web/backend/Features/Features.Auth/Services/TokenService.cs)
|
||||
**Implementation:**
|
||||
[TokenService.cs](../../web/backend/Features/Features.Auth/Services/TokenService.cs)
|
||||
|
||||
- Reads token secrets from environment variables
|
||||
- Extracts and validates claims (Sub, UniqueName)
|
||||
- Throws `UnauthorizedException` on validation failure
|
||||
|
||||
`TokenService` is registered by `Features.Auth`'s own
|
||||
`AddFeaturesAuth()` extension method, except for the lower-level
|
||||
`ITokenInfrastructure` (JWT signing/verification) it depends on, which is
|
||||
registered by the host (`API.Core/Program.cs`) since
|
||||
`JwtAuthenticationHandler` (host-level auth middleware) also depends on
|
||||
it directly.
|
||||
`TokenService` is registered by `Features.Auth`'s own `AddFeaturesAuth()`
|
||||
extension method, except for the lower-level `ITokenInfrastructure` (JWT
|
||||
signing/verification) it depends on, which is registered by the host
|
||||
(`API.Core/Program.cs`) since `JwtAuthenticationHandler` (host-level auth
|
||||
middleware) also depends on it directly.
|
||||
|
||||
### Integration points
|
||||
|
||||
@@ -97,9 +97,9 @@ it directly.
|
||||
**Flow:**
|
||||
|
||||
1. Receives a refresh token via `RefreshTokenCommand`
|
||||
2. Delegates to `ITokenService.RefreshTokenAsync()`, which validates the
|
||||
token, retrieves the user account via `IAuthRepository.GetUserByIdAsync()`,
|
||||
and issues a new access/refresh token pair
|
||||
2. Delegates to `ITokenService.RefreshTokenAsync()`, which validates the token,
|
||||
retrieves the user account via `IAuthRepository.GetUserByIdAsync()`, and
|
||||
issues a new access/refresh token pair
|
||||
3. Maps the result onto `LoginPayload`
|
||||
|
||||
#### [AuthController](../../web/backend/Features/Features.Auth/Controllers/AuthController.cs)
|
||||
|
||||
Reference in New Issue
Block a user