Architecture
System context, containers, repositories, request paths, deployment topology and the architecture decision records (ADR-001 to ADR-016) of the HOCO B2B ecosystem.
The HOCO ecosystem is one central API and one database serving four interfaces — the cahier's founding principle: « Les trois solutions utilisent une API centrale sécurisée et une base de données unique » (cahier §2). This page shows the system from the outside in (C4 levels 1 and 2), the two repositories, how a request travels, where everything runs, and the decisions behind it. The inside of the server is on Architecture and data and the service catalogue.
System context
Who uses the platform and which external systems it depends on (C4 level 1). External services are « à confirmer » in the cahier (§11); their costs and quotas must be validated by HOCO (INT-07).
| External system | Used for | Release | Called by |
|---|---|---|---|
| Firebase Cloud Messaging | Push notifications with deep links to both mobile apps (NOT-02) | V1 | worker |
| SMS gateway (provider to choose) | OTP codes, invitations, critical messages (INT-03) | V1 | worker |
| E-mail (SMTP provider to choose; Mailpit locally) | Staff invitations and resets, documents, export links (INT-04) | V1 | worker |
| Maps provider (ADR-015) | Delegate map and navigation hand-off, back-office maps, optional geocoding (INT-01) | V1 | apps (SDKs), api (geocoding) |
| S3-compatible storage (ADR-009) | Photos, documents, generated PDFs, exports, product images (INT-05) | V1 | api (signing), worker, apps (direct upload) |
V1: invitation share links and the regional support button (wa.me link to the number of the user's support region, SUP-05) opened by the apps; V2: Business API messages (NOT-08) | V1 / V2 | apps / worker | |
| Carrier APIs, ERP/accounting, stock system | Shipment and tracking, accounting exchange, stock synchronisation (DLV-05, INT-06, STK-09) | V2 | worker |
Containers
What runs where (C4 level 2). Every arrow into the API carries the caller's session; nothing but the API touches the database.
| Container | Technology | Responsibility | Talks to |
|---|---|---|---|
api | Node 22, Express 5, oRPC, Zod 4, Prisma 7, Better Auth | REST under /v1, OpenAPI document, the two SSE streams (/v1/admin/events for the back-office, /v1/events for each user's own live events), authentication (including anonymous guest browsing of the catalog), permissions, every business rule; writes domain rows and OutboxEvent in one transaction; runs migrations at start | PostgreSQL, Redis, storage (signing) |
worker | same image as api | BullMQ processors, job schedulers (cron in Africa/Algiers), outbox dispatcher, PDF and image processing, provider calls | PostgreSQL, Redis, storage, FCM, SMS, e-mail |
dashboard | Next.js 16 App Router, React 19, Tailwind 4, @hoco/ui, TanStack Query/Table/Form, next-intl | Back-office UI for the six staff roles; live updates over SSE | api only |
web | same stack as dashboard | Responsive reseller web (V1, deployed with the rest before the pilot): guest browsing without prices, ordering, live order status over SSE; the invitation landing /i/{code} | api only |
| Reseller app | Flutter (apps/reseller) | Guest browsing without prices; online-first ordering, tracking (live over SSE while open), returns and after-sales claims, finance, regional WhatsApp support; push | api, storage, FCM |
| Delegate app | Flutter (apps/delegate), drift | Offline-first tours, visits, prospects, invitations, assisted orders; location sampling during tours | api, storage, FCM, maps SDK |
| PostgreSQL 17 | Dokploy database service | The single system of record | — |
| Redis 7 | Dokploy database service, AOF on, noeviction | BullMQ queues, rate-limit counters, session cache, SSE stream buffer | — |
docs | Fumadocs (Next.js), deployed later on the VPS | This site | — |
The server's internal structure (24 modules, middleware chain, data ownership) is level 3; see Architecture and data and the service catalogue.
Repositories
Two repositories (ADR-001). Their only contract is the OpenAPI 3.1 document exported from packages/contracts through the server.
AnisBetehi/hoco — pnpm workspaces + Turborepo, Biome, lefthook, commitlint, Vitest. Owner: Anis.
| Path | Holds | Lands with |
|---|---|---|
apps/server/src/index.ts | api process entry: validates the environment (@hoco/env/server), serves the app, graceful stop on SIGTERM | exists (PRJ-01) |
apps/server/src/app.ts | The Express app: request id, access log, CORS, /health, /openapi.json, /reference, the /v1 handler, error envelope for unmatched and unhandled errors | exists; middleware chain completed in SRV-04 |
apps/server/src/router.ts | The contract implemented: one entry per key of contract (a missing key fails the type-check) | exists; grows with every module |
apps/server/src/worker.ts | worker process: BullMQ workers, job schedulers, outbox dispatcher | to add in SRV-08 |
apps/server/src/http/ | context.ts (the oRPC implementer os and request context), v1.ts (OpenAPI handler and interceptors), openapi.ts (document generation), errors.ts (envelope encoder), system.ts; then rate limit, session, app-version, idempotency and SSE | SRV-04, SRV-26 |
apps/server/src/infra/ | logger.ts (pino) today; Prisma client and withTx, Redis, queue registry, outbox emit(), event registry next | SRV-07, SRV-08 |
apps/server/src/modules/<name>/ | One folder per module (router, service, repository, events, jobs, policies — conventions) | per module, see the service catalogue |
packages/contracts | oRPC contract: contract.ts, one file per module in routers/, shared Zod schemas in schemas/ (error envelope, money, pagination), declared errors, enums | SRV-03 |
packages/db | Multi-file Prisma schema, migrations, generated client (prisma/generated) | SRV-02 |
packages/api-client | createApiClient(baseUrl) — typed oRPC client over the OpenAPI link, cookies included, error envelope decoded into ORPCError — and createApiQueryUtils() for TanStack Query | exists; used from DSH-01 |
packages/env | Validated environment per app (./server, ./dashboard, ./web, ./load) | OPS-01 |
packages/shared | Pure helpers: formatDzd, normalizeDzPhone, business-day and rounding helpers | PRJ-01 onwards |
packages/ui | Design system for the Next apps (Base UI, CVA, Tailwind 4 tokens) | DSH-01 |
docker/ | One image recipe for server, dashboard, web (APP build argument) and its start script | OPS-02 |
Request paths
Every client talks to https://api.hoco.dz/v1 (staging api-staging.hoco.dz; the domain is a working assumption until HOCO confirms it). Conventions are on the API reference.
| Hop | Mobile apps (reseller, delegate) | Web apps (back-office, reseller web) |
|---|---|---|
| Entry | api.hoco.dz → VPS → Traefik (TLS, Let's Encrypt) → api | Pages from admin.hoco.dz / app.hoco.dz (Next.js); data calls from the browser straight to api.hoco.dz |
| Credentials | Authorization: Bearer <sessionToken> from secure storage; none for a guest browsing the catalog | httpOnly session cookie set by the API: Domain=.hoco.dz; Secure; HttpOnly; SameSite=Lax, sent with credentials: "include"; none for a guest on the reseller web |
| Client headers | Accept-Language, X-App-Version, X-App-Platform, X-Device-Id, Idempotency-Key on mutations | Accept-Language, Idempotency-Key on mutations; the browser adds Origin |
| Cross-origin | not applicable (native HTTP) | CORS with credentials; allowed origins = CORS_ORIGINS; same-site subdomains so SameSite=Lax cookies flow |
| Version gate | below the minimum version for the app and platform: 426 UPGRADE_REQUIRED with details.storeUrl | none — the Next apps are deployed together with the API |
| Server rendering | — | Server components and route handlers call the API over the internal Docker network (API_INTERNAL_URL, planned), forwarding the user's cookie; they never read the database |
| Session renewal | sliding 30 days (resellers, delegates); the token stays the same | sliding (7 days for staff — decision to confirm); the API re-sets the cookie |
| Real-time | Reseller app: SSE on GET /v1/events while the app is in the foreground (bearer header, Last-Event-ID), FCM push otherwise. Delegate app: FCM push (including silent sync hints) and GET /sync/delta pulls | Back-office: SSE on GET /v1/admin/events. Reseller web: SSE on GET /v1/events while a page is open (ADR-008) |
There are two streams with the same mechanics. The back-office stream carries operational events filtered by permission; the own stream carries only the caller's events — the status changes of their orders, assisted orders awaiting their confirmation, replies to their tickets, their returns, new inbox notifications (ORD-15, SYN-07). Push notifications still go out for the same changes, so a closed app is informed too.
The back-office live stream, from the domain change to the operator's screen (the own stream works the same way, filtered by recipient instead of permission):
The back-office stream is permission-filtered per connection and its events are invalidation hints: the dashboard refetches the affected queries, except delegate.position, which carries the position itself. The own stream carries the new status and timestamp so an order screen can update without a round-trip. The event types of both streams and their mapping to domain events are listed in the SSE projection; the endpoints are admin.events.stream and events.stream.
Deployment topology
Four environments (cahier §13.1 asks for development, pre-production / recette client and production; staging is added for continuous integration). Ports in development: server 3100, dashboard 3101, web 3102, docs 3103.
| Environment | Where | Deployed from | Data | Used by |
|---|---|---|---|---|
| Local | developer machine, docker compose up -d: Postgres :5442, Redis :6390, MinIO :9100 (console :9101), Mailpit SMTP :1125 / UI :8125 | working tree | demo seed (OPS-01) | the team |
| Staging | VPS 2, Dokploy project staging (api-staging.hoco.dz) | every push to dev (automatic) | seed + test data, reset at will | the team, Imad's builds against the real API (MOB-12) |
| Pre-production (recette) | VPS 2, Dokploy project preprod | a release candidate tag | anonymised copy or acceptance dataset | HOCO's acceptance (QA-02) |
| Production | VPS 1, Dokploy project production (api.hoco.dz, admin.hoco.dz, app.hoco.dz) | release tags on master (manual trigger) | real data | HOCO, delegates, resellers |
Assumption — the dashboard is served at admin.hoco.dz and the reseller web at app.hoco.dz (the invitation link https://app.hoco.dz/i/AB12CD34 is canonical); staging and pre-production hostnames follow <app>-staging / <app>-preprod. Final domains are decision D-12.
| Service (per environment) | Image / source | Replicas (V1) | Public route | Notes |
|---|---|---|---|---|
api | docker/app.Dockerfile with APP=server; runs prisma migrate deploy then the API | 1 | api.hoco.dz | The only process that migrates. Health check /health; readiness /ready (database, Redis, migrations). |
worker | same image as api, runtime APP=worker (branch to add to docker/start.sh with SRV-08) | 1 | none | Internal port for /health and /metrics only. Never migrates. |
dashboard | APP=dashboard (NEXT_PUBLIC_API_URL as build argument) | 1 | admin.hoco.dz | Pure client of the API. |
web | APP=web | 1 | app.hoco.dz | V1, live at go-live with the other apps. Also serves the invitation landing /i/{code} and /.well-known/assetlinks.json (Android App Links), which must answer from the first shared invitation. |
| PostgreSQL 17 | Dokploy database service | 1 | none | Scheduled pg_dump to off-site S3, restore test in SRV-28. |
| Redis 7 | Dokploy database service | 1 | none | appendonly yes, maxmemory-policy noeviction (BullMQ requirement); every cache key has a TTL. |
| Object storage | MinIO on the VPS (ADR-009) | 1 | public bucket via its own host name | Private bucket only through signed URLs; nightly backup off the VPS. |
| Docs | apps/docs built with next build, deployed later on the VPS | 1 | docs subdomain | Restricted to the team and HOCO (login in front of the site). Until then the team runs it locally (pnpm --filter @hoco/docs dev). |
Three-month frame
V1 — server, back-office, reseller web and both mobile apps — is designed, built and deployed within three months; production go-live comes before the two-week pilot, and generalisation and V2 follow (see the timeline). The reseller web is therefore deployed with everything else, which also covers the invitation links (https://app.hoco.dz/i/{code}) delegates share from the pilot onwards (IVT-04). Every environment above must exist before QA-01 starts.
Backups, restore drills, RTO/RPO, monitoring and the release procedure are on Deployment and operations.
Architecture decision records
Each record states the context, the decision, the alternatives that were weighed and the consequences. A record is never edited to say something else: a later decision supersedes it with a new record that links back.
| ADR | Decision | Status |
|---|---|---|
| ADR-001 | Two repositories: TypeScript monorepo and Flutter monorepo | Accepted |
| ADR-002 | REST + OpenAPI 3.1, contract-first with oRPC (not tRPC) | Accepted |
| ADR-003 | One central API; the Next.js apps are pure clients | Accepted |
| ADR-004 | Modular monolith with a separate worker process from one image | Accepted |
| ADR-005 | Better Auth: phone number + bearer plugins, cookies for the web | Accepted (staff session length to confirm) |
| ADR-006 | PostgreSQL 17 + Prisma 7, UUIDv7, client-generated ids for offline records | Accepted |
| ADR-007 | Transactional outbox + BullMQ | Accepted |
| ADR-008 | Server-Sent Events for real-time: a back-office stream and each user's own stream | Accepted |
| ADR-009 | S3-compatible storage with pre-signed uploads | Accepted (provider pending) |
| ADR-010 | Offline-first delegate app | Accepted |
| ADR-011 | Money in integer centimes; UTC timestamps; business days in Africa/Algiers | Accepted |
| ADR-012 | Docs-as-code in the monorepo, hosted on the VPS | Accepted |
| ADR-013 | Dokploy on a VPS | Accepted (VPS provider and location pending) |
| ADR-014 | French first, i18n and RTL ready from day one | Accepted |
| ADR-015 | Maps provider: Google Maps proposed | Proposed, pending HOCO's decision |
| ADR-016 | GPS tolerance and location sampling only during tours | Accepted as defaults, values pending HOCO's validation |
ADR-001 — Two repositories: a TypeScript monorepo and a Flutter monorepo
Context. Five deliverables in two stacks. Anis builds the server, back-office, reseller web, docs and DevOps in TypeScript; Imad builds the reseller and delegate apps in Flutter. The two Flutter apps share a lot (design system, generated API client, catalog and cart widgets, offline engine), as do the TypeScript apps (contracts, UI kit, env, helpers). Tooling differs completely: pnpm + Turborepo + Biome versus pub workspaces + Melos + flutter analyze, and Android signing in CI. The only hard coupling between the stacks is the API contract.
Decision.
AnisBetehi/hocoholdsapps/server,apps/dashboard,apps/web,apps/docsandpackages/config,env,db,contracts,api-client,ui,shared.hoco-mobile(Imad's GitHub account) holdsapps/reseller,apps/delegateand thehoco_*packages.- The interface is the OpenAPI 3.1 document produced by
pnpm openapi:export, tagged with each contract version;hoco_apiis regenerated from a pinned version, never from a moving target. - Contract changes are reviewed by Imad as the first consumer (the
CODEOWNERSrule for/packages/contracts/is already prepared).
Alternatives considered.
- One polyglot monorepo — contract and Dart client could change in one PR, but two toolchains in one root, slower CI on every change, and blurred ownership. Turborepo does not orchestrate Dart tasks.
- One repository per app — the two Flutter apps would duplicate
hoco_ui,hoco_apiand the offline engine, or publish them as versioned packages: more ceremony for a team of two developers.
Consequences.
- Each developer owns a repository, its CI and its conventions; branch model and commit rules are the same on both sides (workflow).
- A contract change is two PRs (contract first, then clients), sequenced by the API-contract workflow and its changelog; the mock server lets mobile work start before the server implementation.
- Continuity risk: the mobile repository sits on a personal account. Before go-live HOCO gets admin access, and the repository, the upload keystore and the Play Console ownership move to an organisation account controlled by HOCO or the team (D-25).
Status. Accepted.
ADR-002 — REST + OpenAPI 3.1, contract-first with oRPC instead of tRPC
Context. Four clients, two of them in Dart. The TypeScript apps want end-to-end types; Flutter needs plain REST and a generated client; the team wants a mock server the day the contract exists (M04 — API contract v1 published) and one schema per operation for validation, types and documentation. Zod 4 is already the validation library.
Decision.
- Every operation is declared in
packages/contractswith oRPC's contract builder:.route({ method, path })relative to/v1, Zod input and output, declared errors, and metadata (permission key, scope, idempotency requirement, release). apps/serverimplements the contract and serves it with oRPC's OpenAPI handler mounted in Express under/v1;/openapi.jsonis generated from the contract and/reference(Scalar) is enabled outside production.@hoco/api-clientuses oRPC's OpenAPI link, so the Next apps send exactly the REST requests the Flutter apps send, plus TanStack Query helpers.- Flutter generates
hoco_apifrom the exported document; Prism serves it as a mock.
Alternatives considered.
- tRPC — excellent for TypeScript-only clients, but its procedure protocol is not REST, OpenAPI is an add-on, and Dart cannot consume it. Rejected.
- Hono +
@hono/zod-openapi— good OpenAPI output, but routes and implementation live together; no separate contract package to generate the TypeScript client from before the server exists. - ts-rest — the same contract-first idea; smaller ecosystem around OpenAPI 3.1 and TanStack Query.
- NestJS + Swagger decorators — heavier runtime, decorator metadata as the source of truth, a second validation layer.
- GraphQL — flexible reads, but HTTP caching, offline replay and per-field authorisation get harder for a CRUD-heavy domain.
Consequences.
- One schema drives validation, TypeScript types, the OpenAPI document, the Dart client and these docs.
- Discipline: nothing is implemented before it is in the contract; the contract package must not import server code.
- The OpenAPI output must express Zod faithfully (integers, ISO date strings, discriminated unions); the mobile CI regenerates
hoco_apion each contract tag to catch generator surprises early. - Breaking changes follow the
/v1policy of the API reference; diffing the exported document between versions in CI (e.g. with oasdiff) is recommended.
Status. Accepted.
ADR-003 — One central API; the Next.js apps are pure clients
Context. The cahier requires one secured central API and one database (§2). Price resolution, stock, credit, scopes and lifecycles must behave identically in the mobile apps, the reseller web and the back-office, and the reseller web may not have web-only rules (RWA-02). Next.js makes it tempting to read the database from Server Actions — the team's previous project did exactly that.
Decision.
- All business logic and all data access live in
@hoco/server. @hoco/dashboardand@hoco/webrender UI and call the API: from the browser (CORS + cookie) or from server components and route handlers (server-to-server, forwarding the user's cookie).- They never depend on
@hoco/db, never hold credentials for the database, storage or providers, and implement no rule beyond presentation: formatting, and hiding actions the user lacks permissions for (fromme.get), which the API re-checks anyway.
Alternatives considered.
- Next.js full-stack (Server Actions + Prisma) for the web apps plus a separate API for mobile — every rule implemented twice; drift guaranteed. Rejected.
- A backend-for-frontend per client — an extra hop and duplication with no need at this scale. Rejected for V1.
Consequences.
- One place to secure and test: the permission sweep of SRV-28 covers every client at once, and parity between mobile and web is structural.
- Every screen waits for its contract operations; the mock server and the contract-first workflow absorb that.
- Server rendering depends on API latency, kept low by calling it over the internal Docker network.
- Enforcement:
@hoco/dbis not a dependency of the Next apps, and a lint rule forbids importing it outsideapps/serverandpackages/db(dependency rules).
Status. Accepted.
ADR-004 — Modular monolith with a separate worker process from one image
Context. One backend developer, 24 cohesive domains, invariants that span modules (checkout touches pricing, promotions, stock and credit), CPU-heavy or slow work (PDF rendering, exports, imports, image processing, SMS), scheduled jobs and long-lived SSE connections.
Decision.
@hoco/serveris a modular monolith of 24 modules underapps/server/src/modules/<name>with enforced boundaries (service catalogue).- It runs as two processes built from the same image (
docker/app.Dockerfile,APP=server):api— HTTP: REST, OpenAPI, SSE; applies migrations at start (docker/start.sh);worker— BullMQ processors, job schedulers, outbox dispatcher; no public HTTP.
- The role is chosen at runtime:
start.shgets anAPP=workerbranch that startssrc/worker.tswithout migrating.
Alternatives considered.
- Microservices — network calls and distributed transactions for invariants that are one SQL transaction today; operational load far beyond a team of one backend developer.
- Serverless functions — SSE, BullMQ workers and Prisma connection management fit poorly.
- A single process for HTTP and jobs — a 50 000-row export or a PDF burst would degrade API latency, and every restart would couple both.
- A separate worker codebase — duplicates module code or forces a shared library split.
Consequences.
- In-process service calls; one database transaction where an invariant requires it.
- The API and the worker restart and scale independently. V1 runs one replica of each; BullMQ, the advisory-lock-elected outbox dispatcher and idempotent schedulers allow more workers later (jobs and scheduler).
- Module boundaries rely on conventions plus lint rules; violations are review blockers.
- Job payloads are versioned (
name+v) because an old worker can briefly run beside a new api during a deploy.
Status. Accepted.
ADR-005 — Better Auth with phone number and bearer plugins; cookies for the web
Context. Resellers and delegates sign in with phone + password (cahier §6), sign-up and password reset use SMS OTP (§11), staff sign in with e-mail or phone. The cahier requires expiry, revocation and attempt limiting (§10). Mobile needs bearer tokens; the two web apps need an httpOnly cookie usable from admin. and app. subdomains. Suspending an account must cut access at once (SEC-06).
Decision.
- Better Auth runs inside
@hoco/server(identity module) with the Prisma adapter onUser,Session,Account,Verification:- phone-number plugin: OTP send/verify, phone + password sign-in;
- bearer plugin: mobile
Authorization: Bearer; - e-mail + password for staff.
- Sessions are opaque and database-backed, so they can be revoked; Redis is the secondary storage for session lookups and rate-limit counters.
- Cookies:
Domain=.hoco.dz; Secure; HttpOnly; SameSite=Lax, with an environment-specific prefix (hocoin production, e.g.hoco_stgin staging), so staging and production cookies — which share the parent domain — never collide.trustedOrigins=CORS_ORIGINS. - Durations: 30 days sliding for resellers and delegates; 7 days sliding for staff with re-authentication for sensitive actions (decision to confirm).
/v1/auth/*operations are contract procedures that call Better Auth's server API, so authentication is in the same OpenAPI document as everything else.- Guests. Catalog operations, promotions and banners,
support.whatsappand the public invitation operations areauth: optionalorpublic: the session step resolves a session when one is sent and otherwise continues as a guest, rate-limited per IP. The catalog then returnsprice: nulland refuses cart operations (AUTH-10). - Customer type. Reseller sign-up records the declared customer type (
SUPER_GROS,DEMI_GROS,DETAIL) on the reseller; only the back-office changes it later, audited (AUTH-11). - Lockout (5 failures → 15 minutes per phone + IP) and OTP limits (1 per 60 s, 5 per hour; 6 digits; 5 minutes; 5 attempts) are enforced in the identity module.
Alternatives considered.
- Auth.js — web-first; bearer sessions for native apps are awkward.
- Lucia — deprecated as a library.
- Firebase Auth or Supabase Auth — external identity provider: per-SMS phone-auth costs, lock-in, data location, and the API would still need its own authorisation layer.
- Keycloak — heavy to operate for one VPS.
- Hand-written JWT — security risk, and stateless tokens cannot be revoked immediately.
Consequences.
- Revocation on suspension, password change and from the sessions list (
auth.sessions.revoke,admin.users.sessions). - One session model for all apps; the request pipeline resolves either header or cookie (request lifecycle).
- Better Auth versions are pinned and auth flows have integration tests, because upgrades follow its release cadence.
- A browser keeps one session per environment: a person holding both a staff and a reseller identity needs two browser profiles — accepted and documented.
Status. Accepted; staff session length and two-factor authentication to confirm (D-33).
ADR-006 — PostgreSQL 17 + Prisma 7; UUIDv7 ids; client-generated ids for offline records
Context. A relational domain with hard invariants (stock never oversold, credit ceilings, gapless invoice numbers, lifecycles), reporting queries, one database for everything (§2), and offline creation in the delegate app that needs ids before the server has seen the record.
Decision.
- Database. PostgreSQL 17 (
postgres:17-alpinelocally on :5442). - ORM. Prisma 7 with the
prisma-clientgenerator (ESM output inpackages/db/prisma/generated), the@prisma/adapter-pgdriver adapter and a multi-file schema (packages/db/prisma/schema/*.prisma, one file per domain). - Migrations. Applied by
prisma migrate deploywhen theapicontainer starts. - Ids. Primary keys are UUIDv7 (
@default(uuid(7))); human-readable business numbers are separate fields fromNumberSequence. - Client-generated ids. For offline-creatable records — field prospects (
Reseller), unplanned visits, visit photos, visit reports, notes (Interaction), store photos and theirFilerows — the client supplies the UUIDv7 and the server upserts on it (offline sync design). - Search. Extensions
pg_trgmandunaccentfor catalog search. - Geography. No PostGIS in V1: coordinates are
lat/lngdoubles, distances use haversine, map clustering uses a grid; to revisit with V2 tour optimisation.
Alternatives considered.
- MySQL/MariaDB — weaker partial indexes, JSON and full-text search.
- MongoDB — invariants and reporting harder.
- Serial integer ids — enumerable, require a round-trip before creation, unusable offline.
- UUIDv4 — random B-tree inserts.
- Drizzle or Kysely — viable; Prisma kept for team precedent and the multi-file schema.
Consequences.
- Ids can be minted anywhere without coordination, insert in time order, and make offline creates naturally idempotent.
- Client ids are validated (version 7, embedded time at most 5 minutes in the future, unique) and never trusted for authorisation.
- Migrations must be backward compatible with the previous release (expand, migrate, contract) because the old container serves while the new one starts.
- Interactive transactions have a 10-second budget and never contain network calls.
Status. Accepted.
ADR-007 — Transactional outbox + BullMQ for asynchronous work
Context. Important events must produce notifications and journal entries (cahier §2.1), update the back-office live view, drive CRM lifecycle automation and refresh analytics — reliably and without slowing requests. Writing to the database then enqueueing to Redis loses the event if the process dies in between.
Decision.
- Emit. Services call
ctx.emit(event)inside their transaction; the event is anOutboxEventrow committed with the change. - Dispatch. The worker's dispatcher wakes on
LISTEN/NOTIFYand polls every second as a safety net. It publishes the SSE projection and enqueues one BullMQ job per subscribed handler into theeventsqueue, deduplicated by job id (event id + handler). - Infrastructure. Redis 7 (AOF on,
noeviction) backs BullMQ. Job schedulers run the cron jobs inAfrica/Algiers.
Alternatives considered.
- Enqueue after commit — loses events on crashes.
- A Postgres-only queue (pg-boss, Graphile Worker) — viable with fewer moving parts. Not chosen because Redis is needed anyway (rate limits, session cache, SSE fan-out) and BullMQ brings rate-limited queues, schedulers and tooling.
- Kafka / RabbitMQ — operational overkill.
- Logical decoding (Debezium) — complex for the gain.
Consequences.
- No lost events; request latency unaffected; dispatched events stay replayable for 30 days.
- Delivery is at-least-once, so every handler is idempotent and tolerant of reordering.
- Redis becomes stateful infrastructure to persist and monitor; outbox lag is an alerting signal (over 30 s for 5 minutes).
Status. Accepted. Queues, dispatcher and every scheduled job: Jobs and scheduler.
ADR-008 — Server-Sent Events for real-time: a back-office stream and each user's own stream
Context. The back-office must show new orders, orders to confirm, status changes, check-ins, tour starts and closings, delegate positions, stock alerts, pending accounts, returns and after-sales claims as they happen (SYN-07, FLD-13; cahier §5.3 « apparaît immédiatement dans le back-office », §6.4 « en temps réel »). Since the product decisions of 17 Oct 2026, every order status change must also reach the reseller live in the app and on the web, and as a notification (ORD-15). Traffic is one-way, server to client: a few dozen staff sessions and, at peak, a few hundred open reseller screens.
Decision.
- Two streams, one mechanism.
GET /v1/admin/events(admin.events.stream): the back-office stream, filtered per connection by the user's permissions.GET /v1/events(events.stream): the caller's own stream — order status changes and modifications, assisted orders awaiting their confirmation, ticket replies, return status changes, new inbox notifications — opened by the reseller app while in the foreground and by the reseller web while a page is open.
- Authentication. The session cookie in the browsers;
Authorization: Bearerfrom the Flutter SSE client (the reseller app does not use the browserEventSource). - Contents. Event types are a small, stable projection of domain events (SSE projection). Back-office events are invalidation hints (the dashboard refetches); own-stream events carry the new status, so the order screen updates without a round-trip.
- Fan-out and resume. The SSE event id is the
OutboxEventid. The worker publishes each projected event on Redis; every api instance subscribes and routes it to its local connections, by permission or by recipient. On reconnect,Last-Event-IDreplays the events dispatched since from the outbox table (at most 500 events or 24 h); beyond that the server sends aresyncevent and the client refetches.delegate.positionis sent without an id: the next position replaces it. - Liveness. A heartbeat comment every 25 s keeps proxies from closing idle connections.
- Push stays. SSE only serves open screens; every status change still produces the push and inbox notification from the notifications matrix. The delegate app relies on push (including silent sync hints) and
GET /sync/delta.
Alternatives considered.
- WebSockets — bidirectional capability nobody needs, plus upgrade authentication, sticky sessions and proxy tuning.
- Polling — every widget of every open screen polling every few seconds is wasteful and still laggy for the live map and order tracking.
- Push only for resellers — not live while the app is open (delivery delays, notifications muted), and nothing on the web.
- A hosted realtime service (Pusher, Ably) — cost, and operational data leaves the platform.
Consequences.
- Native reconnection and resume in browsers; the same protocol from Flutter; works through Traefik on HTTP/1.1 and HTTP/2.
- One long-lived connection per open screen; one Redis subscription per api instance for both streams.
- The reseller app closes its stream when it goes to the background, to save battery and avoid Android background limits.
- A missed event costs a refetch, never an inconsistency: the REST resources stay the source of truth.
Status. Accepted.
ADR-009 — S3-compatible object storage with pre-signed uploads
Context. Store, visit and delivery photos; store documents (RC, NIF, ID); payment proofs; ticket attachments; generated PDFs; exports; import files; product images (cahier §11, §10 « Protection des documents et fichiers téléchargés »). Uploads come from phones on weak networks; the API should not proxy bytes.
Decision.
- Buckets. Two per environment:
S3_BUCKET_PUBLICfor product images, brand logos and banners, served through a public base URL or CDN;S3_BUCKET_PRIVATEfor everything else. - Upload.
files.createUploadreturns a pre-signed PUT valid 10 minutes; the client uploads directly;files.completeUploadchecks size, type and purpose, then the worker strips metadata and makes thumbnails. - Private reads. Signed GET URLs valid 5 minutes, issued after an access check on the record that owns the file (file flows).
- Keys. Object keys carry no personal data.
- Provider (decided 18 Oct 2026). A MinIO instance on the VPS in every environment (locally :9100, console :9101), accessed through the S3 API. MinIO shares the server's failure domain, so its buckets are backed up off the VPS nightly with the database.
Alternatives considered.
- Files in the database — bloats tables and backups.
- Multipart uploads through the API — API memory and bandwidth, timeouts on weak networks.
- Provider-specific features — lock-in.
Consequences.
- The API stays light, and any S3-compatible provider works.
- A pre-signed PUT cannot cap the size, so the size is verified at completion and oversize objects are deleted.
- Abandoned uploads are purged by a daily job; protecting the private bucket (versioning or replication) is part of the backup plan.
Status. Accepted; provider pending.
ADR-010 — Offline-first delegate app: local database, idempotent outbox, delta pulls
Context. The delegate app must keep a tour, visits, notes and photos locally under weak network; a sync queue handles retries, duplicates, errors and conflicts; the user sees synchronisé, en attente or en erreur (cahier §7.3, SYN-04…SYN-06). Resellers order from shops where a live price, stock and credit check is required anyway.
Decision.
- The delegate app is offline-first:
- a drift database holds its offline scopes;
- an outbox holds mutations, each with its own
Idempotency-KeyandoccurredAt, replayed in order throughPOST /sync/batch; GET /sync/deltapulls the changes for its scopes;- photos go through an upload queue.
- The reseller app and web are online-first with read caches: no offline ordering. They can be browsed as a guest (no prices, no cart) and keep order screens live over SSE (ADR-008).
- What is offline-capable, the conflict catalogue and the replay rules are in the offline sync design; the client side is on mobile offline sync.
Alternatives considered.
- An online-only delegate app — fails §7.3.
- Generic bidirectional replication (CouchDB, PowerSync, ElectricSQL) — third-party infrastructure, per-scope authorisation pushed into sync rules, business rules bypassed.
- Offline ordering for resellers — stale prices, stock and promotions at submission. Rejected.
Consequences.
- Field work survives coverage gaps.
- Every delegate mutation is idempotent and replay-safe, and conflict codes are part of the contract.
- A sync centre screen (DA-27) and a real field test (MOB-13) are required.
Status. Accepted.
ADR-011 — Money in integer centimes; timestamps in UTC; business days in Africa/Algiers
Context. DZD amounts with 19 % VAT by default; floating-point drift is unacceptable on invoices. The business day is Algerian time (UTC+1, no DST). Devices may have wrong clocks, and reports and objectives are per day.
Decision.
- Money is an integer number of centimes of DZD everywhere — database, API, apps — with
currency: "DZD"on orders and invoices. Rates (VAT, discounts) are basis points (1900= 19 %). - Arithmetic stays in integers with one rounding helper (half up, to the centime) in
@hoco/shared; display goes throughformatDzd()(125000→1 250,00 DA), identical on server and browsers. - Timestamps are
timestamptz, exchanged as ISO-8601 withZ; date-only fields areYYYY-MM-DD. - Business days. "Today", tour days, objective periods, report days and cron schedules are computed in
Africa/Algiersby shared helpers.
Alternatives considered.
- Decimal libraries in JavaScript — serialisation pitfalls and weight for no gain over integers.
- Floats — rejected.
- Local-time storage — ambiguous across servers and devices.
Consequences.
- Exact totals and reproducible documents.
- Column widths are chosen per field in the database design: a 32-bit integer stops at 21 474 836,47 DA.
- The VAT rounding basis (per line or per document) is a business rule (business rules).
- Day boundaries are tested around 23:00–01:00 UTC.
Status. Accepted.
ADR-012 — Docs-as-code in the monorepo, hosted on the VPS
Context. Requirements, API catalogue, screen inventory and plan must stay consistent with each other and with the code, and be shared with HOCO and the team. Plan dates come from a scheduler and must never be typed by hand.
Decision.
apps/docs(Fumadocs on Next.js) lives in the monorepo.- Canonical data is JSON (
content/spec/*.json,content/plan/tasks.json) rendered by components; team-task pages are generated from the plan. - MDX is compiled in CI (
check-mdx). - The site is deployed later on the VPS with the other apps (Dokploy); until then the team runs it locally.
- Access is restricted to the team and HOCO because the site holds client and planning information.
Alternatives considered.
- Notion or Confluence — drift from the code, no data-driven tables.
- GitHub wiki — no components, weak structure.
- Docusaurus — fine, but Fumadocs matches the Next.js and Tailwind stack.
- A separate hosting provider — one more account and bill for a small internal site; the VPS already runs everything else.
Consequences.
- Changes are reviewable, and dates, requirements and endpoints have one source.
- Writers follow the MDX rules of
apps/docs/AUTHORING.md. - Once SRV-03 publishes the contract, the OpenAPI document becomes authoritative for operations and these pages follow it.
Status. Accepted.
ADR-013 — Dokploy on a VPS for the server, dashboard and reseller web
Context. A small team and a cost-sensitive client; three environments required (§13.1) plus staging; automatic backups with tested restores (§10, SEC-05); managed TLS; simple deploys; a possible data-residency constraint from Law 18-07 (SEC-08).
Decision.
- Platform. Dokploy (open-source PaaS: Docker Swarm + Traefik) on two VPS: production alone on one, staging and pre-production as two Dokploy projects on the other.
- Services. Each environment runs
api,worker,dashboard,web, plus PostgreSQL 17 and Redis 7 as Dokploy database services with scheduled backups to off-site S3. - Build and release. Images come from
docker/app.Dockerfilewith theAPPbuild argument; staging deploys every push todev, production deploys release tags frommaster. - TLS. Traefik with Let's Encrypt.
Alternatives considered.
- Managed PaaS (Render, Fly.io, Railway) — monthly cost per service, no Algerian region, data location.
- Kubernetes — operational overkill.
- Raw docker compose with scripts — no UI, manual TLS and rollbacks.
- Coolify — comparable; team precedent favours Dokploy.
- An Algerian cloud provider — remains possible as the host for Dokploy if residency requires it.
Consequences.
- Low cost, full control, and the same recipe in every environment.
- A single VPS is a single failure domain: backups, a written restore runbook and stated RTO/RPO targets are mandatory (deployment and operations).
- OS patching and monitoring are the team's job (OPS-04).
- The VPS provider and country are decided with HOCO (D-15).
Status. Accepted; VPS provider and location pending.
ADR-014 — French first; internationalisation and RTL ready from day one
Context. « Français en V1 ; arabe et anglais prévus selon validation » (cahier §9, NFR-06). Arabic means right-to-left layouts; retrofitting i18n is expensive.
Decision.
- Apps. No user-facing string is hard-coded: next-intl catalogs
fr(default),ar,enin the Next apps; ARB files inhoco_l10nfor Flutter. - API. Messages are localised from
Accept-Language(French by default) while error codes stay stable; notification templates exist per event × channel × locale; users keep alocalepreference. - Layout. Logical start/end CSS properties and mirrored directional icons.
- Data. Reference data carries French and Arabic names (wilayas, communes). Product content stays French-only in V1.
Alternatives considered.
- French hard-coded — cheap now, expensive later.
- Runtime machine translation — quality and cost.
Consequences.
- Adding Arabic becomes translation plus RTL QA, not refactoring.
- Every change adds keys in three catalogs (French filled, others placeholders), with a missing-key check.
- RTL is verified on key screens (MOB-17).
Status. Accepted.
ADR-015 — Maps provider: Google Maps proposed, pending HOCO's decision
Context. The cahier lists « Google Maps, Mapbox ou service équivalent pour adresses, itinéraires et géocodage » (§11, INT-01). Needs: the delegate's map with stops and current position, hand-off to turn-by-turn navigation, the back-office live map and reseller map, and optional geocoding (GEO-04, COULD). Costs and quotas must be validated by HOCO (§11).
Decision (proposed).
- Delegate app. Google Maps Platform through the Maps SDK for Android/iOS (
google_maps_flutter). - Back-office. The Maps JavaScript API.
- Navigation. Handed to the Google Maps or Waze app through an intent: no in-app routing in V1.
- Geocoding. The Geocoding API, only on demand (address to position on back-office forms).
- Server. Distances, check-in tolerance and the recommended visit order use haversine — no routing API in V1.
- Keys. Per environment and platform, restricted by app signature or HTTP referrer;
GOOGLE_MAPS_API_KEYstays server-side for geocoding.
Alternatives considered.
- Mapbox — good SDKs and styling, per-active-user pricing, thinner address and POI data in Algeria.
- OpenStreetMap-based stack — free data, but tile hosting and geocoding quality are on us.
- HERE — less used in the Flutter ecosystem.
Consequences.
- The best coverage and the app delegates already know.
- HOCO's billing account and quota monitoring are required.
hoco_mapsisolates the SDK, so choosing Mapbox later changes one package and the back-office map component.
Status. Proposed — pending decision D-14.
ADR-016 — GPS check-in tolerance and location sampling only during tours (privacy by design)
Context. « La position est utilisée uniquement selon les règles validées par le client » and arrival « peut être soumise à une distance maximale » (cahier §6.3); tolerance, frequency, consent and retention are « à valider » (§8); consent and transparency are required (§10, SEC-07). Law 18-07 applies to delegates' location data; battery life on mid-range Android matters (NFR-04).
Decision (defaults, all tunable in settings):
- Check-in. Tolerance 150 m with a reported accuracy of at most 100 m. The app checks locally for immediate feedback; the server recomputes and stores the distance.
- Sampling. Only while a tour is
IN_PROGRESS, every 5 minutes or 200 m; it stops when the tour isSUSPENDEDorCOMPLETED. The server drops any ping recorded outside an in-progress window. - Retention. Positions are kept 90 days, then purged by a daily job.
- Consent and transparency. Consent before the first sampling (DA-04), transparency settings (DA-26), and an Android foreground-service notification while sampling.
- Access. Trails are visible only with
field.view. - End of day (proposal). Tours still open at 21:00 are suspended automatically, so sampling never runs overnight.
- Rule changes. A fact recorded offline is judged by the rules in force when it was recorded (
occurredAt), never by a setting published afterwards (offline sync design).
Alternatives considered.
- Continuous background tracking during working hours — privacy, battery and legal exposure.
- No tracking — makes live follow-up (FLD-13) impossible.
- Geofence events per stop only — no kilometres and a poorer live view.
Consequences.
- A defensible privacy position with a clear technical guarantee: no position stored outside a tour.
- Kilometres and the live map are approximations at 5-minute sampling.
- The tolerance must be validated in the field, where dense centres and indoor shops degrade GPS (MOB-13), and every value must be validated by HOCO (cahier §15, « Décisions restantes à valider »).
Status. Accepted as defaults; values pending HOCO's validation (D-06, D-07, D-24, retention D-11).
Decision register
Every open decision with its question, source, options and consequences, recommendation, owner, the plan task that needs the answer, the default applied if no answer comes, and its status.
Design plan (21 days)
How Akram designs the four HOCO interfaces in 21 working days — design system first, inventory validation on day 5, then every V1 screen — with inputs, outputs, reviews, client checkpoints and the work that follows handoff.