Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

OmneTask

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.

Contents

  1. Prerequisites
  2. Running the stack
  3. Repository layout
  4. Domain model
  5. API reference
  6. Authentication & authorization
  7. Audit, soft delete & concurrency
  8. Validation & error handling
  9. Logging
  10. Database & migrations
  11. Frontend
  12. Tests
  13. Environment variables
  14. Request lifecycle

Prerequisites

  • .NET 10 SDK (dotnet --version10.0.100)
  • Node.js 20+ and pnpm (corepack enable is fine)
  • Docker (required for Postgres, Testcontainers, and the compose stack)
  • Aspire project templates (one-time): dotnet new install Aspire.ProjectTemplates

Running the stack

Local development — .NET Aspire

(cd frontend && pnpm install)                       # one-time
cd backend && dotnet run --project src/OmneTask.AppHost

The Aspire dashboard URL is printed at startup. From it you can access:

  • postgres — Postgres 16, persisted volume
  • db-migrator — runs once; applies EF migrations, seeds roles, admin, and 100 sample products; then exits
  • api — 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.

Production-style — Docker Compose

./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 volume

Compose 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.

Wiping the database

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-data

Repository layout

backend/
  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

Domain model

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 + IsDeleted columns live on the entity but are not serialized on ProductDto. The wire contract exposes only id / name / description / price / xmin — see the API reference for the full JSON shape.

API reference

All routes under /api/v1. JSON over HTTP; auth via Authorization: Bearer <jwt> header.

Auth

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

Users

Method Route Access Purpose
POST /api/v1/users Admin { email, password, roles[] }

Products

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.

Authentication & authorization

  • 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 with ValidIssuer, ValidAudience, ValidateLifetime, and a 30-second clock skew.
  • RolesAdmin, User. Policies: RequireUser, RequireAdmin.
  • No public sign-up. Admin seeds on first startup; additional users are created via POST /api/v1/users by an Admin.
  • Password policy — minimum 4 chars, no complexity requirements (suitable for demo; tighten AuthExtensions.AddAuth for real deployments).

The password change endpoint uses UserManager.ChangePasswordAsync which verifies the current password and re-hashes on success.

Audit, soft delete & concurrency

Audit fields

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).

Soft delete

  1. When an IAudited entity is marked EntityState.Deleted, ConvertDeletesToSoftDeletes switches it to Modified with IsDeleted = true. The audit pipeline then stamps ModifiedOn/ModifiedBy with the user who performed the delete.
  2. A global EF query filter (e => !e.IsDeleted) is applied to every IAudited entity type in OnModelCreating. All reads transparently exclude deleted rows.
  3. SoftDeletePurgeService (BackgroundService) wakes on a configurable interval, hard-deletes rows older than RetentionDays, and runs VACUUM products to reclaim space. Uses ExecuteDeleteAsync with IgnoreQueryFilters so it bypasses the soft-delete interception.

Optimistic concurrency

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.

Validation & error handling

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.

Domain exceptions

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)

Pipeline

Mediator pipeline behaviors wrap every request:

  1. SecurityContextBehavior — stamps ISecureRequest.SecurityContext from the current user.
  2. ValidationBehavior — resolves IValidator<TRequest> (FluentValidation), throws ValidationException on failure.
  3. UnhandledExceptionBehavior — logs any non-AppException before rethrowing.
  4. Handler.

Logging

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 sinklogs/omnetask-api-YYYYMMDD.json (CompactJsonFormatter, 14-day retention) — one event per line, ELK-ready.
  • Framework noise (Microsoft.AspNetCore) gated to Warning.

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.

Database & migrations

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.Infrastructure

Run 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

Frontend

  • Routing: react-router-dom v7. <RequireAuth> guards /, /profile, /products/new, /products/:id/edit.
  • Auth: AuthContext holds the current user; token stored in localStorage (omnetask.token). Client-side /me probe on mount restores the session.
  • API client: openapi-fetch configured with types generated from openapi.json (see below). Two middlewares:
    • authMiddleware — injects the Authorization header.
    • errorToastMiddleware — turns non-2xx into PrimeReact toasts (silent for the /me probe on startup).
  • Server state: TanStack Query. useQuery for the product list + single product; useMutation for create/update/delete. Mutations invalidate ['products'] (and ['product', id] on edit).
  • Forms: react-hook-form + zod resolver. Schema duplicates the backend FluentValidation rules (client-side feedback; server remains source of truth).
  • UI: PrimeReact + Tailwind. DataTable in lazy mode posts the full PrimeReact lazy-load payload to POST /api/v1/products/query; Prime.TableFilter translates it to LINQ server-side.

Regenerating the typed client

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 on

After this, pnpm nx run @org/web:typecheck will surface any drift in the call sites.

Tests

cd backend && dotnet test

The 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 bumps ModifiedOn while preserving CreatedOn)
  • Soft delete + purge (backdating a row then calling PurgeAsync directly)
  • 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.

Environment variables

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

Request lifecycle

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)

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages