Architecture and data
Application boundaries, shared packages and their dependency rules, the system of record per module, consistency, caching, file flows, the request lifecycle inside the server, errors, observability and configuration.
This page is the inside view of the platform described on Architecture: what each application may do, how the shared packages depend on each other, which module owns which data, and the rules that keep that data consistent from the HTTP request to the database commit. Module responsibilities are on the service catalogue; the models themselves on the database design.
Application boundaries
One API owns every rule and every byte of data (ADR-003); everything else is a client.
| Application | May | May not |
|---|---|---|
@hoco/server — api process | Own every business rule, permission and scope check; read and write PostgreSQL through its modules; serve REST under /v1, /openapi.json, /reference (outside production) and the two SSE streams; sign storage URLs; enqueue jobs through the outbox; apply migrations at start | Call an external provider inside a database transaction; send SMS, push or e-mail synchronously (it enqueues); keep state in memory that another instance would need (sessions, rate limits and stream buffers live in Redis) |
@hoco/server — worker process | Run BullMQ processors and job schedulers, dispatch the outbox, call providers (FCM, SMS, SMTP, storage), render PDFs and images, build exports and run imports | Serve public HTTP (only /health and /metrics on an internal port); run migrations; change data outside module services |
@hoco/dashboard (back-office) | Render the back-office for staff roles; call the API through @hoco/api-client from the browser (cookie) or from server components (forwarding the cookie); subscribe to /v1/admin/events; hide actions the user lacks permissions for (from me.get) | Import @hoco/db or Prisma; hold database, storage or provider credentials; compute prices, totals, stock, credit or permissions; cache personal data across users on the server |
@hoco/web (reseller web) | Everything the reseller app does, on the web: guest browsing without prices, cart, orders with live status over /v1/events, returns and after-sales claims, finance, support; the invitation landing /i/{code} and /.well-known/assetlinks.json | Any rule the mobile app does not have (RWA-02); same prohibitions as the dashboard |
| Reseller app (Flutter) | Browse as a guest; cache reference data, catalog pages and images for display; keep the SSE stream open while in the foreground; register a device for push; read the install referrer; open the regional WhatsApp link | Compute authoritative prices or totals (display what the API returns); queue orders offline; show stock quantities (only availability exists in its API) |
| Delegate app (Flutter) | Keep its offline scopes in a local database; record mutations offline with an Idempotency-Key and occurredAt and replay them in order; check GPS tolerance locally for feedback; sample location only during an in-progress tour; queue photos | Create or submit assisted orders offline (drafts stay on the device); close a tour offline; sample location outside a tour; discard queued data on sign-out or session expiry (offline sync design) |
@hoco/docs | Read content/spec/*.json and content/plan/tasks.json; render pages | Call the API; contain secrets; be part of the Docker images |
Shared packages
| Package | Holds | Depends on (workspace) | Used by |
|---|---|---|---|
@hoco/config | tsconfig.base.json (strict, noUncheckedIndexedAccess, ESM) | — | every package and app (dev) |
@hoco/shared | Pure, framework-free helpers: formatDzd, normalizeDzPhone, the rounding helper, Africa/Algiers business-day helpers, business-number and invitation-code formats, UUIDv7 checks, the offline plausibility constants (5 minutes, 72 hours) | — | server, dashboard, web, contracts |
@hoco/env | Validated environment per runtime: ./server, ./dashboard, ./web, and ./load which finds the root .env | — | server (./server), dashboard, web |
@hoco/db | Multi-file Prisma schema (prisma/schema/*.prisma), migrations, the generated client (prisma/generated), prisma.config.ts | — | server only (and its scripts) |
@hoco/contracts | The oRPC contract: contract.ts, one router file per module (src/routers/<module>.ts), shared schemas (ErrorEnvelope, money, pagination), declared errors, enums | @hoco/shared (formats) | server, api-client, OpenAPI export |
@hoco/api-client | createApiClient() — oRPC client over the OpenAPI link, credentials: "include", error envelope decoded into ORPCError — and createApiQueryUtils() for TanStack Query | @hoco/contracts | dashboard, web |
@hoco/ui | Design system for the Next apps: Base UI primitives, CVA variants, Tailwind 4 tokens from Akram's design system, hooks | — | dashboard, web |
Dependency rules
- Only
apps/serverdepends on@hoco/db. The Next apps have no Prisma client and noDATABASE_URL(ADR-003). @hoco/contractsis pure. Zod,@orpc/contractand@hoco/sharedonly — no server code, no Prisma types, no Node APIs — so it can be bundled into browsers and exported to OpenAPI without a running server.@hoco/sharedhas no dependencies and no I/O; it is the only code that runs identically on the server, in browsers and in tests.@hoco/uiknows nothing about the API. Data fetching lives in the apps.- Server-only secrets never reach a browser bundle.
@hoco/env/dashboardand@hoco/env/webexpose onlyNEXT_PUBLIC_*client variables; anything else read in a client component fails the build. - Inside the server, modules import each other only through
modules/<name>/index.ts; repositories touch only their module's tables (service catalogue).
Enforcement: the dependencies declared in each package.json (pnpm's strict node_modules makes undeclared imports fail), plus a restricted-imports lint rule in biome.json forbidding @hoco/db and @prisma/* outside apps/server and packages/db, and cross-module imports other than index.ts inside the server. Enum values are defined once in @hoco/contracts; a server test asserts they equal the Prisma enums and apps/docs/content/spec/enums.json.
Data ownership
The system of record: every table has one owning module, the only one that writes it. "Written by" lists the operations and jobs that change the data — always through the owner's service; "Read by" lists the modules (in-process) and apps (through the API). Field-level details are on the database design pages linked in each heading's domain.
Identity and access
| Entity | Owner | Written by | Read by |
|---|---|---|---|
User | identity | auth.signup.reseller, admin.users.create, admin.users.update, admin.users.setStatus, me.update, CRM account validation (identity.activateAccount()) | every module (actor), me.get, BO users |
Session | identity | auth.login, auth.logout, auth.sessions.revoke, admin.users.sessions, suspension and password change (revocation), identity.purgeExpiredSessions | session step of every request (cached in Redis) |
Account, Verification | identity (Better Auth) | sign-in credentials, auth.otp.send, auth.otp.verify, password reset and change | Better Auth |
Device | identity | me.devices.register, me.devices.remove, auth.logout, invalid-token reports from notifications | notifications (push targets), sync (last sync), audit |
Role, Permission, RolePermission, UserRole | access | seeds, admin.roles.updatePermissions, role assignment in admin.users.create / update | permission guard, me.get, BO roles matrix |
NotificationPreference | notifications | notifications.preferences.update | notifications |
Geography and zones
| Entity | Owner | Written by | Read by |
|---|---|---|---|
Wilaya, Commune | geo | versioned seed only | every app (reference.*, ETag), crm, support (region resolution through geo.nearestCommune()), analytics |
Zone, ZoneArea | geo | admin.zones.crud | access (delegate scope), field, crm, analytics |
DelegateZone | geo | zone assignment in admin.delegates.crud (through geo.setDelegateZones()) | access, sync (scope digest), field |
DelegateProfile | field | admin.delegates.crud | field, delegate.profile, analytics |
CRM
| Entity | Owner | Written by | Read by |
|---|---|---|---|
Reseller (incl. customerType, validation status, lifecycle status) | crm | admin.resellers.* (create, update, setStatus, assign, bulk, validate), delegate.resellers.create / update (online or replayed), auth.signup.reseller (claim or create, declared customer type), profile.update (allowed fields), lifecycle event handlers, crm.inactivityScan, imports | every module in scope: cart and pricing (customer type), orders, support (declared wilaya), field, analytics; RA/RW profile, DA, BO |
ResellerAddress | crm | addresses.*, admin.resellers.update | cart (checkout options), orders (snapshot at submission), fulfillment |
ResellerDocument, ResellerPhoto | crm | profile.documents.add, admin.resellers.documents.add, delegate.resellers.addPhoto | BO 360° page, DA sheet |
ResellerStatusChange | crm | every lifecycle change | timeline, conversion reports |
ResellerAssignment | crm | admin.resellers.assign, admin.resellers.bulk, field creation | access (scope), sync (scope at occurredAt), analytics |
Interaction | crm | admin.resellers.notes.add, delegate.resellers.addNote, event handlers (orders, returns, visits, payments, tickets, credit notes) | admin.resellers.timeline, DA sheet |
CrmTask | crm | admin.tasks.*, delegate.tasks.*, visit reports (crm.upsertFollowUpFromReport()), crm.inactivityScan | DA (delta feed), BO, notifications (task.due) |
Segment, ResellerSegment | crm | admin.segments.crud, admin.resellers.bulk | promotions audience and visibility rules (facts passed in by callers), analytics |
Catalog and pricing
| Entity | Owner | Written by | Read by |
|---|---|---|---|
Brand, Category (tree with sub-categories) | catalog | admin.brands.crud, admin.categories.crud, admin.categories.reorder, imports | every app, guests included |
Product | catalog | admin.products.*, imports, stock.out / stock.restocked handlers (automatic status) | catalog endpoints, cart, orders (snapshot), analytics |
ProductVariant (SKU, barcode, limits, priceSuperGros, priceDemiGros, priceDetail, vatRate, recommendedPrice, restricted purchasePrice) | catalog | admin.variants.crud, imports | catalog endpoints (prices only for signed-in callers, purchase price only with permission), pricing (selectPrice input), cart, orders |
ProductImage | catalog | admin.products.update with uploaded files | every app (public CDN) |
AttributeDefinition | catalog | admin.attributes.crud | catalog.facets, product editor |
VariantAttributeValue | catalog | admin.variants.crud, imports | catalog filters and facets |
ProductVisibilityRule | catalog | admin.products.visibility | catalog queries, cart |
Favorite | catalog | favorites.add, favorites.remove | favorites.list, product cards |
PriceChange | pricing | the variant save (pricing.recordPriceChange() in the same transaction), imports | admin.variants.priceHistory, audit |
V2 PriceList, PriceListItem | pricing | V2 per-reseller agreements (PRC-03) | — |
Stock
| Entity | Owner | Written by | Read by |
|---|---|---|---|
Warehouse | inventory | admin.warehouses.crud | inventory |
StockLevel | inventory | the inventory service only: receipts, adjustments, inventory validation, and — called by orders — reservation, release, deduction, re-entry after a late cancellation, return reception | availability for catalog and cart (IN_STOCK / OUT_OF_STOCK only), BO stock pages (quantities), analytics |
StockMovement | inventory | one row per variation, in the same transaction as the level change | admin.stock.movements, analytics, the nightly drift check |
InventorySession, InventoryLine | inventory | admin.inventory.crud, admin.inventory.validate | BO |
Orders and delivery
| Entity | Owner | Written by | Read by |
|---|---|---|---|
Cart, CartItem | cart | cart.*, orders.reorder (through the cart service) | cart, checkout |
Order, OrderItem | orders | cart.checkout (orders.placeOrder()), admin.orders.*, delegate.assisted.*, orders.cancel, orders.confirmAssisted, orders.rejectAssisted, after-sales REPLACEMENT resolutions, orders.expireAssisted | RA/RW/DA/BO order endpoints, billing and fulfillment (data passed in), analytics |
OrderStatusChange, OrderChange | orders | every transition and controlled modification | orders.get timeline, admin.orders.history |
ReturnRequest, ReturnItem | orders | returns.create, admin.returns.decide, admin.returns.receive, after-sales RETURN resolutions | returns.*, admin.returns.*, support, analytics |
Delivery | fulfillment | created by orders at confirmation; admin.deliveries.update; proof and failure through orders' orchestration | BO delivery board, orders.get |
Carrier | fulfillment | admin.carriers.crud | fulfillment |
Finance
| Entity | Owner | Written by | Read by |
|---|---|---|---|
Invoice, InvoiceLine | billing | billing.issueInvoice() called by orders when the order reaches billing.invoiceOn; admin.orders.documents; admin.invoices.cancel | invoices.*, orders.document, finance.summary, analytics |
Payment, PaymentAllocation | billing | payments.declare, admin.payments.record, admin.payments.verify, admin.payments.allocate | finance endpoints, crm timeline (events), analytics |
CreditAccount, CreditLimitChange | billing | admin.credit.update; exposure recomputed by billing on invoice and payment changes | cart (credit availability), orders (confirmation check), finance.summary |
CreditNote | billing | billing.issueCreditNote() at return reception and for after-sales CREDIT_NOTE resolutions | returns.get, invoices.get, finance pages, analytics |
V2 PaymentReminder | billing | V2 reminders job | — |
Promotions and loyalty
| Entity | Owner | Written by | Read by |
|---|---|---|---|
Promotion, PromotionScope, PromotionAudience | promotions | admin.promotions.*, promotions.applySchedule, cap consumption at checkout, cap release handlers | promotions.*, cart engine, analytics |
Banner | promotions | admin.banners.crud | banners.list, catalog.home |
V2 Pack, PackItem · LoyaltyAccount, LoyaltyTransaction, Reward, RewardRedemption, Voucher | promotions · loyalty | V2 | V2 |
Field operations and invitations
| Entity | Owner | Written by | Read by |
|---|---|---|---|
Objective | field | admin.objectives.crud | delegate.objectives, analytics |
Tour, TourStatusChange | field | admin.tours.*, delegate.tours.* (online or through sync.batch), tours.activateToday, field.autoSuspendOpenTours, the account.suspended handler | DA (delta feed), BO, analytics |
Visit, VisitChecklistItem, VisitPhoto, VisitReport | field | delegate.visits.* and delegate.tours.addVisit (online or replayed), admin.tours.setStops, admin.visits.unlock | DA, BO, crm (through events), analytics |
VisitType, ChecklistTemplate | field | admin.visitTypes.crud, admin.checklists.crud | DA (reference scope of the delta feed), BO |
LocationPing | field | sync.locationPings (field.ingestPings()), field.purgeLocationPings | admin.tours.track, admin.live.delegates, tour kilometres |
Invitation, InvitationEvent | invitations | delegate.invitations.create / markShared, invitations.attribute, handlers of account.created and order.submitted, the first catalog view | DA, BO funnel, analytics |
Support and notifications
| Entity | Owner | Written by | Read by |
|---|---|---|---|
Ticket, TicketItem, TicketMessage | support | tickets.*, admin.tickets.* (including admin.tickets.resolve), support.autoClose, system messages from order and return handlers | RA/RW, BO, crm timeline (events) |
SupportRegion, SupportRegionWilaya | support | admin.supportRegions.crud | support.whatsapp, support.contacts |
Announcement, AnnouncementRead | support | admin.announcements.*, announcements.markRead, support.publishScheduledAnnouncements | announcements.list, DA delta feed |
Notification, NotificationDelivery | notifications | event handlers, notifications.markRead / markAllRead, channel workers, notifications.reconcileDeliveries | inbox endpoints, admin.notifications.log |
NotificationTemplate, NotificationRule | notifications | seeds, admin.notifications.templates, admin.notifications.rules | notifications |
Platform
| Entity | Owner | Written by | Read by |
|---|---|---|---|
File | files | files.createUpload, files.completeUpload, worker (generated documents, exports, thumbnails), files.purgeOrphans | files.downloadUrl; owning records reference fileId |
AuditLog | audit | ctx.audit() inside every module's transactions | admin.audit.list |
Setting, ReferenceItem, AppVersionPolicy | settings | admin.settings.update, admin.referenceLists.crud, admin.appVersions.crud, seeds | every module (cached), appConfig.get, reference.lists, the app-version gate |
NumberSequence | settings | settings.nextNumber(tx, type) called by the module creating the numbered record | — |
IdempotencyRecord | sync | the idempotency step, sync.batch items, sync.purgeIdempotencyRecords | the idempotency step |
OutboxEvent | jobs | ctx.emit() in every module, the dispatcher (dispatchedAt, attempts, availableAt), jobs.purgeDispatchedOutbox | dispatcher, event handlers, SSE resume |
ExportJob, DashboardLayout | analytics | admin.exports.create and export wrappers, the exports queue, exports.expire; admin.dashboard.layout.update | admin.exports.*, admin.dashboard.layout.get |
ImportJob | integrations | admin.imports.create, admin.products.import, the imports queue | admin.imports.get |
Consistency rules
Transactions
- One command, one transaction. A mutating procedure runs its whole effect inside
withTx(ctx, fn)— a Prisma interactive transaction withmaxWait2 s andtimeout10 s, isolationREAD COMMITTED. Cross-module invariants are service calls that receive the sametx. - Nothing external inside. No HTTP call, no Redis publish, no queue add, no file upload inside
fn; those happen after commit, through the outbox. - Explicit row locks (
SELECT … FOR UPDATEin the owner's repository) where two writers can race: the order being transitioned, the cart at checkout,CreditAccount, the promotion cap counter,StockLevelrows,NumberSequence. Locks are always taken in the same order to prevent deadlocks: cart → order or return → credit account → promotions → stock levels (sorted by variant id, then warehouse id) → number sequence. - Retry of conflicts. A Prisma write conflict or deadlock (
P2034) re-runsfnup to two times — safe because nothing external happened yet — then answers500 INTERNAL_ERROR, which clients retry with the sameIdempotency-Key. - Database guards.
statement_timeoutof 15 s on the application role;idle_in_transaction_session_timeoutof 15 s.
Outbox
ctx.emit(tx, event) buffers events during the transaction; withTx inserts them as OutboxEvent rows just before commit, together with the AuditLog entries collected by ctx.audit(). A rolled-back command emits nothing. The worker dispatches committed events to handlers and to the SSE streams (outbox dispatcher).
Idempotency
Every mutating operation accepts Idempotency-Key; it is required on cart.checkout, sync.batch, every delegate.* mutation and every operation flagged in the API reference.
- The idempotency step hashes the canonical request (operation id, path parameters, sorted JSON body) and looks up
IdempotencyRecordby (caller, key). The caller is the user id, or the device id and IP for public operations such asinvitations.attribute. - Completed, same hash → the stored status and body are replayed with
Idempotent-Replayed: true; nothing runs. - Same key, different hash →
409 IDEMPOTENCY_KEY_REUSED. - In progress (lock not expired) →
409 IDEMPOTENCY_IN_PROGRESS; the client retries after a second. - New → a record with
status: null(in progress), then the procedure runs; a record still in progress after 60 s is treated as abandoned and taken over by the next attempt. - Completion happens inside the business transaction:
withTxwrites the response snapshot to the record before commit, so a crash can never leave a change committed without its replay record. - What is stored:
2xxresponses and business refusals (400,403 OUT_OF_SCOPE,404,409conflicts,422) — a replay returns the same refusal, and a corrected action is a new action with a new key (sync.batch). Transient outcomes are not stored, so the same key can be retried:5xx,401,429,409 IDEMPOTENCY_IN_PROGRESS. - Records are kept 30 days (
sync.purgeIdempotencyRecords).
Optimistic locking
Aggregates edited from several places carry an integer version (database design): Order, Tour, Visit, CreditAccount and Reseller (edited offline by delegates). Other aggregates rely on status guards in their UPDATE, and counters (stock levels, promotion caps, number sequences) on atomic increments. Responses include version; edits and transitions send it back; the repository updates with WHERE id = $1 AND version = $2 and increments it. Zero rows updated → 409 VERSION_CONFLICT with details.current (the current representation), and the client shows what changed. Offline edits from the delegate app carry the version they were made against; a mismatch comes back as a sync CONFLICT with the current record, and the app re-applies the delegate's own changes on top of it as a new action (offline sync design).
Integrity in the database
The database is the last line of defence; services check first and give the friendly error.
| Rule | Constraint | Error when violated |
|---|---|---|
| Main reseller phone unique after normalisation (cahier §4.2) | unique index on Reseller.phone (E.164) | 409 PHONE_ALREADY_EXISTS |
| Business numbers unique | unique index per number column | never expected (500) |
| One tour per delegate and date; one tour in progress per delegate | unique (delegateId, date); partial unique on status IN_PROGRESS/SUSPENDED | 409 TOUR_DATE_TAKEN, 409 TOUR_ALREADY_IN_PROGRESS |
| One delivery per order | unique Delivery.orderId | 500 |
| Stock never negative | check reserved >= 0, physical - reserved >= 0 unless an override movement is recorded | 422 STOCK_INSUFFICIENT |
| Claimed + returned quantity never above delivered quantity | checked in the claim and return transactions under the order lock | 422 QUANTITY_NOT_ALLOWED |
| Client-generated ids are UUIDv7 and unique | primary key; version and time checked in the contract | 400 VALIDATION_FAILED |
| History is never deleted | no DELETE grant on history and audit tables for the application role | — |
Business numbers
settings.nextNumber(tx, type) locks the NumberSequence row of (type, year), increments it and formats the number (CMD-2026-000158). Because the increment is part of the business transaction, a rollback gives the number back: invoice and credit-note numbers stay gapless, as invoicing requires.
Snapshots
Orders snapshot what the reseller agreed to at submission: product names, references, the selected price of the reseller's customer type, VAT rate, promotions, address. Invoices and credit notes snapshot their lines. A later price, name or address change never alters a past document.
Eventual-consistency budget
| What | Consistency | Target |
|---|---|---|
| The response to the caller | strong (same transaction) | — |
| Notifications, CRM lifecycle automation, product out-of-stock status, invitation steps | eventual through the outbox | p95 under 2 s after commit |
| Back-office and reseller live streams | eventual | p95 under 2 s; a missed event costs a refetch |
| Dashboards "today" | live queries | — |
| Daily aggregates | rebuilt nightly, the last three days recomputed | provisional for 3 days because offline data can arrive 72 h late |
| Delegate devices | delta pulls and silent push hints | minutes when online |
Caching
Never cached: prices for a signed-in caller, availability, carts, orders, finance, anything personal. Correctness first; the database is fast enough at HOCO's scale.
HTTP caching
| Resource | Cache-Control | Validator | Notes |
|---|---|---|---|
reference.wilayas, reference.communes | public, max-age=86400 | ETag (seed version) | 304 Not Modified on revalidation |
reference.lists | private, max-age=300 | ETag | invalidated by settings.changed |
appConfig.get | no-cache | ETag | must reflect maintenance mode and forced updates at once |
Catalog, promotions and banners for guests (catalog.*, promotions.*, banners.list with no session) | public, max-age=60 | ETag | no price and no quantity in the body, so shareable; Vary: Authorization, Cookie |
| Catalog and promotions for signed-in callers | private, no-cache | ETag | prices and targeting depend on the caller |
| Cart, orders, returns, finance, tickets, notifications | private, no-store | — | |
| Product images, brand logos, banners | public, max-age=31536000, immutable | — | a changed image gets a new object key |
/openapi.json | public, max-age=300 | ETag |
Server-side caches
| Data | Where | TTL | Invalidation |
|---|---|---|---|
| Session lookups | Redis (Better Auth secondary storage) | session expiry | revocation deletes the entry |
| Role permissions, a delegate's scope (zones, assigned resellers) | in-process LRU per api instance | 60 s | role.permissions_changed, delegate.zones_changed, zone.updated, reseller.assigned, broadcast on the Redis channel hoco:cache |
| Settings, app-version policies, reference lists | in-process | 60 s | settings.changed |
| Support regions (wilaya → region and number) | in-process | 10 min | any admin.supportRegions.crud write |
| Guest facet counts per normalised query | Redis | 60 s | TTL only |
| Rate-limit counters | Redis | the limit's window | — |
| Live fan-out of the two SSE streams | Redis pub/sub channels hoco:sse:admin and hoco:sse:user | none (fire and forget) | resume reads OutboxEvent rows instead |
Redis runs with noeviction (BullMQ needs it), so every cache key has a TTL and memory is alerted on at 70 %.
Query clients
The Next apps use TanStack Query through createApiQueryUtils(). Defaults (the scaffold sets staleTime: 30_000 in apps/dashboard/src/utils/orpc.ts):
new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000, // lists and details
gcTime: 5 * 60_000,
refetchOnWindowFocus: true,
retry: (failureCount, error) => isRetryable(error) && failureCount < 2, // never on 4xx
},
mutations: { retry: false }, // a retry re-sends the same Idempotency-Key explicitly
},
});| Query family | Override |
|---|---|
Reference data, permissions (me.get) | staleTime: Infinity, refetched on sign-in and on settings.changed hints |
| Back-office lists and details | default; invalidated by back-office SSE types (e.g. order.status_changed invalidates the order detail, the orders list, the confirmation queue and the delivery board) |
| Back-office KPIs | staleTime: 60_000 plus SSE invalidation |
| Reseller order list and detail (web) | updated in place from the own stream's order.status_changed with setQueryData, then revalidated |
| Catalog lists | placeholderData: keepPreviousData while filters change |
Mobile caches are Imad's: the reseller app caches reference data, catalog pages and images for read-only browsing with an offline banner; the delegate app keeps its offline scopes in drift (mobile offline sync).
File flows
Every file is a File row plus an object in one of two buckets (ADR-009). Limits come from FilePurpose:
| Value | UI label (fr) | Meaning |
|---|---|---|
PRODUCT_IMAGE | Image produit | Public bucket, ≤ 5 MB, JPEG/PNG/WebP. |
BRAND_LOGO | Logo de marque | Public bucket. |
BANNER_IMAGE | Bannière | Public bucket. |
STORE_PHOTO | Photo du magasin | Private, ≤ 5 MB. |
VISIT_PHOTO | Photo de visite | Private, ≤ 5 MB. |
STORE_DOCUMENT | Document du magasin | Private, ≤ 10 MB, PDF/JPEG/PNG. |
PAYMENT_PROOF | Justificatif de paiement | Private, ≤ 10 MB. |
DELIVERY_PROOF | Preuve de livraison | Private. |
TICKET_ATTACHMENT | Pièce jointe | Private, ≤ 10 MB. |
AVATAR | Photo de profil | Private. |
GENERATED_DOCUMENT | Document généré | Private: invoices, delivery notes, exports. |
IMPORT_FILE | Fichier d'import | Private: Excel/CSV. |
CATALOG_PDF | Catalogue PDF | Public, ≤ 30 MB, PDF: the catalogue edition of a NEW_CATALOGUE announcement (SN-19). |
ANNOUNCEMENT_IMAGE | Image d'annonce | Public, ≤ 5 MB, image: the picture of an announcement (BO-57, SRV-41). |
Upload (photos, documents, proofs, attachments):
files.createUpload with purpose, mimeType, size and — from the mobile apps — a client-generated UUIDv7 id for the File. The server checks the purpose's type and size limits, creates the File in status pending, and returns its id plus a pre-signed PUT URL valid 10 minutes on the right bucket. Calling again with the same file id while it is still pending returns a fresh URL for the same file — no orphan, even after a URL expired offline.
The client uploads the bytes directly to storage with the returned headers. Mobile compresses images and strips EXIF data before upload.
files.completeUpload: the server reads the object's metadata (HEAD), rejects and deletes it if the size or type does not match, marks the file uploaded, and enqueues the files job (magic-byte check, re-encoding without metadata, thumbnails such as w320 and w960 in WebP).
The owning operation attaches the file (delegate.visits.addPhoto, profile.documents.add, payments.declare, tickets.create, returns.create…). Files never attached are deleted after 7 days; pending ones after 24 hours (files.purgeOrphans, hourly).
| Flow | How |
|---|---|
| Private read | files.downloadUrl checks that the caller can read the owning record (same scope rules as the record itself), then returns a signed GET valid 5 minutes. Order documents go through orders.document, which returns the signed URL of the invoice or delivery-note PDF. |
| Public assets | Product images, brand logos and banners are uploaded by the back-office to the public bucket and served from its public base URL with immutable caching. |
| Generated documents | Invoices, delivery notes, credit notes and picking lists are rendered by the worker (documents queue) from the stored snapshot, stored as GENERATED_DOCUMENT, and linked to their record; regenerating reproduces the same content. |
| Exports | admin.exports.create → ExportJob → exports queue → private file → in-app notification and signed URL; files expire after exports.retentionDays (7). |
| Imports | Upload an IMPORT_FILE → admin.imports.create (dry run: row-level report, nothing written) → apply as a second run through the owning modules' services → error rows exported. |
Object keys are {purpose}/{yyyy}/{mm}/{fileId}.{ext}: no names, phones or other personal data in keys.
Request lifecycle
Every request crosses the same chain. Steps 1–3 exist in the scaffold (apps/server/src/app.ts); the others land with SRV-04 to SRV-06.
| # | Step | Where | Does | Can answer |
|---|---|---|---|---|
| 1 | Request id | Express middleware | Reuses a valid X-Request-Id or generates one; returned in the response header and in every error envelope | — |
| 2 | Logging | pino-http | One access line per request: id, method, route, status, duration, then user, app and app version once known | — |
| 3 | CORS and security headers | cors, helmet | Origins from CORS_ORIGINS with credentials. Allowed request headers must include Authorization, Content-Type, Accept-Language, Idempotency-Key, X-App-Version, X-App-Platform, X-Device-Id, X-Request-Id, Last-Event-ID; exposed: X-Request-Id, Idempotent-Replayed, Retry-After, Deprecation, Sunset | preflight 204 |
| 4 | Limits | Express | JSON body at most 1 MB; request timeout 30 s (the two SSE routes exempt) | 413, 503 |
| 5 | Rate limit | Redis sliding window | 600 requests per minute per user; per IP for guests and public routes; sign-in and OTP limits enforced inside the auth procedures (keyed by phone + IP) | 429 RATE_LIMITED + Retry-After |
| 6 | Session | Better Auth (getSession from bearer or cookie) | Resolves user, session and account status; slides expiry. Optional-auth routes continue as guest; public routes skip. Cookie-authenticated mutations must come from an allowed Origin | 401 UNAUTHENTICATED, 403 ACCOUNT_SUSPENDED, 403 PERMISSION_DENIED (foreign origin) |
| 7 | App-version gate | Express middleware, mobile requests only (X-App-Platform) | Compares X-App-Version with AppVersionPolicy; maintenance mode | 426 UPGRADE_REQUIRED, 503 MAINTENANCE |
| 8 | Idempotency | Express middleware, mutating methods | Idempotency rules; a missing required key is a validation error | replay with Idempotent-Replayed: true, 409 IDEMPOTENCY_KEY_REUSED, 409 IDEMPOTENCY_IN_PROGRESS, 400 VALIDATION_FAILED |
| 9 | oRPC handler | OpenAPIHandler under /v1 (http/v1.ts) | Matches the contract route, decodes path, query and body | 404 NOT_FOUND |
| 10 | Permission and scope guard | oRPC middleware declared before input validation | Checks the permission key from the procedure's metadata and the user type; resolves ctx.scope (self, delegate); blocks ordering for accounts pending validation | 403 PERMISSION_DENIED, 403 ACCOUNT_PENDING_VALIDATION, 403 RESELLER_SUSPENDED |
| 11 | Input validation | Zod schemas of the contract (strict objects) | Rejects unknown fields, wrong types, out-of-range values | 400 VALIDATION_FAILED with details.fields |
| 12 | Module service | modules/<name>/service.ts | Rules, state machines, withTx | 409 and 422 business codes |
| 13 | Prisma | repo.ts | Scoped queries (out-of-scope records read as absent), version checks, row locks | 404 NOT_FOUND, 409 VERSION_CONFLICT, 409 PHONE_ALREADY_EXISTS |
| 14 | Outbox, audit, idempotency completion | withTx, before commit | Inserts OutboxEvent and AuditLog rows, completes the idempotency record | — |
| 15 | Response | oRPC encoder (customErrorResponseBodyEncoder) | Output encoded; errors in the envelope with the request id | — |
Error handling
Every error leaves the API as an HTTP status and the envelope defined on the API reference: { "error": { "code", "message", "details", "requestId" } }. Codes are stable UPPER_SNAKE_CASE and registered on Errors; messages are localised from Accept-Language (French by default) through server catalogs keyed by code, with details values interpolated.
| Source | Becomes |
|---|---|
Contract-declared error (.errors({ STOCK_INSUFFICIENT: … }) thrown as ORPCError) | its declared status and code; details typed in the OpenAPI document (customErrorResponseBodySchema in http/openapi.ts) |
| Zod input validation | 400 VALIDATION_FAILED, details.fields: [{ path, code, message }] |
| Unmatched route | 404 NOT_FOUND |
Prisma P2025 (record not found or out of scope) | 404 NOT_FOUND |
Prisma P2002 (unique violation) | 409 with the code mapped from the constraint (PHONE_ALREADY_EXISTS…); an unmapped constraint is a bug, logged as such |
Prisma P2034 (write conflict, deadlock) | retried twice inside withTx, then 500 INTERNAL_ERROR |
| Version mismatch | 409 VERSION_CONFLICT with details.current |
| Anything unexpected | 500 INTERNAL_ERROR, logged with stack and sent to Sentry; the message never exposes internals |
Align the scaffold with the registry
Today the scaffold returns oRPC's own codes for some cases: BAD_REQUEST with details.issues for invalid input, INTERNAL_SERVER_ERROR for unhandled errors, HTTP_<status> for Hono exceptions. SRV-04 maps them to the registry: VALIDATION_FAILED with details.fields, INTERNAL_ERROR.
Client retry rule (all apps): retry only network failures, 408, 429 (after Retry-After), 409 IDEMPOTENCY_IN_PROGRESS (after a second) and 5xx, always with the same Idempotency-Key, with exponential backoff; never retry other 4xx. Logging rule: 4xx at info (bursts of 401/403 per IP at warn), 5xx at error with stack and Sentry.
Observability
| Signal | V1 implementation |
|---|---|
| Logs | pino JSON to stdout (infra/logger.ts, service: "hoco-server"), collected by Docker/Dokploy. Fields: time, level, requestId, userId, userType, app, appVersion, method, route (template, not raw path), status, ms, opId, jobName, eventKey. Redacted: authorization, cookie, passwords, OTP codes, session tokens, signed URLs; phones masked (+213555***456). Retention 30 days (assumption, with the privacy policy). |
| Metrics | Prometheus format on /metrics, internal port only (proposal: prom-client): HTTP requests and durations per route and status; DB pool usage; outbox lag; queue depth, oldest job age and failures per queue; last success per scheduled job; open SSE connections per stream; sync item results and lag; notification deliveries per channel and result; orders waiting for confirmation and the oldest one's age. |
| Traces | Sentry performance (sample rate 0.1 in production) with Prisma instrumentation; OpenTelemetry export can be added later without code changes in modules. |
| Errors | Sentry in the server, the Next apps and both Flutter apps; release = COMMIT_SHA, environment = APP_ENV; user id only, no personal data. |
| Health | /health (liveness, exists); /ready (database SELECT 1, Redis PING, migrations applied) for the platform's health check; GET /v1/system/health and /v1/system/version in the contract; the worker's /health on its internal port. |
| Uptime | An external monitor on /health of each public host, plus heartbeat URLs pinged by the scheduler and by the backup check (jobs and scheduler). |
| Alert | Threshold (initial) | To |
|---|---|---|
| API errors | 5xx above 2 % of requests for 5 minutes | team chat + Sentry |
| API latency | p95 above 1 s for 10 minutes on any route group | team chat |
| Outbox lag | above 30 s for 5 minutes | team chat |
| Queue backlog | oldest events job older than 5 minutes; exports older than 30 minutes | team chat |
| Dead-letter entry | any new entry | team chat + Sentry |
| Missed schedule | now − last success above twice the job's period | team chat |
| Backup | no fresh dump at the 06:00 check | team chat + e-mail |
| Redis memory, disk | above 70 % / 80 % | team chat |
| Orders waiting for confirmation | oldest SUBMITTED order older than 2 hours during working hours (setting) | validators, in-app (business alert) |
| TLS | certificate expiring within 14 days |
Performance targets (NFR-01): server-side p95 under 300 ms for catalog reads and facets, under 800 ms for writes, under 1.5 s for checkout — verified by the load test of SRV-28 and detailed with the other cross-cutting targets on cross-cutting requirements.
Configuration
Two kinds of configuration, never mixed:
- Environment variables — infrastructure, endpoints and secrets. One
.envat the repository root locally (cp .env.example .env; found by@hoco/env/loadfrom any folder); per-service variables in Dokploy elsewhere. Validated at boot by@hoco/env/server(t3-env + Zod): a missing or malformed value stops the process with a clear error. - Business settings —
Settingrows edited in the back-office (BO-64), validated, audited and cached 60 s: order minimum, reservation and deduction statuses, invoice trigger, GPS tolerance, sampling, retention, return window, assisted-order delay, payment methods, credit policy… Never an environment variable.
| Variable | Used by | Local value | Production | Notes |
|---|---|---|---|---|
NODE_ENV | all | development | production | also test |
LOG_LEVEL | server | info | info | fatal … trace, silent |
PORT | server, Next apps | 3100 (server default) | 3000 (set by the image) | dashboard 3101, web 3102 in dev scripts |
COMMIT_SHA | server | — | build argument | shown by GET /v1/system/version, Sentry release |
APP | container | — | server, worker, dashboard, web | selects the process in docker/start.sh (worker branch to add) |
DATABASE_URL | server, packages/db | postgresql://postgres:postgres@localhost:5442/hoco | Dokploy Postgres internal URL | required |
REDIS_URL | server | redis://localhost:6390 | Dokploy Redis internal URL | required once queues land |
S3_ENDPOINT, S3_REGION | server | http://localhost:9100, us-east-1 | provider endpoint and region | MinIO locally |
S3_ACCESS_KEY, S3_SECRET_KEY | server | minioadmin / minioadmin | secret | |
S3_BUCKET_PUBLIC, S3_BUCKET_PRIVATE | server | hoco-public, hoco-private | per environment | |
BETTER_AUTH_SECRET | server | placeholder | secret, at least 32 characters | rotating it signs everyone out |
BETTER_AUTH_URL | server | http://localhost:3100 | https://api.hoco.dz | |
API_PUBLIC_URL | server | http://localhost:3100 | https://api.hoco.dz | OpenAPI servers, links in messages |
CORS_ORIGINS | server | http://localhost:3101,http://localhost:3102 | https://admin.hoco.dz,https://app.hoco.dz | also Better Auth trusted origins |
DASHBOARD_URL, WEB_URL | server | http://localhost:3101, http://localhost:3102 | https://admin.hoco.dz, https://app.hoco.dz | links in notifications, invitation links |
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM | worker | Mailpit localhost:1125 (UI :8125) | provider | |
SMS_PROVIDER, SMS_API_KEY | worker | empty | provider to choose | |
SIMULATE_SMS | worker | true | never true | SMS logged instead of sent |
FIREBASE_PROJECT_ID, FIREBASE_CLIENT_EMAIL, FIREBASE_PRIVATE_KEY | worker | empty | per environment's Firebase project | push |
GOOGLE_MAPS_API_KEY | server | empty | server key (geocoding only) | ADR-015 |
NEXT_PUBLIC_API_URL | dashboard, web | http://localhost:3100 | https://api.hoco.dz | inlined at build time (build argument) |
APP_ENV (planned) | all | local | staging, preprod, production | NODE_ENV is production in every deployed tier |
COOKIE_DOMAIN, COOKIE_PREFIX (planned) | server | unset, hoco_dev | .hoco.dz, hoco | distinct prefix per tier (ADR-005) |
S3_PUBLIC_URL, S3_FORCE_PATH_STYLE (planned) | server | http://localhost:9100/hoco-public, true | CDN base URL, provider default | public asset URLs; path-style for MinIO |
API_INTERNAL_URL (planned) | dashboard, web | http://localhost:3100 | internal Docker URL of api | server-side calls, never exposed to browsers |
SENTRY_DSN, NEXT_PUBLIC_SENTRY_DSN (planned) | all | empty | per project | |
NEXT_PUBLIC_MAPS_API_KEY (planned) | dashboard | empty | referrer-restricted browser key | separate from the server key |
SYNC_CURSOR_SECRET (planned) | server | any | secret | signs delta cursors (offline sync) |
WORKER_HEALTH_PORT (planned) | worker | 3200 | 3200 | internal only |
HEARTBEAT_URL_SCHEDULER, HEARTBEAT_URL_BACKUP (planned) | worker | empty | uptime-monitor push URLs | dead man's switches |
BACKUP_S3_BUCKET, BACKUP_S3_PREFIX (planned) | worker | empty | off-site backup location | read by ops.verifyBackup |
Variables marked planned are not in .env.example yet; each is added there and in @hoco/env/server (or the Next app's schema) by the task that introduces it.
Cross-cutting requirements
Non-functional, security and privacy requirements in measurable form — performance budgets, availability and backups, scalability, compatibility, responsive back-office, i18n and RTL, accessibility, exports, logging and alerting, security controls, privacy and quality gates — each with its acceptance and its verification.
Service catalogue
The 24 modules of the HOCO API server — responsibility, owned models, operations, events, jobs, settings, consumers, tasks and risks — and the catalogue of domain events and their SSE projections.