Skip to main content

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

ServerHostnameLocationRoleInternal IP
Hermeshermeshel1 (Helsinki)Production10.1.0.1
Atlasatlasnbg1 (Nuremberg)Staging + CI runner10.1.0.2
IrisirisObservability + Docs10.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.json at 600 permissions or it won't start
  • Cloudflare Universal SSL doesn't cover third-level subdomains — use Full (Strict) mode
  • tls: {} instead of certResolver behind Cloudflare Access
  • Non-root Coolify user requires docker compose up -d workaround for manual operations

Storage

Hetzner Object Storage (S3-compatible) — "Mneme" bucket:

  • Production: mneme bucket, hel1 region
  • Staging: mneme-staging bucket, nbg1 region
  • All buckets are private — files served via presigned URLs (1hr expiry)
  • Implementation: HetznerStorageService implementing IStorageService interface
  • LocalStorageService was 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 exec not npx for running binaries
  • Inline code comments explain "why", not "what"
  • Split auth-client.ts/auth-server.ts for browser vs server contexts
  • All imports use @pcmr/types for 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 to apps/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:

  • baseURL must be the frontend URL (not API URL) — Better Auth builds email verification links from this
  • BETTER_AUTH_URL env var = frontend URL on both web and API containers
  • Cookie domain: .pcmr.gr (leading dot = all subdomains) — empty string for local dev (browsers reject Domain=localhost)
  • Session cookies: __Secure-better-auth.session_token on HTTPS, better-auth.session_token on 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.gr domain

First-login flow for staff:

  1. Change temporary password
  2. Verify email
  3. Set up TOTP 2FA
  4. 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 — the 2fa-verify route.ts manually signs this cookie to work around a better-call bug where Set-Cookie headers get overwritten

Database Schema

29 Prisma models, 16 enums. Key models:

Auth (managed by Better Auth):

  • User — extended with firstName, lastName, mustChangePassword, role
  • Session, 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 history
  • OperatingSystem, Service, Warranty, Shipping, Peripheral, Software

Builds:

  • Build — QA form fields, 10 named photo slots, booklet tracking
  • BuildTimelineStep, BuildEventLog, TestRun

Other:

  • Attachment — polymorphic, supports order/build/timeline_step/event_log/test_run entities
  • ComponentPriceHistory — supplier pricing records
  • WaitlistEntry
  • CookieConsent — 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:

  1. SessionGuard — validates Better Auth session cookie, attaches user to request
  2. FirstLoginGuard — blocks access if mustChangePassword === true
  3. 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.gr and staff-staging.pcmr.gr)
  • Role-aware redirects (staff → /staff/portal, users → /portal)
  • Request ID generation (x-request-id header 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:

  1. Keep the NestJS API URL server-side (not exposed to browser)
  2. Allow server-side session cookie forwarding
  3. 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_progressbuilding (renamed to assembling in fulfillment)
  • testingtesting
  • completedready

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

EventRecipientService
Quote createdCustomerOrdersService
awaiting_payment transitionCustomerOrdersService
Payment confirmedCustomerPaymentsService
Order claimedCustomerClaimService
Quote accepted by customerStaff notify emailPortalOrdersService
fulfillmentStatus → readyCustomerOrdersService
fulfillmentStatus → shippedCustomerOrdersService
fulfillmentStatus → completedCustomerOrdersService
orderStatus → cancelledCustomerOrdersService
paymentStatus → refundedCustomerOrdersService
Build startedCustomerBuildsService
Warranty expiring (30 days)CustomerWarrantyNotificationsService (cron)
Password resetUserBetter Auth
Email verificationUserBetter Auth
Waitlist joinedRequester + notify addressWaitlistService

Payment Flow (Viva Smart Checkout)

Flow

  1. Customer clicks "Pay" on portal order detail
  2. Browser POSTs to /api/payments/create-order (Next.js proxy)
  3. Next.js forwards to NestJS POST /payments/create-order
  4. NestJS authenticates via OAuth2 to demo-accounts.vivapayments.com (token cached 25s)
  5. NestJS POSTs to demo-api.vivapayments.com/checkout/v2/orders — gets back orderCode
  6. orderCode saved to Order.vivaOrderCode in DB
  7. NestJS returns { orderCode } to browser
  8. Browser redirects to https://demo.vivapayments.com/web/checkout?ref={orderCode}
  9. Customer pays on Viva's hosted page
  10. Viva redirects to https://staging.pcmr.gr/payment/success?t={transactionId}&s={orderCode}
  11. Success page POSTs to /api/payments/verify
  12. NestJS calls GET /checkout/v2/transactions/{transactionId} to verify
  13. Verifies: statusId === 'F', amount matches (in euros not cents!), merchantTrns matches orderId (when available)
  14. Updates Order.paymentStatus = paid, Order.invoiceReference = transactionId
  15. 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

EnvironmentAuth URLAPI URLCheckout URL
Demohttps://demo-accounts.vivapayments.comhttps://demo-api.vivapayments.comhttps://demo.vivapayments.com
Productionhttps://accounts.vivapayments.comhttps://api.vivapayments.comhttps://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

  1. Push to develop branch → triggers staging deploy on Atlas
  2. Push to main branch → triggers production deploy on Hermes
  3. 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:

  1. Drop and recreate schema
  2. Mark migrations 1-8 as applied (prisma migrate resolve --applied)
  3. Run prisma migrate deploy — only migration 9 (full baseline) executes
  4. Migration 9 (20260419164134) is a full schema baseline — migrations 1-8 are superseded

Docker

Multi-stage Dockerfile for API:

  • builder stage: installs deps, runs prisma generate, builds NestJS
  • runner stage: 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 config
  • apps/api/start.sh — must have LF line endings (not CRLF) or Alpine sh fails

Booklet Generation

Staff can generate two PDF documents per build:

  1. Quick Start Guide — customer-facing, covers system info, components, OS, warranty
  2. 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)

VariableRequiredNotes
DATABASE_URLYesPostgreSQL connection string
BETTER_AUTH_SECRETYesMust match web app
BETTER_AUTH_URLYesFrontend public URL (not API URL)
FRONTEND_URLYesUsed for email links and CORS
COOKIE_DOMAINYesEmpty for local dev, .pcmr.gr for prod
GOOGLE_CLIENT_IDYesOAuth
GOOGLE_CLIENT_SECRETYesOAuth
VIVA_BASE_URLYeshttps://demo-api.vivapayments.com (demo)
VIVA_AUTH_URLYeshttps://demo-accounts.vivapayments.com (demo)
VIVA_CLIENT_IDYesSmart Checkout credentials
VIVA_CLIENT_SECRETYesSmart Checkout credentials
VIVA_SOURCE_CODEYes4-digit Viva payment source code
VIVA_WEBHOOK_KEYYesHMAC signing key for webhook verification
STORAGE_ENDPOINTYeshttps://nbg1.your-objectstorage.com (staging)
STORAGE_BUCKETYesmneme-staging (staging), mneme (prod)
STORAGE_ACCESS_KEYYesHetzner Object Storage
STORAGE_SECRET_KEYYesHetzner Object Storage
STORAGE_REGIONYesnbg1 (staging), hel1 (prod)
STORAGE_PRESIGN_EXPIRYNoSeconds, default 3600
SMTP_HOSTYessmtppro.zoho.eu
SMTP_PORTYes587
SMTP_USERYesZoho email address
SMTP_PASSYesZoho password
SMTP_SECURENofalse for port 587
EMAIL_FROMYesDisplay name + email
WAITLIST_NOTIFY_EMAILNoComma-separated list
LOKI_URLNohttp://10.1.0.4:3100
LOKI_HOSTNoSame as LOKI_URL
GLITCHTIP_DSNNoError tracking
PORTNoDefault 3001
NODE_ENVYesproduction in prod

Web (apps/web)

VariableRequiredNotes
NEXT_PUBLIC_API_URLYesBrowser-side API URL
API_URLYesServer-side internal API URL
BETTER_AUTH_SECRETYesMust match API
BETTER_AUTH_URLYesAPI URL for server-side session
NEXT_PUBLIC_VIVA_CHECKOUT_URLNoDefaults to demo Viva URL
GLITCHTIP_DSNNoServer-side error tracking
NEXT_PUBLIC_GLITCHTIP_DSNNoClient-side error tracking (use public URL, not internal hostname)

Runbooks

Create a staff admin account

sudo docker exec -it <api_container> sh -c \
"node dist/scripts/create-admin.js [email protected] temppass123 FirstName LastName superadmin"

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

  1. Call GET https://demo.vivapayments.com/api/messages/config/token with Basic Auth (Merchant ID + API Key)
  2. Update VIVA_WEBHOOK_KEY env var in Coolify
  3. Restart API container
  4. 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

IssueSeverityStatus
CI runner on Hermes (prod server)MediumAccepted risk, move to dedicated runner post-launch
Claim endpoint accepts userId from bodyMediumAccepted risk for MVP, fix post-launch
No unit testsMediumPost-launch
Floating-point arithmetic for order totalsMediumMigrate to decimal.js post-launch
Claim code entropy (10M combinations)MediumIncrease to crypto.randomBytes post-launch
Attachment entityId has no FK constraintLowDocumented design decision, cleanup job post-launch
baseUrl deprecated in tsconfigLowOne red squiggle in VSCode, app works fine
as any casts in order/build servicesLowMigrate 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.