Skip to content

Repository files navigation

Exycle

Item-exchange (barter) platform. Users list items they're willing to trade, browse what others have, and negotiate two-sided trades in real time. A transaction is sealed only when both parties approve the exchange.

The codebase started life as a netcoreapp3.1 + CRA 3.4 / React 16 tutorial-derived app deployed on Azure App Service. It's now a .NET 10 LTS API (clean architecture, MediatR vertical slices, EF Core 10 + Postgres, SignalR) and a Vite + React 19 + Mantine 9 SPA, packaged as a single multi-stage Docker image and deployed to a self-hosted k3s cluster. See § Project status for what's done, in progress, and on the backlog.


Tech stack at a glance

Layer Choice
Backend runtime .NET 10 LTS (released Nov 2025; supported through Nov 2028)
Language C# 13, TypeScript 5.x (strict)
Web framework ASP.NET Core 10 (Kestrel + MVC controllers)
ORM EF Core 10 with Npgsql provider
Database PostgreSQL 16
CQRS / mediator MediatR 12
Validation FluentValidation 11 (auto-validation on MVC)
Mapping Riok.Mapperly 4 (compile-time source generator, MIT)
Auth ASP.NET Core Identity + JWT bearer (HMAC-SHA512) + HttpOnly refresh cookie
Real-time SignalR (per-item group chat hub)
Photos Cloudinary
Email SendGrid
Logging Serilog → compact JSON to stdout
Metrics prometheus-net.AspNetCore at /metrics
Health Microsoft.Extensions.Diagnostics.HealthChecks + AspNetCore.HealthChecks.NpgSql
Security headers NetEscapades.AspNetCore.SecurityHeaders
Frontend tooling Vite 8, ESLint 10
Frontend runtime React 19, MobX-free (Zustand 5)
UI library Mantine 9 + @tabler/icons-react
Forms @mantine/form + Zod (via mantine-form-zod-resolver)
Server state TanStack Query 5
Routing React Router 7
HTTP client axios 1.x with single-flight refresh-token interceptor
SignalR client @microsoft/signalr 10
Container Multi-stage Dockerfile: node:22-alpinedotnet/sdk:10.0dotnet/aspnet:10.0-noble-chiseled (non-root, no shell)
Orchestration Local: Docker Compose. Cluster: k3s (manifests in Cucox91/cucox-lab-infra)

Repository layout

.
├── API/                               # ASP.NET Core entry point + controllers
│   ├── Controllers/                   # Thin: each method is one Mediator.Send
│   ├── Middleware/                    # ErrorHandlingMiddleware (RestException → JSON)
│   ├── SignalR/                       # ChatHub (per-item live comments)
│   ├── Program.cs                     # Bootstrap Serilog, host builder, run migrations + seed
│   └── Startup.cs                     # DI, auth, CSP, health probes, /metrics, routing
│
├── Application/                       # MediatR handlers ("vertical slices")
│   ├── Comments/                      # one Create handler; SignalR is the only call site
│   ├── Errors/                        # RestException
│   ├── Followers/                     # Add / Delete / List
│   ├── Interfaces/                    # IUserAccessor, IPhotoAccessor, IEmailSender, IJwtGenerator, IProfileReader
│   ├── Items/                         # CRUD + photo handlers + DTOs
│   ├── Mapping/ExycleMapper.cs        # Mapperly partial class, replaces AutoMapper
│   ├── PhotoService/                  # Add/Delete/SetMain for both items and users
│   ├── ProfileService/                # Details / Edit / ListItems + ProfileReader
│   ├── TransactionService/            # Begin / List / Details / AddItem / RemoveItem /
│   │                                  #   Approve / Unapprove + TransactionStatus helper
│   ├── UserService/                   # Login / Register / VerifyEmail / RefreshToken / etc.
│   └── Validators/                    # Reusable FluentValidation rules (e.g. password complexity)
│
├── Domain/                            # POCOs (User, Item, Transaction, ...). No framework deps.
│
├── Infrastructure/                    # External services + concrete impls of Application/Interfaces
│   ├── Email/      EmailSender (SendGrid) + SendGridSettings
│   ├── Photos/     PhotoAccessor (Cloudinary) + CloudinarySettings
│   └── Security/   JwtGenerator + UserAccessor
│
├── Persistence/                       # EF Core DbContext + migrations + Seed
│   ├── DataContext.cs                 # IdentityDbContext<User>; relationships configured here
│   ├── Migrations/                    # Auto-applied at API startup (Program.cs)
│   └── Seed.cs                        # Creates the dev test user (see "Seed user" below)
│
├── client-app/                        # Vite + React 19 + Mantine SPA
│   ├── src/
│   │   ├── App.tsx                    # Routes + RequireAuth guard
│   │   ├── main.tsx                   # MantineProvider, QueryClientProvider, BrowserRouter
│   │   ├── lib/
│   │   │   ├── api.ts                 # axios instance, JWT interceptor, refresh-on-401
│   │   │   ├── chatHub.ts             # useChatHub(itemId) — SignalR hook
│   │   │   ├── itemsApi.ts            # typed wrappers for /api/items*
│   │   │   ├── profilesApi.ts         # typed wrappers for /api/profiles*
│   │   │   └── transactionsApi.ts     # typed wrappers for /api/transactions*
│   │   ├── stores/auth.ts             # Zustand auth store (persisted to localStorage)
│   │   ├── types.ts                   # TypeScript mirrors of all *Dto classes
│   │   └── features/
│   │       ├── auth/                  # Login / Register / VerifyEmail
│   │       ├── items/                 # List / Details / Form / PhotoUploader / Comments
│   │       ├── layout/                # AppShell (header + navbar)
│   │       ├── profiles/              # Page / Edit / Photos / Items / Followings tabs
│   │       └── transactions/          # List / Details / AddItemDialog
│   ├── vite.config.ts                 # Dev proxy: /api + /chat → :8080 (ws:true for SignalR)
│   ├── postcss.config.cjs             # Mantine PostCSS preset
│   └── package.json
│
├── Dockerfile                         # Multi-stage SPA + API → chiseled non-root runtime
├── docker-compose.yml                 # Local dev: api + postgres
├── .env.example                       # Template; copy to .env and fill in real values
├── Exycle.sln
└── README.md                          # ← you are here

Local development

Quick start (Docker Compose)

The complete stack — API, frontend bundle, Postgres — in one container build. Best for "does it work end-to-end" testing.

# 1. Copy the env template, fill in real values (see "Environment variables" below).
cp .env.example .env
$EDITOR .env

# 2. Generate a fresh JWT signing key. Must be ≥ 64 bytes for HMAC-SHA512.
openssl rand -base64 64                         # paste into EXYCLE_TOKEN_KEY in .env

# 3. Up.
docker compose up --build

The API binds http://localhost:8080 and serves both /api/* and the SPA bundle from wwwroot. Postgres is available on localhost:5432 for inspection (psql -h localhost -U exycle -d exycle, password exycle-dev-password).

The migrations apply on container start (Program.Main) and Seed.SeedData populates the test user.

Native development (recommended for fast iteration)

Two terminals:

# Terminal 1 — Postgres only via compose, then API natively for fast .NET iteration.
docker compose up -d postgres
dotnet run --project API
# Terminal 2 — Vite dev server with HMR. Hot-reloads on .ts/.tsx edits.
cd client-app
npm install
npm run dev                                       # http://localhost:3000

Vite proxies /api/* and /chat (SignalR upgrade) to the API on :8080. The API's CORS allowlist matches http://localhost:3000.

Environment variables

Variable Example What it's for
EXYCLE_TOKEN_KEY output of openssl rand -base64 64 JWT signing key. Must be ≥ 64 bytes (HMAC-SHA512)
EXYCLE_CLOUDINARY_CLOUD_NAME your-cloud Cloudinary CDN tenant
EXYCLE_CLOUDINARY_API_KEY 12345… Cloudinary API key
EXYCLE_CLOUDINARY_API_SECRET Cloudinary API secret
EXYCLE_SENDGRID_USER support@example.com Verified sender email
EXYCLE_SENDGRID_KEY SG.… SendGrid API key

.env is gitignored. Production secrets on the cluster live in a SOPS-encrypted Kubernetes Secret.

Seed user

Persistence/Seed.cs creates this user on first DB run so you can sign in immediately:

Username exycle
Email support@exycle.com
Password 123**Qwe

To exercise multi-user flows (transactions, follows, comments) register a second account through the UI. Email verification is on; either click the SendGrid link or skip it via:

docker compose exec postgres psql -U exycle -d exycle \
  -c "UPDATE \"AspNetUsers\" SET \"EmailConfirmed\"=true WHERE \"UserName\"='<the-username>';"

Common operational tasks

Task Command
Rebuild the image and restart docker compose up --build
Reset the database docker compose down --volumes && docker compose up --build
Tail API logs docker logs -f exycle-api
Open psql against the dev DB docker compose exec postgres psql -U exycle -d exycle
Generate an EF migration dotnet ef migrations add <Name> --project Persistence --startup-project API
Apply pending migrations (automatic at API startup; or dotnet ef database update --project Persistence --startup-project API)
Frontend prod build cd client-app && npm run build (output → dist/, copied into image's wwwroot)
Run frontend tests / lint cd client-app && npm run lint

Architecture

Backend layers

┌──────────────────────────────────────────────────────────────────┐
│ API (controllers, middleware, SignalR hub)                       │
│   ↓ Mediator.Send                                                │
│ Application (handlers, validators, mapper, DTOs, interfaces)     │
│   ↓ DbContext / interface impls                                  │
│ Persistence (EF Core DbContext + migrations)         Infrastruc- │
│   ↓ Npgsql                                          ture (Cloud- │
│ PostgreSQL                                          inary, Send- │
│                                                     Grid, JWT)   │
└──────────────────────────────────────────────────────────────────┘

Each layer references only what it needs; Domain is dependency-free (plain POCOs, no framework attributes).

Request lifecycle

  1. Client sends an HTTP request with Authorization: Bearer <jwt> (or ?access_token=<jwt> for the /chat SignalR path).
  2. ErrorHandlingMiddleware wraps the pipeline; RestException thrown anywhere downstream is converted to a JSON { errors: ... } with the appropriate status code.
  3. NetEscapades.AspNetCore.SecurityHeaders adds CSP, X-Frame-Options, referrer policy, etc.
  4. Authentication middleware validates the JWT.
  5. Routing dispatches to a controller method. The controller is one line: await Mediator.Send(...).
  6. The handler runs in Application/. Handlers depend only on DataContext, IUserAccessor, IMapper (replaced by ExycleMapper), and per-feature interfaces.
  7. EF Core executes against Postgres. Mapperly maps entity → DTO.
  8. The DTO returns through the controller; Newtonsoft.Json serializes it (camelCase) and the client receives the response.

Authentication

  • Login / register / verify email are anonymous endpoints on UsersController.
  • Login returns a UserDto containing a JWT (HMAC-SHA512, 15 min lifetime) and sets an HttpOnly cookie (refreshToken, also 15 min).
  • Refresh (POST /api/users/refreshToken) reads the cookie, validates it, and returns a new JWT + rotates the cookie. The axios interceptor on the client side intercepts 401s and calls this endpoint single-flight (multiple parallel 401s share one refresh).
  • SignalR authenticates via the access_token query parameter (the WebSocket upgrade can't carry custom headers); the JWT bearer middleware's OnMessageReceived hook routes it through.

Data model highlights

  • User : IdentityUser — extends Identity with FirstName, LastName, address fields, MemberSince, navigation collections for items, follows, photos, refresh tokens.
  • Item — title, description, available flag, User owner, Photos, Comments, TransactionsPool (link table to Transaction).
  • Transaction — two parties (Party, CounterParty), ItemsPool (collection of TransactionItem), IsApprovedByParty / IsApprovedByCounterParty, Status (new | pending | completed), DateClosed. Source of truth for the lifecycle is the approval booleans + DateClosed; Status is a denormalized string for display.
  • UserFollowing — many-to-many on User (Observer ↔ Target). Cascade delete is restricted on both sides to avoid wiping a user when a followed/follower is removed.

Real-time chat

API/SignalR/ChatHub.cs exposes:

Hub method (server) Direction Purpose
SendComment(Create.Command) client → server persist + broadcast
AddToGroup(itemId) client → server subscribe to an item's chat
RemoveFromGroup(itemId) client → server unsubscribe
ReceiveComment server → client new comment broadcast
Send server → client join/leave status string

The client wrapper is client-app/src/lib/chatHub.ts. Each mounted ItemDetailsPage opens its own connection, joins the item's group, and tears down on unmount.


Frontend overview

Routes (all behind RequireAuth except auth flows)

Path Component Purpose
/login, /register, /user/verifyEmail auth/* unauthenticated
/ ItemsListPage grid + search + availability filter + pagination
/items/new, /items/:id/edit ItemFormPage shared create/edit form
/items/:id ItemDetailsPage hero photo, thumbnails, comments, propose-trade
/profiles/:username ProfilePage header + tabs (Items, Photos, Followers, Following)
/transactions TransactionsListPage list mine, filter by status
/transactions/:id TransactionDetailsPage bidirectional pool editor + approval card

State management

  • Auth — Zustand store, persisted to localStorage. Only the store touches localStorage; everything else reads from useAuthStore.
  • Server state — TanStack Query handles fetching/caching/invalidation for every API resource. Default staleTime is 30s; we don't retry on 4xx (404/401 etc. are usually permanent). Mutations call queryClient.setQueryData(...) directly when the server returns the full updated resource (transactions do this), so the page re-renders without a fetch round-trip.
  • Forms@mantine/form + Zod via mantine-form-zod-resolver. Schemas mirror the server-side FluentValidation rules.

Production deployment

Image

Dockerfile is multi-stage:

  1. node:22-alpine stage: npm ci, then npm run build produces the SPA bundle in /client/dist.
  2. mcr.microsoft.com/dotnet/sdk:10.0 stage: restore + dotnet publish produces the API in /publish.
  3. mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled runtime: no shell, no package manager, runs as $APP_UID (1654) by default. The SPA bundle lands in /app/wwwroot; ASP.NET Core's UseStaticFiles + UseDefaultFiles serves it. The FallbackController returns the same Index view for any unmatched path so client-side routing works on hard refresh.

Build for both architectures and push:

docker buildx build --platform linux/amd64,linux/arm64 \
  -t ghcr.io/cucox91/exycle:0.1.0 \
  --push .

Kubernetes (k3s)

Manifests live in Cucox91/cucox-lab-infra:

k8s/apps/exycle/
├── 00-namespace.yaml          # PSA `restricted`
├── 10-configmap.yaml          # ASPNETCORE_ENVIRONMENT, URLs
├── 15-secrets.yaml            # SOPS-encrypted (Cloudinary, SendGrid, JWT, Postgres password)
├── 20-postgres.yaml           # StatefulSet + headless Service, 5Gi local-path PVC
├── 30-deployment.yaml         # API Deployment, runAsUser 1654, ROFS, drop ALL caps
├── 35-service.yaml            # ClusterIP, port 80 → targetPort 8080
├── 36-servicemonitor.yaml     # Prometheus scrape (release=kube-prometheus-stack)
└── 40-ingress.yaml            # exycle.com + www.exycle.com → exycle-api

Apply order matters (postgres must be Ready before the API tries to migrate):

kubectl apply -f k8s/apps/exycle/00-namespace.yaml
kubectl apply -f k8s/apps/exycle/10-configmap.yaml
sops --decrypt k8s/apps/exycle/15-secrets.yaml | kubectl apply -f -
kubectl apply -f k8s/apps/exycle/20-postgres.yaml
kubectl -n exycle wait --for=condition=Ready pod/exycle-postgres-0 --timeout=300s
kubectl apply -f k8s/apps/exycle/{30-deployment,35-service,36-servicemonitor,40-ingress}.yaml
kubectl -n exycle rollout status deployment/exycle-api

Public exposure is via cloudflared on lab-edge01 → ingress-nginx (MetalLB VIP) → the Service. Cloudflare DNS points the apex and www at the tunnel.


Project status

Done

  • Modernization — netcoreapp3.1 → .NET 10 LTS (every package updated, breaking changes adapted, EF Core 3 → 10).
  • Containerization — multi-stage chiseled Dockerfile, docker-compose for local dev, Postgres as the only database in both dev and prod.
  • Observability — Serilog JSON logs, Prometheus /metrics, health probes (/healthz/live, /healthz/ready).
  • k8s manifests — full set in cucox-lab-infra.
  • Frontend rewrite — CRA 3 / React 16 / MobX 5 / Semantic UI → Vite 8 / React 19 / Zustand 5 / Mantine 9. Auth flow, items (CRUD + photos), profiles (edit + photos + items + followers/following), transactions (full lifecycle: list, details, bidirectional pool editing, approve/unapprove), live SignalR comments.
  • AutoMapper → Mapperly — closes GHSA-rvv3-g6hj-g44x without a commercial license.

Open / planned

  • 3.6 Polish
    • Per-route code splitting (the bundle is at ~820 KB / 245 KB gzipped; lazy-loading the transactions/profiles routes should cut that 3-4×).
    • a11y pass on forms + modals.
    • Mobile responsive sweep.
    • Backend privacy: ProfileDto currently returns Address1/Address2/Zip to anyone; should be owner-only.
    • DataProtection key persistence (Postgres-backed via Microsoft.AspNetCore.DataProtection.EntityFrameworkCore) so refresh-token cookies survive pod restarts.
    • Move off lazy-loading proxies in favor of explicit .Include(). Cleaner change tracking, fewer N+1 surprises.
  • 3.7 Cluster cutover
    • Push image to GHCR, kubectl rollout restart against the deployed manifests.
    • Append exycle.com / www.exycle.com to cloudflared/config.yaml.tmpl and re-deploy the tunnel.
    • Cloudflare DNS: CNAME apex + www to the tunnel UUID.
    • Soak period, then take down the legacy Azure deployment.
  • Future
    • User ratings post-seal (the original product spec — fulfillment tracking + reputation).
    • CloudNativePG for HA Postgres + PITR.
    • Re-add Facebook (or any external) login via Microsoft.AspNetCore.Authentication.Facebook if there's demand.

Adding a feature

The MediatR vertical-slice pattern keeps changes localized:

  1. DTO — add to Application/<Feature>/<Whatever>Dto.cs.
  2. Handler — add Application/<Feature>/<Action>.cs with nested Command/Query, Validator (if applicable), and Handler.
  3. Mapper — add a partial method on Application/Mapping/ExycleMapper.cs (Mapperly auto-generates the implementation; non-partial methods are picked up by signature for custom mappings).
  4. Controller — one new method on the matching controller; one line: return await Mediator.Send(...).
  5. Frontend type — mirror the DTO in client-app/src/types.ts.
  6. Frontend API helper — add to the matching lib/<feature>Api.ts.
  7. Frontend page/component — under client-app/src/features/<feature>/.

For schema changes:

# After modifying entities in Domain/ or relationship config in
# Persistence/DataContext.cs:
dotnet ef migrations add <DescriptiveName> \
  --project Persistence --startup-project API

Migrations apply automatically at API startup. The Postgres column types are produced by Npgsql's provider; review the generated SQL for anything non-trivial (especially constraints and indexes).


Useful references

About

An Old Dream project. A barter application where the main goal is to exchange goods and services without need of currency. Giving a second life to used things or exchange of services.

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages