mirror of
https://git.meshkee.com/Meshkee/backend.git
synced 2026-08-11 22:30:59 +04:30
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:
co-authored by
Cursor
parent
bb59d5e9ba
commit
016cc15bf0
@@ -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`.
|
||||
@@ -33,3 +33,4 @@ Do **not** use Prisma Migrate. SQL migrations are authoritative.
|
||||
- Minimize diff scope; match existing module patterns
|
||||
- Reuse existing services/guards instead of reimplementing
|
||||
- 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)
|
||||
|
||||
@@ -42,3 +42,14 @@ OPENAI_API_KEY=
|
||||
OPENAI_MODEL=gpt-4o-mini
|
||||
|
||||
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
@@ -6,7 +6,9 @@ App path on server: `/opt/meshkee/app`
|
||||
|
||||
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
|
||||
|
||||
@@ -126,12 +128,28 @@ curl -s http://127.0.0.1:3000/api/v1/ | head
|
||||
|
||||
## 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`:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
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;
|
||||
|
||||
@@ -146,29 +164,68 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
Enable and get a certificate:
|
||||
Enable and get certificates:
|
||||
|
||||
```bash
|
||||
sudo ln -sf /etc/nginx/sites-available/meshkee-api /etc/nginx/sites-enabled/
|
||||
sudo rm -f /etc/nginx/sites-enabled/default
|
||||
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
|
||||
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
|
||||
git pull
|
||||
./database/migrate.sh # if there are new SQL migrations
|
||||
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
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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 website’s 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
@@ -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 site’s 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 & 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.<domain>/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><WEBSITE_DOMAIN></code> with that site’s 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>
|
||||
@@ -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
@@ -3,6 +3,12 @@
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"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": {
|
||||
"name": "Meshkee Website API",
|
||||
"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)",
|
||||
"name": "Meshkee Website API (Global)",
|
||||
"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"
|
||||
},
|
||||
"variable": [
|
||||
{
|
||||
"key": "baseUrl",
|
||||
"value": "http://localhost:3000/api/v1"
|
||||
"value": "https://api.meshkee.com/api/v1"
|
||||
},
|
||||
{
|
||||
"key": "domain",
|
||||
"value": "shop-a.local"
|
||||
"value": "YOUR_WEBSITE_DOMAIN"
|
||||
},
|
||||
{
|
||||
"key": "businessId",
|
||||
"value": "1"
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"key": "accessToken",
|
||||
@@ -27,35 +27,35 @@
|
||||
},
|
||||
{
|
||||
"key": "productId",
|
||||
"value": "1"
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"key": "productSlug",
|
||||
"value": "meshkee-x-phone"
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"key": "blogId",
|
||||
"value": "1"
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"key": "blogSlug",
|
||||
"value": "how-to-choose-phone"
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"key": "blogCategoryId",
|
||||
"value": "5"
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"key": "portfolioId",
|
||||
"value": "1"
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"key": "portfolioSlug",
|
||||
"value": "phone-launch-campaign"
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"key": "portfolioCategoryId",
|
||||
"value": "7"
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"key": "storeItemVariantId",
|
||||
@@ -95,11 +95,11 @@
|
||||
},
|
||||
{
|
||||
"key": "categoryId",
|
||||
"value": "2"
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"key": "brandId",
|
||||
"value": "1"
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"item": [
|
||||
@@ -159,7 +159,7 @@
|
||||
],
|
||||
"body": {
|
||||
"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"
|
||||
}
|
||||
@@ -191,7 +191,7 @@
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"cellNumber\": \"+989124444444\",\n \"password\": \"password\"\n}"
|
||||
"raw": "{\n \"cellNumber\": \"+98XXXXXXXXXX\",\n \"password\": \"YOUR_PASSWORD\"\n}"
|
||||
},
|
||||
"url": "{{baseUrl}}/auth/login"
|
||||
}
|
||||
@@ -1166,6 +1166,13 @@
|
||||
{
|
||||
"name": "Homepage",
|
||||
"item": [
|
||||
{
|
||||
"name": "Get business info (website)",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "{{baseUrl}}/tenants/{{domain}}/website/business-info"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "List category groups (website)",
|
||||
"request": {
|
||||
|
||||
@@ -137,9 +137,11 @@ model Domain {
|
||||
sslEnabled Boolean @default(false) @map("ssl_enabled")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
expiresAt DateTime? @map("expires_at") @db.Timestamptz(6)
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
expiresAt DateTime? @map("expires_at") @db.Timestamptz(6)
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
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([host], map: "idx_domains_host")
|
||||
|
||||
@@ -29,6 +29,8 @@ import { ContactSubmissionsModule } from './contact-submissions/contact-submissi
|
||||
import { FavoritesModule } from './favorites/favorites.module';
|
||||
import { BrandsModule } from './brands/brands.module';
|
||||
import { WebsiteModule } from './website/website.module';
|
||||
import { InternalSslModule } from './internal-ssl/internal-ssl.module';
|
||||
import { WebsiteDocsModule } from './website-docs/website-docs.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -44,6 +46,7 @@ import { WebsiteModule } from './website/website.module';
|
||||
StorageModule,
|
||||
MediaModule,
|
||||
DomainAdminModule,
|
||||
InternalSslModule,
|
||||
CategoriesModule,
|
||||
ProductsModule,
|
||||
BlogsModule,
|
||||
@@ -62,6 +65,7 @@ import { WebsiteModule } from './website/website.module';
|
||||
FavoritesModule,
|
||||
BrandsModule,
|
||||
WebsiteModule,
|
||||
WebsiteDocsModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -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 { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
@@ -18,6 +29,13 @@ export class DomainAdminController {
|
||||
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')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
update(
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { DomainAdminController } from './domain-admin.controller';
|
||||
import { DomainAdminService } from './domain-admin.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
imports: [AuthModule, ConfigModule],
|
||||
controllers: [DomainAdminController],
|
||||
providers: [DomainAdminService],
|
||||
})
|
||||
|
||||
@@ -4,7 +4,9 @@ import {
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
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 { 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 = {
|
||||
id: bigint;
|
||||
host: string;
|
||||
@@ -23,6 +30,8 @@ type DomainRow = {
|
||||
isActive: boolean;
|
||||
expiresAt: Date | null;
|
||||
createdAt: Date;
|
||||
lastDeployedAt: Date | null;
|
||||
lastDeployStatus: string | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -30,6 +39,7 @@ export class DomainAdminService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
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) {
|
||||
await this.assertSuperAdmin(actor);
|
||||
|
||||
@@ -51,7 +65,7 @@ export class DomainAdminService {
|
||||
${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`
|
||||
SELECT
|
||||
d.id AS "id",
|
||||
@@ -61,7 +75,9 @@ export class DomainAdminService {
|
||||
d.ssl_enabled AS "sslEnabled",
|
||||
d.is_active AS "isActive",
|
||||
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
|
||||
JOIN businesses b ON b.id = d.business_id
|
||||
${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 };
|
||||
}
|
||||
|
||||
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) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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() };
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -1,12 +1,21 @@
|
||||
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 { BigIntSerializerInterceptor } from './common/interceptors/bigint-serializer.interceptor';
|
||||
import { resolveWebsiteDocsRoot } from './website-docs/website-docs.paths';
|
||||
|
||||
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(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
@@ -20,6 +29,9 @@ async function bootstrap() {
|
||||
const port = process.env.PORT ?? 3000;
|
||||
await app.listen(port);
|
||||
console.log(`API running on http://localhost:${port}/api/v1`);
|
||||
console.log(
|
||||
`Website API docs: http://localhost:${port}/docs/website (root=${resolveWebsiteDocsRoot()})`,
|
||||
);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
||||
@@ -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 website’s 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
@@ -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 site’s 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 & 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.<domain>/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><WEBSITE_DOMAIN></code> with that site’s 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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { WebsiteDocsController } from './website-docs.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [WebsiteDocsController],
|
||||
})
|
||||
export class WebsiteDocsModule {}
|
||||
@@ -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];
|
||||
}
|
||||
Reference in New Issue
Block a user