Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Philosophy: correctness and foot-gun rules are build errors; pure style is a
# suggestion so it never blocks a build. EnforceCodeStyleInBuild is on and
# warnings are errors (Directory.Build.props), so a "warning" here fails CI.

root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 4

[*.{json,yml,yaml,slnx,csproj,props,targets}]
indent_size = 2

[*.{js,ts,vue,css,html,cjs,mjs}]
indent_size = 2

[*.md]
trim_trailing_whitespace = false

[*.cs]
# --- Naming ------------------------------------------------------------
# Private fields are camelCase with no underscore prefix.
dotnet_naming_rule.private_fields_are_camel_case.severity = warning
dotnet_naming_rule.private_fields_are_camel_case.symbols = private_fields
dotnet_naming_rule.private_fields_are_camel_case.style = camel_case_style

dotnet_naming_symbols.private_fields.applicable_kinds = field
dotnet_naming_symbols.private_fields.applicable_accessibilities = private

dotnet_naming_style.camel_case_style.capitalization = camel_case

# --- Style (suggestions — never block the build) -----------------------
csharp_style_var_for_built_in_types = true:suggestion
csharp_style_var_when_type_is_apparent = true:suggestion
csharp_style_var_elsewhere = true:suggestion
csharp_style_expression_bodied_methods = when_on_single_line:suggestion
csharp_style_expression_bodied_properties = when_on_single_line:suggestion
csharp_style_prefer_switch_expression = true:suggestion
csharp_style_prefer_primary_constructors = true:suggestion
dotnet_style_prefer_collection_expression = true:suggestion

# --- Correctness: promoted to error ------------------------------------
# File-scoped namespaces everywhere.
csharp_style_namespace_declarations = file_scoped:error
dotnet_diagnostic.IDE0161.severity = error

# Exhaustive switch expressions: no missing enum members, and never a
# discard arm to hide them. A new enum value must break the build at every
# switch that ignores it (CS8524 stays quiet — unnamed values are cast noise).
dotnet_diagnostic.CS8509.severity = error
dotnet_diagnostic.CS8524.severity = none

# Null-safety escapes are bugs, not warnings.
dotnet_diagnostic.CS8600.severity = error
dotnet_diagnostic.CS8602.severity = error
dotnet_diagnostic.CS8603.severity = error
dotnet_diagnostic.CS8604.severity = error
dotnet_diagnostic.CS8618.severity = error
dotnet_diagnostic.CS8625.severity = error

# Fire-and-forget tasks.
dotnet_diagnostic.CS4014.severity = error
dotnet_diagnostic.VSTHRD110.severity = error
# Don't force the Async suffix.
dotnet_diagnostic.VSTHRD200.severity = none

# Pass CancellationToken when one is available.
dotnet_diagnostic.CA2016.severity = error

# Multiple enumeration of IEnumerable.
dotnet_diagnostic.CA1851.severity = error

# Hygiene.
dotnet_diagnostic.IDE0005.severity = warning
dotnet_diagnostic.IDE0051.severity = warning
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* @KaliCZ
32 changes: 32 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
version: 2
updates:
# GitHub Actions — grouped weekly bumps.
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
groups:
actions:
patterns:
- "*"

# Backend — NuGet versions all live in Directory.Packages.props (central
# package management); repo root is the entry point.
- package-ecosystem: nuget
directory: /
schedule:
interval: weekly
groups:
nuget:
patterns:
- "*"

# Frontend — npm packages from frontend/package.json.
- package-ecosystem: npm
directory: /frontend
schedule:
interval: weekly
groups:
npm:
patterns:
- "*"
143 changes: 143 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
# Quality gates only — this repo is a demo/template with no deploy stage.
# The backend "linter" is the build itself: analyzers run in-build and
# warnings are errors (Directory.Build.props + .editorconfig).
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}

permissions:
contents: read

jobs:
backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
global-json-file: global.json

- name: Cache NuGet packages
uses: actions/cache@v6
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('Directory.Packages.props', '**/*.csproj') }}
restore-keys: nuget-${{ runner.os }}-

- name: Restore
run: dotnet restore ProductReviews.slnx

- name: Build
run: dotnet build ProductReviews.slnx --no-restore -c Release

# Domain unit + property tests, then wire-level integration tests against
# a real PostgreSQL via Testcontainers (Docker is preinstalled on the runner).
- name: Test
run: dotnet test --no-build -c Release

frontend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v7

- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
cache-dependency-path: frontend/package-lock.json

- name: Install dependencies
run: npm ci

- name: Check formatting
run: npm run format:check

- name: Typecheck
run: npm run typecheck

- name: Unit tests
run: npm run test:unit

- name: Build
run: npm run build

e2e:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7

- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
global-json-file: global.json

- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
cache-dependency-path: frontend/package-lock.json

- name: Cache NuGet packages
uses: actions/cache@v6
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('Directory.Packages.props', '**/*.csproj') }}
restore-keys: nuget-${{ runner.os }}-

# Pre-build so the Playwright webServer step spends its timeout on the
# stack (container pulls, Zitadel init), not on compilation.
- name: Build backend
run: dotnet build ProductReviews.slnx

- name: Install frontend dependencies
run: npm ci
working-directory: frontend

- name: Get Playwright version
id: playwright-version
run: echo "version=$(node -e "console.log(require('./package-lock.json').packages['node_modules/@playwright/test'].version)")" >> $GITHUB_OUTPUT
working-directory: frontend

- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}

- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: ./node_modules/.bin/playwright install --with-deps chromium
working-directory: frontend

# Boots the whole Aspire stack (Postgres, Zitadel + hosted login, API,
# frontend preview) and signs in through the real login form; also fails
# if the committed openapi.json drifted from the running API.
- name: Run E2E tests
run: npm run test:e2e
working-directory: frontend

- name: Upload Playwright traces
if: "!cancelled()"
uses: actions/upload-artifact@v7
with:
name: playwright-traces
path: frontend/test-results/
retention-days: 7
if-no-files-found: ignore
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,7 @@ CodeCoverage/
*.VisualState.xml
TestResult.xml
nunit-*.xml

# Zitadel dev PATs written by the AppHost bind mount
.zitadel/

114 changes: 114 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# CLAUDE.md

Agent instructions for this repository — and the conventions to copy when this
repo is used as a template for a new project.

## Read the design first

The docs are the spec; code follows them. Before changing an area, read what
governs it:

| Changing… | Read first |
| ---------------------------------- | ---------- |
| Anything at all | [docs/technical-requirements.md](docs/technical-requirements.md) |
| User-facing behavior | [docs/business-requirements.md](docs/business-requirements.md) |
| A decision that feels reversible | The matching ADR in [docs/adr/](docs/adr/README.md) — supersede, never edit |
| Validation, DTOs, domain types | [ADR-0002](docs/adr/0002-validation-lives-in-the-type-system.md), [ADR-0003](docs/adr/0003-business-errors-are-result-enums.md) |
| API surface / frontend types | [ADR-0004](docs/adr/0004-openapi-is-the-frontend-contract.md) |
| Auth | [ADR-0005](docs/adr/0005-auth-zitadel-oidc-pkce.md) |
| Tests | [ADR-0007](docs/adr/0007-tests-use-real-dependencies.md) |

If a change contradicts a doc, change the doc in the same PR — or don't make
the change.

## Commands

```shell
dotnet run --project src/ProductReviews.AppHost # the whole stack (Docker required)
dotnet build ProductReviews.slnx # warnings are errors
dotnet test # unit + property + integration (Docker required)
npm --prefix frontend run test:unit # Vitest
npm --prefix frontend run test:e2e # Playwright against the full stack
npm --prefix frontend run typecheck # vue-tsc
npm --prefix frontend run refresh:api # refresh openapi.json + regenerate schema.d.ts (stack must be running)
dotnet ef migrations add <Name> --project src/ProductReviews.Domain --output-dir Persistence/Migrations
```

Demo sign-in: `demo@productreviews.local` / `ProductReviews123!` (second user:
`critic@productreviews.local`). If Zitadel misbehaves after config changes,
reset it: remove the `productreviews-zitadel` container, the
`productreviews-postgres-data` volume, and the `.zitadel/` directory, then
restart the AppHost.

## Architecture rules (the non-negotiables)

1. **Vertical slices.** A feature lives in `Features/<Feature>/` (API) and
`<Feature>/` (Domain) — one folder each, everything it owns inside. This
applies to infrastructure too: one concern per file
(`Infrastructure/RateLimits.cs`, `Observability.cs`, …) with
`Configure(...)`/`Use(...)` statics. No grab-bag `ServiceExtensions`.
2. **DDD for data and logic.** Entities own their behavior
(`Review.ApplyEdit`, `Product.RefreshRatingSummary`); handlers orchestrate,
never property-spray. One handler file per operation, containing its error
enum.
3. **Controllers, not minimal APIs.** Endpoints are controller actions with
explicit `ProducesResponseType` metadata.
4. **Strong types everywhere; never lose information.** Constrained values use
`Kalicz.StrongTypes` wrappers (or a custom `[NumericWrapper]` like
`Rating`) in DTOs, handler signatures, entities, and responses. Data
annotations and guard clauses re-checking a type's rule are defects.
External input parses with `As…`/`TryCreate` (null = reject); internal
values use `To…`/`Create` (throw = bug).
5. **DTOs are an API-layer contract.** Domain objects are never serialized.
Handlers return domain models + error enums; the controller maps success to
a response DTO and each error enum value to HTTP in an exhaustive `switch`
(no `default` arm — a new enum value must break the build).
6. **Optionality is nullability, and it round-trips.** Required C# property ⇒
`required` non-nullable in OpenAPI ⇒ required in TypeScript. Three-state
updates (keep / clear / set) use `Maybe<T>?`, never a magic value.
7. **Proof-of-loading reads.** Multi-entity reads project into records whose
constructors demand the loaded data (`ProductWithReviews`,
`ReviewWithVotes` via `CompleteQueries`) — never pass entities around and
hope the navigation was included.
8. **Denormalized aggregates are recomputed, never incremented** — always from
source rows, in the same `SaveChanges`.
9. **Seeding happens at startup** (idempotent, through domain entities), never
in migrations. Migrations are schema-only and tool-generated — never edit
them by hand.
10. **Tests use real dependencies.** No mocking libraries. Integration tests
assert raw JSON from anonymous payloads; property tests generate
strong-typed values (`Kalicz.StrongTypes.FsCheck`). E2E signs in through
the real Zitadel form.
11. **The OpenAPI document is the frontend contract.** Frontend code only
calls the API through the generated `openapi-fetch` client. After changing
any DTO/route: run the stack, `npm --prefix frontend run refresh:api`,
commit `openapi.json` + `schema.d.ts` together with the API change — the
E2E contract test fails otherwise.
12. **Nothing blanket-global.** `[Authorize]` per action (no
`RequireAuthorization()` on the route table), ServiceDefaults in its own
namespace, no global route middleware in the frontend.

## Naming and style

- UTC instants end in `Utc` (`CreatedAtUtc`). Identifiers are spelled out —
no abbreviations.
- Private fields are camelCase without an underscore prefix.
- Comments only for a non-obvious *why*, one sentence; when in doubt, none.
- Correctness analyzer rules are build errors (see [.editorconfig](.editorconfig));
style rules are suggestions. `TreatWarningsAsErrors` is on.
- Central package management: versions live in
[Directory.Packages.props](Directory.Packages.props) only.

## Where code goes

| New… | Goes to |
| -------------------------- | ------- |
| Endpoint + DTOs | `src/ProductReviews.Api/Features/<Feature>/` |
| Business operation | `src/ProductReviews.Domain/<Feature>/<Verb><Noun>.cs` (handler + error enum) |
| Entity / value type | `src/ProductReviews.Domain/<Feature>/` |
| EF configuration | `src/ProductReviews.Domain/Persistence/Configurations/` |
| Cross-cutting API concern | `src/ProductReviews.Api/Infrastructure/<Concern>.cs` |
| Orchestration resource | `src/ProductReviews.AppHost/` |
| Page / component | `frontend/src/pages/` / `frontend/src/components/` |
| Domain test | `tests/ProductReviews.Domain.Tests/` (prefer a property test) |
| API behavior test | `tests/ProductReviews.Api.IntegrationTests/` (wire-level) |
Loading