A full-stack Products CRUD application.
- Backend: .NET 10 Web API (FastEndpoints) using Mediator (CQRS), EF Core 10 + PostgreSQL, ASP.NET Core Identity with JWT bearer auth.
- Frontend: React + Vite + TypeScript, TanStack Query for server state, PrimeReact components, Tailwind for layout, form validation via react-hook-form + Zod. Typed API client generated from the backend's OpenAPI spec.
- Orchestration: .NET Aspire for local development; Docker Compose for production-style runs.
- Prerequisites
- Running the stack
- Repository layout
- Domain model
- API reference
- Authentication & authorization
- Audit, soft delete & concurrency
- Validation & error handling
- Logging
- Database & migrations
- Frontend
- Tests
- Environment variables
- Request lifecycle
- .NET 10 SDK (
dotnet --version≥10.0.100) - Node.js 20+ and pnpm (
corepack enableis fine) - Docker (required for Postgres, Testcontainers, and the compose stack)
- Aspire project templates (one-time):
dotnet new install Aspire.ProjectTemplates
(cd frontend && pnpm install) # one-time
cd backend && dotnet run --project src/OmneTask.AppHostThe Aspire dashboard URL is printed at startup. From it you can access:
postgres— Postgres 16, persisted volumedb-migrator— runs once; applies EF migrations, seeds roles, admin, and 100 sample products; then exitsapi— waits for the migrator (WaitForCompletion) before starting; Swagger at/swagger(Development only)web— Vite dev server, proxies/api/*to the API
Default seeded credentials: admin / admin.
./scripts/docker-up.sh # foreground, admin/admin by default
./scripts/docker-up.sh -d # detached
./scripts/docker-up.sh down -v # stop and drop postgres volumeCompose exposes:
- API on
http://localhost:8080 - Web (nginx → API proxy) on
http://localhost:8081
Override admin/JWT via env vars (OMNETASK_ADMIN_EMAIL, OMNETASK_ADMIN_PASSWORD, OMNETASK_JWT_SIGNING_KEY). The script echoes the resolved values on startup; a --reset-admin flag forces the hard-coded defaults when stale exports are lingering in your shell.
Used when the EF migration set changes during development (the persisted volume desyncs with __EFMigrationsHistory):
./scripts/docker-up.sh down -v
# or for Aspire:
docker volume rm omnetask.apphost-*-postgres-databackend/
OmneTask.sln
src/
OmneTask.AppHost/ Aspire orchestrator (postgres, migrator, api, web)
OmneTask.ServiceDefaults/ OpenTelemetry, health checks, service discovery
OmneTask.Api/ HTTP API, FastEndpoints, middleware, Serilog
OmneTask.Application/ Mediator handlers, validators, pipeline behaviors
OmneTask.Domain/ Product entity, DomainEntity<TId>, SecurityContext
OmneTask.Infrastructure/ AppDbContext, Identity, JWT, soft-delete purge
Prime.TableFilter/ PrimeReact lazy-load payload → LINQ translator
tools/
OmneTask.DbMigrator/ Console app — migrations + seeding, runs out-of-process
tests/
OmneTask.IntegrationTests/ Testcontainers Postgres + WebApplicationFactory<Program>
frontend/ Nx workspace (pnpm)
apps/web/ React + Vite + PrimeReact + Tailwind
src/api/ openapi.json snapshot + generated schema.ts
src/app/auth/ AuthContext, LoginPage, ProfilePage
src/app/products/ ProductsPage, ProductList, ProductForm, api.ts
src/app/layout/ Navbar, Footer, Layout
docker-compose.yml Postgres + migrator + API + web (nginx)
scripts/docker-up.sh Compose wrapper with defaults + masked output
Product : DomainEntity<int>
| Field | Type | Notes |
|---|---|---|
| Id | int |
Postgres serial |
| Name | string |
≤ 200, required |
| Description | string? |
≤ 2000 |
| Price | decimal |
numeric(18,2), ≥ 0 |
| Xmin | uint |
Postgres xmin system column, mapped as EF concurrency token |
| CreatedOn / CreatedBy / CreatedIP | audit | Set automatically in AppDbContext.SaveChanges |
| ModifiedOn / ModifiedBy / ModifiedIP | audit | Bumped on every update; Created* are preserved |
| IsDeleted | bool |
Soft-delete flag; filtered globally |
All entities implementing IAudited get the audit fields and the !IsDeleted global query filter automatically.
Note: the audit +
IsDeletedcolumns live on the entity but are not serialized onProductDto. The wire contract exposes onlyid / name / description / price / xmin— see the API reference for the full JSON shape.
All routes under /api/v1. JSON over HTTP; auth via Authorization: Bearer <jwt> header.
| Method | Route | Access | Purpose |
|---|---|---|---|
| POST | /api/v1/auth/login |
anonymous | { email, password } → { token, expiresAt } |
| GET | /api/v1/auth/me |
authenticated | { id, email, name, roles[] } |
| POST | /api/v1/auth/change-password |
authenticated | { currentPassword, newPassword } → 204 |
| Method | Route | Access | Purpose |
|---|---|---|---|
| POST | /api/v1/users |
Admin | { email, password, roles[] } |
| Method | Route | Access | Purpose |
|---|---|---|---|
| POST | /api/v1/products/query |
authenticated | PrimeReact lazy-load list (paging, sort, filters) |
| GET | /api/v1/products/{id} |
authenticated | Single product |
| POST | /api/v1/products |
authenticated | Create |
| PUT | /api/v1/products/{id} |
authenticated | Update — requires xmin |
| DELETE | /api/v1/products/{id} |
Admin | Soft delete (returns 204) |
ProductDto (the response shape on list/get/create/update) is intentionally
slimmer than the entity — audit fields (CreatedBy, CreatedOn, etc.) are
not exposed publicly:
{
"id": 1,
"name": "Sample Product",
"description": "A sample product for testing.",
"price": 10.99,
"xmin": 12345
}xmin is the Postgres concurrency token — the client must echo it back on
PUT /api/v1/products/{id} so the server can detect lost updates.
POST /api/v1/products/query request body matches the PrimeReact DataTable lazy-load payload:
{
"first": 0,
"rows": 10,
"sortField": "Name",
"sortOrder": 1,
"filters": {
"Name": { "value": "widget", "matchMode": "contains", "operator": "and" },
"Price": [
{ "value": 10, "matchMode": "gte", "operator": "and" },
{ "value": 100, "matchMode": "lt", "operator": "and" }
]
}
}The filters dictionary keys must be the entity's property names (Name, Description, Price — case-sensitive, translated via reflection by Prime.TableFilter). Each value can be a single { value, matchMode } object or an array of constraints chained with AND/OR operators.
- Identity storage — ASP.NET Core Identity tables in the same Postgres database as
Products. - Tokens — HS256 JWT signed with
OMNETASK_JWT_SIGNING_KEY(≥ 32 chars). Validated withValidIssuer,ValidAudience,ValidateLifetime, and a 30-second clock skew. - Roles —
Admin,User. Policies:RequireUser,RequireAdmin. - No public sign-up. Admin seeds on first startup; additional users are created via
POST /api/v1/usersby an Admin. - Password policy — minimum 4 chars, no complexity requirements (suitable for demo; tighten
AuthExtensions.AddAuthfor real deployments).
The password change endpoint uses UserManager.ChangePasswordAsync which verifies the current password and re-hashes on success.
Populated by AppDbContext.ApplyAuditFields on every SaveChanges. The user context comes from ICurrentUserAccessor (reads the authenticated claims via IHttpContextAccessor). CreatedBy/CreatedOn/CreatedIP are preserved across updates (the EF entry has IsModified = false forced on those properties).
- When an
IAuditedentity is markedEntityState.Deleted,ConvertDeletesToSoftDeletesswitches it toModifiedwithIsDeleted = true. The audit pipeline then stampsModifiedOn/ModifiedBywith the user who performed the delete. - A global EF query filter (
e => !e.IsDeleted) is applied to everyIAuditedentity type inOnModelCreating. All reads transparently exclude deleted rows. SoftDeletePurgeService(BackgroundService) wakes on a configurable interval, hard-deletes rows older thanRetentionDays, and runsVACUUM productsto reclaim space. UsesExecuteDeleteAsyncwithIgnoreQueryFiltersso it bypasses the soft-delete interception.
Product.Xmin is mapped to Postgres' built-in xmin system column (no schema change — Postgres bumps it on every UPDATE automatically) and marked as EF's concurrency token. The client echoes xmin on PUT; Edit.Handler sets it as the OriginalValue so EF compares against the client's version. A mismatch throws DbUpdateConcurrencyException which the middleware maps to 409 CONCURRENCY_CONFLICT.
All errors are returned as RFC 7807 application/problem+json:
{
"type": "https://httpstatuses.com/404",
"title": "Product not found.",
"status": 404,
"code": "PRODUCT_NOT_FOUND",
"traceId":"00-2a4…-…-01",
"errors": null
}For validation failures errors is a { field: [messages] } dictionary and code is VALIDATION_FAILED.
| Exception | HTTP | Code |
|---|---|---|
NotFoundException |
404 | PRODUCT_NOT_FOUND, RECORD_NOT_FOUND, ... |
BusinessLogicException |
400 | INVALID_CREDENTIALS, USER_EMAIL_REQUIRED, ... |
ValidationException |
400 | VALIDATION_FAILED (+ errors dict) |
ConcurrencyConflictException |
409 | CONCURRENCY_CONFLICT |
| any other | 500 | UNHANDLED_ERROR (stack trace only in logs) |
Mediator pipeline behaviors wrap every request:
SecurityContextBehavior— stampsISecureRequest.SecurityContextfrom the current user.ValidationBehavior— resolvesIValidator<TRequest>(FluentValidation), throwsValidationExceptionon failure.UnhandledExceptionBehavior— logs any non-AppExceptionbefore rethrowing.- Handler.
Serilog, registered via AddSerilog (coexists with the standard ILogger provider chain, so OpenTelemetry's logs exporter still flows to the Aspire dashboard):
- Console sink — human-readable, includes
cid={CorrelationId}. - File sink —
logs/omnetask-api-YYYYMMDD.json(CompactJsonFormatter, 14-day retention) — one event per line, ELK-ready. - Framework noise (
Microsoft.AspNetCore) gated toWarning.
CorrelationIdMiddleware honors an inbound X-Correlation-Id header (falls back to OTel Activity.TraceId, then a fresh GUID), pushes it onto LogContext, and echoes it on the response. The exception middleware enriches both the per-error line and the request-completed line with ErrorCode / ErrorStatus so Kibana can aggregate by ErrorCode.keyword.
Migrations live in OmneTask.Infrastructure/Migrations and are applied out-of-process by OmneTask.DbMigrator (a console app). The API blocks on its completion (WaitForCompletion in Aspire, service_completed_successfully in compose), so the API never starts against an unmigrated database.
DatabaseSeeder runs right after MigrateAsync and idempotently creates:
- Roles (
Admin,User) - The admin user from
OMNETASK_ADMIN_EMAIL/OMNETASK_ADMIN_PASSWORD - 100 sample products (Bogus, deterministic seed) — only if the table is empty
Add a new migration:
cd backend
dotnet ef migrations add <Name> \
--project src/OmneTask.Infrastructure \
--startup-project src/OmneTask.InfrastructureRun the migrator standalone (useful in CI):
ConnectionStrings__omnetaskdb="Host=...;Database=omnetaskdb;Username=...;Password=..." \
OMNETASK_JWT_SIGNING_KEY="…" \
OMNETASK_ADMIN_EMAIL="…" OMNETASK_ADMIN_PASSWORD="…" \
dotnet run --project backend/tools/OmneTask.DbMigrator- Routing:
react-router-domv7.<RequireAuth>guards/,/profile,/products/new,/products/:id/edit. - Auth:
AuthContextholds the current user; token stored inlocalStorage(omnetask.token). Client-side/meprobe on mount restores the session. - API client:
openapi-fetchconfigured with types generated fromopenapi.json(see below). Two middlewares:authMiddleware— injects theAuthorizationheader.errorToastMiddleware— turns non-2xx into PrimeReact toasts (silent for the/meprobe on startup).
- Server state: TanStack Query.
useQueryfor the product list + single product;useMutationfor create/update/delete. Mutations invalidate['products'](and['product', id]on edit). - Forms:
react-hook-form+zodresolver. Schema duplicates the backend FluentValidation rules (client-side feedback; server remains source of truth). - UI: PrimeReact + Tailwind. DataTable in
lazymode posts the full PrimeReact lazy-load payload toPOST /api/v1/products/query;Prime.TableFiltertranslates it to LINQ server-side.
The OpenAPI spec is committed at frontend/apps/web/src/api/openapi.json and the TypeScript schema at frontend/apps/web/src/api/schema.ts. Regenerate when the backend contract changes:
# 1) flip the [Skip] off in ExportOpenApi.cs, then:
ASPNETCORE_ENVIRONMENT=Development \
dotnet test backend/tests/OmneTask.IntegrationTests \
--filter "FullyQualifiedName~ExportOpenApi"
# 2) regenerate schema.ts
cd frontend && pnpm api:types
# 3) flip the [Skip] back onAfter this, pnpm nx run @org/web:typecheck will surface any drift in the call sites.
cd backend && dotnet testThe integration suite spins up a real postgres:16-alpine via Testcontainers and exercises the API via WebApplicationFactory<Program>. Coverage:
- Products CRUD + paging/sorting/search via the PrimeReact payload
- Audit-field stamping (create stamps
CreatedBy, update bumpsModifiedOnwhile preservingCreatedOn) - Soft delete + purge (backdating a row then calling
PurgeAsyncdirectly) - Concurrency conflict (stale
xmin→ 409) - Validation problem-details
- Auth: login success / 400 on bad creds,
/me, admin-only user creation, 401 on anonymous reads, 403 on delete-as-user
ExportOpenApi is skipped by default; enable it on demand to refresh the frontend's spec snapshot.
| Variable | Purpose | Required | Default |
|---|---|---|---|
ConnectionStrings__omnetaskdb |
Postgres connection string | yes in prod | injected by Aspire / compose |
OMNETASK_JWT_SIGNING_KEY |
HS256 key, ≥ 32 chars | yes | — |
OMNETASK_JWT_ISSUER |
Token issuer | no | omnetask |
OMNETASK_JWT_AUDIENCE |
Token audience | no | omnetask |
OMNETASK_JWT_LIFETIME_MINUTES |
Access-token lifetime | no | 60 |
OMNETASK_ADMIN_EMAIL |
Seeded admin username (not necessarily an email) | yes | — |
OMNETASK_ADMIN_PASSWORD |
Seeded admin password | yes | — |
OMNETASK_SOFTDELETE_RETENTION_DAYS |
Days to retain soft-deleted rows | no | 30 |
OMNETASK_SOFTDELETE_PURGE_INTERVAL_MINUTES |
Purge job cadence | no | 60 |
OMNETASK_SOFTDELETE_RUN_VACUUM |
Run VACUUM after purge |
no | true |
HTTP request
→ CorrelationIdMiddleware (honors/sets X-Correlation-Id, pushes to LogContext)
→ SerilogRequestLogging (emits one INF line per request with elapsed ms, status, etc.)
→ ExceptionHandlingMiddleware (AppException + DbUpdateConcurrencyException → ProblemDetails)
→ Authentication / Authorization (JwtBearer validates + populates HttpContext.User)
→ FastEndpoints endpoint (thin — just dispatches to Mediator)
→ SecurityContextBehavior (stamps SecurityContext on ISecureRequest)
→ ValidationBehavior (FluentValidation → ValidationException on failure)
→ UnhandledExceptionBehavior (logs unexpected exceptions)
→ Handler (uses AppDbContext directly)
→ AppDbContext.SaveChangesAsync (soft-delete interception → audit stamping → base.SaveChangesAsync)