mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 01:54:43 +00:00
Compare commits
40 Commits
dee38fd1a3
...
feat/auth-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5028a43f4b | ||
|
|
fffbe68dc2 | ||
|
|
e6240bc889 | ||
|
|
4a5abd4639 | ||
|
|
d02f31b55c | ||
|
|
e961ce4042 | ||
|
|
dfb67bcdfa | ||
|
|
0ecc9a830e | ||
|
|
5ac55b8be0 | ||
|
|
19791df6a5 | ||
|
|
0780349a79 | ||
|
|
c0cb013f54 | ||
|
|
dfe248ea5f | ||
|
|
ff775e3b8a | ||
|
|
0e51630439 | ||
|
|
bd0e9e4a20 | ||
|
|
c5972b9f04 | ||
|
|
e1b6cba1d4 | ||
|
|
0ff42c3c70 | ||
|
|
a6a9046457 | ||
|
|
40ae75808c | ||
|
|
fbede956e9 | ||
|
|
3793fca65e | ||
|
|
78d0eaa739 | ||
|
|
180ed41e68 | ||
|
|
ce2c90070e | ||
|
|
de63e9f216 | ||
|
|
7187631ebc | ||
|
|
5fa78fbdf6 | ||
|
|
f85ff79a2c | ||
|
|
a3ec7d63f2 | ||
|
|
9d5a463d93 | ||
|
|
d958933250 | ||
|
|
4aa7ba5d9b | ||
|
|
272a25fb05 | ||
|
|
d9411eb00e | ||
|
|
78ad32e93a | ||
|
|
d6e176bcd9 | ||
|
|
8d59f09cb2 | ||
|
|
30317326ce |
52
.claude/skills/doc-drift-pipeline/SKILL.md
Normal file
52
.claude/skills/doc-drift-pipeline/SKILL.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
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`.
|
||||||
55
.claude/skills/doc-drift-web/SKILL.md
Normal file
55
.claude/skills/doc-drift-web/SKILL.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
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`.
|
||||||
101
.github/pull_request_template.md
vendored
Normal file
101
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
## 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.
|
||||||
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
|||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
status=0
|
status=0
|
||||||
for name in test-env-api-specs test-env-unit-tests test-env-frontend-tests; do
|
for name in test-env-database-migrations test-env-database-seed 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
1
.gitignore
vendored
@@ -22,6 +22,7 @@ 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
|
||||||
|
|||||||
@@ -13,3 +13,21 @@ 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.
|
||||||
|
|||||||
43
docs/makefile
Normal file
43
docs/makefile
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# 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'
|
||||||
@@ -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-08-31
|
last-updated: 2026-09-02
|
||||||
tags:
|
tags:
|
||||||
- pipeline
|
- pipeline
|
||||||
- cpp
|
- cpp
|
||||||
@@ -248,74 +248,28 @@ skipped and the pipeline continues. `GenerateUsers()` runs before
|
|||||||
|
|
||||||
### Key components
|
### Key components
|
||||||
|
|
||||||
- `src/main.cc`: argument parsing and Boost.DI composition root.
|
| Component | Description |
|
||||||
- `CuratedJsonDataService`: implements `ICuratedDataService`; takes a
|
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
`CuratedDataFilePaths` DTO (locations/personas/forenames/surnames paths) in
|
| `src/main.cc` | Argument parsing and the Boost.DI composition root. |
|
||||||
its constructor, then parses and validates curated location, persona, and
|
| `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. |
|
||||||
forename/surname JSON, memoizing each result after its first load on a given
|
| `WikipediaEnrichmentService` | Queries Wikipedia extracts, caches results, and returns empty context on failure. `MockEnrichmentService` is the no-op substitute used in `--mocked` runs. |
|
||||||
instance. `MockCuratedDataService` is the in-memory substitute (4 fixed
|
| `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. |
|
||||||
locations, 3 personas, and name data for `US`/`DE`/`FR`/`BE`) used in
|
| `MockGenerator` | Uses stable hash-based output so the same city/persona/name input always produces the same brewery or user. |
|
||||||
`--mocked` runs.
|
| `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. |
|
||||||
- `WikipediaEnrichmentService`: queries Wikipedia extracts, caches results,
|
| `SqliteExportService` | Creates a dated SQLite file per run and persists each successful user and brewery into normalized tables. |
|
||||||
returns empty context on failure. `MockEnrichmentService` is the no-op
|
| 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. |
|
||||||
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
|
||||||
|
|
||||||
`WikipediaEnrichmentService` fetches two Wikipedia extracts per city: a generic
|
| Component / behavior | Details |
|
||||||
"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
|
| `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. |
|
||||||
first successful (or empty) lookup.
|
| `GetLocationContext()` | Returns an empty string when the web client is unavailable or when lookup/parsing fails. |
|
||||||
|
| `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. |
|
||||||
`GetLocationContext()` returns an empty string when the web client is
|
| `MockGenerator` | Uses stable hashes for repeatable output in demos and Storybook runs. |
|
||||||
unavailable or when lookup/parsing fails.
|
| `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. |
|
||||||
`LlamaGenerator` validates model output as structured JSON. On validation
|
| `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. |
|
||||||
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
|
||||||
|
|
||||||
@@ -463,30 +417,32 @@ 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/`.
|
||||||
|
|
||||||
- `src/main.cc`: argument parsing and DI composition root.
|
| Path | Purpose |
|
||||||
- `src/biergarten_pipeline_orchestrator/`: orchestration, sampling, logging, and
|
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
export.
|
| `src/main.cc` | Argument parsing and DI composition root. |
|
||||||
- `src/services/curated_data/`: `CuratedJsonDataService`, the file-backed
|
| `src/biergarten_pipeline_orchestrator/` | Orchestration, sampling, logging, and export. |
|
||||||
`ICuratedDataService`, and `MockCuratedDataService`, the in-memory
|
| `src/services/curated_data/` | `CuratedJsonDataService`, the file-backed `ICuratedDataService`, and `MockCuratedDataService`, the in-memory `ICuratedDataService` used in `--mocked` runs. |
|
||||||
`ICuratedDataService` used in `--mocked` runs.
|
| `src/services/enrichment/wikipedia/` | Enrichment service and cache. |
|
||||||
- `src/services/enrichment/wikipedia/`: enrichment service and cache.
|
| `src/services/sqlite/` | SQLite export implementation. |
|
||||||
- `src/services/sqlite/`: SQLite export implementation.
|
| `src/data_generation/llama/` | Local inference, prompt loading, and output validation. |
|
||||||
- `src/data_generation/llama/`: local inference, prompt loading, output
|
| `src/data_generation/mock/` | Deterministic fallback. |
|
||||||
validation.
|
| `runpod/` | Container build and runtime launcher. |
|
||||||
- `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. The next passes add additional fixture types so the app can
|
SQLite exports.
|
||||||
exercise the full brewery and social domains without live data. For the detailed
|
|
||||||
engineering breakdown of what's needed to reach the architecture in
|
|
||||||
[`diagrams/planned/`](./diagrams/planned/), see [ROADMAP.md](./ROADMAP.md).
|
|
||||||
|
|
||||||
### Testing (very high priority)
|
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).
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Pipeline roadmap — reaching the planned architecture
|
title: Pipeline roadmap — reaching the planned architecture
|
||||||
last-updated: 2026-08-31
|
last-updated: 2026-09-01
|
||||||
tags:
|
tags:
|
||||||
- pipeline
|
- pipeline
|
||||||
- roadmap
|
- roadmap
|
||||||
@@ -8,22 +8,17 @@ tags:
|
|||||||
- concurrency
|
- concurrency
|
||||||
---
|
---
|
||||||
|
|
||||||
This is the engineering breakdown for closing the gap between the **current**
|
Engineering breakdown for closing the gap between the **current** pipeline and
|
||||||
pipeline and the **planned** architecture documented in
|
the **planned** architecture in
|
||||||
[`diagrams/planned/class.md`](./diagrams/planned/class.md) and
|
[`diagrams/planned/class.md`](./diagrams/planned/class.md) /
|
||||||
[`diagrams/planned/activity.md`](./diagrams/planned/activity.md). Nothing in
|
[`diagrams/planned/activity.md`](./diagrams/planned/activity.md). Items are
|
||||||
`diagrams/planned/` is implemented yet — this file tracks what it would take to
|
grouped by layer, roughly by dependency order. For the current implementation,
|
||||||
get there. For the current implementation, see [README.md](./README.md).
|
see [README.md](./README.md).
|
||||||
|
|
||||||
Items are grouped by layer and roughly ordered by dependency: later groups build
|
The only concurrency today is a log dispatcher thread draining a
|
||||||
on types and services introduced earlier.
|
`BoundedChannel<LogEntry>` into spdlog; sampling, enrichment, generation, and
|
||||||
|
export run synchronously on the main thread — §6 introduces the planned worker
|
||||||
The only concurrency in the current pipeline is the log dispatcher thread
|
threads.
|
||||||
(`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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -31,231 +26,183 @@ architecture needs.
|
|||||||
|
|
||||||
`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` (currently a plain `std::string`) with
|
`EnrichedCity::region_context` (a plain `string` today) with it.
|
||||||
`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` result payloads.
|
- [ ] Add `BeerResult`, `CheckinResult`, `RatingResult` payloads.
|
||||||
- [ ] Add `GenerationMetadata` (`generation_id`, `generated_time`,
|
- [ ] Add `GenerationMetadata` (`generation_id`, `generated_time`,
|
||||||
`context_provided`, `generated_with`).
|
`context_provided`, `generated_with`).
|
||||||
- [x] Add `first_name`, `last_name`, `gender`, and `activity_weight` to
|
- [x] `UserResult` gained `first_name`/`last_name`/`gender`/`activity_weight`
|
||||||
`UserResult` (currently just `username`, `bio`). `first_name`/
|
(was just `username`/`bio`), copied from the sampled `Name`, not
|
||||||
`last_name`/`gender` are copied from the sampled `Name` (see below), not
|
|
||||||
LLM-invented.
|
LLM-invented.
|
||||||
- [x] Add `Name` (`first_name`, `last_name`, `gender`) — the sampled result
|
- [x] Added `Name` (`first_name`, `last_name`, `gender`) and `ForenameEntry`
|
||||||
handed to `DataGenerator::GenerateUser`. Add `ForenameEntry` (`name`,
|
(`name`, `gender`). Flatter than planned: no `NamesByCountry` wrapper —
|
||||||
`gender`). Landed as a flatter shape than originally planned here: no
|
`ICuratedDataService` exposes `ForenamesByCountryMap`/
|
||||||
`NamesByCountry` wrapper class. `curated_data_service.h` instead declares
|
`SurnamesByCountryMap` (keyed by ISO 3166-1) directly, and sampling is a
|
||||||
`ForenameList = std::vector<ForenameEntry>` and
|
free function, `SampleName(forenames, surnames, iso3166_1, rng)`, not a
|
||||||
`SurnameList = std::vector<std::string>`, and `ICuratedDataService`
|
class method.
|
||||||
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] Add `UserRecord` (`location`, `user : UserResult`, `email`,
|
- [x] Added `UserRecord` (`location`, `user`, `email`, `date_of_birth`), the
|
||||||
`date_of_birth`) as the current stand-in for the planned `GeneratedUser` —
|
current stand-in for the planned `GeneratedUser`. `email`/ `date_of_birth`
|
||||||
`email` and `date_of_birth` are programmatically generated by the
|
are programmatic (never LLM-authored), for downstream auth-account
|
||||||
orchestrator, never LLM-authored, so a downstream auth-account seeding
|
seeding. Missing `user_id`/`password`/`metadata`; already exported via
|
||||||
consumer can register real accounts from the pipeline's SQLite export. No
|
`ProcessRecord(const UserRecord&)` (§5).
|
||||||
`password`, `user_id`, or `metadata` field yet, matching `BreweryRecord`'s
|
- [x] Added `UserPersona` (`name`, `description`, `style_affinities`).
|
||||||
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] Extract an interface; have `CuratedJsonDataService` implement it. Landed
|
- [x] Extracted `ICuratedDataService` (not `DataPreloader`); `Load*()` methods
|
||||||
as `ICuratedDataService` (`services/curated_data/curated_data_service.h`),
|
take no arguments, return `const&`. `CuratedJsonDataService` (renamed from
|
||||||
not `DataPreloader` — `LoadLocations()`, `LoadPersonas()`,
|
`JsonLoader`) takes a `CuratedDataFilePaths` DTO at construction and
|
||||||
`LoadForenamesByCountry()`, and `LoadSurnamesByCountry()` are all virtual
|
memoizes each `Load*()` result. `MockCuratedDataService` (fixed data for
|
||||||
methods returning `const&` and take no arguments. `CuratedJsonDataService`
|
`US`/`DE`/`FR`/`BE`) binds in its place under `--mocked`.
|
||||||
(`services/curated_data/curated_json_data_service.h`, originally landed as
|
- [ ] Add `LoadBeerStyles()`. `beer-styles.json` exists and is copied into the
|
||||||
`JsonLoader` under `json_handling/` before being renamed and moved) takes
|
Docker image, but no loader reads it and the native CMake build doesn't
|
||||||
a `CuratedDataFilePaths` DTO (`locations_path`, `personas_path`,
|
copy it into `build/` at all.
|
||||||
`forenames_path`, `surnames_path`) in its constructor rather than a path
|
- [x] Added `LoadPersonas()` and authored `personas.json`.
|
||||||
per `Load*()` call, and memoizes each result in a private `cache` struct
|
- [x] Added `LoadForenamesByCountry()` / `LoadSurnamesByCountry()` (two methods,
|
||||||
(`locations`, `personas`, `forenames_by_country`, `surnames_by_country`),
|
not one combined `LoadNamesByCountry()`), parsing vendored CC0 data from
|
||||||
so a second call on the same instance returns the cached result instead of
|
`sigpwned/popular-names-by-country-dataset` — see
|
||||||
re-parsing — safe because `CuratedJsonDataService` outlives every call
|
ETHICS-AND-KNOWN-ISSUES.md for provenance. Deliberately not pre-paired or
|
||||||
site (owned by `BiergartenPipelineOrchestrator` via
|
filtered to `locations.json`'s countries, to keep per-forename gender and
|
||||||
`unique_ptr<ICuratedDataService>` for the whole run). `main.cc`'s DI
|
support more countries later without re-sourcing.
|
||||||
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) directory exists today.
|
Entirely new — no `includes/policy/` (or equivalent) exists today.
|
||||||
|
|
||||||
- [ ] `ContextStrategy` interface + `BreweryContextStrategy` /
|
- [ ] `ContextStrategy` + `BreweryContextStrategy`/`BeerContextStrategy`. Today
|
||||||
`BeerContextStrategy`. Today
|
`WikipediaEnrichmentService::GetLocationContext` hardcodes a `"brewing"`
|
||||||
`WikipediaEnrichmentService::GetLocationContext` hardcodes a generic
|
query and a `"beer in {country}"` query directly.
|
||||||
`"brewing"` query and a `"beer in {country}"` query directly — no
|
- [ ] `SamplingStrategy` + `UniformSamplingStrategy`, replacing the inline
|
||||||
per-phase strategy selection.
|
`std::ranges::sample(...)` in `QueryCitiesWithCountries()`.
|
||||||
- [ ] `SamplingStrategy` interface + `UniformSamplingStrategy`, replacing the
|
- [ ] `BeerSelectionStrategy` + `RandomBeerSelectionStrategy` (depends on §1,
|
||||||
inline `std::ranges::sample(...)` call in
|
§2).
|
||||||
`BiergartenPipelineOrchestrator::QueryCitiesWithCountries()`.
|
- [ ] `CheckinDistributionStrategy` + `JCurveCheckinStrategy` /
|
||||||
- [ ] `BeerSelectionStrategy` interface + `RandomBeerSelectionStrategy`, to pick
|
`RandomCheckinStrategy` — the "Check-In System" README next step, made
|
||||||
styles per brewery from the `BeerStyle` palette (depends on §1 and §2).
|
concrete.
|
||||||
- [ ] `CheckinDistributionStrategy` interface + `JCurveCheckinStrategy` /
|
- [ ] `FollowGenerationStrategy` + `RandomFollowStrategy` /
|
||||||
`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 the `DataGenerator` interface with `GenerateBeer`,
|
- [ ] Extend `DataGenerator` with `GenerateBeer`, `GenerateCheckin`,
|
||||||
`GenerateCheckin`, `GenerateRating` (today: only `GenerateBrewery` and
|
`GenerateRating` (today: only `GenerateBrewery`/`GenerateUser`).
|
||||||
`GenerateUser`).
|
- [x] Added `OpenAIGenerator`, a third backend calling the OpenAI Chat
|
||||||
- [x] Add `OpenAIGenerator`, a third `DataGenerator` backend (alongside
|
Completions API with Structured Outputs (`json_schema`, `strict`) —
|
||||||
`MockGenerator`/`LlamaGenerator`) that calls the OpenAI Chat Completions
|
schema-valid JSON with no `IPromptFormatter`/GBNF grammar needed. Selected
|
||||||
API with Structured Outputs (`json_schema`, `strict`) to guarantee
|
via `--openai`/`GeneratorMode::kOpenAI`; landed ahead of §3/§4. Now shown
|
||||||
schema-valid JSON without needing `IPromptFormatter` or a GBNF grammar —
|
in `diagrams/planned/class.md` alongside `MockGenerator`/`LlamaGenerator`,
|
||||||
selected via `--openai`/`GeneratorMode::kOpenAI`. This landed ahead of the
|
noting only `GenerateBrewery`/`GenerateUser` are implemented so far.
|
||||||
planned architecture in §3/§4 and isn't reflected there yet.
|
- [x] Implemented `LlamaGenerator::GenerateUser` for real (GBNF grammar,
|
||||||
- [x] Implement `LlamaGenerator::GenerateUser` for real, with a retry loop
|
`ValidateUserJson`, 3 retries with corrective feedback,
|
||||||
mirroring `GenerateBrewery` (GBNF grammar, `ValidateUserJson`, up to 3
|
`prompts/USER_GENERATION.md`). Signature is
|
||||||
attempts with corrective feedback) and a new `prompts/USER_GENERATION.md`.
|
`(city, persona, name) : UserResult`, matching the activity diagram.
|
||||||
Changed the `DataGenerator::GenerateUser` signature to
|
- [ ] Implement
|
||||||
`(city : const EnrichedCity&, persona : const UserPersona&, name : const Name&) : UserResult`,
|
`MockGenerator::GenerateBeer`/`GenerateCheckin`/`GenerateRating`.
|
||||||
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 loading a GGUF that
|
`LlamaGenerator::ValidateModelArchitecture()` so a mismatched GGUF chat
|
||||||
doesn't match the configured chat template fails fast instead of silently
|
template fails fast instead of degrading silently.
|
||||||
producing degraded output.
|
- [ ] Add prompt templates for beer/checkin/rating, next to
|
||||||
- [ ] Add prompt template files for beer/checkin/rating generation next to the
|
`prompts/BREWERY_GENERATION.md`.
|
||||||
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
|
- [x] `IExportService::ProcessRecord(const UserRecord&)` and the `users` table
|
||||||
`SqliteExportService`'s `users` table (see `kCreateUsersTableSql` /
|
are implemented — every successful user is exported with its resolved
|
||||||
`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` (today
|
`ProcessRating`, `ProcessFollow`.
|
||||||
`ProcessRecord(const BreweryRecord&)` and
|
|
||||||
`ProcessRecord(const UserRecord&)` exist).
|
|
||||||
- [ ] Add `beers`, `checkins`, `ratings`, `follows` tables to
|
- [ ] Add `beers`, `checkins`, `ratings`, `follows` tables to
|
||||||
`SqliteExportService::InitializeSchema()` (`cities`, `breweries`,
|
`InitializeSchema()` (`cities`/`breweries`/`brewery_addresses`/`users`/
|
||||||
`brewery_addresses`, `users`, and `user_addresses` already exist) — see
|
`user_addresses` already exist — see `sqlite_statement_helpers.h` for the
|
||||||
`kCreateCitiesTableSql` / `kCreateBreweriesTableSql` /
|
pattern).
|
||||||
`kCreateBreweryAddressesTableSql` / `kCreateUsersTableSql` /
|
- [ ] Add a `brewery_cache_` alongside `city_cache_`, and move from one open
|
||||||
`kCreateUserAddressesTableSql` in
|
transaction for the whole run to per-phase batched commits (`BEGIN` /
|
||||||
`includes/services/database/sqlite_statement_helpers.h` for the existing
|
`COMMIT & BEGIN` on a batch threshold), per the planned activity diagram.
|
||||||
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>` already exists and is production-tested — but it's
|
- [ ] `BoundedChannel<T>` exists and is production-tested but only wired to the
|
||||||
only wired to the single log channel today. Stand up the per-phase
|
log channel. Stand up the per-phase producer/consumer channels (`loc_ch`,
|
||||||
producer/consumer channels (`loc_ch`, `exp_ch`, etc.) the planned activity
|
`exp_ch`, etc.) with a dedicated LLM worker thread and SQLite worker
|
||||||
diagram describes, each with a dedicated LLM worker thread and SQLite
|
thread each; the brewery phase needs a third, a Geocode Worker between
|
||||||
worker thread.
|
them on its own `geo_ch` (§9).
|
||||||
- [ ] Rewrite `BiergartenPipelineOrchestrator::Run()` from one synchronous pass
|
- [ ] Rewrite `Run()` from one synchronous pass into phased methods —
|
||||||
over `generated_breweries_` into phased methods — `RunUserPhase` →
|
`RunUserPhase` → `RunBreweryAndBeerPhase` (`RunBeerPhase` sub-step once
|
||||||
`RunBreweryAndBeerPhase` (with a `RunBeerPhase` sub-step once
|
`brewery_pool_` fills) → `RunCheckinPhase`/`RunFollowPhase` (parallel) →
|
||||||
`brewery_pool_` is populated) → `RunCheckinPhase` / `RunFollowPhase`
|
`RunRatingPhase` — backed by `user_pool_`, `brewery_pool_`, `beer_pool_`,
|
||||||
(these two can run in parallel) → `RunRatingPhase` — backed by
|
`checkin_pool_`, `follow_pool_`.
|
||||||
`user_pool_`, `brewery_pool_`, `beer_pool_`, `checkin_pool_`,
|
- [ ] Structurally join worker threads (e.g. `std::jthread`) before `Run()`
|
||||||
`follow_pool_`.
|
returns, per the existing comment in `biergarten_pipeline_orchestrator.h`,
|
||||||
- [ ] Honor the structural-concurrency requirement already called out in a
|
so nothing logs to a closed channel during teardown.
|
||||||
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 that's
|
- [ ] Decide whether to restore the city/region-specific Wikipedia query
|
||||||
currently commented out in
|
currently commented out in `get_summary.cc` — the §3 `ContextStrategy`
|
||||||
`src/services/enrichment/wikipedia/get_summary.cc` (`GetLocationContext`).
|
work is a natural place to reintroduce it.
|
||||||
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` in the planned activity diagram) instead of
|
`PreWarmLocationCache`) instead of fetching lazily per record.
|
||||||
fetching lazily per record, so the streaming phases never block on a cold
|
|
||||||
cache mid-run.
|
|
||||||
|
|
||||||
## 8. Fixtures / Build
|
## 8. Fixtures / Build
|
||||||
|
|
||||||
- [x] Author `personas.json`, `forenames-by-country.json`, and
|
- [x] Authored `personas.json`, `forenames-by-country.json`,
|
||||||
`surnames-by-country.json` (see §2).
|
`surnames-by-country.json` (§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` so native builds and the Docker
|
`configure_file` step in `CMakeLists.txt`.
|
||||||
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 own phase notes ("brewery*pool* is now fully
|
The planned activity diagram's phase notes imply a dependency order:
|
||||||
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 later generation phase has somewhere to land
|
2. Export schema (§5) — so every generation phase has somewhere to land output.
|
||||||
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), which is the only piece that actually
|
4. Concurrency/orchestration rewrite (§6) — wires §1–§5 into the phased,
|
||||||
wires §1–§5 into the phased, parallel pipeline shown in the planned diagrams.
|
parallel pipeline the planned diagrams show.
|
||||||
5. Enrichment cache pre-warming and the city/region query decision (§7) — useful
|
5. Enrichment cache pre-warming and the city/region query decision (§7) — most
|
||||||
at any point, but most valuable once phases run concurrently.
|
valuable once phases run concurrently.
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
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])
|
||||||
@@ -19,6 +23,8 @@ flowchart TD
|
|||||||
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"]
|
||||||
|
|
||||||
@@ -47,6 +53,10 @@ flowchart TD
|
|||||||
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}
|
||||||
@@ -72,6 +82,9 @@ flowchart TD
|
|||||||
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"])
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
config:
|
||||||
|
layout: elk
|
||||||
|
---
|
||||||
|
|
||||||
classDiagram
|
classDiagram
|
||||||
class BiergartenPipelineOrchestrator {
|
class BiergartenPipelineOrchestrator {
|
||||||
-shared_ptr~ILogger~ logger_
|
-shared_ptr~ILogger~ logger_
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
# The Biergarten Data Pipeline — Activity Diagram (Planned)
|
# The Biergarten Data Pipeline — Activity Diagram (Planned)
|
||||||
|
|
||||||
Each generation phase runs a producer/consumer trio concurrently: an
|
Each generation phase runs a producer/consumer chain 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 Checkin and Follow phases run in parallel with each other once the
|
batches. The brewery phase inserts a fourth stage, a **Geocode Worker**, between
|
||||||
user and brewery pools are populated.
|
the LLM Worker and the SQLite Worker: reverse-geocoding is network-bound and
|
||||||
|
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"
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
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])
|
||||||
@@ -54,7 +59,7 @@ flowchart TD
|
|||||||
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, exp_ch)"]
|
BreweryPhase --> BCreate["Create BoundedChannels (loc_ch, geo_ch, exp_ch)"]
|
||||||
|
|
||||||
subgraph BREWGEN["Phase 1a — Brewery Generation (concurrent)"]
|
subgraph BREWGEN["Phase 1a — Brewery Generation (concurrent)"]
|
||||||
direction LR
|
direction LR
|
||||||
@@ -69,9 +74,22 @@ flowchart TD
|
|||||||
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 → exp_ch"]
|
BL5 --> BL6["Send GeneratedBrewery → geo_ch"]
|
||||||
BL6 --> BL1
|
BL6 --> BL1
|
||||||
BL1 -- no --> BL7["Close exp_ch"]
|
BL1 -- no --> BL7["Close geo_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?"}
|
||||||
@@ -88,8 +106,9 @@ flowchart TD
|
|||||||
|
|
||||||
BCreate --> BP1
|
BCreate --> BP1
|
||||||
BP3 -.->|loc_ch| BL1
|
BP3 -.->|loc_ch| BL1
|
||||||
BL7 -.->|exp_ch| BS2
|
BL7 -.->|geo_ch| BG1
|
||||||
BL7 & BS9 --> BrewJoin["Orchestrator: Join LLM Worker, SQLite Worker<br/>brewery_pool_ is now fully populated"]
|
BG9 -.->|exp_ch| BS2
|
||||||
|
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)"]
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
---
|
||||||
|
config:
|
||||||
|
layout: elk
|
||||||
|
---
|
||||||
classDiagram
|
classDiagram
|
||||||
namespace DomainModels {
|
namespace DomainModels {
|
||||||
class Location {
|
class Location {
|
||||||
@@ -392,6 +396,34 @@ 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>>
|
||||||
@@ -452,7 +484,22 @@ 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 {
|
||||||
@@ -510,6 +557,7 @@ 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_
|
||||||
@@ -534,6 +582,7 @@ 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
|
||||||
@@ -562,6 +611,7 @@ 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
|
||||||
@@ -571,6 +621,8 @@ 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
|
||||||
|
|||||||
@@ -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,<br/>cross-slice email commands"]
|
SH2["Shared.Application<br/>ValidationBehavior"]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph DOMAIN["Domain Layer"]
|
subgraph DOMAIN["Domain Layer"]
|
||||||
@@ -89,16 +89,14 @@ 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["Infrastructure.Email /<br/>Email.Templates"]
|
I4["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
|
||||||
S1 -- "SendRegistrationEmailCommand<br/>(via Shared.Application contract)" --> S3
|
S3 == "ProjectReference<br/>(handles UserRegisteredNotification & ConfirmationEmailResendRequestedNotification)" ==> S1
|
||||||
S1 -- "ProjectReference<br/>(avatar upload)" --> S5
|
S1 -- "ProjectReference<br/>(avatar upload)" --> S5
|
||||||
|
|
||||||
SLICES --> SHARED
|
SLICES --> SHARED
|
||||||
@@ -123,7 +121,8 @@ own
|
|||||||
|
|
||||||
| Component | Description |
|
| Component | Description |
|
||||||
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| `Program.cs` | Registers MediatR (scanning every `Features.*` assembly), FluentValidation, and uses `AddApplicationPart` so each slice's controllers are discovered by MVC. |
|
| `Program.cs` | Thin composition root: builds the host and calls the `ServiceCollectionExtensions` methods below. |
|
||||||
|
| `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. |
|
||||||
@@ -132,7 +131,6 @@ 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**:
|
||||||
|
|
||||||
@@ -166,19 +164,21 @@ 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.Jwt`/
|
whichever infrastructure project the slice needs (`Infrastructure.PasswordHashing`
|
||||||
`Infrastructure.PasswordHashing` for Users, `Infrastructure.Email`/
|
for Users, `Infrastructure.Email.Templates` for Emails)
|
||||||
`Infrastructure.Email.Templates` for Emails, `Infrastructure.FileUpload` for
|
|
||||||
PhotoUpload)
|
|
||||||
- `Shared.Contracts`, `Shared.Application`
|
- `Shared.Contracts`, `Shared.Application`
|
||||||
- No other `Features.*` project, with one exception: `Features.Users` references
|
- No other `Features.*` project, with two exceptions: `Features.Users`
|
||||||
`Features.PhotoUpload` directly for avatar uploads
|
references `Features.PhotoUpload` directly for avatar uploads, and
|
||||||
|
`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`, never the domain entity directly
|
- Read endpoints return a dedicated `Dto` rather than the domain entity
|
||||||
|
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 every controller returns |
|
| `Shared.Contracts` | `ResponseBody<T>`/`ResponseBody`, the API response envelope `AuthController`, `BreweryController`, and `CityController` return; `UserController`'s read endpoints return their DTOs unwrapped |
|
||||||
| `Shared.Application` | `ValidationBehavior<TRequest,TResponse>` (the MediatR pipeline behavior that runs FluentValidation before a handler executes) and the cross-slice email commands (`SendRegistrationEmailCommand`, `SendResendConfirmationEmailCommand`) |
|
| `Shared.Application` | `ValidationBehavior<TRequest,TResponse>`, the MediatR pipeline behavior that runs FluentValidation before a handler executes |
|
||||||
|
|
||||||
**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," not a general-purpose dumping ground
|
slice".
|
||||||
|
|
||||||
#### Infrastructure layer
|
#### Infrastructure layer
|
||||||
|
|
||||||
@@ -207,11 +207,8 @@ 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**:
|
||||||
|
|
||||||
@@ -269,7 +266,8 @@ 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
|
||||||
@@ -384,6 +382,7 @@ 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
|
||||||
@@ -400,6 +399,8 @@ 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,10 +566,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).
|
||||||
|
|
||||||
|
|||||||
@@ -15,33 +15,51 @@ convention. For the vertical-slice code that talks to this schema, see
|
|||||||
|
|
||||||
## Schema
|
## Schema
|
||||||
|
|
||||||
The full schema lives under
|
The schema is split into six SQL Server schemas, one per feature area, each
|
||||||
`web/backend/Database/Database.Migrations/scripts/01-schema/` with one numbered
|
living in its own numbered folder under
|
||||||
script per table.
|
`web/backend/Database/Database.Migrations/scripts/`. The folder numbers
|
||||||
|
(`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:
|
||||||
|
|
||||||
```
|
```
|
||||||
01-schema
|
scripts
|
||||||
├── 01-UserAccount.sql
|
├── 01-Auth (UserAccount, UserVerification, UserCredential)
|
||||||
├── 02-Photo.sql
|
│ ├── 00-Schema.sql
|
||||||
├── 03-UserVerification.sql
|
│ ├── 01-UserAccount.sql
|
||||||
├── 04-UserCredential.sql
|
│ ├── 02-UserVerification.sql
|
||||||
├── 05-UserFollow.sql
|
│ └── 03-UserCredential.sql
|
||||||
├── 06-Country.sql
|
├── 02-Geolocation (Country, StateProvince, City)
|
||||||
├── 07-StateProvince.sql
|
│ ├── 00-Schema.sql
|
||||||
├── 08-City.sql
|
│ ├── 01-Country.sql
|
||||||
├── 09-BreweryPost.sql
|
│ ├── 02-StateProvince.sql
|
||||||
├── 10-BreweryPostLocation.sql
|
│ └── 03-City.sql
|
||||||
├── 11-BreweryPostPhoto.sql
|
├── 03-Media (Photo)
|
||||||
├── 12-BeerStyle.sql
|
│ ├── 00-Schema.sql
|
||||||
├── 13-BeerPost.sql
|
│ └── 01-Photo.sql
|
||||||
├── 14-BeerPostPhoto.sql
|
├── 04-Brewery (BreweryPost, BreweryPostLocation, BreweryPostPhoto)
|
||||||
├── 15-BeerPostComment.sql
|
│ ├── 00-Schema.sql
|
||||||
├── 16-UserProfile.sql
|
│ ├── 01-BreweryPost.sql
|
||||||
└── 17-UserAvatar.sql
|
│ ├── 02-BreweryPostLocation.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. Each
|
Each script is run in order by `Database.Migrations` (DbUp) on startup. Tables
|
||||||
table is queried directly by the owning feature slice's repository (see
|
are referenced schema-qualified in code (e.g. `Auth.UserAccount`), not under
|
||||||
|
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
|
||||||
@@ -58,14 +76,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` |
|
| `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` |
|
||||||
| `UserAvatar` | `Domain.Entities.UserAvatar`, `Features.Users` | Links a `UserProfile` to a `Photo` as its avatar; written via `IUserProfileRepository.SaveAvatarAsync` |
|
| `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 |
|
||||||
| `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`; 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`; `CityName` and `StateProvinceID` together are unique; 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; not yet seeded by `Database.Seed` |
|
| `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` |
|
||||||
|
|
||||||
### 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
|
||||||
|
|
||||||
|
|||||||
@@ -2,21 +2,19 @@ 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 rgb(241,243,234) Handler Layer
|
box 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 rgb(235,236,227) Infrastructure Layer
|
box Infrastructure Layer
|
||||||
participant JWT as JWT Infrastructure
|
participant JWT as JWT Infrastructure
|
||||||
end
|
end
|
||||||
box rgb(219,238,221) Repository / Cross-slice
|
box 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))
|
||||||
|
|||||||
@@ -2,17 +2,15 @@ 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 rgb(241,243,234) Handler Layer
|
box Handler Layer
|
||||||
participant DeleteAccountHandler
|
participant DeleteAccountHandler
|
||||||
participant UserMgr as UserManager(ApplicationUser)
|
participant UserMgr as UserManager(ApplicationUser)
|
||||||
end
|
end
|
||||||
box rgb(219,238,221) Repository / Cross-slice
|
box 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()))
|
||||||
@@ -37,7 +35,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 (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)
|
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)
|
||||||
|
|
||||||
alt Foreign key violation (dependent records exist)
|
alt Foreign key violation (dependent records exist)
|
||||||
UserStore-->>UserMgr: IdentityResult.Failed (AccountHasDependentRecords)
|
UserStore-->>UserMgr: IdentityResult.Failed (AccountHasDependentRecords)
|
||||||
@@ -49,7 +47,7 @@ sequenceDiagram
|
|||||||
deactivate UserMgr
|
deactivate UserMgr
|
||||||
|
|
||||||
alt Account has dependent records
|
alt Account has dependent records
|
||||||
DeleteAccountHandler->>Mediator: throw ConflictException "Account cannot be deleted while it still has associated posts, comments, photos, or follows."
|
DeleteAccountHandler->>Mediator: throw ConflictException<br>"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)
|
||||||
|
|||||||
@@ -2,22 +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 rgb(241,243,234) Handler Layer
|
box 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 rgb(235,236,227) Infrastructure Layer
|
box Infrastructure Layer
|
||||||
participant Argon2 as Argon2PasswordHasher
|
participant Argon2 as Argon2PasswordHasher
|
||||||
participant JWT as JWT Infrastructure
|
participant JWT as JWT Infrastructure
|
||||||
end
|
end
|
||||||
box rgb(219,238,221) Repository / Cross-slice
|
box 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)
|
||||||
|
|||||||
@@ -2,21 +2,19 @@ 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 rgb(241,243,234) Handler Layer
|
box 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 rgb(235,236,227) Infrastructure Layer
|
box Infrastructure Layer
|
||||||
participant JWT as JWT Infrastructure
|
participant JWT as JWT Infrastructure
|
||||||
end
|
end
|
||||||
box rgb(219,238,221) Repository / Cross-slice
|
box 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))
|
||||||
|
|||||||
@@ -2,26 +2,24 @@ 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 rgb(241,243,234) Handler Layer
|
box 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 rgb(235,236,227) Infrastructure Layer
|
box 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 rgb(219,238,221) Repository / Cross-slice
|
box 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)
|
||||||
@@ -41,7 +39,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: "$argon2id$v=19$m=65536,t=4,p=2$<salt>$<hash>" (PHC string)
|
Argon2-->>UserMgr: PHC string in Argon2id format (algorithm, version, memory/time/parallelism params, salt, hash)
|
||||||
deactivate Argon2
|
deactivate Argon2
|
||||||
|
|
||||||
UserMgr->>UserStore: SetPasswordHashAsync(user, hash)
|
UserMgr->>UserStore: SetPasswordHashAsync(user, hash)
|
||||||
@@ -73,7 +71,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, not a stored procedure
|
Note right of UserStore: Both statements run via Dapper against a single DbTransaction
|
||||||
|
|
||||||
UserStore-->>UserMgr: IdentityResult.Success (user.Id set)
|
UserStore-->>UserMgr: IdentityResult.Success (user.Id set)
|
||||||
deactivate UserStore
|
deactivate UserStore
|
||||||
@@ -129,9 +127,9 @@ sequenceDiagram
|
|||||||
TokenSvc-->>RegHandler: Confirmation Token
|
TokenSvc-->>RegHandler: Confirmation Token
|
||||||
deactivate TokenSvc
|
deactivate TokenSvc
|
||||||
|
|
||||||
RegHandler->>Mediator: Send(SendRegistrationEmailCommand) (Shared.Application cross-slice contract)
|
RegHandler->>Mediator: Publish(UserRegisteredNotification)
|
||||||
activate Mediator
|
activate Mediator
|
||||||
Note right of Mediator: Routed to Features.Emails' SendRegistrationEmailHandler — the second of two cross-slice dispatches in this flow (profile creation is the first)
|
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)
|
||||||
Mediator->>EmailDispatcher: SendRegistrationEmailAsync(firstName, email, confirmationToken)
|
Mediator->>EmailDispatcher: SendRegistrationEmailAsync(firstName, email, confirmationToken)
|
||||||
activate EmailDispatcher
|
activate EmailDispatcher
|
||||||
|
|
||||||
@@ -149,15 +147,15 @@ sequenceDiagram
|
|||||||
SMTP-->>EmailDispatcher: Success / throws
|
SMTP-->>EmailDispatcher: Success / throws
|
||||||
deactivate SMTP
|
deactivate SMTP
|
||||||
deactivate EmailDispatcher
|
deactivate EmailDispatcher
|
||||||
deactivate Mediator
|
|
||||||
|
|
||||||
alt Email sent successfully
|
alt Email sent successfully
|
||||||
RegHandler->>RegHandler: emailSent = true
|
Note right of Mediator: UserRegisteredNotificationHandler completes normally
|
||||||
else mediator.Send throws
|
else EmailDispatcher throws
|
||||||
RegHandler->>RegHandler: catch(Exception), log to stderr, emailSent = false
|
Note right of Mediator: UserRegisteredNotificationHandler catches and logs the exception — registration is unaffected
|
||||||
end
|
end
|
||||||
|
deactivate Mediator
|
||||||
|
|
||||||
RegHandler-->>Mediator: RegistrationPayload(userAccountId, username, refreshToken, accessToken, confirmationEmailSent)
|
RegHandler-->>Mediator: RegistrationPayload(userAccountId, username, refreshToken, accessToken)
|
||||||
deactivate RegHandler
|
deactivate RegHandler
|
||||||
Mediator-->>API: RegistrationPayload
|
Mediator-->>API: RegistrationPayload
|
||||||
deactivate Mediator
|
deactivate Mediator
|
||||||
|
|||||||
@@ -2,23 +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 rgb(241,243,234) Handler Layer
|
box 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 rgb(235,236,227) Infrastructure Layer
|
box 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 rgb(219,238,221) Repository / Cross-slice
|
box 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))
|
||||||
@@ -53,13 +51,20 @@ sequenceDiagram
|
|||||||
TokenSvc-->>ResendHandler: confirmation token
|
TokenSvc-->>ResendHandler: confirmation token
|
||||||
deactivate TokenSvc
|
deactivate TokenSvc
|
||||||
|
|
||||||
ResendHandler->>Mediator: Send(SendResendConfirmationEmailCommand)
|
ResendHandler->>Mediator: Publish(ConfirmationEmailResendRequestedNotification)
|
||||||
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
|
||||||
|
|||||||
@@ -2,17 +2,15 @@ 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 rgb(241,243,234) Handler Layer
|
box Handler Layer
|
||||||
participant UpdateEmailHandler
|
participant UpdateEmailHandler
|
||||||
participant UserMgr as UserManager(ApplicationUser)
|
participant UserMgr as UserManager(ApplicationUser)
|
||||||
end
|
end
|
||||||
box rgb(219,238,221) Repository / Cross-slice
|
box 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))
|
||||||
|
|||||||
@@ -2,20 +2,18 @@ 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 rgb(241,243,234) Handler Layer
|
box Handler Layer
|
||||||
participant UpdatePasswordHandler
|
participant UpdatePasswordHandler
|
||||||
participant UserMgr as UserManager(ApplicationUser)
|
participant UserMgr as UserManager(ApplicationUser)
|
||||||
end
|
end
|
||||||
box rgb(235,236,227) Infrastructure Layer
|
box Infrastructure Layer
|
||||||
participant Argon2 as Argon2PasswordHasher
|
participant Argon2 as Argon2PasswordHasher
|
||||||
end
|
end
|
||||||
box rgb(219,238,221) Repository / Cross-slice
|
box 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))
|
||||||
|
|||||||
@@ -2,17 +2,15 @@ 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 rgb(241,243,234) Handler Layer
|
box Handler Layer
|
||||||
participant UpdateProfileHandler
|
participant UpdateProfileHandler
|
||||||
participant UserMgr as UserManager(ApplicationUser)
|
participant UserMgr as UserManager(ApplicationUser)
|
||||||
end
|
end
|
||||||
box rgb(219,238,221) Repository / Cross-slice
|
box 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))
|
||||||
|
|||||||
@@ -2,17 +2,15 @@ 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 rgb(241,243,234) Handler Layer
|
box Handler Layer
|
||||||
participant UpdateUsernameHandler
|
participant UpdateUsernameHandler
|
||||||
participant UserMgr as UserManager(ApplicationUser)
|
participant UserMgr as UserManager(ApplicationUser)
|
||||||
end
|
end
|
||||||
box rgb(219,238,221) Repository / Cross-slice
|
box 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))
|
||||||
|
|||||||
@@ -1,9 +1,47 @@
|
|||||||
# Database Schema
|
# Database Schema
|
||||||
|
|
||||||
Every table also has a `RowVersion ROWVERSION` column (optimistic concurrency),
|
Six diagrams, one per SQL Server schema — mirrors the `Auth` / `Geolocation` /
|
||||||
omitted below for brevity. Tables marked `(*)` have no repository/feature slice
|
`Media` / `Brewery` / `Beer` / `Social` schema split under
|
||||||
yet.
|
`web/backend/Database/Database.Migrations/scripts/`. Every table also has a
|
||||||
|
`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.mmd"
|
--8<-- "web/diagrams/database-schema/auth.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"
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,162 +0,0 @@
|
|||||||
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"
|
|
||||||
}
|
|
||||||
44
docs/web/diagrams/database-schema/auth.mmd
Normal file
44
docs/web/diagrams/database-schema/auth.mmd
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
|
}
|
||||||
51
docs/web/diagrams/database-schema/beer.mmd
Normal file
51
docs/web/diagrams/database-schema/beer.mmd
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
|
}
|
||||||
41
docs/web/diagrams/database-schema/brewery.mmd
Normal file
41
docs/web/diagrams/database-schema/brewery.mmd
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
|
}
|
||||||
26
docs/web/diagrams/database-schema/geolocation.mmd
Normal file
26
docs/web/diagrams/database-schema/geolocation.mmd
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
19
docs/web/diagrams/database-schema/media.mmd
Normal file
19
docs/web/diagrams/database-schema/media.mmd
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
|
}
|
||||||
34
docs/web/diagrams/database-schema/social.mmd
Normal file
34
docs/web/diagrams/database-schema/social.mmd
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
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)"
|
||||||
|
}
|
||||||
@@ -1,7 +1,38 @@
|
|||||||
# 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.mmd"
|
--8<-- "web/diagrams/deployment/dev.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
|
||||||
@@ -9,8 +40,8 @@
|
|||||||
| 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; 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, in parallel once seeding completes; results are aggregated. |
|
||||||
| **Prod network** (`prodnet`, bridge) | Isolated from dev/test networks. Volumes: `sqlserverdata-prod`, `nuget-cache-prod`. No seed step in production. |
|
| **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. |
|
||||||
| **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. |
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
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
|
|
||||||
13
docs/web/diagrams/deployment/db-only.mmd
Normal file
13
docs/web/diagrams/deployment/db-only.mmd
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
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
|
||||||
20
docs/web/diagrams/deployment/dev.mmd
Normal file
20
docs/web/diagrams/deployment/dev.mmd
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
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
|
||||||
15
docs/web/diagrams/deployment/minimal.mmd
Normal file
15
docs/web/diagrams/deployment/minimal.mmd
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
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)
|
||||||
13
docs/web/diagrams/deployment/prod.mmd
Normal file
13
docs/web/diagrams/deployment/prod.mmd
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
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
|
||||||
29
docs/web/diagrams/deployment/test.mmd
Normal file
29
docs/web/diagrams/deployment/test.mmd
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
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
|
||||||
@@ -16,13 +16,14 @@ doesn't wire up any slice; it applies schema directly.
|
|||||||
|
|
||||||
## Project reference dependencies
|
## Project reference dependencies
|
||||||
|
|
||||||
Solid arrows are compile-time project references. Dashed arrows are the two
|
Solid arrows are compile-time project references — every slice-to-slice
|
||||||
places slices talk to each other without a direct reference: the email MediatR
|
dependency is one now that the email MediatR contract has moved off
|
||||||
contract routed through `Shared.Application`, and the one place that pattern is
|
`Shared.Application`: `Features.Users` on `Features.PhotoUpload` for avatar
|
||||||
broken outright. Every DB-backed slice depends on the same three foundation
|
uploads, and `Features.Emails` on `Features.Users` for the registration and
|
||||||
projects (`Shared.Contracts`, `Domain.Entities`, `Database.Connection`) — drawn
|
resend-confirmation notifications. Every DB-backed slice depends on the same
|
||||||
as one box instead of fifteen separate arrows; see the notes for what else each
|
three foundation projects (`Shared.Contracts`, `Domain.Entities`,
|
||||||
of those foundation projects pulls in.
|
`Database.Connection`) — drawn as one box instead of fifteen separate arrows;
|
||||||
|
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"
|
||||||
@@ -44,8 +45,6 @@ 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
|
||||||
@@ -54,17 +53,20 @@ 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 one exception to slice isolation**: `Features.Users` takes a direct
|
- **The exceptions to slice isolation**:
|
||||||
project reference to `Features.PhotoUpload`
|
- `Features.Users` takes a direct project reference to `Features.PhotoUpload`
|
||||||
- sends its `UploadPhotoCommand` via MediatR from `UploadAvatarHandler`
|
- sends its `UploadPhotoCommand` via MediatR from `UploadAvatarHandler`
|
||||||
- unlike the email flow, the command type lives in the target slice itself,
|
- the command type lives in the target slice itself, not in
|
||||||
not in `Shared.Application`
|
`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
|
||||||
|
|
||||||
@@ -95,9 +97,9 @@ 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`, `Infrastructure.Jwt`, and each feature slice)
|
all of `Shared.Contracts` and each feature slice)
|
||||||
- `Infrastructure.Configuration` (the shared options-binding project
|
- `Configuration` (the shared options-binding project
|
||||||
referenced by `Database.Connection`, `Infrastructure.Email`,
|
referenced by `Database.Connection`, `Features.PhotoUpload`,
|
||||||
`Infrastructure.FileUpload`, and `Features.Emails` directly)
|
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
|
||||||
|
|||||||
@@ -1,59 +1,29 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -1,77 +1,38 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
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
|
||||||
PhotoSlice --> FOUNDATION & FileUpload
|
UsersSlice --> FOUNDATION & PwdHash
|
||||||
|
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")]
|
||||||
|
|||||||
@@ -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 unit test projects
|
unit.tests # All Features.*.Tests and Shared.*.Tests unit test projects
|
||||||
frontend.tests # Storybook Vitest + Playwright suites (own Playwright image)
|
frontend.tests # Storybook Vitest + Playwright suites (own Playwright image)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -134,27 +134,136 @@ 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 enabled
|
- Health checks on every long-running service
|
||||||
- Restart policies (unless-stopped)
|
- Restart policies that survive a reboot (see
|
||||||
|
[Restart tolerance](#restart-tolerance))
|
||||||
- Security hardening
|
- Security hardening
|
||||||
- Only `frontend` publishes a port to the host; `sqlserver` and `api.core` are
|
- Only `frontend` publishes a port to the host, bound to `127.0.0.1` so a reverse
|
||||||
reachable only from other containers on `prodnet`
|
proxy can sit in front of it; `sqlserver`, `api.core` and `seaweedfs` are
|
||||||
|
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)
|
||||||
frontend # Production website (port 3000, the only public binding)
|
seaweedfs # S3-compatible object storage (internal only)
|
||||||
|
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
|
||||||
@@ -215,6 +324,12 @@ 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
|
||||||
@@ -255,8 +370,10 @@ api.core:
|
|||||||
|
|
||||||
**Production**:
|
**Production**:
|
||||||
|
|
||||||
- `sqlserverdata-prod` - Production database files
|
- `biergartendata-prod` - Database files, external; survives `down -v`
|
||||||
- `nuget-cache-prod` - Production NuGet cache
|
- `seaweedfsdata-prod` - SeaweedFS object storage, external; survives `down -v`
|
||||||
|
|
||||||
|
See [Production data volumes](#production-data-volumes).
|
||||||
|
|
||||||
### Mounted volumes
|
### Mounted volumes
|
||||||
|
|
||||||
@@ -269,6 +386,41 @@ 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:
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ file (`development` in dev, `production` in prod).
|
|||||||
|
|
||||||
### SMTP configuration (backend)
|
### SMTP configuration (backend)
|
||||||
|
|
||||||
Read by `Infrastructure.Email/SmtpEmailProvider.cs` for sending confirmation and
|
Read by `Features.Emails/Services/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**: `Infrastructure.Email/SmtpEmailProvider.cs` throws on
|
- **Implementation**: `Features.Emails/Services/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,22 +255,30 @@ SMTP_FROM_NAME=The Biergarten App # Optional, defaults to "The Biergarten
|
|||||||
|
|
||||||
### File storage configuration (backend)
|
### File storage configuration (backend)
|
||||||
|
|
||||||
Read by `Infrastructure.FileUpload/S3FileStorageProvider.cs` for uploading and
|
Read by `Features.PhotoUpload/Services/S3FileStorageProvider.cs` for uploading
|
||||||
retrieving photos via an S3-compatible object store.
|
and 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
|
||||||
these four values is missing
|
the four required 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
|
||||||
|
|
||||||
@@ -317,6 +325,7 @@ 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
|
||||||
|
|
||||||
@@ -340,6 +349,7 @@ 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 |
|
||||||
@@ -414,6 +424,7 @@ 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
|
||||||
|
|||||||
@@ -76,10 +76,11 @@ 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 unit tests
|
### Feature slice and shared 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:
|
handlers. Shared cross-cutting code (e.g. MediatR pipeline behaviors) has
|
||||||
|
its own test project under `Shared/`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd web/backend
|
cd web/backend
|
||||||
@@ -87,9 +88,10 @@ 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; do dotnet test "$proj"; done
|
for proj in Features/*.Tests Shared/*.Tests; do dotnet test "$proj"; done
|
||||||
```
|
```
|
||||||
|
|
||||||
**Requirements**:
|
**Requirements**:
|
||||||
@@ -247,13 +249,16 @@ 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 Infrastructure.Email's IEmailProvider
|
│ ├── MockEmailProvider.cs # Substitutes Features.Emails' 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)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -278,17 +283,25 @@ 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
|
||||||
@@ -296,10 +309,13 @@ Features.Users.Tests/
|
|||||||
└── TestSupport/ # shared test fixtures/helpers
|
└── TestSupport/ # shared test fixtures/helpers
|
||||||
```
|
```
|
||||||
|
|
||||||
Each of the other slices (`Features.Breweries.Tests`, `Features.Emails.Tests`,
|
`Features.Breweries.Tests` and `Features.PhotoUpload.Tests` follow the same
|
||||||
`Features.PhotoUpload.Tests`) follows the same shape: a `Commands/`/`Queries/`
|
shape: a `Commands/`/`Queries/` folder with one test file per handler, plus a
|
||||||
folder with one test file per handler (`Features.PhotoUpload.Tests` also adds a
|
`*ValidatorTests.cs` file beside each handler whose command or query has a
|
||||||
validator test file alongside its handler test).
|
FluentValidation validator (`Features.Breweries.Tests` has one of these in
|
||||||
|
`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
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ The Biergarten API validates JSON Web Tokens (JWTs) across three token types:
|
|||||||
|
|
||||||
### Infrastructure layer
|
### Infrastructure layer
|
||||||
|
|
||||||
#### [ITokenInfrastructure](../../web/backend/Infrastructure/Infrastructure.Jwt/ITokenInfrastructure.cs)
|
#### [ITokenInfrastructure](../../web/backend/Features/Features.Users/Services/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/Infrastructure/Infrastructure.Jwt/JwtInfrastructure.cs)
|
[JwtInfrastructure.cs](../../web/backend/Features/Features.Users/Services/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` is registered by `Features.Users`' own `AddFeaturesUsers()`
|
`TokenService` and the lower-level `ITokenInfrastructure` (JWT
|
||||||
extension method, except for the lower-level `ITokenInfrastructure` (JWT
|
signing/verification) it depends on are both registered by `Features.Users`'
|
||||||
signing/verification) it depends on, which is registered by the host
|
own `AddFeaturesUsers()` extension method. `JwtAuthenticationHandler`
|
||||||
(`API.Core/Program.cs`) since `JwtAuthenticationHandler` (host-level auth
|
(host-level auth middleware) also depends on `ITokenInfrastructure` directly,
|
||||||
middleware) also depends on it directly.
|
resolving it from the same DI container.
|
||||||
|
|
||||||
### Integration points
|
### Integration points
|
||||||
|
|
||||||
|
|||||||
@@ -68,8 +68,12 @@ 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
|
||||||
|
|||||||
@@ -7,24 +7,27 @@
|
|||||||
<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.4.1" />
|
<PackageReference Include="MediatR" Version="12.5.0" />
|
||||||
</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="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" />
|
<ProjectReference Include="..\..\Configuration\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>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text.Encodings.Web;
|
using System.Text.Encodings.Web;
|
||||||
using Infrastructure.Configuration;
|
using Features.Users.Services;
|
||||||
using Infrastructure.Jwt;
|
using Configuration;
|
||||||
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,17 +25,13 @@ 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, falling
|
/// The signing secret is resolved first from the <c>ACCESS_TOKEN_SECRET</c> environment variable.
|
||||||
/// back to the <c>Jwt:SecretKey</c> configuration value, to stay consistent with the secret source used
|
/// Fails if the secret is not configured, the <c>Authorization</c> header is
|
||||||
/// 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");
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
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." });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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 },
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -25,6 +25,7 @@ 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/"]
|
||||||
@@ -32,13 +33,9 @@ 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 ["Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj", "Infrastructure/Infrastructure.Configuration/"]
|
COPY ["Configuration/Configuration.csproj", "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 . .
|
||||||
|
|||||||
@@ -1,132 +1,86 @@
|
|||||||
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;
|
||||||
|
|
||||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
namespace API.Core;
|
||||||
|
|
||||||
// Add current controllers to the exception filter
|
internal class Program
|
||||||
builder
|
|
||||||
.Services.AddControllers(options => { options.Filters.Add<GlobalExceptionFilter>(); })
|
|
||||||
.AddApplicationPart(typeof(BreweryController).Assembly)
|
|
||||||
.AddApplicationPart(typeof(AuthController).Assembly);
|
|
||||||
|
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
|
||||||
builder.Services.AddSwaggerGen(options =>
|
|
||||||
{
|
{
|
||||||
options.AddSecurityDefinition(
|
public static void Main(string[] args)
|
||||||
"Bearer",
|
|
||||||
new OpenApiSecurityScheme
|
|
||||||
{
|
{
|
||||||
Name = "Authorization",
|
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||||
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",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
builder.Services.AddOpenApi();
|
|
||||||
|
|
||||||
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
|
builder.Logging.ClearProviders().AddConsole();
|
||||||
builder.Services.AddValidatorsFromAssembly(typeof(BreweryController).Assembly);
|
|
||||||
builder.Services.AddValidatorsFromAssembly(typeof(AuthController).Assembly);
|
|
||||||
builder.Services.AddValidatorsFromAssembly(typeof(UploadPhotoCommand).Assembly);
|
|
||||||
builder.Services.AddFluentValidationAutoValidation();
|
|
||||||
|
|
||||||
// Add MediatR.
|
if (!builder.Environment.IsProduction())
|
||||||
// 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.Logging.AddDebug();
|
||||||
|
|
||||||
builder.Services.AddDatabaseConnection();
|
builder
|
||||||
|
.Services.AddApiControllers()
|
||||||
|
.AddValidationAndMediatR()
|
||||||
|
.AddApplicationServices()
|
||||||
|
.AddFeatureModules()
|
||||||
|
.AddCoreInfrastructure()
|
||||||
|
.AddJwtAuthentication();
|
||||||
|
|
||||||
builder.Services.AddFeaturesBreweries();
|
WebApplication app = builder.Build();
|
||||||
builder.Services.AddFeaturesUsers();
|
|
||||||
builder.Services.AddFeaturesEmails();
|
|
||||||
builder.Services.AddFeaturesPhotoUpload();
|
|
||||||
|
|
||||||
// ITokenInfrastructure is registered here because JwtAuthenticationHandler depends on it directly.
|
app.UseSwagger()
|
||||||
builder.Services.AddScoped<ITokenInfrastructure, JwtInfrastructure>();
|
.UseSwaggerUI()
|
||||||
|
.UseHttpsRedirection()
|
||||||
|
.UseAuthentication()
|
||||||
|
.UseAuthorization();
|
||||||
|
|
||||||
builder.Services.AddSingleton<IFileStorageProvider, S3FileStorageProvider>();
|
app.MapOpenApi();
|
||||||
|
app.MapHealthChecks("/health");
|
||||||
|
app.MapControllers();
|
||||||
|
|
||||||
builder.Services.AddScoped<GlobalExceptionFilter>();
|
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;
|
||||||
|
|
||||||
builder
|
return Results.Ok(
|
||||||
.Services.AddAuthentication("JWT")
|
new ResponseBody<object>
|
||||||
.AddScheme<JwtAuthenticationOptions, JwtAuthenticationHandler>("JWT", options => { });
|
{
|
||||||
|
Message = "Protected endpoint accessed successfully",
|
||||||
|
Payload = new { userId, username },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
).RequireAuthorization(policy => policy.AddAuthenticationSchemes("JWT").RequireAuthenticatedUser());
|
||||||
|
|
||||||
builder.Services.AddAuthorization();
|
// 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();
|
||||||
|
|
||||||
WebApplication app = builder.Build();
|
app.MapFallback(() =>
|
||||||
|
Results.NotFound(
|
||||||
|
new ResponseBody { Message = "Are you lost? That route does not exist." }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.ExcludeFromDescription();
|
||||||
|
|
||||||
app.UseSwagger();
|
app.Services.GetRequiredService<IHostApplicationLifetime>()
|
||||||
app.UseSwaggerUI();
|
.ApplicationStopping.Register(() =>
|
||||||
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.Logger.LogInformation("Application is shutting down gracefully...");
|
||||||
});
|
});
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
121
web/backend/API/API.Core/ServiceCollectionExtensions.cs
Normal file
121
web/backend/API/API.Core/ServiceCollectionExtensions.cs
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,8 +38,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\API.Core\API.Core.csproj" />
|
<ProjectReference Include="..\API.Core\API.Core.csproj" />
|
||||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" />
|
<ProjectReference Include="..\..\Configuration\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" />
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ 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/"]
|
||||||
@@ -22,13 +23,9 @@ 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 ["Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj", "Infrastructure/Infrastructure.Configuration/"]
|
COPY ["Configuration/Configuration.csproj", "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 . .
|
||||||
|
|||||||
@@ -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 "Route not found."
|
And the response JSON should have "message" equal "Are you lost? That route does not exist."
|
||||||
130
web/backend/API/API.Specs/Features/PublicUserProfile.feature
Normal file
130
web/backend/API/API.Specs/Features/PublicUserProfile.feature
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
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
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using Infrastructure.Email;
|
using Features.Emails.Services;
|
||||||
|
|
||||||
namespace API.Specs.Mocks;
|
namespace API.Specs.Mocks;
|
||||||
|
|
||||||
|
|||||||
55
web/backend/API/API.Specs/Mocks/MockFileStorageProvider.cs
Normal file
55
web/backend/API/API.Specs/Mocks/MockFileStorageProvider.cs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -167,4 +167,86 @@ 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
protected TestApiFactory GetFactory()
|
internal TestApiFactory GetFactory()
|
||||||
{
|
{
|
||||||
TestApiFactory? factory = Scenario.TryGetValue<TestApiFactory>(
|
TestApiFactory? factory = Scenario.TryGetValue<TestApiFactory>(
|
||||||
FactoryKey,
|
FactoryKey,
|
||||||
|
|||||||
@@ -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 Infrastructure.Configuration;
|
using 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,6 +347,46 @@ 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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
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;
|
||||||
@@ -23,11 +22,6 @@ 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);
|
||||||
@@ -53,24 +47,21 @@ public class BrewerySteps(ScenarioContext scenario) : ApiStepsBase(scenario)
|
|||||||
.Services.GetRequiredService<ISqlConnectionFactory>();
|
.Services.GetRequiredService<ISqlConnectionFactory>();
|
||||||
LocationRepository repository = new(connectionFactory);
|
LocationRepository repository = new(connectionFactory);
|
||||||
|
|
||||||
CityLocation fixtureCity = new(
|
Guid countryId =
|
||||||
FixtureCityName,
|
await repository.GetCountryIdAsync(FixtureCountryIsoCode)
|
||||||
|
?? await repository.CreateCountryAsync(FixtureCountryName, FixtureCountryIsoCode);
|
||||||
|
|
||||||
|
Guid stateProvinceId =
|
||||||
|
await repository.GetStateProvinceIdAsync(FixtureStateProvinceIsoCode)
|
||||||
|
?? await repository.CreateStateProvinceAsync(
|
||||||
FixtureStateProvinceName,
|
FixtureStateProvinceName,
|
||||||
FixtureStateProvinceIsoCode,
|
FixtureStateProvinceIsoCode,
|
||||||
FixtureCountryName,
|
countryId
|
||||||
FixtureCountryIsoCode
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await CityProvisioningLock.WaitAsync();
|
Guid cityId =
|
||||||
Guid cityId;
|
await repository.GetCityIdAsync(FixtureCityName, FixtureStateProvinceIsoCode)
|
||||||
try
|
?? await repository.CreateCityAsync(FixtureCityName, stateProvinceId);
|
||||||
{
|
|
||||||
cityId = await repository.GetOrCreateCityIdAsync(fixtureCity);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
CityProvisioningLock.Release();
|
|
||||||
}
|
|
||||||
|
|
||||||
Scenario[CityIdKey] = cityId;
|
Scenario[CityIdKey] = cityId;
|
||||||
}
|
}
|
||||||
@@ -271,46 +262,6 @@ 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)
|
||||||
{
|
{
|
||||||
|
|||||||
290
web/backend/API/API.Specs/Steps/UserSteps.cs
Normal file
290
web/backend/API/API.Specs/Steps/UserSteps.cs
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
|
using API.Core;
|
||||||
using API.Specs.Mocks;
|
using API.Specs.Mocks;
|
||||||
using Features.Emails.Services;
|
using Features.Emails.Services;
|
||||||
using Infrastructure.Email;
|
using Features.ImageUploads.Services;
|
||||||
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;
|
||||||
|
|
||||||
public class TestApiFactory : WebApplicationFactory<Program>
|
internal class TestApiFactory : WebApplicationFactory<Program>
|
||||||
{
|
{
|
||||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||||
{
|
{
|
||||||
@@ -32,6 +33,15 @@ public 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>();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
<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" />
|
||||||
@@ -12,25 +19,21 @@
|
|||||||
<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="/Features/Users/">
|
<Folder Name="/Configuration/">
|
||||||
<Project Path="Features\Features.Users.Tests\Features.Users.Tests.csproj" />
|
<Project Path="Configuration/Configuration.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/">
|
||||||
@@ -44,5 +47,6 @@
|
|||||||
<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>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<RootNamespace>Infrastructure.Configuration</RootNamespace>
|
<RootNamespace>Configuration</RootNamespace>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
namespace Infrastructure.Configuration;
|
namespace Configuration;
|
||||||
|
|
||||||
public static class ConfigurationHelpers
|
public static class ConfigurationHelpers
|
||||||
{
|
{
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace Infrastructure.Configuration;
|
namespace Configuration;
|
||||||
|
|
||||||
public static class ConfigurationKeys
|
public static class ConfigurationKeys
|
||||||
{
|
{
|
||||||
@@ -23,6 +23,14 @@ public static class ConfigurationKeys
|
|||||||
public const string SmtpFromName = "SMTP_FROM_NAME";
|
public const string SmtpFromName = "SMTP_FROM_NAME";
|
||||||
|
|
||||||
public const string SeaweedFsServiceUrl = "SEAWEEDFS_SERVICE_URL";
|
public const string SeaweedFsServiceUrl = "SEAWEEDFS_SERVICE_URL";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The browser-reachable base URL of the object store, used to sign download URLs. Falls back to
|
||||||
|
/// <see cref="SeaweedFsServiceUrl" /> when unset: SigV4 covers the host, so a URL signed against
|
||||||
|
/// the container-internal endpoint is only usable by callers that can resolve that host.
|
||||||
|
/// </summary>
|
||||||
|
public const string SeaweedFsPublicUrl = "SEAWEEDFS_PUBLIC_URL";
|
||||||
|
|
||||||
public const string SeaweedFsAccessKeyId = "SEAWEEDFS_ACCESS_KEY_ID";
|
public const string SeaweedFsAccessKeyId = "SEAWEEDFS_ACCESS_KEY_ID";
|
||||||
public const string SeaweedFsSecretAccessKey = "SEAWEEDFS_SECRET_ACCESS_KEY";
|
public const string SeaweedFsSecretAccessKey = "SEAWEEDFS_SECRET_ACCESS_KEY";
|
||||||
public const string SeaweedFsBucket = "SEAWEEDFS_BUCKET";
|
public const string SeaweedFsBucket = "SEAWEEDFS_BUCKET";
|
||||||
@@ -21,6 +21,6 @@
|
|||||||
/>
|
/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" />
|
<ProjectReference Include="..\..\Configuration\Configuration.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Infrastructure.Configuration;
|
using Configuration;
|
||||||
using Microsoft.Data.SqlClient;
|
using Microsoft.Data.SqlClient;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Infrastructure.Configuration;
|
using Configuration;
|
||||||
using Microsoft.Data.SqlClient;
|
using Microsoft.Data.SqlClient;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
|
|||||||
@@ -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="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" />
|
<ProjectReference Include="..\..\Configuration\Configuration.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<EmbeddedResource Include="scripts/**/*.sql" />
|
<EmbeddedResource Include="scripts/**/*.sql" />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
using Database.Connection;
|
using Database.Connection;
|
||||||
using Infrastructure.Configuration;
|
using Configuration;
|
||||||
using Microsoft.Data.SqlClient;
|
using Microsoft.Data.SqlClient;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.schemas WHERE name = 'Auth')
|
||||||
|
EXEC('CREATE SCHEMA Auth');
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
CREATE TABLE dbo.UserAccount
|
CREATE TABLE Auth.UserAccount
|
||||||
(
|
(
|
||||||
UserAccountID UNIQUEIDENTIFIER
|
UserAccountID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_UserAccountID DEFAULT NEWID(),
|
CONSTRAINT DF_UserAccountID DEFAULT NEWID(),
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
CREATE TABLE UserVerification -- delete verification data when user account is deleted
|
CREATE TABLE Auth.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 UserVerification -- delete verification data when user account is d
|
|||||||
|
|
||||||
CONSTRAINT FK_UserVerification_UserAccount
|
CONSTRAINT FK_UserVerification_UserAccount
|
||||||
FOREIGN KEY (UserAccountID)
|
FOREIGN KEY (UserAccountID)
|
||||||
REFERENCES UserAccount (UserAccountID)
|
REFERENCES Auth.UserAccount (UserAccountID)
|
||||||
ON DELETE CASCADE,
|
ON DELETE CASCADE,
|
||||||
|
|
||||||
CONSTRAINT AK_UserVerification_UserAccountID
|
CONSTRAINT AK_UserVerification_UserAccountID
|
||||||
@@ -24,4 +24,4 @@ CREATE TABLE UserVerification -- delete verification data when user account is d
|
|||||||
|
|
||||||
CREATE
|
CREATE
|
||||||
NONCLUSTERED INDEX IX_UserVerification_UserAccount
|
NONCLUSTERED INDEX IX_UserVerification_UserAccount
|
||||||
ON UserVerification(UserAccountID);
|
ON Auth.UserVerification(UserAccountID);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
CREATE TABLE UserCredential -- delete credentials when user account is deleted
|
CREATE TABLE Auth.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 UserCredential -- delete credentials when user account is deleted
|
|||||||
|
|
||||||
CONSTRAINT FK_UserCredential_UserAccount
|
CONSTRAINT FK_UserCredential_UserAccount
|
||||||
FOREIGN KEY (UserAccountID)
|
FOREIGN KEY (UserAccountID)
|
||||||
REFERENCES UserAccount (UserAccountID)
|
REFERENCES Auth.UserAccount (UserAccountID)
|
||||||
ON DELETE CASCADE
|
ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE
|
CREATE
|
||||||
NONCLUSTERED INDEX IX_UserCredential_UserAccount
|
NONCLUSTERED INDEX IX_UserCredential_UserAccount
|
||||||
ON UserCredential(UserAccountID);
|
ON Auth.UserCredential(UserAccountID);
|
||||||
|
|
||||||
CREATE
|
CREATE
|
||||||
NONCLUSTERED INDEX IX_UserCredential_Account_Active
|
NONCLUSTERED INDEX IX_UserCredential_Account_Active
|
||||||
ON UserCredential(UserAccountID, IsRevoked, Expiry)
|
ON Auth.UserCredential(UserAccountID, IsRevoked, Expiry)
|
||||||
INCLUDE (Hash);
|
INCLUDE (Hash);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.schemas WHERE name = 'Geolocation')
|
||||||
|
EXEC('CREATE SCHEMA Geolocation');
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
CREATE TABLE Country
|
CREATE TABLE Geolocation.Country
|
||||||
(
|
(
|
||||||
CountryID UNIQUEIDENTIFIER
|
CountryID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_CountryID DEFAULT NEWID(),
|
CONSTRAINT DF_CountryID DEFAULT NEWID(),
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
CREATE TABLE StateProvince
|
CREATE TABLE Geolocation.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,
|
||||||
|
|
||||||
ISO3166_2 CHAR(6) NOT NULL,
|
|
||||||
-- eg 'US-CA' for California, 'CA-ON' for Ontario
|
|
||||||
|
|
||||||
CountryID UNIQUEIDENTIFIER NOT NULL,
|
CountryID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
|
-- eg 'US-CA' for California, 'CA-ON' for Ontario
|
||||||
|
ISO3166_2 CHAR(6) NOT NULL,
|
||||||
|
|
||||||
RowVersion ROWVERSION,
|
RowVersion ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_StateProvince
|
CONSTRAINT PK_StateProvince
|
||||||
@@ -18,11 +18,14 @@ CREATE TABLE 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 Country (CountryID)
|
REFERENCES Geolocation.Country (CountryID)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE
|
CREATE
|
||||||
NONCLUSTERED INDEX IX_StateProvince_Country
|
NONCLUSTERED INDEX IX_StateProvince_Country
|
||||||
ON StateProvince(CountryID);
|
ON Geolocation.StateProvince(CountryID);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
CREATE TABLE City
|
CREATE TABLE Geolocation.City
|
||||||
(
|
(
|
||||||
CityID UNIQUEIDENTIFIER
|
CityID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_CityID DEFAULT NEWID(),
|
CONSTRAINT DF_CityID DEFAULT NEWID(),
|
||||||
@@ -14,9 +14,11 @@ CREATE TABLE City
|
|||||||
|
|
||||||
CONSTRAINT FK_City_StateProvince
|
CONSTRAINT FK_City_StateProvince
|
||||||
FOREIGN KEY (StateProvinceID)
|
FOREIGN KEY (StateProvinceID)
|
||||||
REFERENCES StateProvince (StateProvinceID)
|
REFERENCES Geolocation.StateProvince (StateProvinceID),
|
||||||
|
|
||||||
|
CONSTRAINT AK_CityStateProvince UNIQUE (CityName, StateProvinceID)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE
|
CREATE
|
||||||
NONCLUSTERED INDEX IX_City_StateProvince
|
NONCLUSTERED INDEX IX_City_StateProvince
|
||||||
ON City(StateProvinceID);
|
ON Geolocation.City(StateProvinceID);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.schemas WHERE name = 'Media')
|
||||||
|
EXEC('CREATE SCHEMA Media');
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
CREATE TABLE Photo -- All photos must be linked to a user account, you cannot delete a user account if they have uploaded photos
|
CREATE TABLE Media.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 Photo -- All photos must be linked to a user account, you cannot de
|
|||||||
|
|
||||||
CONSTRAINT FK_Photo_UploadedBy
|
CONSTRAINT FK_Photo_UploadedBy
|
||||||
FOREIGN KEY (UploadedByID)
|
FOREIGN KEY (UploadedByID)
|
||||||
REFERENCES UserAccount (UserAccountID)
|
REFERENCES Auth.UserAccount (UserAccountID)
|
||||||
ON DELETE NO ACTION
|
ON DELETE NO ACTION
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE
|
CREATE
|
||||||
NONCLUSTERED INDEX IX_Photo_UploadedByID
|
NONCLUSTERED INDEX IX_Photo_UploadedByID
|
||||||
ON Photo(UploadedByID);
|
ON Media.Photo(UploadedByID);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.schemas WHERE name = 'Brewery')
|
||||||
|
EXEC('CREATE SCHEMA Brewery');
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
CREATE TABLE BreweryPost -- A user cannot be deleted if they have a post
|
CREATE TABLE Brewery.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 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 UserAccount (UserAccountID)
|
REFERENCES Auth.UserAccount (UserAccountID)
|
||||||
ON DELETE NO ACTION
|
ON DELETE NO ACTION
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE
|
CREATE
|
||||||
NONCLUSTERED INDEX IX_BreweryPost_PostedByID
|
NONCLUSTERED INDEX IX_BreweryPost_PostedByID
|
||||||
ON BreweryPost(PostedByID);
|
ON Brewery.BreweryPost(PostedByID);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
CREATE TABLE BreweryPostLocation
|
CREATE TABLE Brewery.BreweryPostLocation
|
||||||
(
|
(
|
||||||
BreweryPostLocationID UNIQUEIDENTIFIER
|
BreweryPostLocationID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_BreweryPostLocationID DEFAULT NEWID(),
|
CONSTRAINT DF_BreweryPostLocationID DEFAULT NEWID(),
|
||||||
@@ -25,24 +25,24 @@ CREATE TABLE BreweryPostLocation
|
|||||||
|
|
||||||
CONSTRAINT FK_BreweryPostLocation_BreweryPost
|
CONSTRAINT FK_BreweryPostLocation_BreweryPost
|
||||||
FOREIGN KEY (BreweryPostID)
|
FOREIGN KEY (BreweryPostID)
|
||||||
REFERENCES BreweryPost (BreweryPostID)
|
REFERENCES Brewery.BreweryPost (BreweryPostID)
|
||||||
ON DELETE CASCADE,
|
ON DELETE CASCADE,
|
||||||
|
|
||||||
CONSTRAINT FK_BreweryPostLocation_City
|
CONSTRAINT FK_BreweryPostLocation_City
|
||||||
FOREIGN KEY (CityID)
|
FOREIGN KEY (CityID)
|
||||||
REFERENCES City (CityID)
|
REFERENCES Geolocation.City (CityID)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE
|
CREATE
|
||||||
NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost
|
NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost
|
||||||
ON BreweryPostLocation(BreweryPostID);
|
ON Brewery.BreweryPostLocation(BreweryPostID);
|
||||||
|
|
||||||
CREATE
|
CREATE
|
||||||
NONCLUSTERED INDEX IX_BreweryPostLocation_City
|
NONCLUSTERED INDEX IX_BreweryPostLocation_City
|
||||||
ON BreweryPostLocation(CityID);
|
ON Brewery.BreweryPostLocation(CityID);
|
||||||
|
|
||||||
CREATE SPATIAL INDEX SIDX_BreweryPostLocation_Coordinates
|
CREATE SPATIAL INDEX SIDX_BreweryPostLocation_Coordinates
|
||||||
ON BreweryPostLocation(Coordinates)
|
ON Brewery.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),
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
CREATE TABLE BreweryPostPhoto -- All photos linked to a post are deleted if the post is deleted
|
CREATE TABLE Brewery.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 BreweryPostPhoto -- All photos linked to a post are deleted if the
|
|||||||
|
|
||||||
CONSTRAINT FK_BreweryPostPhoto_BreweryPost
|
CONSTRAINT FK_BreweryPostPhoto_BreweryPost
|
||||||
FOREIGN KEY (BreweryPostID)
|
FOREIGN KEY (BreweryPostID)
|
||||||
REFERENCES BreweryPost (BreweryPostID)
|
REFERENCES Brewery.BreweryPost (BreweryPostID)
|
||||||
ON DELETE CASCADE,
|
ON DELETE CASCADE,
|
||||||
|
|
||||||
CONSTRAINT FK_BreweryPostPhoto_Photo
|
CONSTRAINT FK_BreweryPostPhoto_Photo
|
||||||
FOREIGN KEY (PhotoID)
|
FOREIGN KEY (PhotoID)
|
||||||
REFERENCES Photo (PhotoID)
|
REFERENCES Media.Photo (PhotoID)
|
||||||
ON DELETE CASCADE
|
ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE
|
CREATE
|
||||||
NONCLUSTERED INDEX IX_BreweryPostPhoto_Photo_BreweryPost
|
NONCLUSTERED INDEX IX_BreweryPostPhoto_Photo_BreweryPost
|
||||||
ON BreweryPostPhoto(PhotoID, BreweryPostID);
|
ON Brewery.BreweryPostPhoto(PhotoID, BreweryPostID);
|
||||||
|
|
||||||
CREATE
|
CREATE
|
||||||
NONCLUSTERED INDEX IX_BreweryPostPhoto_BreweryPost_Photo
|
NONCLUSTERED INDEX IX_BreweryPostPhoto_BreweryPost_Photo
|
||||||
ON BreweryPostPhoto(BreweryPostID, PhotoID);
|
ON Brewery.BreweryPostPhoto(BreweryPostID, PhotoID);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.schemas WHERE name = 'Beer')
|
||||||
|
EXEC('CREATE SCHEMA Beer');
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
CREATE TABLE BeerStyle
|
CREATE TABLE Beer.BeerStyle
|
||||||
(
|
(
|
||||||
BeerStyleID UNIQUEIDENTIFIER
|
BeerStyleID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_BeerStyleID DEFAULT NEWID(),
|
CONSTRAINT DF_BeerStyleID DEFAULT NEWID(),
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
CREATE TABLE BeerPost
|
CREATE TABLE Beer.BeerPost
|
||||||
(
|
(
|
||||||
BeerPostID UNIQUEIDENTIFIER
|
BeerPostID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_BeerPostID DEFAULT NEWID(),
|
CONSTRAINT DF_BeerPostID DEFAULT NEWID(),
|
||||||
@@ -31,16 +31,16 @@ CREATE TABLE BeerPost
|
|||||||
|
|
||||||
CONSTRAINT FK_BeerPost_PostedBy
|
CONSTRAINT FK_BeerPost_PostedBy
|
||||||
FOREIGN KEY (PostedByID)
|
FOREIGN KEY (PostedByID)
|
||||||
REFERENCES UserAccount (UserAccountID)
|
REFERENCES Auth.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 BeerStyle (BeerStyleID),
|
REFERENCES Beer.BeerStyle (BeerStyleID),
|
||||||
|
|
||||||
CONSTRAINT FK_BeerPost_Brewery
|
CONSTRAINT FK_BeerPost_Brewery
|
||||||
FOREIGN KEY (BrewedByID)
|
FOREIGN KEY (BrewedByID)
|
||||||
REFERENCES BreweryPost (BreweryPostID),
|
REFERENCES Brewery.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 BeerPost
|
|||||||
|
|
||||||
CREATE
|
CREATE
|
||||||
NONCLUSTERED INDEX IX_BeerPost_PostedBy
|
NONCLUSTERED INDEX IX_BeerPost_PostedBy
|
||||||
ON BeerPost(PostedByID);
|
ON Beer.BeerPost(PostedByID);
|
||||||
|
|
||||||
CREATE
|
CREATE
|
||||||
NONCLUSTERED INDEX IX_BeerPost_BeerStyle
|
NONCLUSTERED INDEX IX_BeerPost_BeerStyle
|
||||||
ON BeerPost(BeerStyleID);
|
ON Beer.BeerPost(BeerStyleID);
|
||||||
|
|
||||||
CREATE
|
CREATE
|
||||||
NONCLUSTERED INDEX IX_BeerPost_BrewedBy
|
NONCLUSTERED INDEX IX_BeerPost_BrewedBy
|
||||||
ON BeerPost(BrewedByID);
|
ON Beer.BeerPost(BrewedByID);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
CREATE TABLE BeerPostPhoto -- All photos linked to a beer post are deleted if the post is deleted
|
CREATE TABLE Beer.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 BeerPostPhoto -- All photos linked to a beer post are deleted if th
|
|||||||
|
|
||||||
CONSTRAINT FK_BeerPostPhoto_BeerPost
|
CONSTRAINT FK_BeerPostPhoto_BeerPost
|
||||||
FOREIGN KEY (BeerPostID)
|
FOREIGN KEY (BeerPostID)
|
||||||
REFERENCES BeerPost (BeerPostID)
|
REFERENCES Beer.BeerPost (BeerPostID)
|
||||||
ON DELETE CASCADE,
|
ON DELETE CASCADE,
|
||||||
|
|
||||||
CONSTRAINT FK_BeerPostPhoto_Photo
|
CONSTRAINT FK_BeerPostPhoto_Photo
|
||||||
FOREIGN KEY (PhotoID)
|
FOREIGN KEY (PhotoID)
|
||||||
REFERENCES Photo (PhotoID)
|
REFERENCES Media.Photo (PhotoID)
|
||||||
ON DELETE CASCADE
|
ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE
|
CREATE
|
||||||
NONCLUSTERED INDEX IX_BeerPostPhoto_Photo_BeerPost
|
NONCLUSTERED INDEX IX_BeerPostPhoto_Photo_BeerPost
|
||||||
ON BeerPostPhoto(PhotoID, BeerPostID);
|
ON Beer.BeerPostPhoto(PhotoID, BeerPostID);
|
||||||
|
|
||||||
CREATE
|
CREATE
|
||||||
NONCLUSTERED INDEX IX_BeerPostPhoto_BeerPost_Photo
|
NONCLUSTERED INDEX IX_BeerPostPhoto_BeerPost_Photo
|
||||||
ON BeerPostPhoto(BeerPostID, PhotoID);
|
ON Beer.BeerPostPhoto(BeerPostID, PhotoID);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
CREATE TABLE BeerPostComment
|
CREATE TABLE Beer.BeerPostComment
|
||||||
(
|
(
|
||||||
BeerPostCommentID UNIQUEIDENTIFIER
|
BeerPostCommentID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_BeerPostComment DEFAULT NEWID(),
|
CONSTRAINT DF_BeerPostComment DEFAULT NEWID(),
|
||||||
@@ -23,11 +23,11 @@ CREATE TABLE BeerPostComment
|
|||||||
|
|
||||||
CONSTRAINT FK_BeerPostComment_BeerPost
|
CONSTRAINT FK_BeerPostComment_BeerPost
|
||||||
FOREIGN KEY (BeerPostID)
|
FOREIGN KEY (BeerPostID)
|
||||||
REFERENCES BeerPost (BeerPostID),
|
REFERENCES Beer.BeerPost (BeerPostID),
|
||||||
|
|
||||||
CONSTRAINT FK_BeerPostComment_UserAccount
|
CONSTRAINT FK_BeerPostComment_UserAccount
|
||||||
FOREIGN KEY (CommentedByID)
|
FOREIGN KEY (CommentedByID)
|
||||||
REFERENCES UserAccount (UserAccountID)
|
REFERENCES Auth.UserAccount (UserAccountID)
|
||||||
ON DELETE NO ACTION,
|
ON DELETE NO ACTION,
|
||||||
|
|
||||||
CONSTRAINT CHK_BeerPostComment_Rating
|
CONSTRAINT CHK_BeerPostComment_Rating
|
||||||
@@ -36,8 +36,8 @@ CREATE TABLE BeerPostComment
|
|||||||
|
|
||||||
CREATE
|
CREATE
|
||||||
NONCLUSTERED INDEX IX_BeerPostComment_BeerPost
|
NONCLUSTERED INDEX IX_BeerPostComment_BeerPost
|
||||||
ON BeerPostComment(BeerPostID);
|
ON Beer.BeerPostComment(BeerPostID);
|
||||||
|
|
||||||
CREATE
|
CREATE
|
||||||
NONCLUSTERED INDEX IX_BeerPostComment_CommentedBy
|
NONCLUSTERED INDEX IX_BeerPostComment_CommentedBy
|
||||||
ON BeerPostComment(CommentedByID);
|
ON Beer.BeerPostComment(CommentedByID);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.schemas WHERE name = 'Social')
|
||||||
|
EXEC('CREATE SCHEMA Social');
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
CREATE TABLE UserFollow
|
CREATE TABLE Social.UserFollow
|
||||||
(
|
(
|
||||||
UserFollowID UNIQUEIDENTIFIER
|
UserFollowID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_UserFollowID DEFAULT NEWID(),
|
CONSTRAINT DF_UserFollowID DEFAULT NEWID(),
|
||||||
@@ -17,12 +17,12 @@ CREATE TABLE UserFollow
|
|||||||
|
|
||||||
CONSTRAINT FK_UserFollow_UserAccount
|
CONSTRAINT FK_UserFollow_UserAccount
|
||||||
FOREIGN KEY (UserAccountID)
|
FOREIGN KEY (UserAccountID)
|
||||||
REFERENCES UserAccount (UserAccountID)
|
REFERENCES Auth.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 UserAccount (UserAccountID)
|
REFERENCES Auth.UserAccount (UserAccountID)
|
||||||
ON DELETE NO ACTION,
|
ON DELETE NO ACTION,
|
||||||
|
|
||||||
CONSTRAINT CK_CannotFollowOwnAccount
|
CONSTRAINT CK_CannotFollowOwnAccount
|
||||||
@@ -32,8 +32,8 @@ CREATE TABLE UserFollow
|
|||||||
|
|
||||||
CREATE
|
CREATE
|
||||||
NONCLUSTERED INDEX IX_UserFollow_UserAccount_FollowingID
|
NONCLUSTERED INDEX IX_UserFollow_UserAccount_FollowingID
|
||||||
ON UserFollow(UserAccountID, FollowingID);
|
ON Social.UserFollow(UserAccountID, FollowingID);
|
||||||
|
|
||||||
CREATE
|
CREATE
|
||||||
NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount
|
NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount
|
||||||
ON UserFollow(FollowingID, UserAccountID);
|
ON Social.UserFollow(FollowingID, UserAccountID);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
CREATE TABLE dbo.UserProfile
|
CREATE TABLE Social.UserProfile
|
||||||
(
|
(
|
||||||
UserProfileID UNIQUEIDENTIFIER
|
UserProfileID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_UserProfileID DEFAULT NEWID(),
|
CONSTRAINT DF_UserProfileID DEFAULT NEWID(),
|
||||||
@@ -17,6 +17,6 @@ CREATE TABLE dbo.UserProfile
|
|||||||
|
|
||||||
CONSTRAINT FK_UserProfile_UserAccount
|
CONSTRAINT FK_UserProfile_UserAccount
|
||||||
FOREIGN KEY (UserAccountID)
|
FOREIGN KEY (UserAccountID)
|
||||||
REFERENCES UserAccount (UserAccountID)
|
REFERENCES Auth.UserAccount (UserAccountID)
|
||||||
ON DELETE CASCADE
|
ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
CREATE TABLE dbo.UserAvatar
|
CREATE TABLE Social.UserAvatar
|
||||||
(
|
(
|
||||||
UserAvatarID UNIQUEIDENTIFIER
|
UserAvatarID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_UserAvatarID DEFAULT NEWID(),
|
CONSTRAINT DF_UserAvatarID DEFAULT NEWID(),
|
||||||
@@ -18,12 +18,12 @@ CREATE TABLE dbo.UserAvatar
|
|||||||
|
|
||||||
CONSTRAINT FK_UserAvatar_UserProfile
|
CONSTRAINT FK_UserAvatar_UserProfile
|
||||||
FOREIGN KEY (UserProfileID)
|
FOREIGN KEY (UserProfileID)
|
||||||
REFERENCES dbo.UserProfile (UserProfileID)
|
REFERENCES Social.UserProfile (UserProfileID)
|
||||||
ON DELETE CASCADE,
|
ON DELETE CASCADE,
|
||||||
|
|
||||||
CONSTRAINT FK_UserAvatar_Photo
|
CONSTRAINT FK_UserAvatar_Photo
|
||||||
FOREIGN KEY (PhotoID)
|
FOREIGN KEY (PhotoID)
|
||||||
REFERENCES dbo.Photo (PhotoID)
|
REFERENCES Media.Photo (PhotoID)
|
||||||
ON DELETE NO ACTION
|
ON DELETE NO ACTION
|
||||||
ON UPDATE NO ACTION,
|
ON UPDATE NO ACTION,
|
||||||
|
|
||||||
@@ -33,11 +33,11 @@ CREATE TABLE dbo.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 dbo.UserAvatar (UserProfileID)
|
ON Social.UserAvatar (UserProfileID)
|
||||||
WHERE ValidTo IS NULL;
|
WHERE ValidTo IS NULL;
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_UserAvatar_Photo
|
CREATE NONCLUSTERED INDEX IX_UserAvatar_Photo
|
||||||
ON dbo.UserAvatar (PhotoID);
|
ON Social.UserAvatar (PhotoID);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX ix_UserAvatar_Profile
|
CREATE NONCLUSTERED INDEX ix_UserAvatar_Profile
|
||||||
ON dbo.UserAvatar (UserProfileID)
|
ON Social.UserAvatar (UserProfileID)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user