PCMR.gr — Architecture Overview
Last updated: April 2026
Author: Compiled from development session notes
Status: Living document — update as the system evolves
What is PCMR.gr?
PCMR.gr is a boutique custom PC building platform serving the Greek market. It is not a traditional e-commerce store — customers cannot self-initiate orders. Instead, staff create quote orders based on customer intake, publish them to customers, who then claim, review, accept, and pay for their build. The platform manages the entire lifecycle from quote to physical delivery.
Business model: Staff-initiated quotes → customer claim/accept → payment → build → QA → delivery
Target market: Gamers, creators, developers, architects, content producers in Greece
Operator: Solo founder (Nikos Motos, Athens) running CTS (CustomTechSolutions) as the parent entity
Infrastructure
Servers
Three Hetzner VPS instances, all connected via WireGuard VPN (olympus network, 10.1.0.0/24):
| Server | Hostname | Location | Role | Internal IP |
|---|---|---|---|---|
| Hermes | hermes | hel1 (Helsinki) | Production | 10.1.0.1 |
| Atlas | atlas | nbg1 (Nuremberg) | Staging + CI runner | 10.1.0.2 |
| Iris | iris | — | Observability + Docs | 10.1.0.4 |
Hermes runs production workloads. The GitHub Actions self-hosted runner also runs here for production deploys — a known risk, documented in the audit, accepted for current scale.
Atlas runs staging workloads and the staging CI runner. Disk fills up quickly from Docker image accumulation — a daily docker system prune cron runs at 00:01 Athens time.
Iris runs the observability stack and internal tooling:
- Loki (log aggregation)
- Grafana (dashboards)
- GlitchTip (error tracking)
- Umami (privacy-friendly analytics, cookieless)
- Uptime Kuma (uptime monitoring)
- Homarr (internal dashboard)
- This documentation site
Networking
All three servers are on a WireGuard mesh VPN (olympus). Internal services communicate over 10.1.0.x addresses without exposing ports publicly.
Cloudflare sits in front of all public-facing domains:
pcmr.gr→ Hermes (production web)api.pcmr.gr→ Hermes (production API)staging.pcmr.gr→ Atlas (staging web)api-staging.pcmr.gr→ Atlas (staging API)docs.pcmr.gr→ Iris (this site)
Cloudflare Access (Zero Trust) gates:
- Coolify UI (
coolify.ctsolutions.gr) - Staff portal (
staff.pcmr.gr,staff-staging.pcmr.gr) - This documentation site (
docs.pcmr.gr)
Traefik runs on each server as the reverse proxy, handling SSL termination and routing to Docker containers. Let's Encrypt certificates via Traefik's ACME integration.
Known gotchas:
- Traefik needs
acme.jsonat600permissions or it won't start - Cloudflare Universal SSL doesn't cover third-level subdomains — use Full (Strict) mode
tls: {}instead ofcertResolverbehind Cloudflare Access- Non-root Coolify user requires
docker compose up -dworkaround for manual operations
Storage
Hetzner Object Storage (S3-compatible) — "Mneme" bucket:
- Production:
mnemebucket,hel1region - Staging:
mneme-stagingbucket,nbg1region - All buckets are private — files served via presigned URLs (1hr expiry)
- Implementation:
HetznerStorageServiceimplementingIStorageServiceinterface LocalStorageServicewas deleted — cloud-only in all environments
Monorepo Structure
pcmr-gr/ ├── apps/ │ ├── api/ # NestJS 11 backend │ ├── web/ # Next.js 15 frontend │ └── docs/ # Docusaurus documentation (this site) ├── packages/ │ └── types/ # Shared TypeScript types (@pcmr/types) ├── turbo.json # Turborepo pipeline config └── pnpm-workspace.yaml
Package manager: pnpm with workspaces
Build orchestration: Turborepo
Node version: 20 (Alpine in Docker)
Key established patterns:
- Use
pnpm execnotnpxfor running binaries - Inline code comments explain "why", not "what"
- Split
auth-client.ts/auth-server.tsfor browser vs server contexts - All imports use
@pcmr/typesfor shared types, never copy-paste types between apps
Backend (apps/api)
Stack
- Framework: NestJS 11
- Database: PostgreSQL via Prisma v7
- Auth: Better Auth with Prisma adapter
- Storage: Hetzner Object Storage (S3-compatible via AWS SDK)
- Email: Nodemailer via Zoho SMTP (
smtppro.zoho.eu:587) - PDF generation:
@react-pdf/renderer(requires NotoSans fonts committed toapps/api/fonts/) - Logging: Pino + pino-loki (ships to Iris Loki instance)
- Error tracking: GlitchTip via Sentry-compatible SDK
- Process: Runs on port 3001
Authentication
Better Auth handles all auth. Key configuration decisions:
baseURLmust be the frontend URL (not API URL) — Better Auth builds email verification links from thisBETTER_AUTH_URLenv var = frontend URL on both web and API containers- Cookie domain:
.pcmr.gr(leading dot = all subdomains) — empty string for local dev (browsers rejectDomain=localhost) - Session cookies:
__Secure-better-auth.session_tokenon HTTPS,better-auth.session_tokenon HTTP - 2FA: TOTP-based, required for all staff accounts before portal access
- Staff accounts: CLI-created only (
node dist/scripts/create-admin.js) — self-registration blocked for@ctsolutions.grdomain
First-login flow for staff:
- Change temporary password
- Verify email
- Set up TOTP 2FA
- Access portal
Session validation in Next.js:
- Middleware (
proxy.ts) checks cookie presence for routing decisions only — not a security gate - Actual session validation happens API-side on every request
__Secure-prefix cookie required on HTTPS — the2fa-verifyroute.ts manually signs this cookie to work around abetter-callbug whereSet-Cookieheaders get overwritten
Database Schema
29 Prisma models, 16 enums. Key models:
Auth (managed by Better Auth):
User— extended withfirstName,lastName,mustChangePassword,roleSession,Account,Verification,TwoFactor
Orders:
Order— three-axis status system (see Order Lifecycle section)OrderStatusHistory— immutable audit log of every status change- 7 line item types:
OrderComponent,OrderOS,OrderService,OrderWarranty,OrderShipping,OrderPeripheral,OrderSoftware
Catalog:
Component— with per-category specs JSON and price historyOperatingSystem,Service,Warranty,Shipping,Peripheral,Software
Builds:
Build— QA form fields, 10 named photo slots, booklet trackingBuildTimelineStep,BuildEventLog,TestRun
Other:
Attachment— polymorphic, supports order/build/timeline_step/event_log/test_run entitiesComponentPriceHistory— supplier pricing recordsWaitlistEntryCookieConsent— GDPR consent logging (scaffolded, legal copy TBD)
Module Structure
src/ ├── auth/ # Better Auth integration + auth controller ├── admin/ # Staff user management (users, roles, bans) ├── orders/ # Order CRUD, state machine, line items ├── builds/ # Build management, QA, photos, booklets ├── payments/ # Viva Smart Checkout integration ├── portal/ # Customer-facing endpoints (accept quote) ├── quotes/ # Public quote view (unauthenticated) ├── claim/ # Order claim flow ├── attachments/ # File upload/download, storage management ├── components/ # Component catalog + price history ├── booklets/ # PDF generation (@react-pdf/renderer) ├── mail/ # Email sending (Nodemailer + templates) ├── settings/ # User profile, password, email change ├── waitlist/ # Pre-launch waitlist ├── warranty-notifications/ # Daily cron for expiry reminders ├── common/ │ ├── guards/ # SessionGuard, RolesGuard, FirstLoginGuard │ ├── decorators/ # @Roles(), @CurrentUser() │ └── types/ # AuthenticatedRequest, etc. └── prisma/ # PrismaService singleton
Guards
Three guards applied in order on all protected endpoints:
- SessionGuard — validates Better Auth session cookie, attaches user to request
- FirstLoginGuard — blocks access if
mustChangePassword === true - RolesGuard — enforces
@Roles()decorator
Why this order matters: FirstLoginGuard must come after SessionGuard (needs req.user) but before RolesGuard (must block even valid roles during first-login).
API Documentation
Swagger UI available at /api-docs (restricted to non-production or behind Cloudflare Access). OpenAPI JSON at /api-docs-json — consumed by this Docusaurus site for the API reference section.
Frontend (apps/web)
Stack
- Framework: Next.js 15 (App Router)
- Auth client: Better Auth client (
auth-client.ts) - Styling: Tailwind CSS v4 + CSS custom properties (design tokens)
- UI components: shadcn/ui (manually initialized for pnpm monorepo)
- Process: Runs on port 3000
Routing Structure
app/ ├── (auth)/ # Login, register, forgot/reset password, verify email ├── (public)/ │ └── (marketing)/ # Home, services, how-it-works, pricing, FAQ, contact ├── (legal)/ # /privacy, /terms, /cookies ├── portal/ # Customer portal (session required) │ ├── orders/ │ └── settings/ ├── staff/ # Staff portal (session + admin/superadmin role required) │ ├── (auth)/ # Staff login, first-login flow │ └── (portal)/ │ └── portal/ │ ├── orders/ │ ├── builds/ │ ├── components/ │ ├── users/ │ └── settings/ ├── quote/[claimCode]/ # Public quote view ├── payment/ # success + failure pages └── api/ # Next.js route handlers (proxy to NestJS)
Middleware (proxy.ts)
proxy.ts is the Next.js middleware (renamed from middleware.ts — Next.js 16 convention). It handles:
- Cookie detection for both
__Secure-(HTTPS) and non-prefixed (local) session tokens - Staff subdomain routing (
staff.pcmr.grandstaff-staging.pcmr.gr) - Role-aware redirects (staff →
/staff/portal, users →/portal) - Request ID generation (
x-request-idheader for log correlation)
Important: Middleware cookie checks are routing hints only, not security gates. All actual auth validation happens API-side.
API Proxy Routes
All Next.js API routes (app/api/*) are thin proxies to NestJS — they forward the request with the session cookie and return the response. They exist to:
- Keep the NestJS API URL server-side (not exposed to browser)
- Allow server-side session cookie forwarding
- Provide a consistent
/api/*namespace from the browser's perspective
Exception: /api/2fa-verify/route.ts — manually signs the session cookie post-2FA to work around a better-call bug where multiple Set-Cookie headers get collapsed.
Design System
Two distinct brand identities:
PCMR.gr:
- Primary:
#400080(deep purple) - Secondary:
#800080 - Background:
#07060d(near-black) - Display font: Roboto Slab
- Body font: Roboto
- Mono font: Roboto Mono
- Aesthetic: Brutalist/editorial, dark-first
CTS (CustomTechSolutions):
- Primary:
#008080(teal) - Accent:
#CC5500(orange-red) - Background:
#080810(dark navy)
Design tokens live in CSS custom properties (--color-primary, --surface-low, etc). shadcn/ui components are themed via these tokens.
Order Lifecycle
Status Axes
Orders have three independent status fields:
orderStatus — where the order is in the business process: draft → quote → claimed → confirmed → [cancelled] ↓ completed
paymentStatus — payment state: unpaid → awaiting_payment → paid → [refunded]
fulfillmentStatus — physical build progress (nullable until build starts): null → awaiting_parts → assembling → setup → testing → packaging → shipped → completed
State Machine Guards
All status transitions are validated by order-transitions.ts:
assertOrderStatusTransition(from, to)assertPaymentStatusTransition(from, to)assertFulfillmentStatusTransition(from, to)
These throw BadRequestException on invalid transitions. They are called in OrdersService.update() before any DB write.
Build status → fulfillment status mapping (BUILD_TO_FULFILLMENT):
in_progress→building(renamed toassemblingin fulfillment)testing→testingcompleted→ready
Important: BuildStatus.failed is intentionally NOT mapped. A failed build is transient — staff fix the issue and transition back. Order/fulfillment statuses only change on meaningful forward transitions.
Email Triggers
| Event | Recipient | Service |
|---|---|---|
| Quote created | Customer | OrdersService |
| awaiting_payment transition | Customer | OrdersService |
| Payment confirmed | Customer | PaymentsService |
| Order claimed | Customer | ClaimService |
| Quote accepted by customer | Staff notify email | PortalOrdersService |
| fulfillmentStatus → ready | Customer | OrdersService |
| fulfillmentStatus → shipped | Customer | OrdersService |
| fulfillmentStatus → completed | Customer | OrdersService |
| orderStatus → cancelled | Customer | OrdersService |
| paymentStatus → refunded | Customer | OrdersService |
| Build started | Customer | BuildsService |
| Warranty expiring (30 days) | Customer | WarrantyNotificationsService (cron) |
| Password reset | User | Better Auth |
| Email verification | User | Better Auth |
| Waitlist joined | Requester + notify address | WaitlistService |
Payment Flow (Viva Smart Checkout)
Flow
- Customer clicks "Pay" on portal order detail
- Browser POSTs to
/api/payments/create-order(Next.js proxy) - Next.js forwards to NestJS
POST /payments/create-order - NestJS authenticates via OAuth2 to
demo-accounts.vivapayments.com(token cached 25s) - NestJS POSTs to
demo-api.vivapayments.com/checkout/v2/orders— gets backorderCode orderCodesaved toOrder.vivaOrderCodein DB- NestJS returns
{ orderCode }to browser - Browser redirects to
https://demo.vivapayments.com/web/checkout?ref={orderCode} - Customer pays on Viva's hosted page
- Viva redirects to
https://staging.pcmr.gr/payment/success?t={transactionId}&s={orderCode} - Success page POSTs to
/api/payments/verify - NestJS calls
GET /checkout/v2/transactions/{transactionId}to verify - Verifies:
statusId === 'F', amount matches (in euros not cents!),merchantTrnsmatches orderId (when available) - Updates
Order.paymentStatus = paid,Order.invoiceReference = transactionId - Sends payment confirmation email
Webhook: Viva also POSTs to POST /payments/webhook on payment events. Signature verification is mandatory (x-viva-signature header, HMAC-SHA256). The webhook is a backup/async path — the frontend verify flow is the primary path.
Environment URLs
| Environment | Auth URL | API URL | Checkout URL |
|---|---|---|---|
| Demo | https://demo-accounts.vivapayments.com | https://demo-api.vivapayments.com | https://demo.vivapayments.com |
| Production | https://accounts.vivapayments.com | https://api.vivapayments.com | https://www.vivapayments.com |
Critical: The auth URL and API URL are different subdomains. Using demo.vivapayments.com for API calls hangs — must use demo-api.vivapayments.com.
Amount: Viva returns amounts in euros (not cents). The verification code uses Math.abs(tx.amount - expectedAmount) > 0.01 for comparison.
Webhook Verification Key
GET /payments/webhook returns { Key: VIVA_WEBHOOK_KEY } for Viva's one-time URL verification handshake. After verification is complete in each environment, this endpoint should be removed. The key is also used to verify webhook payload signatures.
CI/CD Pipeline
Flow
- Push to
developbranch → triggers staging deploy on Atlas - Push to
mainbranch → triggers production deploy on Hermes - Each deploy: Docker build → push to registry → Coolify rolling update
Migration Automation
apps/api/start.sh runs before the NestJS server starts:
#!/bin/sh
set -e
node /app/node_modules/.pnpm/.../prisma/build/index.js \
migrate deploy \
--config /app/prisma/prisma.config.deploy.js
exec node dist/src/main.js
prisma.config.deploy.js uses absolute paths and module.exports format (not ESM) — Prisma v7 config file quirk.
Migration workflow for new environments:
- Drop and recreate schema
- Mark migrations 1-8 as applied (
prisma migrate resolve --applied) - Run
prisma migrate deploy— only migration 9 (full baseline) executes - Migration 9 (
20260419164134) is a full schema baseline — migrations 1-8 are superseded
Docker
Multi-stage Dockerfile for API:
builderstage: installs deps, runsprisma generate, builds NestJSrunnerstage: copies prod deps, dist, fonts, prisma files, start.sh
Critical files that must be copied to runner:
apps/api/fonts/— NotoSans fonts for PDF generation (booklets fail silently without these)apps/api/prisma/prisma.config.deploy.js— migration configapps/api/start.sh— must have LF line endings (not CRLF) or Alpine sh fails
Booklet Generation
Staff can generate two PDF documents per build:
- Quick Start Guide — customer-facing, covers system info, components, OS, warranty
- Technical Dossier — internal/staff facing, includes QA data, benchmarks, thermal readings
Implementation: BookletsService using @react-pdf/renderer. Renders React components to PDF buffer, uploads to Hetzner Object Storage, creates Attachment records linked to the order.
Critical: NotoSans font files must be committed to apps/api/fonts/ and copied into the Docker image. The fonts are referenced by absolute path /app/fonts/NotoSans-*.ttf. Missing fonts cause silent generation failure.
Photo slots: 10 named slots per build:
coverPhoto, mboIoPhoto, gpuIoPhoto, caseIoPhoto, psuPhoto, powerBtnPhoto, panelAPhoto, panelBPhoto, foamPhoto, thermalPhoto
Environment Variables
API (apps/api)
| Variable | Required | Notes |
|---|---|---|
DATABASE_URL | Yes | PostgreSQL connection string |
BETTER_AUTH_SECRET | Yes | Must match web app |
BETTER_AUTH_URL | Yes | Frontend public URL (not API URL) |
FRONTEND_URL | Yes | Used for email links and CORS |
COOKIE_DOMAIN | Yes | Empty for local dev, .pcmr.gr for prod |
GOOGLE_CLIENT_ID | Yes | OAuth |
GOOGLE_CLIENT_SECRET | Yes | OAuth |
VIVA_BASE_URL | Yes | https://demo-api.vivapayments.com (demo) |
VIVA_AUTH_URL | Yes | https://demo-accounts.vivapayments.com (demo) |
VIVA_CLIENT_ID | Yes | Smart Checkout credentials |
VIVA_CLIENT_SECRET | Yes | Smart Checkout credentials |
VIVA_SOURCE_CODE | Yes | 4-digit Viva payment source code |
VIVA_WEBHOOK_KEY | Yes | HMAC signing key for webhook verification |
STORAGE_ENDPOINT | Yes | https://nbg1.your-objectstorage.com (staging) |
STORAGE_BUCKET | Yes | mneme-staging (staging), mneme (prod) |
STORAGE_ACCESS_KEY | Yes | Hetzner Object Storage |
STORAGE_SECRET_KEY | Yes | Hetzner Object Storage |
STORAGE_REGION | Yes | nbg1 (staging), hel1 (prod) |
STORAGE_PRESIGN_EXPIRY | No | Seconds, default 3600 |
SMTP_HOST | Yes | smtppro.zoho.eu |
SMTP_PORT | Yes | 587 |
SMTP_USER | Yes | Zoho email address |
SMTP_PASS | Yes | Zoho password |
SMTP_SECURE | No | false for port 587 |
EMAIL_FROM | Yes | Display name + email |
WAITLIST_NOTIFY_EMAIL | No | Comma-separated list |
LOKI_URL | No | http://10.1.0.4:3100 |
LOKI_HOST | No | Same as LOKI_URL |
GLITCHTIP_DSN | No | Error tracking |
PORT | No | Default 3001 |
NODE_ENV | Yes | production in prod |
Web (apps/web)
| Variable | Required | Notes |
|---|---|---|
NEXT_PUBLIC_API_URL | Yes | Browser-side API URL |
API_URL | Yes | Server-side internal API URL |
BETTER_AUTH_SECRET | Yes | Must match API |
BETTER_AUTH_URL | Yes | API URL for server-side session |
NEXT_PUBLIC_VIVA_CHECKOUT_URL | No | Defaults to demo Viva URL |
GLITCHTIP_DSN | No | Server-side error tracking |
NEXT_PUBLIC_GLITCHTIP_DSN | No | Client-side error tracking (use public URL, not internal hostname) |
Runbooks
Create a staff admin account
sudo docker exec -it <api_container> sh -c \
Clear disk space on Atlas (staging)
sudo docker system prune -a
A daily cron runs at 00:01 to prune images older than 24 hours automatically.
Run database migrations manually
# SSH into the server
# Find the postgres container name
sudo docker ps | grep postgres
# Exec into API container
sudo docker exec -it <api_container> sh
# Run migrations (inside container)
node /app/node_modules/.pnpm/.../prisma/build/index.js \
migrate deploy \
--config /app/prisma/prisma.config.deploy.js
Rotate Viva webhook key
- Call
GET https://demo.vivapayments.com/api/messages/config/tokenwith Basic Auth (Merchant ID + API Key) - Update
VIVA_WEBHOOK_KEYenv var in Coolify - Restart API container
- Re-verify webhook URL in Viva dashboard
Force redeploy without code changes
Trigger a manual deploy from Coolify UI, or push an empty commit:
git commit --allow-empty -m "chore: force redeploy"
git push
Check if migrations are pending
sudo docker exec -it <api_container> sh -c \
"node /app/node_modules/.pnpm/.../prisma/build/index.js migrate status \
--config /app/prisma/prisma.config.deploy.js"
Known Issues & Technical Debt
| Issue | Severity | Status |
|---|---|---|
| CI runner on Hermes (prod server) | Medium | Accepted risk, move to dedicated runner post-launch |
| Claim endpoint accepts userId from body | Medium | Accepted risk for MVP, fix post-launch |
| No unit tests | Medium | Post-launch |
| Floating-point arithmetic for order totals | Medium | Migrate to decimal.js post-launch |
| Claim code entropy (10M combinations) | Medium | Increase to crypto.randomBytes post-launch |
| Attachment entityId has no FK constraint | Low | Documented design decision, cleanup job post-launch |
baseUrl deprecated in tsconfig | Low | One red squiggle in VSCode, app works fine |
| as any casts in order/build services | Low | Migrate to Prisma.OrderGetPayload types post-launch |
Design Decisions & Why
Why Better Auth instead of NextAuth? Better Auth runs server-side in NestJS (not Next.js), giving us a single auth source of truth accessible to all API endpoints. NextAuth is Next.js-coupled and would require session forwarding to NestJS on every request.
Why custom 2FA cookie signing in /api/2fa-verify/route.ts?
[email protected] has a bug where headers.set('set-cookie') overwrites previous values, so when verifyTotp sets two cookies (session token + 2FA cookie expiry), only the last one survives. We sign and set the session cookie manually in the Next.js route handler.
Why pnpm deploy --filter=api --prod --legacy instead of just copying node_modules?
pnpm deploy produces a clean production dependency tree without dev dependencies and resolves the pnpm virtual store correctly. --legacy is required because the monorepo uses a shared workspace lockfile.
Why is BETTER_AUTH_URL set to the frontend URL (not API URL)?
Better Auth uses baseURL to construct links in verification emails. If set to the API URL, email verification links point to the API instead of the frontend. The frontend proxies all /api/auth/* requests to NestJS, so Better Auth's base can safely be the frontend URL.
Why are migrations 1-8 marked as applied and only migration 9 runs? During development, a major schema refactor generated a new baseline migration (migration 9) that contains the complete schema. Migrations 1-8 are superseded. When deploying to a fresh database, we mark 1-8 as applied without running them, then apply only migration 9 against the empty schema.
Why Hetzner Object Storage instead of AWS S3? Cost and data sovereignty. Hetzner's S3-compatible API works with the standard AWS SDK — zero code changes required if we ever need to migrate. Data stays in EU data centers.
Why self-hosted Umami instead of Google Analytics? GDPR compliance. Umami in cookieless mode doesn't set cookies and doesn't process personal data, making it arguably exempt from consent requirements under GDPR recital 173. No consent banner needed for analytics.