Initial commit: nick docs
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Obsidian workspace/UI state (keep config, ignore per-machine state)
|
||||
.obsidian/workspace.json
|
||||
.obsidian/workspace-mobile.json
|
||||
.obsidian/cache
|
||||
.trash/
|
||||
223
00 - Overview/Glossary.md
Normal file
223
00 - Overview/Glossary.md
Normal file
@@ -0,0 +1,223 @@
|
||||
---
|
||||
title: Glossary
|
||||
tags: [overview, glossary, definitions, terminology]
|
||||
created: 2026-05-23
|
||||
---
|
||||
|
||||
# Glossary
|
||||
|
||||
> [!info] How to read this page
|
||||
> Terms are listed alphabetically. Where a term is also the subject of its own doc, the heading links there. When a term references another glossary entry, it is shown as plain bold rather than a wikilink — flip back here as needed.
|
||||
|
||||
---
|
||||
|
||||
### Admin
|
||||
|
||||
> [!info] Definition
|
||||
> A user with `role: "admin"` and full read/write access to every part of the platform — moderation, payments, disputes, content. Created at boot via `backend/src/infrastructure/database/init-admin.ts`. See [[Roles & Personas]].
|
||||
|
||||
### Buyer (User)
|
||||
|
||||
> [!info] Definition
|
||||
> A user with `role: "buyer"`. Posts **Purchase Requests** and accepts **Seller Offers**. The default role assigned at signup. UI label: "User".
|
||||
|
||||
### Category
|
||||
|
||||
> [!info] Definition
|
||||
> A taxonomy node used to classify Purchase Requests and templates. Sellers can flag themselves as a preferred seller in a category. Backed by the `Category` model (`backend/src/models/Category.ts`); seeded via `seedCategories.ts`.
|
||||
|
||||
### Chat
|
||||
|
||||
> [!info] Definition
|
||||
> A multi-party real-time conversation persisted in MongoDB (`Chat` model) and streamed over Socket.IO. Chats are scoped to a Purchase Request, a Dispute, or a direct support thread. Per-chat rate limiting is enforced by `chatRateLimiter.ts`.
|
||||
|
||||
### DePay
|
||||
|
||||
> [!info] Definition
|
||||
> A drop-in Web3 payment widget (`@depay/widgets`) used on the frontend to let buyers pay directly from their own wallet on supported EVM chains. The transaction is signed in the wallet, broadcast to the chain, and verified server-side. See [[Payments Overview]].
|
||||
|
||||
### Delivery Code
|
||||
|
||||
> [!info] Definition
|
||||
> A six-digit one-time code generated when a physical order ships. The buyer reads it to the courier (or the seller types it into the app at handoff) to confirm receipt and release escrow. Stored on `PurchaseRequest.deliveryInfo.deliveryCode` with `deliveryCodeExpiresAt` and a usage audit trail (`backend/src/models/PurchaseRequest.ts`).
|
||||
|
||||
### Dispute
|
||||
|
||||
> [!info] Definition
|
||||
> A formal complaint opened by either party when a deal goes wrong. Creates a three-way chat (buyer, seller, admin) and a `Dispute` document with a structured `timeline[]`, `evidence[]`, and `resolution`. Categories: `product_quality | delivery_delay | wrong_item | payment_issue | seller_behavior | other`. Outcomes: `refund | replacement | compensation | warning_seller | ban_seller | no_action`. See `backend/src/models/Dispute.ts`.
|
||||
|
||||
### Escrow
|
||||
|
||||
> [!info] Definition
|
||||
> The custodial period during which buyer funds are held by the platform (SHKeeper or the smart contract layer) after payment but before release to the seller. Escrow guarantees the seller will be paid if they deliver, and guarantees the buyer can be refunded if they do not. The defining feature of Amn.
|
||||
|
||||
### Idempotency
|
||||
|
||||
> [!info] Definition
|
||||
> The property that the same request (identified by an idempotency key) can safely be retried without performing the underlying operation more than once. Critical for payment webhooks — SHKeeper may deliver the same webhook several times if it does not receive a 200 quickly. Amn enforces idempotency in `PaymentCoordinator` and at the model level via unique constraints on transaction hashes.
|
||||
|
||||
### JWT (Access / Refresh)
|
||||
|
||||
> [!info] Definition
|
||||
> JSON Web Tokens issued by the auth service. **Access tokens** are short-lived and carried on every authenticated request in the `Authorization: Bearer` header. **Refresh tokens** are longer-lived, stored on the `User` document, and exchanged for a new access token without re-prompting credentials. Rotated on every refresh.
|
||||
|
||||
### Level
|
||||
|
||||
> [!info] Definition
|
||||
> A user tier derived from accumulated points. Configured in `LevelConfig` (`backend/src/models/LevelConfig.ts`). Higher levels unlock perks such as reduced fees or higher-priority placement.
|
||||
|
||||
### Negotiation
|
||||
|
||||
> [!info] Definition
|
||||
> The back-and-forth between a buyer and a seller after an offer is submitted but before it is accepted. Implemented as standard chat messages in the request's chat room. A purchase request enters the `in_negotiation` status when at least one offer exists and discussion is active.
|
||||
|
||||
### Notification
|
||||
|
||||
> [!info] Definition
|
||||
> A platform event delivered to a user, either in-app (via Socket.IO to the user's personal room) or by email (`nodemailer`). Persisted as `Notification` documents so they can be read on subsequent sessions. Categories include `offer_received`, `payment_confirmed`, `dispute_update`, and more.
|
||||
|
||||
### Owner
|
||||
|
||||
> [!info] Definition
|
||||
> The UI label for a **Seller**. They "own" a shop. Synonym; same underlying `role: "seller"`.
|
||||
|
||||
### Passkey
|
||||
|
||||
> [!info] Definition
|
||||
> A WebAuthn credential bound to a device (platform or cross-platform). Replaces passwords for fast biometric login. Multiple passkeys per user supported (`User.passkeys[]` in `backend/src/models/User.ts:125`). See [[Authentication Flow]].
|
||||
|
||||
### Pay-in
|
||||
|
||||
> [!info] Definition
|
||||
> Money flowing **into** escrow from the buyer. Recorded as `Payment.direction: "in"`. The buyer's choice of pay-in surface (SHKeeper invoice vs. Web3 wallet) is independent of how the payout will be sent.
|
||||
|
||||
### Pay-in Intent
|
||||
|
||||
> [!info] Definition
|
||||
> The pre-authorisation record created when a buyer commits to paying but the funds have not yet arrived on-chain. Holds the chosen amount, currency, expected wallet address (SHKeeper) or counterparty (DePay), and an expiry. Becomes a confirmed `Payment` once the chain or webhook confirms settlement.
|
||||
|
||||
### Payment
|
||||
|
||||
> [!info] Definition
|
||||
> A monetary transaction record. One `Payment` document covers a pay-in, payout, or refund. Carries `direction`, `provider`, `blockchain` metadata (network, tx hash, confirmations), buyer, seller, and the related purchase request and seller offer. See `backend/src/models/Payment.ts`.
|
||||
|
||||
### Payment Coordinator
|
||||
|
||||
> [!info] Definition
|
||||
> A backend module (`backend/src/services/payment/paymentCoordinator.ts`) that serialises payment state transitions using Redis locks. Prevents the race condition where a webhook and a manual confirmation both try to flip the same payment to `paid`.
|
||||
|
||||
### Payout
|
||||
|
||||
> [!info] Definition
|
||||
> Money flowing **out** of escrow to the seller's wallet. Recorded as `Payment.direction: "out"`. Triggered by admin action after delivery is confirmed; implemented via SHKeeper's payout API (`shkeeperPayoutService.ts`).
|
||||
|
||||
### Points
|
||||
|
||||
> [!info] Definition
|
||||
> The loyalty currency. Earned for completed deals, verified referrals, and certain in-app actions. Spent on fee discounts or platform perks. Each grant or spend writes a `PointTransaction` row for full auditability (`backend/src/models/PointTransaction.ts`).
|
||||
|
||||
### Point Transaction
|
||||
|
||||
> [!info] Definition
|
||||
> An immutable record of a single change to a user's point balance — credit or debit, with `reason`, `relatedEntity`, and timestamp. The audit trail for the points system.
|
||||
|
||||
### Preferred Seller
|
||||
|
||||
> [!info] Definition
|
||||
> A seller a buyer can specify on a purchase request to direct the offer notification to a narrower audience. Listed in `PurchaseRequest.preferredSellerIds[]`. Other sellers may still see the request if it is public.
|
||||
|
||||
### Purchase Request
|
||||
|
||||
> [!info] Definition
|
||||
> The buyer's brief — what they want to acquire, for how much, by when, and where to deliver. The originating document of every deal. Long status enum walking the entire lifecycle from `pending_payment` through `completed`. See `backend/src/models/PurchaseRequest.ts`.
|
||||
|
||||
### Referral
|
||||
|
||||
> [!info] Definition
|
||||
> A signup attributed to an existing user via their unique `referralCode`. Triggered through the `/r/:code` short URL (`backend/src/app.ts:274`) which redirects to the signup page with the code pre-filled. Referrer earns points when the referred user completes qualifying actions.
|
||||
|
||||
### Referral Code
|
||||
|
||||
> [!info] Definition
|
||||
> A short unique string on every user (`User.referralCode`) that identifies them as a referrer. Used in the `/r/:code` redirect.
|
||||
|
||||
### Request Template
|
||||
|
||||
> [!info] Definition
|
||||
> A reusable, pre-configured purchase request that acts like an express checkout. Admin templates appear in a platform catalogue; seller templates are shop-scoped. Clicking "buy" on a template spawns a real `PurchaseRequest` with the template's defaults. See `backend/src/models/RequestTemplate.ts` and `frontend/template-request-feature.md`.
|
||||
|
||||
### Review
|
||||
|
||||
> [!info] Definition
|
||||
> Post-completion feedback from one party about the other — star rating plus free-form text. Stored in the `Review` model. Aggregated onto the seller's shop profile.
|
||||
|
||||
### Role
|
||||
|
||||
> [!info] Definition
|
||||
> The user's identity tier in the system: `"admin" | "buyer" | "seller"` (`backend/src/models/User.ts:94`). Drives middleware authorisation. Support is a special admin variant rather than a distinct role.
|
||||
|
||||
### Seller (Owner)
|
||||
|
||||
> [!info] Definition
|
||||
> A user with `role: "seller"`. Submits offers on requests, runs a shop, receives payouts. Retains all buyer capabilities. UI label: "Owner".
|
||||
|
||||
### Seller Offer
|
||||
|
||||
> [!info] Definition
|
||||
> A single bid attached to one Purchase Request — `price`, `deliveryTime`, `status` (`pending | accepted | rejected | withdrawn`), optional `validUntil`, optional attachments. See `backend/src/models/SellerOffer.ts`.
|
||||
|
||||
### Shop
|
||||
|
||||
> [!info] Definition
|
||||
> A seller's public profile + storefront — name, banner, description, payout wallet, response-time SLA, working hours. Configured in `ShopSettings` and rendered at `/dashboard/shops/<id>`.
|
||||
|
||||
### SHKeeper
|
||||
|
||||
> [!info] Definition
|
||||
> A self-hosted crypto payment processor used as Amn's primary custodial pay-in / payout rail. Issues a fresh wallet address per invoice, watches the chain for incoming USDT, and emits a signed webhook on settlement. Lives at `https://pay.amn.gg` per `backend/TODO.md`. Integration code under `backend/src/services/payment/shkeeper/`.
|
||||
|
||||
### Socket Room
|
||||
|
||||
> [!info] Definition
|
||||
> A logical channel inside the Socket.IO server that scopes events to a subset of connected clients. Rooms used by Amn include `user-<id>`, `chat-<id>`, `request-<id>`, `seller-<id>`, `buyer-<id>`, `sellers`, `buyers`. See `backend/src/app.ts:79-179`.
|
||||
|
||||
### Status (Order)
|
||||
|
||||
> [!info] Definition
|
||||
> The current point in a Purchase Request's lifecycle. Full enum: `pending_payment | pending | active | received_offers | in_negotiation | payment | processing | delivery | delivered | confirming | completed | cancelled | seller_paid`. Each transition emits a Socket.IO event and may trigger notifications.
|
||||
|
||||
### Support
|
||||
|
||||
> [!info] Definition
|
||||
> A constrained admin persona dedicated to answering tickets and triaging disputes. Same role enum (`admin`) but with permission middleware that blocks destructive operations. Seeded as `support@amn.gg`.
|
||||
|
||||
### USDT / USDC
|
||||
|
||||
> [!info] Definition
|
||||
> The two stablecoins Amn supports out of the box for pay-in and payout. USDT is the default for SHKeeper invoices; both are supported in offer pricing (`SellerOffer.price.currency` enum: `USD | EUR | IRR | USDT | USDC`).
|
||||
|
||||
### Webhook
|
||||
|
||||
> [!info] Definition
|
||||
> An inbound HTTP POST from an external service notifying Amn of an event. SHKeeper webhooks (`/api/payment/shkeeper/webhook`) are the most important — they confirm pay-ins. All webhooks are HMAC-signed; verification uses `SHKEEPER_WEBHOOK_SECRET`. Failed verifications are dropped.
|
||||
|
||||
### WalletConnect
|
||||
|
||||
> [!info] Definition
|
||||
> An open protocol for connecting wallets to web apps via a QR code or deep link. Configured but **currently disabled** in `frontend/src/web3/config.ts` pending an SSR-compatibility fix; MetaMask is the active connector.
|
||||
|
||||
### Web3 Wallet
|
||||
|
||||
> [!info] Definition
|
||||
> A user-controlled key store (MetaMask, hardware wallet, mobile wallet) that signs blockchain transactions. The frontend talks to wallets through Wagmi + Viem and the user keeps custody of funds right up to the moment of payment.
|
||||
|
||||
---
|
||||
|
||||
## See also
|
||||
|
||||
- [[Introduction]] — high-level project framing.
|
||||
- [[System Overview]] — how the pieces connect.
|
||||
- [[Roles & Personas]] — who uses what.
|
||||
- [[Tech Stack]] — exact tool versions.
|
||||
- [[02 - Data Models]] — schemas for every term defined above.
|
||||
- [[04 - Flows]] — the lifecycle diagrams those terms appear in.
|
||||
71
00 - Overview/Introduction.md
Normal file
71
00 - Overview/Introduction.md
Normal file
@@ -0,0 +1,71 @@
|
||||
---
|
||||
title: Introduction
|
||||
tags: [overview, introduction, mission, product]
|
||||
created: 2026-05-23
|
||||
---
|
||||
|
||||
# Introduction
|
||||
|
||||
> [!info] About this vault
|
||||
> This is the technical documentation for **Amn** (internal code name: *nick app*), a crypto-native escrow marketplace. The vault is organised into numbered sections — start here in `00 - Overview`, then drill into [[01 - Architecture]], [[02 - Data Models]], and so on.
|
||||
|
||||
## Mission
|
||||
|
||||
**Amn exists to make peer-to-peer commerce trustworthy without forcing either side to trust the other.**
|
||||
|
||||
The platform is built on a simple premise that has been hard to pull off in practice: a buyer should be able to describe what they want, receive competing offers from sellers, pay safely, and only release that payment once the goods or service have actually been delivered — all without exposing themselves to chargebacks, payment fraud, or a counterparty who simply disappears. Sellers, in turn, should know that the money they are quoting for is already locked in escrow before they invest time and inventory in fulfilling the order.
|
||||
|
||||
Amn solves this with a fully integrated escrow flow, a real-time chat layer that keeps both parties accountable, and a human dispute system that catches the edge cases that automation cannot.
|
||||
|
||||
## The problem we solve
|
||||
|
||||
Traditional marketplaces tend to live at one of two extremes:
|
||||
|
||||
1. **Fully custodial platforms** (Amazon, eBay, Fiverr) take a large cut, dictate every term of the transaction, and freeze funds on a whim. They work, but they are expensive and opaque.
|
||||
2. **Free-form P2P channels** (Telegram groups, Discord servers, direct DMs) charge nothing but offer no protection at all. The first scam empties the wallet and there is no recourse.
|
||||
|
||||
Amn sits between the two. It charges a thin escrow margin, holds funds for only as long as it takes to confirm delivery, and supports both fiat-style stablecoin escrow (via [[SHKeeper]]) and direct on-chain settlement (via [[DePay]] and the user's own wallet) — meaning the buyer can keep custody of their crypto until the literal moment of release.
|
||||
|
||||
> [!tip] Why "crypto-native"?
|
||||
> The escrow rails are built around stablecoins (USDT/USDC) on EVM chains rather than card networks. That means no chargebacks, no 3-day settlement, no geographic restrictions — and a transparent, auditable transaction trail for every step of the deal. See [[Tech Stack]] for the full Web3 surface.
|
||||
|
||||
## Target users
|
||||
|
||||
Amn serves four distinct personas, each with their own workflows and dashboard. The full breakdown lives in [[Roles & Personas]]; in summary:
|
||||
|
||||
- **Buyers** (a.k.a. *Users*) post a **Purchase Request** describing what they want — a physical product, a digital good, a service, or a consultation — and receive bids from sellers.
|
||||
- **Sellers** (a.k.a. *Owners*) browse open requests, submit **Seller Offers** with their price and delivery time, and run their shop with the help of a personalised dashboard, reviews, and a points-based loyalty programme.
|
||||
- **Admins** moderate the marketplace, resolve disputes, manage categories and templates, configure payout wallets, and have full visibility into payments and users.
|
||||
- **Support agents** are a lightweight subset of admin who can join chats, respond to user tickets, and triage disputes without having destructive permissions.
|
||||
|
||||
Beyond the four roles, two ambient audiences read the platform:
|
||||
- **Anonymous visitors** browse the [[Blog]], read public seller profiles, and convert through the referral programme.
|
||||
- **Search engines** index the public blog and seller shops, which are server-rendered by Next.js for SEO.
|
||||
|
||||
## What makes Amn distinctive
|
||||
|
||||
A handful of design choices set Amn apart from generic marketplace software:
|
||||
|
||||
1. **Dual payment rails.** Every order can be paid through SHKeeper (a self-hosted crypto payment processor that issues a fresh wallet per invoice) *or* through a Web3 wallet connect flow (DePay + Wagmi/Viem + MetaMask). The buyer picks; the escrow logic is identical downstream. See [[Payments Overview]].
|
||||
2. **Request-first marketplace.** Most platforms list *products*. Amn lists *needs*. Buyers describe what they want and let the market come to them — closer to a reverse auction than a catalogue. The unidirectional flow eliminates the "thousand-listings-with-no-stock" problem.
|
||||
3. **Request Templates.** Power buyers (and admins) can publish reusable purchase request templates that act like express checkouts — a buyer clicks "I want this" and the order is opened pre-filled. Templates are the bridge between Amn and conventional ecommerce.
|
||||
4. **First-class i18n with RTL.** The frontend ships with six locales out of the box (English, French, Vietnamese, Chinese, Arabic, Persian) and full right-to-left support — Persian is the default fallback. See `frontend/src/locales/locales-config.ts:36`.
|
||||
5. **Real-time everything.** Chat, offer notifications, payment status, dispute updates, and presence indicators all flow over Socket.IO rooms. The UI is not just reactive to user input — it is reactive to other users.
|
||||
6. **Points and referrals built in.** A loyalty system rewards completed deals, referrals, and verified actions. Points unlock fee tiers and are tracked in `PointTransaction` records for full auditability.
|
||||
7. **AI assist.** OpenAI is wired into the backend to help draft purchase requests, summarise long chat threads, and surface intent classification for the admin dashboard.
|
||||
8. **Human dispute resolution with a paper trail.** When a deal goes wrong, a three-way chat is opened (buyer, seller, admin) with a full timeline of every action and a structured resolution record. Disputes never disappear into a black box.
|
||||
|
||||
## How to read this vault
|
||||
|
||||
> [!note] Recommended reading order
|
||||
> 1. **[[Introduction]]** — you are here.
|
||||
> 2. **[[System Overview]]** — the 10,000-foot map.
|
||||
> 3. **[[Tech Stack]]** — every dependency with versions.
|
||||
> 4. **[[Roles & Personas]]** — who uses what.
|
||||
> 5. **[[Glossary]]** — definitions of every domain term you will encounter.
|
||||
>
|
||||
> After the overview, jump to **[[01 - Architecture]]** for service boundaries, **[[02 - Data Models]]** for schemas, or **[[04 - Flows]]** for end-to-end user journeys.
|
||||
|
||||
## Project status at a glance
|
||||
|
||||
Amn is at version **2.6.x** across both repositories, on the `development` branch, and tagged "production-ready with minor enhancements" by the project leads. The core escrow loop, real-time chat, multi-language UI, dispute system, points programme, and blog are all live. Active work focuses on UX polish, admin analytics, and a more granular permissions matrix — see `backend/TODO.md` and `frontend/VERSION_0_PREPARATION_TODO.md` for the rolling task list, and [[Roadmap]] (forthcoming) for the strategic view.
|
||||
237
00 - Overview/Roles & Personas.md
Normal file
237
00 - Overview/Roles & Personas.md
Normal file
@@ -0,0 +1,237 @@
|
||||
---
|
||||
title: Roles & Personas
|
||||
tags: [overview, roles, personas, permissions, rbac]
|
||||
created: 2026-05-23
|
||||
---
|
||||
|
||||
# Roles & Personas
|
||||
|
||||
> [!info] Where roles live in code
|
||||
> The hard role enum is defined in `backend/src/models/User.ts:94` as `"admin" | "buyer" | "seller"`. Support is implemented as an admin variant (a dedicated `support@amn.gg` user is created at bootstrap — see `backend/TODO.md`) rather than as its own enum value. Permission checks live in route middleware and in service guards.
|
||||
|
||||
Amn has four user personas. Three are first-class roles in the data model; the fourth (Support) is a special-cased admin with reduced privileges.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Visitor["Anonymous visitor<br/>(blog, public shops)"]
|
||||
Buyer["Buyer<br/>(User)"]
|
||||
Seller["Seller<br/>(Owner)"]
|
||||
Support["Support<br/>(admin variant)"]
|
||||
Admin["Admin"]
|
||||
|
||||
Visitor -->|signs up| Buyer
|
||||
Buyer -->|requests seller mode<br/>+ admin approval| Seller
|
||||
Buyer & Seller -->|opens ticket| Support
|
||||
Support -->|escalates| Admin
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Buyer (User)
|
||||
|
||||
> [!example] Who they are
|
||||
> Default new user. Anyone who signs up is a buyer until they request seller status. The persona name in the UI is **"User"** but the model role is `buyer`.
|
||||
|
||||
### Primary workflows
|
||||
|
||||
- **Browse and search** the public marketplace and request templates.
|
||||
- **Create a [[Purchase Request]]** describing what they want — product type (physical / digital / service / consultation), budget, urgency, delivery info, attachments. See `backend/src/models/PurchaseRequest.ts`.
|
||||
- **Review incoming [[Seller Offer]]s**, negotiate over chat, accept the best one.
|
||||
- **Pay** via [[SHKeeper]] (custodial crypto invoice) or Web3 wallet ([[DePay]] + MetaMask through Wagmi).
|
||||
- **Track the order** through `processing → delivery → delivered → confirming → completed` states.
|
||||
- **Confirm receipt** (or let the SLA auto-confirm), leave a review, accrue points.
|
||||
- **Open a [[Dispute]]** if delivery never lands, item is wrong, or quality is poor.
|
||||
- **Refer friends** via their personal referral code; earn points per signup that converts.
|
||||
- **Manage profile**: avatar, addresses, wallet address, passkeys, notification preferences, language.
|
||||
|
||||
### Key permissions
|
||||
|
||||
- Create, edit, cancel **own** purchase requests.
|
||||
- Accept / reject offers on **own** requests.
|
||||
- Initiate payments on **own** requests.
|
||||
- Open disputes on **own** orders.
|
||||
- Chat with sellers on **own** open requests and disputes.
|
||||
- Read public sellers, public requests, public blog.
|
||||
- **Cannot**: see other users' private data, moderate content, issue payouts, change another user's role.
|
||||
|
||||
### Dashboard sections (frontend routes)
|
||||
|
||||
The buyer dashboard lives under `/dashboard` (`frontend/src/app/dashboard/`). Notable areas:
|
||||
|
||||
- `/dashboard` — overview (recent requests, recent offers received, points balance)
|
||||
- `/dashboard/request` — list, detail, and create flow for purchase requests
|
||||
- `/dashboard/request-template` — saved templates and the catalogue of admin templates
|
||||
- `/dashboard/chat` — all conversations
|
||||
- `/dashboard/payment` — payment history (in + out) and active invoices
|
||||
- `/dashboard/disputes` — open and resolved disputes
|
||||
- `/dashboard/points` — points, levels, referrals
|
||||
- `/dashboard/account` — profile, security (passkeys), addresses, language
|
||||
- `/dashboard/post` — read-only blog reader (also public)
|
||||
|
||||
> [!tip] See also
|
||||
> Full buyer playbook in [[User Guide]]. Auth specifics in [[Authentication Flow]].
|
||||
|
||||
---
|
||||
|
||||
## Seller (Owner)
|
||||
|
||||
> [!example] Who they are
|
||||
> A buyer who has been promoted to `role: "seller"`. The UI sometimes calls them **"Owner"** because they own a shop. Sellers retain all buyer capabilities — they can still post requests of their own.
|
||||
|
||||
### Primary workflows
|
||||
|
||||
- **Configure shop**: shop name, banner, description, response time SLA, accepted payment methods, payout wallet address. See `backend/src/models/ShopSettings.ts` and `frontend/src/sections/shop-settings/`.
|
||||
- **Discover requests** through the seller feed (filtered by category and preferred-seller status). Receive live notifications when a relevant request is posted via the `sellers` / `seller-<id>` Socket.IO rooms (`backend/src/app.ts:101-112`).
|
||||
- **Submit offers** with price, currency (USDT default, USDC, USD, EUR, IRR supported), delivery time, optional attachments and notes.
|
||||
- **Negotiate** in the per-request chat — bilateral with the buyer until an offer is accepted.
|
||||
- **Fulfil** the order: ship physical goods (with optional tracking number), or upload/email digital deliverables.
|
||||
- **Use the [[delivery code]]** for physical handoffs: a six-digit one-time code the buyer reads to the courier to confirm receipt.
|
||||
- **Receive payout** automatically via SHKeeper to the configured wallet once the order is finalised (admin-triggered batch or per-order based on shop policy).
|
||||
- **Manage [[Request Templates]]** scoped to their shop — publish "off-the-shelf" offerings buyers can purchase in one click.
|
||||
- **Engage with reviews and disputes**: respond to reviews, contest disputes, provide evidence.
|
||||
|
||||
### Key permissions
|
||||
|
||||
- All buyer permissions.
|
||||
- Create / edit / withdraw **own** seller offers.
|
||||
- Read all public purchase requests + read-only access to requests where they have an active offer.
|
||||
- Manage **own** shop settings, payout wallet, business hours.
|
||||
- Publish [[Request Templates]] scoped to their shop.
|
||||
- Mark orders as shipped, upload digital deliverables, request delivery codes.
|
||||
- Respond in disputes they are a party to.
|
||||
- **Cannot**: see another seller's analytics, edit another seller's shop, moderate the platform.
|
||||
|
||||
### Dashboard sections
|
||||
|
||||
Seller dashboard reuses the same `/dashboard` shell with extra modules:
|
||||
|
||||
- `/dashboard/shops` — public-facing shop browser (also visible to buyers)
|
||||
- `/dashboard/shop-settings` — edit own shop, payout wallet, working hours
|
||||
- `/dashboard/request-template` — create / edit shop-scoped templates
|
||||
- `/dashboard/payment` — receivables, payout history, pending releases
|
||||
- `/dashboard/disputes` — disputes where the seller is the respondent
|
||||
|
||||
> [!tip] See also
|
||||
> [[Seller Guide]] walks through onboarding, first listing, and payout setup end-to-end. [[Payments Overview]] explains the escrow + payout state machine.
|
||||
|
||||
---
|
||||
|
||||
## Admin
|
||||
|
||||
> [!example] Who they are
|
||||
> Platform operators with full read/write access. Admins are seeded at startup via `backend/src/infrastructure/database/init-admin.ts`. There is typically one root admin (`admin@amn.gg`) and additional admins promoted through database operations or a dedicated admin-creation endpoint.
|
||||
|
||||
### Primary workflows
|
||||
|
||||
- **Moderate users**: suspend / unsuspend accounts (`User.status: "active" | "suspended" | "deleted"`, see `backend/src/models/User.ts`), promote buyers to sellers, ban repeat offenders.
|
||||
- **Moderate marketplace content**: categories (`Category` model), request templates (the canonical platform-wide ones), blog posts.
|
||||
- **Resolve disputes**: get assigned to disputes, drive them to resolution, choose an outcome (`refund | replacement | compensation | warning_seller | ban_seller | no_action`). See `backend/src/services/dispute/DisputeService.ts`.
|
||||
- **Operate payments**: trigger payouts, fetch on-chain transactions, manually confirm stuck payments (the manual transaction-hash flow described in `backend/TODO.md`), audit the SHKeeper webhook history (`services/payment/shkeeper/webhookStats.ts`).
|
||||
- **Configure the platform**: levels (`LevelConfig`), points multipliers, blog seed content, default templates.
|
||||
- **Run data cleanup**: `/api/admin/cleanup` exposes destructive maintenance utilities (`services/admin/`).
|
||||
- **Author blog posts** via the TipTap rich-text editor.
|
||||
- **Monitor health**: SHKeeper status (background health monitor in `app.ts:433`), Redis, MongoDB.
|
||||
|
||||
### Key permissions
|
||||
|
||||
- **All** read and write across the platform — no row-level scoping.
|
||||
- Promote users, change roles, suspend accounts.
|
||||
- Approve / reject seller applications.
|
||||
- Resolve any dispute, override any payment state.
|
||||
- Trigger payouts and refunds.
|
||||
- Edit / delete any blog post, template, or category.
|
||||
- Run admin cleanup endpoints (irreversible — use with care).
|
||||
- Access the full Sentry stream and webhook logs.
|
||||
|
||||
### Dashboard sections
|
||||
|
||||
Admins see the buyer/seller surfaces plus dedicated admin modules (typically under `/dashboard` with admin-only guards, growing into a dedicated `/admin` area in later versions):
|
||||
|
||||
- User management (search, suspend, role change)
|
||||
- Dispute queue with assignment and resolution
|
||||
- Payment console (manual confirmation, payout dispatch, webhook log)
|
||||
- Category and template management
|
||||
- Blog editor (publish / unpublish / featured)
|
||||
- Platform analytics (TODO — see `backend/TODO.md`)
|
||||
- Data cleanup utilities
|
||||
|
||||
> [!warning] Destructive operations
|
||||
> `/api/admin/cleanup/*` and direct role mutations are not undoable. Admin actions should be logged to an audit trail. Audit logging is on the roadmap (`backend/TODO.md` → Security Enhancements → Audit Logging).
|
||||
|
||||
> [!tip] See also
|
||||
> [[Admin Guide]] gives the step-by-step for moderation, dispute resolution, and payment operations.
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
> [!example] Who they are
|
||||
> A dedicated support persona — the seeded `support@amn.gg` account — that exists to handle user tickets and triage disputes without granting full destructive access. Implemented as an `admin` role with a restricted permission profile applied in middleware (see `backend/TODO.md` → "Support user creation").
|
||||
|
||||
### Primary workflows
|
||||
|
||||
- **Join existing chats** to assist a buyer or seller in real time.
|
||||
- **Triage disputes**: read incoming disputes, attach notes, assign to the right admin, set priority.
|
||||
- **Answer user queries** about payments and orders without performing the payment action itself.
|
||||
- **Escalate** anything that requires destructive action (refunds, bans, manual payouts) to a full admin.
|
||||
|
||||
### Key permissions
|
||||
|
||||
- Read access to all user accounts (no PII edits).
|
||||
- Read access to all disputes and chats.
|
||||
- Comment / reply in disputes and chats (write access scoped to messages, not resolution).
|
||||
- **Cannot**: change roles, issue payouts, suspend users, delete content, resolve a dispute, edit settings.
|
||||
|
||||
### Dashboard sections
|
||||
|
||||
Support sees a stripped-down admin view focused on the inbox:
|
||||
|
||||
- Support chat queue
|
||||
- Dispute triage list
|
||||
- User lookup (read-only)
|
||||
- Order lookup (read-only)
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting concerns
|
||||
|
||||
### Role transitions
|
||||
|
||||
| From | To | How | Audit |
|
||||
|---|---|---|---|
|
||||
| Anonymous | Buyer | Self-service signup | `User` created |
|
||||
| Buyer | Seller | Application → admin approval | `User.role` change |
|
||||
| Buyer / Seller | Admin | Manual DB / boot-time seed | High-risk, manual |
|
||||
| Admin | Support | Permission profile applied at middleware | Role stays `admin` |
|
||||
|
||||
### Permission model
|
||||
|
||||
Permissions are enforced at three layers:
|
||||
|
||||
1. **Route middleware** (e.g. `requireAuth`, `requireRole('admin')`) — coarse gating on the HTTP layer.
|
||||
2. **Service guards** — finer ownership checks (e.g. "you can only edit a request whose `buyerId` matches your `userId`").
|
||||
3. **Frontend route guards** — `frontend/src/auth/guard/` hides UI from users who would be rejected anyway, preventing dead-ends.
|
||||
|
||||
### Identity sources
|
||||
|
||||
- **Email + password** — primary, with bcrypt hashing.
|
||||
- **Passkey (WebAuthn)** — multi-device, stored in `User.passkeys[]`.
|
||||
- **Google OAuth** — server-verified, links by email.
|
||||
|
||||
See [[Authentication Flow]] for the full sequence diagram.
|
||||
|
||||
### Internationalisation per persona
|
||||
|
||||
All four personas see the same six-language UI (en, fr, vi, cn, ar, fa) with the default fallback being Persian (`fa`) — see `frontend/src/locales/locales-config.ts:39`. Date formatting switches to the Jalali calendar in Persian.
|
||||
|
||||
---
|
||||
|
||||
## See also
|
||||
|
||||
- [[Introduction]] — project mission and target audience framing.
|
||||
- [[System Overview]] — where each persona fits in the architecture.
|
||||
- [[Authentication Flow]] — how identity becomes a role.
|
||||
- [[Admin Guide]] — operational handbook for admins and support.
|
||||
- [[User Guide]] — buyer journey end-to-end.
|
||||
- [[Seller Guide]] — seller onboarding and shop ops.
|
||||
- [[Glossary]] — definitions of every domain term referenced above.
|
||||
216
00 - Overview/System Overview.md
Normal file
216
00 - Overview/System Overview.md
Normal file
@@ -0,0 +1,216 @@
|
||||
---
|
||||
title: System Overview
|
||||
tags: [overview, architecture, system-map, mermaid]
|
||||
created: 2026-05-23
|
||||
---
|
||||
|
||||
# System Overview
|
||||
|
||||
> [!info] Scope
|
||||
> This document gives you a single-page map of Amn from browser to blockchain. It is intentionally broad — the goal is for a new developer to walk away knowing what each major box does and how the boxes talk to each other. For deep dives into any subsystem, follow the wikilinks into [[01 - Architecture]].
|
||||
|
||||
## The 10,000-foot view
|
||||
|
||||
Amn is a **two-repo system**:
|
||||
|
||||
- **Frontend** (`/Users/mojtabaheidari/code/frontend`) — a Next.js 16 App Router application that serves the marketplace UI, the admin dashboard, the public blog, and the user-facing Web3 wallet flow.
|
||||
- **Backend** (`/Users/mojtabaheidari/code/backend`) — an Express 5 + TypeScript API server that owns all business logic, persists to MongoDB, caches in Redis, and brokers all external integrations.
|
||||
|
||||
The two repos are deployable independently. They communicate over **HTTPS (REST)** for stateful actions and over **WebSocket (Socket.IO)** for live updates. The frontend never talks directly to MongoDB, Redis, SHKeeper, or OpenAI — every external interaction is mediated by the backend so that secrets stay on the server.
|
||||
|
||||
## System map
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Client["Client tier"]
|
||||
Browser["Browser<br/>(Chrome / Safari / mobile)"]
|
||||
Wallet["Wallet extension<br/>(MetaMask / WalletConnect)"]
|
||||
end
|
||||
|
||||
subgraph FE["Frontend tier — Next.js 16"]
|
||||
SSR["Next.js SSR / RSC<br/>App Router"]
|
||||
ClientJS["Client JS<br/>MUI v7 + React 19"]
|
||||
Wagmi["Wagmi + Viem<br/>Web3 client"]
|
||||
SocketC["socket.io-client"]
|
||||
I18n["i18next<br/>6 locales + RTL"]
|
||||
end
|
||||
|
||||
subgraph BE["Backend tier — Node.js / Express 5"]
|
||||
REST["REST API<br/>/api/*"]
|
||||
SocketS["Socket.IO server<br/>rooms per user / chat / request"]
|
||||
Auth["Auth service<br/>JWT + Passkey + Google"]
|
||||
Market["Marketplace service<br/>Requests, Offers, Templates"]
|
||||
ChatSvc["Chat service"]
|
||||
PaySvc["Payment service<br/>+ PaymentCoordinator"]
|
||||
Disp["Dispute service"]
|
||||
Points["Points / Referrals"]
|
||||
BlogSvc["Blog service"]
|
||||
AISvc["AI service"]
|
||||
Notif["Notification service"]
|
||||
Files["File upload<br/>(multer + sharp)"]
|
||||
end
|
||||
|
||||
subgraph Data["Data tier"]
|
||||
Mongo[("MongoDB<br/>via Mongoose")]
|
||||
RedisDB[("Redis<br/>cache + locks")]
|
||||
Disk[("Local disk<br/>/uploads")]
|
||||
end
|
||||
|
||||
subgraph External["External services"]
|
||||
SHK["SHKeeper<br/>crypto invoicing"]
|
||||
DePay["DePay widget"]
|
||||
Chain["EVM chains<br/>BSC / ETH / Polygon"]
|
||||
SMTP["SMTP<br/>(nodemailer)"]
|
||||
OpenAI["OpenAI API"]
|
||||
Google["Google OAuth"]
|
||||
Sentry["Sentry"]
|
||||
Alchemy["Alchemy RPC"]
|
||||
end
|
||||
|
||||
Browser --> SSR
|
||||
Browser <--> ClientJS
|
||||
ClientJS <--> SocketC
|
||||
ClientJS --> Wagmi
|
||||
Wagmi <--> Wallet
|
||||
Wallet <--> Chain
|
||||
|
||||
SSR --> REST
|
||||
ClientJS --> REST
|
||||
SocketC <--> SocketS
|
||||
|
||||
REST --> Auth & Market & ChatSvc & PaySvc & Disp & Points & BlogSvc & AISvc & Notif & Files
|
||||
SocketS --> ChatSvc & Notif & Market
|
||||
|
||||
Auth & Market & ChatSvc & PaySvc & Disp & Points & BlogSvc --> Mongo
|
||||
Auth & PaySvc & Notif --> RedisDB
|
||||
Files --> Disk
|
||||
|
||||
PaySvc <--> SHK
|
||||
SHK -.webhook.-> PaySvc
|
||||
PaySvc --> Chain
|
||||
Wagmi --> DePay
|
||||
DePay --> Chain
|
||||
PaySvc -.tx fetch.-> Alchemy
|
||||
|
||||
Notif --> SMTP
|
||||
Auth --> Google
|
||||
AISvc --> OpenAI
|
||||
BE --> Sentry
|
||||
FE --> Sentry
|
||||
```
|
||||
|
||||
## Walk-through of each subsystem
|
||||
|
||||
### Authentication & identity — [[Authentication Flow]]
|
||||
|
||||
Auth is the gate to every authenticated route. Amn supports three login methods in parallel:
|
||||
|
||||
- **Email + password (JWT).** Standard `bcrypt`-hashed credentials, access + refresh token pair, six-digit email verification codes, and password reset codes. Source: `backend/src/services/auth/authService.ts`.
|
||||
- **Passkey (WebAuthn).** Platform and cross-platform authenticators are registered against the user account; multiple devices per user are stored in `User.passkeys[]` (`backend/src/models/User.ts:125`).
|
||||
- **Google OAuth.** Server-side verification via `google-auth-library`. See `backend/src/services/auth/googleOAuthService.ts`.
|
||||
|
||||
Roles are `admin | buyer | seller` (`backend/src/models/User.ts:94`). Role checks happen in route middleware. **Refresh tokens** are stored on the `User` document and rotated.
|
||||
|
||||
### Marketplace — [[Marketplace Domain]]
|
||||
|
||||
The heart of the platform. Three first-class models drive it:
|
||||
|
||||
- **PurchaseRequest** (`backend/src/models/PurchaseRequest.ts`) — the buyer's brief, with productType (`physical_product | digital_product | service | consultation`), budget range, urgency, delivery info (physical or online), and a long status enum that walks the entire deal: `pending_payment → pending → active → received_offers → in_negotiation → payment → processing → delivery → delivered → confirming → completed`.
|
||||
- **SellerOffer** (`backend/src/models/SellerOffer.ts`) — a single bid attached to a request with `price`, `deliveryTime`, `status`, `validUntil`, and free-form `notes`.
|
||||
- **RequestTemplate** (`backend/src/models/RequestTemplate.ts`) — a reusable "express checkout" version of a purchase request that can spawn real `PurchaseRequest` instances at click time.
|
||||
|
||||
Services live in `backend/src/services/marketplace/` and are exposed through `/api/marketplace/*`. The frontend uses a mix of React Query (`@tanstack/react-query`) and SWR for data fetching, with mutations gated through the actions layer in `frontend/src/actions/`.
|
||||
|
||||
### Payments — [[Payments Overview]] / [[SHKeeper Integration]]
|
||||
|
||||
Payments are where Amn is most distinctive. The backend supports **three payment surfaces** routed through a common `Payment` model (`backend/src/models/Payment.ts`):
|
||||
|
||||
- **SHKeeper** — `/api/payment/shkeeper`. Mounted at `backend/src/app.ts:327`. Issues a fresh wallet address per invoice, polls / webhooks for payment confirmation, and runs through `PaymentCoordinator` to avoid race conditions where a payment status is updated twice. Health is monitored in the background (`shkeeperHealthCheck.ts`, started in `app.ts:433`).
|
||||
- **Decentralized (Wagmi + DePay)** — `/api/payment/decentralized`. The user signs and sends the transfer from their own wallet; the backend then verifies the transaction on-chain via `blockchainTxFetcher.ts` and the Alchemy SDK.
|
||||
- **Payout** — `/api/payment/shkeeper/payout`. Admin-triggered release of escrow funds to the seller's wallet once delivery is confirmed.
|
||||
|
||||
All three surfaces converge on the same `Payment` record (with `direction: 'in' | 'out' | 'refund'`) and trigger the same downstream events: order status update, notification, points award. **Pending payments are auto-cleaned** by a background timer started in `app.ts:374`.
|
||||
|
||||
### Real-time chat — [[Chat System]]
|
||||
|
||||
Chat is built on Socket.IO rooms. Every entity that needs live updates gets its own room (see `backend/src/app.ts:79-178`):
|
||||
|
||||
- `user-<id>` — personal notifications
|
||||
- `chat-<id>` — chat room messages, typing indicators, presence
|
||||
- `request-<id>` — purchase request lifecycle events
|
||||
- `buyer-<id>` / `seller-<id>` — marketplace-wide updates
|
||||
- `sellers` / `buyers` — global broadcast pools
|
||||
|
||||
Messages persist to MongoDB through the `Chat` model and are rate-limited per chat (`chatRateLimiter.ts`). The frontend's `socket/` directory wraps `socket.io-client` and exposes typed event hooks to React components.
|
||||
|
||||
### Notifications — [[Notifications]]
|
||||
|
||||
Two notification channels:
|
||||
|
||||
- **In-app** — `Notification` documents pushed over Socket.IO to `user-<id>` rooms; rendered in the frontend's bell-icon drawer.
|
||||
- **Email** — `nodemailer` + SMTP for verification codes, password resets, and high-importance events. See `backend/src/services/email/`.
|
||||
|
||||
Push and SMS are tracked as **planned** in `backend/TODO.md`.
|
||||
|
||||
### Disputes — [[Dispute System]]
|
||||
|
||||
When a deal goes wrong (see [[Glossary#Dispute]]), either party can open a dispute. The backend (`backend/src/services/dispute/DisputeService.ts`) creates a **three-way chat** between buyer, seller, and admin, opens a `Dispute` document with a structured `timeline[]` and `evidence[]`, and assigns the dispute to an admin via `assignAdmin()`. Resolution can be `refund | replacement | compensation | warning_seller | ban_seller | no_action` and is recorded on the dispute itself.
|
||||
|
||||
### Points & referrals — [[Points System]]
|
||||
|
||||
Each user has an embedded `points` object (`total | available | used | level`) and `referralStats` (`backend/src/models/User.ts`). Every grant or spend writes a `PointTransaction` record for auditability. The points module supplies referral codes (the `/r/:code` short URL in `app.ts:274` redirects to signup), tracks level progression against `LevelConfig`, and exposes the user-facing dashboard through `frontend/src/sections/points/`.
|
||||
|
||||
### Blog — [[Blog System]]
|
||||
|
||||
A simple admin-authored CMS. `BlogPost` documents support categories, tags, and embedded video via TipTap's rich-text editor on the frontend. Public reads are unauthenticated (`/api/blog/posts`); writes require admin role. Seed data lives in `backend/src/seeds/seedBlogPosts.ts` and runs on dev startup.
|
||||
|
||||
### AI — [[AI Assist]]
|
||||
|
||||
OpenAI (model configurable per call) is exposed through `/api/ai/*`. The current surface includes purchase-request drafting, chat summarisation, and admin-facing intent classification. Requests are queued from the frontend's `actions/ai*` modules and use streaming where appropriate.
|
||||
|
||||
### File uploads
|
||||
|
||||
`multer` accepts multipart uploads (max 10 MB body, `app.ts:232`), `sharp` resizes images on the fly, and files land in `/uploads` (mounted as a static directory at `app.ts:260`). In production the path is configurable via `UPLOAD_PATH`.
|
||||
|
||||
### Caching, locks & background jobs
|
||||
|
||||
**Redis** (`backend/src/services/redis/`) is used for:
|
||||
- short-lived caches (sessions, marketplace listings)
|
||||
- locks used by `PaymentCoordinator` to serialise status transitions
|
||||
- rate-limit counters (currently disabled in code but plumbed in)
|
||||
|
||||
**Background workers** run inside the Express process for now — no separate worker tier. Notable timers:
|
||||
- `startPendingPaymentsCleanup()` — sweeps stale unpaid invoices
|
||||
- `startShkeeperHealthMonitor()` — pings the SHKeeper instance and surfaces alerts
|
||||
- Auto-seed logic on startup (gated by `NODE_ENV` and `AUTO_SEED_ON_START`)
|
||||
|
||||
## Request lifecycle (the happy path)
|
||||
|
||||
> [!example] End-to-end deal walk-through
|
||||
> 1. Buyer signs in (JWT). UI joins `user-<buyerId>` over Socket.IO.
|
||||
> 2. Buyer creates a [[Purchase Request]] → `POST /api/marketplace/requests`. The request lands in `pending`/`active`. Sellers in the matching category receive a Socket.IO notification.
|
||||
> 3. Seller views the request, opens [[Seller Offer]] modal, submits price + delivery time → `POST /api/marketplace/offers`. Buyer sees the offer arrive live.
|
||||
> 4. Buyer accepts an offer → request moves to `payment`. UI opens the payment selector.
|
||||
> 5. Buyer picks **SHKeeper** → backend creates a SHKeeper invoice, returns a wallet address + QR code. Buyer pays. SHKeeper webhook hits `/api/payment/shkeeper/webhook`; `PaymentCoordinator` flips `Payment.status = paid` and `PurchaseRequest.status = processing`.
|
||||
> 6. Seller ships. Buyer confirms delivery (or it auto-confirms after the SLA window). Admin triggers (or schedules) a **payout** → SHKeeper releases USDT to the seller's wallet.
|
||||
> 7. Both parties leave reviews. Points are awarded. The deal is closed.
|
||||
>
|
||||
> If the buyer disputes the delivery, jump to step 7 of the [[Dispute Flow]] instead.
|
||||
|
||||
## Cross-cutting concerns
|
||||
|
||||
- **Observability** — Sentry is initialised at the very top of `app.ts` (line 2-3) and on the frontend in `sentry.{client,edge,server}.config.ts`. Logs flow through `backend/src/utils/logger.ts`.
|
||||
- **Security** — `helmet`, CORS scoped to `FRONTEND_URL`, JWT with bcrypt-hashed passwords, role-gated middleware. Rate limiting is plumbed but currently disabled (see `app.ts:227`).
|
||||
- **Internationalisation** — Six locales (en, fr, vi, cn, ar, fa), with `fa` as default. RTL via `stylis-plugin-rtl`. See `frontend/src/locales/`.
|
||||
- **Theming** — MUI v7 with a custom theme in `frontend/src/theme/`. Dark mode is on the roadmap.
|
||||
- **Containerisation** — Docker Compose stacks for dev and prod live in both repos (`docker-compose.dev.yml`, `docker-compose.production.yml`).
|
||||
|
||||
## Where to go next
|
||||
|
||||
- [[Tech Stack]] — exact versions of every dependency.
|
||||
- [[Roles & Personas]] — who does what in the system.
|
||||
- [[Glossary]] — a domain dictionary you will want open in another pane.
|
||||
- [[01 - Architecture]] — service boundaries, module layout, and deployment topology.
|
||||
- [[02 - Data Models]] — MongoDB collections and field-by-field schemas.
|
||||
- [[03 - API Reference]] — every endpoint, its payload, and its auth requirements.
|
||||
- [[04 - Flows]] — diagrammed user journeys for every major use case.
|
||||
233
00 - Overview/Tech Stack.md
Normal file
233
00 - Overview/Tech Stack.md
Normal file
@@ -0,0 +1,233 @@
|
||||
---
|
||||
title: Tech Stack
|
||||
tags: [overview, tech-stack, dependencies, versions]
|
||||
created: 2026-05-23
|
||||
---
|
||||
|
||||
# Tech Stack
|
||||
|
||||
> [!info] Versions
|
||||
> Versions below are pulled directly from `frontend/package.json` and `backend/package.json` on the `development` branch. Where a `^` range is declared in package.json, the **declared minimum** is shown — the lockfile may have resolved a newer patch. When in doubt, check `yarn.lock` in each repo.
|
||||
|
||||
## Frontend stack
|
||||
|
||||
The frontend is a Next.js 16 App Router application written in TypeScript. The build is deliberately heavy on best-in-class libraries rather than home-grown solutions: MUI for components, Wagmi for Web3, React Query / SWR for data, Zod for validation, Sentry for errors. The package is `amn-frontend@2.6.5-beta` and requires Node `>=20`.
|
||||
|
||||
### Core framework & language
|
||||
|
||||
| Tool | Version | Purpose | Where used |
|
||||
|---|---|---|---|
|
||||
| Next.js | ^16.1.1 | App Router framework, SSR, RSC, image opt, file-based routing | `frontend/src/app/**` |
|
||||
| React | ^19.1.0 | UI runtime (concurrent, transitions) | Everywhere |
|
||||
| react-dom | ^19.1.0 | DOM renderer | Entry / layout |
|
||||
| TypeScript | ^5.8.3 | Strict typing across the codebase | `tsconfig.json` |
|
||||
| Node.js | >=20 | Runtime for `next dev`, `next build`, `next start` | `package.json` `engines` |
|
||||
|
||||
### UI, theming & icons
|
||||
|
||||
| Tool | Version | Purpose | Where used |
|
||||
|---|---|---|---|
|
||||
| @mui/material | ^7.1.0 | Component library (buttons, inputs, dialogs) | `frontend/src/components/**` |
|
||||
| @mui/lab | ^7.0.0-beta.12 | Pre-release MUI components (Timeline, Masonry) | Dispute timeline, layouts |
|
||||
| @mui/material-nextjs | ^7.1.0 | App Router cache + SSR integration | `frontend/src/app/layout.tsx` |
|
||||
| @mui/x-data-grid | ^8.4.0 | Admin grids with sorting / filtering | Admin dashboard tables |
|
||||
| @mui/x-date-pickers | ^8.4.0 | Date + time inputs | Request forms, delivery |
|
||||
| @mui/x-tree-view | ^8.4.0 | Category and admin trees | Category management |
|
||||
| @emotion/react | ^11.14.0 | CSS-in-JS engine for MUI | Theme + sx prop |
|
||||
| @emotion/styled | ^11.14.0 | Styled-component sugar | Custom MUI wrappers |
|
||||
| @emotion/cache | ^11.14.0 | RTL-aware emotion cache | RTL plugin chain |
|
||||
| stylis | ^4.3.6 | Emotion's CSS preprocessor | Style pipeline |
|
||||
| stylis-plugin-rtl | ^2.1.1 | Right-to-left CSS transformation | Persian/Arabic layouts |
|
||||
| @iconify/react | ^6.0.0 | Iconify icon component (100k+ icons) | Icons everywhere |
|
||||
| framer-motion | ^12.13.0 | Animations and transitions | Modals, page transitions |
|
||||
| @fontsource-variable/{inter,dm-sans,nunito-sans,public-sans} | ^5.2.5 | Self-hosted variable fonts | Theme typography |
|
||||
| @fontsource/barlow | ^5.2.5 | Display font for headlines | Marketing pages |
|
||||
|
||||
### Data, state & forms
|
||||
|
||||
| Tool | Version | Purpose | Where used |
|
||||
|---|---|---|---|
|
||||
| @tanstack/react-query | ^5.83.0 | Server state, mutations, caching | API hooks under `actions/` |
|
||||
| swr | ^2.3.3 | Lightweight data fetching | Lists, polling endpoints |
|
||||
| react-hook-form | ^7.56.4 | Form state and validation glue | Every form |
|
||||
| @hookform/resolvers | ^5.0.1 | Bridges RHF with Zod schemas | Form schemas |
|
||||
| zod | ^4.0.10 | Runtime + compile-time schema validation | `actions/**/schema.ts` |
|
||||
| es-toolkit | ^1.38.0 | Modern Lodash alternative | Utility helpers |
|
||||
| dayjs | ^1.11.13 | Date manipulation | Formatting throughout |
|
||||
| date-fns-jalali | ^4.1.0-0 | Jalali (Persian) date formatting | Persian locale |
|
||||
| axios | ^1.11.0 | HTTP client | Backend calls in `actions/` |
|
||||
|
||||
### Web3 & crypto
|
||||
|
||||
| Tool | Version | Purpose | Where used |
|
||||
|---|---|---|---|
|
||||
| wagmi | ^2.15.6 | React hooks for EVM (connect, signer, tx) | `frontend/src/web3/` |
|
||||
| viem | ^2.31.7 | Low-level EVM client | RPC calls, encoding |
|
||||
| ethers | ^6.15.0 | Alternative EVM client (legacy paths) | Tx signing helpers |
|
||||
| @depay/widgets | ^13.0.36 | DePay drop-in payment widget | Wallet checkout |
|
||||
| alchemy-sdk | ^3.6.1 | Enriched RPC + transaction history | Tx verification |
|
||||
|
||||
### Realtime, i18n, rich text
|
||||
|
||||
| Tool | Version | Purpose | Where used |
|
||||
|---|---|---|---|
|
||||
| socket.io-client | ^4.8.1 | WebSocket transport | Chat, notifications |
|
||||
| i18next | ^25.2.1 | i18n engine | All translations |
|
||||
| react-i18next | ^15.5.2 | React bindings for i18next | `useTranslation` hooks |
|
||||
| i18next-browser-languagedetector | ^8.1.0 | Browser locale detection | Initial language pick |
|
||||
| i18next-resources-to-backend | ^1.2.1 | Lazy locale loading | `frontend/src/locales/` |
|
||||
| accept-language | ^3.0.20 | Server-side language negotiation | SSR locale detection |
|
||||
| @tiptap/{core,react,starter-kit,...} | ^2.12.0 | Rich-text editor (blog, descriptions) | Blog editor |
|
||||
| lowlight | ^3.3.0 | Code syntax highlighting in TipTap | Blog code blocks |
|
||||
| react-markdown | 10.1.0 | Markdown rendering | Blog views, chat |
|
||||
| remark-gfm / rehype-raw / rehype-highlight | latest | Markdown extensions | Blog pipeline |
|
||||
| turndown | 7.2.0 | HTML → Markdown converter | Blog migration |
|
||||
|
||||
### Maps, media, misc UI
|
||||
|
||||
| Tool | Version | Purpose | Where used |
|
||||
|---|---|---|---|
|
||||
| mapbox-gl | ^3.12.0 | Map rendering | Address picker |
|
||||
| react-map-gl | ^8.0.4 | React wrapper for Mapbox | Address picker |
|
||||
| react-apexcharts | ^1.7.0 | Charts and graphs | Admin analytics |
|
||||
| react-dropzone | ^14.3.8 | Drag-and-drop file upload | Attachments |
|
||||
| react-phone-number-input | ^3.4.12 | International phone input | Profile, address |
|
||||
| mui-one-time-password-input | ^5.0.0 | OTP input cells | Email verification |
|
||||
| embla-carousel + autoplay/auto-scroll | 8.6.0 | Carousels | Marketing, blog hero |
|
||||
| notistack | ^3.0.2 | Snackbar / toast queue | App-wide notifications |
|
||||
| sonner | ^2.0.3 | Modern toast library | Newer surfaces |
|
||||
| nprogress | ^0.2.0 | Top-of-page progress bar | Page transitions |
|
||||
| qrcode | ^1.5.4 | QR code generation | Wallet QR for invoices |
|
||||
| simplebar-react | ^3.3.0 | Custom scrollbars | Sidebars, drawers |
|
||||
| yet-another-react-lightbox | ^3.23.2 | Image lightbox | Gallery viewers |
|
||||
| autosuggest-highlight | ^3.3.4 | Search match highlighting | Combobox results |
|
||||
| minimal-shared | ^1.0.11 | Shared theme helpers (from Minimal template) | Theme tokens |
|
||||
|
||||
### Observability & tooling
|
||||
|
||||
| Tool | Version | Purpose | Where used |
|
||||
|---|---|---|---|
|
||||
| @sentry/nextjs | ^10.22.0 | Error + perf monitoring | `sentry.*.config.ts` |
|
||||
| @playwright/test | ^1.56.1 | E2E test runner | `e2e/` |
|
||||
| jest | ^29.7.0 | Unit test runner | `__tests__/` |
|
||||
| @testing-library/react | ^16.3.0 | React component testing | Component tests |
|
||||
| eslint + perfectionist/import/react plugins | ^9.27.0 | Linting | `eslint.config.mjs` |
|
||||
| prettier | ^3.5.3 | Formatting | `prettier.config.mjs` |
|
||||
| @svgr/webpack | ^8.1.0 | SVG → React component loader | Build pipeline |
|
||||
|
||||
## Backend stack
|
||||
|
||||
The backend is `amn-backend@2.6.3-beta`, an Express 5 server in TypeScript backed by MongoDB (Mongoose), Redis, and Socket.IO. It owns all integrations with SHKeeper, the EVM chains, OpenAI, Google OAuth, and SMTP.
|
||||
|
||||
### Core runtime & framework
|
||||
|
||||
| Tool | Version | Purpose | Where used |
|
||||
|---|---|---|---|
|
||||
| Node.js | (per Dockerfile) | Runtime | `Dockerfile.prod` |
|
||||
| TypeScript | ^5.8.3 | Strict typing | `tsconfig.json` |
|
||||
| express | ^5.2.1 | HTTP framework | `backend/src/app.ts` |
|
||||
| body-parser | ^2.2.0 | Body parsing (legacy fallback) | Body middleware |
|
||||
| helmet | ^8.1.0 | HTTP security headers | `app.ts:189` |
|
||||
| cors | ^2.8.5 | Cross-origin policy | `app.ts:194` |
|
||||
| express-rate-limit | ^8.0.1 | Rate-limit middleware (currently off) | Plumbed in |
|
||||
| express-validator | ^7.2.1 | Request validation | Auth, marketplace |
|
||||
| multer | ^2.0.2 | Multipart file uploads | `services/file/` |
|
||||
| sharp | ^0.34.3 | Image resizing / format conversion | Upload pipeline |
|
||||
| dotenv | ^17.2.0 | Env var loader | Bootstrap |
|
||||
| uuid | ^11.1.0 | ID generation | Tokens, ephemeral IDs |
|
||||
| axios | ^1.11.0 | Outbound HTTP (SHKeeper, blockchain) | Integration calls |
|
||||
| @babel/runtime | ^7.27.6 | Babel runtime helpers | Compiled output |
|
||||
|
||||
> [!warning] React in backend dependencies
|
||||
> `react` and `react-dom` are listed in `backend/package.json:86-87`. These are vestigial — they slipped in via a tool that shared types and are not used at runtime. Safe to remove during cleanup; see `backend/TODO.md`.
|
||||
|
||||
### Database & cache
|
||||
|
||||
| Tool | Version | Purpose | Where used |
|
||||
|---|---|---|---|
|
||||
| mongoose | ^8.16.4 | MongoDB ODM | `backend/src/models/**` |
|
||||
| redis | ^5.6.0 | Cache, locks, rate-limit store | `services/redis/`, `app.ts:362` |
|
||||
| mongodb-memory-server | ^10.2.0 (dev) | In-memory Mongo for tests | `__tests__/` |
|
||||
|
||||
### Auth, crypto & validation
|
||||
|
||||
| Tool | Version | Purpose | Where used |
|
||||
|---|---|---|---|
|
||||
| jsonwebtoken | ^9.0.2 | JWT issue + verify | `services/auth/` |
|
||||
| bcrypt | ^6.0.0 | Password hashing (native) | Auth service |
|
||||
| bcryptjs | ^3.0.2 | Pure-JS fallback for bcrypt | Auth fallback |
|
||||
| google-auth-library | ^10.3.0 | Google OAuth token verification | `googleOAuthService.ts` |
|
||||
| crypto | ^1.0.1 | Node `crypto` polyfill (legacy package) | Webhook signing |
|
||||
|
||||
### Realtime, AI, email
|
||||
|
||||
| Tool | Version | Purpose | Where used |
|
||||
|---|---|---|---|
|
||||
| socket.io | ^4.8.1 | WebSocket server with rooms | `app.ts:70-179` |
|
||||
| openai | ^5.10.1 | OpenAI SDK | `services/ai/` |
|
||||
| nodemailer | ^7.0.5 | SMTP email | `services/email/` |
|
||||
| @types/nodemailer | ^6.4.17 | Types | dev |
|
||||
|
||||
### Blockchain
|
||||
|
||||
| Tool | Version | Purpose | Where used |
|
||||
|---|---|---|---|
|
||||
| ethers | ^6.15.0 | EVM client | `services/blockchain/` |
|
||||
| web3 | ^4.16.0 | Alternative EVM client | Legacy paths |
|
||||
|
||||
### Observability
|
||||
|
||||
| Tool | Version | Purpose | Where used |
|
||||
|---|---|---|---|
|
||||
| @sentry/node | ^10.22.0 | Error + perf monitoring | `config/sentry.ts` |
|
||||
| @sentry/profiling-node | ^10.22.0 | CPU profiling | Sentry init |
|
||||
|
||||
### Build & test
|
||||
|
||||
| Tool | Version | Purpose | Where used |
|
||||
|---|---|---|---|
|
||||
| ts-node | ^10.9.2 | TS execution in dev / seeds | `npm run dev` |
|
||||
| nodemon | ^3.1.10 | Auto-restart on change | `dev:start` script |
|
||||
| jest | ^29.7.0 | Unit / integration tests | `__tests__/` |
|
||||
| ts-jest | ^29.4.0 | TS transform for Jest | jest config |
|
||||
| supertest | ^7.1.4 | HTTP-level API tests | `__tests__/` |
|
||||
| eslint | ^9.31.0 | Linting | `eslint.config.js` |
|
||||
| prettier | ^3.6.2 | Formatting | scripts |
|
||||
|
||||
## Infrastructure
|
||||
|
||||
| Component | Tool / version | Purpose | Notes |
|
||||
|---|---|---|---|
|
||||
| Container engine | Docker + Docker Compose | Dev & prod deployment | `docker-compose.dev.yml`, `docker-compose.production.yml` in each repo |
|
||||
| Reverse proxy | Nginx (external) | TLS termination, routing | `TRUST_PROXY=true` recognised in `app.ts:64` |
|
||||
| Database | MongoDB | Primary store | Connection string via env |
|
||||
| Cache | Redis | Sessions, locks, ephemeral data | Optional — backend boots without it |
|
||||
| Object storage | Local disk `/uploads` | User uploads | `UPLOAD_PATH` env override |
|
||||
| Process manager | Docker `restart: unless-stopped` (typical) | Crash recovery | Per compose file |
|
||||
| CI/CD | Manual + `scripts/auto-version.sh` | Semver bumps + tags | `npm run release:*` |
|
||||
|
||||
## External services
|
||||
|
||||
| Service | Purpose | Touchpoint in code |
|
||||
|---|---|---|
|
||||
| **SHKeeper** | Self-hosted crypto payment processor — issues wallets, watches for incoming USDT, pays out | `backend/src/services/payment/shkeeper/` |
|
||||
| **DePay** | Drop-in Web3 widget for wallet-to-wallet payment | `@depay/widgets` on frontend |
|
||||
| **EVM chains** (BSC, Ethereum mainnet, Sepolia, Polygon) | Settlement layer for stablecoin transfers | `frontend/src/web3/config.ts`, backend `blockchain/` |
|
||||
| **Alchemy RPC** | Hosted EVM RPC + transaction lookup | Frontend `alchemy-sdk`, backend `blockchainTxFetcher.ts` |
|
||||
| **MetaMask / WalletConnect** | Wallet connectors via Wagmi | `web3/config.ts` (WalletConnect commented out pending SSR fix) |
|
||||
| **OpenAI** | LLM for drafting / summarising | `backend/src/services/ai/` |
|
||||
| **Google OAuth** | Federated login | `googleOAuthService.ts` |
|
||||
| **SMTP** (provider configured per env) | Transactional email | `services/email/` |
|
||||
| **Sentry** | Error + performance monitoring | Both repos |
|
||||
| **BSCScan / explorers** | Link-out for tx visualisation | Payment UI |
|
||||
| **Mapbox** | Maps for address selection | `frontend/src/sections/address/` |
|
||||
|
||||
> [!tip] How to upgrade
|
||||
> Always upgrade frontend and backend together when they share a wire protocol — chat events, Socket.IO room schemas, and the `Payment` model are the three areas most sensitive to drift. After bumping a major version, run both `yarn typecheck` and the full test suites (`yarn test` in backend, `yarn test` and `yarn test:e2e` in frontend) before tagging a release.
|
||||
|
||||
## See also
|
||||
|
||||
- [[System Overview]] — how all these pieces talk to each other.
|
||||
- [[Roles & Personas]] — who consumes each part of the stack.
|
||||
- [[01 - Architecture/Deployment Topology]] — production layout.
|
||||
- [[07 - Development/Local Setup]] — getting the stack running on your machine.
|
||||
311
01 - Architecture/Backend Architecture.md
Normal file
311
01 - Architecture/Backend Architecture.md
Normal file
@@ -0,0 +1,311 @@
|
||||
---
|
||||
title: Backend Architecture
|
||||
tags: [architecture, backend]
|
||||
created: 2026-05-23
|
||||
---
|
||||
|
||||
# Backend Architecture
|
||||
|
||||
Module-level architecture of the Express 5 + TypeScript + Mongoose backend at `/Users/mojtabaheidari/code/backend` (development branch).
|
||||
|
||||
> [!info]
|
||||
> Repo: `git@git.manko.yoga:222/nick/backend.git` · Branch: `development` · Version: 2.6.3-beta (`package.json:4`)
|
||||
|
||||
---
|
||||
|
||||
## 1. Folder tree
|
||||
|
||||
```
|
||||
backend/src/
|
||||
├── app.ts # Express bootstrap, middleware chain, route registration
|
||||
├── config/ # Per-feature config (legacy — most moved to shared/config)
|
||||
├── controllers/ # HTTP request handlers (slim — delegate to services)
|
||||
├── infrastructure/
|
||||
│ ├── database/ # Mongoose connection, retries, graceful shutdown
|
||||
│ └── socket/socketService.ts # Socket.IO server, rooms, emit helpers
|
||||
├── models/ # Mongoose models — see 02 - Data Models/
|
||||
├── routes/ # Express Router definitions (mounted in app.ts)
|
||||
├── scripts/ # CLI utilities (seed:users, seed:categories, ...)
|
||||
├── seeds/ # Seed data fixtures
|
||||
├── services/
|
||||
│ ├── ai/ # OpenAI integration (descriptions, moderation)
|
||||
│ ├── auth/ # JWT, OAuth, Passkey, password reset
|
||||
│ ├── blockchain/ # Web3 read/verify helpers
|
||||
│ ├── blog/ # Posts, categories, comments
|
||||
│ ├── chat/ # Conversations, messages, attachments
|
||||
│ ├── dispute/ # Dispute lifecycle, evidence, mediator
|
||||
│ ├── file/ # Multer uploads, MIME validation
|
||||
│ ├── marketplace/ # PurchaseRequest, SellerOffer, Template, Shop
|
||||
│ ├── notification/ # Templates, delivery, mark-as-read
|
||||
│ ├── payment/ # Payment orchestration + shkeeper/ subdir
|
||||
│ │ └── shkeeper/ # SHKeeper API, webhook, payout
|
||||
│ ├── points/ # Loyalty points, levels, redemption
|
||||
│ ├── redis/ # Redis client, cache helpers
|
||||
│ ├── user/ # Profile, preferences, addresses
|
||||
│ ├── admin/ # Admin-only operations
|
||||
│ └── email/ # Nodemailer transport + templates
|
||||
├── shared/
|
||||
│ ├── config/index.ts # Centralised env-var loader (typed)
|
||||
│ ├── middleware/ # auth, errorHandler, validators
|
||||
│ ├── types/ # Cross-cutting TypeScript types
|
||||
│ └── utils/response-handler.ts # Standard success/error response envelope
|
||||
└── utils/ # Pure utility fns (logger, currencyUtils, etc.)
|
||||
```
|
||||
|
||||
> [!tip]
|
||||
> Service folders are self-contained: each typically has `<feature>Service.ts`, `<feature>Controller.ts`, `<feature>Routes.ts`, `<feature>Validation.ts`. This makes each service movable to a microservice later with minimal coupling.
|
||||
|
||||
---
|
||||
|
||||
## 2. Bootstrap — `src/app.ts`
|
||||
|
||||
The bootstrap is intentionally linear and easy to audit. Execution order:
|
||||
|
||||
1. **Imports & env load** — `dotenv` (if used), then `import { config } from './shared/config'`.
|
||||
2. **Express app construction** — `const app = express();`
|
||||
3. **Trust proxy** — `app.set('trust proxy', config.trustProxy)` so X-Forwarded-For works behind Nginx.
|
||||
4. **Security headers** — `app.use(helmet({ ... }))`.
|
||||
5. **CORS** — `cors({ origin: config.frontendUrl, credentials: true, methods: [...] })`.
|
||||
6. **Body parsers** — `express.json({ limit: '10mb' })`, `express.urlencoded({ extended: true })`.
|
||||
7. **Static uploads** — `app.use('/uploads', express.static(uploadDir))`.
|
||||
8. **Health endpoint** — `GET /health` for Docker healthcheck and external monitors.
|
||||
9. **Route mounting** — every `/api/*` route registered before the error handler.
|
||||
10. **404 handler** — catches unmatched `/api/*`.
|
||||
11. **Error handler** — central `errorHandler` middleware formats responses via `response-handler.ts`.
|
||||
12. **HTTP server creation** — `const server = http.createServer(app)`.
|
||||
13. **Socket.IO attach** — `initSocket(server, corsOptions)` (see [[Real-time Layer]]).
|
||||
14. **DB connect** — `await connectDatabase()`.
|
||||
15. **Redis connect** — `await connectRedis()`.
|
||||
16. **Listen** — `server.listen(config.port, ...)`.
|
||||
17. **Graceful shutdown** — SIGTERM/SIGINT handlers close server, drain sockets, close Mongoose, close Redis.
|
||||
18. **Optional dev seeding** — when `NODE_ENV === 'development'` and `SEED_USERS !== 'false'`, the bootstrap calls the seed scripts to provision default test users.
|
||||
|
||||
---
|
||||
|
||||
## 3. Middleware chain
|
||||
|
||||
| Order | Middleware | Where | Purpose |
|
||||
|---|---|---|---|
|
||||
| 1 | `helmet` | global | Sets security headers (CSP, X-Frame-Options, ...). |
|
||||
| 2 | `cors` | global | Origin allow-list = `config.frontendUrl`, credentials enabled. |
|
||||
| 3 | `express.json` / `express.urlencoded` | global | Body parsers (10MB limit). |
|
||||
| 4 | `morgan` (dev only) | global | HTTP request log to stdout. |
|
||||
| 5 | `requestId` | global | Adds `X-Request-Id` for log correlation. |
|
||||
| 6 | `authMiddleware` | per-route | Verifies JWT, attaches `req.user`. Mounted only on protected routes. |
|
||||
| 7 | `roleGuard('admin'|'seller'|...)` | per-route | RBAC check after auth. |
|
||||
| 8 | `validate(schema)` | per-route | express-validator + zod inputs. |
|
||||
| 9 | `controllerFn` | per-route | Delegates to service layer. |
|
||||
| 10 | `notFound` | tail | Returns 404 envelope for unmatched routes. |
|
||||
| 11 | `errorHandler` | tail | Catches thrown errors, formats response. |
|
||||
|
||||
> [!warning]
|
||||
> Rate-limit middleware is **disabled by default for personal use** (see `app.ts:227` cited in the architecture review). Enable before any real public traffic — `express-rate-limit` is already a dependency.
|
||||
|
||||
---
|
||||
|
||||
## 4. Route registration
|
||||
|
||||
The full route table mounted by `app.ts`:
|
||||
|
||||
| Mount path | Module | Auth | Notes |
|
||||
|---|---|---|---|
|
||||
| `/api/auth` | `services/auth/authRoutes.ts` | mixed | login, register, refresh, OAuth, passkey |
|
||||
| `/api/user` | `services/user/userRoutes.ts` | JWT | profile, preferences |
|
||||
| `/api/address` | `services/user/addressRoutes.ts` | JWT | CRUD addresses |
|
||||
| `/api/marketplace/requests` | `services/marketplace/controllerRoutes.ts` | JWT | PurchaseRequest CRUD |
|
||||
| `/api/marketplace/offers` | `services/marketplace/controllerRoutes.ts` | JWT (seller) | SellerOffer CRUD |
|
||||
| `/api/marketplace/templates` | `services/marketplace/controllerRoutes.ts` | JWT (seller) | RequestTemplate CRUD |
|
||||
| `/api/marketplace/categories` | `services/marketplace/controllerRoutes.ts` | public read | Category list |
|
||||
| `/api/marketplace/shop-settings` | `services/marketplace/shopSettingsController.ts` | JWT (seller) | Shop profile |
|
||||
| `/api/payment` | `services/payment/paymentRoutes.ts` | JWT | Payment intent, status |
|
||||
| `/api/payment/shkeeper/webhook` | `services/payment/shkeeper/shkeeperWebhook.ts` | HMAC | Inbound from gateway |
|
||||
| `/api/payment/payout` | `services/payment/shkeeper/shkeeperPayoutService.ts` | JWT (seller/admin) | Withdraw to wallet |
|
||||
| `/api/chat` | `services/chat/chatRoutes.ts` | JWT | Conversations, messages |
|
||||
| `/api/notification` | `services/notification/notificationRoutes.ts` | JWT | List, mark read |
|
||||
| `/api/dispute` | `services/dispute/disputeRoutes.ts` | JWT | Open, evidence, resolve |
|
||||
| `/api/blog` | `services/blog/blogRoutes.ts` | mixed | Public read, admin write |
|
||||
| `/api/admin` | `services/admin/adminRoutes.ts` | JWT (admin) | Mod operations |
|
||||
| `/api/points` | `services/points/pointsRoutes.ts` | JWT | Balance, redemption |
|
||||
| `/api/ai` | `services/ai/aiRoutes.ts` | JWT | OpenAI-backed helpers |
|
||||
| `/api/file` | `services/file/fileRoutes.ts` | JWT | Multipart upload |
|
||||
|
||||
Full per-endpoint details → [[03 - API Reference/API Overview]] and the service-specific reference docs.
|
||||
|
||||
---
|
||||
|
||||
## 5. Service layer pattern
|
||||
|
||||
Every service module follows this contract:
|
||||
|
||||
```ts
|
||||
// services/<feature>/<feature>Service.ts
|
||||
export class FeatureService {
|
||||
static async createX(input, ctx): Promise<X> { /* business logic */ }
|
||||
static async getX(id, ctx): Promise<X | null> { /* ... */ }
|
||||
static async listX(filter, ctx): Promise<X[]> { /* ... */ }
|
||||
static async updateX(id, patch, ctx): Promise<X> { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
- Controllers are **thin** — they validate request shape, call the service, format the response.
|
||||
- Services own **business logic**, side effects (DB writes, socket emits, email sends).
|
||||
- Models are **pure schema** — only Mongoose definitions + virtuals/hooks.
|
||||
|
||||
Cross-service calls are direct imports — no event bus yet. When the system grows, the seam between services is a natural place to introduce a message queue.
|
||||
|
||||
---
|
||||
|
||||
## 6. Dependency map (simplified)
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
auth[auth]
|
||||
user[user]
|
||||
market[marketplace]
|
||||
pay[payment]
|
||||
chat[chat]
|
||||
notify[notification]
|
||||
dispute[dispute]
|
||||
points[points]
|
||||
file[file]
|
||||
email[email]
|
||||
socket[socket]
|
||||
|
||||
auth --> user
|
||||
auth --> notify
|
||||
market --> notify
|
||||
market --> chat
|
||||
market --> file
|
||||
pay --> market
|
||||
pay --> notify
|
||||
pay --> socket
|
||||
dispute --> market
|
||||
dispute --> chat
|
||||
dispute --> notify
|
||||
points --> notify
|
||||
notify --> socket
|
||||
notify --> email
|
||||
```
|
||||
|
||||
> [!note]
|
||||
> `socket` and `email` are leaf services — every notification path funnels through them. Mocking these two in tests covers most side-effect verification.
|
||||
|
||||
---
|
||||
|
||||
## 7. Error handling
|
||||
|
||||
All thrown errors are caught by the central error handler. The expected shape:
|
||||
|
||||
```ts
|
||||
class AppError extends Error {
|
||||
statusCode: number; // HTTP 4xx/5xx
|
||||
code: string; // app-specific code, e.g. "PAYMENT_ALREADY_REFUNDED"
|
||||
details?: unknown; // optional debug payload
|
||||
}
|
||||
```
|
||||
|
||||
Response envelope (success path is `{success:true,data:...}`):
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "VALIDATION_FAILED",
|
||||
"message": "email is required",
|
||||
"details": [{ "path": "email", "msg": "required" }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See `backend/src/shared/utils/response-handler.ts` and `backend/src/shared/middleware/errorHandler.ts`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Configuration
|
||||
|
||||
Single source of truth for env vars: `src/shared/config/index.ts`. It exports a typed `config` object — anywhere you would write `process.env.X`, instead import `config.x`.
|
||||
|
||||
Full table in [[Environment Variables]]. Critical ones:
|
||||
|
||||
| Key | Default | Notes |
|
||||
|---|---|---|
|
||||
| `PORT` | `5001` | Listen port |
|
||||
| `MONGODB_URI` | `mongodb://localhost:27017/nickapp` | Includes db name |
|
||||
| `REDIS_URI` | `redis://localhost:6379` | + `REDIS_PASSWORD` |
|
||||
| `JWT_SECRET` | required | ≥32 chars |
|
||||
| `JWT_EXPIRES_IN` | `7d` | |
|
||||
| `REFRESH_TOKEN_EXPIRES_IN` | `30d` | |
|
||||
| `FRONTEND_URL` | `http://localhost:3000` | CORS origin |
|
||||
| `SHKEEPER_API_URL` | `https://pay.amn.gg` | |
|
||||
| `SHKEEPER_API_KEY` | required | |
|
||||
| `SHKEEPER_WEBHOOK_SECRET` | required | HMAC key |
|
||||
| `SMTP_*` | required | Nodemailer |
|
||||
| `OPENAI_API_KEY` | required | |
|
||||
|
||||
---
|
||||
|
||||
## 9. Database & connection management
|
||||
|
||||
- **Mongoose** is the ODM. Connection in `src/infrastructure/database/`.
|
||||
- Connection options enable retryable writes, exponential backoff on reconnect.
|
||||
- Indexes are defined on each model and auto-created on connect (Mongoose `autoIndex: true` in dev, recommend `false` in prod with explicit migration).
|
||||
- See [[Data Model Overview]] for the relational map and per-model docs.
|
||||
|
||||
Redis client (in `src/services/redis/`) provides:
|
||||
- Session caching (login attempts, lockout counters)
|
||||
- Rate-limit counters (when middleware is enabled)
|
||||
- Hot-path caches (category list, level configs)
|
||||
|
||||
---
|
||||
|
||||
## 10. Background work
|
||||
|
||||
The codebase has no dedicated queue runner — scheduled / async work is triggered inline from request handlers and uses `setTimeout` / `setInterval` patterns where needed (e.g., delayed retries). Consider introducing Bull / BullMQ if you grow:
|
||||
|
||||
- Payment status reconciliation (polling SHKeeper for stragglers)
|
||||
- Notification email digests
|
||||
- Auto-release escrow timers
|
||||
- Token / refresh-token cleanup
|
||||
|
||||
---
|
||||
|
||||
## 11. Testing
|
||||
|
||||
Jest test suites in `backend/__tests__/`:
|
||||
|
||||
| File | Covers |
|
||||
|---|---|
|
||||
| `models.test.ts` | Schema validation, virtuals, hooks |
|
||||
| `payment-services.test.ts` | Payment orchestration logic |
|
||||
| `complete-backend.test.ts` | Cross-service integration |
|
||||
| `shkeeper-backend.test.ts` | SHKeeper service + webhook |
|
||||
|
||||
Run with `npm run test:all`. CI runs the same. Reach for `npm run test:models`, `npm run test:payment`, etc. when iterating on a slice.
|
||||
|
||||
---
|
||||
|
||||
## 12. Notable files for orientation
|
||||
|
||||
| File | Why it matters |
|
||||
|---|---|
|
||||
| `src/app.ts` | Bootstrap — read once to understand wiring |
|
||||
| `src/shared/config/index.ts` | All env vars, typed |
|
||||
| `src/shared/utils/response-handler.ts` | Standard response shape |
|
||||
| `src/shared/middleware/auth.ts` | JWT verify + RBAC |
|
||||
| `src/infrastructure/socket/socketService.ts` | All socket plumbing |
|
||||
| `src/services/payment/shkeeper/shkeeperWebhook.ts` | Webhook signature scheme |
|
||||
| `src/services/marketplace/PurchaseRequestService.ts` | Core marketplace state machine |
|
||||
| `src/services/auth/authService.ts` | Auth flows, lockout, hashing |
|
||||
| `src/models/User.ts` | Central entity with role/preferences |
|
||||
| `openapi.json` | Generated API spec — definitive endpoint list |
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- [[System Architecture]] — full system topology
|
||||
- [[Frontend Architecture]] — how the FE talks to this BE
|
||||
- [[Real-time Layer]] — Socket.IO room model
|
||||
- [[Security Architecture]] — JWT, passkeys, webhook HMAC
|
||||
- [[Data Model Overview]] — entity-relationship map
|
||||
- [[Authentication Flow]] · [[Payment Flow - SHKeeper]] · [[Dispute Flow]]
|
||||
369
01 - Architecture/Frontend Architecture.md
Normal file
369
01 - Architecture/Frontend Architecture.md
Normal file
@@ -0,0 +1,369 @@
|
||||
---
|
||||
title: Frontend Architecture
|
||||
tags: [architecture, frontend, nextjs]
|
||||
created: 2026-05-23
|
||||
---
|
||||
|
||||
# Frontend Architecture
|
||||
|
||||
Module-level architecture of the Next.js 16 (App Router) + TypeScript + MUI v7 frontend at `/Users/mojtabaheidari/code/frontend` (development branch).
|
||||
|
||||
> [!info]
|
||||
> Repo: `git@git.manko.yoga:222/nick/frontend.git` · Branch: `development` · Version: 1.9.6 (`package.json:4`) · Dev port `3000`, Docker port `8083`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Folder tree
|
||||
|
||||
```
|
||||
frontend/src/
|
||||
├── app/ # Next.js 16 App Router (server + client islands)
|
||||
│ ├── layout.tsx # Root layout — providers, fonts, Sentry
|
||||
│ ├── (public)/ # Unauthenticated routes
|
||||
│ │ ├── post/[slug]/ # Blog reader
|
||||
│ │ └── shop/[seller]/[id] # Public seller / item view
|
||||
│ ├── auth/jwt/ # sign-in, sign-up, verify, reset, update
|
||||
│ ├── dashboard/ # AuthGuard + EmailVerificationGuard
|
||||
│ │ ├── overview/ # KPI / home tiles
|
||||
│ │ ├── chat/ # Real-time chat
|
||||
│ │ ├── account/ # Profile, address, notifications, wallet, passkey
|
||||
│ │ ├── request/ # Buyer purchase requests
|
||||
│ │ ├── request-template/ # Seller request templates
|
||||
│ │ ├── payment/ # Payment history / detail
|
||||
│ │ ├── points/ # Loyalty hub (transactions, referrals, levels)
|
||||
│ │ ├── disputes/ # Dispute hub
|
||||
│ │ ├── user/ # Admin user management
|
||||
│ │ ├── post/ # Admin blog editor
|
||||
│ │ ├── shop-settings/ # Seller shop config
|
||||
│ │ └── shops/ # Browse / checkout (dashboard scope)
|
||||
│ ├── error/ # Global error page
|
||||
│ └── not-found.tsx # 404
|
||||
├── sections/ # Page-specific composition modules (one folder per feature)
|
||||
│ └── (chat|payment|request|request-template|dispute|user|points|...)
|
||||
├── components/ # Reusable UI primitives (hook-form, table, upload, editor, ...)
|
||||
├── layouts/ # Page-template wrappers (auth-centered, auth-split, dashboard, main)
|
||||
├── theme/ # MUI theme creation, palette, typography, overrides
|
||||
├── settings/ # Settings drawer (mode, layout, direction, color, font)
|
||||
├── contexts/ # React Context providers (socket-context)
|
||||
├── hooks/ # 50+ custom hooks (use-boolean, use-socket, use-web3-wagmi, …)
|
||||
├── lib/ # Cross-cutting libs (axios.ts with interceptors)
|
||||
├── locales/ # i18next config + langs/{en,fa,ar,fr,cn,vi}/*.json
|
||||
└── types/ # Cross-app TypeScript types
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Rendering strategy
|
||||
|
||||
- **App Router** with `output: 'standalone'` in `next.config.ts` for production single-binary serving.
|
||||
- **Server components by default**, **client components** opted into with `"use client"`. Heavy interactive pages (chat, editor, dashboard) are mostly client-side; SEO routes (`shop/`, `post/[slug]`) leverage server rendering.
|
||||
- **Turbopack** in dev (`next dev --turbopack`).
|
||||
- **Streaming** via `loading.tsx` and Suspense boundaries.
|
||||
- **MUI cache** is wired up via `@mui/material-nextjs` in the root layout to avoid FOUC across server/client boundary.
|
||||
|
||||
---
|
||||
|
||||
## 3. Provider tree (root layout)
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
A[RootLayout]
|
||||
A --> B[AppRouterCacheProvider<br/>MUI emotion cache]
|
||||
B --> C[ThemeProvider<br/>theme + RTL stylis]
|
||||
C --> D[LocalizationProvider<br/>dayjs adapter]
|
||||
D --> E[I18nProvider<br/>i18next]
|
||||
E --> F[QueryClientProvider<br/>React Query]
|
||||
F --> G[SocketProvider<br/>Socket.IO context]
|
||||
G --> H[SnackbarProvider<br/>notistack]
|
||||
H --> I[Children — routes]
|
||||
```
|
||||
|
||||
Order matters: theme must wrap query (because mutations show snackbars styled by theme); socket wraps snackbar (so socket-driven notifications can fire snackbars).
|
||||
|
||||
---
|
||||
|
||||
## 4. Route layout & guards
|
||||
|
||||
| Route group | Layout | Guard chain |
|
||||
|---|---|---|
|
||||
| `(public)` | `layouts/main` | none |
|
||||
| `auth/jwt/*` | `layouts/auth-centered` or `auth-split` | redirect if already authed |
|
||||
| `dashboard/*` | `layouts/dashboard` (sidebar + topbar + breadcrumbs) | `AuthGuard` → `EmailVerificationGuard` |
|
||||
| `dashboard/user/*` | dashboard | + `role: admin` |
|
||||
| `dashboard/post/*` (editor) | dashboard | + `role: admin` |
|
||||
| `dashboard/shop-settings/*` | dashboard | + `role: seller` |
|
||||
|
||||
Guards live in `frontend/src/auth/` (HOC + hook). They consult the JWT-derived user context and redirect unauthenticated to `/auth/jwt/sign-in?returnTo=...`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Sections vs components vs hooks
|
||||
|
||||
The codebase enforces a three-layer split:
|
||||
|
||||
| Layer | Lives in | Purpose | Example |
|
||||
|---|---|---|---|
|
||||
| **Section** | `src/sections/<feature>/` | Page-specific composition; orchestrates components | `sections/chat/view/ChatView.tsx` |
|
||||
| **Component** | `src/components/<name>/` | Reusable across sections | `components/hook-form/RHFTextField.tsx` |
|
||||
| **Hook** | `src/hooks/<name>.ts` | Encapsulated stateful behavior | `hooks/use-chat-socket.ts` |
|
||||
|
||||
> [!tip]
|
||||
> Per the cursor rules (`backend/.cursor/rules/ui-development-standards.mdc`), every component folder has `index.ts` (barrel export), `<name>.tsx` (component), `classes.ts` (styled-component class names), `types.ts` (interface). Following this layout keeps each component refactorable.
|
||||
|
||||
---
|
||||
|
||||
## 6. State management
|
||||
|
||||
The frontend deliberately uses **three** state mechanisms, each for one concern:
|
||||
|
||||
| Concern | Tool | Where |
|
||||
|---|---|---|
|
||||
| Server data | **React Query** | every `useXxxQuery` / `useXxxMutation` hook |
|
||||
| Cross-page shared state | **React Context** | `contexts/socket-context.tsx`, settings context, auth context |
|
||||
| Per-component / local UI | `useState`, `useReducer`, `use-boolean`, `use-set-state` | inside components |
|
||||
|
||||
No Redux, no MobX, no Recoil. The cursor rules also mention **Zustand** as the preferred client store if one is added, but the dev branch currently relies on React Query + Context.
|
||||
|
||||
### React Query setup
|
||||
|
||||
- `QueryClient` created once at the root layout with `defaultOptions`:
|
||||
- `queries: { staleTime: 30_000, retry: 1, refetchOnWindowFocus: false }`
|
||||
- `mutations: { retry: 0 }`
|
||||
- Query keys are tuples — convention: `['<resource>', <filter|id|...>]` e.g. `['requests', { status: 'open' }]`.
|
||||
- Mutations invalidate related keys in `onSuccess`.
|
||||
|
||||
---
|
||||
|
||||
## 7. API client (`src/lib/axios.ts`)
|
||||
|
||||
A single axios instance underpins every HTTP call:
|
||||
|
||||
```ts
|
||||
const api = axios.create({
|
||||
baseURL: process.env.NEXT_PUBLIC_API_URL,
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
// Request interceptor — attach token
|
||||
api.interceptors.request.use((cfg) => {
|
||||
const token = getStoredToken();
|
||||
if (token) cfg.headers.Authorization = `Bearer ${token}`;
|
||||
cfg.headers['X-Request-Id'] = randomUUID();
|
||||
return cfg;
|
||||
});
|
||||
|
||||
// Response interceptor — handle 401, normalize errors
|
||||
api.interceptors.response.use(
|
||||
(res) => res.data,
|
||||
async (err) => {
|
||||
if (err.response?.status === 401 && !retried) {
|
||||
// attempt refresh-token flow
|
||||
await refresh();
|
||||
return api.request(err.config);
|
||||
}
|
||||
throw normalizeError(err);
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
Endpoint constants live alongside the hook that uses them — no central `api/endpoints.ts`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Real-time integration
|
||||
|
||||
The `SocketProvider` in `contexts/socket-context.tsx`:
|
||||
|
||||
1. Opens the connection after authentication (token from storage).
|
||||
2. Auto-joins user-specific rooms (`user-{id}`, `seller-{id}` or `buyer-{id}` based on role).
|
||||
3. Exposes a `useSocket()` hook returning `socket`, `connected`, helper emitters.
|
||||
|
||||
Higher-level hooks build on this:
|
||||
|
||||
| Hook | Subscribes to |
|
||||
|---|---|
|
||||
| `use-chat-socket` | `chat:new-message`, typing indicators, read receipts |
|
||||
| `use-conversations` | conversation list updates |
|
||||
| `use-notifications` | `notification:received` |
|
||||
| `use-purchase-requests` | `request:offer-received`, status changes |
|
||||
| `use-marketplace-socket` | broad market events |
|
||||
| `use-unified-real-time` | multi-event aggregator |
|
||||
|
||||
See [[Real-time Layer]] for the full event catalog.
|
||||
|
||||
---
|
||||
|
||||
## 9. Web3 integration
|
||||
|
||||
```ts
|
||||
// wagmi config (approx — confirm in src/web3/ or src/lib/wagmi.ts)
|
||||
const config = createConfig({
|
||||
chains: [bsc, polygon, mainnet, sepolia],
|
||||
transports: {
|
||||
[bsc.id]: http(`https://...alchemy.com/${KEY}`),
|
||||
[polygon.id]: http(`https://polygon-mainnet.g.alchemy.com/v2/${KEY}`),
|
||||
...
|
||||
},
|
||||
connectors: [
|
||||
injected(), // MetaMask
|
||||
walletConnect({ projectId: NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID }),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
Wallet UI: connect / disconnect / show address / show balance via `use-web3-wagmi`, `use-web3-context`. The DePay widget (`@depay/widgets`) is loaded for the assisted-pay flow.
|
||||
|
||||
---
|
||||
|
||||
## 10. Internationalization
|
||||
|
||||
- `i18next` + `react-i18next` initialized in `src/locales/locales-config.ts` with 6 langs (en, fa, ar, fr, cn, vi).
|
||||
- Translation files in `langs/<locale>/*.json` (e.g., `common.json`).
|
||||
- Direction (`ltr`|`rtl`) lives in settings; `stylis-plugin-rtl` is wired into the MUI cache when `dir === 'rtl'`.
|
||||
- Date formatting via `dayjs` with locale auto-loaded.
|
||||
- Number formatting via `Intl.NumberFormat` + helper at `locales/utils/number-format-locale.ts`.
|
||||
- DataGrid has Persian-specific locale at `custom-fa-data-grid-locale.ts`.
|
||||
|
||||
See [[Internationalization & RTL]] for full detail.
|
||||
|
||||
---
|
||||
|
||||
## 11. Forms & validation
|
||||
|
||||
`react-hook-form` + `zod` schema via `@hookform/resolvers/zod`. Custom field wrappers in `components/hook-form/`:
|
||||
|
||||
| Wrapper | Wraps |
|
||||
|---|---|
|
||||
| `RHFTextField` | MUI `TextField` |
|
||||
| `RHFSelect` | MUI `Select` |
|
||||
| `RHFAutocomplete` | MUI `Autocomplete` |
|
||||
| `RHFCheckbox` | MUI `Checkbox` |
|
||||
| `RHFRadioGroup` | MUI `RadioGroup` |
|
||||
| `RHFSwitch` | MUI `Switch` |
|
||||
| `RHFUpload` | custom Dropzone (`components/upload`) |
|
||||
| `RHFEditor` | TipTap editor wrapper |
|
||||
| `RHFDatePicker` | `@mui/x-date-pickers` |
|
||||
| `RHFPhoneInput` | `react-phone-number-input` |
|
||||
| `RHFCountrySelect` | `components/country-select` |
|
||||
|
||||
A typical form section:
|
||||
|
||||
```tsx
|
||||
const schema = z.object({ email: z.string().email(), password: z.string().min(8) });
|
||||
const methods = useForm({ resolver: zodResolver(schema) });
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<RHFTextField name="email" label="Email" />
|
||||
<RHFTextField name="password" label="Password" type="password" />
|
||||
<LoadingButton type="submit" loading={methods.formState.isSubmitting}>Sign in</LoadingButton>
|
||||
</FormProvider>
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Theming
|
||||
|
||||
`src/theme/index.ts` creates the theme; `src/theme/options/` contains palette / typography / overrides. Light & dark variants share tokens; the active mode is read from the **Settings** drawer.
|
||||
|
||||
- Primary: `Public Sans Variable` (per cursor rules).
|
||||
- Secondary: `Barlow`.
|
||||
- Breakpoints: xs=600, sm=600, md=900, lg=1200, xl=1536.
|
||||
- Shape radius: 8 (default), customizable in settings.
|
||||
|
||||
See [[Theme Configuration]] and [[Design System Overview]].
|
||||
|
||||
---
|
||||
|
||||
## 13. Settings drawer
|
||||
|
||||
`src/settings/` provides a side-drawer that lets a user toggle:
|
||||
|
||||
- Mode (light / dark / system)
|
||||
- Contrast (default / high)
|
||||
- Layout (vertical / mini / horizontal nav)
|
||||
- Direction (ltr / rtl)
|
||||
- Color preset (one of N curated palettes)
|
||||
- Font family override
|
||||
|
||||
State persists in `localStorage` under `settings-key`.
|
||||
|
||||
---
|
||||
|
||||
## 14. Editor (TipTap)
|
||||
|
||||
`components/editor/` wraps `@tiptap/react` with these extensions enabled:
|
||||
|
||||
- `StarterKit` (paragraph, headings, bold/italic, lists, blockquote)
|
||||
- `Link` (URL parsing)
|
||||
- `Image` (upload via `components/upload`)
|
||||
- `Underline`, `TextAlign`
|
||||
- `CodeBlock` + `CodeBlockLowlight` (syntax highlighting via `lowlight`)
|
||||
- `Placeholder`
|
||||
|
||||
Used in:
|
||||
- Blog post editor (`dashboard/post/new`, `dashboard/post/[id]/edit`)
|
||||
- Long-form description fields in Purchase Request & Request Template forms
|
||||
- Dispute evidence narrative
|
||||
|
||||
Content is stored as HTML in MongoDB. `react-markdown` + `remark-gfm` are available where Markdown rendering is preferred (e.g., chat messages).
|
||||
|
||||
---
|
||||
|
||||
## 15. File uploads
|
||||
|
||||
`components/upload/` provides a dropzone with:
|
||||
- Multi-file selection
|
||||
- Drag-and-drop
|
||||
- Per-file progress
|
||||
- Preview (`components/file-thumbnail/` shows by MIME)
|
||||
- Removal
|
||||
- Total-size enforcement (5 MB default, matches `MAX_FILE_SIZE`)
|
||||
|
||||
Backed by `/api/file/*` (multipart upload).
|
||||
|
||||
---
|
||||
|
||||
## 16. Sentry
|
||||
|
||||
`@sentry/nextjs` is initialised at app boot. Errors include user context (role, userId) and breadcrumbs (route changes, API failures). Source maps uploaded at build time.
|
||||
|
||||
---
|
||||
|
||||
## 17. Build & deploy
|
||||
|
||||
`package.json`:
|
||||
- `dev` → `next dev -p 8083 --turbopack`
|
||||
- `build` → `next build && cp .next/static .next/standalone/.next/ && cp -r public .next/standalone/`
|
||||
- `start` → `PORT=8083 node .next/standalone/server.js`
|
||||
|
||||
`Dockerfile` is a 2-stage multi-stage build that produces `.next/standalone/` and copies into a small `node:22-alpine` runner image with non-root user `nextjs`. Healthcheck via `curl http://localhost:8083`.
|
||||
|
||||
CI: `.gitea/workflows/deploy.yml` runs `scripts/deploy.sh` on push to `main` / `master`.
|
||||
|
||||
See [[Docker Setup]], [[CI-CD Pipeline]], and [[Deployment]].
|
||||
|
||||
---
|
||||
|
||||
## 18. Notable files for orientation
|
||||
|
||||
| File | Why it matters |
|
||||
|---|---|
|
||||
| `src/app/layout.tsx` | Provider tree |
|
||||
| `src/lib/axios.ts` | Every HTTP call goes through this |
|
||||
| `src/contexts/socket-context.tsx` | Realtime plumbing |
|
||||
| `src/theme/index.ts` | Theme creation entry |
|
||||
| `src/locales/locales-config.ts` | i18next setup |
|
||||
| `src/settings/context/` | Settings context (persistence) |
|
||||
| `next.config.ts` | Standalone build, COOP/COEP headers for Web3 popups |
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- [[System Architecture]] — bird's-eye topology
|
||||
- [[Backend Architecture]] — server peer
|
||||
- [[Real-time Layer]] — Socket.IO plumbing
|
||||
- [[Design System Overview]] · [[Theme Configuration]] · [[Components]]
|
||||
- [[Tech Stack]] — versions
|
||||
- [[Coding Standards]] — UI cursor-rules summary
|
||||
224
01 - Architecture/Infrastructure.md
Normal file
224
01 - Architecture/Infrastructure.md
Normal file
@@ -0,0 +1,224 @@
|
||||
---
|
||||
title: Infrastructure
|
||||
tags: [architecture, infrastructure, docker, devops]
|
||||
created: 2026-05-23
|
||||
---
|
||||
|
||||
# Infrastructure
|
||||
|
||||
How the system is packaged, deployed and run in production. Read alongside [[Docker Setup]] (operations runbook) and [[CI-CD Pipeline]].
|
||||
|
||||
---
|
||||
|
||||
## 1. Runtime topology
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
DNS[DNS amn.gg] --> CF[CloudFlare / Edge SSL]
|
||||
CF --> Nginx
|
||||
subgraph Host[Single Docker host]
|
||||
Nginx[nginx:alpine<br/>nickapp-nginx<br/>:8083]
|
||||
FE[nickapp-frontend:latest<br/>Next.js standalone<br/>:8083]
|
||||
BE[nickapp-backend:latest<br/>Express + Socket.IO<br/>:5001]
|
||||
Mongo[(mongo:8.0<br/>mongodb_data volume)]
|
||||
Redis[(redis:8-alpine<br/>redis_data volume)]
|
||||
Up[/uploads volume/]
|
||||
end
|
||||
Nginx --> FE
|
||||
Nginx --> BE
|
||||
BE --> Mongo
|
||||
BE --> Redis
|
||||
BE --- Up
|
||||
FE --- Up
|
||||
Watchtower>Watchtower<br/>auto-update] -.-> BE
|
||||
Watchtower -.-> FE
|
||||
```
|
||||
|
||||
> [!info]
|
||||
> Single-host today. Horizontal scaling requires Redis pub/sub adapter for Socket.IO and externalizing the `uploads/` volume (S3 / R2).
|
||||
|
||||
---
|
||||
|
||||
## 2. Docker images
|
||||
|
||||
| Image | Source Dockerfile | Stages | Final size target | User |
|
||||
|---|---|---|---|---|
|
||||
| `nickapp-backend:latest` | `backend/Dockerfile.prod` | builder + runner | ~200 MB | `marketplace` (uid 1001) |
|
||||
| `nickapp-backend:dev` | `backend/Dockerfile.dev` | single | ~400 MB | `marketplace` (uid 1001) |
|
||||
| `nickapp-frontend:latest` | `frontend/Dockerfile` | builder + runner | ~250 MB | `nextjs` (uid 1001) |
|
||||
| `nickapp-frontend:dev` | `frontend/Dockerfile.dev` | single | ~600 MB | root |
|
||||
|
||||
All built on `node:22-alpine`. Backend images include Python+make+g++ in the builder stage for native deps (bcrypt). Frontend runner only ships the `.next/standalone` output + `public/`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Compose stacks
|
||||
|
||||
### 3.1 `docker-compose.dev.yml` (backend repo)
|
||||
|
||||
Used by developers running `npm run docker:dev`. Three services:
|
||||
|
||||
| Service | Image | Ports | Volumes | Depends on |
|
||||
|---|---|---|---|---|
|
||||
| `nickdev-backend` | built from `Dockerfile.dev` | `5001:5001` | `./src:/app/src`, `./uploads:/app/uploads` | mongodb, redis |
|
||||
| `nickdev-mongodb` | `mongo:8.0` | `27017:27017` | `mongodb_data:/data/db` | — |
|
||||
| `nickdev-redis` | `redis:8-alpine` | (internal) | `redis_data:/data` | — |
|
||||
|
||||
Network: `nickapp-network` (bridge). Env: `.env.local`.
|
||||
|
||||
Frontend dev runs OUTSIDE compose — developer launches `yarn dev` on host so React Fast Refresh works.
|
||||
|
||||
### 3.2 `docker-compose.production.yml` (backend repo)
|
||||
|
||||
Used in production. Five services:
|
||||
|
||||
| Service | Image | Ports | Healthcheck | Watchtower |
|
||||
|---|---|---|---|---|
|
||||
| `nickapp-nginx` | `nginx:alpine` | `8083:80` | implicit | — |
|
||||
| `nickapp-backend` | `nickapp-backend:latest` (pulled) | 5001 internal | `node healthcheck.js` 30s | ✓ |
|
||||
| `nickapp-frontend` | `nickapp-frontend:latest` (pulled) | 8083 internal | `curl http://localhost:8083` 30s | ✓ |
|
||||
| `nickapp-mongodb` | `mongo:8.0` | internal | `mongosh ping` 30s | — |
|
||||
| `nickapp-redis` | `redis:8-alpine` (with `--requirepass`) | internal | `redis-cli ping` 30s | — |
|
||||
|
||||
Volumes: `mongodb_data`, `redis_data`, `./uploads`, `./nginx/nginx.conf`, `./nginx/logs`. Env: single root `.env`.
|
||||
|
||||
> [!warning]
|
||||
> `REDIS_PASSWORD` MUST be set in the production `.env` before starting — otherwise the Redis container fails its healthcheck and dependents won't start.
|
||||
|
||||
---
|
||||
|
||||
## 4. Nginx configuration
|
||||
|
||||
The Nginx proxy at `./nginx/nginx.conf` (mounted read-only) is responsible for:
|
||||
|
||||
- Terminating internal HTTP (SSL handled by an external edge — CloudFlare / nginx-proxy / Caddy)
|
||||
- Routing `/api/*` and `/socket.io/*` to `nickapp-backend:5001`
|
||||
- Routing everything else to `nickapp-frontend:8083`
|
||||
- Serving `/uploads/*` directly from the shared volume (bypasses the Node process)
|
||||
- Standard gzip / compression / client_max_body_size (≥10 MB to match backend body limit)
|
||||
|
||||
> [!tip]
|
||||
> Put the Nginx access log path on a tmpfs or rotate it aggressively — the container exposes `./nginx/logs` so the host can manage retention.
|
||||
|
||||
---
|
||||
|
||||
## 5. Watchtower (auto-update)
|
||||
|
||||
Both `nickapp-backend` and `nickapp-frontend` carry the `watchtower.enable=true` label. Watchtower polls the container registry on its configured interval and re-pulls when the `latest` tag moves.
|
||||
|
||||
Release cycle:
|
||||
1. Developer pushes commits to a feature branch → merged into `development`.
|
||||
2. Manual Gitea workflow `docker-build-simple.yml` builds & pushes `nickapp-backend:latest` (and a versioned tag) to `git.manko.yoga/manawenuz/escrow-backend`.
|
||||
3. Within the next poll interval (default 5 min) Watchtower restarts the affected service.
|
||||
|
||||
> [!warning]
|
||||
> Because Watchtower restarts only on tag change, sequential pushes are safe — but a broken build pushed to `latest` will roll out automatically. Keep `dev` and `production` tags separated, or pin production to a versioned tag.
|
||||
|
||||
---
|
||||
|
||||
## 6. Persistent storage
|
||||
|
||||
| Volume | What it stores | Backup priority |
|
||||
|---|---|---|
|
||||
| `mongodb_data` | All business data (users, requests, payments, chats, disputes...) | **Critical** — daily dump |
|
||||
| `redis_data` | Cache, session, rate counters | Low — losing it logs everyone out but no data loss |
|
||||
| `./uploads` (host bind) | Avatars, product images, dispute evidence, documents | **High** — daily rsync |
|
||||
| `./nginx/logs` | Access / error logs | Medium |
|
||||
|
||||
See [[Backup & Recovery]] for the runbook.
|
||||
|
||||
---
|
||||
|
||||
## 7. CI / CD
|
||||
|
||||
Located at `backend/.gitea/workflows/`:
|
||||
|
||||
| Workflow | Trigger | Output |
|
||||
|---|---|---|
|
||||
| `docker-build-simple.yml` | manual | Build → push backend image to registry (`latest` + version) |
|
||||
| `docker-build-dev.yml` | manual | Dev image variant |
|
||||
| `docker-build-no-cache.yml` | manual | Clean build (no GH-Actions cache) |
|
||||
|
||||
Frontend at `frontend/.gitea/workflows/`:
|
||||
|
||||
| Workflow | Trigger | Output |
|
||||
|---|---|---|
|
||||
| `deploy.yml` | push to `main`/`master`, manual | Runs `scripts/deploy.sh` (deploy via SSH) |
|
||||
| `devDeploy.yml` | manual | Deploy to dev env |
|
||||
|
||||
Single secret required: `GITEATOKEN` (Gitea PAT with `write:package`).
|
||||
|
||||
Full breakdown → [[CI-CD Pipeline]].
|
||||
|
||||
---
|
||||
|
||||
## 8. Secrets
|
||||
|
||||
Never committed. Required in production `.env`:
|
||||
|
||||
- `JWT_SECRET`, `REFRESH_TOKEN_SECRET`
|
||||
- `MONGODB_URI`, `REDIS_PASSWORD`
|
||||
- `SHKEEPER_API_KEY`, `SHKEEPER_WEBHOOK_SECRET`
|
||||
- `SMTP_USER`, `SMTP_PASS`
|
||||
- `OPENAI_API_KEY`
|
||||
- `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`
|
||||
- `NEXT_PUBLIC_ALCHEMY_API_KEY_*` (frontend public envs — embedded at build)
|
||||
|
||||
Recommend storing host-side in an `.env` outside the repo, mounted via Compose. For multi-host: Vault / AWS SSM / GCP Secret Manager.
|
||||
|
||||
---
|
||||
|
||||
## 9. Observability
|
||||
|
||||
| Signal | Where |
|
||||
|---|---|
|
||||
| App logs | container `stdout` → `docker logs nickapp-backend` |
|
||||
| Access logs | `./nginx/logs` |
|
||||
| Frontend errors | Sentry (`@sentry/nextjs`) |
|
||||
| Backend errors | Sentry (optional — add `@sentry/node`) |
|
||||
| Healthchecks | `GET /health` (backend) · `GET /` (frontend) · `mongosh ping` · `redis-cli ping` |
|
||||
|
||||
See [[Monitoring]] for the full table of metrics & recommended alerts.
|
||||
|
||||
---
|
||||
|
||||
## 10. Networking
|
||||
|
||||
| Direction | Port | Protocol | Notes |
|
||||
|---|---|---|---|
|
||||
| Internet → Nginx | 8083 | HTTP | SSL terminated upstream |
|
||||
| Browser → Backend | 5001 | HTTP + WS | via Nginx `/api`, `/socket.io` |
|
||||
| Backend → MongoDB | 27017 | TCP | Docker network |
|
||||
| Backend → Redis | 6379 | TCP | Docker network |
|
||||
| Backend → SHKeeper | 443 | HTTPS | External |
|
||||
| Backend → SMTP | 587 | TLS | External |
|
||||
| Backend → OpenAI | 443 | HTTPS | External |
|
||||
| Browser → Blockchain RPC | 443 | HTTPS | Alchemy URLs |
|
||||
| Browser → WalletConnect | 443 | HTTPS | Relay server |
|
||||
|
||||
---
|
||||
|
||||
## 11. Hardening checklist
|
||||
|
||||
- [ ] Non-root user inside every container (`marketplace` / `nextjs`) ✓ in Dockerfiles
|
||||
- [ ] Read-only Nginx config mount ✓
|
||||
- [ ] Redis requires password ✓
|
||||
- [ ] MongoDB has root credentials + bind to internal network only ✓
|
||||
- [ ] Rate limit enabled before public traffic ⚠ currently disabled
|
||||
- [ ] Refresh tokens rotated on use ⚠ partial
|
||||
- [ ] All public env values reviewed (no `IS_DEVELOPMENT=true` in prod build) ⚠
|
||||
- [ ] Watchtower scoped to specific repos
|
||||
- [ ] Sentry source-map upload at build time ✓ (frontend)
|
||||
|
||||
See [[Security Architecture]] for the deeper review.
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- [[System Architecture]] · [[Backend Architecture]] · [[Frontend Architecture]]
|
||||
- [[Docker Setup]] — operational walk-through with commands
|
||||
- [[CI-CD Pipeline]] — workflow breakdown
|
||||
- [[Deployment]] — first-deploy runbook
|
||||
- [[Backup & Recovery]] · [[Monitoring]] · [[Incident Response]]
|
||||
- [[Environment Variables]] — full env-var catalog
|
||||
220
01 - Architecture/Real-time Layer.md
Normal file
220
01 - Architecture/Real-time Layer.md
Normal file
@@ -0,0 +1,220 @@
|
||||
---
|
||||
title: Real-time Layer
|
||||
tags: [architecture, realtime, websocket, socket.io]
|
||||
created: 2026-05-23
|
||||
---
|
||||
|
||||
# Real-time Layer
|
||||
|
||||
Socket.IO 4.8 is the single transport for all server-pushed updates: chat messages, notifications, payment status, offer arrivals, dispute updates.
|
||||
|
||||
> [!info]
|
||||
> Backend handler: `backend/src/infrastructure/socket/socketService.ts`. Frontend context: `frontend/src/contexts/socket-context.tsx` consumed via `useSocket()`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Connection lifecycle
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor U as Browser
|
||||
participant FE as React (SocketProvider)
|
||||
participant BE as Socket.IO Server
|
||||
U->>FE: Login completes (JWT stored)
|
||||
FE->>BE: io.connect(url, { auth: { token } })
|
||||
BE->>BE: verifyJwt(token) → req.user
|
||||
BE-->>FE: "connect" + socket.id
|
||||
FE->>BE: emit "join-user-room", userId
|
||||
BE-->>FE: ack
|
||||
Note over BE,FE: Long-lived connection. Auto-reconnect handles drops.
|
||||
BE-->>FE: emit "notification:received" (when triggered)
|
||||
FE->>FE: update React Query cache / show snackbar
|
||||
```
|
||||
|
||||
On the client:
|
||||
- Socket is **created lazily** after auth state is known.
|
||||
- Connection auto-recovers on disconnect (Socket.IO default exponential backoff).
|
||||
- The provider exposes `connected: boolean` so UI can show a "reconnecting…" indicator.
|
||||
|
||||
On the server:
|
||||
- The `connection` handler verifies the JWT from `socket.handshake.auth.token`.
|
||||
- If invalid → `socket.disconnect(true)`.
|
||||
- If valid → `socket.data.user = decoded` is set for use in subsequent handlers.
|
||||
|
||||
---
|
||||
|
||||
## 2. Room model
|
||||
|
||||
Rooms are joined explicitly by the client via emitter events. Names use a `prefix-{id}` convention so socket addressing matches the canonical entity ID.
|
||||
|
||||
| Room | Joined by | Purpose |
|
||||
|---|---|---|
|
||||
| `user-{userId}` | every authenticated socket | User-targeted events (notifications, payment updates for them) |
|
||||
| `seller-{userId}` | sellers on login | Seller-targeted events (new offer requests in their category) |
|
||||
| `sellers` | all sellers | Broadcast to all sellers (e.g., new public request) |
|
||||
| `buyer-{userId}` | buyers on login | Buyer-targeted events |
|
||||
| `buyers` | all buyers | Broadcast to all buyers |
|
||||
| `request-{requestId}` | participants of a request (buyer + accepted seller) | Per-request updates (status, offer arrivals) |
|
||||
| `chat-{chatId}` | conversation participants | Live chat messages, typing, read receipts |
|
||||
| `admin` | admin users | Mod-only events (new dispute opened, suspicious activity) |
|
||||
|
||||
Client-side join/leave emitters (defined in `socketService.ts`):
|
||||
|
||||
| Emit | Args | Effect |
|
||||
|---|---|---|
|
||||
| `join-user-room` | `userId` | Adds socket to `user-{userId}` |
|
||||
| `join-request-room` | `requestId` | Adds to `request-{requestId}` |
|
||||
| `leave-request-room` | `requestId` | Removes from `request-{requestId}` |
|
||||
| `join-seller-room` | `sellerId` | Adds to `seller-{sellerId}` and `sellers` |
|
||||
| `leave-seller-room` | `sellerId` | Removes from both |
|
||||
| `join-buyer-room` | `buyerId` | Adds to `buyer-{buyerId}` and `buyers` |
|
||||
| `leave-buyer-room` | `buyerId` | Removes from both |
|
||||
| `join-chat-room` | `chatId` | Adds to `chat-{chatId}` |
|
||||
|
||||
---
|
||||
|
||||
## 3. Emit helpers (server-side)
|
||||
|
||||
The socket service exposes typed emitters used across all services so individual modules never touch `io` directly:
|
||||
|
||||
```ts
|
||||
// src/infrastructure/socket/socketService.ts
|
||||
export function emitToRoom(room: string, event: string, payload: unknown): void;
|
||||
export function emitToUser(userId: string, event: string, payload: unknown): void;
|
||||
export function emitToSellers(event: string, payload: unknown): void;
|
||||
export function emitToBuyers(event: string, payload: unknown): void;
|
||||
export function emitGlobalEvent(event: string, payload: unknown): void;
|
||||
```
|
||||
|
||||
This indirection makes it trivial to:
|
||||
- Add per-event logging
|
||||
- Throttle / batch in the future
|
||||
- Swap to a Redis pub/sub adapter when scaling out
|
||||
|
||||
---
|
||||
|
||||
## 4. Event catalog
|
||||
|
||||
### 4.1 Notifications
|
||||
|
||||
| Event | Payload | Emitted by | Rooms |
|
||||
|---|---|---|---|
|
||||
| `notification:received` | `Notification` doc | NotificationService | `user-{recipientId}` |
|
||||
| `notification:read` | `{ id, readAt }` | NotificationService.markRead | `user-{id}` |
|
||||
|
||||
### 4.2 Marketplace
|
||||
|
||||
| Event | Payload | Emitted by | Rooms |
|
||||
|---|---|---|---|
|
||||
| `request:created` | `PurchaseRequest` | PurchaseRequestService.create | `sellers` (or category-scoped sellers in future) |
|
||||
| `request:offer-received` | `{ requestId, offer: SellerOffer }` | SellerOfferService.create | `user-{buyerId}`, `request-{requestId}` |
|
||||
| `request:status-updated` | `{ requestId, status }` | various | `request-{requestId}` |
|
||||
| `offer:negotiation-update` | `{ offerId, message }` | SellerOfferService.counter | `request-{requestId}` |
|
||||
| `offer:accepted` | `{ offerId }` | SellerOfferService.accept | `seller-{sellerId}`, `user-{buyerId}` |
|
||||
|
||||
### 4.3 Payment
|
||||
|
||||
| Event | Payload | Emitted by |
|
||||
|---|---|---|
|
||||
| `payment:status-updated` | `{ paymentId, status, providerPaymentId }` | shkeeperWebhook → PaymentService |
|
||||
| `payment:created` | `Payment` | PaymentService.createPayInIntent |
|
||||
| `payment:completed` | `{ paymentId }` | PaymentService on webhook completion |
|
||||
| `payout:created` | `Payout` | shkeeperPayoutService |
|
||||
| `payout:completed` | `{ payoutId, txHash }` | payout polling / webhook |
|
||||
|
||||
### 4.4 Chat
|
||||
|
||||
| Event | Payload | Rooms |
|
||||
|---|---|---|
|
||||
| `chat:new-message` | `Message` | `chat-{chatId}` and `user-{participantId}` (for badge) |
|
||||
| `chat:message-read` | `{ chatId, messageId, userId, readAt }` | `chat-{chatId}` |
|
||||
| `chat:typing` | `{ chatId, userId }` | `chat-{chatId}` |
|
||||
| `chat:stopped-typing` | `{ chatId, userId }` | `chat-{chatId}` |
|
||||
|
||||
### 4.5 Dispute
|
||||
|
||||
| Event | Payload | Rooms |
|
||||
|---|---|---|
|
||||
| `dispute:opened` | `Dispute` | `request-{requestId}`, `admin` |
|
||||
| `dispute:evidence-added` | `{ disputeId, evidence }` | `request-{requestId}` |
|
||||
| `dispute:resolved` | `{ disputeId, resolution }` | `request-{requestId}`, `user-{buyerId}`, `user-{sellerId}` |
|
||||
|
||||
### 4.6 System
|
||||
|
||||
| Event | Payload | Rooms |
|
||||
|---|---|---|
|
||||
| `system:maintenance` | `{ message, startsAt }` | global broadcast |
|
||||
| `system:announcement` | `{ message }` | global broadcast |
|
||||
|
||||
---
|
||||
|
||||
## 5. Client-side hooks
|
||||
|
||||
Higher-level React hooks consume the socket and integrate with React Query so UI stays consistent:
|
||||
|
||||
| Hook | Listens for | Effect |
|
||||
|---|---|---|
|
||||
| `useChatSocket(chatId)` | `chat:new-message`, `chat:typing` | Appends to chat query cache; updates typing state |
|
||||
| `useConversations()` | `chat:new-message` (any chat) | Bumps conversation in list; updates unread count |
|
||||
| `useNotifications()` | `notification:received` | Prepends to notification list; shows snackbar |
|
||||
| `usePurchaseRequests()` | `request:status-updated`, `request:offer-received` | Invalidates `['requests']` cache |
|
||||
| `useMarketplaceSocket()` | `request:created` (seller side) | Bumps seller feed |
|
||||
| `useUnifiedRealTime()` | multi | Aggregates the above for the dashboard overview |
|
||||
|
||||
Pattern: each hook subscribes inside a `useEffect` and unsubscribes in cleanup. Use `socket.off(event, handler)` to avoid handler leaks on re-render.
|
||||
|
||||
---
|
||||
|
||||
## 6. Authentication & authorization on sockets
|
||||
|
||||
- **Connection-time auth** — JWT verified in handshake; invalid token → disconnect.
|
||||
- **Per-event auth** — Critical handlers re-check role from `socket.data.user.role`. Example: `admin` room membership only added for admins.
|
||||
- **Tampering** — `userId` arguments in `join-*-room` events are **NOT trusted blindly**; the server must verify `socket.data.user.id === userId` before adding to the room (cite: needs verification in `socketService.ts`).
|
||||
|
||||
> [!warning]
|
||||
> If `join-user-room` accepts any userId from the client, malicious users could subscribe to other users' notifications. Always cross-check with `socket.data.user.id`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Reconnection & buffering
|
||||
|
||||
Socket.IO buffers up to N events per disconnected client by default — but **the server does not replay missed events after a long disconnect**. Frontend strategy:
|
||||
|
||||
1. On reconnect, the SocketProvider re-fires `join-*-room` for the user's current state.
|
||||
2. Critical hooks (notifications, chat) call `refetch()` on reconnect to backfill from REST.
|
||||
3. Last-event-id pattern is **not implemented**; consider adding for chat reliability.
|
||||
|
||||
---
|
||||
|
||||
## 8. Scaling to multiple backend nodes
|
||||
|
||||
Socket.IO requires a shared bus when running >1 backend instance. Recommended adapter:
|
||||
|
||||
```ts
|
||||
import { createAdapter } from '@socket.io/redis-adapter';
|
||||
import { createClient } from 'redis';
|
||||
const pub = createClient({ url: config.redisUri, password: config.redisPassword });
|
||||
const sub = pub.duplicate();
|
||||
await Promise.all([pub.connect(), sub.connect()]);
|
||||
io.adapter(createAdapter(pub, sub));
|
||||
```
|
||||
|
||||
Without this adapter, emits from node A never reach sockets connected to node B.
|
||||
|
||||
Sticky sessions on the load balancer are also required so a given client always lands on the same node (otherwise the handshake / connection upgrade fails).
|
||||
|
||||
---
|
||||
|
||||
## 9. Testing
|
||||
|
||||
- Backend tests (`backend/__tests__/`) mock the socket emitter via the indirection layer — no real socket server in unit tests.
|
||||
- Manual smoke test: open two browser tabs as buyer + seller, accept an offer, watch both receive `offer:accepted` instantly.
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- [[Backend Architecture]] · [[Frontend Architecture]]
|
||||
- [[Chat Flow]] · [[Notification Flow]] · [[Payment Flow - SHKeeper]] · [[Dispute Flow]]
|
||||
- [[Security Architecture]] — socket auth concerns
|
||||
- [[Socket Events]] — full event reference (developer-facing API doc)
|
||||
227
01 - Architecture/Security Architecture.md
Normal file
227
01 - Architecture/Security Architecture.md
Normal file
@@ -0,0 +1,227 @@
|
||||
---
|
||||
title: Security Architecture
|
||||
tags: [architecture, security, authentication, rbac]
|
||||
created: 2026-05-23
|
||||
---
|
||||
|
||||
# Security Architecture
|
||||
|
||||
How identity, authorization, transport, and integrity are handled across the platform.
|
||||
|
||||
> [!important]
|
||||
> Read alongside [[Authentication Flow]] (user-facing), [[Passkey (WebAuthn) Flow]], and [[Payment Flow - SHKeeper]] (webhook HMAC).
|
||||
|
||||
---
|
||||
|
||||
## 1. Threat model — at a glance
|
||||
|
||||
| Threat | Mitigation |
|
||||
|---|---|
|
||||
| Credential stuffing | bcrypt 12-round hashing + account lockout + rate-limit (when enabled) |
|
||||
| Session hijacking | Short-lived JWTs (7d), opaque refresh tokens (30d), token rotation |
|
||||
| CSRF | JWT in `Authorization` header (not cookie), CORS allow-list |
|
||||
| XSS | Helmet CSP, React auto-escaping, sanitize HTML before storage |
|
||||
| SQL/NoSQL injection | Mongoose parameterized queries, no `$where` strings, schema validation |
|
||||
| Webhook spoofing | HMAC SHA-256 over body + secret, constant-time compare |
|
||||
| File upload abuse | Multer MIME validation, 5 MB cap, non-executable storage, served by Nginx not Node |
|
||||
| Replay attacks | Per-payment idempotency on `providerPaymentId`, per-request `X-Request-Id` |
|
||||
| Account takeover | Email verification required, password reset code expiry (1h), passkey support |
|
||||
| Phishing | Passkey origin binding (`NEXT_PUBLIC_PASSKEY_ORIGIN`), email domain pinning |
|
||||
| Data leakage | Role-gated endpoints, field-level projection (`select: false` on password), redacted logs |
|
||||
|
||||
---
|
||||
|
||||
## 2. Authentication layers
|
||||
|
||||
### 2.1 Email + password (primary)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor U as User
|
||||
participant FE as Frontend
|
||||
participant BE as Backend
|
||||
participant DB as MongoDB
|
||||
U->>FE: enters credentials
|
||||
FE->>BE: POST /api/auth/login { email, password }
|
||||
BE->>DB: User.findOne({ email })
|
||||
DB-->>BE: user doc (incl. hashed password)
|
||||
BE->>BE: bcrypt.compare(password, hash)
|
||||
alt invalid
|
||||
BE->>DB: increment loginAttempts; lock at N
|
||||
BE-->>FE: 401 / 423 locked
|
||||
else valid
|
||||
BE->>BE: sign JWT (7d), refresh (30d)
|
||||
BE->>DB: store refresh-token id; clear attempts
|
||||
BE-->>FE: 200 { user, token, refreshToken }
|
||||
end
|
||||
```
|
||||
|
||||
- Password rules enforced by `authValidation.ts`: ≥8 chars, mixed case + digit recommended (cite the validator for exact rules).
|
||||
- bcrypt rounds = 12 (`authService.ts`).
|
||||
- Lockout: after N failed attempts within window, account locked for cooldown — see `authService.ts:113-145`.
|
||||
- Reset code emailed on `passwordResetCode` request; valid 1h.
|
||||
|
||||
### 2.2 Google OAuth 2.0
|
||||
|
||||
- Frontend uses `NEXT_PUBLIC_GOOGLE_CLIENT_ID` for the Sign-In with Google button.
|
||||
- ID token sent to backend → `googleOAuthService.ts` verifies via Google's public keys → either links to existing User by email or creates a new one.
|
||||
- See [[Google OAuth Flow]].
|
||||
|
||||
### 2.3 WebAuthn / Passkey
|
||||
|
||||
- Standards-based passwordless.
|
||||
- Backend: `passkeyService.ts` orchestrates registration and assertion challenges.
|
||||
- Frontend env: `NEXT_PUBLIC_PASSKEY_RP_NAME=Amn`, `NEXT_PUBLIC_PASSKEY_RP_ID=<domain>`, `NEXT_PUBLIC_PASSKEY_ORIGIN=<origin>`.
|
||||
- See [[Passkey (WebAuthn) Flow]].
|
||||
|
||||
> [!warning]
|
||||
> Dev env files ship `NEXT_PUBLIC_PASSKEY_RP_ID=localhost`. In production this MUST be the actual eTLD+1 domain (e.g., `amn.gg`) — passkeys are scoped to the RP ID and can't be transferred.
|
||||
|
||||
### 2.4 Refresh-token rotation
|
||||
|
||||
- On `POST /api/auth/refresh`, the backend:
|
||||
- Verifies the supplied refresh token.
|
||||
- Issues a NEW access token + a NEW refresh token.
|
||||
- Invalidates the old refresh token id in MongoDB.
|
||||
- If the same refresh token is presented twice → all sessions for that user are invalidated (token reuse detection).
|
||||
|
||||
---
|
||||
|
||||
## 3. Authorization (RBAC)
|
||||
|
||||
### 3.1 Roles
|
||||
|
||||
| Role | Source | Capabilities |
|
||||
|---|---|---|
|
||||
| `buyer` (user) | default on signup | Create requests, pay, chat, dispute, rate |
|
||||
| `seller` (owner) | chosen at signup OR upgraded | Make offers, build templates, run a shop, withdraw |
|
||||
| `admin` | seed / manual | Moderate, mediate disputes, manage users/blogs/levels |
|
||||
| `support` | seed / manual | Read-only on most data, can reset passwords, escalate |
|
||||
|
||||
A single User may be `buyer` and `seller` simultaneously (combined role).
|
||||
|
||||
### 3.2 Enforcement points
|
||||
|
||||
- **Middleware** — `authMiddleware` (verifies JWT) followed by `roleGuard(role)` on every route that requires elevation.
|
||||
- **Service layer** — defensive `assertRole(ctx, 'admin')` calls inside critical service methods so even mis-mounted routes can't bypass.
|
||||
- **UI** — `AuthGuard` + `EmailVerificationGuard` + role-aware nav (`components/nav-section`) hide admin/seller menus for users without permission. This is convenience only — never the security boundary.
|
||||
|
||||
---
|
||||
|
||||
## 4. Transport security
|
||||
|
||||
- **HTTPS** terminated upstream (CloudFlare / external Nginx). Internal cluster is HTTP.
|
||||
- **HSTS** header set by upstream proxy (recommended `max-age=31536000; includeSubDomains; preload`).
|
||||
- **CORS** — exactly one origin allowed: `config.frontendUrl`. `credentials: true`.
|
||||
- **CSP** — Helmet default, currently permissive for Web3 popup compatibility (see `frontend/next.config.ts` setting COOP=`same-origin-allow-popups`, COEP=`unsafe-none`).
|
||||
|
||||
---
|
||||
|
||||
## 5. Webhook integrity (SHKeeper)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant SHK
|
||||
participant BE
|
||||
SHK->>BE: POST /api/payment/shkeeper/webhook<br/>X-Signature: sha256=<hmac>
|
||||
BE->>BE: hmac = HMAC_SHA256(SHKEEPER_WEBHOOK_SECRET, body)
|
||||
BE->>BE: crypto.timingSafeEqual(hmac, providedSig)
|
||||
alt mismatch
|
||||
BE-->>SHK: 401 Unauthorized
|
||||
else match
|
||||
BE->>BE: process payment update
|
||||
BE-->>SHK: 200 OK
|
||||
end
|
||||
```
|
||||
|
||||
- Body must be the raw bytes used for HMAC — apply `express.raw({ type: 'application/json' })` on the webhook route ONLY (the rest of the app uses parsed JSON).
|
||||
- In dev (`NODE_ENV === 'development'`) signature verification can be bypassed for local testing — confirm this is gated and never reachable in prod.
|
||||
- Idempotency: identical webhook delivered twice should be no-op. Check by `(providerPaymentId, status)` tuple before mutating.
|
||||
|
||||
See [[Payment Flow - SHKeeper]] for the full flow.
|
||||
|
||||
---
|
||||
|
||||
## 6. Input validation
|
||||
|
||||
- **Backend** — `express-validator` per route (e.g., `authValidation.ts`), centralised `validate` middleware that 422s on failure with `{ details: [...] }`.
|
||||
- **Frontend** — `zod` schemas via `@hookform/resolvers/zod`. Same schema can be re-exported to a `shared/` package for true single-source-of-truth (not yet wired).
|
||||
- **Mongoose** — schema-level `type`, `required`, `enum`, `min`/`max`, custom `validate` functions as a last line of defence.
|
||||
|
||||
---
|
||||
|
||||
## 7. File upload safety
|
||||
|
||||
- Stored under `uploads/{avatars|documents|products|temp}/` — non-executable, served by Nginx (no Node interpretation).
|
||||
- MIME allow-list in `fileService.ts`: images for avatars/products, PDFs/docs for evidence.
|
||||
- 5 MB hard cap (`MAX_FILE_SIZE=5242880`).
|
||||
- Original filenames hashed → no path traversal, no clobber.
|
||||
- Recommended: virus scan via ClamAV before exposing to other users (dispute evidence, chat attachments).
|
||||
|
||||
---
|
||||
|
||||
## 8. Secrets management
|
||||
|
||||
- Production secrets injected via host `.env`, mounted into compose `env_file`.
|
||||
- Never log secrets — logger redaction recommended (winston/pino formatter).
|
||||
- `.env*` files in `.gitignore`. Repo includes only `.env.development` / `.env.production` templates with **public** values (NEXT_PUBLIC_*).
|
||||
- Rotate `JWT_SECRET` invalidates all existing JWTs — schedule a maintenance window.
|
||||
- Rotate `SHKEEPER_WEBHOOK_SECRET` coordinated with SHKeeper dashboard (set new → verify → remove old).
|
||||
|
||||
See [[Environment Variables]] for the catalog.
|
||||
|
||||
---
|
||||
|
||||
## 9. Rate limiting & abuse
|
||||
|
||||
- Backend has `express-rate-limit` ready but currently disabled (`app.ts:227`).
|
||||
- Recommended pre-launch settings:
|
||||
- `/api/auth/*` — 10 req / 5 min / IP
|
||||
- `/api/auth/login` — 5 req / 5 min / IP **and** /email
|
||||
- global API — 100 req / 15 min / IP (current default constants)
|
||||
- Counters stored in Redis when enabled.
|
||||
- For chat and notifications, debounce at the client to avoid spamming legitimate emits.
|
||||
|
||||
---
|
||||
|
||||
## 10. Audit logging
|
||||
|
||||
The codebase currently uses `morgan` (HTTP access logs) and ad-hoc `logger.info/warn/error`. For PCI-adjacent operations (payments) consider:
|
||||
|
||||
- Append-only audit log of every payment / payout / refund / role change.
|
||||
- Include actor (userId), target, action, before/after diff, request id.
|
||||
- Persist in a separate Mongo collection or external log sink with retention ≥1y.
|
||||
|
||||
---
|
||||
|
||||
## 11. Frontend session storage
|
||||
|
||||
- JWT and refresh token stored in `localStorage` (per current implementation — cite to verify in `frontend/src/lib/`).
|
||||
- Risk: XSS = total takeover. Mitigations: strict CSP, no `dangerouslySetInnerHTML` on untrusted content, audit dependencies (`yarn audit`).
|
||||
- Alternative: store refresh token in `httpOnly` cookie and keep only short-lived access token in memory — recommended for production hardening.
|
||||
|
||||
---
|
||||
|
||||
## 12. Hardening checklist (pre-launch)
|
||||
|
||||
- [ ] Enable rate-limit middleware
|
||||
- [ ] Promote refresh tokens to `httpOnly` cookies
|
||||
- [ ] Replace `localhost` passkey RP ID with production domain
|
||||
- [ ] Disable `NEXT_PUBLIC_IS_DEVELOPMENT=true` and `ENABLE_DEBUG=true` in prod build
|
||||
- [ ] Verify `NODE_ENV=production` in backend prod env
|
||||
- [ ] Pin production Watchtower to versioned tag (not `latest`)
|
||||
- [ ] Add backend Sentry SDK + source maps
|
||||
- [ ] Rotate all dev-seeded credentials before public launch
|
||||
- [ ] Run `yarn audit` / `npm audit` and triage CVEs
|
||||
- [ ] Pentest the payment + dispute flows specifically
|
||||
- [ ] Review every `> [!warning]` callout in this vault
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- [[Authentication Flow]] · [[Google OAuth Flow]] · [[Passkey (WebAuthn) Flow]] · [[Password Reset Flow]]
|
||||
- [[Backend Architecture]] · [[Frontend Architecture]] · [[Real-time Layer]]
|
||||
- [[Payment Flow - SHKeeper]] — webhook HMAC details
|
||||
- [[Environment Variables]] — secret catalog
|
||||
- [[Incident Response]] — what to do when something goes wrong
|
||||
202
01 - Architecture/System Architecture.md
Normal file
202
01 - Architecture/System Architecture.md
Normal file
@@ -0,0 +1,202 @@
|
||||
---
|
||||
title: System Architecture
|
||||
tags: [architecture, system, overview]
|
||||
created: 2026-05-23
|
||||
---
|
||||
|
||||
# System Architecture
|
||||
|
||||
End-to-end architecture for the **Amn** escrow marketplace platform — a two-repo system (frontend + backend) that brokers crypto-escrowed transactions between buyers and sellers.
|
||||
|
||||
> [!info]
|
||||
> Read [[Backend Architecture]] and [[Frontend Architecture]] for component-level detail. Read [[Infrastructure]] for deployment topology.
|
||||
|
||||
---
|
||||
|
||||
## 1. High-level topology
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Browser[Browser / PWA]
|
||||
CDN[Static CDN]
|
||||
Nginx[Nginx Reverse Proxy<br/>:80/:443]
|
||||
FE[Next.js Frontend<br/>standalone server<br/>:8083]
|
||||
BE[Express Backend<br/>+ Socket.IO<br/>:5001]
|
||||
Mongo[(MongoDB 8)]
|
||||
Redis[(Redis 8)]
|
||||
SHK[SHKeeper<br/>Crypto Gateway]
|
||||
SMTP[SMTP<br/>Nodemailer]
|
||||
OAI[OpenAI API]
|
||||
BC[Blockchain RPC<br/>Alchemy / WalletConnect]
|
||||
|
||||
Browser -->|HTTPS + WSS| CDN
|
||||
Browser -->|HTTPS| Nginx
|
||||
Nginx --> FE
|
||||
Nginx --> BE
|
||||
FE -->|REST /api/*| BE
|
||||
FE -.->|Socket.IO| BE
|
||||
BE --> Mongo
|
||||
BE --> Redis
|
||||
BE -->|Pay-in / Pay-out| SHK
|
||||
SHK -.->|Webhook HMAC| BE
|
||||
BE --> SMTP
|
||||
BE --> OAI
|
||||
FE -->|Wallet Connect| BC
|
||||
BE -->|Tx verify| BC
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Request lifecycle (typical authenticated REST call)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor U as User
|
||||
participant FE as Frontend (Next.js)
|
||||
participant AX as Axios Client
|
||||
participant BE as Backend (Express)
|
||||
participant MW as Middleware Chain
|
||||
participant SVC as Service Layer
|
||||
participant DB as MongoDB
|
||||
participant SK as Socket.IO
|
||||
|
||||
U->>FE: Interact with UI
|
||||
FE->>AX: useMutation(...).mutate(payload)
|
||||
AX->>AX: Attach Authorization: Bearer <jwt>
|
||||
AX->>BE: POST /api/marketplace/requests
|
||||
BE->>MW: helmet, cors, body-parser
|
||||
MW->>MW: authMiddleware → verify JWT
|
||||
MW->>MW: validation middleware
|
||||
MW->>SVC: controller invokes service
|
||||
SVC->>DB: Mongoose create / update
|
||||
DB-->>SVC: document
|
||||
SVC->>SK: emitToRoom("user-{id}", "request:created", payload)
|
||||
SVC-->>BE: response payload
|
||||
BE->>AX: 200 { success, data }
|
||||
AX->>FE: React Query cache update
|
||||
FE-->>U: UI re-render
|
||||
```
|
||||
|
||||
Concurrent realtime path:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant FE as Frontend
|
||||
participant BE as Backend
|
||||
FE->>BE: socket.connect() w/ auth token
|
||||
BE-->>FE: socket connected
|
||||
FE->>BE: join-user-room <userId>
|
||||
BE-->>FE: joined user-{userId}
|
||||
Note over BE,FE: long-lived connection
|
||||
BE-->>FE: emit "notification:received"
|
||||
FE->>FE: update notification badge
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Deployment topology
|
||||
|
||||
Production runs as a single Docker Compose stack (`backend/docker-compose.production.yml`) behind an internal Nginx that proxies to both the frontend and backend containers. Watchtower watches the container registry (`git.manko.yoga/manawenuz/escrow-backend`) and auto-pulls new images tagged `latest`.
|
||||
|
||||
| Layer | Service | Image | Port | Purpose |
|
||||
|---|---|---|---|---|
|
||||
| Edge | Nginx | `nginx:alpine` | 8083 | Reverse proxy + static assets |
|
||||
| App | Frontend | `nickapp-frontend:latest` | 8083 (internal) | Next.js standalone |
|
||||
| App | Backend | `nickapp-backend:latest` | 5001 (internal) | Express + Socket.IO |
|
||||
| Data | MongoDB | `mongo:8.0` | 27017 (internal) | Primary store |
|
||||
| Data | Redis | `redis:8-alpine` | 6379 (internal) | Cache + sessions + rate-limit counters |
|
||||
|
||||
External SSL termination, DNS, and CDN are assumed to live in front of Nginx (CloudFlare / nginx-proxy / similar).
|
||||
|
||||
> [!note]
|
||||
> Dev uses `backend/docker-compose.dev.yml` (no Nginx, no frontend container — developer runs FE locally with `yarn dev`). See [[Docker Setup]] for full compose breakdown.
|
||||
|
||||
---
|
||||
|
||||
## 4. Network ports
|
||||
|
||||
| Port | Process | Visibility | Notes |
|
||||
|---|---|---|---|
|
||||
| 8083 | Frontend (Docker) | Public via Nginx | `next start` standalone |
|
||||
| 3000 | Frontend (local dev) | localhost | `yarn dev` — hot reload |
|
||||
| 5001 | Backend (Express + Socket.IO) | Public via Nginx `/api` & `/socket.io` | One process, two protocols |
|
||||
| 27017 | MongoDB | Internal | Mapped to host only in dev |
|
||||
| 6379 | Redis | Internal | Mapped to host only in dev |
|
||||
| 222 | Gitea SSH | Public | Used for `git clone` |
|
||||
|
||||
---
|
||||
|
||||
## 5. Data flow patterns
|
||||
|
||||
### 5.1 Read path
|
||||
|
||||
REST `GET` requests are cache-able. React Query on the frontend de-duplicates concurrent requests, retries on failure (exponential backoff), and treats data as stale after `staleTime` (default 60s, varies per query). Backend reads use Mongoose lean queries where possible for performance.
|
||||
|
||||
### 5.2 Write path
|
||||
|
||||
Mutations follow optimistic-then-confirm:
|
||||
1. Frontend `useMutation` issues the request, often with `onMutate` updating cache optimistically.
|
||||
2. Backend validates → writes to MongoDB → emits affected socket rooms → returns canonical response.
|
||||
3. Frontend invalidates related query keys; React Query refetches.
|
||||
4. Other connected clients receive the socket event and refetch on their side.
|
||||
|
||||
### 5.3 Webhook path (inbound)
|
||||
|
||||
External services (SHKeeper) POST to `/api/payment/shkeeper/webhook`. The backend verifies HMAC signature, updates the `Payment` document, advances any linked `PurchaseRequest`/`SellerOffer` state, and emits Socket.IO events to both buyer and seller rooms.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant SHK as SHKeeper
|
||||
participant BE as Backend
|
||||
participant DB as MongoDB
|
||||
participant Buyer
|
||||
participant Seller
|
||||
SHK->>BE: POST /api/payment/shkeeper/webhook<br/>X-Signature: HMAC-SHA256
|
||||
BE->>BE: verifySignature(body, header, SHKEEPER_WEBHOOK_SECRET)
|
||||
BE->>DB: Payment.updateOne({providerPaymentId}, {status:"completed"})
|
||||
BE->>DB: PurchaseRequest.updateOne(..., {status:"funded"})
|
||||
BE-->>Buyer: socket emit "payment:status-updated"
|
||||
BE-->>Seller: socket emit "request:funded"
|
||||
BE-->>SHK: 200 OK
|
||||
```
|
||||
|
||||
See [[Payment Flow - SHKeeper]] for the full sequence.
|
||||
|
||||
---
|
||||
|
||||
## 6. Scaling considerations
|
||||
|
||||
| Concern | Current state | Scale-out path |
|
||||
|---|---|---|
|
||||
| Backend stateless? | Yes — JWT-only auth, no in-memory session | Run N replicas behind LB; use Redis pub/sub adapter for Socket.IO |
|
||||
| MongoDB | Single-node | Replica set → sharding by `buyerId` |
|
||||
| Redis | Single-node | Cluster mode; separate cache vs session DBs |
|
||||
| Socket.IO | Single process | `@socket.io/redis-adapter` for multi-node fan-out |
|
||||
| File uploads | Local `uploads/` mount | S3 / R2; multer-s3 adapter |
|
||||
| Logs | Container stdout | Loki / ELK |
|
||||
| Errors | Sentry (`@sentry/nextjs` on FE) | Add Sentry backend SDK |
|
||||
|
||||
> [!warning]
|
||||
> Before horizontal-scaling backend, switch Socket.IO to the Redis adapter — otherwise users on different nodes will not receive each other's events.
|
||||
|
||||
---
|
||||
|
||||
## 7. Cross-cutting concerns
|
||||
|
||||
- **Auth** — Bearer JWT on REST, same token also passed via Socket.IO `auth` payload on connect. See [[Security Architecture]] & [[Authentication Flow]].
|
||||
- **i18n** — Frontend supports en/fa/ar/fr/cn/vi via `i18next`. RTL handled by `stylis-plugin-rtl` per direction. See [[Internationalization & RTL]].
|
||||
- **Realtime** — All notification/chat/payment state changes broadcast over Socket.IO. See [[Real-time Layer]].
|
||||
- **Errors** — Standardized envelope `{ success: false, error: { code, message, details } }` via `backend/src/shared/utils/response-handler.ts`. Sentry on frontend.
|
||||
- **Idempotency** — Payment webhooks idempotent by `providerPaymentId` + status. Pay-out uses a client-supplied `reference` string.
|
||||
|
||||
---
|
||||
|
||||
## 8. Related documents
|
||||
|
||||
- [[Backend Architecture]] — module-level walk-through of the Express app
|
||||
- [[Frontend Architecture]] — Next.js App Router & section organization
|
||||
- [[Infrastructure]] — Docker, compose, registry, Watchtower
|
||||
- [[Real-time Layer]] — Socket.IO setup, rooms, events
|
||||
- [[Security Architecture]] — auth, hashing, rate-limit, webhook HMAC
|
||||
- [[Tech Stack]] — exact versions & purpose of every dependency
|
||||
- [[Payment Flow - SHKeeper]] — end-to-end crypto pay-in flow
|
||||
82
02 - Data Models/Address.md
Normal file
82
02 - Data Models/Address.md
Normal file
@@ -0,0 +1,82 @@
|
||||
---
|
||||
title: Address
|
||||
tags: [data-model, mongoose]
|
||||
aliases: [Shipping Address, IAddress]
|
||||
---
|
||||
|
||||
# Address
|
||||
|
||||
User-owned address book entry. Each row carries the recipient name, optional phone, full address text, city/state/country/zip, an address type (`Home` / `Office` / `Other`), and a `primary` flag. A pre-save hook enforces a single primary address per user by demoting the user's other addresses when the saving document is primary.
|
||||
|
||||
> [!note] Source
|
||||
> `backend/src/models/Address.ts:20` — schema definition
|
||||
> `backend/src/models/Address.ts:89` — model export
|
||||
|
||||
## Schema
|
||||
|
||||
| Field | Type | Required | Default | Validation | Index | Description |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `userId` | ObjectId → [[User]] | yes | — | — | yes (single + compound) | Owner. |
|
||||
| `name` | String | yes | — | trim | — | Recipient name. |
|
||||
| `phoneNumber` | String | no | — | trim | — | Recipient phone. |
|
||||
| `fullAddress` | String | yes | — | trim | — | Address line. |
|
||||
| `city` | String | yes | — | trim | — | City. |
|
||||
| `state` | String | yes | — | trim | — | State / province. |
|
||||
| `country` | String | yes | — | trim | — | Country. |
|
||||
| `zipCode` | String | no | — | trim | — | Postal code. |
|
||||
| `addressType` | String | no | `Home` | enum: `Home` / `Office` / `Other` | — | Address type label. |
|
||||
| `primary` | Boolean | no | `false` | — | yes (compound, desc) | Whether this is the default address. |
|
||||
| `createdAt` | Date | auto | — | — | — | Mongoose timestamp. |
|
||||
| `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. |
|
||||
|
||||
## Virtuals
|
||||
|
||||
None defined.
|
||||
|
||||
## Indexes
|
||||
|
||||
- `{ userId: 1 }` — from field-level `index: true` at `backend/src/models/Address.ts:25`.
|
||||
- `{ userId: 1, primary: -1 }` — `backend/src/models/Address.ts:75`.
|
||||
|
||||
## Pre/Post Hooks
|
||||
|
||||
| Hook | Behaviour |
|
||||
| --- | --- |
|
||||
| `pre('save')` (`backend/src/models/Address.ts:78`) | If the document being saved is `primary: true`, demotes (`primary: false`) every other address belonging to the same `userId`. |
|
||||
|
||||
## Instance Methods
|
||||
|
||||
None defined.
|
||||
|
||||
## Static Methods
|
||||
|
||||
None defined.
|
||||
|
||||
## Relationships
|
||||
|
||||
- **References**: [[User]] (`userId`).
|
||||
- **Referenced by**: none directly. Address text is also embedded into [[PurchaseRequest]] `deliveryInfo.deliveryAddress` at request time, denormalised so historical requests do not change if the address book changes later.
|
||||
|
||||
## State Transitions
|
||||
|
||||
No status field. The boolean `primary` flag follows a simple at-most-one invariant maintained by the pre-save hook.
|
||||
|
||||
## Common Queries
|
||||
|
||||
```ts
|
||||
// User's address book
|
||||
Address.find({ userId }).sort({ primary: -1, updatedAt: -1 });
|
||||
|
||||
// Primary address
|
||||
Address.findOne({ userId, primary: true });
|
||||
|
||||
// Set a new primary (hook handles demotion)
|
||||
const addr = await Address.findById(id);
|
||||
addr.primary = true;
|
||||
await addr.save();
|
||||
|
||||
// Delete an address
|
||||
Address.deleteOne({ _id, userId });
|
||||
```
|
||||
|
||||
Related: [[User]], [[PurchaseRequest]].
|
||||
113
02 - Data Models/BlogPost.md
Normal file
113
02 - Data Models/BlogPost.md
Normal file
@@ -0,0 +1,113 @@
|
||||
---
|
||||
title: BlogPost
|
||||
tags: [data-model, mongoose]
|
||||
aliases: [Blog Post, Article, IBlogPost]
|
||||
---
|
||||
|
||||
# BlogPost
|
||||
|
||||
Editorial content for the marketplace's blog. Each post has a title, an auto-generated slug, rich `content`, optional cover image, gallery, and embedded videos (YouTube / Vimeo / Aparat / other). Carries publication workflow (`draft` / `published` / `archived`), denormalised author info, SEO metadata, and counters for views, likes, and comments. Two pre-save hooks handle slug generation and `publishedAt` stamping.
|
||||
|
||||
> [!note] Source
|
||||
> `backend/src/models/BlogPost.ts:39` — schema definition
|
||||
> `backend/src/models/BlogPost.ts:182` — model export
|
||||
|
||||
## Schema
|
||||
|
||||
| Field | Type | Required | Default | Validation | Index | Description |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `title` | String | yes | — | trim, maxlength 200 | — | Post title. |
|
||||
| `slug` | String | no | (auto-generated) | lowercase, trim | unique, sparse | URL slug. |
|
||||
| `description` | String | yes | — | maxlength 500 | — | Short summary. |
|
||||
| `content` | String | yes | — | — | — | Full body (markdown / HTML). |
|
||||
| `coverImage` | String | no | — | — | — | Hero image URL. |
|
||||
| `images[]` | String[] | no | — | — | — | Gallery URLs. |
|
||||
| `videos[].url` | String | yes | — | — | — | Video URL. |
|
||||
| `videos[].title` | String | no | — | — | — | Video title. |
|
||||
| `videos[].platform` | String | no | `youtube` | enum: `youtube` / `vimeo` / `aparat` / `other` | — | Platform. |
|
||||
| `videos[].embedId` | String | no | — | — | — | Embed id (if applicable). |
|
||||
| `author.id` | ObjectId → [[User]] | yes | — | — | — | Author user. |
|
||||
| `author.name` | String | yes | — | — | — | Denormalised author name. |
|
||||
| `author.avatar` | String | no | — | — | — | Avatar URL. |
|
||||
| `category` | String | yes | `tutorial` | enum: `tutorial` / `news` / `guide` / `tips` / `announcement` / `other` | yes (compound) | Editorial category. |
|
||||
| `tags[]` | String[] | no | — | trim | yes | Free-form tags. |
|
||||
| `status` | String | no | `draft` | enum: `draft` / `published` / `archived` | yes (compound) | Workflow state. |
|
||||
| `publishedAt` | Date | no | — | — | yes (compound) | Auto-set when status → `published`. |
|
||||
| `views` | Number | no | `0` | — | — | View counter. |
|
||||
| `likes` | Number | no | `0` | — | — | Like counter. |
|
||||
| `comments` | Number | no | `0` | — | — | Comment counter. |
|
||||
| `readTime` | Number | no | `5` | — | — | Estimated read time (minutes). |
|
||||
| `featured` | Boolean | no | `false` | — | yes (compound) | Front-page promotion. |
|
||||
| `seo.metaTitle` | String | no | — | — | — | SEO title. |
|
||||
| `seo.metaDescription` | String | no | — | — | — | SEO description. |
|
||||
| `seo.metaKeywords[]` | String[] | no | — | — | — | SEO keywords. |
|
||||
| `createdAt` | Date | auto | — | — | — | Mongoose timestamp. |
|
||||
| `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. |
|
||||
|
||||
Virtuals are enabled in `toJSON` and `toObject` even though none are declared on the schema.
|
||||
|
||||
## Virtuals
|
||||
|
||||
None defined (but enabled in serialisation).
|
||||
|
||||
## Indexes
|
||||
|
||||
Defined at `backend/src/models/BlogPost.ts:148-151`. Plus the implicit unique sparse index on `slug`:
|
||||
|
||||
- `{ status: 1, publishedAt: -1 }` — published feed.
|
||||
- `{ category: 1, status: 1 }` — category page.
|
||||
- `{ tags: 1 }` — tag lookup.
|
||||
- `{ featured: 1, status: 1 }` — featured posts.
|
||||
|
||||
## Pre/Post Hooks
|
||||
|
||||
| Hook | Behaviour |
|
||||
| --- | --- |
|
||||
| `pre('save')` (`backend/src/models/BlogPost.ts:154`) | Auto-generates `slug` from the title (English letters only) plus a timestamp suffix; falls back to `post-<timestamp>` for non-Latin titles. |
|
||||
| `pre('save')` (`backend/src/models/BlogPost.ts:175`) | When `status` is modified to `published` and `publishedAt` is empty, sets `publishedAt = new Date()`. |
|
||||
|
||||
## Instance Methods
|
||||
|
||||
None defined.
|
||||
|
||||
## Static Methods
|
||||
|
||||
None defined.
|
||||
|
||||
## Relationships
|
||||
|
||||
- **References**: [[User]] (`author.id`).
|
||||
- **Referenced by**: none.
|
||||
|
||||
## State Transitions
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> draft
|
||||
draft --> published : author publishes
|
||||
published --> archived : admin archives
|
||||
published --> draft : unpublish
|
||||
archived --> published : restore
|
||||
archived --> [*]
|
||||
```
|
||||
|
||||
## Common Queries
|
||||
|
||||
```ts
|
||||
// Public feed
|
||||
BlogPost.find({ status: 'published' }).sort({ publishedAt: -1 }).limit(20);
|
||||
|
||||
// By slug (detail page)
|
||||
BlogPost.findOne({ slug, status: 'published' });
|
||||
|
||||
// Featured carousel
|
||||
BlogPost.find({ featured: true, status: 'published' }).sort({ publishedAt: -1 });
|
||||
|
||||
// Tag search
|
||||
BlogPost.find({ tags: tag, status: 'published' });
|
||||
|
||||
// Increment views atomically
|
||||
BlogPost.updateOne({ _id }, { $inc: { views: 1 } });
|
||||
```
|
||||
|
||||
Related: [[User]].
|
||||
87
02 - Data Models/Category.md
Normal file
87
02 - Data Models/Category.md
Normal file
@@ -0,0 +1,87 @@
|
||||
---
|
||||
title: Category
|
||||
tags: [data-model, mongoose]
|
||||
aliases: [Category Model, Taxonomy, ICategory]
|
||||
---
|
||||
|
||||
# Category
|
||||
|
||||
Hierarchical taxonomy node used by [[PurchaseRequest]] and [[RequestTemplate]]. Each row is bilingual (`name` in the local language, `nameEn` in English), supports parent/child via `parentId`, has an icon and a display `order`, and an `isActive` toggle for soft-removal.
|
||||
|
||||
> [!note] Source
|
||||
> `backend/src/models/Category.ts:15` — schema definition
|
||||
> `backend/src/models/Category.ts:60` — model export
|
||||
|
||||
## Schema
|
||||
|
||||
| Field | Type | Required | Default | Validation | Index | Description |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `name` | String | yes | — | trim | yes | Local language name. |
|
||||
| `nameEn` | String | yes | — | trim | yes | English name. |
|
||||
| `description` | String | no | — | trim | — | Description. |
|
||||
| `icon` | String | no | — | trim | — | Icon identifier / URL. |
|
||||
| `isActive` | Boolean | no | `true` | — | yes | Active flag. |
|
||||
| `parentId` | ObjectId → [[Category]] | no | `null` | — | yes | Parent category (`null` for top level). |
|
||||
| `order` | Number | no | `0` | — | — | Display order. |
|
||||
| `createdAt` | Date | auto | — | — | — | Mongoose timestamp. |
|
||||
| `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. |
|
||||
|
||||
## Virtuals
|
||||
|
||||
None defined.
|
||||
|
||||
## Indexes
|
||||
|
||||
Defined at `backend/src/models/Category.ts:55-58`:
|
||||
|
||||
- `{ name: 1 }`
|
||||
- `{ nameEn: 1 }`
|
||||
- `{ isActive: 1 }`
|
||||
- `{ parentId: 1 }`
|
||||
|
||||
## Pre/Post Hooks
|
||||
|
||||
None declared.
|
||||
|
||||
## Instance Methods
|
||||
|
||||
None defined.
|
||||
|
||||
## Static Methods
|
||||
|
||||
None defined.
|
||||
|
||||
## Relationships
|
||||
|
||||
- **References**: [[Category]] (self, via `parentId`).
|
||||
- **Referenced by**: [[PurchaseRequest]] (`categoryId`), [[RequestTemplate]] (`categoryId`).
|
||||
|
||||
## State Transitions
|
||||
|
||||
No status field — only the `isActive` boolean for soft-disable.
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> active
|
||||
active --> inactive : admin disables
|
||||
inactive --> active : admin re-enables
|
||||
```
|
||||
|
||||
## Common Queries
|
||||
|
||||
```ts
|
||||
// Top-level categories
|
||||
Category.find({ parentId: null, isActive: true }).sort({ order: 1 });
|
||||
|
||||
// Children of a category
|
||||
Category.find({ parentId, isActive: true }).sort({ order: 1 });
|
||||
|
||||
// Bilingual search
|
||||
Category.find({ $or: [{ name: regex }, { nameEn: regex }], isActive: true });
|
||||
|
||||
// Full tree (basic, two-level)
|
||||
const roots = await Category.find({ parentId: null }).sort({ order: 1 });
|
||||
const children = await Category.find({ parentId: { $in: roots.map(r => r._id) } });
|
||||
```
|
||||
|
||||
Related: [[PurchaseRequest]], [[RequestTemplate]].
|
||||
144
02 - Data Models/Chat.md
Normal file
144
02 - Data Models/Chat.md
Normal file
@@ -0,0 +1,144 @@
|
||||
---
|
||||
title: Chat
|
||||
tags: [data-model, mongoose]
|
||||
aliases: [Conversation, IChat, IMessage]
|
||||
---
|
||||
|
||||
# Chat
|
||||
|
||||
Conversation container with embedded messages. Used for buyer-seller direct chats, group chats, and support tickets. Each chat carries a list of participants, an embedded `messages[]` array (with reactions, replies, edit history), a denormalised `lastMessage` snapshot for list views, and per-user `unreadCounts`. A chat can be linked to any other entity through the `relatedTo` discriminator (currently `PurchaseRequest`, `SellerOffer`, or `Transaction`).
|
||||
|
||||
> [!note] Source
|
||||
> `backend/src/models/Chat.ts:130` — chat schema definition
|
||||
> `backend/src/models/Chat.ts:69` — message subdocument schema
|
||||
> `backend/src/models/Chat.ts:348` — model export
|
||||
|
||||
> [!warning] Embedded messages
|
||||
> Messages live inside the chat document. Very long-running chats can grow past the 16 MB document limit. Treat this as a known constraint of the current schema.
|
||||
|
||||
## Schema — Chat
|
||||
|
||||
| Field | Type | Required | Default | Validation | Index | Description |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `type` | String | yes | `direct` | enum: `direct` / `group` / `support` | yes | Conversation type. |
|
||||
| `name` | String | no | — | maxlength 100 | — | Display name (group chats). |
|
||||
| `description` | String | no | — | maxlength 500 | — | Optional description. |
|
||||
| `participants[].userId` | ObjectId → [[User]] | yes | — | — | yes | Member id. |
|
||||
| `participants[].role` | String | no | `member` | enum: `member` / `admin` / `owner` | — | Member role. |
|
||||
| `participants[].joinedAt` | Date | no | `Date.now` | — | — | Join time. |
|
||||
| `participants[].lastSeen` | Date | no | — | — | — | Last activity. |
|
||||
| `participants[].leftAt` | Date | no | — | — | — | If left, when. |
|
||||
| `participants[].isActive` | Boolean | no | `true` | — | — | Still a participant. |
|
||||
| `messages[]` | Subdocument[] | no | `[]` | — | yes (`messages.timestamp`) | Embedded messages. |
|
||||
| `relatedTo.type` | String | no | — | enum: `PurchaseRequest` / `SellerOffer` / `Transaction` | yes (compound) | Linked entity kind. |
|
||||
| `relatedTo.id` | ObjectId | no | — | — | yes (compound) | Linked entity id. |
|
||||
| `lastMessage.content` | String | no | — | — | — | Snapshot for list views. |
|
||||
| `lastMessage.senderId` | ObjectId → [[User]] | no | — | — | — | Last sender. |
|
||||
| `lastMessage.timestamp` | Date | no | — | — | — | Last message time. |
|
||||
| `lastMessage.messageType` | String | no | — | — | — | Last message type. |
|
||||
| `unreadCounts[].userId` | ObjectId → [[User]] | no | — | — | — | User the counter belongs to. |
|
||||
| `unreadCounts[].count` | Number | no | `0` | — | — | Number of unread messages. |
|
||||
| `settings.isArchived` | Boolean | no | `false` | — | — | Archived flag. |
|
||||
| `settings.isMuted` | Boolean | no | `false` | — | — | Muted flag. |
|
||||
| `settings.mutedUntil` | Date | no | — | — | — | Mute expiry. |
|
||||
| `settings.notifications` | Boolean | no | `true` | — | — | Per-chat notification toggle. |
|
||||
| `metadata.createdBy` | ObjectId → [[User]] | yes | — | — | — | Original creator. |
|
||||
| `metadata.createdAt` | Date | no | `Date.now` | — | — | Created timestamp. |
|
||||
| `metadata.updatedAt` | Date | no | `Date.now` | — | — | Touched by pre-save. |
|
||||
| `metadata.lastActivity` | Date | no | `Date.now` | — | yes (desc) | Sort key for chat lists. |
|
||||
|
||||
> [!note] No top-level `timestamps`
|
||||
> Unlike most models, this schema does not pass `{ timestamps: true }`. It uses its own `metadata.createdAt` / `metadata.updatedAt` instead, maintained by the pre-save hook.
|
||||
|
||||
## Schema — Message (embedded)
|
||||
|
||||
| Field | Type | Required | Default | Validation | Description |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `senderId` | ObjectId → [[User]] | yes | — | — | Author. |
|
||||
| `senderType` | String | no | `User` | — | Currently fixed. |
|
||||
| `content` | String | yes | — | maxlength 5000 | Message body. |
|
||||
| `messageType` | String | no | `text` | enum: `text` / `image` / `file` / `system` | Body kind. |
|
||||
| `fileUrl` | String | no | — | — | If file/image. |
|
||||
| `fileName` | String | no | — | — | Original filename. |
|
||||
| `fileSize` | Number | no | — | — | Bytes. |
|
||||
| `timestamp` | Date | no | `Date.now` | — | Sent time. |
|
||||
| `isRead` | Boolean | no | `false` | — | Read flag. |
|
||||
| `isEdited` | Boolean | no | `false` | — | Edited flag. |
|
||||
| `editedAt` | Date | no | — | — | When edited. |
|
||||
| `replyTo` | ObjectId | no | — | — | Reply target message id. |
|
||||
| `reactions[].userId` | ObjectId → [[User]] | no | — | — | Reacting user. |
|
||||
| `reactions[].reaction` | String | no | — | maxlength 10 | Emoji. |
|
||||
|
||||
## Virtuals
|
||||
|
||||
| Virtual | Returns | Definition |
|
||||
| --- | --- | --- |
|
||||
| `participantsCount` | Count of active participants | `backend/src/models/Chat.ts:259` |
|
||||
|
||||
## Indexes
|
||||
|
||||
Defined at `backend/src/models/Chat.ts:243-247`:
|
||||
|
||||
- `{ 'participants.userId': 1 }`
|
||||
- `{ 'metadata.lastActivity': -1 }`
|
||||
- `{ 'relatedTo.type': 1, 'relatedTo.id': 1 }`
|
||||
- `{ 'messages.timestamp': -1 }`
|
||||
- `{ type: 1 }`
|
||||
|
||||
## Pre/Post Hooks
|
||||
|
||||
| Hook | Behaviour |
|
||||
| --- | --- |
|
||||
| `pre('save')` (`backend/src/models/Chat.ts:250`) | Updates `metadata.updatedAt` and refreshes `metadata.lastActivity` when there are messages. |
|
||||
|
||||
## Instance Methods
|
||||
|
||||
| Signature | Purpose |
|
||||
| --- | --- |
|
||||
| `getUnreadCount(userId: Types.ObjectId): number` | Returns the unread counter for a participant. `backend/src/models/Chat.ts:264` |
|
||||
| `addMessage(messageData: Partial<IMessage>): IMessage` | Pushes a message, updates `lastMessage`, increments unread counters for everyone except the sender, and bumps `lastActivity`. `backend/src/models/Chat.ts:270` |
|
||||
| `markAsRead(userId, messageIds?: Types.ObjectId[]): void` | Marks listed messages (or all) as read for the user, zeros their unread counter, and updates `lastSeen`. `backend/src/models/Chat.ts:308` |
|
||||
|
||||
## Static Methods
|
||||
|
||||
None defined.
|
||||
|
||||
## Relationships
|
||||
|
||||
- **References**: [[User]] (`participants[].userId`, `messages[].senderId`, `messages[].reactions[].userId`, `lastMessage.senderId`, `unreadCounts[].userId`, `metadata.createdBy`).
|
||||
- **Referenced by**: [[Dispute]] (`chatId`), and any [[PurchaseRequest]] / [[SellerOffer]] indirectly through `relatedTo`.
|
||||
|
||||
## State Transitions
|
||||
|
||||
No top-level status. Chat-level archival is a boolean flag (`settings.isArchived`):
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> active
|
||||
active --> muted : user mutes
|
||||
muted --> active : unmute / mute expires
|
||||
active --> archived : user archives
|
||||
archived --> active : restore
|
||||
```
|
||||
|
||||
## Common Queries
|
||||
|
||||
```ts
|
||||
// A user's recent chats
|
||||
Chat.find({ 'participants.userId': userId, 'participants.isActive': true })
|
||||
.sort({ 'metadata.lastActivity': -1 });
|
||||
|
||||
// Chat for a purchase request
|
||||
Chat.findOne({ 'relatedTo.type': 'PurchaseRequest', 'relatedTo.id': prId });
|
||||
|
||||
// Append a message
|
||||
const chat = await Chat.findById(id);
|
||||
chat.addMessage({ senderId, content: 'hi', messageType: 'text' });
|
||||
await chat.save();
|
||||
|
||||
// Mark read
|
||||
chat.markAsRead(userId);
|
||||
await chat.save();
|
||||
```
|
||||
|
||||
Related: [[User]], [[PurchaseRequest]], [[SellerOffer]], [[Dispute]].
|
||||
105
02 - Data Models/Data Model Overview.md
Normal file
105
02 - Data Models/Data Model Overview.md
Normal file
@@ -0,0 +1,105 @@
|
||||
---
|
||||
title: Data Model Overview
|
||||
tags: [data-model, mongoose, overview]
|
||||
aliases: [Models Index, Schema Overview]
|
||||
---
|
||||
|
||||
# Data Model Overview
|
||||
|
||||
This section documents every Mongoose model that backs the marketplace. The persistence layer lives in `backend/src/models/` and is exported through a single barrel file at `backend/src/models/index.ts`. All models target MongoDB via Mongoose, lean on `timestamps: true` for `createdAt` / `updatedAt`, and follow a consistent pattern: one schema per file, an exported `I<Name>` TypeScript interface, and named exports for the compiled model.
|
||||
|
||||
> [!note] Scope
|
||||
> Sixteen models are documented here. The "File" concept exists only at the service layer (`backend/src/services/file/`) and is not persisted as its own Mongoose collection, so it is not listed below.
|
||||
|
||||
## Index of Models
|
||||
|
||||
- [[User]] — Core identity. Stores credentials, profile, preferences, referral data, points, and WebAuthn passkeys. Every other model that records "who did what" points back at a `User._id`. Buyers, sellers, and admins all live in this collection, differentiated by a `role` enum.
|
||||
- [[PurchaseRequest]] — The buyer-side document at the heart of the marketplace. Captures what a buyer wants, the budget, urgency, delivery preferences, and the full lifecycle status (`pending_payment` → `seller_paid`). Aggregates [[SellerOffer]] references and tracks delivery codes.
|
||||
- [[SellerOffer]] — A seller's bid against a [[PurchaseRequest]]. Holds price, delivery ETA, attachments, and a small status machine (`pending` / `accepted` / `rejected` / `withdrawn`).
|
||||
- [[Payment]] — Records every monetary movement: buyer pay-in, seller payout, refund. Integrates with the SHKeeper crypto gateway and tracks escrow state plus on-chain transaction metadata.
|
||||
- [[Chat]] — Conversation container with embedded messages, participants, unread counters, and reactions. Used for direct buyer-seller chats, group chats, and support tickets. Can be linked to a [[PurchaseRequest]] or [[SellerOffer]].
|
||||
- [[Notification]] — Per-user notification with category, type, and 90-day TTL for automatic cleanup. References any related entity by stringified id.
|
||||
- [[RequestTemplate]] — A seller-authored, sharable template that pre-fills a [[PurchaseRequest]]. Carries a public shareable link, usage counter, and an optional default proposal.
|
||||
- [[Dispute]] — Buyer-raised complaint tied to a [[PurchaseRequest]]. Captures evidence uploads, a timeline of admin actions, deadlines, and a structured resolution.
|
||||
- [[BlogPost]] — Editorial content: title, slug, rich content, media, SEO metadata, view/like counters, and a draft/published/archived workflow.
|
||||
- [[Address]] — User shipping address book entry. Enforces a single primary address per user via a pre-save hook.
|
||||
- [[Category]] — Hierarchical product/service taxonomy referenced by [[PurchaseRequest]] and [[RequestTemplate]]. Supports parent/child via `parentId` and bilingual `name` / `nameEn`.
|
||||
- [[Review]] — Polymorphic 1-5 star review against either a seller or a [[RequestTemplate]] (`subjectType` discriminator). One review per reviewer per subject (compound unique index).
|
||||
- [[PointTransaction]] — Ledger of point grants and spends per user. Sources include purchase, referral, bonus, admin grant, and redemption.
|
||||
- [[LevelConfig]] — Static configuration of loyalty tiers (level number, point thresholds, benefits, icon, color). Driven by admins; consumed by the [[User]].points.level field.
|
||||
- [[ShopSettings]] — One-to-one storefront branding for a seller: name, description, avatar, cover image, review toggles, and social links.
|
||||
- [[TempVerification]] — Short-lived signup record that holds candidate user data and a verification code. Auto-purges via TTL when `emailVerificationCodeExpires` passes.
|
||||
|
||||
## Relationship Diagram
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
USER ||--o{ PURCHASE_REQUEST : "creates as buyer"
|
||||
USER ||--o{ SELLER_OFFER : "submits as seller"
|
||||
USER ||--o{ ADDRESS : "owns"
|
||||
USER ||--o{ NOTIFICATION : "receives"
|
||||
USER ||--o{ POINT_TRANSACTION : "earns/spends"
|
||||
USER ||--o{ REQUEST_TEMPLATE : "authors as seller"
|
||||
USER ||--o| SHOP_SETTINGS : "configures"
|
||||
USER ||--o{ BLOG_POST : "publishes"
|
||||
USER ||--o{ REVIEW : "writes as reviewer"
|
||||
USER ||--o{ DISPUTE : "raises as buyer"
|
||||
USER ||--o{ USER : "referred by"
|
||||
|
||||
PURCHASE_REQUEST }o--|| CATEGORY : "belongs to"
|
||||
PURCHASE_REQUEST ||--o{ SELLER_OFFER : "receives"
|
||||
PURCHASE_REQUEST ||--o{ PAYMENT : "settled by"
|
||||
PURCHASE_REQUEST ||--o| CHAT : "discussed in"
|
||||
PURCHASE_REQUEST ||--o{ DISPUTE : "may trigger"
|
||||
PURCHASE_REQUEST ||--o| REVIEW : "rated by buyer"
|
||||
|
||||
SELLER_OFFER ||--o| PAYMENT : "funds"
|
||||
SELLER_OFFER }o--|| PURCHASE_REQUEST : "responds to"
|
||||
|
||||
PAYMENT }o--|| USER : "buyer"
|
||||
PAYMENT }o--|| USER : "seller"
|
||||
|
||||
CHAT }o--o{ USER : "participants"
|
||||
CHAT ||--o{ DISPUTE : "support channel"
|
||||
|
||||
REQUEST_TEMPLATE }o--|| CATEGORY : "belongs to"
|
||||
REQUEST_TEMPLATE ||--o{ REVIEW : "rated as subject"
|
||||
|
||||
CATEGORY ||--o{ CATEGORY : "parent of"
|
||||
|
||||
POINT_TRANSACTION }o--|| USER : "owner"
|
||||
LEVEL_CONFIG ||..|| USER : "level lookup"
|
||||
|
||||
TEMP_VERIFICATION ||..|| USER : "promoted to"
|
||||
```
|
||||
|
||||
## Conventions Across All Models
|
||||
|
||||
> [!note] Shared schema patterns
|
||||
> - **Timestamps**: every model declares `{ timestamps: true }`, so `createdAt` and `updatedAt` are always present.
|
||||
> - **ObjectId references**: foreign keys use `Schema.Types.ObjectId` with an explicit `ref` (e.g. `ref: 'User'`). The two exceptions are [[Notification]] and [[Payment]] which use string-typed or `Mixed` identifiers in places to support template-flow payments.
|
||||
> - **Soft delete**: deletion is modelled as a `status` flag (e.g. `User.status = 'deleted'`, `BlogPost.status = 'archived'`) rather than physical removal.
|
||||
> - **TTL indexes**: short-lived collections ([[Notification]], [[TempVerification]]) use `{ expireAfterSeconds: ... }` so MongoDB does the cleanup.
|
||||
> - **toJSON sanitisation**: [[User]] overrides `toJSON` to strip credentials, refresh tokens, and verification codes before serialisation.
|
||||
|
||||
> [!warning] Index discipline
|
||||
> Several schemas leave a comment noting that `unique: true` already creates an index — adding `schema.index({ field: 1 })` on top would produce a duplicate-index warning at startup. When introducing new indexes, search for `unique: true` first.
|
||||
|
||||
## Lifecycle View
|
||||
|
||||
The dominant happy-path flow exercises five collections in order:
|
||||
|
||||
1. A buyer (`User`) creates a `PurchaseRequest` with `status: 'pending'`.
|
||||
2. Sellers (other `User`s) attach `SellerOffer` documents; the request transitions through `received_offers` → `in_negotiation` as the parties chat in a `Chat`.
|
||||
3. The buyer accepts an offer; a `Payment` is opened against the SHKeeper provider with `escrowState: 'funded'`.
|
||||
4. The seller marks the request `delivery` → `delivered`; the buyer confirms with the 6-digit `deliveryCode` and the request becomes `completed`.
|
||||
5. The escrow `Payment` flips to `released` and a payout `Payment` (`direction: 'out'`) is issued. Optionally the buyer writes a `Review` and earns a `PointTransaction`.
|
||||
|
||||
If anything goes sideways, the buyer can open a `Dispute`, which freezes the flow until an admin resolves it (refund, replacement, compensation, or no-action).
|
||||
|
||||
## How to Navigate
|
||||
|
||||
Each model has its own note in this folder. Cross-references use `[[wikilinks]]` so backlinks work in Obsidian's graph view. Schemas are documented at field-level granularity — every field is listed with its type, default, validation, and indexing decisions. Where a model carries a meaningful state machine, a Mermaid `stateDiagram-v2` accompanies the schema table.
|
||||
|
||||
> [!note] Source of truth
|
||||
> The information below is mirrored from the TypeScript schema definitions. If a field listed here disagrees with the code, the code wins — please update the note. All source citations use the form `backend/src/models/<File>.ts:<line>`.
|
||||
127
02 - Data Models/Dispute.md
Normal file
127
02 - Data Models/Dispute.md
Normal file
@@ -0,0 +1,127 @@
|
||||
---
|
||||
title: Dispute
|
||||
tags: [data-model, mongoose]
|
||||
aliases: [Complaint, IDispute]
|
||||
---
|
||||
|
||||
# Dispute
|
||||
|
||||
Buyer-raised complaint tied to a [[PurchaseRequest]]. Captures the reason, priority, category, an array of evidence uploads, a chronological `timeline` of actions, an optional resolution, and SLA deadlines. An admin (`adminId`) is assigned during triage and resolves the dispute with a structured action (`refund`, `replacement`, `compensation`, `warning_seller`, `ban_seller`, or `no_action`).
|
||||
|
||||
> [!note] Source
|
||||
> `backend/src/models/Dispute.ts:69` — schema definition
|
||||
> `backend/src/models/Dispute.ts:238` — model export
|
||||
|
||||
## Schema
|
||||
|
||||
| Field | Type | Required | Default | Validation | Index | Description |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `purchaseRequestId` | ObjectId → [[PurchaseRequest]] | yes | — | — | yes | The disputed request. |
|
||||
| `buyerId` | ObjectId → [[User]] | yes | — | — | yes | Complaining buyer. |
|
||||
| `sellerId` | ObjectId → [[User]] | no | — | — | yes | Implicated seller. |
|
||||
| `adminId` | ObjectId → [[User]] | no | — | — | yes (single + compound) | Admin owning the case. |
|
||||
| `reason` | String | yes | — | trim, maxlength 200 | — | Short reason. |
|
||||
| `description` | String | yes | — | trim, maxlength 2000 | — | Detailed description. |
|
||||
| `priority` | String | no | `medium` | enum: `low` / `medium` / `high` / `urgent` | yes | Triage priority. |
|
||||
| `category` | String | yes | — | enum: `product_quality` / `delivery_delay` / `wrong_item` / `payment_issue` / `seller_behavior` / `other` | yes | Issue type. |
|
||||
| `status` | String | no | `pending` | enum: `pending` / `in_progress` / `waiting_response` / `resolved` / `rejected` / `closed` | yes (single + compound) | Lifecycle state. |
|
||||
| `evidence[].type` | String | yes | — | enum: `image` / `document` / `screenshot` / `video` | — | Evidence kind. |
|
||||
| `evidence[].url` | String | yes | — | — | — | Stored URL. |
|
||||
| `evidence[].description` | String | no | — | — | — | Notes. |
|
||||
| `evidence[].uploadedBy` | ObjectId → [[User]] | yes | — | — | — | Uploader. |
|
||||
| `evidence[].uploadedAt` | Date | no | `Date.now` | — | — | Upload time. |
|
||||
| `chatId` | ObjectId → [[Chat]] | no | — | — | — | Linked support chat. |
|
||||
| `timeline[].action` | String | yes | — | — | — | Action label. |
|
||||
| `timeline[].performedBy` | ObjectId → [[User]] | yes | — | — | — | Actor. |
|
||||
| `timeline[].performedAt` | Date | no | `Date.now` | — | — | When. |
|
||||
| `timeline[].details` | String | no | — | — | — | Free-form notes. |
|
||||
| `resolution.action` | String | no | — | enum: `refund` / `replacement` / `compensation` / `warning_seller` / `ban_seller` / `no_action` | — | Outcome. |
|
||||
| `resolution.amount` | Number | no | — | — | — | Monetary amount (refund/compensation). |
|
||||
| `resolution.currency` | String | no | — | enum: `USD` / `EUR` / `IRR` / `USDT` | — | Currency. |
|
||||
| `resolution.notes` | String | no | — | maxlength 1000 | — | Resolution notes. |
|
||||
| `resolution.resolvedBy` | ObjectId → [[User]] | no | — | — | — | Admin who resolved. |
|
||||
| `resolution.resolvedAt` | Date | no | — | — | — | When resolved. |
|
||||
| `deadline` | Date | no | — | — | — | Overall SLA deadline. |
|
||||
| `responseDeadline` | Date | no | — | — | — | Response SLA. |
|
||||
| `tags[]` | String[] | no | — | trim | — | Filter tags. |
|
||||
| `closedAt` | Date | no | — | — | — | When closed. |
|
||||
| `createdAt` | Date | auto | — | — | yes (desc) | Mongoose timestamp. |
|
||||
| `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. |
|
||||
|
||||
> [!note] `messages` in the interface
|
||||
> The TypeScript interface mentions an optional embedded `messages[]` array, but the actual Mongoose schema does not declare it — messages live in [[Chat]] via `chatId`.
|
||||
|
||||
## Virtuals
|
||||
|
||||
None defined.
|
||||
|
||||
## Indexes
|
||||
|
||||
Defined at `backend/src/models/Dispute.ts:212-223`:
|
||||
|
||||
- `{ purchaseRequestId: 1 }`
|
||||
- `{ buyerId: 1 }`
|
||||
- `{ sellerId: 1 }`
|
||||
- `{ adminId: 1 }`
|
||||
- `{ status: 1 }`
|
||||
- `{ priority: 1 }`
|
||||
- `{ category: 1 }`
|
||||
- `{ createdAt: -1 }`
|
||||
- `{ status: 1, priority: -1 }` — admin queue
|
||||
- `{ adminId: 1, status: 1 }` — per-admin workload
|
||||
|
||||
## Pre/Post Hooks
|
||||
|
||||
| Hook | Behaviour |
|
||||
| --- | --- |
|
||||
| `pre('save')` (`backend/src/models/Dispute.ts:226`) | On new documents pushes a `dispute_created` entry into `timeline` attributed to `buyerId`. |
|
||||
|
||||
## Instance Methods
|
||||
|
||||
None defined.
|
||||
|
||||
## Static Methods
|
||||
|
||||
None defined.
|
||||
|
||||
## Relationships
|
||||
|
||||
- **References**: [[PurchaseRequest]] (`purchaseRequestId`), [[User]] (`buyerId`, `sellerId`, `adminId`, evidence and timeline contributors, `resolution.resolvedBy`), [[Chat]] (`chatId`).
|
||||
- **Referenced by**: none directly.
|
||||
|
||||
## State Transitions
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> pending
|
||||
pending --> in_progress : admin assigned
|
||||
in_progress --> waiting_response : awaiting party
|
||||
waiting_response --> in_progress : response received
|
||||
in_progress --> resolved : action applied
|
||||
in_progress --> rejected : invalid
|
||||
resolved --> closed
|
||||
rejected --> closed
|
||||
closed --> [*]
|
||||
```
|
||||
|
||||
## Common Queries
|
||||
|
||||
```ts
|
||||
// Admin queue
|
||||
Dispute.find({ status: { $in: ['pending', 'in_progress', 'waiting_response'] } })
|
||||
.sort({ priority: -1, createdAt: 1 });
|
||||
|
||||
// Buyer's disputes
|
||||
Dispute.find({ buyerId }).sort({ createdAt: -1 });
|
||||
|
||||
// Seller's open disputes
|
||||
Dispute.find({ sellerId, status: { $nin: ['resolved', 'rejected', 'closed'] } });
|
||||
|
||||
// Append timeline entry atomically
|
||||
Dispute.updateOne(
|
||||
{ _id },
|
||||
{ $push: { timeline: { action, performedBy: adminId, performedAt: new Date(), details } } }
|
||||
);
|
||||
```
|
||||
|
||||
Related: [[PurchaseRequest]], [[User]], [[Chat]], [[Payment]].
|
||||
90
02 - Data Models/LevelConfig.md
Normal file
90
02 - Data Models/LevelConfig.md
Normal file
@@ -0,0 +1,90 @@
|
||||
---
|
||||
title: LevelConfig
|
||||
tags: [data-model, mongoose]
|
||||
aliases: [Level, Loyalty Tier, ILevelConfig]
|
||||
---
|
||||
|
||||
# LevelConfig
|
||||
|
||||
Admin-managed configuration of loyalty tiers. Each row defines one level (`level`, `name`, `nameEn`, point window via `minPoints` / `maxPoints`), the perks unlocked (`benefits.*`), and presentation details (`icon`, `color`, `order`). The `User.points.level` field is resolved against this collection.
|
||||
|
||||
> [!note] Source
|
||||
> `backend/src/models/LevelConfig.ts:24` — schema definition
|
||||
> `backend/src/models/LevelConfig.ts:93` — model export
|
||||
|
||||
## Schema
|
||||
|
||||
| Field | Type | Required | Default | Validation | Index | Description |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `level` | Number | yes | — | — | unique | Numeric level (1, 2, 3, ...). |
|
||||
| `name` | String | yes | — | — | — | Local language name. |
|
||||
| `nameEn` | String | yes | — | — | — | English name. |
|
||||
| `minPoints` | Number | yes | `0` | — | yes | Inclusive lower bound. |
|
||||
| `maxPoints` | Number | no | — | — | — | Inclusive upper bound (open if omitted). |
|
||||
| `benefits.discountPercent` | Number | no | `0` | — | — | Percentage discount unlocked. |
|
||||
| `benefits.freeShipping` | Boolean | no | `false` | — | — | Free shipping perk. |
|
||||
| `benefits.prioritySupport` | Boolean | no | `false` | — | — | Priority support perk. |
|
||||
| `benefits.specialOffers` | Boolean | no | `false` | — | — | Exclusive offers perk. |
|
||||
| `icon` | String | no | `solar:medal-star-bold` | — | — | Icon identifier. |
|
||||
| `color` | String | no | `#94a3b8` | — | — | Display color. |
|
||||
| `order` | Number | yes | — | — | yes | Display order. |
|
||||
| `isActive` | Boolean | no | `true` | — | yes | Active flag. |
|
||||
| `createdAt` | Date | auto | — | — | — | Mongoose timestamp. |
|
||||
| `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. |
|
||||
|
||||
## Virtuals
|
||||
|
||||
None defined.
|
||||
|
||||
## Indexes
|
||||
|
||||
Defined at `backend/src/models/LevelConfig.ts:89-91`. Plus the implicit unique index on `level`:
|
||||
|
||||
- `{ minPoints: 1 }`
|
||||
- `{ order: 1 }`
|
||||
- `{ isActive: 1 }`
|
||||
|
||||
## Pre/Post Hooks
|
||||
|
||||
None declared.
|
||||
|
||||
## Instance Methods
|
||||
|
||||
None defined.
|
||||
|
||||
## Static Methods
|
||||
|
||||
None defined.
|
||||
|
||||
## Relationships
|
||||
|
||||
- **References**: none.
|
||||
- **Referenced by**: indirectly by [[User]] (`points.level`) and [[PointTransaction]] (`metadata.levelBefore` / `metadata.levelAfter`).
|
||||
|
||||
## State Transitions
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> active
|
||||
active --> inactive : admin disables
|
||||
inactive --> active : admin re-enables
|
||||
```
|
||||
|
||||
## Common Queries
|
||||
|
||||
```ts
|
||||
// All active levels (ordered)
|
||||
LevelConfig.find({ isActive: true }).sort({ order: 1 });
|
||||
|
||||
// Resolve a point total to a level
|
||||
LevelConfig.findOne({
|
||||
isActive: true,
|
||||
minPoints: { $lte: points },
|
||||
$or: [{ maxPoints: { $gte: points } }, { maxPoints: { $exists: false } }],
|
||||
}).sort({ minPoints: -1 });
|
||||
|
||||
// Fetch by level number
|
||||
LevelConfig.findOne({ level });
|
||||
```
|
||||
|
||||
Related: [[User]], [[PointTransaction]].
|
||||
99
02 - Data Models/Notification.md
Normal file
99
02 - Data Models/Notification.md
Normal file
@@ -0,0 +1,99 @@
|
||||
---
|
||||
title: Notification
|
||||
tags: [data-model, mongoose]
|
||||
aliases: [User Notification, INotification]
|
||||
---
|
||||
|
||||
# Notification
|
||||
|
||||
Per-user notification entry. Each row binds to one `userId` (stored as a string rather than ObjectId), carries a typed severity (`info` / `success` / `warning` / `error`) and a domain category, optionally references another entity via `relatedId`, and supports an `actionUrl` for deep-linking. Old notifications are auto-purged by a 90-day TTL index.
|
||||
|
||||
> [!note] Source
|
||||
> `backend/src/models/Notification.ts:18` — schema definition
|
||||
> `backend/src/models/Notification.ts:79` — model export
|
||||
|
||||
> [!warning] String userId
|
||||
> `userId` is a String, not an ObjectId, and is not declared with `ref`. Consumers must cast to `ObjectId` if they want to `populate()` it as a [[User]].
|
||||
|
||||
## Schema
|
||||
|
||||
| Field | Type | Required | Default | Validation | Index | Description |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `userId` | String | yes | — | — | yes (single + compound) | Owner of the notification. |
|
||||
| `title` | String | yes | — | maxlength 200 | — | Headline. |
|
||||
| `message` | String | yes | — | maxlength 1000 | — | Body. |
|
||||
| `type` | String | yes | `info` | enum: `info` / `success` / `warning` / `error` | — | Severity. |
|
||||
| `category` | String | yes | — | enum: `purchase_request` / `offer` / `payment` / `delivery` / `system` | yes (compound) | Domain bucket. |
|
||||
| `relatedId` | String | no | — | — | yes | Id of the related entity (e.g. [[PurchaseRequest]]). |
|
||||
| `metadata` | Mixed | no | — | — | — | Arbitrary payload. |
|
||||
| `actionUrl` | String | no | — | maxlength 500 | — | Deep link. |
|
||||
| `isRead` | Boolean | no | `false` | — | yes (compound) | Read flag. |
|
||||
| `readAt` | Date | no | — | — | — | When read. |
|
||||
| `createdAt` | Date | auto | — | — | yes (compound + TTL) | Mongoose timestamp. |
|
||||
| `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. |
|
||||
|
||||
The collection name is overridden to `notifications` via `collection: 'notifications'`.
|
||||
|
||||
## Virtuals
|
||||
|
||||
None defined.
|
||||
|
||||
## Indexes
|
||||
|
||||
Defined at `backend/src/models/Notification.ts:71-77`:
|
||||
|
||||
- `{ userId: 1, createdAt: -1 }` — user feed.
|
||||
- `{ userId: 1, isRead: 1 }` — unread badge.
|
||||
- `{ userId: 1, category: 1 }` — category filter.
|
||||
- `{ relatedId: 1 }` — lookup by linked entity.
|
||||
- `{ createdAt: 1 }` with `expireAfterSeconds: 60 * 60 * 24 * 90` — auto-delete after 90 days.
|
||||
|
||||
Plus the implicit index from `userId` having `index: true` at the field level.
|
||||
|
||||
## Pre/Post Hooks
|
||||
|
||||
None declared.
|
||||
|
||||
## Instance Methods
|
||||
|
||||
None defined.
|
||||
|
||||
## Static Methods
|
||||
|
||||
None defined.
|
||||
|
||||
## Relationships
|
||||
|
||||
- **References**: [[User]] indirectly through `userId` (string); arbitrary entity via `relatedId`.
|
||||
- **Referenced by**: none.
|
||||
|
||||
## State Transitions
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> unread
|
||||
unread --> read : user opens
|
||||
read --> [*] : TTL purge (90d)
|
||||
unread --> [*] : TTL purge (90d)
|
||||
```
|
||||
|
||||
## Common Queries
|
||||
|
||||
```ts
|
||||
// User feed
|
||||
Notification.find({ userId }).sort({ createdAt: -1 }).limit(50);
|
||||
|
||||
// Unread badge count
|
||||
Notification.countDocuments({ userId, isRead: false });
|
||||
|
||||
// Mark all read
|
||||
Notification.updateMany(
|
||||
{ userId, isRead: false },
|
||||
{ $set: { isRead: true, readAt: new Date() } }
|
||||
);
|
||||
|
||||
// All notifications about a request
|
||||
Notification.find({ relatedId: purchaseRequestId.toString() });
|
||||
```
|
||||
|
||||
Related: [[User]], [[PurchaseRequest]], [[SellerOffer]], [[Payment]].
|
||||
157
02 - Data Models/Payment.md
Normal file
157
02 - Data Models/Payment.md
Normal file
@@ -0,0 +1,157 @@
|
||||
---
|
||||
title: Payment
|
||||
tags: [data-model, mongoose]
|
||||
aliases: [Payment Record, Escrow, IPayment]
|
||||
---
|
||||
|
||||
# Payment
|
||||
|
||||
Records every monetary movement in the marketplace: buyer pay-ins, seller payouts, and refunds. Designed around the SHKeeper crypto payment gateway with explicit fields for blockchain network, transaction hash, escrow state, and provider invoice ids. The `provider` and `direction` discriminators let one collection hold all four flow types (incoming buyer payment, outgoing seller payout, refund, and "other" provider integrations).
|
||||
|
||||
> [!note] Source
|
||||
> `backend/src/models/Payment.ts:3` — schema definition
|
||||
> `backend/src/models/Payment.ts:257` — model export (default export)
|
||||
|
||||
> [!warning] Mixed types
|
||||
> `purchaseRequestId`, `sellerOfferId`, and `sellerId` use `Schema.Types.Mixed`. They are usually `ObjectId`s, but the template-checkout flow passes string ids that do not yet exist in the database, so the schema accepts both.
|
||||
|
||||
## Schema
|
||||
|
||||
| Field | Type | Required | Default | Validation | Index | Description |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `purchaseRequestId` | Mixed (ObjectId or String) | yes | — | — | yes (compound, partial) | Linked [[PurchaseRequest]] id (or template id). |
|
||||
| `sellerOfferId` | Mixed (ObjectId or String) | yes | — | — | — | Linked [[SellerOffer]] id (or template offer ref). |
|
||||
| `buyerId` | ObjectId → [[User]] | yes | — | — | yes (compound) | Buyer paying. |
|
||||
| `sellerId` | Mixed (ObjectId or String) | yes | — | — | yes (compound) | Seller receiving (or template seller). |
|
||||
| `amount.amount` | Number | yes | — | — | — | Numeric amount. |
|
||||
| `amount.currency` | String | yes | `USDT` | — | — | Settlement currency. |
|
||||
| `provider` | String | no | `shkeeper` | enum: `shkeeper` / `other` | yes (compound, partial) | Payment processor. |
|
||||
| `direction` | String | no | `in` | enum: `in` / `out` / `refund` | yes (compound, partial) | Flow direction. |
|
||||
| `blockchain.network` | String | no | — | — | — | Network identifier. |
|
||||
| `blockchain.transactionHash` | String | no | — | — | yes (sparse) | On-chain tx hash. |
|
||||
| `blockchain.blockchain` | String | no | — | enum: `ethereum` / `polygon` / `bsc` / `avalanche` / `solana` / `optimism` / `arbitrum` / `base` / `gnosis` | — | Chain. |
|
||||
| `blockchain.token` | String | no | — | — | — | Token symbol. |
|
||||
| `blockchain.sender` | String | no | — | — | — | Source address. |
|
||||
| `blockchain.receiver` | String | no | — | — | — | Destination address. |
|
||||
| `blockchain.confirmedAt` | Date | no | — | — | — | When tx confirmed. |
|
||||
| `blockchain.confirmations` | Number | no | `0` | — | — | Confirmation count. |
|
||||
| `status` | String | no | `pending` | enum: `pending` / `processing` / `confirmed` / `completed` / `failed` / `cancelled` / `refunded` | yes (compound) | Lifecycle status. |
|
||||
| `escrowState` | String | no | — | enum: `funded` / `releasable` / `released` / `refunded` / `releasing` / `failed` | — | Escrow lifecycle. |
|
||||
| `providerPaymentId` | String | no | — | — | yes (sparse) | External provider id for idempotency. |
|
||||
| `metadata.userAgent` | String | no | — | — | — | Browser UA. |
|
||||
| `metadata.ipAddress` | String | no | — | — | — | Client IP. |
|
||||
| `metadata.walletType` | String | no | — | — | — | Wallet category. |
|
||||
| `metadata.paymentMethod` | String | no | — | — | — | Payment method label. |
|
||||
| `metadata.shkeeperUrl` | String | no | — | — | — | Invoice URL. |
|
||||
| `metadata.shkeeperInvoiceId` | String | no | — | — | — | Invoice id. |
|
||||
| `metadata.shkeeperData` | Mixed | no | — | — | — | Raw provider payload. |
|
||||
| `metadata.shkeeperStatus` | String | no | — | — | — | Provider status string. |
|
||||
| `metadata.balanceFiat` | String | no | — | — | — | Fiat-equivalent balance. |
|
||||
| `metadata.balanceCrypto` | String | no | — | — | — | Crypto balance. |
|
||||
| `metadata.cryptoName` | String | no | — | — | — | Crypto label. |
|
||||
| `metadata.walletAddress` | String | no | — | — | — | Wallet address. |
|
||||
| `metadata.shkeeperTaskId` | String | no | — | — | — | Payout task id. |
|
||||
| `metadata.lastWebhookAt` | Date | no | — | — | — | Last webhook timestamp. |
|
||||
| `metadata.webhookPayload` | Mixed | no | — | — | — | Last webhook body. |
|
||||
| `metadata.createdVia` | String | no | — | — | — | Origin marker. |
|
||||
| `metadata.payoutType` | String | no | — | — | — | Payout sub-type. |
|
||||
| `metadata.error` | String | no | — | — | — | Last error message. |
|
||||
| `metadata.failedAt` | Date | no | — | — | — | When it failed. |
|
||||
| `createdAt` | Date | auto | `Date.now` | — | yes (compound) | Mongoose timestamp. |
|
||||
| `processedAt` | Date | no | — | — | — | When processing started. |
|
||||
| `completedAt` | Date | no | — | — | — | When fully settled. |
|
||||
| `notes` | String | no | — | — | — | Free-form notes. |
|
||||
| `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. |
|
||||
|
||||
## Virtuals
|
||||
|
||||
| Virtual | Returns | Definition |
|
||||
| --- | --- | --- |
|
||||
| `paymentRef` | `PAY-<LAST_8_OF_ID_UPPERCASE>` | `backend/src/models/Payment.ts:191` |
|
||||
|
||||
The schema enables `toJSON: { virtuals: true }` and `toObject: { virtuals: true }` so the ref appears in API responses.
|
||||
|
||||
## Indexes
|
||||
|
||||
Defined at `backend/src/models/Payment.ts:174-188`:
|
||||
|
||||
- `{ status: 1, createdAt: -1 }` — admin queues.
|
||||
- `{ buyerId: 1, status: 1 }` — buyer dashboard.
|
||||
- `{ sellerId: 1, status: 1 }` — seller dashboard.
|
||||
- `{ 'blockchain.transactionHash': 1 }` (sparse) — webhook lookup by hash.
|
||||
- `{ providerPaymentId: 1 }` (sparse) — provider idempotency.
|
||||
- `{ buyerId: 1, purchaseRequestId: 1, provider: 1, direction: 1 }` (unique, partial: `provider === 'shkeeper' && direction === 'in' && status === 'pending'`, name `uniq_pending_shkeeper_by_buyer_session`) — guards against duplicate pending invoices.
|
||||
|
||||
## Pre/Post Hooks
|
||||
|
||||
None declared.
|
||||
|
||||
## Instance Methods
|
||||
|
||||
None defined.
|
||||
|
||||
## Static Methods
|
||||
|
||||
None defined.
|
||||
|
||||
## Relationships
|
||||
|
||||
- **References**: [[User]] (`buyerId`, sometimes `sellerId`), [[PurchaseRequest]] (`purchaseRequestId`), [[SellerOffer]] (`sellerOfferId`).
|
||||
- **Referenced by**: Indirectly through [[PurchaseRequest]] status transitions and [[Dispute]] resolution amounts; no model holds a direct foreign key back to `Payment`.
|
||||
|
||||
## State Transitions
|
||||
|
||||
Payment status:
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> pending
|
||||
pending --> processing : webhook received
|
||||
processing --> confirmed : tx confirmed
|
||||
confirmed --> completed : escrow released / payout done
|
||||
pending --> cancelled : buyer aborts
|
||||
processing --> failed : provider error
|
||||
completed --> refunded : dispute resolved
|
||||
failed --> [*]
|
||||
cancelled --> [*]
|
||||
completed --> [*]
|
||||
refunded --> [*]
|
||||
```
|
||||
|
||||
Escrow state (for `direction: 'in'`):
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> funded : buyer pays
|
||||
funded --> releasable : delivery confirmed
|
||||
releasable --> releasing : payout initiated
|
||||
releasing --> released : payout completed
|
||||
funded --> refunded : dispute refund
|
||||
releasing --> failed : payout error
|
||||
released --> [*]
|
||||
refunded --> [*]
|
||||
failed --> [*]
|
||||
```
|
||||
|
||||
## Common Queries
|
||||
|
||||
```ts
|
||||
// Buyer history
|
||||
Payment.find({ buyerId, direction: 'in' }).sort({ createdAt: -1 });
|
||||
|
||||
// Seller payouts
|
||||
Payment.find({ sellerId, direction: 'out', status: 'completed' });
|
||||
|
||||
// Webhook lookup
|
||||
Payment.findOne({ providerPaymentId });
|
||||
|
||||
// Pending escrows ready for release
|
||||
Payment.find({ direction: 'in', escrowState: 'releasable' });
|
||||
|
||||
// Idempotent invoice creation (will fail by unique index if a pending one exists)
|
||||
Payment.create({
|
||||
buyerId, purchaseRequestId, provider: 'shkeeper', direction: 'in', status: 'pending', ...
|
||||
});
|
||||
```
|
||||
|
||||
Related: [[PurchaseRequest]], [[SellerOffer]], [[User]], [[Dispute]].
|
||||
93
02 - Data Models/PointTransaction.md
Normal file
93
02 - Data Models/PointTransaction.md
Normal file
@@ -0,0 +1,93 @@
|
||||
---
|
||||
title: PointTransaction
|
||||
tags: [data-model, mongoose]
|
||||
aliases: [Point Ledger, Loyalty Transaction, IPointTransaction]
|
||||
---
|
||||
|
||||
# PointTransaction
|
||||
|
||||
Append-only ledger of loyalty point movements. Each row represents one earn / spend / expire event for a user, with a source attribution (`purchase` / `referral` / `bonus` / `admin` / `redemption`), the amount moved, and the resulting balance snapshot. Metadata is flexible to support different sources (order amount, commission, level changes, referenced [[PurchaseRequest]]).
|
||||
|
||||
> [!note] Source
|
||||
> `backend/src/models/PointTransaction.ts:25` — schema definition
|
||||
> `backend/src/models/PointTransaction.ts:84` — model export
|
||||
|
||||
## Schema
|
||||
|
||||
| Field | Type | Required | Default | Validation | Index | Description |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `user` | ObjectId → [[User]] | yes | — | — | yes (single + compound) | Owner of the transaction. |
|
||||
| `type` | String | yes | — | enum: `earn` / `spend` / `expire` | yes (compound) | Movement direction. |
|
||||
| `source` | String | yes | — | enum: `purchase` / `referral` / `bonus` / `admin` / `redemption` | yes (compound) | Source bucket. |
|
||||
| `amount` | Number | yes | — | — | — | Points moved (positive integer; semantics by `type`). |
|
||||
| `balance` | Number | yes | — | — | — | Available balance after the move. |
|
||||
| `order` | ObjectId → Order | no | — | — | — | Linked order id (legacy ref, see warning). |
|
||||
| `referredUser` | ObjectId → [[User]] | no | — | — | — | Referred user (for referral earns). |
|
||||
| `description` | String | yes | — | — | — | Human label. |
|
||||
| `metadata.orderAmount` | Number | no | — | — | — | Order amount snapshot. |
|
||||
| `metadata.commission` | Number | no | — | — | — | Commission snapshot. |
|
||||
| `metadata.levelBefore` | Number | no | — | — | — | Pre-level snapshot. |
|
||||
| `metadata.levelAfter` | Number | no | — | — | — | Post-level snapshot. |
|
||||
| `metadata.purchaseRequestId` | String | no | — | — | — | Linked [[PurchaseRequest]] id. |
|
||||
| `expiresAt` | Date | no | — | — | yes (sparse) | When the points expire (for `earn`). |
|
||||
| `createdAt` | Date | auto | — | — | yes (compound, desc) | Mongoose timestamp. |
|
||||
| `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. |
|
||||
|
||||
> [!warning] `order` reference
|
||||
> The schema declares `ref: 'Order'`, but there is no `Order` model in `backend/src/models/`. In practice this slot is used for the [[PurchaseRequest]] id; consumers should not rely on Mongoose `populate('order')` working.
|
||||
|
||||
## Virtuals
|
||||
|
||||
None defined.
|
||||
|
||||
## Indexes
|
||||
|
||||
Defined at `backend/src/models/PointTransaction.ts:80-82`. Plus the implicit index from `user` being declared with `index: true`:
|
||||
|
||||
- `{ user: 1, createdAt: -1 }` — user ledger view.
|
||||
- `{ type: 1, source: 1 }` — analytics.
|
||||
- `{ expiresAt: 1 }` (sparse) — expiry sweeps.
|
||||
|
||||
## Pre/Post Hooks
|
||||
|
||||
None declared.
|
||||
|
||||
## Instance Methods
|
||||
|
||||
None defined.
|
||||
|
||||
## Static Methods
|
||||
|
||||
None defined.
|
||||
|
||||
## Relationships
|
||||
|
||||
- **References**: [[User]] (`user`, `referredUser`).
|
||||
- **Referenced by**: none. Loosely related to [[PurchaseRequest]] via `metadata.purchaseRequestId` (string).
|
||||
|
||||
## State Transitions
|
||||
|
||||
No status field — entries are immutable once written. A consumer scans for `expiresAt < now` to create offsetting `type: 'expire'` rows.
|
||||
|
||||
## Common Queries
|
||||
|
||||
```ts
|
||||
// User ledger
|
||||
PointTransaction.find({ user: userId }).sort({ createdAt: -1 }).limit(50);
|
||||
|
||||
// Latest balance (most recent row)
|
||||
PointTransaction.findOne({ user: userId }).sort({ createdAt: -1 });
|
||||
|
||||
// Referral earnings
|
||||
PointTransaction.find({ user: userId, source: 'referral', type: 'earn' });
|
||||
|
||||
// Points expiring soon
|
||||
PointTransaction.find({ expiresAt: { $lte: oneWeekFromNow }, type: 'earn' });
|
||||
|
||||
// Analytics: total earned vs spent per source
|
||||
PointTransaction.aggregate([
|
||||
{ $group: { _id: { type: '$type', source: '$source' }, total: { $sum: '$amount' } } }
|
||||
]);
|
||||
```
|
||||
|
||||
Related: [[User]], [[LevelConfig]], [[PurchaseRequest]].
|
||||
172
02 - Data Models/PurchaseRequest.md
Normal file
172
02 - Data Models/PurchaseRequest.md
Normal file
@@ -0,0 +1,172 @@
|
||||
---
|
||||
title: PurchaseRequest
|
||||
tags: [data-model, mongoose]
|
||||
aliases: [Purchase Request, Buy Request, IPurchaseRequest]
|
||||
---
|
||||
|
||||
# PurchaseRequest
|
||||
|
||||
The central buyer-side document. A `PurchaseRequest` captures what a buyer wants to acquire (physical product, digital product, service, or consultation), the budget envelope, urgency, delivery details, and the entire lifecycle from creation through payment, delivery, and completion. Sellers respond by attaching [[SellerOffer]] documents; the buyer accepts one, a [[Payment]] is opened, and delivery is verified by a 6-digit code.
|
||||
|
||||
> [!note] Source
|
||||
> `backend/src/models/PurchaseRequest.ts:95` — schema definition
|
||||
> `backend/src/models/PurchaseRequest.ts:387` — model export
|
||||
|
||||
## Schema
|
||||
|
||||
| Field | Type | Required | Default | Validation | Index | Description |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `buyerId` | ObjectId → [[User]] | yes | — | — | yes | Buyer that owns the request. |
|
||||
| `title` | String | yes | — | trim, maxlength 200 | — | Short headline. |
|
||||
| `description` | String | yes | — | trim, maxlength 2000 | — | Long form description. |
|
||||
| `categoryId` | ObjectId → [[Category]] | yes | — | — | yes | Category the request belongs to. |
|
||||
| `productType` | String | no | `physical_product` | enum: `physical_product` / `digital_product` / `service` / `consultation` | yes | What kind of fulfilment is expected. |
|
||||
| `productLink` | String | no | — | trim, must match `/^https?:\/\/.+/` | — | Reference URL for the desired product. |
|
||||
| `size` | String | no | — | trim, maxlength 100 | — | Product size. |
|
||||
| `color` | String | no | — | trim, maxlength 100 | — | Product color. |
|
||||
| `brand` | String | no | — | trim, maxlength 100 | — | Brand preference. |
|
||||
| `preferredSellerIds[]` | ObjectId → [[User]] | no | `[]` | — | — | Targeted sellers for a private request. |
|
||||
| `quantity` | Number | no | `1` | min 1 | — | Unit count. |
|
||||
| `budget.min` | Number | no | — | min 0 | — | Lower bound. |
|
||||
| `budget.max` | Number | no | — | min 0 | — | Upper bound. |
|
||||
| `budget.currency` | String | no | `USD` | enum: `USD` / `EUR` / `IRR` | — | Budget currency. |
|
||||
| `urgency` | String | no | `medium` | enum: `low` / `medium` / `high` / `urgent` | yes | Buyer urgency. |
|
||||
| `status` | String | no | `pending` | enum (13 values, see below) | yes | Lifecycle state. |
|
||||
| `isPublic` | Boolean | no | `true` | — | — | Public marketplace listing vs. private request. |
|
||||
| `tags[]` | String[] | no | — | trim | — | Free-form tags. |
|
||||
| `specifications[].key` | String | yes | — | trim | — | Spec key. |
|
||||
| `specifications[].value` | String | yes | — | trim | — | Spec value. |
|
||||
| `specifications[].label` | String | no | — | trim | — | Human label. |
|
||||
| `deliveryInfo.deliveryType` | String | yes | `physical` | enum: `physical` / `online` | — | Delivery channel. |
|
||||
| `deliveryInfo.address` | String | no | — | — | — | Physical address. |
|
||||
| `deliveryInfo.preferredDate` | Date | no | — | — | — | Buyer's target date. |
|
||||
| `deliveryInfo.notes` | String | no | — | — | — | Free-form notes. |
|
||||
| `deliveryInfo.deliveryAddress.name` | String | no | — | — | — | Recipient name. |
|
||||
| `deliveryInfo.deliveryAddress.phoneNumber` | String | no | — | — | — | Recipient phone. |
|
||||
| `deliveryInfo.deliveryAddress.fullAddress` | String | no | — | — | — | Full address string. |
|
||||
| `deliveryInfo.deliveryAddress.addressType` | String | no | — | — | — | e.g. Home / Office. |
|
||||
| `deliveryInfo.email` | String | no | — | email regex | — | For digital delivery. |
|
||||
| `deliveryInfo.sellerDeliveryInfo.estimatedDeliveryDate` | Date | no | — | — | — | Seller's ETA date. |
|
||||
| `deliveryInfo.sellerDeliveryInfo.estimatedDeliveryTime` | String | no | — | — | — | Seller's ETA time. |
|
||||
| `deliveryInfo.sellerDeliveryInfo.trackingNumber` | String | no | — | — | — | Carrier tracking. |
|
||||
| `deliveryInfo.sellerDeliveryInfo.deliveryNotes` | String | no | — | — | — | Notes from seller. |
|
||||
| `deliveryInfo.sellerDeliveryInfo.shippingMethod` | String | no | — | — | — | Method label. |
|
||||
| `deliveryInfo.sellerDeliveryInfo.downloadLink` | String | no | — | — | — | Download URL for digital products. |
|
||||
| `deliveryInfo.sellerDeliveryInfo.digitalFiles[]` | String[] | no | — | — | — | Digital file URLs. |
|
||||
| `deliveryInfo.deliveryDateTime` | Date | no | — | — | — | Confirmed delivery datetime. |
|
||||
| `deliveryInfo.deliveryDate` | Date | no | — | — | — | Confirmed delivery date. |
|
||||
| `deliveryInfo.shippedAt` | Date | no | — | — | — | Timestamp of shipment. |
|
||||
| `deliveryInfo.deliveryCode` | String | no | — | trim, length 6 | — | 6-digit handoff code. |
|
||||
| `deliveryInfo.deliveryCodeGeneratedAt` | Date | no | — | — | — | When code was issued. |
|
||||
| `deliveryInfo.deliveryCodeExpiresAt` | Date | no | — | — | — | When code expires. |
|
||||
| `deliveryInfo.deliveryCodeUsed` | Boolean | no | `false` | — | — | Whether the code has been redeemed. |
|
||||
| `deliveryInfo.deliveryCodeUsedAt` | Date | no | — | — | — | When it was redeemed. |
|
||||
| `deliveryInfo.deliveryCodeUsedBy` | ObjectId → [[User]] | no | — | — | — | Seller that redeemed. |
|
||||
| `deliveryInfo.deliveredAt` | Date | no | — | — | — | Final delivery timestamp. |
|
||||
| `deliveryInfo.deliveryAttempts[].sellerId` | ObjectId → [[User]] | yes | — | — | — | Seller making the attempt. |
|
||||
| `deliveryInfo.deliveryAttempts[].attemptedAt` | Date | no | `Date.now` | — | — | When attempted. |
|
||||
| `deliveryInfo.deliveryAttempts[].success` | Boolean | yes | — | — | — | Whether it succeeded. |
|
||||
| `deliveryInfo.deliveryAttempts[].code` | String | no | — | — | — | Code entered (only stored on success). |
|
||||
| `serviceInfo.duration` | Number | no | — | min 0.5 | — | Hours, only for service/consultation. |
|
||||
| `serviceInfo.sessionType` | String | no | — | enum: `online` / `in_person` / `hybrid` | — | Service session type. |
|
||||
| `serviceInfo.location` | String | no | — | trim, maxlength 200 | — | Service location. |
|
||||
| `serviceInfo.requirements[]` | String[] | no | — | trim | — | Pre-requisites. |
|
||||
| `attachments[]` | String[] | no | — | — | — | Attached file URLs. |
|
||||
| `offers[]` | ObjectId → [[SellerOffer]] | no | — | — | — | Offers received. |
|
||||
| `selectedOfferId` | ObjectId → [[SellerOffer]] | no | `null` | — | — | Accepted offer. |
|
||||
| `rating` | Number | no | `null` | min 1, max 5 | — | Buyer's post-delivery rating. |
|
||||
| `feedback` | String | no | `null` | maxlength 1000 | — | Buyer's feedback text. |
|
||||
| `deliveryConfirmed` | Boolean | no | `false` | — | — | Buyer confirmation flag. |
|
||||
| `deliveryConfirmedAt` | Date | no | `null` | — | — | Confirmation timestamp. |
|
||||
| `metadata.source` | String | no | `manual` | enum: `manual` / `template` / `api` | — | Where the request came from. |
|
||||
| `metadata.templateId` | String | no | — | trim | — | Originating [[RequestTemplate]] id. |
|
||||
| `metadata.version` | String | no | — | trim | — | Schema version. |
|
||||
| `createdAt` | Date | auto | — | — | yes (desc) | Mongoose timestamp. |
|
||||
| `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. |
|
||||
|
||||
## Virtuals
|
||||
|
||||
None defined.
|
||||
|
||||
## Indexes
|
||||
|
||||
Single-field — `backend/src/models/PurchaseRequest.ts:376-381`:
|
||||
|
||||
- `{ buyerId: 1 }`
|
||||
- `{ categoryId: 1 }`
|
||||
- `{ productType: 1 }`
|
||||
- `{ status: 1 }`
|
||||
- `{ createdAt: -1 }`
|
||||
- `{ urgency: 1 }`
|
||||
|
||||
Compound — `backend/src/models/PurchaseRequest.ts:384-385`:
|
||||
|
||||
- `{ productType: 1, status: 1 }`
|
||||
- `{ categoryId: 1, productType: 1 }`
|
||||
|
||||
## Pre/Post Hooks
|
||||
|
||||
None declared at the schema level.
|
||||
|
||||
## Instance Methods
|
||||
|
||||
None defined.
|
||||
|
||||
## Static Methods
|
||||
|
||||
None defined.
|
||||
|
||||
## Relationships
|
||||
|
||||
- **References**: [[User]] (`buyerId`, `preferredSellerIds[]`, `deliveryInfo.deliveryCodeUsedBy`, `deliveryInfo.deliveryAttempts[].sellerId`), [[Category]] (`categoryId`), [[SellerOffer]] (`offers[]`, `selectedOfferId`).
|
||||
- **Referenced by**: [[SellerOffer]] (`purchaseRequestId`), [[Payment]] (`purchaseRequestId`), [[Dispute]] (`purchaseRequestId`), [[Chat]] (`relatedTo.id` when `relatedTo.type === 'PurchaseRequest'`), [[Review]] (`purchaseRequestId`).
|
||||
|
||||
## State Transitions
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> pending_payment
|
||||
[*] --> pending
|
||||
pending_payment --> pending : payment confirmed
|
||||
pending --> active : published
|
||||
active --> received_offers : first offer
|
||||
received_offers --> in_negotiation : buyer engages
|
||||
in_negotiation --> payment : offer accepted
|
||||
payment --> processing : payment captured
|
||||
processing --> delivery : shipped
|
||||
delivery --> delivered : handed over
|
||||
delivered --> confirming : code redeemed
|
||||
confirming --> completed : buyer confirms
|
||||
completed --> seller_paid : payout released
|
||||
pending --> cancelled
|
||||
active --> cancelled
|
||||
received_offers --> cancelled
|
||||
in_negotiation --> cancelled
|
||||
completed --> [*]
|
||||
seller_paid --> [*]
|
||||
cancelled --> [*]
|
||||
```
|
||||
|
||||
## Common Queries
|
||||
|
||||
```ts
|
||||
// Buyer's open requests
|
||||
PurchaseRequest.find({ buyerId, status: { $in: ['pending', 'active', 'received_offers'] } });
|
||||
|
||||
// Public marketplace feed
|
||||
PurchaseRequest.find({ isPublic: true, status: 'active' }).sort({ createdAt: -1 });
|
||||
|
||||
// Sellers' eligible queue
|
||||
PurchaseRequest.find({ productType, status: 'active', categoryId });
|
||||
|
||||
// Populate offers
|
||||
PurchaseRequest.findById(id).populate('offers').populate('selectedOfferId');
|
||||
|
||||
// Redeem delivery code
|
||||
PurchaseRequest.findOneAndUpdate(
|
||||
{ _id: id, 'deliveryInfo.deliveryCode': code, 'deliveryInfo.deliveryCodeUsed': false },
|
||||
{ $set: { 'deliveryInfo.deliveryCodeUsed': true, 'deliveryInfo.deliveryCodeUsedAt': new Date() } }
|
||||
);
|
||||
```
|
||||
|
||||
Related: [[SellerOffer]], [[Payment]], [[Chat]], [[Dispute]], [[Review]], [[RequestTemplate]], [[Category]].
|
||||
134
02 - Data Models/RequestTemplate.md
Normal file
134
02 - Data Models/RequestTemplate.md
Normal file
@@ -0,0 +1,134 @@
|
||||
---
|
||||
title: RequestTemplate
|
||||
tags: [data-model, mongoose]
|
||||
aliases: [Template, Request Template, IRequestTemplate]
|
||||
---
|
||||
|
||||
# RequestTemplate
|
||||
|
||||
A reusable template authored by a seller. When a buyer visits the template's `shareableLink`, the front-end pre-fills a new [[PurchaseRequest]] with the template's category, urgency, specs, delivery info, and an optional default seller `proposal`. The schema mirrors `PurchaseRequest` for fast cloning, plus template-specific bookkeeping (`isActive`, `usageCount`, `maxUsage`, `expiresAt`).
|
||||
|
||||
> [!note] Source
|
||||
> `backend/src/models/RequestTemplate.ts:65` — schema definition
|
||||
> `backend/src/models/RequestTemplate.ts:295` — model export
|
||||
|
||||
## Schema
|
||||
|
||||
| Field | Type | Required | Default | Validation | Index | Description |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `sellerId` | ObjectId → [[User]] | yes | — | — | yes (compound) | Template author. |
|
||||
| `title` | String | yes | — | trim, maxlength 200 | — | Headline. |
|
||||
| `description` | String | yes | — | trim, maxlength 2000 | — | Description. |
|
||||
| `categoryId` | ObjectId → [[Category]] | yes | — | — | yes (compound) | Category. |
|
||||
| `productType` | String | no | `physical_product` | enum: `physical_product` / `digital_product` / `service` / `consultation` | yes (compound) | Fulfilment type. |
|
||||
| `productLink` | String | no | — | URL regex | — | Reference URL. |
|
||||
| `size` | String | no | — | trim, maxlength 100 | — | Size. |
|
||||
| `color` | String | no | — | trim, maxlength 100 | — | Color. |
|
||||
| `brand` | String | no | — | trim, maxlength 100 | — | Brand. |
|
||||
| `quantity` | Number | no | `1` | min 1 | — | Default unit count. |
|
||||
| `budget.min` | Number | no | — | min 0 | — | Lower bound. |
|
||||
| `budget.max` | Number | no | — | min 0 | — | Upper bound. |
|
||||
| `budget.currency` | String | no | `USD` | enum: `USD` / `EUR` / `IRR` | — | Currency. |
|
||||
| `urgency` | String | no | `medium` | enum: `low` / `medium` / `high` / `urgent` | — | Urgency. |
|
||||
| `tags[]` | String[] | no | — | trim | — | Tags. |
|
||||
| `specifications[].key` | String | yes | — | trim | — | Spec key. |
|
||||
| `specifications[].value` | String | yes | — | trim | — | Spec value. |
|
||||
| `specifications[].label` | String | no | — | trim | — | Human label. |
|
||||
| `deliveryInfo.deliveryType` | String | no | `physical` | enum: `physical` / `online` | — | Delivery channel. |
|
||||
| `deliveryInfo.notes` | String | no | — | — | — | Notes. |
|
||||
| `deliveryInfo.email` | String | no | — | email regex | — | Digital delivery email. |
|
||||
| `serviceInfo.duration` | Number | no | — | min 0.5 | — | Hours. |
|
||||
| `serviceInfo.sessionType` | String | no | — | enum: `online` / `in_person` / `hybrid` | — | Session type. |
|
||||
| `serviceInfo.location` | String | no | — | trim, maxlength 200 | — | Location. |
|
||||
| `serviceInfo.requirements[]` | String[] | no | — | trim | — | Pre-requisites. |
|
||||
| `proposal.title` | String | no | — | trim, maxlength 200 | — | Default offer title. |
|
||||
| `proposal.price` | Number | no | — | min 0.01 | — | Default offer price. |
|
||||
| `proposal.deliveryTime` | Number | no | — | min 1, max 365 | — | Default ETA in days. |
|
||||
| `proposal.description` | String | no | — | trim, maxlength 1000 | — | Default offer description. |
|
||||
| `attachments[]` | String[] | no | — | — | — | File URLs. |
|
||||
| `images[]` | String[] | no | — | trim | — | Image URLs. |
|
||||
| `metadata.source` | String | no | `manual` | enum: `manual` / `template` / `api` | — | Origin. |
|
||||
| `metadata.templateId` | String | no | — | trim | — | Originating template id. |
|
||||
| `metadata.version` | String | no | — | trim | — | Schema version. |
|
||||
| `isActive` | Boolean | no | `true` | — | yes (single + compound) | Active flag. |
|
||||
| `shareableLink` | String | yes | — | trim | unique (+ compound) | Public link slug. |
|
||||
| `usageCount` | Number | no | `0` | min 0 | — | Number of times used. |
|
||||
| `maxUsage` | Number | no | `null` | min 1 | — | Optional cap. |
|
||||
| `expiresAt` | Date | no | `null` | — | yes | Optional expiry. |
|
||||
| `createdAt` | Date | auto | — | — | yes (desc) | Mongoose timestamp. |
|
||||
| `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. |
|
||||
|
||||
## Virtuals
|
||||
|
||||
None defined.
|
||||
|
||||
## Indexes
|
||||
|
||||
Defined at `backend/src/models/RequestTemplate.ts:283-293`:
|
||||
|
||||
- `{ categoryId: 1 }`
|
||||
- `{ productType: 1 }`
|
||||
- `{ isActive: 1 }`
|
||||
- `{ createdAt: -1 }`
|
||||
- `{ expiresAt: 1 }`
|
||||
- `{ sellerId: 1, isActive: 1 }`
|
||||
- `{ shareableLink: 1, isActive: 1 }`
|
||||
- `{ productType: 1, isActive: 1 }`
|
||||
- `{ categoryId: 1, productType: 1 }`
|
||||
|
||||
`shareableLink` and `sellerId` already get indexes from `unique: true` / field-level conventions (see source comment at line 282).
|
||||
|
||||
## Pre/Post Hooks
|
||||
|
||||
None declared.
|
||||
|
||||
## Instance Methods
|
||||
|
||||
None defined.
|
||||
|
||||
## Static Methods
|
||||
|
||||
None defined.
|
||||
|
||||
## Relationships
|
||||
|
||||
- **References**: [[User]] (`sellerId`), [[Category]] (`categoryId`).
|
||||
- **Referenced by**: [[PurchaseRequest]] (`metadata.templateId` as string), [[Review]] (`subjectId` when `subjectType === 'template'`).
|
||||
|
||||
## State Transitions
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> active : created
|
||||
active --> inactive : seller toggles off
|
||||
inactive --> active : seller toggles on
|
||||
active --> expired : expiresAt passed
|
||||
active --> capped : usageCount == maxUsage
|
||||
expired --> [*]
|
||||
capped --> [*]
|
||||
```
|
||||
|
||||
> [!note] Soft state
|
||||
> Only `isActive` is persisted directly. `expired` and `capped` are derived at query time using `expiresAt` and `usageCount`.
|
||||
|
||||
## Common Queries
|
||||
|
||||
```ts
|
||||
// Seller's active templates
|
||||
RequestTemplate.find({ sellerId, isActive: true }).sort({ createdAt: -1 });
|
||||
|
||||
// Public template by slug
|
||||
RequestTemplate.findOne({ shareableLink: slug, isActive: true });
|
||||
|
||||
// Bump usage atomically
|
||||
RequestTemplate.findOneAndUpdate(
|
||||
{ _id, isActive: true, $or: [{ maxUsage: null }, { $expr: { $lt: ['$usageCount', '$maxUsage'] } }] },
|
||||
{ $inc: { usageCount: 1 } },
|
||||
{ new: true }
|
||||
);
|
||||
|
||||
// Cleanup expired
|
||||
RequestTemplate.find({ expiresAt: { $lt: new Date() }, isActive: true });
|
||||
```
|
||||
|
||||
Related: [[PurchaseRequest]], [[User]], [[Category]], [[Review]].
|
||||
95
02 - Data Models/Review.md
Normal file
95
02 - Data Models/Review.md
Normal file
@@ -0,0 +1,95 @@
|
||||
---
|
||||
title: Review
|
||||
tags: [data-model, mongoose]
|
||||
aliases: [Rating, IReview]
|
||||
---
|
||||
|
||||
# Review
|
||||
|
||||
Polymorphic 1-5 star review. The `subjectType` discriminator (`seller` or `template`) plus `subjectId` identifies what is being reviewed. `sellerId` is always present so per-seller aggregations work regardless of subject. A compound unique index on `(subjectType, subjectId, reviewerId)` prevents a reviewer from posting two reviews for the same subject.
|
||||
|
||||
> [!note] Source
|
||||
> `backend/src/models/Review.ts:19` — schema definition
|
||||
> `backend/src/models/Review.ts:38` — model export
|
||||
|
||||
## Schema
|
||||
|
||||
| Field | Type | Required | Default | Validation | Index | Description |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `subjectType` | String | yes | — | enum: `seller` / `template` | yes (compound) | Discriminator. |
|
||||
| `subjectId` | ObjectId | yes | — | — | yes (compound) | Id of the seller [[User]] or [[RequestTemplate]]. |
|
||||
| `sellerId` | ObjectId → [[User]] | yes | — | — | — | Seller associated with the review (always populated). |
|
||||
| `reviewerId` | ObjectId → [[User]] | yes | — | — | yes (compound + unique) | Author. |
|
||||
| `rating` | Number | yes | — | min 1, max 5 | — | Star rating. |
|
||||
| `comment` | String | no | `""` | maxlength 1000 | — | Free-form comment. |
|
||||
| `isVerifiedBuyer` | Boolean | no | `false` | — | — | Whether the reviewer actually bought from this seller. |
|
||||
| `purchaseRequestId` | ObjectId → [[PurchaseRequest]] | no | `null` | — | — | Source request (if any). |
|
||||
| `status` | String | no | `published` | enum: `published` / `pending` / `rejected` | — | Moderation status. |
|
||||
| `createdAt` | Date | auto | — | — | yes (compound, desc) | Mongoose timestamp. |
|
||||
| `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. |
|
||||
|
||||
## Virtuals
|
||||
|
||||
None defined.
|
||||
|
||||
## Indexes
|
||||
|
||||
Defined at `backend/src/models/Review.ts:34-36`:
|
||||
|
||||
- `{ subjectType: 1, subjectId: 1, createdAt: -1 }` — listing for a subject.
|
||||
- `{ reviewerId: 1, subjectType: 1 }` — reviewer history.
|
||||
- `{ subjectType: 1, subjectId: 1, reviewerId: 1 }` — **unique**, one review per reviewer per subject.
|
||||
|
||||
## Pre/Post Hooks
|
||||
|
||||
None declared.
|
||||
|
||||
## Instance Methods
|
||||
|
||||
None defined.
|
||||
|
||||
## Static Methods
|
||||
|
||||
None defined.
|
||||
|
||||
## Relationships
|
||||
|
||||
- **References**: [[User]] (`sellerId`, `reviewerId`, and `subjectId` when `subjectType === 'seller'`), [[RequestTemplate]] (`subjectId` when `subjectType === 'template'`), [[PurchaseRequest]] (`purchaseRequestId`).
|
||||
- **Referenced by**: none.
|
||||
|
||||
## State Transitions
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> published : default
|
||||
[*] --> pending : moderation required
|
||||
pending --> published : approved
|
||||
pending --> rejected : rejected
|
||||
published --> rejected : flagged
|
||||
rejected --> [*]
|
||||
```
|
||||
|
||||
## Common Queries
|
||||
|
||||
```ts
|
||||
// All reviews for a seller
|
||||
Review.find({ subjectType: 'seller', subjectId: sellerUserId, status: 'published' })
|
||||
.sort({ createdAt: -1 });
|
||||
|
||||
// Average rating per seller
|
||||
Review.aggregate([
|
||||
{ $match: { subjectType: 'seller', subjectId: sellerUserId, status: 'published' } },
|
||||
{ $group: { _id: null, avg: { $avg: '$rating' }, count: { $sum: 1 } } }
|
||||
]);
|
||||
|
||||
// Reviews written by a user
|
||||
Review.find({ reviewerId: userId }).sort({ createdAt: -1 });
|
||||
|
||||
// Reviews for a template
|
||||
Review.find({ subjectType: 'template', subjectId: templateId, status: 'published' });
|
||||
```
|
||||
|
||||
> [!warning] Duplicate prevention
|
||||
> Attempting to insert a second review for the same `(subjectType, subjectId, reviewerId)` will fail with a `E11000 duplicate key` error from MongoDB. Application code should treat that as "already reviewed".
|
||||
|
||||
Related: [[User]], [[RequestTemplate]], [[PurchaseRequest]].
|
||||
96
02 - Data Models/SellerOffer.md
Normal file
96
02 - Data Models/SellerOffer.md
Normal file
@@ -0,0 +1,96 @@
|
||||
---
|
||||
title: SellerOffer
|
||||
tags: [data-model, mongoose]
|
||||
aliases: [Seller Offer, Bid, ISellerOffer]
|
||||
---
|
||||
|
||||
# SellerOffer
|
||||
|
||||
A seller's bid against a [[PurchaseRequest]]. Stores the proposed price, the delivery time commitment, optional notes/attachments, and a small status machine (`pending` / `accepted` / `rejected` / `withdrawn`). The parent `PurchaseRequest` keeps the array of offer ids in `offers[]` and the chosen one in `selectedOfferId`.
|
||||
|
||||
> [!note] Source
|
||||
> `backend/src/models/SellerOffer.ts:24` — schema definition
|
||||
> `backend/src/models/SellerOffer.ts:100` — model export
|
||||
|
||||
## Schema
|
||||
|
||||
| Field | Type | Required | Default | Validation | Index | Description |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `sellerId` | ObjectId → [[User]] | yes | — | — | yes | Seller submitting the bid. |
|
||||
| `purchaseRequestId` | ObjectId → [[PurchaseRequest]] | yes | — | — | yes | Parent request. |
|
||||
| `title` | String | yes | — | trim, maxlength 200 | — | Offer headline. |
|
||||
| `description` | String | yes | — | trim, maxlength 1000 | — | Pitch and details. |
|
||||
| `price.amount` | Number | yes | — | min 0 | — | Quoted amount. |
|
||||
| `price.currency` | String | yes | `USDT` | enum: `USD` / `EUR` / `IRR` / `USDT` / `USDC` | — | Quote currency. |
|
||||
| `deliveryTime.amount` | Number | yes | — | min 1 | — | Numeric ETA. |
|
||||
| `deliveryTime.unit` | String | yes | — | enum: `hours` / `days` / `weeks` | — | ETA unit. |
|
||||
| `status` | String | no | `pending` | enum: `pending` / `accepted` / `rejected` / `withdrawn` | yes | Offer status. |
|
||||
| `attachments[]` | String[] | no | — | — | — | URLs of supporting files. |
|
||||
| `notes` | String | no | — | trim | — | Internal/private notes. |
|
||||
| `validUntil` | Date | no | — | — | — | Expiration. |
|
||||
| `createdAt` | Date | auto | — | — | yes (desc) | Mongoose timestamp. |
|
||||
| `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. |
|
||||
|
||||
## Virtuals
|
||||
|
||||
None defined.
|
||||
|
||||
## Indexes
|
||||
|
||||
Defined at `backend/src/models/SellerOffer.ts:95-98`:
|
||||
|
||||
- `{ sellerId: 1 }`
|
||||
- `{ purchaseRequestId: 1 }`
|
||||
- `{ status: 1 }`
|
||||
- `{ createdAt: -1 }`
|
||||
|
||||
## Pre/Post Hooks
|
||||
|
||||
None declared.
|
||||
|
||||
## Instance Methods
|
||||
|
||||
None defined.
|
||||
|
||||
## Static Methods
|
||||
|
||||
None defined.
|
||||
|
||||
## Relationships
|
||||
|
||||
- **References**: [[User]] (`sellerId`), [[PurchaseRequest]] (`purchaseRequestId`).
|
||||
- **Referenced by**: [[PurchaseRequest]] (`offers[]`, `selectedOfferId`), [[Payment]] (`sellerOfferId`), [[Chat]] (`relatedTo.id` when `relatedTo.type === 'SellerOffer'`).
|
||||
|
||||
## State Transitions
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> pending
|
||||
pending --> accepted : buyer accepts
|
||||
pending --> rejected : buyer rejects
|
||||
pending --> withdrawn : seller cancels
|
||||
accepted --> [*]
|
||||
rejected --> [*]
|
||||
withdrawn --> [*]
|
||||
```
|
||||
|
||||
## Common Queries
|
||||
|
||||
```ts
|
||||
// Offers for a request
|
||||
SellerOffer.find({ purchaseRequestId }).sort({ createdAt: -1 });
|
||||
|
||||
// Seller's active offers
|
||||
SellerOffer.find({ sellerId, status: 'pending' });
|
||||
|
||||
// Reject siblings on accept
|
||||
SellerOffer.updateMany(
|
||||
{ purchaseRequestId, _id: { $ne: acceptedId }, status: 'pending' },
|
||||
{ status: 'rejected' }
|
||||
);
|
||||
|
||||
// Cleanup expired offers
|
||||
SellerOffer.find({ validUntil: { $lt: new Date() }, status: 'pending' });
|
||||
```
|
||||
|
||||
Related: [[PurchaseRequest]], [[Payment]], [[User]].
|
||||
90
02 - Data Models/ShopSettings.md
Normal file
90
02 - Data Models/ShopSettings.md
Normal file
@@ -0,0 +1,90 @@
|
||||
---
|
||||
title: ShopSettings
|
||||
tags: [data-model, mongoose]
|
||||
aliases: [Shop, Storefront, IShopSettings]
|
||||
---
|
||||
|
||||
# ShopSettings
|
||||
|
||||
One-to-one storefront configuration for a seller. Holds the shop name, description, avatar, cover image, public visibility flag, review toggles (`allowSellerReviews`, `allowTemplateReviews`), and social links. The unique constraint on `sellerId` enforces the one-shop-per-seller invariant.
|
||||
|
||||
> [!note] Source
|
||||
> `backend/src/models/ShopSettings.ts:22` — schema definition
|
||||
> `backend/src/models/ShopSettings.ts:86` — model export
|
||||
|
||||
## Schema
|
||||
|
||||
| Field | Type | Required | Default | Validation | Index | Description |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `sellerId` | ObjectId → [[User]] | yes | — | — | unique | Owning seller (one shop per seller). |
|
||||
| `name` | String | yes | — | trim | — | Shop name. |
|
||||
| `description` | String | no | `""` | trim | — | Shop description. |
|
||||
| `avatar` | String | no | `""` | — | — | Avatar URL. |
|
||||
| `coverImage` | String | no | `""` | — | — | Cover image URL. |
|
||||
| `isPublic` | Boolean | no | `true` | — | — | Public visibility flag. |
|
||||
| `allowSellerReviews` | Boolean | no | `true` | — | — | Whether buyers can review the seller. |
|
||||
| `allowTemplateReviews` | Boolean | no | `true` | — | — | Whether buyers can review templates. |
|
||||
| `socialLinks.facebook` | String | no | `""` | — | — | Facebook URL. |
|
||||
| `socialLinks.instagram` | String | no | `""` | — | — | Instagram URL. |
|
||||
| `socialLinks.linkedin` | String | no | `""` | — | — | LinkedIn URL. |
|
||||
| `socialLinks.twitter` | String | no | `""` | — | — | Twitter / X URL. |
|
||||
| `createdAt` | Date | auto | — | — | — | Mongoose timestamp. |
|
||||
| `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. |
|
||||
|
||||
## Virtuals
|
||||
|
||||
None defined.
|
||||
|
||||
## Indexes
|
||||
|
||||
- Implicit unique index on `sellerId` (from `unique: true`). No additional indexes are declared (see comment at `backend/src/models/ShopSettings.ts:84`).
|
||||
|
||||
## Pre/Post Hooks
|
||||
|
||||
None declared.
|
||||
|
||||
## Instance Methods
|
||||
|
||||
None defined.
|
||||
|
||||
## Static Methods
|
||||
|
||||
None defined.
|
||||
|
||||
## Relationships
|
||||
|
||||
- **References**: [[User]] (`sellerId`).
|
||||
- **Referenced by**: none. [[Review]] toggles for the seller are read from here.
|
||||
|
||||
## State Transitions
|
||||
|
||||
No status field. The `isPublic` boolean is the only visibility control:
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> public
|
||||
public --> private : seller toggles off
|
||||
private --> public : seller toggles on
|
||||
```
|
||||
|
||||
## Common Queries
|
||||
|
||||
```ts
|
||||
// Fetch the seller's shop
|
||||
ShopSettings.findOne({ sellerId });
|
||||
|
||||
// Upsert on first save
|
||||
ShopSettings.findOneAndUpdate(
|
||||
{ sellerId },
|
||||
{ $set: { name, description, ... } },
|
||||
{ upsert: true, new: true }
|
||||
);
|
||||
|
||||
// Public shop directory
|
||||
ShopSettings.find({ isPublic: true }).sort({ createdAt: -1 });
|
||||
```
|
||||
|
||||
> [!warning] Creating two shops will fail
|
||||
> Inserting a second `ShopSettings` document with the same `sellerId` will fail with `E11000 duplicate key`. Application code should always use `findOneAndUpdate` with `upsert: true`.
|
||||
|
||||
Related: [[User]], [[Review]], [[RequestTemplate]].
|
||||
97
02 - Data Models/TempVerification.md
Normal file
97
02 - Data Models/TempVerification.md
Normal file
@@ -0,0 +1,97 @@
|
||||
---
|
||||
title: TempVerification
|
||||
tags: [data-model, mongoose]
|
||||
aliases: [Temp Verification, Pending Signup, ITempVerification]
|
||||
---
|
||||
|
||||
# TempVerification
|
||||
|
||||
Short-lived holding collection for unverified signups. When a user begins registration the candidate data (email, hashed password, first name, last name, role, optional referral code) is saved here together with a verification OTP and its expiry. After the OTP is confirmed the row is promoted to a real [[User]] document and removed. Rows that are never confirmed self-destruct via a TTL index keyed on `emailVerificationCodeExpires`.
|
||||
|
||||
> [!note] Source
|
||||
> `backend/src/models/TempVerification.ts:16` — schema definition
|
||||
> `backend/src/models/TempVerification.ts:67` — model export
|
||||
|
||||
## Schema
|
||||
|
||||
| Field | Type | Required | Default | Validation | Index | Description |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `email` | String | yes | — | lowercase, trim | unique | Candidate email. |
|
||||
| `password` | String | no | `""` | — | — | Hashed password (optional for passkey-only flows). |
|
||||
| `firstName` | String | yes | — | trim | — | First name. |
|
||||
| `lastName` | String | yes | — | trim | — | Last name. |
|
||||
| `role` | String | no | `buyer` | enum: `buyer` / `seller` | — | Requested role. |
|
||||
| `referralCode` | String | no | — | trim | — | Inviter's referral code. |
|
||||
| `emailVerificationCode` | String | yes | — | — | — | OTP. |
|
||||
| `emailVerificationCodeExpires` | Date | yes | — | — | TTL (`expireAfterSeconds: 0`) | OTP expiry. |
|
||||
| `createdAt` | Date | auto | — | — | — | Mongoose timestamp. |
|
||||
| `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. |
|
||||
|
||||
## Virtuals
|
||||
|
||||
None defined.
|
||||
|
||||
## Indexes
|
||||
|
||||
- Implicit unique index on `email`.
|
||||
- `{ emailVerificationCodeExpires: 1 }` with `expireAfterSeconds: 0` — `backend/src/models/TempVerification.ts:65`. MongoDB removes the document automatically once `emailVerificationCodeExpires` passes.
|
||||
|
||||
> [!note] TTL semantics
|
||||
> `expireAfterSeconds: 0` together with the indexed date field means "delete this document as soon as the date in `emailVerificationCodeExpires` is reached".
|
||||
|
||||
## Pre/Post Hooks
|
||||
|
||||
None declared.
|
||||
|
||||
## Instance Methods
|
||||
|
||||
None defined.
|
||||
|
||||
## Static Methods
|
||||
|
||||
None defined.
|
||||
|
||||
## Relationships
|
||||
|
||||
- **References**: none.
|
||||
- **Referenced by**: none. Promotes into a [[User]] document on successful verification.
|
||||
|
||||
## State Transitions
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> pending : signup started
|
||||
pending --> verified : code confirmed
|
||||
pending --> expired : TTL purge
|
||||
verified --> [*] : promoted to User
|
||||
expired --> [*]
|
||||
```
|
||||
|
||||
> [!warning] No persistent "verified" state
|
||||
> A `TempVerification` row only ever exists in the `pending` state from the database's point of view. Once verified the row is deleted (and a [[User]] row is created); if not verified it is purged by the TTL index.
|
||||
|
||||
## Common Queries
|
||||
|
||||
```ts
|
||||
// Look up by email during signup
|
||||
TempVerification.findOne({ email: email.toLowerCase() });
|
||||
|
||||
// Validate OTP
|
||||
TempVerification.findOne({
|
||||
email: email.toLowerCase(),
|
||||
emailVerificationCode: code,
|
||||
emailVerificationCodeExpires: { $gt: new Date() },
|
||||
});
|
||||
|
||||
// Replace stale row on a re-attempt
|
||||
TempVerification.findOneAndUpdate(
|
||||
{ email },
|
||||
{ $set: { emailVerificationCode, emailVerificationCodeExpires, ... } },
|
||||
{ upsert: true, new: true }
|
||||
);
|
||||
|
||||
// Delete after promotion
|
||||
TempVerification.deleteOne({ email });
|
||||
```
|
||||
|
||||
Related: [[User]].
|
||||
137
02 - Data Models/User.md
Normal file
137
02 - Data Models/User.md
Normal file
@@ -0,0 +1,137 @@
|
||||
---
|
||||
title: User
|
||||
tags: [data-model, mongoose]
|
||||
aliases: [User Model, IUser, Account]
|
||||
---
|
||||
|
||||
# User
|
||||
|
||||
The core identity document for every actor in the marketplace: buyers, sellers, and admins. Stores credentials (password + WebAuthn passkeys), profile/preference data, referral bookkeeping, point balances, and a soft-delete status flag. Almost every other model carries an `ObjectId` reference back to `User`, so this collection is the relational hub of the system.
|
||||
|
||||
> [!note] Source
|
||||
> `backend/src/models/User.ts:70` — schema definition
|
||||
> `backend/src/models/User.ts:257` — model export
|
||||
|
||||
## Schema
|
||||
|
||||
| Field | Type | Required | Default | Validation | Index | Description |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `email` | String | yes | — | lowercase, trim | unique | Primary login identifier. |
|
||||
| `password` | String | no | — | minlength 6 | — | Hashed password. Optional to support passkey-only accounts. |
|
||||
| `firstName` | String | no | `"کاربر"` | trim | — | Persian default ("user"). |
|
||||
| `lastName` | String | no | `"جدید"` | trim | — | Persian default ("new"). |
|
||||
| `role` | String | yes | `"buyer"` | enum: `admin` / `buyer` / `seller` | yes | Authorisation tier. |
|
||||
| `isEmailVerified` | Boolean | no | `false` | — | — | Set to true after [[TempVerification]] is consumed. |
|
||||
| `emailVerificationToken` | String | no | — | — | — | Legacy token-based email verification. |
|
||||
| `emailVerificationCode` | String | no | — | — | — | OTP code for email verification. |
|
||||
| `emailVerificationCodeExpires` | Date | no | — | — | — | Expiry for `emailVerificationCode`. |
|
||||
| `passwordResetToken` | String | no | — | — | — | Token for reset link flow. |
|
||||
| `passwordResetExpires` | Date | no | — | — | — | Expiry of `passwordResetToken`. |
|
||||
| `passwordResetCode` | String | no | — | — | — | OTP reset code. |
|
||||
| `passwordResetCodeExpires` | Date | no | — | — | — | Expiry for OTP reset code. |
|
||||
| `passkeys[]` | Subdocument array | no | `[]` | — | — | WebAuthn credentials (see below). |
|
||||
| `passkeys[].id` | String | yes | — | — | — | Credential ID. |
|
||||
| `passkeys[].publicKey` | String | yes | — | — | — | Stored public key. |
|
||||
| `passkeys[].counter` | Number | yes | `0` | — | — | Signature counter. |
|
||||
| `passkeys[].deviceType` | String | yes | — | enum: `platform` / `cross-platform` | — | Authenticator class. |
|
||||
| `passkeys[].deviceName` | String | no | — | — | — | Optional human label. |
|
||||
| `passkeys[].createdAt` | Date | no | `Date.now` | — | — | Registration timestamp. |
|
||||
| `profile.avatar` | String | no | — | — | — | Avatar URL. |
|
||||
| `profile.photoURL` | String | no | — | — | — | Alternative photo URL. |
|
||||
| `profile.phone` | String | no | — | — | — | Contact phone. |
|
||||
| `profile.address.street` | String | no | — | — | — | Inline address (separate from [[Address]] book). |
|
||||
| `profile.address.city` | String | no | — | — | — | — |
|
||||
| `profile.address.state` | String | no | — | — | — | — |
|
||||
| `profile.address.zipCode` | String | no | — | — | — | — |
|
||||
| `profile.address.country` | String | no | — | — | — | — |
|
||||
| `profile.bio` | String | no | — | — | — | Free-form bio. |
|
||||
| `profile.website` | String | no | — | — | — | Personal website URL. |
|
||||
| `profile.walletAddress` | String | no | — | — | — | On-chain wallet address. |
|
||||
| `profile.isPublic` | Boolean | no | `false` | — | — | Whether the profile is publicly visible. |
|
||||
| `preferences.language` | String | no | `"en"` | — | — | UI language. |
|
||||
| `preferences.currency` | String | no | `"USD"` | — | — | Display currency. |
|
||||
| `preferences.notifications.email` | Boolean | no | `true` | — | — | Opt-in for email notifications. |
|
||||
| `preferences.notifications.sms` | Boolean | no | `false` | — | — | Opt-in for SMS notifications. |
|
||||
| `preferences.notifications.push` | Boolean | no | `true` | — | — | Opt-in for push notifications. |
|
||||
| `status` | String | no | `"active"` | enum: `active` / `suspended` / `deleted` | yes | Soft-delete and moderation flag. |
|
||||
| `lastLoginAt` | Date | no | — | — | — | Updated by auth middleware. |
|
||||
| `refreshTokens[]` | String[] | no | `[]` | — | — | Outstanding JWT refresh tokens. |
|
||||
| `referralCode` | String | no | — | — | unique, sparse | Personal invite code. |
|
||||
| `referredBy` | ObjectId → User | no | — | — | yes | Who invited this user. |
|
||||
| `points.total` | Number | no | `0` | — | — | Lifetime points earned. |
|
||||
| `points.available` | Number | no | `0` | — | — | Currently spendable. |
|
||||
| `points.used` | Number | no | `0` | — | — | Cumulative spent. |
|
||||
| `points.level` | Number | no | `1` | — | yes (`points.level`) | Resolved against [[LevelConfig]]. |
|
||||
| `referralStats.totalReferrals` | Number | no | `0` | — | — | Count of invited users. |
|
||||
| `referralStats.activeReferrals` | Number | no | `0` | — | — | Subset that became active buyers. |
|
||||
| `referralStats.totalEarned` | Number | no | `0` | — | — | Cumulative reward earnings. |
|
||||
| `createdAt` | Date | auto | — | — | — | Mongoose timestamp. |
|
||||
| `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. |
|
||||
|
||||
## Virtuals
|
||||
|
||||
| Virtual | Returns | Definition |
|
||||
| --- | --- | --- |
|
||||
| `fullName` | `${firstName} ${lastName}` | `backend/src/models/User.ts:238` |
|
||||
|
||||
## Indexes
|
||||
|
||||
Defined explicitly (in addition to the implicit `email` unique index):
|
||||
|
||||
- `{ role: 1 }` — `backend/src/models/User.ts:231`
|
||||
- `{ status: 1 }` — `backend/src/models/User.ts:232`
|
||||
- `{ referralCode: 1 }` — `backend/src/models/User.ts:233`
|
||||
- `{ referredBy: 1 }` — `backend/src/models/User.ts:234`
|
||||
- `{ 'points.level': 1 }` — `backend/src/models/User.ts:235`
|
||||
|
||||
## Pre/Post Hooks
|
||||
|
||||
None declared at the schema level.
|
||||
|
||||
## Instance Methods
|
||||
|
||||
| Signature | Purpose |
|
||||
| --- | --- |
|
||||
| `toJSON(): object` | Strips `password`, `refreshTokens`, all `emailVerification*` and `passwordReset*` fields before serialisation. Defined at `backend/src/models/User.ts:243`. |
|
||||
|
||||
## Static Methods
|
||||
|
||||
None defined on the schema.
|
||||
|
||||
## Relationships
|
||||
|
||||
- **References**: [[User]] (self, via `referredBy`).
|
||||
- **Referenced by**: [[PurchaseRequest]] (`buyerId`, `preferredSellerIds`, `deliveryInfo.deliveryCodeUsedBy`, `deliveryInfo.deliveryAttempts[].sellerId`), [[SellerOffer]] (`sellerId`), [[Payment]] (`buyerId`, `sellerId`), [[Chat]] (`participants[].userId`, `messages[].senderId`, `metadata.createdBy`), [[Notification]] (`userId` as string), [[RequestTemplate]] (`sellerId`), [[Dispute]] (`buyerId`, `sellerId`, `adminId`), [[BlogPost]] (`author.id`), [[Address]] (`userId`), [[Review]] (`sellerId`, `reviewerId`), [[PointTransaction]] (`user`, `referredUser`), [[ShopSettings]] (`sellerId`).
|
||||
|
||||
## State Transitions
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> active : signup verified
|
||||
active --> suspended : admin action
|
||||
suspended --> active : admin restore
|
||||
active --> deleted : self-delete
|
||||
suspended --> deleted : admin purge
|
||||
deleted --> [*]
|
||||
```
|
||||
|
||||
## Common Queries
|
||||
|
||||
```ts
|
||||
// Find by email (login)
|
||||
User.findOne({ email: email.toLowerCase() });
|
||||
|
||||
// Active sellers
|
||||
User.find({ role: 'seller', status: 'active' });
|
||||
|
||||
// Validate referral
|
||||
User.findOne({ referralCode: code });
|
||||
|
||||
// Leaderboard by points
|
||||
User.find({ status: 'active' }).sort({ 'points.total': -1 }).limit(10);
|
||||
|
||||
// Promote level
|
||||
User.updateOne({ _id: id }, { $set: { 'points.level': newLevel } });
|
||||
```
|
||||
|
||||
Related: [[TempVerification]], [[LevelConfig]], [[PointTransaction]], [[ShopSettings]].
|
||||
118
03 - API Reference/AI API.md
Normal file
118
03 - API Reference/AI API.md
Normal file
@@ -0,0 +1,118 @@
|
||||
---
|
||||
title: AI API
|
||||
tags: [api, ai, reference]
|
||||
---
|
||||
|
||||
# AI API
|
||||
|
||||
Endpoints live under `/api/ai/*`. The router is [`backend/src/services/ai/aiRoutes.ts`](../../backend/src/services/ai/aiRoutes.ts) and delegates to [`aiController`](../../backend/src/services/ai/aiController.ts), which wraps the OpenAI SDK.
|
||||
|
||||
The AI service is intentionally generic — there are no resource-specific endpoints (no "generate product description" route). Instead, four general-purpose endpoints (`generate`, `analyze`, `translate`, `assist`) accept a `purpose` field that the controller maps to a system prompt template. The frontend uses these for:
|
||||
|
||||
- Product description generation (purpose: `description`)
|
||||
- Content moderation (purpose: `moderation` on `/analyze`)
|
||||
- Product recommendations (purpose: `recommend` on `/generate`)
|
||||
- In-app helper (`/assist`)
|
||||
|
||||
> Note: the router currently exposes these endpoints **without** `authenticateToken`. Front-end usage assumes the user is logged in. If you need to add auth, do it in the router. OpenAI cost-protection and rate-limiting are handled inside `aiController` via the Redis-backed `rateLimitService`.
|
||||
|
||||
## Endpoints
|
||||
|
||||
### POST /api/ai/generate
|
||||
|
||||
**Description:** Generate free-form text. Used for product descriptions, marketing copy, recommendation rationales.
|
||||
**Auth required:** No (intended for authenticated frontends)
|
||||
**Request body:**
|
||||
```ts
|
||||
{
|
||||
prompt: string;
|
||||
purpose?: "description" | "recommend" | "summary" | "generic"; // default "generic"
|
||||
context?: Record<string, unknown>; // injected into the system prompt
|
||||
maxTokens?: number; // default 512, capped server-side
|
||||
temperature?: number; // 0..2, default 0.7
|
||||
language?: "en" | "fa" | "ar"; // default "en"
|
||||
}
|
||||
```
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"text": "...",
|
||||
"model": "gpt-4o-mini",
|
||||
"tokensUsed": { "prompt": 120, "completion": 340, "total": 460 }
|
||||
}
|
||||
}
|
||||
```
|
||||
**Errors:** `400` invalid params, `429` quota exceeded, `502` upstream OpenAI error.
|
||||
|
||||
### POST /api/ai/analyze
|
||||
|
||||
**Description:** Analyze a piece of text. Used for moderation (flag offensive content), sentiment, keyword extraction.
|
||||
**Auth required:** No
|
||||
**Request body:**
|
||||
```ts
|
||||
{
|
||||
text: string;
|
||||
purpose?: "moderation" | "sentiment" | "keywords" | "summary"; // default "moderation"
|
||||
language?: "en" | "fa" | "ar";
|
||||
}
|
||||
```
|
||||
**Response 200 (moderation example):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"purpose": "moderation",
|
||||
"flagged": false,
|
||||
"categories": { "hate": 0.001, "harassment": 0.0 },
|
||||
"suggestion": null
|
||||
}
|
||||
}
|
||||
```
|
||||
**Response 200 (sentiment example):**
|
||||
```json
|
||||
{ "success": true, "data": { "purpose": "sentiment", "score": 0.62, "label": "positive" } }
|
||||
```
|
||||
|
||||
### POST /api/ai/translate
|
||||
|
||||
**Description:** Translate `text` between languages.
|
||||
**Auth required:** No
|
||||
**Request body:**
|
||||
```ts
|
||||
{
|
||||
text: string;
|
||||
from?: "en" | "fa" | "ar" | "auto"; // default "auto"
|
||||
to: "en" | "fa" | "ar";
|
||||
}
|
||||
```
|
||||
**Response 200:** `{ success, data: { translated: "...", detectedFrom: "en" } }`
|
||||
|
||||
### POST /api/ai/assist
|
||||
|
||||
**Description:** In-app conversational assistant. Maintains short-term context per call (the client passes prior turns explicitly).
|
||||
**Auth required:** No
|
||||
**Request body:**
|
||||
```ts
|
||||
{
|
||||
messages: Array<{ role: "user" | "assistant" | "system"; content: string }>;
|
||||
purpose?: "help" | "search" | "navigate"; // tunes the system prompt
|
||||
context?: { userId?: string; route?: string; locale?: string };
|
||||
}
|
||||
```
|
||||
**Response 200:** `{ success, data: { reply: { role: "assistant", content: "..." }, tokensUsed } }`
|
||||
|
||||
## Configuration
|
||||
|
||||
The OpenAI client is configured via env:
|
||||
|
||||
- `OPENAI_API_KEY` (required)
|
||||
- `OPENAI_MODEL` (default `gpt-4o-mini`)
|
||||
- `OPENAI_BASE_URL` (override for self-hosted proxies)
|
||||
|
||||
## Related
|
||||
|
||||
- [[AI Integration]]
|
||||
- [[Moderation Pipeline]]
|
||||
- [[Rate Limiting]]
|
||||
173
03 - API Reference/API Overview.md
Normal file
173
03 - API Reference/API Overview.md
Normal file
@@ -0,0 +1,173 @@
|
||||
---
|
||||
title: API Overview
|
||||
tags: [api, reference, overview]
|
||||
---
|
||||
|
||||
# API Overview
|
||||
|
||||
The AMN backend is an Express.js + TypeScript service that exposes a REST API plus a Socket.IO real-time channel. Every HTTP endpoint is mounted under `/api/<service>/...` in [`backend/src/app.ts`](../../backend/src/app.ts) and follows a consistent response envelope, authentication scheme, and error model.
|
||||
|
||||
This page is the entry point for the API. See the individual service pages for endpoint details:
|
||||
|
||||
- [[Authentication API]] - register/login/passkeys/Google OAuth
|
||||
- [[User API]] - profile, wallet, admin user management
|
||||
- [[Marketplace API]] - purchase requests, seller offers, templates, shop, reviews
|
||||
- [[Payment API]] - SHKeeper, Web3, DePay, payouts
|
||||
- [[Chat API]] - conversations and messages
|
||||
- [[Notification API]] - in-app notifications
|
||||
- [[Dispute API]] - dispute resolution
|
||||
- [[Blog API]] - blog posts (admin-managed)
|
||||
- [[Admin API]] - user management, data cleanup, manual ops
|
||||
- [[Points API]] - loyalty points, levels, referrals
|
||||
- [[AI API]] - OpenAI-backed text endpoints
|
||||
- [[File API]] - upload, delete, serve
|
||||
- [[Socket Events]] - real-time events
|
||||
- [[Error Codes]] - status mapping and error shape
|
||||
|
||||
## Base URLs
|
||||
|
||||
| Environment | Base URL |
|
||||
| --- | --- |
|
||||
| Local development | `http://localhost:5001/api` |
|
||||
| Staging / dev | `https://dev.amn.gg/api` |
|
||||
| Production | `https://amn.gg/api` |
|
||||
|
||||
The base port is set via `PORT` env var; in `development` it defaults to `5001`. CORS is restricted to `process.env.FRONTEND_URL` and credentials are allowed (`cors({ origin, credentials: true })` in `app.ts`).
|
||||
|
||||
Health check (not under `/api`): `GET /health` → `{ success, message, timestamp, environment, version }`.
|
||||
|
||||
API discovery endpoint: `GET /api` → returns a map of available service prefixes.
|
||||
|
||||
## Versioning
|
||||
|
||||
The API is currently un-versioned (there is no `/v1` in paths). Breaking changes are communicated via release notes in the repository. The version string is exposed on `/health` and is sourced from `package.json` (`process.env.npm_package_version`). At the time of writing the deployed version is in the `4.x` line.
|
||||
|
||||
## Authentication
|
||||
|
||||
All protected endpoints use a stateless JWT in the standard HTTP Authorization header:
|
||||
|
||||
```
|
||||
Authorization: Bearer <accessToken>
|
||||
```
|
||||
|
||||
The token is verified by `authenticateToken` in [`backend/src/shared/middleware/auth.ts`](../../backend/src/shared/middleware/auth.ts) using `config.jwtSecret`. The decoded payload is normalised into `req.user = { id, email, role }` regardless of which key carried it (`id`, `userId`, `_id`, `sub`).
|
||||
|
||||
Tokens are issued by `POST /api/auth/login`, `POST /api/auth/google/signin`, `POST /api/auth/google/signup`, `POST /api/auth/passkey/authenticate`, and `POST /api/auth/refresh-token`. Refresh tokens are stored on the [[User]] document (`refreshTokens` array) so an admin password change wipes them and forces re-login.
|
||||
|
||||
Role-based access uses `authorizeRoles('admin', ...)` after `authenticateToken`. Three roles exist: `buyer`, `seller`, `admin`.
|
||||
|
||||
WebAuthn / Passkey flows live under `/api/auth/passkey/*` and exchange a challenge for an assertion; on success a regular JWT pair is returned (`tokens.accessToken`, `tokens.refreshToken`). See [[Authentication Flow]] for the full lifecycle.
|
||||
|
||||
## Standard response envelope
|
||||
|
||||
The canonical helper is `ResponseHandler` in [`backend/src/shared/utils/response-handler.ts`](../../backend/src/shared/utils/response-handler.ts). Successful responses look like:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Success",
|
||||
"data": { /* payload */ },
|
||||
"statusCode": 200,
|
||||
"timestamp": "2026-05-23T10:00:00.000Z",
|
||||
"path": "/api/marketplace/purchase-requests",
|
||||
"method": "GET",
|
||||
"meta": { /* optional */ }
|
||||
}
|
||||
```
|
||||
|
||||
Paginated responses add a `pagination` block:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [ /* items */ ],
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"limit": 20,
|
||||
"total": 137,
|
||||
"totalPages": 7,
|
||||
"hasMore": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Error responses use the same envelope with `success: false`:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "Validation failed",
|
||||
"error": "Validation Error",
|
||||
"statusCode": 400,
|
||||
"timestamp": "2026-05-23T10:00:00.000Z",
|
||||
"path": "/api/auth/register",
|
||||
"method": "POST",
|
||||
"data": [ /* validation details */ ]
|
||||
}
|
||||
```
|
||||
|
||||
Caveat: not every endpoint uses `ResponseHandler`. Legacy routes (mainly under `/api/users`, `/api/marketplace` legacy, `/api/payment/decentralized`, parts of `/api/payment/shkeeper`) return ad-hoc shapes such as `{ error: string }` on failure or bare objects on success. When in doubt consult the per-endpoint response section in the service pages. The error middleware in [`shared/middleware/errorHandler.ts`](../../backend/src/shared/middleware/errorHandler.ts) covers uncaught exceptions and emits `{ success: false, error, statusCode, stack? }`.
|
||||
|
||||
## Pagination conventions
|
||||
|
||||
Most list endpoints accept `?page=<n>&limit=<n>` query params:
|
||||
|
||||
- `page` defaults to `1`, 1-based.
|
||||
- `limit` defaults vary (20 for notifications/marketplace, 50 for user lists) and is capped (e.g. 100 for templates).
|
||||
- Server computes `total`, `totalPages`, and `hasMore` and returns them under `pagination`.
|
||||
- Some endpoints also accept `offset` (e.g. payment lists) as a raw skip count.
|
||||
|
||||
Sort parameters: list endpoints commonly accept `sortBy` (default `createdAt`) and `sortOrder` (`asc` | `desc`, default `desc`).
|
||||
|
||||
## Common query params
|
||||
|
||||
| Parameter | Used by | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `search` | users, templates, posts | Case-insensitive regex over title/name/email fields |
|
||||
| `status` | payments, requests, disputes, notifications | Filter by entity state |
|
||||
| `role` | users, contacts | Filter by `buyer`/`seller`/`admin` |
|
||||
| `isActive` | users, templates | Boolean filter |
|
||||
| `categoryId` | templates, requests | MongoId category filter |
|
||||
| `unreadOnly` | notifications | Only unread items |
|
||||
| `format` | payment export | `json` (default) or `csv` |
|
||||
|
||||
## Rate limiting
|
||||
|
||||
Rate limiting is currently **disabled** in the deployed code (`app.ts` logs `🔓 Rate limiting COMPLETELY DISABLED for personal use`). The intended policy when re-enabled is **100 requests per 15-minute window per IP**, applied per `/api/*` route. The Express `trust proxy` setting is enabled in production so the real client IP is read from `X-Forwarded-For` (Nginx terminator).
|
||||
|
||||
Redis-backed rate limiting helpers exist in `src/services/redis/rateLimitService.ts` and are used by sensitive auth flows (password reset, email verification) even with the global limiter off.
|
||||
|
||||
## CORS
|
||||
|
||||
CORS is configured globally in `app.ts`:
|
||||
|
||||
```ts
|
||||
cors({
|
||||
origin: process.env.FRONTEND_URL,
|
||||
credentials: true,
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
|
||||
allowedHeaders: ["Content-Type", "Authorization", "X-Requested-With"],
|
||||
})
|
||||
```
|
||||
|
||||
Only the configured `FRONTEND_URL` may make cross-origin requests with credentials. The SHKeeper configuration endpoint (`GET /api/payment/shkeeper/config`) overrides this with `Access-Control-Allow-Origin: *` because it is consumed by the SHKeeper payment widget hosted on another domain.
|
||||
|
||||
Uploaded files served from `/uploads/*` use `helmet({ crossOriginResourcePolicy: { policy: "cross-origin" } })` so they can be embedded from the frontend domain.
|
||||
|
||||
## Real-time channel
|
||||
|
||||
Socket.IO runs on the same HTTP server. The frontend should connect to the API origin with credentials. Clients join rooms via `join-user-room`, `join-request-room`, `join-seller-room`, `join-buyer-room`, `join-chat-room`. Full catalog: [[Socket Events]].
|
||||
|
||||
## Standard HTTP status codes
|
||||
|
||||
- `200 OK` - success
|
||||
- `201 Created` - resource created
|
||||
- `400 Bad Request` - validation error
|
||||
- `401 Unauthorized` - missing/invalid token
|
||||
- `403 Forbidden` - role/ownership failure
|
||||
- `404 Not Found` - resource or route missing
|
||||
- `409 Conflict` - duplicate (email, review)
|
||||
- `423 Locked` - account locked (auth flows)
|
||||
- `500 Internal Server Error` - unhandled exception
|
||||
|
||||
See [[Error Codes]] for the app-specific codes returned in the `error` field.
|
||||
143
03 - API Reference/Admin API.md
Normal file
143
03 - API Reference/Admin API.md
Normal file
@@ -0,0 +1,143 @@
|
||||
---
|
||||
title: Admin API
|
||||
tags: [api, admin, reference]
|
||||
---
|
||||
|
||||
# Admin API
|
||||
|
||||
There is no single `/api/admin` namespace — admin-only endpoints are scattered across the service routers. This page catalogs them in one place. All require `Bearer JWT` with `req.user.role === 'admin'`. The two enforcement patterns are:
|
||||
|
||||
- Middleware: `authorizeRoles('admin')` after `authenticateToken` (used by the dispute, data-cleanup, blog routers).
|
||||
- Inline check inside the handler: `if (req.user.role !== 'admin') return 403` (used by user, points, payment routes).
|
||||
|
||||
## User management
|
||||
|
||||
See full descriptions in [[User API]].
|
||||
|
||||
| Endpoint | Action |
|
||||
| --- | --- |
|
||||
| `POST /api/user/admin/create` | Create user with role/status |
|
||||
| `DELETE /api/user/admin/:userId` | Delete user (admins cannot delete each other) |
|
||||
| `PATCH /api/user/admin/:userId/status` | Activate / suspend |
|
||||
| `PATCH /api/user/admin/:userId/toggle-status` | Flip active flag |
|
||||
| `PATCH /api/user/admin/:userId/role` | Change role |
|
||||
| `GET /api/user/admin/list` | Paginated directory + stats |
|
||||
| `GET /api/user/admin/:userId/dependencies` | Pre-delete dependency check |
|
||||
| `GET /api/users/admin/stats` | Aggregate user analytics |
|
||||
| `GET /api/users/admin/:userId` | Full user detail (admin view) |
|
||||
| `PUT /api/users/admin/:userId` | Mass update user |
|
||||
| `PUT /api/users/admin/update/:email` | Mass update by email |
|
||||
| `PATCH /api/users/admin/:userId/password` | Force password reset (wipes refresh tokens) |
|
||||
| `POST /api/users/admin/:userId/resend-verification` | Resend verification email |
|
||||
|
||||
## Listing / marketplace moderation
|
||||
|
||||
See [[Marketplace API]]. Admins can use most marketplace endpoints with elevated privileges (e.g. delete any purchase request, override offer status). Specific admin-only actions:
|
||||
|
||||
| Endpoint | Action |
|
||||
| --- | --- |
|
||||
| `PUT /api/marketplace/offers/:id/status` | Direct status mutation including admin overrides |
|
||||
| `POST /api/marketplace/purchase-requests/:id/release-payment` | Force escrow release |
|
||||
| `PATCH /api/marketplace/purchase-requests/:id/status` (any → any) | Override request state machine |
|
||||
|
||||
Template approval is implicit: admins use the same template CRUD endpoints with override privileges.
|
||||
|
||||
## Dispute mediation
|
||||
|
||||
See [[Dispute API]].
|
||||
|
||||
| Endpoint | Action |
|
||||
| --- | --- |
|
||||
| `POST /api/disputes/:id/assign` | Assign moderator |
|
||||
| `PATCH /api/disputes/:id/status` | Update status |
|
||||
| `POST /api/disputes/:id/resolve` | Final decision (buyer / seller / split) |
|
||||
| `GET /api/disputes/statistics` | Admin dashboard data |
|
||||
|
||||
## Manual payment operations
|
||||
|
||||
See [[Payment API]].
|
||||
|
||||
| Endpoint | Action |
|
||||
| --- | --- |
|
||||
| `POST /api/payment/payments/cleanup-pending` | Delete stale pending payments |
|
||||
| `POST /api/payment/payments/:id/fetch-tx` | Re-query chain for missing tx hash |
|
||||
| `POST /api/payment/payments/auto-fetch-missing` | Batch tx-hash backfill |
|
||||
| `POST /api/payment/shkeeper/:id/release` | Build escrow-release tx |
|
||||
| `POST /api/payment/shkeeper/:id/release/confirm` | Confirm release tx hash |
|
||||
| `POST /api/payment/shkeeper/:id/refund` | Build refund tx |
|
||||
| `POST /api/payment/shkeeper/:id/refund/confirm` | Confirm refund tx hash |
|
||||
| `POST /api/payment/shkeeper/payout` | Create payout task |
|
||||
| `GET /api/payment/shkeeper/webhook-stats` | Webhook telemetry |
|
||||
| `POST /api/payment/decentralized/admin-payout` | Direct admin-wallet payout |
|
||||
|
||||
## Points (admin)
|
||||
|
||||
See [[Points API]].
|
||||
|
||||
| Endpoint | Action |
|
||||
| --- | --- |
|
||||
| `POST /api/points/admin/add` | Manually grant / deduct points for a user |
|
||||
|
||||
## Data cleanup
|
||||
|
||||
Router: [`backend/src/services/admin/dataCleanupRoutes.ts`](../../backend/src/services/admin/dataCleanupRoutes.ts). Mounted under `/api/admin/cleanup/*`. The router applies `authenticateToken` + `authorizeRoles('admin')` to every endpoint.
|
||||
|
||||
### GET /api/admin/cleanup/stats
|
||||
|
||||
**Description:** Per-collection document counts and sizes.
|
||||
**Response 200:** `{ success, data: { collections: [{ name, count, sizeBytes }] } }`
|
||||
|
||||
### GET /api/admin/cleanup/collections
|
||||
|
||||
**Description:** List collections that can be cleaned and the supported flags.
|
||||
**Response 200:** `{ success, data: { collections, options } }`
|
||||
|
||||
### POST /api/admin/cleanup/clean
|
||||
|
||||
**Description:** Bulk delete records. Defaults to `dryRun: true` and `keepAdmins: true`.
|
||||
**Request body:**
|
||||
```ts
|
||||
{
|
||||
collections?: string[]; // default ["all"]
|
||||
dryRun?: boolean; // default true
|
||||
keepAdmins?: boolean; // default true
|
||||
olderThanDays?: number; // optional age filter
|
||||
confirm?: "DELETE_ALL_DATA"; // required for actual deletion
|
||||
}
|
||||
```
|
||||
**Response 200:** `{ success, data: { deletedCounts, dryRun } }`
|
||||
|
||||
### DELETE /api/admin/cleanup/user/:userId
|
||||
|
||||
**Description:** Cascade-delete all data for a specific user (GDPR). Requires `?confirm=DELETE_USER_DATA` for real execution.
|
||||
**Query params:** `dryRun=true|false`, `confirm=DELETE_USER_DATA`
|
||||
|
||||
### POST /api/admin/cleanup/temp
|
||||
|
||||
**Description:** Purge temporary data older than N hours (verification codes, file temp uploads).
|
||||
**Request body:** `{ olderThanHours?: number }` (default 24)
|
||||
|
||||
### POST /api/admin/cleanup/seed-templates
|
||||
|
||||
**Description:** Re-runs the request templates seeder (production safe; idempotent).
|
||||
|
||||
### POST /api/admin/cleanup/seed-all
|
||||
|
||||
**Description:** Seeds users, addresses, and templates in dependency order. Used to bootstrap a fresh staging environment.
|
||||
|
||||
## Analytics
|
||||
|
||||
There is no dedicated analytics router. Admin dashboards stitch together:
|
||||
|
||||
- `GET /api/users/admin/stats` (user metrics)
|
||||
- `GET /api/payment/stats` (payment aggregates)
|
||||
- `GET /api/disputes/statistics` (dispute KPIs)
|
||||
- `GET /api/admin/cleanup/stats` (collection sizes)
|
||||
- `GET /api/payment/shkeeper/webhook-stats` (provider health)
|
||||
- `GET /api/payment/shkeeper/wallet-monitor/status` (chain monitor)
|
||||
|
||||
## Related
|
||||
|
||||
- [[Admin Console Architecture]]
|
||||
- [[Authorization Model]]
|
||||
- [[Error Codes]]
|
||||
305
03 - API Reference/Authentication API.md
Normal file
305
03 - API Reference/Authentication API.md
Normal file
@@ -0,0 +1,305 @@
|
||||
---
|
||||
title: Authentication API
|
||||
tags: [api, auth, reference]
|
||||
---
|
||||
|
||||
# Authentication API
|
||||
|
||||
All endpoints are mounted under `/api/auth/*` in `backend/src/app.ts`. The routes file is [`backend/src/services/auth/authRoutes.ts`](../../backend/src/services/auth/authRoutes.ts) and the WebAuthn sub-routes are in [`passkeyRoutes.ts`](../../backend/src/services/auth/passkeyRoutes.ts). Controller logic lives in [`authController.ts`](../../backend/src/services/auth/authController.ts) and [`authService.ts`](../../backend/src/services/auth/authService.ts).
|
||||
|
||||
Two distinct identities are involved: a [[User]] (`models/User.ts`) and a [[TempVerification]] document that holds pending registration data until the email code is confirmed. Tokens are signed JWTs (access + refresh) created in `authService`. See [[Authentication Flow]] for the high-level lifecycle diagram.
|
||||
|
||||
## Registration
|
||||
|
||||
### POST /api/auth/register
|
||||
|
||||
**Description:** Start a new registration. Creates a [[TempVerification]] document and emails an 8-digit verification code. The actual [[User]] is only created once the code is verified.
|
||||
**Auth required:** No
|
||||
**Request body:**
|
||||
```ts
|
||||
{
|
||||
email: string;
|
||||
firstName?: string; // default "کاربر"
|
||||
lastName?: string; // default "جدید"
|
||||
role?: "buyer" | "seller"; // default "buyer"
|
||||
password?: string; // accepted now or at verify-email-code time
|
||||
referralCode?: string; // optional, links to referrer for [[Points API]]
|
||||
}
|
||||
```
|
||||
**Response 200:**
|
||||
```json
|
||||
{ "success": true, "message": "Verification code sent ...", "data": { "email": "x@y.z" } }
|
||||
```
|
||||
**Errors:** `400` validation, `409 USER_EXISTS` if email already registered.
|
||||
**Side effects:**
|
||||
- Upserts a `TempVerification` row.
|
||||
- Sends `emailService.sendVerificationCodeEmail`.
|
||||
- No socket emission yet (user does not exist).
|
||||
**Source:** `authController.register`
|
||||
|
||||
### POST /api/auth/verify-email-code
|
||||
|
||||
**Description:** Confirms the registration code. Creates the [[User]], deletes the `TempVerification`, processes any referral, issues JWT tokens, and starts the session in Redis.
|
||||
**Auth required:** No
|
||||
**Request body:**
|
||||
```ts
|
||||
{
|
||||
email: string;
|
||||
code: string; // 8 digits
|
||||
password?: string; // required if not provided at register
|
||||
}
|
||||
```
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Email verified",
|
||||
"data": {
|
||||
"user": { "_id": "...", "email": "...", "role": "buyer", ... },
|
||||
"tokens": { "accessToken": "...", "refreshToken": "..." }
|
||||
}
|
||||
}
|
||||
```
|
||||
**Errors:** `400` invalid/expired code, `404` no pending verification, `409` email already taken.
|
||||
**Side effects:**
|
||||
- Creates [[User]], deletes `TempVerification`.
|
||||
- Calls [[Points API]] referral hook when `referralCode` was supplied (emits `referral-signup` to `user-<referrerId>`).
|
||||
- Stores refresh token in `user.refreshTokens`.
|
||||
- Welcome email via `emailService.sendWelcomeEmail`.
|
||||
|
||||
### GET /api/auth/verify-email/:token
|
||||
|
||||
**Description:** Legacy URL-based verification (link in email). Marks `isEmailVerified=true`.
|
||||
**Auth required:** No
|
||||
**Response 200:** `{ "success": true, "message": "Email verified successfully" }`
|
||||
**Errors:** `400` invalid/expired token.
|
||||
|
||||
### POST /api/auth/resend-verification
|
||||
|
||||
**Description:** Re-issues the 8-digit code for a pending or unverified user.
|
||||
**Auth required:** No
|
||||
**Request body:** `{ email: string }`
|
||||
**Response 200:** `{ "success": true, "message": "Verification code resent" }`
|
||||
**Errors:** `400` invalid email, `404` no user/temp record, `429` rate-limited (Redis).
|
||||
|
||||
### POST /api/auth/force-verify-user
|
||||
|
||||
**Description:** Development-only helper to mark a user verified without going through email.
|
||||
**Auth required:** No (intended for dev only — gate with env in prod)
|
||||
|
||||
## Login & sessions
|
||||
|
||||
### POST /api/auth/login
|
||||
|
||||
**Description:** Email/password login. Validates credentials with bcrypt, signs JWT pair, stores refresh token on user, returns sanitized user object.
|
||||
**Auth required:** No
|
||||
**Request body:**
|
||||
```ts
|
||||
{
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
```
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Login successful",
|
||||
"data": {
|
||||
"user": { "_id": "...", "email": "...", "role": "buyer", "firstName": "..." },
|
||||
"tokens": { "accessToken": "...", "refreshToken": "..." }
|
||||
}
|
||||
}
|
||||
```
|
||||
**Errors:**
|
||||
- `400` validation
|
||||
- `401` invalid credentials
|
||||
- `403` email not verified
|
||||
- `423` account locked (after repeated failures, tracked in Redis via `rateLimitService`)
|
||||
**Side effects:**
|
||||
- Updates `user.lastLoginAt`.
|
||||
- Pushes refresh token onto `user.refreshTokens`.
|
||||
- Redis session start via `sessionService`.
|
||||
|
||||
### POST /api/auth/refresh-token
|
||||
|
||||
**Description:** Exchanges a refresh token for a new access token. Rotates the refresh token.
|
||||
**Auth required:** No (refresh token in body)
|
||||
**Request body:** `{ refreshToken: string }`
|
||||
**Response 200:** `{ "success": true, "data": { "tokens": { "accessToken": "...", "refreshToken": "..." } } }`
|
||||
**Errors:** `401` token expired / not present in user record, `403` user disabled.
|
||||
|
||||
### POST /api/auth/logout
|
||||
|
||||
**Description:** Removes the current refresh token from the user record and clears the Redis session.
|
||||
**Auth required:** Bearer JWT
|
||||
**Response 200:** `{ "success": true, "message": "Logged out" }`
|
||||
**Side effects:** `redisService` session removed.
|
||||
|
||||
## Google OAuth
|
||||
|
||||
### POST /api/auth/google/signup
|
||||
|
||||
**Description:** Verifies a Google ID token, creates a new [[User]] (no password), optionally links a referral, returns JWT tokens.
|
||||
**Auth required:** No
|
||||
**Request body:**
|
||||
```ts
|
||||
{
|
||||
googleToken: string;
|
||||
role?: "buyer" | "seller";
|
||||
referralCode?: string;
|
||||
}
|
||||
```
|
||||
**Response 200:** Same shape as `verify-email-code` response.
|
||||
**Errors:** `400` invalid Google token, `409` email already registered (suggest sign-in instead).
|
||||
|
||||
### POST /api/auth/google/signin
|
||||
|
||||
**Description:** Verifies a Google ID token and signs in an existing user. Will not create a new account.
|
||||
**Auth required:** No
|
||||
**Request body:** `{ googleToken: string }`
|
||||
**Response 200:** `{ success, data: { user, tokens } }`
|
||||
**Errors:** `400` invalid token, `404` no user with that Google email.
|
||||
|
||||
## Passkey / WebAuthn
|
||||
|
||||
Routes are nested under `/api/auth/` via `passkeyRoutes`. Service: `passkeyService.ts`.
|
||||
|
||||
### POST /api/auth/passkey/authenticate/challenge
|
||||
|
||||
**Description:** Generates a sign-in challenge that the browser/authenticator will sign. No `userId` required — the assertion will identify the user.
|
||||
**Auth required:** No
|
||||
**Response 200:** `{ "success": true, "challenge": { /* PublicKeyCredentialRequestOptions */ } }`
|
||||
|
||||
### POST /api/auth/passkey/authenticate
|
||||
|
||||
**Description:** Verifies the WebAuthn assertion and, on success, returns a JWT pair.
|
||||
**Auth required:** No
|
||||
**Request body:** `{ challenge, assertion }` (assertion is the browser's `navigator.credentials.get()` output).
|
||||
**Response 200:** `{ "success": true, "userId": "...", "user": { ... }, "tokens": { ... } }`
|
||||
**Errors:** `400` missing fields, `404` `Passkey not found`, `500` verification error.
|
||||
|
||||
### POST /api/auth/passkey/register/challenge
|
||||
|
||||
**Description:** Generates a registration challenge for the authenticated user.
|
||||
**Auth required:** Bearer JWT
|
||||
**Response 200:** `{ "success": true, "challenge": { /* PublicKeyCredentialCreationOptions */ } }`
|
||||
|
||||
### POST /api/auth/passkey/register
|
||||
|
||||
**Description:** Verifies a new passkey registration and stores the credential on the user.
|
||||
**Auth required:** Bearer JWT
|
||||
**Request body:** `{ challenge, credential }`
|
||||
**Response 200:** `{ "success": true, "message": "Passkey registered successfully" }`
|
||||
|
||||
### GET /api/auth/passkey/list
|
||||
|
||||
**Description:** Returns the calling user's registered passkeys (id, label, created date).
|
||||
**Auth required:** Bearer JWT
|
||||
**Response 200:** `{ "success": true, "passkeys": [...] }`
|
||||
|
||||
### DELETE /api/auth/passkey/:passkeyId
|
||||
|
||||
**Description:** Removes a passkey by id.
|
||||
**Auth required:** Bearer JWT
|
||||
**Response 200:** `{ "success": true, "message": "Passkey removed successfully" }`
|
||||
|
||||
## Password management
|
||||
|
||||
### POST /api/auth/request-password-reset
|
||||
|
||||
**Description:** Generates a reset token, stores it on the user, and emails a reset link plus a numeric code.
|
||||
**Auth required:** No
|
||||
**Request body:** `{ email: string }`
|
||||
**Response 200:** `{ "success": true, "message": "Password reset email sent" }` (always returns success to avoid email enumeration).
|
||||
**Side effects:** `emailService.sendPasswordResetEmail`; rate-limited per IP via Redis.
|
||||
|
||||
### POST /api/auth/reset-password
|
||||
|
||||
**Description:** Sets a new password using a token from the reset email. Wipes refresh tokens.
|
||||
**Auth required:** No
|
||||
**Request body:**
|
||||
```ts
|
||||
{
|
||||
token: string;
|
||||
password: string; // 6+ chars, mixed case + digit
|
||||
}
|
||||
```
|
||||
**Response 200:** `{ "success": true, "message": "Password updated" }`
|
||||
**Errors:** `400` invalid/expired token or weak password.
|
||||
|
||||
### POST /api/auth/reset-password-with-code
|
||||
|
||||
**Description:** Alternative reset flow using a numeric code instead of a tokenised URL.
|
||||
**Auth required:** No
|
||||
**Request body:** `{ email, code, password }`
|
||||
**Response 200:** `{ "success": true }`
|
||||
|
||||
### POST /api/auth/change-password
|
||||
|
||||
**Description:** Authenticated password change.
|
||||
**Auth required:** Bearer JWT
|
||||
**Request body:**
|
||||
```ts
|
||||
{
|
||||
currentPassword: string;
|
||||
newPassword: string; // 6+ chars, mixed case + digit
|
||||
}
|
||||
```
|
||||
**Response 200:** `{ "success": true, "message": "Password updated" }`
|
||||
**Errors:** `400` validation, `401` wrong current password.
|
||||
**Side effects:** Clears `user.refreshTokens` (forces re-login on other devices).
|
||||
|
||||
## Current user / profile
|
||||
|
||||
### GET /api/auth/profile
|
||||
|
||||
**Description:** Returns the full sanitized [[User]] document for the caller.
|
||||
**Auth required:** Bearer JWT
|
||||
**Response 200:** `{ "success": true, "data": { /* User */ } }`
|
||||
|
||||
### PUT /api/auth/profile (and POST /api/auth/update-profile)
|
||||
|
||||
**Description:** Updates the caller's profile (first/last name, phone, bio, website, language/currency preferences).
|
||||
**Auth required:** Bearer JWT
|
||||
**Request body:** Partial profile, see `updateProfileValidation`:
|
||||
```ts
|
||||
{
|
||||
firstName?: string; // 2-50
|
||||
lastName?: string; // 2-50
|
||||
profile?: {
|
||||
phone?: string; // E.164-ish
|
||||
bio?: string; // <=500
|
||||
website?: string; // URL
|
||||
};
|
||||
preferences?: {
|
||||
language?: "en" | "fa" | "ar";
|
||||
currency?: "USD" | "EUR" | "IRR" | "AED";
|
||||
};
|
||||
}
|
||||
```
|
||||
**Response 200:** Updated user.
|
||||
**Errors:** `400` validation.
|
||||
|
||||
## Account deletion
|
||||
|
||||
### DELETE /api/auth/account
|
||||
|
||||
**Description:** Permanently deletes the caller's account after re-authenticating with password.
|
||||
**Auth required:** Bearer JWT
|
||||
**Request body:** `{ password: string }`
|
||||
**Response 200:** `{ "success": true, "message": "Account deleted" }`
|
||||
**Errors:** `401` bad password.
|
||||
**Side effects:** Removes [[User]] document, clears Redis session, cascades configured by `dataCleanupService`.
|
||||
|
||||
## Error codes summary
|
||||
|
||||
| HTTP | App code | Meaning |
|
||||
| --- | --- | --- |
|
||||
| 400 | `Validation Error` | `express-validator` rejected the body |
|
||||
| 401 | — | Bad credentials / missing token |
|
||||
| 403 | — | Email not verified or insufficient role |
|
||||
| 409 | `USER_EXISTS` | Email already in use |
|
||||
| 423 | — | Account temporarily locked after failed logins |
|
||||
|
||||
See [[Error Codes]] for the global error shape.
|
||||
125
03 - API Reference/Blog API.md
Normal file
125
03 - API Reference/Blog API.md
Normal file
@@ -0,0 +1,125 @@
|
||||
---
|
||||
title: Blog API
|
||||
tags: [api, blog, reference]
|
||||
---
|
||||
|
||||
# Blog API
|
||||
|
||||
Endpoints live under `/api/blog/*`. The router is [`backend/src/routes/blogRoutes.ts`](../../backend/src/routes/blogRoutes.ts) and delegates to [`blogController`](../../backend/src/services/blog/blogController.ts).
|
||||
|
||||
Public endpoints (post listing and reads) are open. Mutating endpoints (create/update/delete) require **admin** role — enforced via `authenticateToken` + `authorizeRoles('admin')`.
|
||||
|
||||
Model: [[BlogPost]]. Image uploads use the [[File API]] (`POST /api/files/upload/blog-images`).
|
||||
|
||||
## Public reads
|
||||
|
||||
### GET /api/blog/posts
|
||||
|
||||
**Description:** Paginated list of published blog posts.
|
||||
**Auth required:** No
|
||||
**Query params:**
|
||||
- `page` (default 1)
|
||||
- `limit` (default 10)
|
||||
- `category`
|
||||
- `tag`
|
||||
- `search` (matches title/excerpt/content)
|
||||
- `sortBy` (default `publishedAt`)
|
||||
- `sortOrder` (`asc` | `desc`, default `desc`)
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"posts": [BlogPost, ...],
|
||||
"pagination": { "page": 1, "limit": 10, "total": 25, "totalPages": 3, "hasMore": true }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/blog/posts/featured
|
||||
|
||||
**Description:** Posts with `isFeatured: true`. Used by the home page hero strip.
|
||||
**Auth required:** No
|
||||
**Response 200:** `{ success, data: { posts } }`
|
||||
|
||||
### GET /api/blog/posts/recent
|
||||
|
||||
**Description:** Most recent N published posts. Used by the footer / sidebar.
|
||||
**Auth required:** No
|
||||
**Query params:** `limit` (default 5)
|
||||
|
||||
### GET /api/blog/posts/search
|
||||
|
||||
**Description:** Full-text search across title/excerpt/content/tags.
|
||||
**Auth required:** No
|
||||
**Query params:** `q` (required), `page`, `limit`
|
||||
**Errors:** `400` if `q` is missing or shorter than 2 characters.
|
||||
|
||||
### GET /api/blog/posts/:slug
|
||||
|
||||
**Description:** Get a single published post by URL slug.
|
||||
**Auth required:** No
|
||||
**Response 200:** `{ success, data: { post: BlogPost } }`
|
||||
**Errors:** `404` not found or not published.
|
||||
**Side effects:** Increments `viewsCount`.
|
||||
|
||||
## Admin (mutating)
|
||||
|
||||
All endpoints below require `Bearer JWT` with `role: "admin"`.
|
||||
|
||||
### GET /api/blog/admin/posts
|
||||
|
||||
**Description:** Admin list including drafts and unpublished posts.
|
||||
**Auth required:** Bearer JWT (admin)
|
||||
**Query params:** `page`, `limit`, `status` (`draft` | `published` | `archived`), `search`
|
||||
**Response 200:** `{ success, data: { posts, pagination } }`
|
||||
|
||||
### GET /api/blog/admin/posts/:id
|
||||
|
||||
**Description:** Get a post by id (admin view, includes draft fields).
|
||||
**Auth required:** Bearer JWT (admin)
|
||||
|
||||
### POST /api/blog/posts
|
||||
|
||||
**Description:** Create a post.
|
||||
**Auth required:** Bearer JWT (admin)
|
||||
**Request body:**
|
||||
```ts
|
||||
{
|
||||
title: string;
|
||||
slug?: string; // auto-generated from title when omitted
|
||||
excerpt?: string;
|
||||
content: string; // Markdown / rich text
|
||||
coverImage?: string; // URL from [[File API]]
|
||||
category?: string;
|
||||
tags?: string[];
|
||||
status?: "draft" | "published"; // default "draft"
|
||||
isFeatured?: boolean;
|
||||
publishedAt?: string; // ISO date, used when status === "published"
|
||||
videoUrl?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
**Response 201:** `{ success, data: { post } }`
|
||||
|
||||
### PUT /api/blog/posts/:id
|
||||
|
||||
**Description:** Update a post. Partial; the body can contain any of the create fields.
|
||||
**Auth required:** Bearer JWT (admin)
|
||||
|
||||
### DELETE /api/blog/posts/:id
|
||||
|
||||
**Description:** Hard-delete a post.
|
||||
**Auth required:** Bearer JWT (admin)
|
||||
**Response 200:** `{ success: true, message: "Post deleted" }`
|
||||
|
||||
## Comments
|
||||
|
||||
The codebase does not currently expose a comments API — comments are intentionally disabled. If/when added, they will appear under `/api/blog/posts/:id/comments`.
|
||||
|
||||
## Related
|
||||
|
||||
- [[BlogPost]]
|
||||
- [[File API]] (for cover and inline image uploads)
|
||||
- `backend/BLOG_API_ENDPOINTS.md` (legacy in-repo notes)
|
||||
- `backend/BLOG_VIDEO_EXAMPLES.md`
|
||||
144
03 - API Reference/Chat API.md
Normal file
144
03 - API Reference/Chat API.md
Normal file
@@ -0,0 +1,144 @@
|
||||
---
|
||||
title: Chat API
|
||||
tags: [api, chat, reference]
|
||||
---
|
||||
|
||||
# Chat API
|
||||
|
||||
All chat endpoints live under `/api/chat/*`. The router is [`backend/src/services/chat/chatRoutes.ts`](../../backend/src/services/chat/chatRoutes.ts), controller is `chatController`, service is `ChatService`. Every endpoint requires `Bearer JWT` — the router applies `authenticateToken` globally.
|
||||
|
||||
Model: [[Chat]]. Real-time delivery happens over Socket.IO rooms named `chat-<chatId>`. Clients must call `join-chat-room` after connecting. See [[Socket Events]] for `new-message`, `messages-read`, `message-edited`, `message-deleted`, `participants-added`, `participant-removed`, and `user-typing` payloads.
|
||||
|
||||
## Conversations
|
||||
|
||||
### POST /api/chat
|
||||
|
||||
**Description:** Create a generic chat between two or more participants.
|
||||
**Auth required:** Bearer JWT
|
||||
**Request body:**
|
||||
```ts
|
||||
{
|
||||
participantIds: string[]; // 2+ user ids (caller is added automatically)
|
||||
type?: "direct" | "group"; // default "direct"
|
||||
title?: string; // group only
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
**Response 201:** `{ success: true, data: { chat: { _id, participants, type, ... } } }`
|
||||
**Errors:** `400` need 2+ participants, `404` participant not found.
|
||||
|
||||
### POST /api/chat/purchase-request
|
||||
|
||||
**Description:** Create (or fetch the existing) chat tied to a specific [[PurchaseRequest]] between buyer and seller. Idempotent.
|
||||
**Auth required:** Bearer JWT
|
||||
**Request body:** `{ purchaseRequestId: string; sellerId: string }`
|
||||
**Response 200/201:** `{ success: true, data: { chat } }`
|
||||
|
||||
### POST /api/chat/support
|
||||
|
||||
**Description:** Open a support chat. Adds the caller and the support/admin pool. Used by the in-app help widget.
|
||||
**Auth required:** Bearer JWT
|
||||
**Request body:** `{ subject?: string; initialMessage?: string }`
|
||||
**Response 201:** `{ success: true, data: { chat } }`
|
||||
|
||||
### GET /api/chat
|
||||
|
||||
**Description:** List the caller's chats, ordered by latest message. Includes unread counts.
|
||||
**Auth required:** Bearer JWT
|
||||
**Query params:** `page`, `limit`, `type` (`direct` | `group` | `support`)
|
||||
**Response 200:** `{ success, data: { chats: [...], pagination } }`
|
||||
|
||||
### GET /api/chat/stats
|
||||
|
||||
**Description:** Aggregate counts (total chats, total messages, total unread).
|
||||
**Auth required:** Bearer JWT
|
||||
|
||||
### GET /api/chat/:id/info
|
||||
|
||||
**Description:** Full chat metadata: participants (populated), title, type, created-at, last message, unread per user.
|
||||
**Auth required:** Bearer JWT (participant)
|
||||
**Errors:** `403` not a participant, `404` not found.
|
||||
|
||||
### PATCH /api/chat/:id/archive
|
||||
|
||||
**Description:** Toggle archived state for the caller (per-user flag).
|
||||
**Auth required:** Bearer JWT (participant)
|
||||
|
||||
### POST /api/chat/:id/participants
|
||||
|
||||
**Description:** Add a participant to a group chat.
|
||||
**Auth required:** Bearer JWT (creator/admin)
|
||||
**Request body:** `{ userId: string }`
|
||||
**Side effects:** Emits `participants-added` on `chat-<id>`.
|
||||
|
||||
### DELETE /api/chat/:id/participants/:participantId
|
||||
|
||||
**Description:** Remove a participant from a group chat.
|
||||
**Auth required:** Bearer JWT (creator/admin)
|
||||
**Side effects:** Emits `participant-removed` on `chat-<id>`.
|
||||
|
||||
## Messages
|
||||
|
||||
### GET /api/chat/:id/messages
|
||||
|
||||
**Description:** Paginated message history (newest first by default).
|
||||
**Auth required:** Bearer JWT (participant)
|
||||
**Query params:** `page`, `limit`, `before` (cursor by createdAt)
|
||||
|
||||
### POST /api/chat/:id/messages
|
||||
|
||||
**Description:** Send a text message.
|
||||
**Auth required:** Bearer JWT (participant)
|
||||
**Request body:**
|
||||
```ts
|
||||
{
|
||||
content: string;
|
||||
type?: "text" | "system"; // default "text"
|
||||
replyToMessageId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
**Response 201:** `{ success, data: { message } }`
|
||||
**Side effects:** Emits `new-message` on `chat-<chatId>`; increments unread for other participants.
|
||||
|
||||
### POST /api/chat/:id/messages/file
|
||||
|
||||
**Description:** Send a message with a binary attachment. Multipart form (`file` field). Maxes follow the [[File API]] limits.
|
||||
**Auth required:** Bearer JWT (participant)
|
||||
**Form fields:**
|
||||
- `file` - the file blob
|
||||
- `content?` - optional caption
|
||||
- `replyToMessageId?` - optional
|
||||
|
||||
**Response 201:** `{ success, data: { message: { attachments: [{ url, filename, mimeType, size }] } } }`
|
||||
|
||||
### PATCH /api/chat/:id/messages/read
|
||||
|
||||
**Description:** Mark all unread messages up to the latest as read for the caller.
|
||||
**Auth required:** Bearer JWT (participant)
|
||||
**Response 200:** `{ success, data: { modifiedCount } }`
|
||||
**Side effects:** Emits `messages-read` on `chat-<id>`.
|
||||
|
||||
### PUT /api/chat/:id/messages/:messageId
|
||||
|
||||
**Description:** Edit an existing message (author only, within edit window).
|
||||
**Auth required:** Bearer JWT (message author)
|
||||
**Request body:** `{ content: string }`
|
||||
**Side effects:** Emits `message-edited` on `chat-<id>`.
|
||||
|
||||
### DELETE /api/chat/:id/messages/:messageId
|
||||
|
||||
**Description:** Soft-delete a message (author or admin). The record stays but `deletedAt` is set and `content` is cleared.
|
||||
**Auth required:** Bearer JWT (author or admin)
|
||||
**Side effects:** Emits `message-deleted` on `chat-<id>`.
|
||||
|
||||
## Typing indicator
|
||||
|
||||
Typing indicators are not REST endpoints — clients emit `typing-start` and `typing-stop` over Socket.IO. The server broadcasts `user-typing` to the chat room. Full details in [[Socket Events]].
|
||||
|
||||
## Related
|
||||
|
||||
- [[Chat]]
|
||||
- [[Chat Flow]]
|
||||
- [[File API]] (for attachment uploads)
|
||||
- [[Notification API]] (for out-of-band notifications when a chat message is received and the user is offline)
|
||||
122
03 - API Reference/Dispute API.md
Normal file
122
03 - API Reference/Dispute API.md
Normal file
@@ -0,0 +1,122 @@
|
||||
---
|
||||
title: Dispute API
|
||||
tags: [api, dispute, reference]
|
||||
---
|
||||
|
||||
# Dispute API
|
||||
|
||||
Endpoints live under `/api/disputes/*`. The router is [`backend/src/routes/disputeRoutes.ts`](../../backend/src/routes/disputeRoutes.ts) and delegates to `DisputeController` ([`backend/src/controllers/disputeController.ts`](../../backend/src/controllers/disputeController.ts)). The router applies `authenticateToken` globally — every endpoint requires `Bearer JWT`.
|
||||
|
||||
Model: [[Dispute]]. A dispute references a [[PurchaseRequest]] plus optional [[Payment]] and is the input to the mediation workflow that ends in either a `resolved_buyer` or `resolved_seller` decision and triggers an escrow release or refund via the [[Payment API]].
|
||||
|
||||
## Create
|
||||
|
||||
### POST /api/disputes
|
||||
|
||||
**Description:** Open a dispute against a purchase request.
|
||||
**Auth required:** Bearer JWT (buyer or seller participant in the request)
|
||||
**Request body:**
|
||||
```ts
|
||||
{
|
||||
purchaseRequestId: string;
|
||||
reason: "not_delivered" | "wrong_item" | "damaged" | "quality" | "other";
|
||||
description: string;
|
||||
evidence?: string[]; // URLs from [[File API]]
|
||||
paymentId?: string;
|
||||
}
|
||||
```
|
||||
**Response 201:** `{ success: true, data: { dispute } }`
|
||||
**Errors:** `400` validation, `403` not a participant of the request, `409` dispute already open for this request.
|
||||
**Side effects:**
|
||||
- Notifies the counter-party via `POST /api/notifications` (`new-notification` socket event).
|
||||
- Pauses any in-flight payout (sets a hold flag on the related [[Payment]]).
|
||||
|
||||
## Read
|
||||
|
||||
### GET /api/disputes
|
||||
|
||||
**Description:** List disputes the caller can see (their own as buyer/seller, all for admins).
|
||||
**Auth required:** Bearer JWT
|
||||
**Query params:**
|
||||
- `status` (`open` | `under_review` | `resolved_buyer` | `resolved_seller` | `closed`)
|
||||
- `purchaseRequestId`
|
||||
- `page`, `limit`, `sortBy`, `sortOrder`
|
||||
**Response 200:** `{ success, data: { disputes, pagination } }`
|
||||
|
||||
### GET /api/disputes/statistics
|
||||
|
||||
**Description:** Aggregated counts (open, by reason, average resolution time) for admin dashboards.
|
||||
**Auth required:** Bearer JWT (admin)
|
||||
**Response 200:** `{ success, data: { open, byReason, avgResolutionHours, ... } }`
|
||||
|
||||
### GET /api/disputes/:id
|
||||
|
||||
**Description:** Full dispute including evidence list, messages, assigned admin, decision (if any).
|
||||
**Auth required:** Bearer JWT (participant or admin)
|
||||
**Errors:** `403` not allowed, `404` not found.
|
||||
|
||||
## Admin operations
|
||||
|
||||
### POST /api/disputes/:id/assign
|
||||
|
||||
**Description:** Assign an admin moderator to the dispute. Sets `assignedAdminId` and transitions status to `under_review`.
|
||||
**Auth required:** Bearer JWT (admin)
|
||||
**Request body:** `{ adminId: string }`
|
||||
**Side effects:** Notifies all participants.
|
||||
|
||||
### PATCH /api/disputes/:id/status
|
||||
|
||||
**Description:** Generic status update (e.g. close without resolution).
|
||||
**Auth required:** Bearer JWT (admin)
|
||||
**Request body:** `{ status: string; note?: string }`
|
||||
|
||||
### POST /api/disputes/:id/resolve
|
||||
|
||||
**Description:** Final adjudication. Records the decision and triggers the appropriate escrow action.
|
||||
**Auth required:** Bearer JWT (admin)
|
||||
**Request body:**
|
||||
```ts
|
||||
{
|
||||
decision: "buyer" | "seller" | "split";
|
||||
refundAmount?: number; // required when "split"
|
||||
releaseAmount?: number; // required when "split"
|
||||
reasoning: string;
|
||||
}
|
||||
```
|
||||
**Response 200:** `{ success, data: { dispute, paymentAction } }`
|
||||
**Side effects:**
|
||||
- `decision === "buyer"` → triggers `POST /api/payment/shkeeper/:id/refund` flow.
|
||||
- `decision === "seller"` → triggers `POST /api/payment/shkeeper/:id/release` flow.
|
||||
- `decision === "split"` → admin executes both partial release and partial refund manually.
|
||||
- Notifies both participants and updates [[PurchaseRequest]] status to `disputed_resolved`.
|
||||
|
||||
## Evidence and messages
|
||||
|
||||
### POST /api/disputes/:id/evidence
|
||||
|
||||
**Description:** Attach additional evidence (image / document URLs from the [[File API]]) to the dispute. Either party can call.
|
||||
**Auth required:** Bearer JWT (participant or admin)
|
||||
**Request body:**
|
||||
```ts
|
||||
{
|
||||
url: string;
|
||||
description?: string;
|
||||
type?: "image" | "document" | "video";
|
||||
}
|
||||
```
|
||||
**Response 200:** `{ success, data: { dispute } }` with the evidence appended.
|
||||
|
||||
### Messages
|
||||
|
||||
Direct messages between disputants and the admin moderator are handled via a dedicated [[Chat]] created automatically when the dispute is opened (`type: "dispute"`). Use the [[Chat API]] endpoints (`POST /api/chat/:id/messages`, `GET /api/chat/:id/messages`) once you have the `chatId` from the dispute payload.
|
||||
|
||||
## Real-time
|
||||
|
||||
Dispute mutations emit notifications via `POST /api/notifications` which delivers `new-notification` socket events to each participant's `user-<userId>` room. See [[Socket Events]] for payload shape.
|
||||
|
||||
## Related
|
||||
|
||||
- [[Dispute]]
|
||||
- [[Dispute Resolution Flow]]
|
||||
- [[Payment API]]
|
||||
- [[Chat API]]
|
||||
110
03 - API Reference/Error Codes.md
Normal file
110
03 - API Reference/Error Codes.md
Normal file
@@ -0,0 +1,110 @@
|
||||
---
|
||||
title: Error Codes
|
||||
tags: [api, errors, reference]
|
||||
---
|
||||
|
||||
# Error Codes
|
||||
|
||||
This page documents the error shape returned by the AMN backend and the HTTP status mapping used across services.
|
||||
|
||||
## Standard error shape
|
||||
|
||||
The canonical helper is `ResponseHandler.error` in [`backend/src/shared/utils/response-handler.ts`](../../backend/src/shared/utils/response-handler.ts). Modern routes return:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "Validation failed",
|
||||
"error": "Validation Error",
|
||||
"statusCode": 400,
|
||||
"timestamp": "2026-05-23T10:00:00.000Z",
|
||||
"path": "/api/auth/register",
|
||||
"method": "POST",
|
||||
"data": [ /* field-level details for validation errors */ ]
|
||||
}
|
||||
```
|
||||
|
||||
Uncaught errors are formatted by [`shared/middleware/errorHandler.ts`](../../backend/src/shared/middleware/errorHandler.ts):
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "Internal Server Error",
|
||||
"statusCode": 500,
|
||||
"stack": "..." // only in NODE_ENV=development
|
||||
}
|
||||
```
|
||||
|
||||
Legacy routes (chiefly `/api/users` legacy paths, `/api/marketplace` legacy paths, `/api/payment/decentralized/*`, parts of `/api/payment/shkeeper/*`) return ad-hoc shapes such as `{ "error": "..." }` or `{ "success": false, "message": "..." }`. Treat any non-`2xx` response as an error and read whichever of `error` / `message` is present.
|
||||
|
||||
## HTTP status mapping
|
||||
|
||||
| Status | When | Examples |
|
||||
| --- | --- | --- |
|
||||
| `200 OK` | Successful read or mutation | Most `GET`s, idempotent `PUT`s/`PATCH`s |
|
||||
| `201 Created` | Resource created | `POST /api/marketplace/purchase-requests`, `POST /api/auth/register` (when user created), `POST /api/marketplace/reviews` |
|
||||
| `202 Accepted` | Async accepted (provider webhooks) | SHKeeper webhook acknowledgement |
|
||||
| `204 No Content` | Mutations with no body to return | Rare — most endpoints return the updated object |
|
||||
| `400 Bad Request` | Validation failure, malformed input | `express-validator` errors, bad MongoIds, missing fields |
|
||||
| `401 Unauthorized` | Missing or invalid JWT | `Access token required`, `Invalid or expired token` |
|
||||
| `403 Forbidden` | Authenticated but not allowed | Role check failed, email not verified, ownership check failed |
|
||||
| `404 Not Found` | Resource (or route) missing | `notFoundHandler`, `Resource not found` from `ResponseHandler.notFound` |
|
||||
| `409 Conflict` | Duplicate / state collision | `USER_EXISTS`, duplicate review, dispute already open |
|
||||
| `423 Locked` | Account temporarily locked | After repeated failed logins (Redis-tracked) |
|
||||
| `429 Too Many Requests` | Rate limit hit | Currently issued only by per-feature Redis limits (auth / AI); global limiter is disabled |
|
||||
| `500 Internal Server Error` | Unhandled exception | Caught by `errorHandler`; included stack trace in dev |
|
||||
| `502 Bad Gateway` | Upstream provider failure | OpenAI / SHKeeper unreachable |
|
||||
|
||||
## Application error codes
|
||||
|
||||
The `error` field of the response envelope contains a human-readable category. Currently used codes:
|
||||
|
||||
| Code | Meaning | Returned by |
|
||||
| --- | --- | --- |
|
||||
| `Validation Error` | `express-validator` rejected the body | All `*Validation` middlewares |
|
||||
| `Not Found` | Generic resource lookup miss | `ResponseHandler.notFound` |
|
||||
| `Unauthorized` | No token / bad token | `authenticateToken`, `ResponseHandler.unauthorized` |
|
||||
| `Forbidden` | Role/ownership check failed | `authorizeRoles`, `ResponseHandler.forbidden` |
|
||||
| `Internal Server Error` | Catch-all 500 | `ResponseHandler.internalError`, `errorHandler` |
|
||||
| `USER_EXISTS` | Email already registered | `POST /api/auth/register` |
|
||||
|
||||
For auth-specific 4xx responses the body's `message` carries the user-facing text (often Persian/Farsi for legacy reasons):
|
||||
|
||||
- `"کاربری با این ایمیل قبلاً ثبتنام کرده است"` - email already in use (409)
|
||||
- `"کد تحویل نادرست است"` - wrong delivery code (400)
|
||||
- `"شما مجاز به ایجاد کد تحویل برای این درخواست نیستید"` - not the buyer (403)
|
||||
|
||||
## Mongoose-specific mappings
|
||||
|
||||
Handled in `errorHandler`:
|
||||
|
||||
| Mongoose error | Mapped status | Body `message` |
|
||||
| --- | --- | --- |
|
||||
| `ValidationError` | 400 | `Validation Error` |
|
||||
| `MongoError` code `11000` | 409 | `Duplicate resource` |
|
||||
| `JsonWebTokenError` | 401 | `Invalid token` |
|
||||
| `TokenExpiredError` | 401 | `Token expired` |
|
||||
|
||||
## Webhook-specific
|
||||
|
||||
| Provider | Endpoint | Status on success | Status on signature mismatch |
|
||||
| --- | --- | --- | --- |
|
||||
| SHKeeper pay-in | `POST /api/payment/shkeeper/webhook` | 200 `{ success: true }` | 401 `{ success: false }` (then ignored) |
|
||||
| SHKeeper payout | `POST /api/payment/shkeeper/payout/webhook` | 200 / 400 with `{ success, message, data }` | 400 |
|
||||
| Generic payment callback | `POST /api/payment/callback` | 200 `{ success: true, message }` | 400 |
|
||||
|
||||
If a webhook is acknowledged with non-2xx, the provider re-delivers (SHKeeper retries every 60 seconds).
|
||||
|
||||
## Client guidance
|
||||
|
||||
1. Always parse `response.json()` even on non-2xx — both `message` and `error` are useful for UX surface text.
|
||||
2. For optimistic UI, treat `409` as "your action raced — refresh".
|
||||
3. For `401`, attempt one silent refresh-token call before redirecting to sign-in.
|
||||
4. For `403`, do not retry — the user lacks permission.
|
||||
5. For `423`, surface the lockout window from the `message`/`data` and direct the user to password reset.
|
||||
|
||||
## Related
|
||||
|
||||
- [[API Overview]]
|
||||
- [[Authentication API]]
|
||||
- [[Rate Limiting]]
|
||||
111
03 - API Reference/File API.md
Normal file
111
03 - API Reference/File API.md
Normal file
@@ -0,0 +1,111 @@
|
||||
---
|
||||
title: File API
|
||||
tags: [api, file, reference]
|
||||
---
|
||||
|
||||
# File API
|
||||
|
||||
Endpoints live under `/api/files/*`. The router is [`backend/src/services/file/fileRoutes.ts`](../../backend/src/services/file/fileRoutes.ts), delegating to `fileController` and `fileService`. Multer is used for multipart parsing and uploaded files are written under `uploads/<subfolder>`.
|
||||
|
||||
All endpoints require `Bearer JWT`. Static serving is wired in `app.ts` at `/uploads/*` with `helmet({ crossOriginResourcePolicy: { policy: "cross-origin" } })` so files can be embedded from the frontend domain.
|
||||
|
||||
## Multer configuration
|
||||
|
||||
`fileService.getUploadMiddleware(options)` produces a Multer instance per route. Options:
|
||||
|
||||
- `subfolder` - where the file lands under `uploads/`
|
||||
- `fieldName` - form field name
|
||||
- `fileTypes` - allowed MIME types (default: any)
|
||||
- `maxFiles` - default 1
|
||||
- `maxFileSizeMB` - default 10 MB
|
||||
|
||||
Global body limits in `app.ts` are `10mb` for `express.json` and `express.urlencoded`.
|
||||
|
||||
## Upload
|
||||
|
||||
### POST /api/files/upload/avatar
|
||||
|
||||
**Description:** Upload an avatar image. Lands in `uploads/temp/` and is then moved when the user persists it on their profile.
|
||||
**Auth required:** Bearer JWT
|
||||
**Form fields:** `avatar` (image; JPEG / PNG / GIF / WebP)
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"url": "/uploads/temp/avatar-1716459200000.jpg",
|
||||
"filename": "avatar-1716459200000.jpg",
|
||||
"mimeType": "image/jpeg",
|
||||
"size": 51234
|
||||
}
|
||||
}
|
||||
```
|
||||
**Errors:** `400` bad type / too large, `401` not authenticated.
|
||||
**Side effects:** None — caller is responsible for `PUT /api/user/profile` to persist the URL.
|
||||
|
||||
### POST /api/files/upload/file
|
||||
|
||||
**Description:** Generic single-file upload (any MIME), lands in `uploads/temp/`.
|
||||
**Auth required:** Bearer JWT
|
||||
**Form fields:** `file`
|
||||
**Response 200:** `{ success, data: { url, filename, mimeType, size } }`
|
||||
|
||||
### POST /api/files/upload/files
|
||||
|
||||
**Description:** Multi-file upload (up to 5).
|
||||
**Auth required:** Bearer JWT
|
||||
**Form fields:** `files` (repeated)
|
||||
**Response 200:** `{ success, data: { files: [{ url, filename, ... }] } }`
|
||||
|
||||
### POST /api/files/upload/request-template-images
|
||||
|
||||
**Description:** Up to 10 images for a [[RequestTemplate]]. Lands in `uploads/request-templates/`.
|
||||
**Auth required:** Bearer JWT
|
||||
**Form fields:** `images` (repeated; JPEG / PNG / GIF / WebP)
|
||||
**Response 200:** `{ success, data: { files: [...] } }`
|
||||
|
||||
### POST /api/files/upload/blog-images
|
||||
|
||||
**Description:** Up to 10 images for a [[BlogPost]]. Lands in `uploads/blog/`.
|
||||
**Auth required:** Bearer JWT
|
||||
**Form fields:** `images` (repeated; JPEG / PNG / GIF / WebP)
|
||||
**Response 200:** `{ success, data: { files: [...] } }`
|
||||
|
||||
## Delete
|
||||
|
||||
### DELETE /api/files/delete
|
||||
|
||||
**Description:** Delete a file by relative path.
|
||||
**Auth required:** Bearer JWT
|
||||
**Request body:** `{ filePath: string }` (e.g. `"temp/avatar-1716459200000.jpg"`)
|
||||
**Response 200:** `{ success, message: "File deleted" }`
|
||||
**Errors:** `400` invalid path (must stay inside `uploads/`), `404` file missing.
|
||||
|
||||
## Inspect
|
||||
|
||||
### GET /api/files/info/:filePath
|
||||
|
||||
**Description:** Returns metadata for a file (URL-encoded `filePath` segment).
|
||||
**Auth required:** Bearer JWT
|
||||
**Response 200:** `{ success, data: { url, size, mimeType, createdAt } }`
|
||||
|
||||
### GET /api/files/stats
|
||||
|
||||
**Description:** Aggregate upload statistics (counts and sizes per subfolder).
|
||||
**Auth required:** Bearer JWT (admin gating planned per TODO in source)
|
||||
**Response 200:** `{ success, data: { subfolders: [{ name, count, sizeBytes }], total: { count, sizeBytes } } }`
|
||||
|
||||
## Serving
|
||||
|
||||
- `GET /uploads/<path>` - static file delivery (no auth, public read). Suitable for embedding avatars and blog/template images.
|
||||
- The server logs `❌ File not found:` for missing paths and `✅ Serving file:` on hits — useful when debugging frontend image refs.
|
||||
|
||||
The on-disk root resolves from `config.uploadPath`. In production this defaults to `/app/uploads`; in development to `<repo>/uploads`. Both are normalised to an absolute path before being passed to `express.static`.
|
||||
|
||||
## Related
|
||||
|
||||
- [[File Storage Architecture]]
|
||||
- [[User API]] (avatar consumer)
|
||||
- [[Marketplace API]] (template image consumer)
|
||||
- [[Blog API]] (blog image consumer)
|
||||
- [[Chat API]] (message attachments use a separate multipart route under chat)
|
||||
499
03 - API Reference/Marketplace API.md
Normal file
499
03 - API Reference/Marketplace API.md
Normal file
@@ -0,0 +1,499 @@
|
||||
---
|
||||
title: Marketplace API
|
||||
tags: [api, marketplace, reference]
|
||||
---
|
||||
|
||||
# Marketplace API
|
||||
|
||||
All marketplace endpoints live under `/api/marketplace/*`. The router is composed of several files mounted from `app.ts`:
|
||||
|
||||
- New controller-pattern routes: [`backend/src/services/marketplace/controllerRoutes.ts`](../../backend/src/services/marketplace/controllerRoutes.ts) (`marketplaceControllerRouter`)
|
||||
- Legacy + delivery-code routes: [`backend/src/services/marketplace/routes.ts`](../../backend/src/services/marketplace/routes.ts) (`marketplaceRouter`)
|
||||
- Request templates: [`requestTemplateRoutes.ts`](../../backend/src/services/marketplace/requestTemplateRoutes.ts) (`/api/marketplace/request-templates/*`)
|
||||
- Shop settings: [`shopSettingsRoutes.ts`](../../backend/src/services/marketplace/shopSettingsRoutes.ts) (`/api/marketplace/shop/*`)
|
||||
- Reviews: [`reviewRoutes.ts`](../../backend/src/services/marketplace/reviewRoutes.ts) (`/api/marketplace/reviews/*`)
|
||||
|
||||
Core models: [[PurchaseRequest]], [[SellerOffer]], [[Category]], [[RequestTemplate]], [[ShopSettings]], [[Review]]. Real-time updates are emitted on the `request-<id>`, `seller-<id>`, `buyer-<id>`, and `sellers`/`buyers` rooms — see [[Socket Events]].
|
||||
|
||||
## Categories
|
||||
|
||||
### GET /api/marketplace/categories
|
||||
|
||||
**Description:** Flat list of all categories.
|
||||
**Auth required:** No
|
||||
**Response 200:** `{ success: true, data: [Category, ...] }`
|
||||
|
||||
### GET /api/marketplace/categories/tree
|
||||
|
||||
**Description:** Nested tree based on `parentId`.
|
||||
**Auth required:** No
|
||||
|
||||
### GET /api/marketplace/categories/:id
|
||||
|
||||
**Description:** Single category by id.
|
||||
**Auth required:** No
|
||||
**Errors:** `404` not found.
|
||||
|
||||
## Sellers
|
||||
|
||||
### GET /api/marketplace/sellers
|
||||
|
||||
**Description:** Public seller directory (users with `role: 'seller'`). Includes aggregated stats (completed orders, ratings).
|
||||
**Auth required:** No
|
||||
|
||||
### GET /api/marketplace/request-templates/sellers
|
||||
|
||||
**Description:** Sellers who currently expose at least one active [[RequestTemplate]] (used by the shop discovery page).
|
||||
**Auth required:** No
|
||||
|
||||
### GET /api/marketplace/request-templates/sellers/:sellerId
|
||||
|
||||
**Description:** Public shop profile for a seller, including their templates and shop settings.
|
||||
**Auth required:** No
|
||||
|
||||
## Purchase Requests
|
||||
|
||||
The buyer-facing CRUD plus seller-side workflow endpoints. Model: [[PurchaseRequest]].
|
||||
|
||||
### POST /api/marketplace/purchase-requests
|
||||
|
||||
**Description:** Create a purchase request (new controller path).
|
||||
**Auth required:** Bearer JWT (buyer)
|
||||
**Request body:**
|
||||
```ts
|
||||
{
|
||||
title: string;
|
||||
description: string;
|
||||
categoryId: string;
|
||||
productLink?: string;
|
||||
size?: string;
|
||||
color?: string;
|
||||
quantity?: number; // default 1
|
||||
budget?: { min?: number; max?: number; currency: "USD" | "EUR" | "IRR" };
|
||||
urgency?: "low" | "medium" | "high";
|
||||
deliveryInfo?: {
|
||||
deliveryType: "physical" | "online";
|
||||
addressId?: string; // when physical
|
||||
email?: string; // when online
|
||||
};
|
||||
preferredSellerIds?: string[];
|
||||
attachments?: string[]; // URLs from [[File API]]
|
||||
}
|
||||
```
|
||||
**Response 201:** `{ success, data: { purchaseRequest } }`
|
||||
**Side effects:** Emits `new-purchase-request` to the `sellers` room (broadcast).
|
||||
**Source:** `marketplaceController.createPurchaseRequest`
|
||||
|
||||
### POST /api/marketplace/purchase-requests/bulk
|
||||
|
||||
**Description:** Bulk creation (used by the template checkout flow).
|
||||
**Auth required:** Bearer JWT
|
||||
**Request body:** `{ items: Array<PurchaseRequestInput> }`
|
||||
|
||||
### GET /api/marketplace/purchase-requests
|
||||
|
||||
**Description:** List requests with filters. Buyers see their own; sellers see ones routed to them; admins see all.
|
||||
**Auth required:** Bearer JWT
|
||||
**Query params:** `status`, `categoryId`, `urgency`, `search`, `page`, `limit`, `sortBy`, `sortOrder`
|
||||
|
||||
### GET /api/marketplace/purchase-requests/my
|
||||
|
||||
**Description:** Shortcut for the caller's own purchase requests.
|
||||
**Auth required:** Bearer JWT
|
||||
|
||||
### GET /api/marketplace/purchase-requests/:id
|
||||
|
||||
**Description:** Single request with populated category and selected offer.
|
||||
**Auth required:** No (public read for shareable links)
|
||||
**Errors:** `404` not found.
|
||||
|
||||
### PATCH /api/marketplace/purchase-requests/:id
|
||||
|
||||
**Description:** Buyer edits draft / pending request fields.
|
||||
**Auth required:** Bearer JWT (owner)
|
||||
|
||||
### PATCH /api/marketplace/purchase-requests/:id/status
|
||||
|
||||
**Description:** Transition the request status (`draft` → `pending` → `payment` → `processing` → `delivery` → `delivered` → `seller_paid` → `completed`, or `cancelled`).
|
||||
**Auth required:** Bearer JWT (owner or admin)
|
||||
**Request body:** `{ status: string }`
|
||||
**Side effects:** Emits `purchase-request-update` to `request-<id>`.
|
||||
|
||||
### DELETE /api/marketplace/purchase-requests/:id
|
||||
|
||||
**Description:** Cancel/delete a request that has no committed payment.
|
||||
**Auth required:** Bearer JWT (owner)
|
||||
|
||||
### GET /api/marketplace/purchase-requests/:id/workflow-steps
|
||||
|
||||
**Description:** Returns the ordered workflow steps + current pointer used by the frontend stepper.
|
||||
**Auth required:** No
|
||||
|
||||
### GET /api/marketplace/purchase-requests/:id/payment-status
|
||||
|
||||
**Description:** Returns the latest payment + escrow state for the request.
|
||||
**Auth required:** Bearer JWT
|
||||
|
||||
### POST /api/marketplace/purchase-requests/:id/sync-payment-status
|
||||
|
||||
**Description:** Force a re-check against the payment provider ([[Payment API]]).
|
||||
**Auth required:** Bearer JWT
|
||||
|
||||
### POST /api/marketplace/purchase-requests/:id/confirm-payment
|
||||
|
||||
**Description:** Legacy buyer-confirmation endpoint kept for old clients.
|
||||
**Auth required:** Bearer JWT
|
||||
|
||||
### POST /api/marketplace/purchase-requests/:id/release-payment
|
||||
|
||||
**Description:** Triggers admin escrow release (mirror of `POST /api/payment/shkeeper/:id/release`).
|
||||
**Auth required:** Bearer JWT (admin)
|
||||
|
||||
### PUT /api/marketplace/purchase-requests/:id/delivery (controller route)
|
||||
|
||||
**Description:** Seller submits shipping details (carrier, tracking number, expected date).
|
||||
**Auth required:** Bearer JWT (selected seller)
|
||||
|
||||
### POST /api/marketplace/purchase-requests/:id/update-delivery (legacy)
|
||||
|
||||
**Description:** Older equivalent retained for compatibility.
|
||||
|
||||
### POST /api/marketplace/requests/:id/start-delivery
|
||||
|
||||
**Description:** Marks the request as `delivery` and notifies the buyer.
|
||||
**Auth required:** Bearer JWT (seller)
|
||||
**Side effects:** Emits `purchase-request-update` event.
|
||||
|
||||
### PATCH /api/marketplace/purchase-requests/:id/confirm-delivery
|
||||
|
||||
**Description:** Buyer confirms goods received (transitions to `delivered`).
|
||||
**Auth required:** Bearer JWT (buyer)
|
||||
|
||||
### POST /api/marketplace/purchase-requests/:id/final-approval
|
||||
|
||||
**Description:** Buyer's final approval that releases escrow to the seller.
|
||||
**Auth required:** Bearer JWT (buyer)
|
||||
|
||||
### GET /api/marketplace/buyers/:buyerId/purchase-requests
|
||||
|
||||
**Description:** Admin/seller view of a buyer's request history.
|
||||
**Auth required:** Bearer JWT
|
||||
|
||||
## Delivery codes
|
||||
|
||||
Six-digit codes the buyer hands to the seller at handover. Backed by `deliveryService`.
|
||||
|
||||
### POST /api/marketplace/purchase-requests/:id/delivery-code/generate
|
||||
|
||||
**Description:** Buyer generates a delivery code. Request must be in `delivery` status.
|
||||
**Auth required:** Bearer JWT (buyer)
|
||||
**Response 200:** `{ success: true, data: { deliveryCode: "123456" } }`
|
||||
**Errors:** `400` wrong status, `403` not buyer, `404` request not found.
|
||||
**Side effects:** Emits `delivery-code-generated` on `request-<id>`.
|
||||
|
||||
### POST /api/marketplace/purchase-requests/:id/delivery-code/verify
|
||||
|
||||
**Description:** Seller verifies the code. On success the status moves to `delivered`.
|
||||
**Auth required:** Bearer JWT (selected seller)
|
||||
**Request body:** `{ code: string }` (exactly 6 chars)
|
||||
**Errors:** `400` bad code, `403` not the selected seller.
|
||||
**Side effects:** Emits `delivery-confirmed` and `buyer-confirmed-delivery`.
|
||||
|
||||
### GET /api/marketplace/purchase-requests/:id/delivery-code
|
||||
|
||||
**Description:** Buyer or seller fetches the current code metadata.
|
||||
**Auth required:** Bearer JWT
|
||||
|
||||
### GET /api/marketplace/purchase-requests/:id/delivery-code/status
|
||||
|
||||
**Description:** Returns `{ isValid, hasDeliveryCode, ... }` so the UI can decide whether to show "regenerate".
|
||||
**Auth required:** Bearer JWT
|
||||
|
||||
## Seller Offers
|
||||
|
||||
Model: [[SellerOffer]].
|
||||
|
||||
### POST /api/marketplace/purchase-requests/:id/offers
|
||||
|
||||
**Description:** Submit an offer against a purchase request.
|
||||
**Auth required:** Bearer JWT (seller)
|
||||
**Request body:**
|
||||
```ts
|
||||
{
|
||||
price: { amount: number; currency: "USD" | "EUR" | "IRR" };
|
||||
deliveryEstimate: { days: number; note?: string };
|
||||
notes?: string;
|
||||
attachments?: string[];
|
||||
}
|
||||
```
|
||||
**Response 201:** `{ success, data: { offer } }`
|
||||
**Side effects:** Emits `new-offer` to `buyer-<buyerId>` and `seller-offer-update` to `seller-<sellerId>`.
|
||||
|
||||
### PUT /api/marketplace/purchase-requests/:id/offers (legacy)
|
||||
|
||||
**Description:** Older offer-update endpoint kept for compatibility.
|
||||
|
||||
### GET /api/marketplace/purchase-requests/:id/offers
|
||||
|
||||
**Description:** List all offers for a request.
|
||||
**Auth required:** No (buyers and prospective sellers compare offers)
|
||||
|
||||
### GET /api/marketplace/purchase-requests/:id/has-offer
|
||||
|
||||
**Description:** Returns `{ hasOffer: boolean }` for the caller (seller-side helper).
|
||||
**Auth required:** Bearer JWT (seller)
|
||||
|
||||
### GET /api/marketplace/purchase-requests/:id/offers/:sellerId
|
||||
|
||||
**Description:** Fetch a specific seller's offer on a request.
|
||||
**Auth required:** No
|
||||
|
||||
### PATCH /api/marketplace/offers/:id
|
||||
|
||||
**Description:** Seller edits their pending offer (price, delivery estimate, notes).
|
||||
**Auth required:** Bearer JWT (offer owner)
|
||||
|
||||
### DELETE /api/marketplace/offers/:id
|
||||
|
||||
**Description:** Seller withdraws their offer.
|
||||
**Auth required:** Bearer JWT (offer owner)
|
||||
|
||||
### PUT /api/marketplace/offers/:id/status
|
||||
|
||||
**Description:** Direct status mutation (admin override / counter-offer states).
|
||||
**Auth required:** Bearer JWT
|
||||
**Request body:** `{ status: "pending" | "accepted" | "rejected" | "withdrawn" | "countered" }`
|
||||
|
||||
### POST /api/marketplace/purchase-requests/:id/select-offer
|
||||
|
||||
**Description:** Buyer selects/accepts an offer; this triggers payment intent creation in [[Payment API]] and rejects all other offers automatically once payment lands.
|
||||
**Auth required:** Bearer JWT (buyer)
|
||||
**Request body:** `{ offerId: string }`
|
||||
**Side effects:**
|
||||
- Updates [[PurchaseRequest]] `selectedOfferId`, status moves toward `payment`.
|
||||
- Emits `seller-offer-update` to all sellers for the request.
|
||||
|
||||
### POST /api/marketplace/offers/:id/accept (legacy)
|
||||
|
||||
**Description:** Older synonym retained for backward compatibility with old clients.
|
||||
|
||||
### DELETE /api/marketplace/offers/:id (controller route)
|
||||
|
||||
**Description:** Controller-pattern delete that also notifies the buyer.
|
||||
|
||||
## Request Templates
|
||||
|
||||
A [[RequestTemplate]] is a re-usable "shop product" a seller can publish. Buyers convert templates into actual purchase requests via the shareable link, individually or in bulk (cart checkout).
|
||||
|
||||
### POST /api/marketplace/request-templates
|
||||
|
||||
**Description:** Create a new template.
|
||||
**Auth required:** Bearer JWT (seller)
|
||||
**Request body:**
|
||||
```ts
|
||||
{
|
||||
title: string; // 1-200 chars
|
||||
description: string; // 1-2000
|
||||
categoryId: string; // MongoId
|
||||
productLink?: string; // valid URL
|
||||
size?: string; // <=100
|
||||
color?: string; // <=100
|
||||
quantity?: number; // 1-10000
|
||||
budget?: { min?: number; max?: number; currency: "USD" | "EUR" | "IRR" };
|
||||
urgency?: "low" | "medium" | "high";
|
||||
deliveryInfo?: { deliveryType: "physical" | "online"; email?: string };
|
||||
maxUsage?: number | null; // 0/null = unlimited
|
||||
expiresAt?: string | null; // ISO date
|
||||
images?: string[]; // URLs from [[File API]]
|
||||
}
|
||||
```
|
||||
**Response 201:** `{ data: { template } }` with a generated `shareableLink`.
|
||||
|
||||
### GET /api/marketplace/request-templates
|
||||
|
||||
**Description:** Paginated list of the caller's templates.
|
||||
**Auth required:** Bearer JWT (seller)
|
||||
**Query params:** `page`, `limit` (1-100), `isActive`, `categoryId`, `search`
|
||||
|
||||
### GET /api/marketplace/request-templates/stats
|
||||
|
||||
**Description:** Aggregate counts of the caller's templates (active, expired, usage).
|
||||
**Auth required:** Bearer JWT (seller)
|
||||
|
||||
### GET /api/marketplace/request-templates/:id
|
||||
|
||||
**Description:** Full template by id (owner view).
|
||||
**Auth required:** Bearer JWT (owner)
|
||||
|
||||
### PUT /api/marketplace/request-templates/:id
|
||||
|
||||
**Description:** Update the template. Same body as create.
|
||||
**Auth required:** Bearer JWT (owner)
|
||||
|
||||
### DELETE /api/marketplace/request-templates/:id
|
||||
|
||||
**Description:** Delete a template.
|
||||
**Auth required:** Bearer JWT (owner)
|
||||
|
||||
### PATCH /api/marketplace/request-templates/:id/toggle-status
|
||||
|
||||
**Description:** Toggle `isActive`.
|
||||
**Auth required:** Bearer JWT (owner)
|
||||
|
||||
### GET /api/marketplace/request-templates/public/:shareableLink
|
||||
|
||||
**Description:** Public read of a template via its shareable slug. Used by the shop preview page.
|
||||
**Auth required:** No
|
||||
**Errors:** `404` link not found or template inactive/expired.
|
||||
|
||||
### POST /api/marketplace/request-templates/:shareableLink/convert
|
||||
|
||||
**Description:** Buyer converts the template into a real [[PurchaseRequest]].
|
||||
**Auth required:** Bearer JWT (buyer)
|
||||
**Request body:** Overrides (quantity, address, etc.)
|
||||
**Response 201:** `{ data: { purchaseRequest } }`
|
||||
|
||||
### POST /api/marketplace/request-templates/batch-convert
|
||||
|
||||
**Description:** Convert several templates at once (cart checkout).
|
||||
**Auth required:** Bearer JWT (buyer)
|
||||
**Request body:**
|
||||
```ts
|
||||
{
|
||||
items: Array<{
|
||||
shareableLink: string;
|
||||
quantity: number; // 1-100
|
||||
sellerId: string; // MongoId
|
||||
}>;
|
||||
status?: "pending" | "pending_payment" | "active";
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/marketplace/request-templates/complete-payment
|
||||
|
||||
**Description:** Marks the requests created by `batch-convert` as paid (called after a successful checkout).
|
||||
**Auth required:** Bearer JWT
|
||||
**Request body:**
|
||||
```ts
|
||||
{
|
||||
requestIds: string[]; // 1+ MongoIds
|
||||
newStatus?: "pending" | "active" | "processing";
|
||||
paymentData?: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
## Shop Settings
|
||||
|
||||
Per-seller storefront preferences. Model: [[ShopSettings]].
|
||||
|
||||
### GET /api/marketplace/shop/settings/:sellerId
|
||||
|
||||
**Description:** Public shop settings for the given seller (used by the shop landing page).
|
||||
**Auth required:** No
|
||||
**Response 200:** `{ data: ShopSettings }`
|
||||
|
||||
### GET /api/marketplace/shop/settings
|
||||
|
||||
**Description:** The authenticated seller's own settings.
|
||||
**Auth required:** Bearer JWT (seller)
|
||||
|
||||
### PUT /api/marketplace/shop/settings
|
||||
|
||||
**Description:** Update shop settings (banner, bio, policies, `allowSellerReviews`, `allowTemplateReviews`, ...).
|
||||
**Auth required:** Bearer JWT (seller)
|
||||
|
||||
## Reviews
|
||||
|
||||
Model: [[Review]]. Reviews can target a seller or a template. Subject must be `seller` or `template`.
|
||||
|
||||
### GET /api/marketplace/reviews/:subjectType/:subjectId
|
||||
|
||||
**Description:** Published reviews + aggregate stats. Honours `allowSellerReviews` / `allowTemplateReviews` from [[ShopSettings]].
|
||||
**Auth required:** No
|
||||
**Query params:** `page` (default 1), `limit` (default 10)
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"data": [Review, ...],
|
||||
"pagination": { "page": 1, "limit": 10, "total": 42, "pages": 5 },
|
||||
"stats": { "count": 42, "avg": 4.6, "one": 1, "two": 0, "three": 3, "four": 10, "five": 28 }
|
||||
}
|
||||
```
|
||||
**Errors:** `400` bad subjectType / invalid id, `403` reviews disabled by seller.
|
||||
|
||||
### GET /api/marketplace/reviews/:subjectType/:subjectId/summary
|
||||
|
||||
**Description:** Stats only (no review list).
|
||||
**Auth required:** No
|
||||
|
||||
### POST /api/marketplace/reviews
|
||||
|
||||
**Description:** Submit a review. The server computes `isVerifiedBuyer` when `purchaseRequestId` is given and the request is in a terminal state (`delivery`, `delivered`, `seller_paid`, `completed`).
|
||||
**Auth required:** Bearer JWT
|
||||
**Request body:**
|
||||
```ts
|
||||
{
|
||||
subjectType: "seller" | "template";
|
||||
subjectId: string;
|
||||
rating: 1 | 2 | 3 | 4 | 5;
|
||||
comment?: string;
|
||||
purchaseRequestId?: string;
|
||||
}
|
||||
```
|
||||
**Errors:** `400` validation, `403` reviews disabled, `404` template not found, `409` duplicate review.
|
||||
**Response 201:** `{ data: Review, stats: { ... } }`
|
||||
|
||||
## Payments (legacy under marketplace)
|
||||
|
||||
These routes are duplicates of the main [[Payment API]] kept under `/api/marketplace/payments/*` for backward compatibility with the early frontend. Prefer the canonical endpoints.
|
||||
|
||||
### POST /api/marketplace/payments
|
||||
### GET /api/marketplace/payments
|
||||
### GET /api/marketplace/payments/:paymentId
|
||||
### PATCH /api/marketplace/payments/:paymentId
|
||||
|
||||
See [[Payment API]] for the canonical descriptions.
|
||||
|
||||
## Verify Web3 payment (legacy)
|
||||
|
||||
### POST /api/marketplace/payments/verify
|
||||
|
||||
**Description:** Legacy Web3 verification endpoint that records a transaction and moves the purchase request to `processing`. Modern flows use `POST /api/payment/shkeeper/confirm-transaction` instead.
|
||||
**Auth required:** Bearer JWT
|
||||
**Request body:**
|
||||
```ts
|
||||
{
|
||||
purchaseRequestId: string;
|
||||
sellerOfferId: string;
|
||||
buyerId: string;
|
||||
sellerId: string;
|
||||
amount: number;
|
||||
currency: "USDT" | string;
|
||||
paymentHash: string;
|
||||
paymentMethod?: string;
|
||||
token?: string;
|
||||
network?: string;
|
||||
escrowWalletAddress?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
**Side effects:** Creates a [[Payment]] record, updates [[PurchaseRequest]] status, emits `payment-received` to `user-<sellerId>`.
|
||||
|
||||
## Real-time events
|
||||
|
||||
Most marketplace mutations fan out via `global.io` to the rooms below — see [[Socket Events]] for payloads:
|
||||
|
||||
- `purchase-request-update` → `request-<id>`
|
||||
- `new-purchase-request` → `sellers`
|
||||
- `new-offer` → `buyer-<buyerId>`
|
||||
- `seller-offer-update` → `seller-<sellerId>` (and global on payment confirm)
|
||||
- `delivery-code-generated` / `delivery-confirmed` / `delivery-update` → `request-<id>`
|
||||
- `request-cancelled` → `user-<buyerId>`, `user-<sellerId>`
|
||||
- `transaction-completed` → `user-<buyerId>`, `user-<sellerId>`
|
||||
|
||||
## Related
|
||||
|
||||
- [[Purchase Request Flow]]
|
||||
- [[Seller Offer Flow]]
|
||||
- [[Template Checkout Flow]]
|
||||
- [[Delivery Code Flow]]
|
||||
110
03 - API Reference/Notification API.md
Normal file
110
03 - API Reference/Notification API.md
Normal file
@@ -0,0 +1,110 @@
|
||||
---
|
||||
title: Notification API
|
||||
tags: [api, notification, reference]
|
||||
---
|
||||
|
||||
# Notification API
|
||||
|
||||
Endpoints live under `/api/notifications/*`. Two routers are mounted:
|
||||
|
||||
- New controller pattern: [`notificationControllerRoutes.ts`](../../backend/src/services/notification/notificationControllerRoutes.ts) (controller-backed, requires auth)
|
||||
- Legacy: [`notification/routes.ts`](../../backend/src/services/notification/routes.ts) (no auth gate — userId is passed in query/body)
|
||||
|
||||
Both routers are mounted at `/api`, so the paths collide; the controller router wins for the shared paths (it is mounted first). The legacy router is still used by background scripts and admin tools that have no JWT context.
|
||||
|
||||
Model: [[Notification]]. Real-time delivery is via `new-notification` and `unread-count-update` Socket.IO events on `user-<userId>`. See [[Socket Events]].
|
||||
|
||||
## List
|
||||
|
||||
### GET /api/notifications
|
||||
|
||||
**Description:** Paginated notifications for the caller.
|
||||
**Auth required:** Bearer JWT (controller route); legacy variant takes `?userId=...`.
|
||||
**Query params:**
|
||||
- `page` (default 1)
|
||||
- `limit` (default 20)
|
||||
- `unreadOnly` (`true` | `false`, default `false`)
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"notifications": [Notification, ...],
|
||||
"pagination": { "page": 1, "limit": 20, "total": 42, "hasMore": true }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/notifications/unread-count
|
||||
|
||||
**Description:** Just the integer unread count.
|
||||
**Auth required:** Bearer JWT
|
||||
**Response 200:** `{ "success": true, "data": { "unreadCount": 5 } }`
|
||||
|
||||
### GET /api/notifications/:id
|
||||
|
||||
**Description:** Single notification by id. Available on the controller router.
|
||||
**Auth required:** Bearer JWT
|
||||
**Errors:** `404` not found, `403` not owner.
|
||||
|
||||
## Mutations
|
||||
|
||||
### PATCH /api/notifications/:id/read
|
||||
|
||||
**Description:** Mark one notification as read.
|
||||
**Auth required:** Bearer JWT (controller); legacy variant requires `{ userId }` in body.
|
||||
**Response 200:** `{ success: true, data: { /* updated notification */ } }`
|
||||
**Side effects:** Emits `unread-count-update` to `user-<userId>`.
|
||||
|
||||
### PATCH /api/notifications/mark-all-read
|
||||
|
||||
**Description:** Mark every notification for the caller as read.
|
||||
**Auth required:** Bearer JWT
|
||||
**Response 200:** `{ "success": true, "data": { "modifiedCount": 12 } }`
|
||||
|
||||
### PATCH /api/notifications/bulk/mark-read
|
||||
|
||||
**Description:** Mark a list of notifications as read.
|
||||
**Auth required:** Bearer JWT
|
||||
**Request body:** `{ ids: string[] }`
|
||||
**Response 200:** `{ success, data: { modifiedCount } }`
|
||||
|
||||
### DELETE /api/notifications/:id
|
||||
|
||||
**Description:** Delete a notification.
|
||||
**Auth required:** Bearer JWT
|
||||
**Errors:** `404` not found.
|
||||
|
||||
### DELETE /api/notifications/bulk/delete
|
||||
|
||||
**Description:** Bulk delete.
|
||||
**Auth required:** Bearer JWT
|
||||
**Request body:** `{ ids: string[] }`
|
||||
|
||||
### POST /api/notifications
|
||||
|
||||
**Description:** Create a notification. Primarily used by other services and admin tools.
|
||||
**Auth required:** Bearer JWT (controller); legacy variant is open.
|
||||
**Request body:**
|
||||
```ts
|
||||
{
|
||||
userId: string;
|
||||
type: string; // e.g. "order_update", "chat_message", "payment_received"
|
||||
title: string;
|
||||
body?: string;
|
||||
data?: Record<string, unknown>;
|
||||
channel?: "in_app" | "email" | "push";
|
||||
}
|
||||
```
|
||||
**Response 201:** `{ success, data: { notification } }`
|
||||
**Side effects:** Emits `new-notification` to `user-<userId>`; also increments unread count via `unread-count-update`.
|
||||
|
||||
## Preferences
|
||||
|
||||
Notification preferences live on [[User]] (`preferences.notifications.email | sms | push`). They are read and written through the [[User API]] (`GET /api/user/profile`, `PUT /api/user/profile`).
|
||||
|
||||
## Related
|
||||
|
||||
- [[Notification]]
|
||||
- [[Notification Flow]]
|
||||
- [[Socket Events]]
|
||||
387
03 - API Reference/Payment API.md
Normal file
387
03 - API Reference/Payment API.md
Normal file
@@ -0,0 +1,387 @@
|
||||
---
|
||||
title: Payment API
|
||||
tags: [api, payment, reference, shkeeper]
|
||||
---
|
||||
|
||||
# Payment API
|
||||
|
||||
The payment surface is split across four routers, all mounted under `/api/payment/*`:
|
||||
|
||||
| Path prefix | File | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `/api/payment/*` | [`paymentControllerRoutes.ts`](../../backend/src/services/payment/paymentControllerRoutes.ts) | New controller pattern (CRUD + configuration) |
|
||||
| `/api/payment/*` | [`paymentRoutes.ts`](../../backend/src/services/payment/paymentRoutes.ts) | Additional legacy endpoints (tx fetch, exports) |
|
||||
| `/api/payment/decentralized/*` | [`decentralizedPaymentRoutes.ts`](../../backend/src/services/payment/decentralizedPaymentRoutes.ts) | DePay / Web3 confirmations |
|
||||
| `/api/payment/shkeeper/*` | [`shkeeper/shkeeperRoutes.ts`](../../backend/src/services/payment/shkeeper/shkeeperRoutes.ts) | SHKeeper pay-in, webhook, release/refund |
|
||||
| `/api/payment/shkeeper/payout*` | [`shkeeper/shkeeperPayoutRoutes.ts`](../../backend/src/services/payment/shkeeper/shkeeperPayoutRoutes.ts) | SHKeeper payouts to sellers |
|
||||
|
||||
Core model: [[Payment]]. Coordination logic to avoid race conditions when multiple sources update the same payment is in `paymentCoordinator.ts`.
|
||||
|
||||
## Configuration / health
|
||||
|
||||
### POST /api/payment/configuration
|
||||
|
||||
**Description:** Returns the payment provider configuration the SHKeeper widget needs (accepted blockchains, escrow receiver address, redirect URLs, webhook URL).
|
||||
**Auth required:** No
|
||||
**Request body:** `{ amount?, currency?, purchaseRequestId? }` (used to scope returned config)
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"accept": [{ "blockchain": "bsc", "token": "0x55d3...", "receiver": "0xa30..." }],
|
||||
"redirect": { "success": "...", "cancel": "..." },
|
||||
"webhook": "https://.../api/payment/shkeeper/webhook"
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/payment/health
|
||||
|
||||
**Description:** Lightweight health probe.
|
||||
**Auth required:** No
|
||||
**Response 200:** `{ success, message, endpoints: { shkeeper, decentralized, health } }`
|
||||
|
||||
### GET /api/payment/shkeeper/config
|
||||
|
||||
**Description:** Same payload as `/configuration` but tailored for the SHKeeper-hosted widget; includes explicit CORS `*` headers.
|
||||
**Auth required:** No
|
||||
|
||||
## Payment records (CRUD)
|
||||
|
||||
### POST /api/payment
|
||||
|
||||
**Description:** Create a payment record (manual entry — usually the SHKeeper intent path is preferred).
|
||||
**Auth required:** Bearer JWT
|
||||
**Request body:**
|
||||
```ts
|
||||
{
|
||||
purchaseRequestId: string;
|
||||
sellerOfferId: string;
|
||||
buyerId: string;
|
||||
sellerId: string;
|
||||
amount: { amount: number; currency: string };
|
||||
blockchain?: { network: string; token: string };
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
**Response 201:** `{ /* Payment document */ }`
|
||||
|
||||
### PUT /api/payment/:id
|
||||
|
||||
**Description:** Update a payment record (status, transactionHash, metadata).
|
||||
**Auth required:** Bearer JWT
|
||||
**Request body:**
|
||||
```ts
|
||||
{
|
||||
status?: "pending" | "processing" | "completed" | "failed" | "cancelled";
|
||||
transactionHash?: string;
|
||||
blockchain?: { ... };
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/payment
|
||||
|
||||
**Description:** List the caller's payments (defaults to `completed,success` status).
|
||||
**Auth required:** Bearer JWT
|
||||
**Query params:** `status`, `limit` (default 50), `offset` (default 0)
|
||||
|
||||
### GET /api/payment/:id
|
||||
|
||||
**Description:** Fetch a payment by id.
|
||||
**Auth required:** Bearer JWT
|
||||
**Errors:** `404` not found.
|
||||
|
||||
### GET /api/payment/:id/debug
|
||||
|
||||
**Description:** Debug bundle including the raw payment, blockchain metadata, and wallet-monitor status.
|
||||
**Auth required:** Bearer JWT
|
||||
**Notes:** Intended for admin / development.
|
||||
|
||||
### GET /api/payment/user/:userId
|
||||
|
||||
**Description:** Payments for a specific user (admin or self).
|
||||
**Auth required:** Bearer JWT
|
||||
**Query params:** `status`, `limit`, `offset`
|
||||
|
||||
### GET /api/payment/stats / GET /api/payment/stats/:userId
|
||||
|
||||
**Description:** Aggregated counts and sums per status.
|
||||
**Auth required:** Bearer JWT
|
||||
|
||||
### GET /api/payment/export / GET /api/payment/export/:userId
|
||||
|
||||
**Description:** Export payments as `json` or `csv`.
|
||||
**Auth required:** Bearer JWT
|
||||
**Query params:** `format=json|csv`
|
||||
|
||||
### POST /api/payment/payments/cleanup-pending
|
||||
|
||||
**Description:** Admin cleanup of stale `pending` payments.
|
||||
**Auth required:** Bearer JWT (admin)
|
||||
**Response 200:** `{ success, deletedCount, message }`
|
||||
|
||||
### POST /api/payment/payments/:id/fetch-tx
|
||||
|
||||
**Description:** Re-queries the blockchain to fetch the missing `transactionHash` for a completed payment.
|
||||
**Auth required:** Bearer JWT
|
||||
**Response 200:** `{ success, transactionHash, network, source, message }`
|
||||
|
||||
### POST /api/payment/payments/auto-fetch-missing
|
||||
|
||||
**Description:** Batch tx-hash backfill across the database.
|
||||
**Auth required:** Bearer JWT
|
||||
**Request body:** `{ limit?: number }` (default 10)
|
||||
|
||||
### POST /api/payment/callback
|
||||
|
||||
**Description:** Generic payment callback (called by the older client SDK).
|
||||
**Auth required:** No (verified by `paymentRef` matching)
|
||||
**Request body:** `{ paymentId, transactionHash, status, data }`
|
||||
|
||||
### POST /api/payment/verify
|
||||
|
||||
**Description:** Frontend verification endpoint used by the Web3 flow. Confirms a payment and updates the related [[PurchaseRequest]].
|
||||
**Auth required:** Bearer JWT
|
||||
|
||||
## SHKeeper - Pay-in
|
||||
|
||||
### POST /api/payment/shkeeper/intents
|
||||
|
||||
**Description:** Creates a SHKeeper pay-in intent. The server provisions an invoice on SHKeeper, stores a [[Payment]] with `provider: "shkeeper"`, `direction: "in"`, returns the hosted-widget URL.
|
||||
**Auth required:** Bearer JWT (buyer)
|
||||
**Request body:**
|
||||
```ts
|
||||
{
|
||||
purchaseRequestId: string;
|
||||
sellerOfferId: string;
|
||||
amount: number;
|
||||
sellerId: string;
|
||||
token?: string; // default "USDT"
|
||||
network?: string; // default "bsc"
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"paymentId": "...",
|
||||
"paymentUrl": "https://pay.amn.gg/invoice/...",
|
||||
"externalId": "AMN_...",
|
||||
"expiresAt": "2026-05-23T11:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
**Errors:** `400` missing fields, `401` not authenticated, `500` SHKeeper error.
|
||||
**Side effects:** Emits `payment-created` globally and to the request room.
|
||||
|
||||
### POST /api/payment/shkeeper/webhook
|
||||
|
||||
**Description:** SHKeeper posts here when an invoice changes state. Handles both raw-string and JSON bodies and verifies the HMAC signature (`x-shkeeper-signature` against the raw body using `SHKEEPER_WEBHOOK_SECRET`).
|
||||
**Auth required:** No (signature-protected)
|
||||
**Body:** The SHKeeper callback envelope (`external_id`, `crypto`, `addr`, `fiat`, `balance_fiat`, `balance_crypto`, `paid`, `status`, `transactions[]`).
|
||||
**Response 200:** `{ success: true }`
|
||||
**Side effects:**
|
||||
- Updates the matching [[Payment]] to `completed` (`OVERPAID` and `PAID` both count).
|
||||
- Releases or rejects [[SellerOffer]] siblings (the chosen offer becomes `accepted`, others `rejected`).
|
||||
- Updates [[PurchaseRequest]] status to `payment` / `processing`.
|
||||
- Emits `seller-offer-update` to each affected seller room and `purchase-request-update` to the request room.
|
||||
|
||||
### POST /api/payment/shkeeper/confirm-transaction
|
||||
|
||||
**Description:** Manual fallback when the webhook misses — the frontend calls this after the buyer signs the EVM transaction directly. Coordinated through `PaymentCoordinator` to avoid double updates.
|
||||
**Auth required:** Bearer JWT
|
||||
**Request body:** `{ paymentId, transactionHash, network? }`
|
||||
**Response 200:** `{ success, message, data: { paymentId, transactionHash, status } }`
|
||||
**Side effects:** Closes the SHKeeper invoice session, then runs the same offer/request updates as the webhook.
|
||||
|
||||
### POST /api/payment/shkeeper/test
|
||||
|
||||
**Description:** Smoke-tests the real SHKeeper API. Development only.
|
||||
**Auth required:** No
|
||||
|
||||
### POST /api/payment/shkeeper/callback-test (and GET equivalent)
|
||||
|
||||
**Description:** Echo-style endpoints used during webhook configuration.
|
||||
**Auth required:** No
|
||||
|
||||
### POST /api/payment/shkeeper/create-test-payment
|
||||
|
||||
**Description:** Inserts a sample [[Payment]] row to exercise the webhook handler.
|
||||
**Auth required:** No
|
||||
|
||||
### POST /api/payment/shkeeper/trigger-webhook
|
||||
|
||||
**Description:** Sends a fake webhook payload to the local webhook endpoint for end-to-end testing.
|
||||
**Auth required:** Bearer JWT
|
||||
|
||||
### GET /api/payment/shkeeper/wallet-monitor/status
|
||||
|
||||
**Description:** Returns the wallet-monitor state (`isMonitoring`, watched wallet addresses).
|
||||
**Auth required:** No
|
||||
|
||||
### GET /api/payment/shkeeper/auto-webhook/status
|
||||
|
||||
**Description:** Returns the auto-webhook fallback monitor state.
|
||||
**Auth required:** No
|
||||
|
||||
### GET /api/payment/shkeeper/webhook-stats
|
||||
|
||||
**Description:** Counters for webhook deliveries (success / failure / duplicates).
|
||||
**Auth required:** Bearer JWT (admin)
|
||||
|
||||
## SHKeeper - Release / Refund (escrow)
|
||||
|
||||
These build an admin-signed transaction off-chain and require a follow-up confirm with the broadcast tx hash. Source: `shkeeperService.buildAdminSignedTxPayload` and `confirmAdminTx`.
|
||||
|
||||
### POST /api/payment/shkeeper/:id/release
|
||||
|
||||
**Description:** Prepares the admin-signed payload to release escrow to the seller. Returns the raw payload — the admin client signs and broadcasts.
|
||||
**Auth required:** Bearer JWT (admin)
|
||||
**Response 200:** `{ success: true, data: { /* tx payload */ } }`
|
||||
|
||||
### POST /api/payment/shkeeper/:id/release/confirm
|
||||
|
||||
**Description:** Records the broadcast transaction hash for the release; marks the payment as released, updates [[PurchaseRequest]] to `seller_paid` and emits `purchase-request-update` (`type: payment_released`).
|
||||
**Auth required:** Bearer JWT (admin)
|
||||
**Request body:** `{ txHash: string }`
|
||||
**Errors:** `400` missing `txHash`.
|
||||
|
||||
### POST /api/payment/shkeeper/:id/refund
|
||||
|
||||
**Description:** Mirror of release, but returns the escrow to the buyer.
|
||||
**Auth required:** Bearer JWT (admin)
|
||||
|
||||
### POST /api/payment/shkeeper/:id/refund/confirm
|
||||
|
||||
**Description:** Records the refund tx hash; emits `purchase-request-update` (`type: payment_refunded`).
|
||||
**Auth required:** Bearer JWT (admin)
|
||||
**Request body:** `{ txHash: string }`
|
||||
|
||||
## SHKeeper - Payouts
|
||||
|
||||
Payouts are SHKeeper-side outbound transfers (admin pays the seller from a hot wallet).
|
||||
|
||||
### POST /api/payment/shkeeper/payout
|
||||
|
||||
**Description:** Create a payout task. The server creates a [[Payment]] row with `direction: "out"` and provider task id, then returns the SHKeeper task descriptor.
|
||||
**Auth required:** Bearer JWT (admin)
|
||||
**Request body:**
|
||||
```ts
|
||||
{
|
||||
purchaseRequestId: string;
|
||||
sellerOfferId: string;
|
||||
buyerId: string;
|
||||
sellerId: string;
|
||||
amount: number;
|
||||
recipientAddress: string;
|
||||
token?: string; // default "USDT"
|
||||
network?: string; // default "bsc"
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
**Response 200:** `{ success, data: { payoutId, taskId, status }, message }`
|
||||
**Side effects:** `emitGlobalEvent('payout-created', { ... })`.
|
||||
**Errors:** `400` for each missing required field, `500` upstream error.
|
||||
|
||||
### GET /api/payment/shkeeper/payout/status/:taskId
|
||||
|
||||
**Description:** Polls SHKeeper for the current task status.
|
||||
**Auth required:** Bearer JWT
|
||||
**Response 200:** `{ success, data: { /* status payload */ } }`
|
||||
|
||||
### POST /api/payment/shkeeper/payout/webhook
|
||||
|
||||
**Description:** SHKeeper webhook for payout state changes. Handled by `processPayoutWebhookEvent`. Emits `payout-completed` (or `payout-updated`) global socket events on success.
|
||||
**Auth required:** No (signature checked)
|
||||
**Response 200/400:** `{ success, message, data }`
|
||||
|
||||
## DePay / Web3 (decentralized)
|
||||
|
||||
### POST /api/payment/decentralized/save
|
||||
|
||||
**Description:** Persists a Web3-initiated payment record.
|
||||
**Auth required:** No
|
||||
**Request body:**
|
||||
```ts
|
||||
{
|
||||
purchaseRequestId: string;
|
||||
buyerId: string;
|
||||
sellerId?: string;
|
||||
amount: number;
|
||||
currency?: string;
|
||||
transactionHash?: string;
|
||||
network?: string;
|
||||
token?: string;
|
||||
walletAddress?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/payment/decentralized/status/:paymentId
|
||||
|
||||
**Description:** Returns the latest status for a decentralized payment.
|
||||
**Auth required:** No
|
||||
|
||||
### PUT /api/payment/decentralized/update
|
||||
|
||||
**Description:** Update a decentralized payment's status / confirmations.
|
||||
**Auth required:** No
|
||||
**Request body:** `{ paymentId, status, confirmations? }`
|
||||
|
||||
### GET /api/payment/decentralized/receiver
|
||||
|
||||
**Description:** Returns the configured escrow receiver wallet address.
|
||||
**Auth required:** No
|
||||
|
||||
### GET /api/payment/decentralized/history/:userId
|
||||
|
||||
**Description:** Decentralized payment history for a user.
|
||||
**Auth required:** No
|
||||
|
||||
### POST /api/payment/decentralized/verify/:paymentId
|
||||
|
||||
**Description:** Re-verifies a single decentralized payment against the chain.
|
||||
**Auth required:** No
|
||||
|
||||
### POST /api/payment/decentralized/verify-all-pending
|
||||
|
||||
**Description:** Iterates all `pending` decentralized payments and re-verifies them.
|
||||
**Auth required:** No (typically called by a cron)
|
||||
|
||||
### POST /api/payment/decentralized/admin-payout
|
||||
|
||||
**Description:** Pay a seller directly from an admin hot wallet (no SHKeeper).
|
||||
**Auth required:** Bearer JWT (admin)
|
||||
**Request body:**
|
||||
```ts
|
||||
{
|
||||
purchaseRequestId: string;
|
||||
receiverWalletAddress: string;
|
||||
amount: number;
|
||||
currency?: string; // default "USDT"
|
||||
network?: string; // default "BSC"
|
||||
}
|
||||
```
|
||||
**Response 200:** `{ success, data: { /* payout receipt */ } }`
|
||||
|
||||
## Status model
|
||||
|
||||
[[Payment]] uses the statuses below across all providers:
|
||||
|
||||
- `pending` - intent created, awaiting on-chain settlement
|
||||
- `processing` - settlement seen, awaiting confirmations
|
||||
- `completed` - confirmed, escrow funded
|
||||
- `failed` - intentionally failed (expired, declined, refused)
|
||||
- `cancelled` - cancelled by user/admin
|
||||
- `released` - escrow released to seller (`shkeeper` flow)
|
||||
- `refunded` - escrow returned to buyer
|
||||
|
||||
Escrow state (`escrowState`): `unfunded` → `funded` → `released` | `refunded`.
|
||||
|
||||
## Related
|
||||
|
||||
- [[Payment Flow]]
|
||||
- [[Escrow Flow]]
|
||||
- [[SHKeeper Webhook Flow]]
|
||||
- [[Socket Events]]
|
||||
140
03 - API Reference/Points API.md
Normal file
140
03 - API Reference/Points API.md
Normal file
@@ -0,0 +1,140 @@
|
||||
---
|
||||
title: Points API
|
||||
tags: [api, points, reference]
|
||||
---
|
||||
|
||||
# Points API
|
||||
|
||||
Endpoints live under `/api/points/*`. The router is [`backend/src/routes/pointsRoutes.ts`](../../backend/src/routes/pointsRoutes.ts), delegating to [`PointsController`](../../backend/src/controllers/pointsController.ts) and `PointsService` ([`backend/src/services/points/PointsService.ts`](../../backend/src/services/points/PointsService.ts)). The router applies `authenticateToken` globally — every endpoint requires `Bearer JWT`.
|
||||
|
||||
Models: [[PointTransaction]], [[LevelConfig]]. Points are minted automatically by the platform (referral signup, successful purchases, reviews) and can be redeemed for discounts or marketplace credits. Levels progress as the user's lifetime points cross configured thresholds.
|
||||
|
||||
## Balance and history
|
||||
|
||||
### GET /api/points/my-points
|
||||
|
||||
**Description:** Caller's current balance, lifetime totals, current level, referral code, and progress to next level.
|
||||
**Auth required:** Bearer JWT
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"balance": 320,
|
||||
"lifetimePoints": 1280,
|
||||
"level": { "name": "silver", "tier": 2, "minPoints": 1000, "perks": [...] },
|
||||
"nextLevel": { "name": "gold", "tier": 3, "minPoints": 2500, "pointsToGo": 1220 },
|
||||
"referralCode": "ABCD1234",
|
||||
"referralsCount": 4
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/points/transactions
|
||||
|
||||
**Description:** Paginated point ledger.
|
||||
**Auth required:** Bearer JWT
|
||||
**Query params:**
|
||||
- `page` (default 1), `limit` (default 20)
|
||||
- `type` (`earn` | `redeem` | `referral` | `purchase` | `review` | `admin_grant` | `admin_deduct`)
|
||||
- `from` / `to` (ISO dates)
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"transactions": [PointTransaction, ...],
|
||||
"pagination": { "page": 1, "limit": 20, "total": 17, "hasMore": false }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/points/referrals
|
||||
|
||||
**Description:** Users referred by the caller plus the points earned from each.
|
||||
**Auth required:** Bearer JWT
|
||||
**Response 200:** `{ success, data: { referrals: [{ userId, name, joinedAt, pointsEarned, status }] } }`
|
||||
|
||||
### GET /api/points/levels
|
||||
|
||||
**Description:** Public list of every configured level (from [[LevelConfig]]). Used by the marketing / levels page.
|
||||
**Auth required:** Bearer JWT (but data is non-sensitive)
|
||||
**Response 200:** `{ success, data: { levels: [LevelConfig, ...] } }`
|
||||
|
||||
### GET /api/points/leaderboard
|
||||
|
||||
**Description:** Top referrers by referral count and points earned. Used for community displays.
|
||||
**Auth required:** Bearer JWT
|
||||
**Query params:** `limit` (default 10), `period` (`all` | `month` | `week`)
|
||||
**Response 200:** `{ success, data: { entries: [{ userId, name, avatar, referrals, pointsEarned }] } }`
|
||||
|
||||
## Mutations
|
||||
|
||||
### POST /api/points/redeem
|
||||
|
||||
**Description:** Redeem points for a marketplace credit / discount. Server validates available balance and configured redemption rate.
|
||||
**Auth required:** Bearer JWT
|
||||
**Request body:**
|
||||
```ts
|
||||
{
|
||||
amount: number; // points to redeem
|
||||
purpose?: "wallet_credit" | "discount_code";
|
||||
purchaseRequestId?: string; // when applying to an in-progress purchase
|
||||
}
|
||||
```
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"transaction": { /* PointTransaction */ },
|
||||
"redemption": { "creditAmount": 3.20, "currency": "USD", "code": "DISC-..." },
|
||||
"newBalance": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
**Errors:** `400` insufficient balance, validation; `409` redemption already claimed.
|
||||
|
||||
### POST /api/points/generate-referral-code
|
||||
|
||||
**Description:** Generates (or rotates) the caller's unique referral code. Idempotent if the user already has one and `force` is not set.
|
||||
**Auth required:** Bearer JWT
|
||||
**Request body:** `{ force?: boolean }`
|
||||
**Response 200:** `{ success, data: { referralCode: "ABCD1234", link: "https://amn.gg/r/ABCD1234" } }`
|
||||
|
||||
The short link redirect (`GET /r/:code`) is mounted at the app root in `app.ts` and forwards to `${FRONTEND_URL}/auth/jwt/sign-up?ref=<code>`.
|
||||
|
||||
## Admin
|
||||
|
||||
### POST /api/points/admin/add
|
||||
|
||||
**Description:** Manually grant (or deduct, with negative amount) points to a user. Logged as a `PointTransaction` with `type: "admin_grant"` / `"admin_deduct"`.
|
||||
**Auth required:** Bearer JWT (admin)
|
||||
**Request body:**
|
||||
```ts
|
||||
{
|
||||
userId: string;
|
||||
amount: number; // positive = grant; negative = deduct
|
||||
reason: string; // mandatory free-text reason
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
**Response 200:** `{ success, data: { transaction, newBalance } }`
|
||||
**Errors:** `403` non-admin, `404` target user not found, `400` would go below zero.
|
||||
|
||||
## Side effects and real-time
|
||||
|
||||
`PointsService` emits Socket.IO events on level-up and referral rewards:
|
||||
|
||||
- `level-up` on `user-<userId>` when a transaction crosses a level threshold.
|
||||
- `referral-reward` on `user-<referrerId>` when a referred user triggers a reward.
|
||||
|
||||
See [[Socket Events]] for payload shape.
|
||||
|
||||
## Related
|
||||
|
||||
- [[PointTransaction]]
|
||||
- [[LevelConfig]]
|
||||
- [[Points and Levels Flow]]
|
||||
- [[Referral Flow]]
|
||||
- `backend/POINTS_MIGRATION.md` (legacy migration notes)
|
||||
152
03 - API Reference/Socket Events.md
Normal file
152
03 - API Reference/Socket Events.md
Normal file
@@ -0,0 +1,152 @@
|
||||
---
|
||||
title: Socket Events
|
||||
tags: [api, socket, realtime, reference]
|
||||
---
|
||||
|
||||
# Socket Events
|
||||
|
||||
The backend runs a Socket.IO server on the same HTTP port as the REST API. It is initialised in [`backend/src/app.ts`](../../backend/src/app.ts) and exposed globally as `global.io`. Helper functions for emitting events from services live in [`backend/src/infrastructure/socket/socketService.ts`](../../backend/src/infrastructure/socket/socketService.ts):
|
||||
|
||||
```ts
|
||||
emitGlobalEvent(event, data); // io.emit
|
||||
emitToRoom(room, event, data); // io.to(room).emit
|
||||
setSocketServer(server);
|
||||
getSocketServer();
|
||||
```
|
||||
|
||||
## Connection
|
||||
|
||||
```ts
|
||||
import { io } from "socket.io-client";
|
||||
|
||||
const socket = io(process.env.NEXT_PUBLIC_API_URL!, {
|
||||
withCredentials: true,
|
||||
transports: ["websocket"],
|
||||
});
|
||||
```
|
||||
|
||||
The CORS policy mirrors the REST API: only `FRONTEND_URL` is allowed, and credentials are enabled. There is no token-based handshake — the server identifies users by the `join-*` events the client emits after connecting. (A future improvement would authenticate via the JWT on handshake; for now, ownership is checked at the REST layer when a mutation triggers an event.)
|
||||
|
||||
The server logs `🔌 User connected: <socketId>` on connect and `🔌 User disconnected: <socketId>` on disconnect. There is no broadcast `user-offline` on disconnect — see "Online status" below.
|
||||
|
||||
## Rooms
|
||||
|
||||
Rooms are the targeting mechanism. The client joins/leaves rooms with these events:
|
||||
|
||||
| Client → Server | Room joined |
|
||||
| --- | --- |
|
||||
| `join-user-room` (userId) | `user-<userId>` |
|
||||
| `join-request-room` (requestId) | `request-<requestId>` |
|
||||
| `leave-request-room` (requestId) | leaves `request-<requestId>` |
|
||||
| `join-seller-room` (sellerId) | `seller-<sellerId>` + `sellers` (global) |
|
||||
| `leave-seller-room` (sellerId) | leaves both |
|
||||
| `join-buyer-room` (buyerId) | `buyer-<buyerId>` + `buyers` (global) |
|
||||
| `leave-buyer-room` (buyerId) | leaves both |
|
||||
| `join-chat-room` (chatId) | `chat-<chatId>` |
|
||||
| `leave-chat-room` (chatId) | leaves `chat-<chatId>` |
|
||||
| `user-online` (userId) | joins `user-<userId>`, broadcasts `user-status-change` |
|
||||
| `typing-start` ({ chatId, userId, userName }) | broadcasts `user-typing` to `chat-<chatId>` |
|
||||
| `typing-stop` ({ chatId, userId }) | broadcasts `user-typing` (isTyping=false) |
|
||||
|
||||
Joining a room is unauthenticated — clients are expected to only join their own rooms. Sensitive data is filtered at the REST layer that emits the event.
|
||||
|
||||
## Server → Client events
|
||||
|
||||
Grouped by the service that emits them.
|
||||
|
||||
### Marketplace
|
||||
|
||||
| Event | Room | Payload | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| `new-purchase-request` | `sellers` | `PurchaseRequest` document | `marketplaceController.createPurchaseRequest` |
|
||||
| `new-offer` | `buyer-<buyerId>` | `{ requestId, offer, sellerId }` | `marketplaceController.createSellerOffer` |
|
||||
| `seller-offer-update` | `seller-<sellerId>` (and global on payment confirm) | `{ sellerId, requestId, eventType: "payment-completed" \| "offer-rejected" \| "offer-accepted", data: { offerId, isSelected, paymentId?, transactionHash?, reason? } }` | `marketplaceController`, `shkeeperRoutes`, `shkeeperWebhook`, `SellerOfferService` |
|
||||
| `purchase-request-update` | `request-<requestId>` | `{ type, requestId, status?, paymentId?, txHash?, provider? }` | `marketplaceController`, `PurchaseRequestService`, `shkeeperRoutes`, `paymentCoordinator` |
|
||||
| `request-cancelled` | `user-<buyerId>`, `user-<sellerId>` | `{ requestId, reason }` | `PurchaseRequestService` |
|
||||
| `transaction-completed` | `user-<buyerId>`, `user-<sellerId>` | `{ requestId, paymentId, amount, currency }` | `marketplaceController` |
|
||||
| `delivery-code-generated` | `request-<requestId>` | `{ requestId, deliveryCode }` (only the seller UI uses this) | `DeliveryService` |
|
||||
| `delivery-update` | `request-<requestId>` | `{ requestId, status, carrier?, trackingNumber? }` | `DeliveryService` |
|
||||
| `delivery-confirmed` | `request-<requestId>` | `{ requestId }` | `DeliveryService` |
|
||||
| `buyer-confirmed-delivery` | `user-<sellerId>` | `{ requestId, buyerId }` | `DeliveryService` |
|
||||
| `template-checkout-payment-confirmed` | global + `template-checkout-<id>` | `{ checkoutId, requestIds, paymentId }` | `templateCheckoutWebhook`, `paymentCoordinator` |
|
||||
| `template-checkout-payment-pending` | global | `{ checkoutId }` | `templateCheckoutWebhook` |
|
||||
| `template-checkout-payment-failed` | global | `{ checkoutId, reason }` | `templateCheckoutWebhook` |
|
||||
|
||||
### Payment
|
||||
|
||||
| Event | Room | Payload | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| `payment-created` | global | `{ paymentId, provider, requestId, buyerId, sellerId, amount, currency }` | `shkeeperService`, `decentralizedPaymentService` |
|
||||
| `payment-received` | `user-<sellerId>` | `{ purchaseRequestId, amount, currency, buyerId }` | `paymentRoutes`, `marketplace/routes` |
|
||||
| `payment-update` | global + room-specific | `{ paymentId, status, escrowState?, txHash? }` | `paymentCoordinator` |
|
||||
| `payout-created` | global | `{ payoutId, taskId, sellerId, amount, currency }` | `shkeeperPayoutService` |
|
||||
| `payout-completed` | global, `user-<sellerId>` | `{ payoutId, taskId, txHash }` | `shkeeperPayoutService`, `decentralizedPaymentService` |
|
||||
| `payout-updated` | global | `{ payoutId, status }` | `shkeeperPayoutService` |
|
||||
|
||||
### Chat
|
||||
|
||||
| Event | Room | Payload |
|
||||
| --- | --- | --- |
|
||||
| `new-message` | `chat-<chatId>` | `{ chatId, message: { _id, content, senderId, createdAt, attachments? }, senderId }` |
|
||||
| `messages-read` | `chat-<chatId>` | `{ chatId, userId, upToMessageId, modifiedCount }` |
|
||||
| `message-edited` | `chat-<chatId>` | `{ chatId, messageId, content, editedAt }` |
|
||||
| `message-deleted` | `chat-<chatId>` | `{ chatId, messageId, deletedAt }` |
|
||||
| `participants-added` | `chat-<chatId>` | `{ chatId, addedUserIds }` |
|
||||
| `participant-removed` | `chat-<chatId>` | `{ chatId, removedUserId }` |
|
||||
| `user-typing` | `chat-<chatId>` | `{ userId, userName?, isTyping }` |
|
||||
| `user-status-change` | broadcast | `{ userId, status: "online", lastSeen }` |
|
||||
|
||||
Sources: [`ChatService.ts`](../../backend/src/services/chat/ChatService.ts), [`chatController.ts`](../../backend/src/services/chat/chatController.ts), and `app.ts` socket handlers.
|
||||
|
||||
### Notification
|
||||
|
||||
| Event | Room | Payload |
|
||||
| --- | --- | --- |
|
||||
| `new-notification` | `user-<userId>` | `{ notification: { _id, type, title, body, data, createdAt } }` |
|
||||
| `unread-count-update` | `user-<userId>` | `{ unreadCount }` |
|
||||
|
||||
Source: [`NotificationService.ts`](../../backend/src/services/notification/NotificationService.ts).
|
||||
|
||||
### Points
|
||||
|
||||
| Event | Room | Payload |
|
||||
| --- | --- | --- |
|
||||
| `level-up` | `user-<userId>` | `{ oldLevel, newLevel, lifetimePoints, perks }` |
|
||||
| `referral-reward` | `user-<referrerId>` | `{ referredUserId, points, transactionId }` |
|
||||
| `referral-signup` | `user-<referrerId>` | `{ referredUserId, name, joinedAt }` |
|
||||
|
||||
Sources: [`PointsService.ts`](../../backend/src/services/points/PointsService.ts), [`authController.ts`](../../backend/src/services/auth/authController.ts).
|
||||
|
||||
## Online status
|
||||
|
||||
A client emits `user-online` after connecting; the server broadcasts `user-status-change` (status `"online"`). There is currently **no** matching offline emit on disconnect because the server does not track which userId belongs to which socketId. To implement presence, store the mapping on connect and emit `user-status-change` (status `"offline"`) in the disconnect handler.
|
||||
|
||||
## Reconnection
|
||||
|
||||
Socket.IO defaults are used: exponential backoff starting at 1s, capped at 5s, with jitter. The recommended client policy is:
|
||||
|
||||
1. On reconnect, re-emit every `join-*` for the rooms the user cares about.
|
||||
2. Re-fetch any state that may have moved while disconnected (`GET /api/notifications/unread-count`, the active purchase request, chat history with `?before=<lastSeen>`).
|
||||
3. Treat the first 5 seconds after reconnect as "catching up" in the UI.
|
||||
|
||||
The server does not buffer missed events; if delivery guarantees matter, fall back to REST.
|
||||
|
||||
## Server-side helpers (for service authors)
|
||||
|
||||
```ts
|
||||
import { emitGlobalEvent, emitToRoom } from "@/infrastructure/socket/socketService";
|
||||
|
||||
emitGlobalEvent("new-purchase-request", request);
|
||||
emitToRoom(`request-${requestId}`, "purchase-request-update", { type: "status_changed", status });
|
||||
```
|
||||
|
||||
Use `emitToRoom` whenever you can — it limits the broadcast surface area. Reserve `emitGlobalEvent` for events that genuinely need every connected client (rare).
|
||||
|
||||
## Related
|
||||
|
||||
- [[Authentication API]] (token issuance)
|
||||
- [[Chat API]]
|
||||
- [[Notification API]]
|
||||
- [[Marketplace API]]
|
||||
- [[Payment API]]
|
||||
- [[Real-time Architecture]]
|
||||
262
03 - API Reference/User API.md
Normal file
262
03 - API Reference/User API.md
Normal file
@@ -0,0 +1,262 @@
|
||||
---
|
||||
title: User API
|
||||
tags: [api, user, reference]
|
||||
---
|
||||
|
||||
# User API
|
||||
|
||||
Two routers are mounted for users:
|
||||
|
||||
- `/api/user/*` - the new controller pattern in [`backend/src/services/user/userControllerRoutes.ts`](../../backend/src/services/user/userControllerRoutes.ts) wired to `userController`.
|
||||
- `/api/users/*` - the legacy router in [`backend/src/services/user/userRoutes.ts`](../../backend/src/services/user/userRoutes.ts) (kept for backward compatibility, primarily used by the admin console).
|
||||
|
||||
Address-book CRUD lives on its own service: [`/api/addresses/*`](../../backend/src/services/address/addressRoutes.ts). All endpoints require `Bearer JWT` unless noted. Source of truth model: [[User]].
|
||||
|
||||
## Profile (current user)
|
||||
|
||||
### GET /api/user/profile
|
||||
|
||||
**Description:** Returns the caller's profile.
|
||||
**Auth required:** Bearer JWT
|
||||
**Response 200:**
|
||||
```json
|
||||
{ "success": true, "data": { /* User without password / verification tokens */ } }
|
||||
```
|
||||
**Source:** `userController.getCurrentUserProfile`
|
||||
|
||||
### PUT /api/user/profile
|
||||
|
||||
**Description:** Updates the caller's profile.
|
||||
**Auth required:** Bearer JWT
|
||||
**Request body (whitelisted fields):**
|
||||
```ts
|
||||
{
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
name?: string; // alias for profile.name
|
||||
phone?: string;
|
||||
bio?: string;
|
||||
website?: string;
|
||||
photoURL?: string; // also mirrored to profile.avatar
|
||||
isPublic?: boolean;
|
||||
address?: {
|
||||
street?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
country?: string;
|
||||
postalCode?: string;
|
||||
};
|
||||
preferences?: {
|
||||
language?: "en" | "fa" | "ar";
|
||||
currency?: "USD" | "EUR" | "IRR" | "AED";
|
||||
notifications?: { email?: boolean; sms?: boolean; push?: boolean };
|
||||
};
|
||||
}
|
||||
```
|
||||
**Response 200:** Updated user.
|
||||
|
||||
### GET /api/users/profile
|
||||
|
||||
**Description:** Legacy equivalent of the above. Returns the full sanitized user document.
|
||||
**Auth required:** Bearer JWT
|
||||
|
||||
### GET /api/users/profile/:userId
|
||||
|
||||
**Description:** Public profile by id. If `profile.isPublic === false` and caller is not the owner or admin, only `firstName`, `lastName`, `avatar`, `role` are returned.
|
||||
**Auth required:** Bearer JWT
|
||||
|
||||
## Avatar upload
|
||||
|
||||
Avatar upload is handled by the [[File API]]:
|
||||
|
||||
`POST /api/files/upload/avatar` (multipart `avatar`) returns a URL that the caller then writes to `profile.avatar` via `PUT /api/user/profile`.
|
||||
|
||||
## Wallet address
|
||||
|
||||
### GET /api/user/wallet-address
|
||||
|
||||
**Description:** Returns the caller's stored EVM wallet address (or `null`).
|
||||
**Auth required:** Bearer JWT
|
||||
**Response 200:** `{ "success": true, "data": { "walletAddress": "0x..." | null } }`
|
||||
|
||||
### PATCH /api/user/wallet-address
|
||||
|
||||
**Description:** Verifies an EIP-191 signed message and stores `profile.walletAddress`. The server uses `ethers.verifyMessage(message, signature)` and rejects if the recovered address does not match.
|
||||
**Auth required:** Bearer JWT
|
||||
**Request body:**
|
||||
```ts
|
||||
{
|
||||
walletAddress: string; // 0x-prefixed 40-hex
|
||||
signature: string; // signed `message`
|
||||
message: string; // human-readable challenge text
|
||||
}
|
||||
```
|
||||
**Response 200:** `{ "success": true, "data": { "walletAddress": "0x..." } }`
|
||||
**Errors:**
|
||||
- `400` missing fields, malformed address, signature mismatch
|
||||
- `404` user not found
|
||||
|
||||
The legacy alias `PATCH /api/users/wallet-address` performs the same logic.
|
||||
|
||||
## Contacts and search
|
||||
|
||||
### GET /api/users/contacts
|
||||
|
||||
**Description:** Returns the users the caller is allowed to chat with based on role:
|
||||
- `buyer` → sees `seller` + `admin`
|
||||
- `seller` → sees `buyer` + `admin`
|
||||
- `admin` → sees everyone
|
||||
**Auth required:** Bearer JWT
|
||||
**Response 200:** `{ "success": true, "data": { "contacts": [...], "count": N } }`
|
||||
|
||||
### GET /api/users/search?q=<term>&role=<role>
|
||||
|
||||
**Description:** Case-insensitive search across `firstName`/`lastName`/`email`. Returns up to 20 active users.
|
||||
**Auth required:** Bearer JWT
|
||||
**Errors:** `400` if `q.length < 2`.
|
||||
|
||||
### GET /api/users?role=...&isActive=true&search=...&page=1&limit=50
|
||||
|
||||
**Description:** Paginated user directory (legacy, no admin gate). See pagination conventions in [[API Overview]].
|
||||
**Auth required:** Bearer JWT
|
||||
|
||||
## Admin: user management
|
||||
|
||||
These are duplicated across the two routers. The newer controller variants live under `/api/user/admin/*`; the legacy bodies live under `/api/users/admin/*`. All require `req.user.role === 'admin'` (the legacy routes check inline; the controller routes only check `authenticateToken` and the controller enforces the role).
|
||||
|
||||
### POST /api/user/admin/create
|
||||
|
||||
**Description:** Admin creates a user with a chosen role and verification state.
|
||||
**Auth required:** Bearer JWT (admin)
|
||||
**Request body:**
|
||||
```ts
|
||||
{
|
||||
email: string;
|
||||
password: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
role?: "buyer" | "seller" | "admin"; // default "buyer"
|
||||
isActive?: boolean; // default true
|
||||
isVerified?: boolean; // default false
|
||||
profile?: { /* free-form */ };
|
||||
}
|
||||
```
|
||||
**Response 201:** `{ success, data: { user } }`
|
||||
**Errors:** `400` missing fields, `403` non-admin, `409` email exists.
|
||||
|
||||
### DELETE /api/user/admin/:userId
|
||||
|
||||
**Description:** Hard-delete a user. Prevents self-deletion and deleting other admins.
|
||||
**Auth required:** Bearer JWT (admin)
|
||||
**Response 200:** `{ success, data: { deletedUserId } }`
|
||||
**Errors:** `400` self-delete, `403` admin-on-admin, `404` not found.
|
||||
|
||||
### PATCH /api/user/admin/:userId/status
|
||||
|
||||
**Description:** Activate / suspend a user.
|
||||
**Auth required:** Bearer JWT (admin)
|
||||
**Request body:** `{ isActive: boolean; reason?: string }`
|
||||
**Response 200:** `{ success, data: { user: { _id, isActive, statusUpdatedAt } } }`
|
||||
|
||||
### PATCH /api/user/admin/:userId/toggle-status
|
||||
|
||||
**Description:** Flip active/suspended without explicit body.
|
||||
**Auth required:** Bearer JWT (admin)
|
||||
|
||||
### PATCH /api/user/admin/:userId/role
|
||||
|
||||
**Description:** Change a user's role.
|
||||
**Auth required:** Bearer JWT (admin)
|
||||
**Request body:** `{ role: "buyer" | "seller" | "admin"; reason?: string }`
|
||||
**Errors:** `400` invalid role.
|
||||
|
||||
### GET /api/user/admin/list
|
||||
|
||||
**Description:** Paginated admin user list with filters.
|
||||
**Auth required:** Bearer JWT (admin)
|
||||
**Query params:** `role`, `isActive`, `isVerified`, `search`, `page`, `limit`, `sortBy`, `sortOrder`
|
||||
**Response 200:** `{ success, data: { users, pagination, stats: { totalUsers, activeUsers, verifiedUsers, buyers, sellers, admins } } }`
|
||||
|
||||
### GET /api/user/admin/:userId/dependencies
|
||||
|
||||
**Description:** Returns a count of related entities (purchase requests, offers, payments) before a destructive admin operation.
|
||||
**Auth required:** Bearer JWT (admin)
|
||||
|
||||
### GET /api/users/admin/stats
|
||||
|
||||
**Description:** Aggregated user stats — total/active/verified counts, role distribution, activity buckets (24h / 7d / 30d).
|
||||
**Auth required:** Bearer JWT (admin)
|
||||
|
||||
### GET /api/users/admin/:userId
|
||||
|
||||
**Description:** Fetch a single user by id (admin view, includes preferences and last-login).
|
||||
**Auth required:** Bearer JWT (admin)
|
||||
|
||||
### PUT /api/users/admin/:userId
|
||||
|
||||
**Description:** Mass update a user document (admin override).
|
||||
**Auth required:** Bearer JWT (admin)
|
||||
|
||||
### PUT /api/users/admin/update/:email
|
||||
|
||||
**Description:** Same as above but keyed on email.
|
||||
**Auth required:** Bearer JWT (admin)
|
||||
|
||||
### PATCH /api/users/admin/:userId/password
|
||||
|
||||
**Description:** Admin forces a new password. Wipes `refreshTokens` so all sessions are invalidated.
|
||||
**Auth required:** Bearer JWT (admin)
|
||||
**Request body:** `{ newPassword: string; reason?: string }`
|
||||
|
||||
### POST /api/users/admin/:userId/resend-verification
|
||||
|
||||
**Description:** Regenerate the 8-digit email verification code and re-send the verification email.
|
||||
**Auth required:** Bearer JWT (admin)
|
||||
**Errors:** `400` user already verified.
|
||||
|
||||
## Address book
|
||||
|
||||
Source: [`backend/src/services/address/addressRoutes.ts`](../../backend/src/services/address/addressRoutes.ts), model: [[Address]].
|
||||
|
||||
### GET /api/addresses
|
||||
|
||||
**Description:** List the caller's addresses.
|
||||
**Auth required:** Bearer JWT
|
||||
**Response 200:** `{ success: true, data: [Address, ...] }`
|
||||
|
||||
### POST /api/addresses
|
||||
|
||||
**Description:** Create a new address. First address auto-becomes primary.
|
||||
**Auth required:** Bearer JWT
|
||||
**Request body:**
|
||||
```ts
|
||||
{
|
||||
fullName: string;
|
||||
phone: string;
|
||||
street: string;
|
||||
city: string;
|
||||
state?: string;
|
||||
country: string;
|
||||
postalCode?: string;
|
||||
isPrimary?: boolean;
|
||||
notes?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### PUT /api/addresses/:addressId
|
||||
|
||||
**Description:** Update an address.
|
||||
**Auth required:** Bearer JWT
|
||||
**Errors:** `404` not owned by user.
|
||||
|
||||
### DELETE /api/addresses/:addressId
|
||||
|
||||
**Description:** Delete an address. If it was the primary, another address is promoted.
|
||||
**Auth required:** Bearer JWT
|
||||
|
||||
### PATCH /api/addresses/:addressId/primary
|
||||
|
||||
**Description:** Promote an address to primary; demotes the previous primary.
|
||||
**Auth required:** Bearer JWT
|
||||
|
||||
See [[Address]] for the schema, and [[Marketplace API]] for how purchase requests reference an address.
|
||||
187
04 - Flows/Authentication Flow.md
Normal file
187
04 - Flows/Authentication Flow.md
Normal file
@@ -0,0 +1,187 @@
|
||||
---
|
||||
title: Authentication Flow
|
||||
tags: [flow, auth, jwt, login, security]
|
||||
related_models: ["[[User]]", "[[TempVerification]]"]
|
||||
related_apis: ["[[Auth API]]", "POST /api/auth/login", "POST /api/auth/refresh-token", "POST /api/auth/logout"]
|
||||
---
|
||||
|
||||
# Authentication Flow
|
||||
|
||||
End-to-end specification for **email + password** authentication, JWT issuance, token lifecycle, refresh-token rotation, and logout cleanup. This is the most-used auth path in the marketplace and underpins every protected API call and Socket.IO room subscription.
|
||||
|
||||
## Actors
|
||||
|
||||
- **User (Buyer / Seller / Admin)** – submits credentials via the frontend.
|
||||
- **Frontend (Next.js)** – the JWT sign-in view in `frontend/src/auth/view/jwt/jwt-sign-in-view.tsx`, which delegates to `signInWithPassword()` in `frontend/src/auth/context/jwt/action.ts`.
|
||||
- **Backend (Express)** – `AuthController.login` in `backend/src/services/auth/authController.ts`.
|
||||
- **MongoDB** – `User` collection (refresh tokens are appended to `user.refreshTokens[]`).
|
||||
- **Redis** – session store (`sessionService.createSession`) and login-attempt rate-limiter (`rateLimitService.checkLoginAttempts`).
|
||||
- **Socket.IO** – not directly involved in login, but the issued JWT is later used to join `user-${userId}` rooms (see [[Notification Flow]] and [[Chat Flow]]).
|
||||
|
||||
## Preconditions
|
||||
|
||||
- The user has already completed [[Registration Flow]] and their `User.isEmailVerified === true`.
|
||||
- The user's `User.status === "active"` (soft-deleted accounts have status `"deleted"`).
|
||||
- Network connectivity is available (the frontend uses `NetworkUtils.isOnline()` from `frontend/src/auth/utils/error-handler.ts`).
|
||||
- `localStorage` is available — the frontend rejects the request early via `StorageUtils.isAvailable()` if storage is blocked.
|
||||
- Backend env vars `JWT_SECRET`, `JWT_EXPIRES_IN`, `REFRESH_TOKEN_EXPIRES_IN` are set (`backend/src/services/auth/authService.ts:19-21`).
|
||||
|
||||
## Step-by-step narrative
|
||||
|
||||
1. **User fills the sign-in form** at `/auth/jwt/sign-in` and clicks "Sign in". The form is implemented in `frontend/src/auth/view/jwt/jwt-sign-in-view.tsx`.
|
||||
2. **Client-side guards**: `signInWithPassword()` (`action.ts:32-116`) verifies the browser is online and `localStorage` is writable; otherwise it throws a typed `AuthErrorHandler` error.
|
||||
3. **HTTP request**: The frontend POSTs `{ email, password }` to `POST /api/auth/login` (resolved by `endpoints.auth.login` in `frontend/src/lib/axios.ts`). An `AbortController` is armed with a 60-second timeout.
|
||||
4. **Validation middleware** runs `loginValidation` (`backend/src/services/auth/authValidation.ts`) — wires into Express via `authRoutes.ts:22`.
|
||||
5. **Rate limiting**: `rateLimitService.checkLoginAttempts(email, 5, 15*60)` is called (`authController.ts:173`). Five failures within 15 minutes returns `429 TOO_MANY_ATTEMPTS`. Counters live in Redis so they survive restarts.
|
||||
6. **User lookup**: `User.findOne({ email, status: "active" }).select("+password")` — `password` is `select: false` by default in the schema and must be explicitly projected.
|
||||
7. **Password comparison**: `authService.comparePassword()` invokes `bcrypt.compare()` (cost factor 12 — see `authService.ts:102-105`). Constant-time per bcrypt's design.
|
||||
8. **Email-verification gate**: If `!user.isEmailVerified`, returns `403 EMAIL_NOT_VERIFIED` with `needsVerification: true`. The frontend intercepts this in `action.ts:104-111` and redirects to `/auth/jwt/verify?email=...`.
|
||||
9. **Reset attempts**: On success, `rateLimitService.resetLoginAttempts(email)` wipes the Redis counter.
|
||||
10. **Last-login stamp**: `user.lastLoginAt = new Date()` is saved.
|
||||
11. **Token issuance**:
|
||||
- **Access token** — `authService.generateToken()` builds a JWT with `{ id, email, role, isEmailVerified, iat }`, signed with `HS256`, `issuer: 'marketplace-backend'`, `audience: 'marketplace-users'`, default `expiresIn` from config.
|
||||
- **Refresh token** — `authService.generateRefreshToken()` issues a separate JWT with `{ id, type: 'refresh' }` and a longer TTL.
|
||||
12. **Refresh-token persistence**: The new refresh token is appended to the `User.refreshTokens` array (`authController.ts:230-231`). This array is the server-side allow-list — only tokens present here can be used to mint new access tokens.
|
||||
13. **Redis session**: `sessionService.createSession(accessToken, userId, email, role, ip, userAgent, 86400)` stores a 24h session record keyed by the access token (`authController.ts:235-247`). Failures are logged but do **not** block login.
|
||||
14. **Response**: `200 OK` with `{ user: user.toJSON(), tokens: { accessToken, refreshToken } }`. `toJSON()` strips `password`, `refreshTokens`, and verification codes (see User model `toJSON` transform).
|
||||
15. **Client-side storage**: The frontend writes both tokens to `localStorage` via `StorageUtils.safeSet()` — keys `accessToken` and `refreshToken` (`action.ts:77-82`).
|
||||
|
||||
> [!warning] Token storage is `localStorage`, not cookies
|
||||
> Tokens are persisted in `window.localStorage`. This is intentional (the API is a separate origin and the app is fully SPA-like), but it means tokens are reachable from any script running on the page. Mitigations in place: strict CSP via `helmet`, no third-party scripts in the auth views, and short access-token TTL with refresh rotation. There are **no httpOnly auth cookies**.
|
||||
|
||||
16. **Axios interceptor** (`frontend/src/lib/axios.ts`) attaches `Authorization: Bearer ${accessToken}` to every subsequent request and, on `401/403`, automatically calls the refresh flow described below.
|
||||
17. **Socket.IO bootstrap**: After login, the dashboard layout connects to Socket.IO and emits `join-user-room`, `join-buyer-room`/`join-seller-room` based on `user.role`. See `backend/src/app.ts:83-126`.
|
||||
|
||||
## Sequence diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor U as User
|
||||
participant FE as Frontend (Next.js)
|
||||
participant BE as Backend (Express)
|
||||
participant DB as MongoDB
|
||||
participant R as Redis
|
||||
participant IO as Socket.IO
|
||||
|
||||
U->>FE: Enter email + password, click Sign in
|
||||
FE->>FE: NetworkUtils.isOnline() / StorageUtils.isAvailable()
|
||||
FE->>BE: POST /api/auth/login { email, password }
|
||||
BE->>R: rateLimitService.checkLoginAttempts(email)
|
||||
R-->>BE: { allowed: true, remaining }
|
||||
BE->>DB: User.findOne({ email, status: "active" }).select("+password")
|
||||
DB-->>BE: user document
|
||||
BE->>BE: bcrypt.compare(password, user.password)
|
||||
alt password invalid
|
||||
BE-->>FE: 401 Invalid credentials
|
||||
else email not verified
|
||||
BE-->>FE: 403 EMAIL_NOT_VERIFIED
|
||||
FE-->>U: Redirect /auth/jwt/verify
|
||||
else success
|
||||
BE->>R: rateLimitService.resetLoginAttempts(email)
|
||||
BE->>DB: user.lastLoginAt = now; user.refreshTokens.push(refresh)
|
||||
BE->>BE: generateToken(authUser) / generateRefreshToken(authUser)
|
||||
BE->>R: sessionService.createSession(accessToken, ...)
|
||||
BE-->>FE: 200 { user, tokens: { accessToken, refreshToken } }
|
||||
FE->>FE: localStorage.setItem('accessToken' / 'refreshToken')
|
||||
FE->>IO: socket.emit('join-user-room', userId)
|
||||
FE-->>U: Redirect to /dashboard
|
||||
end
|
||||
```
|
||||
|
||||
## API calls
|
||||
|
||||
| Method | Endpoint | Source |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/auth/login` | `backend/src/services/auth/authRoutes.ts:22` → `authController.login` |
|
||||
| `POST` | `/api/auth/refresh-token` | `authRoutes.ts:24-27` → `authController.refreshToken` |
|
||||
| `POST` | `/api/auth/logout` | `authRoutes.ts:68` → `authController.logout` (protected) |
|
||||
| `GET` | `/api/auth/profile` | `authRoutes.ts:69` → `authController.getProfile` |
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`users` collection**: `lastLoginAt` updated; `refreshTokens` array gains one entry per successful login or refresh.
|
||||
- No write at all on a failed attempt (only Redis counter increments).
|
||||
- On `changePassword` / `resetPassword`: `refreshTokens` is reset to `[]` (forces re-login on every device — `authController.ts:600` and `:685`).
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
- Login itself emits nothing. After the response, the frontend emits the **client-side** events `join-user-room`, `join-buyer-room` or `join-seller-room` to subscribe to targeted notifications.
|
||||
|
||||
## Side effects
|
||||
|
||||
- **Redis session**: 24-hour key holding `{ userId, email, role, ip, userAgent }`. Used for forced logout (admin can call `sessionService.deleteSession(token)`).
|
||||
- **Redis rate-limit counter**: TTL 15 min, reset on success.
|
||||
- **No email** is sent on a normal login (no "new sign-in" notification today — opportunity for future enhancement).
|
||||
- **Sentry**: any unexpected exception bubbles to `Sentry.setupExpressErrorHandler` (`app.ts:351`).
|
||||
|
||||
## Refresh-token flow
|
||||
|
||||
The access token is short-lived. When a protected request returns `401 TOKEN_INVALID` or `403`, the axios interceptor calls:
|
||||
|
||||
1. `POST /api/auth/refresh-token` with `{ refreshToken }` from `localStorage`.
|
||||
2. Backend `authController.refreshToken` (`:263-313`) verifies the token via `verifyRefreshToken`, checks it is **still present in `user.refreshTokens[]`**, then issues a brand-new access **and** refresh token.
|
||||
3. The old refresh token is **removed** from the array and the new one is pushed — implementing **refresh-token rotation**. A leaked-but-stale token therefore becomes invalid the moment the legitimate user refreshes.
|
||||
4. The new pair is written back to `localStorage` and the original failed request is retried.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant FE as Frontend axios
|
||||
participant BE as Backend
|
||||
participant DB as MongoDB
|
||||
|
||||
FE->>BE: GET /api/marketplace/... (Bearer access)
|
||||
BE-->>FE: 401 TOKEN_INVALID
|
||||
FE->>BE: POST /api/auth/refresh-token { refreshToken }
|
||||
BE->>BE: verifyRefreshToken(refreshToken)
|
||||
BE->>DB: User.findById(decoded.id); ensure refresh ∈ user.refreshTokens
|
||||
BE->>BE: Generate new access + refresh tokens
|
||||
BE->>DB: user.refreshTokens = [...minus old, new]
|
||||
BE-->>FE: 200 { tokens: { accessToken, refreshToken } }
|
||||
FE->>BE: GET /api/marketplace/... (Bearer new access) — retry
|
||||
```
|
||||
|
||||
## Logout flow
|
||||
|
||||
1. Frontend `signOut()` (`action.ts:146-176`) reads `refreshToken` from `localStorage` and POSTs `/api/auth/logout` with a 10-second timeout.
|
||||
2. Backend `authController.logout` (`:316-344`) removes the refresh token from `user.refreshTokens[]` and calls `sessionService.deleteSession(accessToken)`.
|
||||
3. **Always-clear**: the frontend's `finally` block removes both `accessToken` and `refreshToken` from `localStorage` regardless of network success — meaning even an offline logout effectively signs the user out locally.
|
||||
|
||||
> [!tip] Force-logout an entire user
|
||||
> Setting `user.refreshTokens = []` in MongoDB instantly invalidates all sessions on next refresh. `changePassword`, `resetPassword`, and `deleteAccount` all do this.
|
||||
|
||||
## Error / edge cases
|
||||
|
||||
- **Wrong password** → `401 Invalid credentials` (intentionally vague — no distinction between "unknown email" and "wrong password").
|
||||
- **Email unverified** → `403 EMAIL_NOT_VERIFIED`; frontend auto-redirects to verify page.
|
||||
- **5+ failures in 15 min** → `429 TOO_MANY_ATTEMPTS`; only an admin can manually clear via Redis.
|
||||
- **Network timeout** → axios `AbortController` cancels at 60s; frontend shows a typed error and the user can retry.
|
||||
- **Redis down** → login still succeeds (session creation is best-effort, wrapped in try/catch at `authController.ts:234-247`). Rate limiting falls back to the in-memory map in `authService.ts:113-145` if `rateLimitService` itself throws.
|
||||
- **Stale refresh token** (rotated by another device) → `403 Invalid refresh token`. Frontend signs out and redirects to sign-in.
|
||||
- **JWT signature mismatch** (secret rotated) → all sessions invalidated server-side; clients clear tokens on first 401.
|
||||
- **Token issued for another audience/issuer** → `verifyToken` returns `null` (`authService.ts:60-79`), middleware returns `403 TOKEN_INVALID`.
|
||||
- **Refresh token used as access token** → blocked by the `if (decoded.type === 'refresh') return null` check in `verifyToken` (`authService.ts:67`). This is critical: a leaked refresh token alone cannot read protected data.
|
||||
- **Soft-deleted account** → `User.findOne({ status: "active" })` filter excludes deleted accounts; login fails as if the email did not exist.
|
||||
|
||||
> [!warning] Constant-time response is approximate
|
||||
> Today we return `401` immediately when the user is missing, before running bcrypt. This is a timing oracle that lets an attacker enumerate registered emails by response-time analysis. Mitigation tracked separately — the recommendation is to always run a dummy bcrypt compare on missing users.
|
||||
|
||||
## Linked flows
|
||||
|
||||
- [[Registration Flow]] — produces the `User` document this flow consumes.
|
||||
- [[Password Reset Flow]] — alternate entry into the account if credentials are lost.
|
||||
- [[Google OAuth Flow]] — parallel auth path that produces equivalent tokens.
|
||||
- [[Passkey (WebAuthn) Flow]] — passwordless alternative.
|
||||
- [[Chat Flow]], [[Notification Flow]] — both consume the access token to authorise Socket.IO rooms.
|
||||
|
||||
## Source files
|
||||
|
||||
- Backend: `backend/src/services/auth/authController.ts:161-260`
|
||||
- Backend: `backend/src/services/auth/authService.ts:24-99`
|
||||
- Backend: `backend/src/services/auth/authRoutes.ts:22`
|
||||
- Backend: `backend/src/services/redis/sessionService.ts`
|
||||
- Backend: `backend/src/services/redis/rateLimitService.ts`
|
||||
- Frontend: `frontend/src/auth/context/jwt/action.ts:32-176`
|
||||
- Frontend: `frontend/src/auth/view/jwt/jwt-sign-in-view.tsx`
|
||||
- Frontend: `frontend/src/lib/axios.ts` (interceptor + endpoints)
|
||||
188
04 - Flows/Chat Flow.md
Normal file
188
04 - Flows/Chat Flow.md
Normal file
@@ -0,0 +1,188 @@
|
||||
---
|
||||
title: Chat Flow
|
||||
tags: [flow, chat, socket-io, messaging]
|
||||
related_models: ["[[Chat]]", "[[Message]]", "[[User]]"]
|
||||
related_apis: ["POST /api/chat", "POST /api/chat/:chatId/messages", "GET /api/chat/:chatId/messages", "POST /api/chat/:chatId/read"]
|
||||
---
|
||||
|
||||
# Chat Flow
|
||||
|
||||
Real-time messaging between buyer & seller (direct), three-way dispute mediation (group), and user-to-support (support). Backed by `Chat` documents with embedded `messages[]` and a Socket.IO room per chat for live updates.
|
||||
|
||||
## Actors
|
||||
|
||||
- **User A (initiator)** — typically a buyer.
|
||||
- **User B (recipient)** — typically a seller.
|
||||
- **Support agent** — for `type: 'support'` chats (user is `support@amn.gg`).
|
||||
- **Admin** — added as a third participant in dispute chats.
|
||||
- **Frontend** — `frontend/src/sections/chat/` (chat list, conversation view, message composer).
|
||||
- **Backend** — `ChatService` (`backend/src/services/chat/ChatService.ts`), routes under `/api/chat`.
|
||||
- **MongoDB** — `chats` collection with embedded `messages`, `participants`, `unreadCounts`, `settings`, `metadata`.
|
||||
- **Socket.IO** — events `new-message`, `chat-notification`, `messages-read`, `user-typing`, `user-status-change`.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- Both parties authenticated.
|
||||
- For `direct` chats tied to a purchase request, the request exists and both users are participants in it.
|
||||
|
||||
## Chat lifecycle
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Created: ChatService.createChat\n(or auto on first contact)
|
||||
Created --> Active: messages flowing
|
||||
Active --> Active: send / read / typing
|
||||
Active --> Archived: settings.isArchived=true
|
||||
Archived --> Active: unarchive
|
||||
Active --> [*]: chat deleted (rare)
|
||||
```
|
||||
|
||||
## Step-by-step narrative
|
||||
|
||||
### Creation
|
||||
|
||||
1. **Direct chat (find-or-create)** — when buyer clicks "Chat with seller" on a request detail, frontend POSTs `POST /api/chat` with `{ type: 'direct', participantIds: [buyer, seller], relatedTo: { type: 'PurchaseRequest', id } }`.
|
||||
2. `ChatService.createChat` (`ChatService.ts:90-192`):
|
||||
- For `direct` with exactly 2 participants, runs `Chat.findOne({ type: 'direct', 'participants.userId': { $all: [...] }, 'participants.isActive': true })` and returns the existing chat if found.
|
||||
- Otherwise creates a new `Chat` with `participants` (each with `role:'member'`, `joinedAt`, `isActive:true`), zeroed `unreadCounts`, default `settings`, `metadata.createdBy`.
|
||||
- Appends a system welcome message (`messageType: 'system'`).
|
||||
- If `relatedTo.type === 'PurchaseRequest'`, also writes `"چت برای درخواست خرید \"{title}\" ایجاد شد"` system line.
|
||||
- Re-loads with `populate('participants.userId', 'firstName lastName profile.avatar email')` for the response.
|
||||
3. **Group chat (dispute)** — same pattern, but `type: 'group'`, all three participants (buyer, seller, admin) added (admin is added later by `DisputeService.assignAdmin`).
|
||||
4. **Support chat** — `ChatService.createSupportChat(userId)` (`:41-88`) auto-discovers `User.findOne({ email: 'support@amn.gg' })` and creates a `type: 'support'` chat with a welcome message. Idempotent.
|
||||
5. **Post-payment auto-chat** — when SHKeeper confirms payment, `shkeeperWebhook.ts:606-618` calls `chatService.createChat` to ensure a direct chat exists between buyer and winning seller.
|
||||
|
||||
### Joining the room (real-time)
|
||||
|
||||
6. On chat page mount, the frontend emits `socket.emit('join-chat-room', chatId)` (`backend/src/app.ts:130-133`). The socket joins room `chat-{chatId}`.
|
||||
7. Optionally `socket.emit('user-online', userId)` so other clients see green status (`app.ts:161-169`).
|
||||
|
||||
### Sending a message
|
||||
|
||||
8. User types and hits send. Frontend POSTs `POST /api/chat/:chatId/messages` with `{ content, messageType?, fileUrl?, fileName?, fileSize?, replyTo? }`.
|
||||
9. `ChatService.sendMessage` (`:195-260`):
|
||||
- Loads chat, verifies the sender is in `participants[]` and `isActive`.
|
||||
- Builds `Message`: `{ senderId, content, messageType: 'text', fileUrl?, fileName?, fileSize?, replyTo?, timestamp, isRead: false, isEdited: false }`.
|
||||
- `chat.addMessage(messageData)` — schema method that pushes the message, updates `metadata.lastActivity`, increments `unreadCounts` for non-senders, and updates the cached `lastMessage` summary.
|
||||
- Persists.
|
||||
- Emits **`new-message`** to `chat-{chatId}` (everyone in the room sees the message immediately).
|
||||
- Emits **`chat-notification`** to each non-sender's `user-{userId}` room (drives the chat-list unread badge and the toast/notification bell if the user is not currently viewing the chat).
|
||||
10. Frontend reconciles its own message list (the sender either appends optimistically and then matches the server echo or waits for the round-trip).
|
||||
|
||||
### Attachments
|
||||
|
||||
11. To attach a file, the user picks a file → frontend calls `chatService.uploadChatFile(chatId, file)` (or the equivalent `POST /api/chat/:chatId/upload`) — backend persists the upload via `fileService` (returns `{ fileUrl, fileName, fileSize }`).
|
||||
12. The send-message call then includes `messageType: 'image' | 'file'` and the file metadata. Files are served from `/uploads`.
|
||||
|
||||
### Read receipts
|
||||
|
||||
13. When the user opens a chat, frontend POSTs `POST /api/chat/:chatId/read` (optionally with `messageIds: string[]`).
|
||||
14. `ChatService.markMessagesAsRead` (`:438-483`):
|
||||
- Calls `chat.markAsRead(userId, messageObjectIds)` (schema method that flips `isRead` on the relevant messages and zeros the user's `unreadCounts` entry).
|
||||
- Emits **`messages-read`** to `chat-{chatId}` so the sender sees the double-tick.
|
||||
|
||||
### Typing indicator
|
||||
|
||||
15. On `input` events, frontend emits `socket.emit('typing-start', { chatId, userId, userName })`; on idle/blur emits `typing-stop`.
|
||||
16. Backend `app.ts:142-158` relays to `chat-{chatId}` as `user-typing` (excluding the sender). No DB persistence.
|
||||
|
||||
## Sequence diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor A as User A
|
||||
actor B as User B
|
||||
participant FE_A as Frontend A
|
||||
participant FE_B as Frontend B
|
||||
participant BE as Backend
|
||||
participant DB as MongoDB
|
||||
participant IO as Socket.IO
|
||||
|
||||
A->>FE_A: Open conversation
|
||||
FE_A->>BE: POST /api/chat {type:direct, participantIds, relatedTo}
|
||||
BE->>DB: find-or-create Chat
|
||||
BE-->>FE_A: { chat }
|
||||
FE_A->>IO: emit 'join-chat-room' chatId
|
||||
FE_B->>IO: emit 'join-chat-room' chatId (when B opens too)
|
||||
|
||||
A->>FE_A: type & send
|
||||
FE_A->>BE: POST /api/chat/{id}/messages {content}
|
||||
BE->>DB: chat.addMessage; metadata.lastActivity=now
|
||||
BE->>IO: emit chat-{id} 'new-message'
|
||||
IO-->>FE_A: 'new-message' (echo)
|
||||
IO-->>FE_B: 'new-message' (live)
|
||||
BE->>IO: emit user-{B} 'chat-notification' (badge)
|
||||
|
||||
B->>FE_B: opens chat
|
||||
FE_B->>BE: POST /api/chat/{id}/read
|
||||
BE->>DB: chat.markAsRead(B)
|
||||
BE->>IO: emit chat-{id} 'messages-read'
|
||||
IO-->>FE_A: 'messages-read' (double-tick)
|
||||
|
||||
A->>IO: emit 'typing-start'
|
||||
IO-->>FE_B: 'user-typing' {isTyping:true}
|
||||
```
|
||||
|
||||
## API calls
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/chat` | Find-or-create chat |
|
||||
| `GET` | `/api/chat` | List user's chats |
|
||||
| `GET` | `/api/chat/:chatId/messages` | Paginated message history |
|
||||
| `POST` | `/api/chat/:chatId/messages` | Send message |
|
||||
| `POST` | `/api/chat/:chatId/upload` | Upload attachment |
|
||||
| `POST` | `/api/chat/:chatId/read` | Mark read |
|
||||
| `POST` | `/api/chat/support` | Create/get support chat |
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`chats`**: insert on create; `$push` into `messages[]`; `$set` `metadata.lastActivity`; `unreadCounts.$.count` increment per recipient; `settings.isArchived` toggled; `participants.$.isActive` flipped on leave.
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
- **`new-message`** → `chat-{chatId}` (every message).
|
||||
- **`chat-notification`** → `user-{recipientId}` for non-senders (badge).
|
||||
- **`messages-read`** → `chat-{chatId}` after read mark.
|
||||
- **`user-typing`** → `chat-{chatId}` (relayed by `app.ts`).
|
||||
- **`user-status-change`** → broadcast when `user-online` is emitted.
|
||||
- **`new-message`** (system) for system welcome lines on chat creation.
|
||||
|
||||
## Side effects
|
||||
|
||||
- **`metadata.lastActivity`** drives the chat-list sort order.
|
||||
- **`lastMessage`** cache lets the chat-list render previews without loading the entire `messages[]` array.
|
||||
- **`unreadCounts`** is the source-of-truth for badge counts; resetting on read also drives global unread totals.
|
||||
- **Embedded messages array** can grow large; consider migrating to a separate `messages` collection if conversations exceed several thousand messages.
|
||||
|
||||
## Error / edge cases
|
||||
|
||||
- **Sender not a participant** → `403 "User is not a participant in this chat"` (`:209-211`).
|
||||
- **Chat not found** → `404` on `getChatMessages`.
|
||||
- **Direct duplicate** → idempotent — `createChat` returns existing chat.
|
||||
- **Empty content** — currently allowed (system messages are typically non-empty though); add a min-length validator if needed.
|
||||
- **Files served from `/uploads`** — anonymous access is allowed. Sensitive attachments (KYC docs, dispute evidence) should be protected by signed URLs or per-user authorisation; currently any user with the URL can fetch.
|
||||
- **Long conversations** — `getChatMessages` slices an in-memory copy of the messages array. For >10k messages this is inefficient. Use aggregation `$slice` or a separate collection.
|
||||
- **Race on `markAsRead`** — two parallel reads may double-zero the counter, which is harmless.
|
||||
- **Typing indicator spam** — clients should debounce `typing-start` and emit `typing-stop` on a 2s idle.
|
||||
|
||||
> [!warning] Notification message uses placeholder sender name
|
||||
> `ChatService.sendMessage` posts `chat-notification` with `senderName: "کاربر"` (`:248`) — the literal Persian word for "user". Resolve `senderName` from `participant.userId.firstName` for a better UX.
|
||||
|
||||
## Linked flows
|
||||
|
||||
- [[Notification Flow]] — `chat-notification` is one of the inputs.
|
||||
- [[Negotiation Flow]] — uses chats heavily.
|
||||
- [[Dispute Flow]] — three-way group chat.
|
||||
- [[Authentication Flow]] — supplies the `user-${userId}` rooms via `join-user-room`.
|
||||
|
||||
## Source files
|
||||
|
||||
- Backend: `backend/src/services/chat/ChatService.ts`
|
||||
- Backend: `backend/src/services/chat/chatController.ts`
|
||||
- Backend: `backend/src/services/chat/routes.ts` (under `/api/chat`)
|
||||
- Backend: `backend/src/models/Chat.ts`
|
||||
- Backend: `backend/src/app.ts:130-179` (Socket.IO chat handlers)
|
||||
- Frontend: `frontend/src/sections/chat/`
|
||||
- Frontend: `frontend/src/contexts/socket-context.tsx` (or equivalent socket provider)
|
||||
127
04 - Flows/Delivery Confirmation Flow.md
Normal file
127
04 - Flows/Delivery Confirmation Flow.md
Normal file
@@ -0,0 +1,127 @@
|
||||
---
|
||||
title: Delivery Confirmation Flow
|
||||
tags: [flow, delivery, escrow-release, code]
|
||||
related_models: ["[[PurchaseRequest]]", "[[Payment]]"]
|
||||
related_apis: ["POST /api/marketplace/purchase-requests/:id/delivery-code", "POST /api/marketplace/purchase-requests/:id/verify-delivery"]
|
||||
---
|
||||
|
||||
# Delivery Confirmation Flow
|
||||
|
||||
After the escrow is funded ([[Payment Flow - SHKeeper]] / [[Payment Flow - DePay & Web3]]) and the seller has prepared the item, the seller **marks shipped**, the buyer **enters a delivery code** to confirm receipt, and the escrow becomes eligible for release ([[Payout Flow]]).
|
||||
|
||||
## Actors
|
||||
|
||||
- **Seller** — marks the order shipped and presents the delivery code to the buyer at hand-off.
|
||||
- **Buyer** — confirms by entering the code in the dashboard.
|
||||
- **Backend** — `DeliveryService` (`backend/src/services/delivery/DeliveryService.ts`), exposed through the marketplace routes (`backend/src/services/marketplace/routes.ts`).
|
||||
- **MongoDB** — `purchaserequests.deliveryInfo` subdocument fields.
|
||||
- **Socket.IO** — `delivery-code-generated`, `delivery-update`.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- `PurchaseRequest.status` is `payment`, `processing`, or `delivery`.
|
||||
- `Payment.escrowState === 'funded'`.
|
||||
|
||||
## Step-by-step narrative
|
||||
|
||||
1. **Seller marks shipped** — from the seller step `frontend/src/sections/request/components/seller-steps/step-3-ship-goods.tsx`, clicks "Mark as shipped". This patches the request status to `delivery`.
|
||||
2. **Delivery code generation** — when the order transitions to `delivery`, `DeliveryService.generateDeliveryCode(requestId)` is invoked. It:
|
||||
- Generates a 6-digit code (`Math.floor(100000 + Math.random()*900000)`).
|
||||
- Sets `deliveryInfo.deliveryCode`, `deliveryCodeGeneratedAt = now`, `deliveryCodeExpiresAt = now + 7d`, `deliveryCodeUsed = false`.
|
||||
- Emits `delivery-code-generated` and `delivery-update` to `request-{requestId}`.
|
||||
- Sends a notification to the buyer with the code (in-app, and via email if configured).
|
||||
3. **Buyer entry** — buyer meets the courier / picks up the item, enters the code in `frontend/src/sections/request/components/seller-steps/delivery-code-verification.tsx` (also surfaced on the buyer side via `step-5-receive-goods.tsx`).
|
||||
4. **Verification** — `POST /api/marketplace/purchase-requests/:id/verify-delivery` with `{ code }`:
|
||||
- Matches `code` against `deliveryInfo.deliveryCode`.
|
||||
- Checks `deliveryCodeExpiresAt > now` and `deliveryCodeUsed === false`.
|
||||
- On success: `deliveryInfo.deliveryCodeUsed = true; deliveryCodeUsedAt = now`. Status flips `delivery → delivered`.
|
||||
- Emits `purchase-request-update` `status-changed`.
|
||||
- Triggers buyer/seller notifications via `notifyDeliveryConfirmed` (see `PurchaseRequestService.ts:631-641`).
|
||||
5. **Optional auto-release timer** — once `status === 'delivered'`, a scheduled job can flip the request to `confirming` and then to `seller_paid` after a grace period (e.g. 48h). The auto-release worker is not yet implemented; today an admin completes the chain via [[Payout Flow]].
|
||||
6. **Manual fast-track** — the buyer can also tap "Confirm I received it" to skip the code (used when the code path fails — e.g. lost in transit) which patches `status` to `delivered`. This relies on admin trust.
|
||||
|
||||
## Sequence diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor S as Seller
|
||||
actor B as Buyer
|
||||
participant FE as Frontend
|
||||
participant BE as Backend
|
||||
participant DB as MongoDB
|
||||
participant IO as Socket.IO
|
||||
|
||||
S->>FE: Click "Mark as shipped"
|
||||
FE->>BE: PATCH /api/marketplace/purchase-requests/{id} {status:"delivery"}
|
||||
BE->>DB: PurchaseRequest.status="delivery"
|
||||
BE->>BE: DeliveryService.generateDeliveryCode
|
||||
BE->>DB: deliveryInfo.deliveryCode=XXXXXX\nexpires=+7d
|
||||
BE->>IO: emit request-{id} 'delivery-code-generated'
|
||||
BE->>B: notification w/ code (in-app/email)
|
||||
|
||||
S->>B: At hand-off, share the 6-digit code (verbally)
|
||||
B->>FE: Enter code in dashboard
|
||||
FE->>BE: POST /api/marketplace/purchase-requests/{id}/verify-delivery {code}
|
||||
BE->>DB: match code, expires>now, !used
|
||||
BE->>DB: deliveryCodeUsed=true; status="delivered"
|
||||
BE->>IO: emit request-{id} 'purchase-request-update' status-changed
|
||||
BE->>B,S: notifyDeliveryConfirmed
|
||||
Note over BE: Auto-release timer (planned) → seller_paid → payout
|
||||
```
|
||||
|
||||
## API calls
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|---|---|---|
|
||||
| `PATCH` | `/api/marketplace/purchase-requests/:id` `{status:"delivery"}` | Seller marks shipped |
|
||||
| `POST` | `/api/marketplace/purchase-requests/:id/delivery-code` | Manual code regeneration (admin) |
|
||||
| `POST` | `/api/marketplace/purchase-requests/:id/verify-delivery` | Buyer confirms with code |
|
||||
| `PATCH` | `/api/marketplace/purchase-requests/:id/confirm-delivery` | Buyer fast-track confirm (no code) |
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`purchaserequests.deliveryInfo`** — `deliveryCode`, `deliveryCodeGeneratedAt`, `deliveryCodeExpiresAt`, `deliveryCodeUsed`, `deliveryCodeUsedAt`.
|
||||
- **`purchaserequests.status`** — `delivery` → `delivered` → (eventually `seller_paid` → `completed`).
|
||||
- **`notifications`** — generated for both parties.
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
- **`delivery-code-generated`** → `request-{id}` (with code, expiresAt).
|
||||
- **`delivery-update`** → `request-{id}` (`type: 'code-generated'`).
|
||||
- **`purchase-request-update`** `status-changed` on `delivery → delivered`.
|
||||
- **`new-notification`** → `user-{buyerId}` with the code.
|
||||
|
||||
## Side effects
|
||||
|
||||
- Code is **emitted via socket and in-app notification**. If a malicious actor has access to the buyer's notifications, they could intercept and confirm delivery prematurely. Treat the code as confidential at the UI layer.
|
||||
- Triggers the path that eventually frees up the escrow (manual today via [[Payout Flow]], auto in the future).
|
||||
|
||||
## Error / edge cases
|
||||
|
||||
- **Wrong code** → `400 Invalid delivery code`.
|
||||
- **Expired code** (>7 days) → `400 Code expired`. Admin can regenerate via the manual endpoint.
|
||||
- **Already used code** → `400 Code already used`.
|
||||
- **Buyer never confirms** → status remains `delivery`. Auto-release timer (not yet built) should trigger `delivered` after N days. Until then, admin intervention.
|
||||
- **Seller delivers but never marks shipped** → buyer can dispute via [[Dispute Flow]]; the dispute resolution will release the escrow regardless.
|
||||
- **Lost code** → `POST /:id/delivery-code` regenerates a new 6-digit value, invalidates the old one, and re-notifies. Restrict to admin/seller to avoid abuse.
|
||||
|
||||
> [!tip] Use the code as proof-of-handover
|
||||
> The seller should ask the courier or the buyer at the door for the code before leaving the item. If the buyer disputes "never received", an unused code is strong circumstantial evidence; a used code = buyer confirmed.
|
||||
|
||||
## Linked flows
|
||||
|
||||
- [[Payment Flow - SHKeeper]] / [[Payment Flow - DePay & Web3]] — funding precondition.
|
||||
- [[Escrow Flow]] — state transitions triggered by confirmation.
|
||||
- [[Payout Flow]] — fires after confirmation (manual today).
|
||||
- [[Dispute Flow]] — escape hatch.
|
||||
- [[Notification Flow]] — channel for the code.
|
||||
|
||||
## Source files
|
||||
|
||||
- Backend: `backend/src/services/delivery/DeliveryService.ts`
|
||||
- Backend: `backend/src/services/marketplace/routes.ts` (delivery endpoints)
|
||||
- Backend: `backend/src/services/marketplace/PurchaseRequestService.ts:631-641` (confirmation notifications)
|
||||
- Frontend: `frontend/src/sections/request/components/seller-steps/step-3-ship-goods.tsx`
|
||||
- Frontend: `frontend/src/sections/request/components/seller-steps/delivery-code-verification.tsx`
|
||||
- Frontend: `frontend/src/sections/request/components/buyer-steps/step-5-receive-goods.tsx`
|
||||
199
04 - Flows/Dispute Flow.md
Normal file
199
04 - Flows/Dispute Flow.md
Normal file
@@ -0,0 +1,199 @@
|
||||
---
|
||||
title: Dispute Flow
|
||||
tags: [flow, dispute, mediator, evidence, chat, state-machine]
|
||||
related_models: ["[[Dispute]]", "[[Chat]]", "[[PurchaseRequest]]", "[[Payment]]"]
|
||||
related_apis: ["POST /api/disputes", "POST /api/disputes/:id/assign", "POST /api/disputes/:id/resolve", "POST /api/disputes/:id/evidence", "PATCH /api/disputes/:id/status"]
|
||||
---
|
||||
|
||||
# Dispute Flow
|
||||
|
||||
When something goes wrong (item not delivered, wrong item, fraud), either party can open a **dispute**. A three-way chat (buyer, seller, admin mediator) is created automatically. After evidence is gathered, the admin **resolves** the dispute — releasing the escrow to the seller, refunding the buyer, splitting the funds, or rejecting the claim.
|
||||
|
||||
## Actors
|
||||
|
||||
- **Buyer** — typical initiator.
|
||||
- **Seller** — party against whom the dispute is raised (or in rarer cases, initiator).
|
||||
- **Admin / Mediator** — assigned to investigate.
|
||||
- **Frontend** — buyer/seller "Report issue" buttons in the request detail view; admin dispute dashboard.
|
||||
- **Backend** — `DisputeService` (`backend/src/services/dispute/DisputeService.ts`), `DisputeController` (`backend/src/controllers/disputeController.ts`), routes at `backend/src/routes/disputeRoutes.ts`.
|
||||
- **MongoDB** — `disputes`, `chats`, `purchaserequests`, `payments`.
|
||||
- **Socket.IO** — `new-notification`, `new-message`, `dispute-updated` (planned).
|
||||
|
||||
## Preconditions
|
||||
|
||||
- The related `PurchaseRequest` exists.
|
||||
- The initiator is the request's buyer or the related seller.
|
||||
- Funds are typically held in escrow (`Payment.escrowState = 'funded'`) — disputes on unfunded orders are accepted but have no monetary impact.
|
||||
|
||||
## Dispute state machine (`Dispute.status`)
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> pending: createDispute()\nresponseDeadline=+48h\ndeadline=+7d
|
||||
pending --> in_progress: admin assigned\nassignAdmin()
|
||||
in_progress --> resolved: admin resolves\naction ∈ {refund, partial, release, reject}
|
||||
in_progress --> closed: admin closes without resolution\n(e.g. duplicate/spam)
|
||||
pending --> closed: same
|
||||
resolved --> [*]
|
||||
closed --> [*]
|
||||
```
|
||||
|
||||
Resolution actions (from `Dispute.resolution.action` enum, see `Dispute.ts`): `refund`, `partial`, `release`, `reject` (the wording differs slightly in the model — verify with `backend/src/models/Dispute.ts`).
|
||||
|
||||
## Step-by-step narrative
|
||||
|
||||
### Phase 1 — Opening
|
||||
|
||||
1. Buyer or seller opens the request detail and clicks **"Report problem"** (`frontend/src/sections/request/components/report-problem-to-admin.tsx`).
|
||||
2. They select a `category` (delivery, payment, quality, fraud, other), a `priority` (`low | medium | high | urgent`), write a `description`, and optionally upload `evidence` (images, screenshots, video, document) via `POST /api/files/upload`.
|
||||
3. Frontend POSTs `POST /api/disputes` with `{ purchaseRequestId, reason, description, priority, category, evidence: [...] }`.
|
||||
4. Backend `DisputeService.createDispute` (`:12-119`):
|
||||
- Loads the purchase request with `populate('selectedOfferId')`.
|
||||
- Resolves the **counter-party `sellerId`** by priority: explicit `data.sellerId` → `selectedOffer.sellerId` → first of `preferredSellerIds`. This means once an offer is accepted, the dispute targets the actual seller, not the entire preferred list.
|
||||
- Creates the `Dispute` with `status: 'pending'`, `responseDeadline = now + 48h`, `deadline = now + 7 days`, and an empty `timeline[]`.
|
||||
- Creates a **`Chat` of type `group`** with the buyer and the resolved seller as participants. The opening message is a system-typed line `"اختلاف جدید ایجاد شد: {reason}"`. The chat's `relatedTo = { type: 'PurchaseRequest', id }`.
|
||||
- Persists `dispute.chatId = chat._id`.
|
||||
5. Notifications (currently a `TODO` in the service — `:107-116`) should fire `new-notification` to the seller. Today the chat creation alone provides real-time presence via the `new-message` socket emit inside `Chat.create`'s lifecycle.
|
||||
|
||||
> [!warning] Dispute does not auto-pause escrow
|
||||
> Today, opening a dispute does **not** flip `Payment.escrowState` away from `funded`. An admin could theoretically still release the escrow before resolving the dispute. Until a `disputed` flag is added to Payment, admins must check the dispute table before any release/refund action.
|
||||
|
||||
### Phase 2 — Admin assignment
|
||||
|
||||
6. The admin dispute dashboard lists pending disputes (sorted by `priority: -1, createdAt: -1`).
|
||||
7. Admin clicks "Pick up" → `POST /api/disputes/:id/assign` with `{ adminId }` (currently the admin's own id).
|
||||
8. `DisputeService.assignAdmin` (`:184-223`):
|
||||
- `dispute.adminId = adminId; dispute.status = 'in_progress'`.
|
||||
- Appends `timeline` entry `{ action: 'admin_assigned', performedBy: adminId, ... }`.
|
||||
- Adds the admin to the dispute `chat.participants[]` (role `admin`).
|
||||
- Saves.
|
||||
|
||||
### Phase 3 — Investigation
|
||||
|
||||
9. All three parties chat in the dispute chat room (same socket mechanics as [[Chat Flow]]). Each party can upload more evidence via `POST /api/disputes/:id/evidence` — `DisputeService.addEvidence` (`:305-337`) appends to `dispute.evidence[]` and writes a `timeline` entry `evidence_added`.
|
||||
10. The admin may also `PATCH /api/disputes/:id/status` with intermediate states or notes; this updates `dispute.status` and writes a `timeline` entry `status_changed`.
|
||||
|
||||
### Phase 4 — Resolution
|
||||
|
||||
11. Once the admin has enough information, they call `POST /api/disputes/:id/resolve` with `{ action, amount?, currency?, notes? }`.
|
||||
12. `DisputeService.resolveDispute` (`:262-300`):
|
||||
- `dispute.status = 'resolved'`
|
||||
- `dispute.resolution = { action, amount, currency, notes, resolvedBy: adminId, resolvedAt: now }`
|
||||
- `dispute.closedAt = now`
|
||||
- Appends `timeline` entry `dispute_resolved`.
|
||||
- Saves.
|
||||
13. **Financial side-effect (manual today):** depending on the action, the admin then triggers either the **payout** ([[Payout Flow]] with `kind: 'release'`) or the **refund** (`kind: 'refund'`, see [[Escrow Flow]]). The dispute service does not automatically dispatch the on-chain action.
|
||||
14. Both parties are notified (TODOs in code — planned: `notifyDisputeResolved`).
|
||||
|
||||
## Sequence diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor B as Buyer
|
||||
actor S as Seller
|
||||
actor A as Admin
|
||||
participant FE as Frontend
|
||||
participant BE as Backend
|
||||
participant DB as MongoDB
|
||||
participant IO as Socket.IO
|
||||
|
||||
B->>FE: "Report problem" on request
|
||||
B->>FE: Choose category, priority, evidence
|
||||
FE->>BE: POST /api/disputes
|
||||
BE->>DB: Dispute.create({status:"pending"})
|
||||
BE->>DB: Chat.create({type:"group", participants:[buyer, seller], system message})
|
||||
BE->>DB: dispute.chatId = chat._id
|
||||
BE-->>FE: { dispute }
|
||||
FE-->>B,S: chat opens (real-time via existing chat join)
|
||||
|
||||
A->>FE: Admin dashboard, click "Pick up"
|
||||
FE->>BE: POST /api/disputes/{id}/assign
|
||||
BE->>DB: dispute.adminId, status="in_progress", timeline.push
|
||||
BE->>DB: chat.participants.push(admin)
|
||||
BE-->>FE: { dispute }
|
||||
|
||||
loop investigation
|
||||
A->>FE: Chat with B & S
|
||||
B-->>BE: POST /api/disputes/{id}/evidence (image)
|
||||
BE->>DB: dispute.evidence.push, timeline.push
|
||||
end
|
||||
|
||||
A->>FE: Click "Resolve" choose action
|
||||
FE->>BE: POST /api/disputes/{id}/resolve { action, amount, notes }
|
||||
BE->>DB: dispute.status="resolved", resolution={...}
|
||||
alt action="refund"
|
||||
A->>BE: trigger refund payout to buyer\n[[Escrow Flow]] / [[Payout Flow]]
|
||||
else action="release"
|
||||
A->>BE: trigger payout to seller\n[[Payout Flow]]
|
||||
else action="partial"
|
||||
A->>BE: split — refund X to buyer, release Y to seller
|
||||
end
|
||||
BE-->>FE: { dispute }
|
||||
IO-->>B,S: 'new-notification' dispute resolved (planned)
|
||||
```
|
||||
|
||||
## API calls
|
||||
|
||||
| Method | Endpoint | Source |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/disputes` | `disputeRoutes.ts:12` → `DisputeController.createDispute` |
|
||||
| `GET` | `/api/disputes` | `disputeRoutes.ts:15` (filters: status, priority, category, adminId, buyer/seller) |
|
||||
| `GET` | `/api/disputes/statistics` | `disputeRoutes.ts:18` |
|
||||
| `GET` | `/api/disputes/:id` | `disputeRoutes.ts:21` |
|
||||
| `POST` | `/api/disputes/:id/assign` | `disputeRoutes.ts:24` |
|
||||
| `PATCH` | `/api/disputes/:id/status` | `disputeRoutes.ts:27` |
|
||||
| `POST` | `/api/disputes/:id/resolve` | `disputeRoutes.ts:30` |
|
||||
| `POST` | `/api/disputes/:id/evidence` | `disputeRoutes.ts:33` |
|
||||
|
||||
All require `authenticateToken` (router-level middleware).
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`disputes`** — insert on open; updates `adminId`, `status`, `timeline[]`, `evidence[]`, `resolution`, `closedAt` over the lifecycle.
|
||||
- **`chats`** — new `group` chat on open; admin appended to `participants[]` on assignment; messages appended throughout.
|
||||
- **`purchaserequests`** — not directly mutated by the dispute service; the resolution side-effect (release/refund) updates the request via [[Escrow Flow]].
|
||||
- **`payments`** — touched indirectly when the admin performs the financial resolution.
|
||||
- **`notifications`** — `TODO` markers in code; planned addition.
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
- **`new-message`** → `chat-{disputeChatId}` for each chat line (via the standard `ChatService.sendMessage` and the system message created in `DisputeService.createDispute`).
|
||||
- **`new-notification`** (planned) → `user-{buyerId}` and `user-{sellerId}` on creation, assignment, evidence-added, resolution.
|
||||
|
||||
## Side effects
|
||||
|
||||
- **Three-way chat creation** is the most visible side effect — pulls the buyer and seller into a controlled conversation room.
|
||||
- **Timeline append-only log** is the audit trail. Surface it in the admin UI for compliance.
|
||||
- **Response deadline = 48h** — used by reminders / SLA dashboards (no automated enforcement today). Past-deadline disputes could auto-escalate priority.
|
||||
- **Hard deadline = 7d** — same intent: a watchdog could mark long-unresolved disputes for admin attention.
|
||||
|
||||
## Error / edge cases
|
||||
|
||||
- **Purchase request missing** → `400 Purchase request not found`.
|
||||
- **No seller identifiable** (orphan request) → dispute still created but with `sellerId: undefined`; the chat becomes 2-party (buyer + admin only). Recommended: reject creation in this case to avoid mediator-less situations.
|
||||
- **Initiator is neither buyer nor seller** → not enforced at service level — should be validated in `DisputeController` (recommended hardening).
|
||||
- **Same user opens multiple disputes for the same request** → no uniqueness constraint today. Consider adding `unique on (purchaseRequestId, status:'pending'|'in_progress')` to prevent duplicates.
|
||||
- **Evidence URL is hot-linked** → frontend uploads through `POST /api/files/upload` and the URL is served from `/uploads`. Ensure auth on the upload endpoint to prevent random users from polluting evidence.
|
||||
- **Dispute resolved without financial follow-up** → the dispute is "resolved" in record only; the escrow stays in its previous state. Add automation that auto-fires the payout/refund when the admin selects `release` or `refund`.
|
||||
- **Admin resigns mid-dispute** → no transfer-of-mediator endpoint today. Add `POST /api/disputes/:id/reassign`.
|
||||
|
||||
> [!tip] Sort disputes by priority + age
|
||||
> The query `Dispute.find().sort({ priority: -1, createdAt: -1 })` already used in `getDisputes` ensures `urgent` ones bubble to the top. Make sure the admin dashboard uses this default sort.
|
||||
|
||||
## Linked flows
|
||||
|
||||
- [[Chat Flow]] — message-level mechanics inside the dispute chat.
|
||||
- [[Escrow Flow]] — the financial state being contested.
|
||||
- [[Payout Flow]] — executed on `release` resolutions.
|
||||
- [[Notification Flow]] — channels for dispute alerts.
|
||||
- [[Delivery Confirmation Flow]] — disputes often arise from failed delivery.
|
||||
|
||||
## Source files
|
||||
|
||||
- Backend: `backend/src/services/dispute/DisputeService.ts`
|
||||
- Backend: `backend/src/controllers/disputeController.ts`
|
||||
- Backend: `backend/src/routes/disputeRoutes.ts`
|
||||
- Backend: `backend/src/models/Dispute.ts`
|
||||
- Frontend: `frontend/src/sections/request/components/report-problem-to-admin.tsx`
|
||||
- Frontend: admin dispute dashboard under `frontend/src/sections/admin/` (subject to organisation)
|
||||
196
04 - Flows/Escrow Flow.md
Normal file
196
04 - Flows/Escrow Flow.md
Normal file
@@ -0,0 +1,196 @@
|
||||
---
|
||||
title: Escrow Flow
|
||||
tags: [flow, escrow, payment, state-machine]
|
||||
related_models: ["[[Payment]]", "[[PurchaseRequest]]"]
|
||||
related_apis: ["POST /api/payment/release/:paymentId", "POST /api/payment/refund/:paymentId"]
|
||||
---
|
||||
|
||||
# Escrow Flow
|
||||
|
||||
The escrow is not a separate smart contract — it is a **state machine on the `Payment` document** combined with a **custodial wallet** (the platform-controlled BSC address `NEXT_PUBLIC_ESCROW_WALLET_ADDRESS`). Funds sit at that wallet once SHKeeper / Web3 verification completes, and are released to the seller or refunded to the buyer based on order outcome.
|
||||
|
||||
## Actors
|
||||
|
||||
- **System** — the backend, on receiving pay-in confirmation.
|
||||
- **Buyer** — confirms delivery to authorise release; can open a dispute to block release.
|
||||
- **Seller** — recipient of release.
|
||||
- **Admin** — resolves disputes and signs payout transactions when manual control is required.
|
||||
- **MongoDB** — `payments` document holds the canonical `escrowState`.
|
||||
|
||||
## Escrow state machine (`Payment.escrowState`)
|
||||
|
||||
Enum from `Payment.ts:112-115`: `funded | releasable | released | refunded | releasing | failed`.
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Pending: Payment.status="pending"\nescrowState=undefined
|
||||
Pending --> Partial: webhook PARTIAL\nescrowState="partial"
|
||||
Pending --> Funded: webhook PAID/OVERPAID\nor on-chain verify success\nescrowState="funded"
|
||||
Partial --> Funded: top-up reaches threshold
|
||||
Funded --> Releasable: buyer confirms delivery\n(or auto-release timer)
|
||||
Releasable --> Releasing: admin/system initiates payout\n[[Payout Flow]]
|
||||
Releasing --> Released: payout tx confirmed\nescrowState="released"
|
||||
Releasing --> Failed: payout tx reverted\nescrowState="failed"
|
||||
Funded --> Refunded: dispute resolution = refund\nescrowState="refunded"
|
||||
Funded --> Refunded: order cancelled\npre-shipment
|
||||
Failed --> Releasing: admin retries
|
||||
Released --> [*]
|
||||
Refunded --> [*]
|
||||
```
|
||||
|
||||
`Payment.status` mirrors a coarser business state:
|
||||
- `pending` → invoice issued, awaiting funds.
|
||||
- `processing` → SHKeeper sees partial / confirmations in progress.
|
||||
- `confirmed` → fully credited (intermediate; sometimes skipped).
|
||||
- `completed` → escrow `funded` and onward.
|
||||
- `failed`, `cancelled`, `refunded` → terminal.
|
||||
|
||||
## Step-by-step narrative
|
||||
|
||||
### 1. Funding
|
||||
|
||||
- Triggered by either [[Payment Flow - SHKeeper]] (webhook `PAID`/`OVERPAID`) or [[Payment Flow - DePay & Web3]] (verified `eth_getTransactionReceipt`).
|
||||
- Backend sets `Payment.status = "completed"` and `Payment.escrowState = "funded"` (`shkeeperWebhook.ts:388-391`, `shkeeperService.ts:600-602`).
|
||||
- Cascade: `PurchaseRequest.status` → `payment`, then `processing` once the seller acknowledges; `SellerOffer.status` → `accepted`; chat created.
|
||||
- Funds physically sit at the **custodial wallet** — SHKeeper's per-invoice deposit address (auto-swept to the merchant wallet) or directly at the escrow wallet in the Web3 path.
|
||||
|
||||
### 2. Holding
|
||||
|
||||
- While `escrowState === "funded"` and the order is in `processing` / `delivery`, the funds are inert. No interest accrues; no on-chain action happens.
|
||||
- The buyer cannot withdraw; the seller cannot collect. Only an admin/system action moves it forward.
|
||||
- Visible in admin dashboard: `GET /api/payment/admin/funded?status=funded` (or similar — see admin payment view in `frontend/src/sections/payment/view/payment-list-admin-view.tsx`).
|
||||
|
||||
### 3. Releasing (happy path)
|
||||
|
||||
- Trigger options:
|
||||
- **Buyer confirms delivery** via the delivery-code flow ([[Delivery Confirmation Flow]]).
|
||||
- **Auto-release timer** elapses (configurable; today a manual or scheduled job — `PurchaseRequestService` exposes status transitions through to `completed`).
|
||||
- **Admin manual release** from the admin payment detail view.
|
||||
- The system marks `Payment.escrowState = "releasable"` (intermediate).
|
||||
- `shkeeperPayoutService.createPayoutTask` (or a manual EVM admin signature via `admin-wallet-payout.tsx`) starts the on-chain transfer to the seller's verified wallet address. State flips to `releasing`.
|
||||
- On confirmation: `confirmAdminTx(paymentId, txHash, 'release')` (`shkeeperService.ts:628-647`) sets:
|
||||
- `Payment.status = 'completed'`
|
||||
- `Payment.escrowState = 'released'`
|
||||
- `Payment.blockchain.transactionHash = <payout tx hash>`
|
||||
- Cascade: `PurchaseRequest.status` → `seller_paid` then `completed`.
|
||||
|
||||
### 4. Refunding (dispute / cancellation)
|
||||
|
||||
- Trigger: dispute resolution with `action: 'refund'` or pre-shipment cancellation.
|
||||
- Backend builds the refund tx via `buildAdminSignedTxPayload(paymentId, 'refund')` (`shkeeperService.ts:614-626`) — destination is `payment.blockchain.sender` (the buyer's verified wallet).
|
||||
- Admin signs and broadcasts (currently a manual step in the admin UI).
|
||||
- On confirmation: `confirmAdminTx(paymentId, txHash, 'refund')` sets:
|
||||
- `Payment.status = 'refunded'`
|
||||
- `Payment.escrowState = 'refunded'`
|
||||
- Cascade: `PurchaseRequest.status` → `cancelled` (or remains in dispute-resolved state).
|
||||
|
||||
### 5. Failed payout
|
||||
|
||||
- If the payout tx reverts (insufficient gas, contract pause, wrong address), `escrowState = 'failed'`. Admin can retry by initiating a fresh payout.
|
||||
|
||||
## Sequence diagram (release path)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor B as Buyer
|
||||
actor A as Admin
|
||||
participant FE as Frontend
|
||||
participant BE as Backend
|
||||
participant DB as MongoDB
|
||||
participant SK as SHKeeper Payout API
|
||||
participant BC as BSC
|
||||
|
||||
B->>FE: Enter delivery code (or auto-timer fires)
|
||||
FE->>BE: POST /api/marketplace/purchase-requests/:id/confirm-delivery
|
||||
BE->>DB: PurchaseRequest.status="delivered"\nPayment.escrowState="releasable"
|
||||
BE-->>FE: ok
|
||||
A->>FE: Click "Release" in admin
|
||||
FE->>BE: POST /api/payment/shkeeper/payout
|
||||
BE->>DB: Payment.escrowState="releasing"
|
||||
BE->>SK: createPayoutTask({recipient, amount})
|
||||
SK->>BC: signed payout tx
|
||||
BC-->>SK: confirmed
|
||||
SK->>BE: payout webhook / poll
|
||||
BE->>BE: confirmAdminTx(paymentId, txHash, "release")
|
||||
BE->>DB: Payment.escrowState="released"\nPurchaseRequest.status="completed"
|
||||
```
|
||||
|
||||
## Sequence diagram (refund path)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor A as Admin
|
||||
participant BE as Backend
|
||||
participant DB as MongoDB
|
||||
participant BC as BSC
|
||||
actor B as Buyer
|
||||
|
||||
A->>BE: Dispute resolved with action="refund"
|
||||
BE->>BE: buildAdminSignedTxPayload(paymentId, "refund")
|
||||
BE-->>A: { to:buyerWallet, amount, token, network }
|
||||
A->>BC: sign + broadcast tx
|
||||
BC-->>A: txHash
|
||||
A->>BE: confirmAdminTx(paymentId, txHash, "refund")
|
||||
BE->>DB: Payment.status="refunded"\nescrowState="refunded"
|
||||
BE->>B: notifyRefundCompleted
|
||||
```
|
||||
|
||||
## API calls
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/payment/admin/release/:paymentId` | Initiate release |
|
||||
| `POST` | `/api/payment/admin/refund/:paymentId` | Initiate refund |
|
||||
| `POST` | `/api/payment/admin/confirm-tx/:paymentId` | Admin marks the signed tx confirmed |
|
||||
| `GET` | `/api/payment/:paymentId/status` | Polled by both parties |
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`payments`**: `status`, `escrowState`, `blockchain.transactionHash`, `completedAt`, `metadata.*` are mutated as the state progresses.
|
||||
- **`purchaserequests`**: `status` cascades (`payment → processing → delivery → delivered → confirming → seller_paid → completed`).
|
||||
- **`notifications`**: created on each terminal state.
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
- **`purchase-request-update`** `status-changed` on every cascading status flip.
|
||||
- **`payment-status`** (planned/admin) — admin dashboard real-time feed.
|
||||
|
||||
## Side effects
|
||||
|
||||
- **Custodial risk** — the escrow wallet's private key sits with the platform. Lose it → lose all in-flight escrows. Operational controls: hardware wallet, multi-sig, cold storage of the recovery seed.
|
||||
- **No on-chain escrow contract** — there is no Solidity escrow today. Migration toward a smart-contract escrow (e.g. OpenZeppelin's `Escrow.sol` pattern) would remove custodial trust at the cost of higher complexity and gas.
|
||||
|
||||
## Error / edge cases
|
||||
|
||||
- **Buyer never confirms delivery** → today requires admin intervention. An auto-release timer (e.g. 7 days after `delivered`) is a recommended addition.
|
||||
- **Seller's wallet address invalid** → payout tx fails or sends to a black hole. Validate `recipientAddress` shape (`^0x[0-9a-fA-F]{40}$`) before signing (`shkeeperPayoutService.ts:62-64` checks `.startsWith('0x')`).
|
||||
- **Partial payment** (`PARTIAL`) → escrow remains in `pending/partial`; release blocked until full payment arrives.
|
||||
- **Overpaid** → currently treated as `completed/funded`; the surplus is not auto-refunded.
|
||||
- **Concurrent release + refund** → blocked by `PaymentCoordinator` serialisation; whichever fires first wins, the other is rejected.
|
||||
- **Payout fails on chain** → state stays in `releasing` until admin re-runs; consider auto-retry with exponential backoff.
|
||||
- **Disputed payment** → `escrowState` is **not** auto-changed when a dispute is opened. Admin must explicitly resolve to refund/release. Add a `disputed` boolean or `escrowState='disputed'` to make this more obvious.
|
||||
|
||||
> [!warning] Single custodial wallet = single point of failure
|
||||
> Centralising all in-flight escrow at one BSC address is the platform's largest operational risk. Use a multi-sig (Gnosis Safe) for the escrow wallet, store one key in HSM, and require two admin signatures for any payout > a threshold.
|
||||
|
||||
> [!tip] Recovering inconsistent state
|
||||
> If `Payment.escrowState` looks stale (e.g. `released` but no on-chain tx hash), inspect with `Payment.find({ escrowState: 'released', 'blockchain.transactionHash': { $exists: false } })` and reconcile via the SHKeeper invoice or the `fix-transaction-hashes.js` script.
|
||||
|
||||
## Linked flows
|
||||
|
||||
- [[Payment Flow - SHKeeper]] — funds the escrow.
|
||||
- [[Payment Flow - DePay & Web3]] — alternative funding path.
|
||||
- [[Delivery Confirmation Flow]] — triggers release.
|
||||
- [[Dispute Flow]] — can divert to refund.
|
||||
- [[Payout Flow]] — executes the release transfer.
|
||||
|
||||
## Source files
|
||||
|
||||
- Backend: `backend/src/models/Payment.ts:96-145` (status + escrowState enums)
|
||||
- Backend: `backend/src/services/payment/shkeeper/shkeeperService.ts:600-647`
|
||||
- Backend: `backend/src/services/payment/shkeeper/shkeeperWebhook.ts:387-411`
|
||||
- Backend: `backend/src/services/payment/paymentCoordinator.ts`
|
||||
- Frontend: `frontend/src/sections/payment/view/payment-list-admin-view.tsx`
|
||||
- Frontend: `frontend/src/sections/request/components/admin-steps/admin-wallet-payout.tsx`
|
||||
144
04 - Flows/Google OAuth Flow.md
Normal file
144
04 - Flows/Google OAuth Flow.md
Normal file
@@ -0,0 +1,144 @@
|
||||
---
|
||||
title: Google OAuth Flow
|
||||
tags: [flow, auth, oauth, google]
|
||||
related_models: ["[[User]]"]
|
||||
related_apis: ["POST /api/auth/google/signup", "POST /api/auth/google/signin"]
|
||||
---
|
||||
|
||||
# Google OAuth Flow
|
||||
|
||||
Google sign-in/up integration using **Google Identity Services** (`accounts.google.com/gsi/client`). The flow short-circuits email verification because Google accounts are pre-verified.
|
||||
|
||||
## Actors
|
||||
|
||||
- **User** with a Google account.
|
||||
- **Google Identity Services (GSI)** — JS SDK loaded on demand by the frontend service.
|
||||
- **Frontend** — `frontend/src/auth/services/google-oauth.ts`, consumed by `frontend/src/auth/view/jwt/jwt-sign-in-view.tsx` and `jwt-sign-up-view.tsx`.
|
||||
- **Backend** — `AuthController.googleSignUp` and `googleSignIn` in `backend/src/services/auth/authController.ts`, backed by `googleOAuthService.verifyGoogleToken()` in `backend/src/services/auth/googleOAuthService.ts`.
|
||||
- **MongoDB** — `User` collection (account linking by email).
|
||||
|
||||
## Preconditions
|
||||
|
||||
- `NEXT_PUBLIC_GOOGLE_CLIENT_ID` is set on the frontend (`frontend/src/auth/services/google-oauth.ts` line 2 — there is a hard-coded fallback for the dev project ID).
|
||||
- Same client ID is whitelisted on the backend `googleOAuthService`.
|
||||
- The current origin is registered under "Authorized JavaScript origins" in Google Cloud Console (see `frontend/GOOGLE_OAUTH_SETUP.md`).
|
||||
|
||||
## Step-by-step narrative
|
||||
|
||||
### Sign-up
|
||||
|
||||
1. User clicks the Google icon on `/auth/jwt/sign-up`. The form is configured with the chosen role (buyer/seller) and an optional referral code.
|
||||
2. The frontend lazy-loads `https://accounts.google.com/gsi/client` if it is not yet on `window.google`.
|
||||
3. `google.accounts.oauth2.initTokenClient({ client_id, scope: 'openid email profile' })` is initialised, and `.requestAccessToken()` opens the Google popup.
|
||||
4. On success the popup returns an **ID token** (a Google-signed JWT containing `email`, `email_verified`, `name`, `given_name`, `family_name`, `picture`, `sub`).
|
||||
5. Frontend calls `signUpWithGoogle({ googleToken, role, referralCode })` (`frontend/src/auth/context/jwt/action.ts:281-304`), which POSTs `POST /api/auth/google/signup`.
|
||||
6. Backend `authController.googleSignUp` (`:781-872`) calls `googleOAuthService.verifyGoogleToken(googleToken)`. The verifier uses `google-auth-library` to validate the JWT signature, expiry, audience (`client_id`), and issuer.
|
||||
7. **Duplicate check**: `User.findOne({ email: googleUser.email })` — if found returns `409 USER_EXISTS` so the user can use *sign-in* instead.
|
||||
8. **New user creation** with `password` omitted, `isEmailVerified: true`, `status: "active"`, `profile.avatar = googleUser.picture`, role from the request.
|
||||
9. **Referral attribution** (`authController.ts:817-838`): same logic as the email path — increment `referrer.referralStats.totalReferrals`, emit `referral-signup` on `user-${referrer._id}`.
|
||||
10. Generate access + refresh tokens, push refresh into `user.refreshTokens[]`, respond with `{ user, tokens }`.
|
||||
11. Frontend stores tokens in `localStorage` and redirects to the dashboard.
|
||||
|
||||
### Sign-in
|
||||
|
||||
1. User clicks the Google icon on `/auth/jwt/sign-in`.
|
||||
2. Same GSI flow as sign-up — Google returns an ID token.
|
||||
3. Frontend calls `signInWithGoogle(googleToken)` → `POST /api/auth/google/signin`.
|
||||
4. Backend verifies the token, looks up `User.findOne({ email: googleUser.email })`. If no user, returns `404 USER_NOT_FOUND` ("please sign up first"). The frontend surfaces a localized prompt.
|
||||
5. On hit: `existingUser.lastLoginAt = now`; if `profile.avatar` is empty and Google has a picture, it is back-filled (`authController.ts:905-907`).
|
||||
6. Tokens issued and returned identically to email login.
|
||||
|
||||
> [!tip] Account linking is implicit by email
|
||||
> A user who originally signed up via email + password can sign in with Google as long as the email matches — no extra "link account" step. The backend simply reuses the existing user document. There is **no** separate `googleId` field stored today, so this is a one-way trust on `googleUser.email`.
|
||||
|
||||
## Sequence diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor U as User
|
||||
participant FE as Frontend
|
||||
participant G as Google GSI
|
||||
participant BE as Backend
|
||||
participant GA as google-auth-library
|
||||
participant DB as MongoDB
|
||||
|
||||
U->>FE: Click Google icon
|
||||
FE->>G: load gsi/client, initTokenClient(client_id)
|
||||
FE->>G: requestAccessToken()
|
||||
G-->>U: Popup → consent
|
||||
U-->>G: Approve
|
||||
G-->>FE: ID token (signed JWT)
|
||||
alt Sign-up
|
||||
FE->>BE: POST /api/auth/google/signup { googleToken, role, referralCode }
|
||||
else Sign-in
|
||||
FE->>BE: POST /api/auth/google/signin { googleToken }
|
||||
end
|
||||
BE->>GA: verifyGoogleToken(googleToken)
|
||||
GA-->>BE: { email, name, picture, ... } or null
|
||||
BE->>DB: User.findOne({ email })
|
||||
alt Sign-up: user exists
|
||||
BE-->>FE: 409 USER_EXISTS
|
||||
else Sign-up: new
|
||||
BE->>DB: User.create({ email, role, isEmailVerified:true, profile.avatar })
|
||||
opt referral
|
||||
BE->>DB: increment referrer.referralStats
|
||||
end
|
||||
else Sign-in: user missing
|
||||
BE-->>FE: 404 USER_NOT_FOUND
|
||||
else Sign-in: ok
|
||||
BE->>DB: user.lastLoginAt = now; back-fill avatar if blank
|
||||
end
|
||||
BE->>BE: generate access + refresh; push refresh
|
||||
BE-->>FE: 200 { user, tokens }
|
||||
FE->>FE: localStorage.setItem(accessToken/refreshToken)
|
||||
FE-->>U: Redirect /dashboard/{role}
|
||||
```
|
||||
|
||||
## API calls
|
||||
|
||||
| Method | Endpoint | Source |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/auth/google/signup` | `authRoutes.ts:30` → `authController.googleSignUp` |
|
||||
| `POST` | `/api/auth/google/signin` | `authRoutes.ts:31` → `authController.googleSignIn` |
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`users` collection**: on sign-up, full insert (no password). On sign-in, only `lastLoginAt`, possibly `profile.avatar`, and a new refresh token appended.
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
- **`referral-signup`** → `user-${referrerId}` when sign-up includes a valid `referralCode`.
|
||||
|
||||
## Side effects
|
||||
|
||||
- **No email** is sent (Google handles trust). No `TempVerification` is created.
|
||||
- The avatar URL is stored from Google's CDN; consider proxying or rehosting if Google's privacy rules change for `googleusercontent.com`.
|
||||
|
||||
## Error / edge cases
|
||||
|
||||
- **Invalid Google token** (bad signature, wrong audience, expired) → `googleOAuthService` returns `null` → `401 INVALID_GOOGLE_TOKEN`.
|
||||
- **User already exists during sign-up** → `409`; frontend prompts to use sign-in instead.
|
||||
- **User missing during sign-in** → `404`; frontend redirects to sign-up.
|
||||
- **Popup blocker** → GSI throws a client-side error caught in the view and surfaced as a toast.
|
||||
- **Network failure to `accounts.google.com`** → GSI rejects; frontend retries on next click.
|
||||
- **`email_verified === false` on the Google token** → currently not enforced; the backend trusts any successful Google response. For an extra-strict mode, gate on `googleUser.email_verified === true` in `googleOAuthService`.
|
||||
|
||||
> [!warning] Single backup file
|
||||
> `frontend/src/auth/services/google-oauth.ts.backup` is checked in. Delete or convert to a documentation note — it leaks a hard-coded client ID that should only live in `.env.*`.
|
||||
|
||||
## Linked flows
|
||||
|
||||
- [[Authentication Flow]] — token issuance and storage are identical from step 9 onward.
|
||||
- [[Registration Flow]] — alternative path that requires email verification.
|
||||
- [[Referral Flow]] — works identically for Google-signup referrals.
|
||||
|
||||
## Source files
|
||||
|
||||
- Frontend: `frontend/src/auth/services/google-oauth.ts`
|
||||
- Frontend: `frontend/src/auth/context/jwt/action.ts:281-331`
|
||||
- Frontend: `frontend/src/auth/view/jwt/jwt-sign-up-view.tsx`, `jwt-sign-in-view.tsx`
|
||||
- Frontend: `frontend/GOOGLE_OAUTH_SETUP.md`
|
||||
- Backend: `backend/src/services/auth/authController.ts:781-941`
|
||||
- Backend: `backend/src/services/auth/googleOAuthService.ts`
|
||||
- Backend: `backend/src/services/auth/authRoutes.ts:30-31`
|
||||
148
04 - Flows/Negotiation Flow.md
Normal file
148
04 - Flows/Negotiation Flow.md
Normal file
@@ -0,0 +1,148 @@
|
||||
---
|
||||
title: Negotiation Flow
|
||||
tags: [flow, marketplace, negotiation, counter-offer, chat]
|
||||
related_models: ["[[SellerOffer]]", "[[PurchaseRequest]]", "[[Chat]]"]
|
||||
related_apis: ["PATCH /api/marketplace/offers/:id", "POST /api/chat", "POST /api/chat/:chatId/messages"]
|
||||
---
|
||||
|
||||
# Negotiation Flow
|
||||
|
||||
After an offer is submitted ([[Seller Offer Flow]]), the buyer and seller can negotiate the price/ETA via **counter-offers** exchanged through the chat. The request status moves to `in_negotiation`, and either party can finalise with accept/reject.
|
||||
|
||||
## Actors
|
||||
|
||||
- **Buyer** — initiates negotiation by replying to an offer or opening chat from the request detail.
|
||||
- **Seller** — receives counter, can accept or counter back.
|
||||
- **Frontend** — chat component (`frontend/src/sections/chat/`) overlaid on the request view; offer-edit modal under `frontend/src/sections/request/components/buyer-steps/step-3-components/`.
|
||||
- **Backend** — `ChatService.sendMessage` for chat lines, `SellerOfferService.updateOffer` for price/ETA edits, `PurchaseRequestService.updatePurchaseRequest` for the status flip.
|
||||
- **MongoDB** — `chats`, `selleroffers`, `purchaserequests`.
|
||||
- **Socket.IO** — `new-message`, `seller-offer-update`, `purchase-request-update`.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- A `SellerOffer` exists on the purchase request (status `pending`).
|
||||
- The purchase request is `received_offers` or `in_negotiation`.
|
||||
- Both parties are still active users.
|
||||
|
||||
## Step-by-step narrative
|
||||
|
||||
1. **Open negotiation chat** — when a buyer first clicks "Chat with seller" on an offer card, the frontend calls `POST /api/chat` to find-or-create a `direct` chat tied to the purchase request (`ChatService.createChat`, `chat.ts:90-192`). The chat's `relatedTo = { type: 'PurchaseRequest', id }` makes it discoverable from the request view.
|
||||
|
||||
> [!tip] Pre-payment chats vs. post-payment chats
|
||||
> A negotiation chat may exist **before** the SHKeeper webhook auto-creates the post-payment chat. The `ChatService.createChat` `direct` find-or-create logic (`ChatService.ts:95-108`) prevents duplicates — the same chat object is reused.
|
||||
|
||||
2. **Status flip to `in_negotiation`** — the first message in the negotiation chat triggers a backend hook (or a manual frontend PATCH) that calls `PurchaseRequestService.updatePurchaseRequest` with `{ status: 'in_negotiation' }`. The status-progression guard allows this (`received_offers → in_negotiation`).
|
||||
|
||||
3. **Buyer proposes a counter** — the buyer types a message like "Can you do $80 instead of $100?". Two patterns are used:
|
||||
- **Free-form** — just a chat message; the seller eyeballs the offer-edit screen and updates the price.
|
||||
- **Structured counter** — the buyer opens an "edit offer" modal that PATCHes `/api/marketplace/offers/{id}` with the new desired terms. This is currently a seller-only edit endpoint; structured buyer counters typically come as a system message in the chat (`messageType: 'system'`) referencing the new price.
|
||||
|
||||
4. **Seller updates the offer** — `SellerOfferService.updateOffer` (`:271-295`):
|
||||
- `SellerOffer.findByIdAndUpdate(id, { ...updateData, updatedAt: now }, { new: true })`.
|
||||
- Emits `purchase-request-update` with `eventType: 'offer-updated'` to `request-{requestId}` (`SellerOfferService.ts:284-288`) — both parties' open tabs refresh.
|
||||
|
||||
5. **Buyer accepts** — clicks "Accept this offer", which kicks off [[Payment Flow - SHKeeper]] with the (now-updated) `sellerOfferId`. The webhook flips offer → `accepted` and request → `payment`.
|
||||
|
||||
6. **Buyer rejects** — calls `PATCH /api/marketplace/offers/{id}` with `{ status: 'rejected' }`. `SellerOfferService.updateOfferStatus` (`:306-353`) sends `notifyOfferRejected` to the seller and stamps `rejectedAt` + `rejectionReason`.
|
||||
|
||||
7. **Seller withdraws** — `withdrawOffer` (`:428-443`) only works while `status === 'pending'`. After rejection/acceptance, withdrawal is impossible.
|
||||
|
||||
8. **Chat continues** — even after status flips, the chat remains open for clarifications (delivery details, dispute prep). See [[Chat Flow]] for message-level semantics.
|
||||
|
||||
## Sequence diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor B as Buyer
|
||||
actor S as Seller
|
||||
participant FE_B as Frontend (buyer)
|
||||
participant FE_S as Frontend (seller)
|
||||
participant BE as Backend
|
||||
participant DB as MongoDB
|
||||
participant IO as Socket.IO
|
||||
|
||||
B->>FE_B: Click "Chat with seller" on offer
|
||||
FE_B->>BE: POST /api/chat (type:direct, relatedTo:PR)
|
||||
BE->>DB: find-or-create Chat
|
||||
BE-->>FE_B: { chat }
|
||||
B->>FE_B: Send "Can you do $80?"
|
||||
FE_B->>BE: POST /api/chat/{id}/messages
|
||||
BE->>DB: Chat.addMessage(...)
|
||||
BE->>IO: emit chat-{id} 'new-message'
|
||||
IO-->>FE_S: 'new-message' (seller sees in real time)
|
||||
BE->>BE: optionally trigger request → in_negotiation
|
||||
BE->>DB: PurchaseRequest.status = "in_negotiation"
|
||||
BE->>IO: emit request-{id} 'purchase-request-update' (status-changed)
|
||||
S->>FE_S: Open edit-offer modal, set new price
|
||||
FE_S->>BE: PATCH /api/marketplace/offers/{id} {price:{amount:80}}
|
||||
BE->>DB: SellerOffer update
|
||||
BE->>IO: emit request-{id} 'purchase-request-update' (offer-updated)
|
||||
IO-->>FE_B: refresh offer card
|
||||
alt Buyer accepts
|
||||
B->>FE_B: Click "Pay" → [[Payment Flow - SHKeeper]]
|
||||
Note over BE: Webhook PAID flips offer→accepted, request→payment
|
||||
else Buyer rejects
|
||||
B->>FE_B: Click "Reject"
|
||||
FE_B->>BE: PATCH /api/marketplace/offers/{id} {status:"rejected"}
|
||||
BE->>DB: offer.status = "rejected"
|
||||
BE->>BE: notifyOfferRejected(seller)
|
||||
IO-->>FE_S: 'new-notification'
|
||||
end
|
||||
```
|
||||
|
||||
## API calls
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/chat` | Find-or-create negotiation chat |
|
||||
| `POST` | `/api/chat/:chatId/messages` | Send chat message |
|
||||
| `PATCH` | `/api/marketplace/offers/:id` | Seller updates price / ETA / notes (counter) |
|
||||
| `PATCH` | `/api/marketplace/purchase-requests/:id` | Status transition to `in_negotiation` |
|
||||
| `POST` | `/api/marketplace/offers/:id/withdraw` | Seller pulls the offer |
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`chats`**: messages appended via `chat.addMessage`; `metadata.lastActivity` bumped; `unreadCounts` incremented for non-sender participants.
|
||||
- **`selleroffers`**: counter changes update `price`, `deliveryTime`, `notes`, `updatedAt`.
|
||||
- **`purchaserequests`**: status flips when first counter arrives.
|
||||
- **`notifications`**: created per status change (accept/reject), not per chat message (chat has its own real-time channel).
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
- **`new-message`** → `chat-{chatId}` (the canonical chat event).
|
||||
- **`chat-notification`** → `user-{participantId}` for non-senders (badge increment).
|
||||
- **`purchase-request-update`** with `eventType: 'offer-updated'` → `request-{id}` whenever the offer is edited.
|
||||
- **`purchase-request-update`** with `eventType: 'status-changed'` → `request-{id}` on the `received_offers → in_negotiation` flip.
|
||||
|
||||
## Side effects
|
||||
|
||||
- The chat's unread badge grows in the recipient's chat list (`Chat.unreadCounts` array). Resets when they open the chat and `POST /api/chat/{id}/read`.
|
||||
- Typing indicators are emitted via `typing-start` / `typing-stop` socket events (see `backend/src/app.ts:142-158`) — purely client-driven.
|
||||
|
||||
## Error / edge cases
|
||||
|
||||
- **Sender not a chat participant** → `403 "User is not a participant in this chat"` (`ChatService.sendMessage:209-211`).
|
||||
- **Counter on an offer the buyer doesn't own a request for** → blocked by the controller (the buyer must be the request's owner).
|
||||
- **Counter on an `accepted` offer** → `updateOffer` does not enforce status; the schema/controller should reject. Current code allows the price change, which is dangerous post-payment. Recommended hardening: guard with `if (offer.status !== 'pending') throw`.
|
||||
- **Status regression attempt** (`in_negotiation → received_offers`) → blocked by `isValidStatusProgression` (`PurchaseRequestService.ts:31-50`).
|
||||
- **Two simultaneous edits** — last-write-wins on `findByIdAndUpdate`; consider optimistic concurrency via `__v` if conflicts become an issue.
|
||||
- **Chat created in negotiation but buyer never pays** → orphan chat remains; the post-payment chat (in [[Chat Flow]]) reuses it because the find-or-create logic matches by participants + relatedTo.
|
||||
|
||||
> [!warning] No structured "counter-offer object"
|
||||
> Today, counter-offer negotiations are mostly free-form chat plus an `updateOffer` edit. There is no `CounterOffer` collection with provenance. If audit/regulatory needs emerge, capture each counter as a snapshot (`{ oldPrice, newPrice, byUserId, atTime }`) on the offer.
|
||||
|
||||
## Linked flows
|
||||
|
||||
- [[Seller Offer Flow]] — the prior step.
|
||||
- [[Payment Flow - SHKeeper]] — closes the negotiation with an on-chain payment.
|
||||
- [[Chat Flow]] — message-level mechanics, attachments, read receipts.
|
||||
- [[Notification Flow]] — accept/reject notifications.
|
||||
|
||||
## Source files
|
||||
|
||||
- Backend: `backend/src/services/marketplace/SellerOfferService.ts:271-353`
|
||||
- Backend: `backend/src/services/marketplace/PurchaseRequestService.ts:408-495`
|
||||
- Backend: `backend/src/services/chat/ChatService.ts:90-260`
|
||||
- Frontend: `frontend/src/sections/request/components/buyer-steps/step-3-components/`
|
||||
- Frontend: `frontend/src/sections/chat/` (chat UI)
|
||||
155
04 - Flows/Notification Flow.md
Normal file
155
04 - Flows/Notification Flow.md
Normal file
@@ -0,0 +1,155 @@
|
||||
---
|
||||
title: Notification Flow
|
||||
tags: [flow, notification, socket-io, email]
|
||||
related_models: ["[[Notification]]", "[[User]]"]
|
||||
related_apis: ["GET /api/notifications", "PATCH /api/notifications/:id/read", "POST /api/notifications/read-all", "DELETE /api/notifications/:id"]
|
||||
---
|
||||
|
||||
# Notification Flow
|
||||
|
||||
Cross-cutting flow that powers the bell icon, toast pop-ups, badge counters, and (optionally) email digests. Notifications are created by many services and travel to the user via both **MongoDB persistence** and **Socket.IO push**.
|
||||
|
||||
## Trigger sources
|
||||
|
||||
- **Purchase request lifecycle** — created, status changed, cancelled (`PurchaseRequestService`).
|
||||
- **Offer lifecycle** — new offer, accepted, rejected, withdrawn (`SellerOfferService`).
|
||||
- **Payment lifecycle** — confirmed, refunded, payout sent (`shkeeperWebhook`, `PurchaseRequestService.notifyPaymentConfirmed`).
|
||||
- **Chat** — `chat-notification` events (see [[Chat Flow]]).
|
||||
- **Dispute** — created, assigned, resolved (TODO in `DisputeService`; the chat itself notifies).
|
||||
- **Delivery** — code generated, delivery confirmed (`DeliveryService`).
|
||||
- **Referral** — sign-up via referral code (`AuthController.verifyEmailWithCode`, `googleSignUp`).
|
||||
- **Points / levels** — `level-up` event when crossing a tier (`PointsService.addPoints:91-99`).
|
||||
- **Admin actions** — e.g. dispute resolution, manual payouts.
|
||||
|
||||
## Actors
|
||||
|
||||
- **System** — the various services calling `NotificationService.createNotification`.
|
||||
- **User** — the recipient.
|
||||
- **Frontend** — bell-icon dropdown and toast subscribers in `frontend/src/layouts/components/notifications-drawer/` and the global socket provider.
|
||||
- **Backend** — `NotificationService` (`backend/src/services/notification/NotificationService.ts`), routes at `/api/notifications`.
|
||||
- **MongoDB** — `notifications` collection (one document per notification).
|
||||
- **Socket.IO** — emits `new-notification` to `user-{userId}`.
|
||||
- **Email** (optional) — periodic digest worker (not implemented today; planned).
|
||||
|
||||
## Step-by-step narrative
|
||||
|
||||
### Creating a notification
|
||||
|
||||
1. Any service builds a `NotificationCreateData` object:
|
||||
```
|
||||
{
|
||||
userId, title, message,
|
||||
type: 'info' | 'success' | 'warning' | 'error',
|
||||
category: 'purchase_request' | 'offer' | 'payment' | 'delivery' | 'system',
|
||||
relatedId?, metadata?, actionUrl?
|
||||
}
|
||||
```
|
||||
2. Calls `await notificationService.createNotification(data)`.
|
||||
3. `NotificationService.createNotification` (`NotificationService.ts:18-37`):
|
||||
- `Notification.create({ ...data, isRead: false, createdAt: now })`.
|
||||
- Calls `this.emitRealTimeNotification(userId, saved)` which `global.io.to('user-${userId}').emit('new-notification', payload)`.
|
||||
4. The notification is persisted and pushed simultaneously.
|
||||
|
||||
### Frontend reception
|
||||
|
||||
5. The frontend's global Socket.IO provider listens for `new-notification` events on its `user-{me}` room (joined on app mount via `socket.emit('join-user-room', userId)`).
|
||||
6. On event: increment the bell-icon badge, optionally show a toast (`notistack`), and prepend the entry into the cached notifications list (React Query cache).
|
||||
7. The bell-icon dropdown also fetches `GET /api/notifications?page=1&limit=20` for paginated history.
|
||||
|
||||
### Reading
|
||||
|
||||
8. User opens the bell-icon dropdown — frontend calls `POST /api/notifications/mark-read` for each viewed entry (or `POST /api/notifications/read-all`).
|
||||
9. `NotificationService.markAsRead(notificationId, userId)` (`NotificationService.ts:74-90`):
|
||||
- `Notification.findOneAndUpdate({ _id, userId }, { isRead: true, readAt: now })`.
|
||||
- Emits `notification-read` (or recomputes unread count) so other open tabs sync.
|
||||
|
||||
### Preferences
|
||||
|
||||
- `User.preferences.notifications` (in the User schema) can hold per-category opt-outs (`emailNotifications`, `pushNotifications`, etc.). The current implementation does not enforce preferences at send-time — all enabled notifications fire. Add a check in `createNotification` to short-circuit when the user has opted out of a category.
|
||||
|
||||
### Email digest (planned)
|
||||
|
||||
- A scheduled worker should `Notification.find({ userId, emailDigested: false, createdAt: { $gte: yesterday } })`, batch by user, render a digest email via `emailService`, mark `emailDigested: true`. Not implemented today.
|
||||
|
||||
## Sequence diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant Svc as Originating Service<br/>(SellerOfferService / Webhook / ...)
|
||||
participant NS as NotificationService
|
||||
participant DB as MongoDB
|
||||
participant IO as Socket.IO
|
||||
participant FE as Frontend
|
||||
actor U as User
|
||||
|
||||
Svc->>NS: createNotification({userId, title, message, ...})
|
||||
NS->>DB: Notification.create
|
||||
NS->>IO: emit user-{userId} 'new-notification'
|
||||
IO-->>FE: 'new-notification' payload
|
||||
FE-->>U: badge++, toast, prepend to list
|
||||
|
||||
U->>FE: open bell dropdown
|
||||
FE->>NS: GET /api/notifications?page=1&limit=20
|
||||
NS->>DB: Notification.find({userId}).sort({createdAt:-1})
|
||||
NS-->>FE: { notifications, total, unreadCount }
|
||||
|
||||
U->>FE: click notification
|
||||
FE->>NS: PATCH /api/notifications/{id}/read
|
||||
NS->>DB: Notification.findOneAndUpdate(isRead:true)
|
||||
FE-->>U: badge--, mark item as read
|
||||
FE-->>U: navigate to notification.actionUrl
|
||||
```
|
||||
|
||||
## API calls
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/notifications` | Paginated list with `unreadCount` |
|
||||
| `GET` | `/api/notifications/unread-count` | Just the unread count for badge |
|
||||
| `PATCH` | `/api/notifications/:id/read` | Mark single notification read |
|
||||
| `POST` | `/api/notifications/read-all` | Mark all read |
|
||||
| `DELETE` | `/api/notifications/:id` | Remove from list |
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`notifications`** — insert on create, update on read, delete on remove.
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
- **`new-notification`** → `user-{userId}`. Payload includes the full notification document (so the frontend doesn't need to re-fetch).
|
||||
- **`level-up`** → `user-{userId}` from `PointsService.addPoints`.
|
||||
- **`referral-signup`** → `user-{referrerId}` from auth verify.
|
||||
- **`chat-notification`** → `user-{participantId}` from `ChatService.sendMessage` (these are not stored in the `notifications` collection — they live alongside but drive only the chat-list badge).
|
||||
|
||||
## Side effects
|
||||
|
||||
- Bell badge count is derived from `unreadCount` returned by the GET endpoint or computed client-side as items arrive.
|
||||
- Notification actions deep-link via `actionUrl` (e.g. `/dashboard/buyer/requests/{id}`).
|
||||
- Sentry breadcrumbs capture failed notification creations.
|
||||
|
||||
## Error / edge cases
|
||||
|
||||
- **User offline** → notification is persisted in MongoDB; the user sees it when they next sign in. The socket emit is lossy (no replay).
|
||||
- **Multiple tabs / devices** → the same `user-{id}` room receives the event in each socket; all tabs update.
|
||||
- **Disabled categories** (planned) → service should early-return without DB write if the user has opted out, otherwise persist but don't push (so it shows in history but not as a toast).
|
||||
- **High volume** (e.g. fan-out to thousands of sellers) → today every notification is a separate Mongo insert + socket emit. For mass announcements, consider `insertMany` + per-room broadcast. The 50ms stagger in [[Purchase Request Flow]] mitigates the worst case.
|
||||
- **Stale unread count** → if the frontend trusts a stale React Query cache, it can show wrong numbers; always reconcile against `unread-count` on bell-icon open.
|
||||
|
||||
> [!tip] Always set `actionUrl`
|
||||
> Every notification should have a deep-link target. Notifications without `actionUrl` lead to dead clicks. The factory methods in `NotificationService` (e.g. `notifyNewOfferReceived`) already enforce this — keep the pattern when adding new helpers.
|
||||
|
||||
## Linked flows
|
||||
|
||||
- Every other flow in this folder emits notifications via this service.
|
||||
- [[Chat Flow]] — separate `chat-notification` socket channel for chat badges.
|
||||
|
||||
## Source files
|
||||
|
||||
- Backend: `backend/src/services/notification/NotificationService.ts`
|
||||
- Backend: `backend/src/services/notification/notificationController.ts`
|
||||
- Backend: `backend/src/services/notification/notificationControllerRoutes.ts`
|
||||
- Backend: `backend/src/services/notification/routes.ts`
|
||||
- Backend: `backend/src/models/Notification.ts`
|
||||
- Frontend: `frontend/src/layouts/components/notifications-drawer/`
|
||||
- Frontend: socket provider (joins `user-{id}` and listens for `new-notification`)
|
||||
162
04 - Flows/Passkey (WebAuthn) Flow.md
Normal file
162
04 - Flows/Passkey (WebAuthn) Flow.md
Normal file
@@ -0,0 +1,162 @@
|
||||
---
|
||||
title: Passkey (WebAuthn) Flow
|
||||
tags: [flow, auth, passkey, webauthn, passwordless]
|
||||
related_models: ["[[User]]"]
|
||||
related_apis: ["POST /api/auth/passkey/register/challenge", "POST /api/auth/passkey/register", "POST /api/auth/passkey/authenticate/challenge", "POST /api/auth/passkey/authenticate", "GET /api/auth/passkey/list", "DELETE /api/auth/passkey/:passkeyId"]
|
||||
---
|
||||
|
||||
# Passkey (WebAuthn) Flow
|
||||
|
||||
Passwordless sign-in using **WebAuthn / Passkeys**. The backend issues challenges, validates signed assertions, stores credential metadata under `User.passkeys[]`, and finally issues the same JWT pair as the password flow.
|
||||
|
||||
## Actors
|
||||
|
||||
- **User** with a WebAuthn-capable authenticator (Touch ID, Face ID, Windows Hello, Android biometric, YubiKey).
|
||||
- **Browser WebAuthn API** — `navigator.credentials.create()` / `.get()`.
|
||||
- **Frontend** — `frontend/src/auth/components/PasskeyManagement.tsx` (registration UI on the account settings page) and `frontend/src/auth/components/PasskeySignIn.tsx` (sign-in entry).
|
||||
- **Backend** — `backend/src/services/auth/passkeyService.ts` and the routes in `backend/src/services/auth/passkeyRoutes.ts`.
|
||||
- **MongoDB** — `User.passkeys[]` subdocument array (id, publicKey, counter, deviceType, deviceName, createdAt).
|
||||
|
||||
## Preconditions
|
||||
|
||||
- The browser supports WebAuthn (`window.PublicKeyCredential`). The frontend checks this and throws `"WebAuthn در این مرورگر پشتیبانی نمیشود"` otherwise.
|
||||
- The relying party ID derives from `config.frontendUrl` — `backend/src/services/auth/passkeyService.ts:36` strips scheme/port to produce the WebAuthn `rpId`.
|
||||
- For **registration**, the user is already authenticated via password or Google (the `/passkey/register/*` routes are behind `authService.authenticateToken`).
|
||||
- For **sign-in**, no auth is required — the authenticator's credential ID identifies the user.
|
||||
- Env vars consumed by the frontend (commonly `NEXT_PUBLIC_PASSKEY_RP_ID`, `NEXT_PUBLIC_PASSKEY_RP_NAME`, `NEXT_PUBLIC_PASSKEY_ORIGIN` per the codebase conventions) drive WebAuthn options on the client.
|
||||
|
||||
## Registration flow
|
||||
|
||||
1. From `/dashboard/account/security`, the user opens the Passkey management card and clicks **"Add new passkey"**.
|
||||
2. Frontend `PasskeyManagement.tsx` calls `POST /api/auth/passkey/register/challenge` (with the bearer access token).
|
||||
3. Backend `passkeyService.generateRegistrationChallenge(userId)` (`passkeyService.ts:58-70`):
|
||||
- `crypto.randomBytes(32).toString('base64url')` — a 256-bit challenge.
|
||||
- Stored in an in-memory `Map<challenge, { userId, timestamp }>` (5-min TTL via interval cleanup).
|
||||
- Returns `{ challenge, rpId, userVerification: 'preferred', timeout: 60000 }`.
|
||||
4. Frontend calls `navigator.credentials.create({ publicKey: { challenge, rp, user, pubKeyCredParams, ... } })`. The browser prompts the authenticator (Touch ID, etc.) and returns a `PublicKeyCredential` containing `id`, `rawId`, `response.clientDataJSON`, `response.attestationObject`.
|
||||
5. Frontend POSTs `POST /api/auth/passkey/register` with `{ challenge, credential }`.
|
||||
6. Backend `passkeyService.verifyRegistration(challenge, credential)` (`:108-146`):
|
||||
- Looks up the stored challenge → `{ userId }`. Deletes it (single-use).
|
||||
- Loads `User.findById(userId)`.
|
||||
- Appends to `user.passkeys[]`: `{ id: credential.id, publicKey: 'simulated-public-key', counter: 0, deviceType: 'platform', deviceName: 'Default Device', createdAt: now }`.
|
||||
- Saves.
|
||||
7. Frontend re-fetches `GET /api/auth/passkey/list` and renders the new entry.
|
||||
|
||||
> [!warning] Attestation validation is stubbed
|
||||
> `passkeyService.verifyRegistration` currently **does not** parse the attestation object or extract the real COSE public key — see the comment block at `passkeyService.ts:122-128` ("In a real implementation, you would..."). The `publicKey` field is the literal string `'simulated-public-key'`. This means a malicious client could register an attacker-controlled credential ID under any user; harden this before production. Use `@simplewebauthn/server` to parse attestation and store the verified public key.
|
||||
|
||||
## Authentication flow
|
||||
|
||||
1. From `/auth/jwt/sign-in`, the user clicks **"Sign in with passkey"**.
|
||||
2. Frontend `PasskeySignIn.tsx` calls `POST /api/auth/passkey/authenticate/challenge` — note this is a **public** route (no bearer token).
|
||||
3. Backend `passkeyService.generateAuthenticationChallengeForSignIn()` (`:88-105`) generates a 32-byte challenge and stores it with `userId: 'pending'`.
|
||||
4. Frontend calls `navigator.credentials.get({ publicKey: { challenge, rpId, userVerification: 'preferred' } })`. The browser surfaces all matching passkeys for the rpId; the user picks one and approves biometrically.
|
||||
5. The authenticator returns a `PublicKeyCredential` whose `response` includes `clientDataJSON`, `authenticatorData`, `signature`, `userHandle`.
|
||||
6. Frontend POSTs `POST /api/auth/passkey/authenticate` with `{ challenge, assertion }`.
|
||||
7. Backend `passkeyService.verifyAuthentication(challenge, assertion)` (`:149-272`):
|
||||
- Confirms the challenge exists (and deletes it).
|
||||
- `User.findOne({ 'passkeys.id': assertion.id })` — finds the user whose passkey matches the credential ID supplied by the authenticator.
|
||||
- `passkey.counter += 1` (the schema stores a counter; a real implementation must reject replays where the new counter is not strictly greater than the stored one).
|
||||
- Issues JWT access + refresh tokens directly via `jwt.sign(...)` (`:230-248`). Note: these are signed by the same `config.jwtSecret` as in `authService`, so they are interchangeable with password-issued tokens.
|
||||
8. Response: `{ success: true, userId, user: {...}, tokens: { accessToken, refreshToken } }`.
|
||||
9. Frontend stores tokens in `localStorage` and redirects to the dashboard.
|
||||
|
||||
## Sequence diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor U as User
|
||||
participant FE as Frontend
|
||||
participant W as WebAuthn (Browser)
|
||||
participant BE as Backend
|
||||
participant DB as MongoDB
|
||||
|
||||
rect rgb(245,247,250)
|
||||
Note over U,DB: Registration (user already authenticated)
|
||||
U->>FE: Click "Add passkey"
|
||||
FE->>BE: POST /api/auth/passkey/register/challenge (Bearer)
|
||||
BE->>BE: generateRegistrationChallenge(userId)\nstore in Map
|
||||
BE-->>FE: { challenge, rpId, ... }
|
||||
FE->>W: navigator.credentials.create({ publicKey })
|
||||
W-->>FE: PublicKeyCredential
|
||||
FE->>BE: POST /api/auth/passkey/register { challenge, credential }
|
||||
BE->>BE: verifyRegistration → consume challenge
|
||||
BE->>DB: user.passkeys.push({ id, counter, deviceType })
|
||||
BE-->>FE: { success: true }
|
||||
end
|
||||
|
||||
rect rgb(245,247,250)
|
||||
Note over U,DB: Authentication (no prior session)
|
||||
U->>FE: Click "Sign in with passkey"
|
||||
FE->>BE: POST /api/auth/passkey/authenticate/challenge (public)
|
||||
BE->>BE: generateAuthenticationChallengeForSignIn → store
|
||||
BE-->>FE: { challenge, rpId, ... }
|
||||
FE->>W: navigator.credentials.get({ publicKey })
|
||||
W-->>FE: PublicKeyCredential (assertion)
|
||||
FE->>BE: POST /api/auth/passkey/authenticate { challenge, assertion }
|
||||
BE->>BE: consume challenge
|
||||
BE->>DB: User.findOne({ 'passkeys.id': assertion.id })
|
||||
DB-->>BE: user with matching passkey
|
||||
BE->>DB: passkey.counter += 1
|
||||
BE->>BE: jwt.sign(access) / jwt.sign(refresh)
|
||||
BE-->>FE: { success, user, tokens }
|
||||
FE->>FE: localStorage.setItem(tokens)
|
||||
FE-->>U: Redirect /dashboard
|
||||
end
|
||||
```
|
||||
|
||||
## API calls
|
||||
|
||||
| Method | Endpoint | Auth | Source |
|
||||
|---|---|---|---|
|
||||
| `POST` | `/api/auth/passkey/register/challenge` | Bearer | `passkeyRoutes.ts:50` |
|
||||
| `POST` | `/api/auth/passkey/register` | Bearer | `passkeyRoutes.ts:66` |
|
||||
| `POST` | `/api/auth/passkey/authenticate/challenge` | Public | `passkeyRoutes.ts:10` |
|
||||
| `POST` | `/api/auth/passkey/authenticate` | Public | `passkeyRoutes.ts:23` |
|
||||
| `GET` | `/api/auth/passkey/list` | Bearer | `passkeyRoutes.ts:87` |
|
||||
| `DELETE` | `/api/auth/passkey/:passkeyId` | Bearer | `passkeyRoutes.ts:103` |
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`users.passkeys`** — append on register, increment `counter` on each successful auth, splice on delete.
|
||||
- A new refresh token is **not** appended to `user.refreshTokens` in the current passkey path (the JWT is signed directly without round-tripping through `authService.generateRefreshToken`). This means the password-flow refresh-token allow-list does not apply to passkey logins. See edge cases.
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
- None directly. The frontend joins the same Socket.IO rooms after login as in [[Authentication Flow]].
|
||||
|
||||
## Side effects
|
||||
|
||||
- **In-memory `storedChallenges` map**: per-instance, not Redis. On a horizontally scaled deployment, the challenge created on instance A can only be verified on instance A. Either pin to a single instance, use sticky sessions, or move to Redis (`paymentRedisService`-style).
|
||||
- **Cleanup interval**: every 5 minutes, expired challenges (>5 min old) are removed (`passkeyService.ts:42-55`).
|
||||
|
||||
## Error / edge cases
|
||||
|
||||
- **Browser without WebAuthn** → frontend throws localized error before issuing the challenge request.
|
||||
- **User cancels biometric prompt** → `NotAllowedError` from the browser; frontend shows "Cancelled" toast.
|
||||
- **Challenge expired or unknown** → backend `Invalid or expired challenge` (`:117`). Frontend asks user to retry.
|
||||
- **Credential ID does not match any user** → `404 Passkey not found` (`passkeyRoutes.ts:40-44`). UX should suggest signing in by email instead.
|
||||
- **Multi-instance backend** (challenge stored on instance A, verified on instance B) → verification fails. Fix by moving `storedChallenges` to Redis.
|
||||
- **Replay** — current implementation does not strictly enforce monotonic counter; revisit before production.
|
||||
- **Refresh-token rotation gap** — passkey-issued refresh tokens are not added to `user.refreshTokens[]`. The standard `/api/auth/refresh-token` will reject them on the next refresh. Until fixed, treat passkey access tokens as short-lived (the user must passkey-sign-in again after expiry) or unify token issuance through `authService.generateRefreshToken` and persist them.
|
||||
|
||||
> [!warning] Production hardening checklist
|
||||
> 1. Replace stub attestation parsing with `@simplewebauthn/server`.
|
||||
> 2. Persist the COSE public key, not a stub string.
|
||||
> 3. Enforce strictly increasing counter (signal of cloned authenticator if not).
|
||||
> 4. Move challenge storage to Redis to support multi-instance deploys.
|
||||
> 5. Add `excludeCredentials` during registration to prevent re-registering the same passkey.
|
||||
> 6. Push the passkey-issued refresh token into `user.refreshTokens[]`.
|
||||
|
||||
## Linked flows
|
||||
|
||||
- [[Authentication Flow]] — token semantics are identical post-issuance.
|
||||
- [[Registration Flow]] — passkey is an additional credential, not a replacement for initial account creation.
|
||||
|
||||
## Source files
|
||||
|
||||
- Backend: `backend/src/services/auth/passkeyService.ts`
|
||||
- Backend: `backend/src/services/auth/passkeyRoutes.ts`
|
||||
- Frontend: `frontend/src/auth/components/PasskeyManagement.tsx`
|
||||
- Frontend: `frontend/src/auth/components/PasskeySignIn.tsx`
|
||||
124
04 - Flows/Password Reset Flow.md
Normal file
124
04 - Flows/Password Reset Flow.md
Normal file
@@ -0,0 +1,124 @@
|
||||
---
|
||||
title: Password Reset Flow
|
||||
tags: [flow, auth, password-reset, email]
|
||||
related_models: ["[[User]]"]
|
||||
related_apis: ["POST /api/auth/request-password-reset", "POST /api/auth/reset-password-with-code"]
|
||||
---
|
||||
|
||||
# Password Reset Flow
|
||||
|
||||
Self-service password recovery: request a 6-digit code by email, submit it with the new password.
|
||||
|
||||
## Actors
|
||||
|
||||
- **User** who has forgotten their password.
|
||||
- **Frontend** — `frontend/src/auth/view/jwt/jwt-reset-password-view.tsx` (request) and `jwt-update-password-view.tsx` (submit new password).
|
||||
- **Backend** — `AuthController.requestPasswordReset` and `AuthController.resetPasswordWithCode` in `backend/src/services/auth/authController.ts`.
|
||||
- **MongoDB** — `User` collection (`passwordResetCode`, `passwordResetCodeExpires`, `refreshTokens`).
|
||||
- **Email service** — `emailService.sendPasswordResetCodeEmail`.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- The account exists and `status === "active"` (deleted accounts are silently treated as non-existent).
|
||||
- The user has access to the email inbox associated with the account.
|
||||
- A 6-digit code is valid for **1 hour** (`authController.ts:556`).
|
||||
|
||||
## Step-by-step narrative
|
||||
|
||||
1. User clicks "Forgot password?" on the sign-in page and lands at `/auth/jwt/reset-password`.
|
||||
2. User enters their email and submits.
|
||||
3. Frontend POSTs `POST /api/auth/request-password-reset { email }`.
|
||||
4. Backend `authController.requestPasswordReset` (`:542-574`):
|
||||
- `User.findOne({ email, status: "active" })`. If absent, returns `200` with the same generic message — **no enumeration**.
|
||||
- Generates a 6-digit code via `authService.generateVerificationCode()`.
|
||||
- Saves `passwordResetCode` and `passwordResetCodeExpires = now + 3_600_000 ms` on the user.
|
||||
- Calls `emailService.sendPasswordResetCodeEmail(email, firstName, code)`.
|
||||
5. Response: `200 "If an account with this email exists, a password reset code has been sent"` regardless of outcome.
|
||||
6. User receives the email and enters the code + new password on `/auth/jwt/update-password`.
|
||||
7. Frontend POSTs `POST /api/auth/reset-password-with-code { email, code, password }`.
|
||||
8. Backend `authController.resetPasswordWithCode` (`:611-657`):
|
||||
- Validates code format `/^\d{6}$/`.
|
||||
- `User.findOne({ email, passwordResetCode: code, passwordResetCodeExpires: { $gt: now }, status: "active" })`. Mismatch → `400 Invalid or expired reset code`.
|
||||
- Hashes the new password with bcrypt cost 12.
|
||||
- Sets `user.password = hashed`, clears `passwordResetCode` and `passwordResetCodeExpires`, **wipes `user.refreshTokens = []`** to invalidate all existing sessions.
|
||||
- Saves.
|
||||
9. Response: `200 "Password reset successfully"`. Frontend redirects to `/auth/jwt/sign-in` for a fresh login.
|
||||
|
||||
## Sequence diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor U as User
|
||||
participant FE as Frontend
|
||||
participant BE as Backend
|
||||
participant DB as MongoDB
|
||||
participant MAIL as Email Service
|
||||
|
||||
U->>FE: Click "Forgot password", enter email
|
||||
FE->>BE: POST /api/auth/request-password-reset { email }
|
||||
BE->>DB: User.findOne({ email, status: "active" })
|
||||
alt user found
|
||||
BE->>BE: code = generateVerificationCode()
|
||||
BE->>DB: user.passwordResetCode = code\nexpires = +1h
|
||||
BE->>MAIL: sendPasswordResetCodeEmail(email, firstName, code)
|
||||
MAIL-->>U: Email with 6-digit code
|
||||
end
|
||||
BE-->>FE: 200 "if account exists, code sent"
|
||||
|
||||
U->>FE: Enter code + new password
|
||||
FE->>BE: POST /api/auth/reset-password-with-code { email, code, password }
|
||||
BE->>DB: User.findOne({ email, code, expires>now })
|
||||
BE->>BE: bcrypt.hash(password, 12)
|
||||
BE->>DB: user.password = hash\nuser.refreshTokens = []\nclear reset fields
|
||||
BE-->>FE: 200 "Password reset successfully"
|
||||
FE-->>U: Redirect /auth/jwt/sign-in
|
||||
```
|
||||
|
||||
## API calls
|
||||
|
||||
| Method | Endpoint | Source |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/auth/request-password-reset` | `authRoutes.ts:44-47` |
|
||||
| `POST` | `/api/auth/reset-password-with-code` | `authRoutes.ts:54-56` |
|
||||
| `POST` | `/api/auth/reset-password` | `authRoutes.ts:49-52` (legacy token-based variant) |
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`users` collection**: on request, sets `passwordResetCode` + `passwordResetCodeExpires`. On submit, replaces `password`, clears reset fields, and empties `refreshTokens`.
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
- None.
|
||||
|
||||
## Side effects
|
||||
|
||||
- **Email**: one transactional message containing the 6-digit code.
|
||||
- **Server-side log**: `authController.ts:559` `console.log` includes the generated code in plain text — same hardening note as [[Registration Flow]].
|
||||
- **Session invalidation**: All refresh tokens cleared → all devices forced to re-login after password change. Access tokens still valid until expiry (typically minutes).
|
||||
|
||||
## Error / edge cases
|
||||
|
||||
- **Unknown email** → always `200`, generic message. No enumeration.
|
||||
- **Invalid code format** → `400` from `isValidVerificationCode` guard before DB lookup.
|
||||
- **Expired code** (>1h) → `400 Invalid or expired reset code`.
|
||||
- **Multiple parallel requests** → each overwrites the previous `passwordResetCode`; the latest email wins, prior codes silently invalidated.
|
||||
- **User attempts reset on deleted account** → treated as unknown (no email sent, `200` returned).
|
||||
- **Email delivery failure** → response still `200`; user can request again.
|
||||
- **Access tokens still valid post-reset** → unavoidable with stateless JWT; mitigated by short TTL. Critical operations should re-verify password.
|
||||
|
||||
> [!warning] Plaintext code in logs
|
||||
> Same as [[Registration Flow]]: the reset code is `console.log`-ed by the controller in all environments. Restrict log access in production or gate the log behind `NODE_ENV !== 'production'`.
|
||||
|
||||
## Linked flows
|
||||
|
||||
- [[Authentication Flow]] — user re-signs-in after reset.
|
||||
- [[Registration Flow]] — same code-generation utility.
|
||||
|
||||
## Source files
|
||||
|
||||
- Backend: `backend/src/services/auth/authController.ts:542-657`
|
||||
- Backend: `backend/src/services/email/emailService.ts` (`sendPasswordResetCodeEmail`)
|
||||
- Frontend: `frontend/src/auth/view/jwt/jwt-reset-password-view.tsx`
|
||||
- Frontend: `frontend/src/auth/view/jwt/jwt-update-password-view.tsx`
|
||||
- Frontend: `frontend/src/auth/context/jwt/action.ts:181-200`, `:261-276`
|
||||
172
04 - Flows/Payment Flow - DePay & Web3.md
Normal file
172
04 - Flows/Payment Flow - DePay & Web3.md
Normal file
@@ -0,0 +1,172 @@
|
||||
---
|
||||
title: Payment Flow - DePay & Web3
|
||||
tags: [flow, payment, web3, wagmi, walletconnect, bsc]
|
||||
related_models: ["[[Payment]]", "[[PurchaseRequest]]"]
|
||||
related_apis: ["POST /api/payment/decentralized/create", "POST /api/payment/decentralized/verify"]
|
||||
---
|
||||
|
||||
# Payment Flow — DePay & Web3 (Wallet-Direct)
|
||||
|
||||
Alternative pay-in path: instead of routing through [[Payment Flow - SHKeeper]], the buyer connects their own wallet (MetaMask / WalletConnect / Coinbase Wallet) and signs an **on-chain transfer to the escrow wallet** directly. The backend then verifies the transaction against the BSC RPC.
|
||||
|
||||
## Actors
|
||||
|
||||
- **Buyer** — owner of the wallet doing the on-chain transfer.
|
||||
- **Frontend** — `frontend/src/web3/` (wagmi provider, web3-provider context); wallet-connect UI in `frontend/src/sections/account/account-wallet-connection.tsx`; the in-checkout integration is in `frontend/src/sections/request/components/buyer-steps/payment-card.tsx` and `frontend/src/sections/request/components/buyer-steps/step-3-components/step-3-payment.tsx`.
|
||||
- **Wagmi / WalletConnect / MetaMask** — wallet stack.
|
||||
- **Backend** — `decentralizedPaymentService.ts` (intent), `BSCTransactionVerifier` (on-chain verification), `decentralizedPaymentRoutes.ts`.
|
||||
- **Blockchain (BSC)** — verified via `https://bsc-dataseed.binance.org/` JSON-RPC.
|
||||
- **MongoDB** — `payments` collection (same model as SHKeeper, different `provider` value).
|
||||
- **Socket.IO** — `payment-created`, plus the cascade events from [[Payment Flow - SHKeeper]] when verification succeeds.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- `NEXT_PUBLIC_ESCROW_WALLET_ADDRESS` is set on the frontend (see `frontend/src/global-config.ts`). This is the destination address — typically a custodial BSC wallet operated by the platform admin.
|
||||
- Wagmi is configured with WalletConnect projectID and supported chains (BSC mainnet `56`, optionally BSC testnet `97`).
|
||||
- Buyer has a wallet with USDT/USDC balance and a small amount of BNB for gas.
|
||||
|
||||
## Step-by-step narrative
|
||||
|
||||
### Phase 1 — Connect wallet
|
||||
|
||||
1. Buyer hits the payment step and sees both pay options. Clicking **"Pay with wallet"** opens the WalletConnect modal via wagmi's `useConnect()`.
|
||||
2. The connection emits an `accountsChanged` event; the web3 context (`frontend/src/web3/context/web3-provider.tsx`) stores `wallet.address` and `wallet.chainId`.
|
||||
3. If `chainId !== 56` (BSC), the UI prompts a `wallet_switchEthereumChain` request.
|
||||
|
||||
### Phase 2 — Create intent on backend
|
||||
|
||||
4. Frontend POSTs `POST /api/payment/decentralized/create` with `{ purchaseRequestId, sellerOfferId, amount, fromAddress: wallet.address, token: 'USDT', network: 'bsc' }`. The backend records a `Payment` with `provider: 'other'` (or `'decentralized'` depending on enum extension), `direction: 'in'`, `status: 'pending'`, `blockchain.{network, token, sender, receiver: ESCROW_WALLET_ADDRESS}`.
|
||||
5. Response includes the **escrow wallet address** and the exact token amount (in decimals — for USDT-BEP20 that's 18 decimals; the helper `convertPaymentAmountForShkeeper` is shared from `currencyUtils.ts`).
|
||||
|
||||
### Phase 3 — Token approval (ERC-20 / BEP-20)
|
||||
|
||||
6. The frontend checks the user's current allowance via `useReadContract` / `allowance(owner, spender)` on the USDT contract.
|
||||
7. If allowance < amount, the frontend prompts an `approve(escrow, amount)` transaction. After confirmation, it proceeds to step 8.
|
||||
- Some flows skip `approve` and use **direct transfer** instead (`transfer(to, amount)` — no approval needed because the buyer is the holder, not a contract pulling from them). The codebase favours direct transfer; see usages of `web3Service.transferToken(...)` in `frontend/src/web3/web3Service.ts`.
|
||||
|
||||
### Phase 4 — On-chain transfer
|
||||
|
||||
8. Frontend calls `transfer(escrowAddress, amount)` on the USDT contract via wagmi's `useWriteContract`. The wallet popup asks the user to confirm gas.
|
||||
9. The buyer signs; the transaction is broadcast.
|
||||
10. Frontend listens via wagmi's `useWaitForTransactionReceipt({ hash })`. On `success` (1 confirmation), it captures the `transactionHash`.
|
||||
|
||||
### Phase 5 — Backend verification
|
||||
|
||||
11. Frontend POSTs `POST /api/payment/decentralized/verify` with `{ paymentId, transactionHash }`.
|
||||
12. Backend `BSCTransactionVerifier.verifyTransaction(txHash)` (`decentralizedPaymentService.ts`):
|
||||
- JSON-RPC `eth_getTransactionReceipt` against `bsc-dataseed.binance.org`.
|
||||
- Confirms `receipt.status === '0x1'` (success).
|
||||
- Computes confirmations = `current eth_blockNumber - receipt.blockNumber`.
|
||||
- Optionally decodes the `Transfer` event log to confirm `from`, `to`, and `value` match the expected payment.
|
||||
13. On success the backend:
|
||||
- Updates the `Payment`: `status = 'completed'`, `escrowState = 'funded'`, `blockchain.transactionHash`, `blockchain.confirmations`, `blockchain.confirmedAt = now`.
|
||||
- Triggers the **same cascade** as the SHKeeper webhook: mark winning offer accepted, reject others, transition request to `payment`, create chat, send notifications, emit socket events.
|
||||
14. Returns `{ status: 'confirmed', confirmations, blockNumber }`.
|
||||
|
||||
### Phase 6 — Frontend reaction
|
||||
|
||||
15. The checkout UI shows "Payment verified" with the block-explorer link (`https://bscscan.com/tx/{hash}`) and transitions to the awaiting-delivery state.
|
||||
|
||||
## Sequence diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor B as Buyer
|
||||
participant W as Wallet (MetaMask/WC)
|
||||
participant FE as Frontend (wagmi)
|
||||
participant BE as Backend
|
||||
participant BC as BSC RPC
|
||||
participant DB as MongoDB
|
||||
participant IO as Socket.IO
|
||||
|
||||
B->>FE: Click "Pay with wallet"
|
||||
FE->>W: connect()
|
||||
W-->>FE: { address, chainId }
|
||||
opt chainId != 56
|
||||
FE->>W: wallet_switchEthereumChain(0x38)
|
||||
end
|
||||
FE->>BE: POST /api/payment/decentralized/create
|
||||
BE->>DB: Payment.create({provider:"other", direction:"in", receiver:ESCROW})
|
||||
BE-->>FE: { paymentId, escrowAddress, amount }
|
||||
opt allowance < amount
|
||||
FE->>W: approve(escrow, amount)
|
||||
W-->>FE: tx confirmed
|
||||
end
|
||||
FE->>W: transfer(escrow, amount)
|
||||
W-->>FE: tx broadcast
|
||||
W-->>BC: signed tx
|
||||
BC-->>W: tx confirmed
|
||||
FE->>BE: POST /api/payment/decentralized/verify { paymentId, txHash }
|
||||
BE->>BC: eth_getTransactionReceipt(txHash)
|
||||
BC-->>BE: { status:0x1, blockNumber, logs }
|
||||
BE->>BC: eth_blockNumber
|
||||
BC-->>BE: currentBlock
|
||||
BE->>BE: confirmations = currentBlock - txBlock
|
||||
BE->>DB: Payment.status="completed"\nescrowState="funded"\ntx hash + confirmations
|
||||
BE->>BE: cascade (mark offer accepted, others rejected,\nrequest→payment, chat, notifications)
|
||||
BE->>IO: emit payment-completed events
|
||||
BE-->>FE: { status:"confirmed", confirmations }
|
||||
FE-->>B: "Payment verified ✓" + BscScan link
|
||||
```
|
||||
|
||||
## API calls
|
||||
|
||||
| Method | Endpoint | Source |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/payment/decentralized/create` | `decentralizedPaymentRoutes.ts` |
|
||||
| `POST` | `/api/payment/decentralized/verify` | `decentralizedPaymentRoutes.ts` |
|
||||
| `GET` | `/api/payment/fetch-tx/:paymentId` | `paymentRoutes.ts` (manual rechecker) |
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`payments`** — same model as the SHKeeper flow. `provider` distinguishes the source.
|
||||
- **`selleroffers`**, **`purchaserequests`**, **`chats`**, **`notifications`** — identical cascade to [[Payment Flow - SHKeeper]] (offer accepted, others rejected, request → `payment`, chat created, notifications fanned out).
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
- **`payment-created`** (admin dashboard) on intent creation.
|
||||
- **`seller-offer-update`** `'payment-completed'` and `'offer-rejected'` post-verification.
|
||||
- **`purchase-request-update`** `'status-changed'`.
|
||||
- **`new-notification`** to both parties.
|
||||
|
||||
## Side effects
|
||||
|
||||
- **No SHKeeper involvement** — the escrow wallet is custodial; the platform admin holds the keys. Payouts from this wallet to sellers happen via [[Payout Flow]] (SHKeeper payouts API) or manual admin signing using `admin-wallet-payout.tsx` UI.
|
||||
- **Verified-wallet field** — the buyer's connected wallet is also saved against `User.profile.walletAddress` (in `WalletConnectionCard`), which is later used to refund this same wallet if the order is disputed.
|
||||
|
||||
## Error / edge cases
|
||||
|
||||
- **Wrong network** → frontend forces a chain switch; if user refuses, transaction will fail or hit the wrong chain (escrow is BSC-only today).
|
||||
- **Insufficient gas (BNB)** → wallet rejects the tx; nothing to verify.
|
||||
- **Transaction reverted** (`receipt.status === '0x0'`) → verifier returns `failed`; backend marks `Payment.status = 'failed'`. Buyer can retry.
|
||||
- **Transaction not yet mined** at verification time → verifier returns `pending` with `error: 'Transaction not found or still pending'`. Frontend retries verification on a backoff schedule.
|
||||
- **Tx hash already used by another payment** — `blockchain.transactionHash` has a sparse index (`Payment.ts:178`); a duplicate verification attempt finds the existing payment and returns its status.
|
||||
- **Wrong amount or wrong recipient** — must be enforced by log decoding (the verifier should reject if the `Transfer` event's `value` is less than expected or `to` ≠ escrow). The current verifier only checks the receipt's `status`; tightening this is recommended.
|
||||
- **RPC throttling** — public BSC dataseed is generous but rate-limits exist; consider a dedicated RPC (Ankr, QuickNode) for production.
|
||||
- **User closes the browser before verification** — the on-chain transfer still happened. A periodic reconciliation job (`/api/payment/fetch-tx/:paymentId`) or admin tool can replay verification from the txHash.
|
||||
- **Confirmation depth** — currently 1 confirmation triggers `completed`. For larger amounts consider gating release until ≥ 12 confirmations on BSC.
|
||||
|
||||
> [!warning] Verify the event log, not just the receipt
|
||||
> A receipt status of `0x1` means the transaction did not revert — it does **not** confirm the right amount went to the right address. Decode the ERC-20 `Transfer(address,address,uint256)` event and assert `to == ESCROW_WALLET_ADDRESS` and `value >= expectedAmount`. The current implementation in `decentralizedPaymentService.ts` checks only the receipt status; harden this before accepting large payments.
|
||||
|
||||
## Linked flows
|
||||
|
||||
- [[Payment Flow - SHKeeper]] — sibling pay-in path; same downstream cascade.
|
||||
- [[Escrow Flow]] — funded state semantics.
|
||||
- [[Payout Flow]] — releasing the funded escrow to the seller.
|
||||
- [[Dispute Flow]] — refunds back to the buyer's verified wallet.
|
||||
|
||||
## Source files
|
||||
|
||||
- Backend: `backend/src/services/payment/decentralizedPaymentService.ts`
|
||||
- Backend: `backend/src/services/payment/decentralizedPaymentRoutes.ts`
|
||||
- Backend: `backend/src/services/payment/paymentCoordinator.ts`
|
||||
- Backend: `backend/src/models/Payment.ts`
|
||||
- Frontend: `frontend/src/web3/web3Service.ts`
|
||||
- Frontend: `frontend/src/web3/context/wagmi-provider.tsx`
|
||||
- Frontend: `frontend/src/web3/context/web3-provider.tsx`
|
||||
- Frontend: `frontend/src/sections/account/account-wallet-connection.tsx`
|
||||
- Frontend: `frontend/src/sections/request/components/buyer-steps/payment-card.tsx`
|
||||
- Frontend: `frontend/src/sections/request/components/buyer-steps/step-3-components/step-3-payment.tsx`
|
||||
- Frontend: `frontend/src/global-config.ts` (`ESCROW_WALLET_ADDRESS`)
|
||||
252
04 - Flows/Payment Flow - SHKeeper.md
Normal file
252
04 - Flows/Payment Flow - SHKeeper.md
Normal file
@@ -0,0 +1,252 @@
|
||||
---
|
||||
title: Payment Flow - SHKeeper
|
||||
tags: [flow, payment, shkeeper, crypto, escrow, webhook]
|
||||
related_models: ["[[Payment]]", "[[PurchaseRequest]]", "[[SellerOffer]]"]
|
||||
related_apis: ["POST /api/payment/shkeeper/create", "POST /api/payment/shkeeper/webhook", "GET /api/payment/shkeeper/status/:id"]
|
||||
---
|
||||
|
||||
# Payment Flow — SHKeeper (Crypto Pay-In)
|
||||
|
||||
End-to-end **crypto pay-in** via the self-hosted [SHKeeper](https://github.com/vsys-host/shkeeper.io) gateway at `pay.amn.gg`. The buyer pays in stablecoins/crypto; SHKeeper monitors the blockchain, sends webhooks back, and the backend marks the escrow funded.
|
||||
|
||||
## Supported assets
|
||||
|
||||
Pulled from env: `SHKEEPER_NETWORKS` and `SHKEEPER_ALLOWED_TOKENS` (`shkeeperService.ts:97-98`).
|
||||
- **Networks**: `bsc`, `ethereum` (default in code).
|
||||
- **Tokens**: `USDT`, `USDC` (default). The endpoint URL is built as `https://pay.amn.gg/api/v1/{NETWORK_PREFIX}-{TOKEN}/payment_request` (`shkeeperService.ts:413-417`).
|
||||
- BSC → `BNB-USDT`, `BNB-USDC` (i.e. BEP-20).
|
||||
- Ethereum → `ETH-USDT`, `ETH-USDC` (ERC-20).
|
||||
- TRC-20 (`USDT-TRC20`) and native `BTC` are mentioned in the task brief but **not currently wired** in `shkeeperService.ts` — only BSC/ETH variants are produced from the code path. Verify SHKeeper-side configuration if those are required.
|
||||
|
||||
## Actors
|
||||
|
||||
- **Buyer** — pays.
|
||||
- **Seller** — passive in this flow; gets notified on success.
|
||||
- **Frontend** — checkout components under `frontend/src/sections/request/components/buyer-steps/step-3-components/` and `frontend/src/sections/payment/`.
|
||||
- **Backend** — `shkeeperService.createPayInIntent` (`backend/src/services/payment/shkeeper/shkeeperService.ts:48-533`) and `shkeeperWebhook.handleShkeeperWebhook` (`backend/src/services/payment/shkeeper/shkeeperWebhook.ts`).
|
||||
- **SHKeeper gateway** (`https://pay.amn.gg`) — issues per-payment deposit addresses, watches the chain, sends webhooks.
|
||||
- **Blockchain** — BSC / Ethereum.
|
||||
- **PaymentCoordinator** (`backend/src/services/payment/paymentCoordinator.ts`) — serialises concurrent payment-status updates from multiple sources (webhook, wallet monitor, manual confirm).
|
||||
- **MongoDB** — `payments`, `purchaserequests`, `selleroffers`, `chats`, `notifications`.
|
||||
- **Redis** — `paymentRedisService` (wallet-address cache, 2 h TTL).
|
||||
- **Socket.IO** — `payment-created`, `seller-offer-update`, `purchase-request-update`.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- Buyer has selected an offer (or is using the template-checkout shortcut).
|
||||
- Backend env: `SHKEEPER_API_URL`, `SHKEEPER_API_KEY`, `SHKEEPER_WEBHOOK_SECRET`, `API_URL` (for the callback URL).
|
||||
- Redis is reachable (graceful degradation if not — see `app.ts:361-367`).
|
||||
- SHKeeper has wallets provisioned for each `crypto_name`.
|
||||
|
||||
## Payment state machine
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> pending: createPayInIntent\n(Payment.status="pending")
|
||||
pending --> pending_partial: webhook PARTIAL\nescrowState="partial"
|
||||
pending --> completed: webhook PAID/OVERPAID\nescrowState="funded"
|
||||
pending --> failed: webhook EXPIRED/CANCELLED\nescrowState="cancelled"
|
||||
pending_partial --> completed: top-up arrives, total ≥ amount
|
||||
pending_partial --> failed: expires
|
||||
completed --> released: admin release → seller payout\n[[Payout Flow]]
|
||||
completed --> refunded: dispute resolution → buyer refund
|
||||
refunded --> [*]
|
||||
released --> [*]
|
||||
failed --> [*]
|
||||
```
|
||||
|
||||
## Step-by-step narrative
|
||||
|
||||
### Phase 1 — Create intent
|
||||
|
||||
1. Buyer clicks "Pay" on the chosen offer (`/dashboard/buyer/requests/{id}` → step-3-select-and-pay).
|
||||
2. Frontend POSTs `POST /api/payment/shkeeper/create` with `{ purchaseRequestId, sellerOfferId, amount, token?, network? }`.
|
||||
3. Backend `createPayInIntent`:
|
||||
- Validates ObjectIds (`shkeeperService.ts:55-71`). Special path for **template checkout** (string IDs starting with `template-checkout-`).
|
||||
- **Duplicate-guard #1** (`:75-116`): if there is already an active `Payment` (`status ∈ {pending, processing}`) for the same `{purchaseRequestId, sellerOfferId, buyerId}`, the existing record is reused — same `paymentUrl`, no new wallet allocation.
|
||||
- **Duplicate-guard #2 (template checkout)** (`:131-198`): if a recent `completed`/`confirmed` payment exists for the same buyer + template session, reuse it; otherwise dedupe pending records within the last 5 minutes.
|
||||
- **Upsert** (`:218-249`): atomic `Payment.findOneAndUpdate(filter, {$setOnInsert: {...}}, {upsert: true, new: true})` keyed by `{buyerId, purchaseRequestId, provider, direction:'in', status:'pending'}` — prevents race-condition duplicates.
|
||||
- Sets `Payment.providerPaymentId = externalId`. For template checkouts, `externalId = template-{ts}-{rand}`; otherwise it's the `Payment._id`.
|
||||
- **Wallet cache lookup** (`:421-450`): `paymentRedisService.getCachedWallet(cacheKey)` — if a wallet was allocated for the same `(amount, token, network, requestId)` within the last 2 h, reuse it. Avoids hammering SHKeeper for the same checkout.
|
||||
- **Call SHKeeper API** (`:453-475`): `POST https://pay.amn.gg/api/v1/{cryptoName}/payment_request` with header `X-Shkeeper-Api-Key`. Body: `{ external_id, fiat: 'USD', amount: '12.34', callback_url: ${API_URL}/api/payment/shkeeper/webhook }`. The HTTP call is wrapped by `shkeeperFetch` (`shkeeperHealthCheck.ts`) which trips the breaker on repeated failures.
|
||||
- **Persist response** (`:484-503`): updates `Payment.metadata.{shkeeperInvoiceId, shkeeperData, cryptoName, walletAddress}`; caches the wallet for 2 h; calls `walletMonitor.addWallet(...)` so the on-chain watcher can confirm independently (belt-and-braces against missed webhooks).
|
||||
4. Returns `{ paymentId, paymentUrl, shkeeperInvoiceId, walletAddress, amount, exchangeRate, displayName, cryptoName }`.
|
||||
5. Emits **`payment-created`** globally via `emitGlobalEvent` (`shkeeperService.ts:277-287`) so the admin dashboard sees the new pending payment in real time.
|
||||
|
||||
### Phase 2 — Buyer pays
|
||||
|
||||
6. Frontend renders a **QR code** for `${walletAddress}?amount=${amount}&token=...` and shows the exchange-rate-locked USDT amount, recalculate-after timer (`recalculate_after` from SHKeeper, typically 15 min), and a copy-to-clipboard button.
|
||||
7. Buyer scans with MetaMask / Trust Wallet / Binance App and sends the on-chain transfer.
|
||||
8. SHKeeper polls the chain, detects the deposit. When confirmations reach the threshold it marks the invoice `PAID` (or `OVERPAID` if the buyer sent extra).
|
||||
|
||||
### Phase 3 — Webhook
|
||||
|
||||
9. SHKeeper POSTs to `${API_URL}/api/payment/shkeeper/webhook` with the `ShkeeperWebhookPayload` shape (`shkeeperWebhook.ts:14-37`):
|
||||
```
|
||||
{
|
||||
external_id, crypto: "BNB-USDT", addr, fiat: "USD",
|
||||
balance_fiat, balance_crypto, paid: true,
|
||||
status: "PAID" | "PENDING" | "EXPIRED" | "CANCELLED" | "PARTIAL" | "OVERPAID",
|
||||
transactions: [{ txid, date, amount_crypto, amount_fiat, trigger: true, ... }],
|
||||
fee_percent, fee_fixed, fee_policy, overpaid_fiat
|
||||
}
|
||||
```
|
||||
10. **Signature verification** (`shkeeperWebhook.ts:84-120`): HMAC-SHA256 of the raw body with `SHKEEPER_WEBHOOK_SECRET`, header `x-shkeeper-signature` (also accepts `x-signature`, `signature`, `x-hub-signature`, `x-hub-signature-256`). Mismatch → `401` in production, allowed in dev. Length-mismatched signature → `401` (avoids `timingSafeEqual` crash).
|
||||
11. **Fallback auth** (`:122-141`): if no signature header but env requires it, the route accepts `X-Shkeeper-Api-Key` matching `SHKEEPER_API_KEY`. Otherwise returns `202` to **stop SHKeeper retries** even if rejected (idempotency principle: always 2xx unless the request itself is mangled).
|
||||
12. **DB reconnect** (`:143-155`): if Mongoose is disconnected, attempt reconnection. On failure → `202 OK` to avoid retry loop, log for investigation.
|
||||
13. **Payment lookup**: `Payment.findOne({ providerPaymentId: payload.external_id })`. If not found and the external_id looks like a template checkout, hand off to `handleTemplateCheckoutWebhook` (`templateCheckoutWebhook.ts`). Otherwise → `202 OK` with a rate-limited log.
|
||||
14. **Duplicate-webhook detection** (`:249-296`): if `metadata.shkeeperStatus`, `balance_fiat`, `paid` are identical to the previous webhook **and** less than 10 seconds have passed → return `202` (idempotent). Logged once per minute per payment.
|
||||
15. **Map SHKeeper status → internal status** (`:387-411`):
|
||||
| SHKeeper | Internal `status` | `escrowState` |
|
||||
|---|---|---|
|
||||
| `PAID` | `completed` | `funded` |
|
||||
| `OVERPAID` | `completed` | `funded` |
|
||||
| `EXPIRED`, `CANCELLED` | `failed` | `cancelled` |
|
||||
| `PARTIAL` | `pending` | `partial` |
|
||||
| `PENDING` | `pending` | — |
|
||||
16. **Extract `transactionHash`** (`:311-385`) — prefers the transaction with `trigger === true`, then falls back to the latest, then to fetching from SHKeeper's invoice endpoint if the webhook somehow arrived without transactions.
|
||||
17. **PaymentCoordinator** (`:482-507`) — `coordinatePaymentUpdate` returns false if another worker already started processing this state change, otherwise `executePaymentUpdate` writes the new status/escrowState/txHash atomically with metadata.
|
||||
18. **Cascade on PAID/OVERPAID** (`:543-714`):
|
||||
- Load `PurchaseRequest` and `SellerOffer` for this payment.
|
||||
- **Mark winning offer accepted**: `selectedOffer.status = 'accepted'; save()`.
|
||||
- **Reject all other offers**: `SellerOffer.updateMany({ purchaseRequestId, _id: { $ne: sellerOfferId } }, { status: 'rejected' })`.
|
||||
- **Promote request**: `status = 'payment'; selectedOfferId = sellerOfferId`.
|
||||
- **Create direct chat** (`chatService.createChat`, see [[Chat Flow]]).
|
||||
- **Notifications**: `notifyPaymentConfirmed` (to both parties), `notifyOfferAccepted` (winner), `notifyRequestStatusChanged` (`received_offers → payment`).
|
||||
- **Socket fan-out**: `seller-offer-update` `'payment-completed'` to winner, `'offer-rejected'` to losers (each carries `offerId`, `reason`).
|
||||
19. **Cleanup**: `simpleAutoWebhook.removePayment(external_id)` stops the simple polling fallback.
|
||||
20. Always respond `202 Accepted` (SHKeeper retries on non-2xx). `200` would cause infinite retries because SHKeeper expects `202` per its convention.
|
||||
|
||||
### Phase 4 — Frontend reaction
|
||||
|
||||
21. The buyer's checkout page subscribes to socket events and polls `GET /api/payment/shkeeper/status/{paymentId}`. When status flips to `completed`, the UI transitions to "Payment received" and unlocks the next buyer step (await delivery).
|
||||
22. The seller's dashboard receives `seller-offer-update` `payment-completed` and surfaces the green "Order paid — start preparing" banner.
|
||||
|
||||
## Sequence diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor B as Buyer
|
||||
participant FE as Frontend
|
||||
participant BE as Backend
|
||||
participant R as Redis
|
||||
participant SK as SHKeeper (pay.amn.gg)
|
||||
participant BC as Blockchain
|
||||
participant DB as MongoDB
|
||||
participant IO as Socket.IO
|
||||
actor S as Seller
|
||||
|
||||
B->>FE: Choose offer, click "Pay"
|
||||
FE->>BE: POST /api/payment/shkeeper/create
|
||||
BE->>DB: dedupe / upsert Payment(status:"pending")
|
||||
BE->>R: getCachedWallet(amount, token, network, requestId)
|
||||
alt cache hit
|
||||
R-->>BE: cached wallet
|
||||
else cache miss
|
||||
BE->>SK: POST /api/v1/{cryptoName}/payment_request\nX-Shkeeper-Api-Key
|
||||
SK-->>BE: { id, wallet, amount, exchange_rate, ... }
|
||||
BE->>R: setCachedWallet (TTL 2h)
|
||||
BE->>BE: walletMonitor.addWallet (chain watcher)
|
||||
end
|
||||
BE->>IO: emit 'payment-created' (admin)
|
||||
BE-->>FE: { paymentId, walletAddress, amount, QR-ready data }
|
||||
FE-->>B: Render QR + countdown + copy address
|
||||
B->>BC: Send USDT/USDC to walletAddress
|
||||
BC-->>SK: deposit confirmed
|
||||
SK->>BE: POST /api/payment/shkeeper/webhook\nx-shkeeper-signature
|
||||
BE->>BE: HMAC verify
|
||||
BE->>DB: Payment.findOne({providerPaymentId})
|
||||
BE->>BE: duplicate-webhook check
|
||||
BE->>BE: PaymentCoordinator.coordinate + execute
|
||||
BE->>DB: Payment.status="completed"\nescrowState="funded"\nblockchain.transactionHash=...
|
||||
BE->>DB: SellerOffer.status="accepted" (others "rejected")
|
||||
BE->>DB: PurchaseRequest.status="payment", selectedOfferId
|
||||
BE->>DB: Chat.create (buyer + winning seller)
|
||||
BE->>IO: emit seller-{winner} 'payment-completed'
|
||||
BE->>IO: emit seller-{loser_i} 'offer-rejected'
|
||||
BE-->>SK: 202 OK
|
||||
IO-->>FE: status updated
|
||||
IO-->>S: dashboard updates
|
||||
FE-->>B: "Payment received ✓"
|
||||
```
|
||||
|
||||
## API calls
|
||||
|
||||
| Method | Endpoint | Purpose | Source |
|
||||
|---|---|---|---|
|
||||
| `POST` | `/api/payment/shkeeper/create` | Create pay-in intent | `shkeeperRoutes.ts` → `shkeeperService.createPayInIntent` |
|
||||
| `POST` | `/api/payment/shkeeper/webhook` | Receive SHKeeper webhook | `shkeeperWebhook.handleShkeeperWebhook` |
|
||||
| `GET` | `/api/payment/shkeeper/status/:paymentId` | Frontend polling | `shkeeperRoutes.ts` |
|
||||
| `GET` | `/api/payment/fetch-tx/:paymentId` | Manual transaction lookup | `paymentRoutes.ts` |
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`payments`**: insert on intent creation (`status: 'pending'`); update on each webhook (status, escrowState, blockchain.transactionHash, metadata).
|
||||
- **`payments`** (unique index `uniq_pending_shkeeper_by_buyer_session`, see `Payment.ts:181-188`): partial unique on `{buyerId, purchaseRequestId, provider:'shkeeper', direction:'in', status:'pending'}` prevents duplicate pending pay-ins.
|
||||
- **`selleroffers`**: `status` flipped (`accepted` / `rejected`) by the webhook cascade.
|
||||
- **`purchaserequests`**: `status` → `payment`, `selectedOfferId` set.
|
||||
- **`chats`**: a new `direct` chat (or reuse) — find-or-create via `ChatService.createChat`.
|
||||
- **`notifications`**: 2–N entries depending on parties.
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
- **`payment-created`** (global) — broadcast on intent creation.
|
||||
- **`seller-offer-update`** with `eventType: 'payment-completed'` → winning seller.
|
||||
- **`seller-offer-update`** with `eventType: 'offer-rejected'` → each losing seller.
|
||||
- **`purchase-request-update`** with `eventType: 'status-changed'` (via `PurchaseRequestService`) → `request-{id}`.
|
||||
- **`new-notification`** → both buyer and seller.
|
||||
|
||||
## Side effects
|
||||
|
||||
- **Redis**: `walletCache:{amount}_{token}_{network}_{requestId}` set for 2 hours.
|
||||
- **Wallet monitor**: `walletMonitor.addWallet(addr, amount, paymentId, token, network)` — the on-chain watcher polls BSC/ETH RPCs directly; if SHKeeper's webhook is lost, the monitor still flips the payment to `completed`. This is the redundancy mechanism noted in the comments.
|
||||
- **simpleAutoWebhook** (`shkeeperSimpleAuto.ts`): a poll-based fallback that asks SHKeeper for invoice status; cleaned up once a real webhook arrives.
|
||||
- **Webhook stats**: `webhookStats.recordWebhook(...)` updates an in-memory ring buffer surfaced via `webhookStats.ts` admin endpoint.
|
||||
|
||||
## Error / edge cases
|
||||
|
||||
- **Duplicate intent submission** → reuse the existing pending payment (no new wallet). UX-safe.
|
||||
- **SHKeeper API unreachable** → `shkeeperFetch` (with circuit breaker) throws; controller returns a **demo fallback** URL (`shkeeperService.ts:520-532`). In production this is observed as a Sentry error.
|
||||
- **Webhook signature mismatch in prod** → `401`, SHKeeper retries — usually the secret has rotated; fix env and they catch up.
|
||||
- **Webhook missing both signature and API key in prod** → `202 OK` (no-op) to prevent retry storm.
|
||||
- **DB disconnected during webhook** → reconnect; on failure `202 OK` + log (consider DLQ).
|
||||
- **`PARTIAL` payment** → state held as `pending/partial`; further deposits to the same address are aggregated by SHKeeper and a new webhook arrives.
|
||||
- **`OVERPAID`** → treated as `completed/funded`; the overage stays with the platform unless an admin manually refunds (no automatic refund of overpayment today).
|
||||
- **`EXPIRED`** → `failed/cancelled`. Buyer can re-initiate; the duplicate-guard will create a fresh intent because the old one is no longer `pending`.
|
||||
- **External_id not found** → `202` with rate-limited log; common for orphaned webhooks from old tests.
|
||||
- **Webhook arrives twice within 10 s with same data** → idempotency skip → `202`.
|
||||
- **`PaymentCoordinator` deferral** → `202` with a "coordinator skipped update" log; the in-flight worker will finish the state change.
|
||||
- **Wallet address reuse** — cached for 2 h means two parallel checkouts for the same `(amount, token, network, requestId)` share one address; whichever pays first wins (acceptable since the duplicate-guard reuses the same `Payment` doc anyway).
|
||||
- **`crypto_name` mismatch** — only `BNB-*` and `ETH-*` are produced; for TRC-20, additional logic is needed in `shkeeperService.ts:415`.
|
||||
|
||||
> [!warning] Webhook returns 202 even on errors
|
||||
> The handler always responds 2xx to avoid SHKeeper's retry storm — even for unknown payments, signature failures (in non-production paths), DB errors, and unexpected exceptions. Operationally this means failed-to-process webhooks are silently swallowed unless someone tails the logs. Hook the catch-all into Sentry severity = `error` and alert on `webhookStats.errorCount`.
|
||||
|
||||
> [!tip] Manual reconciliation
|
||||
> Use `fix-transaction-hashes.js` at repo root to backfill `blockchain.transactionHash` for payments where the webhook arrived without transactions. See [[Payout Flow]] for the parallel payout-side script usage.
|
||||
|
||||
## Linked flows
|
||||
|
||||
- [[Purchase Request Flow]] — supplies the request being paid for.
|
||||
- [[Seller Offer Flow]] — supplies the offer being accepted.
|
||||
- [[Payment Flow - DePay & Web3]] — alternative direct-wallet route.
|
||||
- [[Escrow Flow]] — what `escrowState=funded` means downstream.
|
||||
- [[Chat Flow]] — auto-created on success.
|
||||
- [[Notification Flow]] — both parties pinged.
|
||||
- [[Payout Flow]] — pays the seller from the funded escrow.
|
||||
- [[Dispute Flow]] — escape if the order goes wrong.
|
||||
|
||||
## Source files
|
||||
|
||||
- Backend: `backend/src/services/payment/shkeeper/shkeeperService.ts` (intent creation, ~650 lines)
|
||||
- Backend: `backend/src/services/payment/shkeeper/shkeeperWebhook.ts` (webhook handler, ~750 lines)
|
||||
- Backend: `backend/src/services/payment/shkeeper/shkeeperHealthCheck.ts` (circuit breaker)
|
||||
- Backend: `backend/src/services/payment/shkeeper/shkeeperRoutes.ts`
|
||||
- Backend: `backend/src/services/payment/paymentCoordinator.ts`
|
||||
- Backend: `backend/src/services/payment/cleanupPendingPayments.ts` (periodic GC)
|
||||
- Backend: `backend/src/services/blockchain/walletMonitor.ts` (chain watcher)
|
||||
- Backend: `backend/src/services/redis/paymentRedisService.ts` (wallet cache)
|
||||
- Backend: `backend/src/models/Payment.ts`
|
||||
- Frontend: `frontend/src/sections/request/components/buyer-steps/step-3-components/step-3-payment.tsx`
|
||||
- Frontend: `frontend/src/sections/payment/`
|
||||
133
04 - Flows/Payout Flow.md
Normal file
133
04 - Flows/Payout Flow.md
Normal file
@@ -0,0 +1,133 @@
|
||||
---
|
||||
title: Payout Flow
|
||||
tags: [flow, payment, payout, shkeeper, seller]
|
||||
related_models: ["[[Payment]]"]
|
||||
related_apis: ["POST /api/payment/shkeeper/payout", "GET /api/payment/shkeeper/payout/:taskId"]
|
||||
---
|
||||
|
||||
# Payout Flow
|
||||
|
||||
How the **seller receives the escrowed crypto** once the order is complete. Two variants are implemented:
|
||||
|
||||
1. **SHKeeper Payouts API** (`shkeeperPayoutService.ts`) — the gateway signs and broadcasts on behalf of the platform.
|
||||
2. **Manual admin wallet payout** (`admin-wallet-payout.tsx`) — an admin connects their own wallet and signs the transfer; the tx hash is reported back to the backend.
|
||||
|
||||
Both result in `Payment.escrowState = 'released'` and an outgoing `Payment` record with `direction: 'out'`.
|
||||
|
||||
## Actors
|
||||
|
||||
- **Admin** (or scheduled system trigger) — initiates the payout.
|
||||
- **Seller** — recipient, has saved their wallet address under `User.profile.walletAddress`.
|
||||
- **Backend** — `shkeeperPayoutService.createPayoutTask` and the manual confirmation routes.
|
||||
- **SHKeeper Payouts API** — `POST https://pay.amn.gg/api/v1/payout` (per SHKeeper docs).
|
||||
- **Blockchain (BSC)** — final on-chain settlement.
|
||||
- **MongoDB** — separate `Payment` document with `direction: 'out'`.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- The original pay-in `Payment` has `escrowState = 'funded'` (or `releasable`).
|
||||
- The seller has set `profile.walletAddress` (validated `^0x...` format).
|
||||
- The corresponding `PurchaseRequest` is in a status that allows payout (`delivered`, `confirming`, `seller_paid`, or `completed`).
|
||||
|
||||
## Step-by-step narrative
|
||||
|
||||
### SHKeeper-mediated payout
|
||||
|
||||
1. Admin (or the auto-release scheduler — not yet implemented) hits `POST /api/payment/shkeeper/payout` with `{ purchaseRequestId, sellerOfferId, buyerId, sellerId, amount, recipientAddress, token?, network? }`.
|
||||
2. Backend `shkeeperPayoutService.createPayoutTask` (`shkeeperPayoutService.ts:40-150`):
|
||||
- Validates ObjectIds and the `recipientAddress` (`startsWith('0x')`).
|
||||
- **Idempotency**: `Payment.findOne({ purchaseRequestId, sellerOfferId, sellerId, provider:'shkeeper', direction:'out', status: { $in:['pending','processing','completed'] } })` — if found, reuses it.
|
||||
- Creates a new `Payment` document with `direction: 'out'`, `escrowState: 'releasing'`, `blockchain.receiver = recipientAddress`.
|
||||
- Calls SHKeeper Payouts API (`POST /api/v1/payout`) with the body documented at <https://shkeeper.io/api/#tag/Payouts>. SHKeeper returns a `task_id`.
|
||||
- Stores `Payment.providerPaymentId = task_id`, `metadata.shkeeperTaskId = task_id`, `metadata.payoutType = 'seller-payment'`.
|
||||
3. Polling or webhook: when SHKeeper completes the payout, it pushes a webhook (or the backend polls `GET /api/v1/payout/{task_id}`) and the system flips `Payment.status = 'completed'`, `escrowState = 'released'`, populates `blockchain.transactionHash`.
|
||||
4. The original pay-in `Payment` is updated in tandem: `escrowState = 'released'`, `PurchaseRequest.status = 'seller_paid'` → `completed`.
|
||||
5. Notifications: `notifyPayoutSent` to the seller, internal admin log.
|
||||
|
||||
### Manual admin payout
|
||||
|
||||
1. Admin opens the request detail in the admin view; the admin-step component `admin-wallet-payout.tsx` shows the recipient and amount.
|
||||
2. Admin connects their wallet (`useWeb3` / `web3Service.connect()`).
|
||||
3. Admin clicks "Send payout"; wagmi triggers `transfer(recipient, amount)` on the USDT contract.
|
||||
4. After confirmation, the admin clicks "Confirm in system", which POSTs `POST /api/payment/admin/confirm-tx/:paymentId` with `{ txHash, kind: 'release' }`.
|
||||
5. Backend `confirmAdminTx(paymentId, txHash, 'release')` (`shkeeperService.ts:628-647`) sets `status: 'completed'`, `escrowState: 'released'`, `blockchain.transactionHash = txHash`.
|
||||
|
||||
### Sequence diagram (SHKeeper payout)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor A as Admin/System
|
||||
participant BE as Backend
|
||||
participant DB as MongoDB
|
||||
participant SK as SHKeeper Payout API
|
||||
participant BC as BSC
|
||||
actor S as Seller
|
||||
|
||||
A->>BE: POST /api/payment/shkeeper/payout
|
||||
BE->>DB: Payment.create({direction:"out", escrowState:"releasing"})
|
||||
BE->>SK: POST /api/v1/payout {to, amount, crypto}
|
||||
SK-->>BE: { task_id, status:"pending" }
|
||||
BE->>DB: Payment.providerPaymentId=task_id
|
||||
SK->>BC: signed payout tx (managed wallet)
|
||||
BC-->>SK: confirmed
|
||||
SK->>BE: webhook payout-completed (or BE polls)
|
||||
BE->>DB: Payment.status="completed"\nescrowState="released"\ntxHash
|
||||
BE->>DB: pay-in Payment.escrowState="released"\nPurchaseRequest.status="seller_paid"
|
||||
BE->>S: notifyPayoutSent
|
||||
```
|
||||
|
||||
## API calls
|
||||
|
||||
| Method | Endpoint | Source |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/payment/shkeeper/payout` | `shkeeperPayoutRoutes.ts` → `createPayoutTask` |
|
||||
| `GET` | `/api/payment/shkeeper/payout/:taskId` | Polls SHKeeper task status |
|
||||
| `POST` | `/api/payment/admin/confirm-tx/:paymentId` | Manual admin confirmation |
|
||||
| `GET` | `/api/payment/admin/payouts` | List payouts (admin dashboard) |
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`payments`** — new outgoing document; updates to `status`, `escrowState`, `blockchain.transactionHash` as the task progresses.
|
||||
- **`payments`** (pay-in counterpart) — `escrowState = 'released'`.
|
||||
- **`purchaserequests`** — `status` advances to `seller_paid` → `completed`.
|
||||
- **`notifications`** — seller payout receipt.
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
- **`payment-status`** (admin) on each transition.
|
||||
- **`purchase-request-update`** `status-changed`.
|
||||
|
||||
## Side effects
|
||||
|
||||
- **`fix-transaction-hashes.js`** at repo root (`backend/fix-transaction-hashes.js`) — script used to backfill missing `blockchain.transactionHash` on payouts where the SHKeeper webhook arrived without the txid (e.g. signature length mismatch in dev). Run locally with the same Mongo URI to repair stale documents. Use it as the reference for the data-fix pattern — pull recent payouts, query SHKeeper for invoice/task details, write back the hash.
|
||||
- **Hash repair** — periodic reconciliation against SHKeeper invoice GET endpoints ensures bookkeeping accuracy.
|
||||
|
||||
## Error / edge cases
|
||||
|
||||
- **Invalid recipient address** → throws synchronously, no DB record created.
|
||||
- **SHKeeper insufficient hot-wallet balance** → SHKeeper returns an error; payout task stays `pending`, backend logs.
|
||||
- **Duplicate payout request** → idempotency: existing payment returned with no extra SHKeeper call.
|
||||
- **Payout reverted on chain** → SHKeeper marks the task `failed`; backend sets `Payment.status = 'failed'`, `escrowState = 'failed'`. Admin retries.
|
||||
- **Missing `transactionHash` after success** → use `fix-transaction-hashes.js` to backfill.
|
||||
- **Manual payout signed but never confirmed in system** → on-chain transfer happened, but `Payment.escrowState` stays `releasing`. Admin can run a reconciliation script that scans the escrow wallet's outgoing txs and matches by amount/timestamp.
|
||||
- **Seller changes wallet address mid-flight** → the saved `recipientAddress` is the snapshot taken at payout creation; subsequent profile changes do not affect in-flight payouts.
|
||||
|
||||
> [!warning] Auto-release is not yet implemented
|
||||
> Today, payouts are admin-initiated. The flow is ready for an automatic trigger when [[Delivery Confirmation Flow]] completes — implement a cron job or queue worker that scans for `PurchaseRequest.status='delivered'` and auto-creates payouts after a configurable grace period.
|
||||
|
||||
## Linked flows
|
||||
|
||||
- [[Escrow Flow]] — sets up the conditions under which payout is allowed.
|
||||
- [[Delivery Confirmation Flow]] — green-lights the payout.
|
||||
- [[Dispute Flow]] — can divert funds to a refund instead.
|
||||
- [[Notification Flow]] — payout receipt to seller.
|
||||
|
||||
## Source files
|
||||
|
||||
- Backend: `backend/src/services/payment/shkeeper/shkeeperPayoutService.ts`
|
||||
- Backend: `backend/src/services/payment/shkeeper/shkeeperPayoutRoutes.ts`
|
||||
- Backend: `backend/src/services/payment/shkeeper/shkeeperService.ts:614-647` (build & confirm admin tx payload)
|
||||
- Backend: `backend/fix-transaction-hashes.js` (reconciliation script)
|
||||
- Frontend: `frontend/src/sections/request/components/admin-steps/admin-wallet-payout.tsx`
|
||||
- Frontend: `frontend/src/web3/web3Service.ts`
|
||||
202
04 - Flows/Purchase Request Flow.md
Normal file
202
04 - Flows/Purchase Request Flow.md
Normal file
@@ -0,0 +1,202 @@
|
||||
---
|
||||
title: Purchase Request Flow
|
||||
tags: [flow, marketplace, buyer, purchase-request]
|
||||
related_models: ["[[PurchaseRequest]]", "[[Category]]", "[[Address]]", "[[SellerOffer]]"]
|
||||
related_apis: ["POST /api/marketplace/purchase-requests", "GET /api/marketplace/purchase-requests", "PATCH /api/marketplace/purchase-requests/:id"]
|
||||
---
|
||||
|
||||
# Purchase Request Flow
|
||||
|
||||
A **buyer** drafts and publishes a purchase request describing what they want to source. Once published, the request becomes visible to sellers (either all sellers or a curated subset), kicking off [[Seller Offer Flow]].
|
||||
|
||||
## Actors
|
||||
|
||||
- **Buyer** — owner of the request.
|
||||
- **Frontend** — multi-step wizard at `/dashboard/request/new` (`frontend/src/app/dashboard/request/new/page.tsx`). The wizard component lives in `frontend/src/sections/request/components/request-form-wizard.tsx` and uses the step files under `frontend/src/sections/request/components/steps/` (basic info, details, budget, review) plus `buyer-steps/` for the post-publish lifecycle.
|
||||
- **Backend** — `PurchaseRequestService.createPurchaseRequest` and the marketplace controller (`backend/src/services/marketplace/marketplaceController.ts`).
|
||||
- **MongoDB** — `purchaserequests`, with population from `users` and `categories`.
|
||||
- **Socket.IO** — emits `purchase-request-update` to the `request-{id}` room and `seller-offer-update` to seller rooms.
|
||||
- **Notification service** — pushes in-app notifications to all targeted sellers.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- User is authenticated and `req.user.role === 'buyer'`.
|
||||
- At least one category exists (seeded via `seedCategories`).
|
||||
- Optional: the buyer has saved a delivery address under `/dashboard/account/addresses`.
|
||||
|
||||
## State machine
|
||||
|
||||
Status progression is enforced by `STATUS_PROGRESSION_ORDER` in `PurchaseRequestService.ts:12-26`. Moving backward is disallowed except into a terminal status.
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> pending: createPurchaseRequest()
|
||||
pending --> received_offers: first SellerOffer saved\nSellerOfferService.createOffer
|
||||
received_offers --> in_negotiation: buyer/seller chat\n(counter-offer, see [[Negotiation Flow]])
|
||||
in_negotiation --> received_offers: counter rejected
|
||||
received_offers --> payment: SHKeeper webhook PAID\n(selected offer)
|
||||
in_negotiation --> payment: same
|
||||
payment --> processing: seller acknowledges
|
||||
processing --> delivery: seller marks shipped
|
||||
delivery --> delivered: buyer enters delivery code
|
||||
delivered --> confirming: optional auto-release timer
|
||||
confirming --> completed: escrow released to seller
|
||||
completed --> finalized: ratings exchanged
|
||||
finalized --> archived: 30 days idle
|
||||
pending --> cancelled: buyer cancels (any pre-payment status)
|
||||
received_offers --> cancelled
|
||||
in_negotiation --> cancelled
|
||||
cancelled --> [*]
|
||||
archived --> [*]
|
||||
```
|
||||
|
||||
Terminal statuses: `completed`, `finalized`, `archived`, `cancelled` (`PurchaseRequestService.ts:28`).
|
||||
|
||||
## Step-by-step narrative
|
||||
|
||||
### Multi-step wizard
|
||||
|
||||
1. Buyer clicks "New request" in the dashboard sidebar and lands at `/dashboard/request/new`.
|
||||
2. **Step 1 — Basic info** (`steps/request-basic-info-step.tsx`): title (5–200 chars), description (20–2000 chars), category selection (dropdown populated from `GET /api/marketplace/categories`).
|
||||
3. **Step 2 — Details** (`steps/request-details-step.tsx`): optional product link, size, color, quantity, free-form specifications (key/value pairs), AI-assisted description generation (calls `POST /api/ai/generate-description` if the user clicks the magic-wand button — see `backend/src/services/ai/`).
|
||||
4. **Step 3 — Budget** (`steps/request-budget-step.tsx`): min/max in chosen currency (default USDT), urgency (low/medium/high), preferred sellers (typeahead bound to `GET /api/users/sellers`; `"all"` means public).
|
||||
5. **Step 4 — Review** (`steps/request-review-step.tsx`): summary; user can attach a saved address (`GET /api/addresses`) or enter a one-off `deliveryInfo.address`. Upload optional attachments via `POST /api/files/upload` — returns URLs persisted into `attachments[]`.
|
||||
6. **Draft vs. publish** — The wizard always POSTs on submit; drafts are not first-class today (the local wizard state is the "draft"). The persisted record is immediately `status: "pending"` and visible to sellers.
|
||||
|
||||
### Submission
|
||||
|
||||
7. Frontend POSTs `POST /api/marketplace/purchase-requests` with the full payload (`PurchaseRequestCreateData` in `PurchaseRequestService.ts:73-106`).
|
||||
8. Backend `PurchaseRequestService.createPurchaseRequest` (`:123-188`):
|
||||
- **Duplicate-guard** (`:128-143`): rejects a request with identical `buyerId`, `title`, `description` within the last 5 minutes. Returns `Error("درخواست مشابه در ۵ دقیقه گذشته ایجاد شده است")`.
|
||||
- **Sanitise `preferredSellerIds`** (`:146-150`): drops literal `"all"` and any invalid ObjectIds.
|
||||
- **`isPublic`** is `true` when the cleaned array is empty OR the original payload included `"all"`. Public requests are visible to every active seller; private requests only to listed sellers.
|
||||
- Builds and saves the `PurchaseRequest` document with `status: "pending"`.
|
||||
9. **Notify the buyer**: `notificationService.notifyPurchaseRequestCreated()` fires asynchronously (no `await`) — an info notification appears in the buyer's bell-icon dropdown.
|
||||
10. **Fan-out to sellers** (`notifyAllSellersAboutNewRequest`, `:190-249`):
|
||||
- If `isPublic`: `User.find({ role: "seller", status: "active" })`.
|
||||
- Otherwise: only the curated `preferredSellerIds`.
|
||||
- Iterates with **50 ms stagger** between notifications to avoid overwhelming Mongo/Socket.IO.
|
||||
- For each seller: `notificationService.createNotification(...)` writes a `Notification` doc AND emits via Socket.IO (`actionUrl: /dashboard/seller/marketplace/request/{id}`).
|
||||
11. **Real-time fan-out** is also performed when sellers eventually act on the request (offers, payments) via `emitPurchaseRequestUpdate(requestId, eventType, data)` (`PurchaseRequestService.ts:53-71`) and `emitOfferUpdate` in [[Seller Offer Flow]].
|
||||
|
||||
### Visibility filter
|
||||
|
||||
When a seller hits `GET /api/marketplace/purchase-requests?sellerId=...`, `PurchaseRequestService.getPurchaseRequests` (`:251-364`) applies a per-status visibility filter:
|
||||
|
||||
| Request status | Visible to seller if |
|
||||
|---|---|
|
||||
| `pending` | `isPublic` OR seller ∈ `preferredSellerIds` |
|
||||
| `received_offers`, `in_negotiation` | seller has an offer for the request OR no offer is selected yet AND (public/preferred) |
|
||||
| `payment`, `processing`, `delivery`, `delivered`, `confirming`, `seller_paid`, `completed` | seller is the **selected** seller (their offer is `selectedOfferId`) |
|
||||
|
||||
## Sequence diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor B as Buyer
|
||||
participant FE as Frontend Wizard
|
||||
participant BE as Backend
|
||||
participant DB as MongoDB
|
||||
participant N as NotificationService
|
||||
participant IO as Socket.IO
|
||||
actor S1 as Seller (n sellers)
|
||||
|
||||
B->>FE: Open /dashboard/request/new
|
||||
loop Steps 1-4
|
||||
B->>FE: Fill basic / details / budget / review
|
||||
FE-->>FE: Local validation
|
||||
end
|
||||
opt AI assist
|
||||
FE->>BE: POST /api/ai/generate-description
|
||||
BE-->>FE: { description }
|
||||
end
|
||||
opt attachments
|
||||
FE->>BE: POST /api/files/upload
|
||||
BE-->>FE: { url }
|
||||
end
|
||||
B->>FE: Click "Publish"
|
||||
FE->>BE: POST /api/marketplace/purchase-requests
|
||||
BE->>DB: Duplicate check (same title+desc in 5m?)
|
||||
BE->>BE: clean preferredSellerIds; compute isPublic
|
||||
BE->>DB: PurchaseRequest.create({status: "pending"})
|
||||
DB-->>BE: savedRequest
|
||||
BE->>N: notifyPurchaseRequestCreated(buyer, requestId)
|
||||
par fan-out to sellers (staggered 50ms)
|
||||
BE->>DB: User.find({role:"seller", status:"active"}) (or preferred)
|
||||
BE->>N: createNotification(seller_i, ...)
|
||||
N->>IO: emit user-{seller_i} 'new-notification'
|
||||
N->>DB: Notification.create
|
||||
end
|
||||
BE-->>FE: 201 { request }
|
||||
FE-->>B: Redirect /dashboard/buyer/requests/{id}
|
||||
IO-->>S1: 'new-notification' (sellers receive in real time)
|
||||
```
|
||||
|
||||
## API calls
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/marketplace/purchase-requests` | Create the request |
|
||||
| `GET` | `/api/marketplace/categories` | Step 1 dropdown |
|
||||
| `GET` | `/api/users/sellers` | Step 3 preferred-sellers typeahead |
|
||||
| `GET` | `/api/addresses` | Step 4 saved addresses |
|
||||
| `POST` | `/api/files/upload` | Attachments |
|
||||
| `POST` | `/api/ai/generate-description` | Optional AI-assisted description |
|
||||
| `GET` | `/api/marketplace/purchase-requests` | Listing (buyer's own and seller's filtered view) |
|
||||
| `GET` | `/api/marketplace/purchase-requests/:id` | Detail page (joins payment data) |
|
||||
| `PATCH` | `/api/marketplace/purchase-requests/:id` | Generic update (status, attachments, etc.) |
|
||||
| `DELETE` | `/api/marketplace/purchase-requests/:id` | Cancel (only before payment) |
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`purchaserequests` collection**: full insert. Subsequent status transitions and `selectedOfferId` updates happen in [[Seller Offer Flow]], [[Payment Flow - SHKeeper]], and [[Delivery Confirmation Flow]].
|
||||
- **`notifications` collection**: one per notified seller plus one for the buyer.
|
||||
- **`users.referralStats`** is not touched at request creation.
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
- **`new-notification`** → `user-{sellerId}` for each notified seller (via `NotificationService.emitRealTimeNotification`).
|
||||
- **`purchase-request-update`** → `request-{id}` on status changes (`emitPurchaseRequestUpdate`, `PurchaseRequestService.ts:53-71`).
|
||||
- **`seller-offer-update`** → `seller-{id}` when an offer is created against this request (see [[Seller Offer Flow]]).
|
||||
- **`request-cancelled`** → `user-{buyerId}` and `user-{sellerId}` when the buyer cancels (`PurchaseRequestService.ts:671-693`).
|
||||
|
||||
## Side effects
|
||||
|
||||
- One Mongo write per notification (potentially N+1 for a large seller base — mitigated by the 50 ms stagger). For mass markets this should be batched.
|
||||
- The buyer is auto-subscribed to the `request-{id}` Socket.IO room on the detail page mount (frontend emits `join-request-room`).
|
||||
- If `urgency === "high"`, the notification message uses the high-priority template — visible in [[Notification Flow]].
|
||||
|
||||
## Error / edge cases
|
||||
|
||||
- **Duplicate within 5 minutes** → `400` with Persian message. Prevents double-submit on flaky networks.
|
||||
- **Invalid category ObjectId** → `400` from Mongoose validation.
|
||||
- **`preferredSellerIds` with invalid ObjectIds** → silently dropped, not an error.
|
||||
- **Empty cleaned `preferredSellerIds` + no `"all"` in original** → `isPublic` is `true` (open marketplace). This is the intended fallback.
|
||||
- **Buyer cancels after payment** → blocked by `STATUS_PROGRESSION_ORDER` (cannot move to `cancelled` from `processing`+ without admin intervention; in practice cancellations after payment must go through [[Dispute Flow]]).
|
||||
- **Notification fan-out failure for one seller** → logged and resolved as `false`; the overall request creation still succeeds.
|
||||
- **Invalid status progression on PATCH** → `400 Invalid status progression` (`PurchaseRequestService.ts:418-424`).
|
||||
- **Seller calls GET listing without `sellerId` query** → logs `"No sellerId provided - returning ALL requests!"` (`:348`) and returns the full set. Frontend always supplies `sellerId` for seller dashboards; missing it is a bug worth catching.
|
||||
|
||||
> [!tip] Status progression is forward-only
|
||||
> Once `status` reaches `payment`, you cannot put it back to `received_offers` even via PATCH. The only escape hatches are the terminal statuses (`cancelled`, `archived`, etc.) and admin tools.
|
||||
|
||||
## Linked flows
|
||||
|
||||
- [[Seller Offer Flow]] — sellers respond to the published request.
|
||||
- [[Negotiation Flow]] — counter-offer mechanics in `in_negotiation`.
|
||||
- [[Payment Flow - SHKeeper]] — buyer pays for the accepted offer.
|
||||
- [[Delivery Confirmation Flow]] — seller ships, buyer confirms.
|
||||
- [[Dispute Flow]] — escape hatch for failed deliveries.
|
||||
- [[Notification Flow]] — backbone of the seller fan-out.
|
||||
|
||||
## Source files
|
||||
|
||||
- Backend: `backend/src/services/marketplace/PurchaseRequestService.ts`
|
||||
- Backend: `backend/src/services/marketplace/marketplaceController.ts`
|
||||
- Backend: `backend/src/services/marketplace/controllerRoutes.ts`
|
||||
- Backend: `backend/src/models/PurchaseRequest.ts`
|
||||
- Frontend: `frontend/src/app/dashboard/request/new/page.tsx`
|
||||
- Frontend: `frontend/src/sections/request/components/request-form-wizard.tsx`
|
||||
- Frontend: `frontend/src/sections/request/components/steps/` (4 step files)
|
||||
- Frontend: `frontend/src/sections/request/view/buyer-request-view.tsx`
|
||||
120
04 - Flows/Rating Flow.md
Normal file
120
04 - Flows/Rating Flow.md
Normal file
@@ -0,0 +1,120 @@
|
||||
---
|
||||
title: Rating Flow
|
||||
tags: [flow, rating, review, moderation]
|
||||
related_models: ["[[Review]]", "[[ShopSettings]]", "[[PurchaseRequest]]"]
|
||||
related_apis: ["POST /api/marketplace/reviews", "GET /api/marketplace/reviews/:subjectType/:subjectId"]
|
||||
---
|
||||
|
||||
# Rating Flow
|
||||
|
||||
After an order is `completed`, the buyer rates the seller and (optionally) leaves a review; the seller can rate the buyer in the reciprocal flow. Reviews are scoped by `subjectType` (`seller` | `template`) and constrained by the seller's `ShopSettings`.
|
||||
|
||||
## Actors
|
||||
|
||||
- **Buyer** (typical reviewer).
|
||||
- **Seller** (subject; can also be a reviewer in the reciprocal direction).
|
||||
- **System** — enforces uniqueness and moderation rules.
|
||||
- **Backend** — `backend/src/services/marketplace/reviewRoutes.ts`.
|
||||
- **MongoDB** — `reviews` collection (`backend/src/models/Review.ts`).
|
||||
|
||||
## Preconditions
|
||||
|
||||
- The associated `PurchaseRequest` is `completed` (or `finalized`).
|
||||
- The reviewer is the buyer of that request (for `isVerifiedBuyer` to be `true`).
|
||||
- The subject's `ShopSettings.allowSellerReviews` / `allowTemplateReviews` is not `false` (`reviewRoutes.ts:15-31`).
|
||||
|
||||
## Step-by-step narrative
|
||||
|
||||
1. From the request detail or seller profile, the buyer clicks "Leave review". The form captures `rating` (1–5) and `comment` (≤ 1000 chars).
|
||||
2. Frontend POSTs `POST /api/marketplace/reviews` with `{ subjectType: 'seller' | 'template', subjectId, rating, comment, purchaseRequestId? }`.
|
||||
3. Backend route handler:
|
||||
- Validates payload.
|
||||
- Calls `isReviewsAllowed(subjectType, subjectId)` — checks the seller's `ShopSettings` (for sellers, look up the seller directly; for templates, look up the template's owning seller).
|
||||
- Sets `isVerifiedBuyer = true` if the user owns a `completed` purchase request from that seller.
|
||||
- Defaults `status: 'published'` (no moderation queue today).
|
||||
4. Inserts a `Review` document. Unique index `{ subjectType, subjectId, reviewerId }` prevents a user from reviewing the same subject twice (`Review.ts:34`).
|
||||
5. Aggregated stats are recomputed on read via `computeStats` (`reviewRoutes.ts:33-62`) — count, average, per-star histogram. No denormalised counter on `User` today; everything is computed at read-time.
|
||||
|
||||
## Visibility
|
||||
|
||||
- Public — anyone hitting `GET /api/marketplace/reviews/seller/:sellerId` sees `status: 'published'` reviews paginated by 10.
|
||||
- If `ShopSettings.allowSellerReviews === false` (or `allowTemplateReviews === false`), reads return `403 Reviews are disabled by seller`.
|
||||
- The seller can flag a review for moderation (planned — current statuses include `pending` and `rejected`, but no UI to flip them today; admin can update via direct DB).
|
||||
|
||||
## Sequence diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor B as Buyer
|
||||
participant FE as Frontend
|
||||
participant BE as Backend
|
||||
participant DB as MongoDB
|
||||
|
||||
B->>FE: Open seller profile / request detail
|
||||
B->>FE: Click "Leave review", choose stars + comment
|
||||
FE->>BE: POST /api/marketplace/reviews
|
||||
BE->>DB: ShopSettings.findOne({sellerId}) → allowSellerReviews?
|
||||
alt allowed
|
||||
BE->>DB: PurchaseRequest.exists({buyer, seller, status:"completed"})?
|
||||
BE->>DB: Review.create({status:"published", isVerifiedBuyer})
|
||||
BE-->>FE: 201 { review }
|
||||
else disabled
|
||||
BE-->>FE: 403 Reviews disabled
|
||||
end
|
||||
|
||||
Note over FE: Public visitor:
|
||||
FE->>BE: GET /api/marketplace/reviews/seller/{id}
|
||||
BE->>DB: Review.find / computeStats aggregate
|
||||
BE-->>FE: { items, pagination, stats:{count, avg, histogram} }
|
||||
```
|
||||
|
||||
## API calls
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/marketplace/reviews` | Submit review |
|
||||
| `GET` | `/api/marketplace/reviews/:subjectType/:subjectId` | List reviews + stats |
|
||||
| `PATCH` | `/api/marketplace/reviews/:id` | Edit own review (within edit window) |
|
||||
| `DELETE` | `/api/marketplace/reviews/:id` | Delete own review |
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`reviews`** — insert on submission; one document per `(subjectType, subjectId, reviewerId)`.
|
||||
- **`shopsettings`** — read-only here; the seller controls `allowSellerReviews` / `allowTemplateReviews` in their shop settings.
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
- None today. A `new-review` event broadcast to `user-{sellerId}` would be a useful enhancement so sellers see reviews appear live.
|
||||
|
||||
## Side effects
|
||||
|
||||
- Recompute aggregate on every list call — fine for small volumes; consider caching `stats` per subject when the review count grows.
|
||||
- Order rating field also stamps `metadata.rating` on the purchase request when the marketplace endpoint accepts ratings inline (see `routes.ts` references in `backend/src/services/marketplace/routes.ts`).
|
||||
|
||||
## Error / edge cases
|
||||
|
||||
- **Duplicate review** → MongoDB `E11000` from the unique index; surface as `409 Already reviewed`.
|
||||
- **Subject disabled reviews** → `403`.
|
||||
- **Reviewer not a verified buyer** → review is still allowed but `isVerifiedBuyer = false`. Display this in the UI.
|
||||
- **Rating out of 1–5** → Mongoose schema validator rejects.
|
||||
- **Comment > 1000 chars** → schema-level rejection.
|
||||
- **Seller toggles `allowSellerReviews=false` after reviews exist** → existing reviews remain stored but become unreadable via the public GET (`reviewRoutes.ts:81-83`).
|
||||
- **Spam / abuse** → no automatic moderation; admin can flip `status` to `rejected` to hide.
|
||||
|
||||
> [!tip] Verified-buyer badge
|
||||
> The `isVerifiedBuyer` flag is the most credible signal for prospective buyers. Always render a "Verified buyer" pill next to reviews where this is `true`.
|
||||
|
||||
## Linked flows
|
||||
|
||||
- [[Purchase Request Flow]] — precursor that makes the buyer "verified".
|
||||
- [[Seller Offer Flow]] — display average rating on offer cards.
|
||||
- [[Dispute Flow]] — a resolved dispute could trigger a review prompt; today they are independent.
|
||||
|
||||
## Source files
|
||||
|
||||
- Backend: `backend/src/services/marketplace/reviewRoutes.ts`
|
||||
- Backend: `backend/src/models/Review.ts`
|
||||
- Backend: `backend/src/services/marketplace/shopSettingsController.ts` (allow flags)
|
||||
- Backend: `backend/src/services/marketplace/routes.ts` (inline rating on order completion)
|
||||
- Frontend: review components under `frontend/src/sections/account/` and seller profile views
|
||||
163
04 - Flows/Referral Flow.md
Normal file
163
04 - Flows/Referral Flow.md
Normal file
@@ -0,0 +1,163 @@
|
||||
---
|
||||
title: Referral Flow
|
||||
tags: [flow, referral, points, growth]
|
||||
related_models: ["[[User]]", "[[PointTransaction]]", "[[LevelConfig]]"]
|
||||
related_apis: ["POST /api/points/generate-referral-code", "GET /api/points/referrals", "GET /api/points/leaderboard"]
|
||||
---
|
||||
|
||||
# Referral Flow
|
||||
|
||||
Each user can generate a personal referral code, share a short URL, and earn points/commission when their referees sign up and complete purchases. Codes can also be entered at sign-up time (Phase 1 attribution) — see [[Registration Flow]].
|
||||
|
||||
## Actors
|
||||
|
||||
- **Referrer** — the user with the code.
|
||||
- **Referred user** — the new sign-up.
|
||||
- **Backend** — `PointsService` (`backend/src/services/points/PointsService.ts`), points routes at `backend/src/routes/pointsRoutes.ts`.
|
||||
- **MongoDB** — `users` (`referralCode`, `referredBy`, `referralStats`, `points`), `pointtransactions`, `levelconfigs`.
|
||||
- **Socket.IO** — `referral-signup` and `level-up` events.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- Authenticated user (for referral-code generation and points endpoints).
|
||||
- The 8-character code is unique (alphabet excludes I/O/0/1 to avoid confusion — `PointsService.ts:13`).
|
||||
|
||||
## Step-by-step narrative
|
||||
|
||||
### 1. Code generation
|
||||
|
||||
1. User opens `/dashboard/account/referrals`. If they don't have a code yet, they click "Generate code".
|
||||
2. Frontend POSTs `POST /api/points/generate-referral-code`.
|
||||
3. `PointsService.generateReferralCode(userId)` (`:12-31`):
|
||||
- Loops generating an 8-character code from `ABCDEFGHJKLMNPQRSTUVWXYZ23456789` until uniqueness is confirmed by `User.findOne({ referralCode })`.
|
||||
- Saves the code to the user.
|
||||
- Returns it.
|
||||
4. Frontend renders the share URL `https://amn.gg/r/{code}` and a copy button.
|
||||
|
||||
### 2. Short-URL redirect
|
||||
|
||||
5. When a friend clicks the short URL, `GET /r/:code` (`backend/src/app.ts:274-278`) redirects to `${FRONTEND_URL}/auth/jwt/sign-up?ref={code}`.
|
||||
6. The sign-up form reads `?ref=` and pre-fills the referral field (hidden or visible).
|
||||
|
||||
### 3. Attribution at sign-up
|
||||
|
||||
7. During [[Registration Flow]] verification (or [[Google OAuth Flow]] sign-up), the controller looks up `User.findOne({ referralCode })`:
|
||||
- Sets `user.referredBy = referrer._id` on the new user.
|
||||
- Increments `referrer.referralStats.totalReferrals`.
|
||||
- Emits **`referral-signup`** to `user-{referrerId}` with the referee's name, email, and updated total.
|
||||
8. At this point the referee is **counted** but no points have changed hands yet. Points award on subsequent business events.
|
||||
|
||||
### 4. Points awarding
|
||||
|
||||
9. `PointsService.addPoints(userId, amount, source, metadata)` (`:36-100`) is called by other services on triggering events:
|
||||
- **Purchase completion** (intended): when a referred user finishes an order, the referrer should get a commission. The hook point is `PurchaseRequestService` `notifyTransactionCompleted` — the exact wiring is implementation-specific; the service exposes `source: 'purchase' | 'referral' | 'bonus' | 'admin'`.
|
||||
- **Bonus**: ad-hoc admin grants.
|
||||
10. Inside `addPoints`:
|
||||
- Transaction-scoped Mongo session.
|
||||
- `user.points.total += amount; user.points.available += amount`.
|
||||
- `PointTransaction.create({ type:'earn', source, amount, balance, metadata })`.
|
||||
- `updateUserLevel(userId, session)` recomputes the user's tier from `LevelConfig`.
|
||||
- Emits **`level-up`** on `user-{userId}` if the level changed (`:91-99`).
|
||||
11. Both the referrer and the referee may earn points (e.g. "give 100, get 100" growth model). The current code awards per `addPoints` call — design decision lives in the caller, not in PointsService.
|
||||
|
||||
### 5. Redemption / payout
|
||||
|
||||
12. Users see their balance under `/dashboard/account/points` and can spend via `POST /api/points/redeem` (e.g. for service-credit or discount codes).
|
||||
13. `PointTransaction` records `type: 'spend'` with negative `amount`, keeping `balance` running.
|
||||
|
||||
## Sequence diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor R as Referrer
|
||||
actor N as New User
|
||||
participant FE as Frontend
|
||||
participant BE as Backend
|
||||
participant DB as MongoDB
|
||||
participant IO as Socket.IO
|
||||
|
||||
R->>FE: Generate referral code
|
||||
FE->>BE: POST /api/points/generate-referral-code
|
||||
BE->>DB: User.findByIdAndUpdate(referralCode=...)
|
||||
BE-->>FE: { code }
|
||||
R->>R: share https://amn.gg/r/{code}
|
||||
|
||||
N->>BE: GET /r/{code}
|
||||
BE-->>N: 302 → /auth/jwt/sign-up?ref={code}
|
||||
N->>FE: Fills sign-up, completes email verification
|
||||
FE->>BE: POST /api/auth/verify-email-code (with referralCode in TempVerification)
|
||||
BE->>DB: User.create
|
||||
BE->>DB: referrer.referralStats.totalReferrals += 1
|
||||
BE->>IO: emit user-{R} 'referral-signup'
|
||||
|
||||
Note over BE,DB: Later, when N completes a purchase
|
||||
BE->>BE: PointsService.addPoints(R, +X, 'referral', {referredUserId:N})
|
||||
BE->>DB: user.points += X; PointTransaction.create
|
||||
BE->>BE: updateUserLevel → maybe 'level-up'
|
||||
BE->>IO: emit user-{R} 'level-up'
|
||||
```
|
||||
|
||||
## API calls
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/points/generate-referral-code` | Generate or rotate referral code |
|
||||
| `GET` | `/api/points/my-points` | Balance + level |
|
||||
| `GET` | `/api/points/transactions` | History |
|
||||
| `GET` | `/api/points/referrals` | Referred users list |
|
||||
| `GET` | `/api/points/leaderboard` | Global top referrers |
|
||||
| `GET` | `/api/points/levels` | Level config (public) |
|
||||
| `POST` | `/api/points/redeem` | Spend points |
|
||||
| `POST` | `/api/points/admin/add` | Admin-only manual grant |
|
||||
| `GET` | `/r/:code` | Short-URL redirect to sign-up |
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`users`**: `referralCode` on generation, `referredBy` on referee creation, `referralStats.{totalReferrals, activeReferrals, totalEarned}` and `points.{total, available, level}` on point events.
|
||||
- **`pointtransactions`**: one document per earn/spend/refund.
|
||||
- **`levelconfigs`**: read-only at runtime (seeded at deploy).
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
- **`referral-signup`** → `user-{referrerId}` on referee creation.
|
||||
- **`level-up`** → `user-{userId}` when crossing a tier.
|
||||
- **`new-notification`** → standard notification channel for points-related milestones.
|
||||
|
||||
## Side effects
|
||||
|
||||
- The referee never sees the referrer's identity unless surfaced in UI.
|
||||
- `points.available` is the spendable balance; `points.total` is the lifetime accumulation (used for level tiers).
|
||||
- Transactions are wrapped in a Mongo session for atomicity (`addPoints:47-88`).
|
||||
|
||||
## Error / edge cases
|
||||
|
||||
- **Code collision** — extremely unlikely with 32^8 ≈ 1.1 × 10¹² combinations; the while-loop in `generateReferralCode` is a hard guarantee.
|
||||
- **Self-referral** — not blocked at controller level. Add a check `if (referrer._id.equals(user._id)) return` in `verifyEmailWithCode` and `googleSignUp` to prevent gaming.
|
||||
- **Referral code entered with leading/trailing spaces** — `.trim()` is applied (`authController.ts:74`, `:127`).
|
||||
- **Referrer deleted** — `referredBy` still points to the deleted user; the new user is effectively un-attributed. Soft-delete preservation is acceptable.
|
||||
- **Points overflow** — `Number` is sufficient up to 2⁵³; no overflow risk in practice.
|
||||
- **Race on level-up** — the Mongo session ensures `user.points` and `PointTransaction` are atomically updated, but two parallel `addPoints` calls might both trigger level-up emit. Idempotent in practice (frontend shows toast once).
|
||||
- **`activeReferrals`** — defined in `referralStats` but no code path increments it currently. Define "active" (e.g. referee has at least one completed purchase) and update accordingly.
|
||||
|
||||
> [!tip] Track conversion, not just sign-ups
|
||||
> `totalReferrals` is incremented on sign-up; consider also tracking `convertedReferrals` (completed purchases) to measure real growth value.
|
||||
|
||||
## Linked flows
|
||||
|
||||
- [[Registration Flow]] — attribution point.
|
||||
- [[Google OAuth Flow]] — also supports `referralCode`.
|
||||
- [[Notification Flow]] — `referral-signup`, `level-up`, and points events surface here.
|
||||
- [[Payment Flow - SHKeeper]] — completion of a purchase is the canonical trigger for awarding referral commission.
|
||||
|
||||
## Source files
|
||||
|
||||
- Backend: `backend/src/services/points/PointsService.ts`
|
||||
- Backend: `backend/src/controllers/pointsController.ts`
|
||||
- Backend: `backend/src/routes/pointsRoutes.ts`
|
||||
- Backend: `backend/src/models/PointTransaction.ts`
|
||||
- Backend: `backend/src/models/LevelConfig.ts`
|
||||
- Backend: `backend/src/services/auth/authController.ts:411-433` (referral attribution on email signup)
|
||||
- Backend: `backend/src/services/auth/authController.ts:817-838` (referral on Google signup)
|
||||
- Backend: `backend/src/app.ts:274-278` (short-URL redirect)
|
||||
- Frontend: `/dashboard/account/referrals` view (see `frontend/src/sections/account/`)
|
||||
195
04 - Flows/Registration Flow.md
Normal file
195
04 - Flows/Registration Flow.md
Normal file
@@ -0,0 +1,195 @@
|
||||
---
|
||||
title: Registration Flow
|
||||
tags: [flow, auth, signup, email-verification, referral]
|
||||
related_models: ["[[User]]", "[[TempVerification]]"]
|
||||
related_apis: ["POST /api/auth/register", "POST /api/auth/verify-email-code", "POST /api/auth/resend-verification"]
|
||||
---
|
||||
|
||||
# Registration Flow
|
||||
|
||||
End-to-end specification for **email + password** registration with role selection (buyer/seller), six-digit email verification, optional referral code attribution, and terms acceptance.
|
||||
|
||||
## Actors
|
||||
|
||||
- **Prospective User** – submits the sign-up form.
|
||||
- **Frontend** – `frontend/src/auth/view/jwt/jwt-sign-up-view.tsx`, calling `signUp()` and later `verifyEmailWithCode()` from `frontend/src/auth/context/jwt/action.ts`.
|
||||
- **Backend** – `AuthController.register` and `AuthController.verifyEmailWithCode` in `backend/src/services/auth/authController.ts`.
|
||||
- **MongoDB** – `TempVerification` collection (temporary), then `User` collection (final).
|
||||
- **Email service** – `backend/src/services/email/emailService.ts` (SMTP/transactional provider) — `sendVerificationCodeEmail()`.
|
||||
- **Socket.IO** – emits `referral-signup` to the referrer if a referral code is supplied.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- The email is not already a verified `User`. If a `TempVerification` already exists, its code and metadata are **regenerated and resent** rather than throwing a conflict.
|
||||
- Outbound SMTP credentials are configured (`EMAIL_*` env vars consumed by `emailService.ts`).
|
||||
- If a `referralCode` is supplied, it does **not** need to exist for sign-up to succeed — invalid codes are silently ignored at verification time.
|
||||
|
||||
## State machine: `TempVerification → User`
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> NotStarted
|
||||
NotStarted --> TempCreated: POST /api/auth/register\nemail + role [+ ref]
|
||||
TempCreated --> TempCreated: POST /api/auth/resend-verification\n(new code, 15-min TTL)
|
||||
TempCreated --> TempExpired: 15 minutes elapse\nor verification fails
|
||||
TempExpired --> TempCreated: User clicks "Resend"
|
||||
TempCreated --> UserActive: POST /api/auth/verify-email-code\n(code + password)
|
||||
UserActive --> [*]
|
||||
note right of TempCreated
|
||||
TempVerification document holds:
|
||||
email, firstName, lastName, role,
|
||||
referralCode, code, codeExpires
|
||||
end note
|
||||
note right of UserActive
|
||||
User created with isEmailVerified=true,
|
||||
status="active"; tokens issued immediately.
|
||||
end note
|
||||
```
|
||||
|
||||
## Step-by-step narrative
|
||||
|
||||
### Phase 1 — Submit registration
|
||||
|
||||
1. **User visits `/auth/jwt/sign-up`** (optionally with `?ref=ABCD1234` from the short-URL referral redirect implemented at `backend/src/app.ts:274-278`).
|
||||
2. **User selects role** (buyer or seller), enters email, password (held in client state only), accepts the Terms checkbox, and clicks "Create account".
|
||||
|
||||
> [!tip] Password is **not** sent to `/register`
|
||||
> The password is only included in the second step (`/verify-email-code`). The intent: never hash and store a password for an unverified account. The TempVerification document carries `password: ''` until verification.
|
||||
|
||||
3. **HTTP request**: `POST /api/auth/register` with `{ email, password?, firstName?, lastName?, role, referralCode? }`. (The frontend currently passes the password through, but the controller stores `''` regardless — see `authController.ts:123`.)
|
||||
4. **Validation middleware** `registerValidation` (`authValidation.ts`) checks email format, password complexity, and role enum.
|
||||
5. **Duplicate check** (`authController.ts:55-64`): `User.findOne({ email })` — if found, returns `409 USER_EXISTS`.
|
||||
6. **Idempotent temp record**: `TempVerification.findOne({ email })` — if present, the existing temp is **updated in place** (new name, role, referralCode, fresh 6-digit code, expiry pushed to now + 15 min).
|
||||
7. **Verification code**: `authService.generateVerificationCode()` (`authService.ts:226-228`) returns a uniformly random 6-digit string.
|
||||
8. **Persistence**: A new `TempVerification` is saved with `{ email, password: '', firstName: defaults to "کاربر", lastName: defaults to "جدید", role, referralCode, emailVerificationCode, emailVerificationCodeExpires }`.
|
||||
9. **Email dispatch**: `emailService.sendVerificationCodeEmail(email, firstName, code)` is called. The email contains the 6-digit code, branding, and a 15-minute expiry notice. Failure to send is logged but the response still succeeds with `201` (the user can resend).
|
||||
10. **Response**: `{ email, message: "Verification code sent to email" }` with HTTP `201` for first-time, `200` for resend.
|
||||
11. **Frontend** transitions to the OTP screen `/auth/jwt/verify?email=...` (`frontend/src/auth/view/jwt/jwt-verify-view.tsx`).
|
||||
|
||||
### Phase 2 — Verify code and finalise
|
||||
|
||||
12. **User enters the 6-digit code** and confirms the password. The password may be re-entered here for safety.
|
||||
13. **HTTP request**: `POST /api/auth/verify-email-code` with `{ email, code, password }`.
|
||||
14. **Format guard**: `authService.isValidVerificationCode(code)` enforces `/^\d{6}$/` (`authService.ts:236-238`).
|
||||
15. **Lookup**: `TempVerification.findOne({ email, emailVerificationCode: code, emailVerificationCodeExpires: { $gt: now } })` — if any field mismatches or the code is older than 15 minutes, returns `400`.
|
||||
16. **Hash password**: `bcrypt.hash(password, 12)` via `authService.hashPassword()`.
|
||||
17. **Create `User`** (`authController.ts:400-435`): `email`, `password: hashedPassword`, `firstName`, `lastName`, `role`, `isEmailVerified: true`, `status: "active"`.
|
||||
18. **Apply referral** (`authController.ts:411-433`): if `tempVerification.referralCode` exists, find the referrer by `User.findOne({ referralCode })`. If found:
|
||||
- `user.referredBy = referrer._id`
|
||||
- `referrer.referralStats.totalReferrals += 1`
|
||||
- Emit `referral-signup` on `user-${referrer._id}` Socket.IO room — see [[Referral Flow]] for the points-awarding side effect that happens later on the first purchase.
|
||||
19. **Persist user**, then **delete** the TempVerification document (`findByIdAndDelete`).
|
||||
20. **Token issuance**: identical to [[Authentication Flow]] — generate access + refresh, push the refresh into `user.refreshTokens[]`.
|
||||
21. **Response**: `{ user, tokens: { accessToken, refreshToken } }`. Frontend writes both into `localStorage` (`action.ts:228-235`) and routes the user into the appropriate dashboard (`/dashboard/buyer` or `/dashboard/seller`).
|
||||
|
||||
## Sequence diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor U as User
|
||||
participant FE as Frontend
|
||||
participant BE as Backend
|
||||
participant DB as MongoDB
|
||||
participant MAIL as Email Service
|
||||
participant IO as Socket.IO
|
||||
|
||||
U->>FE: Fill sign-up form (email, role, ref?, password)
|
||||
FE->>BE: POST /api/auth/register
|
||||
BE->>DB: User.findOne({ email })
|
||||
DB-->>BE: null
|
||||
BE->>DB: TempVerification.findOne({ email })
|
||||
DB-->>BE: null
|
||||
BE->>BE: code = generateVerificationCode()
|
||||
BE->>DB: TempVerification.create({...code, expires=+15m})
|
||||
BE->>MAIL: sendVerificationCodeEmail(email, firstName, code)
|
||||
MAIL-->>U: Email with 6-digit code
|
||||
BE-->>FE: 201 { email, message }
|
||||
FE-->>U: Redirect /auth/jwt/verify
|
||||
|
||||
U->>FE: Enter code + (re)password
|
||||
FE->>BE: POST /api/auth/verify-email-code { email, code, password }
|
||||
BE->>DB: TempVerification.findOne({ email, code, expires>now })
|
||||
DB-->>BE: tempVerification doc
|
||||
BE->>BE: hashPassword(password)
|
||||
BE->>DB: User.create({...isEmailVerified:true, status:active})
|
||||
opt referral present
|
||||
BE->>DB: User.findOne({ referralCode })
|
||||
DB-->>BE: referrer
|
||||
BE->>DB: referrer.referralStats.totalReferrals += 1
|
||||
BE->>IO: emit user-{refId} 'referral-signup'
|
||||
end
|
||||
BE->>DB: TempVerification.findByIdAndDelete(...)
|
||||
BE->>BE: generate tokens; push refresh
|
||||
BE-->>FE: 200 { user, tokens }
|
||||
FE->>FE: localStorage.setItem(accessToken, refreshToken)
|
||||
FE-->>U: Redirect /dashboard/{role}
|
||||
```
|
||||
|
||||
## API calls
|
||||
|
||||
| Method | Endpoint | Source |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/auth/register` | `authRoutes.ts:21` → `authController.register` |
|
||||
| `POST` | `/api/auth/verify-email-code` | `authRoutes.ts:34` → `authController.verifyEmailWithCode` |
|
||||
| `POST` | `/api/auth/resend-verification` | `authRoutes.ts:36-40` → `authController.resendVerificationEmail` |
|
||||
| `GET` | `/r/:code` | `app.ts:274-278` — short-URL redirect that injects `?ref=` into the sign-up page |
|
||||
| `POST` | `/api/auth/force-verify-user` | Dev-only — `authController.forceVerifyUser` (rejects outside `NODE_ENV=development`) |
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`tempverifications` collection**: insert on first POST, in-place update on duplicate POST (`authController.ts:66-108`), delete on successful verification.
|
||||
- **`users` collection**: full insert on successful verification (`authController.ts:400-435`). The first refresh token is appended in the same save.
|
||||
- **`users` collection (referrer)**: `referralStats.totalReferrals` incremented (`authController.ts:419`).
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
- **`referral-signup`** → `user-${referrerId}` room when a referred user verifies. Payload:
|
||||
```
|
||||
{ userId, userName, userEmail, timestamp, totalReferrals }
|
||||
```
|
||||
Source: `authController.ts:423-431`.
|
||||
|
||||
## Side effects
|
||||
|
||||
- **Email**: one transactional message per `/register` and per `/resend-verification`. Content is generated by `emailService.sendVerificationCodeEmail`. Plain-text fallback included.
|
||||
- **Sentry**: errors during `User.create` or email dispatch are captured server-side.
|
||||
- **Logs**: the controller `console.log`s the generated code in **all environments** (`authController.ts:88`, `:117`, `:518`). Useful in dev; in prod the same log line ends up in CloudWatch/Sentry breadcrumbs. (Tracked as a hardening item.)
|
||||
|
||||
> [!warning] Verification code is logged server-side
|
||||
> The generated 6-digit code is `console.log`-ed by the controller even in production. Anyone with log access can take over an unverified account. Move behind `if (NODE_ENV !== 'production')`.
|
||||
|
||||
## Error / edge cases
|
||||
|
||||
- **Email already registered (verified)** → `409 USER_EXISTS`.
|
||||
- **Email already in temp (unverified)** → `200`, code regenerated, email re-sent. User-friendly; no error.
|
||||
- **Code mismatch / expired (>15 min)** → `400 Invalid or expired verification code`. The TempVerification is **not** deleted, so the user can request a new code via "Resend".
|
||||
- **Code format wrong (non-digits or wrong length)** → `400` from `isValidVerificationCode` guard before DB lookup.
|
||||
- **Email delivery failure** → response still `201`/`200`; the user can hit "Resend" or check spam.
|
||||
- **Referral code that does not match any user** → silently ignored; the user is still created with `referredBy: undefined`.
|
||||
- **Race condition: two parallel registrations for the same email** → MongoDB unique index on `User.email` ensures only one user document; the loser of the race sees `E11000` and returns `409 USER_EXISTS`.
|
||||
- **Race condition: verify request arrives twice with the same code** → second request finds no TempVerification and returns `400`. The created `User` is the canonical record.
|
||||
- **Role tampering** → role is validated by `registerValidation` enum (`buyer | seller`). Admin role is created only via the bootstrap seed (`initializeAdminUser` in `app.ts:377`), never via this flow.
|
||||
|
||||
## Defaults & quirks
|
||||
|
||||
- `firstName` / `lastName` are not required by the frontend in many sign-up variants; the controller defaults them to Persian placeholders `"کاربر"` / `"جدید"` (`authController.ts:52-53`). They can be edited later under `/dashboard/account/profile`.
|
||||
- The TempVerification TTL is enforced by the `emailVerificationCodeExpires` check, not by a Mongo TTL index — expired docs remain in the collection until overwritten or manually purged.
|
||||
|
||||
## Linked flows
|
||||
|
||||
- [[Authentication Flow]] — the next time the user signs in.
|
||||
- [[Referral Flow]] — full points-awarding mechanics triggered here.
|
||||
- [[Google OAuth Flow]] — alternative path that bypasses `TempVerification` (Google identities are pre-verified).
|
||||
- [[Password Reset Flow]] — if the user forgets the password they set during verification.
|
||||
|
||||
## Source files
|
||||
|
||||
- Backend: `backend/src/services/auth/authController.ts:33-158` (register), `:364-469` (verify), `:498-539` (resend)
|
||||
- Backend: `backend/src/services/auth/authValidation.ts` (validation rules)
|
||||
- Backend: `backend/src/models/TempVerification.ts` (temp schema)
|
||||
- Backend: `backend/src/services/email/emailService.ts` (`sendVerificationCodeEmail`)
|
||||
- Backend: `backend/src/app.ts:274-278` (short referral redirect)
|
||||
- Frontend: `frontend/src/auth/view/jwt/jwt-sign-up-view.tsx`
|
||||
- Frontend: `frontend/src/auth/view/jwt/jwt-verify-view.tsx`
|
||||
- Frontend: `frontend/src/auth/context/jwt/action.ts:121-256`
|
||||
197
04 - Flows/Seller Offer Flow.md
Normal file
197
04 - Flows/Seller Offer Flow.md
Normal file
@@ -0,0 +1,197 @@
|
||||
---
|
||||
title: Seller Offer Flow
|
||||
tags: [flow, marketplace, seller, offer]
|
||||
related_models: ["[[SellerOffer]]", "[[PurchaseRequest]]", "[[Notification]]"]
|
||||
related_apis: ["POST /api/marketplace/offers", "GET /api/marketplace/offers/request/:requestId", "PATCH /api/marketplace/offers/:id"]
|
||||
---
|
||||
|
||||
# Seller Offer Flow
|
||||
|
||||
A **seller** browses open purchase requests and submits an offer with a price, delivery time, and notes. The buyer is notified in real time and can accept (which moves the request to [[Payment Flow - SHKeeper]]) or reject.
|
||||
|
||||
## Actors
|
||||
|
||||
- **Seller** — proposes an offer.
|
||||
- **Buyer** — receives the offer in their request detail view.
|
||||
- **Frontend (seller)** — `frontend/src/sections/request/components/seller-steps/step-1-send-proposal.tsx` and the seller marketplace listing under `frontend/src/app/dashboard/seller/marketplace/`.
|
||||
- **Frontend (buyer)** — `frontend/src/sections/request/components/buyer-steps/step-3-select-and-pay.tsx` for the offer chooser.
|
||||
- **Backend** — `SellerOfferService` (`backend/src/services/marketplace/SellerOfferService.ts`) and controller routes.
|
||||
- **MongoDB** — `selleroffers` collection.
|
||||
- **Socket.IO** — `seller-offer-update` and `purchase-request-update` events.
|
||||
- **Notification service** — buyer notifications.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- Seller is authenticated, `role === "seller"`, `status === "active"`.
|
||||
- Target purchase request exists and `status` is `pending` or `received_offers` (`SellerOfferService.ts:83-85`).
|
||||
- Seller does **not** already have an offer on this request (uniqueness enforced by `SellerOfferService.createOffer`).
|
||||
|
||||
## Offer state machine
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> pending: createOffer()
|
||||
pending --> active: (optional — manual seller activation)
|
||||
pending --> withdrawn: seller withdraws (only while pending)
|
||||
pending --> rejected: another offer accepted\nor buyer rejects this one
|
||||
pending --> accepted: acceptOffer()\nor SHKeeper PAID webhook
|
||||
accepted --> [*]
|
||||
rejected --> [*]
|
||||
withdrawn --> [*]
|
||||
pending --> expired_via_withdrawn: validUntil < now\n→ markExpiredOffersAsWithdrawn (cron)
|
||||
```
|
||||
|
||||
The active enum values are `pending | accepted | rejected | withdrawn` (`SellerOfferService.ts:308`). `validUntil` expirations are converted to `withdrawn`.
|
||||
|
||||
## Step-by-step narrative
|
||||
|
||||
### Discovery
|
||||
|
||||
1. Seller opens `/dashboard/seller/marketplace`. The page hits `GET /api/marketplace/purchase-requests?sellerId={me}` and renders cards.
|
||||
2. Filtering rules: only `pending` or `received_offers` requests where the seller is public-eligible or in `preferredSellerIds` (see visibility table in [[Purchase Request Flow]]).
|
||||
3. Clicking a card navigates to `/dashboard/seller/marketplace/request/{id}`; the seller sees the buyer's description, attachments, address city/region (full address withheld), and existing offers if visible.
|
||||
|
||||
### Submission
|
||||
|
||||
4. Seller clicks "Send proposal" — opens `step-1-send-proposal.tsx`. Fields:
|
||||
- **Title** (defaults to a derivative of the request title)
|
||||
- **Description / notes**
|
||||
- **Price** (amount + currency, default USDT)
|
||||
- **Delivery time** (amount + unit: hours / days / weeks)
|
||||
- **Attachments** (optional, via `POST /api/files/upload`)
|
||||
- **Valid until** (optional expiry)
|
||||
5. Frontend POSTs `POST /api/marketplace/offers`.
|
||||
6. Backend `SellerOfferService.createOffer` (`:51-140`):
|
||||
- **Uniqueness**: `SellerOffer.findOne({ purchaseRequestId, sellerId })` — if present, throws `"شما قبلاً برای این درخواست پیشنهاد دادهاید"` (`:74`). Use `updateOffer` to amend.
|
||||
- **Status guard**: loads the `PurchaseRequest`; rejects if its status is anything other than `pending` or `received_offers`.
|
||||
- Saves the offer (`status: "pending"` by default in the schema).
|
||||
- Re-loads with `.populate('sellerId').populate('purchaseRequestId')` for the response.
|
||||
7. **Real-time fan-out** (`emitOfferUpdate`, `:24-46`): emits `seller-offer-update` to `seller-{sellerId}` so the seller's other tabs reflect the new offer instantly.
|
||||
8. **Status auto-progression**: if this is the **first** offer on a `pending` request, the request transitions to `received_offers` (`:107-122`).
|
||||
9. **Buyer notification**: `notificationService.notifyNewOfferReceived(buyerId, requestId, requestTitle, sellerName)` writes a `Notification` and emits to `user-{buyerId}`.
|
||||
10. Response: `200 { offer }` (populated).
|
||||
|
||||
### Buyer review
|
||||
|
||||
11. Buyer's request detail page (`/dashboard/buyer/requests/{id}`) joins `GET /api/marketplace/offers/request/{requestId}` — `SellerOfferService.getOffersByPurchaseRequest` returns all offers sorted by `createdAt: -1`.
|
||||
12. Each offer card shows seller name, avatar, rating (from [[Rating Flow]]), price, ETA, notes.
|
||||
13. Buyer either **negotiates** (opens chat → [[Negotiation Flow]]) or **accepts** the offer by triggering payment.
|
||||
|
||||
### Accept → Payment
|
||||
|
||||
14. The buyer's "Pay this offer" button kicks off [[Payment Flow - SHKeeper]] with `purchaseRequestId` and `sellerOfferId`. The offer is **not** immediately marked `accepted`; the SHKeeper webhook does that atomically when the on-chain payment is confirmed.
|
||||
15. On `PAID`/`OVERPAID` webhook (see `backend/src/services/payment/shkeeper/shkeeperWebhook.ts:573-714`):
|
||||
- The selected offer's `status` → `accepted`.
|
||||
- All other offers on the same request → `rejected` via `SellerOffer.updateMany`.
|
||||
- The purchase request: `status = "payment"`, `selectedOfferId = sellerOfferId`.
|
||||
- A direct chat is created (see [[Chat Flow]]).
|
||||
- Notifications: `notifyOfferAccepted` to the winning seller, generic rejection notifications to the others (`SellerOfferService.acceptOffer` does the same in the manual path).
|
||||
- Socket events: `seller-offer-update` `payment-completed` to the winner, `seller-offer-update` `offer-rejected` to losers (`shkeeperWebhook.ts:679-705`).
|
||||
|
||||
### Withdrawal
|
||||
|
||||
16. Seller can withdraw their `pending` offer from `/dashboard/seller/marketplace/offers/{offerId}` → `withdrawOffer` (`SellerOfferService.ts:428-443`). The DB filter `{ status: 'pending' }` means withdrawal is impossible once `accepted` or `rejected`.
|
||||
|
||||
## Sequence diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor S as Seller
|
||||
actor B as Buyer
|
||||
participant FE_S as Frontend (seller)
|
||||
participant FE_B as Frontend (buyer)
|
||||
participant BE as Backend
|
||||
participant DB as MongoDB
|
||||
participant N as NotificationService
|
||||
participant IO as Socket.IO
|
||||
|
||||
S->>FE_S: Browse /dashboard/seller/marketplace
|
||||
FE_S->>BE: GET /api/marketplace/purchase-requests?sellerId
|
||||
BE-->>FE_S: filtered list
|
||||
S->>FE_S: Open request, click "Send proposal"
|
||||
S->>FE_S: Fill price, ETA, notes; submit
|
||||
FE_S->>BE: POST /api/marketplace/offers
|
||||
BE->>DB: ensure no existing offer; check status
|
||||
BE->>DB: SellerOffer.create({status:"pending"})
|
||||
opt first offer on the request
|
||||
BE->>DB: PurchaseRequest.status = "received_offers"
|
||||
end
|
||||
BE->>N: notifyNewOfferReceived(buyer, requestId, sellerName)
|
||||
N->>IO: emit user-{buyer} new-notification
|
||||
BE->>IO: emit seller-{sellerId} 'new-offer'
|
||||
BE-->>FE_S: 200 { offer }
|
||||
IO-->>FE_B: new-notification (buyer's bell icon)
|
||||
B->>FE_B: Open request detail
|
||||
FE_B->>BE: GET /api/marketplace/offers/request/{id}
|
||||
BE-->>FE_B: offers[]
|
||||
alt Buyer accepts via payment
|
||||
B->>FE_B: Click "Pay" → starts [[Payment Flow - SHKeeper]]
|
||||
Note over BE,DB: SHKeeper webhook PAID arrives later;<br/>winning offer → accepted, others → rejected
|
||||
else Buyer negotiates
|
||||
B->>FE_B: Open chat → [[Negotiation Flow]]
|
||||
end
|
||||
```
|
||||
|
||||
## API calls
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/marketplace/offers` | Create offer |
|
||||
| `GET` | `/api/marketplace/offers/request/:requestId` | Buyer view of offers on a request |
|
||||
| `GET` | `/api/marketplace/offers/seller/:sellerId` | Seller's own offer history |
|
||||
| `GET` | `/api/marketplace/offers/:id` | Single offer details |
|
||||
| `PATCH` | `/api/marketplace/offers/:id` | Update price / ETA / notes (seller, while pending) |
|
||||
| `POST` | `/api/marketplace/offers/:id/accept` | Manual acceptance (when not via webhook) |
|
||||
| `POST` | `/api/marketplace/offers/:id/withdraw` | Seller withdraws |
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`selleroffers`**: insert on create; update on accept/reject/withdraw; `updateMany` to bulk-reject other offers when one is accepted (`SellerOfferService.acceptOffer:376-388`).
|
||||
- **`purchaserequests`**: status moves to `received_offers` on first offer, then `payment` on successful payment, and `selectedOfferId` is set.
|
||||
- **`notifications`**: at least one per state change.
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
- **`seller-offer-update`** with `eventType: 'new-offer'` → `seller-{sellerId}` (creator's other tabs).
|
||||
- **`purchase-request-update`** with `eventType: 'offer-updated'` → `request-{requestId}` on edits (`SellerOfferService.ts:284-288`).
|
||||
- **`seller-offer-update`** with `eventType: 'payment-completed'` to winning seller, `'offer-rejected'` to losers (emitted by the webhook handler).
|
||||
- **`new-notification`** → `user-{buyerId}` for each new offer.
|
||||
|
||||
## Side effects
|
||||
|
||||
- Triggers chat creation **only after payment** (not on offer creation) — minimises noise from speculative offers.
|
||||
- The `rejectionReason` field is set to `"Another offer was accepted by buyer"` for losers (`SellerOfferService.ts:387`).
|
||||
- Seller statistics aggregate (`getOfferStatistics`, `:446-475`) is computed on demand for dashboards; no denormalised counter on the user document.
|
||||
|
||||
## Error / edge cases
|
||||
|
||||
- **Duplicate offer by same seller** → `400` with localized error. Use `updateOffer` instead.
|
||||
- **Request status not open** → `400 "این درخواست دیگر برای پیشنهاد باز نیست"` (`:84`).
|
||||
- **Price = 0 or negative** → Mongoose validator on `SellerOffer.price.amount` rejects (`SellerOfferService.ts:55-60` logs the validation state).
|
||||
- **Seller withdraws an `accepted` offer** → blocked by the `{ status: 'pending' }` filter; returns `null`.
|
||||
- **`validUntil` in the past at creation** → schema-level validator should reject; otherwise the next `markExpiredOffersAsWithdrawn` cron run flips it to `withdrawn`.
|
||||
- **Race condition: two payments to two different offers** → unlikely (frontend disables payment buttons once one is chosen); even if both arrive, the SHKeeper webhook coordinator (`PaymentCoordinator`) is idempotent and the first PAID wins.
|
||||
- **Offer for a deleted request** → orphan; the webhook handler logs `"Purchase request not found"` and continues. Periodic cleanup should remove orphans.
|
||||
|
||||
> [!tip] Real-time UX
|
||||
> Sellers should `socket.emit('join-seller-room', myUserId)` on dashboard mount so they see `seller-offer-update` events instantly when the buyer accepts/rejects.
|
||||
|
||||
## Linked flows
|
||||
|
||||
- [[Purchase Request Flow]] — produces the requests sellers offer on.
|
||||
- [[Negotiation Flow]] — counter-offer in `in_negotiation`.
|
||||
- [[Payment Flow - SHKeeper]] — locks in the accepted offer.
|
||||
- [[Chat Flow]] — direct chat opened after payment.
|
||||
- [[Notification Flow]] — channels for offer events.
|
||||
- [[Rating Flow]] — seller's average rating displayed in the offer card.
|
||||
|
||||
## Source files
|
||||
|
||||
- Backend: `backend/src/services/marketplace/SellerOfferService.ts`
|
||||
- Backend: `backend/src/services/marketplace/marketplaceController.ts`
|
||||
- Backend: `backend/src/models/SellerOffer.ts`
|
||||
- Backend: `backend/src/services/payment/shkeeper/shkeeperWebhook.ts:573-714` (acceptance via webhook)
|
||||
- Frontend: `frontend/src/sections/request/components/seller-steps/step-1-send-proposal.tsx`
|
||||
- Frontend: `frontend/src/sections/request/components/buyer-steps/step-3-select-and-pay.tsx`
|
||||
- Frontend: `frontend/src/app/dashboard/seller/marketplace/`
|
||||
207
05 - Design System/Colors.md
Normal file
207
05 - Design System/Colors.md
Normal file
@@ -0,0 +1,207 @@
|
||||
---
|
||||
title: Colors
|
||||
tags: [design-system, colors, palette]
|
||||
created: 2026-05-23
|
||||
---
|
||||
|
||||
# Colors
|
||||
|
||||
The palette is built from semantic groups (`primary`, `secondary`, `info`, `success`, `warning`, `error`, plus a 9-step `grey` scale) and exposed via the MUI theme. **Never hard-code hex values in components.**
|
||||
|
||||
> [!warning]
|
||||
> Hardcoded colors break dark mode and any future preset switch. Use `sx={{ color: 'primary.main' }}` or `theme.palette.primary.main`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Semantic groups
|
||||
|
||||
Every color group has the shape:
|
||||
|
||||
```ts
|
||||
{
|
||||
lighter: '#…', // tint background, alerts
|
||||
light: '#…', // hover surfaces, badges
|
||||
main: '#…', // primary use (buttons, links)
|
||||
dark: '#…', // hover state of `main`
|
||||
darker: '#…', // pressed state
|
||||
contrastText: '#…', // WCAG AA on `main`
|
||||
}
|
||||
```
|
||||
|
||||
### 1.1 Primary
|
||||
|
||||
| Token | Light hex (default preset) | Dark hex |
|
||||
|---|---|---|
|
||||
| `primary.lighter` | `#D0ECFE` | `#CCF4FE` |
|
||||
| `primary.light` | `#73BAFB` | `#68CDF9` |
|
||||
| `primary.main` | `#1877F2` | `#078DEE` |
|
||||
| `primary.dark` | `#0C44AE` | `#0351AB` |
|
||||
| `primary.darker` | `#042174` | `#012972` |
|
||||
| `primary.contrastText` | `#FFFFFF` | `#FFFFFF` |
|
||||
|
||||
Used for primary actions, links, focused inputs.
|
||||
|
||||
### 1.2 Secondary
|
||||
|
||||
Typical: a complementary purple/teal. Confirm by reading `theme/options/palette.ts`.
|
||||
|
||||
### 1.3 Info / Success / Warning / Error
|
||||
|
||||
Standard semantic colors:
|
||||
|
||||
| Group | Hue | Used for |
|
||||
|---|---|---|
|
||||
| `info` | cyan/blue | Neutral informative banners |
|
||||
| `success` | green | Confirmed payments, accepted offers |
|
||||
| `warning` | amber | Pending state, mild alerts |
|
||||
| `error` | red | Disputes, validation errors, destructive |
|
||||
|
||||
Each follows the same `lighter/light/main/dark/darker/contrastText` shape.
|
||||
|
||||
### 1.4 Grey
|
||||
|
||||
Nine-step scale (100 → 900). Use for:
|
||||
|
||||
- `grey.100`–`grey.300` → backgrounds, dividers, disabled
|
||||
- `grey.500` → secondary text, muted icons
|
||||
- `grey.700`–`grey.900` → primary text, dark headings
|
||||
|
||||
### 1.5 Text & Background
|
||||
|
||||
```ts
|
||||
text: {
|
||||
primary: grey[800], // body
|
||||
secondary: grey[600], // muted
|
||||
disabled: grey[500],
|
||||
},
|
||||
background: {
|
||||
default: '#FFFFFF', // light mode page
|
||||
paper: '#FFFFFF', // cards, dialogs
|
||||
neutral: grey[200], // page wash
|
||||
},
|
||||
```
|
||||
|
||||
In dark mode these invert (`default` becomes near-black, `paper` slightly lighter, `text.primary` near-white).
|
||||
|
||||
---
|
||||
|
||||
## 2. Action states
|
||||
|
||||
```ts
|
||||
action: {
|
||||
active: alpha(grey[600], 1),
|
||||
hover: alpha(grey[500], 0.08),
|
||||
selected: alpha(grey[500], 0.16),
|
||||
disabled: alpha(grey[500], 0.8),
|
||||
disabledBackground: alpha(grey[500], 0.24),
|
||||
focus: alpha(grey[500], 0.24),
|
||||
hoverOpacity: 0.08,
|
||||
disabledOpacity: 0.48,
|
||||
}
|
||||
```
|
||||
|
||||
Use via `sx={{ bgcolor: 'action.hover' }}` etc.
|
||||
|
||||
---
|
||||
|
||||
## 3. Color presets
|
||||
|
||||
The settings drawer ships several **color presets** that swap only `primary` (and optionally `secondary`) while keeping every other group stable. Typical presets (confirm in `theme/options/presets/`):
|
||||
|
||||
| Preset | Primary hue | Vibe |
|
||||
|---|---|---|
|
||||
| `default` | Blue | Trust, finance |
|
||||
| `purple` | Indigo/Purple | Premium |
|
||||
| `cyan` | Cyan | Tech, fresh |
|
||||
| `blue` | Vibrant blue | Strong CTA |
|
||||
| `orange` | Orange | Energetic |
|
||||
| `red` | Brand red | Bold |
|
||||
|
||||
To add a preset:
|
||||
|
||||
1. Add a `<name>.ts` file in `theme/options/presets/` exporting `{ lighter, light, main, dark, darker, contrastText }`.
|
||||
2. Register in the presets index map.
|
||||
3. Surface in the settings drawer color picker (`src/settings/drawer/`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Contrast verification
|
||||
|
||||
WCAG AA targets:
|
||||
|
||||
| Element | Min contrast |
|
||||
|---|---|
|
||||
| Body text (≥18 px regular or ≥14 px bold) | **4.5 : 1** |
|
||||
| Large text (≥24 px regular or ≥18 px bold) | **3 : 1** |
|
||||
| Icon-only buttons | **3 : 1** for their boundary |
|
||||
| Form input borders | **3 : 1** |
|
||||
|
||||
> [!tip]
|
||||
> When picking a new `main` color, run it through [Stark](https://stark.co/) or the Chrome DevTools "Inspect color" checker against `background.paper` AND `background.default` for both light and dark modes.
|
||||
|
||||
---
|
||||
|
||||
## 5. Using colors in `sx`
|
||||
|
||||
```tsx
|
||||
// Token-based — preferred
|
||||
<Box sx={{ bgcolor: 'primary.main', color: 'primary.contrastText' }} />
|
||||
|
||||
// Function form when you need to derive
|
||||
<Box sx={(theme) => ({ bgcolor: alpha(theme.palette.primary.main, 0.08) })} />
|
||||
|
||||
// In styled component
|
||||
const Item = styled('div')(({ theme }) => ({
|
||||
background: theme.palette.background.paper,
|
||||
color: theme.palette.text.primary,
|
||||
}));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Charts & data viz
|
||||
|
||||
Use the helper `theme.palette.<group>.main` array for series colors. The `chart/` component family (ApexCharts wrapper) reads these tokens so the chart palette stays in sync with the theme.
|
||||
|
||||
For categorical data with >7 series, layer in `secondary.main`, `info.main`, `success.main`, `warning.main`, `error.main`, then darker variants — never invent ad-hoc hex.
|
||||
|
||||
---
|
||||
|
||||
## 7. Diff visualisation (chat, evidence)
|
||||
|
||||
| Concept | Color token |
|
||||
|---|---|
|
||||
| Added / accepted | `success.main` with `success.lighter` background |
|
||||
| Removed / rejected | `error.main` with `error.lighter` background |
|
||||
| Pending review | `warning.main` |
|
||||
| Informational | `info.main` |
|
||||
|
||||
---
|
||||
|
||||
## 8. Avatar backgrounds
|
||||
|
||||
`MuiAvatar` override hashes the user's name to one of the semantic `main` colors so the same person always gets the same background. Implementation in `components/user-avatar/`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Brand vs semantic
|
||||
|
||||
| Use case | Token |
|
||||
|---|---|
|
||||
| Primary CTA button | `primary.main` |
|
||||
| Success snackbar | `success.main` |
|
||||
| Error border on form field | `error.main` |
|
||||
| Page background | `background.default` |
|
||||
| Card background | `background.paper` |
|
||||
| Sidebar background | `background.neutral` |
|
||||
| Divider line | `divider` |
|
||||
| Primary text | `text.primary` |
|
||||
| Muted helper text | `text.secondary` |
|
||||
|
||||
---
|
||||
|
||||
## 10. Related
|
||||
|
||||
- [[Theme Configuration]] · [[Design System Overview]] · [[Typography]]
|
||||
- [[Components]] — components consuming these tokens
|
||||
- [[Settings & Theming]] — preset switcher
|
||||
222
05 - Design System/Components.md
Normal file
222
05 - Design System/Components.md
Normal file
@@ -0,0 +1,222 @@
|
||||
---
|
||||
title: Components
|
||||
tags: [design-system, components, ui]
|
||||
created: 2026-05-23
|
||||
---
|
||||
|
||||
# Components
|
||||
|
||||
Inventory of reusable components in `frontend/src/components/`. Each component lives in its own folder (`index.ts` + `<name>.tsx` + `classes.ts` + `types.ts`).
|
||||
|
||||
> [!info]
|
||||
> Pattern: components are reusable **and free of business logic**. Page-specific composition lives in `frontend/src/sections/`. Hooks that fetch data live in `frontend/src/hooks/`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Form components (`components/hook-form/`)
|
||||
|
||||
RHF + MUI wrappers. Used inside a `<FormProvider {...methods}>` context. All accept `name`, plus the MUI props of the underlying component.
|
||||
|
||||
| Component | Wraps | Notes |
|
||||
|---|---|---|
|
||||
| `RHFTextField` | `TextField` | The workhorse text input |
|
||||
| `RHFSelect` | `Select` + `MenuItem` | Pass `children={<MenuItem value="..."/>...}` |
|
||||
| `RHFAutocomplete` | `Autocomplete` | Async-loader friendly; pass `options` |
|
||||
| `RHFCheckbox` | `Checkbox` + `FormControlLabel` | Single boolean |
|
||||
| `RHFMultiCheckbox` | grid of checkboxes | Returns `string[]` |
|
||||
| `RHFRadioGroup` | `RadioGroup` | Returns selected value |
|
||||
| `RHFSwitch` | `Switch` + label | Boolean toggle |
|
||||
| `RHFEditor` | TipTap wrapper | HTML output |
|
||||
| `RHFUpload` | `components/upload` Dropzone | Single or multiple |
|
||||
| `RHFDatePicker` | `@mui/x-date-pickers DatePicker` | Locale-aware |
|
||||
| `RHFDateTimePicker` | `DateTimePicker` | — |
|
||||
| `RHFTimePicker` | `TimePicker` | — |
|
||||
| `RHFPhoneInput` | `react-phone-number-input` | E.164 output |
|
||||
| `RHFCountrySelect` | `components/country-select` | ISO-2 output |
|
||||
| `RHFNumberInput` | `components/number-input` | Locale-formatted, accepts min/max/step |
|
||||
| `RHFRating` | `Rating` | 0–5 |
|
||||
| `RHFSlider` | `Slider` | Single or range |
|
||||
|
||||
> [!tip]
|
||||
> Form-level errors render automatically via `formState.errors[name]` — pass `helperText={errors.name?.message}` is **not needed**; the wrapper handles it.
|
||||
|
||||
---
|
||||
|
||||
## 2. Data display
|
||||
|
||||
| Component | Purpose |
|
||||
|---|---|
|
||||
| `custom-data-grid/` | MUI DataGrid Pro wrapper — sets density, locale, custom toolbar, RTL support |
|
||||
| `table/` | Lower-level table utilities (head, pagination, no-data row, skeleton) |
|
||||
| `chart/` | ApexCharts wrapper with theme-synced palette |
|
||||
| `markdown/` | `react-markdown` + `remark-gfm` for chat/evidence rendering |
|
||||
| `lightbox/` | Image gallery (gallery / dispute evidence) |
|
||||
| `image/` | Next `Image` wrapper with fallback skeleton + error state |
|
||||
| `file-thumbnail/` | Renders icon by MIME type for non-image files |
|
||||
| `flag-icon/` | Country flag SVG (used in phone input, locale switcher) |
|
||||
| `iconify/` | Iconify SVG wrapper — `<Iconify icon="solar:user-bold" />` |
|
||||
| `svg-color/` | Renders SVG with `color` prop (mask-image trick) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Navigation
|
||||
|
||||
| Component | Purpose |
|
||||
|---|---|
|
||||
| `nav-section/` | Sidebar nav with role-filtering, group headers, mini variant |
|
||||
| `nav-basic/` | Simple horizontal nav for marketing pages |
|
||||
| `custom-breadcrumbs/` | Page header breadcrumbs + action slot |
|
||||
| `progress-bar/` | NProgress integration for route transitions |
|
||||
|
||||
The sidebar reads from a configuration array — see `src/layouts/dashboard/config-nav-dashboard.ts` (or similar) — where each entry can specify `roles: ['admin']` to hide from other users.
|
||||
|
||||
---
|
||||
|
||||
## 4. Feedback
|
||||
|
||||
| Component | Purpose |
|
||||
|---|---|
|
||||
| `snackbar/` | Notistack wrapper — `enqueueSnackbar('Done', { variant: 'success' })` |
|
||||
| `loading-screen/` | Full-screen splash for boot or guarded routes |
|
||||
| `empty-content/` | "No results" placeholder with illustration |
|
||||
| `search-not-found/` | Variant of empty-content for failed searches |
|
||||
| `email-verification-banner/` | Persistent top banner when email unverified |
|
||||
|
||||
---
|
||||
|
||||
## 5. Overlays & dialogs
|
||||
|
||||
| Component | Purpose |
|
||||
|---|---|
|
||||
| `custom-dialog/` | Modal dialog with title/content/actions slots |
|
||||
| `custom-popover/` | Click-or-hover popover (used for menus, tooltips, info) |
|
||||
|
||||
---
|
||||
|
||||
## 6. Inputs (raw, non-RHF)
|
||||
|
||||
| Component | Purpose |
|
||||
|---|---|
|
||||
| `number-input/` | Numeric with +/- buttons + locale format |
|
||||
| `phone-input/` | Phone with country selector |
|
||||
| `country-select/` | Country dropdown |
|
||||
| `incrementer-button/` | Tiny `−` / `+` quantity stepper |
|
||||
| `upload/` | Dropzone with progress, preview, removal |
|
||||
|
||||
---
|
||||
|
||||
## 7. User & profile
|
||||
|
||||
| Component | Purpose |
|
||||
|---|---|
|
||||
| `user-avatar/` | Hashes name → color; respects `src` if avatar uploaded |
|
||||
| `user-profile-card/` | Profile summary card (name, role, level, points) |
|
||||
| `notifications-drawer/` | Side drawer listing notifications, mark-as-read |
|
||||
|
||||
---
|
||||
|
||||
## 8. Media
|
||||
|
||||
| Component | Purpose |
|
||||
|---|---|
|
||||
| `video-player/` | Plyr / video.js wrapper for blog & template videos |
|
||||
| `map/` | Mapbox + react-map-gl wrapper (delivery addresses) |
|
||||
| `carousel/` | Embla carousel — multiple variants in `hooks/use-carousel-*` |
|
||||
|
||||
---
|
||||
|
||||
## 9. Animation
|
||||
|
||||
| Component | Purpose |
|
||||
|---|---|
|
||||
| `animate/` | Framer-motion presets (fade, slide, scale) for page transitions and lists |
|
||||
|
||||
---
|
||||
|
||||
## 10. Utility
|
||||
|
||||
| Component | Purpose |
|
||||
|---|---|
|
||||
| `scrollbar/` | SimpleBar wrapper — themed scrollbar with shadow on overflow |
|
||||
| `label/` | Tag/badge — variants: filled, outlined, soft |
|
||||
| `filters-result/` | Active filter chips with × to remove |
|
||||
| `logo/` | App logo SVG (responsive: full mark + icon-only at small width) |
|
||||
| `color-utils/` | Color manipulation helpers (alpha, darken, contrast) |
|
||||
|
||||
---
|
||||
|
||||
## 11. Settings
|
||||
|
||||
| Component | Purpose |
|
||||
|---|---|
|
||||
| `settings/` | The mounted Settings drawer + provider hooks |
|
||||
|
||||
---
|
||||
|
||||
## 12. Debug
|
||||
|
||||
| Component | Purpose |
|
||||
|---|---|
|
||||
| `debug/` | Dev-only diagnostic overlays (only rendered when `NEXT_PUBLIC_ENABLE_DEBUG=true`) |
|
||||
|
||||
---
|
||||
|
||||
## 13. Editor (`components/editor/`)
|
||||
|
||||
TipTap rich-text editor with this extension set:
|
||||
|
||||
- `StarterKit` (paragraph, headings, bold/italic, lists, blockquote, history)
|
||||
- `Link` (URL parsing, target=_blank by default for external)
|
||||
- `Image` (drag-drop, uploads via `components/upload`)
|
||||
- `Underline`, `TextAlign`
|
||||
- `CodeBlock` + `CodeBlockLowlight` (syntax highlighting via `lowlight`)
|
||||
- `Placeholder`
|
||||
|
||||
Output: HTML string. Round-trip safe with `react-markdown` + `turndown` when Markdown storage is preferred.
|
||||
|
||||
Used in:
|
||||
- Blog post editor (`dashboard/post/new`)
|
||||
- Long-form description fields (request, template)
|
||||
- Dispute evidence narrative
|
||||
|
||||
---
|
||||
|
||||
## 14. Payment-specific (`components/payment/`)
|
||||
|
||||
| Component | Purpose |
|
||||
|---|---|
|
||||
| `<TokenSelector>` | USDT / USDC / BTC / ETH chip selector |
|
||||
| `<NetworkSelector>` | BSC / TRC20 / Ethereum / Polygon chip selector |
|
||||
| `<QrInvoice>` | Renders the SHKeeper invoice QR + copy-address button |
|
||||
| `<PaymentStatus>` | Live status pill that updates from socket events |
|
||||
|
||||
(Confirm names against actual exports — these are typical for this stack.)
|
||||
|
||||
---
|
||||
|
||||
## 15. Adding a new component
|
||||
|
||||
1. Create `src/components/<kebab-name>/` with `index.ts`, `<kebab-name>.tsx`, `classes.ts`, `types.ts`.
|
||||
2. Re-export from `src/components/<kebab-name>/index.ts`: `export * from './<kebab-name>'`.
|
||||
3. Place ONLY rendering + styling; do NOT call `useQuery` / `useMutation` — pass data via props.
|
||||
4. Use the `sx` prop, not styled-components, unless the styling is non-trivial.
|
||||
5. Cover with at least one Jest snapshot test in `__tests__/`.
|
||||
|
||||
---
|
||||
|
||||
## 16. Component vs Section vs Hook decision
|
||||
|
||||
| Question | Where it goes |
|
||||
|---|---|
|
||||
| "I want to render a chip with a delete icon." | `components/` |
|
||||
| "I want a chat sidebar with conversation list + active chat + composer." | `sections/chat/` |
|
||||
| "I want to subscribe to chat updates and keep the cache in sync." | `hooks/use-chat-socket.ts` |
|
||||
|
||||
---
|
||||
|
||||
## 17. Related
|
||||
|
||||
- [[Design System Overview]] · [[Theme Configuration]] · [[Colors]] · [[Typography]]
|
||||
- [[Layouts]] · [[Internationalization & RTL]]
|
||||
- [[Frontend Architecture]] — where these components live in the bigger picture
|
||||
- [[Coding Standards]] — file layout & import order rules
|
||||
179
05 - Design System/Design System Overview.md
Normal file
179
05 - Design System/Design System Overview.md
Normal file
@@ -0,0 +1,179 @@
|
||||
---
|
||||
title: Design System Overview
|
||||
tags: [design-system, ui, mui]
|
||||
created: 2026-05-23
|
||||
---
|
||||
|
||||
# Design System Overview
|
||||
|
||||
The frontend design system is built on **Material-UI v7** with project-specific tokens, an LTR + RTL-aware emotion cache, and a user-controllable settings drawer (mode, layout, color preset, font, direction).
|
||||
|
||||
> [!info]
|
||||
> All design decisions documented here are enforced by the cursor rules at `backend/.cursor/rules/ui-development-standards.mdc`. When in doubt, that file is the authoritative source.
|
||||
|
||||
---
|
||||
|
||||
## 1. Philosophy
|
||||
|
||||
- **Material first, brand second.** Use MUI components and the `sx` prop with array syntax for responsive overrides. Do NOT subclass primitives unless absolutely necessary.
|
||||
- **Tokens over magic numbers.** All colors, spacings, radii, and shadows come from the theme — never hard-code hex values or pixel offsets.
|
||||
- **RTL-first.** Persian and Arabic are first-class. Layouts must mirror correctly without manual `direction` overrides.
|
||||
- **Accessibility-by-default.** WCAG AA contrast (4.5:1), semantic HTML, keyboard navigation, ARIA labels on icon-only buttons.
|
||||
- **One component, one folder.** Each component lives in `components/<name>/` with `index.ts` + `component.tsx` + `classes.ts` + `types.ts` so it can be refactored without grep-and-replace pain.
|
||||
- **Compose, don't inherit.** Sections compose components, components compose primitives, primitives wrap MUI.
|
||||
|
||||
---
|
||||
|
||||
## 2. Component layering
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
P[Pages — src/app/]
|
||||
S[Sections — src/sections/]
|
||||
C[Components — src/components/]
|
||||
L[Layouts — src/layouts/]
|
||||
H[Hooks — src/hooks/]
|
||||
T[Theme — src/theme/]
|
||||
M[MUI v7]
|
||||
|
||||
P --> S
|
||||
P --> L
|
||||
S --> C
|
||||
C --> M
|
||||
L --> C
|
||||
L --> M
|
||||
C --> H
|
||||
S --> H
|
||||
M --> T
|
||||
```
|
||||
|
||||
- **Pages** are thin — they mount a section and pass URL params.
|
||||
- **Sections** are page-specific compositions of components + hooks + queries.
|
||||
- **Components** are reusable, free of business logic.
|
||||
- **Layouts** wrap pages with sidebar / topbar / auth scaffolding.
|
||||
|
||||
---
|
||||
|
||||
## 3. Token system
|
||||
|
||||
The theme exposes these token groups (see [[Theme Configuration]] for exact values):
|
||||
|
||||
| Group | Examples | Used as |
|
||||
|---|---|---|
|
||||
| **Palette** | `primary.main`, `error.dark`, `grey.500`, `background.paper` | `sx={{ color: 'primary.main' }}` |
|
||||
| **Typography** | `h1`-`h6`, `subtitle1/2`, `body1/2`, `caption`, `overline`, `button` | `<Typography variant="h2">` |
|
||||
| **Spacing** | unit = 8 px; use multiples: `spacing(1)` = 8, `spacing(2)` = 16, … | `sx={{ p: 2, mt: 3 }}` |
|
||||
| **Shape** | `borderRadius` = 8 (default), 12 for cards | `sx={{ borderRadius: 1 }}` |
|
||||
| **Shadows** | `shadows[1]`–`shadows[24]` + custom `customShadows` | `sx={{ boxShadow: 1 }}` |
|
||||
| **Breakpoints** | `xs`/`sm`/`md`/`lg`/`xl` = 0/600/900/1200/1536 | `sx={{ flexDirection: { xs: 'column', md: 'row' } }}` |
|
||||
| **Z-index** | named layers: `appBar`, `drawer`, `modal`, `snackbar`, `tooltip` | `sx={{ zIndex: 'modal' }}` |
|
||||
|
||||
The unit of spacing is **8 px**. Never use `padding: '14px'` — use `sx={{ p: 1.75 }}` if you really need it (or, better, fix the value to a multiple).
|
||||
|
||||
---
|
||||
|
||||
## 4. Modes
|
||||
|
||||
The app supports three independent user-controllable axes:
|
||||
|
||||
| Axis | Values | Persistence |
|
||||
|---|---|---|
|
||||
| Mode | `light` · `dark` · `system` | `localStorage.settings.mode` |
|
||||
| Contrast | `default` · `bold` | `localStorage.settings.contrast` |
|
||||
| Layout | `vertical` · `mini` · `horizontal` | `localStorage.settings.layout` |
|
||||
| Direction | `ltr` · `rtl` | `localStorage.settings.direction` (auto-set from locale) |
|
||||
| Color preset | one of N curated palettes | `localStorage.settings.colorPresets` |
|
||||
| Font family | per theme defaults or overridden | `localStorage.settings.fontFamily` |
|
||||
|
||||
Toggled from the **Settings Drawer** (`src/settings/drawer/`). See [[Settings & Theming]].
|
||||
|
||||
---
|
||||
|
||||
## 5. Folder structure
|
||||
|
||||
```
|
||||
frontend/src/
|
||||
├── theme/
|
||||
│ ├── index.ts # createTheme + provider wiring
|
||||
│ ├── options/
|
||||
│ │ ├── palette.ts # Palette tokens (light + dark)
|
||||
│ │ ├── typography.ts # Type scale & families
|
||||
│ │ ├── shadows.ts # Elevation shadows
|
||||
│ │ ├── custom-shadows.ts # Brand-specific glow/elevation
|
||||
│ │ ├── overrides/ # MUI component overrides
|
||||
│ │ └── …
|
||||
│ └── styles/ # Global CSS resets, scrollbar
|
||||
├── settings/
|
||||
│ ├── context/ # SettingsContext provider
|
||||
│ └── drawer/ # The visible drawer UI
|
||||
├── components/ # ~50 reusable components (see below)
|
||||
└── layouts/
|
||||
├── auth-centered/
|
||||
├── auth-split/
|
||||
├── dashboard/ # sidebar + topbar
|
||||
└── main/ # public marketing-style
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Naming & code conventions (recap from cursor rules)
|
||||
|
||||
- File names: kebab-case (`custom-data-grid.tsx`).
|
||||
- Component names: PascalCase (`CustomDataGrid`).
|
||||
- Hook names: `use-*.ts` exporting `useXxx`.
|
||||
- Folder per component with `index.ts` barrel.
|
||||
- Import order: **Styles → Side-effects → Types → External libs → MUI → Internal**.
|
||||
- Forbidden: inline hex colors, `!important`, mixing `style={{}}` with `sx`.
|
||||
- Icons: **Iconify only** (`eva:*`, `solar:*` prefixes preferred).
|
||||
|
||||
---
|
||||
|
||||
## 7. Accessibility checklist
|
||||
|
||||
- All interactive elements reachable with `Tab`.
|
||||
- Focus visible (don't override default outline without replacement).
|
||||
- Icon-only buttons have `aria-label`.
|
||||
- Form fields linked to labels via `htmlFor` or wrapping `<label>`.
|
||||
- Color contrast verified at WCAG AA (`4.5:1` body text, `3:1` large text).
|
||||
- Live regions for snackbars (`aria-live="polite"`).
|
||||
- Skip-to-content link before the navbar.
|
||||
|
||||
---
|
||||
|
||||
## 8. RTL support
|
||||
|
||||
- The MUI emotion cache toggles `stylis-plugin-rtl` when `direction === 'rtl'`.
|
||||
- DataGrid honors a custom Persian locale at `locales/custom-fa-data-grid-locale.ts`.
|
||||
- Icon orientation (chevrons, arrows) is mirrored automatically by stylis-plugin-rtl.
|
||||
- Date pickers use `dayjs` with locale `fa`/`ar` loaded dynamically.
|
||||
|
||||
See [[Internationalization & RTL]] for the full setup.
|
||||
|
||||
---
|
||||
|
||||
## 9. Where to add new visuals
|
||||
|
||||
| Need | Add it to |
|
||||
|---|---|
|
||||
| New brand color | `theme/options/palette.ts` (light + dark) |
|
||||
| New text variant | `theme/options/typography.ts` |
|
||||
| MUI component override (default props, root sx) | `theme/options/overrides/<Component>.ts` |
|
||||
| Reusable widget | `src/components/<kebab-case-name>/` |
|
||||
| Page-specific composition | `src/sections/<feature>/<thing>.tsx` |
|
||||
| Layout variant | `src/layouts/<name>/` |
|
||||
| New language | `src/locales/langs/<code>/` + register in `locales-config.ts` |
|
||||
|
||||
---
|
||||
|
||||
## 10. Related
|
||||
|
||||
- [[Theme Configuration]] — exact MUI theme structure
|
||||
- [[Typography]] — type scale & font loading
|
||||
- [[Colors]] — palette tokens & contrast
|
||||
- [[Components]] — inventory of reusable components
|
||||
- [[Layouts]] — layout patterns
|
||||
- [[Internationalization & RTL]] — i18n + bidi
|
||||
- [[Iconography]] — Iconify usage rules
|
||||
- [[Settings & Theming]] — drawer-driven user prefs
|
||||
- [[Frontend Architecture]] — where this fits in the broader system
|
||||
- [[Coding Standards]] — full cursor-rules reference
|
||||
183
05 - Design System/Iconography.md
Normal file
183
05 - Design System/Iconography.md
Normal file
@@ -0,0 +1,183 @@
|
||||
---
|
||||
title: Iconography
|
||||
tags: [design-system, icons, iconify]
|
||||
created: 2026-05-23
|
||||
---
|
||||
|
||||
# Iconography
|
||||
|
||||
Icons come from **Iconify** — a unified SVG icon library aggregating 100k+ icons from dozens of icon sets, loaded on-demand.
|
||||
|
||||
> [!important]
|
||||
> Per cursor rules (`backend/.cursor/rules/ui-development-standards.mdc`), Iconify is the **only** icon system. Do NOT import from `@mui/icons-material`, `react-icons`, or inline SVG files unless you're building a wholly custom illustration.
|
||||
|
||||
---
|
||||
|
||||
## 1. Library
|
||||
|
||||
Installed as `@iconify/react`. Wrapped by `frontend/src/components/iconify/`:
|
||||
|
||||
```tsx
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
|
||||
<Iconify icon="solar:user-bold" />
|
||||
<Iconify icon="eva:close-fill" width={20} />
|
||||
<Iconify icon="mingcute:check-fill" sx={{ color: 'success.main' }} />
|
||||
```
|
||||
|
||||
Props: `icon` (required), `width` (number or string, default 20), `sx` (any MUI sx), plus standard HTML attributes.
|
||||
|
||||
---
|
||||
|
||||
## 2. Approved icon sets
|
||||
|
||||
Per the cursor rules, **prefer** these prefixes:
|
||||
|
||||
| Prefix | Set | Style | Best for |
|
||||
|---|---|---|---|
|
||||
| `solar:` | Solar Icons | Bold / Line / Linear / Outline | Default everywhere — most consistent |
|
||||
| `eva:` | Eva Icons | Fill / Outline | Compact UI icons (close, more, plus) |
|
||||
| `mingcute:` | MingCute | Fill / Line | Detailed actions |
|
||||
| `mdi:` | Material Design Icons | Many variants | Use only when above sets lack the metaphor |
|
||||
| `simple-icons:` | Brand logos | Monochrome | Social platforms, third-party brands |
|
||||
| `flagpack:` | Country flags | Filled | Language switcher, address country |
|
||||
|
||||
> [!tip]
|
||||
> Pick ONE style per set across the app for visual consistency. The default convention is `solar:*-bold` for filled and `solar:*-line-duotone` for accents.
|
||||
|
||||
---
|
||||
|
||||
## 3. Sizing
|
||||
|
||||
| Size | Use |
|
||||
|---|---|
|
||||
| 16 px | Inline with text (caption, body2) |
|
||||
| 20 px | Inline with body1, navigation items, buttons (default) |
|
||||
| 24 px | Standalone action buttons, primary nav |
|
||||
| 32 px | Empty-state illustrations, large CTAs |
|
||||
| 48 px+ | Hero illustrations (consider Lottie or `react-icons` set built for hero use) |
|
||||
|
||||
Set via `width` (icon is always square):
|
||||
|
||||
```tsx
|
||||
<Iconify icon="solar:bell-bold" width={24} />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Color
|
||||
|
||||
Color comes from the parent's `color` CSS property (Iconify renders with `fill: currentColor`). Use the `sx` prop:
|
||||
|
||||
```tsx
|
||||
<Iconify icon="solar:check-circle-bold" sx={{ color: 'success.main' }} />
|
||||
<Iconify icon="solar:warning-bold" sx={{ color: 'warning.dark' }} />
|
||||
```
|
||||
|
||||
Never hard-code hex — go through palette tokens.
|
||||
|
||||
---
|
||||
|
||||
## 5. Common icon mappings
|
||||
|
||||
A non-exhaustive convention table:
|
||||
|
||||
| Action / Concept | Recommended icon |
|
||||
|---|---|
|
||||
| Search | `solar:magnifer-bold` |
|
||||
| Close | `eva:close-fill` |
|
||||
| Add / Create | `solar:add-circle-bold` or `mingcute:add-fill` |
|
||||
| Edit | `solar:pen-bold` |
|
||||
| Delete | `solar:trash-bin-trash-bold` |
|
||||
| Settings | `solar:settings-bold` |
|
||||
| Notifications | `solar:bell-bold` (badge for unread) |
|
||||
| User / Profile | `solar:user-bold` |
|
||||
| Avatar group | `solar:users-group-rounded-bold` |
|
||||
| Shopping cart | `solar:cart-bold` |
|
||||
| Filter | `solar:filter-bold` |
|
||||
| Sort | `solar:sort-bold` |
|
||||
| More menu | `eva:more-vertical-fill` |
|
||||
| Back | `solar:alt-arrow-left-bold` (auto-flips RTL) |
|
||||
| Forward | `solar:alt-arrow-right-bold` |
|
||||
| Up / Down | `solar:alt-arrow-up-bold` / `solar:alt-arrow-down-bold` |
|
||||
| Success | `solar:check-circle-bold` |
|
||||
| Warning | `solar:danger-bold` |
|
||||
| Error | `solar:close-circle-bold` |
|
||||
| Info | `solar:info-circle-bold` |
|
||||
| Upload | `solar:upload-bold` |
|
||||
| Download | `solar:download-bold` |
|
||||
| Copy | `solar:copy-bold` |
|
||||
| Eye (show/hide password) | `solar:eye-bold` / `solar:eye-closed-bold` |
|
||||
| Wallet | `solar:wallet-money-bold` |
|
||||
| Coin / Token | `solar:dollar-bold`, `mingcute:coin-fill` |
|
||||
| QR code | `solar:qr-code-bold` |
|
||||
| Chat | `solar:chat-round-bold` |
|
||||
| Send (chat) | `solar:plain-bold` |
|
||||
| Attachment | `eva:attach-2-fill` |
|
||||
| Calendar | `solar:calendar-bold` |
|
||||
| Clock | `solar:clock-circle-bold` |
|
||||
| Location | `solar:map-point-bold` |
|
||||
| Phone | `solar:phone-bold` |
|
||||
| Email | `solar:letter-bold` |
|
||||
| Lock | `solar:lock-password-bold` |
|
||||
| Unlock | `solar:lock-unlock-bold` |
|
||||
| Star (rating) | `solar:star-bold` (filled) / `solar:star-linear` (empty) |
|
||||
| Hamburger | `solar:hamburger-menu-bold` |
|
||||
| Refresh | `solar:refresh-bold` |
|
||||
| Logout | `solar:logout-3-bold` |
|
||||
|
||||
---
|
||||
|
||||
## 6. RTL & direction
|
||||
|
||||
Direction-implying icons (chevrons, arrows, swipe) are flipped automatically by `stylis-plugin-rtl` for arrow-class icons. Semantic icons (play, share, send) should NOT flip — wrap them in:
|
||||
|
||||
```tsx
|
||||
<Box sx={{ direction: 'ltr' }}>
|
||||
<Iconify icon="solar:play-bold" />
|
||||
</Box>
|
||||
```
|
||||
|
||||
Or use `transform: 'scaleX(-1)'` on RTL if you specifically need to mirror.
|
||||
|
||||
---
|
||||
|
||||
## 7. Custom SVG (when Iconify is not enough)
|
||||
|
||||
Place SVGs in `public/assets/icons/` or `frontend/src/assets/icons/` and use:
|
||||
|
||||
```tsx
|
||||
import { SvgColor } from 'src/components/svg-color';
|
||||
|
||||
<SvgColor src="/assets/icons/custom-shape.svg" sx={{ color: 'primary.main' }} />
|
||||
```
|
||||
|
||||
`svg-color` uses the mask-image technique so a single-color SVG inherits the parent color, matching Iconify behavior.
|
||||
|
||||
For multi-color illustrations, ship them as separate files in `public/illustrations/` and reference with Next `Image`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Accessibility
|
||||
|
||||
- Icon-only buttons MUST have `aria-label`:
|
||||
```tsx
|
||||
<IconButton aria-label="close"><Iconify icon="eva:close-fill" /></IconButton>
|
||||
```
|
||||
- Icons next to a label are decorative — no aria-label needed.
|
||||
- Live status icons (loading, success) inside a `aria-live="polite"` region announce changes.
|
||||
|
||||
---
|
||||
|
||||
## 9. Performance
|
||||
|
||||
- Iconify loads icons on demand from its CDN (or self-hosted if configured).
|
||||
- Bundle impact is minimal — only the icon component, not the icon data.
|
||||
- For the most-used set (e.g., `solar:*`), consider pre-loading via `@iconify-icons/solar` to remove the CDN dependency.
|
||||
|
||||
---
|
||||
|
||||
## 10. Related
|
||||
|
||||
- [[Design System Overview]] · [[Components]] · [[Colors]]
|
||||
- [[Coding Standards]] — Iconify rule cited
|
||||
276
05 - Design System/Internationalization & RTL.md
Normal file
276
05 - Design System/Internationalization & RTL.md
Normal file
@@ -0,0 +1,276 @@
|
||||
---
|
||||
title: Internationalization & RTL
|
||||
tags: [design-system, i18n, rtl, localization]
|
||||
created: 2026-05-23
|
||||
---
|
||||
|
||||
# Internationalization & RTL
|
||||
|
||||
The frontend supports six languages with full LTR/RTL bidi handling. Configuration lives in `frontend/src/locales/`.
|
||||
|
||||
| Code | Language | Direction | Native name |
|
||||
|---|---|---|---|
|
||||
| `en` | English | LTR | English |
|
||||
| `fa` | Persian (Farsi) | **RTL** | فارسی |
|
||||
| `ar` | Arabic | **RTL** | العربية |
|
||||
| `fr` | French | LTR | Français |
|
||||
| `cn` | Chinese (Simplified) | LTR | 简体中文 |
|
||||
| `vi` | Vietnamese | LTR | Tiếng Việt |
|
||||
|
||||
---
|
||||
|
||||
## 1. Library stack
|
||||
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| `i18next` | Core i18n engine |
|
||||
| `react-i18next` | React bindings (`useTranslation`, `<Trans>`) |
|
||||
| `i18next-browser-languagedetector` | Detect from `navigator.language` / cookie |
|
||||
| `stylis-plugin-rtl` | Auto-flip CSS for RTL |
|
||||
| `dayjs` | Date formatting with locale support |
|
||||
| `@mui/x-date-pickers/AdapterDayjs` | Picker UI honors locale |
|
||||
| `Intl.NumberFormat` (built-in) | Numeric formatting |
|
||||
|
||||
---
|
||||
|
||||
## 2. Setup (`src/locales/locales-config.ts`)
|
||||
|
||||
```ts
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
|
||||
import enCommon from './langs/en/common.json';
|
||||
import faCommon from './langs/fa/common.json';
|
||||
import arCommon from './langs/ar/common.json';
|
||||
// ... fr, cn, vi
|
||||
|
||||
export const allLangs = [
|
||||
{ value: 'en', label: 'English', dir: 'ltr', icon: 'flagpack:gb-ukm' },
|
||||
{ value: 'fa', label: 'فارسی', dir: 'rtl', icon: 'flagpack:ir' },
|
||||
{ value: 'ar', label: 'العربية', dir: 'rtl', icon: 'flagpack:sa' },
|
||||
{ value: 'fr', label: 'Français', dir: 'ltr', icon: 'flagpack:fr' },
|
||||
{ value: 'cn', label: '简体中文', dir: 'ltr', icon: 'flagpack:cn' },
|
||||
{ value: 'vi', label: 'Tiếng Việt', dir: 'ltr', icon: 'flagpack:vn' },
|
||||
];
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources: {
|
||||
en: { common: enCommon },
|
||||
fa: { common: faCommon },
|
||||
ar: { common: arCommon },
|
||||
fr: { common: frCommon },
|
||||
cn: { common: cnCommon },
|
||||
vi: { common: viCommon },
|
||||
},
|
||||
fallbackLng: 'en',
|
||||
defaultNS: 'common',
|
||||
interpolation: { escapeValue: false },
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Translation files
|
||||
|
||||
```
|
||||
src/locales/langs/
|
||||
├── en/common.json
|
||||
├── fa/common.json
|
||||
├── ar/common.json
|
||||
├── fr/common.json
|
||||
├── cn/common.json
|
||||
└── vi/common.json
|
||||
```
|
||||
|
||||
Convention: one file per **namespace**. `common.json` is the default; split if any namespace exceeds ~500 keys. Examples (key paths):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"actions": { "save": "Save", "cancel": "Cancel", "delete": "Delete" },
|
||||
"auth": { "signIn": "Sign in", "signOut": "Sign out", "rememberMe": "Remember me" },
|
||||
"marketplace": {
|
||||
"request": {
|
||||
"create": "Create a request",
|
||||
"openOffers": "{{count}} open offer",
|
||||
"openOffers_plural": "{{count}} open offers"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Usage in components
|
||||
|
||||
```tsx
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function CreateButton() {
|
||||
const { t } = useTranslation();
|
||||
return <Button>{t('marketplace.request.create')}</Button>;
|
||||
}
|
||||
```
|
||||
|
||||
For sentences with embedded React (link, bold) use `<Trans>`:
|
||||
|
||||
```tsx
|
||||
<Trans i18nKey="legal.terms">
|
||||
By signing up you agree to our <Link href="/terms">Terms</Link>.
|
||||
</Trans>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Direction (LTR / RTL)
|
||||
|
||||
Triggered by the active locale (`dir: 'rtl'` for `fa` / `ar`).
|
||||
|
||||
### 5.1 Cache swap
|
||||
|
||||
```tsx
|
||||
const cacheKey = direction === 'rtl' ? 'css-rtl' : 'css';
|
||||
const stylisPlugins = direction === 'rtl' ? [rtlPlugin] : [];
|
||||
|
||||
<AppRouterCacheProvider options={{ key: cacheKey, prepend: true, stylisPlugins }}>
|
||||
{children}
|
||||
</AppRouterCacheProvider>
|
||||
```
|
||||
|
||||
> [!warning]
|
||||
> The cache key **must differ** between modes. Sharing the key causes stale CSS to linger when direction changes at runtime.
|
||||
|
||||
### 5.2 Theme
|
||||
|
||||
```ts
|
||||
const theme = createTheme({ direction, ... });
|
||||
```
|
||||
|
||||
### 5.3 `<html dir>`
|
||||
|
||||
The root layout sets `<html lang={locale} dir={direction}>`. Some screen readers and form behaviors (e.g., number input flow) rely on this attribute.
|
||||
|
||||
---
|
||||
|
||||
## 6. RTL CSS rules
|
||||
|
||||
| Don't | Do |
|
||||
|---|---|
|
||||
| `margin-left: 8px` | `marginInlineStart: 1` (or `ml: 1` — MUI's `sx` translates) |
|
||||
| `padding-right: 16px` | `paddingInlineEnd: 2` |
|
||||
| `text-align: left` | `textAlign: 'start'` |
|
||||
| `float: left` | `float: 'inline-start'` (or refactor) |
|
||||
| `border-left: 2px` | `borderInlineStart: 2` |
|
||||
| `direction: ltr` hardcoded | inherit from html unless intentional (e.g., code blocks always LTR) |
|
||||
|
||||
Icons that imply direction (chevrons, arrows) are auto-flipped by `stylis-plugin-rtl`. If an icon is **semantic** (e.g., a play button), wrap it in:
|
||||
|
||||
```tsx
|
||||
<Box sx={{ direction: 'ltr' }}>
|
||||
<Iconify icon="solar:play-bold" />
|
||||
</Box>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Date & time formatting
|
||||
|
||||
```tsx
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/fa';
|
||||
dayjs.locale('fa');
|
||||
|
||||
dayjs('2026-05-23').format('LL'); // → "۲ خرداد ۱۴۰۵"
|
||||
```
|
||||
|
||||
Use the `Format` helpers from `components/...` for consistent display patterns:
|
||||
|
||||
| Helper | Output |
|
||||
|---|---|
|
||||
| `fDate(d)` | locale-aware short date |
|
||||
| `fDateTime(d)` | date + time |
|
||||
| `fTime(d)` | time only |
|
||||
| `fToNow(d)` | "3 hours ago" |
|
||||
| `fIsBetween(start, end)` | boolean window check |
|
||||
|
||||
---
|
||||
|
||||
## 8. Number & currency formatting
|
||||
|
||||
```ts
|
||||
import { fNumber, fCurrency, fPercent } from '@/locales/utils/number-format-locale';
|
||||
|
||||
fNumber(1234567); // "1,234,567" / "۱٬۲۳۴٬۵۶۷"
|
||||
fCurrency(99.5, 'USD'); // "$99.50" / "۹۹٫۵۰ دلار"
|
||||
fPercent(0.123); // "12.3%"
|
||||
```
|
||||
|
||||
Underneath, these wrap `Intl.NumberFormat` with the active locale, falling back to `en-US` when the formatter doesn't support a locale (e.g., older browsers + `fa`).
|
||||
|
||||
---
|
||||
|
||||
## 9. DataGrid Farsi locale
|
||||
|
||||
`src/locales/custom-fa-data-grid-locale.ts` exports a Persian translation map for MUI X DataGrid (column menu, pagination labels, filter operators, etc.). Wired in via:
|
||||
|
||||
```tsx
|
||||
<DataGrid
|
||||
localeText={locale === 'fa' ? customFaDataGrid : undefined}
|
||||
...
|
||||
/>
|
||||
```
|
||||
|
||||
For Arabic, the built-in MUI `arSD` locale works.
|
||||
|
||||
---
|
||||
|
||||
## 10. Language switcher UI
|
||||
|
||||
In the topbar, a chip with the flag + native name opens a popover listing all `allLangs`. On click:
|
||||
|
||||
1. `i18n.changeLanguage(code)`
|
||||
2. `settings.onUpdate('direction', dir)` ← persists into settings context
|
||||
3. Page does NOT reload — the cache + theme + provider all react reactively.
|
||||
|
||||
---
|
||||
|
||||
## 11. Adding a new language
|
||||
|
||||
1. Create `src/locales/langs/<code>/common.json` with the same key set as `en/common.json` (use `i18next-parser` to identify missing keys).
|
||||
2. Register in `allLangs` array in `locales-config.ts`.
|
||||
3. Add `dayjs` locale import if it differs from the existing set.
|
||||
4. Verify number formatter outputs on a sample page.
|
||||
5. If RTL, add `direction: 'rtl'` and **test bidi** thoroughly.
|
||||
|
||||
---
|
||||
|
||||
## 12. Translation workflow
|
||||
|
||||
- Source of truth: `en/common.json` (English).
|
||||
- For new strings, add to `en/` first, then translate to other locales (manual or via Crowdin / Lokalise integration).
|
||||
- Lint check: `i18next-parser` can compare locale files for missing keys.
|
||||
|
||||
---
|
||||
|
||||
## 13. Common pitfalls
|
||||
|
||||
| Pitfall | Fix |
|
||||
|---|---|
|
||||
| Hardcoded English in JSX | Move to `common.json` |
|
||||
| `align="left"` on a Box | Use `textAlign="start"` |
|
||||
| `marginLeft` instead of `ml` shorthand | Use shorthand (auto-RTL) |
|
||||
| Forgot to load Persian font | Add to `theme/options/typography.ts` |
|
||||
| Date showing as English digits in fa | Forgot `dayjs.locale('fa')` |
|
||||
| Numbers in RTL appearing reversed | Wrap in `<bdi>` |
|
||||
|
||||
---
|
||||
|
||||
## 14. Related
|
||||
|
||||
- [[Design System Overview]] · [[Theme Configuration]] · [[Typography]]
|
||||
- [[Settings & Theming]] — direction toggle
|
||||
- [[Frontend Architecture]] — provider tree
|
||||
- [[Roles & Personas]] — locale defaults per role (admin defaults English, buyers can be Persian)
|
||||
217
05 - Design System/Layouts.md
Normal file
217
05 - Design System/Layouts.md
Normal file
@@ -0,0 +1,217 @@
|
||||
---
|
||||
title: Layouts
|
||||
tags: [design-system, layout, ui]
|
||||
created: 2026-05-23
|
||||
---
|
||||
|
||||
# Layouts
|
||||
|
||||
Page-template wrappers in `frontend/src/layouts/`. Each layout encapsulates a header, optional sidebar, content slot, and footer — pages mount inside one and never re-implement chrome.
|
||||
|
||||
---
|
||||
|
||||
## 1. Layout variants
|
||||
|
||||
| Layout | Used for | Header | Sidebar | Footer |
|
||||
|---|---|---|---|---|
|
||||
| `auth-centered/` | Sign-in, sign-up, verify, reset-password | Logo only | none | Compact links |
|
||||
| `auth-split/` | Marketing-leaning auth (sign-up with promo panel) | Logo only | Left visual panel | none |
|
||||
| `dashboard/` | Every `/dashboard/*` route | Topbar with user menu + notifications + language + settings | Vertical / Mini / Horizontal | none |
|
||||
| `main/` | Public marketing pages (`shop/`, `post/`, landing) | Marketing nav | none | Full footer |
|
||||
|
||||
---
|
||||
|
||||
## 2. Dashboard layout
|
||||
|
||||
`layouts/dashboard/` is the workhorse. It uses MUI's `LayoutSection` pattern — slots for header, sidebar, main, footer with media-query-aware responsive behavior.
|
||||
|
||||
### 2.1 Anatomy
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ TopBar ───────────────────────── search bell ▾ │ ← `header-section`
|
||||
├─────────┬───────────────────────────────────────────┤
|
||||
│ Nav │ Breadcrumbs │
|
||||
│ │ ┌─ Page content (mounted route) ──────┐ │
|
||||
│ ┌───┐ │ │ │ │
|
||||
│ │ ○ │ │ │ │ │
|
||||
│ └───┘ │ │ │ │
|
||||
│ user │ │ │ │
|
||||
│ profile│ │ │ │
|
||||
├─────────┴───────────────────────────────────────────┤
|
||||
│ (optional) Footer slot — unused in dashboard │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.2 Sidebar variants
|
||||
|
||||
User can switch via the **Settings Drawer**:
|
||||
|
||||
| Variant | Width | Behavior |
|
||||
|---|---|---|
|
||||
| `vertical` | 280 px | Default; icon + label, collapsible groups |
|
||||
| `mini` | 88 px | Icons only; labels in tooltip on hover |
|
||||
| `horizontal` | full-width below header | Nav as a horizontal menu — main content below |
|
||||
|
||||
### 2.3 Topbar
|
||||
|
||||
Contains, left-to-right:
|
||||
|
||||
- Sidebar toggle button (mobile / mini-mode)
|
||||
- Logo (mini, shown only when sidebar is collapsed)
|
||||
- Search (Cmd+K palette) — optional
|
||||
- Language selector (chip with flag)
|
||||
- Settings drawer toggle (cog icon)
|
||||
- Notifications drawer toggle (bell with unread count badge)
|
||||
- Account dropdown (avatar → profile, sign-out)
|
||||
|
||||
### 2.4 Nav configuration
|
||||
|
||||
Nav items defined as a tree in a config file (e.g., `layouts/dashboard/config-nav-dashboard.ts`):
|
||||
|
||||
```ts
|
||||
const navData = [
|
||||
{
|
||||
subheader: 'Overview',
|
||||
items: [
|
||||
{ title: 'Dashboard', path: paths.dashboard.root, icon: ICONS.dashboard },
|
||||
{ title: 'Chat', path: paths.dashboard.chat, icon: ICONS.chat },
|
||||
],
|
||||
},
|
||||
{
|
||||
subheader: 'Marketplace',
|
||||
items: [
|
||||
{ title: 'Requests', path: paths.dashboard.request.root, icon: ICONS.request,
|
||||
roles: ['buyer', 'admin'] },
|
||||
{ title: 'Templates', path: paths.dashboard.template.root, icon: ICONS.template,
|
||||
roles: ['seller', 'admin'] },
|
||||
// ...
|
||||
],
|
||||
},
|
||||
// ...
|
||||
];
|
||||
```
|
||||
|
||||
Each `items[i]` may carry `roles: string[]` to scope visibility. The nav renderer filters items based on the current user's role before rendering — entries without `roles` are shown to everyone.
|
||||
|
||||
### 2.5 Breadcrumbs
|
||||
|
||||
`components/custom-breadcrumbs` renders the active route trail plus an optional action button slot (e.g., "+ New Request"). Convention: set via the page component.
|
||||
|
||||
---
|
||||
|
||||
## 3. Auth layouts
|
||||
|
||||
### 3.1 `auth-centered/`
|
||||
|
||||
Single column, centered card. Used by sign-in, password reset, email verification.
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ Logo │
|
||||
│ │
|
||||
│ ┌────────────────────┐ │
|
||||
│ │ Title │ │
|
||||
│ │ Form fields... │ │
|
||||
│ │ CTA │ │
|
||||
│ └────────────────────┘ │
|
||||
│ │
|
||||
│ Tiny footer links │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 `auth-split/`
|
||||
|
||||
Two-column on `md+`, single column on small screens. Left = visual / brand panel; right = form.
|
||||
|
||||
```
|
||||
┌──────────────┬───────────────┐
|
||||
│ │ Logo │
|
||||
│ Visual │ │
|
||||
│ panel │ Form │
|
||||
│ (image, │ │
|
||||
│ quote, │ CTA │
|
||||
│ benefit) │ │
|
||||
│ │ Footer │
|
||||
└──────────────┴───────────────┘
|
||||
```
|
||||
|
||||
Used for sign-up to balance marketing copy with the form.
|
||||
|
||||
---
|
||||
|
||||
## 4. Main (public) layout
|
||||
|
||||
`layouts/main/` is for unauthenticated visitor pages. Sticky header, then content, then full footer.
|
||||
|
||||
Header includes:
|
||||
|
||||
- Logo
|
||||
- Top-level nav (Shop, Blog, About)
|
||||
- Language switcher
|
||||
- "Sign in" / "Sign up" buttons (or "Dashboard" if signed in)
|
||||
|
||||
Footer includes:
|
||||
|
||||
- Brand block
|
||||
- Sitemap
|
||||
- Legal links
|
||||
- Social media
|
||||
- Newsletter signup (optional)
|
||||
|
||||
---
|
||||
|
||||
## 5. Responsive behavior
|
||||
|
||||
| Breakpoint | xs (<600) | sm (≥600) | md (≥900) | lg (≥1200) | xl (≥1536) |
|
||||
|---|---|---|---|---|---|
|
||||
| Dashboard sidebar | drawer overlay | drawer overlay | mini default | full default | full default |
|
||||
| Auth-split | single col | single col | two col | two col | two col |
|
||||
| Main nav | hamburger | hamburger | inline | inline | inline |
|
||||
| Page padding | 16 px | 16 px | 24 px | 32 px | 32 px |
|
||||
|
||||
Implementation note: do NOT add `@media` rules in components. Use the responsive `sx` syntax:
|
||||
|
||||
```tsx
|
||||
<Box sx={{ p: { xs: 2, md: 3, lg: 4 } }} />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Sticky elements
|
||||
|
||||
- TopBar uses `position: sticky; top: 0; z-index: appBar` so it stays on scroll.
|
||||
- Action bars on long forms can use the same pattern at bottom (`bottom: 0`).
|
||||
|
||||
---
|
||||
|
||||
## 7. Skeleton & loading
|
||||
|
||||
- Each `dashboard/*/page.tsx` can ship a sibling `loading.tsx` that renders skeleton placeholders matching the final layout.
|
||||
- For long-loading sections, use `<Suspense fallback={<SkeletonX />}>`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Error boundaries
|
||||
|
||||
- App-level error boundary at root layout catches uncaught render errors.
|
||||
- Each route segment may ship `error.tsx` for scoped recovery (e.g., `dashboard/shops/error.tsx`).
|
||||
- Snackbars handle non-fatal API errors.
|
||||
|
||||
---
|
||||
|
||||
## 9. Adding a new layout
|
||||
|
||||
1. Create `layouts/<name>/` with `index.tsx` (default export the layout component).
|
||||
2. Slot pattern: accept `children`; render header + nav + main + (optional) footer.
|
||||
3. Reuse `components/scrollbar` for the main scroll area to keep custom scrollbar styling.
|
||||
4. If it's an auth flow, wire it into `app/auth/<flow>/layout.tsx`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Related
|
||||
|
||||
- [[Design System Overview]] · [[Theme Configuration]] · [[Components]]
|
||||
- [[Frontend Architecture]] — where layouts sit in the tree
|
||||
- [[Roles & Personas]] — role-driven nav filtering
|
||||
- [[Settings & Theming]] — layout variant switcher
|
||||
222
05 - Design System/Settings & Theming.md
Normal file
222
05 - Design System/Settings & Theming.md
Normal file
@@ -0,0 +1,222 @@
|
||||
---
|
||||
title: Settings & Theming
|
||||
tags: [design-system, settings, theming]
|
||||
created: 2026-05-23
|
||||
---
|
||||
|
||||
# Settings & Theming
|
||||
|
||||
A drawer-based UI lets the end user toggle visual preferences. Settings persist in `localStorage` and rebuild the MUI theme on the fly.
|
||||
|
||||
> [!info]
|
||||
> Implementation: `frontend/src/settings/context/` (state) + `frontend/src/settings/drawer/` (UI) + `frontend/src/components/settings/` (helpers).
|
||||
|
||||
---
|
||||
|
||||
## 1. What's user-controllable
|
||||
|
||||
| Axis | Values | Default | Persisted |
|
||||
|---|---|---|---|
|
||||
| **Mode** | `light` · `dark` · `system` | `system` | localStorage |
|
||||
| **Contrast** | `default` · `bold` | `default` | localStorage |
|
||||
| **Layout** | `vertical` · `mini` · `horizontal` | `vertical` | localStorage |
|
||||
| **Direction** | `ltr` · `rtl` | derived from locale | localStorage (overrides locale default) |
|
||||
| **Color preset** | one of `default`, `purple`, `cyan`, `blue`, `orange`, `red` | `default` | localStorage |
|
||||
| **Font family** | `Public Sans Variable`, `DM Sans Variable`, `Inter Variable`, `Nunito Sans Variable` | `Public Sans Variable` | localStorage |
|
||||
| **Compact navigation** | boolean | `false` | localStorage |
|
||||
| **Border radius** | 0–24 | 8 | localStorage |
|
||||
| **Stretched container** | boolean | `false` | localStorage |
|
||||
|
||||
---
|
||||
|
||||
## 2. State shape
|
||||
|
||||
```ts
|
||||
// frontend/src/settings/types.ts (approx)
|
||||
export interface Settings {
|
||||
mode: 'light' | 'dark' | 'system';
|
||||
contrast: 'default' | 'bold';
|
||||
layout: 'vertical' | 'mini' | 'horizontal';
|
||||
direction: 'ltr' | 'rtl';
|
||||
colorPreset: string;
|
||||
fontFamily: string;
|
||||
compactLayout: boolean;
|
||||
primaryColor?: string;
|
||||
stretch: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
Stored in a single `localStorage` key (`settings` or `settings-key`). The context provider hydrates on mount, falls back to defaults if absent.
|
||||
|
||||
---
|
||||
|
||||
## 3. Context API
|
||||
|
||||
```ts
|
||||
// frontend/src/settings/context/use-settings.ts (approx)
|
||||
export interface SettingsContext {
|
||||
state: Settings;
|
||||
canReset: boolean;
|
||||
onReset(): void;
|
||||
onUpdate<K extends keyof Settings>(key: K, value: Settings[K]): void;
|
||||
onUpdateField<K extends keyof Settings>(key: K, value: Settings[K]): void;
|
||||
openDrawer: boolean;
|
||||
onToggleDrawer(): void;
|
||||
}
|
||||
|
||||
const { state, onUpdate, onToggleDrawer } = useSettings();
|
||||
```
|
||||
|
||||
Use `onUpdate` to change any value. The context fires a re-render that:
|
||||
1. Updates localStorage.
|
||||
2. Triggers a new `buildTheme()` invocation up at `ThemeProvider`.
|
||||
3. Components re-render with the new theme tokens.
|
||||
|
||||
---
|
||||
|
||||
## 4. Drawer UI
|
||||
|
||||
The drawer (`settings/drawer/SettingsDrawer.tsx`) is a right-side `MuiDrawer` with sections:
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ Settings [reset] │
|
||||
├──────────────────────────────┤
|
||||
│ Mode [☀] [🌙] [⌐] │
|
||||
│ │
|
||||
│ Contrast ◯ Default ◯ Bold │
|
||||
│ │
|
||||
│ Direction ⬅ LTR ➡ RTL │
|
||||
│ │
|
||||
│ Layout ▤ Vertical │
|
||||
│ ▣ Mini │
|
||||
│ ━ Horizontal │
|
||||
│ │
|
||||
│ Color [●●●●●●] │
|
||||
│ │
|
||||
│ Font [Public Sans ▼] │
|
||||
│ │
|
||||
│ Border radius [—•—] 8 │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
Each section uses the `BlockOption` helper component for consistent styling.
|
||||
|
||||
---
|
||||
|
||||
## 5. Wiring at the root
|
||||
|
||||
```tsx
|
||||
// frontend/src/app/layout.tsx (simplified)
|
||||
<SettingsProvider defaultSettings={defaults}>
|
||||
<ThemeProviderFromSettings>
|
||||
{children}
|
||||
<SettingsDrawer />
|
||||
</ThemeProviderFromSettings>
|
||||
</SettingsProvider>
|
||||
```
|
||||
|
||||
`ThemeProviderFromSettings` reads the settings context and rebuilds the theme on every change.
|
||||
|
||||
---
|
||||
|
||||
## 6. Locale ↔ direction coupling
|
||||
|
||||
By default, switching to `fa` / `ar` flips `direction: 'rtl'`. The user CAN override (a Persian-speaker on a desktop might prefer LTR sometimes).
|
||||
|
||||
```ts
|
||||
// In a settings change handler:
|
||||
onUpdate('direction', i18n.language === 'fa' ? 'rtl' : 'ltr');
|
||||
```
|
||||
|
||||
Or let the user pin direction independently — saved direction wins over locale-derived direction.
|
||||
|
||||
---
|
||||
|
||||
## 7. Mode = `system`
|
||||
|
||||
When `mode === 'system'`:
|
||||
|
||||
```ts
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const effectiveMode = prefersDark ? 'dark' : 'light';
|
||||
```
|
||||
|
||||
Subscribe to changes:
|
||||
|
||||
```ts
|
||||
useEffect(() => {
|
||||
if (settings.mode !== 'system') return;
|
||||
const mql = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const onChange = () => forceUpdate();
|
||||
mql.addEventListener('change', onChange);
|
||||
return () => mql.removeEventListener('change', onChange);
|
||||
}, [settings.mode]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Color presets
|
||||
|
||||
Defined in `frontend/src/theme/options/presets/` or similar. Each preset only swaps `primary` (and optionally `secondary`) — `grey`, semantic colors, and background stay constant so layouts feel consistent across presets.
|
||||
|
||||
Adding a preset:
|
||||
|
||||
1. Add a `<name>.ts` exporting `{ lighter, light, main, dark, darker, contrastText }`.
|
||||
2. Register in `colorPresets` map.
|
||||
3. Add a swatch entry in the drawer's color picker block.
|
||||
|
||||
---
|
||||
|
||||
## 9. Font preset
|
||||
|
||||
Switching the font family applies at the theme level:
|
||||
|
||||
```ts
|
||||
typography: { fontFamily: settings.fontFamily, ... }
|
||||
```
|
||||
|
||||
If a font isn't bundled, dynamically `import('@fontsource-variable/<name>')` first to avoid FOUC.
|
||||
|
||||
---
|
||||
|
||||
## 10. Compact / stretched layout
|
||||
|
||||
| Setting | Effect |
|
||||
|---|---|
|
||||
| `compactLayout: true` | Reduces top spacing / padding on dashboard pages |
|
||||
| `stretch: true` | Removes the centered max-width on content (full-bleed) |
|
||||
|
||||
---
|
||||
|
||||
## 11. Border radius
|
||||
|
||||
Slider exposed as 0 → 24. Affects `theme.shape.borderRadius`, cascading to every component using `sx={{ borderRadius: 1 }}` semantics.
|
||||
|
||||
---
|
||||
|
||||
## 12. Reset to defaults
|
||||
|
||||
`onReset()` clears localStorage and re-hydrates with the default object. `canReset` is true when current state differs from defaults — used to enable/disable the Reset button.
|
||||
|
||||
---
|
||||
|
||||
## 13. Hydration mismatch hazard
|
||||
|
||||
Because settings live in `localStorage`, the server render can't know them. The provider implements a 2-pass strategy:
|
||||
|
||||
1. First render uses **defaults** (matches what the server emits → no hydration mismatch).
|
||||
2. After mount, the provider reads localStorage and re-renders with the user's settings.
|
||||
|
||||
Brief flash possible. To mitigate, either:
|
||||
- Suppress the first paint (split layout into client-only).
|
||||
- Or set the user's settings into a cookie at sign-in so server can pre-render correctly.
|
||||
|
||||
---
|
||||
|
||||
## 14. Related
|
||||
|
||||
- [[Design System Overview]] · [[Theme Configuration]] · [[Colors]] · [[Typography]]
|
||||
- [[Layouts]] — layout variants drive sidebar
|
||||
- [[Internationalization & RTL]] — direction coupling
|
||||
- [[Frontend Architecture]] — provider tree
|
||||
250
05 - Design System/Theme Configuration.md
Normal file
250
05 - Design System/Theme Configuration.md
Normal file
@@ -0,0 +1,250 @@
|
||||
---
|
||||
title: Theme Configuration
|
||||
tags: [design-system, theme, mui]
|
||||
created: 2026-05-23
|
||||
---
|
||||
|
||||
# Theme Configuration
|
||||
|
||||
The MUI theme is constructed in `frontend/src/theme/index.ts` and composed from option modules in `frontend/src/theme/options/`. The resulting theme is provided at the root layout, wrapped by an RTL-aware emotion cache.
|
||||
|
||||
---
|
||||
|
||||
## 1. Construction pipeline
|
||||
|
||||
```ts
|
||||
// approximate — read theme/index.ts for the canonical version
|
||||
import { createTheme } from '@mui/material/styles';
|
||||
import { palette } from './options/palette';
|
||||
import { typography } from './options/typography';
|
||||
import { shadows, customShadows } from './options/shadows';
|
||||
import { componentsOverrides } from './options/overrides';
|
||||
|
||||
export function buildTheme(opts: { mode: 'light' | 'dark'; direction: 'ltr' | 'rtl'; preset: string; }) {
|
||||
return createTheme({
|
||||
direction: opts.direction,
|
||||
palette: palette(opts.mode, opts.preset),
|
||||
typography,
|
||||
shape: { borderRadius: 8 },
|
||||
shadows: shadows(opts.mode),
|
||||
customShadows: customShadows(opts.mode),
|
||||
components: componentsOverrides(opts),
|
||||
breakpoints: {
|
||||
values: { xs: 0, sm: 600, md: 900, lg: 1200, xl: 1536 },
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
The settings context calls `buildTheme()` whenever any axis changes, then re-mounts `<ThemeProvider>`. Because MUI v7 supports CSS variables, the swap is cheap (no React tree thrash).
|
||||
|
||||
---
|
||||
|
||||
## 2. Provider wiring (root layout)
|
||||
|
||||
```tsx
|
||||
// frontend/src/app/layout.tsx (simplified)
|
||||
<AppRouterCacheProvider options={{ key: dir === 'rtl' ? 'css-rtl' : 'css', prepend: true, stylisPlugins: dir === 'rtl' ? [rtlPlugin] : [] }}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
||||
<I18nProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SocketProvider>
|
||||
<SnackbarProvider>{children}</SnackbarProvider>
|
||||
</SocketProvider>
|
||||
</QueryClientProvider>
|
||||
</I18nProvider>
|
||||
</LocalizationProvider>
|
||||
</ThemeProvider>
|
||||
</AppRouterCacheProvider>
|
||||
```
|
||||
|
||||
> [!warning]
|
||||
> The cache `key` MUST differ between LTR and RTL (`'css'` vs `'css-rtl'`) — otherwise switching direction at runtime keeps the previous direction's CSS in the head and the layout breaks.
|
||||
|
||||
---
|
||||
|
||||
## 3. Palette
|
||||
|
||||
See [[Colors]] for the full table. Structure:
|
||||
|
||||
```ts
|
||||
{
|
||||
mode: 'light' | 'dark',
|
||||
primary: { lighter, light, main, dark, darker, contrastText },
|
||||
secondary: { lighter, light, main, dark, darker, contrastText },
|
||||
info: { lighter, light, main, dark, darker, contrastText },
|
||||
success: { lighter, light, main, dark, darker, contrastText },
|
||||
warning: { lighter, light, main, dark, darker, contrastText },
|
||||
error: { lighter, light, main, dark, darker, contrastText },
|
||||
grey: { 100…900 },
|
||||
text: { primary, secondary, disabled },
|
||||
background:{ default, paper, neutral },
|
||||
action: { active, hover, selected, focus, disabled, disabledBackground },
|
||||
}
|
||||
```
|
||||
|
||||
Color presets (selectable in settings) swap the `primary` + `secondary` color groups while leaving `grey`, `error`, etc. untouched.
|
||||
|
||||
---
|
||||
|
||||
## 4. Typography
|
||||
|
||||
```ts
|
||||
{
|
||||
fontFamily: '"Public Sans Variable", "Helvetica", "Arial", sans-serif',
|
||||
fontWeightRegular: 400,
|
||||
fontWeightMedium: 500,
|
||||
fontWeightSemiBold: 600,
|
||||
fontWeightBold: 700,
|
||||
h1: { fontSize: 64, lineHeight: 80/64, letterSpacing: -1 },
|
||||
h2: { fontSize: 48, lineHeight: 64/48 },
|
||||
h3: { fontSize: 32, lineHeight: 48/32 },
|
||||
h4: { fontSize: 24, lineHeight: 36/24 },
|
||||
h5: { fontSize: 20, lineHeight: 30/20 },
|
||||
h6: { fontSize: 18, lineHeight: 28/18 },
|
||||
subtitle1: { fontSize: 16, fontWeight: 600 },
|
||||
subtitle2: { fontSize: 14, fontWeight: 600 },
|
||||
body1: { fontSize: 16 },
|
||||
body2: { fontSize: 14 },
|
||||
caption: { fontSize: 12 },
|
||||
overline: { fontSize: 12, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 1.1 },
|
||||
button: { fontSize: 14, fontWeight: 700, textTransform: 'unset' },
|
||||
}
|
||||
```
|
||||
|
||||
Variants `h1` and `h2` scale down responsively. Use the `responsiveFontSizes(theme)` helper if you need automatic scaling.
|
||||
|
||||
See [[Typography]] for font loading, secondary font (Barlow), and i18n font fallbacks.
|
||||
|
||||
---
|
||||
|
||||
## 5. Spacing
|
||||
|
||||
```ts
|
||||
spacing: 8 // unit
|
||||
// theme.spacing(1) === '8px'
|
||||
// theme.spacing(2) === '16px'
|
||||
// theme.spacing(0.5) === '4px'
|
||||
```
|
||||
|
||||
Use the shorthand props on the `sx`:
|
||||
|
||||
```tsx
|
||||
<Box sx={{ p: 2, mt: 3, mx: { xs: 1, md: 4 } }} />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Shape & radii
|
||||
|
||||
```ts
|
||||
shape: { borderRadius: 8 } // default
|
||||
// cards typically: borderRadius: 16
|
||||
// pills: borderRadius: 9999
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Shadows
|
||||
|
||||
MUI's default `shadows` array (25 levels: `none`, then `1`–`24`) is replaced with a softer custom set. Additionally, `customShadows` defines brand-specific tinted shadows used by the `Card`, `Button`, `Dialog` overrides:
|
||||
|
||||
```ts
|
||||
customShadows: {
|
||||
z1, z4, z8, z12, z16, z20, z24,
|
||||
primary: 'rgba(<primary.main>, 0.24) 0 8 16 0',
|
||||
secondary: '…',
|
||||
info: '…',
|
||||
success: '…',
|
||||
warning: '…',
|
||||
error: '…',
|
||||
card: 'rgba(0,0,0,0.04) 0 0 2 0, rgba(0,0,0,0.08) 0 12 24 -4',
|
||||
dialog: '…',
|
||||
dropdown: '…',
|
||||
}
|
||||
```
|
||||
|
||||
Read inside `sx`:
|
||||
|
||||
```tsx
|
||||
sx={{ boxShadow: (theme) => theme.customShadows.z8 }}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Component overrides
|
||||
|
||||
`theme/options/overrides/` contains one file per overridden component, each exporting `{ defaultProps, styleOverrides }` merged into `components`:
|
||||
|
||||
| Component | Notable override |
|
||||
|---|---|
|
||||
| `MuiButton` | `disableRipple`, custom `customShadows.primary` on contained variant |
|
||||
| `MuiCard` | `borderRadius: 16`, `customShadows.card` |
|
||||
| `MuiPaper` | elevation flattened to `0` by default (use `card` shadow explicitly) |
|
||||
| `MuiTextField` | `variant: 'outlined'` default, custom focus ring |
|
||||
| `MuiTooltip` | dark background regardless of mode, smaller padding |
|
||||
| `MuiAvatar` | letter-based fallback with hashed background color |
|
||||
| `MuiTableCell` | denser padding, header weight 600 |
|
||||
| `MuiDialog` | `customShadows.dialog`, max-width responsive |
|
||||
| `MuiAlert` | tinted background using palette `lighter` |
|
||||
| `MuiTab` | uppercase off, color from `primary.main` when selected |
|
||||
| `MuiAutocomplete` | custom popup paper shadow |
|
||||
|
||||
Add an override:
|
||||
|
||||
1. Create `theme/options/overrides/<Component>.ts` exporting a function `(theme) => ({...})`.
|
||||
2. Register it in `theme/options/overrides/index.ts`.
|
||||
3. It will be merged into the next `createTheme` call automatically.
|
||||
|
||||
---
|
||||
|
||||
## 9. CSS variables (MUI v7)
|
||||
|
||||
`createTheme` with `cssVariables: true` (recommended) emits CSS custom properties at the `<html>` root and switches them per mode without re-rendering. The default is on in v7 — verify in `theme/index.ts`.
|
||||
|
||||
This means a custom CSS rule can reference:
|
||||
|
||||
```css
|
||||
.my-thing {
|
||||
background: var(--mui-palette-primary-main);
|
||||
padding: calc(var(--mui-spacing) * 2);
|
||||
}
|
||||
```
|
||||
|
||||
Use this sparingly — prefer the `sx` prop.
|
||||
|
||||
---
|
||||
|
||||
## 10. Per-locale font swap
|
||||
|
||||
When the active locale is `fa` or `ar`, the typography option module can layer a Persian/Arabic font (e.g., **Vazirmatn**, **IRANSans**) ahead of `Public Sans Variable`:
|
||||
|
||||
```ts
|
||||
fontFamily: dir === 'rtl'
|
||||
? '"Vazirmatn", "Public Sans Variable", sans-serif'
|
||||
: '"Public Sans Variable", sans-serif',
|
||||
```
|
||||
|
||||
Otherwise Latin glyphs still render via the Public Sans fallback. See [[Internationalization & RTL]].
|
||||
|
||||
---
|
||||
|
||||
## 11. Customisation checklist
|
||||
|
||||
- [ ] New brand color → update `palette.ts` (light + dark) and consider a preset.
|
||||
- [ ] New variant → add to typography options and the `TypographyOptions` interface augmentation.
|
||||
- [ ] Component override → file in `overrides/`.
|
||||
- [ ] Verify in both modes (light + dark) AND both directions (ltr + rtl).
|
||||
- [ ] Verify against WCAG AA contrast.
|
||||
- [ ] Snapshot test the component if visual stability matters.
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- [[Design System Overview]]
|
||||
- [[Colors]] · [[Typography]] · [[Components]] · [[Layouts]]
|
||||
- [[Internationalization & RTL]] · [[Settings & Theming]]
|
||||
- [[Frontend Architecture]]
|
||||
186
05 - Design System/Typography.md
Normal file
186
05 - Design System/Typography.md
Normal file
@@ -0,0 +1,186 @@
|
||||
---
|
||||
title: Typography
|
||||
tags: [design-system, typography, fonts]
|
||||
created: 2026-05-23
|
||||
---
|
||||
|
||||
# Typography
|
||||
|
||||
The system uses **Public Sans Variable** as the primary face with **Barlow** as a secondary (display) face, plus locale-specific Persian/Arabic faces loaded when the active language requires them.
|
||||
|
||||
---
|
||||
|
||||
## 1. Font stack
|
||||
|
||||
Loaded via `@fontsource-variable` (variable fonts streamed at build) plus `@fontsource/barlow`. Confirm in `frontend/package.json`:
|
||||
|
||||
```jsonc
|
||||
"@fontsource-variable/public-sans": "^5.2.5", // Primary
|
||||
"@fontsource-variable/dm-sans": "^5.2.5", // Optional preset
|
||||
"@fontsource-variable/inter": "^5.2.5", // Optional preset
|
||||
"@fontsource-variable/nunito-sans": "^5.2.5", // Optional preset
|
||||
"@fontsource/barlow": "^5.2.5", // Secondary (display)
|
||||
```
|
||||
|
||||
Imported in `frontend/src/app/layout.tsx` (or a fonts module) so Next can fingerprint and preload them.
|
||||
|
||||
Default font-family stack in the theme:
|
||||
|
||||
```css
|
||||
font-family: "Public Sans Variable", "Helvetica", "Arial", sans-serif;
|
||||
```
|
||||
|
||||
Display-only headings (banners, hero) may override with Barlow via the `sx` prop:
|
||||
|
||||
```tsx
|
||||
<Typography variant="h1" sx={{ fontFamily: '"Barlow", serif' }}>Welcome</Typography>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Type scale
|
||||
|
||||
| Variant | Size (px) | Line height | Weight | Use |
|
||||
|---|---|---|---|---|
|
||||
| `h1` | 64 | 80 | 800 | Hero titles only |
|
||||
| `h2` | 48 | 64 | 800 | Page titles |
|
||||
| `h3` | 32 | 48 | 700 | Section titles |
|
||||
| `h4` | 24 | 36 | 700 | Card titles, dialog headers |
|
||||
| `h5` | 20 | 30 | 700 | Sub-section titles |
|
||||
| `h6` | 18 | 28 | 700 | Item titles, sidebar headers |
|
||||
| `subtitle1` | 16 | 24 | 600 | Form section labels |
|
||||
| `subtitle2` | 14 | 22 | 600 | List item subtitles |
|
||||
| `body1` | 16 | 24 | 400 | Body copy default |
|
||||
| `body2` | 14 | 22 | 400 | Secondary copy, table cells |
|
||||
| `caption` | 12 | 18 | 400 | Helper text, timestamps |
|
||||
| `overline` | 12 | 18 | 700 | Tags, all-caps category labels |
|
||||
| `button` | 14 | 24 | 700 | Button text — NOT uppercase |
|
||||
|
||||
> [!tip]
|
||||
> Use `responsiveFontSizes(theme)` once at theme creation to scale `h1`/`h2` on small screens automatically.
|
||||
|
||||
---
|
||||
|
||||
## 3. Weights
|
||||
|
||||
The variable Public Sans face supports 100–900. Convention:
|
||||
|
||||
| Weight | Token | Use |
|
||||
|---|---|---|
|
||||
| 400 | `fontWeightRegular` | Body |
|
||||
| 500 | `fontWeightMedium` | Mild emphasis |
|
||||
| 600 | `fontWeightSemiBold` | Subtitles, table headers |
|
||||
| 700 | `fontWeightBold` | Headings |
|
||||
| 800 | (raw) | Display |
|
||||
|
||||
Never use `font-weight: bolder` — always pick from the table.
|
||||
|
||||
---
|
||||
|
||||
## 4. Letter-spacing
|
||||
|
||||
- Headings (h1–h2): `-1` (tight, modern)
|
||||
- Overline: `+1.1` (open, all-caps)
|
||||
- Body, subtitle: `0`
|
||||
|
||||
---
|
||||
|
||||
## 5. Numeric variants
|
||||
|
||||
Tabular figures matter for financial UI (payments, prices). Enable per-instance:
|
||||
|
||||
```tsx
|
||||
<Typography variant="body1" sx={{ fontVariantNumeric: 'tabular-nums' }}>
|
||||
{amount}
|
||||
</Typography>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Multi-script support
|
||||
|
||||
The system officially supports Latin, **Persian (Farsi)**, and **Arabic** rendering; French and Vietnamese use Latin glyphs already covered by Public Sans; Chinese (Simplified) falls back to the OS-default CJK font (the current fontsource set does not ship CJK).
|
||||
|
||||
Persian/Arabic font swap is layered at theme build:
|
||||
|
||||
```ts
|
||||
fontFamily:
|
||||
locale === 'fa' || locale === 'ar'
|
||||
? '"Vazirmatn", "Public Sans Variable", "Tahoma", sans-serif'
|
||||
: '"Public Sans Variable", "Helvetica", sans-serif'
|
||||
```
|
||||
|
||||
> [!note]
|
||||
> "Vazirmatn" is the **recommended** Persian face for new installations — it has full Unicode coverage and a variable axis. If not installed via `@fontsource`, ship it as a self-hosted woff2 under `public/fonts/`.
|
||||
|
||||
For Chinese (`cn`), consider adding `"PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC"` to the family at the front.
|
||||
|
||||
For Vietnamese (`vi`) — Public Sans already covers all needed glyphs.
|
||||
|
||||
---
|
||||
|
||||
## 7. Line-length
|
||||
|
||||
Aim for **45–75 characters per line** on body copy. Constrain with `maxWidth`:
|
||||
|
||||
```tsx
|
||||
<Typography variant="body1" sx={{ maxWidth: 720 }}>{longBody}</Typography>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Text truncation
|
||||
|
||||
Single-line:
|
||||
|
||||
```tsx
|
||||
<Typography noWrap>{text}</Typography>
|
||||
```
|
||||
|
||||
Multi-line (clamp at 2 lines):
|
||||
|
||||
```tsx
|
||||
<Typography
|
||||
sx={{
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>{text}</Typography>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Bidi & RTL considerations
|
||||
|
||||
- Don't hard-code `text-align: left/right` — use `text-align: start/end` so it flips with direction.
|
||||
- For inline mixed-script (Persian + English), prefer the auto direction inside `<bdi>` or wrap with the Unicode `` (RLE) / `` (PDF) markers.
|
||||
- Numeric formatting per locale handled by `Intl.NumberFormat`; do NOT translate digits manually.
|
||||
|
||||
---
|
||||
|
||||
## 10. Loading & FOUC prevention
|
||||
|
||||
- All fonts loaded at the route segment that needs them (Next.js best practice).
|
||||
- `font-display: swap` is the default in fontsource — visible text uses fallback first, then swaps.
|
||||
- For the FIRST PAINT on auth pages, preload the primary Latin face by adding `<link rel="preload" as="font" />` to the root layout.
|
||||
|
||||
---
|
||||
|
||||
## 11. Customising
|
||||
|
||||
Add a font:
|
||||
|
||||
1. `yarn add @fontsource-variable/<name>`
|
||||
2. `import '@fontsource-variable/<name>'` in `src/app/layout.tsx`.
|
||||
3. Add to `fontFamily` stack in `theme/options/typography.ts`.
|
||||
4. Register as a settings option if user-selectable.
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- [[Theme Configuration]] · [[Design System Overview]] · [[Colors]]
|
||||
- [[Internationalization & RTL]]
|
||||
- [[Settings & Theming]]
|
||||
435
06 - Usage/Admin Guide.md
Normal file
435
06 - Usage/Admin Guide.md
Normal file
@@ -0,0 +1,435 @@
|
||||
---
|
||||
title: Admin Guide
|
||||
tags: [usage, role/admin]
|
||||
audience: Admin
|
||||
created: 2026-05-23
|
||||
---
|
||||
|
||||
# Admin Guide
|
||||
|
||||
Operational usage for **Admin** users — moderation, dispute mediation, payment ops, blog management, system oversight.
|
||||
|
||||
> [!info]
|
||||
> Looking for a different role? See [[User Guide]] (Buyer), [[Seller Guide]] (Owner), [[Support Guide]].
|
||||
|
||||
---
|
||||
|
||||
## 1. Default admin credentials
|
||||
|
||||
In a freshly-seeded environment (dev or first prod boot with `AUTO_SEED_ON_START=true`):
|
||||
|
||||
| Email | Password | Role |
|
||||
|---|---|---|
|
||||
| `admin@marketplace.com` | `Moji6364` | admin |
|
||||
|
||||
> [!warning]
|
||||
> Change this password immediately in production. The default values are committed to the seed scripts.
|
||||
|
||||
Sign in at `/auth/jwt/sign-in` like any other user. The dashboard sidebar will surface admin-only sections.
|
||||
|
||||
---
|
||||
|
||||
## 2. Admin dashboard overview
|
||||
|
||||
**Dashboard → Overview** for admin shows aggregated platform KPIs:
|
||||
|
||||
| Tile | Metric | Source |
|
||||
|---|---|---|
|
||||
| Active users (30d) | Distinct logins | Users collection |
|
||||
| New registrations (today / 7d / 30d) | Signups | TempVerification + User |
|
||||
| Open requests | Status = open | PurchaseRequest |
|
||||
| In-flight payments | Status = pending or processing | Payment |
|
||||
| Open disputes | Status = open | Dispute |
|
||||
| Revenue (fees) | Platform commission ledger | Payment + ShopSettings |
|
||||
| Pending approvals | New seller verifications, blog drafts | various |
|
||||
|
||||
> [!tip]
|
||||
> Click any KPI tile to drill into the filtered list.
|
||||
|
||||
---
|
||||
|
||||
## 3. User management
|
||||
|
||||
### 3.1 List users
|
||||
|
||||
**Dashboard → User → List**:
|
||||
|
||||
- DataGrid with: email, name, role, status, joined date, last activity, points balance, kyc state.
|
||||
- Filters: role, status (active/banned/pending verification), country, signup-date range.
|
||||
- Search: email, name, phone.
|
||||
- Export to CSV (admin-only action).
|
||||
|
||||
### 3.2 Edit a user
|
||||
|
||||
Click a user row → **Edit**:
|
||||
|
||||
- Change role (e.g., promote to seller, demote to buyer, grant admin).
|
||||
- Manually verify email.
|
||||
- Reset password (sends a reset code to their email).
|
||||
- Adjust points balance (audited).
|
||||
- Ban / unban.
|
||||
- View linked addresses, payment methods, devices.
|
||||
|
||||
> [!warning]
|
||||
> Role escalation is logged in the audit trail. Granting admin rights should be limited and reversible — use `support` for less-privileged staff (see [[Support Guide]]).
|
||||
|
||||
### 3.3 Create a user (admin-only)
|
||||
|
||||
**User → New** lets you provision an account without email verification (useful for migrating staff or VIP onboarding).
|
||||
|
||||
### 3.4 Ban a user
|
||||
|
||||
1. Open user row → **Ban**.
|
||||
2. Provide a reason (logged + emailed to user).
|
||||
3. Optional: auto-resolve all their open requests/offers (recommended for fraud cases).
|
||||
|
||||
A banned user:
|
||||
- Cannot sign in.
|
||||
- Their open offers are withdrawn.
|
||||
- Their open requests are cancelled.
|
||||
- Their funds in escrow are NOT auto-released — handle through disputes.
|
||||
|
||||
---
|
||||
|
||||
## 4. Listing & content moderation
|
||||
|
||||
### 4.1 Purchase Request review
|
||||
|
||||
Public requests are typically auto-published, but admin can review:
|
||||
|
||||
**Marketplace → Requests → Filter status: Open**. Look for:
|
||||
|
||||
- Suspicious wording (drugs, weapons, prohibited items — block list TBD)
|
||||
- Unrealistic prices (likely scams)
|
||||
- Foreign language abuse
|
||||
|
||||
Action: **Hide** (sets visibility off without deleting), or **Force-cancel** with reason.
|
||||
|
||||
### 4.2 Seller Template review
|
||||
|
||||
**Marketplace → Templates** — sellers' shareable templates. Same scrutiny as requests. Reject with reason; the seller is notified.
|
||||
|
||||
### 4.3 Blog post review
|
||||
|
||||
Admin owns blog content:
|
||||
|
||||
**Dashboard → Post → List**:
|
||||
|
||||
- Drafts, published, archived
|
||||
- New: `Post → New` → TipTap editor → fill title, slug, category, cover image, body, SEO meta → **Publish**.
|
||||
- Edit existing: same flow.
|
||||
- Comments moderation (if comments enabled): approve / reject / spam.
|
||||
|
||||
---
|
||||
|
||||
## 5. Dispute mediation
|
||||
|
||||
The most critical admin function.
|
||||
|
||||
### 5.1 Dispute queue
|
||||
|
||||
**Dashboard → Disputes** lists every dispute with:
|
||||
|
||||
- Reason
|
||||
- Status (open / awaiting evidence / under review / resolved)
|
||||
- Buyer & seller links
|
||||
- Amount in escrow
|
||||
- Days open
|
||||
- Last action
|
||||
|
||||
Sort by **Days open desc** to handle aging cases first.
|
||||
|
||||
### 5.2 Picking up a dispute
|
||||
|
||||
Click a dispute → **Assign to me** (or it's auto-assigned on first open).
|
||||
|
||||
You'll see:
|
||||
- Buyer's claim + evidence
|
||||
- Seller's response + evidence
|
||||
- Full chat history between buyer/seller
|
||||
- Order timeline (payment, shipment, confirmation actions)
|
||||
- Payment & escrow status
|
||||
|
||||
### 5.3 Communicating
|
||||
|
||||
A 3-way chat exists in the dispute view:
|
||||
- Send a message visible to both parties ("Please clarify…").
|
||||
- Send a message visible to one party only (rare).
|
||||
- Request additional evidence.
|
||||
|
||||
### 5.4 Resolving
|
||||
|
||||
When you have enough information, click **Resolve**:
|
||||
|
||||
| Resolution | What happens |
|
||||
|---|---|
|
||||
| **Refund buyer (100%)** | Escrow returned to buyer; seller marked at-fault |
|
||||
| **Refund buyer (partial)** | Specify split; e.g., 70% buyer, 30% seller |
|
||||
| **Release to seller** | Buyer's claim denied; full payout to seller |
|
||||
| **Mutual cancel** | Buyer & seller agreed to refund |
|
||||
|
||||
You MUST write a resolution narrative explaining your reasoning. Both parties see this.
|
||||
|
||||
> [!warning]
|
||||
> Resolutions are **irreversible** in the system. Triple-check the split before confirming.
|
||||
|
||||
See [[Dispute Flow]] and the [[Dispute]] model.
|
||||
|
||||
### 5.5 Pattern detection
|
||||
|
||||
If you see repeat disputes against the same seller (or repeat frivolous disputes from the same buyer), check:
|
||||
- **Dashboard → User → [profile] → History** for past disputes.
|
||||
- Consider banning persistent bad actors after review.
|
||||
|
||||
---
|
||||
|
||||
## 6. Payment operations
|
||||
|
||||
### 6.1 Payment monitoring
|
||||
|
||||
**Dashboard → Payment → List** shows all payments with filters by status, provider, network, time range.
|
||||
|
||||
Watch for:
|
||||
- **Stuck payments** (pending > 1h) — SHKeeper webhook may have failed; check logs.
|
||||
- **Failed webhooks** — SHKeeper retried but signature didn't verify; see [[Payment API]].
|
||||
- **Missing tx hashes** on completed payments — run the repair script (see §6.3).
|
||||
|
||||
### 6.2 Manual payout
|
||||
|
||||
For sellers who can't access self-service or for one-off ops:
|
||||
|
||||
1. **Payment → Payout → New**.
|
||||
2. Fields: recipient address, amount, token (USDT…), network (BSC…), reference, description.
|
||||
3. Submit → ts-node script also exists at `backend/manual-payout-test.ts` for local testing.
|
||||
|
||||
Behind the scenes this calls SHKeeper's payout endpoint. See [[Payout Flow]].
|
||||
|
||||
### 6.3 Fix missing transaction hashes
|
||||
|
||||
Some completed payments may lack the on-chain tx hash (webhook race, partial confirmation). Run:
|
||||
|
||||
```bash
|
||||
cd /Users/mojtabaheidari/code/backend
|
||||
node fix-transaction-hashes.js
|
||||
```
|
||||
|
||||
The script polls SHKeeper for each affected invoice and patches `transactionHash` + `blockchain.transactionHash` in MongoDB.
|
||||
|
||||
See [[Scripts]] for the full inventory.
|
||||
|
||||
### 6.4 Refunds
|
||||
|
||||
Issued automatically when a dispute resolves to "refund buyer". For manual refunds (e.g., goodwill):
|
||||
|
||||
1. **Payment → [payment] → Refund**.
|
||||
2. Specify amount (full or partial).
|
||||
3. Confirm. The refund triggers a payout-style return to the buyer's address.
|
||||
|
||||
---
|
||||
|
||||
## 7. Loyalty / Points system
|
||||
|
||||
### 7.1 Levels
|
||||
|
||||
**Dashboard → Points → Levels** (admin view):
|
||||
|
||||
- View all `LevelConfig` tiers.
|
||||
- Edit thresholds (points required).
|
||||
- Edit benefits (discount %, perks).
|
||||
- Add / archive levels.
|
||||
|
||||
See [[LevelConfig]] model.
|
||||
|
||||
### 7.2 Award bonus points
|
||||
|
||||
For promotions, contests, makegoods:
|
||||
|
||||
1. **Points → Award bonus**.
|
||||
2. Pick a user (by email).
|
||||
3. Amount + reason (logged in audit).
|
||||
4. Submit → user is notified.
|
||||
|
||||
Recorded as a `PointTransaction` with `source: 'admin'`.
|
||||
|
||||
### 7.3 Audit point transactions
|
||||
|
||||
**Points → Transactions** with admin filter shows every earn / spend / expire / admin-adjust event for any user.
|
||||
|
||||
---
|
||||
|
||||
## 8. Category management
|
||||
|
||||
**Marketplace → Categories** (or a dedicated admin area):
|
||||
|
||||
- Add new category (Persian name + English name + icon + parent for sub-categories).
|
||||
- Edit / archive.
|
||||
- Reorder (drag-drop).
|
||||
- Seed initial set via `npm run seed:categories`.
|
||||
|
||||
See [[Category]] model.
|
||||
|
||||
---
|
||||
|
||||
## 9. System health & ops
|
||||
|
||||
### 9.1 Healthcheck
|
||||
|
||||
```bash
|
||||
curl https://amn.gg/health
|
||||
# → { "status": "ok", "uptime": 12345, "db": "connected", "redis": "connected" }
|
||||
```
|
||||
|
||||
(Confirm exact shape from `backend/src/app.ts`.)
|
||||
|
||||
Healthcheck is also wired into Docker (`HEALTHCHECK` in `backend/Dockerfile.prod`), so a failing healthcheck restarts the container automatically.
|
||||
|
||||
### 9.2 Container status
|
||||
|
||||
On the host:
|
||||
|
||||
```bash
|
||||
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Image}}'
|
||||
# nickapp-backend Up 2h (healthy) git.manko.yoga/manawenuz/escrow-backend:latest
|
||||
# nickapp-frontend Up 2h (healthy) …/frontend:latest
|
||||
# nickapp-nginx Up 2h nginx:alpine
|
||||
# nickapp-mongodb Up 2h (healthy) mongo:8.0
|
||||
# nickapp-redis Up 2h (healthy) redis:8-alpine
|
||||
```
|
||||
|
||||
### 9.3 Logs
|
||||
|
||||
```bash
|
||||
docker logs -f --tail 200 nickapp-backend
|
||||
docker logs -f --tail 200 nickapp-frontend
|
||||
docker logs -f --tail 200 nickapp-nginx
|
||||
```
|
||||
|
||||
Sentry collects unhandled errors with full context. See [[Monitoring]].
|
||||
|
||||
### 9.4 Restart
|
||||
|
||||
```bash
|
||||
docker compose -f /path/to/docker-compose.production.yml restart nickapp-backend
|
||||
```
|
||||
|
||||
Or roll a new image: push to registry → Watchtower auto-restarts within 5 min.
|
||||
|
||||
### 9.5 Database access
|
||||
|
||||
```bash
|
||||
docker exec -it nickapp-mongodb mongosh \
|
||||
--username "$MONGO_USERNAME" --password "$MONGO_PASSWORD"
|
||||
use nickapp
|
||||
db.users.find({ role: 'admin' })
|
||||
```
|
||||
|
||||
> [!warning]
|
||||
> Direct DB writes bypass all validation, hooks, and audit logging. Use the admin UI whenever possible.
|
||||
|
||||
See [[Database Operations]] for the runbook.
|
||||
|
||||
---
|
||||
|
||||
## 10. Reports & analytics
|
||||
|
||||
### 10.1 Built-in admin reports
|
||||
|
||||
(If implemented — verify in `backend/src/services/admin/`.)
|
||||
|
||||
- **Revenue by month / category / seller**
|
||||
- **Top sellers by GMV / completion rate**
|
||||
- **Top buyers by spend**
|
||||
- **Dispute rate by category / seller**
|
||||
- **Funnel: signup → first request → first payment**
|
||||
|
||||
Export to CSV for offline analysis.
|
||||
|
||||
### 10.2 Ad-hoc queries
|
||||
|
||||
For complex investigations, use `mongosh` directly (see §9.5).
|
||||
|
||||
---
|
||||
|
||||
## 11. Communications
|
||||
|
||||
### 11.1 System announcement
|
||||
|
||||
(If admin has a broadcast UI — check `Notification` model.)
|
||||
|
||||
1. Compose message (title, body, link).
|
||||
2. Audience: all users / specific role / specific list.
|
||||
3. Send → appears in users' notification drawer and (optionally) emails.
|
||||
|
||||
Backed by `notification:received` socket event broadcast to all relevant rooms. See [[Notification Flow]].
|
||||
|
||||
### 11.2 Maintenance window
|
||||
|
||||
For planned downtime:
|
||||
|
||||
1. Post an in-app announcement 24h ahead.
|
||||
2. At T-15 min, broadcast `system:maintenance` socket event.
|
||||
3. UI shows a yellow banner: "Maintenance in 15 min — please save your work."
|
||||
4. Take the system offline (compose stop, deploy, compose start).
|
||||
|
||||
See [[Incident Response]] for unplanned ops.
|
||||
|
||||
---
|
||||
|
||||
## 12. Support escalations
|
||||
|
||||
Support agents (see [[Support Guide]]) handle Tier-1 issues. Items that escalate to admin:
|
||||
|
||||
- Refund > $X
|
||||
- Role changes
|
||||
- Account un-ban
|
||||
- Suspected fraud / chargeback dispute
|
||||
- Manual payout exceptions
|
||||
- Data correction (anything bypassing the UI)
|
||||
|
||||
Admins should reply to escalations within 1 business day SLA.
|
||||
|
||||
---
|
||||
|
||||
## 13. Common admin tasks (quick reference)
|
||||
|
||||
| Task | Where |
|
||||
|---|---|
|
||||
| Reset a user's password | User → [profile] → Reset password |
|
||||
| Manually verify a user | User → [profile] → Verify email |
|
||||
| Promote user to seller | User → [profile] → Edit → role |
|
||||
| Award points | Points → Award |
|
||||
| Approve a refund | Payment → [payment] → Refund |
|
||||
| Resolve a dispute | Disputes → [case] → Resolve |
|
||||
| Hide a request | Marketplace → [request] → Hide |
|
||||
| Publish a blog post | Post → [post] → Publish |
|
||||
| Add a category | Marketplace → Categories → New |
|
||||
| Check system health | `curl /health` |
|
||||
|
||||
---
|
||||
|
||||
## 14. Audit & accountability
|
||||
|
||||
Every admin action SHOULD be logged. Today the system logs role changes, refunds, point-adjustments, and dispute resolutions in the `Notification` and `PointTransaction` collections with `metadata.actor`. Stronger audit trail (append-only collection with diffs) is on the roadmap.
|
||||
|
||||
> [!warning]
|
||||
> Do NOT use admin powers to read private chats unless required for dispute investigation. Document the case ID and reason for each access.
|
||||
|
||||
---
|
||||
|
||||
## 15. Operations runbooks (pointers)
|
||||
|
||||
When something breaks, see:
|
||||
|
||||
- [[Incident Response]] — what to do when the site is down
|
||||
- [[Backup & Recovery]] — restore from backup
|
||||
- [[Monitoring]] — what to watch
|
||||
- [[Database Operations]] — direct DB ops
|
||||
|
||||
---
|
||||
|
||||
## 16. Related
|
||||
|
||||
- [[User Guide]] · [[Seller Guide]] · [[Support Guide]]
|
||||
- Flows: [[Dispute Flow]] · [[Payout Flow]] · [[Notification Flow]]
|
||||
- Models: [[User]] · [[Dispute]] · [[Payment]] · [[LevelConfig]] · [[PointTransaction]] · [[Category]] · [[BlogPost]]
|
||||
- [[Admin API]] · [[Glossary]]
|
||||
365
06 - Usage/Seller Guide.md
Normal file
365
06 - Usage/Seller Guide.md
Normal file
@@ -0,0 +1,365 @@
|
||||
---
|
||||
title: Seller Guide
|
||||
tags: [usage, role/seller, role/owner]
|
||||
audience: Seller (Owner)
|
||||
created: 2026-05-23
|
||||
---
|
||||
|
||||
# Seller Guide (Owner)
|
||||
|
||||
End-to-end usage for a **Seller** (Shop Owner) — the person who responds to buyer purchase requests, fulfils orders, and withdraws escrow funds.
|
||||
|
||||
> [!info]
|
||||
> Looking for a different role? See [[User Guide]] (Buyer), [[Admin Guide]], [[Support Guide]].
|
||||
|
||||
---
|
||||
|
||||
## 1. Becoming a Seller
|
||||
|
||||
You can either:
|
||||
|
||||
- **Sign up as Seller** at registration (`/auth/jwt/sign-up` → choose role = Seller).
|
||||
- **Upgrade from Buyer**: existing buyer → **Dashboard → Account → Become a seller** → submit shop info → admin verification.
|
||||
|
||||
Either path requires:
|
||||
|
||||
- Verified email
|
||||
- Phone number on file (some regions require KYC)
|
||||
- Initial shop info (name, country, payout wallet address)
|
||||
|
||||
---
|
||||
|
||||
## 2. Configuring your shop
|
||||
|
||||
### 2.1 Shop branding
|
||||
|
||||
**Dashboard → Shop Settings → Settings**:
|
||||
|
||||
| Field | Notes |
|
||||
|---|---|
|
||||
| Shop name | Public; shown in shop URL `/shop/{slug}` |
|
||||
| Tagline | One-line summary on the shop card |
|
||||
| About | Rich-text editor (use the editor for headings, lists, links) |
|
||||
| Avatar / logo | 256×256 recommended |
|
||||
| Cover image | 1600×400 recommended |
|
||||
| Category specialisations | Tag your shop with categories you cover |
|
||||
| Country / Region | Used for delivery filters |
|
||||
| Default response time | "I usually respond within X hours" |
|
||||
| Default delivery window | "Typical fulfilment 3–5 business days" |
|
||||
|
||||
### 2.2 Social links
|
||||
|
||||
**Shop Settings → Socials**: Twitter, Instagram, Telegram, website. Shown on your public shop page.
|
||||
|
||||
### 2.3 Security
|
||||
|
||||
**Shop Settings → Security**:
|
||||
- Withdraw confirmation requires email code (recommended)
|
||||
- Two-factor authentication
|
||||
- Login alerts
|
||||
|
||||
### 2.4 Payout wallet
|
||||
|
||||
**Account → Wallet** or **Shop Settings → Payout**:
|
||||
|
||||
- Add a crypto address you control on the supported networks (BSC, Polygon, …).
|
||||
- All payouts (escrow releases) go here.
|
||||
- You can change it, but new payouts go to the new address — past payouts are settled to whichever address was active at the time.
|
||||
|
||||
> [!warning]
|
||||
> Your payout address is the **single source of funds out of escrow**. Triple-check it. If it's wrong, funds can be irretrievably lost.
|
||||
|
||||
---
|
||||
|
||||
## 3. Request Templates
|
||||
|
||||
Templates are pre-defined product/service offerings. Buyers can create a request from your template with one click.
|
||||
|
||||
### 3.1 Create a template
|
||||
|
||||
**Dashboard → Request Templates → + New**:
|
||||
|
||||
1. **Title** — what you sell ("Vintage leather jacket", "Custom logo design").
|
||||
2. **Category** — primary category.
|
||||
3. **Description** — rich text, use images.
|
||||
4. **Pricing** — fixed price or "starts at" range. Specify currency.
|
||||
5. **Delivery window** — typical days from acceptance.
|
||||
6. **Customisations** — list of options (size, color, quantity) buyers can choose.
|
||||
7. **Videos** (optional) — embed up to N video URLs.
|
||||
8. **Default proposal** — your standard offer text that auto-populates when a buyer creates from this template.
|
||||
9. **Expiration** — leave blank for evergreen; set a date for limited-time offers.
|
||||
10. **Visibility** — public (anyone can use) or unlisted (shareable URL only).
|
||||
|
||||
Click **Publish**. You'll get a shareable URL: `https://amn.gg/shop/{seller}/{templateId}`.
|
||||
|
||||
### 3.2 Manage templates
|
||||
|
||||
**Dashboard → Request Templates** shows all templates with:
|
||||
|
||||
- Status (active / expired / draft)
|
||||
- Usage count (how many buyers used it → became a real request)
|
||||
- Conversion rate (used → accepted)
|
||||
- Quick edit / clone / disable
|
||||
|
||||
See [[RequestTemplate]] and [[Purchase Request Flow]].
|
||||
|
||||
---
|
||||
|
||||
## 4. Finding open buyer requests
|
||||
|
||||
Once your shop is configured, you can browse incoming buyer demand.
|
||||
|
||||
### 4.1 Public requests
|
||||
|
||||
**Dashboard → Shops → Browse** (or via the marketplace surface):
|
||||
|
||||
- Filter by category, budget range, delivery deadline, buyer location.
|
||||
- Sort by recency, budget, urgency.
|
||||
- Click a request for details.
|
||||
|
||||
### 4.2 Invited requests
|
||||
|
||||
If a buyer specifically invites you (often via a template URL), the request appears under **Dashboard → Requests → Invited**.
|
||||
|
||||
### 4.3 Notifications
|
||||
|
||||
- New public request matching your specialisation → notification + email (if enabled).
|
||||
- Direct invitation → push notification.
|
||||
|
||||
---
|
||||
|
||||
## 5. Submitting an offer
|
||||
|
||||
On a request detail page → **Submit offer**:
|
||||
|
||||
1. **Your price** (in the buyer's requested currency).
|
||||
2. **Delivery window** (days from acceptance).
|
||||
3. **Notes** (rich text — explain your approach, materials, brand, etc.).
|
||||
4. **Attachments** (optional — photos of work samples, references).
|
||||
5. **Expire offer in** (default 7 days) — after which the offer auto-rejects.
|
||||
|
||||
Click **Send offer**. The buyer is notified instantly via socket.
|
||||
|
||||
> [!tip]
|
||||
> Offers with personalised notes convert ~3× better than generic ones. Show the buyer you read their request.
|
||||
|
||||
See [[Seller Offer Flow]].
|
||||
|
||||
---
|
||||
|
||||
## 6. Negotiating
|
||||
|
||||
The buyer may message you to discuss:
|
||||
|
||||
- Lowering your price
|
||||
- Faster delivery
|
||||
- Customisations not in your template
|
||||
|
||||
You can:
|
||||
|
||||
1. **Reply via chat** to explain or negotiate.
|
||||
2. **Issue a counter-offer** — opens a new offer slot replacing your previous one.
|
||||
3. **Accept their counter-proposal** if they sent one.
|
||||
|
||||
See [[Negotiation Flow]].
|
||||
|
||||
---
|
||||
|
||||
## 7. Acceptance — what happens
|
||||
|
||||
When the buyer accepts your offer:
|
||||
|
||||
1. All other sellers' offers on this request are auto-rejected.
|
||||
2. Status moves to **Accepted**.
|
||||
3. The buyer is prompted to pay into escrow.
|
||||
4. You receive a notification.
|
||||
5. You can prepare to fulfil — but **DO NOT ship until escrow is funded**.
|
||||
|
||||
> [!warning]
|
||||
> Shipping before funding = you have no recourse if buyer never pays. Always wait for status = **Funded**.
|
||||
|
||||
---
|
||||
|
||||
## 8. Fulfilment
|
||||
|
||||
Once status = **Funded**:
|
||||
|
||||
1. Prepare the item / service.
|
||||
2. On the request page → click **Mark as shipped**.
|
||||
3. Optionally add:
|
||||
- Tracking number + carrier
|
||||
- Photos of packed item
|
||||
- Notes for buyer
|
||||
4. The buyer is notified.
|
||||
|
||||
For digital goods (designs, files): upload via the request's **Deliverables** section. Buyer is notified to confirm.
|
||||
|
||||
See [[Delivery Confirmation Flow]].
|
||||
|
||||
---
|
||||
|
||||
## 9. Getting paid
|
||||
|
||||
When the buyer confirms receipt → status = **Delivered** → escrow released.
|
||||
|
||||
> [!important]
|
||||
> Today, escrow release after confirmation triggers a **payout request** which an admin manually processes. See [[Payout Flow]]. Payouts typically clear within 1–2 business days.
|
||||
|
||||
### 9.1 Withdraw
|
||||
|
||||
**Dashboard → Payment → Payouts** lists pending and processed payouts.
|
||||
|
||||
For self-service withdrawal (if enabled):
|
||||
|
||||
1. Click **Request payout**.
|
||||
2. Confirm your payout wallet address.
|
||||
3. Enter the amount (≤ available balance).
|
||||
4. Submit.
|
||||
5. Status moves through **pending → processing → completed**.
|
||||
|
||||
Once **completed**, you'll see the on-chain transaction hash linking to a block explorer.
|
||||
|
||||
---
|
||||
|
||||
## 10. Disputes
|
||||
|
||||
If a buyer opens a dispute:
|
||||
|
||||
1. You're notified immediately.
|
||||
2. **The escrow is paused** — neither party can move funds until the dispute resolves.
|
||||
3. Open the dispute page → review the buyer's claim and evidence.
|
||||
4. **Submit your evidence**: photos (e.g., shipped item, packaging proof), chat screenshots, tracking confirmation.
|
||||
5. Write a clear response narrative.
|
||||
6. An **admin mediator** reviews both sides and issues a decision.
|
||||
|
||||
Possible outcomes:
|
||||
|
||||
- **Full refund to buyer** (dispute upheld) — you lose the sale + may lose listing if pattern.
|
||||
- **Partial refund** — split.
|
||||
- **Release to you** (dispute denied) — full payout proceeds.
|
||||
- **Mutual cancellation** — agreed refund.
|
||||
|
||||
Tips to win disputes:
|
||||
|
||||
- Always include proof of shipping with timestamp.
|
||||
- Save chat screenshots (the platform retains them, but redundant proof helps).
|
||||
- Respond to disputes within 24 hours.
|
||||
- Don't argue with the buyer publicly in chat — let the mediator decide.
|
||||
|
||||
See [[Dispute Flow]].
|
||||
|
||||
---
|
||||
|
||||
## 11. Reviews
|
||||
|
||||
After each completed transaction:
|
||||
|
||||
- Buyer rates you (1–5 stars + comment).
|
||||
- You rate the buyer.
|
||||
|
||||
Your overall rating is shown on every offer you make. Common rating thresholds:
|
||||
|
||||
- **4.8+** — Top-rated badge, priority listing
|
||||
- **4.0–4.7** — Normal
|
||||
- **<4.0** — Limited visibility, manual review may follow
|
||||
|
||||
Respond to negative reviews professionally — your reply is public.
|
||||
|
||||
---
|
||||
|
||||
## 12. Analytics
|
||||
|
||||
**Dashboard → Overview** shows your seller KPIs:
|
||||
|
||||
| Metric | Description |
|
||||
|---|---|
|
||||
| Open requests | Active conversations |
|
||||
| Acceptance rate | Offers accepted / submitted |
|
||||
| Avg. response time | How fast you reply |
|
||||
| Avg. delivery time | From accept to ship |
|
||||
| Revenue (30/90/365 days) | Net of fees |
|
||||
| Active templates | Live request templates |
|
||||
| Rating | Current overall stars |
|
||||
| Dispute rate | % of orders disputed |
|
||||
|
||||
Improve weak areas to climb the seller leaderboard.
|
||||
|
||||
---
|
||||
|
||||
## 13. Earning points (Seller side)
|
||||
|
||||
Sellers earn points for:
|
||||
|
||||
- Each successful sale
|
||||
- Maintaining high rating
|
||||
- Fast response times
|
||||
- Referring other sellers / buyers
|
||||
|
||||
Points unlock benefits — see [[User Guide]] §12 and [[LevelConfig]].
|
||||
|
||||
---
|
||||
|
||||
## 14. Settings
|
||||
|
||||
Same as Buyer (theme, language, notifications, security) plus:
|
||||
|
||||
- **Auto-decline** new requests when busy (set a date range).
|
||||
- **Vacation mode** — pause public visibility without losing offers in flight.
|
||||
- **Tax info** — if applicable in your jurisdiction.
|
||||
|
||||
---
|
||||
|
||||
## 15. Common issues
|
||||
|
||||
### "My offer isn't getting acceptance"
|
||||
|
||||
- Compare your price to similar offers (visible in the request side panel).
|
||||
- Improve your **default response time** & rating.
|
||||
- Add personalised notes to each offer.
|
||||
- Use rich descriptions in templates.
|
||||
|
||||
### "Escrow funds not showing up after delivery"
|
||||
|
||||
> [!warning]
|
||||
> Auto-release on delivery confirmation is **not yet automated** — an admin manually triggers payouts on a schedule. Allow 1–2 business days.
|
||||
|
||||
If it's been longer, contact support.
|
||||
|
||||
### "Wrong payout address"
|
||||
|
||||
If you noticed a wrong address BEFORE the payout was sent → update in **Shop Settings → Payout** and contact support.
|
||||
|
||||
If the payout was already broadcast → recovery requires on-chain coordination; contact support immediately with the transaction hash.
|
||||
|
||||
### "Dispute opened — what to do first"
|
||||
|
||||
1. Don't panic.
|
||||
2. Read the buyer's claim carefully.
|
||||
3. Gather all proof (shipping receipts, photos, chat history).
|
||||
4. Submit evidence within 24 hours.
|
||||
5. Be respectful in your narrative.
|
||||
|
||||
### "Need to take time off"
|
||||
|
||||
Set **Vacation mode** in Shop Settings. New requests auto-decline with a courteous message; existing orders proceed normally.
|
||||
|
||||
---
|
||||
|
||||
## 16. Tips & best practices
|
||||
|
||||
> [!tip]
|
||||
> - **Reply fast** — first responder often wins.
|
||||
> - **Photos sell** — clear, well-lit images convert.
|
||||
> - **Be specific in delivery dates** — buyers value predictability.
|
||||
> - **Use templates** for repeat offerings — saves time, increases conversion.
|
||||
> - **Build a portfolio in your shop About** — recent work, certifications.
|
||||
> - **Respond to ALL reviews** — even one-line "Thank you" helps.
|
||||
|
||||
---
|
||||
|
||||
## 17. Related
|
||||
|
||||
- [[User Guide]] · [[Admin Guide]] · [[Support Guide]]
|
||||
- Flows: [[Seller Offer Flow]] · [[Negotiation Flow]] · [[Delivery Confirmation Flow]] · [[Payout Flow]] · [[Dispute Flow]] · [[Rating Flow]]
|
||||
- Models: [[User]] · [[RequestTemplate]] · [[SellerOffer]] · [[ShopSettings]] · [[Review]]
|
||||
- [[Glossary]]
|
||||
331
06 - Usage/Support Guide.md
Normal file
331
06 - Usage/Support Guide.md
Normal file
@@ -0,0 +1,331 @@
|
||||
---
|
||||
title: Support Guide
|
||||
tags: [usage, role/support]
|
||||
audience: Support agent
|
||||
created: 2026-05-23
|
||||
---
|
||||
|
||||
# Support Guide
|
||||
|
||||
End-to-end usage for **Support** agents — Tier-1 customer help, account recovery, troubleshooting. Support has elevated read permissions but limited write power; serious actions escalate to admin.
|
||||
|
||||
> [!info]
|
||||
> Looking for a different role? See [[User Guide]] (Buyer), [[Seller Guide]] (Owner), [[Admin Guide]].
|
||||
|
||||
---
|
||||
|
||||
## 1. Support account
|
||||
|
||||
Support agents are provisioned by an admin (see [[Admin Guide]] §3.3). A typical seed:
|
||||
|
||||
| Email | Role | Capabilities |
|
||||
|---|---|---|
|
||||
| `support@amn.gg` | support | Read users, reset passwords, view chat (case-scoped), escalate |
|
||||
|
||||
A support account CANNOT:
|
||||
|
||||
- Change roles
|
||||
- Ban / unban
|
||||
- Resolve disputes (read-only)
|
||||
- Issue refunds (escalate to admin)
|
||||
- Modify points or financial records
|
||||
- Publish blog posts
|
||||
|
||||
---
|
||||
|
||||
## 2. Workflow
|
||||
|
||||
Support tickets flow through these stages:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[New ticket] --> B[Triage]
|
||||
B --> C{Resolvable?}
|
||||
C -- yes --> D[Resolve]
|
||||
C -- no --> E[Escalate to admin]
|
||||
D --> F[Close]
|
||||
E --> G[Wait]
|
||||
G --> H[Admin resolves]
|
||||
H --> F
|
||||
```
|
||||
|
||||
Ticket sources:
|
||||
|
||||
- In-app contact form (writes to a `support` Notification / dedicated collection)
|
||||
- Email to `support@amn.gg` (forwards to ticketing tool)
|
||||
- Live chat widget (if enabled)
|
||||
|
||||
---
|
||||
|
||||
## 3. Common Tier-1 tasks
|
||||
|
||||
### 3.1 "I can't sign in"
|
||||
|
||||
1. Ask the user for their email.
|
||||
2. **Dashboard → User → [profile]** to look them up.
|
||||
3. Check status: `active`, `banned`, `pending email verification`?
|
||||
4. If pending verification → **Resend verification email** (button in user profile).
|
||||
5. If account locked → wait out the lockout (default 30 min) OR escalate to admin for immediate unlock.
|
||||
6. If forgotten password → tell the user to use **Forgot password** on the sign-in page OR you can trigger **Reset password** from their profile (sends a fresh reset code).
|
||||
|
||||
> [!warning]
|
||||
> Never ask a user for their password. Never SET a password for them — the platform sends a reset code so only they can complete the change.
|
||||
|
||||
### 3.2 "I didn't receive the email"
|
||||
|
||||
Checklist:
|
||||
|
||||
1. Confirm the correct email is on file.
|
||||
2. Ask them to check Spam / Promotions.
|
||||
3. Check if the user's email domain is on a known block list (yopmail, mailinator, etc.).
|
||||
4. Resend (max 3 retries / hour).
|
||||
5. If issue persists → check backend SMTP logs (escalate to admin).
|
||||
|
||||
### 3.3 "How do I change my email?"
|
||||
|
||||
1. Ask the user to first verify ownership of the new email.
|
||||
2. Today this is an admin-assisted change — escalate.
|
||||
|
||||
(Roadmap: self-service email change with verify-old + verify-new.)
|
||||
|
||||
### 3.4 "I lost access to my Passkey device"
|
||||
|
||||
1. They can use their password as a fallback.
|
||||
2. Direct to **Dashboard → Account → Passkey → Remove [device]**.
|
||||
3. Add a new passkey from the new device.
|
||||
4. If passkey was their ONLY auth method → escalate to admin who can manually unlock.
|
||||
|
||||
### 3.5 "I'm a seller — how do I withdraw?"
|
||||
|
||||
1. Verify they completed shop setup (payout wallet on file).
|
||||
2. Walk them through **Dashboard → Payment → Payouts → Request payout**.
|
||||
3. Remind them that payouts process within 1–2 business days.
|
||||
4. If a specific payout is stuck → escalate to admin with the payout ID.
|
||||
|
||||
### 3.6 "My payment is stuck"
|
||||
|
||||
1. Get the payment ID or invoice URL.
|
||||
2. Open in Admin user (if you have read access).
|
||||
3. Check status:
|
||||
- `pending` for <1h — tell user to wait for blockchain confirmation.
|
||||
- `pending` for >1h — escalate to admin (webhook may have failed).
|
||||
- `processing` — usually clears within minutes.
|
||||
- `failed` — note the error code and escalate.
|
||||
4. If user sent wrong network/token → **escalate immediately**, recovery may require manual on-chain work.
|
||||
|
||||
### 3.7 "I want to delete my account"
|
||||
|
||||
1. Confirm via email reply (anti-impersonation).
|
||||
2. Check for: open orders, in-flight payments, open disputes — none should exist.
|
||||
3. If any open obligations → ask user to settle first.
|
||||
4. Escalate to admin for the actual deletion (it's a manual op to ensure data retention compliance).
|
||||
|
||||
### 3.8 "How do I become a seller?"
|
||||
|
||||
Walk them through:
|
||||
|
||||
1. **Dashboard → Account → Become a seller**.
|
||||
2. Fill in shop info (name, country, payout address, social links).
|
||||
3. Submit → admin verification within 1 business day.
|
||||
4. Once approved → can build templates and submit offers.
|
||||
|
||||
See [[Seller Guide]].
|
||||
|
||||
### 3.9 "I see stale data / the page is broken"
|
||||
|
||||
Suggest the **localStorage cleanup** trick:
|
||||
|
||||
1. Open browser DevTools (`F12` or `Cmd+Opt+I`).
|
||||
2. Console tab → paste:
|
||||
```js
|
||||
Object.keys(localStorage)
|
||||
.filter(k => /^(manual-payment|buyer-step|LAST_(PAYMENT|TEMPLATE|SOCKET))-/.test(k))
|
||||
.forEach(k => localStorage.removeItem(k));
|
||||
location.reload();
|
||||
```
|
||||
3. (This pattern matches the keys cleared by `frontend/cleanup-localstorage.js`.)
|
||||
|
||||
If the issue persists → screenshot + browser info + URL → escalate.
|
||||
|
||||
### 3.10 "I want a refund"
|
||||
|
||||
1. Confirm what they're requesting (full / partial / for which order).
|
||||
2. Check the order status & history.
|
||||
3. If order is **Funded** and not yet shipped → they can negotiate cancellation with the seller (no admin needed).
|
||||
4. If order is shipped/disputed/refund-disputed → direct them to open a **dispute** (or it's already open).
|
||||
5. Goodwill refund < $10 — admin can issue. Note the case and escalate.
|
||||
6. Larger refunds — must go through dispute process.
|
||||
|
||||
See [[Dispute Flow]].
|
||||
|
||||
---
|
||||
|
||||
## 4. Triage rubric
|
||||
|
||||
Assign priority based on impact:
|
||||
|
||||
| Priority | Examples | SLA |
|
||||
|---|---|---|
|
||||
| **P0 — Site outage / data loss** | Cannot reach amn.gg, all logins failing, payments silently failing | Immediate page admin |
|
||||
| **P1 — User cannot transact** | Specific user can't pay, payout failed, dispute stuck > 7 days | 4 hours |
|
||||
| **P2 — Functional bug, workaround exists** | Notification not arriving, cosmetic issue | 1 business day |
|
||||
| **P3 — Question / how-to** | "How do I…" inquiries | 2 business days |
|
||||
|
||||
---
|
||||
|
||||
## 5. Escalation matrix
|
||||
|
||||
| Issue | Escalate to |
|
||||
|---|---|
|
||||
| Account un-ban request | Admin |
|
||||
| Role change (buyer→seller, granting support) | Admin |
|
||||
| Refund > $10 | Admin |
|
||||
| Dispute resolution | Admin (mediator) |
|
||||
| Manual payout exception | Admin |
|
||||
| Database correction | Admin + engineer on-call |
|
||||
| Site outage | Engineering on-call |
|
||||
| Security incident (suspected breach / abuse) | Admin + engineering on-call IMMEDIATELY |
|
||||
| Legal request (law enforcement, GDPR) | Admin + legal/compliance |
|
||||
|
||||
---
|
||||
|
||||
## 6. Communication templates
|
||||
|
||||
### 6.1 Password reset
|
||||
|
||||
```
|
||||
Subject: Reset your Amn password
|
||||
|
||||
Hi {name},
|
||||
|
||||
You can reset your password at https://amn.gg/auth/jwt/reset-password. You'll receive a 6-digit code by email valid for 1 hour.
|
||||
|
||||
If you didn't request this, please ignore this email — your password remains unchanged.
|
||||
|
||||
— Amn Support
|
||||
```
|
||||
|
||||
### 6.2 Verification email not received
|
||||
|
||||
```
|
||||
Hi {name},
|
||||
|
||||
The verification email was sent. Please:
|
||||
|
||||
1. Check your Spam / Promotions folder.
|
||||
2. Allow up to 5 minutes for delivery.
|
||||
3. Click "Resend code" if it still hasn't arrived.
|
||||
|
||||
If the issue persists, reply with the email address you signed up with so we can investigate.
|
||||
|
||||
— Amn Support
|
||||
```
|
||||
|
||||
### 6.3 Payment stuck (>1 hour)
|
||||
|
||||
```
|
||||
Hi {name},
|
||||
|
||||
We see your payment {paymentId} is still showing pending. Crypto confirmations usually take a few minutes; an hour is unusual.
|
||||
|
||||
We've escalated this to our payments team for investigation. We'll update you within 4 hours.
|
||||
|
||||
In the meantime, please do NOT send additional funds — that may complicate recovery.
|
||||
|
||||
— Amn Support
|
||||
```
|
||||
|
||||
### 6.4 Refund request → dispute path
|
||||
|
||||
```
|
||||
Hi {name},
|
||||
|
||||
I understand you'd like a refund for order {orderId}. The fairest way to resolve this is through our dispute process, which gives both you and the seller a chance to provide evidence before a neutral mediator decides.
|
||||
|
||||
To open a dispute:
|
||||
|
||||
1. Open the order page (link below).
|
||||
2. Click "Open dispute".
|
||||
3. Choose a reason and upload photos / chat screenshots as evidence.
|
||||
|
||||
A mediator will pick up your case within 24 hours.
|
||||
|
||||
Link: https://amn.gg/dashboard/request/{orderId}
|
||||
|
||||
— Amn Support
|
||||
```
|
||||
|
||||
### 6.5 Account un-ban (require admin approval)
|
||||
|
||||
```
|
||||
Hi {name},
|
||||
|
||||
I've escalated your account review to our admin team. They'll look at the original reason for the suspension and respond within 1 business day.
|
||||
|
||||
In the meantime, you won't be able to sign in. Funds in escrow are unaffected and will be settled once any open orders complete.
|
||||
|
||||
— Amn Support
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Tools & access
|
||||
|
||||
| Tool | Purpose | Permission |
|
||||
|---|---|---|
|
||||
| Admin dashboard (read mode) | Look up users, orders, payments | Yes (read) |
|
||||
| User → Reset password | Trigger fresh reset code | Yes |
|
||||
| User → Resend verification | Resend signup code | Yes |
|
||||
| Notification system | Send "follow-up" notifications | Yes (scoped) |
|
||||
| Chat history viewer | Read dispute chats | Yes (case-scoped) |
|
||||
| MongoDB direct | Ad-hoc queries | No (escalate) |
|
||||
| Payout trigger | Issue payouts | No (escalate) |
|
||||
| Refund issuer | Issue refunds | No (escalate) |
|
||||
| Dispute resolver | Make resolution | No (escalate) |
|
||||
|
||||
---
|
||||
|
||||
## 8. Privacy & ethics
|
||||
|
||||
> [!warning]
|
||||
> Support has powerful read access. Do not browse user chats, payments, or addresses unless investigating a specific ticket. Each access should be tied to a ticket ID, ideally logged.
|
||||
|
||||
- Never disclose one user's data to another user.
|
||||
- Verify identity before discussing account specifics (signed-in session > email reply > anonymous form).
|
||||
- For financial / legal inquiries, escalate to admin + legal.
|
||||
|
||||
---
|
||||
|
||||
## 9. Common knowledge base
|
||||
|
||||
Bookmark these for instant reference:
|
||||
|
||||
- [[User Guide]] — common Buyer questions
|
||||
- [[Seller Guide]] — common Seller questions
|
||||
- [[Glossary]] — terminology reference
|
||||
- [[Authentication Flow]] · [[Password Reset Flow]] · [[Passkey (WebAuthn) Flow]] — how auth actually works
|
||||
- [[Payment Flow - SHKeeper]] · [[Payment Flow - DePay & Web3]] — how payments flow
|
||||
- [[Dispute Flow]] — when refund requests need to go to dispute
|
||||
- [[Notification Flow]] — why a user might not have received an email
|
||||
- [[Error Codes]] — interpret HTTP errors / app-specific codes the user reports
|
||||
|
||||
---
|
||||
|
||||
## 10. Improving the docs
|
||||
|
||||
If you answer the same question repeatedly, propose a **knowledge-base article**:
|
||||
|
||||
1. Note the recurring question.
|
||||
2. Write a short answer with screenshots.
|
||||
3. Submit to admin for inclusion in `frontend/src/sections/blog/` or this vault.
|
||||
|
||||
A good support team grows the docs over time.
|
||||
|
||||
---
|
||||
|
||||
## 11. Related
|
||||
|
||||
- [[User Guide]] · [[Seller Guide]] · [[Admin Guide]]
|
||||
- All flow docs in [[04 - Flows]] folder
|
||||
- [[Incident Response]] — when support is alerted to outages
|
||||
- [[Glossary]]
|
||||
410
06 - Usage/User Guide.md
Normal file
410
06 - Usage/User Guide.md
Normal file
@@ -0,0 +1,410 @@
|
||||
---
|
||||
title: User Guide
|
||||
tags: [usage, role/buyer]
|
||||
audience: Buyer
|
||||
created: 2026-05-23
|
||||
---
|
||||
|
||||
# User Guide (Buyer)
|
||||
|
||||
End-to-end usage of the Amn marketplace for a **Buyer** — the person who creates purchase requests and pays sellers via crypto escrow.
|
||||
|
||||
> [!info]
|
||||
> Looking for a different role? See [[Seller Guide]], [[Admin Guide]], [[Support Guide]].
|
||||
|
||||
---
|
||||
|
||||
## 1. Creating your account
|
||||
|
||||
### 1.1 Sign up
|
||||
|
||||
1. Visit **`/auth/jwt/sign-up`**.
|
||||
2. Enter:
|
||||
- **Email** (will receive a verification code)
|
||||
- **Password** (≥ 8 chars; mix of letters/digits/symbol recommended)
|
||||
- **Role**: choose **Buyer**. (You can later add Seller capabilities — see [[Seller Guide]].)
|
||||
3. Optional: enter a **referral code** if a friend invited you (you'll both earn points — see [[Referral Flow]]).
|
||||
4. Accept Terms of Service.
|
||||
5. Click **Sign up** → you'll receive an email with a 6-digit code.
|
||||
|
||||
> [!warning]
|
||||
> Codes expire in 1 hour. If yours expires, click **Resend code** on the verify page.
|
||||
|
||||
Alternative sign-up methods:
|
||||
|
||||
- **Sign in with Google** — single-click using your Google account ([[Google OAuth Flow]]).
|
||||
- **Passkey / Touch ID / Face ID** — passwordless after first sign-up ([[Passkey (WebAuthn) Flow]]).
|
||||
|
||||
### 1.2 Verify your email
|
||||
|
||||
1. Open the verification email and copy the 6-digit code.
|
||||
2. Paste into the **Verify** page (`/auth/jwt/verify`).
|
||||
3. Click **Verify**.
|
||||
4. You land on the dashboard.
|
||||
|
||||
> [!tip]
|
||||
> Until verified, a yellow banner appears on every page asking you to verify. Some actions (paying, opening disputes) are gated behind email verification.
|
||||
|
||||
### 1.3 Complete your profile
|
||||
|
||||
Go to **Dashboard → Account → Profile** and fill in:
|
||||
|
||||
| Field | Why it matters |
|
||||
|---|---|
|
||||
| Display name | Shown to sellers in chat / on offers |
|
||||
| Avatar | Helps sellers recognize repeat buyers |
|
||||
| Phone number | Used for delivery coordination (optional) |
|
||||
| Country / Language | Sets default currency display & language |
|
||||
|
||||
---
|
||||
|
||||
## 2. Adding addresses
|
||||
|
||||
Delivery addresses are required before some sellers will accept your offer.
|
||||
|
||||
1. **Dashboard → Account → Address**.
|
||||
2. Click **+ Add address**.
|
||||
3. Fill in: label (Home, Office, …), recipient name, country, full address, postal code, phone.
|
||||
4. Set one as **Primary** — it will be auto-filled into new purchase requests.
|
||||
5. You can add up to **N** addresses (depending on tier).
|
||||
|
||||
---
|
||||
|
||||
## 3. Connecting a wallet
|
||||
|
||||
If you want to pay via **Web3** instead of SHKeeper invoice:
|
||||
|
||||
1. **Dashboard → Account → Wallet**.
|
||||
2. Click **Connect Wallet**.
|
||||
3. Choose **MetaMask** (browser) or **WalletConnect** (mobile).
|
||||
4. Approve the connection request in your wallet.
|
||||
5. The connected address appears as a chip. You can disconnect anytime.
|
||||
|
||||
> [!info]
|
||||
> Connecting a wallet is **optional**. SHKeeper QR payments work without one. See [[Payment Flow - DePay & Web3]].
|
||||
|
||||
---
|
||||
|
||||
## 4. Setting up Passkeys (recommended)
|
||||
|
||||
1. **Dashboard → Account → Passkey**.
|
||||
2. Click **Add passkey**.
|
||||
3. Follow your device's prompt (Touch ID, Face ID, Windows Hello, USB key…).
|
||||
4. Name the passkey ("MacBook", "iPhone 15", …) and save.
|
||||
5. Next sign-in, you'll see a **Sign in with passkey** button.
|
||||
|
||||
Multiple passkeys can be added — one per device.
|
||||
|
||||
---
|
||||
|
||||
## 5. Browsing the marketplace
|
||||
|
||||
| Where | What you see |
|
||||
|---|---|
|
||||
| **`/shop`** | Public list of sellers & featured items |
|
||||
| **`/shop/[seller]`** | A specific seller's shop |
|
||||
| **`/shop/[seller]/[id]`** | An item / request template detail |
|
||||
| **`/post`** | Blog posts (announcements, tutorials) |
|
||||
| **Dashboard → Overview** | Your personalised dashboard |
|
||||
|
||||
---
|
||||
|
||||
## 6. Creating a Purchase Request
|
||||
|
||||
A **Purchase Request** is a description of what you want to buy. Sellers see open requests and submit offers.
|
||||
|
||||
### 6.1 From scratch
|
||||
|
||||
1. **Dashboard → Request → + New Request**.
|
||||
2. Fill in the multi-step form:
|
||||
- **Step 1 — Basics**: title, category, sub-category.
|
||||
- **Step 2 — Details**: rich description (use the editor — markdown, images), budget range, currency.
|
||||
- **Step 3 — Delivery**: pick an address, preferred delivery date.
|
||||
- **Step 4 — Attachments**: upload reference photos, spec sheets (max 5 MB each).
|
||||
- **Step 5 — Visibility**: public (any seller can offer) or invite-only (specific sellers).
|
||||
3. Click **Save as draft** to come back later, or **Publish** to make it live.
|
||||
|
||||
> [!tip]
|
||||
> Use the **AI-assist** button next to the description field — it suggests phrasing improvements (uses OpenAI). See [[AI API]].
|
||||
|
||||
### 6.2 From a template
|
||||
|
||||
If a seller shared a request template URL:
|
||||
|
||||
1. Open the link (or **Dashboard → Request → From template**).
|
||||
2. Pre-filled with the seller's defaults.
|
||||
3. Tweak quantity / customisations.
|
||||
4. Publish — the original seller is automatically invited.
|
||||
|
||||
See [[Purchase Request Flow]] for the full state machine.
|
||||
|
||||
### 6.3 Managing your requests
|
||||
|
||||
**Dashboard → Request** shows all your requests with status:
|
||||
|
||||
| Status | Meaning |
|
||||
|---|---|
|
||||
| Draft | Saved, not yet published |
|
||||
| Open | Live; sellers can offer |
|
||||
| In negotiation | At least one offer received, conversation active |
|
||||
| Accepted | You accepted an offer; awaiting payment |
|
||||
| Funded | Payment in escrow; awaiting delivery |
|
||||
| Shipped | Seller marked as shipped |
|
||||
| Delivered | You confirmed receipt; escrow released |
|
||||
| Completed | Closed; can leave a review |
|
||||
| Disputed | A dispute is open |
|
||||
| Cancelled | Cancelled before payment |
|
||||
| Refunded | Money returned (dispute outcome) |
|
||||
| Expired | Open too long with no acceptable offers |
|
||||
|
||||
Click a request to:
|
||||
|
||||
- See offers (compare prices, delivery times, seller ratings).
|
||||
- Chat with a specific seller.
|
||||
- Edit (only while in **Draft** or **Open**).
|
||||
- Cancel (only while no payment is in flight).
|
||||
|
||||
---
|
||||
|
||||
## 7. Receiving and reviewing offers
|
||||
|
||||
When a seller submits an offer:
|
||||
|
||||
1. You get a **notification** (bell badge + email if enabled).
|
||||
2. Click into the request → **Offers** tab.
|
||||
3. Compare offers side-by-side:
|
||||
- Price
|
||||
- Delivery time
|
||||
- Seller rating + completed orders
|
||||
- Optional notes (use of materials, brand, etc.)
|
||||
|
||||
### 7.1 Negotiate
|
||||
|
||||
Each offer has a **Chat** button. Use it to:
|
||||
|
||||
- Ask clarifying questions.
|
||||
- Request a counter-offer (lower price, faster delivery).
|
||||
- Discuss customisations.
|
||||
|
||||
The seller can issue a **counter-offer** which appears as a new offer entry on the same chat. See [[Negotiation Flow]].
|
||||
|
||||
### 7.2 Accept
|
||||
|
||||
Click **Accept this offer** → confirmation modal → **Confirm**.
|
||||
|
||||
Effects:
|
||||
- All other open offers on this request are auto-rejected.
|
||||
- The request status moves to **Accepted**.
|
||||
- The chat continues with this seller only.
|
||||
- You are prompted to pay.
|
||||
|
||||
---
|
||||
|
||||
## 8. Paying for an order
|
||||
|
||||
Two payment paths. Pick at the **Pay** step.
|
||||
|
||||
### 8.1 Path A — SHKeeper invoice (recommended for non-crypto-native users)
|
||||
|
||||
1. Click **Pay with crypto invoice**.
|
||||
2. Choose a token + network (e.g., USDT on BSC).
|
||||
3. A QR code + address appears.
|
||||
4. Open your wallet (any wallet that supports the network).
|
||||
5. Scan the QR, send the exact amount, confirm in your wallet.
|
||||
6. The page updates in real-time as the blockchain confirms (typically 30s–5 min).
|
||||
7. Status moves to **Funded** when fully confirmed.
|
||||
|
||||
> [!warning]
|
||||
> Send the **exact** amount on the **exact** network. Sending USDT on the wrong network (e.g., ERC-20 instead of BSC) WILL lose your funds. The displayed network is binding.
|
||||
|
||||
See [[Payment Flow - SHKeeper]].
|
||||
|
||||
### 8.2 Path B — Direct Web3 wallet
|
||||
|
||||
1. Click **Pay from connected wallet** (requires a connected wallet — see §3).
|
||||
2. Your wallet pops up a transaction approval (token transfer to escrow address).
|
||||
3. Approve & sign.
|
||||
4. Wait for on-chain confirmation.
|
||||
5. Backend verifies the transaction and moves status to **Funded**.
|
||||
|
||||
See [[Payment Flow - DePay & Web3]].
|
||||
|
||||
---
|
||||
|
||||
## 9. Tracking the order
|
||||
|
||||
Once **Funded**, the seller is notified to ship.
|
||||
|
||||
1. Seller marks the order **Shipped** — you receive a notification + optional tracking number.
|
||||
2. When you receive the item, return to the request page → **Confirm receipt**.
|
||||
3. This releases the escrow to the seller and moves status to **Delivered**.
|
||||
|
||||
See [[Delivery Confirmation Flow]].
|
||||
|
||||
> [!warning]
|
||||
> Only confirm after you've received and inspected the item. Confirming is **irreversible** — to reverse, you must open a dispute.
|
||||
|
||||
---
|
||||
|
||||
## 10. Opening a dispute
|
||||
|
||||
If something is wrong (item not as described, never arrived, wrong quantity…):
|
||||
|
||||
1. On the request page → **Open dispute**.
|
||||
2. Choose a reason (Not received / Damaged / Not as described / Other).
|
||||
3. Upload evidence (photos, screenshots — max 5 MB each).
|
||||
4. Write a clear narrative.
|
||||
5. Submit.
|
||||
|
||||
An **admin mediator** is assigned within 24 hours. You and the seller will both be able to chat with the mediator and submit additional evidence.
|
||||
|
||||
Possible outcomes:
|
||||
|
||||
- **Full refund** — escrow returns to you.
|
||||
- **Partial refund** — split between you and seller.
|
||||
- **Release to seller** — your dispute is denied; escrow goes to seller.
|
||||
- **Mutual cancellation** — both parties agree to refund.
|
||||
|
||||
See [[Dispute Flow]].
|
||||
|
||||
---
|
||||
|
||||
## 11. Rating
|
||||
|
||||
After the order is **Completed** (delivered or dispute resolved):
|
||||
|
||||
1. You're prompted to leave a rating + review.
|
||||
2. Stars 1–5, plus a free-text comment (optional but encouraged).
|
||||
3. The seller also rates you.
|
||||
|
||||
Ratings affect seller reputation (visible on their shop page). See [[Rating Flow]].
|
||||
|
||||
---
|
||||
|
||||
## 12. Earning & spending points
|
||||
|
||||
Points are earned via:
|
||||
|
||||
- Sign-up bonus
|
||||
- Each completed purchase (X points per $1 — see [[LevelConfig]] for tiers)
|
||||
- Referrals (you and the referred friend both get points)
|
||||
- Special bonuses (admin-awarded for promotions)
|
||||
|
||||
**Dashboard → Points** shows:
|
||||
|
||||
- **Balance** — current points
|
||||
- **Transactions** — history
|
||||
- **Levels** — current tier + benefits + next-tier threshold
|
||||
- **Referrals** — your unique referral link + invited friends
|
||||
|
||||
Higher levels unlock:
|
||||
|
||||
- Discount % on platform fee
|
||||
- Priority support
|
||||
- Free shipping (on partnered sellers)
|
||||
- Special offers / early access
|
||||
|
||||
Spend points by selecting "Use points" at checkout (subject to seller acceptance).
|
||||
|
||||
See [[Referral Flow]] and the [[PointTransaction]] model.
|
||||
|
||||
---
|
||||
|
||||
## 13. Settings
|
||||
|
||||
### 13.1 Theme & display
|
||||
|
||||
Click the **cog** icon in the topbar to open the **Settings drawer**:
|
||||
|
||||
- Light / Dark / System mode
|
||||
- Color preset
|
||||
- Layout (Vertical / Mini / Horizontal)
|
||||
- Direction (auto from language, override possible)
|
||||
- Font family
|
||||
- Border radius
|
||||
|
||||
See [[Settings & Theming]].
|
||||
|
||||
### 13.2 Language
|
||||
|
||||
Click the **flag chip** in the topbar to switch language. Persian and Arabic automatically flip the UI to RTL. See [[Internationalization & RTL]].
|
||||
|
||||
### 13.3 Notifications
|
||||
|
||||
**Dashboard → Account → Notifications**:
|
||||
|
||||
- Email notifications (on/off per category)
|
||||
- In-app notifications (cannot be fully disabled)
|
||||
- Push notifications (browser permission required)
|
||||
|
||||
### 13.4 Security
|
||||
|
||||
**Dashboard → Account → Security**:
|
||||
|
||||
- Change password
|
||||
- Manage passkeys
|
||||
- View active sessions / sign out elsewhere
|
||||
- Two-factor authentication (if enabled)
|
||||
|
||||
---
|
||||
|
||||
## 14. Common issues
|
||||
|
||||
### "I can't sign in"
|
||||
|
||||
1. Confirm you're using the correct email.
|
||||
2. Click **Forgot password** to reset.
|
||||
3. If multiple failed attempts, the account is **temporarily locked** for cooldown.
|
||||
4. If issue persists, contact support — see [[Support Guide]].
|
||||
|
||||
### "My email verification didn't arrive"
|
||||
|
||||
1. Check spam folder.
|
||||
2. Click **Resend code** on the verify page.
|
||||
3. Try a different email if the issue persists.
|
||||
|
||||
### "I sent USDT on the wrong network"
|
||||
|
||||
> [!warning]
|
||||
> If you sent the wrong token/network combination, funds are likely **unrecoverable**. Contact support immediately with the transaction hash — recovery may be possible if the receive address is shared across networks.
|
||||
|
||||
### "The page froze / I see stale data"
|
||||
|
||||
1. Refresh the page.
|
||||
2. If issue persists, paste the following in browser DevTools console (it clears stuck localStorage state):
|
||||
```js
|
||||
// From frontend/cleanup-localstorage.js
|
||||
Object.keys(localStorage).filter(k => /^(manual-payment|buyer-step|LAST_(PAYMENT|TEMPLATE|SOCKET))-/.test(k)).forEach(k => localStorage.removeItem(k));
|
||||
```
|
||||
3. Refresh.
|
||||
|
||||
### "I can't pay — wallet won't connect"
|
||||
|
||||
1. Make sure your wallet extension is installed and unlocked.
|
||||
2. Confirm you're on a **supported network** (BSC, Polygon, Ethereum, Sepolia).
|
||||
3. Try WalletConnect (QR code) on mobile.
|
||||
|
||||
### "I want to delete my account"
|
||||
|
||||
Contact support — account deletion is a manual operation by admins to ensure all funds and disputes are cleared first.
|
||||
|
||||
---
|
||||
|
||||
## 15. Tips & best practices
|
||||
|
||||
> [!tip]
|
||||
> - Save offers as drafts and revisit before publishing — it's harder to edit live requests.
|
||||
> - Read seller ratings AND read recent reviews (not just the score).
|
||||
> - Use chat early to clarify expectations — fewer disputes later.
|
||||
> - Confirm receipt only after physical inspection.
|
||||
> - Take photos at unboxing for evidence in case of dispute.
|
||||
> - Keep your wallet seed phrase offline.
|
||||
|
||||
---
|
||||
|
||||
## 16. Related
|
||||
|
||||
- [[Seller Guide]] · [[Admin Guide]] · [[Support Guide]]
|
||||
- Flows: [[Authentication Flow]] · [[Registration Flow]] · [[Purchase Request Flow]] · [[Payment Flow - SHKeeper]] · [[Payment Flow - DePay & Web3]] · [[Delivery Confirmation Flow]] · [[Dispute Flow]] · [[Rating Flow]] · [[Referral Flow]]
|
||||
- Models: [[User]] · [[PurchaseRequest]] · [[Payment]] · [[Address]]
|
||||
- [[Glossary]]
|
||||
380
07 - Development/Coding Standards.md
Normal file
380
07 - Development/Coding Standards.md
Normal file
@@ -0,0 +1,380 @@
|
||||
---
|
||||
title: Coding Standards
|
||||
tags: [development]
|
||||
---
|
||||
|
||||
# Coding Standards
|
||||
|
||||
This page is the authoritative source for code style, file layout, and review rules across both repos. The UI section is a condensation of `backend/.cursor/rules/ui-development-standards.mdc` — **that file is binding for any UI work**.
|
||||
|
||||
---
|
||||
|
||||
## 1. TypeScript
|
||||
|
||||
### Backend (`tsconfig.json`)
|
||||
|
||||
- `strict: true` — no implicit any, strict null checks, all the trimmings.
|
||||
- `target: ES2020`, `module: commonjs` (Node 22 ESM is not used yet).
|
||||
- Path aliases (use them, do not write deep relative imports):
|
||||
|
||||
```ts
|
||||
import { config } from "@shared/config"; // src/shared/config
|
||||
import { paymentSvc } from "@services/payment"; // src/services/payment
|
||||
import { redis } from "@infrastructure/redis"; // src/infrastructure/redis
|
||||
```
|
||||
|
||||
- `declaration: true` + `sourceMap: true` — keep this on. Source maps are required by Sentry stack traces.
|
||||
|
||||
### Frontend
|
||||
|
||||
- `strict: true`, `jsx: preserve` for Next.
|
||||
- All component props **must** be typed and exported (`export type ComponentNameProps = …`).
|
||||
- Prefer `type` over `interface` except when declaring something that must be extendable from a consumer module.
|
||||
- Re-export from a barrel `index.ts` per folder — never deep-import (`import x from 'src/components/foo/internal/x'`).
|
||||
|
||||
---
|
||||
|
||||
## 2. ESLint & Prettier
|
||||
|
||||
### Backend ESLint (`backend/eslint.config.js`)
|
||||
|
||||
Flat config, TypeScript-only:
|
||||
|
||||
- `@typescript-eslint/no-unused-vars: warn`
|
||||
- `@typescript-eslint/no-explicit-any: warn` — `any` is allowed when you justify it, but failing this rule will be flagged in code review.
|
||||
- `no-console: off` — backend uses `src/utils/logger.ts` (a thin `console.log` wrapper) so console statements are fine in scripts. Inside services, prefer `log(...)` from the logger so you can later swap to structured logging.
|
||||
|
||||
Commands:
|
||||
|
||||
```bash
|
||||
npm run lint # check
|
||||
npm run lint:fix # auto-fix
|
||||
npm run format # prettier --write src/**/*.ts
|
||||
npm run typecheck # tsc --noEmit
|
||||
```
|
||||
|
||||
### Frontend ESLint (`frontend/eslint.config.mjs`)
|
||||
|
||||
A heavier config combining `typescript-eslint`, `eslint-plugin-react`, `eslint-plugin-react-hooks`, `eslint-plugin-import`, `eslint-plugin-perfectionist` (for sorting), and `eslint-plugin-unused-imports`.
|
||||
|
||||
The most-cited rule in PR review: **import sorting**. The `perfectionist` plugin enforces this order — `eslint --fix` will reorder automatically:
|
||||
|
||||
```ts
|
||||
// 1. Style imports
|
||||
import './styles.css';
|
||||
|
||||
// 2. Side effects
|
||||
import 'react-hot-toast';
|
||||
|
||||
// 3. Type imports (always isolated)
|
||||
import type { ComponentProps } from '@mui/material';
|
||||
|
||||
// 4. External libraries
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
// 5. MUI components
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
|
||||
// 6. Internal (routes → hooks → utils → components → sections → auth → types)
|
||||
import { paths } from 'src/routes/paths';
|
||||
import { useAuthContext } from 'src/auth/hooks';
|
||||
import { formatNumber } from 'src/utils/format-number';
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
import { HomeHero } from 'src/sections/home';
|
||||
import type { User } from 'src/types/user';
|
||||
```
|
||||
|
||||
Run before pushing:
|
||||
|
||||
```bash
|
||||
yarn lint
|
||||
yarn lint:fix
|
||||
```
|
||||
|
||||
### Prettier
|
||||
|
||||
Both repos use Prettier defaults from the local config:
|
||||
|
||||
- 2-space indent
|
||||
- single quotes
|
||||
- trailing commas (`es5` on frontend, `all` on backend)
|
||||
- semicolons on
|
||||
|
||||
`yarn lint:fix` / `npm run format` both run Prettier.
|
||||
|
||||
---
|
||||
|
||||
## 3. Naming conventions
|
||||
|
||||
| Kind | Convention | Example |
|
||||
|------|------------|---------|
|
||||
| TS files (general) | kebab-case | `format-number.ts` |
|
||||
| React component file | kebab-case folder, `component.tsx` inside | `request-card/component.tsx` |
|
||||
| Class | PascalCase | `class PaymentService` |
|
||||
| Function | camelCase | `createPayInIntent()` |
|
||||
| React component | PascalCase | `RequestCard` |
|
||||
| Hook | camelCase starting with `use` | `useSocket`, `useAuthContext` |
|
||||
| Constant | SCREAMING_SNAKE | `MAX_FILE_SIZE` |
|
||||
| Mongoose model | PascalCase singular | `User`, `PurchaseRequest` |
|
||||
| Mongo collection | lowercase plural (auto) | `users`, `purchaserequests` |
|
||||
| Route handler | `<verb><Noun>` | `getRequestById`, `createOffer` |
|
||||
| Express route file | `<domain>Routes.ts` | `paymentRoutes.ts` |
|
||||
|
||||
---
|
||||
|
||||
## 4. Backend — service file layout
|
||||
|
||||
A typical service folder under `src/services/`:
|
||||
|
||||
```
|
||||
src/services/marketplace/
|
||||
├── index.ts # Barrel — only public exports
|
||||
├── marketplaceRoutes.ts # Router (express.Router) — auth middleware, validation, controller calls
|
||||
├── marketplaceController.ts # HTTP layer — parses req, calls service, formats response envelope
|
||||
├── marketplaceService.ts # Business logic — talks to models, throws domain errors
|
||||
└── marketplaceRepository.ts # Optional Mongoose query helpers (when service grows)
|
||||
```
|
||||
|
||||
### Response envelope
|
||||
|
||||
Every JSON response (success or error) uses the same envelope so the frontend can rely on a single response shape:
|
||||
|
||||
```ts
|
||||
// success
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: <payload>,
|
||||
message?: 'Optional human message',
|
||||
});
|
||||
|
||||
// error (always via next(err) → errorHandler middleware)
|
||||
res.status(err.status || 500).json({
|
||||
success: false,
|
||||
error: err.code || 'INTERNAL_ERROR',
|
||||
message: err.message,
|
||||
details?: err.details,
|
||||
});
|
||||
```
|
||||
|
||||
### Error handler pattern
|
||||
|
||||
Throw typed errors and let `src/shared/middleware/errorHandler.ts` catch them:
|
||||
|
||||
```ts
|
||||
// Controllers / services
|
||||
class HttpError extends Error {
|
||||
constructor(public status: number, public code: string, message: string, public details?: unknown) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
throw new HttpError(404, 'REQUEST_NOT_FOUND', 'Purchase request not found');
|
||||
```
|
||||
|
||||
Wrap async route handlers so rejected promises reach `next()`:
|
||||
|
||||
```ts
|
||||
const asyncHandler = (fn) => (req, res, next) =>
|
||||
Promise.resolve(fn(req, res, next)).catch(next);
|
||||
|
||||
router.get('/:id', asyncHandler(controller.getById));
|
||||
```
|
||||
|
||||
### Logging
|
||||
|
||||
Use `src/utils/logger.ts`:
|
||||
|
||||
```ts
|
||||
import { log, logError } from "src/utils/logger";
|
||||
|
||||
log(`✅ Payment ${id} confirmed`);
|
||||
logError("SHKeeper webhook verification failed", err);
|
||||
```
|
||||
|
||||
Never use raw `console.error` in service code — it bypasses Sentry breadcrumbs.
|
||||
|
||||
---
|
||||
|
||||
## 5. Frontend — UI standards
|
||||
|
||||
**Authoritative source: `backend/.cursor/rules/ui-development-standards.mdc`.** Summary below.
|
||||
|
||||
### Component file layout
|
||||
|
||||
For non-trivial components, use a folder:
|
||||
|
||||
```
|
||||
src/components/request-card/
|
||||
├── index.ts # Barrel: export * from './component'
|
||||
├── component.tsx # The React component
|
||||
├── classes.ts # Styled classes or sx fragments
|
||||
└── types.ts # Props type definitions
|
||||
```
|
||||
|
||||
For one-file atoms, a single `name.tsx` is fine.
|
||||
|
||||
### MUI sx-prop — array syntax
|
||||
|
||||
The codebase uses the **array form** of the `sx` prop everywhere, because it composes cleanly with the spread-from-parent pattern:
|
||||
|
||||
```tsx
|
||||
<Box
|
||||
sx={[
|
||||
{ p: 3, borderRadius: 2, bgcolor: 'background.paper' },
|
||||
(theme) => ({ [theme.breakpoints.up('md')]: { flexDirection: 'row' } }),
|
||||
isActive && { backgroundColor: 'primary.main' },
|
||||
...(Array.isArray(sx) ? sx : [sx]),
|
||||
]}
|
||||
{...other}
|
||||
/>
|
||||
```
|
||||
|
||||
Reasons:
|
||||
1. Conditional styles compose naturally (`condition && {...}` is ignored when false).
|
||||
2. Theme callbacks are first-class items in the array.
|
||||
3. The trailing spread allows parent overrides without prop drilling.
|
||||
|
||||
### No inline colors — use the theme
|
||||
|
||||
```tsx
|
||||
// ❌ Wrong
|
||||
sx={{ color: '#00A76F', bgcolor: '#FFF' }}
|
||||
|
||||
// ✅ Right
|
||||
sx={(theme) => ({
|
||||
color: theme.vars.palette.primary.main,
|
||||
bgcolor: 'background.paper',
|
||||
})}
|
||||
```
|
||||
|
||||
Use `theme.vars.palette` (CSS variables) — automatically dark/light aware.
|
||||
|
||||
### Forms — React Hook Form + Zod
|
||||
|
||||
All forms use `react-hook-form` with `zodResolver` and the `RHF*` components from `src/components/hook-form`:
|
||||
|
||||
```tsx
|
||||
const schema = z.object({
|
||||
email: z.string().email('Invalid email'),
|
||||
amount: z.number().min(1),
|
||||
});
|
||||
|
||||
const methods = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { email: '', amount: 0 },
|
||||
});
|
||||
|
||||
<FormProvider {...methods}>
|
||||
<form onSubmit={methods.handleSubmit(onSubmit)}>
|
||||
<RHFTextField name="email" label="Email" />
|
||||
<RHFTextField name="amount" type="number" label="Amount" />
|
||||
</form>
|
||||
</FormProvider>
|
||||
```
|
||||
|
||||
### Icons — Iconify only
|
||||
|
||||
There is exactly one icon component:
|
||||
|
||||
```tsx
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
|
||||
<Iconify icon="eva:home-fill" />
|
||||
<Iconify icon="solar:user-outline" width={24} height={24} />
|
||||
```
|
||||
|
||||
Do **not** introduce another icon library (no `lucide-react`, no `@mui/icons-material`). Iconify covers everything via its registered icon sets.
|
||||
|
||||
### Hooks
|
||||
|
||||
- File: `use-<kebab>.ts`. Component name: `useCamelCase`.
|
||||
- One hook per file. If a hook needs sub-helpers, colocate them in the same file.
|
||||
- Custom hooks live in `src/hooks/` if generic, otherwise next to the feature.
|
||||
|
||||
### Accessibility & responsiveness
|
||||
|
||||
Mandatory checks:
|
||||
|
||||
- Touch targets ≥ 44px.
|
||||
- Keyboard focus visible.
|
||||
- Color contrast meets WCAG AA (theme already conforms).
|
||||
- Use `Container` for page-level padding and the breakpoint system (`theme.breakpoints.up('md')`) for layouts.
|
||||
|
||||
---
|
||||
|
||||
## 6. Commit conventions
|
||||
|
||||
Authoritative file: `frontend/.commitlintrc.json` (extends `@commitlint/config-conventional`). Backend follows the same convention informally — the AI versioning script (`scripts/ai-enhanced.sh`) reads commit messages to decide on a major/minor/patch bump.
|
||||
|
||||
### Allowed types
|
||||
|
||||
`feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`, `perf`, `ci`, `build`, `revert`
|
||||
|
||||
### Rules enforced
|
||||
|
||||
- Type must be present, lower-case.
|
||||
- Subject must be present, lower-case.
|
||||
- Header (`type(scope): subject`) max 100 chars.
|
||||
|
||||
### Examples
|
||||
|
||||
```
|
||||
feat: add seller payout history table
|
||||
fix(payment): handle missing transaction hash from shkeeper
|
||||
docs: clarify env vars table
|
||||
refactor(auth): extract jwt refresh into helper
|
||||
chore: bump dependencies
|
||||
feat!: breaking change to api response shape
|
||||
```
|
||||
|
||||
### Breaking changes
|
||||
|
||||
Append `!` after the type or include `BREAKING CHANGE:` in the body. This triggers a major-version bump in the auto-version script. See [[Git Workflow#versioning]].
|
||||
|
||||
### Skip a version bump
|
||||
|
||||
Include `[skip-version]` anywhere in the message — the AI script will recognise it and skip the bump.
|
||||
|
||||
---
|
||||
|
||||
## 7. Testing standards
|
||||
|
||||
- All new code should be reachable by at least one test. See [[Testing]].
|
||||
- Test file naming: `*.test.ts(x)` for unit/integration, `*.spec.ts` for Playwright.
|
||||
- Place new backend tests in `__tests__/` — Jest discovers them via `**/__tests__/**/*.test.ts`.
|
||||
- Place new frontend tests in `__tests__/<domain>-test/` or colocate `Component.test.tsx` next to the component.
|
||||
|
||||
---
|
||||
|
||||
## 8. PR review checklist
|
||||
|
||||
Before requesting review:
|
||||
|
||||
- [ ] Lint passes (`yarn lint` / `npm run lint`)
|
||||
- [ ] Typecheck passes (`npm run typecheck`)
|
||||
- [ ] Relevant tests added / updated
|
||||
- [ ] No `console.log` in shipped code (frontend uses `src/utils/logger.ts`)
|
||||
- [ ] No new icon library introduced (Iconify only)
|
||||
- [ ] No inline hex colors (theme only)
|
||||
- [ ] Import order obeys the linter
|
||||
- [ ] Commit messages follow the convention
|
||||
- [ ] If touching env vars, [[Environment Variables]] is updated
|
||||
- [ ] If adding scripts, [[Scripts]] is updated
|
||||
|
||||
---
|
||||
|
||||
## 9. Banned patterns
|
||||
|
||||
| Don't | Do |
|
||||
|-------|-----|
|
||||
| `any` in new code | derive a precise type, fall back to `unknown` + narrowing |
|
||||
| `console.log` outside scripts | `log(...)` from `utils/logger` |
|
||||
| Deep relative imports (`../../../foo`) | path aliases (`@shared/foo` backend, `src/...` frontend) |
|
||||
| Inline `style={{ color: '#fff' }}` | `sx` prop with theme tokens |
|
||||
| `@mui/icons-material`, `react-icons`, `lucide-react` | `Iconify` |
|
||||
| `useState` for global state that 3+ components need | a context in `src/contexts/` or a custom hook |
|
||||
| Direct `axios.create` calls in components | use `src/lib/axios.ts` or an action in `src/actions/` |
|
||||
| Hard-coded URLs | constants in `src/routes/paths.ts` (frontend) or env vars (backend) |
|
||||
| Schema changes without a migration | add a migration script in `src/scripts/` and document it |
|
||||
276
07 - Development/Environment Variables.md
Normal file
276
07 - Development/Environment Variables.md
Normal file
@@ -0,0 +1,276 @@
|
||||
---
|
||||
title: Environment Variables
|
||||
tags: [development]
|
||||
---
|
||||
|
||||
# Environment Variables
|
||||
|
||||
Every environment variable read by either repo. Use this as the canonical reference when filling in a new `.env`, debugging missing-config errors, or reviewing a PR that touches config.
|
||||
|
||||
Sources scanned:
|
||||
|
||||
- Backend: `src/shared/config/index.ts`, `src/app.ts`, every `process.env.*` reference in `src/`, and `.env.sentry.example`.
|
||||
- Frontend: `.env.development`, `.env.local`, `.env.production`, `.env.sentry.example`, `next.config.ts`, all `process.env.*` references in `src/`.
|
||||
|
||||
> [!warning] Many secrets in the checked-in `.env.*` files of the frontend are publicly visible (Alchemy key, WalletConnect ID, Google OAuth client ID, Sentry DSN). Rotate these immediately if the repo leaks. Anything not marked `NEXT_PUBLIC_` is **not** exposed to the browser.
|
||||
|
||||
---
|
||||
|
||||
## How env is loaded
|
||||
|
||||
### Backend
|
||||
|
||||
`src/shared/config/index.ts` calls `dotenv.config({ path: '.env.development' })` and then `dotenv.config()` (default `.env`). In Docker dev, `docker-compose.dev.yml` injects `env_file: .env.local` and in production `env_file: .env`. There is **no fallback**: if a required var is missing, the typed access (`process.env.JWT_SECRET!`) yields `undefined` and the service crashes on first use.
|
||||
|
||||
### Frontend
|
||||
|
||||
Next.js auto-loads `.env`, `.env.local`, `.env.development`, `.env.production` in the standard precedence order. Only variables prefixed `NEXT_PUBLIC_` are exposed to the browser bundle. The production Dockerfile **hard-codes** several `NEXT_PUBLIC_*` values via `ENV` directives at build time so they are baked into the static bundle (see [[Docker Setup#frontend-dockerfile]]).
|
||||
|
||||
---
|
||||
|
||||
## Database
|
||||
|
||||
| Name | Repo | Required | Default | Example | Purpose |
|
||||
|------|------|----------|---------|---------|---------|
|
||||
| `MONGODB_URI` | backend | ✅ | — | `mongodb://mongodb:27017` | Mongo connection string (no auth in dev) |
|
||||
| `DB_NAME` | backend | ✅ | — | `marketplace` | Database name appended to the URI |
|
||||
|
||||
In `docker-compose.production.yml` the Mongo service is `mongodb` and is reachable as `mongodb://mongodb:27017` from the backend container.
|
||||
|
||||
---
|
||||
|
||||
## Cache / Redis
|
||||
|
||||
| Name | Repo | Required | Default | Example | Purpose |
|
||||
|------|------|----------|---------|---------|---------|
|
||||
| `REDIS_URI` | backend | ✅ | — | `redis://redis:6379` | Connection string used by `services/redis` |
|
||||
| `REDIS_PASSWORD` | backend | prod only | — | `super-secret` | Substituted into the prod Redis command line (`--requirepass`) |
|
||||
|
||||
In dev, Redis runs without a password. In production the compose entrypoint is `redis-server --requirepass "$REDIS_PASSWORD"` so include `:password@` in `REDIS_URI` accordingly.
|
||||
|
||||
---
|
||||
|
||||
## Auth / JWT
|
||||
|
||||
| Name | Repo | Required | Default | Example | Purpose |
|
||||
|------|------|----------|---------|---------|---------|
|
||||
| `JWT_SECRET` | backend | ✅ | — | 64 hex chars | HMAC key for access tokens |
|
||||
| `JWT_EXPIRES_IN` | backend | ✅ | — | `1h` | Access token lifetime |
|
||||
| `REFRESH_TOKEN_EXPIRES_IN` | backend | ✅ | — | `30d` | Refresh token lifetime |
|
||||
| `ADMIN_EMAIL` | backend | optional | `admin@marketplace.com` | — | Email of the initial admin created by `init-admin` |
|
||||
| `ADMIN_PASSWORD` | backend | optional | `Moji6364` | — | Password for the initial admin |
|
||||
| `ADMIN_FIRST_NAME` | backend | optional | `Admin` | — | First name for the seeded admin |
|
||||
| `ADMIN_LAST_NAME` | backend | optional | `User` | — | Last name for the seeded admin |
|
||||
| `GOOGLE_CLIENT_ID` | backend | optional | — | `...apps.googleusercontent.com` | Verifies Google ID tokens server-side |
|
||||
|
||||
> [!warning] Rotate `JWT_SECRET` only during a maintenance window — every active session is invalidated.
|
||||
|
||||
---
|
||||
|
||||
## Email / SMTP
|
||||
|
||||
| Name | Repo | Required | Default | Example | Purpose |
|
||||
|------|------|----------|---------|---------|---------|
|
||||
| `SMTP_HOST` | backend | ✅ | — | `smtp.zoho.com` | Outbound mail host |
|
||||
| `SMTP_PORT` | backend | ✅ | — | `465` | TCP port (numeric) |
|
||||
| `SMTP_SECURE` | backend | ✅ | — | `true` | `true` for TLS, `false` for STARTTLS |
|
||||
| `SMTP_USER` | backend | ✅ | — | `no-reply@amn.gg` | SMTP username |
|
||||
| `SMTP_PASS` | backend | ✅ | — | — | SMTP password (or app password) |
|
||||
| `SMTP_FROM` | backend | ✅ | — | `"AMN" <no-reply@amn.gg>` | Default `From` header |
|
||||
|
||||
---
|
||||
|
||||
## Payments — SHKeeper
|
||||
|
||||
SHKeeper is the crypto payment gateway. See [[Payment Flow]] and [[SHKeeper Integration]] in the architecture section.
|
||||
|
||||
| Name | Repo | Required | Default | Example | Purpose |
|
||||
|------|------|----------|---------|---------|---------|
|
||||
| `SHKEEPER_BASE_URL` | backend | ✅ | — | `https://shkeeper.example.com` | Base API URL |
|
||||
| `SHKEEPER_API_URL` | backend | ✅ | — | `https://shkeeper.example.com/api/v1` | Versioned API URL |
|
||||
| `SHKEEPER_API_KEY` | backend | ✅ | — | — | `X-Shkeeper-Api-Key` header |
|
||||
| `SHKEEPER_WEBHOOK_SECRET` | backend | ✅ | — | — | HMAC secret for inbound webhook signatures |
|
||||
| `SHKEEPER_CALLBACK_SECRET` | backend | ✅ | — | — | Older alias for webhook secret; some payloads still use it |
|
||||
| `SHKEEPER_ALLOWED_TOKENS` | backend | optional | `USDT,USDC` | `USDT,USDC,BTC` | Comma-separated list of accepted tokens |
|
||||
| `SHKEEPER_NETWORKS` | backend | optional | `bsc,polygon` | `bsc,polygon,eth` | Networks enabled in checkout |
|
||||
| `SHKEEPER_ENVIRONMENT` | backend | optional | `production` | `sandbox` | Switches SHKeeper sandbox vs prod behaviour |
|
||||
| `SHKEEPER_FORCE_PAYOUT_DEMO` | backend | optional | `false` | `true` | Skips real-chain payout; demo-confirms after 5s |
|
||||
| `SHKEEPER_FORCE_REAL` | backend | optional | `false` | `true` | Forces real-chain even in dev/sandbox |
|
||||
| `ADMIN_PAYOUT_WALLET_ADDRESS` | backend | ✅ for payouts | — | `0xAc23…` | Wallet that receives platform fees / payouts |
|
||||
| `ESCROW_WALLET_ADDRESS` | backend | ✅ | — | `0xa304…` | Master escrow address used by payments service |
|
||||
| `RECEIVER_WALLET_ADDRESS` | backend | optional | — | `0x…` | Used by alternative payout flows |
|
||||
|
||||
---
|
||||
|
||||
## Payments — DePay / Web3 (frontend)
|
||||
|
||||
| Name | Repo | Required | Default | Example | Purpose |
|
||||
|------|------|----------|---------|---------|---------|
|
||||
| `NEXT_PUBLIC_DEPAY_INTEGRATION_ID` | frontend | ✅ for DePay | — | `1330e2d3-…` | DePay widget integration ID |
|
||||
| `NEXT_PUBLIC_ESCROW_WALLET_ADDRESS` | frontend | ✅ | — | `0xa304…` | Escrow address shown to buyers in the wallet flow |
|
||||
| `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID` | frontend | ✅ | — | `283b54dd…` | WalletConnect v2 project ID |
|
||||
| `NEXT_PUBLIC_ALCHEMY_API_KEY_MAINNET` | frontend | ✅ | — | — | Alchemy RPC for mainnet |
|
||||
| `NEXT_PUBLIC_ALCHEMY_API_KEY_POLYGON` | frontend | ✅ | — | — | Alchemy RPC for Polygon |
|
||||
| `NEXT_PUBLIC_ALCHEMY_API_KEY_SEPOLIA` | frontend | optional | — | — | Alchemy RPC for Sepolia (testing) |
|
||||
|
||||
---
|
||||
|
||||
## OAuth
|
||||
|
||||
| Name | Repo | Required | Default | Example | Purpose |
|
||||
|------|------|----------|---------|---------|---------|
|
||||
| `GOOGLE_CLIENT_ID` | backend | optional | — | `…apps.googleusercontent.com` | Verifies Google ID tokens server-side |
|
||||
| `NEXT_PUBLIC_GOOGLE_CLIENT_ID` | frontend | ✅ for Google login | — | `…apps.googleusercontent.com` | Client ID used by Google Identity Services in the browser |
|
||||
|
||||
---
|
||||
|
||||
## OpenAI
|
||||
|
||||
| Name | Repo | Required | Default | Example | Purpose |
|
||||
|------|------|----------|---------|---------|---------|
|
||||
| `OPENAI_API_KEY` | backend | ✅ for AI features | — | `sk-…` | Used by `services/ai` |
|
||||
| `OPENAI_DEFAULT_MODEL` | backend | ✅ for AI | `gpt-4o-mini` | `gpt-4o` | Default chat model |
|
||||
| `OPENAI_MODEL` | backend | optional | falls back to default | `gpt-4o` | Per-call override read in legacy paths |
|
||||
| `OPENAI_MAX_TOKENS` | backend | ✅ for AI | `1024` | `4096` | Hard cap per request |
|
||||
| `OPENAI_TEMPERATURE` | backend | ✅ for AI | `0.7` | `0.2` | Decoded as float |
|
||||
|
||||
---
|
||||
|
||||
## App URLs
|
||||
|
||||
| Name | Repo | Required | Default | Example | Purpose |
|
||||
|------|------|----------|---------|---------|---------|
|
||||
| `FRONTEND_URL` | backend | ✅ | — | `http://localhost:8083` | Used by CORS, Socket.IO `origin`, password-reset email links |
|
||||
| `BACKEND_URL` | backend | optional | derived from `PORT` | `http://localhost:5001` | Used in webhook callbacks & emails |
|
||||
| `API_URL` | backend | optional | `${BACKEND_URL}/api` | `http://localhost:5001/api` | Self-reference for outbound webhooks |
|
||||
| `PORT` | backend | ✅ | — | `5001` | HTTP listen port |
|
||||
| `TRUST_PROXY` | backend | optional | auto-on in prod | `true` | Enables `app.set('trust proxy', 1)` for Nginx |
|
||||
| `NEXT_PUBLIC_APP_URL` | frontend | ✅ | — | `http://localhost:8083` | Self-URL used in metadata + OG tags |
|
||||
| `NEXT_PUBLIC_APP_NAME` | frontend | optional | `AMN` | `ایسکرو آنلاین` | Display name in nav / titles |
|
||||
| `NEXT_PUBLIC_APP_VERSION` | frontend | optional | `package.json` | `1.0.2` | Shown in the version logger |
|
||||
| `NEXT_PUBLIC_API_URL` | frontend | ✅ | — | `http://localhost:5001/api` | Axios base URL |
|
||||
| `NEXT_PUBLIC_API_BASE_URL` | frontend | optional | derived | `http://localhost:5001` | Used by a few legacy callers |
|
||||
| `NEXT_PUBLIC_BACKEND_URL` | frontend | ✅ | — | `http://localhost:5001` | Used by file URL builders |
|
||||
| `NEXT_PUBLIC_SERVER_URL` | frontend | optional | mirror of backend URL | `http://localhost:5001` | Server-side rendering fallback |
|
||||
| `NEXT_PUBLIC_SOCKET_URL` | frontend | ✅ | — | `http://localhost:5001` | Socket.IO endpoint |
|
||||
| `NEXT_PUBLIC_ASSETS_DIR` | frontend | optional | `""` | `/assets` | Prefix for static asset URLs |
|
||||
| `NEXT_PUBLIC_MAPBOX_API_KEY` | frontend | optional | — | `pk.…` | Mapbox token for delivery map |
|
||||
|
||||
---
|
||||
|
||||
## Rate limiting / files
|
||||
|
||||
| Name | Repo | Required | Default | Example | Purpose |
|
||||
|------|------|----------|---------|---------|---------|
|
||||
| `RATE_LIMIT_WINDOW_MS` | backend | ✅ | — | `900000` | Express-rate-limit window |
|
||||
| `RATE_LIMIT_MAX_REQUESTS` | backend | ✅ | — | `100` | Requests allowed per window per IP |
|
||||
| `MAX_FILE_SIZE` | backend | ✅ | — | `10485760` (10 MB) | Multer upload cap (bytes) |
|
||||
| `UPLOAD_PATH` | backend | optional | `/app/uploads` | `/var/uploads` | Disk path for uploads (mounted volume) |
|
||||
|
||||
---
|
||||
|
||||
## Feature flags / seeding
|
||||
|
||||
| Name | Repo | Required | Default | Example | Purpose |
|
||||
|------|------|----------|---------|---------|---------|
|
||||
| `AUTO_SEED_ON_START` | backend | optional | `false` | `true` | Re-seeds users/addresses/templates on boot if `users` is empty |
|
||||
| `SEED_USERS` | backend | optional | `false` | `true` | One-shot user seeding flag honoured by `seedUsers.ts` |
|
||||
| `FORCE_SEED_TEMPLATES` | backend | optional | `false` | `true` | Re-creates request templates even if some exist |
|
||||
| `BUILD_STATIC_EXPORT` | frontend | optional | `false` | `true` | Static export build (currently unused) |
|
||||
| `NEXT_PUBLIC_IS_DEVELOPMENT` | frontend | optional | `false` | `true` | Shows the dev-only banner & debug helpers |
|
||||
| `NEXT_PUBLIC_ENABLE_DEBUG` | frontend | optional | `false` | `true` | Verbose console logging in the browser |
|
||||
| `NEXT_TELEMETRY_DISABLED` | frontend | optional | `0` | `1` | Disables Next.js telemetry |
|
||||
|
||||
---
|
||||
|
||||
## Passkey / WebAuthn (frontend)
|
||||
|
||||
| Name | Repo | Required | Default | Example | Purpose |
|
||||
|------|------|----------|---------|---------|---------|
|
||||
| `NEXT_PUBLIC_PASSKEY_RP_NAME` | frontend | optional | `Amn` | `Amn` | Relying-party display name |
|
||||
| `NEXT_PUBLIC_PASSKEY_RP_ID` | frontend | optional | `localhost` | `amn.gg` | Relying-party origin host |
|
||||
| `NEXT_PUBLIC_PASSKEY_ORIGIN` | frontend | optional | derived | `https://amn.gg` | Allowed origin for the WebAuthn challenge |
|
||||
|
||||
---
|
||||
|
||||
## Sentry
|
||||
|
||||
| Name | Repo | Required | Default | Example | Purpose |
|
||||
|------|------|----------|---------|---------|---------|
|
||||
| `NEXT_PUBLIC_SENTRY_DSN` | frontend | optional | — | `https://…ingest.sentry.io/…` | Browser & server Sentry DSN |
|
||||
| `SENTRY_ORG` | frontend | build-time | — | `manawenuz` | Used by `@sentry/nextjs` source-map upload |
|
||||
| `SENTRY_PROJECT` | frontend | build-time | — | `escrow-frontend` | Sentry project slug |
|
||||
| `SENTRY_AUTH_TOKEN` | frontend | build-time | — | — | Auth token for source-map upload (CI secret) |
|
||||
| `SENTRY_SUPPRESS_INSTRUMENTATION_FILE_WARNING` | frontend | optional | — | `1` | Silences known dev warning |
|
||||
| `SENTRY_SUPPRESS_GLOBAL_ERROR_HANDLER_FILE_WARNING` | frontend | optional | — | `1` | Silences known dev warning |
|
||||
| `SENTRY_DSN` | backend | optional | — | `https://…ingest.sentry.io/…` | Backend Sentry DSN (set in `src/config/sentry.ts`) |
|
||||
|
||||
The backend Sentry init runs **before** any other import (`src/app.ts` line 1) so DSN must be present in the env at process start.
|
||||
|
||||
---
|
||||
|
||||
## Quick `.env.local` template (backend, dev)
|
||||
|
||||
```bash
|
||||
NODE_ENV=development
|
||||
PORT=5001
|
||||
TRUST_PROXY=false
|
||||
|
||||
# Database
|
||||
MONGODB_URI=mongodb://mongodb:27017
|
||||
DB_NAME=marketplace
|
||||
|
||||
# Cache
|
||||
REDIS_URI=redis://redis:6379
|
||||
|
||||
# Auth
|
||||
JWT_SECRET=<openssl rand -hex 32>
|
||||
JWT_EXPIRES_IN=1h
|
||||
REFRESH_TOKEN_EXPIRES_IN=30d
|
||||
|
||||
# URLs
|
||||
FRONTEND_URL=http://localhost:8083
|
||||
BACKEND_URL=http://localhost:5001
|
||||
|
||||
# Rate limit
|
||||
RATE_LIMIT_WINDOW_MS=900000
|
||||
RATE_LIMIT_MAX_REQUESTS=100
|
||||
|
||||
# Files
|
||||
MAX_FILE_SIZE=10485760
|
||||
UPLOAD_PATH=/app/uploads
|
||||
|
||||
# SMTP
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=465
|
||||
SMTP_SECURE=true
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
SMTP_FROM="AMN <no-reply@amn.gg>"
|
||||
|
||||
# SHKeeper (set when ready)
|
||||
SHKEEPER_BASE_URL=
|
||||
SHKEEPER_API_URL=
|
||||
SHKEEPER_API_KEY=
|
||||
SHKEEPER_WEBHOOK_SECRET=
|
||||
SHKEEPER_CALLBACK_SECRET=
|
||||
SHKEEPER_FORCE_PAYOUT_DEMO=true
|
||||
|
||||
# OpenAI (optional)
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_DEFAULT_MODEL=gpt-4o-mini
|
||||
OPENAI_MAX_TOKENS=1024
|
||||
OPENAI_TEMPERATURE=0.7
|
||||
|
||||
# Seeding
|
||||
AUTO_SEED_ON_START=true
|
||||
|
||||
# Wallets
|
||||
ESCROW_WALLET_ADDRESS=0xa3049825c0785095EEd5E7976E0E539466c84044
|
||||
ADMIN_PAYOUT_WALLET_ADDRESS=
|
||||
|
||||
# OAuth
|
||||
GOOGLE_CLIENT_ID=
|
||||
```
|
||||
|
||||
> [!tip] Generate `JWT_SECRET` deterministically per environment so you don't accidentally invalidate sessions when restarting. Store it in your team's secret manager.
|
||||
219
07 - Development/Git Workflow.md
Normal file
219
07 - Development/Git Workflow.md
Normal file
@@ -0,0 +1,219 @@
|
||||
---
|
||||
title: Git Workflow
|
||||
tags: [development]
|
||||
---
|
||||
|
||||
# Git Workflow
|
||||
|
||||
How code moves from a developer's laptop to production.
|
||||
|
||||
---
|
||||
|
||||
## 1. Repositories & branches
|
||||
|
||||
Both repos are hosted on the self-hosted Gitea instance at **`git.manko.yoga`** (SSH on **port 222**, HTTPS on 443).
|
||||
|
||||
| Repo | Path | Branches |
|
||||
|------|------|----------|
|
||||
| Backend | `ssh://git@git.manko.yoga:222/nick/backend.git` | `main`, `development`, `feature/*`, `fix/*` |
|
||||
| Frontend | `ssh://git@git.manko.yoga:222/nick/frontend.git` | `main`, `development`, `feature/*`, `fix/*` |
|
||||
|
||||
### Branch roles
|
||||
|
||||
| Branch | Role | Auto-deploy |
|
||||
|--------|------|-------------|
|
||||
| `main` (or `master`) | **Production.** Always deployable. | Yes — Gitea Actions builds + pushes `:latest`, Watchtower then pulls it. See [[CI-CD Pipeline]] and [[Deployment]]. |
|
||||
| `development` | **Active dev.** Integration branch where features land before promotion. | Yes — builds + pushes `:dev` tag (not consumed by prod Watchtower). |
|
||||
| `feature/<short-slug>` | One change in flight. Branched from `development`. | No |
|
||||
| `fix/<short-slug>` | A bug fix. Branched from `development` (or `main` for a hotfix). | No |
|
||||
| `hotfix/<slug>` | Urgent production fix branched from `main`. | No until merged |
|
||||
|
||||
```
|
||||
main ─●────────●───────────────●───────► (prod, tagged v2.6.x)
|
||||
▲ release merge
|
||||
development ─●────●────●───●───●────●────●────●──► │
|
||||
▲ ▲ ▲ ▲ ▲ │
|
||||
│ │ │ │ │ │
|
||||
feature/* branches off development │
|
||||
hotfix/* ─────────────────────────────●──────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Day-to-day developer flow
|
||||
|
||||
```bash
|
||||
# 1. Sync development
|
||||
git checkout development
|
||||
git pull --rebase
|
||||
|
||||
# 2. Create a branch
|
||||
git checkout -b feature/add-seller-payouts
|
||||
|
||||
# 3. Hack
|
||||
# ...
|
||||
|
||||
# 4. Verify locally
|
||||
npm run lint && npm run typecheck && npm run test
|
||||
# or for frontend
|
||||
yarn lint && yarn test
|
||||
|
||||
# 5. Commit (Conventional Commits — see Coding Standards)
|
||||
git commit -m "feat(payment): add seller payout history view"
|
||||
|
||||
# 6. Push & open PR
|
||||
git push -u origin feature/add-seller-payouts
|
||||
```
|
||||
|
||||
Open the PR in Gitea against `development`. CI will run on the PR if you have workflows enabled.
|
||||
|
||||
> [!tip] Use the `.gitmessage` template (`git config commit.template .gitmessage`) — it pre-fills the conventional-commit format with hints.
|
||||
|
||||
---
|
||||
|
||||
## 3. Commit message convention
|
||||
|
||||
Enforced on the frontend by `frontend/.commitlintrc.json` (and followed by convention on backend). See [[Coding Standards#commit-conventions]] for the full rules and examples.
|
||||
|
||||
Quick reference:
|
||||
|
||||
```
|
||||
type(scope): subject
|
||||
|
||||
feat: add user authentication
|
||||
fix(payment): handle missing tx hash
|
||||
docs: clarify env vars table
|
||||
chore: bump dependencies
|
||||
feat!: redesign API endpoints # breaking change
|
||||
```
|
||||
|
||||
Append `[skip-version]` to skip the auto-version bump when releasing.
|
||||
|
||||
---
|
||||
|
||||
## 4. PR review process
|
||||
|
||||
1. Open the PR with a description that covers **what**, **why**, and **how to test**.
|
||||
2. Link to the related ticket / issue.
|
||||
3. CI must be green (build, lint, tests where wired up).
|
||||
4. **At least one reviewer** approves.
|
||||
5. Squash-merge into `development` with a clean conventional-commit message.
|
||||
6. Delete the source branch.
|
||||
|
||||
Reviewers check against [[Coding Standards#pr-review-checklist]].
|
||||
|
||||
> [!warning] Do **not** force-push to a PR branch after review starts — it invalidates review history. Use additional commits and let the squash-merge tidy up.
|
||||
|
||||
---
|
||||
|
||||
## 5. Releasing to production
|
||||
|
||||
The release path is `development → main → registry → production`.
|
||||
|
||||
```bash
|
||||
# On a clean development that's ready to ship:
|
||||
git checkout development
|
||||
git pull --rebase
|
||||
|
||||
# Run AI-assisted version bump (chooses major/minor/patch from commit messages)
|
||||
npm run smart-release
|
||||
# This: bumps package.json, commits "chore: bump version to vX.Y.Z",
|
||||
# creates a `vX.Y.Z` tag, and pushes commits + tags.
|
||||
|
||||
# Promote to main
|
||||
git checkout main
|
||||
git pull --rebase
|
||||
git merge --ff-only development # fast-forward only — no merge commit
|
||||
git push
|
||||
```
|
||||
|
||||
What happens next:
|
||||
|
||||
- Pushing to `main` triggers the `docker-build-no-cache.yml` workflow on the backend (and `deploy.yml` on the frontend) — see [[CI-CD Pipeline]].
|
||||
- That workflow builds the image and pushes both `:<version>` and `:latest` to `git.manko.yoga/manawenuz/escrow-backend`.
|
||||
- **Watchtower** on the production host (see [[Deployment]]) polls the registry every interval, detects the new `:latest`, and rolls the production container.
|
||||
|
||||
### Manual builds
|
||||
|
||||
If you want to push a specific version without going through main, use:
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
npm run release:patch # commits + tags + pushes; CI handles the rest
|
||||
./scripts/build-and-push.sh # build locally and push dev tag
|
||||
|
||||
# Frontend
|
||||
./scripts/deploy.sh # local build + push :latest
|
||||
```
|
||||
|
||||
See [[Scripts]] for details.
|
||||
|
||||
---
|
||||
|
||||
## 6. Versioning
|
||||
|
||||
Semantic versioning, automated where possible.
|
||||
|
||||
- `package.json` `version` is the source of truth.
|
||||
- The AI version scripts (`backend/scripts/ai-enhanced.sh`, `frontend/scripts/ai-enhanced.sh`) classify the last commit:
|
||||
- `feat:` → minor
|
||||
- `fix:` → patch
|
||||
- `feat!:` or `BREAKING CHANGE:` → major
|
||||
- `docs:`/`chore:`/`[skip-version]` → skip
|
||||
- `auto-version.sh` applies the bump, commits, tags `v<version>`, and (with the npm wrappers) pushes.
|
||||
|
||||
Confidence levels (`high`/`medium`/`low`/`very-low`) gate the action — low confidence asks for a manual decision rather than auto-applying.
|
||||
|
||||
The CI workflows tag built images with both the `package.json` version and the moving tag (`:latest` for main, `:dev` for development).
|
||||
|
||||
---
|
||||
|
||||
## 7. Hotfix flow
|
||||
|
||||
```bash
|
||||
# Branch from main
|
||||
git checkout main && git pull --rebase
|
||||
git checkout -b hotfix/critical-payment-bug
|
||||
|
||||
# Fix, test, commit
|
||||
git commit -m "fix(payment): correct rounding on payout amount"
|
||||
|
||||
# Push + PR into main
|
||||
git push -u origin hotfix/critical-payment-bug
|
||||
# (open PR targeted at main; merge after review)
|
||||
|
||||
# After merge, port the fix back to development:
|
||||
git checkout development && git pull --rebase
|
||||
git merge --no-ff main
|
||||
git push
|
||||
```
|
||||
|
||||
Bump the patch version on `main` before merging so the new image gets a real tag, not just `:latest`.
|
||||
|
||||
---
|
||||
|
||||
## 8. SSH access on port 222
|
||||
|
||||
Gitea SSH listens on **222**, not 22, so the URL is always `ssh://git@git.manko.yoga:222/...`. Add this once to `~/.ssh/config`:
|
||||
|
||||
```ssh
|
||||
Host git.manko.yoga
|
||||
HostName git.manko.yoga
|
||||
Port 222
|
||||
User git
|
||||
IdentityFile ~/.ssh/id_ed25519
|
||||
```
|
||||
|
||||
Then `git clone git@git.manko.yoga:nick/backend.git` "just works" without the explicit `:222`.
|
||||
|
||||
Container registry uses standard ports — `git.manko.yoga/manawenuz/escrow-backend:latest` over HTTPS — authenticated by a personal access token (`GITEATOKEN` secret in CI).
|
||||
|
||||
---
|
||||
|
||||
## 9. Rules of thumb
|
||||
|
||||
> [!tip] Keep PRs small. Anything > 500 LOC of net change is a candidate for splitting.
|
||||
|
||||
> [!warning] Never commit `.env*` files containing real secrets. The repos do already have committed `.env.development` / `.env.production` with **public** values — if you add a real secret, treat it as leaked and rotate.
|
||||
|
||||
> [!warning] Never force-push `main` or `development`. If you absolutely must rewrite history, coordinate with the team and re-tag the affected versions.
|
||||
273
07 - Development/Local Setup.md
Normal file
273
07 - Development/Local Setup.md
Normal file
@@ -0,0 +1,273 @@
|
||||
---
|
||||
title: Local Setup
|
||||
tags: [development]
|
||||
---
|
||||
|
||||
# Local Setup
|
||||
|
||||
This guide walks you through running both repositories of the marketplace stack on your workstation. The platform is split into two services:
|
||||
|
||||
- **Backend** — Node.js 22+ / Express 5 / MongoDB 8 / Redis 8 / Socket.IO, served on port `5001`.
|
||||
- **Frontend** — Next.js 16 / React 19 / MUI v7, served on port `8083` (or `3000` in Docker dev).
|
||||
|
||||
By the end of this page you will have the API running locally with MongoDB + Redis containers, a seeded set of test accounts, and the Next.js dashboard talking to it through your browser. For ongoing reference see [[Environment Variables]], [[Project Structure]], and [[Scripts]].
|
||||
|
||||
---
|
||||
|
||||
## 1. Prerequisites
|
||||
|
||||
Install the following before you start:
|
||||
|
||||
| Tool | Version | Why |
|
||||
|------|---------|-----|
|
||||
| Node.js | `>= 22` (backend), `>= 20` (frontend) | Runtime |
|
||||
| Yarn | `1.22.22` (Classic) | Pinned via `packageManager` field |
|
||||
| Docker Desktop | latest | Runs MongoDB + Redis + (optionally) backend/frontend |
|
||||
| Git | `>= 2.40` | SSH-based clone from Gitea |
|
||||
| OpenSSL | system default | For generating local secrets |
|
||||
| `ngrok` (optional) | latest | For webhook testing — see [[Scripts#start-ngrok-sh]] |
|
||||
|
||||
> [!tip] Use a single Node version manager (`nvm`, `fnm`, or `volta`) and pin to `22`. Yarn Classic is required — do **not** upgrade to Berry, the lockfiles are incompatible.
|
||||
|
||||
You also need an SSH key registered with Gitea. The Git server runs on a non-standard port (`222`), so add an entry to `~/.ssh/config`:
|
||||
|
||||
```ssh
|
||||
Host git.manko.yoga
|
||||
HostName git.manko.yoga
|
||||
Port 222
|
||||
User git
|
||||
IdentityFile ~/.ssh/id_ed25519
|
||||
```
|
||||
|
||||
Verify connectivity:
|
||||
|
||||
```bash
|
||||
ssh -T git@git.manko.yoga -p 222
|
||||
```
|
||||
|
||||
You should see a Gitea welcome line. If you see "Permission denied (publickey)", upload your public key to your Gitea profile first.
|
||||
|
||||
---
|
||||
|
||||
## 2. Clone the repos
|
||||
|
||||
The two repos are siblings — keep them next to each other (the production compose file references `../frontend` from the backend folder):
|
||||
|
||||
```bash
|
||||
mkdir -p ~/code && cd ~/code
|
||||
|
||||
git clone ssh://git@git.manko.yoga:222/nick/backend.git
|
||||
git clone ssh://git@git.manko.yoga:222/nick/frontend.git
|
||||
```
|
||||
|
||||
Switch each repo to the `development` branch:
|
||||
|
||||
```bash
|
||||
cd ~/code/backend && git checkout development
|
||||
cd ~/code/frontend && git checkout development
|
||||
```
|
||||
|
||||
> [!warning] `main`/`master` is the production branch and is consumed by the Watchtower auto-update flow. Never push WIP commits there. See [[Git Workflow]].
|
||||
|
||||
---
|
||||
|
||||
## 3. Install dependencies
|
||||
|
||||
Backend uses **npm** for scripts but `yarn install` for lockfile parity with Docker, while the frontend is pure Yarn:
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd ~/code/backend
|
||||
yarn install --frozen-lockfile
|
||||
|
||||
# Frontend
|
||||
cd ~/code/frontend
|
||||
yarn install --frozen-lockfile
|
||||
```
|
||||
|
||||
Both installs take 2–5 minutes on a cold cache. If `node-gyp`/`sharp` fail on macOS, install Xcode CLT (`xcode-select --install`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Configure `.env` files
|
||||
|
||||
Each repo ships example files. Copy them and fill in secrets — full reference is in [[Environment Variables]].
|
||||
|
||||
### Backend (`/Users/mojtabaheidari/code/backend/.env.local`)
|
||||
|
||||
`docker-compose.dev.yml` reads `.env.local`. The container expects at minimum:
|
||||
|
||||
```bash
|
||||
NODE_ENV=development
|
||||
PORT=5001
|
||||
MONGODB_URI=mongodb://mongodb:27017
|
||||
DB_NAME=marketplace
|
||||
REDIS_URI=redis://redis:6379
|
||||
JWT_SECRET=$(openssl rand -hex 32)
|
||||
JWT_EXPIRES_IN=1h
|
||||
REFRESH_TOKEN_EXPIRES_IN=30d
|
||||
FRONTEND_URL=http://localhost:8083
|
||||
MAX_FILE_SIZE=10485760
|
||||
UPLOAD_PATH=/app/uploads
|
||||
RATE_LIMIT_WINDOW_MS=900000
|
||||
RATE_LIMIT_MAX_REQUESTS=100
|
||||
```
|
||||
|
||||
For payments, OpenAI, SMTP, etc., refer to [[Environment Variables]].
|
||||
|
||||
### Frontend
|
||||
|
||||
Three env files already exist; pick the one that matches your scenario:
|
||||
|
||||
| File | When to use |
|
||||
|------|-------------|
|
||||
| `.env.local` | Local Next dev (`yarn dev`) — points at `http://localhost:5001` |
|
||||
| `.env.development` | Docker compose dev — points at `dev.amn.gg` via Nginx |
|
||||
| `.env.production` | Production build — points at `amn.gg` |
|
||||
|
||||
Next.js automatically picks `.env.local` for `next dev`. Do **not** check this file in if you change secrets.
|
||||
|
||||
---
|
||||
|
||||
## 5. Start the backend
|
||||
|
||||
You have two equivalent paths.
|
||||
|
||||
### Option A — All-in-Docker (recommended)
|
||||
|
||||
Builds the backend image, brings up MongoDB + Redis + backend on `nickapp-network`, and mounts `./src` for hot reload:
|
||||
|
||||
```bash
|
||||
cd ~/code/backend
|
||||
npm run docker:dev
|
||||
```
|
||||
|
||||
Follow logs:
|
||||
|
||||
```bash
|
||||
npm run docker:dev:logs
|
||||
```
|
||||
|
||||
Stop:
|
||||
|
||||
```bash
|
||||
npm run docker:dev:down
|
||||
```
|
||||
|
||||
### Option B — Local Node + Docker datastores
|
||||
|
||||
Run only the datastores in Docker and the API on the host:
|
||||
|
||||
```bash
|
||||
cd ~/code/backend
|
||||
docker compose -f docker-compose.dev.yml up -d mongodb redis
|
||||
npm run dev # ts-node + nodemon on port 5001
|
||||
```
|
||||
|
||||
Override `MONGODB_URI=mongodb://localhost:27017` in `.env` if you take this route, since `mongodb` only resolves inside the compose network.
|
||||
|
||||
> [!tip] If port `5001` is already in use, set `PORT=5002` in `.env.local` and update `NEXT_PUBLIC_API_URL` in the frontend env to match.
|
||||
|
||||
---
|
||||
|
||||
## 6. Seed test data
|
||||
|
||||
Once MongoDB is healthy, populate it with default users, categories, addresses, and templates:
|
||||
|
||||
```bash
|
||||
cd ~/code/backend
|
||||
npm run seed:all # users + addresses (clears existing)
|
||||
npm run seed:categories # marketplace taxonomy
|
||||
```
|
||||
|
||||
`seed:all` creates the canonical test accounts (password `Moji6364` for all):
|
||||
|
||||
| Role | Email |
|
||||
|------|-------|
|
||||
| Admin | `admin@marketplace.com` |
|
||||
| Buyer | `buyer@marketplace.com` |
|
||||
| Seller | `seller@marketplace.com` |
|
||||
| Seller (alt) | `seller2@marketplace.com` |
|
||||
|
||||
You can also enable auto-seeding on container start by adding `AUTO_SEED_ON_START=true` to `.env.local`. Auto-seed runs only when the `users` collection has no non-admin entries — safe to leave on.
|
||||
|
||||
See [[Scripts#seed-scripts]] for the full list (`seed:users`, `seed:addresses`, `seed:categories`, `seed:all`, plus `createSupportUser.ts`, `createTestRequest.ts`, etc.).
|
||||
|
||||
---
|
||||
|
||||
## 7. Start the frontend
|
||||
|
||||
```bash
|
||||
cd ~/code/frontend
|
||||
yarn dev
|
||||
```
|
||||
|
||||
Next.js starts on **port 8083** (`next dev -p 8083 --turbopack`). The dashboard is at:
|
||||
|
||||
- http://localhost:8083 — landing
|
||||
- http://localhost:8083/auth/jwt/sign-in — login
|
||||
- http://localhost:8083/dashboard — authenticated area
|
||||
|
||||
If you used the Docker compose dev workflow with the production-mode container, port `3000` may also be exposed; check the compose output.
|
||||
|
||||
---
|
||||
|
||||
## 8. Verify
|
||||
|
||||
Run these smoke checks before you start coding:
|
||||
|
||||
```bash
|
||||
# Backend health (should return JSON with success: true)
|
||||
curl -s http://localhost:5001/health | jq .
|
||||
|
||||
# API root
|
||||
curl -s http://localhost:5001/ | jq .
|
||||
|
||||
# Login (returns JWT)
|
||||
curl -s -X POST http://localhost:5001/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"admin@marketplace.com","password":"Moji6364"}' | jq .
|
||||
```
|
||||
|
||||
In the browser, open http://localhost:8083, log in with `admin@marketplace.com / Moji6364`, and confirm the dashboard loads. If chat or notification badges show up, sockets connected too.
|
||||
|
||||
> [!tip] Tail backend logs in a separate terminal: `npm run docker:dev:logs`. Look for `✅ Connected to MongoDB`, `🔌 User connected`, and `🚀 Server running on port 5001`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Common issues
|
||||
|
||||
| Symptom | Fix |
|
||||
|---------|-----|
|
||||
| `EADDRINUSE :::5001` | Another process owns the port — `lsof -i :5001` then `kill`, or change `PORT`. |
|
||||
| `MongoServerError: Authentication failed` | The compose file does **not** set Mongo auth in dev; remove any `user:pass@` prefix from `MONGODB_URI`. |
|
||||
| `ECONNREFUSED 127.0.0.1:6379` | Redis container is down — `docker compose -f docker-compose.dev.yml ps` to check. |
|
||||
| CORS errors in the browser | `FRONTEND_URL` in backend `.env.local` must exactly match the origin you open in the browser (scheme + host + port). |
|
||||
| `yarn install` hangs on `sharp` | Run `yarn config set network-timeout 600000` and retry. |
|
||||
| `next dev` fails with module-not-found after a `git pull` | Run `yarn install` again — Next 16 is sensitive to drift in `react`/`react-dom`. |
|
||||
| Sockets do not connect | Confirm `NEXT_PUBLIC_SOCKET_URL` matches the backend origin and that no browser extension blocks WebSockets. |
|
||||
|
||||
---
|
||||
|
||||
## 10. Quick reset
|
||||
|
||||
If your local state gets weird, the backend ships a one-shot reset script:
|
||||
|
||||
```bash
|
||||
cd ~/code/backend
|
||||
./scripts/reset-server.sh
|
||||
```
|
||||
|
||||
This stops the dev compose stack, restarts it, runs health checks against MongoDB / Redis / `/health`, and probes the login endpoint with the seeded admin user. Output is colourised and ends with the canonical test credentials. See [[Scripts#reset-server-sh]] for details.
|
||||
|
||||
> [!warning] `reset-server.sh` does **not** drop volumes by default. To wipe the database, uncomment the `down -v` line in the script or run `docker compose -f docker-compose.dev.yml down -v` first.
|
||||
|
||||
---
|
||||
|
||||
## Next steps
|
||||
|
||||
- Walk the codebase via [[Project Structure]].
|
||||
- Read [[Coding Standards]] before opening your first PR.
|
||||
- Run the test suites — [[Testing]].
|
||||
- Inspect what runs in each container — [[Docker Setup]] (Operations).
|
||||
- For shipping changes through CI, see [[CI-CD Pipeline]] (Operations).
|
||||
200
07 - Development/Project Structure.md
Normal file
200
07 - Development/Project Structure.md
Normal file
@@ -0,0 +1,200 @@
|
||||
---
|
||||
title: Project Structure
|
||||
tags: [development]
|
||||
---
|
||||
|
||||
# Project Structure
|
||||
|
||||
A bird's-eye view of both repos. For deep dives, follow the cross-links to [[Backend Architecture]] and [[Frontend Architecture]].
|
||||
|
||||
---
|
||||
|
||||
## Backend — `/Users/mojtabaheidari/code/backend`
|
||||
|
||||
A service-oriented Express 5 app. Each business domain owns a folder under `src/services/` containing its routes, controllers, services, and (sometimes) its own models. Cross-cutting concerns live in `src/shared/` and `src/infrastructure/`.
|
||||
|
||||
```
|
||||
backend/
|
||||
├── src/
|
||||
│ ├── app.ts # Express bootstrap: middleware, routes, Socket.IO, startup
|
||||
│ ├── config/ # Sentry init (loaded before anything else)
|
||||
│ ├── controllers/ # Thin HTTP controllers for orphan endpoints (disputes, points)
|
||||
│ ├── routes/ # Router exports for orphan controllers above
|
||||
│ ├── models/ # Mongoose schemas (single source of truth for data)
|
||||
│ ├── infrastructure/
|
||||
│ │ ├── database/ # Mongo connection + admin bootstrap
|
||||
│ │ └── socket/ # Socket.IO server adapter & emitter helpers
|
||||
│ ├── services/ # Domain services — see breakdown below
|
||||
│ ├── shared/
|
||||
│ │ ├── config/ # Typed `config` object (env loader)
|
||||
│ │ ├── middleware/ # auth, errorHandler, rate limit, request logger
|
||||
│ │ ├── types/ # Cross-domain type aliases
|
||||
│ │ └── utils/ # Helpers reused across services
|
||||
│ ├── utils/ # logger, currencyUtils, videoHelpers
|
||||
│ ├── seeds/ # Idempotent data seeders
|
||||
│ └── scripts/ # One-off operational scripts (TS + sh)
|
||||
├── __tests__/ # Jest suites (see Testing)
|
||||
├── scripts/ # Shell scripts (build/push, version, ngrok, reset)
|
||||
├── nginx/ # Nginx conf (production compose)
|
||||
├── mongo-init/ # Mongo initdb.d JS (one-time bootstrap)
|
||||
├── uploads/ # User uploads — mounted as volume
|
||||
├── Dockerfile.dev # Hot-reload image (ts-node + nodemon)
|
||||
├── Dockerfile.prod # Multi-stage build image (compiled JS, non-root user)
|
||||
├── docker-compose.dev.yml # Local stack: backend + mongo + redis
|
||||
├── docker-compose.production.yml # Prod stack: nginx + backend + frontend + mongo + redis
|
||||
├── .gitea/workflows/ # Gitea Actions CI
|
||||
├── healthcheck.js # Container HEALTHCHECK probe
|
||||
├── eslint.config.js # Flat ESLint config (TS strict)
|
||||
├── jest.config.js # ts-jest preset
|
||||
└── package.json
|
||||
```
|
||||
|
||||
### `src/services/` folders
|
||||
|
||||
Each service folder follows the same shape: `<service>Routes.ts`, `<service>Controller.ts`, `<service>Service.ts`, sometimes a `<service>Repository.ts` and an `index.ts` barrel.
|
||||
|
||||
| Folder | Purpose |
|
||||
|--------|---------|
|
||||
| `address/` | CRUD for buyer/seller addresses (max 3 per user) |
|
||||
| `admin/` | Admin-only data-cleanup + diagnostic endpoints |
|
||||
| `ai/` | OpenAI-backed assistants (offer suggestions, content moderation) |
|
||||
| `auth/` | Login, signup, refresh, Google OAuth, passkey/WebAuthn, temp verification |
|
||||
| `blockchain/` | Generic wallet/chain helpers shared by payment providers |
|
||||
| `blog/` | Public blog posts, comments, video helpers |
|
||||
| `chat/` | Realtime messaging between buyers & sellers |
|
||||
| `delivery/` | Delivery tracking + shipping events |
|
||||
| `dispute/` | Dispute opening, evidence upload, arbitration |
|
||||
| `email/` | Nodemailer service + transactional templates |
|
||||
| `file/` | Multer uploads + Sharp image processing |
|
||||
| `marketplace/` | Purchase requests, seller offers, accept/reject, categories |
|
||||
| `notification/` | In-app notifications + delivery (socket + email) |
|
||||
| `payment/` | Payment orchestration; sub-folders `shkeeper/`, `depay/`, `web3/` |
|
||||
| `points/` | Reputation points + level config |
|
||||
| `redis/` | Redis client wrapper (caching, rate counters) |
|
||||
| `user/` | Profile, settings, role management |
|
||||
|
||||
### `src/models/`
|
||||
|
||||
Each `.ts` file is a Mongoose model — see [[Data Models]] for full schema docs. Highlights:
|
||||
|
||||
- `User`, `Address`, `Category` — identity & taxonomy
|
||||
- `PurchaseRequest`, `SellerOffer`, `RequestTemplate` — marketplace core
|
||||
- `Payment`, `PointTransaction`, `LevelConfig` — money + reputation
|
||||
- `Chat`, `Notification`, `Dispute`, `Review`, `BlogPost`, `ShopSettings`, `TempVerification` — supporting domains
|
||||
|
||||
### `src/seeds/`
|
||||
|
||||
Idempotent, runnable via `npm run seed:*`. See [[Scripts#seed-scripts]].
|
||||
|
||||
---
|
||||
|
||||
## Frontend — `/Users/mojtabaheidari/code/frontend`
|
||||
|
||||
Next.js 16 App Router with the `src/` layout. The structure follows the Minimal v7 template: pages in `app/`, page-level UI in `sections/`, reusable atoms in `components/`, and a strong split between server data fetchers (`actions/`) and client UI.
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── app/ # Next.js App Router — route groups: (auth) (dashboard) post shop
|
||||
│ │ ├── layout.tsx # Root layout (providers, font, metadata)
|
||||
│ │ ├── page.tsx # Landing page
|
||||
│ │ ├── auth/ # /auth/* routes
|
||||
│ │ ├── dashboard/ # /dashboard/* routes (authenticated)
|
||||
│ │ ├── post/ # Public blog posts
|
||||
│ │ ├── shop/ # Public shop pages
|
||||
│ │ ├── error/ # Custom error route
|
||||
│ │ ├── loading.tsx # Global loading skeleton
|
||||
│ │ └── not-found.tsx # 404
|
||||
│ ├── sections/ # Page-level UI grouped by domain
|
||||
│ │ ├── account/ address/ blog/ chat/ dispute/ error/
|
||||
│ │ ├── overview/ payment/ points/
|
||||
│ │ ├── request/ request-template/ shop-settings/ user/
|
||||
│ ├── components/ # Reusable UI atoms & molecules (MUI-based)
|
||||
│ │ ├── hook-form/ # RHF-wrapped MUI inputs (RHFTextField, RHFSelect, …)
|
||||
│ │ ├── iconify/ # The single icon component (use only this)
|
||||
│ │ ├── animate/ carousel/ chart/ custom-* / nav-section/
|
||||
│ │ ├── upload/ file/ image/ markdown/ editor/ map/ video-player/
|
||||
│ │ └── … # see full list in the codebase
|
||||
│ ├── contexts/ # Top-level React contexts (socket-context.tsx)
|
||||
│ ├── hooks/ # Generic hooks: useBoolean, useSetState, useSnackbar, useSocket
|
||||
│ ├── lib/ # axios.ts — singleton API client with interceptors
|
||||
│ ├── locales/ # i18n: langs/, i18n-provider, server.ts, use-locales
|
||||
│ ├── layouts/ # AppShell variants: auth-centered, auth-split, dashboard, main, simple
|
||||
│ ├── theme/ # MUI theme (core + with-settings overrides)
|
||||
│ ├── settings/ # Theme-switcher drawer + persisted user settings
|
||||
│ ├── actions/ # Server-side / shared async API calls (axios)
|
||||
│ ├── auth/ # JWT / OAuth / passkey context, guards, hooks, services
|
||||
│ ├── socket/ # Socket.IO client, hooks, components, contexts
|
||||
│ ├── web3/ # WalletConnect + Alchemy + DePay glue
|
||||
│ ├── routes/ # Static path constants (paths object)
|
||||
│ ├── utils/ # logger, format-number, format-time, localStorage, …
|
||||
│ ├── types/ # Shared TS types (mirrors backend models where useful)
|
||||
│ ├── assets/ # SVGs, illustrations
|
||||
│ ├── _mock/ # Mock data for storybooks / tests
|
||||
│ ├── global-config.ts # Read-only app config (name, version, paths)
|
||||
│ └── global.css # Tailwind-less global styles
|
||||
├── public/ # Static assets served at /
|
||||
├── e2e/ # Playwright specs
|
||||
├── __tests__/ # Jest + RTL specs (organised by domain)
|
||||
├── scripts/ # Shell scripts (deploy, debug, version, console-log migration)
|
||||
├── docs/ # In-repo design notes (not the Obsidian vault)
|
||||
├── doc/ # Legacy docs folder
|
||||
├── Dockerfile # Production multi-stage Next.js build
|
||||
├── Dockerfile.dev # Dev image (yarn dev on port 3000)
|
||||
├── next.config.ts # Standalone output, image domains, Sentry integration
|
||||
├── playwright.config.ts # E2E test config
|
||||
├── jest.config.js # Jest + jsdom + RTL
|
||||
├── eslint.config.mjs # Flat ESLint with perfectionist sorting
|
||||
├── prettier.config.mjs # Prettier config
|
||||
├── .commitlintrc.json # Conventional commits enforcement
|
||||
├── sentry.client.config.ts # Browser Sentry init
|
||||
├── sentry.server.config.ts # Server runtime Sentry init
|
||||
└── sentry.edge.config.ts # Edge runtime Sentry init
|
||||
```
|
||||
|
||||
### `src/sections/` vs `src/components/`
|
||||
|
||||
A common confusion when first navigating the codebase:
|
||||
|
||||
- **components/** — generic, reused everywhere, no business logic. (e.g. `Iconify`, `RHFTextField`, `EmptyContent`.)
|
||||
- **sections/** — page-level UI bound to a domain. (e.g. `sections/payment/CheckoutSummary.tsx`.) Sections may import from `components/` but not the other way around.
|
||||
|
||||
### `src/auth/` vs `src/socket/` vs `src/web3/`
|
||||
|
||||
These are feature-bundles that ship their own context, hooks, services, and components together. Treat each like an internal package — import only from its `index.ts` barrel.
|
||||
|
||||
### Routes & API
|
||||
|
||||
- `routes/paths.ts` — every URL the app navigates to, expressed as a typed object.
|
||||
- `lib/axios.ts` — the single axios instance with auth interceptor and base URL from `NEXT_PUBLIC_API_URL`.
|
||||
- `actions/*.ts` — async functions wrapping axios calls; consumed by both server components and client components.
|
||||
|
||||
---
|
||||
|
||||
## Repository layout (top-level)
|
||||
|
||||
```
|
||||
~/code/
|
||||
├── backend/ # this repo
|
||||
├── frontend/ # this repo
|
||||
└── docs/ # this Obsidian vault
|
||||
```
|
||||
|
||||
The production `docker-compose.yml` lives in `backend/` but references `../frontend` for the frontend build context — keep both folders as siblings.
|
||||
|
||||
---
|
||||
|
||||
## Where to add new things
|
||||
|
||||
| You want to add… | Put it under… |
|
||||
|---|---|
|
||||
| A new public API route | `backend/src/services/<domain>/<domain>Routes.ts` (or a new domain folder) |
|
||||
| A new Mongo schema | `backend/src/models/<Name>.ts` + export from `models/index.ts` |
|
||||
| A reusable UI component | `frontend/src/components/<kebab-name>/` with `index.ts` + `component.tsx` + `types.ts` |
|
||||
| A page-specific block | `frontend/src/sections/<domain>/` |
|
||||
| A new dashboard page | `frontend/src/app/dashboard/<route>/page.tsx` |
|
||||
| A shared hook | `frontend/src/hooks/use-<name>.ts` |
|
||||
| A one-shot ops script | `backend/scripts/<name>.sh` (operational) or `backend/src/scripts/<name>.ts` (touches DB) |
|
||||
| A seed script | `backend/src/seeds/<name>.ts` and add an `npm run seed:<name>` entry |
|
||||
|
||||
For the architectural rationale behind these splits see [[Backend Architecture]] and [[Frontend Architecture]].
|
||||
365
07 - Development/Scripts.md
Normal file
365
07 - Development/Scripts.md
Normal file
@@ -0,0 +1,365 @@
|
||||
---
|
||||
title: Scripts
|
||||
tags: [development]
|
||||
---
|
||||
|
||||
# Scripts
|
||||
|
||||
A catalogue of every operational script across both repos. For each: **purpose**, **when to run**, and an **example invocation**. Scripts that touch the database have warnings.
|
||||
|
||||
> [!warning] Many of these scripts mutate live data (drop collections, push Docker tags, force version bumps). Read the script before running it on anything that matters.
|
||||
|
||||
---
|
||||
|
||||
## Backend — `backend/scripts/` (shell)
|
||||
|
||||
These shell scripts orchestrate the dev/build/release pipeline. They live next to the repo root and assume the repo is the current working directory.
|
||||
|
||||
### `auto-version.sh`
|
||||
|
||||
**Purpose.** Bump the `version` in `package.json` and create a matching git tag. Has an `auto` mode that asks `ai-enhanced.sh` to suggest the bump from the last commit message.
|
||||
|
||||
**When to run.** Before pushing a release. The `release:*` npm scripts call this for you.
|
||||
|
||||
**Example.**
|
||||
|
||||
```bash
|
||||
./scripts/auto-version.sh patch # 2.6.3 → 2.6.4
|
||||
./scripts/auto-version.sh minor # 2.6.3 → 2.7.0
|
||||
./scripts/auto-version.sh major # 2.6.3 → 3.0.0
|
||||
./scripts/auto-version.sh auto # let the AI decide
|
||||
./scripts/auto-version.sh help
|
||||
|
||||
# or via npm wrappers:
|
||||
npm run version:patch
|
||||
npm run release:minor
|
||||
npm run smart-release # auto + push + push --tags
|
||||
```
|
||||
|
||||
The script creates a commit `chore: bump version to <v>` and tag `v<version>`.
|
||||
|
||||
> [!warning] Make sure your working tree is clean and on the branch you want tagged. The script will not stash changes.
|
||||
|
||||
---
|
||||
|
||||
### `ai-enhanced.sh`
|
||||
|
||||
**Purpose.** Analyse a commit message (last commit by default) against rules in `ai-rules.conf` and recommend `major`/`minor`/`patch`/`skip` plus a confidence level. Used by `auto-version.sh auto`.
|
||||
|
||||
**When to run.** Standalone diagnostic before a release; otherwise invoked indirectly.
|
||||
|
||||
**Example.**
|
||||
|
||||
```bash
|
||||
./scripts/ai-enhanced.sh # analyse last commit
|
||||
./scripts/ai-enhanced.sh "feat: add seller payouts" # analyse a string
|
||||
npm run ai:detect
|
||||
```
|
||||
|
||||
Exports `AI_VERSION_TYPE` and `AI_CONFIDENCE` for downstream scripts.
|
||||
|
||||
---
|
||||
|
||||
### `build-and-push.sh`
|
||||
|
||||
**Purpose.** Build the production Docker image (`Dockerfile.prod`) tagged with `dev-<package-version>` plus `latest`, then push to the Gitea registry.
|
||||
|
||||
**When to run.** For a manual dev release outside of CI. CI does the same thing automatically — see [[CI-CD Pipeline]].
|
||||
|
||||
**Example.**
|
||||
|
||||
```bash
|
||||
./scripts/build-and-push.sh
|
||||
```
|
||||
|
||||
You must `docker login git.manko.yoga -u manawenuz` first. Pushes both tags and asks whether to clean up the local images afterwards.
|
||||
|
||||
---
|
||||
|
||||
### `build-and-push-dev.sh`
|
||||
|
||||
**Purpose.** Variant of `build-and-push.sh` that tags only with the `dev-*` tag (no `latest`). Used when you want Watchtower to leave production untouched.
|
||||
|
||||
**When to run.** Manual dev-only push.
|
||||
|
||||
**Example.**
|
||||
|
||||
```bash
|
||||
./scripts/build-and-push-dev.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `build-image.sh`
|
||||
|
||||
**Purpose.** Local-only build (no push). Tags `<registry>/<image>:<version>` and `:latest`. Useful for testing the production Dockerfile locally without sending it anywhere.
|
||||
|
||||
**When to run.** Verifying a `Dockerfile.prod` change before pushing.
|
||||
|
||||
**Example.**
|
||||
|
||||
```bash
|
||||
./scripts/build-image.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `reset-server.sh`
|
||||
|
||||
**Purpose.** Stop the dev compose stack, restart it fresh, wait for services, then probe MongoDB, Redis, `/health`, and the login endpoint. Prints the seeded test credentials at the end.
|
||||
|
||||
**When to run.** When your local containers are in a weird state, or after pulling new images.
|
||||
|
||||
**Example.**
|
||||
|
||||
```bash
|
||||
./scripts/reset-server.sh
|
||||
```
|
||||
|
||||
> [!warning] By default this preserves volumes. To wipe the database, edit the script to uncomment `docker-compose ... down -v` or run that command manually first.
|
||||
|
||||
---
|
||||
|
||||
### `start-ngrok.sh`
|
||||
|
||||
**Purpose.** Start `ngrok http` against a local port (default `8083`) and print the public URL by polling the inspector at `127.0.0.1:4040`. Lets you receive SHKeeper webhooks on your laptop.
|
||||
|
||||
**When to run.** Local SHKeeper webhook development.
|
||||
|
||||
**Example.**
|
||||
|
||||
```bash
|
||||
./scripts/start-ngrok.sh 5001 # tunnel to backend
|
||||
./scripts/start-ngrok.sh # tunnel to default 8083
|
||||
REGION=eu ./scripts/start-ngrok.sh
|
||||
```
|
||||
|
||||
Logs go to `~/.ngrok-<port>.log` and the pid to `~/.ngrok-<port>.pid`. Requires `brew install ngrok` and `ngrok config add-authtoken <token>`.
|
||||
|
||||
---
|
||||
|
||||
### `fix-dispute-sellers.js`
|
||||
|
||||
**Purpose.** One-off Node script that walks the `disputes` collection and back-fills missing `sellerId` fields by joining through `purchaseRequests` + `sellerOffers`. Born from a real-world data-quality bug.
|
||||
|
||||
**When to run.** Only if disputes appear in admin UI without a seller. Otherwise: never.
|
||||
|
||||
**Example.**
|
||||
|
||||
```bash
|
||||
MONGODB_URI=mongodb://localhost:27017/marketplace node ./scripts/fix-dispute-sellers.js
|
||||
```
|
||||
|
||||
> [!warning] Mutates dispute documents. Take a backup first ([[Backup & Recovery]]).
|
||||
|
||||
---
|
||||
|
||||
## Backend — `backend/src/scripts/` (TypeScript + helpers)
|
||||
|
||||
These are runnable with `ts-node`. The `npm run seed:*` entries wrap the most common ones.
|
||||
|
||||
### Seed scripts
|
||||
|
||||
| Script | npm wrapper | Purpose |
|
||||
|--------|-------------|---------|
|
||||
| `seedUsers.ts` | `npm run seed:users` | Create the four canonical test users (admin, buyer, seller, seller2). Idempotent: skips users that already exist. |
|
||||
| `seedAddresses.ts` | `npm run seed:addresses` | Wipe and re-create sample addresses for the test users (max 3 per user, primary flag set). **Requires users to exist.** |
|
||||
| `seedUsersAndAddresses.ts` | `npm run seed:all` | Combined: drops and recreates users + addresses in the right order. The canonical "fresh database" command. |
|
||||
| `seedCategories.ts` | `npm run seed:categories` | Insert the marketplace category tree. Idempotent. |
|
||||
| `seeds/seedRequestTemplates.ts` | manual | Seed example request templates buyers can use as starting points. |
|
||||
| `seeds/seedBlogPosts.ts` | manual | Seed sample blog content. |
|
||||
| `seeds/seedLevels.ts` | manual | Insert level config for the points/reputation system. |
|
||||
| `seeds/migrateUserPoints.ts` | manual | One-shot migration of legacy point balances. |
|
||||
|
||||
**Example.**
|
||||
|
||||
```bash
|
||||
npm run seed:all
|
||||
npm run seed:categories
|
||||
ts-node src/seeds/seedRequestTemplates.ts
|
||||
```
|
||||
|
||||
> [!warning] `seed:addresses` and `seed:all` drop existing data. Don't run against production unless that is what you want.
|
||||
|
||||
### Other TS helpers
|
||||
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `clearCategories.ts` | Wipe the `categories` collection (paired with `seedCategories.ts`). |
|
||||
| `clearChats.ts` | Wipe the `chats` collection. Useful in QA. |
|
||||
| `createSupportUser.ts` | Create the marketplace support user (used in chat). |
|
||||
| `createTestMessage.ts` | Insert a single test chat message between buyer and seller. |
|
||||
| `createTestRequest.ts` | Create a sample purchase request from the seeded buyer. |
|
||||
| `updateCategories.ts` | Apply schema updates to existing categories without re-seeding. |
|
||||
| `updateRequestStatus.ts` | Force-set the `status` field of a purchase request — useful when QA-ing a flow. |
|
||||
| `makeUserAdmin.ts` | Promote a user to admin. Pass email as an arg. |
|
||||
| `createDemoShops.ts` | Generate demo seller shops for screenshots / staging. |
|
||||
|
||||
**Example.**
|
||||
|
||||
```bash
|
||||
ts-node src/scripts/makeUserAdmin.ts user@example.com
|
||||
ts-node src/scripts/createTestRequest.ts
|
||||
ts-node src/scripts/updateRequestStatus.ts <requestId> completed
|
||||
```
|
||||
|
||||
### Bash helpers under `src/scripts/`
|
||||
|
||||
These call the API with `curl` to drive larger demo-data flows:
|
||||
|
||||
- `createDemoTemplates.sh`, `createDemoWithPicsum.sh`, `createFullDemo.sh`, `createProductionTemplates.sh` — populate templates with placeholder data
|
||||
- `deleteSellerTemplates.sh` — wipe templates owned by a given seller
|
||||
- `updateShopImages.sh` — bulk-update shop images
|
||||
|
||||
Each script takes a base URL + admin token. Inspect them before running.
|
||||
|
||||
---
|
||||
|
||||
## Backend — root-level helpers
|
||||
|
||||
### `manual-test.ts`
|
||||
|
||||
**Purpose.** Local sanity check for the SHKeeper service: calls `createPayInIntent` with mock data and verifies a webhook signature in dev mode.
|
||||
|
||||
**When to run.** Smoke-test after changing SHKeeper code without running the full suite.
|
||||
|
||||
**Example.**
|
||||
|
||||
```bash
|
||||
ts-node manual-test.ts
|
||||
```
|
||||
|
||||
### `manual-payout-test.ts`
|
||||
|
||||
**Purpose.** POST `/api/payments/payout` to the local backend with hard-coded payout data, then poll status after 5 s.
|
||||
|
||||
**When to run.** End-to-end payout flow check against a running local server.
|
||||
|
||||
**Example.**
|
||||
|
||||
```bash
|
||||
# Backend must be running first:
|
||||
npm run dev &
|
||||
ts-node manual-payout-test.ts
|
||||
```
|
||||
|
||||
> [!warning] Will create a real payout record in the DB. With `SHKEEPER_FORCE_PAYOUT_DEMO=true` no on-chain transaction is sent; without that flag a real on-chain transfer can occur.
|
||||
|
||||
### `fix-transaction-hashes.js`
|
||||
|
||||
**Purpose.** One-off backfill — walks completed Payments missing `transactionHash`, queries SHKeeper for the original invoice, extracts the confirmed transaction hash, and updates the payment document.
|
||||
|
||||
**When to run.** Only if you see payments displayed as "completed" with a missing tx hash. Rate-limits itself with a 1s delay per record.
|
||||
|
||||
**Example.**
|
||||
|
||||
```bash
|
||||
MONGODB_URI=mongodb://localhost:27017/marketplace \
|
||||
SHKEEPER_BASE_URL=https://shkeeper.example.com \
|
||||
SHKEEPER_API_KEY=... \
|
||||
node fix-transaction-hashes.js
|
||||
```
|
||||
|
||||
> [!warning] Hits the live SHKeeper API and writes to MongoDB. Take a backup ([[Backup & Recovery]]).
|
||||
|
||||
### `check-templates.js`, `get-admin-token.js`
|
||||
|
||||
Diagnostic helpers. `check-templates.js` lists templates in the DB; `get-admin-token.js` logs in as the seeded admin and prints the JWT.
|
||||
|
||||
---
|
||||
|
||||
## Frontend — `frontend/scripts/`
|
||||
|
||||
### Deployment
|
||||
|
||||
| Script | Purpose | When |
|
||||
|--------|---------|------|
|
||||
| `deploy.sh` | Build production image, tag with package version + `latest`, push to Gitea registry. Triggered by Gitea Actions (`deploy.yml`) on push to `main`. | Production release. |
|
||||
| `deployDev.sh` | Same as `deploy.sh` but pushes only the `dev` tag. Triggered by `devDeploy.yml` on push to `development`. | Dev release. |
|
||||
|
||||
### Versioning
|
||||
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `auto-version.sh` | Mirror of backend `auto-version.sh` for the frontend. |
|
||||
| `ai-enhanced.sh` | Mirror of backend AI version detector. Reads `ai-rules.conf`. |
|
||||
| `ai-version-detect.sh` | Legacy / simpler version detector kept for fallback. |
|
||||
| `setup-versioning.sh` | One-shot setup: installs a `post-commit` git hook that prints the recommended version bump. |
|
||||
| `post-commit-hook.sh` | The hook installed by `setup-versioning.sh`. |
|
||||
|
||||
### Debugging & maintenance
|
||||
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `debug-env.sh` | Print every `NEXT_PUBLIC_*` env var currently exported. |
|
||||
| `debug-ui-api.sh` | Curl the backend health, auth, and a handful of marketplace endpoints; reports each. |
|
||||
| `cleanup-docker-networks.sh` | Prune dangling Docker networks left behind by failed compose runs. |
|
||||
| `show-credentials.sh` | Print the seeded test credentials (admin/buyer/seller). |
|
||||
| `find-console-logs.sh` | List every `console.log/info/warn/error` left in `src/`. |
|
||||
| `clean-console-logs.sh` | Auto-remove or comment out console logs. |
|
||||
| `migrate-all-console-logs.sh` | Convert `console.*` calls to `logger.*`. Read `migrate-to-logger.md` before running. |
|
||||
|
||||
### Testing helpers
|
||||
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `quick-test-suite.sh` | Run a curated subset of Jest + Playwright tests for a fast PR check. |
|
||||
| `integration-test.sh` | Heavier scripted integration run against a live backend. |
|
||||
| `test-marketplace-workflow.js` | Node script that drives a buyer → seller → checkout → payout flow via the API. |
|
||||
| `test-offer-rejection.js` | Reproduce the offer-rejection bug pattern; useful for regression checks. |
|
||||
|
||||
**Examples.**
|
||||
|
||||
```bash
|
||||
yarn ./scripts/debug-env.sh
|
||||
./scripts/debug-ui-api.sh http://localhost:5001
|
||||
./scripts/quick-test-suite.sh
|
||||
node ./scripts/test-marketplace-workflow.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Frontend — root-level helpers
|
||||
|
||||
### `cleanup-localstorage.js`
|
||||
|
||||
**Purpose.** Browser console snippet — paste into DevTools to clear stale `manual-payment-*`, `buyer-step-*`, `LAST_PAYMENT_*`, `LAST_TEMPLATE_*`, and `LAST_SOCKET_*` keys from `localStorage`.
|
||||
|
||||
**When to run.** When QA reports state-related glitches that disappear in incognito.
|
||||
|
||||
**Example.** Paste the entire file body into the browser console and press Enter.
|
||||
|
||||
---
|
||||
|
||||
## Summary cheat-sheet
|
||||
|
||||
```bash
|
||||
# === Backend ===
|
||||
npm run dev # local node, no docker
|
||||
npm run docker:dev # full stack in docker
|
||||
npm run docker:dev:down # tear it down
|
||||
npm run docker:dev:logs # follow logs
|
||||
|
||||
npm run seed:all # reset users + addresses (DESTRUCTIVE)
|
||||
npm run seed:categories # idempotent
|
||||
|
||||
npm run test # jest
|
||||
npm run test:coverage # + coverage
|
||||
|
||||
./scripts/reset-server.sh # nuke and rebuild dev compose
|
||||
|
||||
npm run release:patch # bump + commit + tag + push
|
||||
npm run smart-release # AI-decided bump + push
|
||||
|
||||
./scripts/build-and-push.sh # manual docker push (dev tag)
|
||||
|
||||
# === Frontend ===
|
||||
yarn dev # next dev on 8083
|
||||
yarn build && yarn start # production-mode locally
|
||||
|
||||
yarn test # jest
|
||||
yarn test:e2e # playwright
|
||||
|
||||
./scripts/deploy.sh # manual prod deploy push
|
||||
./scripts/debug-ui-api.sh # curl smoke tests
|
||||
```
|
||||
262
07 - Development/Testing.md
Normal file
262
07 - Development/Testing.md
Normal file
@@ -0,0 +1,262 @@
|
||||
---
|
||||
title: Testing
|
||||
tags: [development]
|
||||
---
|
||||
|
||||
# Testing
|
||||
|
||||
Both repos use **Jest** as the unit/integration runner. The frontend additionally uses **React Testing Library** for component tests and **Playwright** for end-to-end browser tests. This page covers what exists today, how to run it, and how to add new tests.
|
||||
|
||||
---
|
||||
|
||||
## Backend testing
|
||||
|
||||
### Stack
|
||||
|
||||
- **Jest 29** + **ts-jest 29** — TypeScript transpilation on the fly
|
||||
- **supertest 7** — HTTP assertions against the Express app
|
||||
- **mongodb-memory-server 10** — in-memory MongoDB per test run (no Docker needed)
|
||||
|
||||
### Jest configuration
|
||||
|
||||
`backend/jest.config.js`:
|
||||
|
||||
- Preset: `ts-jest`
|
||||
- Environment: `node`
|
||||
- Test glob: `**/__tests__/**/*.test.ts` and `**/?(*.)+(spec|test).ts`
|
||||
- `setupFilesAfterEach`: `__tests__/setup.ts` — boots `mongodb-memory-server`, connects mongoose, and cleans collections between tests
|
||||
- `maxWorkers: 1` — tests run serially (DB-state-sensitive)
|
||||
- `testTimeout: 30000`
|
||||
|
||||
### Test suites
|
||||
|
||||
`backend/__tests__/` contains:
|
||||
|
||||
| File | What it covers |
|
||||
|------|----------------|
|
||||
| `basic.test.ts` | Smoke test of the bootstrap |
|
||||
| `file-service.test.ts` | Upload + Sharp image-processing pipeline |
|
||||
| `payment-integration.test.ts` | End-to-end pay-in / pay-out across providers |
|
||||
| `payment-system.test.ts` | Payment service unit tests |
|
||||
| `shkeeper-webhook.test.ts` | Signature verification + status transition |
|
||||
| `simple-marketplace.test.ts` | Purchase-request + offer flow |
|
||||
| `simple-payment.test.ts` | Single-provider payment fast-path |
|
||||
| `simple-user.test.ts` | Auth + signup + JWT issuance |
|
||||
| `setup.ts` | Shared Jest setup (DB, env vars, helpers) |
|
||||
|
||||
There are also four large aggregate suites referenced in `package.json` (some may live in branches or be reintroduced as the codebase evolves):
|
||||
|
||||
- `models.test.ts` — every Mongoose schema, validation, indexes, relationships
|
||||
- `payment-services.test.ts` — DePay, SHKeeper, Web3, admin operations
|
||||
- `complete-backend.test.ts` — Auth, marketplace, chat, notification, address, user, file, email, AI
|
||||
- `shkeeper-backend.test.ts` — Service layer + API endpoints for SHKeeper
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
cd ~/code/backend
|
||||
|
||||
npm run test # run all *.test.ts files once (forceExit on)
|
||||
npm run test:watch # interactive watch mode
|
||||
npm run test:coverage # also emit coverage report to ./coverage/
|
||||
npm run test:all # explicit __tests__/ folder
|
||||
|
||||
# Focused suites (each maps to a single file):
|
||||
npm run test:models # jest __tests__/models.test.ts
|
||||
npm run test:payment # jest __tests__/payment-services.test.ts
|
||||
npm run test:complete # jest __tests__/complete-backend.test.ts
|
||||
npm run test:shkeeper # jest __tests__/shkeeper-backend.test.ts
|
||||
```
|
||||
|
||||
Pass extra Jest flags after `--`:
|
||||
|
||||
```bash
|
||||
npm run test -- --testPathPattern=payment --verbose
|
||||
```
|
||||
|
||||
### Coverage targets
|
||||
|
||||
- Statements & lines: **≥ 80 %** on changed files
|
||||
- Branches: **≥ 70 %** on changed files
|
||||
- Critical paths (auth, payment, escrow release) — aim for **≥ 90 %**
|
||||
|
||||
Coverage is collected from `src/**/*.ts` excluding `.d.ts` and `__tests__/`. View the HTML report at `coverage/lcov-report/index.html` after running `npm run test:coverage`.
|
||||
|
||||
### Adding a new backend test
|
||||
|
||||
1. Place file under `__tests__/` (or colocated `*.test.ts` next to the source).
|
||||
2. Import the app and use `supertest`:
|
||||
|
||||
```ts
|
||||
import request from 'supertest';
|
||||
import { app } from '../src/app';
|
||||
|
||||
describe('GET /api/health', () => {
|
||||
it('returns 200', async () => {
|
||||
const res = await request(app).get('/health');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
3. Use the in-memory DB — connections are wired in `setup.ts`. Each test starts with a clean collection.
|
||||
4. Mock outbound HTTP (SHKeeper, OpenAI) with `jest.spyOn(axios, 'post')`. Never hit a real provider from tests.
|
||||
|
||||
> [!warning] `maxWorkers: 1` makes tests serial. Don't introduce timing-sensitive parallelism — instead, keep individual tests small and deterministic.
|
||||
|
||||
---
|
||||
|
||||
## Frontend testing
|
||||
|
||||
### Stack
|
||||
|
||||
- **Jest 29** + **ts-jest 29** + **jsdom 29** — component & util tests
|
||||
- **@testing-library/react 16** + **@testing-library/jest-dom 6** + **@testing-library/user-event 14**
|
||||
- **Playwright 1.56** — browser-driven E2E
|
||||
- **identity-obj-proxy** — stubs CSS module imports
|
||||
|
||||
### Jest configuration
|
||||
|
||||
`frontend/jest.config.js`:
|
||||
|
||||
- Environment: `jsdom`
|
||||
- Roots: `<rootDir>/src` and `<rootDir>/__tests__`
|
||||
- Test globs: `**/__tests__/**/*.test.(ts|tsx|js)`, `**/__tests__/**/*.spec.(ts|tsx|js)`, `**/*.(test|spec).(ts|tsx|js)`
|
||||
- Asset mapping: images & fonts → `__tests__/mocks/fileMock.js`, CSS → `identity-obj-proxy`
|
||||
- Module aliases: `src/*` → `<rootDir>/src/*`
|
||||
- `setupFilesAfterEach`: `jest.setup.js` (sets up RTL matchers, axios mocks, env)
|
||||
|
||||
### Test layout — `__tests__/`
|
||||
|
||||
Tests are grouped by domain:
|
||||
|
||||
```
|
||||
__tests__/
|
||||
├── account-test/
|
||||
├── address-test/
|
||||
├── auth-test/
|
||||
├── chat-test/
|
||||
├── components-test/
|
||||
├── file-test/
|
||||
├── integration-test/
|
||||
├── marketplace-test/
|
||||
├── notification-test/
|
||||
├── payment-test/
|
||||
├── user-test/
|
||||
├── utils-test/
|
||||
└── mocks/
|
||||
```
|
||||
|
||||
Each folder contains one or more `*.test.tsx` files. See `__tests__/README.md`, `PROJECT_TEST_CHECKLIST.md`, and `TEST_ORGANIZATION_SUMMARY.md` in the repo for current status.
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
cd ~/code/frontend
|
||||
|
||||
yarn test # full Jest suite
|
||||
yarn test -- --watch # watch mode
|
||||
yarn test -- --coverage # coverage report
|
||||
yarn test -- payment # name-pattern filter
|
||||
```
|
||||
|
||||
### Playwright E2E
|
||||
|
||||
`frontend/e2e/` contains four headless-Chromium suites:
|
||||
|
||||
| File | Coverage |
|
||||
|------|----------|
|
||||
| `auth.spec.ts` | Sign up, login, logout, password reset |
|
||||
| `marketplace.spec.ts` | Browse, create request, accept offer |
|
||||
| `shop.spec.ts` | Public shop pages |
|
||||
| `performance.spec.ts` | Performance budgets (LCP, INP) |
|
||||
|
||||
```bash
|
||||
yarn test:e2e # headless run
|
||||
yarn test:e2e:ui # open Playwright Inspector
|
||||
yarn test:e2e:headed # show the browser
|
||||
yarn test:e2e:debug # step through with devtools
|
||||
yarn test:perf # only e2e/performance.spec.ts
|
||||
yarn playwright:install # one-time browser download
|
||||
```
|
||||
|
||||
Playwright assumes the backend + frontend are reachable at the URLs in `playwright.config.ts` (defaults: `http://localhost:8083` and `http://localhost:5001`). Start both stacks first — see [[Local Setup]].
|
||||
|
||||
### Coverage targets (frontend)
|
||||
|
||||
- Components: **≥ 70 %** statement coverage
|
||||
- Hooks / utilities: **≥ 90 %**
|
||||
- Critical flows (login, checkout): covered by both unit and Playwright suites
|
||||
|
||||
### Adding a new component test
|
||||
|
||||
1. Colocate `MyComponent.test.tsx` next to `MyComponent.tsx`, or place in `__tests__/<domain>-test/`.
|
||||
2. Render with RTL and assert via accessible queries:
|
||||
|
||||
```tsx
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ThemeProvider } from 'src/theme/theme-provider';
|
||||
import { MyComponent } from './MyComponent';
|
||||
|
||||
const renderWithProviders = (ui: React.ReactElement) =>
|
||||
render(<ThemeProvider>{ui}</ThemeProvider>);
|
||||
|
||||
it('submits the form', async () => {
|
||||
renderWithProviders(<MyComponent />);
|
||||
await userEvent.type(screen.getByLabelText(/email/i), 'a@b.com');
|
||||
await userEvent.click(screen.getByRole('button', { name: /submit/i }));
|
||||
expect(screen.getByText(/thanks/i)).toBeInTheDocument();
|
||||
});
|
||||
```
|
||||
|
||||
3. Mock `lib/axios` if the component makes API calls:
|
||||
|
||||
```ts
|
||||
import api from 'src/lib/axios';
|
||||
jest.mock('src/lib/axios');
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
mockedApi.post.mockResolvedValueOnce({ data: { success: true } });
|
||||
```
|
||||
|
||||
### Adding a Playwright test
|
||||
|
||||
1. Add `e2e/<feature>.spec.ts`:
|
||||
|
||||
```ts
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('user can log in', async ({ page }) => {
|
||||
await page.goto('/auth/jwt/sign-in');
|
||||
await page.getByLabel('Email').fill('admin@marketplace.com');
|
||||
await page.getByLabel('Password').fill('Moji6364');
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
});
|
||||
```
|
||||
|
||||
2. Run `yarn test:e2e:headed` to debug.
|
||||
3. Keep specs idempotent — clean up any test data the spec creates (or rely on the seeded test users).
|
||||
|
||||
---
|
||||
|
||||
## CI integration
|
||||
|
||||
The Gitea Actions workflows (see [[CI-CD Pipeline]]) currently build and push Docker images but do not yet run Jest. If you add test gating, add a `Run tests` step before the `Build` step:
|
||||
|
||||
```yaml
|
||||
- name: Install
|
||||
run: yarn install --frozen-lockfile
|
||||
- name: Test
|
||||
run: yarn test --ci --runInBand
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Debugging tips
|
||||
|
||||
- **Backend test hangs** — add `--detectOpenHandles --forceExit`. Almost always a forgotten `mongoose.disconnect()` or open Redis client. The shared `setup.ts` handles this, but custom suites might not.
|
||||
- **Frontend test fails on `window.matchMedia`** — `jest.setup.js` polyfills it; if you add a new test runner config, copy that polyfill.
|
||||
- **Playwright flaky** — use `await expect(...).toBeVisible()` rather than `waitForSelector` and increase per-test timeout in `playwright.config.ts` for slow flows.
|
||||
- **Coverage low but the test exists** — make sure the file is in `collectCoverageFrom` and not excluded by an `index.ts` filter.
|
||||
315
08 - Operations/Backup & Recovery.md
Normal file
315
08 - Operations/Backup & Recovery.md
Normal file
@@ -0,0 +1,315 @@
|
||||
---
|
||||
title: Backup & Recovery
|
||||
tags: [operations]
|
||||
---
|
||||
|
||||
# Backup & Recovery
|
||||
|
||||
How to keep the marketplace recoverable from data loss. Covers MongoDB, Redis, the `uploads/` directory, and environment secrets, plus the disaster-recovery runbook.
|
||||
|
||||
---
|
||||
|
||||
## 1. RTO / RPO targets
|
||||
|
||||
| Asset | RPO (data loss tolerated) | RTO (downtime tolerated) | Backup cadence |
|
||||
|-------|---------------------------|--------------------------|----------------|
|
||||
| MongoDB | 1 hour | 1 hour | Hourly `mongodump` + nightly offsite |
|
||||
| `uploads/` directory | 24 hours | 2 hours | Nightly `rsync` to offsite |
|
||||
| Redis | 1 hour (regeneratable) | 0 minutes (app survives empty cache) | Nightly RDB snapshot |
|
||||
| Production `.env` | n/a (manual) | 5 minutes | Stored in 1Password / Bitwarden vault |
|
||||
| Container images | n/a (CI rebuilds) | 15 minutes | Tagged in registry by version |
|
||||
|
||||
Adjust these targets when product SLAs change.
|
||||
|
||||
---
|
||||
|
||||
## 2. MongoDB
|
||||
|
||||
### 2.1 Dump
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# scripts/backup-mongo.sh — run hourly via cron
|
||||
set -euo pipefail
|
||||
|
||||
STAMP=$(date -u +%FT%H%M%SZ)
|
||||
DEST=/var/backups/mongo
|
||||
mkdir -p "$DEST"
|
||||
|
||||
docker exec nickapp-mongodb \
|
||||
mongodump --db=marketplace --archive --gzip \
|
||||
> "$DEST/marketplace-$STAMP.gz"
|
||||
|
||||
# Keep last 24 hourly + 14 daily
|
||||
find "$DEST" -name 'marketplace-*.gz' -mtime +14 -delete
|
||||
```
|
||||
|
||||
Cron entry:
|
||||
|
||||
```
|
||||
0 * * * * /usr/local/bin/backup-mongo.sh >> /var/log/backup-mongo.log 2>&1
|
||||
```
|
||||
|
||||
### 2.2 Offsite
|
||||
|
||||
Push the most recent dump to S3 (or Backblaze B2, or `rclone` to any provider) nightly:
|
||||
|
||||
```bash
|
||||
aws s3 cp "$DEST"/marketplace-*.gz \
|
||||
"s3://marketplace-backups/mongo/" \
|
||||
--recursive --exclude "*" --include "marketplace-*.gz" \
|
||||
--storage-class STANDARD_IA
|
||||
```
|
||||
|
||||
Set a 90-day lifecycle policy on the bucket to age out old copies.
|
||||
|
||||
### 2.3 Restore
|
||||
|
||||
> [!warning] Restoring is **destructive** to the current data. Always practise on a staging clone before doing it for real.
|
||||
|
||||
```bash
|
||||
# Restore against an empty database (fresh container)
|
||||
docker exec -i nickapp-mongodb \
|
||||
mongorestore --archive --gzip --drop \
|
||||
< /var/backups/mongo/marketplace-2026-05-20T0300Z.gz
|
||||
|
||||
# Verify
|
||||
docker exec nickapp-mongodb mongosh \
|
||||
--eval "use marketplace; db.users.countDocuments()"
|
||||
```
|
||||
|
||||
For partial restore (single collection):
|
||||
|
||||
```bash
|
||||
docker exec -i nickapp-mongodb \
|
||||
mongorestore --archive --gzip --drop \
|
||||
--nsInclude='marketplace.payments' \
|
||||
< /var/backups/mongo/marketplace-2026-05-20T0300Z.gz
|
||||
```
|
||||
|
||||
### 2.4 Validate backups
|
||||
|
||||
A monthly drill — restore the latest dump into a throwaway container and run smoke queries:
|
||||
|
||||
```bash
|
||||
docker run --rm -v $(pwd)/marketplace-latest.gz:/dump.gz mongo:8.2 \
|
||||
sh -c "mongorestore --archive=/dump.gz --gzip && mongosh --eval 'db.getMongo().getDBNames()'"
|
||||
```
|
||||
|
||||
If validation fails, treat as a sev-2 incident (see [[Incident Response]]).
|
||||
|
||||
---
|
||||
|
||||
## 3. Redis
|
||||
|
||||
Redis data is regeneratable — losing it means logged-out users + cold caches, no business data lost. Still cheap to back up.
|
||||
|
||||
### 3.1 Snapshot
|
||||
|
||||
```bash
|
||||
# Trigger a save and copy out
|
||||
docker exec nickapp-redis redis-cli -a "$REDIS_PASSWORD" BGSAVE
|
||||
sleep 5
|
||||
docker cp nickapp-redis:/data/dump.rdb /var/backups/redis/redis-$(date -u +%FT%H%M%SZ).rdb
|
||||
```
|
||||
|
||||
Daily cron is sufficient.
|
||||
|
||||
### 3.2 Restore
|
||||
|
||||
```bash
|
||||
# Stop redis, drop the RDB into the volume, start
|
||||
docker compose -f docker-compose.production.yml stop redis
|
||||
docker cp /var/backups/redis/redis-2026-05-20T0300Z.rdb nickapp-redis:/data/dump.rdb
|
||||
docker compose -f docker-compose.production.yml start redis
|
||||
```
|
||||
|
||||
If you've enabled AOF, also copy `appendonly.aof`. See [[Database Operations#persistence]].
|
||||
|
||||
---
|
||||
|
||||
## 4. `uploads/` directory
|
||||
|
||||
Stored on the host at `/opt/backend/uploads/` and bind-mounted into both backend and nginx containers. This is where every user upload lives — losing it means broken images, missing dispute evidence, and unhappy users.
|
||||
|
||||
### 4.1 Nightly sync
|
||||
|
||||
```bash
|
||||
rsync -av --delete /opt/backend/uploads/ \
|
||||
s3://marketplace-backups/uploads/
|
||||
|
||||
# Or rclone to any provider
|
||||
rclone sync /opt/backend/uploads/ backblaze:marketplace-uploads --transfers 8
|
||||
```
|
||||
|
||||
Cron:
|
||||
|
||||
```
|
||||
30 3 * * * /usr/local/bin/backup-uploads.sh >> /var/log/backup-uploads.log 2>&1
|
||||
```
|
||||
|
||||
### 4.2 Restore
|
||||
|
||||
```bash
|
||||
rsync -av s3://marketplace-backups/uploads/ /opt/backend/uploads/
|
||||
# fix ownership for the marketplace container (uid 1001)
|
||||
chown -R 1001:1001 /opt/backend/uploads
|
||||
```
|
||||
|
||||
Restart the backend container so any in-flight uploads find the right directory layout.
|
||||
|
||||
---
|
||||
|
||||
## 5. Secrets & configuration
|
||||
|
||||
### 5.1 `.env` files
|
||||
|
||||
The production `.env` lives at `/opt/backend/.env`. It is **not** version-controlled and **not** in any standard backup. Source of truth: the team password manager (1Password / Bitwarden vault).
|
||||
|
||||
After any change:
|
||||
|
||||
1. Update the host file.
|
||||
2. Update the vault entry with the new value, a one-line "why", and the date.
|
||||
3. `docker compose -f docker-compose.production.yml up -d` to apply.
|
||||
|
||||
### 5.2 SSL certs
|
||||
|
||||
If you run a host-level Caddy / Nginx with Let's Encrypt, certs auto-renew. Back up `/var/lib/caddy/.local/share/caddy/` (Caddy) or `/etc/letsencrypt/` (Certbot) — useful if you migrate hosts.
|
||||
|
||||
### 5.3 Container registry credentials
|
||||
|
||||
`/root/.docker/config.json` on the production host holds the `git.manko.yoga` login Watchtower uses. Recreate after a rebuild:
|
||||
|
||||
```bash
|
||||
docker login git.manko.yoga -u manawenuz
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Disaster recovery runbook
|
||||
|
||||
> Scenario: production host is unrecoverable (disk failure, cloud provider lost the VM, etc.).
|
||||
|
||||
### Phase 1 — Provision
|
||||
|
||||
1. Spin up a new VM matching the previous spec (≥ 4 vCPU, 8 GB RAM, 100 GB SSD).
|
||||
2. Install Docker Engine + compose plugin.
|
||||
3. Restore DNS pointing or stand up a temporary subdomain (`recovery.amn.gg`).
|
||||
|
||||
### Phase 2 — Code
|
||||
|
||||
```bash
|
||||
cd /opt
|
||||
git clone ssh://git@git.manko.yoga:222/nick/backend.git
|
||||
git clone ssh://git@git.manko.yoga:222/nick/frontend.git
|
||||
cd backend && git checkout main
|
||||
```
|
||||
|
||||
### Phase 3 — Config
|
||||
|
||||
```bash
|
||||
# Restore .env from the vault
|
||||
nano /opt/backend/.env
|
||||
|
||||
# Restore nginx config
|
||||
mkdir -p nginx/logs
|
||||
# copy nginx.conf from the vault / repo / your laptop
|
||||
```
|
||||
|
||||
### Phase 4 — Data
|
||||
|
||||
```bash
|
||||
# Mongo
|
||||
mkdir -p /var/backups/mongo
|
||||
aws s3 cp s3://marketplace-backups/mongo/marketplace-LATEST.gz /var/backups/mongo/
|
||||
|
||||
# Uploads
|
||||
mkdir -p /opt/backend/uploads
|
||||
aws s3 sync s3://marketplace-backups/uploads/ /opt/backend/uploads/
|
||||
chown -R 1001:1001 /opt/backend/uploads
|
||||
|
||||
# Redis (optional — empty is fine)
|
||||
mkdir -p /var/backups/redis
|
||||
aws s3 cp s3://marketplace-backups/redis/redis-LATEST.rdb /var/backups/redis/
|
||||
```
|
||||
|
||||
### Phase 5 — Start stack
|
||||
|
||||
```bash
|
||||
cd /opt/backend
|
||||
docker login git.manko.yoga -u manawenuz
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
# wait ~60s
|
||||
docker compose -f docker-compose.production.yml ps
|
||||
```
|
||||
|
||||
### Phase 6 — Restore data into running containers
|
||||
|
||||
```bash
|
||||
# Mongo
|
||||
docker exec -i nickapp-mongodb \
|
||||
mongorestore --archive --gzip --drop \
|
||||
< /var/backups/mongo/marketplace-LATEST.gz
|
||||
|
||||
# Redis
|
||||
docker compose stop redis
|
||||
docker cp /var/backups/redis/redis-LATEST.rdb nickapp-redis:/data/dump.rdb
|
||||
docker compose start redis
|
||||
```
|
||||
|
||||
### Phase 7 — Verify
|
||||
|
||||
```bash
|
||||
curl -fsS http://localhost:8083/api/health | jq
|
||||
docker exec nickapp-mongodb mongosh --eval "use marketplace; db.users.countDocuments()"
|
||||
docker compose logs --tail=200 nickapp-backend | grep -E "✅|❌"
|
||||
```
|
||||
|
||||
### Phase 8 — Restart Watchtower & cut over DNS
|
||||
|
||||
```bash
|
||||
docker run -d --name watchtower --restart unless-stopped \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v /root/.docker/config.json:/config.json \
|
||||
-e WATCHTOWER_POLL_INTERVAL=300 \
|
||||
-e WATCHTOWER_LABEL_ENABLE=true \
|
||||
containrrr/watchtower
|
||||
|
||||
# Update DNS for amn.gg / dev.amn.gg to the new host's IP
|
||||
```
|
||||
|
||||
### Phase 9 — Post-mortem
|
||||
|
||||
Write a post-mortem (template in [[Incident Response#postmortem-template]]) and update this runbook with anything that surprised you.
|
||||
|
||||
---
|
||||
|
||||
## 7. Quick-reference commands
|
||||
|
||||
```bash
|
||||
# Mongo dump
|
||||
docker exec nickapp-mongodb mongodump --db=marketplace --archive --gzip > backup.gz
|
||||
# Mongo restore
|
||||
docker exec -i nickapp-mongodb mongorestore --archive --gzip --drop < backup.gz
|
||||
|
||||
# Redis snapshot
|
||||
docker exec nickapp-redis redis-cli -a "$REDIS_PASSWORD" BGSAVE
|
||||
docker cp nickapp-redis:/data/dump.rdb redis.rdb
|
||||
|
||||
# Uploads to S3
|
||||
rclone sync /opt/backend/uploads/ s3:marketplace-backups/uploads/
|
||||
|
||||
# Restore .env
|
||||
# Pull from vault, paste into /opt/backend/.env, docker compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Testing the plan
|
||||
|
||||
> [!tip] Backups are not real until they've been restored. Drill quarterly:
|
||||
>
|
||||
> 1. Spin up a throwaway VM.
|
||||
> 2. Walk Phases 2–7 of the DR runbook with the most recent backups.
|
||||
> 3. Time it. If RTO is busted, fix the gap before the next drill.
|
||||
> 4. Capture lessons in this file.
|
||||
259
08 - Operations/CI-CD Pipeline.md
Normal file
259
08 - Operations/CI-CD Pipeline.md
Normal file
@@ -0,0 +1,259 @@
|
||||
---
|
||||
title: CI-CD Pipeline
|
||||
tags: [operations]
|
||||
---
|
||||
|
||||
# CI/CD Pipeline
|
||||
|
||||
How code goes from a push to a running container in production. The CI is **Gitea Actions** running on the same Gitea instance that hosts the repos. The CD is **Watchtower** on the production host (covered in [[Deployment]]).
|
||||
|
||||
---
|
||||
|
||||
## 1. Where workflows live
|
||||
|
||||
| Repo | Path | Files |
|
||||
|------|------|-------|
|
||||
| Backend | `.gitea/workflows/` | `docker-build-simple.yml`, `docker-build-dev.yml`, `docker-build-no-cache.yml` |
|
||||
| Frontend | `.gitea/workflows/` | `deploy.yml`, `devDeploy.yml` |
|
||||
|
||||
Gitea Actions speaks the same YAML dialect as GitHub Actions — most third-party actions (`actions/checkout@v4`, `docker/login-action@v3`, `docker/build-push-action@v5`) work unchanged.
|
||||
|
||||
---
|
||||
|
||||
## 2. Required secrets
|
||||
|
||||
Configured per repo at **Settings → Actions → Secrets**.
|
||||
|
||||
| Secret | Repo | Purpose |
|
||||
|--------|------|---------|
|
||||
| `GITEATOKEN` | both | Personal access token for the `manawenuz` user with `write:packages` scope. Used by every workflow to log into the container registry at `git.manko.yoga`. |
|
||||
| `SENTRY_AUTH_TOKEN` | frontend | (Optional) For source-map upload during Next.js build. Skipped if absent. |
|
||||
|
||||
The registry itself is implicit: `git.manko.yoga` with `manawenuz` as the user. Image paths are `git.manko.yoga/manawenuz/<image>`.
|
||||
|
||||
> [!warning] If `GITEATOKEN` expires or is rotated, all workflows fail at the `docker/login-action` step. Rotate proactively (annual reminder).
|
||||
|
||||
---
|
||||
|
||||
## 3. Backend workflows
|
||||
|
||||
### `docker-build-simple.yml` — manual build
|
||||
|
||||
```yaml
|
||||
name: Manual Build and Push Docker Image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version to build (leave empty for package.json)'
|
||||
required: false
|
||||
type: string
|
||||
```
|
||||
|
||||
- **Trigger.** Manual only (via Gitea UI → Actions → "Run workflow").
|
||||
- **Steps.** Checkout → buildx → `docker login` → read version (input or `package.json`) → build `Dockerfile.prod` → push tags `:<version>` and `:dev` → echo result.
|
||||
- **When to use.** Cutting an ad-hoc build of a specific commit without merging to a branch. The `:dev` tag is overwritten — production (`:latest`) is **not** touched.
|
||||
- **Cache.** Uses `type=gha` cache to speed up subsequent runs.
|
||||
|
||||
### `docker-build-dev.yml` — dev branch auto-build
|
||||
|
||||
```yaml
|
||||
on:
|
||||
push:
|
||||
branches: [ development ]
|
||||
tags: [ 'v*' ]
|
||||
```
|
||||
|
||||
- **Trigger.** Every push to `development` and every tag matching `v*`.
|
||||
- **Tags pushed.** `:dev-<package-version>` + moving `:dev`.
|
||||
- **Effect.** Refreshes the dev image. The production Watchtower **does not** watch `:dev`, so this is safe to push as often as you want.
|
||||
|
||||
### `docker-build-no-cache.yml` — production build
|
||||
|
||||
```yaml
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
tags: [ 'v*' ]
|
||||
```
|
||||
|
||||
- **Trigger.** Every push to `main` (or `master`) and every `v*` tag.
|
||||
- **Tags pushed.** `:<package-version>` + moving `:latest`.
|
||||
- **Effect.** Watchtower polls `:latest`, detects the new digest, restarts `nickapp-backend` on the production host. See [[Deployment#routine-deploy]].
|
||||
- **No cache.** The file is named "No Cache" but actually does not pass `cache-from`/`cache-to`, so each build is from scratch. Slower (~5–8 min) but eliminates a class of stale-layer bugs. The `simple` workflow uses GHA cache for speed.
|
||||
|
||||
> [!tip] If you need to invalidate a cached layer in the `simple` workflow, run `no-cache` once — the resulting tag overwrites the registry digest and `simple`'s next run will start from a cleaner base.
|
||||
|
||||
---
|
||||
|
||||
## 4. Frontend workflows
|
||||
|
||||
Both workflows share the same shape: spin up a `node:22` container, run a deploy shell script that does `docker login + build + push`.
|
||||
|
||||
### `deploy.yml` — production
|
||||
|
||||
```yaml
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
workflow_dispatch:
|
||||
```
|
||||
|
||||
Calls `./scripts/deploy.sh` — see [[Scripts#deployment]]. The script:
|
||||
|
||||
1. Reads `package.json` version.
|
||||
2. `docker login git.manko.yoga -u manawenuz -p $GITEATOKEN`.
|
||||
3. Builds `git.manko.yoga/manawenuz/escrow-frontend:<version>` and `:latest` from `Dockerfile`.
|
||||
4. Pushes both tags.
|
||||
|
||||
`:latest` is what production Watchtower watches → live deploy follows automatically.
|
||||
|
||||
### `devDeploy.yml` — development branch
|
||||
|
||||
Same as `deploy.yml` but triggered on `development` and runs `./scripts/deployDev.sh`, which pushes only `:dev`.
|
||||
|
||||
---
|
||||
|
||||
## 5. End-to-end timeline (production deploy)
|
||||
|
||||
```
|
||||
t=0 Developer merges PR → main
|
||||
t+5s Gitea webhook fires
|
||||
t+10s Gitea Actions runner pulls repo, starts container
|
||||
t+30s docker/setup-buildx-action initialised
|
||||
t+45s docker/login-action authenticated
|
||||
t+2-5m docker/build-push-action builds Dockerfile.prod
|
||||
t+5m Push to git.manko.yoga/manawenuz/escrow-backend:latest
|
||||
t+5m+ Watchtower (next poll, up to 5 min) detects new digest
|
||||
t+10m Watchtower stops old container, starts new one
|
||||
t+10m40s start_period=40s elapses, healthcheck passes
|
||||
t+11m Nginx routes traffic to the new container
|
||||
```
|
||||
|
||||
**Typical SLA: 10–12 minutes from merge to live.** For an emergency rollback see [[Deployment#roll-back]].
|
||||
|
||||
---
|
||||
|
||||
## 6. Versioning automation
|
||||
|
||||
Tied to `backend/scripts/auto-version.sh` + `ai-enhanced.sh` (and the frontend mirror). Full reference in [[Git Workflow#versioning]] and [[Scripts#auto-version-sh]].
|
||||
|
||||
In short:
|
||||
|
||||
```bash
|
||||
# Developer side, on the branch they're releasing:
|
||||
npm run smart-release
|
||||
# → AI analyses last commit, picks bump (major/minor/patch/skip)
|
||||
# → bumps package.json
|
||||
# → commits "chore: bump version to vX.Y.Z"
|
||||
# → tags vX.Y.Z
|
||||
# → git push && git push --tags
|
||||
```
|
||||
|
||||
The push to `main` (or the `v*` tag) then triggers `docker-build-no-cache.yml`, which:
|
||||
|
||||
- Reads the new version from `package.json` (`node -p "require('./package.json').version"`)
|
||||
- Builds and pushes `:<version>` + `:latest`
|
||||
|
||||
So both the **image tag** and the **git tag** carry the same `vX.Y.Z` — easy to correlate when investigating an issue.
|
||||
|
||||
---
|
||||
|
||||
## 7. Adding tests to the pipeline
|
||||
|
||||
The workflows today only build + push; they do **not** run Jest or Playwright. To gate releases on tests, add a `test` job before the build:
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
container: node:22
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: yarn install --frozen-lockfile
|
||||
- run: yarn lint
|
||||
- run: yarn test --ci --runInBand
|
||||
- run: yarn test:e2e # if a service container is available
|
||||
|
||||
build-and-push:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
# ...existing steps...
|
||||
```
|
||||
|
||||
Or run lint + typecheck as a pre-gate using a separate workflow that triggers on PR opened/synchronised.
|
||||
|
||||
---
|
||||
|
||||
## 8. Inspecting a build
|
||||
|
||||
In Gitea: **Actions → workflow → run** to see real-time logs.
|
||||
|
||||
Useful CLI for the registry from your laptop:
|
||||
|
||||
```bash
|
||||
# List images and tags
|
||||
curl -s -u "manawenuz:$GITEATOKEN" \
|
||||
"https://git.manko.yoga/v2/manawenuz/escrow-backend/tags/list" | jq
|
||||
|
||||
# Pull a specific tag
|
||||
docker login git.manko.yoga -u manawenuz
|
||||
docker pull git.manko.yoga/manawenuz/escrow-backend:2.6.3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Self-hosted runner notes
|
||||
|
||||
Gitea Actions can use either built-in `act_runner` or your own. Currently the workflows are written for `runs-on: ubuntu-latest`, which the act_runner supplies via a generic Ubuntu container. If you need:
|
||||
|
||||
- More CPU/RAM for builds → register a beefier self-hosted runner and change `runs-on:` to its label.
|
||||
- A Docker-in-Docker setup (frontend `deploy.yml` does this with `options: --privileged`) — confirm the runner trusts the workflow.
|
||||
|
||||
---
|
||||
|
||||
## 10. Failure modes & remediation
|
||||
|
||||
| Failure | Most likely cause | Fix |
|
||||
|---------|------------------|-----|
|
||||
| `unauthorized: authentication required` at push | `GITEATOKEN` expired or lacks `write:packages` | Rotate the token, update the repo secret |
|
||||
| `Cannot perform an interactive login from a non TTY device` | Old docker-login-action version | Bump to `docker/login-action@v3` |
|
||||
| Build hangs at `yarn install` | npm registry timeout | Increase `network-timeout` (already 600000); re-run |
|
||||
| Image pushed but Watchtower doesn't roll | Watchtower can't reach the registry | `docker logs watchtower`; verify `/root/.docker/config.json` is mounted into the container |
|
||||
| New container fails healthcheck | App crash on boot | `docker logs nickapp-backend`; check env vars, follow [[Incident Response]] |
|
||||
| Multi-arch warnings about platform | Build runner is arm64 but prod is amd64 | Add `--platform=linux/amd64` to `docker/build-push-action` inputs |
|
||||
| Image size grew suddenly | Dev dep crept into prod stage | Audit `Dockerfile.prod` for missing `--production` flag in the runtime stage |
|
||||
|
||||
---
|
||||
|
||||
## 11. Pipeline diagram
|
||||
|
||||
```
|
||||
Push to development Push to main
|
||||
│ │
|
||||
▼ ▼
|
||||
┌───────────────────────────┐ ┌───────────────────────────┐
|
||||
│ docker-build-dev.yml │ │ docker-build-no-cache.yml │
|
||||
│ (backend) │ │ (backend) │
|
||||
│ devDeploy.yml (frontend) │ │ deploy.yml (frontend) │
|
||||
└───────────────┬───────────┘ └───────────────┬───────────┘
|
||||
│ │
|
||||
push :<version>,:dev push :<version>,:latest
|
||||
│ │
|
||||
▼ ▼
|
||||
git.manko.yoga/manawenuz/...:dev git.manko.yoga/manawenuz/...:latest
|
||||
│ │
|
||||
│ ▼
|
||||
│ ┌──────────────────────────┐
|
||||
│ │ Watchtower │
|
||||
│ │ (poll every 5 minutes) │
|
||||
│ └──────────────┬───────────┘
|
||||
│ │
|
||||
manual pull on staging restart containers
|
||||
│
|
||||
▼
|
||||
Production live
|
||||
```
|
||||
|
||||
Cross-links: [[Deployment]] for what happens on the host, [[Git Workflow]] for what happens upstream, [[Scripts]] for the deploy shell scripts.
|
||||
301
08 - Operations/Database Operations.md
Normal file
301
08 - Operations/Database Operations.md
Normal file
@@ -0,0 +1,301 @@
|
||||
---
|
||||
title: Database Operations
|
||||
tags: [operations]
|
||||
---
|
||||
|
||||
# Database Operations
|
||||
|
||||
Day-to-day operations for the two stateful services: **MongoDB 8.2** (primary data store) and **Redis 8** (cache, rate-limit counters, ephemeral session data).
|
||||
|
||||
For schema details see [[Data Models]]. For backup procedures and disaster recovery see [[Backup & Recovery]].
|
||||
|
||||
---
|
||||
|
||||
## 1. MongoDB
|
||||
|
||||
### 1.1 Connection
|
||||
|
||||
| Env | URI in compose | Auth |
|
||||
|-----|---------------|------|
|
||||
| Dev | `mongodb://mongodb:27017` | none |
|
||||
| Prod | `mongodb://mongodb:27017` (private network) or with creds via `.env` | typically none on the private network, but enable `--auth` if exposed |
|
||||
|
||||
The DB name comes from `DB_NAME` (e.g. `marketplace`). See [[Environment Variables#database]].
|
||||
|
||||
Connect from a shell inside the host:
|
||||
|
||||
```bash
|
||||
# Dev
|
||||
docker exec -it nickdev-mongodb mongosh
|
||||
|
||||
# Prod
|
||||
docker exec -it nickapp-mongodb mongosh
|
||||
> use marketplace
|
||||
> show collections
|
||||
```
|
||||
|
||||
If auth is enabled:
|
||||
|
||||
```bash
|
||||
docker exec -it nickapp-mongodb mongosh \
|
||||
-u "$MONGO_INITDB_ROOT_USERNAME" -p "$MONGO_INITDB_ROOT_PASSWORD" \
|
||||
--authenticationDatabase admin
|
||||
```
|
||||
|
||||
### 1.2 Init scripts (`mongo-init/`)
|
||||
|
||||
The production compose bind-mounts `./mongo-init` into `/docker-entrypoint-initdb.d`. Mongo runs `*.js` and `*.sh` from this folder **only on a fresh datadir** (first boot of a new volume). Use this to:
|
||||
|
||||
- Create application users (`db.createUser({...})`)
|
||||
- Bootstrap collections + indexes that must exist before the app starts
|
||||
|
||||
Example `mongo-init/01-create-user.js`:
|
||||
|
||||
```js
|
||||
db = db.getSiblingDB('marketplace');
|
||||
db.createUser({
|
||||
user: 'marketplace_app',
|
||||
pwd: process.env.MARKETPLACE_APP_PWD,
|
||||
roles: [{ role: 'readWrite', db: 'marketplace' }],
|
||||
});
|
||||
```
|
||||
|
||||
> [!warning] These scripts do **not** run when you restart an existing container. To force re-init, drop the `mongodb_data` volume — which destroys all data. Plan accordingly.
|
||||
|
||||
### 1.3 Indexes
|
||||
|
||||
Indexes are declared in Mongoose schemas under `backend/src/models/`. The app calls `Model.createIndexes()` on connection (via the model's `syncIndexes`/`ensureIndexes` lifecycle). Highlights:
|
||||
|
||||
| Collection | Key indexes |
|
||||
|------------|-------------|
|
||||
| `users` | `email` (unique), `googleId` (sparse), `role`, `createdAt` |
|
||||
| `addresses` | `userId` + compound for primary lookup |
|
||||
| `purchaserequests` | `buyerId`, `status`, `createdAt`, text index on `title`+`description` |
|
||||
| `selleroffers` | `requestId`, `sellerId`, `status` |
|
||||
| `payments` | `providerPaymentId` (unique sparse), `userId`, `status`, `createdAt`, `transactionHash` |
|
||||
| `chats` | `participants` (array), `updatedAt` |
|
||||
| `notifications` | `userId` + `read`, `createdAt` |
|
||||
| `tempverifications` | TTL on `expiresAt` (auto-deletes expired OTPs) |
|
||||
|
||||
To verify a specific collection:
|
||||
|
||||
```js
|
||||
db.payments.getIndexes()
|
||||
```
|
||||
|
||||
To add a new index without code-gen — preferred path is to declare it in the Mongoose schema and ship a deploy. For emergency hotfixes:
|
||||
|
||||
```js
|
||||
db.payments.createIndex({ providerPaymentId: 1 }, { unique: true, sparse: true });
|
||||
```
|
||||
|
||||
### 1.4 TTL indexes
|
||||
|
||||
Currently used on `tempverifications.expiresAt` (5-minute auto-purge of email OTPs / passkey challenges). Mongo's TTL monitor runs every 60 seconds — purge isn't immediate.
|
||||
|
||||
If you add more TTL indexes:
|
||||
|
||||
```js
|
||||
db.notifications.createIndex({ createdAt: 1 }, { expireAfterSeconds: 60 * 60 * 24 * 90 }); // 90 days
|
||||
```
|
||||
|
||||
### 1.5 Backup with `mongodump`
|
||||
|
||||
```bash
|
||||
# Connect into the container, dump locally, copy out
|
||||
docker exec nickapp-mongodb sh -c \
|
||||
"mongodump --db=marketplace --archive=/tmp/marketplace-$(date +%F).archive --gzip"
|
||||
docker cp nickapp-mongodb:/tmp/marketplace-$(date +%F).archive ./backups/
|
||||
|
||||
# Or stream directly to host
|
||||
docker exec nickapp-mongodb \
|
||||
mongodump --db=marketplace --archive --gzip \
|
||||
> ./backups/marketplace-$(date +%F).gz
|
||||
```
|
||||
|
||||
For full details (retention, RTO/RPO, offsite copies) see [[Backup & Recovery]].
|
||||
|
||||
### 1.6 Restore
|
||||
|
||||
```bash
|
||||
# Restore an archive to an empty database
|
||||
docker exec -i nickapp-mongodb \
|
||||
mongorestore --archive --gzip --drop \
|
||||
< ./backups/marketplace-2026-05-20.gz
|
||||
```
|
||||
|
||||
`--drop` drops each collection before restoring. Omit it to merge.
|
||||
|
||||
> [!warning] Restoring is **destructive** to current data. Always practise on a staging clone first.
|
||||
|
||||
### 1.7 Migrations
|
||||
|
||||
There is no formal migration framework. Two patterns are used:
|
||||
|
||||
- **Mongoose schema changes** are forward-compatible (new optional fields default to `undefined`). Older documents will still load.
|
||||
- **Data backfills** are one-shot scripts in `backend/src/scripts/` (e.g. `migrateUserPoints.ts`, `fix-transaction-hashes.js`, `fix-dispute-sellers.js`).
|
||||
|
||||
Pattern for a new migration:
|
||||
|
||||
1. Add a `src/seeds/migrate<Thing>.ts` script that is idempotent (use `$exists: false` guards).
|
||||
2. Run on staging, confirm.
|
||||
3. Take a backup ([[Backup & Recovery]]).
|
||||
4. Run in production: `docker exec -it nickapp-backend node dist/seeds/migrate<Thing>.js`.
|
||||
5. Commit the script (it serves as a record of what changed).
|
||||
|
||||
### 1.8 Common admin queries
|
||||
|
||||
```js
|
||||
// Count by collection
|
||||
db.users.countDocuments({ role: 'buyer' })
|
||||
|
||||
// Disk usage per collection
|
||||
db.runCommand({ collStats: 'payments', scale: 1024*1024 }).size
|
||||
|
||||
// Slow queries
|
||||
db.setProfilingLevel(1, { slowms: 200 }) // log queries > 200ms
|
||||
db.system.profile.find().sort({ ts: -1 }).limit(10)
|
||||
|
||||
// Lock contention
|
||||
db.serverStatus().locks
|
||||
```
|
||||
|
||||
### 1.9 Seeding production safely
|
||||
|
||||
Seed scripts are designed to be idempotent for **categories** but **destructive** for users/addresses. Don't run `seed:all` in production.
|
||||
|
||||
Safe in production:
|
||||
|
||||
```bash
|
||||
docker exec -it nickapp-backend node dist/seeds/seedCategories.js
|
||||
docker exec -it nickapp-backend node dist/seeds/seedLevels.js
|
||||
```
|
||||
|
||||
Optional auto-seed on startup: set `AUTO_SEED_ON_START=true` in `.env`. The bootstrap code only seeds when no non-admin users exist — safe to leave on.
|
||||
|
||||
> [!warning] **Never** run `seed:all` or `seed:users` against production. They drop the existing `users` and `addresses` collections.
|
||||
|
||||
---
|
||||
|
||||
## 2. Redis
|
||||
|
||||
### 2.1 Connection
|
||||
|
||||
Dev: `redis://redis:6379` (no password).
|
||||
Prod: `redis://:<REDIS_PASSWORD>@redis:6379`. The compose command line is `redis-server --requirepass "$REDIS_PASSWORD"`.
|
||||
|
||||
Inspect:
|
||||
|
||||
```bash
|
||||
docker exec -it nickapp-redis redis-cli -a "$REDIS_PASSWORD"
|
||||
> INFO server
|
||||
> DBSIZE
|
||||
> KEYS * # prod-unsafe on large datasets, use SCAN
|
||||
```
|
||||
|
||||
### 2.2 What we store
|
||||
|
||||
- **Rate-limit counters** for `express-rate-limit`
|
||||
- **Session data** for refresh-token tracking and revocation lists
|
||||
- **Socket.IO adapter state** (when scaled horizontally — currently single-node)
|
||||
- **Application caches** (TTL'd keys for expensive aggregates)
|
||||
- **Idempotency keys** for webhook deduplication
|
||||
|
||||
Key prefixes follow `<service>:<entity>:<id>`. E.g. `payment:idem:<requestId>`, `auth:refresh:<userId>`.
|
||||
|
||||
### 2.3 Persistence
|
||||
|
||||
Redis 8 defaults to **RDB snapshots** + optional **AOF**. Our compose uses the default config:
|
||||
|
||||
- RDB snapshot triggers: `save 3600 1`, `save 300 100`, `save 60 10000`.
|
||||
- AOF is **disabled** by default.
|
||||
- RDB file lives at `/data/dump.rdb` inside the `redis_data` volume.
|
||||
|
||||
**To enable AOF** for stronger durability, override the command in `docker-compose.production.yml`:
|
||||
|
||||
```yaml
|
||||
redis:
|
||||
command: ["sh","-lc","redis-server --requirepass \"$${REDIS_PASSWORD}\" --appendonly yes --appendfsync everysec"]
|
||||
```
|
||||
|
||||
`appendfsync everysec` is the common compromise: at most 1 second of writes lost on crash, with negligible perf impact.
|
||||
|
||||
### 2.4 Eviction policy
|
||||
|
||||
Default is `noeviction` — Redis refuses writes when memory is full. For our use (caches that can be regenerated), set:
|
||||
|
||||
```bash
|
||||
docker exec nickapp-redis redis-cli -a "$REDIS_PASSWORD" \
|
||||
CONFIG SET maxmemory 256mb
|
||||
docker exec nickapp-redis redis-cli -a "$REDIS_PASSWORD" \
|
||||
CONFIG SET maxmemory-policy allkeys-lru
|
||||
```
|
||||
|
||||
Persist by adding to a custom `redis.conf` mounted at `/usr/local/etc/redis/redis.conf` (then change the compose `command:` to `["redis-server","/usr/local/etc/redis/redis.conf","--requirepass",...]`).
|
||||
|
||||
### 2.5 Backup
|
||||
|
||||
Redis backups are usually unnecessary (the data is regeneratable) but still cheap:
|
||||
|
||||
```bash
|
||||
# Snapshot now
|
||||
docker exec nickapp-redis redis-cli -a "$REDIS_PASSWORD" BGSAVE
|
||||
docker cp nickapp-redis:/data/dump.rdb ./backups/redis-$(date +%F).rdb
|
||||
```
|
||||
|
||||
`BGSAVE` is non-blocking (forks). For AOF, copy `/data/appendonly.aof` too.
|
||||
|
||||
### 2.6 Cache flush
|
||||
|
||||
When deploying breaking changes to cached schemas:
|
||||
|
||||
```bash
|
||||
# Flush everything (DEV ONLY)
|
||||
docker exec nickapp-redis redis-cli -a "$REDIS_PASSWORD" FLUSHALL
|
||||
|
||||
# Targeted (safer)
|
||||
docker exec nickapp-redis redis-cli -a "$REDIS_PASSWORD" \
|
||||
--scan --pattern 'payment:idem:*' | \
|
||||
xargs -L 1 docker exec nickapp-redis redis-cli -a "$REDIS_PASSWORD" DEL
|
||||
```
|
||||
|
||||
> [!warning] `FLUSHALL` will sign out every user with an active refresh token and reset every rate-limit counter. Avoid in production unless that is what you want.
|
||||
|
||||
### 2.7 Monitoring
|
||||
|
||||
```bash
|
||||
docker exec nickapp-redis redis-cli -a "$REDIS_PASSWORD" INFO stats
|
||||
docker exec nickapp-redis redis-cli -a "$REDIS_PASSWORD" INFO memory
|
||||
docker exec nickapp-redis redis-cli -a "$REDIS_PASSWORD" SLOWLOG GET 10
|
||||
```
|
||||
|
||||
Watch `evicted_keys`, `keyspace_misses`, `rejected_connections` — see [[Monitoring]] for thresholds.
|
||||
|
||||
---
|
||||
|
||||
## 3. Maintenance windows
|
||||
|
||||
For both DBs, schedule a window when:
|
||||
|
||||
- Bumping major version (Mongo 8 → 9, Redis 8 → 9)
|
||||
- Restoring from backup
|
||||
- Running a destructive migration
|
||||
|
||||
Suggested checklist:
|
||||
|
||||
1. Announce in #ops Slack / status page.
|
||||
2. Trigger `mongodump` (see [[Backup & Recovery]]).
|
||||
3. Stop the backend container so writes stop: `docker compose stop nickapp-backend`.
|
||||
4. Perform the operation.
|
||||
5. Restart backend: `docker compose start nickapp-backend`.
|
||||
6. Verify health: `curl https://amn.gg/api/health`.
|
||||
7. Close window.
|
||||
|
||||
---
|
||||
|
||||
## 4. Cross-links
|
||||
|
||||
- [[Backup & Recovery]] — formal backup/restore procedures, RTO/RPO targets, offsite storage.
|
||||
- [[Monitoring]] — what metrics to watch (slow queries, evictions, replication lag).
|
||||
- [[Incident Response]] — runbooks for "MongoDB unreachable" and "Redis unreachable".
|
||||
- [[Data Models]] — schema details for every collection.
|
||||
255
08 - Operations/Deployment.md
Normal file
255
08 - Operations/Deployment.md
Normal file
@@ -0,0 +1,255 @@
|
||||
---
|
||||
title: Deployment
|
||||
tags: [operations]
|
||||
---
|
||||
|
||||
# Deployment
|
||||
|
||||
How the production stack runs and gets updated on the live host. The stack is fully containerised and self-updates via Watchtower from the Gitea container registry.
|
||||
|
||||
---
|
||||
|
||||
## 1. Topology
|
||||
|
||||
```
|
||||
┌─────────────────────────┐
|
||||
HTTPS 443 ──────────►│ External SSL term. │
|
||||
│ (DNS amn.gg, dev.amn.gg)│
|
||||
└────────────┬────────────┘
|
||||
│ HTTP 80 (in-VPC)
|
||||
▼
|
||||
┌──────────────────────────────────┐
|
||||
│ Nginx container │
|
||||
│ (nickapp-nginx, port 80) │
|
||||
└─┬───────────────────┬────────────┘
|
||||
│ │
|
||||
│ / │ /api /socket.io
|
||||
▼ ▼
|
||||
┌─────────────────────┐ ┌──────────────────────────┐
|
||||
│ nickapp-frontend │ │ nickapp-backend │
|
||||
│ Next.js, port 8083 │ │ Express 5, port 5001 │
|
||||
└─────────────────────┘ └──────┬────────────┬──────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌──────────┐ ┌──────────┐
|
||||
│ mongodb │ │ redis │
|
||||
│ 8.2 │ │ 8 │
|
||||
└──────────┘ └──────────┘
|
||||
|
||||
┌──────────────────────────────────┐
|
||||
│ Watchtower │
|
||||
│ Polls registry → restarts │
|
||||
│ containers labelled enable=true │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
All containers run on the **`default`** Docker network defined by `docker-compose.production.yml`. Watchtower runs as a sidecar container on the same host.
|
||||
|
||||
DNS resolves both `amn.gg` and `dev.amn.gg` to the production host's public IP. SSL termination happens **outside** the compose stack (typically via the hosting provider's edge or a host-level reverse proxy), and traffic is forwarded as HTTP to the `nginx` container on port `80` (mapped to host `8083`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Compose file
|
||||
|
||||
`backend/docker-compose.production.yml` is the single source of truth. Services:
|
||||
|
||||
| Service | Image | Ports | Volumes | Notes |
|
||||
|---------|-------|-------|---------|-------|
|
||||
| `nginx` | `nginx:alpine` | `8083:80` | `./nginx/nginx.conf`, `./nginx/logs`, `./uploads` (served as `/uploads`) | Reverse proxy |
|
||||
| `nickapp-backend` | `nickapp-backend:latest` (build from `Dockerfile.prod`) | not exposed externally | `./uploads:/app/uploads` | Labelled for Watchtower |
|
||||
| `nickapp-frontend` | `nickapp-frontend:latest` (build from `../frontend/Dockerfile`) | `expose: 8083` | — | Labelled for Watchtower |
|
||||
| `mongodb` | `mongo:8.2` | not exposed | `mongodb_data:/data/db`, `./mongo-init:/docker-entrypoint-initdb.d` | Healthcheck via `mongosh ping` |
|
||||
| `redis` | `redis:8-alpine` | not exposed | `redis_data:/data` | Started with `--requirepass "$REDIS_PASSWORD"` |
|
||||
|
||||
Healthchecks are configured for backend (`curl /health`), frontend (`curl /`), Mongo (`mongosh ping`), and Redis (`redis-cli -a $REDIS_PASSWORD ping`). See [[Monitoring]].
|
||||
|
||||
Watchtower polls images labelled `com.centurylinklabs.watchtower.enable=true` — currently `nickapp-backend` and `nickapp-frontend`. MongoDB and Redis are **not** auto-updated.
|
||||
|
||||
---
|
||||
|
||||
## 3. Registry & images
|
||||
|
||||
| Image | Registry path |
|
||||
|-------|---------------|
|
||||
| Backend prod | `git.manko.yoga/manawenuz/escrow-backend:latest` |
|
||||
| Backend dev | `git.manko.yoga/manawenuz/escrow-backend:dev` |
|
||||
| Backend tagged | `git.manko.yoga/manawenuz/escrow-backend:<package-version>` |
|
||||
| Frontend | `git.manko.yoga/manawenuz/escrow-frontend:latest` and `:<version>` |
|
||||
|
||||
`docker-compose.production.yml` currently builds locally on first up (`build: context: .`). Once images are in the registry the file can be switched to `image: git.manko.yoga/manawenuz/escrow-backend:latest` to let Watchtower pull straight from there.
|
||||
|
||||
> [!tip] To pin a specific version while debugging, edit the compose file to `image: git.manko.yoga/manawenuz/escrow-backend:2.6.3` and re-run `docker compose up -d`. Remove the Watchtower label or the agent will undo it on next poll.
|
||||
|
||||
---
|
||||
|
||||
## 4. Watchtower
|
||||
|
||||
Watchtower runs as its own container (managed outside the compose file) with `WATCHTOWER_LABEL_ENABLE=true` so it only touches services that opt in. On each poll cycle (default 5 minutes, configurable via `WATCHTOWER_POLL_INTERVAL`) it:
|
||||
|
||||
1. Pulls the latest digest for each enabled service's image.
|
||||
2. Compares to the running container's digest.
|
||||
3. If different, stops the container, removes it, and starts a new one from the new image, preserving all named volumes.
|
||||
|
||||
Configuration knobs typically set on the host:
|
||||
|
||||
```bash
|
||||
docker run -d --name watchtower \
|
||||
--restart unless-stopped \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v /root/.docker/config.json:/config.json \ # so it can pull from the private Gitea registry
|
||||
-e WATCHTOWER_POLL_INTERVAL=300 \
|
||||
-e WATCHTOWER_LABEL_ENABLE=true \
|
||||
-e WATCHTOWER_CLEANUP=true \
|
||||
-e WATCHTOWER_INCLUDE_RESTARTING=true \
|
||||
containrrr/watchtower
|
||||
```
|
||||
|
||||
The `~/.docker/config.json` must have a valid login for `git.manko.yoga` (created via `docker login git.manko.yoga -u manawenuz`).
|
||||
|
||||
---
|
||||
|
||||
## 5. First-time deploy (cold start)
|
||||
|
||||
> [!warning] Run these steps on a fresh production host. They are destructive on an existing one. See [[Backup & Recovery]] before touching live data.
|
||||
|
||||
### Prerequisites on the host
|
||||
|
||||
- Ubuntu 22.04+ (or any systemd Linux), Docker Engine 24+, `docker compose` plugin
|
||||
- `git` installed
|
||||
- DNS `amn.gg` + `dev.amn.gg` already pointing here
|
||||
- An SSL terminator (Caddy / Nginx / Cloudflare) reverse-proxying to host port `8083`
|
||||
- Registry login: `docker login git.manko.yoga -u manawenuz`
|
||||
|
||||
### Steps
|
||||
|
||||
```bash
|
||||
# 1. Clone both repos as siblings (compose references ../frontend)
|
||||
cd /opt
|
||||
git clone ssh://git@git.manko.yoga:222/nick/backend.git
|
||||
git clone ssh://git@git.manko.yoga:222/nick/frontend.git
|
||||
cd backend
|
||||
git checkout main
|
||||
|
||||
# 2. Create the production .env
|
||||
sudo nano .env # fill from Environment Variables doc; production values, real secrets
|
||||
|
||||
# 3. Provision the nginx config + uploads dir
|
||||
mkdir -p nginx/logs uploads mongo-init
|
||||
sudo cp /path/to/nginx.conf nginx/nginx.conf
|
||||
# (the nginx.conf forwards /api/* and /socket.io/* to nickapp-backend:5001,
|
||||
# forwards /uploads/* to /uploads (volume), and everything else to nickapp-frontend:8083)
|
||||
|
||||
# 4. Build & start the stack
|
||||
docker compose -f docker-compose.production.yml up --build -d
|
||||
|
||||
# 5. Verify
|
||||
docker compose -f docker-compose.production.yml ps
|
||||
docker compose -f docker-compose.production.yml logs -f --tail=200
|
||||
curl -fsS http://localhost:8083/api/health | jq .
|
||||
|
||||
# 6. Seed initial data (optional — if AUTO_SEED_ON_START=true is set, it's already done)
|
||||
docker compose -f docker-compose.production.yml exec nickapp-backend node dist/scripts/seedCategories.js
|
||||
|
||||
# 7. Start Watchtower (one-time)
|
||||
docker run -d --name watchtower --restart unless-stopped \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v /root/.docker/config.json:/config.json \
|
||||
-e WATCHTOWER_POLL_INTERVAL=300 \
|
||||
-e WATCHTOWER_LABEL_ENABLE=true \
|
||||
-e WATCHTOWER_CLEANUP=true \
|
||||
containrrr/watchtower
|
||||
```
|
||||
|
||||
### SSL / TLS
|
||||
|
||||
Termination happens at the edge — outside the compose stack. The two common setups:
|
||||
|
||||
- **Caddy on the host** forwarding `amn.gg` and `dev.amn.gg` to `127.0.0.1:8083`. Caddy handles Let's Encrypt automatically.
|
||||
- **Cloudflare Full (strict)** in front of the host. Use Cloudflare Origin certificates on the host's Caddy/Nginx.
|
||||
|
||||
Either way, the compose stack itself sees only HTTP on port 80 inside the nginx container. The `nginx.conf` should set `proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto` and the backend already trusts the proxy when `NODE_ENV=production` (see `trust proxy` block in `src/app.ts`).
|
||||
|
||||
---
|
||||
|
||||
## 6. Routine deploy (after first deploy)
|
||||
|
||||
The normal flow is **fully automatic**:
|
||||
|
||||
1. Developer merges PR to `main` (see [[Git Workflow]]).
|
||||
2. Gitea Actions runs `.gitea/workflows/docker-build-no-cache.yml` (backend) or `deploy.yml` (frontend). The workflow builds the production image and pushes `:latest` + `:<version>` to the registry. See [[CI-CD Pipeline]].
|
||||
3. Watchtower polls the registry, sees a new digest, restarts the container.
|
||||
4. Healthcheck on the new container passes after `start_period=40s`, traffic resumes.
|
||||
|
||||
Total time from merge to live: **5–10 minutes** depending on Watchtower poll interval and image size.
|
||||
|
||||
### Force an immediate deploy
|
||||
|
||||
If you don't want to wait for the poll:
|
||||
|
||||
```bash
|
||||
# On the production host:
|
||||
cd /opt/backend
|
||||
docker login git.manko.yoga -u manawenuz # if creds expired
|
||||
docker compose -f docker-compose.production.yml pull nickapp-backend nickapp-frontend
|
||||
docker compose -f docker-compose.production.yml up -d nickapp-backend nickapp-frontend
|
||||
```
|
||||
|
||||
The `up -d` will detect changed images and restart only the affected containers.
|
||||
|
||||
### Roll back
|
||||
|
||||
```bash
|
||||
# Find available versions
|
||||
docker images git.manko.yoga/manawenuz/escrow-backend
|
||||
|
||||
# Pin to the previous tag in the compose file
|
||||
sed -i 's|escrow-backend:latest|escrow-backend:2.6.2|' docker-compose.production.yml
|
||||
|
||||
# Re-up
|
||||
docker compose -f docker-compose.production.yml up -d nickapp-backend
|
||||
|
||||
# Disable Watchtower for the affected service until you're ready to resume
|
||||
docker compose ... restart # no-op if you removed the watchtower label
|
||||
```
|
||||
|
||||
> [!warning] Watchtower will undo a pin to a non-`latest` tag on its next poll if the container still has the `watchtower.enable=true` label. Either remove the label temporarily or pause Watchtower (`docker stop watchtower`).
|
||||
|
||||
---
|
||||
|
||||
## 7. Logs
|
||||
|
||||
```bash
|
||||
# All services
|
||||
docker compose -f docker-compose.production.yml logs -f --tail=300
|
||||
|
||||
# Single service
|
||||
docker compose -f docker-compose.production.yml logs -f nickapp-backend
|
||||
|
||||
# Nginx access log
|
||||
tail -f /opt/backend/nginx/logs/access.log
|
||||
```
|
||||
|
||||
Backend logs are also captured by Sentry breadcrumbs when an error occurs — see [[Monitoring]].
|
||||
|
||||
---
|
||||
|
||||
## 8. Maintenance window
|
||||
|
||||
Plan a 5-minute window when bumping major versions or running migrations:
|
||||
|
||||
```bash
|
||||
# Announce + drain
|
||||
# (set a maintenance banner in the frontend if possible)
|
||||
|
||||
# Take a backup first
|
||||
./scripts/backup-mongo.sh # or per Backup & Recovery
|
||||
|
||||
# Pull new images, restart
|
||||
docker compose -f docker-compose.production.yml pull
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
|
||||
# Verify
|
||||
curl -fsS https://amn.gg/api/health
|
||||
```
|
||||
|
||||
If anything goes sideways, follow [[Incident Response]].
|
||||
381
08 - Operations/Docker Setup.md
Normal file
381
08 - Operations/Docker Setup.md
Normal file
@@ -0,0 +1,381 @@
|
||||
---
|
||||
title: Docker Setup
|
||||
tags: [operations]
|
||||
---
|
||||
|
||||
# Docker Setup
|
||||
|
||||
Walk-through of every Dockerfile, compose file, volume, and network used by the marketplace stack. Cross-references [[Deployment]] for the live-host configuration and [[Local Setup]] for developer use.
|
||||
|
||||
---
|
||||
|
||||
## 1. Backend — `Dockerfile.dev`
|
||||
|
||||
Path: `/Users/mojtabaheidari/code/backend/Dockerfile.dev`
|
||||
|
||||
```dockerfile
|
||||
FROM node:22-alpine
|
||||
RUN corepack enable
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN yarn install --frozen-lockfile
|
||||
COPY . .
|
||||
RUN mkdir -p uploads/{avatars,documents,products,temp}
|
||||
RUN addgroup -g 1001 -S nodejs && adduser -S marketplace -u 1001
|
||||
RUN chown -R marketplace:nodejs /app
|
||||
USER marketplace
|
||||
EXPOSE 5001
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD node healthcheck.js
|
||||
CMD ["yarn", "dev"]
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- **Base.** `node:22-alpine` — small, glibc-musl. Corepack is enabled to use the pinned Yarn 1.22.22.
|
||||
- **Install.** `yarn install --frozen-lockfile` brings dev dependencies (needed for `ts-node` + `nodemon` hot reload).
|
||||
- **Uploads scaffold.** Creates the four canonical upload directories so the API doesn't have to `mkdir` at runtime.
|
||||
- **Non-root user.** Process runs as `marketplace` (uid `1001`). Defence-in-depth.
|
||||
- **Healthcheck.** `healthcheck.js` does a local HTTP GET to `/health` (see [[Monitoring]]).
|
||||
- **CMD.** `yarn dev` → `nodemon --exec ts-node src/app.ts`. Source code is mounted from the host so saves trigger restarts.
|
||||
|
||||
Used by `docker-compose.dev.yml`. Not pushed to the registry — dev images are local.
|
||||
|
||||
---
|
||||
|
||||
## 2. Backend — `Dockerfile.prod`
|
||||
|
||||
Path: `/Users/mojtabaheidari/code/backend/Dockerfile.prod`
|
||||
|
||||
Multi-stage build to keep the runtime image small and free of build tooling.
|
||||
|
||||
```dockerfile
|
||||
# ---- builder ----
|
||||
FROM node:22-alpine AS builder
|
||||
RUN corepack enable
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
COPY healthcheck.js ./
|
||||
RUN yarn install --frozen-lockfile
|
||||
COPY . .
|
||||
RUN yarn build # tsc → ./dist
|
||||
|
||||
# ---- production ----
|
||||
FROM node:22-alpine AS production
|
||||
RUN corepack enable
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
COPY healthcheck.js ./
|
||||
RUN yarn install --frozen-lockfile --production && yarn cache clean
|
||||
COPY --from=builder /app/dist ./dist
|
||||
RUN mkdir -p uploads/{avatars,documents,products,temp}
|
||||
RUN addgroup -g 1001 -S nodejs && adduser -S marketplace -u 1001
|
||||
RUN chown -R marketplace:nodejs /app
|
||||
USER marketplace
|
||||
EXPOSE 5001
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD node healthcheck.js
|
||||
CMD ["node", "dist/app.js"]
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- **Two stages.** `builder` compiles TS to JS; `production` keeps only the compiled output + production deps. Final image is ~150 MB.
|
||||
- **No dev deps.** `--production` flag in the second stage trims away TypeScript, Jest, ts-node etc.
|
||||
- **Same non-root pattern.** `marketplace:nodejs` (uid 1001).
|
||||
- **CMD.** Plain `node dist/app.js` — no transpilation at runtime.
|
||||
- **Uploads.** The directory is created inside the image, then the running container mounts `/app/uploads` from a host volume in compose (overrides the embedded dir).
|
||||
|
||||
Built and pushed by `.gitea/workflows/docker-build-no-cache.yml` (and friends — see [[CI-CD Pipeline]]). The resulting image is `git.manko.yoga/manawenuz/escrow-backend:<version>` + `:latest`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Frontend — `Dockerfile` (production)
|
||||
|
||||
Path: `/Users/mojtabaheidari/code/frontend/Dockerfile`
|
||||
|
||||
Multi-stage Next.js **standalone** build.
|
||||
|
||||
```dockerfile
|
||||
# ---- builder ----
|
||||
FROM node:22-alpine AS builder
|
||||
# (NEXT_PUBLIC_* vars set here so they bake into the bundle)
|
||||
ENV NEXT_PUBLIC_API_URL=https://dev.amn.gg/api
|
||||
ENV NEXT_PUBLIC_BACKEND_URL=https://dev.amn.gg
|
||||
# ...more ENV lines (see file)...
|
||||
|
||||
RUN apk add --no-cache git python3 make g++ py3-pip
|
||||
RUN corepack enable
|
||||
WORKDIR /app
|
||||
COPY package.json yarn.lock* ./
|
||||
RUN yarn install --frozen-lockfile --production=false --network-timeout 600000
|
||||
COPY src ./src
|
||||
COPY public ./public
|
||||
COPY next.config.ts tsconfig.json ./
|
||||
COPY *.config.mjs ./
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN yarn build # produces .next/standalone + .next/static
|
||||
|
||||
# ---- runner ----
|
||||
FROM node:22-alpine AS runner
|
||||
RUN apk add --no-cache curl
|
||||
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
|
||||
WORKDIR /app
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
ENV PORT=8083 HOSTNAME="0.0.0.0" NODE_ENV=production
|
||||
ENV NEXT_PUBLIC_SENTRY_DSN=https://...sentry.io/...
|
||||
USER nextjs
|
||||
EXPOSE 8083
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8083 || exit 1
|
||||
CMD ["node", "server.js"]
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- **Baked env vars.** `NEXT_PUBLIC_*` variables are set as `ENV` in the builder stage so Next inlines them into the static bundle at build time. To deploy to a different domain you must rebuild — there is no runtime override for `NEXT_PUBLIC_*`. See [[Environment Variables#how-env-is-loaded]].
|
||||
- **System packages.** `git python3 make g++ py3-pip` are needed by `node-gyp` for native modules (e.g. `sharp`, `@google-cloud/local-auth`).
|
||||
- **Standalone output.** `next.config.ts` sets `output: 'standalone'`, so the runner stage copies only `.next/standalone/` and `public/` — a self-contained tree with a built-in `server.js`. Final runtime image: ~250 MB.
|
||||
- **Non-root.** `nextjs` (uid 1001).
|
||||
- **`server.js`** is generated by Next.js — it embeds the necessary Node modules and starts the production server.
|
||||
|
||||
---
|
||||
|
||||
## 4. Frontend — `Dockerfile.dev`
|
||||
|
||||
Path: `/Users/mojtabaheidari/code/frontend/Dockerfile.dev`
|
||||
|
||||
```dockerfile
|
||||
FROM node:22-alpine
|
||||
RUN apk add --no-cache git python3 make g++ py3-pip
|
||||
RUN corepack enable
|
||||
WORKDIR /app
|
||||
COPY package.json yarn.lock* ./
|
||||
RUN yarn config set network-timeout 600000 && \
|
||||
yarn config set network-concurrency 1 && \
|
||||
yarn install --frozen-lockfile --network-timeout 600000
|
||||
COPY . .
|
||||
EXPOSE 3000
|
||||
CMD ["yarn", "dev:docker"]
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Listens on **port 3000** in dev (matches the legacy convention).
|
||||
- `yarn dev:docker` is a variant of `dev` that binds 0.0.0.0 so the container is reachable from the host.
|
||||
- No multi-stage — speed > size.
|
||||
|
||||
Used for local development if you choose to run the frontend in Docker instead of via `yarn dev`. Most developers run frontend natively for HMR speed; backend in Docker for parity.
|
||||
|
||||
---
|
||||
|
||||
## 5. `docker-compose.dev.yml`
|
||||
|
||||
Path: `/Users/mojtabaheidari/code/backend/docker-compose.dev.yml`
|
||||
|
||||
```yaml
|
||||
name: nickapp-development
|
||||
|
||||
services:
|
||||
nickdev-backend:
|
||||
build: { context: ., dockerfile: Dockerfile.dev }
|
||||
container_name: nickdev-backend
|
||||
env_file: [.env.local]
|
||||
ports: ["5001:5001"]
|
||||
volumes:
|
||||
- ./src:/app/src
|
||||
- ./uploads:/app/uploads
|
||||
depends_on: [mongodb, redis]
|
||||
restart: unless-stopped
|
||||
networks: [nickapp-network]
|
||||
|
||||
mongodb:
|
||||
image: mongo:8.2
|
||||
container_name: nickdev-mongodb
|
||||
ports: ["27017:27017"]
|
||||
env_file: [.env.local]
|
||||
volumes: [mongodb_data:/data/db]
|
||||
restart: unless-stopped
|
||||
networks: [nickapp-network]
|
||||
|
||||
redis:
|
||||
image: redis:8-alpine
|
||||
container_name: nickdev-redis
|
||||
env_file: [.env.local]
|
||||
command: redis-server
|
||||
volumes: [redis_data:/data]
|
||||
restart: unless-stopped
|
||||
networks: [nickapp-network]
|
||||
|
||||
networks:
|
||||
nickapp-network: { driver: bridge }
|
||||
|
||||
volumes:
|
||||
mongodb_data:
|
||||
redis_data:
|
||||
```
|
||||
|
||||
Highlights:
|
||||
|
||||
- **No auth on Mongo/Redis in dev.** Mongo runs default; Redis runs plain `redis-server`.
|
||||
- **Source mounted.** `./src` is volume-mounted into the backend container so hot reload works.
|
||||
- **Uploads mounted.** `./uploads` on the host is bind-mounted to `/app/uploads` so files survive container restarts.
|
||||
- **Port mappings:** `5001` (backend) + `27017` (Mongo) exposed to host. Redis is **not** exposed by default.
|
||||
- **Network.** `nickapp-network` bridge — Mongo/Redis are reachable as `mongodb` / `redis` from the backend container.
|
||||
|
||||
---
|
||||
|
||||
## 6. `docker-compose.production.yml`
|
||||
|
||||
Path: `/Users/mojtabaheidari/code/backend/docker-compose.production.yml`
|
||||
|
||||
Five services. Reproducing only the most important bits — full file lives in the repo and is summarised in [[Deployment#compose-file]].
|
||||
|
||||
```yaml
|
||||
name: nickapp-production
|
||||
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: nickapp-nginx
|
||||
ports: ["8083:80"]
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./nginx/logs:/var/log/nginx
|
||||
- ./uploads:/uploads
|
||||
depends_on: [nickapp-backend, nickapp-frontend]
|
||||
networks: [default]
|
||||
|
||||
nickapp-backend:
|
||||
build: { context: ., dockerfile: Dockerfile.prod }
|
||||
image: nickapp-backend:latest
|
||||
container_name: nickapp-backend
|
||||
platform: linux/amd64
|
||||
env_file: [.env]
|
||||
volumes: [./uploads:/app/uploads]
|
||||
depends_on: [mongodb, redis]
|
||||
networks: [default]
|
||||
healthcheck: { test: ["CMD","curl","-f","http://localhost:5001/health"], ... }
|
||||
labels: ["com.centurylinklabs.watchtower.enable=true"]
|
||||
|
||||
mongodb:
|
||||
image: mongo:8.2
|
||||
container_name: nickapp-mongodb
|
||||
env_file: [.env]
|
||||
volumes:
|
||||
- mongodb_data:/data/db
|
||||
- ./mongo-init:/docker-entrypoint-initdb.d
|
||||
healthcheck: { test: ["CMD","mongosh","--eval","db.adminCommand('ping')"], ... }
|
||||
|
||||
redis:
|
||||
image: redis:8-alpine
|
||||
container_name: nickapp-redis
|
||||
env_file: [.env]
|
||||
command: ["sh","-lc","redis-server --requirepass \"$${REDIS_PASSWORD}\""]
|
||||
volumes: [redis_data:/data]
|
||||
healthcheck: { test: ["CMD","redis-cli","-a","$${REDIS_PASSWORD}","ping"], ... }
|
||||
|
||||
nickapp-frontend:
|
||||
build: { context: ../frontend, dockerfile: Dockerfile }
|
||||
image: nickapp-frontend:latest
|
||||
container_name: nickapp-frontend
|
||||
platform: linux/amd64
|
||||
env_file: [.env]
|
||||
environment: [PORT=8083, NODE_ENV=production]
|
||||
expose: ["8083"]
|
||||
healthcheck: { test: ["CMD","curl","-f","http://localhost:8083/"], ... }
|
||||
labels: ["com.centurylinklabs.watchtower.enable=true"]
|
||||
|
||||
networks:
|
||||
default: { driver: bridge }
|
||||
|
||||
volumes:
|
||||
mongodb_data:
|
||||
redis_data:
|
||||
```
|
||||
|
||||
Key differences from dev:
|
||||
|
||||
- **Nginx** added as the public entry point.
|
||||
- **Backend and frontend** are labelled for **Watchtower** auto-updates.
|
||||
- **Mongo and Redis** are **not** Watchtower-managed — their major versions need manual planning + backup ([[Backup & Recovery]]).
|
||||
- **Redis password** is read from `.env` (escaped `$$` so docker compose doesn't expand it).
|
||||
- **Frontend build context** points at `../frontend` — the two repos must live as siblings on disk.
|
||||
- **No host port mapping** for backend/frontend — they are reached only via the nginx container.
|
||||
- **platform: linux/amd64** is pinned because production hosts are x86_64; ARM developers must `--platform=linux/amd64` if they build locally for prod.
|
||||
|
||||
---
|
||||
|
||||
## 7. Volumes
|
||||
|
||||
| Volume | Mount point | Lifecycle | Notes |
|
||||
|--------|-------------|-----------|-------|
|
||||
| `mongodb_data` (named) | `/data/db` in `mongodb` | Persistent | The whole database. Back up via `mongodump`. |
|
||||
| `redis_data` (named) | `/data` in `redis` | Persistent | RDB snapshots + AOF if configured. |
|
||||
| `./uploads` (bind) | `/app/uploads` in backend, `/uploads` in nginx | Persistent on host | User-uploaded files. Critical — back up the directory. |
|
||||
| `./nginx/nginx.conf` (bind, RO) | `/etc/nginx/nginx.conf` | Static | Reverse-proxy config. |
|
||||
| `./nginx/logs` (bind) | `/var/log/nginx` | Append-only on host | Access + error logs. |
|
||||
| `./mongo-init` (bind, RO) | `/docker-entrypoint-initdb.d` | One-time | JS files Mongo runs **only on a fresh datadir** to create initial users / indexes. |
|
||||
|
||||
Inspect named volumes:
|
||||
|
||||
```bash
|
||||
docker volume ls
|
||||
docker volume inspect nickapp-production_mongodb_data
|
||||
```
|
||||
|
||||
> [!warning] `docker compose down -v` deletes named volumes. Never run this in production unless you've backed up first.
|
||||
|
||||
---
|
||||
|
||||
## 8. Networks
|
||||
|
||||
- **Dev:** `nickapp-network` bridge. All three services join it; the backend reaches `mongodb` and `redis` by container name.
|
||||
- **Prod:** the default compose network (also a bridge), named `nickapp-production_default`. Same DNS-by-container-name semantics. Nginx talks to `nickapp-backend:5001` and `nickapp-frontend:8083` over this network.
|
||||
|
||||
Inspect:
|
||||
|
||||
```bash
|
||||
docker network ls
|
||||
docker network inspect nickapp-production_default
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Image build & push from a developer machine
|
||||
|
||||
For a production-parity build locally (without going through CI):
|
||||
|
||||
```bash
|
||||
cd ~/code/backend
|
||||
docker build --platform=linux/amd64 -f Dockerfile.prod \
|
||||
-t git.manko.yoga/manawenuz/escrow-backend:test .
|
||||
|
||||
# Sanity-check size + run
|
||||
docker images git.manko.yoga/manawenuz/escrow-backend
|
||||
docker run --rm -p 5001:5001 --env-file .env.local \
|
||||
git.manko.yoga/manawenuz/escrow-backend:test
|
||||
```
|
||||
|
||||
For the official path (build + push to registry) use `./scripts/build-and-push.sh` — see [[Scripts#build-and-push-sh]] — or rely on [[CI-CD Pipeline]] to do it on every push.
|
||||
|
||||
---
|
||||
|
||||
## 10. Image cleanup
|
||||
|
||||
Builds accumulate. Periodically prune:
|
||||
|
||||
```bash
|
||||
docker system prune -a -f
|
||||
docker volume prune -f # ⚠ removes unused named volumes — check first
|
||||
docker builder prune -a -f # buildx cache
|
||||
|
||||
# scripted (backend)
|
||||
npm run docker:clean
|
||||
```
|
||||
|
||||
`docker:clean` runs `docker system prune -a -f && docker volume prune -f` — confirm you don't need anything before you run it.
|
||||
|
||||
> [!warning] `docker volume prune` will delete `mongodb_data` and `redis_data` if their compose project is currently `down`. Always run `docker compose up -d` first to keep the volumes "in use".
|
||||
393
08 - Operations/Incident Response.md
Normal file
393
08 - Operations/Incident Response.md
Normal file
@@ -0,0 +1,393 @@
|
||||
---
|
||||
title: Incident Response
|
||||
tags: [operations]
|
||||
---
|
||||
|
||||
# Incident Response
|
||||
|
||||
Runbooks for the most likely production incidents, plus communication templates and a post-mortem template. Use this page during an active incident — keep [[Monitoring]], [[Database Operations]], and [[Backup & Recovery]] open in adjacent tabs.
|
||||
|
||||
---
|
||||
|
||||
## 1. Severity matrix
|
||||
|
||||
| Sev | Meaning | Response time | Examples |
|
||||
|-----|---------|---------------|----------|
|
||||
| **Sev 1** | Site fully down or unable to process payments | 15 min | Backend container in crashloop; Mongo unreachable; SHKeeper API permanently failing |
|
||||
| **Sev 2** | Major feature broken for a large share of users | 1 hour | Email sending broken; Redis disk full; chat undelivered |
|
||||
| **Sev 3** | Minor / cosmetic issue, isolated user reports | next business day | Single failed webhook; one user can't upload PDF |
|
||||
| **Sev 4** | No user impact, hygiene item | backlog | Backup older than 24h; disk > 80%; missed deploy |
|
||||
|
||||
Escalate one sev higher if more than 10 reports inside 5 minutes.
|
||||
|
||||
---
|
||||
|
||||
## 2. First 5 minutes — always do this
|
||||
|
||||
1. **Acknowledge.** Reply in the on-call channel that you are taking it.
|
||||
2. **Open Sentry.** Filter to the last 15 minutes for new issue spikes.
|
||||
3. **Open the host shell.** `ssh prod` ready.
|
||||
4. **Health endpoint.** `curl -fsS https://amn.gg/api/health` → does it respond?
|
||||
5. **Container status.** `docker ps --format "table {{.Names}}\t{{.Status}}"`.
|
||||
6. **Recent deploy?** Was the `:latest` tag bumped in the last 30 min? If yes, **roll back first** (see [[Deployment#roll-back]]) and investigate after stability is restored.
|
||||
|
||||
If you can't form a hypothesis in 5 minutes, **roll back to the previous image tag** anyway. Stability before forensics.
|
||||
|
||||
---
|
||||
|
||||
## 3. Common incidents
|
||||
|
||||
### 3.1 Backend down (crashloop, no response on /health)
|
||||
|
||||
**Symptoms.** `https://amn.gg/api/health` times out or 5xx; `nickapp-backend` shows `Restarting` in `docker ps`.
|
||||
|
||||
**Runbook.**
|
||||
|
||||
```bash
|
||||
# 1. Inspect last lines
|
||||
docker logs --tail=200 nickapp-backend
|
||||
|
||||
# 2. Common causes:
|
||||
# - Missing env var (`process.env.X!` throws on first read)
|
||||
# - MongoDB unreachable (see 3.2)
|
||||
# - Port conflict
|
||||
# - Out of memory (look for OOMKilled)
|
||||
docker inspect nickapp-backend | jq '.[0].State'
|
||||
|
||||
# 3. If OOM: increase memory limit in compose, restart
|
||||
# If missing env: add to /opt/backend/.env, then `docker compose up -d`
|
||||
|
||||
# 4. If recent deploy: roll back
|
||||
sed -i 's|:latest|:<previous-version>|' docker-compose.production.yml
|
||||
docker compose up -d nickapp-backend
|
||||
# Pause Watchtower for nickapp-backend so it doesn't re-pull
|
||||
docker stop watchtower
|
||||
```
|
||||
|
||||
**Communication.** Post in #incidents using the template in §4.
|
||||
|
||||
---
|
||||
|
||||
### 3.2 MongoDB unreachable
|
||||
|
||||
**Symptoms.** Backend logs show `MongoNetworkError`, `MongooseServerSelectionError`, or `Could not connect to server`.
|
||||
|
||||
**Runbook.**
|
||||
|
||||
```bash
|
||||
# 1. Container alive?
|
||||
docker ps -a --filter "name=mongodb"
|
||||
|
||||
# 2. If exited:
|
||||
docker logs --tail=200 nickapp-mongodb
|
||||
# Common: corrupt journal, disk full, OOM
|
||||
|
||||
# 3. Disk check
|
||||
df -h /var/lib/docker
|
||||
|
||||
# 4. If disk full:
|
||||
# - prune old container logs: docker system prune
|
||||
# - rotate logs if needed
|
||||
# - extend volume
|
||||
|
||||
# 5. Restart
|
||||
docker compose -f docker-compose.production.yml up -d mongodb
|
||||
|
||||
# 6. Verify
|
||||
docker exec nickapp-mongodb mongosh --eval "db.adminCommand('ping')"
|
||||
|
||||
# 7. If data is corrupt, restore from latest dump — see Backup & Recovery
|
||||
```
|
||||
|
||||
> [!warning] If Mongo is corrupted and you must restore, **stop the backend container first** to prevent partial writes during restore. See [[Database Operations#restore]].
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Redis unreachable
|
||||
|
||||
**Symptoms.** Logs show `ECONNREFUSED redis:6379` or `NOAUTH Authentication required`. Rate limits stop working, refresh tokens can't be revoked, but most read flows still work.
|
||||
|
||||
**Runbook.**
|
||||
|
||||
```bash
|
||||
# 1. Container alive?
|
||||
docker ps -a --filter "name=redis"
|
||||
|
||||
# 2. If down:
|
||||
docker logs --tail=200 nickapp-redis
|
||||
docker compose -f docker-compose.production.yml up -d redis
|
||||
|
||||
# 3. Auth issue?
|
||||
docker exec nickapp-redis redis-cli -a "$REDIS_PASSWORD" PING
|
||||
# Should return PONG
|
||||
|
||||
# 4. If `$REDIS_PASSWORD` mismatch between .env and command:
|
||||
nano /opt/backend/.env # confirm REDIS_PASSWORD set
|
||||
docker compose up -d redis backend
|
||||
|
||||
# 5. If memory full + noeviction policy → rejecting writes:
|
||||
docker exec nickapp-redis redis-cli -a "$REDIS_PASSWORD" CONFIG SET maxmemory-policy allkeys-lru
|
||||
```
|
||||
|
||||
The app gracefully degrades when Redis is unreachable for short windows — don't panic, but fix within an hour.
|
||||
|
||||
---
|
||||
|
||||
### 3.4 SHKeeper API down (payments blocked)
|
||||
|
||||
**Symptoms.** Backend logs show repeated `SHKeeper request failed: ECONNREFUSED` or non-2xx responses from `$SHKEEPER_API_URL`. Buyers see "Payment unavailable" in checkout. Sev 1 — money is involved.
|
||||
|
||||
**Runbook.**
|
||||
|
||||
```bash
|
||||
# 1. Confirm SHKeeper itself is reachable
|
||||
curl -fsS -H "X-Shkeeper-Api-Key: $SHKEEPER_API_KEY" \
|
||||
"$SHKEEPER_API_URL/api/v1/healthcheck"
|
||||
|
||||
# 2. If 5xx from SHKeeper → it's their side
|
||||
# - Check their status page / contact provider
|
||||
# - Toggle a banner in the frontend warning buyers
|
||||
# - Consider switching SHKEEPER_FORCE_PAYOUT_DEMO=true so QA still works
|
||||
# (do NOT do this for real customer money)
|
||||
|
||||
# 3. If our network can't reach it:
|
||||
# - test from the host: curl from the host vs from inside the container
|
||||
docker exec nickapp-backend curl -v "$SHKEEPER_API_URL"
|
||||
# - DNS / firewall changes?
|
||||
|
||||
# 4. While blocked, monitor stuck payments
|
||||
docker exec nickapp-mongodb mongosh --eval \
|
||||
"use marketplace; db.payments.find({status:'pending', createdAt:{\$lt: new Date(Date.now() - 30*60*1000)}}).count()"
|
||||
|
||||
# 5. Once SHKeeper is back, the app retries automatically. Verify the
|
||||
# backlog drains. If a payment is stuck > 24h, manually verify against
|
||||
# SHKeeper and use fix-transaction-hashes.js if needed.
|
||||
```
|
||||
|
||||
**Always communicate.** Even short payment outages erode trust — post a status update.
|
||||
|
||||
---
|
||||
|
||||
### 3.5 Email delivery failure
|
||||
|
||||
**Symptoms.** Logs show `SMTPError` from `nodemailer`. Password resets, welcome emails, dispute notifications fail. Sev 2.
|
||||
|
||||
**Runbook.**
|
||||
|
||||
```bash
|
||||
# 1. Test SMTP credentials from the container
|
||||
docker exec nickapp-backend node -e "
|
||||
const nm = require('nodemailer');
|
||||
nm.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: Number(process.env.SMTP_PORT),
|
||||
secure: process.env.SMTP_SECURE === 'true',
|
||||
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
|
||||
}).verify().then(console.log).catch(console.error);
|
||||
"
|
||||
|
||||
# 2. If auth failed → password rotated by provider, update SMTP_PASS in .env
|
||||
# 3. If connection timed out → provider rate-limit; switch provider/sender
|
||||
# 4. If specific domains bounce → check SPF / DKIM / DMARC records for amn.gg
|
||||
```
|
||||
|
||||
Users can still operate the app without email; queue critical emails for retry once SMTP is restored.
|
||||
|
||||
---
|
||||
|
||||
### 3.6 WebSocket disconnect storm
|
||||
|
||||
**Symptoms.** Backend logs flood with `🔌 User connected/disconnected` cycling; clients spinning on chat / notification badges. Sev 2.
|
||||
|
||||
**Runbook.**
|
||||
|
||||
```bash
|
||||
# 1. Confirm symptoms
|
||||
docker logs --tail=500 nickapp-backend | grep -c "🔌"
|
||||
|
||||
# 2. Check Nginx access log for socket.io polling spam
|
||||
tail -f /opt/backend/nginx/logs/access.log | grep socket.io
|
||||
|
||||
# 3. Common causes:
|
||||
# - Nginx not configured for WebSocket upgrade (returns 502 → client falls back to polling → reconnect loop)
|
||||
# - Client clock skew breaking JWT validation on every reconnect
|
||||
# - Redis adapter mis-configured (if scaled horizontally — not the case today)
|
||||
|
||||
# 4. Quick mitigation: increase Nginx proxy_read_timeout
|
||||
# Permanent: ensure nginx.conf has:
|
||||
# proxy_http_version 1.1;
|
||||
# proxy_set_header Upgrade $http_upgrade;
|
||||
# proxy_set_header Connection "upgrade";
|
||||
|
||||
# 5. Restart nginx + backend
|
||||
docker compose restart nginx nickapp-backend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.7 Suspicious activity / abuse
|
||||
|
||||
**Symptoms.** Sentry alerts on unusual error volume from one IP; rate-limit logs spiking; reports of brute-force on `/api/auth/login`.
|
||||
|
||||
**Runbook.**
|
||||
|
||||
```bash
|
||||
# 1. Identify the offender
|
||||
tail -n 10000 /opt/backend/nginx/logs/access.log \
|
||||
| awk '{print $1}' | sort | uniq -c | sort -rn | head
|
||||
|
||||
# 2. Block at the edge (Cloudflare / host firewall)
|
||||
# Or use `ufw deny from <ip>` on the host
|
||||
|
||||
# 3. Confirm rate limits in app
|
||||
grep "RATE_LIMIT" /opt/backend/.env
|
||||
# Defaults: 100 req / 15 min per IP. Tighten if abuse continues.
|
||||
|
||||
# 4. If the abuse targets a specific user account:
|
||||
docker exec -it nickapp-backend node -e "
|
||||
// disable the user via mongoose
|
||||
require('./dist/infrastructure/database/connection').connectDatabase()
|
||||
.then(() => require('./dist/models').User.updateOne({email:'attacker@x.com'}, {$set:{disabled:true}}))
|
||||
.then(console.log)
|
||||
"
|
||||
|
||||
# 5. Preserve evidence: copy access logs to /var/incidents/<date>/
|
||||
```
|
||||
|
||||
If user data may have leaked, treat as sev 1 and follow your data-breach disclosure process.
|
||||
|
||||
---
|
||||
|
||||
## 4. Communication templates
|
||||
|
||||
### Initial incident notification
|
||||
|
||||
```
|
||||
🚨 [SEV-X] <one-line summary>
|
||||
Started: <time UTC>
|
||||
Impact: <which users / features>
|
||||
Status: investigating
|
||||
On-call: <@you>
|
||||
Updates: every 15 minutes in this thread
|
||||
```
|
||||
|
||||
### Mid-incident update
|
||||
|
||||
```
|
||||
[SEV-X] Update <n>
|
||||
Time: <UTC>
|
||||
Status: <investigating / mitigating / monitoring>
|
||||
What we know: <facts>
|
||||
What we're trying: <action>
|
||||
Next update: <time>
|
||||
```
|
||||
|
||||
### Resolution
|
||||
|
||||
```
|
||||
✅ [SEV-X] Resolved
|
||||
Started: <UTC>
|
||||
Ended: <UTC>
|
||||
Duration: <minutes>
|
||||
Impact: <users / features / requests affected>
|
||||
Root cause: <one sentence>
|
||||
Permanent fix: <PR / ticket>
|
||||
Postmortem: <doc link, by <date>>
|
||||
```
|
||||
|
||||
### Customer-facing status
|
||||
|
||||
```
|
||||
We're investigating an issue affecting <feature> that started at <time>.
|
||||
We'll post an update by <time + 15 min>.
|
||||
```
|
||||
|
||||
Avoid speculation in customer-facing copy. Say "investigating", "applying fix", "monitoring", "resolved" — and nothing else until you actually know.
|
||||
|
||||
---
|
||||
|
||||
## 5. Post-mortem template
|
||||
|
||||
Use within 5 business days of any sev 1 or sev 2.
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Post-mortem — <short title>
|
||||
date: <YYYY-MM-DD>
|
||||
severity: SEV-X
|
||||
duration: <minutes>
|
||||
authors: [<names>]
|
||||
tags: [postmortem]
|
||||
---
|
||||
|
||||
## Summary
|
||||
One paragraph: what broke, who was affected, how long, how it was fixed.
|
||||
|
||||
## Timeline (UTC)
|
||||
- HH:MM — first signal (alert, user report)
|
||||
- HH:MM — on-call ack
|
||||
- HH:MM — hypothesis: ...
|
||||
- HH:MM — mitigation deployed
|
||||
- HH:MM — verified resolved
|
||||
- HH:MM — incident closed
|
||||
|
||||
## Impact
|
||||
- Users affected: <count or %>
|
||||
- Features affected: <list>
|
||||
- Money affected: <if payments>
|
||||
- Data loss: <yes/no — describe>
|
||||
|
||||
## Root cause
|
||||
Honest, blameless. Distinguish trigger vs underlying cause.
|
||||
|
||||
## What went well
|
||||
- ...
|
||||
|
||||
## What went poorly
|
||||
- ...
|
||||
|
||||
## Where we got lucky
|
||||
- ...
|
||||
|
||||
## Action items
|
||||
| # | Item | Owner | Due | Ticket |
|
||||
|---|------|-------|-----|--------|
|
||||
| 1 | Add /health probe for MongoDB | @x | 2026-06-01 | OPS-123 |
|
||||
| 2 | Tighten rate limit on /auth/login | @y | 2026-05-30 | OPS-124 |
|
||||
|
||||
## Detection improvements
|
||||
What new alert / dashboard would have caught this earlier?
|
||||
|
||||
## Process improvements
|
||||
What runbook / docs need updating? Update [[Incident Response]] right now.
|
||||
```
|
||||
|
||||
Store postmortems alongside this vault — suggested path `/Users/mojtabaheidari/code/docs/08 - Operations/postmortems/YYYY-MM-DD-<slug>.md`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Escalation contacts
|
||||
|
||||
(Fill in for your team; placeholder structure below.)
|
||||
|
||||
| Role | Primary | Backup | Channel |
|
||||
|------|---------|--------|---------|
|
||||
| On-call engineer | <name> | <name> | #incidents |
|
||||
| Payments lead | <name> | <name> | DM |
|
||||
| Infrastructure | <name> | <name> | DM |
|
||||
| Product / customer comms | <name> | <name> | #customer-comms |
|
||||
| SHKeeper provider contact | <email> | — | email |
|
||||
| SMTP provider | <email> | — | email |
|
||||
|
||||
---
|
||||
|
||||
## 7. After every incident
|
||||
|
||||
- [ ] Updated this page with any new gotchas?
|
||||
- [ ] Updated [[Monitoring]] with new metrics/alerts to add?
|
||||
- [ ] Updated [[Backup & Recovery]] if backup gaps were exposed?
|
||||
- [ ] Action items tracked?
|
||||
- [ ] Customer comms sent (if user-impacting)?
|
||||
- [ ] Post-mortem published?
|
||||
|
||||
Cross-links: [[Deployment]] for rollback steps, [[Database Operations]] for DB diagnostics, [[Backup & Recovery]] for restore procedures, [[Monitoring]] for metrics to watch.
|
||||
253
08 - Operations/Monitoring.md
Normal file
253
08 - Operations/Monitoring.md
Normal file
@@ -0,0 +1,253 @@
|
||||
---
|
||||
title: Monitoring
|
||||
tags: [operations]
|
||||
---
|
||||
|
||||
# Monitoring
|
||||
|
||||
What's instrumented today and what to watch. Today's stack is intentionally lean — health endpoints, Docker healthchecks, Sentry, and access logs. Bigger metric pipelines (Prometheus, Grafana, OpenSearch) are a future addition.
|
||||
|
||||
---
|
||||
|
||||
## 1. Health endpoint
|
||||
|
||||
Path: `GET /health` (backend, port `5001`).
|
||||
|
||||
Defined in `backend/src/app.ts`:
|
||||
|
||||
```ts
|
||||
app.get("/health", (req, res) => {
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Marketplace Backend API is running",
|
||||
timestamp: new Date().toISOString(),
|
||||
environment: config.nodeEnv,
|
||||
version: packageJson.version,
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Returns `200` with a JSON envelope as soon as Express is up. Does **not** currently probe MongoDB or Redis — they are checked via separate Docker healthchecks. If you want deep health, extend the endpoint to ping both data stores and return `503` on failure.
|
||||
|
||||
Public URL behind Nginx: `https://amn.gg/api/health`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Docker healthchecks
|
||||
|
||||
Each long-lived container has a `HEALTHCHECK` baked in or declared in compose.
|
||||
|
||||
| Container | Probe | Interval | Failure threshold |
|
||||
|-----------|-------|----------|-------------------|
|
||||
| `nickapp-backend` | `node healthcheck.js` (HTTP GET `/health`) | 30s | 3 retries |
|
||||
| `nickapp-frontend` | `curl -f http://localhost:8083/` | 30s | 3 retries |
|
||||
| `mongodb` | `mongosh --eval "db.adminCommand('ping')"` | 30s | 3 retries |
|
||||
| `redis` | `redis-cli -a $REDIS_PASSWORD ping` | 30s | 3 retries |
|
||||
|
||||
`healthcheck.js` (backend) is a tiny Node script that does a local HTTP GET to `/health` and exits 0 / 1.
|
||||
|
||||
Inspect health:
|
||||
|
||||
```bash
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}"
|
||||
|
||||
# Detailed
|
||||
docker inspect --format='{{json .State.Health}}' nickapp-backend | jq
|
||||
```
|
||||
|
||||
If a container is `unhealthy`, Watchtower will **not** roll it (it expects the new container to pass healthcheck). Investigate with `docker logs <container>`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Sentry — error tracking
|
||||
|
||||
### Frontend
|
||||
|
||||
`@sentry/nextjs ^10.22.0` is wired in via three config files at the repo root:
|
||||
|
||||
- `sentry.client.config.ts` — browser SDK (with Session Replay enabled at 10% session / 100% error rate).
|
||||
- `sentry.server.config.ts` — server-rendered components (no Replay).
|
||||
- `sentry.edge.config.ts` — edge runtime (not currently used heavily).
|
||||
|
||||
Common settings:
|
||||
|
||||
```ts
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.1 : 1.0,
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
enabled: process.env.NODE_ENV === 'production',
|
||||
ignoreErrors: ['ResizeObserver loop limit exceeded', 'ChunkLoadError', ...],
|
||||
});
|
||||
```
|
||||
|
||||
Errors from `localhost` are filtered out — only prod errors land in the dashboard.
|
||||
|
||||
### Backend
|
||||
|
||||
`@sentry/node ^10.22.0` + `@sentry/profiling-node ^10.22.0` are initialised **first** in `src/app.ts` (before any other import) via `src/config/sentry.ts`. DSN comes from `SENTRY_DSN` env var (see [[Environment Variables#sentry]]).
|
||||
|
||||
What's captured:
|
||||
|
||||
- Uncaught exceptions in route handlers
|
||||
- Promise rejections inside `asyncHandler`-wrapped routes
|
||||
- Manually-captured errors via `Sentry.captureException(err)`
|
||||
- Performance traces (10% sample rate in prod)
|
||||
- Profiling samples via `@sentry/profiling-node`
|
||||
|
||||
### Source maps
|
||||
|
||||
Frontend uploads source maps to Sentry at build time when `SENTRY_AUTH_TOKEN`, `SENTRY_ORG`, and `SENTRY_PROJECT` are set in the CI env. Without them the build still succeeds but Sentry traces will show minified frames.
|
||||
|
||||
### Alerts
|
||||
|
||||
Configure in the Sentry dashboard (Issues → Alerts) — common alerts:
|
||||
|
||||
- Any new issue in production → Slack
|
||||
- Error frequency > 50/minute → page on-call
|
||||
- Performance regression on `/api/payments/*` traces → email
|
||||
|
||||
---
|
||||
|
||||
## 4. Logs
|
||||
|
||||
### Backend application logs
|
||||
|
||||
Routed through `src/utils/logger.ts` — currently a thin `console.log` wrapper with emoji prefixes. Output goes to stdout, captured by Docker:
|
||||
|
||||
```bash
|
||||
# Live tail
|
||||
docker compose -f docker-compose.production.yml logs -f --tail=200 nickapp-backend
|
||||
|
||||
# Search for a request
|
||||
docker logs nickapp-backend 2>&1 | grep "POST /api/payments"
|
||||
|
||||
# Pre-filter by date
|
||||
docker logs --since 1h nickapp-backend
|
||||
```
|
||||
|
||||
Notable log lines to look for:
|
||||
|
||||
| Prefix | Meaning |
|
||||
|--------|---------|
|
||||
| `✅ Connected to MongoDB` | DB connection established |
|
||||
| `🚀 Server running on port 5001` | App fully started |
|
||||
| `🔌 User connected: <id>` | Socket.IO connection |
|
||||
| `📥` | Inbound HTTP request log |
|
||||
| `💳 SHKeeper` | SHKeeper webhook / API call |
|
||||
| `🔐 Webhook verification` | Webhook signature check result |
|
||||
| `❌ Error` | Manual error log (also captured by Sentry) |
|
||||
|
||||
### Nginx access + error logs
|
||||
|
||||
Bind-mounted to `./nginx/logs/` on the host:
|
||||
|
||||
```bash
|
||||
tail -f /opt/backend/nginx/logs/access.log
|
||||
tail -f /opt/backend/nginx/logs/error.log
|
||||
```
|
||||
|
||||
Rotate these via host `logrotate` to avoid disk fill.
|
||||
|
||||
### Frontend logs
|
||||
|
||||
Next.js logs go to the container stdout:
|
||||
|
||||
```bash
|
||||
docker logs -f nickapp-frontend
|
||||
```
|
||||
|
||||
Browser-side logs that need attention go through Sentry (above) — `src/utils/logger.ts` in the frontend forwards via Sentry breadcrumbs.
|
||||
|
||||
---
|
||||
|
||||
## 5. Key metrics to watch
|
||||
|
||||
Today these are read manually from logs / Sentry. As Prometheus is added, encode them as alerting rules.
|
||||
|
||||
### Application
|
||||
|
||||
| Metric | Where to check | Healthy | Alert |
|
||||
|--------|---------------|---------|-------|
|
||||
| 5xx rate | Sentry, Nginx access.log | < 0.5 % | > 2 % over 5 min |
|
||||
| `/health` p95 latency | curl + timer | < 100 ms | > 1 s |
|
||||
| Login success rate | Sentry custom event | > 95 % | < 90 % |
|
||||
| Socket disconnect storm | `🔌 User disconnected` log frequency | < 1/s sustained | > 10/s sustained |
|
||||
| OpenAI 429s | Backend log `OpenAI ... 429` | 0 | any |
|
||||
|
||||
### Payments
|
||||
|
||||
| Metric | Where | Healthy | Alert |
|
||||
|--------|-------|---------|-------|
|
||||
| Payment success rate | `db.payments.aggregate([{$group:{_id:"$status",n:{$sum:1}}}])` | > 95 % completed of 24h-old payments | < 90 % |
|
||||
| Webhook signature failures | log `Webhook verification failed` | 0 | > 0 |
|
||||
| SHKeeper API errors (5xx) | log + Sentry | 0 | > 5/min sustained |
|
||||
| Payouts stuck in `pending` > 30 min | `db.payments.find({type:'payout',status:'pending',createdAt:{$lt:ISODate(30 min ago)}})` | empty | non-empty |
|
||||
| Missing `transactionHash` after `completed` | the same query that drives `fix-transaction-hashes.js` | empty | non-empty |
|
||||
|
||||
### MongoDB
|
||||
|
||||
```js
|
||||
db.serverStatus().connections // active connections; alert if >1000
|
||||
db.serverStatus().opcounters // ops/sec
|
||||
db.serverStatus().wiredTiger.cache // cache hit ratio; aim > 95 %
|
||||
db.currentOp({ secs_running: { $gte: 5 } }) // long-running queries
|
||||
```
|
||||
|
||||
### Redis
|
||||
|
||||
```bash
|
||||
docker exec nickapp-redis redis-cli -a "$REDIS_PASSWORD" INFO stats
|
||||
# Watch: instantaneous_ops_per_sec, keyspace_hits/misses, rejected_connections, evicted_keys
|
||||
```
|
||||
|
||||
Alert thresholds: `rejected_connections > 0`, `evicted_keys` rising while you don't expect cache pressure, `latency_ms` p99 > 5ms.
|
||||
|
||||
### Host
|
||||
|
||||
| Metric | Tool | Healthy | Alert |
|
||||
|--------|------|---------|-------|
|
||||
| Disk usage on `/var/lib/docker` | `df -h` | < 80 % | > 90 % |
|
||||
| `/opt/backend/uploads` size | `du -sh` | watch trend | bursty growth (>5 GB/day) |
|
||||
| Memory pressure | `free -h`, `docker stats` | < 80 % | swap actively used |
|
||||
| Open file descriptors | `cat /proc/<pid>/limits` | well under hard limit | nearing limit |
|
||||
|
||||
---
|
||||
|
||||
## 6. Smoke tests after a deploy
|
||||
|
||||
Drop these in a runbook for the on-call:
|
||||
|
||||
```bash
|
||||
# 1. API health
|
||||
curl -fsS https://amn.gg/api/health | jq '.success,.version,.environment'
|
||||
|
||||
# 2. Login
|
||||
curl -fsS -X POST https://amn.gg/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"admin@marketplace.com","password":"<prod-admin-pwd>"}' \
|
||||
| jq '.success,.data.user.email'
|
||||
|
||||
# 3. Frontend HTML loads
|
||||
curl -fsS https://amn.gg/ -I | head -1 # expect 200
|
||||
|
||||
# 4. Socket.IO handshake
|
||||
curl -fsS "https://amn.gg/socket.io/?EIO=4&transport=polling" -I | head -1
|
||||
|
||||
# 5. Containers healthy
|
||||
docker ps --filter "name=nickapp-" --format "table {{.Names}}\t{{.Status}}"
|
||||
```
|
||||
|
||||
Any non-OK → see [[Incident Response]].
|
||||
|
||||
---
|
||||
|
||||
## 7. Future work
|
||||
|
||||
- **Prometheus + Grafana** with Node exporter + Mongo exporter + Redis exporter — for proper time-series.
|
||||
- **OpenTelemetry** spans from backend → Sentry / Jaeger.
|
||||
- **Healthcheck endpoint** that probes Mongo + Redis and returns `503` when degraded.
|
||||
- **PagerDuty / OpsGenie** wiring from Sentry alerts.
|
||||
- **Synthetic checks** (Pingdom / UptimeRobot) hitting `/health` from multiple regions.
|
||||
|
||||
For now, Sentry + Docker healthchecks + manual log checks cover the basics. See [[Incident Response]] for what to do when something fires.
|
||||
229
README.md
Normal file
229
README.md
Normal file
@@ -0,0 +1,229 @@
|
||||
---
|
||||
title: Amn Marketplace — Documentation Vault
|
||||
tags: [moc, index]
|
||||
created: 2026-05-23
|
||||
---
|
||||
|
||||
# Amn Marketplace — Documentation Vault
|
||||
|
||||
Complete technical & operational documentation for the **Amn** (a.k.a. "nick app") crypto-escrow marketplace platform. This vault is exhaustive enough to **re-implement the system from scratch** with no access to the source code.
|
||||
|
||||
> [!info]
|
||||
> **Repos:** `git@git.manko.yoga:222/nick/{backend,frontend}.git` · **Branch:** `development` · **Vault generated:** 2026-05-23
|
||||
|
||||
---
|
||||
|
||||
## How to read this vault
|
||||
|
||||
Open this folder in **Obsidian** for the best experience (graph view + wikilinks + Mermaid rendering). It also reads as plain Markdown anywhere.
|
||||
|
||||
Suggested reading paths:
|
||||
|
||||
- **New developer** → `00 Overview` → `01 Architecture` → `07 Development/Local Setup` → start coding.
|
||||
- **Re-implementer** → `00 Overview` → `02 Data Models` → `03 API Reference` → `04 Flows` → `01 Architecture` for plumbing details.
|
||||
- **Designer** → `00 Overview` → `05 Design System` → `01 Architecture/Frontend Architecture`.
|
||||
- **Product / PM** → `00 Overview` → `04 Flows` → `06 Usage`.
|
||||
- **Operator / DevOps** → `00 Overview` → `01 Architecture/Infrastructure` → `08 Operations`.
|
||||
- **Support staff** → `06 Usage/Support Guide` + `00 Overview/Glossary` + relevant flow docs.
|
||||
|
||||
---
|
||||
|
||||
## 00 — Overview
|
||||
|
||||
Project context, the cast of characters, and shared vocabulary.
|
||||
|
||||
- [[Introduction]] — mission, problem, distinctive design choices
|
||||
- [[System Overview]] — bird's-eye system map with Mermaid diagram
|
||||
- [[Tech Stack]] — every dependency with version + role
|
||||
- [[Roles & Personas]] — Buyer / Seller / Admin / Support and what each does
|
||||
- [[Glossary]] — 38 alphabetised terms
|
||||
|
||||
## 01 — Architecture
|
||||
|
||||
How the system is composed at every layer.
|
||||
|
||||
- [[System Architecture]] — end-to-end topology + request lifecycle
|
||||
- [[Backend Architecture]] — Express 5 + Mongoose + Socket.IO module map
|
||||
- [[Frontend Architecture]] — Next.js 16 App Router + provider tree
|
||||
- [[Infrastructure]] — Docker images, compose stacks, registry, Watchtower
|
||||
- [[Real-time Layer]] — Socket.IO rooms, events, scaling notes
|
||||
- [[Security Architecture]] — auth layers, RBAC, HMAC, hardening checklist
|
||||
|
||||
## 02 — Data Models
|
||||
|
||||
Per-entity Mongoose schemas — fields, relationships, state machines.
|
||||
|
||||
- [[Data Model Overview]] — ER-style map + reading order
|
||||
- Core entities: [[User]] · [[PurchaseRequest]] · [[SellerOffer]] · [[Payment]] · [[Chat]] · [[Notification]] · [[Dispute]]
|
||||
- Marketplace extras: [[RequestTemplate]] · [[ShopSettings]] · [[Category]] · [[Review]]
|
||||
- User extras: [[Address]] · [[TempVerification]]
|
||||
- Loyalty: [[PointTransaction]] · [[LevelConfig]]
|
||||
- Content: [[BlogPost]]
|
||||
|
||||
## 03 — API Reference
|
||||
|
||||
Every endpoint, grouped by service. Auth, request/response shapes, errors, socket events.
|
||||
|
||||
- [[API Overview]] — base URLs, envelope, pagination, conventions
|
||||
- Auth & users: [[Authentication API]] · [[User API]]
|
||||
- Marketplace: [[Marketplace API]]
|
||||
- Payments: [[Payment API]]
|
||||
- Real-time / messaging: [[Chat API]] · [[Notification API]] · [[Socket Events]]
|
||||
- Disputes & ratings: [[Dispute API]]
|
||||
- Content: [[Blog API]]
|
||||
- Admin & ops: [[Admin API]]
|
||||
- Loyalty: [[Points API]]
|
||||
- Utility: [[File API]] · [[AI API]]
|
||||
- Errors: [[Error Codes]]
|
||||
|
||||
## 04 — Flows
|
||||
|
||||
End-to-end narratives for every user-visible interaction, with Mermaid sequence/state diagrams.
|
||||
|
||||
**Identity**
|
||||
- [[Authentication Flow]] · [[Registration Flow]] · [[Google OAuth Flow]] · [[Passkey (WebAuthn) Flow]] · [[Password Reset Flow]]
|
||||
|
||||
**Marketplace**
|
||||
- [[Purchase Request Flow]] · [[Seller Offer Flow]] · [[Negotiation Flow]]
|
||||
|
||||
**Money**
|
||||
- [[Payment Flow - SHKeeper]] · [[Payment Flow - DePay & Web3]] · [[Escrow Flow]] · [[Payout Flow]]
|
||||
|
||||
**Resolution**
|
||||
- [[Dispute Flow]] · [[Delivery Confirmation Flow]] · [[Rating Flow]]
|
||||
|
||||
**Engagement**
|
||||
- [[Chat Flow]] · [[Notification Flow]] · [[Referral Flow]]
|
||||
|
||||
## 05 — Design System
|
||||
|
||||
The visual & UX language of the frontend.
|
||||
|
||||
- [[Design System Overview]] — philosophy, tokens, layering
|
||||
- [[Theme Configuration]] — MUI theme structure
|
||||
- [[Typography]] — type scale & font loading
|
||||
- [[Colors]] — palette tokens + contrast
|
||||
- [[Components]] — inventory of reusable components
|
||||
- [[Layouts]] — auth / dashboard / main layouts
|
||||
- [[Internationalization & RTL]] — i18n + bidi rendering
|
||||
- [[Iconography]] — Iconify usage rules
|
||||
- [[Settings & Theming]] — user-controllable visual axes
|
||||
|
||||
## 06 — Usage
|
||||
|
||||
End-user guides — one per role.
|
||||
|
||||
- [[User Guide]] (Buyer)
|
||||
- [[Seller Guide]] (Owner)
|
||||
- [[Admin Guide]]
|
||||
- [[Support Guide]]
|
||||
|
||||
## 07 — Development
|
||||
|
||||
For engineers contributing to the codebase.
|
||||
|
||||
- [[Local Setup]] — get the system running on your machine
|
||||
- [[Environment Variables]] — unified env catalog (both repos)
|
||||
- [[Project Structure]] — side-by-side folder map
|
||||
- [[Coding Standards]] — cursor-rules summary + commit conventions
|
||||
- [[Testing]] — Jest, RTL, Playwright
|
||||
- [[Scripts]] — every CLI utility (seeds, build, deploy, repair)
|
||||
- [[Git Workflow]] — branch model + tag strategy
|
||||
|
||||
## 08 — Operations
|
||||
|
||||
For engineers / SREs running the system in production.
|
||||
|
||||
- [[Deployment]] — first-deploy runbook
|
||||
- [[Docker Setup]] — image & compose breakdown
|
||||
- [[CI-CD Pipeline]] — Gitea workflows + secrets
|
||||
- [[Database Operations]] — direct DB ops (Mongo, Redis)
|
||||
- [[Monitoring]] — healthchecks, Sentry, metrics
|
||||
- [[Backup & Recovery]] — dump/restore + DR
|
||||
- [[Incident Response]] — common runbooks
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting indexes
|
||||
|
||||
### By topic
|
||||
|
||||
| Topic | Start here |
|
||||
|---|---|
|
||||
| **Payments** | [[Payment Flow - SHKeeper]] → [[Payment API]] → [[Payment]] → [[Payout Flow]] |
|
||||
| **Auth** | [[Authentication Flow]] → [[Authentication API]] → [[Security Architecture]] |
|
||||
| **Real-time** | [[Real-time Layer]] → [[Socket Events]] → [[Chat Flow]] / [[Notification Flow]] |
|
||||
| **Disputes** | [[Dispute Flow]] → [[Dispute API]] → [[Dispute]] → [[Admin Guide]] §5 |
|
||||
| **Web3** | [[Payment Flow - DePay & Web3]] → [[Frontend Architecture]] §9 |
|
||||
| **i18n / RTL** | [[Internationalization & RTL]] → [[Typography]] |
|
||||
| **Theming** | [[Design System Overview]] → [[Theme Configuration]] → [[Settings & Theming]] |
|
||||
|
||||
### By role
|
||||
|
||||
| If you are… | Start with |
|
||||
|---|---|
|
||||
| Buyer | [[User Guide]] |
|
||||
| Seller / Owner | [[Seller Guide]] |
|
||||
| Admin | [[Admin Guide]] |
|
||||
| Support agent | [[Support Guide]] |
|
||||
| Backend engineer | [[Backend Architecture]] · [[Data Model Overview]] |
|
||||
| Frontend engineer | [[Frontend Architecture]] · [[Design System Overview]] |
|
||||
| DevOps / SRE | [[Infrastructure]] · [[Deployment]] · [[Incident Response]] |
|
||||
| Product / PM | [[Introduction]] · [[Roles & Personas]] · `04 Flows` |
|
||||
|
||||
---
|
||||
|
||||
## Vault conventions
|
||||
|
||||
- **Wikilinks** `[[Document Name]]` (no extension) — Obsidian resolves them automatically.
|
||||
- **YAML frontmatter** at the top of every file — `title`, `tags`, `created`.
|
||||
- **Callouts** `> [!note]`, `> [!warning]`, `> [!tip]`, `> [!info]`, `> [!important]`, `> [!example]`.
|
||||
- **Mermaid** diagrams in fenced code blocks (`flowchart`, `sequenceDiagram`, `stateDiagram-v2`, `erDiagram`).
|
||||
- **Citations** `file:lineNumber` whenever referring to specific code (e.g., `backend/src/app.ts:79-179`).
|
||||
- **Tables** for structured data — env vars, endpoints, model fields, etc.
|
||||
|
||||
---
|
||||
|
||||
## Statistics
|
||||
|
||||
- **~85 markdown files** across 9 sections
|
||||
- **~600 KB total** of documentation
|
||||
- **~80,000 words** of prose
|
||||
- **Mermaid diagrams** for every major flow and architecture view
|
||||
- **Wikilinks** throughout for graph-view navigation
|
||||
|
||||
---
|
||||
|
||||
## Known limitations & roadmap items
|
||||
|
||||
These are documented in their respective sections but worth highlighting:
|
||||
|
||||
> [!warning]
|
||||
> - Backend rate-limit middleware is currently **disabled** (`backend/src/app.ts:227`). Enable before any public traffic — see [[Security Architecture]].
|
||||
> - Passkey service is partly **stubbed** — see [[Passkey (WebAuthn) Flow]] for production-hardening checklist.
|
||||
> - Auto-release of escrow on delivery confirmation **not yet automated** — admin runs manual payouts. See [[Delivery Confirmation Flow]] + [[Payout Flow]].
|
||||
> - Opening a dispute does **not pause** the escrow until admin intervention. See [[Dispute Flow]] + [[Escrow Flow]].
|
||||
> - Several development env values committed as public — see [[Environment Variables]] for rotation list.
|
||||
> - Single-host deployment; horizontal scaling requires Redis adapter for Socket.IO — see [[Real-time Layer]] §8.
|
||||
|
||||
---
|
||||
|
||||
## Contributing to this vault
|
||||
|
||||
- Add files under the appropriate section folder.
|
||||
- Use the conventions above (frontmatter, wikilinks, callouts).
|
||||
- Run `git diff` on the source repo before updating docs — keep cited line numbers fresh.
|
||||
- For new flows, follow the structure of [[Authentication Flow]] (Actors → Preconditions → Steps → Mermaid → API calls → DB writes → Socket events → Side effects → Errors → Related).
|
||||
- For new models, follow the structure of [[User]] (purpose → schema table → virtuals/hooks → methods → relationships → state diagram → queries → related).
|
||||
|
||||
---
|
||||
|
||||
## License & ownership
|
||||
|
||||
The vault is the project's internal documentation. Treat all credentials, addresses, and operational details as **confidential**. Public-facing copies should redact the seed credentials, env values, and any production URLs/IDs that aren't already public.
|
||||
|
||||
---
|
||||
|
||||
## End
|
||||
|
||||
Welcome to the codebase. If anything here is unclear, the source is in the [[Backend Architecture]] / [[Frontend Architecture]] cited files — fix the docs as you go.
|
||||
Reference in New Issue
Block a user