002 — System Architecture¶
C4 Level 1 — System Context¶
flowchart TB
CU(["Customer Portal User<br/>/ Company Admin"])
AM(["Account Manager<br/>Pricing / Read-only"])
ADM(["ERP / System Admin"])
SYS["<b>SAMTIA B2B E-Commerce Platform</b><br/>Quote-driven B2B trade on Odoo 19"]
MAIL[["SMTP relay<br/>outgoing mail"]]
ODOOSH[["Odoo.sh<br/>backend hosting + CI"]]
CFW[["Cloudflare Workers<br/>production portal hosting + CI/CD"]]
REG[["Private Docker registry<br/>+ internal server"]]
CU -->|"HTTPS storefront"| SYS
AM -->|"HTTPS admin portal<br/>+ Odoo ERP Back Office"| SYS
ADM -->|"Odoo ERP Back Office"| SYS
SYS -->|"quotation PDF, password-reset code,<br/>invitation QR, state alerts"| MAIL
SYS -.->|"backend deployed to"| ODOOSH
SYS -.->|"production portal deployed to"| CFW
SYS -.->|"non-production portal images to"| REG
The platform has no inbound third-party integrations. The only outbound dependency in the runtime path is SMTP.
C4 Level 2 — Containers¶
flowchart TB
subgraph Browser["User browser"]
NEXT["<b>Next.js 15 portal app</b><br/>React 19 · TypeScript · Tailwind 4<br/>Customer Portal + AMP in one build"]
end
subgraph OdooSh["Odoo.sh runtime"]
WEB["<b>Odoo 19 HTTP workers</b><br/>REST controllers + ORM + ERP Back Office UI"]
BUS["<b>Odoo bus / WebSocket</b><br/>/websocket"]
CRON["<b>ir.cron scheduler</b>"]
DB[("PostgreSQL")]
FS[("Filestore<br/>attachments, images")]
end
NEXT -->|"REST · JSON · session cookie<br/>CORS with credentials"| WEB
NEXT <-->|"WebSocket frames<br/>chat · presence · notifications"| BUS
WEB --> DB
WEB --> FS
BUS --> DB
CRON --> DB
WEB --> SMTP[["SMTP"]]
| Container | Technology | Responsibility |
|---|---|---|
| Portal app | Next.js 15 App Router — Cloudflare Worker (OpenNext) in production, standalone container elsewhere | All customer and account-manager screens; no business rules |
| Odoo HTTP workers | Python / Odoo 19 | REST API, business logic, ORM, ERP Back Office UI, PDF rendering |
| Odoo bus | Odoobus.bus + /websocket |
Push channel for chat messages, presence, notifications, grid auto-refresh |
| Scheduler | ir.cron |
Presence hygiene (stale sessions → offline) |
| PostgreSQL | — | Single database; all tenants share it, isolated by data scoping |
| Filestore | Odoo filestore | Product media, chatter attachments, generated PDFs |
C4 Level 3 — Components inside Odoo¶
flowchart TB
subgraph HTTP["HTTP layer"]
CC["controllers/<br/>Customer Portal routes"]
AC["controllers_admin/<br/>AMP routes"]
MC["access_management/controllers<br/>permission lookup"]
BC["<b>lp_base.BaseController</b><br/>envelope · CORS · auth decorators"]
end
subgraph DOMAIN["Domain layer (models)"]
ORD["sale.order + line<br/>portal state machine"]
PRD["product.template / product.product<br/>catalog + pricing projection"]
USR["res.users / res.partner<br/>identity + tenancy"]
MSG["mail.message · discuss.channel<br/>ir.attachment · mail.presence"]
CFG["brand · banner · merchant<br/>notification · alert · user tag<br/>product request line"]
end
subgraph MIX["Cross-cutting mixins"]
SEC["ecommerce.base.secure_model<br/>reversible ID obfuscation"]
REF["ecommerce.base_auto_refresh_model<br/>bus push on write"]
end
CC --> BC
AC --> BC
MC --> BC
BC --> DOMAIN
DOMAIN --> MIX
DOMAIN --> ORM[("Odoo ORM / PostgreSQL")]
Layering rule¶
Controllers are thin. They parse the request, call a single api_* / *_payload method on
a model, and wrap the returned dictionary in the response envelope. All business logic,
validation, tenancy scoping and serialisation live in the models. This is the single most
important convention in the backend — see 006.
Request Lifecycle — a typical authenticated call¶
sequenceDiagram
participant B as Browser (Next.js)
participant C as Odoo controller
participant D as Decorator (lp_base)
participant M as Model (api_* method)
participant DB as PostgreSQL
B->>C: OPTIONS /ecommerce/api/orders
C->>D: preflight
D-->>B: 204 + CORS headers
B->>C: POST /ecommerce/api/orders (cookie)
C->>D: http_auth_validated
alt session is public
D-->>B: 401 · response_code "401"
else authenticated
D->>M: api_get_orders(user_identity, filters)
M->>M: resolve user → company partner
M->>M: build tenant-scoped domain
M->>DB: search + read
DB-->>M: records
M-->>C: {response_code, orders, pagination}
C-->>B: 200 · JSON envelope + CORS
end
Notes:
- Every route is declared
auth='public'at the Odoo level and gated by the decorator instead. This exists so the controller can return the JSON envelope with a business code rather than Odoo's HTML login redirect. - Business failures return HTTP 200 with a non-zero
response_code. Only session expiry returns HTTP 401.
Real-Time Path¶
sequenceDiagram
participant P as Portal (browser)
participant WS as Odoo /websocket
participant BUS as bus.bus
participant M as Model write
P->>WS: connect
P->>WS: subscribe [channels] %% MUST be the first frame
Note over P,WS: Odoo.sh's gateway rejects any frame before subscribe (close 4001)
P->>WS: update_presence (after subscribe)
M->>BUS: _sendone(channel, type, payload)
BUS-->>WS: relay
WS-->>P: frame → store update → UI re-render
Presence is additionally maintained over plain HTTP (/ecommerce/api/presence/ping and
/offline) because the Odoo.sh gateway does not reliably deliver socket-close events. A
one-minute cron sweeps presences whose heartbeat went stale. See
012.
Integration Contract Between the Two Applications¶
| Concern | Contract |
|---|---|
| Transport | HTTPS,Content-Type: application/json (multipart for uploads) |
| Base paths | /ecommerce/api/* (customer), /ecommerce/api/admin/* + /api/admin/* + /ecommerce/admin/api/* (AMP), /amm/api/* (permissions) |
| Auth | Odoo session cookie, sent withwithCredentials: true |
| Envelope | { "response_code": "0", "response_message": "...", ...payload } |
| Success | response_code === "0" |
| Errors | 5xxx–6xxx business codes, HTTP 200;"401" with HTTP 401 = session expired |
| Pagination | page (default 1), limit (default 20); response carries a pagination block |
| Language | en_US only — the portals send and render a single language |
| Identifiers | Optionally obfuscated — see016 |
| Real-time | wss://<api-host>/websocket, derived automatically from the API base URL |
The frontend enforces one global rule: a response_code of "401" clears local storage and
redirects to /login.