System architecture
Three clients talk to one Fastify API. The web app is server-rendered at the edge (CloudFront + Lambda); the mobile app calls the API directly. The API owns all business logic and is the only thing that touches the database, cache, media store, and third-party services.
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#FFF3EC','primaryBorderColor':'#EE8A54','primaryTextColor':'#1C1917','lineColor':'#B0A99F','secondaryColor':'#EEF3F0','tertiaryColor':'#F3F1ED','clusterBkg':'#FBFAF8','clusterBorder':'#E1DCD4'}}}%%
flowchart TB
subgraph CL["Clients"]
W["Web app
Next.js"]
M["Mobile app
React Native"]
AD["Admin panel
/admin (in web)"]
end
subgraph EDGE["AWS edge"]
CF["CloudFront CDN"]
LM["Lambda
SSR + media"]
end
API["Fastify API
REST + WebSocket
ECS Fargate"]
subgraph DATA["Data"]
PG[("PostgreSQL
RDS")]
RD[("Redis
cache · rate-limit")]
S3[("S3
media")]
end
subgraph EXT["External services"]
SAT["SATIM
CIB payments"]
TS["Cloudflare
Turnstile"]
OA["Google · Apple
OAuth"]
PUSH["FCM · APNs
push"]
SES["AWS SES
email"]
end
W --> CF --> LM --> API
AD --> CF
M --> API
API --> PG
API --> RD
API --> S3
S3 --> CF
W -. captcha .-> TS
API --> SAT
API --> OA
API --> PUSH
API --> SES
Authentication & sessions
Users sign up with email + password or with Google / Apple. The API issues a short-lived access token (JWT) plus a refresh token. Admins must enrol in 2FA (TOTP), and sensitive money / admin actions demand a fresh 2FA "step-up". Logging out or resetting a password invalidates existing access tokens.
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#FFF3EC','primaryBorderColor':'#EE8A54','primaryTextColor':'#1C1917','lineColor':'#B0A99F','tertiaryColor':'#F3F1ED','clusterBkg':'#FBFAF8','clusterBorder':'#E1DCD4'}}}%%
flowchart TB
start(["Visitor"]) --> choice{"Sign up or log in?"}
choice -->|"Email"| pw["Email + password"]
choice -->|"Social"| oauth["Google / Apple OAuth"]
pw --> verify["Verify email
(link)"]
oauth --> link["Link / create
oauth_identities"]
verify --> issue["Issue JWT access (7d)
+ refresh token"]
link --> issue
issue --> role{"Admin?"}
role -->|"No"| user["Authenticated user"]
role -->|"Yes"| twofa["2FA enrolment
required (TOTP)"]
twofa --> admin["Admin session"]
admin --> stepup{"Sensitive action?
money · RBAC · KYC"}
stepup -->|"Yes"| fresh["Require fresh 2FA
step-up"]
fresh --> act["Action allowed"]
user --> logout["Logout / password reset"]
logout --> revoke["Access tokens invalidated
(tokens_invalid_before)"]
- Token boundaries: a 2FA-challenge token must never act as a full session; an old access token must stop working after logout / password reset.
- Step-up: refund, dispute, RBAC grants and org KYC all require a fresh 2FA, not just enrolment.
- Social: same email via password and via Google should resolve to one account, not two.
Listing lifecycle
A seller composes a listing, uploads photos (which flow through the media pipeline), and submits. Content passes moderation — banned-word and image checks, with an AI-flag queue for review — before it goes live and becomes searchable. Buyers then discover it and contact the seller.
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#FFF3EC','primaryBorderColor':'#EE8A54','primaryTextColor':'#1C1917','lineColor':'#B0A99F','tertiaryColor':'#F3F1ED'}}}%%
flowchart LR
compose["Seller composes
(deposer)"] --> photos["Upload photos
→ media pipeline"]
photos --> submit["Submit listing"]
submit --> mod{"Moderation
banned words · image · AI flag"}
mod -->|"Flagged"| queue["Admin review queue"]
queue -->|"Approve"| live
queue -->|"Reject"| rejected["Rejected
seller notified"]
mod -->|"Clean"| live["Published · live"]
live --> index["Indexed for search
+ saved-search alerts"]
index --> view["Buyer views listing"]
view --> contact["Contact seller
chat / call"]
live --> boost["Optional: boost
→ Payments (§5)"]
- States: draft → pending → published → rejected / expired / removed — each should render and behave correctly on web + mobile.
- Moderation: banned words block; an AI-flagged item lands in the admin queue, not live.
- 69 wilayas (not 58) everywhere a location is shown or filtered.
Search & messaging
Discovery is faceted search with relevance ranking and keyset pagination; buyers can save a search to be alerted on new matches. Contacting a seller opens a conversation — text, images and documents — and delivers a push notification that deep-links straight into the chat.
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#FFF3EC','primaryBorderColor':'#EE8A54','primaryTextColor':'#1C1917','lineColor':'#B0A99F','tertiaryColor':'#F3F1ED','clusterBkg':'#FBFAF8','clusterBorder':'#E1DCD4'}}}%%
flowchart TB
subgraph SRCH["Search & discovery"]
q["Query + facets
category · wilaya · price · features"] --> eng["Search engine
relevance · keyset pagination"]
eng --> res["Results"]
res --> save["Save search"]
save --> alert["New match →
saved-search alert (email)"]
end
subgraph MSG["Messaging"]
open["Buyer contacts seller"] --> conv["Conversation"]
conv --> msg["Messages
text · image · document"]
msg --> push["Push notification
FCM / APNs"]
push --> deep["Deep link → chat
(web + mobile)"]
end
res --> open
- Pagination: "load more" must keep the active filters (no stale-filter bleed) and never skip or duplicate rows.
- Saved-search alerts fire only on genuinely new matches after the save.
- Deep links: a chat push opens the correct conversation from a cold start on iOS + Android.
Payments & monetization
Paid products (listing boosts, shop boosts, photo packs, subscriptions) go into a cart. Online card payment runs through SATIM (Algeria's CIB gateway): a dedicated pay page collects consent + an anti-bot check, the server registers the order and redirects to SATIM's hosted card page, then confirms the outcome server-side before granting anything. A manual bank-transfer path is the fallback. Every paid cart produces a fiscal invoice (facture) and a receipt.
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#FFF3EC','primaryBorderColor':'#EE8A54','primaryTextColor':'#1C1917','lineColor':'#B0A99F','tertiaryColor':'#F3F1ED','clusterBkg':'#FBFAF8','clusterBorder':'#E1DCD4'}}}%%
flowchart TB
buy["Buy boost / subscription / photo pack"] --> cart["Cart (pending)"]
cart --> mode{"SATIM online enabled?"}
mode -->|"No"| manual["Manual bank transfer
instructions"]
manual --> adminpaid["Admin marks paid"]
mode -->|"Yes"| pay["Pay page
amount · CGV consent · Turnstile · CIB button"]
pay --> reg["checkout-session
verify captcha → register with SATIM"]
reg --> hosted["Redirect to SATIM
hosted card page"]
hosted --> ret["Return to site"]
ret --> confirm["Server confirm
acknowledgeTransaction"]
confirm --> ok{"Accepted?
OrderStatus 2"}
ok -->|"Yes"| grant["Grant benefit
markCartPaid"]
ok -->|"No"| reject["Rejected page
reason + SATIM 3020"]
adminpaid --> grant
grant --> inv["Invoice (facture)
+ receipt: print / PDF / email"]
reg -. closed tab .-> recon["Reconcile job
(safety net)"]
recon --> confirm
- Grant only on success: only an accepted result (
OrderStatus 2) grants; every declined test card must refuse with no benefit and no invoice. - Server-truth: the outcome and amount come from the server confirm, never the browser redirect.
- Receipt (SATIM cert): accepted page shows status, transaction id, order ref, approval code, CIB, date+time, amount, and the
3020line; print / download-PDF / email all work. - Money units: cart/pricing are whole DZD; invoices/ledger are centimes — the ×100 boundary must hold.
Admin & moderation
The admin panel lives inside the web app under /admin, gated server-side by role and fine-grained permissions (a legacy super-admin holds a * wildcard). Staff moderate listings and media, resolve user reports (which can suspend a listing or block a user), review fraud signals, run finance, and handle organization KYC — with a 2FA step-up on the dangerous actions.
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#FFF3EC','primaryBorderColor':'#EE8A54','primaryTextColor':'#1C1917','lineColor':'#B0A99F','tertiaryColor':'#F3F1ED','clusterBkg':'#FBFAF8','clusterBorder':'#E1DCD4'}}}%%
flowchart TB
admin["Admin / staff"] --> gate["/admin — role + permission gate
2FA enrolled"]
gate --> dash["Dashboards
KPIs · revenue · funnels"]
gate --> modq["Moderation queue
listings · media · banned words"]
gate --> reports["Reports → resolve
suspend listing / block user"]
gate --> fraud["Fraud signals
repeat offenders · velocity"]
gate --> fin["Finance
carts · payments · invoices · leads"]
gate --> orgs["Organizations
KYC verify / reject"]
reports -. dangerous .-> su["2FA step-up"]
fin -. money .-> su
orgs -. KYC .-> su
- Authorization is two-source:
users.role(legacy) and access-groups — don't judge access from the groups UI alone. - Report actions really act: "listing removed" suspends the listing; "user blocked" blocks the user + seller.
- Every dangerous mutation is gated on its own permission and a fresh 2FA.
Infrastructure & deployment
Each repo deploys from main via GitHub Actions. The backend runs DB migrations as a one-shot, then rolls the ECS service; the web deploys to CloudFront + Lambda with a blocking post-deploy contract test. Media is served signed through CloudFront to a Lambda transform. Mobile ships as native builds (Android APK/AAB, iOS TestFlight).
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#FFF3EC','primaryBorderColor':'#EE8A54','primaryTextColor':'#1C1917','lineColor':'#B0A99F','tertiaryColor':'#F3F1ED','clusterBkg':'#FBFAF8','clusterBorder':'#E1DCD4'}}}%%
flowchart LR
subgraph CI["push to main → GitHub Actions"]
be["backend"] --> mig["Migrate DB
(one-shot)"] --> ecs["Roll ECS service"]
web["web"] --> onx["Build OpenNext"] --> cfl["CloudFront + Lambda"] --> ctest["Contract test
(blocking)"]
mob["mobile"] --> nat["Native build
Android APK/AAB · iOS TestFlight"]
end
subgraph MEDIA["Media serving"]
up["Upload → S3"] --> sign["Signed media.dealyly.com/t/"] --> tr["CloudFront → Lambda transform
resize · webp"]
end
ecs --> run["Prod API"]
cfl --> run2["Prod web"]
- Migrations: a new SQL migration must be journalled with a strictly-increasing
when, or it is silently skipped. - Web gate: the blocking contract test must pass post-deploy (it has caught site-wide 500s before).
- Mobile depends on backend: a mobile change that needs a new endpoint ships only after that endpoint is live.