18 Commits

Author SHA1 Message Date
Aaron Po
dee38fd1a3 Update docs formatting 2026-09-01 00:07:05 -04:00
Aaron Po
52910a17d5 Fix deploy-docs.yml 2026-08-31 22:51:35 -04:00
Aaron Po
f6264496a5 Remove pip cache from setup-python since no requirements file exists to key it 2026-08-31 22:32:34 -04:00
Aaron Po
809b9b0f72 Fix Argon2 hash-format claims and add the missing profile-creation step to the auth diagrams 2026-08-31 22:03:39 -04:00
Aaron Po
1dcc1bd625 Fix fabricated ITokenService signatures and error-message claims in token-validation.md 2026-08-31 22:03:36 -04:00
Aaron Po
93feee5a72 Fix deployment diagram's solution file name, ports, service lists, and network/volume names 2026-08-31 22:03:32 -04:00
Aaron Po
a028f7ec72 Fix React Router/Node version claims, the Infrastructure.Sql rename, and point env setup at generate-env.sh 2026-08-31 22:03:28 -04:00
Aaron Po
a6d6724251 Rename JsonLoader to CuratedJsonDataService and add the address-lookup classes to the pipeline diagrams 2026-08-31 22:03:24 -04:00
Aaron Po
399ae86c1f Document the pipeline's OpenAI generator and reverse-geocoding subsystem, and fix stale table/copy-list claims 2026-08-31 22:03:19 -04:00
Aaron Po
6f89e5c0c6 restruc 2026-08-31 21:44:22 -04:00
Aaron Po
8ac1981f81 extract mermaid docs 2026-08-31 21:20:51 -04:00
Aaron Po
6a061f4e28 Update architecture.md 2026-08-31 20:27:02 -04:00
Aaron Po
12af9cfee2 remove puml 2026-08-31 20:14:01 -04:00
Aaron Po
f4471c7fd7 Add mkdocs site and GitHub Pages deployment for docs 2026-08-31 19:54:04 -04:00
Aaron Po
d459b8559c Ignore mkdocs build output 2026-08-31 19:53:47 -04:00
Aaron Po
772a65d896 updates 2026-08-31 18:33:20 -04:00
Aaron Po
30995f6f40 Cleanup prose in written documentation 2026-08-31 17:23:46 -04:00
Aaron Po
f28f188a54 Remove svgs, update puml diagrams -- separate auth diagram into multiple diagrams 2026-08-31 17:23:33 -04:00
385 changed files with 3082 additions and 8767 deletions

View File

@@ -1,52 +0,0 @@
---
name: doc-drift-pipeline
description: Checks whether recent changes under tooling/pipeline/ have made docs/pipeline/ stale. Use when the user asks to check for doc drift on the pipeline, before committing changes to tooling/pipeline, or when asked "does this need a doc update" for the C++ seed-data pipeline.
---
# Doc Drift Check — Pipeline
Given a diff scoped to `tooling/pipeline/` (uncommitted changes, a range of
commits, or a specific PR), determine whether it invalidates any factual
claim in `docs/pipeline/`.
## Steps
1. Run `git diff -- tooling/pipeline/` (or
`git diff <base>...<head> -- tooling/pipeline/` if a range is given) to
see what changed. If the diff also touches `web/**`, note that it's out of
scope here — use the `doc-drift-web` skill for that half.
2. Based on which files changed, identify the relevant doc(s):
- `tooling/pipeline/src/**`, `includes/**` (implemented behavior) →
`docs/pipeline/README.md`, `docs/pipeline/diagrams/current/*.mmd`
(activity, class)
- Work that's scaffolded/flagged but not wired into the default run path
(roadmap-only) → `docs/pipeline/ROADMAP.md`,
`docs/pipeline/diagrams/planned/*.mmd` — do not touch `current/` for
planned work that hasn't landed
- Changes to pipeline bias/hallucination handling, model prompts, sampling
logic, or known-limitation workarounds →
`docs/pipeline/ETHICS-AND-KNOWN-ISSUES.md`
- `tooling/pipeline/runpod/Dockerfile`, build/run scripts →
`docs/pipeline/README.md` (setup/usage sections)
- `docs/pipeline/french-cities.example` — check it still matches the input
schema the pipeline actually reads
- A new file added under `docs/pipeline/**` → also check `docs/index.md`
and `mkdocs.yml` nav are updated to reference it
3. Read the relevant doc section(s) and check each specific factual claim
(tables, "currently X", "not yet implemented", roadmap checkboxes) against
the diff. For diagrams, this means reading the `.mmd` source (not just the
wrapping `.md`) and checking nodes/edges/labels against the diff — a
pipeline stage or control-flow change can invalidate a diagram without
touching any prose.
4. When a diff adds real behavior that was previously only in
`diagrams/planned/`, check whether that planned diagram/roadmap entry
should now move to `current/`/README rather than just adding a duplicate
claim in both places.
5. Report only what's actually stale — do not rewrite unrelated prose, do not
invent claims not grounded in the diff.
6. If nothing is affected, say so plainly and make no edits.
7. For confirmed drift, propose the exact edit (old text → new text, or the
changed `.mmd` node/edge) rather than committing directly, unless
explicitly told to apply it. Flag `.mmd` changes separately since they may
need a re-render (`docs/rendered-diagrams/pipeline/**`) via
`docs/makefile`.

View File

@@ -1,55 +0,0 @@
---
name: doc-drift-web
description: Checks whether recent changes under web/ have made docs/web/ stale. Use when the user asks to check for doc drift on the web app, before committing changes to web/backend or web/frontend, or when asked "does this need a doc update" for web/API/frontend work.
---
# Doc Drift Check — Web
Given a diff scoped to `web/` (uncommitted changes, a range of commits, or a
specific PR), determine whether it invalidates any factual claim in
`docs/web/`.
## Steps
1. Run `git diff -- web/` (or `git diff <base>...<head> -- web/` if a range is
given) to see what changed. If the diff also touches `tooling/pipeline/**`,
note that it's out of scope here — use the `doc-drift-pipeline` skill for
that half.
2. Based on which files changed, identify the relevant doc(s):
- `web/backend/API/**`, `Features.*/**` (general) → `docs/web/architecture.md`
- `web/backend/Features/Features.Users/**/Authentication/**`,
identity/token code (`ITokenService`, `RefreshToken*`, `ConfirmEmail*`,
login/registration handlers) → `docs/web/token-validation.md`,
`docs/web/diagrams/auth/*.mmd` (pick the matching flow: login,
registration, refresh-token, confirm-user, delete-account,
update-email/password/profile/username, resend-confirmation,
error-handling)
- `web/backend/Database/**` (entities, `Database.Migrations/scripts/**`) →
`docs/web/database.md`, `docs/web/diagrams/database-schema/*.mmd` (pick
the matching schema: beer, brewery, identity, location, media)
- Host wiring / DI registration (`API/**Program.cs`,
`Database.Seed/**Program.cs`) → `docs/web/diagrams/vertical-slice/host-wiring.mmd`
- New project references between `Features.*`/`Shared.*`/`Domain.*`
projects (new `.csproj` `<ProjectReference>`) →
`docs/web/diagrams/vertical-slice/project-references.mmd`
- `web/backend/Dockerfile*`, `web/frontend/Dockerfile*`, compose/deploy
config → `docs/web/docker.md`, `docs/web/diagrams/deployment/*.mmd`
(dev, test, minimal, db-only, prod), `docs/web/diagrams/deployment.md`
- `web/.env.example`, `web/.env.test`, appsettings → `docs/web/environment-variables.md`
- `web/backend/API/API.Specs/**`, test infra/fixtures → `docs/web/testing.md`
- New backend project setup, dev tooling → `docs/web/getting-started.md`
- `web/frontend/**` → `docs/web/architecture.md` (Frontend architecture section)
- A new file added under `docs/web/**` → also check `docs/index.md` and
`mkdocs.yml` nav are updated to reference it
3. Read the relevant doc section(s) and check each specific factual claim
(tables, "currently X", "not yet implemented", roadmap checkboxes) against
the diff. For diagrams, this means reading the `.mmd` source (not just the
wrapping `.md`) and checking nodes/edges/labels against the diff — a
schema or flow change can invalidate a diagram without touching any prose.
4. Report only what's actually stale — do not rewrite unrelated prose, do not
invent claims not grounded in the diff.
5. If nothing is affected, say so plainly and make no edits.
6. For confirmed drift, propose the exact edit (old text → new text, or the
changed `.mmd` node/edge) rather than committing directly, unless
explicitly told to apply it. Flag `.mmd` changes separately since they may
need a re-render (`docs/rendered-diagrams/web/**`) via `docs/makefile`.

View File

@@ -1,101 +0,0 @@
## Summary
<!--
State WHAT is changing and WHY, in two to five sentences.
A reader should understand the change without opening the diff.
Describe the problem first, then the outcome.
-->
## Type of change
<!-- Tick the one option that best describes this PR. -->
- [ ] Bug fix
- [ ] New feature
- [ ] Refactor (no change in behaviour)
- [ ] Performance improvement
- [ ] Documentation
- [ ] Tests only
- [ ] Build, CI or dependency update
- [ ] Other (please specify):
## Related issues
<!--
Link the issue(s) this PR relates to. Use one keyword per issue so that each is closed on merge:
Fixes #123
Fixes #124, fixes #125
Fixes owner/other-repo#456
Closing keywords only take effect when the PR targets the repository's default branch.
Use "Related to #123" if the PR should not close the issue.
If there is no issue, write "N/A" and explain the motivation in the Summary.
-->
## Approach and context
<!--
Explain how the change works and why this approach was chosen.
Include, where relevant:
- The decisions you made that are not obvious from the code.
- Alternatives you considered and why you rejected them.
- Known limitations or shortcomings, and any planned follow-up work.
- Background such as benchmark results or design documents.
Summarise the key points of any external link. Links may be inaccessible to future readers.
-->
## How this was tested
<!--
Describe what you ran and what you observed. Be specific, and state only what you actually did.
For example: the automated tests added or changed, manual steps and their outcome,
environments or platforms covered, and anything you were unable to test.
For a bug fix, note the test that fails without the fix.
-->
## Compatibility and risk
- [ ] This change is backwards compatible.
- [ ] This change is **breaking** (complete the details below).
- [ ] This change deprecates existing behaviour (complete the details below).
<!--
If breaking or deprecating, describe: what changes, who is affected,
and the migration path or recommended alternative.
Also note any deployment, configuration, data migration or rollback considerations.
-->
## User-facing change
<!--
Does this PR change behaviour that users, operators or API consumers will notice?
If not, write "None".
If so, write one or two sentences suitable for release notes, in plain language and from the
user's point of view. If users must take action when upgrading, state clearly that action is required.
-->
```release-note
```
## Notes for reviewers
<!--
Help reviewers focus. For example:
- Where to start, or a suggested order in which to read the files.
- Areas where you would particularly value scrutiny, or where you are uncertain.
- How any mechanical, generated or bulk changes were produced (for example a search-and-replace
or a code generator), so that reviewers can reproduce the steps.
- Screenshots or recordings for visual changes (include before and after where useful).
-->
## Checklist
- [ ] The PR has a single, focused purpose and contains no unrelated changes.
- [ ] The title is a clear, imperative summary of the change (for example, "Add retry to payment client").
- [ ] I have added or updated tests that cover this change.
- [ ] The build succeeds and the tests pass locally.
- [ ] The code follows the existing patterns and style of this repository.
- [ ] I have updated documentation, changelogs or API references where required.
- [ ] I have reviewed my own diff, and this description reflects the final state of the change.

View File

@@ -33,7 +33,7 @@ jobs:
if: always() if: always()
run: | run: |
status=0 status=0
for name in test-env-database-migrations test-env-database-seed test-env-api-specs test-env-unit-tests test-env-frontend-tests; do for name in test-env-api-specs test-env-unit-tests test-env-frontend-tests; do
code="$(docker inspect -f '{{.State.ExitCode}}' "$name" 2>/dev/null || echo 1)" code="$(docker inspect -f '{{.State.ExitCode}}' "$name" 2>/dev/null || echo 1)"
echo "$name exited with $code" echo "$name exited with $code"
if [ "$code" != "0" ]; then if [ "$code" != "0" ]; then

1
.gitignore vendored
View File

@@ -22,7 +22,6 @@ scratchpad.txt
/web/frontend/playwright-report/ /web/frontend/playwright-report/
/web/frontend/test-results/ /web/frontend/test-results/
/test-results/ /test-results/
/docs/rendered-diagrams/
# misc # misc
.DS_Store .DS_Store

View File

@@ -13,21 +13,3 @@ Welcome to the documentation for The Biergarten App.
- **[Architecture](web/architecture.md)** — System design and components - **[Architecture](web/architecture.md)** — System design and components
- **[Website](web/getting-started.md)** — Backend and frontend documentation - **[Website](web/getting-started.md)** — Backend and frontend documentation
- **[Pipeline](pipeline/README.md)** — Data generation pipeline - **[Pipeline](pipeline/README.md)** — Data generation pipeline
## AI Disclosure
Parts of this documentation are drafted and maintained with AI assistance
(Claude, Sonnet or Opus, via Claude Code), including prose (architecture
notes, guides) and diagrams (the Mermaid sources under `diagrams/`).
AI-authored changes are reviewed by a maintainer before merging, same as any
other contribution.
Staleness checks are partly automated too, to guard against doc-rot: the
repo's `doc-drift-web` and `doc-drift-pipeline` Claude Code skills
(`.claude/skills/`) diff recent code changes against these docs and flag
claims that may need updating.
This is separate from the pipeline's own use of AI to *generate seed data*
(brewery names, descriptions, user profiles) — see
[ETHICS-AND-KNOWN-ISSUES.md](pipeline/ETHICS-AND-KNOWN-ISSUES.md) for that
disclosure.

View File

@@ -1,43 +0,0 @@
# Makefile for rendering all Mermaid (.mmd) diagrams to SVG,
# preserving the source directory structure under OUT_ROOT.
#
# Usage:
# make # render all diagrams
# make clean # remove all rendered output
# make FORMAT=png # render as PNG instead of SVG
# make FORMAT=pdf # render as PDF instead of SVG
#
# Requires: @mermaid-js/mermaid-cli (mmdc) on PATH
# npm install -g @mermaid-js/mermaid-cli
SRC_ROOT := .
OUT_ROOT := rendered-diagrams
FORMAT := svg
# Optional: point to a shared Mermaid config/theme file if you have one.
# CONFIG := ./mermaidconfig.json
# CONFIG_FLAG := -c $(CONFIG)
CONFIG_FLAG :=
# Find every .mmd file under SRC_ROOT, excluding the output directory itself.
SRC_FILES := $(shell find $(SRC_ROOT) -type f -name "*.mmd" -not -path "./$(OUT_ROOT)/*")
# Map each source file to its corresponding output file, preserving subdirectories.
OUT_FILES := $(patsubst $(SRC_ROOT)/%.mmd,$(OUT_ROOT)/%.$(FORMAT),$(SRC_FILES))
.PHONY: all clean list
all: $(OUT_FILES)
# Pattern rule: build any output file from its matching .mmd source file,
# creating the destination subdirectory as needed.
$(OUT_ROOT)/%.$(FORMAT): $(SRC_ROOT)/%.mmd
@mkdir -p $(dir $@)
mmdc $(CONFIG_FLAG) -i $< -o $@
clean:
rm -rf $(OUT_ROOT)
# Debug helper: print which source files were discovered.
list:
@echo "$(SRC_FILES)" | tr ' ' '\n'

View File

@@ -1,6 +1,6 @@
--- ---
title: Biergarten Pipeline — C++ CLI for generating seed data title: Biergarten Pipeline — C++ CLI for generating seed data
last-updated: 2026-09-02 last-updated: 2026-08-31
tags: tags:
- pipeline - pipeline
- cpp - cpp
@@ -248,28 +248,74 @@ skipped and the pipeline continues. `GenerateUsers()` runs before
### Key components ### Key components
| Component | Description | - `src/main.cc`: argument parsing and Boost.DI composition root.
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | - `CuratedJsonDataService`: implements `ICuratedDataService`; takes a
| `src/main.cc` | Argument parsing and the Boost.DI composition root. | `CuratedDataFilePaths` DTO (locations/personas/forenames/surnames paths) in
| `CuratedJsonDataService` | Implements `ICuratedDataService`; takes a `CuratedDataFilePaths` DTO (locations/personas/forenames/surnames paths) in its constructor, parses and validates curated location, persona, and forename/surname JSON, and memoizes each result after its first load on a given instance. `MockCuratedDataService` is the in-memory substitute (4 fixed locations, 3 personas, and name data for `US`/`DE`/`FR`/`BE`) used in `--mocked` runs. | its constructor, then parses and validates curated location, persona, and
| `WikipediaEnrichmentService` | Queries Wikipedia extracts, caches results, and returns empty context on failure. `MockEnrichmentService` is the no-op substitute used in `--mocked` runs. | forename/surname JSON, memoizing each result after its first load on a given
| `LlamaGenerator` | Formats prompts for Gemma 4, validates JSON output for both `GenerateBrewery` and `GenerateUser`, and retries malformed responses up to three times with corrective feedback in the retry prompt. The token budget is fixed across attempts; it is not raised automatically on truncation. | instance. `MockCuratedDataService` is the in-memory substitute (4 fixed
| `MockGenerator` | Uses stable hash-based output so the same city/persona/name input always produces the same brewery or user. | locations, 3 personas, and name data for `US`/`DE`/`FR`/`BE`) used in
| `NominatimAddressService` | Reverse-geocodes each brewery's jittered coordinates into a street address (`address_line1`, `postal_code`) via the public [Nominatim API](https://nominatim.openstreetmap.org), respecting its one-request-per-second usage policy. Falls back to a placeholder `address_line1` ("Address unavailable") and empty `postal_code` when the lookup fails or returns no usable street address. `MockAddressService` is the no-network substitute (`"123 Mock Street"`, `"00000"`) used in `--mocked` runs. Only breweries get a resolved street address; generated users carry a coordinate pair with no address fields. | `--mocked` runs.
| `SqliteExportService` | Creates a dated SQLite file per run and persists each successful user and brewery into normalized tables. | - `WikipediaEnrichmentService`: queries Wikipedia extracts, caches results,
| Brewery/user payloads | Brewery payloads include English and local-language name and description fields. User payloads carry a sampled first/last name and gender, an LLM-generated username/bio/activity weight, and a programmatically generated (not LLM-authored) unique email and date of birth. | returns empty context on failure. `MockEnrichmentService` is the no-op
substitute used in `--mocked` runs.
- `LlamaGenerator`: formats prompts for Gemma 4, validates JSON output for both
`GenerateBrewery` and `GenerateUser`, retries malformed responses up to three
times with corrective feedback in the retry prompt. The token budget is fixed
across attempts; it is not raised automatically on truncation.
- `MockGenerator`: stable hash-based output so the same city/persona/name input
always produces the same brewery or user.
- `NominatimAddressService`: reverse-geocodes each brewery's jittered
coordinates into a street address (`address_line1`, `postal_code`) via the
public [Nominatim API](https://nominatim.openstreetmap.org), respecting its
one-request-per-second usage policy. Falls back to a placeholder
`address_line1` ("Address unavailable") and empty `postal_code` when the
lookup fails or returns no usable street address. `MockAddressService` is the
no-network substitute (`"123 Mock Street"`, `"00000"`) used in `--mocked`
runs. Only breweries get a resolved street address; generated users carry a
coordinate pair with no address fields.
- `SqliteExportService`: creates a dated SQLite file per run and persists each
successful user and brewery into normalized tables.
- Brewery payloads include English and local-language name and description
fields. User payloads carry a sampled first/last name and gender, an
LLM-generated username/bio/activity weight, and a programmatically generated
(not LLM-authored) unique email and date of birth.
### Runtime behaviour ### Runtime behaviour
| Component / behavior | Details | `WikipediaEnrichmentService` fetches two Wikipedia extracts per city: a generic
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | "brewing" extract and a "beer in `{country}`" extract. It does not currently
| `WikipediaEnrichmentService` | Fetches two Wikipedia extracts per city: a generic "brewing" extract and a "beer in `{country}`" extract. It does not currently query a city- or region-specific page. Each query string is cached after its first successful (or empty) lookup. | query a city- or region-specific page. Each query string is cached after its
| `GetLocationContext()` | Returns an empty string when the web client is unavailable or when lookup/parsing fails. | first successful (or empty) lookup.
| `LlamaGenerator` | Validates model output as structured JSON. On validation failure it retries up to three times, replaying the previous error message in the next prompt so the model can self-correct. All runs to date have produced valid output on the first pass; the retry path is kept for resilience. |
| `MockGenerator` | Uses stable hashes for repeatable output in demos and Storybook runs. | `GetLocationContext()` returns an empty string when the web client is
| `NominatimAddressService.ReverseGeocode()` | Sleeps one second after every request to respect Nominatim's usage policy, and returns `std::nullopt` (logged as a warning, not a skipped city) if the request fails, the response has no `address` object, or neither `house_number` nor `road` is present — the brewery is still generated and exported, just with a placeholder address. | unavailable or when lookup/parsing fails.
| `CuratedJsonDataService` | Memoizes each of `LoadLocations()`, `LoadPersonas()`, `LoadForenamesByCountry()`, and `LoadSurnamesByCountry()` independently the first time each is called, since `BiergartenPipelineOrchestrator` owns a single `ICuratedDataService` instance for the whole run; later calls return the cached result instead of re-parsing. |
| `GenerateUsers()` | Samples a forename/surname pair per city via `SampleName()`, keyed by the city's ISO 3166-1 code. Countries present in `locations.json` but absent from either name fixture (currently `KE`, `SE`, `SG`, `TH`, `VN`, `ZA`) are skipped, the same way a failed enrichment or generation call skips a city; see ETHICS-AND-KNOWN-ISSUES.md's Names-by-Country Dataset section. | `LlamaGenerator` validates model output as structured JSON. On validation
failure it retries up to three times, replaying the previous error message in
the next prompt so the model can self-correct. All runs to date have produced
valid output on the first pass; the retry path is kept for resilience.
`MockGenerator` uses stable hashes for repeatable output in demos and Storybook
runs.
`NominatimAddressService.ReverseGeocode()` sleeps one second after every request
to respect Nominatim's usage policy, and returns `std::nullopt` (logged as a
warning, not a skipped city) if the request fails, the response has no `address`
object, or neither `house_number` nor `road` is present — the brewery is still
generated and exported, just with a placeholder address.
`CuratedJsonDataService` memoizes each of `LoadLocations()`, `LoadPersonas()`,
`LoadForenamesByCountry()`, and `LoadSurnamesByCountry()` independently the
first time each is called, since `BiergartenPipelineOrchestrator` owns a single
`ICuratedDataService` instance for the whole run; later calls return the cached
result instead of re-parsing.
`GenerateUsers()` samples a forename/surname pair per city via `SampleName()`,
keyed by the city's ISO 3166-1 code. Countries present in `locations.json` but
absent from either name fixture (currently `KE`, `SE`, `SG`, `TH`, `VN`, `ZA`)
are skipped, the same way a failed enrichment or generation call skips a city;
see ETHICS-AND-KNOWN-ISSUES.md's Names-by-Country Dataset section.
### Process flow - activity diagram ### Process flow - activity diagram
@@ -417,32 +463,30 @@ Silicon; CUDA or HIP/ROCm is detected on Linux when the toolkit is present.
Paths below are relative to `tooling/pipeline/`. Paths below are relative to `tooling/pipeline/`.
| Path | Purpose | - `src/main.cc`: argument parsing and DI composition root.
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | - `src/biergarten_pipeline_orchestrator/`: orchestration, sampling, logging, and
| `src/main.cc` | Argument parsing and DI composition root. | export.
| `src/biergarten_pipeline_orchestrator/` | Orchestration, sampling, logging, and export. | - `src/services/curated_data/`: `CuratedJsonDataService`, the file-backed
| `src/services/curated_data/` | `CuratedJsonDataService`, the file-backed `ICuratedDataService`, and `MockCuratedDataService`, the in-memory `ICuratedDataService` used in `--mocked` runs. | `ICuratedDataService`, and `MockCuratedDataService`, the in-memory
| `src/services/enrichment/wikipedia/` | Enrichment service and cache. | `ICuratedDataService` used in `--mocked` runs.
| `src/services/sqlite/` | SQLite export implementation. | - `src/services/enrichment/wikipedia/`: enrichment service and cache.
| `src/data_generation/llama/` | Local inference, prompt loading, and output validation. | - `src/services/sqlite/`: SQLite export implementation.
| `src/data_generation/mock/` | Deterministic fallback. | - `src/data_generation/llama/`: local inference, prompt loading, output
| `runpod/` | Container build and runtime launcher. | validation.
- `src/data_generation/mock/`: deterministic fallback.
- `runpod/`: container build and runtime launcher.
--- ---
## 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. SQLite exports. The next passes add additional fixture types so the app can
exercise the full brewery and social domains without live data. For the detailed
engineering breakdown of what's needed to reach the architecture in
[`diagrams/planned/`](./diagrams/planned/), see [ROADMAP.md](./ROADMAP.md).
The next passes add additional fixture types so the app can exercise the full ### Testing (very high priority)
brewery and social domains without live data.
For the detailed engineering breakdown of what's needed to reach the
architecture in [`diagrams/planned/`](./diagrams/planned/), see
[ROADMAP.md](./ROADMAP.md).
### Testing
- 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.

View File

@@ -1,6 +1,6 @@
--- ---
title: Pipeline roadmap — reaching the planned architecture title: Pipeline roadmap — reaching the planned architecture
last-updated: 2026-09-01 last-updated: 2026-08-31
tags: tags:
- pipeline - pipeline
- roadmap - roadmap
@@ -8,17 +8,22 @@ tags:
- concurrency - concurrency
--- ---
Engineering breakdown for closing the gap between the **current** pipeline and This is the engineering breakdown for closing the gap between the **current**
the **planned** architecture in pipeline and the **planned** architecture documented in
[`diagrams/planned/class.md`](./diagrams/planned/class.md) / [`diagrams/planned/class.md`](./diagrams/planned/class.md) and
[`diagrams/planned/activity.md`](./diagrams/planned/activity.md). Items are [`diagrams/planned/activity.md`](./diagrams/planned/activity.md). Nothing in
grouped by layer, roughly by dependency order. For the current implementation, `diagrams/planned/` is implemented yet — this file tracks what it would take to
see [README.md](./README.md). get there. For the current implementation, see [README.md](./README.md).
The only concurrency today is a log dispatcher thread draining a Items are grouped by layer and roughly ordered by dependency: later groups build
`BoundedChannel<LogEntry>` into spdlog; sampling, enrichment, generation, and on types and services introduced earlier.
export run synchronously on the main thread — §6 introduces the planned worker
threads. The only concurrency in the current pipeline is the log dispatcher thread
(`main.cc` spawns it to drain a `BoundedChannel<LogEntry>` into spdlog for the
whole run). Sampling, enrichment, generation, and export all happen
synchronously on the main thread inside `BiergartenPipelineOrchestrator::Run()`
— §6 below is what introduces the additional worker threads the planned
architecture needs.
--- ---
@@ -26,183 +31,231 @@ threads.
`tooling/pipeline/includes/data_model/generated_models.h` and `models.h`. `tooling/pipeline/includes/data_model/generated_models.h` and `models.h`.
- [ ] Add `Completeness` enum (`Full`/`Partial`/`Absent`) and a - [ ] Add `Completeness` enum (`Full`, `Partial`, `Absent`) and a
`LocationContext` struct (`text`, `completeness`, `char_count`); replace `LocationContext` struct (`text`, `completeness`, `char_count`). Replace
`EnrichedCity::region_context` (a plain `string` today) with it. `EnrichedCity::region_context` (currently a plain `std::string`) with
`context : LocationContext`.
- [ ] Add `BeerStyle` (`name`, `description`, `min_abv`, `max_abv`, `min_ibu`, - [ ] Add `BeerStyle` (`name`, `description`, `min_abv`, `max_abv`, `min_ibu`,
`max_ibu`). `max_ibu`).
- [ ] Add `BeerResult`, `CheckinResult`, `RatingResult` payloads. - [ ] Add `BeerResult`, `CheckinResult`, `RatingResult` result payloads.
- [ ] Add `GenerationMetadata` (`generation_id`, `generated_time`, - [ ] Add `GenerationMetadata` (`generation_id`, `generated_time`,
`context_provided`, `generated_with`). `context_provided`, `generated_with`).
- [x] `UserResult` gained `first_name`/`last_name`/`gender`/`activity_weight` - [x] Add `first_name`, `last_name`, `gender`, and `activity_weight` to
(was just `username`/`bio`), copied from the sampled `Name`, not `UserResult` (currently just `username`, `bio`). `first_name`/
`last_name`/`gender` are copied from the sampled `Name` (see below), not
LLM-invented. LLM-invented.
- [x] Added `Name` (`first_name`, `last_name`, `gender`) and `ForenameEntry` - [x] Add `Name` (`first_name`, `last_name`, `gender`) — the sampled result
(`name`, `gender`). Flatter than planned: no `NamesByCountry` wrapper — handed to `DataGenerator::GenerateUser`. Add `ForenameEntry` (`name`,
`ICuratedDataService` exposes `ForenamesByCountryMap`/ `gender`). Landed as a flatter shape than originally planned here: no
`SurnamesByCountryMap` (keyed by ISO 3166-1) directly, and sampling is a `NamesByCountry` wrapper class. `curated_data_service.h` instead declares
free function, `SampleName(forenames, surnames, iso3166_1, rng)`, not a `ForenameList = std::vector<ForenameEntry>` and
class method. `SurnameList = std::vector<std::string>`, and `ICuratedDataService`
exposes two maps keyed by ISO 3166-1 code —
`ForenamesByCountryMap = unordered_map<string, ForenameList>` and
`SurnamesByCountryMap = unordered_map<string, SurnameList>` — directly
(see §2). Sampling is a free function,
`SampleName(forenames_by_country, surnames_by_country, iso3166_1, rng)`,
in `generate_users.cc`'s anonymous namespace, not a method on a class.
Pairing a forename with a surname happens at sample time so gender
(present per-forename in the source data) is never discarded the way a
pre-flattened `{first_name, last_name}` list would lose it; see §2.
- [ ] Extend `GeneratedBrewery` with `brewery_id`, `context_completeness`, - [ ] Extend `GeneratedBrewery` with `brewery_id`, `context_completeness`,
`metadata` (currently just `{location, brewery}`). `metadata` (currently just `{location, brewery}`).
- [ ] Add `GeneratedBeer`, `GeneratedCheckin`, `GeneratedRating`, - [ ] Add `GeneratedBeer`, `GeneratedCheckin`, `GeneratedRating`,
`GeneratedFollow` aggregate structs. `GeneratedFollow` aggregate structs.
- [x] Added `UserRecord` (`location`, `user`, `email`, `date_of_birth`), the - [x] Add `UserRecord` (`location`, `user : UserResult`, `email`,
current stand-in for the planned `GeneratedUser`. `email`/ `date_of_birth` `date_of_birth`) as the current stand-in for the planned `GeneratedUser` —
are programmatic (never LLM-authored), for downstream auth-account `email` and `date_of_birth` are programmatically generated by the
seeding. Missing `user_id`/`password`/`metadata`; already exported via orchestrator, never LLM-authored, so a downstream auth-account seeding
`ProcessRecord(const UserRecord&)` (§5). consumer can register real accounts from the pipeline's SQLite export. No
- [x] Added `UserPersona` (`name`, `description`, `style_affinities`). `password`, `user_id`, or `metadata` field yet, matching `BreweryRecord`'s
equally pre-`metadata` shape. Already wired into
`IExportService`/`SqliteExportService`: a `users` table exists and
`GenerateUsers()` exports each successful record via
`ProcessRecord(const UserRecord&)` (see §5) in addition to logging and
holding `generated_users_` in memory. Still missing vs. the planned
`GeneratedUser`: `user_id`, `password`, and `metadata`.
- [x] Add `UserPersona` (`name`, `description`, `style_affinities`).
## 2. Data Preloading ## 2. Data Preloading
`tooling/pipeline/includes/services/curated_data/`, fixture files. `tooling/pipeline/includes/services/curated_data/`, fixture files.
- [x] Extracted `ICuratedDataService` (not `DataPreloader`); `Load*()` methods - [x] Extract an interface; have `CuratedJsonDataService` implement it. Landed
take no arguments, return `const&`. `CuratedJsonDataService` (renamed from as `ICuratedDataService` (`services/curated_data/curated_data_service.h`),
`JsonLoader`) takes a `CuratedDataFilePaths` DTO at construction and not `DataPreloader` — `LoadLocations()`, `LoadPersonas()`,
memoizes each `Load*()` result. `MockCuratedDataService` (fixed data for `LoadForenamesByCountry()`, and `LoadSurnamesByCountry()` are all virtual
`US`/`DE`/`FR`/`BE`) binds in its place under `--mocked`. methods returning `const&` and take no arguments. `CuratedJsonDataService`
- [ ] Add `LoadBeerStyles()`. `beer-styles.json` exists and is copied into the (`services/curated_data/curated_json_data_service.h`, originally landed as
Docker image, but no loader reads it and the native CMake build doesn't `JsonLoader` under `json_handling/` before being renamed and moved) takes
copy it into `build/` at all. a `CuratedDataFilePaths` DTO (`locations_path`, `personas_path`,
- [x] Added `LoadPersonas()` and authored `personas.json`. `forenames_path`, `surnames_path`) in its constructor rather than a path
- [x] Added `LoadForenamesByCountry()` / `LoadSurnamesByCountry()` (two methods, per `Load*()` call, and memoizes each result in a private `cache` struct
not one combined `LoadNamesByCountry()`), parsing vendored CC0 data from (`locations`, `personas`, `forenames_by_country`, `surnames_by_country`),
`sigpwned/popular-names-by-country-dataset` — see so a second call on the same instance returns the cached result instead of
ETHICS-AND-KNOWN-ISSUES.md for provenance. Deliberately not pre-paired or re-parsing — safe because `CuratedJsonDataService` outlives every call
filtered to `locations.json`'s countries, to keep per-forename gender and site (owned by `BiergartenPipelineOrchestrator` via
support more countries later without re-sourcing. `unique_ptr<ICuratedDataService>` for the whole run). `main.cc`'s DI
injector also gained a `MockCuratedDataService` (fixed in-memory data: 4
locations across `US`/`DE`/`FR`/`BE`, 3 personas, and matching
forename/surname sets for those four countries), bound in place of
`CuratedJsonDataService` under `--mocked`, mirroring the existing
`MockEnrichmentService`/`MockGenerator` pattern.
- [ ] Add `LoadBeerStyles()`. `beer-styles.json` already exists in the repo and
is already copied into the Docker image (`runpod/Dockerfile`), but no
loader reads it yet, and the native CMake build doesn't copy it into
`build/` at all — `CMakeLists.txt`'s "Runtime Assets" step only copies
`locations.json`, `personas.json`, `forenames-by-country.json`,
`surnames-by-country.json`, and `prompts/`.
- [x] Add `LoadPersonas()` and author `personas.json` (doesn't exist yet).
- [x] Add name-by-country loading, parsing
`tooling/pipeline/forenames-by-country.json` and
`surnames-by-country.json`. Landed as two separate methods —
`LoadForenamesByCountry()` and `LoadSurnamesByCountry()` — each returning
a flat `ForenamesByCountryMap` / `SurnamesByCountryMap` (aliases for
`unordered_map<string, ForenameList>` /
`unordered_map<string, SurnameList>`) keyed by ISO 3166-1 code, rather
than one combined `LoadNamesByCountry() : NamesByCountry` call. Both files
are vendored verbatim (unmodified, full multinational coverage) from
`sigpwned/popular-names-by-country-dataset` (CC0) — see
ETHICS-AND-KNOWN-ISSUES.md's Names-by-Country Dataset section for
provenance. Deliberately not pre-paired or filtered down to just the
countries in `locations.json`: keeping the source shape (including
per-forename gender) intact means the loader can support more countries
later, or gender-aware persona/bio generation, without re-sourcing data.
## 3. Policy / Strategy Layer ## 3. Policy / Strategy Layer
Entirely new — no `includes/policy/` (or equivalent) exists today. Entirely new — no `includes/policy/` (or equivalent) directory exists today.
- [ ] `ContextStrategy` + `BreweryContextStrategy`/`BeerContextStrategy`. Today - [ ] `ContextStrategy` interface + `BreweryContextStrategy` /
`WikipediaEnrichmentService::GetLocationContext` hardcodes a `"brewing"` `BeerContextStrategy`. Today
query and a `"beer in {country}"` query directly. `WikipediaEnrichmentService::GetLocationContext` hardcodes a generic
- [ ] `SamplingStrategy` + `UniformSamplingStrategy`, replacing the inline `"brewing"` query and a `"beer in {country}"` query directly — no
`std::ranges::sample(...)` in `QueryCitiesWithCountries()`. per-phase strategy selection.
- [ ] `BeerSelectionStrategy` + `RandomBeerSelectionStrategy` (depends on §1, - [ ] `SamplingStrategy` interface + `UniformSamplingStrategy`, replacing the
§2). inline `std::ranges::sample(...)` call in
- [ ] `CheckinDistributionStrategy` + `JCurveCheckinStrategy` / `BiergartenPipelineOrchestrator::QueryCitiesWithCountries()`.
`RandomCheckinStrategy` — the "Check-In System" README next step, made - [ ] `BeerSelectionStrategy` interface + `RandomBeerSelectionStrategy`, to pick
concrete. styles per brewery from the `BeerStyle` palette (depends on §1 and §2).
- [ ] `FollowGenerationStrategy` + `RandomFollowStrategy` / - [ ] `CheckinDistributionStrategy` interface + `JCurveCheckinStrategy` /
`RandomCheckinStrategy` — this is the "Check-In System" item already
called out in README.md's Next Steps, made concrete.
- [ ] `FollowGenerationStrategy` interface + `RandomFollowStrategy` /
`ActivityWeightedFollowStrategy`. `ActivityWeightedFollowStrategy`.
## 4. Data Generation ## 4. Data Generation
`tooling/pipeline/includes/data_generation/`, `src/data_generation/`. `tooling/pipeline/includes/data_generation/`, `src/data_generation/`.
- [ ] Extend `DataGenerator` with `GenerateBeer`, `GenerateCheckin`, - [ ] Extend the `DataGenerator` interface with `GenerateBeer`,
`GenerateRating` (today: only `GenerateBrewery`/`GenerateUser`). `GenerateCheckin`, `GenerateRating` (today: only `GenerateBrewery` and
- [x] Added `OpenAIGenerator`, a third backend calling the OpenAI Chat `GenerateUser`).
Completions API with Structured Outputs (`json_schema`, `strict`) — - [x] Add `OpenAIGenerator`, a third `DataGenerator` backend (alongside
schema-valid JSON with no `IPromptFormatter`/GBNF grammar needed. Selected `MockGenerator`/`LlamaGenerator`) that calls the OpenAI Chat Completions
via `--openai`/`GeneratorMode::kOpenAI`; landed ahead of §3/§4. Now shown API with Structured Outputs (`json_schema`, `strict`) to guarantee
in `diagrams/planned/class.md` alongside `MockGenerator`/`LlamaGenerator`, schema-valid JSON without needing `IPromptFormatter` or a GBNF grammar —
noting only `GenerateBrewery`/`GenerateUser` are implemented so far. selected via `--openai`/`GeneratorMode::kOpenAI`. This landed ahead of the
- [x] Implemented `LlamaGenerator::GenerateUser` for real (GBNF grammar, planned architecture in §3/§4 and isn't reflected there yet.
`ValidateUserJson`, 3 retries with corrective feedback, - [x] Implement `LlamaGenerator::GenerateUser` for real, with a retry loop
`prompts/USER_GENERATION.md`). Signature is mirroring `GenerateBrewery` (GBNF grammar, `ValidateUserJson`, up to 3
`(city, persona, name) : UserResult`, matching the activity diagram. attempts with corrective feedback) and a new `prompts/USER_GENERATION.md`.
- [ ] Implement Changed the `DataGenerator::GenerateUser` signature to
`MockGenerator::GenerateBeer`/`GenerateCheckin`/`GenerateRating`. `(city : const EnrichedCity&, persona : const UserPersona&, name : const Name&) : UserResult`,
matching what the activity diagram actually passes.
`MockGenerator::GenerateUser` updated to match the new signature too.
- [ ] Implement `MockGenerator::GenerateBeer` / `GenerateCheckin` /
`GenerateRating`.
- [ ] Add `IPromptFormatter::ExpectedArchitecture()` and - [ ] Add `IPromptFormatter::ExpectedArchitecture()` and
`LlamaGenerator::ValidateModelArchitecture()` so a mismatched GGUF chat `LlamaGenerator::ValidateModelArchitecture()` so loading a GGUF that
template fails fast instead of degrading silently. doesn't match the configured chat template fails fast instead of silently
- [ ] Add prompt templates for beer/checkin/rating, next to producing degraded output.
`prompts/BREWERY_GENERATION.md`. - [ ] Add prompt template files for beer/checkin/rating generation next to the
existing `prompts/BREWERY_GENERATION.md`.
## 5. Export Service ## 5. Export Service
`tooling/pipeline/includes/services/database/`, `src/services/sqlite/`. `tooling/pipeline/includes/services/database/`, `src/services/sqlite/`.
- [x] `IExportService::ProcessRecord(const UserRecord&)` and the `users` table - [x] `IExportService::ProcessRecord(const UserRecord&)` and
are implemented — every successful user is exported with its resolved `SqliteExportService`'s `users` table (see `kCreateUsersTableSql` /
`kInsertUserSql` in
`includes/services/database/sqlite_statement_helpers.h`) are implemented —
`GenerateUsers()` exports every successful user alongside its resolved
`location_id`. `location_id`.
- [ ] Extend `IExportService` with `ProcessBeer`, `ProcessCheckin`, - [ ] Extend `IExportService` with `ProcessBeer`, `ProcessCheckin`,
`ProcessRating`, `ProcessFollow`. `ProcessRating`, `ProcessFollow` (today
`ProcessRecord(const BreweryRecord&)` and
`ProcessRecord(const UserRecord&)` exist).
- [ ] Add `beers`, `checkins`, `ratings`, `follows` tables to - [ ] Add `beers`, `checkins`, `ratings`, `follows` tables to
`InitializeSchema()` (`cities`/`breweries`/`brewery_addresses`/`users`/ `SqliteExportService::InitializeSchema()` (`cities`, `breweries`,
`user_addresses` already exist — see `sqlite_statement_helpers.h` for the `brewery_addresses`, `users`, and `user_addresses` already exist) — see
pattern). `kCreateCitiesTableSql` / `kCreateBreweriesTableSql` /
- [ ] Add a `brewery_cache_` alongside `city_cache_`, and move from one open `kCreateBreweryAddressesTableSql` / `kCreateUsersTableSql` /
transaction for the whole run to per-phase batched commits (`BEGIN` / `kCreateUserAddressesTableSql` in
`COMMIT & BEGIN` on a batch threshold), per the planned activity diagram. `includes/services/database/sqlite_statement_helpers.h` for the existing
pattern to follow.
- [ ] Add a `brewery_cache_` alongside the existing `city_cache_`, and move from
one open transaction for the whole run to per-phase batched commits
(`BEGIN` / `COMMIT & BEGIN` on a batch-size threshold), as shown in the
planned activity diagram.
## 6. Concurrency & Orchestration ## 6. Concurrency & Orchestration
`tooling/pipeline/includes/concurrency/`, `tooling/pipeline/includes/concurrency/`,
`src/biergarten_pipeline_orchestrator/`. `src/biergarten_pipeline_orchestrator/`.
- [ ] `BoundedChannel<T>` exists and is production-tested but only wired to the - [ ] `BoundedChannel<T>` already exists and is production-tested — but it's
log channel. Stand up the per-phase producer/consumer channels (`loc_ch`, only wired to the single log channel today. Stand up the per-phase
`exp_ch`, etc.) with a dedicated LLM worker thread and SQLite worker producer/consumer channels (`loc_ch`, `exp_ch`, etc.) the planned activity
thread each; the brewery phase needs a third, a Geocode Worker between diagram describes, each with a dedicated LLM worker thread and SQLite
them on its own `geo_ch` (§9). worker thread.
- [ ] Rewrite `Run()` from one synchronous pass into phased methods — - [ ] Rewrite `BiergartenPipelineOrchestrator::Run()` from one synchronous pass
`RunUserPhase` → `RunBreweryAndBeerPhase` (`RunBeerPhase` sub-step once over `generated_breweries_` into phased methods — `RunUserPhase` →
`brewery_pool_` fills) → `RunCheckinPhase`/`RunFollowPhase` (parallel) → `RunBreweryAndBeerPhase` (with a `RunBeerPhase` sub-step once
`RunRatingPhase` — backed by `user_pool_`, `brewery_pool_`, `beer_pool_`, `brewery_pool_` is populated) → `RunCheckinPhase` / `RunFollowPhase`
`checkin_pool_`, `follow_pool_`. (these two can run in parallel) → `RunRatingPhase` — backed by
- [ ] Structurally join worker threads (e.g. `std::jthread`) before `Run()` `user_pool_`, `brewery_pool_`, `beer_pool_`, `checkin_pool_`,
returns, per the existing comment in `biergarten_pipeline_orchestrator.h`, `follow_pool_`.
so nothing logs to a closed channel during teardown. - [ ] Honor the structural-concurrency requirement already called out in a
comment on `BiergartenPipelineOrchestrator::Run()` in
`biergarten_pipeline_orchestrator.h`: once real worker threads exist, they
must be structurally joined (e.g. via `std::jthread`) before `Run()`
returns, so no worker logs to a closed channel during teardown.
## 7. Enrichment ## 7. Enrichment
- [ ] Decide whether to restore the city/region-specific Wikipedia query - [ ] Decide whether to restore the city/region-specific Wikipedia query that's
currently commented out in `get_summary.cc` — the §3 `ContextStrategy` currently commented out in
work is a natural place to reintroduce it. `src/services/enrichment/wikipedia/get_summary.cc` (`GetLocationContext`).
The `ContextStrategy` work in §3 is a natural place to reintroduce it via
`BreweryContextStrategy::QueriesFor()`.
- [ ] Pre-warm caches at startup (`PreWarmBeerStyleCache`, - [ ] Pre-warm caches at startup (`PreWarmBeerStyleCache`,
`PreWarmLocationCache`) instead of fetching lazily per record. `PreWarmLocationCache` in the planned activity diagram) instead of
fetching lazily per record, so the streaming phases never block on a cold
cache mid-run.
## 8. Fixtures / Build ## 8. Fixtures / Build
- [x] Authored `personas.json`, `forenames-by-country.json`, - [x] Author `personas.json`, `forenames-by-country.json`, and
`surnames-by-country.json` (§2). `surnames-by-country.json` (see §2).
- [ ] Fix the `beer-styles.json` build-tree gap: add it to the "Runtime Assets" - [ ] Fix the `beer-styles.json` build-tree gap: add it to the "Runtime Assets"
`configure_file` step in `CMakeLists.txt`. `configure_file` step in `CMakeLists.txt` so native builds and the Docker
image agree on what's available at runtime.
## 9. Geocoding
`tooling/pipeline/includes/services/address/`, `src/services/address/` — added
entirely after `diagrams/planned/` was authored; wasn't tracked here until now.
- [x] Added `IAddressService`
(`ReverseGeocode(lon, lat) : optional<AddressLookupResult>`),
`NominatimAddressService` (public Nominatim API, 1s sleep per request,
`nullopt` on failure or no usable address), and `MockAddressService`
(fixed placeholder for `--mocked`). Called once per brewery, after
`GenerateBrewery` and before export, to resolve
`BreweryAddress::address_line1`/`postal_code` — users aren't geocoded.
`diagrams/planned/class.md` now models this as an
`InfrastructureGeocoding` namespace owned by the orchestrator.
- [ ] Give the brewery phase its own **Geocode Worker** thread, between the LLM
worker and the SQLite worker (`loc_ch` → LLM Worker → `geo_ch` → Geocode
Worker → `exp_ch` → SQLite Worker), rather than calling `ReverseGeocode`
inline — Nominatim's rate limit is unrelated to inference speed, so
coupling them stalls whichever side is slower and needlessly cools the
LLM's KV cache. It never skips a record on lookup failure, only attaches a
placeholder; shown as its own subgraph (`B_GEO`) in
`diagrams/planned/activity.md`. One worker per phase is safe today since
`RunUserPhase` joins before `RunBreweryPhase` starts; concurrent phases
plus user-geocoding would need a shared rate-limited client instead, since
the throttle is global, not per-thread.
--- ---
## Suggested build order ## Suggested build order
The planned activity diagram's phase notes imply a dependency order: The planned activity diagram's own phase notes ("brewery*pool* is now fully
populated. Phase 1b may begin.", etc.) imply a dependency order. Roughly:
1. Domain models (§1) and data preloading (§2) — nothing else compiles without 1. Domain models (§1) and data preloading (§2) — nothing else compiles without
these. these.
2. Export schema (§5) — so every generation phase has somewhere to land output. 2. Export schema (§5) — so every later generation phase has somewhere to land
its output.
3. Policy/strategy layer (§3) and generator interface (§4). 3. Policy/strategy layer (§3) and generator interface (§4).
4. Concurrency/orchestration rewrite (§6) — wires §1–§5 into the phased, 4. Concurrency/orchestration rewrite (§6), which is the only piece that actually
parallel pipeline the planned diagrams show. wires §1–§5 into the phased, parallel pipeline shown in the planned diagrams.
5. Enrichment cache pre-warming and the city/region query decision (§7) — most 5. Enrichment cache pre-warming and the city/region query decision (§7) — useful
valuable once phases run concurrently. at any point, but most valuable once phases run concurrently.

View File

@@ -1,8 +1,4 @@
--- flowchart TD
config:
layout: elk
---
flowchart LR
Start([start]) --> ParseArgs["main.cc: ParseArguments(argc, argv)"] Start([start]) --> ParseArgs["main.cc: ParseArguments(argc, argv)"]
ParseArgs --> ArgsValid{Arguments valid?} ParseArgs --> ArgsValid{Arguments valid?}
ArgsValid -- no --> LogErr[Log error / usage info] --> Stop1([stop]) ArgsValid -- no --> LogErr[Log error / usage info] --> Stop1([stop])
@@ -23,8 +19,6 @@ flowchart LR
LookupFailed -- yes --> SkipCity1[Log warning, skip city] --> EnrichLoop LookupFailed -- yes --> SkipCity1[Log warning, skip city] --> EnrichLoop
LookupFailed -- no --> StoreEnriched["Store EnrichedCity{Location, region_context}"] --> EnrichLoop LookupFailed -- no --> StoreEnriched["Store EnrichedCity{Location, region_context}"] --> EnrichLoop
EnrichLoop -- done --> GenUsers[Orchestrator: GenerateUsers enriched_cities] EnrichLoop -- done --> GenUsers[Orchestrator: GenerateUsers enriched_cities]
SkipCity1 ~~~ GenUsers
StoreEnriched ~~~ GenUsers
GenUsers --> LoadUserData["CuratedJsonDataService: LoadPersonas / LoadForenamesByCountry / LoadSurnamesByCountry<br/>each memoized after first parse; MockCuratedDataService (--mocked) returns a fixed in-memory dataset instead, skipping these JSON files"] GenUsers --> LoadUserData["CuratedJsonDataService: LoadPersonas / LoadForenamesByCountry / LoadSurnamesByCountry<br/>each memoized after first parse; MockCuratedDataService (--mocked) returns a fixed in-memory dataset instead, skipping these JSON files"]
@@ -53,10 +47,6 @@ flowchart LR
UserExportFail -- yes --> UserExportWarn["Log warning: SQLite export failed"] --> UserLoop UserExportFail -- yes --> UserExportWarn["Log warning: SQLite export failed"] --> UserLoop
UserExportFail -- no --> UserLoop UserExportFail -- no --> UserLoop
UserLoop -- done --> GenBrew[Orchestrator: GenerateBreweries enriched_cities] UserLoop -- done --> GenBrew[Orchestrator: GenerateBreweries enriched_cities]
SkipCity2 ~~~ GenBrew
UserGenFail ~~~ GenBrew
UserExportWarn ~~~ GenBrew
UserExportFail ~~~ GenBrew
GenBrew --> BrewLoop{For each EnrichedCity} GenBrew --> BrewLoop{For each EnrichedCity}
BrewLoop --> BrewGenMode{Generator mode} BrewLoop --> BrewGenMode{Generator mode}
@@ -82,9 +72,6 @@ flowchart LR
BrewExportFail -- yes --> BrewExportWarn["Log warning: SQLite export failed (data loss prevented per-record; pipeline continues)"] --> BrewLoop BrewExportFail -- yes --> BrewExportWarn["Log warning: SQLite export failed (data loss prevented per-record; pipeline continues)"] --> BrewLoop
BrewExportFail -- no --> BrewLoop BrewExportFail -- no --> BrewLoop
BrewLoop -- done --> Finalize["SqliteExportService.Finalize()<br/>commits transaction, closes database connection"] BrewLoop -- done --> Finalize["SqliteExportService.Finalize()<br/>commits transaction, closes database connection"]
BrewGenFail ~~~ Finalize
BrewExportWarn ~~~ Finalize
BrewExportFail ~~~ Finalize
Finalize --> CloseLog["main.cc: close log_channel, join log dispatcher thread"] Finalize --> CloseLog["main.cc: close log_channel, join log dispatcher thread"]
CloseLog --> Stop2(["return 0 / stop"]) CloseLog --> Stop2(["return 0 / stop"])

View File

@@ -1,8 +1,3 @@
---
config:
layout: elk
---
classDiagram classDiagram
class BiergartenPipelineOrchestrator { class BiergartenPipelineOrchestrator {
-shared_ptr~ILogger~ logger_ -shared_ptr~ILogger~ logger_

View File

@@ -1,15 +1,11 @@
# The Biergarten Data Pipeline — Activity Diagram (Planned) # The Biergarten Data Pipeline — Activity Diagram (Planned)
Each generation phase runs a producer/consumer chain concurrently: an Each generation phase runs a producer/consumer trio concurrently: an
**Orchestrator** producer feeds a bounded channel, an **LLM Worker** consumes it **Orchestrator** producer feeds a bounded channel, an **LLM Worker** consumes it
and generates records (closing its output channel when the input is drained and and generates records (closing its output channel when the input is drained and
closed), and a **SQLite Worker** consumes that output channel and commits in closed), and a **SQLite Worker** consumes that output channel and commits in
batches. The brewery phase inserts a fourth stage, a **Geocode Worker**, between batches. The Checkin and Follow phases run in parallel with each other once the
the LLM Worker and the SQLite Worker: reverse-geocoding is network-bound and user and brewery pools are populated.
rate-limited independently of LLM inference (Nominatim's 1-request-per-second
policy), so keeping it off the LLM Worker's thread means neither stage stalls
waiting on the other's bottleneck. The Checkin and Follow phases run in parallel
with each other once the user and brewery pools are populated.
```mermaid ```mermaid
--8<-- "pipeline/diagrams/planned/activity.mmd" --8<-- "pipeline/diagrams/planned/activity.mmd"

View File

@@ -1,9 +1,4 @@
--- flowchart TD
config:
layout: elk
---
flowchart LR
Start([start]) --> ParseArgs["Main: ParseArguments(argc, argv)"] Start([start]) --> ParseArgs["Main: ParseArguments(argc, argv)"]
ParseArgs --> InvalidArgs{Invalid args?} ParseArgs --> InvalidArgs{Invalid args?}
InvalidArgs -- yes --> LogErr["spdlog::error"] --> Stop1([stop]) InvalidArgs -- yes --> LogErr["spdlog::error"] --> Stop1([stop])
@@ -59,7 +54,7 @@ flowchart LR
UL9 & US9 --> UserJoin["Orchestrator: Join LLM Worker, SQLite Worker"] UL9 & US9 --> UserJoin["Orchestrator: Join LLM Worker, SQLite Worker"]
UserJoin --> BreweryPhase["Orchestrator: RunBreweryPhase(sampled_locations)"] UserJoin --> BreweryPhase["Orchestrator: RunBreweryPhase(sampled_locations)"]
BreweryPhase --> BCreate["Create BoundedChannels (loc_ch, geo_ch, exp_ch)"] BreweryPhase --> BCreate["Create BoundedChannels (loc_ch, exp_ch)"]
subgraph BREWGEN["Phase 1a — Brewery Generation (concurrent)"] subgraph BREWGEN["Phase 1a — Brewery Generation (concurrent)"]
direction LR direction LR
@@ -74,22 +69,9 @@ flowchart LR
BL2 --> BL3["GetLocationContextFromCache(task.location) — guaranteed cache hit"] BL2 --> BL3["GetLocationContextFromCache(task.location) — guaranteed cache hit"]
BL3 --> BL4["GenerateBrewery(enriched_city, context, task.user) via DataGenerator<br/>KV cache stays warm; brewery linked to sampled owner_user_id"] BL3 --> BL4["GenerateBrewery(enriched_city, context, task.user) via DataGenerator<br/>KV cache stays warm; brewery linked to sampled owner_user_id"]
BL4 --> BL5["PipelineLogger.Log(Info, BreweryGeneration, city, brewery_id, llm)"] BL4 --> BL5["PipelineLogger.Log(Info, BreweryGeneration, city, brewery_id, llm)"]
BL5 --> BL6["Send GeneratedBrewery → geo_ch"] BL5 --> BL6["Send GeneratedBrewery → exp_ch"]
BL6 --> BL1 BL6 --> BL1
BL1 -- no --> BL7["Close geo_ch"] BL1 -- no --> BL7["Close exp_ch"]
end
subgraph B_GEO["Geocode Worker"]
BG1{"geo_ch has items?"}
BG1 -- yes --> BG2["Receive GeneratedBrewery"]
BG2 --> BG3["AddressService.ReverseGeocode(brewery.longitude, brewery.latitude)<br/>rate-limited independently of LLM throughput — NominatimAddressService sleeps 1s per request; MockAddressService (--mocked) returns a fixed placeholder, no network call"]
BG3 --> BG4{"Address resolved?"}
BG4 -- yes --> BG5["Attach resolved AddressLookupResult to brewery"]
BG4 -- no --> BG6["Log warning: could not reverse geocode; attach placeholder address_line1, empty postal_code — brewery is never dropped"]
BG5 --> BG7["PipelineLogger.Log(Info, BreweryGeneration, city, brewery_id, geocode)"]
BG6 --> BG7
BG7 --> BG8["Send GeneratedBrewery → exp_ch"]
BG8 --> BG1
BG1 -- no --> BG9["Close exp_ch"]
end end
subgraph B_SQL["SQLite Worker"] subgraph B_SQL["SQLite Worker"]
BS1["BEGIN TRANSACTION"] --> BS2{"exp_ch has items?"} BS1["BEGIN TRANSACTION"] --> BS2{"exp_ch has items?"}
@@ -106,9 +88,8 @@ flowchart LR
BCreate --> BP1 BCreate --> BP1
BP3 -.->|loc_ch| BL1 BP3 -.->|loc_ch| BL1
BL7 -.->|geo_ch| BG1 BL7 -.->|exp_ch| BS2
BG9 -.->|exp_ch| BS2 BL7 & BS9 --> BrewJoin["Orchestrator: Join LLM Worker, SQLite Worker<br/>brewery_pool_ is now fully populated"]
BL7 & BG9 & BS9 --> BrewJoin["Orchestrator: Join LLM Worker, Geocode Worker, SQLite Worker<br/>brewery_pool_ is now fully populated"]
BrewJoin --> BeerPhase["Orchestrator: RunBeerPhase()"] BrewJoin --> BeerPhase["Orchestrator: RunBeerPhase()"]
BeerPhase --> BeCreate["Create BoundedChannels (brew_ch, exp_ch)"] BeerPhase --> BeCreate["Create BoundedChannels (brew_ch, exp_ch)"]

View File

@@ -1,7 +1,3 @@
---
config:
layout: elk
---
classDiagram classDiagram
namespace DomainModels { namespace DomainModels {
class Location { class Location {
@@ -396,34 +392,6 @@ classDiagram
} }
} }
namespace InfrastructureGeocoding {
class AddressLookupResult {
+string address_line1
+string postal_code
}
class AddressService {
<<interface>>
+ReverseGeocode(longitude, latitude) optional~AddressLookupResult~
}
class NominatimAddressService {
-unique_ptr~WebClient~ client_
-Logger logger_
+ReverseGeocode(longitude, latitude) optional~AddressLookupResult~
}
class MockAddressService {
+ReverseGeocode(longitude, latitude) optional~AddressLookupResult~
}
}
note for NominatimAddressService "Already implemented (services/address/), not planned: queries the public Nominatim API, sleeping 1s per request per its usage policy; returns nullopt on failure or when no usable street address is found. Ships today, ahead of the rest of this diagram."
note for MockAddressService "Already implemented, not planned: returns a fixed placeholder address ('123 Mock Street', '00000') for --mocked runs, no network call."
AddressService <|.. NominatimAddressService
AddressService <|.. MockAddressService
AddressService ..> AddressLookupResult : returns
NominatimAddressService *-- WebClient
namespace InfrastructurePrompting { namespace InfrastructurePrompting {
class IPromptDirectory { class IPromptDirectory {
<<interface>> <<interface>>
@@ -484,22 +452,7 @@ classDiagram
+Format(system_prompt, user_prompt) string +Format(system_prompt, user_prompt) string
+ExpectedArchitecture() string_view +ExpectedArchitecture() string_view
} }
class OpenAIGenerator {
-string api_key_
-string model_
-Logger logger_
-unique_ptr~IPromptDirectory~ prompt_directory_
-unique_ptr~WebClient~ web_client_
+GenerateBrewery(location, context) BreweryResult
+GenerateBeer(brewery_id, location, context, style) BeerResult
+GenerateUser(city, persona, name) UserResult
+GenerateCheckin(user, brewery, timestamp) CheckinResult
+GenerateRating(user, beer, checkin_id) RatingResult
-CallChatCompletionsApi(...) string
}
} }
note for OpenAIGenerator "Already implemented (data_generation/openai/), not planned: calls the OpenAI Chat Completions API with Structured Outputs (json_schema, strict) to guarantee schema-valid JSON, needing no IPromptFormatter or GBNF grammar. Ships today for GenerateBrewery/GenerateUser only, ahead of the rest of this diagram — GenerateBeer/GenerateCheckin/GenerateRating are shown here for interface completeness but not yet implemented on any backend."
namespace InfrastructureDataExport { namespace InfrastructureDataExport {
class ExportService { class ExportService {
@@ -557,7 +510,6 @@ classDiagram
-unique_ptr~DataGenerator~ generator_ -unique_ptr~DataGenerator~ generator_
-unique_ptr~Logger~ logger_ -unique_ptr~Logger~ logger_
-unique_ptr~ExportService~ exporter_ -unique_ptr~ExportService~ exporter_
-unique_ptr~AddressService~ address_service_
-unique_ptr~ContextStrategy~ brewery_context_strategy_ -unique_ptr~ContextStrategy~ brewery_context_strategy_
-unique_ptr~SamplingStrategy~ sampling_strategy_ -unique_ptr~SamplingStrategy~ sampling_strategy_
-unique_ptr~BeerSelectionStrategy~ beer_selection_strategy_ -unique_ptr~BeerSelectionStrategy~ beer_selection_strategy_
@@ -582,7 +534,6 @@ classDiagram
BiergartenPipelineOrchestrator *-- EnrichmentService BiergartenPipelineOrchestrator *-- EnrichmentService
BiergartenPipelineOrchestrator *-- DataGenerator BiergartenPipelineOrchestrator *-- DataGenerator
BiergartenPipelineOrchestrator *-- ExportService BiergartenPipelineOrchestrator *-- ExportService
BiergartenPipelineOrchestrator *-- AddressService
BiergartenPipelineOrchestrator *-- CheckinDistributionStrategy BiergartenPipelineOrchestrator *-- CheckinDistributionStrategy
BiergartenPipelineOrchestrator *-- FollowGenerationStrategy BiergartenPipelineOrchestrator *-- FollowGenerationStrategy
BiergartenPipelineOrchestrator *-- SamplingStrategy BiergartenPipelineOrchestrator *-- SamplingStrategy
@@ -611,7 +562,6 @@ classDiagram
WebClient <|.. HttpWebClient WebClient <|.. HttpWebClient
DataGenerator <|.. MockGenerator DataGenerator <|.. MockGenerator
DataGenerator <|.. LlamaGenerator DataGenerator <|.. LlamaGenerator
DataGenerator <|.. OpenAIGenerator
PromptFormatter <|.. Gemma4JinjaPromptFormatter PromptFormatter <|.. Gemma4JinjaPromptFormatter
ExportService <|.. SqliteExportService ExportService <|.. SqliteExportService
DateTimeProvider <|.. SystemDateTimeProvider DateTimeProvider <|.. SystemDateTimeProvider
@@ -621,8 +571,6 @@ classDiagram
LlamaGenerator *-- PromptFormatter LlamaGenerator *-- PromptFormatter
LlamaGenerator *-- IPromptDirectory LlamaGenerator *-- IPromptDirectory
LlamaGenerator ..> GeneratorOptions LlamaGenerator ..> GeneratorOptions
OpenAIGenerator *-- IPromptDirectory
OpenAIGenerator *-- WebClient
SqliteExportService *-- DateTimeProvider SqliteExportService *-- DateTimeProvider
PipelineLogger o-- BoundedChannel : logs to PipelineLogger o-- BoundedChannel : logs to

View File

@@ -77,7 +77,7 @@ flowchart TB
subgraph SHARED["Shared Projects"] subgraph SHARED["Shared Projects"]
direction LR direction LR
SH1["Shared.Contracts<br/>ResponseBody envelope"] SH1["Shared.Contracts<br/>ResponseBody envelope"]
SH2["Shared.Application<br/>ValidationBehavior"] SH2["Shared.Application<br/>ValidationBehavior,<br/>cross-slice email commands"]
end end
subgraph DOMAIN["Domain Layer"] subgraph DOMAIN["Domain Layer"]
@@ -89,14 +89,16 @@ flowchart TB
subgraph INFRA["Infrastructure Layer"] subgraph INFRA["Infrastructure Layer"]
direction LR direction LR
I1["Database.Connection"] I1["Database.Connection"]
I2["Infrastructure.Jwt"]
I3["Infrastructure.PasswordHashing"] I3["Infrastructure.PasswordHashing"]
I4["Email.Templates"] I4["Infrastructure.Email /<br/>Email.Templates"]
I5["Infrastructure.FileUpload"]
end end
DB[("Database (SQL Server)")] DB[("Database (SQL Server)")]
HOST -- "discovers controllers via<br/>AddApplicationPart" --> SLICES HOST -- "discovers controllers via<br/>AddApplicationPart" --> SLICES
S3 == "ProjectReference<br/>(handles UserRegisteredNotification & ConfirmationEmailResendRequestedNotification)" ==> S1 S1 -- "SendRegistrationEmailCommand<br/>(via Shared.Application contract)" --> S3
S1 -- "ProjectReference<br/>(avatar upload)" --> S5 S1 -- "ProjectReference<br/>(avatar upload)" --> S5
SLICES --> SHARED SLICES --> SHARED
@@ -121,8 +123,7 @@ own
| Component | Description | | Component | Description |
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `Program.cs` | Thin composition root: builds the host and calls the `ServiceCollectionExtensions` methods below. | | `Program.cs` | Registers MediatR (scanning every `Features.*` assembly), FluentValidation, and uses `AddApplicationPart` so each slice's controllers are discovered by MVC. |
| `ServiceCollectionExtensions.cs` | Registers MediatR (scanning every `Features.*` assembly), FluentValidation, and uses `AddApplicationPart` so each slice's controllers are discovered by MVC. |
| `GlobalException.cs` | Global exception filter (host-level cross-cutting concern). | | `GlobalException.cs` | Global exception filter (host-level cross-cutting concern). |
| `Authentication/JwtAuthenticationHandler.cs` | JWT auth scheme middleware. | | `Authentication/JwtAuthenticationHandler.cs` | JWT auth scheme middleware. |
| Swagger/OpenAPI documentation, health check endpoints | Host-level documentation and health endpoints. | | Swagger/OpenAPI documentation, health check endpoints | Host-level documentation and health endpoints. |
@@ -131,6 +132,7 @@ own
- Every `Features.*` project (for controller/MediatR discovery) - Every `Features.*` project (for controller/MediatR discovery)
- `Shared.Contracts`, `Shared.Application` - `Shared.Contracts`, `Shared.Application`
- `Infrastructure.Jwt` (for the auth middleware)
**Rules**: **Rules**:
@@ -164,21 +166,19 @@ brewery photo upload command) rather than bound to an HTTP route directly.
- `Domain.Entities`, `Domain.Exceptions` - `Domain.Entities`, `Domain.Exceptions`
- `Database.Connection` (generic ADO.NET connection/command plumbing) plus - `Database.Connection` (generic ADO.NET connection/command plumbing) plus
whichever infrastructure project the slice needs (`Infrastructure.PasswordHashing` whichever infrastructure project the slice needs (`Infrastructure.Jwt`/
for Users, `Infrastructure.Email.Templates` for Emails) `Infrastructure.PasswordHashing` for Users, `Infrastructure.Email`/
`Infrastructure.Email.Templates` for Emails, `Infrastructure.FileUpload` for
PhotoUpload)
- `Shared.Contracts`, `Shared.Application` - `Shared.Contracts`, `Shared.Application`
- No other `Features.*` project, with two exceptions: `Features.Users` - No other `Features.*` project, with one exception: `Features.Users` references
references `Features.PhotoUpload` directly for avatar uploads, and `Features.PhotoUpload` directly for avatar uploads
`Features.Emails` references `Features.Users` directly to handle
`UserRegisteredNotification` and `ConfirmationEmailResendRequestedNotification`
**Rules**: **Rules**:
- All business logic for that feature lives in its command/query handlers - 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` rather than the domain entity - Read endpoints return a dedicated `Dto`, never the domain entity directly
directly. The one exception is `UserController.GetAuthenticated`, which
returns the caller's own `UserAccount` entity
#### Shared projects (`Shared.Contracts`, `Shared.Application`) #### Shared projects (`Shared.Contracts`, `Shared.Application`)
@@ -189,13 +189,13 @@ needs it, or because duplicating it four times would be worse than sharing it
| Project | Purpose | | Project | Purpose |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Shared.Contracts` | `ResponseBody<T>`/`ResponseBody`, the API response envelope `AuthController`, `BreweryController`, and `CityController` return; `UserController`'s read endpoints return their DTOs unwrapped | | `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 | | `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**: **Rules**:
- Kept deliberately small: this is the exception to "no slice depends on another - Kept deliberately small: this is the exception to "no slice depends on another
slice". slice," not a general-purpose dumping ground
#### Infrastructure layer #### Infrastructure layer
@@ -207,8 +207,11 @@ whichever slices need them
| Project | Description | | Project | Description |
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Database.Connection` | Generic ADO.NET connection/command plumbing (`DefaultSqlConnectionFactory`, the abstract `DapperRepository` base class), not domain-specific; each slice's own `Repository/` folder builds on this. | | `Database.Connection` | Generic ADO.NET connection/command plumbing (`DefaultSqlConnectionFactory`, the abstract `DapperRepository` base class), not domain-specific; each slice's own `Repository/` folder builds on this. |
| `Infrastructure.Jwt` | JWT token generation and validation. |
| `Infrastructure.PasswordHashing` | Argon2id password hashing. | | `Infrastructure.PasswordHashing` | Argon2id password hashing. |
| `Infrastructure.Email` | Email sending capabilities (SMTP/MailKit). |
| `Infrastructure.Email.Templates` | Email template rendering (Razor components). | | `Infrastructure.Email.Templates` | Email template rendering (Razor components). |
| `Infrastructure.FileUpload` | `IFileStorageProvider` abstraction over S3-compatible object storage, implemented by `S3FileStorageProvider` (targets the SeaweedFS container in dev; any S3-compatible endpoint in other environments) using the AWS SDK's `AmazonS3Client` with `ForcePathStyle = true`. |
**Dependencies**: **Dependencies**:
@@ -266,8 +269,7 @@ everything needed to understand or change one capability lives in one project
- Controllers generally bind directly to the Command/Query as the request body; - Controllers generally 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
- The exception to this is for authenticated routes, when the current user id - The exception to this is for authenticated routes, when the current user id
needs to be extracted from the authentication token to be used in a command needs to be extracted from the authentication token to be used in a command.
or query.
- A single shared `ValidationBehavior<TRequest,TResponse>` - A single shared `ValidationBehavior<TRequest,TResponse>`
(`Shared.Application/Behaviors/`) runs FluentValidation validators in the (`Shared.Application/Behaviors/`) runs FluentValidation validators in the
MediatR pipeline before any handler executes MediatR pipeline before any handler executes
@@ -382,7 +384,6 @@ web/frontend/
│ │ - catalog beers, beer styles │ │ - catalog beers, beer styles
│ │ - home landing page │ │ - home landing page
│ │ - theme theme switcher/guide │ │ - theme theme switcher/guide
│ │ - users public user profile page (/users/:id)
│ ├── hooks/ Shared hooks not tied to one feature, for example useMediaQuery │ ├── hooks/ Shared hooks not tied to one feature, for example useMediaQuery
│ ├── routes.ts Route table (React Router 8 config-based routing) │ ├── routes.ts Route table (React Router 8 config-based routing)
│ ├── root.tsx App shell and global providers │ ├── root.tsx App shell and global providers
@@ -399,8 +400,6 @@ web/frontend/
`/breweries/:id`) with Leaflet-powered maps and geolocation-based "nearby" `/breweries/:id`) with Leaflet-powered maps and geolocation-based "nearby"
search search
- Render the auth demo, account dashboard, and theme guide routes - Render the auth demo, account dashboard, and theme guide routes
- Render the public user profile route (`/users/:id`), including a user's
following list and (via brewery comments) links back to commenter profiles
- Manage cookie-backed website session state - Manage cookie-backed website session state
- Call the .NET API for login, registration, token refresh, confirmation, and - Call the .NET API for login, registration, token refresh, confirmation, and
brewery/location data brewery/location data
@@ -565,12 +564,11 @@ are not seeded either.
**Environments**: **Environments**:
| Environment | Compose file | | Environment | Compose file |
| --------------- | ------------------------------- | | ----------- | -------------------------- |
| Development | `docker-compose.dev.yaml` | | Development | `docker-compose.dev.yaml` |
| Testing | `docker-compose.test.yaml` | | Testing | `docker-compose.test.yaml` |
| Production | `docker-compose.prod.yaml` | | Production | `docker-compose.prod.yaml` |
| Production seed | `docker-compose.prod.seed.yaml` |
For details, see [Docker Guide](docker.md). For details, see [Docker Guide](docker.md).

View File

@@ -15,51 +15,33 @@ convention. For the vertical-slice code that talks to this schema, see
## Schema ## Schema
The schema is split into six SQL Server schemas, one per feature area, each The full schema lives under
living in its own numbered folder under `web/backend/Database/Database.Migrations/scripts/01-schema/` with one numbered
`web/backend/Database/Database.Migrations/scripts/`. The folder numbers script per table.
(`01`-`06`) fix the load order so foreign-key dependencies resolve — DbUp runs
embedded scripts in alphabetical-by-resource-name order, so e.g. `04-Brewery`
(which references `Auth`, `Geolocation`, and `Media`) runs after all three.
Each folder's `00-Schema.sql` creates its SQL schema before its tables run:
``` ```
scripts 01-schema
├── 01-Auth (UserAccount, UserVerification, UserCredential) ├── 01-UserAccount.sql
│ ├── 00-Schema.sql ├── 02-Photo.sql
│ ├── 01-UserAccount.sql ├── 03-UserVerification.sql
│ ├── 02-UserVerification.sql ├── 04-UserCredential.sql
│ └── 03-UserCredential.sql ├── 05-UserFollow.sql
├── 02-Geolocation (Country, StateProvince, City) ├── 06-Country.sql
│ ├── 00-Schema.sql ├── 07-StateProvince.sql
│ ├── 01-Country.sql ├── 08-City.sql
│ ├── 02-StateProvince.sql ├── 09-BreweryPost.sql
│ └── 03-City.sql ├── 10-BreweryPostLocation.sql
├── 03-Media (Photo) ├── 11-BreweryPostPhoto.sql
│ ├── 00-Schema.sql ├── 12-BeerStyle.sql
│ └── 01-Photo.sql ├── 13-BeerPost.sql
├── 04-Brewery (BreweryPost, BreweryPostLocation, BreweryPostPhoto) ├── 14-BeerPostPhoto.sql
│ ├── 00-Schema.sql ├── 15-BeerPostComment.sql
│ ├── 01-BreweryPost.sql ├── 16-UserProfile.sql
│ ├── 02-BreweryPostLocation.sql └── 17-UserAvatar.sql
│ └── 03-BreweryPostPhoto.sql
├── 05-Beer (BeerStyle, BeerPost, BeerPostPhoto, BeerPostComment)
│ ├── 00-Schema.sql
│ ├── 01-BeerStyle.sql
│ ├── 02-BeerPost.sql
│ ├── 03-BeerPostPhoto.sql
│ └── 04-BeerPostComment.sql
└── 06-Social (UserFollow, UserProfile, UserAvatar)
├── 00-Schema.sql
├── 01-UserFollow.sql
├── 02-UserProfile.sql
└── 03-UserAvatar.sql
``` ```
Each script is run in order by `Database.Migrations` (DbUp) on startup. Tables Each script is run in order by `Database.Migrations` (DbUp) on startup. Each
are referenced schema-qualified in code (e.g. `Auth.UserAccount`), not under table is queried directly by the owning feature slice's repository (see
the default `dbo` schema. Each table is queried directly by the owning
feature slice's repository (see
[Direct SQL via Dapper/ADO.NET](architecture.md#direct-sql-via-dapperadonet)). [Direct SQL via Dapper/ADO.NET](architecture.md#direct-sql-via-dapperadonet)).
A generated DBML copy of the schema (for import into a diagramming tool such as A generated DBML copy of the schema (for import into a diagramming tool such as
@@ -76,14 +58,14 @@ updates (`UPDATE ... WHERE ... AND RowVersion = @RowVersion`).
| `UserAccount` | `Domain.Entities.UserAccount`, `Features.Users` | `Username` and `Email` are unique | | `UserAccount` | `Domain.Entities.UserAccount`, `Features.Users` | `Username` and `Email` are unique |
| `UserCredential` | `Domain.Entities.UserCredential`, `Features.Users` | Argon2id password hash; `IsRevoked`/`RevokedAt` track credential rotation, not JWTs | | `UserCredential` | `Domain.Entities.UserCredential`, `Features.Users` | Argon2id password hash; `IsRevoked`/`RevokedAt` track credential rotation, not JWTs |
| `UserVerification` | `Domain.Entities.UserVerification`, `Features.Users` | Written when a user confirms their email | | `UserVerification` | `Domain.Entities.UserVerification`, `Features.Users` | Written when a user confirms their email |
| `UserProfile` | `Domain.Entities.UserProfile`, `Features.Users` | 1:1 with `UserAccount`; holds the user's biography; written via `IUserProfileRepository`, read via `IUserListRepository`'s left joins in both `GetByIdAsync` and `GetAllAsync` | | `UserProfile` | `Domain.Entities.UserProfile`, `Features.Users` | 1:1 with `UserAccount`; holds the user's biography; written via `IUserProfileRepository` |
| `UserAvatar` | `Domain.Entities.UserAvatar`, `Features.Users` | Links a `UserProfile` to a `Photo` as its avatar; written via `IUserProfileRepository.SaveAvatarAsync`, read via `IUserListRepository`'s `ValidTo IS NULL` join for the active avatar | | `UserAvatar` | `Domain.Entities.UserAvatar`, `Features.Users` | Links a `UserProfile` to a `Photo` as its avatar; written via `IUserProfileRepository.SaveAvatarAsync` |
| `BreweryPost` | `Domain.Entities.BreweryPost`, `Features.Breweries` | A user-submitted brewery listing | | `BreweryPost` | `Domain.Entities.BreweryPost`, `Features.Breweries` | A user-submitted brewery listing |
| `BreweryPostLocation` | `Domain.Entities.BreweryPostLocation`, `Features.Breweries` | 1:1 with `BreweryPost`; `Coordinates` is a `GEOGRAPHY` column, read via a plain Dapper query that selects `CONVERT(varbinary(max), Coordinates)`, since Dapper can't deserialize SQL Server UDTs directly | | `BreweryPostLocation` | `Domain.Entities.BreweryPostLocation`, `Features.Breweries` | 1:1 with `BreweryPost`; `Coordinates` is a `GEOGRAPHY` column, read via a plain Dapper query that selects `CONVERT(varbinary(max), Coordinates)`, since Dapper can't deserialize SQL Server UDTs directly |
| `City` | `Domain.Entities.City`, `Features.Locations` | Referenced by `BreweryPostLocation.CityID`; `CityName` and `StateProvinceID` together are unique; written via `ILocationRepository`, read via `ILocationRepository` and `IBreweryRepository`'s location joins, currently written only by `Database.Seed` | | `City` | `Domain.Entities.City`, `Features.Locations` | Referenced by `BreweryPostLocation.CityID`; written via `ILocationRepository`, read via `ILocationRepository` and `IBreweryRepository`'s location joins, currently written only by `Database.Seed` |
| `StateProvince` | `Domain.Entities.StateProvince`, `Features.Locations` | Referenced by `City.StateProvinceID`; created on demand by `ILocationRepository.GetOrCreateCityIdAsync`, read via `IBreweryRepository`'s location joins | | `StateProvince` | `Domain.Entities.StateProvince`, `Features.Locations` | Referenced by `City.StateProvinceID`; created on demand by `ILocationRepository.GetOrCreateCityIdAsync`, read via `IBreweryRepository`'s location joins |
| `Country` | `Domain.Entities.Country`, `Features.Locations` | Referenced by `StateProvince.CountryID`; created on demand by `ILocationRepository.GetOrCreateCityIdAsync`, read via `IBreweryRepository`'s location joins | | `Country` | `Domain.Entities.Country`, `Features.Locations` | Referenced by `StateProvince.CountryID`; created on demand by `ILocationRepository.GetOrCreateCityIdAsync`, read via `IBreweryRepository`'s location joins |
| `Photo` | `Domain.Entities.Photo`, `Features.PhotoUpload` | Written via `IPhotoUploadRepository.CreateAsync`, sent as an `UploadPhotoCommand` from other features' handlers; `Hyperlink` holds the object key within the bucket, which readers sign into a time-limited URL via `IFileStorageProvider.GetPresignedUrl`; the active avatar's row is also read via `IUserListRepository`'s `Media.Photo` join; not yet seeded by `Database.Seed` | | `Photo` | `Domain.Entities.Photo`, `Features.PhotoUpload` | Written via `IPhotoUploadRepository.CreateAsync`, sent as an `UploadPhotoCommand` from other features' handlers; not yet seeded by `Database.Seed` |
### Tables defined in the schema but not yet wired to a feature slice ### Tables defined in the schema but not yet wired to a feature slice

View File

@@ -2,19 +2,21 @@ sequenceDiagram
actor User actor User
participant API as API Controller participant API as API Controller
participant Mediator as MediatR pipeline<br/>(ValidationBehavior) participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
box Handler Layer box rgb(241,243,234) Handler Layer
participant ConfirmHandler as ConfirmUserHandler participant ConfirmHandler as ConfirmUserHandler
participant UserMgr as UserManager(ApplicationUser) participant UserMgr as UserManager(ApplicationUser)
participant TokenSvc as TokenService participant TokenSvc as TokenService
end end
box Infrastructure Layer box rgb(235,236,227) Infrastructure Layer
participant JWT as JWT Infrastructure participant JWT as JWT Infrastructure
end end
box Repository / Cross-slice box rgb(219,238,221) Repository / Cross-slice
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore) participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
end end
participant DB as SQL Server participant DB as SQL Server
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
User->>API: POST /api/auth/confirm?token={token} (requires Bearer access token) User->>API: POST /api/auth/confirm?token={token} (requires Bearer access token)
activate API activate API
API->>Mediator: Send(ConfirmUserCommand(token)) API->>Mediator: Send(ConfirmUserCommand(token))

View File

@@ -2,15 +2,17 @@ sequenceDiagram
actor User actor User
participant API as API Controller participant API as API Controller
participant Mediator as MediatR pipeline<br/>(ValidationBehavior) participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
box Handler Layer box rgb(241,243,234) Handler Layer
participant DeleteAccountHandler participant DeleteAccountHandler
participant UserMgr as UserManager(ApplicationUser) participant UserMgr as UserManager(ApplicationUser)
end end
box Repository / Cross-slice box rgb(219,238,221) Repository / Cross-slice
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore) participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
end end
participant DB as SQL Server participant DB as SQL Server
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
User->>API: DELETE /api/auth/account (requires Bearer access token) User->>API: DELETE /api/auth/account (requires Bearer access token)
activate API activate API
API->>Mediator: Send(DeleteAccountCommand(GetAuthenticatedUserId())) API->>Mediator: Send(DeleteAccountCommand(GetAuthenticatedUserId()))
@@ -35,7 +37,7 @@ sequenceDiagram
UserMgr->>UserStore: DeleteAsync(user) UserMgr->>UserStore: DeleteAsync(user)
activate UserStore activate UserStore
UserStore->>DB: DELETE FROM UserAccount WHERE UserAccountID = @Id UserStore->>DB: DELETE FROM UserAccount WHERE UserAccountID = @Id
Note right of UserStore: UserCredential, UserVerification, and UserProfile cascade on delete<br/>(UserAvatar cascades transitively through UserProfile).<br/>Posts, comments, photos, and follows use ON DELETE NO ACTION<br/>if any reference this account, the DELETE throws a foreign key violation (SQL error 547) Note right of UserStore: UserCredential, UserVerification, and UserProfile cascade on delete (UserAvatar cascades transitively through UserProfile). Posts, comments, photos, and follows use ON DELETE NO ACTION — if any reference this account, the DELETE throws a foreign key violation (SQL error 547)
alt Foreign key violation (dependent records exist) alt Foreign key violation (dependent records exist)
UserStore-->>UserMgr: IdentityResult.Failed (AccountHasDependentRecords) UserStore-->>UserMgr: IdentityResult.Failed (AccountHasDependentRecords)
@@ -47,7 +49,7 @@ sequenceDiagram
deactivate UserMgr deactivate UserMgr
alt Account has dependent records alt Account has dependent records
DeleteAccountHandler->>Mediator: throw ConflictException<br>"Account cannot be deleted while it still has associated posts, comments, photos, or follows." DeleteAccountHandler->>Mediator: throw ConflictException "Account cannot be deleted while it still has associated posts, comments, photos, or follows."
Mediator->>API: propagate Mediator->>API: propagate
API->>User: 409 Conflict API->>User: 409 Conflict
else !result.Succeeded (other failure) else !result.Succeeded (other failure)

View File

@@ -2,20 +2,22 @@ sequenceDiagram
actor User actor User
participant API as API Controller participant API as API Controller
participant Mediator as MediatR pipeline<br/>(ValidationBehavior) participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
box Handler Layer box rgb(241,243,234) Handler Layer
participant LoginHandler participant LoginHandler
participant UserMgr as UserManager(ApplicationUser) participant UserMgr as UserManager(ApplicationUser)
participant TokenSvc as TokenService participant TokenSvc as TokenService
end end
box Infrastructure Layer box rgb(235,236,227) Infrastructure Layer
participant Argon2 as Argon2PasswordHasher participant Argon2 as Argon2PasswordHasher
participant JWT as JWT Infrastructure participant JWT as JWT Infrastructure
end end
box Repository / Cross-slice box rgb(219,238,221) Repository / Cross-slice
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore) participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
end end
participant DB as SQL Server participant DB as SQL Server
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
User->>API: POST /api/auth/login {username, password} User->>API: POST /api/auth/login {username, password}
activate API activate API
API->>Mediator: Send(LoginCommand) API->>Mediator: Send(LoginCommand)

View File

@@ -2,19 +2,21 @@ sequenceDiagram
actor User actor User
participant API as API Controller participant API as API Controller
participant Mediator as MediatR pipeline<br/>(ValidationBehavior) participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
box Handler Layer box rgb(241,243,234) Handler Layer
participant RefreshHandler as RefreshTokenHandler participant RefreshHandler as RefreshTokenHandler
participant UserMgr as UserManager(ApplicationUser) participant UserMgr as UserManager(ApplicationUser)
participant TokenSvc as TokenService participant TokenSvc as TokenService
end end
box Infrastructure Layer box rgb(235,236,227) Infrastructure Layer
participant JWT as JWT Infrastructure participant JWT as JWT Infrastructure
end end
box Repository / Cross-slice box rgb(219,238,221) Repository / Cross-slice
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore) participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
end end
participant DB as SQL Server participant DB as SQL Server
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
User->>API: POST /api/auth/refresh<br/>Header: X-Refresh-Token: {token} User->>API: POST /api/auth/refresh<br/>Header: X-Refresh-Token: {token}
activate API activate API
API->>Mediator: Send(RefreshTokenCommand(refreshToken)) API->>Mediator: Send(RefreshTokenCommand(refreshToken))

View File

@@ -2,24 +2,26 @@ sequenceDiagram
actor User actor User
participant API as API Controller participant API as API Controller
participant Mediator as MediatR pipeline<br/>(ValidationBehavior) participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
box Handler Layer box rgb(241,243,234) Handler Layer
participant RegHandler as RegisterUserHandler participant RegHandler as RegisterUserHandler
participant UserMgr as UserManager(ApplicationUser) participant UserMgr as UserManager(ApplicationUser)
participant TokenSvc as TokenService participant TokenSvc as TokenService
end end
box Infrastructure Layer box rgb(235,236,227) Infrastructure Layer
participant Argon2 as Argon2PasswordHasher participant Argon2 as Argon2PasswordHasher
participant JWT as JWT Infrastructure participant JWT as JWT Infrastructure
participant SMTP as Email Provider participant SMTP as Email Provider
participant Template as Template Provider participant Template as Template Provider
end end
box Repository / Cross-slice box rgb(219,238,221) Repository / Cross-slice
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore) participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
participant ProfileHandler as CreateUserProfileHandler<br/>(Profile slice) participant ProfileHandler as CreateUserProfileHandler<br/>(Profile slice)
participant EmailDispatcher as EmailDispatcher<br/>(Features.Emails) participant EmailDispatcher as EmailDispatcher<br/>(Features.Emails)
end end
participant DB as SQL Server participant DB as SQL Server
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
User->>API: POST /api/auth/register {username, firstName, lastName, email, dateOfBirth, password} User->>API: POST /api/auth/register {username, firstName, lastName, email, dateOfBirth, password}
activate API activate API
API->>Mediator: Send(RegisterUserCommand) API->>Mediator: Send(RegisterUserCommand)
@@ -39,7 +41,7 @@ sequenceDiagram
UserMgr->>Argon2: HashPassword(user, password) UserMgr->>Argon2: HashPassword(user, password)
activate Argon2 activate Argon2
Note right of Argon2: Argon2id — salt 16 bytes, memory 64MB, iterations 4, parallelism 2 (fixed, to avoid thread-pool exhaustion), hash output 32 bytes Note right of Argon2: Argon2id — salt 16 bytes, memory 64MB, iterations 4, parallelism 2 (fixed, to avoid thread-pool exhaustion), hash output 32 bytes
Argon2-->>UserMgr: PHC string in Argon2id format (algorithm, version, memory/time/parallelism params, salt, hash) Argon2-->>UserMgr: "$argon2id$v=19$m=65536,t=4,p=2$&lt;salt&gt;$&lt;hash&gt;" (PHC string)
deactivate Argon2 deactivate Argon2
UserMgr->>UserStore: SetPasswordHashAsync(user, hash) UserMgr->>UserStore: SetPasswordHashAsync(user, hash)
@@ -71,7 +73,7 @@ sequenceDiagram
UserStore->>DB: INSERT INTO UserCredential (UserAccountId, Hash) UserStore->>DB: INSERT INTO UserCredential (UserAccountId, Hash)
UserStore->>DB: COMMIT TRANSACTION UserStore->>DB: COMMIT TRANSACTION
deactivate DB deactivate DB
Note right of UserStore: Both statements run via Dapper against a single DbTransaction Note right of UserStore: Both statements run via Dapper against a single DbTransaction, not a stored procedure
UserStore-->>UserMgr: IdentityResult.Success (user.Id set) UserStore-->>UserMgr: IdentityResult.Success (user.Id set)
deactivate UserStore deactivate UserStore
@@ -127,9 +129,9 @@ sequenceDiagram
TokenSvc-->>RegHandler: Confirmation Token TokenSvc-->>RegHandler: Confirmation Token
deactivate TokenSvc deactivate TokenSvc
RegHandler->>Mediator: Publish(UserRegisteredNotification) RegHandler->>Mediator: Send(SendRegistrationEmailCommand) (Shared.Application cross-slice contract)
activate Mediator activate Mediator
Note right of Mediator: Features.Emails has a direct ProjectReference on Features.Users and handles the notification via UserRegisteredNotificationHandler — the second of two cross-slice dispatches in this flow (profile creation is the first) Note right of Mediator: Routed to Features.Emails' SendRegistrationEmailHandler — the second of two cross-slice dispatches in this flow (profile creation is the first)
Mediator->>EmailDispatcher: SendRegistrationEmailAsync(firstName, email, confirmationToken) Mediator->>EmailDispatcher: SendRegistrationEmailAsync(firstName, email, confirmationToken)
activate EmailDispatcher activate EmailDispatcher
@@ -147,15 +149,15 @@ sequenceDiagram
SMTP-->>EmailDispatcher: Success / throws SMTP-->>EmailDispatcher: Success / throws
deactivate SMTP deactivate SMTP
deactivate EmailDispatcher deactivate EmailDispatcher
alt Email sent successfully
Note right of Mediator: UserRegisteredNotificationHandler completes normally
else EmailDispatcher throws
Note right of Mediator: UserRegisteredNotificationHandler catches and logs the exception — registration is unaffected
end
deactivate Mediator deactivate Mediator
RegHandler-->>Mediator: RegistrationPayload(userAccountId, username, refreshToken, accessToken) alt Email sent successfully
RegHandler->>RegHandler: emailSent = true
else mediator.Send throws
RegHandler->>RegHandler: catch(Exception), log to stderr, emailSent = false
end
RegHandler-->>Mediator: RegistrationPayload(userAccountId, username, refreshToken, accessToken, confirmationEmailSent)
deactivate RegHandler deactivate RegHandler
Mediator-->>API: RegistrationPayload Mediator-->>API: RegistrationPayload
deactivate Mediator deactivate Mediator

View File

@@ -2,21 +2,23 @@ sequenceDiagram
actor User actor User
participant API as API Controller participant API as API Controller
participant Mediator as MediatR pipeline<br/>(ValidationBehavior) participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
box Handler Layer box rgb(241,243,234) Handler Layer
participant ResendHandler as ResendConfirmationEmailHandler participant ResendHandler as ResendConfirmationEmailHandler
participant UserMgr as UserManager(ApplicationUser) participant UserMgr as UserManager(ApplicationUser)
participant TokenSvc as TokenService participant TokenSvc as TokenService
end end
box Infrastructure Layer box rgb(235,236,227) Infrastructure Layer
participant SMTP as Email Provider participant SMTP as Email Provider
participant Template as Template Provider participant Template as Template Provider
end end
box Repository / Cross-slice box rgb(219,238,221) Repository / Cross-slice
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore) participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
participant EmailDispatcher as EmailDispatcher<br/>(Features.Emails) participant EmailDispatcher as EmailDispatcher<br/>(Features.Emails)
end end
participant DB as SQL Server participant DB as SQL Server
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
User->>API: POST /api/auth/confirm/resend?userId={id} (requires Bearer access token) User->>API: POST /api/auth/confirm/resend?userId={id} (requires Bearer access token)
activate API activate API
API->>Mediator: Send(ResendConfirmationEmailCommand(userId)) API->>Mediator: Send(ResendConfirmationEmailCommand(userId))
@@ -51,20 +53,13 @@ sequenceDiagram
TokenSvc-->>ResendHandler: confirmation token TokenSvc-->>ResendHandler: confirmation token
deactivate TokenSvc deactivate TokenSvc
ResendHandler->>Mediator: Publish(ConfirmationEmailResendRequestedNotification) ResendHandler->>Mediator: Send(SendResendConfirmationEmailCommand)
activate Mediator activate Mediator
Note right of Mediator: Features.Emails has a direct ProjectReference on Features.Users and handles the notification via ConfirmationEmailResendRequestedNotificationHandler
Mediator->>EmailDispatcher: SendResendConfirmationEmailAsync(firstName, email, confirmationToken) Mediator->>EmailDispatcher: SendResendConfirmationEmailAsync(firstName, email, confirmationToken)
activate EmailDispatcher activate EmailDispatcher
EmailDispatcher->>Template: Render email (same Razor pipeline as registration) EmailDispatcher->>Template: Render email (same Razor pipeline as registration)
EmailDispatcher->>SMTP: SendAsync(email, subject, body) EmailDispatcher->>SMTP: SendAsync(email, subject, body)
deactivate EmailDispatcher deactivate EmailDispatcher
alt Email sent successfully
Note right of Mediator: ConfirmationEmailResendRequestedNotificationHandler completes normally
else EmailDispatcher throws
Note right of Mediator: ConfirmationEmailResendRequestedNotificationHandler catches and logs the exception — the resend request is unaffected
end
deactivate Mediator deactivate Mediator
end end
end end

View File

@@ -2,15 +2,17 @@ sequenceDiagram
actor User actor User
participant API as API Controller participant API as API Controller
participant Mediator as MediatR pipeline<br/>(ValidationBehavior) participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
box Handler Layer box rgb(241,243,234) Handler Layer
participant UpdateEmailHandler participant UpdateEmailHandler
participant UserMgr as UserManager(ApplicationUser) participant UserMgr as UserManager(ApplicationUser)
end end
box Repository / Cross-slice box rgb(219,238,221) Repository / Cross-slice
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore) participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
end end
participant DB as SQL Server participant DB as SQL Server
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
User->>API: PATCH /api/auth/email {newEmail} (requires Bearer access token) User->>API: PATCH /api/auth/email {newEmail} (requires Bearer access token)
activate API activate API
API->>Mediator: Send(UpdateEmailCommand(GetAuthenticatedUserId(), newEmail)) API->>Mediator: Send(UpdateEmailCommand(GetAuthenticatedUserId(), newEmail))

View File

@@ -2,18 +2,20 @@ sequenceDiagram
actor User actor User
participant API as API Controller participant API as API Controller
participant Mediator as MediatR pipeline<br/>(ValidationBehavior) participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
box Handler Layer box rgb(241,243,234) Handler Layer
participant UpdatePasswordHandler participant UpdatePasswordHandler
participant UserMgr as UserManager(ApplicationUser) participant UserMgr as UserManager(ApplicationUser)
end end
box Infrastructure Layer box rgb(235,236,227) Infrastructure Layer
participant Argon2 as Argon2PasswordHasher participant Argon2 as Argon2PasswordHasher
end end
box Repository / Cross-slice box rgb(219,238,221) Repository / Cross-slice
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore) participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
end end
participant DB as SQL Server participant DB as SQL Server
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
User->>API: PATCH /api/auth/password {currentPassword, newPassword} (requires Bearer access token) User->>API: PATCH /api/auth/password {currentPassword, newPassword} (requires Bearer access token)
activate API activate API
API->>Mediator: Send(UpdatePasswordCommand(GetAuthenticatedUserId(), currentPassword, newPassword)) API->>Mediator: Send(UpdatePasswordCommand(GetAuthenticatedUserId(), currentPassword, newPassword))

View File

@@ -2,15 +2,17 @@ sequenceDiagram
actor User actor User
participant API as API Controller participant API as API Controller
participant Mediator as MediatR pipeline<br/>(ValidationBehavior) participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
box Handler Layer box rgb(241,243,234) Handler Layer
participant UpdateProfileHandler participant UpdateProfileHandler
participant UserMgr as UserManager(ApplicationUser) participant UserMgr as UserManager(ApplicationUser)
end end
box Repository / Cross-slice box rgb(219,238,221) Repository / Cross-slice
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore) participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
end end
participant DB as SQL Server participant DB as SQL Server
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
User->>API: PATCH /api/auth/profile {firstName, lastName, dateOfBirth} (requires Bearer access token) User->>API: PATCH /api/auth/profile {firstName, lastName, dateOfBirth} (requires Bearer access token)
activate API activate API
API->>Mediator: Send(UpdateProfileCommand(GetAuthenticatedUserId(), firstName, lastName, dateOfBirth)) API->>Mediator: Send(UpdateProfileCommand(GetAuthenticatedUserId(), firstName, lastName, dateOfBirth))

View File

@@ -2,15 +2,17 @@ sequenceDiagram
actor User actor User
participant API as API Controller participant API as API Controller
participant Mediator as MediatR pipeline<br/>(ValidationBehavior) participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
box Handler Layer box rgb(241,243,234) Handler Layer
participant UpdateUsernameHandler participant UpdateUsernameHandler
participant UserMgr as UserManager(ApplicationUser) participant UserMgr as UserManager(ApplicationUser)
end end
box Repository / Cross-slice box rgb(219,238,221) Repository / Cross-slice
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore) participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
end end
participant DB as SQL Server participant DB as SQL Server
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
User->>API: PATCH /api/auth/username {newUsername} (requires Bearer access token) User->>API: PATCH /api/auth/username {newUsername} (requires Bearer access token)
activate API activate API
API->>Mediator: Send(UpdateUsernameCommand(GetAuthenticatedUserId(), newUsername)) API->>Mediator: Send(UpdateUsernameCommand(GetAuthenticatedUserId(), newUsername))

View File

@@ -1,47 +1,9 @@
# Database Schema # Database Schema
Six diagrams, one per SQL Server schema — mirrors the `Auth` / `Geolocation` / Every table also has a `RowVersion ROWVERSION` column (optimistic concurrency),
`Media` / `Brewery` / `Beer` / `Social` schema split under omitted below for brevity. Tables marked `(*)` have no repository/feature slice
`web/backend/Database/Database.Migrations/scripts/`. Every table also has a yet.
`RowVersion ROWVERSION` column (optimistic concurrency), omitted below for
brevity. Tables marked `(*)` have no repository/feature slice yet.
Boxes with no attribute list (e.g. `Photo` in the auth diagram) are owned by a
different schema and shown only to keep the relationship visible; see the
diagram named after that schema for its full definition.
## Auth (`Auth`)
```mermaid ```mermaid
--8<-- "web/diagrams/database-schema/auth.mmd" --8<-- "web/diagrams/database-schema.mmd"
```
## Social (`Social`)
```mermaid
--8<-- "web/diagrams/database-schema/social.mmd"
```
## Media (`Media`)
```mermaid
--8<-- "web/diagrams/database-schema/media.mmd"
```
## Geolocation (`Geolocation`)
```mermaid
--8<-- "web/diagrams/database-schema/geolocation.mmd"
```
## Brewery (`Brewery`)
```mermaid
--8<-- "web/diagrams/database-schema/brewery.mmd"
```
## Beer (`Beer`)
```mermaid
--8<-- "web/diagrams/database-schema/beer.mmd"
``` ```

View File

@@ -0,0 +1,162 @@
erDiagram
User ||--o{ Cred : "has credentials"
User ||--o| Verify : "has verification"
User ||--o| Profile : "has profile"
Profile ||--o{ Avatar : "has avatar history"
Avatar }o--|| Photo : "uses photo"
User ||--o{ Follow : "follows"
User ||--o{ Follow : "followed by"
User ||--o{ Photo : "uploads"
Country ||--o{ StateProvince : "has"
StateProvince ||--o{ City : "has"
User ||--o{ Brewery : "posts"
Brewery ||--o| BreweryLoc : "has location"
City ||--o{ BreweryLoc : "located in"
Brewery ||--o{ BreweryPhoto : "has photos"
Photo ||--o{ BreweryPhoto : "used in"
User ||--o{ Beer : "posts"
BeerStyle ||--o{ Beer : "categorizes"
Brewery ||--o{ Beer : "brews"
Beer ||--o{ BeerPhoto : "has photos"
Photo ||--o{ BeerPhoto : "used in"
Beer ||--o{ BeerComment : "has comments"
User ||--o{ BeerComment : "comments"
User {
uniqueidentifier UserAccountID PK
varchar Username UK
nvarchar FirstName
nvarchar LastName
varchar Email UK
datetime CreatedAt
datetime UpdatedAt "nullable"
date DateOfBirth
}
Cred {
uniqueidentifier UserCredentialID PK
uniqueidentifier UserAccountID FK
datetime CreatedAt
datetime Expiry
nvarchar Hash
bit IsRevoked
datetime RevokedAt "nullable"
}
Verify {
uniqueidentifier UserVerificationID PK
uniqueidentifier UserAccountID "FK, UK"
datetime VerificationDateTime
}
Profile {
uniqueidentifier UserProfileID PK
uniqueidentifier UserAccountID "FK, UK"
nvarchar Biography
}
Avatar {
uniqueidentifier UserAvatarID PK
uniqueidentifier UserProfileID FK
uniqueidentifier PhotoID FK
datetime2 ValidFrom
datetime2 ValidTo "nullable; at most one row per UserProfileID has ValidTo IS NULL (active avatar)"
}
Follow {
uniqueidentifier UserFollowID PK
uniqueidentifier UserAccountID FK
uniqueidentifier FollowingID FK
datetime CreatedAt
}
Photo {
uniqueidentifier PhotoID PK
nvarchar Hyperlink "nullable"
uniqueidentifier UploadedByID FK
datetime UploadedAt
}
Country {
uniqueidentifier CountryID PK
nvarchar CountryName
char ISO3166_1 UK
}
StateProvince {
uniqueidentifier StateProvinceID PK
nvarchar StateProvinceName
char ISO3166_2 UK
uniqueidentifier CountryID FK
}
City {
uniqueidentifier CityID PK
nvarchar CityName
uniqueidentifier StateProvinceID FK
}
Brewery {
uniqueidentifier BreweryPostID PK
nvarchar BreweryName
uniqueidentifier PostedByID FK
nvarchar Description
datetime CreatedAt
datetime UpdatedAt "nullable"
}
BreweryLoc {
uniqueidentifier BreweryPostLocationID PK
uniqueidentifier BreweryPostID "FK, UK"
nvarchar AddressLine1
nvarchar AddressLine2 "nullable"
nvarchar PostalCode
uniqueidentifier CityID FK
geography Coordinates "nullable"
}
BreweryPhoto {
uniqueidentifier BreweryPostPhotoID PK
uniqueidentifier BreweryPostID FK
uniqueidentifier PhotoID FK
datetime LinkedAt
}
BeerStyle {
uniqueidentifier BeerStyleID PK
nvarchar StyleName UK
nvarchar Description "nullable"
}
Beer {
uniqueidentifier BeerPostID PK
nvarchar Name
nvarchar Description
decimal ABV
int IBU
uniqueidentifier PostedByID FK
uniqueidentifier BeerStyleID FK
uniqueidentifier BrewedByID FK
datetime CreatedAt
datetime UpdatedAt "nullable"
}
BeerPhoto {
uniqueidentifier BeerPostPhotoID PK
uniqueidentifier BeerPostID FK
uniqueidentifier PhotoID FK
datetime LinkedAt
}
BeerComment {
uniqueidentifier BeerPostCommentID PK
nvarchar Comment
uniqueidentifier BeerPostID FK
uniqueidentifier CommentedByID FK
int Rating
datetime CreatedAt
datetime UpdatedAt "nullable"
}

View File

@@ -1,44 +0,0 @@
---
config:
layout: elk
---
erDiagram
User ||--o{ Cred : "has credentials"
User ||--o| Verify : "has verification"
%% Cross-schema: defined in full in social.mmd / media.mmd / brewery.mmd / beer.mmd
User ||--o| Profile : "has profile"
User ||--o{ Follow : "follows"
User ||--o{ Follow : "followed by"
User ||--o{ Photo : "uploads"
User ||--o{ Brewery : "posts"
User ||--o{ Beer : "posts"
User ||--o{ BeerComment : "comments"
User {
uniqueidentifier UserAccountID PK
varchar Username UK
nvarchar FirstName
nvarchar LastName
varchar Email UK
datetime CreatedAt
datetime UpdatedAt "nullable"
date DateOfBirth
}
Cred {
uniqueidentifier UserCredentialID PK
uniqueidentifier UserAccountID FK
datetime CreatedAt
datetime Expiry
nvarchar Hash
bit IsRevoked
datetime RevokedAt "nullable"
}
Verify {
uniqueidentifier UserVerificationID PK
uniqueidentifier UserAccountID "FK, UK"
datetime VerificationDateTime
}

View File

@@ -1,51 +0,0 @@
---
config:
layout: elk
---
erDiagram
BeerStyle ||--o{ Beer : "categorizes"
Beer ||--o{ BeerPhoto : "has photos"
Beer ||--o{ BeerComment : "has comments"
%% Cross-schema: defined in full in auth.mmd / brewery.mmd / media.mmd
User ||--o{ Beer : "posts"
Brewery ||--o{ Beer : "brews"
User ||--o{ BeerComment : "comments"
BeerPhoto }o--|| Photo : "uses photo"
BeerStyle {
uniqueidentifier BeerStyleID PK
nvarchar StyleName UK
nvarchar Description "nullable"
}
Beer {
uniqueidentifier BeerPostID PK
nvarchar Name
nvarchar Description
decimal ABV
int IBU
uniqueidentifier PostedByID FK
uniqueidentifier BeerStyleID FK
uniqueidentifier BrewedByID FK
datetime CreatedAt
datetime UpdatedAt "nullable"
}
BeerComment {
uniqueidentifier BeerPostCommentID PK
nvarchar Comment
uniqueidentifier BeerPostID FK
uniqueidentifier CommentedByID FK
int Rating
datetime CreatedAt
datetime UpdatedAt "nullable"
}
BeerPhoto {
uniqueidentifier BeerPostPhotoID PK
uniqueidentifier BeerPostID FK
uniqueidentifier PhotoID FK
datetime LinkedAt
}

View File

@@ -1,41 +0,0 @@
---
config:
layout: elk
---
erDiagram
Brewery ||--o| BreweryLocation : "has location"
Brewery ||--o{ BreweryPhoto : "has photos"
Brewery ||--o{ Beer : "brews"
%% Cross-schema: defined in full in auth.mmd / geolocation.mmd / media.mmd / beer.mmd
User ||--o{ Brewery : "posts"
BreweryLocation }o--|| City : "located in"
BreweryPhoto }o--|| Photo : "uses photo"
Brewery {
uniqueidentifier BreweryPostID PK
nvarchar BreweryName
uniqueidentifier PostedByID FK
nvarchar Description
datetime CreatedAt
datetime UpdatedAt "nullable"
}
BreweryLocation {
uniqueidentifier BreweryPostLocationID PK
uniqueidentifier BreweryPostID "FK, UK"
nvarchar AddressLine1
nvarchar AddressLine2 "nullable"
nvarchar PostalCode
uniqueidentifier CityID FK
geography Coordinates "nullable"
}
BreweryPhoto {
uniqueidentifier BreweryPostPhotoID PK
uniqueidentifier BreweryPostID FK
uniqueidentifier PhotoID FK
datetime LinkedAt
}

View File

@@ -1,26 +0,0 @@
erDiagram
Country ||--o{ StateProvince : "has"
StateProvince ||--o{ City : "has"
%% Cross-schema: defined in full in brewery.mmd
City ||--o{ BreweryLocation : "located in"
Country {
uniqueidentifier CountryID PK
nvarchar CountryName
char ISO3166_1 UK
}
StateProvince {
uniqueidentifier StateProvinceID PK
nvarchar StateProvinceName
char ISO3166_2 UK
uniqueidentifier CountryID FK
}
%% CityName + StateProvinceID form a composite unique constraint (AK_CityStateProvince)
City {
uniqueidentifier CityID PK
nvarchar CityName UK
uniqueidentifier StateProvinceID FK,UK
}

View File

@@ -1,19 +0,0 @@
---
config:
layout: elk
---
erDiagram
User ||--o{ Photo : "uploads"
%% Cross-schema: defined in full in auth.mmd / social.mmd / brewery.mmd / beer.mmd
Avatar }o--|| Photo : "uses photo"
Photo ||--o{ BreweryPhoto : "used in"
Photo ||--o{ BeerPhoto : "used in"
Photo {
uniqueidentifier PhotoID PK
nvarchar Hyperlink "nullable"
uniqueidentifier UploadedByID FK
datetime UploadedAt
}

View File

@@ -1,34 +0,0 @@
---
config:
layout: elk
---
erDiagram
User ||--o| Profile : "has profile"
User ||--o{ Follow : "follows"
User ||--o{ Follow : "followed by"
Profile ||--o{ Avatar : "has avatar history"
%% Cross-schema: defined in full in auth.mmd / media.mmd
Avatar }o--|| Photo : "uses photo"
Profile {
uniqueidentifier UserProfileID PK
uniqueidentifier UserAccountID "FK, UK"
nvarchar Biography
}
Follow {
uniqueidentifier UserFollowID PK
uniqueidentifier UserAccountID FK
uniqueidentifier FollowingID FK
datetime CreatedAt
}
Avatar {
uniqueidentifier UserAvatarID PK
uniqueidentifier UserProfileID FK
uniqueidentifier PhotoID FK
datetime2 ValidFrom
datetime2 ValidTo "nullable; at most one row per UserProfileID has ValidTo IS NULL (active avatar)"
}

View File

@@ -1,38 +1,7 @@
# Docker Deployment Architecture # Docker Deployment Architecture
Five diagrams, one per Compose file — mixing all five networks in one graph made
it impossible to tell which containers actually talk to each other. Solid arrows
are real container traffic; dashed arrows are `depends_on` startup gating with
no data flowing over them.
## Development (`docker-compose.dev.yaml`)
```mermaid ```mermaid
--8<-- "web/diagrams/deployment/dev.mmd" --8<-- "web/diagrams/deployment.mmd"
```
## Test (`docker-compose.test.yaml`)
```mermaid
--8<-- "web/diagrams/deployment/test.mmd"
```
## Production (`docker-compose.prod.yaml`)
```mermaid
--8<-- "web/diagrams/deployment/prod.mmd"
```
## Database-only (`docker-compose.db.yaml`)
```mermaid
--8<-- "web/diagrams/deployment/db-only.mmd"
```
## Minimal (`docker-compose.min.yaml`)
```mermaid
--8<-- "web/diagrams/deployment/minimal.mmd"
``` ```
## Notes ## Notes
@@ -40,8 +9,8 @@ no data flowing over them.
| Environment | Details | | Environment | Details |
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Dev network** (`devnet`, bridge) | Internal DNS resolves service names such as `sqlserver` and `api.core`. Volumes: `sqlserverdata-dev`, `nuget-cache-dev`, `seaweedfsdata-dev`. Startup order: SQL Server (health check) → migrations (run-once) → seed (run-once, depends on `seaweedfs`) → API (long-running, depends on seed). | | **Dev network** (`devnet`, bridge) | Internal DNS resolves service names such as `sqlserver` and `api.core`. Volumes: `sqlserverdata-dev`, `nuget-cache-dev`, `seaweedfsdata-dev`. Startup order: SQL Server (health check) → migrations (run-once) → seed (run-once, depends on `seaweedfs`) → API (long-running, depends on seed). |
| **Test network** (`testnet`, bridge) | All test components are isolated; fresh DB instance each run (`CLEAR_DATABASE=true`, ephemeral `sqlserverdata-test` / `seaweedfsdata-test` volumes). `api.specs`, `unit.tests`, and `frontend.tests` all run as separate containers, in parallel once seeding completes; results are aggregated. | | **Test network** (`testnet`, bridge) | All test components are isolated; fresh DB instance each run (`CLEAR_DATABASE=true`, ephemeral `sqlserverdata-test` / `seaweedfsdata-test` volumes). `api.specs`, `unit.tests`, and `frontend.tests` all run as separate containers; results are aggregated. |
| **Prod network** (`prodnet`, bridge, named `biergarten-prodnet`) | Isolated from dev/test networks. Volumes: `biergartendata-prod` and `seaweedfsdata-prod`, both external, so `down -v` keeps them. Startup order: SeaweedFS (health check) → API (health check) → frontend, with `frontend` the only service publishing a host port, bound to `127.0.0.1`. Migrating and seeding run on demand as their own run-once jobs joining `biergarten-prodnet`, so an `up` or a reboot replays neither. | | **Prod network** (`prodnet`, bridge) | Isolated from dev/test networks. Volumes: `sqlserverdata-prod`, `nuget-cache-prod`. No seed step in production. |
| **Database-only environment** | Same devnet volumes/images as `docker-compose.dev.yaml`, minus `api.core`, `frontend`, and `mailpit` — for working against the database (and object storage, since `seaweedfs` is still included) without running the API or website. | | **Database-only environment** | Same devnet volumes/images as `docker-compose.dev.yaml`, minus `api.core`, `frontend`, and `mailpit` — for working against the database (and object storage, since `seaweedfs` is still included) without running the API or website. |
| **Minimal environment** | Database + migrations + seed + `seaweedfs` + mailpit, no API or frontend container — for frontend development against an `API.Core` process run outside Docker. | | **Minimal environment** | Database + migrations + seed + `seaweedfs` + mailpit, no API or frontend container — for frontend development against an `API.Core` process run outside Docker. |
| `unit.tests` | Builds `Dockerfile.tests`, which restores/builds against `Biergarten.API.slnx` and then runs `dotnet test` for every `Features/*.Tests` project in turn (`Features.Users.Tests`, `Features.Breweries.Tests`, `Features.Emails.Tests`, `Features.PhotoUpload.Tests`), mocking each handler's repository/service dependencies with Moq — no real database needed. | | `unit.tests` | Builds `Dockerfile.tests`, which restores/builds against `Biergarten.API.slnx` and then runs `dotnet test` for every `Features/*.Tests` project in turn (`Features.Users.Tests`, `Features.Breweries.Tests`, `Features.Emails.Tests`, `Features.PhotoUpload.Tests`), mocking each handler's repository/service dependencies with Moq — no real database needed. |

View File

@@ -0,0 +1,84 @@
flowchart TB
Developer(["Developer"])
Host[["Docker Host"]]
Developer -->|docker compose up| Host
Host -->|http://localhost:8080| DevAPI
subgraph DEV["Development Environment (docker-compose.dev.yaml)"]
DevDB[("SQL Server 2022<br/>Biergarten Database<br/>:1434")]
DevAPI["API Container<br/>ASP.NET Core 10<br/>:8080 HTTP / :8081 HTTPS"]
DevMig["Migrations (run-once)<br/>Database.Migrations"]
DevSeed["Seed (run-once)<br/>Database.Seed"]
DevMailpit["Mailpit<br/>:8025 Web UI / :1025 SMTP"]
end
DevMig -->|1. Run migrations| DevDB
DevSeed -->|2. Seed data| DevDB
DevAPI -->|3. Connect and serve| DevDB
DevAPI -.->|4. SMTP_HOST/PORT| DevMailpit
DevMig -.->|depends_on| DevDB
DevSeed -.->|depends_on| DevMig
DevAPI -.->|depends_on| DevSeed
subgraph TEST["Test Environment (docker-compose.test.yaml)"]
TestDB[("SQL Server, isolated instance<br/>Biergarten Test Database<br/>no host port published")]
TestMig["Migrations (test)"]
TestSeed["Seed (test)<br/>minimal: test.user only"]
TestSeaweedFS["seaweedfs<br/>S3-compatible object storage"]
Specs["API.Specs<br/>Reqnroll + xUnit<br/>(integration tests)"]
UnitTests["unit.tests<br/>xUnit + Moq<br/>Features.*.Tests, one container"]
FrontendTests["frontend.tests<br/>Playwright/Vitest"]
end
TestMig -->|1. Migrate| TestDB
TestSeed -->|2. Seed| TestDB
Specs -->|3. Integration test| TestDB
UnitTests -.->|depends_on for startup ordering only, no real connection| TestDB
TestMig -.->|depends_on| TestDB
TestSeed -.->|depends_on| TestMig
TestSeed -.->|depends_on| TestSeaweedFS
Specs -.->|depends_on| TestSeed
UnitTests -.->|depends_on| TestSeed
Results[["test-results/ (mounted volume)<br/>api-specs/results.trx<br/>Features.Users.Tests.trx<br/>Features.Breweries.Tests.trx<br/>Features.Emails.Tests.trx<br/>Features.PhotoUpload.Tests.trx"]]
Specs -->|Export TRX| Results
UnitTests -->|Export TRX, one per slice| Results
FrontendTests -->|Export results| Results
subgraph PROD["Production Environment (docker-compose.prod.yaml)"]
ProdDB[("SQL Server 2022<br/>Biergarten Database<br/>no host port published")]
ProdMig["Migrations (run-once)"]
ProdAPI["API Container<br/>ASP.NET Core 10<br/>:8080 HTTP / :8081 HTTPS"]
end
ProdMig -->|1. Run migrations| ProdDB
ProdAPI -->|2. Connect and serve| ProdDB
ProdMig -.->|depends_on| ProdDB
ProdAPI -.->|"depends_on: sql-server healthy (not migrations/seed)"| ProdDB
subgraph DBONLY["Database-only Environment (docker-compose.db.yaml)"]
DbOnlyDB[("SQL Server<br/>:1434")]
DbOnlyMig["Migrations (run-once)"]
DbOnlySeed["Seed (run-once)"]
DbOnlySeaweedFS["seaweedfs<br/>S3-compatible object storage"]
end
DbOnlyMig -->|1. Run migrations| DbOnlyDB
DbOnlySeed -->|2. Seed data| DbOnlyDB
DbOnlyMig -.->|depends_on| DbOnlyDB
DbOnlySeed -.->|depends_on| DbOnlyMig
DbOnlySeed -.->|depends_on| DbOnlySeaweedFS
subgraph MIN["Minimal Environment (docker-compose.min.yaml)"]
MinDB[("SQL Server<br/>:1433")]
MinMig["Migrations (run-once)"]
MinMailpit["Mailpit<br/>:8025 / :1025"]
MinSeed["Seed (run-once)"]
MinSeaweedFS["seaweedfs<br/>S3-compatible object storage"]
end
MinMig -->|1. Run migrations| MinDB
MinSeed -->|2. Seed data| MinDB
MinMig -.->|depends_on| MinDB
MinSeed -.->|depends_on| MinMig
MinSeed -.->|depends_on| MinSeaweedFS

View File

@@ -1,13 +0,0 @@
sequenceDiagram
box Database-only Environment (docker-compose.db.yaml)
participant DbOnlyDB as SQL Server<br/>:1434
participant DbOnlySeaweedFS as seaweedfs<br/>S3-compatible object storage
participant DbOnlyMig as Migrations (run-once)
participant DbOnlySeed as Seed (run-once)
end
DbOnlyMig-->>DbOnlyDB: depends_on
DbOnlyMig->>DbOnlyDB: Run migrations
DbOnlySeed-->>DbOnlyMig: depends_on
DbOnlySeed-->>DbOnlySeaweedFS: depends_on
DbOnlySeed->>DbOnlyDB: Seed data

View File

@@ -1,20 +0,0 @@
sequenceDiagram
actor Developer
participant Host as Docker Host
box Development Environment (docker-compose.dev.yaml)
participant DevDB as SQL Server 2022<br/>Biergarten Database<br/>:1434
participant DevMig as Migrations (run-once)<br/>Database.Migrations
participant DevSeed as Seed (run-once)<br/>Database.Seed
participant DevAPI as API Container<br/>ASP.NET Core 10<br/>:8080 HTTP / :8081 HTTPS
participant DevMailpit as Mailpit<br/>:8025 Web UI / :1025 SMTP
end
Developer->>Host: docker compose up
DevMig-->>DevDB: depends_on (healthy)
DevMig->>DevDB: Run migrations
DevSeed-->>DevMig: depends_on
DevSeed->>DevDB: Seed data
DevAPI-->>DevSeed: depends_on
DevAPI->>DevDB: Connect and serve
DevAPI-->>DevMailpit: SMTP_HOST/PORT
Host->>DevAPI: http://localhost:8080

View File

@@ -1,15 +0,0 @@
sequenceDiagram
box Minimal Environment (docker-compose.min.yaml)
participant MinDB as SQL Server<br/>:1433
participant MinSeaweedFS as seaweedfs<br/>S3-compatible object storage
participant MinMig as Migrations (run-once)
participant MinSeed as Seed (run-once)
participant MinMailpit as Mailpit<br/>:8025 / :1025
end
MinMig-->>MinDB: depends_on
MinMig->>MinDB: Run migrations
MinSeed-->>MinMig: depends_on
MinSeed-->>MinSeaweedFS: depends_on
MinSeed->>MinDB: Seed data
Note over MinDB,MinMailpit: consumed by an externally run API.Core process (not shown)

View File

@@ -1,13 +0,0 @@
sequenceDiagram
box Production Environment (docker-compose.prod.yaml)
participant ProdDB as SQL Server 2022<br/>Biergarten Database<br/>no host port published
participant ProdS3 as SeaweedFS<br/>S3 gateway :8333<br/>no host port published
participant ProdAPI as API Container<br/>ASP.NET Core 10<br/>:8080 HTTP / :8081 HTTPS
participant ProdWeb as Frontend Container<br/>Express + React Router<br/>127.0.0.1:5656
end
ProdAPI-->>ProdS3: depends_on: seaweedfs healthy
ProdWeb-->>ProdAPI: depends_on: api.core healthy
ProdAPI->>ProdDB: Connect and serve
ProdAPI->>ProdS3: Store and read photos
Note over ProdDB: Migrating and seeding are separate run-once jobs,<br/>run at deploy time rather than by the stack

View File

@@ -1,29 +0,0 @@
sequenceDiagram
box Test Environment (docker-compose.test.yaml)
participant TestDB as SQL Server, isolated instance<br/>Biergarten Test Database<br/>no host port published
participant TestSeaweedFS as seaweedfs<br/>S3-compatible object storage
participant TestMig as Migrations (test)
participant TestSeed as Seed (test)<br/>minimal: test.user only
participant Specs as API.Specs<br/>Reqnroll + xUnit<br/>(integration tests)
participant UnitTests as unit.tests<br/>xUnit + Moq<br/>Features.*.Tests, one container
participant FrontendTests as frontend.tests<br/>Playwright/Vitest
end
participant Results as test-results/ (mounted volume)
TestMig-->>TestDB: depends_on
TestMig->>TestDB: Migrate
TestSeed-->>TestMig: depends_on
TestSeed-->>TestSeaweedFS: depends_on
TestSeed->>TestDB: Seed
par Specs
Specs-->>TestSeed: depends_on
Specs->>TestDB: Integration test
Specs->>Results: Export TRX
and unit.tests
UnitTests-->>TestSeed: depends_on
UnitTests-->>TestDB: depends_on (startup ordering only, no real connection)
UnitTests->>Results: Export TRX, one per slice
and frontend.tests
FrontendTests->>Results: Export results
end

View File

@@ -16,14 +16,13 @@ doesn't wire up any slice; it applies schema directly.
## Project reference dependencies ## Project reference dependencies
Solid arrows are compile-time project references — every slice-to-slice Solid arrows are compile-time project references. Dashed arrows are the two
dependency is one now that the email MediatR contract has moved off places slices talk to each other without a direct reference: the email MediatR
`Shared.Application`: `Features.Users` on `Features.PhotoUpload` for avatar contract routed through `Shared.Application`, and the one place that pattern is
uploads, and `Features.Emails` on `Features.Users` for the registration and broken outright. Every DB-backed slice depends on the same three foundation
resend-confirmation notifications. Every DB-backed slice depends on the same projects (`Shared.Contracts`, `Domain.Entities`, `Database.Connection`) — drawn
three foundation projects (`Shared.Contracts`, `Domain.Entities`, as one box instead of fifteen separate arrows; see the notes for what else each
`Database.Connection`) — drawn as one box instead of fifteen separate arrows; of those foundation projects pulls in.
see the notes for what else each of those foundation projects pulls in.
```mermaid ```mermaid
--8<-- "web/diagrams/vertical-slice/project-references.mmd" --8<-- "web/diagrams/vertical-slice/project-references.mmd"
@@ -45,6 +44,8 @@ see the notes for what else each of those foundation projects pulls in.
commands/queries + handlers, validators, and Dapper repository commands/queries + handlers, validators, and Dapper repository
- `Features.Users` covers auth (`Commands/Authentication`), account management - `Features.Users` covers auth (`Commands/Authentication`), account management
(`Commands/Account`), and profile/avatar (`Commands/Profile`) (`Commands/Account`), and profile/avatar (`Commands/Profile`)
- internally namespaced `Features.Auth`, despite project name
`Features.Users`
- `Features.Emails` and `Features.PhotoUpload` have no controller — invoked - `Features.Emails` and `Features.PhotoUpload` have no controller — invoked
only via MediatR commands sent from other slices only via MediatR commands sent from other slices
- `Features.Locations` has no controller; one MediatR handler - `Features.Locations` has no controller; one MediatR handler
@@ -53,20 +54,17 @@ see the notes for what else each of those foundation projects pulls in.
- `Features.Beers` is scaffolded (project + references only, no source files - `Features.Beers` is scaffolded (project + references only, no source files
yet) yet)
- **The exceptions to slice isolation**: - **The one exception to slice isolation**: `Features.Users` takes a direct
- `Features.Users` takes a direct project reference to `Features.PhotoUpload` project reference to `Features.PhotoUpload`
- sends its `UploadPhotoCommand` via MediatR from `UploadAvatarHandler` - sends its `UploadPhotoCommand` via MediatR from `UploadAvatarHandler`
- the command type lives in the target slice itself, not in - unlike the email flow, the command type lives in the target slice itself,
`Shared.Application` not in `Shared.Application`
- `Features.Emails` takes a direct project reference to `Features.Users`
- handles `UserRegisteredNotification` (published from
`RegisterUserHandler`) and `ConfirmationEmailResendRequestedNotification`
(published from `ResendConfirmationEmailHandler`)
- both notification types live in `Features.Users`
(`Features.Users.Notifications`), not in `Shared.Application`
- **Shared.Application**: - **Shared.Application**:
- `ValidationBehavior` (MediatR pipeline) - `ValidationBehavior` (MediatR pipeline)
- Cross-slice email commands (`SendRegistrationEmailCommand`,
`SendResendConfirmationEmailCommand`)
- MediatR command contract (not project reference)
- Every slice (including `Features.Emails`) references it for the pipeline - Every slice (including `Features.Emails`) references it for the pipeline
behavior alone behavior alone
@@ -97,9 +95,9 @@ see the notes for what else each of those foundation projects pulls in.
- **Omitted from the diagram**: - **Omitted from the diagram**:
- `Domain.Exceptions` (referenced by nearly every project directly, including - `Domain.Exceptions` (referenced by nearly every project directly, including
all of `Shared.Contracts` and each feature slice) all of `Shared.Contracts`, `Infrastructure.Jwt`, and each feature slice)
- `Configuration` (the shared options-binding project - `Infrastructure.Configuration` (the shared options-binding project
referenced by `Database.Connection`, `Features.PhotoUpload`, referenced by `Database.Connection`, `Infrastructure.Email`,
and `Features.Emails` directly) `Infrastructure.FileUpload`, and `Features.Emails` directly)
- both are plumbing-level dependencies of nearly everything above, and drawing - both are plumbing-level dependencies of nearly everything above, and drawing
them would just add a second copy of the same fan-in them would just add a second copy of the same fan-in

View File

@@ -1,29 +1,59 @@
--- ---
config: config:
layout: elk
flowchart:
curve: stepAfter
layout: elk
--- ---
flowchart TB flowchart TB
API["API.Core<br/>(ASP.NET Core Web API)"] API["API.Core<br/>(ASP.NET Core Web API)"]
Seed["Database.Seed<br/>(console app)"] Seed["Database.Seed<br/>(console app)"]
Migrations["Database.Migrations<br/>(console app)"] Migrations["Database.Migrations<br/>(console app)"]
subgraph SLICES["Feature Slices"] subgraph SLICES["Feature Slices"]
direction LR direction LR
UsersSlice["Features.Users<br/>(controller)"] UsersSlice["Features.Users<br/>(controller)"]
BrewerySlice["Features.Breweries<br/>(controller)"] BrewerySlice["Features.Breweries<br/>(controller)"]
PhotoSlice["Features.PhotoUpload<br/>(MediatR only)"] PhotoSlice["Features.PhotoUpload<br/>(MediatR only)"]
EmailsSlice["Features.Emails<br/>(MediatR only)"] EmailsSlice["Features.Emails<br/>(MediatR only)"]
LocationsSlice["Features.Locations<br/>(plain DI service)"] LocationsSlice["Features.Locations<br/>(plain DI service)"]
BeersSlice["Features.Beers<br/>(scaffolded, no code yet)"] BeersSlice["Features.Beers<br/>(scaffolded, no code yet)"]
end end
API -->|AddApplicationPart + MediatR scan| UsersSlice API -->|AddApplicationPart + MediatR scan| UsersSlice
API -->|AddApplicationPart + MediatR scan| BrewerySlice API -->|AddApplicationPart + MediatR scan| BrewerySlice
API -->|MediatR scan only| PhotoSlice API -->|MediatR scan only| PhotoSlice
API -->|MediatR scan only| EmailsSlice API -->|MediatR scan only| EmailsSlice
Seed -->|AddFeaturesUsers| UsersSlice Seed -->|AddFeaturesUsers| UsersSlice
Seed -->|AddFeaturesBreweries| BrewerySlice Seed -->|AddFeaturesBreweries| BrewerySlice
Seed -->|AddFeaturesPhotoUpload| PhotoSlice Seed -->|AddFeaturesPhotoUpload| PhotoSlice
Seed -->|AddFeaturesLocations| LocationsSlice Seed -->|AddFeaturesLocations| LocationsSlice

View File

@@ -1,38 +1,77 @@
--- ---
config: config:
layout: elk
flowchart: {}
layout: fixed
--- ---
flowchart TB flowchart TB
subgraph SLICES["Feature Slices"] subgraph SLICES["Feature Slices"]
direction LR direction LR
BrewerySlice["Features.Breweries"] BrewerySlice["Features.Breweries"]
LocationsSlice["Features.Locations"] LocationsSlice["Features.Locations"]
BeersSlice["Features.Beers<br>(scaffolded)"] BeersSlice["Features.Beers<br>(scaffolded)"]
PhotoSlice["Features.PhotoUpload"] PhotoSlice["Features.PhotoUpload"]
UsersSlice["Features.Users"] UsersSlice["Features.Users"]
EmailsSlice["Features.Emails"] EmailsSlice["Features.Emails"]
end end
subgraph FOUNDATION["Foundation (every DB-backed slice depends on all three)"] subgraph FOUNDATION["Foundation (every DB-backed slice depends on all three)"]
direction LR direction LR
SharedContracts["Shared.Contracts"] SharedContracts["Shared.Contracts"]
Domain["Domain.Entities"] Domain["Domain.Entities"]
DBConn["Database.Connection"] DBConn["Database.Connection"]
end end
subgraph INFRA["Slice-specific Infrastructure"] subgraph INFRA["Slice-specific Infrastructure"]
direction LR direction LR
JWT["Infrastructure.Jwt"]
PwdHash["Infrastructure.PasswordHashing"] PwdHash["Infrastructure.PasswordHashing"]
FileUpload["Infrastructure.FileUpload"]
Email["Infrastructure.Email"]
EmailTemplates["Infrastructure.Email.Templates"] EmailTemplates["Infrastructure.Email.Templates"]
end end
UsersSlice == project ref, sends UploadPhotoCommand ==> PhotoSlice UsersSlice == project ref, sends UploadPhotoCommand ==> PhotoSlice
EmailsSlice == project ref, handles UserRegisteredNotification & ConfirmationEmailResendRequestedNotification ==> UsersSlice
UsersSlice -. sends email commands .-> SharedApp["Shared.Application<br>(email command contract)"]
EmailsSlice -. handles email commands .-> SharedApp
BrewerySlice --> FOUNDATION BrewerySlice --> FOUNDATION
LocationsSlice --> FOUNDATION LocationsSlice --> FOUNDATION
BeersSlice --> FOUNDATION BeersSlice --> FOUNDATION
PhotoSlice --> FOUNDATION
UsersSlice --> FOUNDATION & PwdHash PhotoSlice --> FOUNDATION & FileUpload
EmailsSlice --> EmailTemplates
UsersSlice --> FOUNDATION & JWT & PwdHash
EmailsSlice --> Email & EmailTemplates
DBConn -- Dapper SQL, no ORM --> Tables[("SQL Server<br>Tables")] DBConn -- Dapper SQL, no ORM --> Tables[("SQL Server<br>Tables")]

View File

@@ -98,7 +98,7 @@ sqlserver # Test database
database.migrations # Fresh schema database.migrations # Fresh schema
database.seed # Test data database.seed # Test data
api.specs # Reqnroll BDD tests api.specs # Reqnroll BDD tests
unit.tests # All Features.*.Tests and Shared.*.Tests unit test projects unit.tests # All Features.*.Tests unit test projects
frontend.tests # Storybook Vitest + Playwright suites (own Playwright image) frontend.tests # Storybook Vitest + Playwright suites (own Playwright image)
``` ```
@@ -134,136 +134,27 @@ containers, which would end the run prematurely. See
- Production logging levels - Production logging levels
- No database clearing - No database clearing
- Optimized build configurations - Optimized build configurations
- Health checks on every long-running service - Health checks enabled
- Restart policies that survive a reboot (see - Restart policies (unless-stopped)
[Restart tolerance](#restart-tolerance))
- Security hardening - Security hardening
- Only `frontend` publishes a port to the host, bound to `127.0.0.1` so a reverse - Only `frontend` publishes a port to the host; `sqlserver` and `api.core` are
proxy can sit in front of it; `sqlserver`, `api.core` and `seaweedfs` are reachable only from other containers on `prodnet`
reachable only from other containers on `prodnet` (named `biergarten-prodnet`
so the seed job can join it from its own Compose project)
- Seeding is a separate, explicitly run compose file
(`docker-compose.prod.seed.yaml`), so an `up` or a restart leaves the existing
catalogue as it is. See [Production data](#production-data) below.
**Services**: **Services**:
```yaml ```yaml
sqlserver # Production SQL Server (internal only) sqlserver # Production SQL Server (internal only)
database.migrations # Schema updates only
api.core # Production API (internal only, no published ports) api.core # Production API (internal only, no published ports)
seaweedfs # S3-compatible object storage (internal only) frontend # Production website (port 3000, the only public binding)
frontend # Production website (127.0.0.1:5656, the only host binding)
``` ```
Migrating and seeding are run-once jobs kept outside this file, so an `up` or a
restart touches neither the schema nor the catalogue. Run them explicitly at
deploy time: see [Migrating production](#migrating-production) and
[Seeding production](#seeding-production-docker-composeprodseedyaml).
**Deploy Production**: **Deploy Production**:
```bash ```bash
# One time, before the first `up`: the data volumes are external, so Compose
# expects them to exist already.
docker volume create biergartendata-prod
docker volume create seaweedfsdata-prod
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
``` ```
Docker seeds each volume with the ownership the image expects and labels it for
SELinux, so the host needs no `chown` and no `:z`/`:Z` mount options on
enforcing distributions such as RHEL, AlmaLinux and Fedora.
#### Migrating production
`database.migrations` is a run-once job rather than a service in the prod stack,
so the schema is migrated when an operator asks for it instead of on every boot.
Build the image and run it against the network the prod stack creates:
```bash
docker build -t database.migrations \
-f web/backend/Database/Database.Migrations/Dockerfile \
--build-arg BUILD_CONFIGURATION=Release --build-arg APP_UID=1000 \
web/backend
docker run --rm --env-file web/.env.prod --network biergarten-prodnet \
-e DOTNET_RUNNING_IN_CONTAINER=true database.migrations
```
DbUp records every script it has applied, so a re-run applies only what is new.
Run this with `sqlserver` already up and healthy, and before the seed job.
#### Restart tolerance
`sqlserver`, `api.core`, `seaweedfs` and `frontend` run under
`restart: unless-stopped`, so the stack returns on its own after a container
crash, a Docker daemon restart or a host reboot, provided Docker itself starts at
boot (`systemctl enable docker`). The migration and seed jobs stay out of the
stack, so a reboot replays neither.
The daemon replays restart policies without replaying the `depends_on` graph, so
after a reboot the containers come back in arbitrary order and each one tolerates
a dependency that arrives late. `api.core` opens its database and S3 connections
per request rather than at startup, and `frontend` answers with its own error
states while `api.core` is still coming up.
Health checks report when the stack has converged:
| Service | Probe |
| --- | --- |
| `sqlserver` | `sqlcmd -Q 'SELECT 1'`, with a 30s start period and 12 retries |
| `api.core` | `GET /health` spoken over bash's `/dev/tcp` — the .NET runtime image carries bash and no HTTP client |
| `seaweedfs` | `curl http://localhost:8333/status`, the S3 gateway, which serves once the master, volume and filer servers are up |
| `frontend` | `wget --spider http://127.0.0.1:5656/healthz`, answered by Express alone and so independent of the API |
`sqlserver` also carries `stop_grace_period: 60s`, giving a checkpoint time to
finish on shutdown instead of being recovered on the next start.
Deploy and wait for the stack to report healthy, or check convergence after a
reboot:
```bash
docker compose --env-file web/.env.prod -f web/docker-compose.prod.yaml up -d --wait
docker compose --env-file web/.env.prod -f web/docker-compose.prod.yaml ps
```
#### Seeding production (`docker-compose.prod.seed.yaml`)
`database.seed` lives in its own compose file, run on demand once the prod stack
is up, `sqlserver` is healthy, and the schema is migrated:
```bash
docker compose --env-file web/.env.prod -f web/docker-compose.prod.seed.yaml \
run --rm --build database.seed
```
The job runs as the `biergarten-prod-seed` Compose project and attaches to the
external `biergarten-prodnet` network, which the main prod stack creates, so it
reaches `sqlserver` and `seaweedfs` under the same hostnames the API uses. It
exits when seeding finishes; `--rm` removes the container. Bring the prod stack
up first, because the seed project has no visibility into the other project's
health checks, so a run started before SQL Server is accepting connections fails.
#### Production data
A production deployment seeded this way serves the AI-generated fixture data
produced by the pipeline. Per
[Pipeline ethics, bias, and known issues](../pipeline/ETHICS-AND-KNOWN-ISSUES.md),
anyone interacting with an application seeded from it has to be told upfront that
the content is AI-generated.
The website meets that obligation with `AiDisclosureDialog`
(`web/frontend/app/features/home/components/AiDisclosureDialog.tsx`), which
the root route renders on every full page load, whichever route the visitor lands
on. It is part of the server-rendered HTML rather than mounted after hydration, so
the disclosure is on screen with the first paint. Acknowledging it hides the
dialog for the remainder of that page load; a new load shows it again.
Keep the seed job and the dialog together: any deployment that runs
`docker-compose.prod.seed.yaml` needs the disclosure in place to satisfy the
ethics documentation.
### 4. Database only (`docker-compose.db.yaml`) ### 4. Database only (`docker-compose.db.yaml`)
**Purpose**: Run just the database for local backend development outside Docker **Purpose**: Run just the database for local backend development outside Docker
@@ -324,12 +215,6 @@ graph TD
In development, `api.core` also depends on `seaweedfs` In development, `api.core` also depends on `seaweedfs`
(`condition: service_started`, not a health check) alongside `database.seed`. (`condition: service_started`, not a health check) alongside `database.seed`.
Production has a shorter chain, because the migration and seed jobs run outside
the stack: `api.core` waits for `seaweedfs` to pass its health check, and
`frontend` waits for `api.core` to report healthy. This ordering applies to
`docker compose up`; see [Restart tolerance](#restart-tolerance) for what happens
on a host reboot.
**Health Check Example** (SQL Server): **Health Check Example** (SQL Server):
```yaml ```yaml
@@ -370,10 +255,8 @@ api.core:
**Production**: **Production**:
- `biergartendata-prod` - Database files, external; survives `down -v` - `sqlserverdata-prod` - Production database files
- `seaweedfsdata-prod` - SeaweedFS object storage, external; survives `down -v` - `nuget-cache-prod` - Production NuGet cache
See [Production data volumes](#production-data-volumes).
### Mounted volumes ### Mounted volumes
@@ -386,41 +269,6 @@ volumes:
Test results are written to host filesystem for CI/CD integration. Test results are written to host filesystem for CI/CD integration.
### Production data volumes
`biergartendata-prod` and `seaweedfsdata-prod` are declared `external: true`.
Compose removes only the volumes it owns, so `docker compose down -v` tears the
stack down and leaves the database and the uploaded photos in place. Deleting
them takes an explicit `docker volume rm`.
The trade-off is that Compose creates neither one, and `up` fails with a clear
error until both exist, hence the `docker volume create` step in
[Deploy Production](#3-production-docker-composeprodyaml). External volumes also
carry their literal name rather than a `<project>_` prefix, so `docker volume ls`
shows `biergartendata-prod` and `seaweedfsdata-prod` unprefixed, unlike the
`web_`-prefixed volumes of the dev and test stacks.
Both live under `/var/lib/docker/volumes`. To put them on a specific disk, point
the daemon's `data-root` at it in `/etc/docker/daemon.json`, or mount the disk at
`/var/lib/docker`.
Back up the database through SQL Server rather than by copying its files, since a
file copy taken while the engine is running can be torn:
```bash
docker exec prod-env-sqlserver /opt/mssql-tools18/bin/sqlcmd \
-S localhost -U sa -P '<db-password>' -C \
-Q "BACKUP DATABASE [<db-name>] TO DISK = '/var/opt/mssql/data/backup.bak'"
docker cp prod-env-sqlserver:/var/opt/mssql/data/backup.bak .
```
Object storage can be archived directly, with the stack stopped:
```bash
docker run --rm -v seaweedfsdata-prod:/data -v "$PWD:/backup" \
alpine tar czf /backup/seaweedfs.tar.gz -C /data .
```
## Networks ## Networks
Each environment uses isolated bridge networks: Each environment uses isolated bridge networks:

View File

@@ -233,7 +233,7 @@ file (`development` in dev, `production` in prod).
### SMTP configuration (backend) ### SMTP configuration (backend)
Read by `Features.Emails/Services/SmtpEmailProvider.cs` for sending confirmation and Read by `Infrastructure.Email/SmtpEmailProvider.cs` for sending confirmation and
account emails. account emails.
```bash ```bash
@@ -246,7 +246,7 @@ SMTP_FROM_EMAIL=noreply@thebiergarten.app # Required, no default
SMTP_FROM_NAME=The Biergarten App # Optional, defaults to "The Biergarten" SMTP_FROM_NAME=The Biergarten App # Optional, defaults to "The Biergarten"
``` ```
- **Implementation**: `Features.Emails/Services/SmtpEmailProvider.cs` throws on - **Implementation**: `Infrastructure.Email/SmtpEmailProvider.cs` throws on
startup if `SMTP_HOST` or `SMTP_FROM_EMAIL` is missing startup if `SMTP_HOST` or `SMTP_FROM_EMAIL` is missing
- **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)
@@ -255,30 +255,22 @@ SMTP_FROM_NAME=The Biergarten App # Optional, defaults to "The Biergarten
### File storage configuration (backend) ### File storage configuration (backend)
Read by `Features.PhotoUpload/Services/S3FileStorageProvider.cs` for uploading Read by `Infrastructure.FileUpload/S3FileStorageProvider.cs` for uploading and
and retrieving photos via an S3-compatible object store. retrieving photos via an S3-compatible object store.
```bash ```bash
SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333 # Required, no default SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333 # Required, no default
SEAWEEDFS_PUBLIC_URL=http://localhost:8333 # Optional, defaults to SEAWEEDFS_SERVICE_URL
SEAWEEDFS_ACCESS_KEY_ID=admin # Required, no default SEAWEEDFS_ACCESS_KEY_ID=admin # Required, no default
SEAWEEDFS_SECRET_ACCESS_KEY=secret # Required, no default SEAWEEDFS_SECRET_ACCESS_KEY=secret # Required, no default
SEAWEEDFS_BUCKET=my-bucket # Required, no default SEAWEEDFS_BUCKET=my-bucket # Required, no default
``` ```
- **Implementation**: `S3FileStorageProvider` throws on construction if any of - **Implementation**: `S3FileStorageProvider` throws on construction if any of
the four required values is missing these four values is missing
- **Local dev**: point at the `seaweedfs` Docker service (S3 API on port 8333) - **Local dev**: point at the `seaweedfs` Docker service (S3 API on port 8333)
- **Production**: point at any S3-compatible endpoint (AWS S3, a hosted - **Production**: point at any S3-compatible endpoint (AWS S3, a hosted
SeaweedFS cluster, and so on); `ForcePathStyle` is always enabled, which SeaweedFS cluster, and so on); `ForcePathStyle` is always enabled, which
SeaweedFS requires and AWS S3 also accepts SeaweedFS requires and AWS S3 also accepts
- **`SEAWEEDFS_PUBLIC_URL`**: the endpoint download URLs are signed against,
used by `GetPresignedUrl` and reaching the API response as
`PublicUserProfileDto.AvatarUrl`. A presigned URL is signed over its host, so
this has to be the address the browser uses (`http://localhost:8333` with the
dev compose file, which publishes the S3 API port), separate from the
in-network `SEAWEEDFS_SERVICE_URL` the API uploads through. The two can be the
same value wherever one address serves both
## Docker-specific variables ## Docker-specific variables
@@ -325,7 +317,6 @@ from the template in place; edit them manually if you go that route.
- `web/docker-compose.dev.yaml` → `web/.env.dev` - `web/docker-compose.dev.yaml` → `web/.env.dev`
- `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`
- `web/docker-compose.prod.seed.yaml` → `web/.env.prod`
## Variable reference table ## Variable reference table
@@ -349,7 +340,6 @@ from the template in place; edit them manually if you go that route.
| `SMTP_FROM_EMAIL` | ✓ | | ✓ | Yes | Email sender address | | `SMTP_FROM_EMAIL` | ✓ | | ✓ | Yes | Email sender address |
| `SMTP_FROM_NAME` | ✓ | | ✓ | No | Defaults to `The Biergarten` | | `SMTP_FROM_NAME` | ✓ | | ✓ | No | Defaults to `The Biergarten` |
| `SEAWEEDFS_SERVICE_URL` | ✓ | | ✓ | Yes | S3-compatible endpoint URL | | `SEAWEEDFS_SERVICE_URL` | ✓ | | ✓ | Yes | S3-compatible endpoint URL |
| `SEAWEEDFS_PUBLIC_URL` | ✓ | | ✓ | No | Endpoint download URLs are signed against; defaults to `SEAWEEDFS_SERVICE_URL` |
| `SEAWEEDFS_ACCESS_KEY_ID` | ✓ | | ✓ | Yes | S3 access key | | `SEAWEEDFS_ACCESS_KEY_ID` | ✓ | | ✓ | Yes | S3 access key |
| `SEAWEEDFS_SECRET_ACCESS_KEY` | ✓ | | ✓ | Yes | S3 secret key | | `SEAWEEDFS_SECRET_ACCESS_KEY` | ✓ | | ✓ | Yes | S3 secret key |
| `SEAWEEDFS_BUCKET` | ✓ | | ✓ | Yes | S3 bucket name | | `SEAWEEDFS_BUCKET` | ✓ | | ✓ | Yes | S3 bucket name |
@@ -424,7 +414,6 @@ SMTP_FROM_NAME=The Biergarten App
# SeaweedFS (S3-compatible storage) # SeaweedFS (S3-compatible storage)
SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333 SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333
SEAWEEDFS_PUBLIC_URL=http://localhost:8333
SEAWEEDFS_ACCESS_KEY_ID=<generated-with-openssl> SEAWEEDFS_ACCESS_KEY_ID=<generated-with-openssl>
SEAWEEDFS_SECRET_ACCESS_KEY=<generated-with-openssl> SEAWEEDFS_SECRET_ACCESS_KEY=<generated-with-openssl>
SEAWEEDFS_BUCKET=my-bucket SEAWEEDFS_BUCKET=my-bucket

View File

@@ -76,11 +76,10 @@ docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
You can run individual test projects locally without Docker: You can run individual test projects locally without Docker:
### Feature slice and shared 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. Shared cross-cutting code (e.g. MediatR pipeline behaviors) has handlers:
its own test project under `Shared/`:
```bash ```bash
cd web/backend cd web/backend
@@ -88,10 +87,9 @@ dotnet test Features/Features.Users.Tests/Features.Users.Tests.csproj
dotnet test Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj dotnet test Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj
dotnet test Features/Features.Emails.Tests/Features.Emails.Tests.csproj dotnet test Features/Features.Emails.Tests/Features.Emails.Tests.csproj
dotnet test Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj dotnet test Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj
dotnet test Shared/Shared.Application.Tests/Shared.Application.Tests.csproj
# Or run all of them at once via the solution: # Or run all of them at once via the solution:
for proj in Features/*.Tests Shared/*.Tests; do dotnet test "$proj"; done for proj in Features/*.Tests; do dotnet test "$proj"; done
``` ```
**Requirements**: **Requirements**:
@@ -249,16 +247,13 @@ API.Specs/
│ ├── ResendConfirmation.feature # Resend-confirmation scenarios │ ├── ResendConfirmation.feature # Resend-confirmation scenarios
│ ├── TokenRefresh.feature # Refresh token scenarios │ ├── TokenRefresh.feature # Refresh token scenarios
│ ├── AccessTokenValidation.feature # Protected endpoint access scenarios │ ├── AccessTokenValidation.feature # Protected endpoint access scenarios
│ ├── NotFound.feature # 404 handling │ └── NotFound.feature # 404 handling
│ └── PublicUserProfile.feature # Public profile & authenticated account scenarios
├── Steps/ ├── Steps/
│ ├── AuthSteps.cs # Step definitions for the Auth features │ ├── AuthSteps.cs # Step definitions for the Auth features
│ ├── UserSteps.cs # Step definitions for the public profile & user-account features
│ └── ApiGeneralSteps.cs # Shared/general step definitions │ └── ApiGeneralSteps.cs # Shared/general step definitions
├── Mocks/ ├── Mocks/
│ ├── MockEmailDispatcher.cs # Substitutes Features.Emails' IEmailDispatcher │ ├── MockEmailDispatcher.cs # Substitutes Features.Emails' IEmailDispatcher
│ ├── MockEmailProvider.cs # Substitutes Features.Emails' IEmailProvider │ └── MockEmailProvider.cs # Substitutes Infrastructure.Email's IEmailProvider
│ └── MockFileStorageProvider.cs # Substitutes Features.ImageUploads' IFileStorageProvider
└── TestApiFactory.cs # Test server setup (swaps in the mocks above) └── TestApiFactory.cs # Test server setup (swaps in the mocks above)
``` ```
@@ -283,25 +278,17 @@ Scenario: Successful user registration
Features.Users.Tests/ Features.Users.Tests/
├── Commands/ ├── Commands/
│ ├── RegisterUserHandlerTests.cs │ ├── RegisterUserHandlerTests.cs
│ ├── RegisterUserValidatorTests.cs
│ ├── ConfirmUserHandlerTests.cs │ ├── ConfirmUserHandlerTests.cs
│ ├── ResendConfirmationEmailHandlerTests.cs │ ├── ResendConfirmationEmailHandlerTests.cs
│ ├── RefreshTokenHandlerTests.cs │ ├── RefreshTokenHandlerTests.cs
│ ├── RefreshTokenValidatorTests.cs
│ ├── LoginValidatorTests.cs # tests LoginCommand's validator
│ ├── UpdateUsernameHandlerTests.cs │ ├── UpdateUsernameHandlerTests.cs
│ ├── UpdateUsernameValidatorTests.cs
│ ├── UpdateEmailHandlerTests.cs │ ├── UpdateEmailHandlerTests.cs
│ ├── UpdateEmailValidatorTests.cs
│ ├── UpdatePasswordHandlerTests.cs │ ├── UpdatePasswordHandlerTests.cs
│ ├── UpdatePasswordValidatorTests.cs
│ ├── UpdateProfileHandlerTests.cs │ ├── UpdateProfileHandlerTests.cs
│ ├── UpdateProfileValidatorTests.cs
│ └── DeleteAccountHandlerTests.cs │ └── DeleteAccountHandlerTests.cs
├── Queries/ ├── Queries/
│ ├── LoginHandlerTests.cs # tests LoginCommand, despite the folder name │ ├── LoginHandlerTests.cs # tests LoginCommand, despite the folder name
│ ├── GetAllUsersHandlerTests.cs │ ├── GetAllUsersHandlerTests.cs
│ ├── GetPublicUserProfileByIdHandlerTests.cs
│ └── GetUserByIdHandlerTests.cs │ └── GetUserByIdHandlerTests.cs
├── Services/ ├── Services/
│ ├── TokenServiceRefreshTests.cs │ ├── TokenServiceRefreshTests.cs
@@ -309,13 +296,10 @@ Features.Users.Tests/
└── TestSupport/ # shared test fixtures/helpers └── TestSupport/ # shared test fixtures/helpers
``` ```
`Features.Breweries.Tests` and `Features.PhotoUpload.Tests` follow the same Each of the other slices (`Features.Breweries.Tests`, `Features.Emails.Tests`,
shape: a `Commands/`/`Queries/` folder with one test file per handler, plus a `Features.PhotoUpload.Tests`) follows the same shape: a `Commands/`/`Queries/`
`*ValidatorTests.cs` file beside each handler whose command or query has a folder with one test file per handler (`Features.PhotoUpload.Tests` also adds a
FluentValidation validator (`Features.Breweries.Tests` has one of these in validator test file alongside its handler test).
`Queries/` as well, for the nearby-search query). `Features.Emails.Tests`
differs: its handlers respond to MediatR notifications, so its test files live
under `Notifications/`.
## Writing tests ## Writing tests

View File

@@ -22,7 +22,7 @@ The Biergarten API validates JSON Web Tokens (JWTs) across three token types:
### Infrastructure layer ### Infrastructure layer
#### [ITokenInfrastructure](../../web/backend/Features/Features.Users/Services/ITokenInfrastructure.cs) #### [ITokenInfrastructure](../../web/backend/Infrastructure/Infrastructure.Jwt/ITokenInfrastructure.cs)
Low-level JWT operations. Low-level JWT operations.
@@ -32,7 +32,7 @@ Low-level JWT operations.
- `ValidateJwtAsync()` - Validates token signature, expiration, and format - `ValidateJwtAsync()` - Validates token signature, expiration, and format
**Implementation:** **Implementation:**
[JwtInfrastructure.cs](../../web/backend/Features/Features.Users/Services/JwtInfrastructure.cs) [JwtInfrastructure.cs](../../web/backend/Infrastructure/Infrastructure.Jwt/JwtInfrastructure.cs)
- Uses Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler - Uses Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler
- Algorithm: HS256 (HMAC-SHA256) - Algorithm: HS256 (HMAC-SHA256)
@@ -76,11 +76,11 @@ directly, bypassing the `Features.Users` slice entirely.
- Extracts and validates claims (Sub, UniqueName) - Extracts and validates claims (Sub, UniqueName)
- Throws `UnauthorizedException` on validation failure - Throws `UnauthorizedException` on validation failure
`TokenService` and the lower-level `ITokenInfrastructure` (JWT `TokenService` is registered by `Features.Users`' own `AddFeaturesUsers()`
signing/verification) it depends on are both registered by `Features.Users`' extension method, except for the lower-level `ITokenInfrastructure` (JWT
own `AddFeaturesUsers()` extension method. `JwtAuthenticationHandler` signing/verification) it depends on, which is registered by the host
(host-level auth middleware) also depends on `ITokenInfrastructure` directly, (`API.Core/Program.cs`) since `JwtAuthenticationHandler` (host-level auth
resolving it from the same DI container. middleware) also depends on it directly.
### Integration points ### Integration points

View File

@@ -68,12 +68,8 @@ WEBSITE_BASE_URL=http://localhost:3000
# ====================== # ======================
# SeaweedFS (S3) Configuration # SeaweedFS (S3) Configuration
# ====================== # ======================
# Credentials and default bucket for the local SeaweedFS S3-compatible storage. # Credentials and default bucket for the local SeaweedFS S3-compatible storage
# SEAWEEDFS_SERVICE_URL is the in-network endpoint the API uploads through;
# SEAWEEDFS_PUBLIC_URL is the endpoint download URLs are signed against, so it has to be
# reachable from the browser. It defaults to SEAWEEDFS_SERVICE_URL when left blank.
SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333 SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333
SEAWEEDFS_PUBLIC_URL=http://localhost:8333
SEAWEEDFS_ACCESS_KEY_ID=admin SEAWEEDFS_ACCESS_KEY_ID=admin
SEAWEEDFS_SECRET_ACCESS_KEY=secret SEAWEEDFS_SECRET_ACCESS_KEY=secret
SEAWEEDFS_BUCKET=my-bucket SEAWEEDFS_BUCKET=my-bucket

View File

@@ -7,27 +7,24 @@
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS> <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="API.Specs" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" /> <PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
<PackageReference Include="MediatR" Version="12.5.0" /> <PackageReference Include="MediatR" Version="12.4.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" /> <ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" /> <ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" /> <ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />
<ProjectReference Include="..\..\Configuration\Configuration.csproj" /> <ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.FileUpload\Infrastructure.FileUpload.csproj" />
<ProjectReference Include="..\..\Features\Features.Breweries\Features.Breweries.csproj" /> <ProjectReference Include="..\..\Features\Features.Breweries\Features.Breweries.csproj" />
<ProjectReference Include="..\..\Features\Features.Users\Features.Users.csproj" /> <ProjectReference Include="..\..\Features\Features.Users\Features.Users.csproj" />
<ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" /> <ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" />
<ProjectReference Include="..\..\Features\Features.PhotoUpload\Features.PhotoUpload.csproj" /> <ProjectReference Include="..\..\Features\Features.PhotoUpload\Features.PhotoUpload.csproj" />
<ProjectReference Include="..\..\Features\Features.Locations\Features.Locations.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" /> <ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" /> <ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
</ItemGroup> </ItemGroup>

View File

@@ -1,7 +1,7 @@
using System.Security.Claims; using System.Security.Claims;
using System.Text.Encodings.Web; using System.Text.Encodings.Web;
using Features.Users.Services; using Infrastructure.Configuration;
using Configuration; using Infrastructure.Jwt;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives; using Microsoft.Extensions.Primitives;
@@ -25,13 +25,17 @@ public class JwtAuthenticationHandler(
/// Validates the incoming request's bearer JWT access token and produces an authentication result. /// Validates the incoming request's bearer JWT access token and produces an authentication result.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// The signing secret is resolved first from the <c>ACCESS_TOKEN_SECRET</c> environment variable. /// The signing secret is resolved first from the <c>ACCESS_TOKEN_SECRET</c> environment variable, falling
/// Fails if the secret is not configured, the <c>Authorization</c> header is /// back to the <c>Jwt:SecretKey</c> configuration value, to stay consistent with the secret source used
/// when tokens are issued. Fails if the secret is not configured, the <c>Authorization</c> header is
/// missing or not <c>Bearer</c>-scheme, or the token fails validation. /// missing or not <c>Bearer</c>-scheme, or the token fails validation.
/// </remarks> /// </remarks>
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.
string? secret = configuration[ConfigurationKeys.AccessTokenSecret]; string? secret = configuration[ConfigurationKeys.AccessTokenSecret];
if (string.IsNullOrWhiteSpace(secret))
secret = configuration[ConfigurationKeys.JwtSecretKeyFallback];
if (string.IsNullOrWhiteSpace(secret)) if (string.IsNullOrWhiteSpace(secret))
return AuthenticateResult.Fail("JWT secret is not configured"); return AuthenticateResult.Fail("JWT secret is not configured");

View File

@@ -0,0 +1,25 @@
using Microsoft.AspNetCore.Mvc;
namespace API.Core.Controllers;
/// <summary>
/// Handles requests that do not match any other route, used as the application's fallback controller.
/// </summary>
/// <remarks>
/// Wired up as the fallback target via <c>app.MapFallbackToController("Handle404", "NotFound")</c> in
/// <c>Program.cs</c>.
/// </remarks>
[ApiController]
[ApiExplorerSettings(IgnoreApi = true)]
[Route("error")]
public class NotFoundController : ControllerBase
{
/// <summary>
/// Returns a 404 response for any request that did not match a route.
/// </summary>
[HttpGet("404")]
public IActionResult Handle404()
{
return NotFound(new { message = "Route not found." });
}
}

View File

@@ -0,0 +1,34 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Shared.Contracts;
using JwtRegisteredClaimNames = System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames;
namespace API.Core.Controllers;
/// <summary>
/// Sample controller demonstrating an endpoint that requires JWT authentication.
/// </summary>
[ApiController]
[Route("api/[controller]")]
[Authorize(AuthenticationSchemes = "JWT")]
public class ProtectedController : ControllerBase
{
/// <summary>
/// Returns the authenticated caller's user ID and username extracted from the JWT claims.
/// </summary>
/// <returns><c>userId</c> and <c>username</c> may be <c>null</c> if not present in the token.</returns>
[HttpGet]
public ActionResult<ResponseBody<object>> Get()
{
string? userId = User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
string? username = User.FindFirst(JwtRegisteredClaimNames.UniqueName)?.Value;
return Ok(
new ResponseBody<object>
{
Message = "Protected endpoint accessed successfully",
Payload = new { userId, username },
}
);
}
}

View File

@@ -25,7 +25,6 @@ COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain/Domain.Except
COPY ["Features/Features.Breweries/Features.Breweries.csproj", "Features/Features.Breweries/"] COPY ["Features/Features.Breweries/Features.Breweries.csproj", "Features/Features.Breweries/"]
COPY ["Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj", "Features/Features.Breweries.Tests/"] COPY ["Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj", "Features/Features.Breweries.Tests/"]
COPY ["Features/Features.Emails/Features.Emails.csproj", "Features/Features.Emails/"] COPY ["Features/Features.Emails/Features.Emails.csproj", "Features/Features.Emails/"]
COPY ["Features/Features.Emails.Templates/Features.Emails.Templates.csproj", "Features/Features.Emails.Templates/"]
COPY ["Features/Features.Emails.Tests/Features.Emails.Tests.csproj", "Features/Features.Emails.Tests/"] COPY ["Features/Features.Emails.Tests/Features.Emails.Tests.csproj", "Features/Features.Emails.Tests/"]
COPY ["Features/Features.Locations/Features.Locations.csproj", "Features/Features.Locations/"] COPY ["Features/Features.Locations/Features.Locations.csproj", "Features/Features.Locations/"]
COPY ["Features/Features.Locations.Tests/Features.Locations.Tests.csproj", "Features/Features.Locations.Tests/"] COPY ["Features/Features.Locations.Tests/Features.Locations.Tests.csproj", "Features/Features.Locations.Tests/"]
@@ -33,9 +32,13 @@ COPY ["Features/Features.PhotoUpload/Features.PhotoUpload.csproj", "Features/Fea
COPY ["Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj", "Features/Features.PhotoUpload.Tests/"] COPY ["Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj", "Features/Features.PhotoUpload.Tests/"]
COPY ["Features/Features.Users/Features.Users.csproj", "Features/Features.Users/"] COPY ["Features/Features.Users/Features.Users.csproj", "Features/Features.Users/"]
COPY ["Features/Features.Users.Tests/Features.Users.Tests.csproj", "Features/Features.Users.Tests/"] COPY ["Features/Features.Users.Tests/Features.Users.Tests.csproj", "Features/Features.Users.Tests/"]
COPY ["Configuration/Configuration.csproj", "Configuration/"] COPY ["Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj", "Infrastructure/Infrastructure.Configuration/"]
COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"]
COPY ["Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj", "Infrastructure/Infrastructure.Email.Templates/"]
COPY ["Infrastructure/Infrastructure.FileUpload/Infrastructure.FileUpload.csproj", "Infrastructure/Infrastructure.FileUpload/"]
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"] COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"]
COPY ["Shared/Shared.Application.Tests/Shared.Application.Tests.csproj", "Shared/Shared.Application.Tests/"]
COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"] COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"]
RUN dotnet restore "Biergarten.API.slnx" RUN dotnet restore "Biergarten.API.slnx"
COPY . . COPY . .

View File

@@ -13,16 +13,16 @@ namespace API.Core;
/// </summary> /// </summary>
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.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Maps <see cref="ValidationException" /> to 400, <see cref="ConflictException" /> to 409, /// Maps <see cref="ValidationException" /> to 400, <see cref="ConflictException" /> to 409,
/// <see cref="NotFoundException" /> to 404, <see cref="UnauthorizedException" /> to 401, /// <see cref="NotFoundException" /> to 404, <see cref="UnauthorizedException" /> to 401,
/// <see cref="ForbiddenException" /> to 403, <see cref="SqlException" /> to 503, /// <see cref="ForbiddenException" /> to 403, <see cref="SqlException" /> to 503,
/// <see cref="Domain.Exceptions.ValidationException" /> to 400, and anything else to 500. /// <see cref="Domain.Exceptions.ValidationException" /> to 400, and anything else to 500.
/// </remarks> /// </remarks>
public void OnException(ExceptionContext context) public void OnException(ExceptionContext context)
{ {
logger.LogError(context.Exception, "Unhandled exception occurred"); logger.LogError(context.Exception, "Unhandled exception occurred");

View File

@@ -1,86 +1,132 @@
using API.Core;
using API.Core.Authentication;
using Database.Connection.DependencyInjection;
using Features.Auth.Controllers;
using Features.Users.DependencyInjection;
using Features.Breweries.Controllers;
using Features.Breweries.DependencyInjection;
using Features.Emails.DependencyInjection;
using Features.Emails.Services;
using Features.ImageUploads.Commands.UploadPhoto;
using Features.ImageUploads.DependencyInjection;
using FluentValidation;
using FluentValidation.AspNetCore;
using Infrastructure.FileUpload;
using Infrastructure.Jwt;
using MediatR;
using Microsoft.OpenApi.Models;
using Shared.Application.Behaviors;
using System.Security.Claims; using System.Security.Claims;
using Shared.Contracts;
using JwtRegisteredClaimNames = System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames; using JwtRegisteredClaimNames = System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames;
namespace API.Core; WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
internal class Program // Add current controllers to the exception filter
builder
.Services.AddControllers(options => { options.Filters.Add<GlobalExceptionFilter>(); })
.AddApplicationPart(typeof(BreweryController).Assembly)
.AddApplicationPart(typeof(AuthController).Assembly);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{ {
public static void Main(string[] args) options.AddSecurityDefinition(
{ "Bearer",
WebApplicationBuilder builder = WebApplication.CreateBuilder(args); new OpenApiSecurityScheme
{
builder.Logging.ClearProviders().AddConsole(); Name = "Authorization",
Type = SecuritySchemeType.Http,
if (!builder.Environment.IsProduction()) Scheme = "Bearer",
builder.Logging.AddDebug(); BearerFormat = "JWT",
In = ParameterLocation.Header,
builder Description = "Enter a JWT access token.",
.Services.AddApiControllers() }
.AddValidationAndMediatR() );
.AddApplicationServices() options.AddSecurityRequirement(
.AddFeatureModules() new OpenApiSecurityRequirement
.AddCoreInfrastructure() {
.AddJwtAuthentication();
WebApplication app = builder.Build();
app.UseSwagger()
.UseSwaggerUI()
.UseHttpsRedirection()
.UseAuthentication()
.UseAuthorization();
app.MapOpenApi();
app.MapHealthChecks("/health");
app.MapControllers();
if (app.Environment.IsEnvironment("Testing"))
app.MapGet(
"/api/protected",
(ClaimsPrincipal user) =>
{
string? userId = user.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
string? username = user.FindFirst(JwtRegisteredClaimNames.UniqueName)?.Value;
return Results.Ok(
new ResponseBody<object>
{
Message = "Protected endpoint accessed successfully",
Payload = new { userId, username },
}
);
}
).RequireAuthorization(policy => policy.AddAuthenticationSchemes("JWT").RequireAuthenticatedUser());
// Easter egg, as per tradition with the previous versions of the Biergarten App.
app.Map(
"/teapot",
() =>
Results.Json(
new ResponseBody
{
Message =
"I'm a little teapot, short and stout. Here is my handle, here is my spout!",
},
statusCode: 418
)
)
.ExcludeFromDescription();
app.MapFallback(() =>
Results.NotFound(
new ResponseBody { Message = "Are you lost? That route does not exist." }
)
)
.ExcludeFromDescription();
app.Services.GetRequiredService<IHostApplicationLifetime>()
.ApplicationStopping.Register(() =>
{ {
app.Logger.LogInformation("Application is shutting down gracefully..."); new OpenApiSecurityScheme
}); {
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer",
},
},
[]
},
}
);
});
builder.Services.AddOpenApi();
app.Run(); builder.Services.AddValidatorsFromAssemblyContaining<Program>();
} builder.Services.AddValidatorsFromAssembly(typeof(BreweryController).Assembly);
} builder.Services.AddValidatorsFromAssembly(typeof(AuthController).Assembly);
builder.Services.AddValidatorsFromAssembly(typeof(UploadPhotoCommand).Assembly);
builder.Services.AddFluentValidationAutoValidation();
// Add MediatR.
// ValidationBehavior runs FluentValidation validators in the MediatR pipeline for command/query handlers.
builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssemblyContaining<Program>();
cfg.RegisterServicesFromAssembly(typeof(BreweryController).Assembly);
cfg.RegisterServicesFromAssembly(typeof(AuthController).Assembly);
cfg.RegisterServicesFromAssembly(typeof(UploadPhotoCommand).Assembly);
cfg.RegisterServicesFromAssembly(typeof(IEmailDispatcher).Assembly);
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
});
builder.Services.AddHealthChecks();
// Configure logging for container output
builder.Logging.ClearProviders();
builder.Logging.AddConsole();
if (!builder.Environment.IsProduction())
builder.Logging.AddDebug();
builder.Services.AddDatabaseConnection();
builder.Services.AddFeaturesBreweries();
builder.Services.AddFeaturesUsers();
builder.Services.AddFeaturesEmails();
builder.Services.AddFeaturesPhotoUpload();
// ITokenInfrastructure is registered here because JwtAuthenticationHandler depends on it directly.
builder.Services.AddScoped<ITokenInfrastructure, JwtInfrastructure>();
builder.Services.AddSingleton<IFileStorageProvider, S3FileStorageProvider>();
builder.Services.AddScoped<GlobalExceptionFilter>();
builder
.Services.AddAuthentication("JWT")
.AddScheme<JwtAuthenticationOptions, JwtAuthenticationHandler>("JWT", options => { });
builder.Services.AddAuthorization();
WebApplication app = builder.Build();
app.UseSwagger();
app.UseSwaggerUI();
app.MapOpenApi();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapHealthChecks("/health");
app.MapControllers();
app.MapFallbackToController("Handle404", "NotFound");
IHostApplicationLifetime lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();
lifetime.ApplicationStopping.Register(() =>
{
app.Logger.LogInformation("Application is shutting down gracefully...");
});
app.Run();

View File

@@ -1,121 +0,0 @@
using API.Core.Authentication;
using Database.Connection.DependencyInjection;
using Features.Breweries.Controllers;
using Features.Breweries.DependencyInjection;
using Features.Emails.DependencyInjection;
using Features.Emails.Services;
using Features.ImageUploads.Commands.UploadPhoto;
using Features.ImageUploads.DependencyInjection;
using Features.Locations.Controllers;
using Features.Locations.DependencyInjection;
using Features.Users.Controllers;
using Features.Users.DependencyInjection;
using FluentValidation;
using FluentValidation.AspNetCore;
using Microsoft.OpenApi.Models;
using Shared.Application.Behaviors;
namespace API.Core;
internal static class ServiceCollectionExtensions
{
extension(IServiceCollection services)
{
public IServiceCollection AddApiControllers() =>
services
.AddControllers(options => options.Filters.Add<GlobalExceptionFilter>())
.AddApplicationPart(typeof(BreweryController).Assembly)
.AddApplicationPart(typeof(AuthController).Assembly)
.AddApplicationPart(typeof(CityController).Assembly)
.Services;
public IServiceCollection AddValidationAndMediatR()
{
services
.AddOpenApi()
.AddValidatorsFromAssemblyContaining<Program>()
.AddValidatorsFromAssembly(typeof(BreweryController).Assembly)
.AddValidatorsFromAssembly(typeof(AuthController).Assembly)
.AddValidatorsFromAssembly(typeof(UploadPhotoCommand).Assembly)
.AddFluentValidationAutoValidation()
.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssemblyContaining<Program>();
cfg.RegisterServicesFromAssembly(typeof(BreweryController).Assembly);
cfg.RegisterServicesFromAssembly(typeof(AuthController).Assembly);
cfg.RegisterServicesFromAssembly(typeof(UploadPhotoCommand).Assembly);
cfg.RegisterServicesFromAssembly(typeof(IEmailDispatcher).Assembly);
cfg.RegisterServicesFromAssembly(typeof(CityController).Assembly);
cfg.AddOpenBehavior(typeof(UnhandledExceptionLoggingBehavior<,>));
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
});
return services;
}
public IServiceCollection AddApplicationServices()
{
services
.AddEndpointsApiExplorer()
.AddSwaggerGen(options =>
{
options.AddSecurityDefinition(
"Bearer",
new OpenApiSecurityScheme
{
Name = "Authorization",
Type = SecuritySchemeType.Http,
Scheme = "Bearer",
BearerFormat = "JWT",
In = ParameterLocation.Header,
Description = "Enter a JWT access token.",
}
);
options.AddSecurityRequirement(
new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer",
},
},
[]
},
}
);
});
services.AddHealthChecks();
return services;
}
public IServiceCollection AddFeatureModules() =>
services
.AddDatabaseConnection()
.AddFeaturesBreweries()
.AddFeaturesUsers()
.AddFeaturesEmails()
.AddFeaturesPhotoUpload()
.AddFeaturesLocations();
public IServiceCollection AddCoreInfrastructure() =>
services.AddScoped<GlobalExceptionFilter>();
public IServiceCollection AddJwtAuthentication()
{
services
.AddAuthentication("JWT")
.AddScheme<JwtAuthenticationOptions, JwtAuthenticationHandler>(
"JWT",
options => { }
);
services.AddAuthorization();
return services;
}
}
}

View File

@@ -38,7 +38,8 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\API.Core\API.Core.csproj" /> <ProjectReference Include="..\API.Core\API.Core.csproj" />
<ProjectReference Include="..\..\Configuration\Configuration.csproj" /> <ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
<ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" /> <ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" />
<ProjectReference Include="..\..\Features\Features.Locations\Features.Locations.csproj" /> <ProjectReference Include="..\..\Features\Features.Locations\Features.Locations.csproj" />
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" /> <ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />

View File

@@ -15,7 +15,6 @@ COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain/Domain.Except
COPY ["Features/Features.Breweries/Features.Breweries.csproj", "Features/Features.Breweries/"] COPY ["Features/Features.Breweries/Features.Breweries.csproj", "Features/Features.Breweries/"]
COPY ["Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj", "Features/Features.Breweries.Tests/"] COPY ["Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj", "Features/Features.Breweries.Tests/"]
COPY ["Features/Features.Emails/Features.Emails.csproj", "Features/Features.Emails/"] COPY ["Features/Features.Emails/Features.Emails.csproj", "Features/Features.Emails/"]
COPY ["Features/Features.Emails.Templates/Features.Emails.Templates.csproj", "Features/Features.Emails.Templates/"]
COPY ["Features/Features.Emails.Tests/Features.Emails.Tests.csproj", "Features/Features.Emails.Tests/"] COPY ["Features/Features.Emails.Tests/Features.Emails.Tests.csproj", "Features/Features.Emails.Tests/"]
COPY ["Features/Features.Locations/Features.Locations.csproj", "Features/Features.Locations/"] COPY ["Features/Features.Locations/Features.Locations.csproj", "Features/Features.Locations/"]
COPY ["Features/Features.Locations.Tests/Features.Locations.Tests.csproj", "Features/Features.Locations.Tests/"] COPY ["Features/Features.Locations.Tests/Features.Locations.Tests.csproj", "Features/Features.Locations.Tests/"]
@@ -23,9 +22,13 @@ COPY ["Features/Features.PhotoUpload/Features.PhotoUpload.csproj", "Features/Fea
COPY ["Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj", "Features/Features.PhotoUpload.Tests/"] COPY ["Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj", "Features/Features.PhotoUpload.Tests/"]
COPY ["Features/Features.Users/Features.Users.csproj", "Features/Features.Users/"] COPY ["Features/Features.Users/Features.Users.csproj", "Features/Features.Users/"]
COPY ["Features/Features.Users.Tests/Features.Users.Tests.csproj", "Features/Features.Users.Tests/"] COPY ["Features/Features.Users.Tests/Features.Users.Tests.csproj", "Features/Features.Users.Tests/"]
COPY ["Configuration/Configuration.csproj", "Configuration/"] COPY ["Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj", "Infrastructure/Infrastructure.Configuration/"]
COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"]
COPY ["Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj", "Infrastructure/Infrastructure.Email.Templates/"]
COPY ["Infrastructure/Infrastructure.FileUpload/Infrastructure.FileUpload.csproj", "Infrastructure/Infrastructure.FileUpload/"]
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"] COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"]
COPY ["Shared/Shared.Application.Tests/Shared.Application.Tests.csproj", "Shared/Shared.Application.Tests/"]
COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"] COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"]
RUN dotnet restore "Biergarten.API.slnx" RUN dotnet restore "Biergarten.API.slnx"
COPY . . COPY . .

View File

@@ -7,4 +7,4 @@ Scenario: GET request to an invalid route returns 404
Given the API is running Given the API is running
When I send an HTTP request "GET" to "/invalid-route" When I send an HTTP request "GET" to "/invalid-route"
Then the response has HTTP status 404 Then the response has HTTP status 404
And the response JSON should have "message" equal "Are you lost? That route does not exist." And the response JSON should have "message" equal "Route not found."

View File

@@ -1,130 +0,0 @@
Feature: Public User Profile
As a client of the API
I want to read a user's public-facing profile
So that a public profile page can render without exposing private account fields
Background:
Given the API is running
Scenario: Public profile exposes only allowlisted fields
Given I have registered a new account
And I have a valid access token for my account
When I retrieve the public profile by ID
Then the response has HTTP status 200
And the public profile response should match the registered account
And the response JSON should have "biography" equal ""
And the response JSON should have "avatarUrl" equal ""
And the response JSON should not contain "email"
And the response JSON should not contain "dateOfBirth"
Scenario: Public profile exposes the uploaded avatar
Given I have registered a new account
And I have a valid access token for my account
And I have uploaded an avatar
When I retrieve the public profile by ID
Then the response has HTTP status 200
And the public profile response should carry my current avatar
Scenario: Public profile exposes the current avatar after it is replaced
Given I have registered a new account
And I have a valid access token for my account
And I have uploaded an avatar
And I have uploaded a new avatar, replacing the previous one
When I retrieve the public profile by ID
Then the response has HTTP status 200
And the public profile response should carry my current avatar
And the public profile response should not carry my previous avatar
Scenario: Fetching the avatar without authentication succeeds
Given I have registered a new account
And I have a valid access token for my account
And I have uploaded an avatar
When I retrieve the public profile by ID without authentication
Then the response has HTTP status 200
And the public profile response should carry my current avatar
Scenario: A different user can read someone else's avatar
Given I have registered a new account
And I have a valid access token for my account
And I have uploaded an avatar
And I am logged in as a different user
When I retrieve the public profile by ID
Then the response has HTTP status 200
And the public profile response should carry my current avatar
Scenario: Listing user accounts exposes each account's avatar
Given I have registered a new account
And I have a valid access token for my account
And I have uploaded an avatar
When I list user accounts
Then the response has HTTP status 200
And my listed account should carry my current avatar
Scenario: Listing user accounts exposes each account's biography
Given I have registered a new account
And I have a valid access token for my account
And I have set my biography to "Drinks lagers."
When I list user accounts
Then the response has HTTP status 200
And my listed account should carry my biography
Scenario: Public profile for a non-existent account returns not found
Given I have registered a new account
And I have a valid access token for my account
When I retrieve the public profile by a non-existent ID
Then the response has HTTP status 404
Scenario: Fetching the public profile without authentication succeeds
Given I have registered a new account
When I retrieve the public profile by ID without authentication
Then the response has HTTP status 200
And the response JSON should have "avatarUrl" equal ""
And the response JSON should not contain "email"
And the response JSON should not contain "dateOfBirth"
Scenario: A different user can read someone else's public profile
Given I have registered a new account
And I am logged in as a different user
When I retrieve the public profile by ID
Then the response has HTTP status 200
And the response JSON should not contain "email"
And the response JSON should not contain "dateOfBirth"
Scenario: Fetching my own account without authentication is rejected
When I retrieve my own account without authentication
Then the response has HTTP status 401
Scenario: Listing user accounts without authentication is rejected
When I list user accounts without authentication
Then the response has HTTP status 401
Scenario: Listing user accounts as an authenticated user excludes private fields
Given I have registered a new account
And I have a valid access token for my account
When I list user accounts
Then the response has HTTP status 200
And the response JSON should contain "avatarUrl"
And the response JSON should not contain "email"
And the response JSON should not contain "dateOfBirth"
Scenario: The caller can fetch their own account
Given I have registered a new account
And I have a valid access token for my account
When I retrieve my own account
Then the response has HTTP status 200
And the public profile response should match the registered account
Scenario: The caller's own account omits the avatar graph
Given I have registered a new account
And I have a valid access token for my account
And I have uploaded an avatar
When I retrieve my own account
Then the response has HTTP status 200
And the response JSON should not contain "userAvatar"
Scenario: The account endpoint serves the caller named by the access token
Given I have registered a new account
And I am logged in as a different user
When I retrieve my own account
Then the response has HTTP status 200
And the account response should be for a different account than the registered one

View File

@@ -1,4 +1,4 @@
using Features.Emails.Services; using Infrastructure.Email;
namespace API.Specs.Mocks; namespace API.Specs.Mocks;

View File

@@ -1,55 +0,0 @@
using Features.ImageUploads.Services;
namespace API.Specs.Mocks;
/// <summary>
/// Test double for <see cref="IFileStorageProvider" /> that records uploads in memory, so specs can
/// exercise photo-backed features without object storage.
/// </summary>
public class MockFileStorageProvider : IFileStorageProvider
{
private readonly List<string> _keys = [];
private readonly Dictionary<string, byte[]> _objects = new();
public IReadOnlyCollection<string> UploadedKeys => _keys.ToList();
/// <summary>The key of the most recent upload.</summary>
/// <exception cref="InvalidOperationException">Thrown when nothing has been uploaded yet.</exception>
public string LastUploadedKey =>
_keys.Count > 0
? _keys[^1]
: throw new InvalidOperationException("no file has been uploaded");
public Task UploadAsync(
string key,
Stream content,
string contentType,
CancellationToken cancellationToken = default
)
{
using MemoryStream buffer = new();
content.CopyTo(buffer);
if (!_objects.ContainsKey(key))
_keys.Add(key);
_objects[key] = buffer.ToArray();
return Task.CompletedTask;
}
public Task DeleteAsync(string key, CancellationToken cancellationToken = default)
{
_objects.Remove(key);
_keys.Remove(key);
return Task.CompletedTask;
}
/// <summary>
/// Stands in for a signed download URL: an absolute URL on a host the object store would serve,
/// carrying the key in its path, so specs can assert the shape callers actually receive.
/// </summary>
public string GetPresignedUrl(string key, TimeSpan expiresIn)
{
return $"https://storage.test/{key}?X-Amz-Expires={(int)expiresIn.TotalSeconds}";
}
}

View File

@@ -167,86 +167,4 @@ public class ApiGeneralSteps(ScenarioContext scenario)
actualValue actualValue
); );
} }
[Then("the response JSON should not contain {string}")]
public void ThenTheResponseJsonShouldNotContainString(string field)
{
scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
using JsonDocument doc = JsonDocument.Parse(responseBody!);
JsonElement root = doc.RootElement;
AssertFieldAbsent(root, field);
if (root.ValueKind == JsonValueKind.Object &&
root.TryGetProperty("payload", out JsonElement payloadElem))
AssertFieldAbsent(payloadElem, field);
}
[Then("the response JSON should contain {string}")]
public void ThenTheResponseJsonShouldContainString(string field)
{
scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
using JsonDocument doc = JsonDocument.Parse(responseBody!);
JsonElement root = doc.RootElement;
if (root.ValueKind == JsonValueKind.Object &&
!root.TryGetProperty(field, out _) &&
root.TryGetProperty("payload", out JsonElement payloadElem))
{
AssertFieldPresent(payloadElem, field);
return;
}
AssertFieldPresent(root, field);
}
/// <summary>
/// Recurses into arrays so a listing endpoint's response can be asserted the same way as a
/// single-resource response.
/// </summary>
private static void AssertFieldAbsent(JsonElement element, string field)
{
switch (element.ValueKind)
{
case JsonValueKind.Object:
element
.TryGetProperty(field, out _)
.Should()
.BeFalse("Expected field '{0}' to be absent from the response", field);
break;
case JsonValueKind.Array:
foreach (JsonElement item in element.EnumerateArray())
AssertFieldAbsent(item, field);
break;
}
}
/// <summary>
/// Recurses into arrays so every item of a listing endpoint's response must carry the field.
/// </summary>
private static void AssertFieldPresent(JsonElement element, string field)
{
switch (element.ValueKind)
{
case JsonValueKind.Object:
element
.TryGetProperty(field, out _)
.Should()
.BeTrue("Expected field '{0}' to be present in the response", field);
break;
case JsonValueKind.Array:
element
.EnumerateArray()
.Should()
.NotBeEmpty(
"Expected at least one item to assert field '{0}' against",
field
);
foreach (JsonElement item in element.EnumerateArray())
AssertFieldPresent(item, field);
break;
}
}
} }

View File

@@ -15,7 +15,7 @@ public abstract class ApiStepsBase(ScenarioContext scenario)
protected const string ResponseKey = "response"; protected const string ResponseKey = "response";
protected const string ResponseBodyKey = "responseBody"; protected const string ResponseBodyKey = "responseBody";
internal TestApiFactory GetFactory() protected TestApiFactory GetFactory()
{ {
TestApiFactory? factory = Scenario.TryGetValue<TestApiFactory>( TestApiFactory? factory = Scenario.TryGetValue<TestApiFactory>(
FactoryKey, FactoryKey,

View File

@@ -1,8 +1,8 @@
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using Features.Users.Services;
using FluentAssertions; using FluentAssertions;
using Configuration; using Infrastructure.Configuration;
using Infrastructure.Jwt;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Reqnroll; using Reqnroll;
@@ -347,46 +347,6 @@ public class AuthSteps(ScenarioContext scenario) : ApiStepsBase(scenario)
Scenario[RegisteredUsernameKey] = ParseRegisteredUsername(root); Scenario[RegisteredUsernameKey] = ParseRegisteredUsername(root);
} }
[Given("I am logged in as a different user")]
public async Task GivenIAmLoggedInAsADifferentUser()
{
HttpClient client = GetClient();
string suffix = Guid.NewGuid().ToString("N")[..8];
var registrationData = new
{
username = $"otheruser-{suffix}",
firstName = "Other",
lastName = "User",
email = $"otheruser-{suffix}@example.com",
dateOfBirth = "1990-01-01",
password = "Password1!",
};
HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/register")
{
Content = new StringContent(
JsonSerializer.Serialize(registrationData),
Encoding.UTF8,
"application/json"
),
};
HttpResponseMessage response = await client.SendAsync(requestMessage);
response.EnsureSuccessStatusCode();
string responseBody = await response.Content.ReadAsStringAsync();
using JsonDocument doc = JsonDocument.Parse(responseBody);
JsonElement payload = doc.RootElement.GetProperty("payload");
string accessToken =
(
payload.TryGetProperty("accessToken", out JsonElement tokenElem)
? tokenElem.GetString()
: null
) ?? throw new InvalidOperationException("accessToken missing from registration payload");
Scenario["accessToken"] = accessToken;
}
[Given("I am logged in")] [Given("I am logged in")]
public async Task GivenIAmLoggedIn() public async Task GivenIAmLoggedIn()
{ {

View File

@@ -1,6 +1,7 @@
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using Database.Connection; using Database.Connection;
using Features.Locations.Dtos;
using Features.Locations.Repository; using Features.Locations.Repository;
using FluentAssertions; using FluentAssertions;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -22,6 +23,11 @@ public class BrewerySteps(ScenarioContext scenario) : ApiStepsBase(scenario)
private const string FixtureStateProvinceIsoCode = "FX-TS"; private const string FixtureStateProvinceIsoCode = "FX-TS";
private const string FixtureCityName = "Fixture City"; private const string FixtureCityName = "Fixture City";
// GetOrCreateCityIdAsync is not fully race-safe under concurrent callers creating the same
// city (see its remarks); scenarios run in parallel across feature classes, so provisioning
// the shared fixture city is serialized the same way AuthSteps serializes its fixture account.
private static readonly SemaphoreSlim CityProvisioningLock = new(1, 1);
private HttpRequestMessage NewAuthenticatedRequest(HttpMethod method, string url) private HttpRequestMessage NewAuthenticatedRequest(HttpMethod method, string url)
{ {
HttpRequestMessage requestMessage = new(method, url); HttpRequestMessage requestMessage = new(method, url);
@@ -47,21 +53,24 @@ public class BrewerySteps(ScenarioContext scenario) : ApiStepsBase(scenario)
.Services.GetRequiredService<ISqlConnectionFactory>(); .Services.GetRequiredService<ISqlConnectionFactory>();
LocationRepository repository = new(connectionFactory); LocationRepository repository = new(connectionFactory);
Guid countryId = CityLocation fixtureCity = new(
await repository.GetCountryIdAsync(FixtureCountryIsoCode) FixtureCityName,
?? await repository.CreateCountryAsync(FixtureCountryName, FixtureCountryIsoCode); FixtureStateProvinceName,
FixtureStateProvinceIsoCode,
FixtureCountryName,
FixtureCountryIsoCode
);
Guid stateProvinceId = await CityProvisioningLock.WaitAsync();
await repository.GetStateProvinceIdAsync(FixtureStateProvinceIsoCode) Guid cityId;
?? await repository.CreateStateProvinceAsync( try
FixtureStateProvinceName, {
FixtureStateProvinceIsoCode, cityId = await repository.GetOrCreateCityIdAsync(fixtureCity);
countryId }
); finally
{
Guid cityId = CityProvisioningLock.Release();
await repository.GetCityIdAsync(FixtureCityName, FixtureStateProvinceIsoCode) }
?? await repository.CreateCityAsync(FixtureCityName, stateProvinceId);
Scenario[CityIdKey] = cityId; Scenario[CityIdKey] = cityId;
} }
@@ -262,6 +271,46 @@ public class BrewerySteps(ScenarioContext scenario) : ApiStepsBase(scenario)
await SendAsync(requestMessage); await SendAsync(requestMessage);
} }
[Given("I am logged in as a different user")]
public async Task GivenIAmLoggedInAsADifferentUser()
{
HttpClient client = GetClient();
string suffix = Guid.NewGuid().ToString("N")[..8];
var registrationData = new
{
username = $"otheruser-{suffix}",
firstName = "Other",
lastName = "User",
email = $"otheruser-{suffix}@example.com",
dateOfBirth = "1990-01-01",
password = "Password1!",
};
HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/register")
{
Content = new StringContent(
JsonSerializer.Serialize(registrationData),
Encoding.UTF8,
"application/json"
),
};
HttpResponseMessage response = await client.SendAsync(requestMessage);
response.EnsureSuccessStatusCode();
string responseBody = await response.Content.ReadAsStringAsync();
using JsonDocument doc = JsonDocument.Parse(responseBody);
JsonElement payload = doc.RootElement.GetProperty("payload");
string accessToken =
(
payload.TryGetProperty("accessToken", out JsonElement tokenElem)
? tokenElem.GetString()
: null
) ?? throw new InvalidOperationException("accessToken missing from registration payload");
Scenario[AccessTokenKey] = accessToken;
}
[Then("retrieving the brewery by ID should now return HTTP status {int}")] [Then("retrieving the brewery by ID should now return HTTP status {int}")]
public async Task ThenRetrievingTheBreweryByIdShouldNowReturnHttpStatus(int expectedCode) public async Task ThenRetrievingTheBreweryByIdShouldNowReturnHttpStatus(int expectedCode)
{ {

View File

@@ -1,290 +0,0 @@
using System.Text.Json;
using API.Specs.Mocks;
using Features.ImageUploads.Services;
using Features.Users.Commands.Profile.UploadAvatar;
using Features.Users.Repository;
using FluentAssertions;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Reqnroll;
namespace API.Specs.Steps;
[Binding]
public class UserSteps(ScenarioContext scenario) : ApiStepsBase(scenario)
{
private const string AccessTokenKey = "accessToken";
private const string RegisteredUserIdKey = "registeredUserId";
private const string RegisteredUsernameKey = "registeredUsername";
private const string CurrentAvatarKeyKey = "currentAvatarKey";
private const string PreviousAvatarKeyKey = "previousAvatarKey";
private const string BiographyKey = "biography";
private static readonly byte[] PngSignature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
private async Task SendAsync(HttpRequestMessage requestMessage)
{
HttpClient client = GetClient();
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
Scenario[ResponseKey] = response;
Scenario[ResponseBodyKey] = responseBody;
}
private HttpRequestMessage NewAuthenticatedRequest(HttpMethod method, string url)
{
HttpRequestMessage requestMessage = new(method, url);
if (Scenario.TryGetValue<string>(AccessTokenKey, out string? accessToken))
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
return requestMessage;
}
private Guid GetRegisteredUserId()
{
return Scenario.TryGetValue(RegisteredUserIdKey, out Guid id)
? id
: throw new InvalidOperationException("registered user ID not found in scenario");
}
/// <summary>
/// Builds the smallest upload the photo validator accepts: a PNG signature padded out to the
/// 1 KiB minimum.
/// </summary>
private static IFormFile CreateAvatarFile()
{
byte[] content = new byte[2048];
PngSignature.CopyTo(content, 0);
MemoryStream stream = new(content);
return new FormFile(stream, 0, content.Length, "file", "avatar.png")
{
Headers = new HeaderDictionary { ["Content-Type"] = "image/png" },
};
}
/// <summary>
/// Uploads an avatar and records the object key the storage provider actually received, rather
/// than re-deriving the key template the handler uses.
/// </summary>
private async Task UploadAvatarAsync()
{
Guid userId = GetRegisteredUserId();
using IServiceScope scope = GetFactory().Services.CreateScope();
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
await mediator.Send(new UploadAvatarCommand(userId, CreateAvatarFile()));
if (Scenario.TryGetValue<string>(CurrentAvatarKeyKey, out string? existing))
Scenario[PreviousAvatarKeyKey] = existing;
Scenario[CurrentAvatarKeyKey] = GetStorageProvider().LastUploadedKey;
}
private MockFileStorageProvider GetStorageProvider()
{
return (MockFileStorageProvider)
GetFactory().Services.GetRequiredService<IFileStorageProvider>();
}
private string GetCurrentAvatarKey()
{
return Scenario.TryGetValue(CurrentAvatarKeyKey, out string? key) && key is not null
? key
: throw new InvalidOperationException("no avatar has been uploaded in this scenario");
}
private string GetPreviousAvatarKey()
{
return Scenario.TryGetValue(PreviousAvatarKeyKey, out string? key) && key is not null
? key
: throw new InvalidOperationException(
"only one avatar has been uploaded in this scenario"
);
}
/// <summary>
/// Asserts that <paramref name="avatarUrl" /> is something a browser can fetch -- an absolute
/// HTTP(S) URL -- pointing at the object stored under <paramref name="expectedKey" />. A bare
/// storage key would resolve against the page's own origin, so the shape is the contract here,
/// not just the value.
/// </summary>
private static Uri AssertFetchableUrl(string? avatarUrl, string expectedKey)
{
avatarUrl.Should().NotBeNullOrWhiteSpace();
Uri.TryCreate(avatarUrl, UriKind.Absolute, out Uri? uri)
.Should()
.BeTrue("avatarUrl must be an absolute URL, but was '{0}'", avatarUrl);
uri!.Scheme.Should().BeOneOf("http", "https");
uri.AbsolutePath.Should().EndWith(expectedKey, "the URL must address the uploaded object");
return uri;
}
private string GetAvatarUrlFromResponse()
{
Scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
using JsonDocument doc = JsonDocument.Parse(responseBody!);
return doc.RootElement.GetProperty("avatarUrl").GetString()!;
}
[Given("I have uploaded an avatar")]
public async Task GivenIHaveUploadedAnAvatar()
{
await UploadAvatarAsync();
}
[Given("I have uploaded a new avatar, replacing the previous one")]
public async Task GivenIHaveUploadedANewAvatarReplacingThePreviousOne()
{
await UploadAvatarAsync();
}
/// <summary>
/// Writes the biography straight through the repository: registration creates the profile row
/// with an empty biography, and no endpoint edits it yet.
/// </summary>
[Given("I have set my biography to {string}")]
public async Task GivenIHaveSetMyBiographyTo(string biography)
{
using IServiceScope scope = GetFactory().Services.CreateScope();
IUserProfileRepository profiles =
scope.ServiceProvider.GetRequiredService<IUserProfileRepository>();
await profiles.UpdateBiographyAsync(
GetRegisteredUserId(),
biography,
CancellationToken.None
);
Scenario[BiographyKey] = biography;
}
[Then("the public profile response should carry my current avatar")]
public void ThenThePublicProfileResponseShouldCarryMyCurrentAvatar()
{
AssertFetchableUrl(GetAvatarUrlFromResponse(), GetCurrentAvatarKey());
}
[Then("the public profile response should not carry my previous avatar")]
public void ThenThePublicProfileResponseShouldNotCarryMyPreviousAvatar()
{
Uri uri = AssertFetchableUrl(GetAvatarUrlFromResponse(), GetCurrentAvatarKey());
uri.AbsolutePath.Should().NotContain(GetPreviousAvatarKey());
}
[Then("my listed account should carry my current avatar")]
public void ThenMyListedAccountShouldCarryMyCurrentAvatar()
{
AssertFetchableUrl(
GetMyListedAccount().GetProperty("avatarUrl").GetString(),
GetCurrentAvatarKey()
);
}
[Then("my listed account should carry my biography")]
public void ThenMyListedAccountShouldCarryMyBiography()
{
string expected =
Scenario.TryGetValue(BiographyKey, out string? biography) && biography is not null
? biography
: throw new InvalidOperationException("no biography was set in this scenario");
GetMyListedAccount().GetProperty("biography").GetString().Should().Be(expected);
}
/// <summary>Finds the registered account in a listing response.</summary>
private JsonElement GetMyListedAccount()
{
Scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
using JsonDocument doc = JsonDocument.Parse(responseBody!);
doc.RootElement.ValueKind.Should().Be(JsonValueKind.Array);
return doc
.RootElement.EnumerateArray()
.Should()
.ContainSingle(item =>
item.GetProperty("userAccountId").GetGuid() == GetRegisteredUserId()
)
.Subject.Clone();
}
[When("I retrieve the public profile by ID")]
public async Task WhenIRetrieveThePublicProfileById()
{
await SendAsync(
NewAuthenticatedRequest(HttpMethod.Get, $"/api/user/{GetRegisteredUserId()}/profile")
);
}
[When("I retrieve the public profile by ID without authentication")]
public async Task WhenIRetrieveThePublicProfileByIdWithoutAuthentication()
{
await SendAsync(
new HttpRequestMessage(HttpMethod.Get, $"/api/user/{GetRegisteredUserId()}/profile")
);
}
[When("I retrieve the public profile by a non-existent ID")]
public async Task WhenIRetrieveThePublicProfileByANonExistentId()
{
await SendAsync(
NewAuthenticatedRequest(HttpMethod.Get, $"/api/user/{Guid.NewGuid()}/profile")
);
}
[When("I retrieve my own account without authentication")]
public async Task WhenIRetrieveMyOwnAccountWithoutAuthentication()
{
await SendAsync(new HttpRequestMessage(HttpMethod.Get, "/api/user/authenticated"));
}
[When("I list user accounts without authentication")]
public async Task WhenIListUserAccountsWithoutAuthentication()
{
await SendAsync(new HttpRequestMessage(HttpMethod.Get, "/api/user"));
}
[When("I list user accounts")]
public async Task WhenIListUserAccounts()
{
await SendAsync(NewAuthenticatedRequest(HttpMethod.Get, "/api/user"));
}
[When("I retrieve my own account")]
public async Task WhenIRetrieveMyOwnAccount()
{
await SendAsync(NewAuthenticatedRequest(HttpMethod.Get, "/api/user/authenticated"));
}
[Then("the public profile response should match the registered account")]
public void ThenThePublicProfileResponseShouldMatchTheRegisteredAccount()
{
Scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
string? username = Scenario.TryGetValue<string>(RegisteredUsernameKey, out string? u)
? u
: throw new InvalidOperationException("registered username not found in scenario");
using JsonDocument doc = JsonDocument.Parse(responseBody!);
JsonElement root = doc.RootElement;
root.GetProperty("userAccountId").GetGuid().Should().Be(GetRegisteredUserId());
root.GetProperty("username").GetString().Should().Be(username);
}
[Then("the account response should be for a different account than the registered one")]
public void ThenTheAccountResponseShouldBeForADifferentAccountThanTheRegisteredOne()
{
Scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
using JsonDocument doc = JsonDocument.Parse(responseBody!);
doc.RootElement.GetProperty("userAccountId")
.GetGuid()
.Should()
.NotBe(GetRegisteredUserId());
}
}

View File

@@ -1,14 +1,13 @@
using API.Core;
using API.Specs.Mocks; using API.Specs.Mocks;
using Features.Emails.Services; using Features.Emails.Services;
using Features.ImageUploads.Services; using Infrastructure.Email;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace API.Specs; namespace API.Specs;
internal class TestApiFactory : WebApplicationFactory<Program> public class TestApiFactory : WebApplicationFactory<Program>
{ {
protected override void ConfigureWebHost(IWebHostBuilder builder) protected override void ConfigureWebHost(IWebHostBuilder builder)
{ {
@@ -33,15 +32,6 @@ internal class TestApiFactory : WebApplicationFactory<Program>
services.Remove(emailDispatcherDescriptor); services.Remove(emailDispatcherDescriptor);
services.AddScoped<IEmailDispatcher, MockEmailDispatcher>(); services.AddScoped<IEmailDispatcher, MockEmailDispatcher>();
ServiceDescriptor? fileStorageDescriptor = services.SingleOrDefault(d =>
d.ServiceType == typeof(IFileStorageProvider)
);
if (fileStorageDescriptor != null)
services.Remove(fileStorageDescriptor);
services.AddSingleton<IFileStorageProvider, MockFileStorageProvider>();
}); });
} }
} }

View File

@@ -1,11 +1,4 @@
<Solution> <Solution>
<Folder Name="/Docker/">
<File Path="../docker-compose.db.yaml"/>
<File Path="../docker-compose.min.yaml"/>
<File Path="../docker-compose.dev.yaml"/>
<File Path="../docker-compose.prod.yaml"/>
<File Path="../docker-compose.test.yaml"/>
</Folder>
<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" />
@@ -19,21 +12,25 @@
<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="/Configuration/"> <Folder Name="/Features/Users/">
<Project Path="Configuration/Configuration.csproj" /> <Project Path="Features\Features.Users.Tests\Features.Users.Tests.csproj" />
<Project Path="Features\Features.Users\Features.Users.csproj" />
</Folder>
<Folder Name="/Infrastructure/">
<Project Path="Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj" />
<Project Path="Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj" />
<Project Path="Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj" />
<Project Path="Infrastructure/Infrastructure.FileUpload/Infrastructure.FileUpload.csproj" />
<Project Path="Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj" />
<Project Path="Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj" />
</Folder> </Folder>
<Folder Name="/Features/" /> <Folder Name="/Features/" />
<Folder Name="/Features/Users/">
<Project Path="Features/Features.Users.Tests/Features.Users.Tests.csproj" />
<Project Path="Features/Features.Users/Features.Users.csproj" />
</Folder>
<Folder Name="/Features/Breweries/"> <Folder Name="/Features/Breweries/">
<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" />
</Folder> </Folder>
<Folder Name="/Features/Emails/"> <Folder Name="/Features/Emails/">
<Project Path="Features/Features.Emails/Features.Emails.csproj" /> <Project Path="Features/Features.Emails/Features.Emails.csproj" />
<Project Path="Features/Features.Emails.Templates/Features.Emails.Templates.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="/Features/Locations/"> <Folder Name="/Features/Locations/">
@@ -47,6 +44,5 @@
<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" />
<Project Path="Shared/Shared.Application.Tests/Shared.Application.Tests.csproj" />
</Folder> </Folder>
</Solution> </Solution>

View File

@@ -21,6 +21,6 @@
/> />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Configuration\Configuration.csproj" /> <ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -1,4 +1,4 @@
using Configuration; using Infrastructure.Configuration;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;

View File

@@ -1,4 +1,4 @@
using Configuration; using Infrastructure.Configuration;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;

View File

@@ -19,7 +19,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" /> <ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />
<ProjectReference Include="..\..\Configuration\Configuration.csproj" /> <ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<EmbeddedResource Include="scripts/**/*.sql" /> <EmbeddedResource Include="scripts/**/*.sql" />

View File

@@ -1,5 +1,5 @@
using Database.Connection; using Database.Connection;
using Configuration; using Infrastructure.Configuration;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;

View File

@@ -1,2 +0,0 @@
IF NOT EXISTS (SELECT 1 FROM sys.schemas WHERE name = 'Auth')
EXEC('CREATE SCHEMA Auth');

View File

@@ -1,4 +1,4 @@
CREATE TABLE Auth.UserAccount CREATE TABLE dbo.UserAccount
( (
UserAccountID UNIQUEIDENTIFIER UserAccountID UNIQUEIDENTIFIER
CONSTRAINT DF_UserAccountID DEFAULT NEWID(), CONSTRAINT DF_UserAccountID DEFAULT NEWID(),

View File

@@ -1,4 +1,4 @@
CREATE TABLE Media.Photo -- All photos must be linked to a user account, you cannot delete a user account if they have uploaded photos CREATE TABLE Photo -- All photos must be linked to a user account, you cannot delete a user account if they have uploaded photos
( (
PhotoID UNIQUEIDENTIFIER PhotoID UNIQUEIDENTIFIER
CONSTRAINT DF_PhotoID DEFAULT NEWID(), CONSTRAINT DF_PhotoID DEFAULT NEWID(),
@@ -18,10 +18,10 @@ CREATE TABLE Media.Photo -- All photos must be linked to a user account, you can
CONSTRAINT FK_Photo_UploadedBy CONSTRAINT FK_Photo_UploadedBy
FOREIGN KEY (UploadedByID) FOREIGN KEY (UploadedByID)
REFERENCES Auth.UserAccount (UserAccountID) REFERENCES UserAccount (UserAccountID)
ON DELETE NO ACTION ON DELETE NO ACTION
); );
CREATE CREATE
NONCLUSTERED INDEX IX_Photo_UploadedByID NONCLUSTERED INDEX IX_Photo_UploadedByID
ON Media.Photo(UploadedByID); ON Photo(UploadedByID);

View File

@@ -1,4 +1,4 @@
CREATE TABLE Auth.UserVerification -- delete verification data when user account is deleted CREATE TABLE UserVerification -- delete verification data when user account is deleted
( (
UserVerificationID UNIQUEIDENTIFIER UserVerificationID UNIQUEIDENTIFIER
CONSTRAINT DF_UserVerificationID DEFAULT NEWID(), CONSTRAINT DF_UserVerificationID DEFAULT NEWID(),
@@ -15,7 +15,7 @@ CREATE TABLE Auth.UserVerification -- delete verification data when user account
CONSTRAINT FK_UserVerification_UserAccount CONSTRAINT FK_UserVerification_UserAccount
FOREIGN KEY (UserAccountID) FOREIGN KEY (UserAccountID)
REFERENCES Auth.UserAccount (UserAccountID) REFERENCES UserAccount (UserAccountID)
ON DELETE CASCADE, ON DELETE CASCADE,
CONSTRAINT AK_UserVerification_UserAccountID CONSTRAINT AK_UserVerification_UserAccountID
@@ -24,4 +24,4 @@ CREATE TABLE Auth.UserVerification -- delete verification data when user account
CREATE CREATE
NONCLUSTERED INDEX IX_UserVerification_UserAccount NONCLUSTERED INDEX IX_UserVerification_UserAccount
ON Auth.UserVerification(UserAccountID); ON UserVerification(UserAccountID);

View File

@@ -1,4 +1,4 @@
CREATE TABLE Auth.UserCredential -- delete credentials when user account is deleted CREATE TABLE UserCredential -- delete credentials when user account is deleted
( (
UserCredentialID UNIQUEIDENTIFIER UserCredentialID UNIQUEIDENTIFIER
CONSTRAINT DF_UserCredentialID DEFAULT NEWID(), CONSTRAINT DF_UserCredentialID DEFAULT NEWID(),
@@ -26,15 +26,15 @@ CREATE TABLE Auth.UserCredential -- delete credentials when user account is dele
CONSTRAINT FK_UserCredential_UserAccount CONSTRAINT FK_UserCredential_UserAccount
FOREIGN KEY (UserAccountID) FOREIGN KEY (UserAccountID)
REFERENCES Auth.UserAccount (UserAccountID) REFERENCES UserAccount (UserAccountID)
ON DELETE CASCADE ON DELETE CASCADE
); );
CREATE CREATE
NONCLUSTERED INDEX IX_UserCredential_UserAccount NONCLUSTERED INDEX IX_UserCredential_UserAccount
ON Auth.UserCredential(UserAccountID); ON UserCredential(UserAccountID);
CREATE CREATE
NONCLUSTERED INDEX IX_UserCredential_Account_Active NONCLUSTERED INDEX IX_UserCredential_Account_Active
ON Auth.UserCredential(UserAccountID, IsRevoked, Expiry) ON UserCredential(UserAccountID, IsRevoked, Expiry)
INCLUDE (Hash); INCLUDE (Hash);

View File

@@ -1,4 +1,4 @@
CREATE TABLE Social.UserFollow CREATE TABLE UserFollow
( (
UserFollowID UNIQUEIDENTIFIER UserFollowID UNIQUEIDENTIFIER
CONSTRAINT DF_UserFollowID DEFAULT NEWID(), CONSTRAINT DF_UserFollowID DEFAULT NEWID(),
@@ -17,12 +17,12 @@ CREATE TABLE Social.UserFollow
CONSTRAINT FK_UserFollow_UserAccount CONSTRAINT FK_UserFollow_UserAccount
FOREIGN KEY (UserAccountID) FOREIGN KEY (UserAccountID)
REFERENCES Auth.UserAccount (UserAccountID) REFERENCES UserAccount (UserAccountID)
ON DELETE NO ACTION, ON DELETE NO ACTION,
CONSTRAINT FK_UserFollow_UserAccountFollowing CONSTRAINT FK_UserFollow_UserAccountFollowing
FOREIGN KEY (FollowingID) FOREIGN KEY (FollowingID)
REFERENCES Auth.UserAccount (UserAccountID) REFERENCES UserAccount (UserAccountID)
ON DELETE NO ACTION, ON DELETE NO ACTION,
CONSTRAINT CK_CannotFollowOwnAccount CONSTRAINT CK_CannotFollowOwnAccount
@@ -32,8 +32,8 @@ CREATE TABLE Social.UserFollow
CREATE CREATE
NONCLUSTERED INDEX IX_UserFollow_UserAccount_FollowingID NONCLUSTERED INDEX IX_UserFollow_UserAccount_FollowingID
ON Social.UserFollow(UserAccountID, FollowingID); ON UserFollow(UserAccountID, FollowingID);
CREATE CREATE
NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount
ON Social.UserFollow(FollowingID, UserAccountID); ON UserFollow(FollowingID, UserAccountID);

View File

@@ -1,4 +1,4 @@
CREATE TABLE Geolocation.Country CREATE TABLE Country
( (
CountryID UNIQUEIDENTIFIER CountryID UNIQUEIDENTIFIER
CONSTRAINT DF_CountryID DEFAULT NEWID(), CONSTRAINT DF_CountryID DEFAULT NEWID(),

View File

@@ -1,14 +1,14 @@
CREATE TABLE Geolocation.StateProvince CREATE TABLE StateProvince
( (
StateProvinceID UNIQUEIDENTIFIER StateProvinceID UNIQUEIDENTIFIER
CONSTRAINT DF_StateProvinceID DEFAULT NEWID(), CONSTRAINT DF_StateProvinceID DEFAULT NEWID(),
StateProvinceName NVARCHAR(100) NOT NULL, StateProvinceName NVARCHAR(100) NOT NULL,
CountryID UNIQUEIDENTIFIER NOT NULL,
-- eg 'US-CA' for California, 'CA-ON' for Ontario
ISO3166_2 CHAR(6) NOT NULL, ISO3166_2 CHAR(6) NOT NULL,
-- eg 'US-CA' for California, 'CA-ON' for Ontario
CountryID UNIQUEIDENTIFIER NOT NULL,
RowVersion ROWVERSION, RowVersion ROWVERSION,
@@ -18,14 +18,11 @@ CREATE TABLE Geolocation.StateProvince
CONSTRAINT AK_StateProvince_ISO3166_2 CONSTRAINT AK_StateProvince_ISO3166_2
UNIQUE (ISO3166_2), UNIQUE (ISO3166_2),
CONSTRAINT AK_StateProvince_Country
UNIQUE (StateProvinceName, CountryID),
CONSTRAINT FK_StateProvince_Country CONSTRAINT FK_StateProvince_Country
FOREIGN KEY (CountryID) FOREIGN KEY (CountryID)
REFERENCES Geolocation.Country (CountryID) REFERENCES Country (CountryID)
); );
CREATE CREATE
NONCLUSTERED INDEX IX_StateProvince_Country NONCLUSTERED INDEX IX_StateProvince_Country
ON Geolocation.StateProvince(CountryID); ON StateProvince(CountryID);

View File

@@ -1,4 +1,4 @@
CREATE TABLE Geolocation.City CREATE TABLE City
( (
CityID UNIQUEIDENTIFIER CityID UNIQUEIDENTIFIER
CONSTRAINT DF_CityID DEFAULT NEWID(), CONSTRAINT DF_CityID DEFAULT NEWID(),
@@ -14,11 +14,9 @@ CREATE TABLE Geolocation.City
CONSTRAINT FK_City_StateProvince CONSTRAINT FK_City_StateProvince
FOREIGN KEY (StateProvinceID) FOREIGN KEY (StateProvinceID)
REFERENCES Geolocation.StateProvince (StateProvinceID), REFERENCES StateProvince (StateProvinceID)
CONSTRAINT AK_CityStateProvince UNIQUE (CityName, StateProvinceID)
); );
CREATE CREATE
NONCLUSTERED INDEX IX_City_StateProvince NONCLUSTERED INDEX IX_City_StateProvince
ON Geolocation.City(StateProvinceID); ON City(StateProvinceID);

View File

@@ -1,4 +1,4 @@
CREATE TABLE Brewery.BreweryPost -- A user cannot be deleted if they have a post CREATE TABLE BreweryPost -- A user cannot be deleted if they have a post
( (
BreweryPostID UNIQUEIDENTIFIER BreweryPostID UNIQUEIDENTIFIER
CONSTRAINT DF_BreweryPostID DEFAULT NEWID(), CONSTRAINT DF_BreweryPostID DEFAULT NEWID(),
@@ -21,10 +21,10 @@ CREATE TABLE Brewery.BreweryPost -- A user cannot be deleted if they have a post
CONSTRAINT FK_BreweryPost_UserAccount CONSTRAINT FK_BreweryPost_UserAccount
FOREIGN KEY (PostedByID) FOREIGN KEY (PostedByID)
REFERENCES Auth.UserAccount (UserAccountID) REFERENCES UserAccount (UserAccountID)
ON DELETE NO ACTION ON DELETE NO ACTION
); );
CREATE CREATE
NONCLUSTERED INDEX IX_BreweryPost_PostedByID NONCLUSTERED INDEX IX_BreweryPost_PostedByID
ON Brewery.BreweryPost(PostedByID); ON BreweryPost(PostedByID);

View File

@@ -1,4 +1,4 @@
CREATE TABLE Brewery.BreweryPostLocation CREATE TABLE BreweryPostLocation
( (
BreweryPostLocationID UNIQUEIDENTIFIER BreweryPostLocationID UNIQUEIDENTIFIER
CONSTRAINT DF_BreweryPostLocationID DEFAULT NEWID(), CONSTRAINT DF_BreweryPostLocationID DEFAULT NEWID(),
@@ -25,24 +25,24 @@ CREATE TABLE Brewery.BreweryPostLocation
CONSTRAINT FK_BreweryPostLocation_BreweryPost CONSTRAINT FK_BreweryPostLocation_BreweryPost
FOREIGN KEY (BreweryPostID) FOREIGN KEY (BreweryPostID)
REFERENCES Brewery.BreweryPost (BreweryPostID) REFERENCES BreweryPost (BreweryPostID)
ON DELETE CASCADE, ON DELETE CASCADE,
CONSTRAINT FK_BreweryPostLocation_City CONSTRAINT FK_BreweryPostLocation_City
FOREIGN KEY (CityID) FOREIGN KEY (CityID)
REFERENCES Geolocation.City (CityID) REFERENCES City (CityID)
); );
CREATE CREATE
NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost
ON Brewery.BreweryPostLocation(BreweryPostID); ON BreweryPostLocation(BreweryPostID);
CREATE CREATE
NONCLUSTERED INDEX IX_BreweryPostLocation_City NONCLUSTERED INDEX IX_BreweryPostLocation_City
ON Brewery.BreweryPostLocation(CityID); ON BreweryPostLocation(CityID);
CREATE SPATIAL INDEX SIDX_BreweryPostLocation_Coordinates CREATE SPATIAL INDEX SIDX_BreweryPostLocation_Coordinates
ON Brewery.BreweryPostLocation(Coordinates) ON BreweryPostLocation(Coordinates)
USING GEOGRAPHY_GRID USING GEOGRAPHY_GRID
WITH ( WITH (
GRIDS = (LEVEL_1 = MEDIUM, LEVEL_2 = MEDIUM, LEVEL_3 = MEDIUM, LEVEL_4 = MEDIUM), GRIDS = (LEVEL_1 = MEDIUM, LEVEL_2 = MEDIUM, LEVEL_3 = MEDIUM, LEVEL_4 = MEDIUM),

View File

@@ -1,4 +1,4 @@
CREATE TABLE Brewery.BreweryPostPhoto -- All photos linked to a post are deleted if the post is deleted CREATE TABLE BreweryPostPhoto -- All photos linked to a post are deleted if the post is deleted
( (
BreweryPostPhotoID UNIQUEIDENTIFIER BreweryPostPhotoID UNIQUEIDENTIFIER
CONSTRAINT DF_BreweryPostPhotoID DEFAULT NEWID(), CONSTRAINT DF_BreweryPostPhotoID DEFAULT NEWID(),
@@ -17,19 +17,19 @@ CREATE TABLE Brewery.BreweryPostPhoto -- All photos linked to a post are deleted
CONSTRAINT FK_BreweryPostPhoto_BreweryPost CONSTRAINT FK_BreweryPostPhoto_BreweryPost
FOREIGN KEY (BreweryPostID) FOREIGN KEY (BreweryPostID)
REFERENCES Brewery.BreweryPost (BreweryPostID) REFERENCES BreweryPost (BreweryPostID)
ON DELETE CASCADE, ON DELETE CASCADE,
CONSTRAINT FK_BreweryPostPhoto_Photo CONSTRAINT FK_BreweryPostPhoto_Photo
FOREIGN KEY (PhotoID) FOREIGN KEY (PhotoID)
REFERENCES Media.Photo (PhotoID) REFERENCES Photo (PhotoID)
ON DELETE CASCADE ON DELETE CASCADE
); );
CREATE CREATE
NONCLUSTERED INDEX IX_BreweryPostPhoto_Photo_BreweryPost NONCLUSTERED INDEX IX_BreweryPostPhoto_Photo_BreweryPost
ON Brewery.BreweryPostPhoto(PhotoID, BreweryPostID); ON BreweryPostPhoto(PhotoID, BreweryPostID);
CREATE CREATE
NONCLUSTERED INDEX IX_BreweryPostPhoto_BreweryPost_Photo NONCLUSTERED INDEX IX_BreweryPostPhoto_BreweryPost_Photo
ON Brewery.BreweryPostPhoto(BreweryPostID, PhotoID); ON BreweryPostPhoto(BreweryPostID, PhotoID);

View File

@@ -1,4 +1,4 @@
CREATE TABLE Beer.BeerStyle CREATE TABLE BeerStyle
( (
BeerStyleID UNIQUEIDENTIFIER BeerStyleID UNIQUEIDENTIFIER
CONSTRAINT DF_BeerStyleID DEFAULT NEWID(), CONSTRAINT DF_BeerStyleID DEFAULT NEWID(),

View File

@@ -1,4 +1,4 @@
CREATE TABLE Beer.BeerPost CREATE TABLE BeerPost
( (
BeerPostID UNIQUEIDENTIFIER BeerPostID UNIQUEIDENTIFIER
CONSTRAINT DF_BeerPostID DEFAULT NEWID(), CONSTRAINT DF_BeerPostID DEFAULT NEWID(),
@@ -31,16 +31,16 @@ CREATE TABLE Beer.BeerPost
CONSTRAINT FK_BeerPost_PostedBy CONSTRAINT FK_BeerPost_PostedBy
FOREIGN KEY (PostedByID) FOREIGN KEY (PostedByID)
REFERENCES Auth.UserAccount (UserAccountID) REFERENCES UserAccount (UserAccountID)
ON DELETE NO ACTION, ON DELETE NO ACTION,
CONSTRAINT FK_BeerPost_BeerStyle CONSTRAINT FK_BeerPost_BeerStyle
FOREIGN KEY (BeerStyleID) FOREIGN KEY (BeerStyleID)
REFERENCES Beer.BeerStyle (BeerStyleID), REFERENCES BeerStyle (BeerStyleID),
CONSTRAINT FK_BeerPost_Brewery CONSTRAINT FK_BeerPost_Brewery
FOREIGN KEY (BrewedByID) FOREIGN KEY (BrewedByID)
REFERENCES Brewery.BreweryPost (BreweryPostID), REFERENCES BreweryPost (BreweryPostID),
CONSTRAINT CHK_BeerPost_ABV CONSTRAINT CHK_BeerPost_ABV
CHECK (ABV >= 0 AND ABV <= 67), CHECK (ABV >= 0 AND ABV <= 67),
@@ -51,12 +51,12 @@ CREATE TABLE Beer.BeerPost
CREATE CREATE
NONCLUSTERED INDEX IX_BeerPost_PostedBy NONCLUSTERED INDEX IX_BeerPost_PostedBy
ON Beer.BeerPost(PostedByID); ON BeerPost(PostedByID);
CREATE CREATE
NONCLUSTERED INDEX IX_BeerPost_BeerStyle NONCLUSTERED INDEX IX_BeerPost_BeerStyle
ON Beer.BeerPost(BeerStyleID); ON BeerPost(BeerStyleID);
CREATE CREATE
NONCLUSTERED INDEX IX_BeerPost_BrewedBy NONCLUSTERED INDEX IX_BeerPost_BrewedBy
ON Beer.BeerPost(BrewedByID); ON BeerPost(BrewedByID);

View File

@@ -1,4 +1,4 @@
CREATE TABLE Beer.BeerPostPhoto -- All photos linked to a beer post are deleted if the post is deleted CREATE TABLE BeerPostPhoto -- All photos linked to a beer post are deleted if the post is deleted
( (
BeerPostPhotoID UNIQUEIDENTIFIER BeerPostPhotoID UNIQUEIDENTIFIER
CONSTRAINT DF_BeerPostPhotoID DEFAULT NEWID(), CONSTRAINT DF_BeerPostPhotoID DEFAULT NEWID(),
@@ -17,19 +17,19 @@ CREATE TABLE Beer.BeerPostPhoto -- All photos linked to a beer post are deleted
CONSTRAINT FK_BeerPostPhoto_BeerPost CONSTRAINT FK_BeerPostPhoto_BeerPost
FOREIGN KEY (BeerPostID) FOREIGN KEY (BeerPostID)
REFERENCES Beer.BeerPost (BeerPostID) REFERENCES BeerPost (BeerPostID)
ON DELETE CASCADE, ON DELETE CASCADE,
CONSTRAINT FK_BeerPostPhoto_Photo CONSTRAINT FK_BeerPostPhoto_Photo
FOREIGN KEY (PhotoID) FOREIGN KEY (PhotoID)
REFERENCES Media.Photo (PhotoID) REFERENCES Photo (PhotoID)
ON DELETE CASCADE ON DELETE CASCADE
); );
CREATE CREATE
NONCLUSTERED INDEX IX_BeerPostPhoto_Photo_BeerPost NONCLUSTERED INDEX IX_BeerPostPhoto_Photo_BeerPost
ON Beer.BeerPostPhoto(PhotoID, BeerPostID); ON BeerPostPhoto(PhotoID, BeerPostID);
CREATE CREATE
NONCLUSTERED INDEX IX_BeerPostPhoto_BeerPost_Photo NONCLUSTERED INDEX IX_BeerPostPhoto_BeerPost_Photo
ON Beer.BeerPostPhoto(BeerPostID, PhotoID); ON BeerPostPhoto(BeerPostID, PhotoID);

View File

@@ -1,4 +1,4 @@
CREATE TABLE Beer.BeerPostComment CREATE TABLE BeerPostComment
( (
BeerPostCommentID UNIQUEIDENTIFIER BeerPostCommentID UNIQUEIDENTIFIER
CONSTRAINT DF_BeerPostComment DEFAULT NEWID(), CONSTRAINT DF_BeerPostComment DEFAULT NEWID(),
@@ -23,11 +23,11 @@ CREATE TABLE Beer.BeerPostComment
CONSTRAINT FK_BeerPostComment_BeerPost CONSTRAINT FK_BeerPostComment_BeerPost
FOREIGN KEY (BeerPostID) FOREIGN KEY (BeerPostID)
REFERENCES Beer.BeerPost (BeerPostID), REFERENCES BeerPost (BeerPostID),
CONSTRAINT FK_BeerPostComment_UserAccount CONSTRAINT FK_BeerPostComment_UserAccount
FOREIGN KEY (CommentedByID) FOREIGN KEY (CommentedByID)
REFERENCES Auth.UserAccount (UserAccountID) REFERENCES UserAccount (UserAccountID)
ON DELETE NO ACTION, ON DELETE NO ACTION,
CONSTRAINT CHK_BeerPostComment_Rating CONSTRAINT CHK_BeerPostComment_Rating
@@ -36,8 +36,8 @@ CREATE TABLE Beer.BeerPostComment
CREATE CREATE
NONCLUSTERED INDEX IX_BeerPostComment_BeerPost NONCLUSTERED INDEX IX_BeerPostComment_BeerPost
ON Beer.BeerPostComment(BeerPostID); ON BeerPostComment(BeerPostID);
CREATE CREATE
NONCLUSTERED INDEX IX_BeerPostComment_CommentedBy NONCLUSTERED INDEX IX_BeerPostComment_CommentedBy
ON Beer.BeerPostComment(CommentedByID); ON BeerPostComment(CommentedByID);

View File

@@ -1,4 +1,4 @@
CREATE TABLE Social.UserProfile CREATE TABLE dbo.UserProfile
( (
UserProfileID UNIQUEIDENTIFIER UserProfileID UNIQUEIDENTIFIER
CONSTRAINT DF_UserProfileID DEFAULT NEWID(), CONSTRAINT DF_UserProfileID DEFAULT NEWID(),
@@ -17,6 +17,6 @@ CREATE TABLE Social.UserProfile
CONSTRAINT FK_UserProfile_UserAccount CONSTRAINT FK_UserProfile_UserAccount
FOREIGN KEY (UserAccountID) FOREIGN KEY (UserAccountID)
REFERENCES Auth.UserAccount (UserAccountID) REFERENCES UserAccount (UserAccountID)
ON DELETE CASCADE ON DELETE CASCADE
); );

View File

@@ -1,4 +1,4 @@
CREATE TABLE Social.UserAvatar CREATE TABLE dbo.UserAvatar
( (
UserAvatarID UNIQUEIDENTIFIER UserAvatarID UNIQUEIDENTIFIER
CONSTRAINT DF_UserAvatarID DEFAULT NEWID(), CONSTRAINT DF_UserAvatarID DEFAULT NEWID(),
@@ -18,12 +18,12 @@ CREATE TABLE Social.UserAvatar
CONSTRAINT FK_UserAvatar_UserProfile CONSTRAINT FK_UserAvatar_UserProfile
FOREIGN KEY (UserProfileID) FOREIGN KEY (UserProfileID)
REFERENCES Social.UserProfile (UserProfileID) REFERENCES dbo.UserProfile (UserProfileID)
ON DELETE CASCADE, ON DELETE CASCADE,
CONSTRAINT FK_UserAvatar_Photo CONSTRAINT FK_UserAvatar_Photo
FOREIGN KEY (PhotoID) FOREIGN KEY (PhotoID)
REFERENCES Media.Photo (PhotoID) REFERENCES dbo.Photo (PhotoID)
ON DELETE NO ACTION ON DELETE NO ACTION
ON UPDATE NO ACTION, ON UPDATE NO ACTION,
@@ -33,11 +33,11 @@ CREATE TABLE Social.UserAvatar
-- At most one active avatar per profile -- At most one active avatar per profile
CREATE UNIQUE NONCLUSTERED INDEX UX_UserAvatar_ActiveOne CREATE UNIQUE NONCLUSTERED INDEX UX_UserAvatar_ActiveOne
ON Social.UserAvatar (UserProfileID) ON dbo.UserAvatar (UserProfileID)
WHERE ValidTo IS NULL; WHERE ValidTo IS NULL;
CREATE NONCLUSTERED INDEX IX_UserAvatar_Photo CREATE NONCLUSTERED INDEX IX_UserAvatar_Photo
ON Social.UserAvatar (PhotoID); ON dbo.UserAvatar (PhotoID);
CREATE NONCLUSTERED INDEX ix_UserAvatar_Profile CREATE NONCLUSTERED INDEX ix_UserAvatar_Profile
ON Social.UserAvatar (UserProfileID) ON dbo.UserAvatar (UserProfileID)

View File

@@ -1,2 +0,0 @@
IF NOT EXISTS (SELECT 1 FROM sys.schemas WHERE name = 'Geolocation')
EXEC('CREATE SCHEMA Geolocation');

View File

@@ -1,2 +0,0 @@
IF NOT EXISTS (SELECT 1 FROM sys.schemas WHERE name = 'Media')
EXEC('CREATE SCHEMA Media');

View File

@@ -1,2 +0,0 @@
IF NOT EXISTS (SELECT 1 FROM sys.schemas WHERE name = 'Brewery')
EXEC('CREATE SCHEMA Brewery');

View File

@@ -1,2 +0,0 @@
IF NOT EXISTS (SELECT 1 FROM sys.schemas WHERE name = 'Beer')
EXEC('CREATE SCHEMA Beer');

View File

@@ -1,2 +0,0 @@
IF NOT EXISTS (SELECT 1 FROM sys.schemas WHERE name = 'Social')
EXEC('CREATE SCHEMA Social');

View File

@@ -1,371 +0,0 @@
using Database.Seed.DatabaseHelpers;
using Database.Seed.Sqlite;
using Database.Seed.SourceDataModels;
using Domain.Entities;
using Features.Breweries.Commands.CreateBrewery;
using Features.Locations.Commands.CreateCity;
using Features.Locations.Commands.CreateCountry;
using Features.Locations.Commands.CreateStateProvince;
using Features.Locations.Queries.GetCity;
using Features.Locations.Queries.GetCountry;
using Features.Locations.Queries.GetStateProvince;
using Features.Users.Commands.Authentication.RegisterUser;
using Features.Users.Commands.Profile.UpdateBiography;
using Features.Users.Commands.Profile.UploadAvatar;
using Features.Users.Dtos;
using idunno.Password;
using MediatR;
using Microsoft.AspNetCore.Http;
using Spectre.Console;
using City = Database.Seed.SourceDataModels.City;
namespace Database.Seed;
public class BiergartenDataSeeder
{
private readonly IMediator _mediator;
private readonly SeedRepository _reader;
public BiergartenDataSeeder(IMediator mediator, SeedRepository reader)
{
_mediator = mediator;
_reader = reader;
}
public async Task Run(CancellationToken cancellationToken = default)
{
AnsiConsole.Write(new Rule("[bold green]Database Seeder[/]").LeftJustified());
AnsiConsole.MarkupLine("[grey]Connecting to SQLite source and loading seed data...[/]");
AnsiConsole.WriteLine();
SeedData seedData = null!;
IReadOnlyList<Guid> userIds = [];
await AnsiConsole
.Status()
.Spinner(Spinner.Known.Dots)
.SpinnerStyle(Style.Parse("green"))
.StartAsync(
"Loading seed data...",
async ctx =>
{
seedData = await _reader.ReadSeedDataAsync(cancellationToken);
ctx.Status($"Done.");
}
);
AnsiConsole.MarkupLine(
$"[green]✓[/] Loaded [bold]{seedData.Breweries.Count}[/] breweries."
);
AnsiConsole.Write(BuildBreweryDisplayTable(seedData.Breweries));
AnsiConsole.MarkupLine($"[green]✓[/] Loaded [bold]{seedData.Users.Count}[/] users.");
AnsiConsole.Write(BuildUserDisplayTable(seedData.Users));
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine($"[green]✓[/] Loaded [bold]{seedData.Cities.Count}[/] cities.");
AnsiConsole.WriteLine();
AnsiConsole.WriteLine("Seed data loaded successfully.");
AnsiConsole.Write(
new Rule("[bold green]Loading seed data into target database.[/]").LeftJustified()
);
await AnsiConsole
.Status()
.Spinner(Spinner.Known.Dots)
.SpinnerStyle(Style.Parse("green"))
.StartAsync(
"Loading user data into target database...",
async ctx =>
{
userIds = await LoadUsersIntoDatabaseAsync(seedData.Users, cancellationToken);
ctx.Status("User data loaded into target database.");
}
);
await AnsiConsole
.Status()
.Spinner(Spinner.Known.Dots)
.SpinnerStyle(Style.Parse("green"))
.StartAsync(
"Loading avatars into target database...",
async ctx =>
{
await LoadAvatarsIntoDatabaseAsync(
userIds,
seedData.Users,
ctx,
cancellationToken
);
ctx.Status("Avatar data loaded into target database.");
}
);
await AnsiConsole
.Status()
.Spinner(Spinner.Known.Dots)
.SpinnerStyle(Style.Parse("green"))
.StartAsync(
"Loading city data into target database...",
async ctx =>
{
await LoadCitiesIntoDatabaseAsync(seedData.Cities, ctx, cancellationToken);
ctx.Status("City data loaded into target database.");
}
);
await AnsiConsole
.Status()
.Spinner(Spinner.Known.Dots)
.SpinnerStyle(Style.Parse("green"))
.StartAsync(
"Loading brewery data into target database...",
async ctx =>
{
await LoadBreweriesIntoDatabaseAsync(
seedData.Breweries,
userIds,
ctx,
cancellationToken
);
ctx.Status("Brewery data loaded into target database.");
}
);
}
private async Task<IReadOnlyList<Guid>> LoadUsersIntoDatabaseAsync(
IReadOnlyList<UserRecord> users,
CancellationToken cancellationToken
)
{
List<Guid> userAccountIds = [];
foreach (UserRecord userRecord in users)
{
cancellationToken.ThrowIfCancellationRequested();
// allowRepeatedCharacters: true -- 12 unique digits was requested from only 10 possible
// digit characters (0-9)
string password = PasswordGenerator.Generate(
length: 64,
numberOfDigits: 12,
numberOfSymbols: 12,
allowRepeatedCharacters: true
);
RegistrationPayload registration = await _mediator.Send(
new RegisterUserCommand(
userRecord.User.Username,
userRecord.User.FirstName,
userRecord.User.LastName,
userRecord.Email,
DateTime.Parse(userRecord.DateOfBirth),
password
),
cancellationToken
);
userAccountIds.Add(registration.UserAccountId);
}
return userAccountIds;
}
private async Task LoadAvatarsIntoDatabaseAsync(
IReadOnlyList<Guid> userIds,
IReadOnlyList<UserRecord> users,
StatusContext ctx,
CancellationToken cancellationToken
)
{
for (int i = 0; i < userIds.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
ctx.Status($"Loading avatar {i + 1}/{userIds.Count} into target database...");
Guid userId = userIds[i];
await _mediator.Send(
new UpdateBiographyCommand(userId, users[i].User.Bio),
cancellationToken
);
byte[] avatarPng = AvatarGenerator.GeneratePng(userId);
await using MemoryStream stream = new(avatarPng);
IFormFile file = new FormFile(stream, 0, stream.Length, "file", $"{userId}.png")
{
Headers = new HeaderDictionary(),
ContentType = "image/png",
};
await _mediator.Send(new UploadAvatarCommand(userId, file), cancellationToken);
}
}
private async Task LoadCitiesIntoDatabaseAsync(
IReadOnlyList<City> cities,
StatusContext ctx,
CancellationToken cancellationToken
)
{
Dictionary<string, Guid> countryIdsByIso31661 = [];
Dictionary<string, Guid> stateProvinceIdsByIso31662 = [];
foreach (City city in cities)
{
cancellationToken.ThrowIfCancellationRequested();
ctx.Status(
$"Loading city {city.CityName}, {city.StateProvinceName}, {city.CountryName}..."
);
if (!countryIdsByIso31661.TryGetValue(city.ISO_3166_1, out Guid countryId))
{
countryId =
await _mediator.Send(new GetCountryQuery(city.ISO_3166_1), cancellationToken)
?? await _mediator.Send(
new CreateCountryCommand(city.CountryName, city.ISO_3166_1),
cancellationToken
);
countryIdsByIso31661[city.ISO_3166_1] = countryId;
}
if (!stateProvinceIdsByIso31662.TryGetValue(city.ISO_3166_2, out Guid stateProvinceId))
{
stateProvinceId =
await _mediator.Send(
new GetStateProvinceQuery(city.ISO_3166_2),
cancellationToken
)
?? await _mediator.Send(
new CreateStateProvinceCommand(
city.StateProvinceName,
city.ISO_3166_2,
countryId
),
cancellationToken
);
stateProvinceIdsByIso31662[city.ISO_3166_2] = stateProvinceId;
}
await _mediator.Send(
new CreateCityCommand(city.CityName, stateProvinceId),
cancellationToken
);
}
}
private async Task LoadBreweriesIntoDatabaseAsync(
IReadOnlyList<BreweryResult> breweries,
IReadOnlyList<Guid> posterUserIds,
StatusContext ctx,
CancellationToken cancellationToken
)
{
if (posterUserIds.Count == 0)
throw new InvalidOperationException(
"Cannot load breweries without any registered users."
);
for (int i = 0; i < breweries.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
BreweryResult breweryRecord = breweries[i];
BreweryAddress address =
breweryRecord.Address
?? throw new InvalidOperationException("Brewery address was not loaded.");
City city =
address.City ?? throw new InvalidOperationException("Brewery city was not loaded.");
ctx.Status($"Loading brewery {i + 1}/{breweries.Count} into target database...");
Guid cityId =
await _mediator.Send(
new GetCityQuery(city.CityName, city.ISO_3166_2),
cancellationToken
)
?? throw new InvalidOperationException(
$"City '{city.CityName}' "
+ $"({city.ISO_3166_2}) was not found. "
+ "Cities must be loaded before breweries."
);
await _mediator.Send(
new CreateBreweryCommand(
posterUserIds[i % posterUserIds.Count],
breweryRecord.NameEn,
breweryRecord.DescriptionEn,
new CreateBreweryLocation(
cityId,
address.AddressLine1,
null,
address.PostalCode,
new CoordinateData(address.Latitude, address.Longitude)
)
),
cancellationToken
);
}
}
private static Table BuildBreweryDisplayTable(IReadOnlyList<BreweryResult> breweries)
{
Table table = new Table()
.AddColumn("Brewery Name (EN)")
.AddColumn("Brewery Name (Local)")
.AddColumn("City")
.AddColumn("State/Province")
.AddColumn("Country")
.AddColumn("Longitude")
.AddColumn("Latitude");
foreach (BreweryResult brewery in breweries)
{
BreweryAddress address =
brewery.Address
?? throw new InvalidOperationException("Brewery address was not loaded.");
City city =
address.City ?? throw new InvalidOperationException("Brewery city was not loaded.");
table.AddRow(
brewery.NameEn,
brewery.NameLocal,
city.CityName,
city.StateProvinceName,
city.CountryName,
address.Longitude.ToString("F6"),
address.Latitude.ToString("F6")
);
}
return table;
}
private static Table BuildUserDisplayTable(IReadOnlyList<UserRecord> users)
{
Table table = new Table()
.AddColumn("Username")
.AddColumn("First Name")
.AddColumn("Last Name")
.AddColumn("Email")
.AddColumn("Date of Birth");
foreach (UserRecord user in users)
{
table.AddRow(
user.User.Username,
user.User.FirstName,
user.User.LastName,
user.Email,
user.DateOfBirth
);
}
return table;
}
}

View File

@@ -9,7 +9,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="idunno.Password.Generator" Version="2.0.0" /> <PackageReference Include="idunno.Password.Generator" Version="2.0.0" />
<PackageReference Include="MediatR" Version="12.5.0" /> <PackageReference Include="MediatR" Version="12.4.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.11" /> <PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.11" />
<PackageReference Include="SkiaSharp" Version="4.151.1" /> <PackageReference Include="SkiaSharp" Version="4.151.1" />
@@ -24,11 +24,9 @@
<ProjectReference Include="..\..\Features\Features.Users\Features.Users.csproj" /> <ProjectReference Include="..\..\Features\Features.Users\Features.Users.csproj" />
<ProjectReference Include="..\..\Features\Features.Locations\Features.Locations.csproj" /> <ProjectReference Include="..\..\Features\Features.Locations\Features.Locations.csproj" />
<ProjectReference Include="..\..\Features\Features.PhotoUpload\Features.PhotoUpload.csproj" /> <ProjectReference Include="..\..\Features\Features.PhotoUpload\Features.PhotoUpload.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.FileUpload\Infrastructure.FileUpload.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None <None CopyToOutputDirectory="PreserveNewest" Include="SeedData\biergarten_seed_2026-08-25T20-45-50.697244Z.sqlite" />
CopyToOutputDirectory="PreserveNewest"
Include="SeedData\biergarten_seed_2026-08-25T20-45-50.697244Z.sqlite"
/>
</ItemGroup> </ItemGroup>
</Project> </Project>

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