From 016cc15bf05de3ebd71eaef58d1be3e87a0af842 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Wed, 22 Jul 2026 21:39:51 +0330 Subject: [PATCH] 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 --- .cursor/rules/git-deploy.mdc | 37 + .cursor/rules/meshkee-project.mdc | 1 + .env.example | 11 + .../034_repair_partial_cart_orders.sql | 104 + .../migrations/035_domain_last_deploy.sql | 4 + docs/DEPLOY.md | 73 +- docs/website-api/AI_PROMPT.md | 43 + ...ebsite-API.global.postman_environment.json | 37 + ...eshkee-Website-API.postman_collection.json | 1674 +++++++++++++++++ docs/website-api/index.html | 109 ++ docs/website-api/openapi.json | 901 +++++++++ nest-cli.json | 8 +- ...ebsite-API.global.postman_environment.json | 37 + ...eshkee-Website-API.postman_collection.json | 43 +- prisma/schema.prisma | 8 +- src/app.module.ts | 4 + src/domain-admin/domain-admin.controller.ts | 20 +- src/domain-admin/domain-admin.module.ts | 3 +- src/domain-admin/domain-admin.service.ts | 91 +- src/internal-ssl/internal-ssl.controller.ts | 21 + src/internal-ssl/internal-ssl.module.ts | 10 + src/internal-ssl/internal-ssl.service.ts | 56 + src/internal-ssl/ssl-sync-token.guard.ts | 35 + src/main.ts | 18 +- src/website-docs/static/AI_PROMPT.md | 43 + ...ebsite-API.global.postman_environment.json | 37 + ...eshkee-Website-API.postman_collection.json | 1674 +++++++++++++++++ src/website-docs/static/index.html | 109 ++ src/website-docs/static/openapi.json | 901 +++++++++ src/website-docs/website-docs.controller.ts | 58 + src/website-docs/website-docs.module.ts | 7 + src/website-docs/website-docs.paths.ts | 19 + 32 files changed, 6159 insertions(+), 37 deletions(-) create mode 100644 .cursor/rules/git-deploy.mdc create mode 100644 database/migrations/034_repair_partial_cart_orders.sql create mode 100644 database/migrations/035_domain_last_deploy.sql create mode 100644 docs/website-api/AI_PROMPT.md create mode 100644 docs/website-api/Meshkee-Website-API.global.postman_environment.json create mode 100644 docs/website-api/Meshkee-Website-API.postman_collection.json create mode 100644 docs/website-api/index.html create mode 100644 docs/website-api/openapi.json create mode 100644 postman/Meshkee-Website-API.global.postman_environment.json create mode 100644 src/internal-ssl/internal-ssl.controller.ts create mode 100644 src/internal-ssl/internal-ssl.module.ts create mode 100644 src/internal-ssl/internal-ssl.service.ts create mode 100644 src/internal-ssl/ssl-sync-token.guard.ts create mode 100644 src/website-docs/static/AI_PROMPT.md create mode 100644 src/website-docs/static/Meshkee-Website-API.global.postman_environment.json create mode 100644 src/website-docs/static/Meshkee-Website-API.postman_collection.json create mode 100644 src/website-docs/static/index.html create mode 100644 src/website-docs/static/openapi.json create mode 100644 src/website-docs/website-docs.controller.ts create mode 100644 src/website-docs/website-docs.module.ts create mode 100644 src/website-docs/website-docs.paths.ts diff --git a/.cursor/rules/git-deploy.mdc b/.cursor/rules/git-deploy.mdc new file mode 100644 index 0000000..fece06b --- /dev/null +++ b/.cursor/rules/git-deploy.mdc @@ -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`. diff --git a/.cursor/rules/meshkee-project.mdc b/.cursor/rules/meshkee-project.mdc index 7882f74..04ad6d3 100644 --- a/.cursor/rules/meshkee-project.mdc +++ b/.cursor/rules/meshkee-project.mdc @@ -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) diff --git a/.env.example b/.env.example index 7bd1fb2..551c18f 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/database/migrations/034_repair_partial_cart_orders.sql b/database/migrations/034_repair_partial_cart_orders.sql new file mode 100644 index 0000000..3c0233e --- /dev/null +++ b/database/migrations/034_repair_partial_cart_orders.sql @@ -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); diff --git a/database/migrations/035_domain_last_deploy.sql b/database/migrations/035_domain_last_deploy.sql new file mode 100644 index 0000000..3c4f158 --- /dev/null +++ b/database/migrations/035_domain_last_deploy.sql @@ -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); diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 6b4cebf..650585f 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -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` | `` | 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 diff --git a/docs/website-api/AI_PROMPT.md b/docs/website-api/AI_PROMPT.md new file mode 100644 index 0000000..686b918 --- /dev/null +++ b/docs/website-api/AI_PROMPT.md @@ -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./api/v1` — same backend.) + +**This website’s apex domain:** `` +(example: `sanihome.ir` — no `www.`, no `api.`, no `customer.`, no `business.`) + +### Hard rules +1. Resolve tenant first: `GET /tenants/` → save `businessId` from `id`. +2. All public content uses `/tenants//...` (no auth). +3. Cart, orders, favorites use `/businesses//...` with `Authorization: Bearer `. +4. Customer register body must include `"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 `` once per project. Everything else is global — same Postman, same OpenAPI, same base URL. diff --git a/docs/website-api/Meshkee-Website-API.global.postman_environment.json b/docs/website-api/Meshkee-Website-API.global.postman_environment.json new file mode 100644 index 0000000..76350d6 --- /dev/null +++ b/docs/website-api/Meshkee-Website-API.global.postman_environment.json @@ -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" +} diff --git a/docs/website-api/Meshkee-Website-API.postman_collection.json b/docs/website-api/Meshkee-Website-API.postman_collection.json new file mode 100644 index 0000000..33df96f --- /dev/null +++ b/docs/website-api/Meshkee-Website-API.postman_collection.json @@ -0,0 +1,1674 @@ +{ + "info": { + "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": "https://api.meshkee.com/api/v1" + }, + { + "key": "domain", + "value": "YOUR_WEBSITE_DOMAIN" + }, + { + "key": "businessId", + "value": "" + }, + { + "key": "accessToken", + "value": "" + }, + { + "key": "refreshToken", + "value": "" + }, + { + "key": "productId", + "value": "" + }, + { + "key": "productSlug", + "value": "" + }, + { + "key": "blogId", + "value": "" + }, + { + "key": "blogSlug", + "value": "" + }, + { + "key": "blogCategoryId", + "value": "" + }, + { + "key": "portfolioId", + "value": "" + }, + { + "key": "portfolioSlug", + "value": "" + }, + { + "key": "portfolioCategoryId", + "value": "" + }, + { + "key": "storeItemVariantId", + "value": "" + }, + { + "key": "cartItemId", + "value": "" + }, + { + "key": "orderId", + "value": "" + }, + { + "key": "commentId", + "value": "" + }, + { + "key": "expertReviewId", + "value": "" + }, + { + "key": "addressId", + "value": "" + }, + { + "key": "countryId", + "value": "" + }, + { + "key": "provinceId", + "value": "" + }, + { + "key": "cityId", + "value": "" + }, + { + "key": "categoryId", + "value": "" + }, + { + "key": "brandId", + "value": "" + } + ], + "item": [ + { + "name": "Tenant", + "item": [ + { + "name": "Resolve tenant by domain", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " if (json.id) pm.collectionVariables.set(\"businessId\", String(json.id));", + "}" + ] + } + } + ], + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}" + } + } + ] + }, + { + "name": "Auth", + "item": [ + { + "name": "Register (customer on website)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.accessToken) pm.collectionVariables.set('accessToken', json.accessToken);", + " if (json.refreshToken) pm.collectionVariables.set('refreshToken', json.refreshToken);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "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" + } + }, + { + "name": "Login - Customer", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " pm.collectionVariables.set('accessToken', json.accessToken);", + " pm.collectionVariables.set('refreshToken', json.refreshToken);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"cellNumber\": \"+98XXXXXXXXXX\",\n \"password\": \"YOUR_PASSWORD\"\n}" + }, + "url": "{{baseUrl}}/auth/login" + } + }, + { + "name": "Me (current user)", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/auth/me" + } + }, + { + "name": "Refresh token", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " pm.collectionVariables.set('accessToken', json.accessToken);", + " pm.collectionVariables.set('refreshToken', json.refreshToken);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"refreshToken\": \"{{refreshToken}}\"\n}" + }, + "url": "{{baseUrl}}/auth/refresh" + } + }, + { + "name": "Send OTP (SMS disabled)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"cellNumber\": \"+989124444444\"\n}" + }, + "url": "{{baseUrl}}/auth/send-otp" + } + }, + { + "name": "Verify OTP (SMS disabled)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"cellNumber\": \"+989124444444\",\n \"code\": \"123456\"\n}" + }, + "url": "{{baseUrl}}/auth/verify-otp" + } + }, + { + "name": "Update profile", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"firstName\": \"Ali\",\n \"lastName\": \"Customer\",\n \"email\": \"ali@example.com\"\n}" + }, + "url": "{{baseUrl}}/auth/profile" + } + }, + { + "name": "Change password", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"currentPassword\": \"password\",\n \"newPassword\": \"password123\"\n}" + }, + "url": "{{baseUrl}}/auth/change-password" + } + } + ] + }, + { + "name": "Addresses", + "item": [ + { + "name": "List my addresses", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " if (json.items?.[0]?.id) pm.collectionVariables.set(\"addressId\", json.items[0].id);", + "}" + ] + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/auth/addresses" + } + }, + { + "name": "Create address", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.address?.id) pm.collectionVariables.set(\"addressId\", json.address.id);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"province\": \"Tehran\",\n \"city\": \"Tehran\",\n \"address\": \"123 Example Street\",\n \"postalCode\": \"1234567890\",\n \"landline\": \"02112345678\"\n}" + }, + "url": "{{baseUrl}}/auth/addresses" + } + }, + { + "name": "Update address", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"province\": \"Tehran\",\n \"city\": \"Tehran\",\n \"address\": \"456 Updated Street\",\n \"postalCode\": \"1234567890\"\n}" + }, + "url": "{{baseUrl}}/auth/addresses/{{addressId}}" + } + }, + { + "name": "Delete address", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/auth/addresses/{{addressId}}" + } + } + ] + }, + { + "name": "Comments", + "item": [ + { + "name": "Submit comment (website)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.comment?.id) pm.collectionVariables.set('commentId', json.comment.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"entityType\": \"product\",\n \"entityId\": \"{{productId}}\",\n \"authorName\": \"Website Visitor\",\n \"authorEmail\": \"visitor@example.com\",\n \"text\": \"Great product! Would recommend.\"\n}" + }, + "url": "{{baseUrl}}/tenants/{{domain}}/comments" + } + }, + { + "name": "List approved comments (website)", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/tenants/{{domain}}/comments?entityType=product&entityId={{productId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "tenants", + "{{domain}}", + "comments" + ], + "query": [ + { + "key": "entityType", + "value": "product" + }, + { + "key": "entityId", + "value": "{{productId}}" + } + ] + } + } + }, + { + "name": "Submit comment on blog (generic API)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.comment?.id) pm.collectionVariables.set('commentId', json.comment.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"entityType\": \"blog\",\n \"entityId\": \"{{blogId}}\",\n \"authorName\": \"Blog Reader\",\n \"authorEmail\": \"reader@example.com\",\n \"text\": \"Great article, thanks for sharing!\"\n}" + }, + "url": "{{baseUrl}}/tenants/{{domain}}/comments" + } + }, + { + "name": "List approved comments for blog (generic API)", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/tenants/{{domain}}/comments?entityType=blog&entityId={{blogId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "tenants", + "{{domain}}", + "comments" + ], + "query": [ + { + "key": "entityType", + "value": "blog" + }, + { + "key": "entityId", + "value": "{{blogId}}" + } + ] + } + } + }, + { + "name": "Submit comment on portfolio (generic API)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.comment?.id) pm.collectionVariables.set('commentId', json.comment.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"entityType\": \"portfolio\",\n \"entityId\": \"{{portfolioId}}\",\n \"authorName\": \"Portfolio Visitor\",\n \"authorEmail\": \"visitor@example.com\",\n \"text\": \"Beautiful work!\"\n}" + }, + "url": "{{baseUrl}}/tenants/{{domain}}/comments" + } + }, + { + "name": "List approved comments for portfolio (generic API)", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/tenants/{{domain}}/comments?entityType=portfolio&entityId={{portfolioId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "tenants", + "{{domain}}", + "comments" + ], + "query": [ + { + "key": "entityType", + "value": "portfolio" + }, + { + "key": "entityId", + "value": "{{portfolioId}}" + } + ] + } + } + } + ] + }, + { + "name": "Blogs", + "item": [ + { + "name": "List published blogs (website)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " if (json.items?.[0]?.id) pm.collectionVariables.set('blogId', json.items[0].id);", + " if (json.items?.[0]?.slug) pm.collectionVariables.set('blogSlug', json.items[0].slug);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/tenants/{{domain}}/blogs?page=1&pageSize=12", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "tenants", + "{{domain}}", + "blogs" + ], + "query": [ + { + "key": "page", + "value": "1" + }, + { + "key": "pageSize", + "value": "12" + }, + { + "key": "type", + "value": "", + "disabled": true + }, + { + "key": "categoryId", + "value": "", + "disabled": true + }, + { + "key": "title", + "value": "", + "disabled": true + } + ] + } + } + }, + { + "name": "Get published blog by slug (website)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/blogs/{{blogSlug}}" + } + }, + { + "name": "List blog comments (website)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/blogs/{{blogId}}/comments" + } + }, + { + "name": "Submit blog comment (website)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.comment?.id) pm.collectionVariables.set('commentId', json.comment.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"authorName\": \"Blog Reader\",\n \"authorEmail\": \"reader@example.com\",\n \"text\": \"Very helpful post!\"\n}" + }, + "url": "{{baseUrl}}/tenants/{{domain}}/blogs/{{blogId}}/comments" + } + } + ] + }, + { + "name": "Portfolios", + "item": [ + { + "name": "List published portfolios (website)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " if (json.items?.[0]?.id) pm.collectionVariables.set('portfolioId', json.items[0].id);", + " if (json.items?.[0]?.slug) pm.collectionVariables.set('portfolioSlug', json.items[0].slug);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/tenants/{{domain}}/portfolios?page=1&pageSize=12", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "tenants", + "{{domain}}", + "portfolios" + ], + "query": [ + { + "key": "page", + "value": "1" + }, + { + "key": "pageSize", + "value": "12" + }, + { + "key": "categoryId", + "value": "", + "disabled": true + }, + { + "key": "title", + "value": "", + "disabled": true + } + ] + } + } + }, + { + "name": "Get published portfolio by slug (website)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/portfolios/{{portfolioSlug}}" + } + }, + { + "name": "List portfolio comments (website)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/portfolios/{{portfolioId}}/comments" + } + }, + { + "name": "Submit portfolio comment (website)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.comment?.id) pm.collectionVariables.set('commentId', json.comment.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"authorName\": \"Portfolio Visitor\",\n \"authorEmail\": \"visitor@example.com\",\n \"text\": \"Impressive project!\"\n}" + }, + "url": "{{baseUrl}}/tenants/{{domain}}/portfolios/{{portfolioId}}/comments" + } + } + ] + }, + { + "name": "Expert Reviews", + "item": [ + { + "name": "Submit expert review (website)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.review?.id) pm.collectionVariables.set('expertReviewId', json.review.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"productId\": \"{{productId}}\",\n \"authorName\": \"Expert Reviewer\",\n \"authorEmail\": \"expert@example.com\",\n \"rate\": 8,\n \"positivePoints\": [\"Build quality\", \"Great battery life\", \"Comfortable fit\"],\n \"negativePoints\": [\"Pricey\", \"Limited colors\"],\n \"text\": \"Solid product overall with a few trade-offs worth considering.\"\n}" + }, + "url": "{{baseUrl}}/tenants/{{domain}}/expert-reviews" + } + }, + { + "name": "List approved expert reviews (website)", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/tenants/{{domain}}/expert-reviews?productId={{productId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "tenants", + "{{domain}}", + "expert-reviews" + ], + "query": [ + { + "key": "productId", + "value": "{{productId}}" + } + ] + } + } + } + ] + }, + { + "name": "Categories", + "item": [ + { + "name": "List product categories", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " if (json.items?.[0]?.id) pm.collectionVariables.set('categoryId', json.items[0].id);", + "}" + ] + } + } + ], + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/tenants/{{domain}}/categories?entityType=product", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "tenants", + "{{domain}}", + "categories" + ], + "query": [ + { + "key": "entityType", + "value": "product" + } + ] + } + } + }, + { + "name": "List blog categories", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/tenants/{{domain}}/categories?entityType=blog", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "tenants", + "{{domain}}", + "categories" + ], + "query": [ + { + "key": "entityType", + "value": "blog" + } + ] + } + } + }, + { + "name": "List portfolio categories", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/tenants/{{domain}}/categories?entityType=portfolio", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "tenants", + "{{domain}}", + "categories" + ], + "query": [ + { + "key": "entityType", + "value": "portfolio" + } + ] + } + } + } + ] + }, + { + "name": "Products", + "item": [ + { + "name": "List published products", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " if (json.items?.[0]?.id) pm.collectionVariables.set('productId', json.items[0].id);", + " if (json.items?.[0]?.slug) pm.collectionVariables.set('productSlug', json.items[0].slug);", + "}" + ] + } + } + ], + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/tenants/{{domain}}/products?page=1&pageSize=12", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "tenants", + "{{domain}}", + "products" + ], + "query": [ + { + "key": "page", + "value": "1" + }, + { + "key": "pageSize", + "value": "12" + }, + { + "key": "name", + "value": "", + "disabled": true + }, + { + "key": "categoryId", + "value": "{{categoryId}}", + "disabled": true + }, + { + "key": "brandId", + "value": "{{brandId}}", + "disabled": true + }, + { + "key": "tag", + "value": "", + "disabled": true + }, + { + "key": "inStore", + "value": "true", + "disabled": true + } + ] + } + } + }, + { + "name": "Get published product by slug", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/products/{{productSlug}}" + } + }, + { + "name": "Get product variations by slug", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/products/{{productSlug}}/variations" + } + }, + { + "name": "Get product technical info by slug", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/products/{{productSlug}}/technical-info" + } + } + ] + }, + { + "name": "Store Items", + "item": [ + { + "name": "List store item variants", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " if (json.items?.[0]?.id) pm.collectionVariables.set('storeItemVariantId', json.items[0].id);", + "}" + ] + } + } + ], + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/tenants/{{domain}}/store-items?page=1&pageSize=20", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "tenants", + "{{domain}}", + "store-items" + ], + "query": [ + { + "key": "page", + "value": "1" + }, + { + "key": "pageSize", + "value": "20" + }, + { + "key": "categoryId", + "value": "", + "disabled": true + }, + { + "key": "brandId", + "value": "", + "disabled": true + }, + { + "key": "productId", + "value": "{{productId}}", + "disabled": true + }, + { + "key": "name", + "value": "", + "disabled": true + }, + { + "key": "inStock", + "value": "true", + "disabled": true + }, + { + "key": "isFestival", + "value": "true", + "disabled": true + }, + { + "key": "minPrice", + "value": "", + "disabled": true + }, + { + "key": "maxPrice", + "value": "", + "disabled": true + } + ] + } + } + }, + { + "name": "Get store item by product ID", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " const variant = json.storeItem?.variants?.[0];", + " if (variant?.id) pm.collectionVariables.set('storeItemVariantId', variant.id);", + "}" + ] + } + } + ], + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/store-items/by-product/{{productId}}" + } + }, + { + "name": "Get store item variant by ID", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/store-items/{{storeItemVariantId}}" + } + } + ] + }, + { + "name": "Homepage", + "item": [ + { + "name": "Get business info (website)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/website/business-info" + } + }, + { + "name": "List category groups (website)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/website/category-groups" + } + }, + { + "name": "List brand groups (website)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/website/brand-groups" + } + }, + { + "name": "List sliders (website)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/website/sliders" + } + }, + { + "name": "List store specials (website)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/store-specials" + } + } + ] + }, + { + "name": "Cart", + "item": [ + { + "name": "Get cart", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/businesses/{{businessId}}/cart" + } + }, + { + "name": "Add variant to cart", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " const items = json.cart?.items || [];", + " if (items.length) pm.collectionVariables.set('cartItemId', items[items.length - 1].id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"storeItemVariantId\": \"{{storeItemVariantId}}\",\n \"quantity\": 1\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/cart/items" + } + }, + { + "name": "Update cart item quantity", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"quantity\": 2\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/cart/items/{{cartItemId}}" + } + }, + { + "name": "Remove cart item", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/businesses/{{businessId}}/cart/items/{{cartItemId}}" + } + }, + { + "name": "Clear cart", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/businesses/{{businessId}}/cart" + } + }, + { + "name": "Checkout cart (cash payment)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.order?.id) pm.collectionVariables.set('orderId', json.order.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"shippingAddress\": {\n \"province\": \"Tehran\",\n \"city\": \"Tehran\",\n \"address\": \"123 Example Street\",\n \"postalCode\": \"1234567890\",\n \"landline\": \"02112345678\"\n },\n \"customerNotes\": \"Please call before delivery\",\n \"payment\": {\n \"type\": \"cash\"\n }\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/cart/checkout" + } + }, + { + "name": "Checkout cart (saved address)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.order?.id) pm.collectionVariables.set('orderId', json.order.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"addressId\": \"{{addressId}}\",\n \"customerNotes\": \"Deliver after 5pm\",\n \"payment\": {\n \"type\": \"e_payment_gate\",\n \"gatewayType\": \"zarinpal\"\n }\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/cart/checkout" + } + } + ] + }, + { + "name": "Orders", + "item": [ + { + "name": "List my orders", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": { + "raw": "{{baseUrl}}/businesses/{{businessId}}/orders?page=1&pageSize=20", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "businesses", + "{{businessId}}", + "orders" + ], + "query": [ + { + "key": "page", + "value": "1" + }, + { + "key": "pageSize", + "value": "20" + } + ] + } + } + }, + { + "name": "Get my order", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/businesses/{{businessId}}/orders/{{orderId}}" + } + } + ] + }, + { + "name": "Contact", + "item": [ + { + "name": "Submit contact form", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"title\": \"Product inquiry\",\n \"name\": \"Website Visitor\",\n \"email\": \"visitor@example.com\",\n \"cellNumber\": \"+989121234567\",\n \"text\": \"I would like more information about your products.\"\n}" + }, + "url": "{{baseUrl}}/tenants/{{domain}}/contact-submissions" + } + } + ] + }, + { + "name": "Favorites", + "item": [ + { + "name": "List favorites", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": { + "raw": "{{baseUrl}}/businesses/{{businessId}}/favorites?page=1&pageSize=20", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "businesses", + "{{businessId}}", + "favorites" + ], + "query": [ + { + "key": "page", + "value": "1" + }, + { + "key": "pageSize", + "value": "20" + } + ] + } + } + }, + { + "name": "Add favorite", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"productId\": \"{{productId}}\"\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/favorites" + } + }, + { + "name": "Remove favorite", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/businesses/{{businessId}}/favorites/{{productId}}" + } + } + ] + }, + { + "name": "Cities (address forms)", + "item": [ + { + "name": "List countries", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " if (json.items?.[0]?.id) pm.collectionVariables.set('countryId', json.items[0].id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/cities?level=country", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cities" + ], + "query": [ + { + "key": "level", + "value": "country" + } + ] + } + } + }, + { + "name": "List Iran provinces", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " if (json.items?.[0]?.id) pm.collectionVariables.set('provinceId', json.items[0].id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/cities?level=province&parentSlug=iran", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cities" + ], + "query": [ + { + "key": "level", + "value": "province" + }, + { + "key": "parentSlug", + "value": "iran" + } + ] + } + } + }, + { + "name": "List cities in Tehran province", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " const tehran = json.items?.find((item) => item.slug === 'tehran');", + " if (tehran?.id) pm.collectionVariables.set('cityId', tehran.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/cities?level=city&parentSlug=tehran-province", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cities" + ], + "query": [ + { + "key": "level", + "value": "city" + }, + { + "key": "parentSlug", + "value": "tehran-province" + } + ] + } + } + }, + { + "name": "List cities by province ID", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/cities?level=city&parentId={{provinceId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cities" + ], + "query": [ + { + "key": "level", + "value": "city" + }, + { + "key": "parentId", + "value": "{{provinceId}}" + } + ] + } + } + }, + { + "name": "Get city by ID", + "request": { + "method": "GET", + "url": "{{baseUrl}}/cities/{{cityId}}" + } + } + ] + } + ] +} diff --git a/docs/website-api/index.html b/docs/website-api/index.html new file mode 100644 index 0000000..493f03a --- /dev/null +++ b/docs/website-api/index.html @@ -0,0 +1,109 @@ + + + + + + Meshkee Website API + + + +
+
Meshkee · Global storefront contract
+

Website API

+

+ One API for every Meshkee business website. Not tied to a single domain. + Set your site’s apex host (e.g. sanihome.ir) and reuse the same endpoints. +

+ +
+

Global links (share these with designers & AI tools):

+ OpenAPI JSON + Download Postman + AI prompt +
+ +

Base URL

+

https://api.meshkee.com/api/v1

+

Optional per-site alias (same backend): https://api.<domain>/api/v1

+ +

How tenants work

+
    +
  1. Variable domain = website apex only (no www/api/customer/business).
  2. +
  3. GET /tenants/{domain}businessId.
  4. +
  5. Public pages: /tenants/{domain}/... (no auth).
  6. +
  7. Cart / orders / favorites: /businesses/{businessId}/... + Bearer JWT.
  8. +
+ +

For a new website AI / designer

+
    +
  1. Open AI_PROMPT.md and paste it into the AI chat.
  2. +
  3. Replace <WEBSITE_DOMAIN> with that site’s apex.
  4. +
  5. Import the Postman collection (set domain, run Resolve tenant).
  6. +
  7. Or feed openapi.json to the AI / codegen tool.
  8. +
+ +

Import Postman

+

+ Postman → Import → Link → paste
+ https://api.meshkee.com/docs/website/Meshkee-Website-API.postman_collection.json +

+
+ + diff --git a/docs/website-api/openapi.json b/docs/website-api/openapi.json new file mode 100644 index 0000000..d0eedc0 --- /dev/null +++ b/docs/website-api/openapi.json @@ -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 }" } } + } + } + } +} diff --git a/nest-cli.json b/nest-cli.json index f9aa683..ad6deb6 100644 --- a/nest-cli.json +++ b/nest-cli.json @@ -3,6 +3,12 @@ "collection": "@nestjs/schematics", "sourceRoot": "src", "compilerOptions": { - "deleteOutDir": true + "deleteOutDir": true, + "assets": [ + { + "include": "website-docs/static/**/*", + "watchAssets": true + } + ] } } diff --git a/postman/Meshkee-Website-API.global.postman_environment.json b/postman/Meshkee-Website-API.global.postman_environment.json new file mode 100644 index 0000000..76350d6 --- /dev/null +++ b/postman/Meshkee-Website-API.global.postman_environment.json @@ -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" +} diff --git a/postman/Meshkee-Website-API.postman_collection.json b/postman/Meshkee-Website-API.postman_collection.json index 2801626..33df96f 100644 --- a/postman/Meshkee-Website-API.postman_collection.json +++ b/postman/Meshkee-Website-API.postman_collection.json @@ -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": { @@ -1664,4 +1671,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c2be4da..259d14b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") diff --git a/src/app.module.ts b/src/app.module.ts index c0caca0..0002868 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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 {} diff --git a/src/domain-admin/domain-admin.controller.ts b/src/domain-admin/domain-admin.controller.ts index 8690062..e97c928 100644 --- a/src/domain-admin/domain-admin.controller.ts +++ b/src/domain-admin/domain-admin.controller.ts @@ -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( diff --git a/src/domain-admin/domain-admin.module.ts b/src/domain-admin/domain-admin.module.ts index 6ded5b8..8f2a1d8 100644 --- a/src/domain-admin/domain-admin.module.ts +++ b/src/domain-admin/domain-admin.module.ts @@ -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], }) diff --git a/src/domain-admin/domain-admin.service.ts b/src/domain-admin/domain-admin.service.ts index c6a9ca5..bca0024 100644 --- a/src/domain-admin/domain-admin.service.ts +++ b/src/domain-admin/domain-admin.service.ts @@ -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 = { + '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(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('WEBSITE_DEPLOY_AGENT_URL')?.trim(); + const token = this.config.get('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); diff --git a/src/internal-ssl/internal-ssl.controller.ts b/src/internal-ssl/internal-ssl.controller.ts new file mode 100644 index 0000000..12cf1c0 --- /dev/null +++ b/src/internal-ssl/internal-ssl.controller.ts @@ -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(); + } +} diff --git a/src/internal-ssl/internal-ssl.module.ts b/src/internal-ssl/internal-ssl.module.ts new file mode 100644 index 0000000..dece247 --- /dev/null +++ b/src/internal-ssl/internal-ssl.module.ts @@ -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 {} diff --git a/src/internal-ssl/internal-ssl.service.ts b/src/internal-ssl/internal-ssl.service.ts new file mode 100644 index 0000000..3b9309b --- /dev/null +++ b/src/internal-ssl/internal-ssl.service.ts @@ -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 { + 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('DASHBOARD_ADMIN_HOST')?.trim() || 'manage.meshkee.com'; + + const hosts = new Set([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('CENTRAL_API_HOST')?.trim() || 'api.meshkee.com'; + + const hosts = new Set([centralHost]); + for (const apex of await this.listActiveApexHosts()) { + hosts.add(`api.${apex}`); + } + + return { hosts: [...hosts].sort() }; + } +} diff --git a/src/internal-ssl/ssl-sync-token.guard.ts b/src/internal-ssl/ssl-sync-token.guard.ts new file mode 100644 index 0000000..fde89a4 --- /dev/null +++ b/src/internal-ssl/ssl-sync-token.guard.ts @@ -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('SSL_SYNC_TOKEN')?.trim(); + if (!expected) { + throw new UnauthorizedException('SSL sync is not configured'); + } + + const req = context.switchToHttp().getRequest(); + 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; + } +} diff --git a/src/main.ts b/src/main.ts index 5647eb1..52f7a6b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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(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(); diff --git a/src/website-docs/static/AI_PROMPT.md b/src/website-docs/static/AI_PROMPT.md new file mode 100644 index 0000000..686b918 --- /dev/null +++ b/src/website-docs/static/AI_PROMPT.md @@ -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./api/v1` — same backend.) + +**This website’s apex domain:** `` +(example: `sanihome.ir` — no `www.`, no `api.`, no `customer.`, no `business.`) + +### Hard rules +1. Resolve tenant first: `GET /tenants/` → save `businessId` from `id`. +2. All public content uses `/tenants//...` (no auth). +3. Cart, orders, favorites use `/businesses//...` with `Authorization: Bearer `. +4. Customer register body must include `"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 `` once per project. Everything else is global — same Postman, same OpenAPI, same base URL. diff --git a/src/website-docs/static/Meshkee-Website-API.global.postman_environment.json b/src/website-docs/static/Meshkee-Website-API.global.postman_environment.json new file mode 100644 index 0000000..76350d6 --- /dev/null +++ b/src/website-docs/static/Meshkee-Website-API.global.postman_environment.json @@ -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" +} diff --git a/src/website-docs/static/Meshkee-Website-API.postman_collection.json b/src/website-docs/static/Meshkee-Website-API.postman_collection.json new file mode 100644 index 0000000..33df96f --- /dev/null +++ b/src/website-docs/static/Meshkee-Website-API.postman_collection.json @@ -0,0 +1,1674 @@ +{ + "info": { + "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": "https://api.meshkee.com/api/v1" + }, + { + "key": "domain", + "value": "YOUR_WEBSITE_DOMAIN" + }, + { + "key": "businessId", + "value": "" + }, + { + "key": "accessToken", + "value": "" + }, + { + "key": "refreshToken", + "value": "" + }, + { + "key": "productId", + "value": "" + }, + { + "key": "productSlug", + "value": "" + }, + { + "key": "blogId", + "value": "" + }, + { + "key": "blogSlug", + "value": "" + }, + { + "key": "blogCategoryId", + "value": "" + }, + { + "key": "portfolioId", + "value": "" + }, + { + "key": "portfolioSlug", + "value": "" + }, + { + "key": "portfolioCategoryId", + "value": "" + }, + { + "key": "storeItemVariantId", + "value": "" + }, + { + "key": "cartItemId", + "value": "" + }, + { + "key": "orderId", + "value": "" + }, + { + "key": "commentId", + "value": "" + }, + { + "key": "expertReviewId", + "value": "" + }, + { + "key": "addressId", + "value": "" + }, + { + "key": "countryId", + "value": "" + }, + { + "key": "provinceId", + "value": "" + }, + { + "key": "cityId", + "value": "" + }, + { + "key": "categoryId", + "value": "" + }, + { + "key": "brandId", + "value": "" + } + ], + "item": [ + { + "name": "Tenant", + "item": [ + { + "name": "Resolve tenant by domain", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " if (json.id) pm.collectionVariables.set(\"businessId\", String(json.id));", + "}" + ] + } + } + ], + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}" + } + } + ] + }, + { + "name": "Auth", + "item": [ + { + "name": "Register (customer on website)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.accessToken) pm.collectionVariables.set('accessToken', json.accessToken);", + " if (json.refreshToken) pm.collectionVariables.set('refreshToken', json.refreshToken);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "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" + } + }, + { + "name": "Login - Customer", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " pm.collectionVariables.set('accessToken', json.accessToken);", + " pm.collectionVariables.set('refreshToken', json.refreshToken);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"cellNumber\": \"+98XXXXXXXXXX\",\n \"password\": \"YOUR_PASSWORD\"\n}" + }, + "url": "{{baseUrl}}/auth/login" + } + }, + { + "name": "Me (current user)", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/auth/me" + } + }, + { + "name": "Refresh token", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " pm.collectionVariables.set('accessToken', json.accessToken);", + " pm.collectionVariables.set('refreshToken', json.refreshToken);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"refreshToken\": \"{{refreshToken}}\"\n}" + }, + "url": "{{baseUrl}}/auth/refresh" + } + }, + { + "name": "Send OTP (SMS disabled)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"cellNumber\": \"+989124444444\"\n}" + }, + "url": "{{baseUrl}}/auth/send-otp" + } + }, + { + "name": "Verify OTP (SMS disabled)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"cellNumber\": \"+989124444444\",\n \"code\": \"123456\"\n}" + }, + "url": "{{baseUrl}}/auth/verify-otp" + } + }, + { + "name": "Update profile", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"firstName\": \"Ali\",\n \"lastName\": \"Customer\",\n \"email\": \"ali@example.com\"\n}" + }, + "url": "{{baseUrl}}/auth/profile" + } + }, + { + "name": "Change password", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"currentPassword\": \"password\",\n \"newPassword\": \"password123\"\n}" + }, + "url": "{{baseUrl}}/auth/change-password" + } + } + ] + }, + { + "name": "Addresses", + "item": [ + { + "name": "List my addresses", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " if (json.items?.[0]?.id) pm.collectionVariables.set(\"addressId\", json.items[0].id);", + "}" + ] + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/auth/addresses" + } + }, + { + "name": "Create address", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.address?.id) pm.collectionVariables.set(\"addressId\", json.address.id);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"province\": \"Tehran\",\n \"city\": \"Tehran\",\n \"address\": \"123 Example Street\",\n \"postalCode\": \"1234567890\",\n \"landline\": \"02112345678\"\n}" + }, + "url": "{{baseUrl}}/auth/addresses" + } + }, + { + "name": "Update address", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"province\": \"Tehran\",\n \"city\": \"Tehran\",\n \"address\": \"456 Updated Street\",\n \"postalCode\": \"1234567890\"\n}" + }, + "url": "{{baseUrl}}/auth/addresses/{{addressId}}" + } + }, + { + "name": "Delete address", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/auth/addresses/{{addressId}}" + } + } + ] + }, + { + "name": "Comments", + "item": [ + { + "name": "Submit comment (website)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.comment?.id) pm.collectionVariables.set('commentId', json.comment.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"entityType\": \"product\",\n \"entityId\": \"{{productId}}\",\n \"authorName\": \"Website Visitor\",\n \"authorEmail\": \"visitor@example.com\",\n \"text\": \"Great product! Would recommend.\"\n}" + }, + "url": "{{baseUrl}}/tenants/{{domain}}/comments" + } + }, + { + "name": "List approved comments (website)", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/tenants/{{domain}}/comments?entityType=product&entityId={{productId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "tenants", + "{{domain}}", + "comments" + ], + "query": [ + { + "key": "entityType", + "value": "product" + }, + { + "key": "entityId", + "value": "{{productId}}" + } + ] + } + } + }, + { + "name": "Submit comment on blog (generic API)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.comment?.id) pm.collectionVariables.set('commentId', json.comment.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"entityType\": \"blog\",\n \"entityId\": \"{{blogId}}\",\n \"authorName\": \"Blog Reader\",\n \"authorEmail\": \"reader@example.com\",\n \"text\": \"Great article, thanks for sharing!\"\n}" + }, + "url": "{{baseUrl}}/tenants/{{domain}}/comments" + } + }, + { + "name": "List approved comments for blog (generic API)", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/tenants/{{domain}}/comments?entityType=blog&entityId={{blogId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "tenants", + "{{domain}}", + "comments" + ], + "query": [ + { + "key": "entityType", + "value": "blog" + }, + { + "key": "entityId", + "value": "{{blogId}}" + } + ] + } + } + }, + { + "name": "Submit comment on portfolio (generic API)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.comment?.id) pm.collectionVariables.set('commentId', json.comment.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"entityType\": \"portfolio\",\n \"entityId\": \"{{portfolioId}}\",\n \"authorName\": \"Portfolio Visitor\",\n \"authorEmail\": \"visitor@example.com\",\n \"text\": \"Beautiful work!\"\n}" + }, + "url": "{{baseUrl}}/tenants/{{domain}}/comments" + } + }, + { + "name": "List approved comments for portfolio (generic API)", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/tenants/{{domain}}/comments?entityType=portfolio&entityId={{portfolioId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "tenants", + "{{domain}}", + "comments" + ], + "query": [ + { + "key": "entityType", + "value": "portfolio" + }, + { + "key": "entityId", + "value": "{{portfolioId}}" + } + ] + } + } + } + ] + }, + { + "name": "Blogs", + "item": [ + { + "name": "List published blogs (website)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " if (json.items?.[0]?.id) pm.collectionVariables.set('blogId', json.items[0].id);", + " if (json.items?.[0]?.slug) pm.collectionVariables.set('blogSlug', json.items[0].slug);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/tenants/{{domain}}/blogs?page=1&pageSize=12", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "tenants", + "{{domain}}", + "blogs" + ], + "query": [ + { + "key": "page", + "value": "1" + }, + { + "key": "pageSize", + "value": "12" + }, + { + "key": "type", + "value": "", + "disabled": true + }, + { + "key": "categoryId", + "value": "", + "disabled": true + }, + { + "key": "title", + "value": "", + "disabled": true + } + ] + } + } + }, + { + "name": "Get published blog by slug (website)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/blogs/{{blogSlug}}" + } + }, + { + "name": "List blog comments (website)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/blogs/{{blogId}}/comments" + } + }, + { + "name": "Submit blog comment (website)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.comment?.id) pm.collectionVariables.set('commentId', json.comment.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"authorName\": \"Blog Reader\",\n \"authorEmail\": \"reader@example.com\",\n \"text\": \"Very helpful post!\"\n}" + }, + "url": "{{baseUrl}}/tenants/{{domain}}/blogs/{{blogId}}/comments" + } + } + ] + }, + { + "name": "Portfolios", + "item": [ + { + "name": "List published portfolios (website)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " if (json.items?.[0]?.id) pm.collectionVariables.set('portfolioId', json.items[0].id);", + " if (json.items?.[0]?.slug) pm.collectionVariables.set('portfolioSlug', json.items[0].slug);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/tenants/{{domain}}/portfolios?page=1&pageSize=12", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "tenants", + "{{domain}}", + "portfolios" + ], + "query": [ + { + "key": "page", + "value": "1" + }, + { + "key": "pageSize", + "value": "12" + }, + { + "key": "categoryId", + "value": "", + "disabled": true + }, + { + "key": "title", + "value": "", + "disabled": true + } + ] + } + } + }, + { + "name": "Get published portfolio by slug (website)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/portfolios/{{portfolioSlug}}" + } + }, + { + "name": "List portfolio comments (website)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/portfolios/{{portfolioId}}/comments" + } + }, + { + "name": "Submit portfolio comment (website)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.comment?.id) pm.collectionVariables.set('commentId', json.comment.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"authorName\": \"Portfolio Visitor\",\n \"authorEmail\": \"visitor@example.com\",\n \"text\": \"Impressive project!\"\n}" + }, + "url": "{{baseUrl}}/tenants/{{domain}}/portfolios/{{portfolioId}}/comments" + } + } + ] + }, + { + "name": "Expert Reviews", + "item": [ + { + "name": "Submit expert review (website)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.review?.id) pm.collectionVariables.set('expertReviewId', json.review.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"productId\": \"{{productId}}\",\n \"authorName\": \"Expert Reviewer\",\n \"authorEmail\": \"expert@example.com\",\n \"rate\": 8,\n \"positivePoints\": [\"Build quality\", \"Great battery life\", \"Comfortable fit\"],\n \"negativePoints\": [\"Pricey\", \"Limited colors\"],\n \"text\": \"Solid product overall with a few trade-offs worth considering.\"\n}" + }, + "url": "{{baseUrl}}/tenants/{{domain}}/expert-reviews" + } + }, + { + "name": "List approved expert reviews (website)", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/tenants/{{domain}}/expert-reviews?productId={{productId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "tenants", + "{{domain}}", + "expert-reviews" + ], + "query": [ + { + "key": "productId", + "value": "{{productId}}" + } + ] + } + } + } + ] + }, + { + "name": "Categories", + "item": [ + { + "name": "List product categories", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " if (json.items?.[0]?.id) pm.collectionVariables.set('categoryId', json.items[0].id);", + "}" + ] + } + } + ], + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/tenants/{{domain}}/categories?entityType=product", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "tenants", + "{{domain}}", + "categories" + ], + "query": [ + { + "key": "entityType", + "value": "product" + } + ] + } + } + }, + { + "name": "List blog categories", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/tenants/{{domain}}/categories?entityType=blog", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "tenants", + "{{domain}}", + "categories" + ], + "query": [ + { + "key": "entityType", + "value": "blog" + } + ] + } + } + }, + { + "name": "List portfolio categories", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/tenants/{{domain}}/categories?entityType=portfolio", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "tenants", + "{{domain}}", + "categories" + ], + "query": [ + { + "key": "entityType", + "value": "portfolio" + } + ] + } + } + } + ] + }, + { + "name": "Products", + "item": [ + { + "name": "List published products", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " if (json.items?.[0]?.id) pm.collectionVariables.set('productId', json.items[0].id);", + " if (json.items?.[0]?.slug) pm.collectionVariables.set('productSlug', json.items[0].slug);", + "}" + ] + } + } + ], + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/tenants/{{domain}}/products?page=1&pageSize=12", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "tenants", + "{{domain}}", + "products" + ], + "query": [ + { + "key": "page", + "value": "1" + }, + { + "key": "pageSize", + "value": "12" + }, + { + "key": "name", + "value": "", + "disabled": true + }, + { + "key": "categoryId", + "value": "{{categoryId}}", + "disabled": true + }, + { + "key": "brandId", + "value": "{{brandId}}", + "disabled": true + }, + { + "key": "tag", + "value": "", + "disabled": true + }, + { + "key": "inStore", + "value": "true", + "disabled": true + } + ] + } + } + }, + { + "name": "Get published product by slug", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/products/{{productSlug}}" + } + }, + { + "name": "Get product variations by slug", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/products/{{productSlug}}/variations" + } + }, + { + "name": "Get product technical info by slug", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/products/{{productSlug}}/technical-info" + } + } + ] + }, + { + "name": "Store Items", + "item": [ + { + "name": "List store item variants", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " if (json.items?.[0]?.id) pm.collectionVariables.set('storeItemVariantId', json.items[0].id);", + "}" + ] + } + } + ], + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/tenants/{{domain}}/store-items?page=1&pageSize=20", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "tenants", + "{{domain}}", + "store-items" + ], + "query": [ + { + "key": "page", + "value": "1" + }, + { + "key": "pageSize", + "value": "20" + }, + { + "key": "categoryId", + "value": "", + "disabled": true + }, + { + "key": "brandId", + "value": "", + "disabled": true + }, + { + "key": "productId", + "value": "{{productId}}", + "disabled": true + }, + { + "key": "name", + "value": "", + "disabled": true + }, + { + "key": "inStock", + "value": "true", + "disabled": true + }, + { + "key": "isFestival", + "value": "true", + "disabled": true + }, + { + "key": "minPrice", + "value": "", + "disabled": true + }, + { + "key": "maxPrice", + "value": "", + "disabled": true + } + ] + } + } + }, + { + "name": "Get store item by product ID", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " const variant = json.storeItem?.variants?.[0];", + " if (variant?.id) pm.collectionVariables.set('storeItemVariantId', variant.id);", + "}" + ] + } + } + ], + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/store-items/by-product/{{productId}}" + } + }, + { + "name": "Get store item variant by ID", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/store-items/{{storeItemVariantId}}" + } + } + ] + }, + { + "name": "Homepage", + "item": [ + { + "name": "Get business info (website)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/website/business-info" + } + }, + { + "name": "List category groups (website)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/website/category-groups" + } + }, + { + "name": "List brand groups (website)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/website/brand-groups" + } + }, + { + "name": "List sliders (website)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/website/sliders" + } + }, + { + "name": "List store specials (website)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/store-specials" + } + } + ] + }, + { + "name": "Cart", + "item": [ + { + "name": "Get cart", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/businesses/{{businessId}}/cart" + } + }, + { + "name": "Add variant to cart", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " const items = json.cart?.items || [];", + " if (items.length) pm.collectionVariables.set('cartItemId', items[items.length - 1].id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"storeItemVariantId\": \"{{storeItemVariantId}}\",\n \"quantity\": 1\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/cart/items" + } + }, + { + "name": "Update cart item quantity", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"quantity\": 2\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/cart/items/{{cartItemId}}" + } + }, + { + "name": "Remove cart item", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/businesses/{{businessId}}/cart/items/{{cartItemId}}" + } + }, + { + "name": "Clear cart", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/businesses/{{businessId}}/cart" + } + }, + { + "name": "Checkout cart (cash payment)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.order?.id) pm.collectionVariables.set('orderId', json.order.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"shippingAddress\": {\n \"province\": \"Tehran\",\n \"city\": \"Tehran\",\n \"address\": \"123 Example Street\",\n \"postalCode\": \"1234567890\",\n \"landline\": \"02112345678\"\n },\n \"customerNotes\": \"Please call before delivery\",\n \"payment\": {\n \"type\": \"cash\"\n }\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/cart/checkout" + } + }, + { + "name": "Checkout cart (saved address)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.order?.id) pm.collectionVariables.set('orderId', json.order.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"addressId\": \"{{addressId}}\",\n \"customerNotes\": \"Deliver after 5pm\",\n \"payment\": {\n \"type\": \"e_payment_gate\",\n \"gatewayType\": \"zarinpal\"\n }\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/cart/checkout" + } + } + ] + }, + { + "name": "Orders", + "item": [ + { + "name": "List my orders", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": { + "raw": "{{baseUrl}}/businesses/{{businessId}}/orders?page=1&pageSize=20", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "businesses", + "{{businessId}}", + "orders" + ], + "query": [ + { + "key": "page", + "value": "1" + }, + { + "key": "pageSize", + "value": "20" + } + ] + } + } + }, + { + "name": "Get my order", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/businesses/{{businessId}}/orders/{{orderId}}" + } + } + ] + }, + { + "name": "Contact", + "item": [ + { + "name": "Submit contact form", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"title\": \"Product inquiry\",\n \"name\": \"Website Visitor\",\n \"email\": \"visitor@example.com\",\n \"cellNumber\": \"+989121234567\",\n \"text\": \"I would like more information about your products.\"\n}" + }, + "url": "{{baseUrl}}/tenants/{{domain}}/contact-submissions" + } + } + ] + }, + { + "name": "Favorites", + "item": [ + { + "name": "List favorites", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": { + "raw": "{{baseUrl}}/businesses/{{businessId}}/favorites?page=1&pageSize=20", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "businesses", + "{{businessId}}", + "favorites" + ], + "query": [ + { + "key": "page", + "value": "1" + }, + { + "key": "pageSize", + "value": "20" + } + ] + } + } + }, + { + "name": "Add favorite", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"productId\": \"{{productId}}\"\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/favorites" + } + }, + { + "name": "Remove favorite", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/businesses/{{businessId}}/favorites/{{productId}}" + } + } + ] + }, + { + "name": "Cities (address forms)", + "item": [ + { + "name": "List countries", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " if (json.items?.[0]?.id) pm.collectionVariables.set('countryId', json.items[0].id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/cities?level=country", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cities" + ], + "query": [ + { + "key": "level", + "value": "country" + } + ] + } + } + }, + { + "name": "List Iran provinces", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " if (json.items?.[0]?.id) pm.collectionVariables.set('provinceId', json.items[0].id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/cities?level=province&parentSlug=iran", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cities" + ], + "query": [ + { + "key": "level", + "value": "province" + }, + { + "key": "parentSlug", + "value": "iran" + } + ] + } + } + }, + { + "name": "List cities in Tehran province", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " const tehran = json.items?.find((item) => item.slug === 'tehran');", + " if (tehran?.id) pm.collectionVariables.set('cityId', tehran.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/cities?level=city&parentSlug=tehran-province", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cities" + ], + "query": [ + { + "key": "level", + "value": "city" + }, + { + "key": "parentSlug", + "value": "tehran-province" + } + ] + } + } + }, + { + "name": "List cities by province ID", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/cities?level=city&parentId={{provinceId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cities" + ], + "query": [ + { + "key": "level", + "value": "city" + }, + { + "key": "parentId", + "value": "{{provinceId}}" + } + ] + } + } + }, + { + "name": "Get city by ID", + "request": { + "method": "GET", + "url": "{{baseUrl}}/cities/{{cityId}}" + } + } + ] + } + ] +} diff --git a/src/website-docs/static/index.html b/src/website-docs/static/index.html new file mode 100644 index 0000000..493f03a --- /dev/null +++ b/src/website-docs/static/index.html @@ -0,0 +1,109 @@ + + + + + + Meshkee Website API + + + +
+
Meshkee · Global storefront contract
+

Website API

+

+ One API for every Meshkee business website. Not tied to a single domain. + Set your site’s apex host (e.g. sanihome.ir) and reuse the same endpoints. +

+ +
+

Global links (share these with designers & AI tools):

+ OpenAPI JSON + Download Postman + AI prompt +
+ +

Base URL

+

https://api.meshkee.com/api/v1

+

Optional per-site alias (same backend): https://api.<domain>/api/v1

+ +

How tenants work

+
    +
  1. Variable domain = website apex only (no www/api/customer/business).
  2. +
  3. GET /tenants/{domain}businessId.
  4. +
  5. Public pages: /tenants/{domain}/... (no auth).
  6. +
  7. Cart / orders / favorites: /businesses/{businessId}/... + Bearer JWT.
  8. +
+ +

For a new website AI / designer

+
    +
  1. Open AI_PROMPT.md and paste it into the AI chat.
  2. +
  3. Replace <WEBSITE_DOMAIN> with that site’s apex.
  4. +
  5. Import the Postman collection (set domain, run Resolve tenant).
  6. +
  7. Or feed openapi.json to the AI / codegen tool.
  8. +
+ +

Import Postman

+

+ Postman → Import → Link → paste
+ https://api.meshkee.com/docs/website/Meshkee-Website-API.postman_collection.json +

+
+ + diff --git a/src/website-docs/static/openapi.json b/src/website-docs/static/openapi.json new file mode 100644 index 0000000..d0eedc0 --- /dev/null +++ b/src/website-docs/static/openapi.json @@ -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 }" } } + } + } + } +} diff --git a/src/website-docs/website-docs.controller.ts b/src/website-docs/website-docs.controller.ts new file mode 100644 index 0000000..1324870 --- /dev/null +++ b/src/website-docs/website-docs.controller.ts @@ -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 = { + '.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); + } +} diff --git a/src/website-docs/website-docs.module.ts b/src/website-docs/website-docs.module.ts new file mode 100644 index 0000000..c31e3db --- /dev/null +++ b/src/website-docs/website-docs.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { WebsiteDocsController } from './website-docs.controller'; + +@Module({ + controllers: [WebsiteDocsController], +}) +export class WebsiteDocsModule {} diff --git a/src/website-docs/website-docs.paths.ts b/src/website-docs/website-docs.paths.ts new file mode 100644 index 0000000..012365f --- /dev/null +++ b/src/website-docs/website-docs.paths.ts @@ -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]; +}