Add website API docs, SSL api-hosts, and git-only deploy workflow.

Serve public storefront docs at /docs/website, expose api.{domain} hosts for API SSL sync, and require push-then-pull deploys instead of rsync.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-07-22 21:39:51 +03:30
co-authored by Cursor
parent bb59d5e9ba
commit 016cc15bf0
32 changed files with 6159 additions and 37 deletions
+37
View File
@@ -0,0 +1,37 @@
---
description: Backend production deploy must use git push then git pull on the API VM — never rsync/scp as the primary deploy path.
alwaysApply: true
---
# Backend deploy (git only)
When deploying the Meshkee CMS API to production (`api.meshkee.com` / VM `/opt/meshkee/app`):
1. **Commit** the changes (only when the user asked to commit/deploy).
2. **Push** to `origin` (`https://git.meshkee.com/Meshkee/backend.git`, usually `main`).
3. **On the API VM**, update from git and rebuild — do **not** rsync/scp the app tree as the normal deploy path.
```bash
ssh -i ~/.ssh/id_ed25519 root@185.164.72.119 'bash -s' <<'REMOTE'
set -euo pipefail
cd /opt/meshkee/app
git fetch origin
git reset --hard origin/main
./database/migrate.sh
npm ci
npm run prisma:generate
npm run build
pm2 restart meshkee-api
REMOTE
```
## Hard rules
- Never use `rsync`/`scp` of the full project as the default deploy once the VM has a working git remote.
- Preserve the server `.env` (never overwrite it from the laptop).
- Exclude: do not commit `.env`, secrets, `node_modules`, or `dist`.
- If `git pull` fails (missing deploy key / auth), fix git access on the VM — do not silently fall back to rsync unless the user explicitly allows an emergency sync.
## VM git access
Deploy key (read-only) on `git.meshkee.com` for repo `Meshkee/backend`, installed as `/root/.ssh/id_ed25519` on the API VM. Remote should be SSH: `git@git.meshkee.com:Meshkee/backend.git`.
+1
View File
@@ -33,3 +33,4 @@ Do **not** use Prisma Migrate. SQL migrations are authoritative.
- Minimize diff scope; match existing module patterns - Minimize diff scope; match existing module patterns
- Reuse existing services/guards instead of reimplementing - Reuse existing services/guards instead of reimplementing
- No commits unless explicitly requested - No commits unless explicitly requested
- **Production deploy:** push to git first, then pull/build on the API VM — see `.cursor/rules/git-deploy.mdc` (never rsync as the normal path)
+11
View File
@@ -42,3 +42,14 @@ OPENAI_API_KEY=
OPENAI_MODEL=gpt-4o-mini OPENAI_MODEL=gpt-4o-mini
MEDIA_MAX_FILE_SIZE_MB=10 MEDIA_MAX_FILE_SIZE_MB=10
# SSL sync (header X-SSL-Sync-Token)
# Dashboards VPS: GET /api/v1/internal/ssl/hosts → manage + business./customer.{apex}
# API VPS: GET /api/v1/internal/ssl/api-hosts → api.meshkee.com + api.{apex}
SSL_SYNC_TOKEN=
DASHBOARD_ADMIN_HOST=manage.meshkee.com
CENTRAL_API_HOST=api.meshkee.com
# Website storefront deploy agent (POST from Super Admin → websites VM)
WEBSITE_DEPLOY_AGENT_URL=http://89.44.241.119:9050/deploy
WEBSITE_DEPLOY_TOKEN=
@@ -0,0 +1,104 @@
-- Repair: finish cart/orders/store selections after partial 019/020 on production.
-- Safe to re-run (IF NOT EXISTS / ON CONFLICT). Prefer running ordered migrations on a fresh DB.
-- Applied on api.meshkee.com 2026-07-21 when 019 created carts but failed before orders,
-- and 020 created store_items but failed before selections / cart remapping.
DO $$ BEGIN
CREATE TYPE order_status AS ENUM (
'pending','confirmed','processing','shipped','delivered','cancelled'
);
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE order_source AS ENUM ('website', 'admin');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
CREATE TABLE IF NOT EXISTS store_item_variant_selections (
variant_id BIGINT NOT NULL,
variation_id BIGINT NOT NULL,
option_id BIGINT NOT NULL,
CONSTRAINT store_item_variant_selections_pkey PRIMARY KEY (variant_id, variation_id),
CONSTRAINT store_item_variant_selections_variant_id_fkey
FOREIGN KEY (variant_id) REFERENCES store_item_variants (id) ON DELETE CASCADE,
CONSTRAINT store_item_variant_selections_variation_id_fkey
FOREIGN KEY (variation_id) REFERENCES category_variations (id) ON DELETE RESTRICT,
CONSTRAINT store_item_variant_selections_option_id_fkey
FOREIGN KEY (option_id) REFERENCES category_variation_options (id) ON DELETE RESTRICT,
CONSTRAINT store_item_variant_selections_unique_option UNIQUE (variant_id, option_id)
);
CREATE INDEX IF NOT EXISTS idx_store_item_variant_selections_option_id
ON store_item_variant_selections (option_id);
CREATE TABLE IF NOT EXISTS cart_items (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
cart_id BIGINT NOT NULL,
store_item_variant_id BIGINT NOT NULL,
quantity INT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT cart_items_cart_id_fkey FOREIGN KEY (cart_id) REFERENCES carts (id) ON DELETE CASCADE,
CONSTRAINT cart_items_store_item_variant_id_fkey
FOREIGN KEY (store_item_variant_id) REFERENCES store_item_variants (id) ON DELETE CASCADE,
CONSTRAINT cart_items_cart_store_item_variant_unique UNIQUE (cart_id, store_item_variant_id),
CONSTRAINT cart_items_quantity_positive CHECK (quantity > 0)
);
CREATE INDEX IF NOT EXISTS idx_cart_items_cart_id ON cart_items (cart_id);
CREATE INDEX IF NOT EXISTS idx_cart_items_store_item_variant_id ON cart_items (store_item_variant_id);
CREATE TABLE IF NOT EXISTS orders (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
business_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
order_number VARCHAR(30) NOT NULL,
status order_status NOT NULL DEFAULT 'pending',
source order_source NOT NULL DEFAULT 'website',
subtotal NUMERIC(12, 2) NOT NULL DEFAULT 0,
shipping_total NUMERIC(12, 2) NOT NULL DEFAULT 0,
discount_total NUMERIC(12, 2) NOT NULL DEFAULT 0,
total NUMERIC(12, 2) NOT NULL DEFAULT 0,
shipping_address JSONB NOT NULL DEFAULT '{}',
address_id BIGINT,
customer_notes TEXT,
admin_notes TEXT,
created_by BIGINT,
process_step_id VARCHAR(64) NOT NULL DEFAULT 'processing',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT orders_business_id_fkey FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE,
CONSTRAINT orders_user_id_fkey FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE RESTRICT,
CONSTRAINT orders_address_id_fkey FOREIGN KEY (address_id) REFERENCES addresses (id) ON DELETE SET NULL,
CONSTRAINT orders_created_by_fkey FOREIGN KEY (created_by) REFERENCES users (id) ON DELETE SET NULL,
CONSTRAINT orders_business_order_number_unique UNIQUE (business_id, order_number),
CONSTRAINT orders_subtotal_non_negative CHECK (subtotal >= 0),
CONSTRAINT orders_shipping_total_non_negative CHECK (shipping_total >= 0),
CONSTRAINT orders_discount_total_non_negative CHECK (discount_total >= 0),
CONSTRAINT orders_total_non_negative CHECK (total >= 0)
);
CREATE INDEX IF NOT EXISTS idx_orders_business_created ON orders (business_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_orders_business_user ON orders (business_id, user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_orders_business_status ON orders (business_id, status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_orders_business_process_step ON orders (business_id, process_step_id);
CREATE TABLE IF NOT EXISTS order_items (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
order_id BIGINT NOT NULL,
store_item_variant_id BIGINT,
product_id BIGINT NOT NULL,
product_title VARCHAR(255) NOT NULL,
variant_sku VARCHAR(100),
unit_price NUMERIC(12, 2) NOT NULL,
compare_at_price NUMERIC(12, 2),
quantity INT NOT NULL,
line_total NUMERIC(12, 2) NOT NULL,
selections_snapshot JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT order_items_order_id_fkey FOREIGN KEY (order_id) REFERENCES orders (id) ON DELETE CASCADE,
CONSTRAINT order_items_store_item_variant_id_fkey
FOREIGN KEY (store_item_variant_id) REFERENCES store_item_variants (id) ON DELETE SET NULL,
CONSTRAINT order_items_product_id_fkey FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE RESTRICT,
CONSTRAINT order_items_quantity_positive CHECK (quantity > 0),
CONSTRAINT order_items_unit_price_non_negative CHECK (unit_price >= 0),
CONSTRAINT order_items_line_total_non_negative CHECK (line_total >= 0)
);
CREATE INDEX IF NOT EXISTS idx_order_items_order_id ON order_items (order_id);
CREATE INDEX IF NOT EXISTS idx_order_items_store_item_variant_id ON order_items (store_item_variant_id);
@@ -0,0 +1,4 @@
-- Track last website deploy attempt per domain (super-admin Websites page)
ALTER TABLE domains
ADD COLUMN IF NOT EXISTS last_deployed_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS last_deploy_status VARCHAR(32);
+65 -8
View File
@@ -6,7 +6,9 @@ App path on server: `/opt/meshkee/app`
API domain: `api.meshkee.com``https://api.meshkee.com/api/v1` API domain: `api.meshkee.com``https://api.meshkee.com/api/v1`
> **Note:** Until the Git remote is accessible from the VM (deploy key / credentials), updates can be synced with `rsync` from your laptop. Pin `sharp@0.33.5` — this VM CPU lacks x64-v2 required by sharp 0.35+. Per-business API aliases (same Nest app on this VM): `api.{apex}` e.g. `api.sanihome.ir``https://api.sanihome.ir/api/v1`. Storefronts still pass the **website apex** in paths (`/tenants/sanihome.ir/...`); only the API hostname changes.
> **Deploy path:** push to `git.meshkee.com` (`Meshkee/backend`), then on the API VM `git fetch` + `reset --hard origin/main` + build + `pm2 restart`. Do **not** rsync the app as the normal update path. The API VM uses a read-only SSH deploy key (`meshkee-api-vm-deploy`). Pin `sharp@0.33.5` — this VM CPU lacks x64-v2 required by sharp 0.35+.
## Prerequisites ## Prerequisites
@@ -126,12 +128,28 @@ curl -s http://127.0.0.1:3000/api/v1/ | head
## 6. Nginx + HTTPS ## 6. Nginx + HTTPS
### DNS (per business website domain)
On the **business domain** DNS (e.g. zone `sanihome.ir`), add a subdomain that points at this **API VM** (same target as `api.meshkee.com`):
| Type | Name / host | Value | Notes |
|------|-------------|-------|--------|
| **A** (preferred) | `api` | `<API_VM_PUBLIC_IP>` | Resolves `api.sanihome.ir` → API server |
| **CNAME** (alternative) | `api` | `api.meshkee.com` | Same effect if your DNS panel allows CNAME on subdomains |
Do **not** point `api.{apex}` at the websites VM or dashboards VM — only the Nest API VM.
Repeat for each storefront apex (`api.ali-mohammadi.ir`, etc.). Central Meshkee DNS already has `api.meshkee.com` → this VM.
### Nginx
Create `/etc/nginx/sites-available/meshkee-api`: Create `/etc/nginx/sites-available/meshkee-api`:
```nginx ```nginx
server { server {
listen 80; listen 80;
server_name api.example.com; # replace with your domain # Central + per-business aliases (add more api.{apex} as domains go live)
server_name api.meshkee.com api.sanihome.ir;
client_max_body_size 15M; client_max_body_size 15M;
@@ -146,29 +164,68 @@ server {
} }
``` ```
Enable and get a certificate: Enable and get certificates:
```bash ```bash
sudo ln -sf /etc/nginx/sites-available/meshkee-api /etc/nginx/sites-enabled/ sudo ln -sf /etc/nginx/sites-available/meshkee-api /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d api.example.com sudo certbot --nginx -d api.meshkee.com -d api.sanihome.ir
# later, when another business goes live:
# sudo certbot --nginx -d api.meshkee.com -d api.sanihome.ir -d api.other-site.ir
``` ```
API base URL: `https://api.example.com/api/v1` API base URLs (identical Nest routes):
## Ongoing updates - Central: `https://api.meshkee.com/api/v1`
- Alias example: `https://api.sanihome.ir/api/v1`
### Website API docs (global link for storefront teams)
After deploy, these are public (no auth):
- Hub: `https://api.meshkee.com/docs/website`
- OpenAPI: `https://api.meshkee.com/docs/website/openapi.json`
- Postman: `https://api.meshkee.com/docs/website/Meshkee-Website-API.postman_collection.json`
- AI brief: `https://api.meshkee.com/docs/website/AI_PROMPT.md`
Files live in `docs/website-api/` and are served by Nest from process cwd. Keep that folder on the VM when deploying.
### Automated cert host list (API VPS)
After deploy, a sync agent on this VM can pull names to cover:
```bash ```bash
curl -s -H "X-SSL-Sync-Token: $SSL_SYNC_TOKEN" \
https://api.meshkee.com/api/v1/internal/ssl/api-hosts
# → { "hosts": ["api.ali-mohammadi.ir", "api.meshkee.com", "api.sanihome.ir", ...] }
```
Dashboards VPS keeps using `GET /api/v1/internal/ssl/hosts` (`business.` / `customer.` / `manage`) — do not mix the two lists.
## Ongoing updates (git only)
From your laptop:
1. Commit and **push** to `origin/main` (`git.meshkee.com/Meshkee/backend`).
2. Deploy on the API VM from that commit (never rsync the tree as the primary path):
```bash
ssh -i ~/.ssh/id_ed25519 root@185.164.72.119 'bash -s' <<'REMOTE'
set -euo pipefail
cd /opt/meshkee/app cd /opt/meshkee/app
git pull git fetch origin
./database/migrate.sh # if there are new SQL migrations git reset --hard origin/main
./database/migrate.sh
npm ci npm ci
npm run prisma:generate npm run prisma:generate
npm run build npm run build
pm2 restart meshkee-api pm2 restart meshkee-api
REMOTE
``` ```
Preserve `/opt/meshkee/app/.env` on the server. App remote must be SSH: `git@git.meshkee.com:Meshkee/backend.git` with the VM deploy key registered as a **read-only deploy key** on the repo.
## Useful commands ## Useful commands
```bash ```bash
+43
View File
@@ -0,0 +1,43 @@
# Meshkee Website API — AI / designer brief
Copy everything below into a new AI chat when building a Meshkee storefront.
---
## System context (paste this)
You are building a **Meshkee business website (storefront)**. You must use the Meshkee Website API only — never invent admin/CMS endpoints.
**Canonical docs (always prefer these):**
- Hub: https://api.meshkee.com/docs/website
- OpenAPI: https://api.meshkee.com/docs/website/openapi.json
- Postman: https://api.meshkee.com/docs/website/Meshkee-Website-API.postman_collection.json
**API base URL:** `https://api.meshkee.com/api/v1`
(Optional alias if configured: `https://api.<WEBSITE_DOMAIN>/api/v1` — same backend.)
**This websites apex domain:** `<WEBSITE_DOMAIN>`
(example: `sanihome.ir` — no `www.`, no `api.`, no `customer.`, no `business.`)
### Hard rules
1. Resolve tenant first: `GET /tenants/<WEBSITE_DOMAIN>` → save `businessId` from `id`.
2. All public content uses `/tenants/<WEBSITE_DOMAIN>/...` (no auth).
3. Cart, orders, favorites use `/businesses/<businessId>/...` with `Authorization: Bearer <accessToken>`.
4. Customer register body must include `"domain": "<WEBSITE_DOMAIN>"`.
5. Cell numbers are E.164 (`+98912...`).
6. Do not call dashboard/CMS routes (`/businesses/.../products` write APIs, media upload, domain-admin, etc.).
### Typical bootstrap sequence
1. `GET /tenants/{domain}` → branding + `businessId`
2. Homepage: business-info, sliders, category-groups, brand-groups, store-specials
3. Catalog: categories, products, store-items
4. Auth: register/login → store tokens
5. Cart checkout with `addressId` or inline `shippingAddress` + `payment`
If OpenAPI and this brief conflict, **OpenAPI wins**.
---
## What to tell each website team
Replace `<WEBSITE_DOMAIN>` once per project. Everything else is global — same Postman, same OpenAPI, same base URL.
@@ -0,0 +1,37 @@
{
"id": "meshkee-website-api-global",
"name": "Meshkee Website API — Global",
"values": [
{
"key": "baseUrl",
"value": "https://api.meshkee.com/api/v1",
"type": "default",
"enabled": true
},
{
"key": "domain",
"value": "YOUR_WEBSITE_DOMAIN",
"type": "default",
"enabled": true
},
{
"key": "businessId",
"value": "",
"type": "default",
"enabled": true
},
{
"key": "accessToken",
"value": "",
"type": "secret",
"enabled": true
},
{
"key": "refreshToken",
"value": "",
"type": "secret",
"enabled": true
}
],
"_postman_variable_scope": "environment"
}
File diff suppressed because it is too large Load Diff
+109
View File
@@ -0,0 +1,109 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Meshkee Website API</title>
<style>
:root {
--bg: #0f1419;
--panel: #1a222c;
--text: #e8eef4;
--muted: #9aa8b5;
--accent: #3d9cf0;
--line: #2a3542;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
background: radial-gradient(1200px 600px at 10% -10%, #1b3a57 0%, var(--bg) 55%);
color: var(--text);
line-height: 1.55;
}
main {
max-width: 760px;
margin: 0 auto;
padding: 3rem 1.25rem 4rem;
}
h1 { font-size: 2rem; margin: 0 0 0.5rem; letter-spacing: -0.02em; }
h2 { font-size: 1.15rem; margin: 2rem 0 0.75rem; }
p, li { color: var(--muted); }
strong { color: var(--text); }
code {
font-family: "IBM Plex Mono", ui-monospace, monospace;
background: #0b1015;
padding: 0.1rem 0.35rem;
border-radius: 4px;
color: #cde3f7;
font-size: 0.92em;
}
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 12px;
padding: 1rem 1.1rem;
margin: 1rem 0;
}
a.btn {
display: inline-block;
margin: 0.35rem 0.5rem 0.35rem 0;
padding: 0.65rem 1rem;
border-radius: 8px;
background: var(--accent);
color: #061018;
text-decoration: none;
font-weight: 600;
}
a.btn.secondary {
background: transparent;
color: var(--text);
border: 1px solid var(--line);
}
.eyebrow { color: var(--accent); font-size: 0.85rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; }
</style>
</head>
<body>
<main>
<div class="eyebrow">Meshkee · Global storefront contract</div>
<h1>Website API</h1>
<p>
One API for <strong>every</strong> Meshkee business website. Not tied to a single domain.
Set your sites apex host (e.g. <code>sanihome.ir</code>) and reuse the same endpoints.
</p>
<div class="panel">
<p style="margin:0 0 0.75rem"><strong>Global links</strong> (share these with designers &amp; AI tools):</p>
<a class="btn" href="./openapi.json">OpenAPI JSON</a>
<a class="btn secondary" href="./Meshkee-Website-API.postman_collection.json">Download Postman</a>
<a class="btn secondary" href="./AI_PROMPT.md">AI prompt</a>
</div>
<h2>Base URL</h2>
<p><code>https://api.meshkee.com/api/v1</code></p>
<p>Optional per-site alias (same backend): <code>https://api.&lt;domain&gt;/api/v1</code></p>
<h2>How tenants work</h2>
<ol>
<li>Variable <code>domain</code> = website apex only (no <code>www</code>/<code>api</code>/<code>customer</code>/<code>business</code>).</li>
<li><code>GET /tenants/{domain}</code><code>businessId</code>.</li>
<li>Public pages: <code>/tenants/{domain}/...</code> (no auth).</li>
<li>Cart / orders / favorites: <code>/businesses/{businessId}/...</code> + Bearer JWT.</li>
</ol>
<h2>For a new website AI / designer</h2>
<ol>
<li>Open <a href="./AI_PROMPT.md">AI_PROMPT.md</a> and paste it into the AI chat.</li>
<li>Replace <code>&lt;WEBSITE_DOMAIN&gt;</code> with that sites apex.</li>
<li>Import the Postman collection (set <code>domain</code>, run Resolve tenant).</li>
<li>Or feed <code>openapi.json</code> to the AI / codegen tool.</li>
</ol>
<h2>Import Postman</h2>
<p>
Postman → Import → Link → paste<br />
<code>https://api.meshkee.com/docs/website/Meshkee-Website-API.postman_collection.json</code>
</p>
</main>
</body>
</html>
+901
View File
@@ -0,0 +1,901 @@
{
"openapi": "3.0.3",
"info": {
"title": "Meshkee Website API",
"version": "1.0.0",
"description": "Global storefront API for every Meshkee business website.\n\n**Not domain-specific.** Replace `{domain}` with the website apex (e.g. `sanihome.ir`).\n\n**Base URL:** `https://api.meshkee.com/api/v1` (or `https://api.{domain}/api/v1` if that alias is configured).\n\n**Tenant rule:** public content uses `/tenants/{domain}/...`. After login, cart/orders/favorites use `/businesses/{businessId}/...` with Bearer JWT.\n\n**Docs:** https://api.meshkee.com/docs/website"
},
"servers": [
{
"url": "https://api.meshkee.com/api/v1",
"description": "Production (central) — use this for all websites"
},
{
"url": "https://api.{domain}/api/v1",
"description": "Optional per-site alias (same backend). {domain} = website apex",
"variables": {
"domain": {
"default": "example.com"
}
}
}
],
"tags": [
{ "name": "Tenant" },
{ "name": "Homepage" },
{ "name": "Categories" },
{ "name": "Products" },
{ "name": "Store" },
{ "name": "Blogs" },
{ "name": "Portfolios" },
{ "name": "Comments" },
{ "name": "Expert Reviews" },
{ "name": "Contact" },
{ "name": "Auth" },
{ "name": "Addresses" },
{ "name": "Cities" },
{ "name": "Cart" },
{ "name": "Orders" },
{ "name": "Favorites" }
],
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
}
},
"parameters": {
"domain": {
"name": "domain",
"in": "path",
"required": true,
"description": "Website apex host only (e.g. sanihome.ir). No www/api/customer/business prefix.",
"schema": { "type": "string", "example": "example.com" }
},
"businessId": {
"name": "businessId",
"in": "path",
"required": true,
"description": "From GET /tenants/{domain} → id",
"schema": { "type": "string" }
}
}
},
"paths": {
"/tenants/{domain}": {
"get": {
"tags": ["Tenant"],
"summary": "Resolve website domain → business",
"parameters": [{ "$ref": "#/components/parameters/domain" }],
"responses": {
"200": {
"description": "Business branding",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"id": { "type": "string" },
"name": { "type": "string" },
"nameFa": { "type": "string" },
"slug": { "type": "string" },
"domain": { "type": "string" },
"primaryColor": { "type": "string", "nullable": true },
"logoUrl": { "type": "string", "nullable": true },
"faviconUrl": { "type": "string", "nullable": true }
}
}
}
}
}
}
}
},
"/tenants/{domain}/website/business-info": {
"get": {
"tags": ["Homepage"],
"summary": "About, contacts, addresses, social",
"parameters": [{ "$ref": "#/components/parameters/domain" }],
"responses": { "200": { "description": "Business public profile" } }
}
},
"/tenants/{domain}/website/sliders": {
"get": {
"tags": ["Homepage"],
"summary": "Homepage sliders + slides",
"parameters": [{ "$ref": "#/components/parameters/domain" }],
"responses": { "200": { "description": "{ items: Slider[] }" } }
}
},
"/tenants/{domain}/website/category-groups": {
"get": {
"tags": ["Homepage"],
"summary": "Homepage category groups",
"parameters": [{ "$ref": "#/components/parameters/domain" }],
"responses": { "200": { "description": "{ items: CategoryGroup[] }" } }
}
},
"/tenants/{domain}/website/brand-groups": {
"get": {
"tags": ["Homepage"],
"summary": "Homepage brand groups",
"parameters": [{ "$ref": "#/components/parameters/domain" }],
"responses": { "200": { "description": "{ items: BrandGroup[] }" } }
}
},
"/tenants/{domain}/store-specials": {
"get": {
"tags": ["Homepage", "Store"],
"summary": "Active store specials",
"parameters": [{ "$ref": "#/components/parameters/domain" }],
"responses": { "200": { "description": "{ items: StoreSpecial[] }" } }
}
},
"/tenants/{domain}/categories": {
"get": {
"tags": ["Categories"],
"summary": "Public categories",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{
"name": "entityType",
"in": "query",
"schema": {
"type": "string",
"enum": ["product", "blog", "portfolio"],
"default": "product"
}
}
],
"responses": { "200": { "description": "{ items: Category[] }" } }
}
},
"/tenants/{domain}/products": {
"get": {
"tags": ["Products"],
"summary": "List published products",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "page", "in": "query", "schema": { "type": "integer" } },
{ "name": "pageSize", "in": "query", "schema": { "type": "integer", "default": 12 } },
{ "name": "name", "in": "query", "schema": { "type": "string" } },
{ "name": "categoryId", "in": "query", "schema": { "type": "string" } },
{ "name": "brandId", "in": "query", "schema": { "type": "string" } },
{ "name": "tag", "in": "query", "schema": { "type": "string" } },
{ "name": "inStore", "in": "query", "schema": { "type": "boolean" } }
],
"responses": { "200": { "description": "{ items, total, page, pageSize }" } }
}
},
"/tenants/{domain}/products/{slug}": {
"get": {
"tags": ["Products"],
"summary": "Product by slug",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ product }" } }
}
},
"/tenants/{domain}/products/{slug}/variations": {
"get": {
"tags": ["Products"],
"summary": "Product variation options",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ variations }" } }
}
},
"/tenants/{domain}/products/{slug}/technical-info": {
"get": {
"tags": ["Products"],
"summary": "Product technical specs",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ form, values }" } }
}
},
"/tenants/{domain}/store-items": {
"get": {
"tags": ["Store"],
"summary": "List sellable variants",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "page", "in": "query", "schema": { "type": "integer" } },
{ "name": "pageSize", "in": "query", "schema": { "type": "integer", "default": 20 } },
{ "name": "categoryId", "in": "query", "schema": { "type": "string" } },
{ "name": "brandId", "in": "query", "schema": { "type": "string" } },
{ "name": "productId", "in": "query", "schema": { "type": "string" } },
{ "name": "name", "in": "query", "schema": { "type": "string" } },
{ "name": "inStock", "in": "query", "schema": { "type": "boolean" } },
{ "name": "isFestival", "in": "query", "schema": { "type": "boolean" } },
{ "name": "minPrice", "in": "query", "schema": { "type": "number" } },
{ "name": "maxPrice", "in": "query", "schema": { "type": "number" } }
],
"responses": { "200": { "description": "{ items, total, page, pageSize }" } }
}
},
"/tenants/{domain}/store-items/by-product/{productId}": {
"get": {
"tags": ["Store"],
"summary": "Variants for one product",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "productId", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ storeItem }" } }
}
},
"/tenants/{domain}/store-items/{variantId}": {
"get": {
"tags": ["Store"],
"summary": "One variant",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "variantId", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ variant }" } }
}
},
"/tenants/{domain}/blogs": {
"get": {
"tags": ["Blogs"],
"summary": "List published blogs",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "page", "in": "query", "schema": { "type": "integer" } },
{ "name": "pageSize", "in": "query", "schema": { "type": "integer", "default": 12 } },
{ "name": "type", "in": "query", "schema": { "type": "string", "enum": ["news", "article", "blog"] } },
{ "name": "categoryId", "in": "query", "schema": { "type": "string" } },
{ "name": "title", "in": "query", "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ items, total, page, pageSize }" } }
}
},
"/tenants/{domain}/blogs/{slug}": {
"get": {
"tags": ["Blogs"],
"summary": "Blog by slug",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ blog }" } }
}
},
"/tenants/{domain}/blogs/{blogId}/comments": {
"get": {
"tags": ["Blogs", "Comments"],
"summary": "Approved blog comments",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "blogId", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ items }" } }
},
"post": {
"tags": ["Blogs", "Comments"],
"summary": "Submit blog comment",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "blogId", "in": "path", "required": true, "schema": { "type": "string" } }
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["authorName", "text"],
"properties": {
"authorName": { "type": "string" },
"authorEmail": { "type": "string" },
"text": { "type": "string" }
}
}
}
}
},
"responses": { "201": { "description": "{ comment, message }" } }
}
},
"/tenants/{domain}/portfolios": {
"get": {
"tags": ["Portfolios"],
"summary": "List published portfolios",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "page", "in": "query", "schema": { "type": "integer" } },
{ "name": "pageSize", "in": "query", "schema": { "type": "integer", "default": 12 } },
{ "name": "categoryId", "in": "query", "schema": { "type": "string" } },
{ "name": "title", "in": "query", "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ items, total, page, pageSize }" } }
}
},
"/tenants/{domain}/portfolios/{slug}": {
"get": {
"tags": ["Portfolios"],
"summary": "Portfolio by slug",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ portfolio }" } }
}
},
"/tenants/{domain}/portfolios/{portfolioId}/comments": {
"get": {
"tags": ["Portfolios", "Comments"],
"summary": "Approved portfolio comments",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "portfolioId", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ items }" } }
},
"post": {
"tags": ["Portfolios", "Comments"],
"summary": "Submit portfolio comment",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "portfolioId", "in": "path", "required": true, "schema": { "type": "string" } }
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["authorName", "text"],
"properties": {
"authorName": { "type": "string" },
"authorEmail": { "type": "string" },
"text": { "type": "string" }
}
}
}
}
},
"responses": { "201": { "description": "{ comment, message }" } }
}
},
"/tenants/{domain}/comments": {
"get": {
"tags": ["Comments"],
"summary": "List approved comments for any entity",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{
"name": "entityType",
"in": "query",
"required": true,
"schema": { "type": "string", "enum": ["product", "blog", "portfolio"] }
},
{ "name": "entityId", "in": "query", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ items }" } }
},
"post": {
"tags": ["Comments"],
"summary": "Submit comment (product/blog/portfolio)",
"parameters": [{ "$ref": "#/components/parameters/domain" }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["entityType", "entityId", "authorName", "text"],
"properties": {
"entityType": { "type": "string", "enum": ["product", "blog", "portfolio"] },
"entityId": { "type": "string" },
"authorName": { "type": "string" },
"authorEmail": { "type": "string" },
"text": { "type": "string" }
}
}
}
}
},
"responses": { "201": { "description": "{ comment, message }" } }
}
},
"/tenants/{domain}/expert-reviews": {
"get": {
"tags": ["Expert Reviews"],
"summary": "Approved expert reviews for a product",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "productId", "in": "query", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ items }" } }
},
"post": {
"tags": ["Expert Reviews"],
"summary": "Submit expert review",
"parameters": [{ "$ref": "#/components/parameters/domain" }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["productId", "authorName", "rate", "positivePoints", "negativePoints", "text"],
"properties": {
"productId": { "type": "string" },
"authorName": { "type": "string" },
"authorEmail": { "type": "string" },
"rate": { "type": "integer", "minimum": 1, "maximum": 10 },
"positivePoints": { "type": "array", "items": { "type": "string" } },
"negativePoints": { "type": "array", "items": { "type": "string" } },
"text": { "type": "string" }
}
}
}
}
},
"responses": { "201": { "description": "{ review, message }" } }
}
},
"/tenants/{domain}/contact-submissions": {
"post": {
"tags": ["Contact"],
"summary": "Contact form",
"parameters": [{ "$ref": "#/components/parameters/domain" }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["title", "name", "text"],
"properties": {
"title": { "type": "string" },
"name": { "type": "string" },
"email": { "type": "string" },
"cellNumber": { "type": "string" },
"text": { "type": "string" }
}
}
}
}
},
"responses": { "201": { "description": "{ submission, message }" } }
}
},
"/auth/register": {
"post": {
"tags": ["Auth"],
"summary": "Register customer on a website",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["cellNumber", "password", "firstName", "lastName", "domain"],
"properties": {
"cellNumber": { "type": "string", "description": "E.164 e.g. +98912..." },
"password": { "type": "string", "minLength": 8 },
"firstName": { "type": "string" },
"lastName": { "type": "string" },
"email": { "type": "string" },
"domain": { "type": "string", "description": "Same website apex as {domain}" }
}
}
}
}
},
"responses": { "201": { "description": "{ user, accessToken, refreshToken, registeredBusiness }" } }
}
},
"/auth/login": {
"post": {
"tags": ["Auth"],
"summary": "Login",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["cellNumber", "password"],
"properties": {
"cellNumber": { "type": "string" },
"password": { "type": "string" }
}
}
}
}
},
"responses": { "200": { "description": "{ user, accessToken, refreshToken }" } }
}
},
"/auth/refresh": {
"post": {
"tags": ["Auth"],
"summary": "Refresh tokens",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["refreshToken"],
"properties": { "refreshToken": { "type": "string" } }
}
}
}
},
"responses": { "200": { "description": "{ user, accessToken, refreshToken }" } }
}
},
"/auth/me": {
"get": {
"tags": ["Auth"],
"summary": "Current user",
"security": [{ "bearerAuth": [] }],
"responses": { "200": { "description": "{ user }" } }
}
},
"/auth/profile": {
"patch": {
"tags": ["Auth"],
"summary": "Update profile",
"security": [{ "bearerAuth": [] }],
"responses": { "200": { "description": "{ message, user }" } }
}
},
"/auth/change-password": {
"post": {
"tags": ["Auth"],
"summary": "Change password",
"security": [{ "bearerAuth": [] }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["currentPassword", "newPassword"],
"properties": {
"currentPassword": { "type": "string" },
"newPassword": { "type": "string", "minLength": 8 }
}
}
}
}
},
"responses": { "200": { "description": "{ message }" } }
}
},
"/auth/send-otp": {
"post": {
"tags": ["Auth"],
"summary": "Send OTP SMS",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["cellNumber"],
"properties": { "cellNumber": { "type": "string" } }
}
}
}
},
"responses": { "200": { "description": "{ enabled, message, expiresInSeconds? }" } }
}
},
"/auth/verify-otp": {
"post": {
"tags": ["Auth"],
"summary": "Verify OTP",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["cellNumber", "code"],
"properties": {
"cellNumber": { "type": "string" },
"code": { "type": "string", "minLength": 6, "maxLength": 6 }
}
}
}
}
},
"responses": { "200": { "description": "{ enabled, verified, message }" } }
}
},
"/auth/addresses": {
"get": {
"tags": ["Addresses"],
"summary": "List my shipping addresses",
"security": [{ "bearerAuth": [] }],
"responses": { "200": { "description": "{ items }" } }
},
"post": {
"tags": ["Addresses"],
"summary": "Create address",
"security": [{ "bearerAuth": [] }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["province", "city", "address"],
"properties": {
"label": { "type": "string" },
"province": { "type": "string" },
"city": { "type": "string" },
"address": { "type": "string" },
"postalCode": { "type": "string" },
"landline": { "type": "string" }
}
}
}
}
},
"responses": { "201": { "description": "{ address }" } }
}
},
"/auth/addresses/{addressId}": {
"patch": {
"tags": ["Addresses"],
"summary": "Update address",
"security": [{ "bearerAuth": [] }],
"parameters": [
{ "name": "addressId", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ address }" } }
},
"delete": {
"tags": ["Addresses"],
"summary": "Delete address",
"security": [{ "bearerAuth": [] }],
"parameters": [
{ "name": "addressId", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ message }" } }
}
},
"/cities": {
"get": {
"tags": ["Cities"],
"summary": "Location tree (countries / provinces / cities)",
"parameters": [
{
"name": "level",
"in": "query",
"schema": { "type": "string", "enum": ["country", "province", "city"] }
},
{ "name": "parentId", "in": "query", "schema": { "type": "string" } },
{ "name": "parentSlug", "in": "query", "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ items }" } }
}
},
"/cities/{cityId}": {
"get": {
"tags": ["Cities"],
"summary": "Get one location node",
"parameters": [
{ "name": "cityId", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ city }" } }
}
},
"/businesses/{businessId}/cart": {
"get": {
"tags": ["Cart"],
"summary": "Get cart",
"security": [{ "bearerAuth": [] }],
"parameters": [{ "$ref": "#/components/parameters/businessId" }],
"responses": { "200": { "description": "{ cart }" } }
},
"delete": {
"tags": ["Cart"],
"summary": "Clear cart",
"security": [{ "bearerAuth": [] }],
"parameters": [{ "$ref": "#/components/parameters/businessId" }],
"responses": { "200": { "description": "{ message, cart }" } }
}
},
"/businesses/{businessId}/cart/items": {
"post": {
"tags": ["Cart"],
"summary": "Add variant to cart",
"security": [{ "bearerAuth": [] }],
"parameters": [{ "$ref": "#/components/parameters/businessId" }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["storeItemVariantId"],
"properties": {
"storeItemVariantId": { "type": "string" },
"quantity": { "type": "integer", "minimum": 1, "default": 1 }
}
}
}
}
},
"responses": { "201": { "description": "{ message, cart }" } }
}
},
"/businesses/{businessId}/cart/items/{itemId}": {
"patch": {
"tags": ["Cart"],
"summary": "Update cart line quantity",
"security": [{ "bearerAuth": [] }],
"parameters": [
{ "$ref": "#/components/parameters/businessId" },
{ "name": "itemId", "in": "path", "required": true, "schema": { "type": "string" } }
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["quantity"],
"properties": { "quantity": { "type": "integer", "minimum": 1 } }
}
}
}
},
"responses": { "200": { "description": "{ message, cart }" } }
},
"delete": {
"tags": ["Cart"],
"summary": "Remove cart line",
"security": [{ "bearerAuth": [] }],
"parameters": [
{ "$ref": "#/components/parameters/businessId" },
{ "name": "itemId", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ message, cart }" } }
}
},
"/businesses/{businessId}/cart/checkout": {
"post": {
"tags": ["Cart"],
"summary": "Checkout → create order",
"security": [{ "bearerAuth": [] }],
"parameters": [{ "$ref": "#/components/parameters/businessId" }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["payment"],
"properties": {
"addressId": { "type": "string" },
"shippingAddress": {
"type": "object",
"properties": {
"province": { "type": "string" },
"city": { "type": "string" },
"address": { "type": "string" },
"postalCode": { "type": "string" },
"landline": { "type": "string" }
}
},
"customerNotes": { "type": "string" },
"payment": {
"type": "object",
"required": ["type"],
"properties": {
"type": {
"type": "string",
"enum": ["pos", "cash", "transfer", "e_payment_gate"]
},
"posType": { "type": "string" },
"transferAccount": { "type": "string" },
"transferRefNumber": { "type": "string" },
"gatewayType": { "type": "string" },
"notes": { "type": "string" }
}
}
}
}
}
}
},
"responses": { "201": { "description": "{ message, order }" } }
}
},
"/businesses/{businessId}/orders": {
"get": {
"tags": ["Orders"],
"summary": "My orders",
"security": [{ "bearerAuth": [] }],
"parameters": [
{ "$ref": "#/components/parameters/businessId" },
{ "name": "page", "in": "query", "schema": { "type": "integer" } },
{ "name": "pageSize", "in": "query", "schema": { "type": "integer", "default": 20 } },
{
"name": "status",
"in": "query",
"schema": {
"type": "string",
"enum": ["pending", "confirmed", "processing", "shipped", "delivered", "cancelled"]
}
}
],
"responses": { "200": { "description": "{ items, total, page, pageSize }" } }
}
},
"/businesses/{businessId}/orders/{orderId}": {
"get": {
"tags": ["Orders"],
"summary": "My order detail",
"security": [{ "bearerAuth": [] }],
"parameters": [
{ "$ref": "#/components/parameters/businessId" },
{ "name": "orderId", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ order }" } }
}
},
"/businesses/{businessId}/favorites": {
"get": {
"tags": ["Favorites"],
"summary": "List favorites",
"security": [{ "bearerAuth": [] }],
"parameters": [
{ "$ref": "#/components/parameters/businessId" },
{ "name": "page", "in": "query", "schema": { "type": "integer" } },
{ "name": "pageSize", "in": "query", "schema": { "type": "integer", "default": 20 } }
],
"responses": { "200": { "description": "{ items, total, page, pageSize }" } }
},
"post": {
"tags": ["Favorites"],
"summary": "Add favorite",
"security": [{ "bearerAuth": [] }],
"parameters": [{ "$ref": "#/components/parameters/businessId" }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["productId"],
"properties": { "productId": { "type": "string" } }
}
}
}
},
"responses": { "201": { "description": "{ favorite, message }" } }
}
},
"/businesses/{businessId}/favorites/{productId}": {
"delete": {
"tags": ["Favorites"],
"summary": "Remove favorite",
"security": [{ "bearerAuth": [] }],
"parameters": [
{ "$ref": "#/components/parameters/businessId" },
{ "name": "productId", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ message }" } }
}
}
}
}
+7 -1
View File
@@ -3,6 +3,12 @@
"collection": "@nestjs/schematics", "collection": "@nestjs/schematics",
"sourceRoot": "src", "sourceRoot": "src",
"compilerOptions": { "compilerOptions": {
"deleteOutDir": true "deleteOutDir": true,
"assets": [
{
"include": "website-docs/static/**/*",
"watchAssets": true
}
]
} }
} }
@@ -0,0 +1,37 @@
{
"id": "meshkee-website-api-global",
"name": "Meshkee Website API — Global",
"values": [
{
"key": "baseUrl",
"value": "https://api.meshkee.com/api/v1",
"type": "default",
"enabled": true
},
{
"key": "domain",
"value": "YOUR_WEBSITE_DOMAIN",
"type": "default",
"enabled": true
},
{
"key": "businessId",
"value": "",
"type": "default",
"enabled": true
},
{
"key": "accessToken",
"value": "",
"type": "secret",
"enabled": true
},
{
"key": "refreshToken",
"value": "",
"type": "secret",
"enabled": true
}
],
"_postman_variable_scope": "environment"
}
@@ -1,21 +1,21 @@
{ {
"info": { "info": {
"name": "Meshkee Website API", "name": "Meshkee Website API (Global)",
"description": "Customer-facing APIs for Meshkee business websites.\n\n**Quick start**\n1. Set `domain` (e.g. shop-a.local) and `baseUrl`\n2. Run **Resolve tenant** saves `businessId`\n3. Run **Login - Customer** or **Register** — saves tokens\n4. Public content: `/tenants/{domain}/...` (no auth)\n5. Cart, orders, favorites: `/businesses/{businessId}/...` (Bearer token)\n\n**Dev seed customer:** +989124444444 / password\n**Dev domain:** shop-a.local (businessId 1)", "description": "# Meshkee Website API — Global reference for all storefronts\n\nCanonical docs: https://api.meshkee.com/docs/website\n\nThis collection is **not** tied to one business. Every Meshkee website (any domain) uses the same endpoints.\n\n## How multi-tenancy works\n1. Set collection variable `domain` = the **website apex** only (e.g. `example.com`, `sanihome.ir`). Never use `www.` / `api.` / `customer.` / `business.` here.\n2. Set `baseUrl` (see below).\n3. Run **Resolve tenant** saves `businessId`.\n4. Public content: `/tenants/{{domain}}/...` (no auth).\n5. After login: cart / orders / favorites use `/businesses/{{businessId}}/...` with Bearer token.\n\n## baseUrl options (same Nest API)\n- Preferred central: `https://api.meshkee.com/api/v1`\n- Per-site alias (if DNS+SSL configured): `https://api.{{domain}}/api/v1`\n- Local: `http://localhost:3000/api/v1`\n\nTenant is always taken from the **path** (`/tenants/{domain}`), not from the API hostname.\n\n## Auth\n- Public: no header\n- Customer: `Authorization: Bearer {{accessToken}}`\n- Register requires body field `domain` = same website apex\n\n## Not in this collection\nCMS / dashboard / super-admin APIs (staff only).\n",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
}, },
"variable": [ "variable": [
{ {
"key": "baseUrl", "key": "baseUrl",
"value": "http://localhost:3000/api/v1" "value": "https://api.meshkee.com/api/v1"
}, },
{ {
"key": "domain", "key": "domain",
"value": "shop-a.local" "value": "YOUR_WEBSITE_DOMAIN"
}, },
{ {
"key": "businessId", "key": "businessId",
"value": "1" "value": ""
}, },
{ {
"key": "accessToken", "key": "accessToken",
@@ -27,35 +27,35 @@
}, },
{ {
"key": "productId", "key": "productId",
"value": "1" "value": ""
}, },
{ {
"key": "productSlug", "key": "productSlug",
"value": "meshkee-x-phone" "value": ""
}, },
{ {
"key": "blogId", "key": "blogId",
"value": "1" "value": ""
}, },
{ {
"key": "blogSlug", "key": "blogSlug",
"value": "how-to-choose-phone" "value": ""
}, },
{ {
"key": "blogCategoryId", "key": "blogCategoryId",
"value": "5" "value": ""
}, },
{ {
"key": "portfolioId", "key": "portfolioId",
"value": "1" "value": ""
}, },
{ {
"key": "portfolioSlug", "key": "portfolioSlug",
"value": "phone-launch-campaign" "value": ""
}, },
{ {
"key": "portfolioCategoryId", "key": "portfolioCategoryId",
"value": "7" "value": ""
}, },
{ {
"key": "storeItemVariantId", "key": "storeItemVariantId",
@@ -95,11 +95,11 @@
}, },
{ {
"key": "categoryId", "key": "categoryId",
"value": "2" "value": ""
}, },
{ {
"key": "brandId", "key": "brandId",
"value": "1" "value": ""
} }
], ],
"item": [ "item": [
@@ -159,7 +159,7 @@
], ],
"body": { "body": {
"mode": "raw", "mode": "raw",
"raw": "{\n \"cellNumber\": \"+989126666666\",\n \"password\": \"password123\",\n \"firstName\": \"New\",\n \"lastName\": \"Customer\",\n \"email\": \"new@example.com\",\n \"domain\": \"{{domain}}\"\n}" "raw": "{\n \"cellNumber\": \"+98XXXXXXXXXX\",\n \"password\": \"min8chars\",\n \"firstName\": \"First\",\n \"lastName\": \"Last\",\n \"email\": \"optional@example.com\",\n \"domain\": \"{{domain}}\"\n}"
}, },
"url": "{{baseUrl}}/auth/register" "url": "{{baseUrl}}/auth/register"
} }
@@ -191,7 +191,7 @@
], ],
"body": { "body": {
"mode": "raw", "mode": "raw",
"raw": "{\n \"cellNumber\": \"+989124444444\",\n \"password\": \"password\"\n}" "raw": "{\n \"cellNumber\": \"+98XXXXXXXXXX\",\n \"password\": \"YOUR_PASSWORD\"\n}"
}, },
"url": "{{baseUrl}}/auth/login" "url": "{{baseUrl}}/auth/login"
} }
@@ -1166,6 +1166,13 @@
{ {
"name": "Homepage", "name": "Homepage",
"item": [ "item": [
{
"name": "Get business info (website)",
"request": {
"method": "GET",
"url": "{{baseUrl}}/tenants/{{domain}}/website/business-info"
}
},
{ {
"name": "List category groups (website)", "name": "List category groups (website)",
"request": { "request": {
@@ -1664,4 +1671,4 @@
] ]
} }
] ]
} }
+5 -3
View File
@@ -137,9 +137,11 @@ model Domain {
sslEnabled Boolean @default(false) @map("ssl_enabled") sslEnabled Boolean @default(false) @map("ssl_enabled")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
expiresAt DateTime? @map("expires_at") @db.Timestamptz(6) expiresAt DateTime? @map("expires_at") @db.Timestamptz(6)
isActive Boolean @default(true) @map("is_active") isActive Boolean @default(true) @map("is_active")
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction) lastDeployedAt DateTime? @map("last_deployed_at") @db.Timestamptz(6)
lastDeployStatus String? @map("last_deploy_status") @db.VarChar(32)
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@index([businessId], map: "idx_domains_business_id") @@index([businessId], map: "idx_domains_business_id")
@@index([host], map: "idx_domains_host") @@index([host], map: "idx_domains_host")
+4
View File
@@ -29,6 +29,8 @@ import { ContactSubmissionsModule } from './contact-submissions/contact-submissi
import { FavoritesModule } from './favorites/favorites.module'; import { FavoritesModule } from './favorites/favorites.module';
import { BrandsModule } from './brands/brands.module'; import { BrandsModule } from './brands/brands.module';
import { WebsiteModule } from './website/website.module'; import { WebsiteModule } from './website/website.module';
import { InternalSslModule } from './internal-ssl/internal-ssl.module';
import { WebsiteDocsModule } from './website-docs/website-docs.module';
@Module({ @Module({
imports: [ imports: [
@@ -44,6 +46,7 @@ import { WebsiteModule } from './website/website.module';
StorageModule, StorageModule,
MediaModule, MediaModule,
DomainAdminModule, DomainAdminModule,
InternalSslModule,
CategoriesModule, CategoriesModule,
ProductsModule, ProductsModule,
BlogsModule, BlogsModule,
@@ -62,6 +65,7 @@ import { WebsiteModule } from './website/website.module';
FavoritesModule, FavoritesModule,
BrandsModule, BrandsModule,
WebsiteModule, WebsiteModule,
WebsiteDocsModule,
], ],
}) })
export class AppModule {} export class AppModule {}
+19 -1
View File
@@ -1,4 +1,15 @@
import { Body, Controller, Delete, Get, Param, Patch, Query, UseGuards } from '@nestjs/common'; import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { AuthUser } from '../auth/auth.types'; import { AuthUser } from '../auth/auth.types';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
@@ -18,6 +29,13 @@ export class DomainAdminController {
return this.service.list(query, user); return this.service.list(query, user);
} }
@Post(':domainId/deploy')
@HttpCode(202)
@UseGuards(JwtAuthGuard)
deploy(@Param('domainId') domainId: string, @CurrentUser() user: AuthUser) {
return this.service.deploy(domainId, user);
}
@Patch(':domainId') @Patch(':domainId')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
update( update(
+2 -1
View File
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { DomainAdminController } from './domain-admin.controller'; import { DomainAdminController } from './domain-admin.controller';
import { DomainAdminService } from './domain-admin.service'; import { DomainAdminService } from './domain-admin.service';
@Module({ @Module({
imports: [AuthModule], imports: [AuthModule, ConfigModule],
controllers: [DomainAdminController], controllers: [DomainAdminController],
providers: [DomainAdminService], providers: [DomainAdminService],
}) })
+89 -2
View File
@@ -4,7 +4,9 @@ import {
ForbiddenException, ForbiddenException,
Injectable, Injectable,
NotFoundException, NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common'; } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { AuthUser } from '../auth/auth.types'; import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service'; import { PermissionsService } from '../auth/permissions.service';
@@ -14,6 +16,11 @@ import { ListDomainsDto } from './dto/list-domains.dto';
import { ToggleSslDto } from './dto/toggle-ssl.dto'; import { ToggleSslDto } from './dto/toggle-ssl.dto';
import { UpdateDomainAdminDto } from './dto/update-domain-admin.dto'; import { UpdateDomainAdminDto } from './dto/update-domain-admin.dto';
/** Apex hosts that have a storefront deploy on the websites VM. */
const WEBSITE_DEPLOY_SLUGS: Record<string, string> = {
'ali-mohammadi.ir': 'ali-mohammadi',
};
type DomainRow = { type DomainRow = {
id: bigint; id: bigint;
host: string; host: string;
@@ -23,6 +30,8 @@ type DomainRow = {
isActive: boolean; isActive: boolean;
expiresAt: Date | null; expiresAt: Date | null;
createdAt: Date; createdAt: Date;
lastDeployedAt: Date | null;
lastDeployStatus: string | null;
}; };
@Injectable() @Injectable()
@@ -30,6 +39,7 @@ export class DomainAdminService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly permissions: PermissionsService, private readonly permissions: PermissionsService,
private readonly config: ConfigService,
) {} ) {}
private async assertSuperAdmin(actor: AuthUser) { private async assertSuperAdmin(actor: AuthUser) {
@@ -38,6 +48,10 @@ export class DomainAdminService {
} }
} }
private deploySlugForHost(host: string): string | null {
return WEBSITE_DEPLOY_SLUGS[host.trim().toLowerCase()] ?? null;
}
async list(query: ListDomainsDto, actor: AuthUser) { async list(query: ListDomainsDto, actor: AuthUser) {
await this.assertSuperAdmin(actor); await this.assertSuperAdmin(actor);
@@ -51,7 +65,7 @@ export class DomainAdminService {
${nameLike ? Prisma.sql`AND d.host ILIKE ${nameLike}` : Prisma.empty} ${nameLike ? Prisma.sql`AND d.host ILIKE ${nameLike}` : Prisma.empty}
`; `;
const [items, totalRow] = await Promise.all([ const [rows, totalRow] = await Promise.all([
this.prisma.$queryRaw<DomainRow[]>(Prisma.sql` this.prisma.$queryRaw<DomainRow[]>(Prisma.sql`
SELECT SELECT
d.id AS "id", d.id AS "id",
@@ -61,7 +75,9 @@ export class DomainAdminService {
d.ssl_enabled AS "sslEnabled", d.ssl_enabled AS "sslEnabled",
d.is_active AS "isActive", d.is_active AS "isActive",
d.expires_at AS "expiresAt", d.expires_at AS "expiresAt",
d.created_at AS "createdAt" d.created_at AS "createdAt",
d.last_deployed_at AS "lastDeployedAt",
d.last_deploy_status AS "lastDeployStatus"
FROM domains d FROM domains d
JOIN businesses b ON b.id = d.business_id JOIN businesses b ON b.id = d.business_id
${where} ${where}
@@ -75,9 +91,80 @@ export class DomainAdminService {
`), `),
]); ]);
const items = rows.map((row) => ({
...row,
deploySlug: this.deploySlugForHost(row.host),
}));
return { items, total: totalRow[0]?.total ?? 0, page, pageSize }; return { items, total: totalRow[0]?.total ?? 0, page, pageSize };
} }
async deploy(domainIdRaw: string, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const domainId = BigInt(domainIdRaw);
const domain = await this.prisma.domain.findUnique({ where: { id: domainId } });
if (!domain) {
throw new NotFoundException('Domain not found');
}
const slug = this.deploySlugForHost(domain.host);
if (!slug) {
throw new BadRequestException('This domain has no storefront deploy configured');
}
const agentUrl = this.config.get<string>('WEBSITE_DEPLOY_AGENT_URL')?.trim();
const token = this.config.get<string>('WEBSITE_DEPLOY_TOKEN')?.trim();
if (!agentUrl || !token) {
throw new ServiceUnavailableException('Website deploy agent is not configured');
}
const markDeploy = async (status: 'started' | 'failed') => {
const updated = await this.prisma.domain.update({
where: { id: domainId },
data: {
lastDeployedAt: new Date(),
lastDeployStatus: status,
},
});
return updated;
};
let response: Response;
try {
response = await fetch(agentUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Deploy-Token': token,
},
body: JSON.stringify({ slug }),
});
} catch {
await markDeploy('failed');
throw new ServiceUnavailableException('Could not reach website deploy agent');
}
if (!response.ok) {
await markDeploy('failed');
const text = await response.text().catch(() => '');
throw new ServiceUnavailableException(
`Deploy agent rejected request (${response.status})${text ? `: ${text}` : ''}`,
);
}
const updated = await markDeploy('started');
return {
status: 'accepted' as const,
slug,
host: domain.host,
message: 'Deploy started on websites server',
lastDeployedAt: updated.lastDeployedAt?.toISOString() ?? null,
lastDeployStatus: updated.lastDeployStatus,
};
}
async update(domainIdRaw: string, dto: UpdateDomainAdminDto, actor: AuthUser) { async update(domainIdRaw: string, dto: UpdateDomainAdminDto, actor: AuthUser) {
await this.assertSuperAdmin(actor); await this.assertSuperAdmin(actor);
@@ -0,0 +1,21 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { InternalSslService } from './internal-ssl.service';
import { SslSyncTokenGuard } from './ssl-sync-token.guard';
@Controller('internal/ssl')
@UseGuards(SslSyncTokenGuard)
export class InternalSslController {
constructor(private readonly service: InternalSslService) {}
/** Dashboards VPS cert sync: active apex → business./customer. hosts + admin. */
@Get('hosts')
listHosts() {
return this.service.listDashboardHosts();
}
/** API VPS cert sync: central api host + api.{apex} per active domain. */
@Get('api-hosts')
listApiHosts() {
return this.service.listApiHosts();
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { InternalSslController } from './internal-ssl.controller';
import { InternalSslService } from './internal-ssl.service';
import { SslSyncTokenGuard } from './ssl-sync-token.guard';
@Module({
controllers: [InternalSslController],
providers: [InternalSslService, SslSyncTokenGuard],
})
export class InternalSslModule {}
+56
View File
@@ -0,0 +1,56 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class InternalSslService {
constructor(
private readonly prisma: PrismaService,
private readonly config: ConfigService,
) {}
private async listActiveApexHosts(): Promise<string[]> {
const domains = await this.prisma.domain.findMany({
where: { isActive: true },
select: { host: true },
orderBy: { host: 'asc' },
});
const hosts: string[] = [];
for (const { host } of domains) {
const apex = host.trim().toLowerCase();
if (apex) hosts.push(apex);
}
return hosts;
}
/** Dashboards VPS: manage + business./customer. per active apex. */
async listDashboardHosts(): Promise<{ hosts: string[] }> {
const adminHost =
this.config.get<string>('DASHBOARD_ADMIN_HOST')?.trim() || 'manage.meshkee.com';
const hosts = new Set<string>([adminHost]);
for (const apex of await this.listActiveApexHosts()) {
hosts.add(`business.${apex}`);
hosts.add(`customer.${apex}`);
}
return { hosts: [...hosts].sort() };
}
/**
* API VPS: central api host + api.{apex} aliases for each active domain.
* Same Nest process; nginx terminates TLS for every name on this list.
*/
async listApiHosts(): Promise<{ hosts: string[] }> {
const centralHost =
this.config.get<string>('CENTRAL_API_HOST')?.trim() || 'api.meshkee.com';
const hosts = new Set<string>([centralHost]);
for (const apex of await this.listActiveApexHosts()) {
hosts.add(`api.${apex}`);
}
return { hosts: [...hosts].sort() };
}
}
+35
View File
@@ -0,0 +1,35 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { timingSafeEqual } from 'crypto';
import { Request } from 'express';
@Injectable()
export class SslSyncTokenGuard implements CanActivate {
constructor(private readonly config: ConfigService) {}
canActivate(context: ExecutionContext): boolean {
const expected = this.config.get<string>('SSL_SYNC_TOKEN')?.trim();
if (!expected) {
throw new UnauthorizedException('SSL sync is not configured');
}
const req = context.switchToHttp().getRequest<Request>();
const provided = String(req.headers['x-ssl-sync-token'] ?? '').trim();
if (!provided || provided.length !== expected.length) {
throw new UnauthorizedException('Invalid SSL sync token');
}
const a = Buffer.from(provided);
const b = Buffer.from(expected);
if (!timingSafeEqual(a, b)) {
throw new UnauthorizedException('Invalid SSL sync token');
}
return true;
}
}
+15 -3
View File
@@ -1,12 +1,21 @@
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common'; import { RequestMethod, ValidationPipe } from '@nestjs/common';
import { NestExpressApplication } from '@nestjs/platform-express';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { BigIntSerializerInterceptor } from './common/interceptors/bigint-serializer.interceptor'; import { BigIntSerializerInterceptor } from './common/interceptors/bigint-serializer.interceptor';
import { resolveWebsiteDocsRoot } from './website-docs/website-docs.paths';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create<NestExpressApplication>(AppModule);
// Public storefront docs — no /api/v1 prefix, no auth
app.setGlobalPrefix('api/v1', {
exclude: [
{ path: 'docs/website', method: RequestMethod.GET },
{ path: 'docs/website/:fileName', method: RequestMethod.GET },
],
});
app.setGlobalPrefix('api/v1');
app.useGlobalPipes( app.useGlobalPipes(
new ValidationPipe({ new ValidationPipe({
whitelist: true, whitelist: true,
@@ -20,6 +29,9 @@ async function bootstrap() {
const port = process.env.PORT ?? 3000; const port = process.env.PORT ?? 3000;
await app.listen(port); await app.listen(port);
console.log(`API running on http://localhost:${port}/api/v1`); console.log(`API running on http://localhost:${port}/api/v1`);
console.log(
`Website API docs: http://localhost:${port}/docs/website (root=${resolveWebsiteDocsRoot()})`,
);
} }
bootstrap(); bootstrap();
+43
View File
@@ -0,0 +1,43 @@
# Meshkee Website API — AI / designer brief
Copy everything below into a new AI chat when building a Meshkee storefront.
---
## System context (paste this)
You are building a **Meshkee business website (storefront)**. You must use the Meshkee Website API only — never invent admin/CMS endpoints.
**Canonical docs (always prefer these):**
- Hub: https://api.meshkee.com/docs/website
- OpenAPI: https://api.meshkee.com/docs/website/openapi.json
- Postman: https://api.meshkee.com/docs/website/Meshkee-Website-API.postman_collection.json
**API base URL:** `https://api.meshkee.com/api/v1`
(Optional alias if configured: `https://api.<WEBSITE_DOMAIN>/api/v1` — same backend.)
**This websites apex domain:** `<WEBSITE_DOMAIN>`
(example: `sanihome.ir` — no `www.`, no `api.`, no `customer.`, no `business.`)
### Hard rules
1. Resolve tenant first: `GET /tenants/<WEBSITE_DOMAIN>` → save `businessId` from `id`.
2. All public content uses `/tenants/<WEBSITE_DOMAIN>/...` (no auth).
3. Cart, orders, favorites use `/businesses/<businessId>/...` with `Authorization: Bearer <accessToken>`.
4. Customer register body must include `"domain": "<WEBSITE_DOMAIN>"`.
5. Cell numbers are E.164 (`+98912...`).
6. Do not call dashboard/CMS routes (`/businesses/.../products` write APIs, media upload, domain-admin, etc.).
### Typical bootstrap sequence
1. `GET /tenants/{domain}` → branding + `businessId`
2. Homepage: business-info, sliders, category-groups, brand-groups, store-specials
3. Catalog: categories, products, store-items
4. Auth: register/login → store tokens
5. Cart checkout with `addressId` or inline `shippingAddress` + `payment`
If OpenAPI and this brief conflict, **OpenAPI wins**.
---
## What to tell each website team
Replace `<WEBSITE_DOMAIN>` once per project. Everything else is global — same Postman, same OpenAPI, same base URL.
@@ -0,0 +1,37 @@
{
"id": "meshkee-website-api-global",
"name": "Meshkee Website API — Global",
"values": [
{
"key": "baseUrl",
"value": "https://api.meshkee.com/api/v1",
"type": "default",
"enabled": true
},
{
"key": "domain",
"value": "YOUR_WEBSITE_DOMAIN",
"type": "default",
"enabled": true
},
{
"key": "businessId",
"value": "",
"type": "default",
"enabled": true
},
{
"key": "accessToken",
"value": "",
"type": "secret",
"enabled": true
},
{
"key": "refreshToken",
"value": "",
"type": "secret",
"enabled": true
}
],
"_postman_variable_scope": "environment"
}
File diff suppressed because it is too large Load Diff
+109
View File
@@ -0,0 +1,109 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Meshkee Website API</title>
<style>
:root {
--bg: #0f1419;
--panel: #1a222c;
--text: #e8eef4;
--muted: #9aa8b5;
--accent: #3d9cf0;
--line: #2a3542;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
background: radial-gradient(1200px 600px at 10% -10%, #1b3a57 0%, var(--bg) 55%);
color: var(--text);
line-height: 1.55;
}
main {
max-width: 760px;
margin: 0 auto;
padding: 3rem 1.25rem 4rem;
}
h1 { font-size: 2rem; margin: 0 0 0.5rem; letter-spacing: -0.02em; }
h2 { font-size: 1.15rem; margin: 2rem 0 0.75rem; }
p, li { color: var(--muted); }
strong { color: var(--text); }
code {
font-family: "IBM Plex Mono", ui-monospace, monospace;
background: #0b1015;
padding: 0.1rem 0.35rem;
border-radius: 4px;
color: #cde3f7;
font-size: 0.92em;
}
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 12px;
padding: 1rem 1.1rem;
margin: 1rem 0;
}
a.btn {
display: inline-block;
margin: 0.35rem 0.5rem 0.35rem 0;
padding: 0.65rem 1rem;
border-radius: 8px;
background: var(--accent);
color: #061018;
text-decoration: none;
font-weight: 600;
}
a.btn.secondary {
background: transparent;
color: var(--text);
border: 1px solid var(--line);
}
.eyebrow { color: var(--accent); font-size: 0.85rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; }
</style>
</head>
<body>
<main>
<div class="eyebrow">Meshkee · Global storefront contract</div>
<h1>Website API</h1>
<p>
One API for <strong>every</strong> Meshkee business website. Not tied to a single domain.
Set your sites apex host (e.g. <code>sanihome.ir</code>) and reuse the same endpoints.
</p>
<div class="panel">
<p style="margin:0 0 0.75rem"><strong>Global links</strong> (share these with designers &amp; AI tools):</p>
<a class="btn" href="./openapi.json">OpenAPI JSON</a>
<a class="btn secondary" href="./Meshkee-Website-API.postman_collection.json">Download Postman</a>
<a class="btn secondary" href="./AI_PROMPT.md">AI prompt</a>
</div>
<h2>Base URL</h2>
<p><code>https://api.meshkee.com/api/v1</code></p>
<p>Optional per-site alias (same backend): <code>https://api.&lt;domain&gt;/api/v1</code></p>
<h2>How tenants work</h2>
<ol>
<li>Variable <code>domain</code> = website apex only (no <code>www</code>/<code>api</code>/<code>customer</code>/<code>business</code>).</li>
<li><code>GET /tenants/{domain}</code><code>businessId</code>.</li>
<li>Public pages: <code>/tenants/{domain}/...</code> (no auth).</li>
<li>Cart / orders / favorites: <code>/businesses/{businessId}/...</code> + Bearer JWT.</li>
</ol>
<h2>For a new website AI / designer</h2>
<ol>
<li>Open <a href="./AI_PROMPT.md">AI_PROMPT.md</a> and paste it into the AI chat.</li>
<li>Replace <code>&lt;WEBSITE_DOMAIN&gt;</code> with that sites apex.</li>
<li>Import the Postman collection (set <code>domain</code>, run Resolve tenant).</li>
<li>Or feed <code>openapi.json</code> to the AI / codegen tool.</li>
</ol>
<h2>Import Postman</h2>
<p>
Postman → Import → Link → paste<br />
<code>https://api.meshkee.com/docs/website/Meshkee-Website-API.postman_collection.json</code>
</p>
</main>
</body>
</html>
+901
View File
@@ -0,0 +1,901 @@
{
"openapi": "3.0.3",
"info": {
"title": "Meshkee Website API",
"version": "1.0.0",
"description": "Global storefront API for every Meshkee business website.\n\n**Not domain-specific.** Replace `{domain}` with the website apex (e.g. `sanihome.ir`).\n\n**Base URL:** `https://api.meshkee.com/api/v1` (or `https://api.{domain}/api/v1` if that alias is configured).\n\n**Tenant rule:** public content uses `/tenants/{domain}/...`. After login, cart/orders/favorites use `/businesses/{businessId}/...` with Bearer JWT.\n\n**Docs:** https://api.meshkee.com/docs/website"
},
"servers": [
{
"url": "https://api.meshkee.com/api/v1",
"description": "Production (central) — use this for all websites"
},
{
"url": "https://api.{domain}/api/v1",
"description": "Optional per-site alias (same backend). {domain} = website apex",
"variables": {
"domain": {
"default": "example.com"
}
}
}
],
"tags": [
{ "name": "Tenant" },
{ "name": "Homepage" },
{ "name": "Categories" },
{ "name": "Products" },
{ "name": "Store" },
{ "name": "Blogs" },
{ "name": "Portfolios" },
{ "name": "Comments" },
{ "name": "Expert Reviews" },
{ "name": "Contact" },
{ "name": "Auth" },
{ "name": "Addresses" },
{ "name": "Cities" },
{ "name": "Cart" },
{ "name": "Orders" },
{ "name": "Favorites" }
],
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
}
},
"parameters": {
"domain": {
"name": "domain",
"in": "path",
"required": true,
"description": "Website apex host only (e.g. sanihome.ir). No www/api/customer/business prefix.",
"schema": { "type": "string", "example": "example.com" }
},
"businessId": {
"name": "businessId",
"in": "path",
"required": true,
"description": "From GET /tenants/{domain} → id",
"schema": { "type": "string" }
}
}
},
"paths": {
"/tenants/{domain}": {
"get": {
"tags": ["Tenant"],
"summary": "Resolve website domain → business",
"parameters": [{ "$ref": "#/components/parameters/domain" }],
"responses": {
"200": {
"description": "Business branding",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"id": { "type": "string" },
"name": { "type": "string" },
"nameFa": { "type": "string" },
"slug": { "type": "string" },
"domain": { "type": "string" },
"primaryColor": { "type": "string", "nullable": true },
"logoUrl": { "type": "string", "nullable": true },
"faviconUrl": { "type": "string", "nullable": true }
}
}
}
}
}
}
}
},
"/tenants/{domain}/website/business-info": {
"get": {
"tags": ["Homepage"],
"summary": "About, contacts, addresses, social",
"parameters": [{ "$ref": "#/components/parameters/domain" }],
"responses": { "200": { "description": "Business public profile" } }
}
},
"/tenants/{domain}/website/sliders": {
"get": {
"tags": ["Homepage"],
"summary": "Homepage sliders + slides",
"parameters": [{ "$ref": "#/components/parameters/domain" }],
"responses": { "200": { "description": "{ items: Slider[] }" } }
}
},
"/tenants/{domain}/website/category-groups": {
"get": {
"tags": ["Homepage"],
"summary": "Homepage category groups",
"parameters": [{ "$ref": "#/components/parameters/domain" }],
"responses": { "200": { "description": "{ items: CategoryGroup[] }" } }
}
},
"/tenants/{domain}/website/brand-groups": {
"get": {
"tags": ["Homepage"],
"summary": "Homepage brand groups",
"parameters": [{ "$ref": "#/components/parameters/domain" }],
"responses": { "200": { "description": "{ items: BrandGroup[] }" } }
}
},
"/tenants/{domain}/store-specials": {
"get": {
"tags": ["Homepage", "Store"],
"summary": "Active store specials",
"parameters": [{ "$ref": "#/components/parameters/domain" }],
"responses": { "200": { "description": "{ items: StoreSpecial[] }" } }
}
},
"/tenants/{domain}/categories": {
"get": {
"tags": ["Categories"],
"summary": "Public categories",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{
"name": "entityType",
"in": "query",
"schema": {
"type": "string",
"enum": ["product", "blog", "portfolio"],
"default": "product"
}
}
],
"responses": { "200": { "description": "{ items: Category[] }" } }
}
},
"/tenants/{domain}/products": {
"get": {
"tags": ["Products"],
"summary": "List published products",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "page", "in": "query", "schema": { "type": "integer" } },
{ "name": "pageSize", "in": "query", "schema": { "type": "integer", "default": 12 } },
{ "name": "name", "in": "query", "schema": { "type": "string" } },
{ "name": "categoryId", "in": "query", "schema": { "type": "string" } },
{ "name": "brandId", "in": "query", "schema": { "type": "string" } },
{ "name": "tag", "in": "query", "schema": { "type": "string" } },
{ "name": "inStore", "in": "query", "schema": { "type": "boolean" } }
],
"responses": { "200": { "description": "{ items, total, page, pageSize }" } }
}
},
"/tenants/{domain}/products/{slug}": {
"get": {
"tags": ["Products"],
"summary": "Product by slug",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ product }" } }
}
},
"/tenants/{domain}/products/{slug}/variations": {
"get": {
"tags": ["Products"],
"summary": "Product variation options",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ variations }" } }
}
},
"/tenants/{domain}/products/{slug}/technical-info": {
"get": {
"tags": ["Products"],
"summary": "Product technical specs",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ form, values }" } }
}
},
"/tenants/{domain}/store-items": {
"get": {
"tags": ["Store"],
"summary": "List sellable variants",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "page", "in": "query", "schema": { "type": "integer" } },
{ "name": "pageSize", "in": "query", "schema": { "type": "integer", "default": 20 } },
{ "name": "categoryId", "in": "query", "schema": { "type": "string" } },
{ "name": "brandId", "in": "query", "schema": { "type": "string" } },
{ "name": "productId", "in": "query", "schema": { "type": "string" } },
{ "name": "name", "in": "query", "schema": { "type": "string" } },
{ "name": "inStock", "in": "query", "schema": { "type": "boolean" } },
{ "name": "isFestival", "in": "query", "schema": { "type": "boolean" } },
{ "name": "minPrice", "in": "query", "schema": { "type": "number" } },
{ "name": "maxPrice", "in": "query", "schema": { "type": "number" } }
],
"responses": { "200": { "description": "{ items, total, page, pageSize }" } }
}
},
"/tenants/{domain}/store-items/by-product/{productId}": {
"get": {
"tags": ["Store"],
"summary": "Variants for one product",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "productId", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ storeItem }" } }
}
},
"/tenants/{domain}/store-items/{variantId}": {
"get": {
"tags": ["Store"],
"summary": "One variant",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "variantId", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ variant }" } }
}
},
"/tenants/{domain}/blogs": {
"get": {
"tags": ["Blogs"],
"summary": "List published blogs",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "page", "in": "query", "schema": { "type": "integer" } },
{ "name": "pageSize", "in": "query", "schema": { "type": "integer", "default": 12 } },
{ "name": "type", "in": "query", "schema": { "type": "string", "enum": ["news", "article", "blog"] } },
{ "name": "categoryId", "in": "query", "schema": { "type": "string" } },
{ "name": "title", "in": "query", "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ items, total, page, pageSize }" } }
}
},
"/tenants/{domain}/blogs/{slug}": {
"get": {
"tags": ["Blogs"],
"summary": "Blog by slug",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ blog }" } }
}
},
"/tenants/{domain}/blogs/{blogId}/comments": {
"get": {
"tags": ["Blogs", "Comments"],
"summary": "Approved blog comments",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "blogId", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ items }" } }
},
"post": {
"tags": ["Blogs", "Comments"],
"summary": "Submit blog comment",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "blogId", "in": "path", "required": true, "schema": { "type": "string" } }
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["authorName", "text"],
"properties": {
"authorName": { "type": "string" },
"authorEmail": { "type": "string" },
"text": { "type": "string" }
}
}
}
}
},
"responses": { "201": { "description": "{ comment, message }" } }
}
},
"/tenants/{domain}/portfolios": {
"get": {
"tags": ["Portfolios"],
"summary": "List published portfolios",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "page", "in": "query", "schema": { "type": "integer" } },
{ "name": "pageSize", "in": "query", "schema": { "type": "integer", "default": 12 } },
{ "name": "categoryId", "in": "query", "schema": { "type": "string" } },
{ "name": "title", "in": "query", "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ items, total, page, pageSize }" } }
}
},
"/tenants/{domain}/portfolios/{slug}": {
"get": {
"tags": ["Portfolios"],
"summary": "Portfolio by slug",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ portfolio }" } }
}
},
"/tenants/{domain}/portfolios/{portfolioId}/comments": {
"get": {
"tags": ["Portfolios", "Comments"],
"summary": "Approved portfolio comments",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "portfolioId", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ items }" } }
},
"post": {
"tags": ["Portfolios", "Comments"],
"summary": "Submit portfolio comment",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "portfolioId", "in": "path", "required": true, "schema": { "type": "string" } }
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["authorName", "text"],
"properties": {
"authorName": { "type": "string" },
"authorEmail": { "type": "string" },
"text": { "type": "string" }
}
}
}
}
},
"responses": { "201": { "description": "{ comment, message }" } }
}
},
"/tenants/{domain}/comments": {
"get": {
"tags": ["Comments"],
"summary": "List approved comments for any entity",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{
"name": "entityType",
"in": "query",
"required": true,
"schema": { "type": "string", "enum": ["product", "blog", "portfolio"] }
},
{ "name": "entityId", "in": "query", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ items }" } }
},
"post": {
"tags": ["Comments"],
"summary": "Submit comment (product/blog/portfolio)",
"parameters": [{ "$ref": "#/components/parameters/domain" }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["entityType", "entityId", "authorName", "text"],
"properties": {
"entityType": { "type": "string", "enum": ["product", "blog", "portfolio"] },
"entityId": { "type": "string" },
"authorName": { "type": "string" },
"authorEmail": { "type": "string" },
"text": { "type": "string" }
}
}
}
}
},
"responses": { "201": { "description": "{ comment, message }" } }
}
},
"/tenants/{domain}/expert-reviews": {
"get": {
"tags": ["Expert Reviews"],
"summary": "Approved expert reviews for a product",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "productId", "in": "query", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ items }" } }
},
"post": {
"tags": ["Expert Reviews"],
"summary": "Submit expert review",
"parameters": [{ "$ref": "#/components/parameters/domain" }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["productId", "authorName", "rate", "positivePoints", "negativePoints", "text"],
"properties": {
"productId": { "type": "string" },
"authorName": { "type": "string" },
"authorEmail": { "type": "string" },
"rate": { "type": "integer", "minimum": 1, "maximum": 10 },
"positivePoints": { "type": "array", "items": { "type": "string" } },
"negativePoints": { "type": "array", "items": { "type": "string" } },
"text": { "type": "string" }
}
}
}
}
},
"responses": { "201": { "description": "{ review, message }" } }
}
},
"/tenants/{domain}/contact-submissions": {
"post": {
"tags": ["Contact"],
"summary": "Contact form",
"parameters": [{ "$ref": "#/components/parameters/domain" }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["title", "name", "text"],
"properties": {
"title": { "type": "string" },
"name": { "type": "string" },
"email": { "type": "string" },
"cellNumber": { "type": "string" },
"text": { "type": "string" }
}
}
}
}
},
"responses": { "201": { "description": "{ submission, message }" } }
}
},
"/auth/register": {
"post": {
"tags": ["Auth"],
"summary": "Register customer on a website",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["cellNumber", "password", "firstName", "lastName", "domain"],
"properties": {
"cellNumber": { "type": "string", "description": "E.164 e.g. +98912..." },
"password": { "type": "string", "minLength": 8 },
"firstName": { "type": "string" },
"lastName": { "type": "string" },
"email": { "type": "string" },
"domain": { "type": "string", "description": "Same website apex as {domain}" }
}
}
}
}
},
"responses": { "201": { "description": "{ user, accessToken, refreshToken, registeredBusiness }" } }
}
},
"/auth/login": {
"post": {
"tags": ["Auth"],
"summary": "Login",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["cellNumber", "password"],
"properties": {
"cellNumber": { "type": "string" },
"password": { "type": "string" }
}
}
}
}
},
"responses": { "200": { "description": "{ user, accessToken, refreshToken }" } }
}
},
"/auth/refresh": {
"post": {
"tags": ["Auth"],
"summary": "Refresh tokens",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["refreshToken"],
"properties": { "refreshToken": { "type": "string" } }
}
}
}
},
"responses": { "200": { "description": "{ user, accessToken, refreshToken }" } }
}
},
"/auth/me": {
"get": {
"tags": ["Auth"],
"summary": "Current user",
"security": [{ "bearerAuth": [] }],
"responses": { "200": { "description": "{ user }" } }
}
},
"/auth/profile": {
"patch": {
"tags": ["Auth"],
"summary": "Update profile",
"security": [{ "bearerAuth": [] }],
"responses": { "200": { "description": "{ message, user }" } }
}
},
"/auth/change-password": {
"post": {
"tags": ["Auth"],
"summary": "Change password",
"security": [{ "bearerAuth": [] }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["currentPassword", "newPassword"],
"properties": {
"currentPassword": { "type": "string" },
"newPassword": { "type": "string", "minLength": 8 }
}
}
}
}
},
"responses": { "200": { "description": "{ message }" } }
}
},
"/auth/send-otp": {
"post": {
"tags": ["Auth"],
"summary": "Send OTP SMS",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["cellNumber"],
"properties": { "cellNumber": { "type": "string" } }
}
}
}
},
"responses": { "200": { "description": "{ enabled, message, expiresInSeconds? }" } }
}
},
"/auth/verify-otp": {
"post": {
"tags": ["Auth"],
"summary": "Verify OTP",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["cellNumber", "code"],
"properties": {
"cellNumber": { "type": "string" },
"code": { "type": "string", "minLength": 6, "maxLength": 6 }
}
}
}
}
},
"responses": { "200": { "description": "{ enabled, verified, message }" } }
}
},
"/auth/addresses": {
"get": {
"tags": ["Addresses"],
"summary": "List my shipping addresses",
"security": [{ "bearerAuth": [] }],
"responses": { "200": { "description": "{ items }" } }
},
"post": {
"tags": ["Addresses"],
"summary": "Create address",
"security": [{ "bearerAuth": [] }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["province", "city", "address"],
"properties": {
"label": { "type": "string" },
"province": { "type": "string" },
"city": { "type": "string" },
"address": { "type": "string" },
"postalCode": { "type": "string" },
"landline": { "type": "string" }
}
}
}
}
},
"responses": { "201": { "description": "{ address }" } }
}
},
"/auth/addresses/{addressId}": {
"patch": {
"tags": ["Addresses"],
"summary": "Update address",
"security": [{ "bearerAuth": [] }],
"parameters": [
{ "name": "addressId", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ address }" } }
},
"delete": {
"tags": ["Addresses"],
"summary": "Delete address",
"security": [{ "bearerAuth": [] }],
"parameters": [
{ "name": "addressId", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ message }" } }
}
},
"/cities": {
"get": {
"tags": ["Cities"],
"summary": "Location tree (countries / provinces / cities)",
"parameters": [
{
"name": "level",
"in": "query",
"schema": { "type": "string", "enum": ["country", "province", "city"] }
},
{ "name": "parentId", "in": "query", "schema": { "type": "string" } },
{ "name": "parentSlug", "in": "query", "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ items }" } }
}
},
"/cities/{cityId}": {
"get": {
"tags": ["Cities"],
"summary": "Get one location node",
"parameters": [
{ "name": "cityId", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ city }" } }
}
},
"/businesses/{businessId}/cart": {
"get": {
"tags": ["Cart"],
"summary": "Get cart",
"security": [{ "bearerAuth": [] }],
"parameters": [{ "$ref": "#/components/parameters/businessId" }],
"responses": { "200": { "description": "{ cart }" } }
},
"delete": {
"tags": ["Cart"],
"summary": "Clear cart",
"security": [{ "bearerAuth": [] }],
"parameters": [{ "$ref": "#/components/parameters/businessId" }],
"responses": { "200": { "description": "{ message, cart }" } }
}
},
"/businesses/{businessId}/cart/items": {
"post": {
"tags": ["Cart"],
"summary": "Add variant to cart",
"security": [{ "bearerAuth": [] }],
"parameters": [{ "$ref": "#/components/parameters/businessId" }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["storeItemVariantId"],
"properties": {
"storeItemVariantId": { "type": "string" },
"quantity": { "type": "integer", "minimum": 1, "default": 1 }
}
}
}
}
},
"responses": { "201": { "description": "{ message, cart }" } }
}
},
"/businesses/{businessId}/cart/items/{itemId}": {
"patch": {
"tags": ["Cart"],
"summary": "Update cart line quantity",
"security": [{ "bearerAuth": [] }],
"parameters": [
{ "$ref": "#/components/parameters/businessId" },
{ "name": "itemId", "in": "path", "required": true, "schema": { "type": "string" } }
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["quantity"],
"properties": { "quantity": { "type": "integer", "minimum": 1 } }
}
}
}
},
"responses": { "200": { "description": "{ message, cart }" } }
},
"delete": {
"tags": ["Cart"],
"summary": "Remove cart line",
"security": [{ "bearerAuth": [] }],
"parameters": [
{ "$ref": "#/components/parameters/businessId" },
{ "name": "itemId", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ message, cart }" } }
}
},
"/businesses/{businessId}/cart/checkout": {
"post": {
"tags": ["Cart"],
"summary": "Checkout → create order",
"security": [{ "bearerAuth": [] }],
"parameters": [{ "$ref": "#/components/parameters/businessId" }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["payment"],
"properties": {
"addressId": { "type": "string" },
"shippingAddress": {
"type": "object",
"properties": {
"province": { "type": "string" },
"city": { "type": "string" },
"address": { "type": "string" },
"postalCode": { "type": "string" },
"landline": { "type": "string" }
}
},
"customerNotes": { "type": "string" },
"payment": {
"type": "object",
"required": ["type"],
"properties": {
"type": {
"type": "string",
"enum": ["pos", "cash", "transfer", "e_payment_gate"]
},
"posType": { "type": "string" },
"transferAccount": { "type": "string" },
"transferRefNumber": { "type": "string" },
"gatewayType": { "type": "string" },
"notes": { "type": "string" }
}
}
}
}
}
}
},
"responses": { "201": { "description": "{ message, order }" } }
}
},
"/businesses/{businessId}/orders": {
"get": {
"tags": ["Orders"],
"summary": "My orders",
"security": [{ "bearerAuth": [] }],
"parameters": [
{ "$ref": "#/components/parameters/businessId" },
{ "name": "page", "in": "query", "schema": { "type": "integer" } },
{ "name": "pageSize", "in": "query", "schema": { "type": "integer", "default": 20 } },
{
"name": "status",
"in": "query",
"schema": {
"type": "string",
"enum": ["pending", "confirmed", "processing", "shipped", "delivered", "cancelled"]
}
}
],
"responses": { "200": { "description": "{ items, total, page, pageSize }" } }
}
},
"/businesses/{businessId}/orders/{orderId}": {
"get": {
"tags": ["Orders"],
"summary": "My order detail",
"security": [{ "bearerAuth": [] }],
"parameters": [
{ "$ref": "#/components/parameters/businessId" },
{ "name": "orderId", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ order }" } }
}
},
"/businesses/{businessId}/favorites": {
"get": {
"tags": ["Favorites"],
"summary": "List favorites",
"security": [{ "bearerAuth": [] }],
"parameters": [
{ "$ref": "#/components/parameters/businessId" },
{ "name": "page", "in": "query", "schema": { "type": "integer" } },
{ "name": "pageSize", "in": "query", "schema": { "type": "integer", "default": 20 } }
],
"responses": { "200": { "description": "{ items, total, page, pageSize }" } }
},
"post": {
"tags": ["Favorites"],
"summary": "Add favorite",
"security": [{ "bearerAuth": [] }],
"parameters": [{ "$ref": "#/components/parameters/businessId" }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["productId"],
"properties": { "productId": { "type": "string" } }
}
}
}
},
"responses": { "201": { "description": "{ favorite, message }" } }
}
},
"/businesses/{businessId}/favorites/{productId}": {
"delete": {
"tags": ["Favorites"],
"summary": "Remove favorite",
"security": [{ "bearerAuth": [] }],
"parameters": [
{ "$ref": "#/components/parameters/businessId" },
{ "name": "productId", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ message }" } }
}
}
}
}
@@ -0,0 +1,58 @@
import {
Controller,
Get,
NotFoundException,
Param,
Res,
} from '@nestjs/common';
import type { Response } from 'express';
import { createReadStream, existsSync } from 'fs';
import { basename, extname, join } from 'path';
import { resolveWebsiteDocsRoot } from './website-docs.paths';
const ALLOWED_FILES = new Set([
'index.html',
'openapi.json',
'AI_PROMPT.md',
'Meshkee-Website-API.postman_collection.json',
'Meshkee-Website-API.global.postman_environment.json',
]);
const CONTENT_TYPES: Record<string, string> = {
'.html': 'text/html; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.md': 'text/markdown; charset=utf-8',
};
@Controller('docs/website')
export class WebsiteDocsController {
private readonly root = resolveWebsiteDocsRoot();
@Get()
getIndex(@Res() res: Response) {
return this.sendFile(res, 'index.html');
}
@Get(':fileName')
getFile(@Param('fileName') fileName: string, @Res() res: Response) {
const safe = basename(fileName);
if (!ALLOWED_FILES.has(safe)) {
throw new NotFoundException(`Unknown docs file: ${fileName}`);
}
return this.sendFile(res, safe);
}
private sendFile(res: Response, fileName: string) {
const filePath = join(this.root, fileName);
if (!existsSync(filePath)) {
throw new NotFoundException(
`Website docs not found on server (${fileName}). Deploy docs/website-api/ with the API.`,
);
}
const type = CONTENT_TYPES[extname(fileName)] ?? 'application/octet-stream';
res.setHeader('Content-Type', type);
res.setHeader('Cache-Control', 'public, max-age=300');
createReadStream(filePath).pipe(res);
}
}
+7
View File
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { WebsiteDocsController } from './website-docs.controller';
@Module({
controllers: [WebsiteDocsController],
})
export class WebsiteDocsModule {}
+19
View File
@@ -0,0 +1,19 @@
import { existsSync } from 'fs';
import { join } from 'path';
/** Resolve docs folder in prod (`dist/website-docs/static`) and repo `docs/website-api`. */
export function resolveWebsiteDocsRoot(): string {
const candidates = [
join(__dirname, 'static'),
join(process.cwd(), 'docs', 'website-api'),
join(process.cwd(), 'src', 'website-docs', 'static'),
];
for (const candidate of candidates) {
if (existsSync(join(candidate, 'index.html'))) {
return candidate;
}
}
return candidates[0];
}