HOCO B2B
SRS

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.

ApplicationMayMay not
@hoco/server — api processOwn 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 startCall 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 processRun BullMQ processors and job schedulers, dispatch the outbox, call providers (FCM, SMS, SMTP, storage), render PDFs and images, build exports and run importsServe 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.jsonAny 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 linkCompute 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 photosCreate 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/docsRead content/spec/*.json and content/plan/tasks.json; render pagesCall the API; contain secrets; be part of the Docker images

Shared packages

PackageHoldsDepends on (workspace)Used by
@hoco/configtsconfig.base.json (strict, noUncheckedIndexedAccess, ESM)—every package and app (dev)
@hoco/sharedPure, 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/envValidated environment per runtime: ./server, ./dashboard, ./web, and ./load which finds the root .env—server (./server), dashboard, web
@hoco/dbMulti-file Prisma schema (prisma/schema/*.prisma), migrations, the generated client (prisma/generated), prisma.config.ts—server only (and its scripts)
@hoco/contractsThe 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-clientcreateApiClient() — oRPC client over the OpenAPI link, credentials: "include", error envelope decoded into ORPCError — and createApiQueryUtils() for TanStack Query@hoco/contractsdashboard, web
@hoco/uiDesign system for the Next apps: Base UI primitives, CVA variants, Tailwind 4 tokens from Akram's design system, hooks—dashboard, web

Dependency rules

  1. Only apps/server depends on @hoco/db. The Next apps have no Prisma client and no DATABASE_URL (ADR-003).
  2. @hoco/contracts is pure. Zod, @orpc/contract and @hoco/shared only — no server code, no Prisma types, no Node APIs — so it can be bundled into browsers and exported to OpenAPI without a running server.
  3. @hoco/shared has no dependencies and no I/O; it is the only code that runs identically on the server, in browsers and in tests.
  4. @hoco/ui knows nothing about the API. Data fetching lives in the apps.
  5. Server-only secrets never reach a browser bundle. @hoco/env/dashboard and @hoco/env/web expose only NEXT_PUBLIC_* client variables; anything else read in a client component fails the build.
  6. 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

EntityOwnerWritten byRead by
Useridentityauth.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
Sessionidentityauth.login, auth.logout, auth.sessions.revoke, admin.users.sessions, suspension and password change (revocation), identity.purgeExpiredSessionssession step of every request (cached in Redis)
Account, Verificationidentity (Better Auth)sign-in credentials, auth.otp.send, auth.otp.verify, password reset and changeBetter Auth
Deviceidentityme.devices.register, me.devices.remove, auth.logout, invalid-token reports from notificationsnotifications (push targets), sync (last sync), audit
Role, Permission, RolePermission, UserRoleaccessseeds, admin.roles.updatePermissions, role assignment in admin.users.create / updatepermission guard, me.get, BO roles matrix
NotificationPreferencenotificationsnotifications.preferences.updatenotifications

Geography and zones

EntityOwnerWritten byRead by
Wilaya, Communegeoversioned seed onlyevery app (reference.*, ETag), crm, support (region resolution through geo.nearestCommune()), analytics
Zone, ZoneAreageoadmin.zones.crudaccess (delegate scope), field, crm, analytics
DelegateZonegeozone assignment in admin.delegates.crud (through geo.setDelegateZones())access, sync (scope digest), field
DelegateProfilefieldadmin.delegates.crudfield, delegate.profile, analytics

CRM

EntityOwnerWritten byRead by
Reseller (incl. customerType, validation status, lifecycle status)crmadmin.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, importsevery module in scope: cart and pricing (customer type), orders, support (declared wilaya), field, analytics; RA/RW profile, DA, BO
ResellerAddresscrmaddresses.*, admin.resellers.updatecart (checkout options), orders (snapshot at submission), fulfillment
ResellerDocument, ResellerPhotocrmprofile.documents.add, admin.resellers.documents.add, delegate.resellers.addPhotoBO 360° page, DA sheet
ResellerStatusChangecrmevery lifecycle changetimeline, conversion reports
ResellerAssignmentcrmadmin.resellers.assign, admin.resellers.bulk, field creationaccess (scope), sync (scope at occurredAt), analytics
Interactioncrmadmin.resellers.notes.add, delegate.resellers.addNote, event handlers (orders, returns, visits, payments, tickets, credit notes)admin.resellers.timeline, DA sheet
CrmTaskcrmadmin.tasks.*, delegate.tasks.*, visit reports (crm.upsertFollowUpFromReport()), crm.inactivityScanDA (delta feed), BO, notifications (task.due)
Segment, ResellerSegmentcrmadmin.segments.crud, admin.resellers.bulkpromotions audience and visibility rules (facts passed in by callers), analytics

Catalog and pricing

EntityOwnerWritten byRead by
Brand, Category (tree with sub-categories)catalogadmin.brands.crud, admin.categories.crud, admin.categories.reorder, importsevery app, guests included
Productcatalogadmin.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)catalogadmin.variants.crud, importscatalog endpoints (prices only for signed-in callers, purchase price only with permission), pricing (selectPrice input), cart, orders
ProductImagecatalogadmin.products.update with uploaded filesevery app (public CDN)
AttributeDefinitioncatalogadmin.attributes.crudcatalog.facets, product editor
VariantAttributeValuecatalogadmin.variants.crud, importscatalog filters and facets
ProductVisibilityRulecatalogadmin.products.visibilitycatalog queries, cart
Favoritecatalogfavorites.add, favorites.removefavorites.list, product cards
PriceChangepricingthe variant save (pricing.recordPriceChange() in the same transaction), importsadmin.variants.priceHistory, audit
V2 PriceList, PriceListItempricingV2 per-reseller agreements (PRC-03)—

Stock

EntityOwnerWritten byRead by
Warehouseinventoryadmin.warehouses.crudinventory
StockLevelinventorythe inventory service only: receipts, adjustments, inventory validation, and — called by orders — reservation, release, deduction, re-entry after a late cancellation, return receptionavailability for catalog and cart (IN_STOCK / OUT_OF_STOCK only), BO stock pages (quantities), analytics
StockMovementinventoryone row per variation, in the same transaction as the level changeadmin.stock.movements, analytics, the nightly drift check
InventorySession, InventoryLineinventoryadmin.inventory.crud, admin.inventory.validateBO

Orders and delivery

EntityOwnerWritten byRead by
Cart, CartItemcartcart.*, orders.reorder (through the cart service)cart, checkout
Order, OrderItemorderscart.checkout (orders.placeOrder()), admin.orders.*, delegate.assisted.*, orders.cancel, orders.confirmAssisted, orders.rejectAssisted, after-sales REPLACEMENT resolutions, orders.expireAssistedRA/RW/DA/BO order endpoints, billing and fulfillment (data passed in), analytics
OrderStatusChange, OrderChangeordersevery transition and controlled modificationorders.get timeline, admin.orders.history
ReturnRequest, ReturnItemordersreturns.create, admin.returns.decide, admin.returns.receive, after-sales RETURN resolutionsreturns.*, admin.returns.*, support, analytics
Deliveryfulfillmentcreated by orders at confirmation; admin.deliveries.update; proof and failure through orders' orchestrationBO delivery board, orders.get
Carrierfulfillmentadmin.carriers.crudfulfillment

Finance

EntityOwnerWritten byRead by
Invoice, InvoiceLinebillingbilling.issueInvoice() called by orders when the order reaches billing.invoiceOn; admin.orders.documents; admin.invoices.cancelinvoices.*, orders.document, finance.summary, analytics
Payment, PaymentAllocationbillingpayments.declare, admin.payments.record, admin.payments.verify, admin.payments.allocatefinance endpoints, crm timeline (events), analytics
CreditAccount, CreditLimitChangebillingadmin.credit.update; exposure recomputed by billing on invoice and payment changescart (credit availability), orders (confirmation check), finance.summary
CreditNotebillingbilling.issueCreditNote() at return reception and for after-sales CREDIT_NOTE resolutionsreturns.get, invoices.get, finance pages, analytics
V2 PaymentReminderbillingV2 reminders job—

Promotions and loyalty

EntityOwnerWritten byRead by
Promotion, PromotionScope, PromotionAudiencepromotionsadmin.promotions.*, promotions.applySchedule, cap consumption at checkout, cap release handlerspromotions.*, cart engine, analytics
Bannerpromotionsadmin.banners.crudbanners.list, catalog.home
V2 Pack, PackItem · LoyaltyAccount, LoyaltyTransaction, Reward, RewardRedemption, Voucherpromotions · loyaltyV2V2

Field operations and invitations

EntityOwnerWritten byRead by
Objectivefieldadmin.objectives.cruddelegate.objectives, analytics
Tour, TourStatusChangefieldadmin.tours.*, delegate.tours.* (online or through sync.batch), tours.activateToday, field.autoSuspendOpenTours, the account.suspended handlerDA (delta feed), BO, analytics
Visit, VisitChecklistItem, VisitPhoto, VisitReportfielddelegate.visits.* and delegate.tours.addVisit (online or replayed), admin.tours.setStops, admin.visits.unlockDA, BO, crm (through events), analytics
VisitType, ChecklistTemplatefieldadmin.visitTypes.crud, admin.checklists.crudDA (reference scope of the delta feed), BO
LocationPingfieldsync.locationPings (field.ingestPings()), field.purgeLocationPingsadmin.tours.track, admin.live.delegates, tour kilometres
Invitation, InvitationEventinvitationsdelegate.invitations.create / markShared, invitations.attribute, handlers of account.created and order.submitted, the first catalog viewDA, BO funnel, analytics

Support and notifications

EntityOwnerWritten byRead by
Ticket, TicketItem, TicketMessagesupporttickets.*, admin.tickets.* (including admin.tickets.resolve), support.autoClose, system messages from order and return handlersRA/RW, BO, crm timeline (events)
SupportRegion, SupportRegionWilayasupportadmin.supportRegions.crudsupport.whatsapp, support.contacts
Announcement, AnnouncementReadsupportadmin.announcements.*, announcements.markRead, support.publishScheduledAnnouncementsannouncements.list, DA delta feed
Notification, NotificationDeliverynotificationsevent handlers, notifications.markRead / markAllRead, channel workers, notifications.reconcileDeliveriesinbox endpoints, admin.notifications.log
NotificationTemplate, NotificationRulenotificationsseeds, admin.notifications.templates, admin.notifications.rulesnotifications

Platform

EntityOwnerWritten byRead by
Filefilesfiles.createUpload, files.completeUpload, worker (generated documents, exports, thumbnails), files.purgeOrphansfiles.downloadUrl; owning records reference fileId
AuditLogauditctx.audit() inside every module's transactionsadmin.audit.list
Setting, ReferenceItem, AppVersionPolicysettingsadmin.settings.update, admin.referenceLists.crud, admin.appVersions.crud, seedsevery module (cached), appConfig.get, reference.lists, the app-version gate
NumberSequencesettingssettings.nextNumber(tx, type) called by the module creating the numbered record—
IdempotencyRecordsyncthe idempotency step, sync.batch items, sync.purgeIdempotencyRecordsthe idempotency step
OutboxEventjobsctx.emit() in every module, the dispatcher (dispatchedAt, attempts, availableAt), jobs.purgeDispatchedOutboxdispatcher, event handlers, SSE resume
ExportJob, DashboardLayoutanalyticsadmin.exports.create and export wrappers, the exports queue, exports.expire; admin.dashboard.layout.updateadmin.exports.*, admin.dashboard.layout.get
ImportJobintegrationsadmin.imports.create, admin.products.import, the imports queueadmin.imports.get

Consistency rules

Transactions

  • One command, one transaction. A mutating procedure runs its whole effect inside withTx(ctx, fn) — a Prisma interactive transaction with maxWait 2 s and timeout 10 s, isolation READ COMMITTED. Cross-module invariants are service calls that receive the same tx.
  • 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 UPDATE in the owner's repository) where two writers can race: the order being transitioned, the cart at checkout, CreditAccount, the promotion cap counter, StockLevel rows, 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-runs fn up to two times — safe because nothing external happened yet — then answers 500 INTERNAL_ERROR, which clients retry with the same Idempotency-Key.
  • Database guards. statement_timeout of 15 s on the application role; idle_in_transaction_session_timeout of 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.

  1. The idempotency step hashes the canonical request (operation id, path parameters, sorted JSON body) and looks up IdempotencyRecord by (caller, key). The caller is the user id, or the device id and IP for public operations such as invitations.attribute.
  2. Completed, same hash → the stored status and body are replayed with Idempotent-Replayed: true; nothing runs.
  3. Same key, different hash → 409 IDEMPOTENCY_KEY_REUSED.
  4. In progress (lock not expired) → 409 IDEMPOTENCY_IN_PROGRESS; the client retries after a second.
  5. 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.
  6. Completion happens inside the business transaction: withTx writes the response snapshot to the record before commit, so a crash can never leave a change committed without its replay record.
  7. What is stored: 2xx responses and business refusals (400, 403 OUT_OF_SCOPE, 404, 409 conflicts, 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.
  8. 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.

RuleConstraintError when violated
Main reseller phone unique after normalisation (cahier §4.2)unique index on Reseller.phone (E.164)409 PHONE_ALREADY_EXISTS
Business numbers uniqueunique index per number columnnever expected (500)
One tour per delegate and date; one tour in progress per delegateunique (delegateId, date); partial unique on status IN_PROGRESS/SUSPENDED409 TOUR_DATE_TAKEN, 409 TOUR_ALREADY_IN_PROGRESS
One delivery per orderunique Delivery.orderId500
Stock never negativecheck reserved >= 0, physical - reserved >= 0 unless an override movement is recorded422 STOCK_INSUFFICIENT
Claimed + returned quantity never above delivered quantitychecked in the claim and return transactions under the order lock422 QUANTITY_NOT_ALLOWED
Client-generated ids are UUIDv7 and uniqueprimary key; version and time checked in the contract400 VALIDATION_FAILED
History is never deletedno 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

WhatConsistencyTarget
The response to the callerstrong (same transaction)—
Notifications, CRM lifecycle automation, product out-of-stock status, invitation stepseventual through the outboxp95 under 2 s after commit
Back-office and reseller live streamseventualp95 under 2 s; a missed event costs a refetch
Dashboards "today"live queries—
Daily aggregatesrebuilt nightly, the last three days recomputedprovisional for 3 days because offline data can arrive 72 h late
Delegate devicesdelta pulls and silent push hintsminutes 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

ResourceCache-ControlValidatorNotes
reference.wilayas, reference.communespublic, max-age=86400ETag (seed version)304 Not Modified on revalidation
reference.listsprivate, max-age=300ETaginvalidated by settings.changed
appConfig.getno-cacheETagmust reflect maintenance mode and forced updates at once
Catalog, promotions and banners for guests (catalog.*, promotions.*, banners.list with no session)public, max-age=60ETagno price and no quantity in the body, so shareable; Vary: Authorization, Cookie
Catalog and promotions for signed-in callersprivate, no-cacheETagprices and targeting depend on the caller
Cart, orders, returns, finance, tickets, notificationsprivate, no-store—
Product images, brand logos, bannerspublic, max-age=31536000, immutable—a changed image gets a new object key
/openapi.jsonpublic, max-age=300ETag

Server-side caches

DataWhereTTLInvalidation
Session lookupsRedis (Better Auth secondary storage)session expiryrevocation deletes the entry
Role permissions, a delegate's scope (zones, assigned resellers)in-process LRU per api instance60 srole.permissions_changed, delegate.zones_changed, zone.updated, reseller.assigned, broadcast on the Redis channel hoco:cache
Settings, app-version policies, reference listsin-process60 ssettings.changed
Support regions (wilaya → region and number)in-process10 minany admin.supportRegions.crud write
Guest facet counts per normalised queryRedis60 sTTL only
Rate-limit countersRedisthe limit's window—
Live fan-out of the two SSE streamsRedis pub/sub channels hoco:sse:admin and hoco:sse:usernone (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 familyOverride
Reference data, permissions (me.get)staleTime: Infinity, refetched on sign-in and on settings.changed hints
Back-office lists and detailsdefault; 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 KPIsstaleTime: 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 listsplaceholderData: 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:

ValueUI label (fr)Meaning
PRODUCT_IMAGEImage produitPublic bucket, ≤ 5 MB, JPEG/PNG/WebP.
BRAND_LOGOLogo de marquePublic bucket.
BANNER_IMAGEBannièrePublic bucket.
STORE_PHOTOPhoto du magasinPrivate, ≤ 5 MB.
VISIT_PHOTOPhoto de visitePrivate, ≤ 5 MB.
STORE_DOCUMENTDocument du magasinPrivate, ≤ 10 MB, PDF/JPEG/PNG.
PAYMENT_PROOFJustificatif de paiementPrivate, ≤ 10 MB.
DELIVERY_PROOFPreuve de livraisonPrivate.
TICKET_ATTACHMENTPièce jointePrivate, ≤ 10 MB.
AVATARPhoto de profilPrivate.
GENERATED_DOCUMENTDocument généréPrivate: invoices, delivery notes, exports.
IMPORT_FILEFichier d'importPrivate: Excel/CSV.
CATALOG_PDFCatalogue PDFPublic, ≤ 30 MB, PDF: the catalogue edition of a NEW_CATALOGUE announcement (SN-19).
ANNOUNCEMENT_IMAGEImage d'annoncePublic, ≤ 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).

FlowHow
Private readfiles.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 assetsProduct 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 documentsInvoices, 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.
Exportsadmin.exports.create → ExportJob → exports queue → private file → in-app notification and signed URL; files expire after exports.retentionDays (7).
ImportsUpload 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.

#StepWhereDoesCan answer
1Request idExpress middlewareReuses a valid X-Request-Id or generates one; returned in the response header and in every error envelope—
2Loggingpino-httpOne access line per request: id, method, route, status, duration, then user, app and app version once known—
3CORS and security headerscors, helmetOrigins 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, Sunsetpreflight 204
4LimitsExpressJSON body at most 1 MB; request timeout 30 s (the two SSE routes exempt)413, 503
5Rate limitRedis sliding window600 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
6SessionBetter 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 Origin401 UNAUTHENTICATED, 403 ACCOUNT_SUSPENDED, 403 PERMISSION_DENIED (foreign origin)
7App-version gateExpress middleware, mobile requests only (X-App-Platform)Compares X-App-Version with AppVersionPolicy; maintenance mode426 UPGRADE_REQUIRED, 503 MAINTENANCE
8IdempotencyExpress middleware, mutating methodsIdempotency rules; a missing required key is a validation errorreplay with Idempotent-Replayed: true, 409 IDEMPOTENCY_KEY_REUSED, 409 IDEMPOTENCY_IN_PROGRESS, 400 VALIDATION_FAILED
9oRPC handlerOpenAPIHandler under /v1 (http/v1.ts)Matches the contract route, decodes path, query and body404 NOT_FOUND
10Permission and scope guardoRPC middleware declared before input validationChecks the permission key from the procedure's metadata and the user type; resolves ctx.scope (self, delegate); blocks ordering for accounts pending validation403 PERMISSION_DENIED, 403 ACCOUNT_PENDING_VALIDATION, 403 RESELLER_SUSPENDED
11Input validationZod schemas of the contract (strict objects)Rejects unknown fields, wrong types, out-of-range values400 VALIDATION_FAILED with details.fields
12Module servicemodules/<name>/service.tsRules, state machines, withTx409 and 422 business codes
13Prismarepo.tsScoped queries (out-of-scope records read as absent), version checks, row locks404 NOT_FOUND, 409 VERSION_CONFLICT, 409 PHONE_ALREADY_EXISTS
14Outbox, audit, idempotency completionwithTx, before commitInserts OutboxEvent and AuditLog rows, completes the idempotency record—
15ResponseoRPC 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.

SourceBecomes
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 validation400 VALIDATION_FAILED, details.fields: [{ path, code, message }]
Unmatched route404 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 mismatch409 VERSION_CONFLICT with details.current
Anything unexpected500 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

SignalV1 implementation
Logspino 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).
MetricsPrometheus 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.
TracesSentry performance (sample rate 0.1 in production) with Prisma instrumentation; OpenTelemetry export can be added later without code changes in modules.
ErrorsSentry 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.
UptimeAn external monitor on /health of each public host, plus heartbeat URLs pinged by the scheduler and by the backup check (jobs and scheduler).
AlertThreshold (initial)To
API errors5xx above 2 % of requests for 5 minutesteam chat + Sentry
API latencyp95 above 1 s for 10 minutes on any route groupteam chat
Outbox lagabove 30 s for 5 minutesteam chat
Queue backlogoldest events job older than 5 minutes; exports older than 30 minutesteam chat
Dead-letter entryany new entryteam chat + Sentry
Missed schedulenow − last success above twice the job's periodteam chat
Backupno fresh dump at the 06:00 checkteam chat + e-mail
Redis memory, diskabove 70 % / 80 %team chat
Orders waiting for confirmationoldest SUBMITTED order older than 2 hours during working hours (setting)validators, in-app (business alert)
TLScertificate expiring within 14 dayse-mail

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 .env at the repository root locally (cp .env.example .env; found by @hoco/env/load from 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 — Setting rows 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.
VariableUsed byLocal valueProductionNotes
NODE_ENValldevelopmentproductionalso test
LOG_LEVELserverinfoinfofatal … trace, silent
PORTserver, Next apps3100 (server default)3000 (set by the image)dashboard 3101, web 3102 in dev scripts
COMMIT_SHAserver—build argumentshown by GET /v1/system/version, Sentry release
APPcontainer—server, worker, dashboard, webselects the process in docker/start.sh (worker branch to add)
DATABASE_URLserver, packages/dbpostgresql://postgres:postgres@localhost:5442/hocoDokploy Postgres internal URLrequired
REDIS_URLserverredis://localhost:6390Dokploy Redis internal URLrequired once queues land
S3_ENDPOINT, S3_REGIONserverhttp://localhost:9100, us-east-1provider endpoint and regionMinIO locally
S3_ACCESS_KEY, S3_SECRET_KEYserverminioadmin / minioadminsecret
S3_BUCKET_PUBLIC, S3_BUCKET_PRIVATEserverhoco-public, hoco-privateper environment
BETTER_AUTH_SECRETserverplaceholdersecret, at least 32 charactersrotating it signs everyone out
BETTER_AUTH_URLserverhttp://localhost:3100https://api.hoco.dz
API_PUBLIC_URLserverhttp://localhost:3100https://api.hoco.dzOpenAPI servers, links in messages
CORS_ORIGINSserverhttp://localhost:3101,http://localhost:3102https://admin.hoco.dz,https://app.hoco.dzalso Better Auth trusted origins
DASHBOARD_URL, WEB_URLserverhttp://localhost:3101, http://localhost:3102https://admin.hoco.dz, https://app.hoco.dzlinks in notifications, invitation links
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROMworkerMailpit localhost:1125 (UI :8125)provider
SMS_PROVIDER, SMS_API_KEYworkeremptyprovider to choose
SIMULATE_SMSworkertruenever trueSMS logged instead of sent
FIREBASE_PROJECT_ID, FIREBASE_CLIENT_EMAIL, FIREBASE_PRIVATE_KEYworkeremptyper environment's Firebase projectpush
GOOGLE_MAPS_API_KEYserveremptyserver key (geocoding only)ADR-015
NEXT_PUBLIC_API_URLdashboard, webhttp://localhost:3100https://api.hoco.dzinlined at build time (build argument)
APP_ENV (planned)alllocalstaging, preprod, productionNODE_ENV is production in every deployed tier
COOKIE_DOMAIN, COOKIE_PREFIX (planned)serverunset, hoco_dev.hoco.dz, hocodistinct prefix per tier (ADR-005)
S3_PUBLIC_URL, S3_FORCE_PATH_STYLE (planned)serverhttp://localhost:9100/hoco-public, trueCDN base URL, provider defaultpublic asset URLs; path-style for MinIO
API_INTERNAL_URL (planned)dashboard, webhttp://localhost:3100internal Docker URL of apiserver-side calls, never exposed to browsers
SENTRY_DSN, NEXT_PUBLIC_SENTRY_DSN (planned)allemptyper project
NEXT_PUBLIC_MAPS_API_KEY (planned)dashboardemptyreferrer-restricted browser keyseparate from the server key
SYNC_CURSOR_SECRET (planned)serveranysecretsigns delta cursors (offline sync)
WORKER_HEALTH_PORT (planned)worker32003200internal only
HEARTBEAT_URL_SCHEDULER, HEARTBEAT_URL_BACKUP (planned)workeremptyuptime-monitor push URLsdead man's switches
BACKUP_S3_BUCKET, BACKUP_S3_PREFIX (planned)workeremptyoff-site backup locationread 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.

On this page