The unified backend that replaces Supabase for sellers/ (Connexo Ventas) and
clients/ (Connexo profiles/businesses).
Implements docs/backend-migration-research.md. Section numbers below refer to
that document.
Stack — TypeScript (strict) on Node 24 · NestJS · PostgreSQL · Prisma · Zod · Socket.IO · BullMQ + Redis · S3-compatible storage · pnpm · Docker.
cp .env.example .env # then fill in JWT_ACCESS_SECRET / JWT_REFRESH_SECRET
docker compose up -d # Postgres + Redis + MinIO + migrations + the API
curl http://localhost:3000/api/healthup applies prisma migrate deploy in a one-shot migrate container and
starts the API only once it exits 0, so an empty volume comes up migrated. To
re-run migrations by hand later:
docker compose run --rm migrateNote the runtime image carries node and the Prisma binary, but no pnpm — inside
the container, call node_modules/.bin/prisma, not pnpm prisma.
Generate the two JWT secrets with openssl rand -hex 32. The app validates its
whole environment at boot and refuses to start on a bad value, rather than
failing later at send time (§5b.3).
Running the API on the host instead:
pnpm install
docker compose up -d postgres redis minio minio-init
pnpm prisma migrate dev
pnpm start:dev # OpenAPI at /api/docs outside productionpnpm test # unit tests for the commission / tier policies
pnpm test:smoke # end-to-end against a running API (see test/smoke.cjs)pnpm test:smoke needs DATABASE_URL in the environment and the API up. It
covers the paths that carry money or secrets: server-side pricing, both wallet
sides of a sale and its deletion, cross-context token rejection, and the
response allow-lists on public endpoints.
One deployable, organised by bounded context → feature module (§5b.1).
src/
├─ config/ Zod-validated environment; fails fast at boot
├─ shared/ Cross-cutting, no business logic
│ ├─ prisma/ The single PrismaService
│ ├─ auth/ JWT + bcrypt, guards, @CurrentUser() / @Roles()
│ ├─ realtime/ RealtimeGateway + RealtimePublisher port
│ ├─ storage/ StoragePort → S3 or local adapter
│ ├─ push/ PushPort → web-push adapter + BullMQ processor
│ ├─ mail/ MailPort → Resend adapter
│ ├─ alerts/ AlertPort → Slack / Discord / email fan-out
│ ├─ ai/ CatalogVisionPort (Gemini), CopywriterPort (Anthropic)
│ ├─ payments/ PaymentGatewayPort → PayPhone adapter
│ ├─ notifications/ Shared notification + push-subscription services
│ └─ errors/ Domain exceptions + one global exception filter
├─ sellers/ auth · profiles · sales · inventory · sedes · academy · dev
└─ clients/ auth · profiles · links · analytics · chat · admin ·
telemetry · ai · public · verticals
Inside a feature: *.controller.ts (HTTP only) → use-cases/ (one business
operation per class) → repositories/ (persistence) → domain/ (pure
policies, no I/O).
§4.2 lists ~25 near-identical CRUD resources across the six verticals. They are
declared as data in clients/verticals/resource-definitions.ts and served by
one generated controller each (owner-crud/). The ownership check therefore
exists once, instead of being re-implemented 25 times. Resources with real
rules — orders, reservations, stock — keep their own use cases.
| Supabase | Here |
|---|---|
| PostgREST table access | NestJS controllers → use cases → Prisma repositories |
| ~60 RPC functions | Use cases under clients/public/ and clients/admin/ |
| Supabase Auth | shared/auth (JWT access + rotating refresh, bcrypt) |
| RLS policies | JwtAuthGuard (deny by default), RolesGuard, owner-scoped queries |
Storage bucket assets |
StoragePort → S3/R2, presigned PUT for video |
| Realtime (23 subscriptions) | One authenticated socket per user, room-scoped |
| 8 Edge Functions | Routes and BullMQ processors (below) |
DB webhooks / pg_net triggers |
Domain events → queued jobs |
expire_trials run by hand |
@Cron nightly, plus POST /admin/jobs/expire-trials |
| Function | Now |
|---|---|
sendPush |
shared/push/push.processor.ts |
notifyError |
POST /api/telemetry/errors → alerts queue |
notifyAdminEvent |
clients/admin/audit.service.ts → alerts queue |
extractCatalog |
POST /api/clients/ai/extract-catalog |
generateCopySuggestions |
POST /api/clients/ai/copy-suggestions |
payphone-process and the missing payphone_charge |
POST /api/public/profiles/:ownerId/payments/payphone |
startChat (missing from the repo) |
POST /api/clients/me/chats |
Each item is §6 of the research doc.
| # | Finding | Fix |
|---|---|---|
| 1 | Sellers stored plaintext passwords, compared in the browser | bcrypt, verified server-side. password → password_hash, never selected outside the login flow |
| 2 | VITE_SUPER_ADMIN_PASSWORD shipped in the public JS bundle |
Gone. A super admin is a row with role = SUPER_ADMIN |
| 3 | Commission / wallet / tier computed in the browser and written directly | PricingPolicy + CommissionPolicy + TierPolicy, server-side. The request cannot carry amount, commission or sellerId |
| 4 | purgeAllData callable by any browser |
/sellers/dev/*, triple-gated: env flag, non-production, SUPER_ADMIN. The env schema refuses the flag in production outright |
| 5 | payphone_charge took the merchant's token from the browser |
Credentials read from barber_payment_secrets server-side; the request body has no token field |
| 6 | AI and push functions deployed --no-verify-jwt |
Authenticated and individually rate-limited |
| 7 | RLS gave anon SELECT on all profile columns | The public profile response is an explicit allow-list; paymentGateways and calendarWebhookUrl never leave the server |
| 8 | Non-atomic read-then-write on wallets and stock | prisma.$transaction with atomic increment / guarded decrement |
| 9 | ChatList subscribed to every chat in the database |
Chat rooms are membership-checked before a socket may join |
| 10 | env files not git-ignored |
.gitignore covers .env, .env.* and a bare env |
Two more, beyond the list:
deleteSalenever reverted the parent distributor's override (§3.3), so deleting a sale left the distributor permanently overpaid. The override is now recorded on the sale row and both wallets are reverted in one transaction.- Public order totals came from the browser.
place_gastro_orderand friends acceptedp_total_price. Totals are recomputed from the owner's own catalogue; the submitted value is ignored.
Every request that reaches the process produces one log line. In production that line is JSON on stdout, which is what Railway, Render, Fly, CloudWatch and Loki ingest and parse into queryable fields.
{"time":"2026-09-16T02:35:49.874Z","level":"info","msg":"http_request",
"env":"production","requestId":"574b700e-…","method":"POST",
"url":"/api/sellers/sales","statusCode":201,"durationMs":25.82,
"ip":"200.31.х.х","userAgent":"…","userId":"269feb15-…",
"authContext":"sellers","role":"SELLER","bytes":469}Locally it is a readable line instead:
WARN [HTTP] POST /api/sellers/auth/login 401 203.05ms [anon] ACCOUNT_NOT_FOUND (6b500d76)
It is middleware, not an interceptor (shared/observability/). A Nest
interceptor runs after the guards, so everything rejected by JwtAuthGuard
(401), RolesGuard (403) or ThrottlerGuard (429) would never be logged —
which is precisely the traffic worth watching on a deployed service. Middleware
also catches 404s for routes that do not exist, and requests the client aborts
mid-flight ("aborted": true).
Levels — error for 5xx, warn for 4xx or anything slower than
HTTP_LOG_SLOW_MS, info otherwise. So level:"error" alone is a usable
alerting filter.
Correlation — each request gets an x-request-id (an inbound one from a
load balancer is reused when it looks sane). It comes back as a response header
and inside every error envelope, so a user can quote the id from a failure and
it will match the log line. RequestContextStore.requestId() reaches it from
anywhere without threading it through signatures.
errorCode — the global exception filter stamps the domain error code onto
the entry, so failures are greppable by cause (INSUFFICIENT_STOCK,
DATABASE_ERROR) and not only by status.
Redaction is a security control here, not tidiness — these requests carry passwords, PayPhone merchant credentials, Web Push keys and clinical data.
AuthorizationandCookienever appear: header capture is an allow-list (HTTP_LOG_HEADERS=true), so a header a proxy adds tomorrow does not silently start being logged.- Query values are redacted by key.
?identifier=on a member lookup is an email or phone number and?memberCode=unlocks a clinical record, so the raw URL is never logged — keys are kept, sensitive values become[redacted]. - Bodies are off by default (
HTTP_LOG_BODY). Even when enabled,redact()blanks secrets and PII, truncates long strings and handles circular refs.
src/shared/observability/redact.spec.ts pins this contract (14 tests): a
regression there is a data leak, not a cosmetic bug.
| Variable | Default | Purpose |
|---|---|---|
HTTP_LOG_ENABLED |
true |
Master switch |
HTTP_LOG_FORMAT |
auto |
auto = JSON in production, pretty elsewhere |
HTTP_LOG_SLOW_MS |
1000 |
Above this, a 2xx is logged at warn |
HTTP_LOG_IGNORE_PATHS |
/api/health |
Health probes would drown the log |
HTTP_LOG_HEADERS |
false |
Adds the allow-listed headers |
HTTP_LOG_BODY |
false |
Adds the redacted body. Debugging only |
TRUST_PROXY |
0 |
Proxy hops in front of the app |
Set
TRUST_PROXY=1when deploying behind a platform router. Without it Express reads the proxy's address, so every log line shows the same IP — and, more seriously,ThrottlerGuardbuckets all traffic as one client, meaning one busy user can rate-limit everybody.
Worth stating plainly rather than discovering later.
- Repositories are not universal. §5b.2 (Dependency Inversion) says use
cases should depend on repository ports and only adapters should import
Prisma.
sellers/salesandsellers/profilesdo that. Mostclients/use cases and the plain-CRUD controllers injectPrismaServicedirectly, because a port per vertical would have roughly tripled the surface for no behavioural gain. ESLint reports these as warnings (39 of them) so the debt stays visible;eslint.config.mjshas the rule. Worth paying down for anything that grows real rules. - The AI copy adapter does not set
output_config.effort.lowwould suit four short marketing lines, but@anthropic-ai/sdk0.68 does not type the parameter. Add it when the SDK exposes it, rather than casting past the types. - e2e coverage is a smoke suite, not per-endpoint tests. §5b.6 asks for
Supertest coverage of every endpoint in §3.4/§4. What exists is
test/smoke.cjs(35 assertions over the money and secret-bearing paths) plus 38 unit tests on the commission/tier policies. The per-endpoint suite and thesocket.io-clientgateway tests are still to write. clients.profilesis modelled as ~70 explicit columns. That mirrors what the frontends read today. If the Phase-0 dump shows a different shape, thedb pullwins.
Phase 0 of §8 is still blocking. The live SQL is in neither repo, so the Prisma schema here was reconstructed from the frontend call sites and the TypeScript types. Before migrating real data:
pg_dump --schema-onlyboth projects (wxkqmotksjcjwhytodvx,aisjtkezgumawgjmwckb).- Restore locally and run
pnpm prisma:pull. The dump wins overschema.prisma;@map/@@mapare already in place so the names in code survive the pull. supabase functions download payphone_charge startChat— both are deployed but absent from the repo, so their exact behaviour is still inferred.- Read the RLS policies. They are the current authorization spec, and the guards here were written from the frontend's behaviour, not from them.
- Export
auth.usersintoclients.users. The bcrypt hashes move as-is, so no client needs to reset their password (§4.1). - Hash the sellers' plaintext passwords into
password_hashand setmust_change_password = truefor anyone still onconnexo123.
Also open, from §9: whether the two apps should share one identity, and whether the sellers offline mode survives. This backend assumes no to both — sellers' localStorage business fallbacks should be dropped, keeping only session and UI caches (§7).
node:24-bookworm-slim, not Alpine: Prisma ships a first-classdebian-openssl-3.0.xengine, so there is no musl guesswork.- Runs as the stock
nodeuser (uid 1000), read-only root filesystem, all capabilities dropped. dumb-initis PID 1, soSIGTERMreaches Node andenableShutdownHooks()closes Prisma, Redis and BullMQ cleanly.pnpm-workspace.yamlmust be present in the install stages: from pnpm 10 it carriesonlyBuiltDependencies, and without it bcrypt's native binding is silently skipped — the image builds fine and dies at startup.prismais a runtime dependency, not a dev one. The client is generated against the production tree, and the image can runprisma migrate deploy.
main.ts installs the Socket.IO Redis adapter, so more than one instance works
without a user on instance A missing an event emitted by instance B. BullMQ
workers run in-process; move them to their own deployment by booting the same
image with the HTTP server disabled if queue volume warrants it.