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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-07-22 21:39:51 +03:30
co-authored by Cursor
parent bb59d5e9ba
commit 016cc15bf0
32 changed files with 6159 additions and 37 deletions
+4
View File
@@ -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 {}
+19 -1
View File
@@ -1,4 +1,15 @@
import { Body, Controller, Delete, Get, Param, Patch, Query, UseGuards } from '@nestjs/common';
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { AuthUser } from '../auth/auth.types';
import { 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(
+2 -1
View File
@@ -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],
})
+89 -2
View File
@@ -4,7 +4,9 @@ import {
ForbiddenException,
Injectable,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Prisma } from '@prisma/client';
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
@@ -14,6 +16,11 @@ import { ListDomainsDto } from './dto/list-domains.dto';
import { ToggleSslDto } from './dto/toggle-ssl.dto';
import { UpdateDomainAdminDto } from './dto/update-domain-admin.dto';
/** Apex hosts that have a storefront deploy on the websites VM. */
const WEBSITE_DEPLOY_SLUGS: Record<string, string> = {
'ali-mohammadi.ir': 'ali-mohammadi',
};
type DomainRow = {
id: bigint;
host: string;
@@ -23,6 +30,8 @@ type DomainRow = {
isActive: boolean;
expiresAt: Date | null;
createdAt: Date;
lastDeployedAt: Date | null;
lastDeployStatus: string | null;
};
@Injectable()
@@ -30,6 +39,7 @@ export class DomainAdminService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
private readonly config: ConfigService,
) {}
private async assertSuperAdmin(actor: AuthUser) {
@@ -38,6 +48,10 @@ export class DomainAdminService {
}
}
private deploySlugForHost(host: string): string | null {
return WEBSITE_DEPLOY_SLUGS[host.trim().toLowerCase()] ?? null;
}
async list(query: ListDomainsDto, actor: AuthUser) {
await this.assertSuperAdmin(actor);
@@ -51,7 +65,7 @@ export class DomainAdminService {
${nameLike ? Prisma.sql`AND d.host ILIKE ${nameLike}` : Prisma.empty}
`;
const [items, totalRow] = await Promise.all([
const [rows, totalRow] = await Promise.all([
this.prisma.$queryRaw<DomainRow[]>(Prisma.sql`
SELECT
d.id AS "id",
@@ -61,7 +75,9 @@ export class DomainAdminService {
d.ssl_enabled AS "sslEnabled",
d.is_active AS "isActive",
d.expires_at AS "expiresAt",
d.created_at AS "createdAt"
d.created_at AS "createdAt",
d.last_deployed_at AS "lastDeployedAt",
d.last_deploy_status AS "lastDeployStatus"
FROM domains d
JOIN businesses b ON b.id = d.business_id
${where}
@@ -75,9 +91,80 @@ export class DomainAdminService {
`),
]);
const items = rows.map((row) => ({
...row,
deploySlug: this.deploySlugForHost(row.host),
}));
return { items, total: totalRow[0]?.total ?? 0, page, pageSize };
}
async deploy(domainIdRaw: string, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const domainId = BigInt(domainIdRaw);
const domain = await this.prisma.domain.findUnique({ where: { id: domainId } });
if (!domain) {
throw new NotFoundException('Domain not found');
}
const slug = this.deploySlugForHost(domain.host);
if (!slug) {
throw new BadRequestException('This domain has no storefront deploy configured');
}
const agentUrl = this.config.get<string>('WEBSITE_DEPLOY_AGENT_URL')?.trim();
const token = this.config.get<string>('WEBSITE_DEPLOY_TOKEN')?.trim();
if (!agentUrl || !token) {
throw new ServiceUnavailableException('Website deploy agent is not configured');
}
const markDeploy = async (status: 'started' | 'failed') => {
const updated = await this.prisma.domain.update({
where: { id: domainId },
data: {
lastDeployedAt: new Date(),
lastDeployStatus: status,
},
});
return updated;
};
let response: Response;
try {
response = await fetch(agentUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Deploy-Token': token,
},
body: JSON.stringify({ slug }),
});
} catch {
await markDeploy('failed');
throw new ServiceUnavailableException('Could not reach website deploy agent');
}
if (!response.ok) {
await markDeploy('failed');
const text = await response.text().catch(() => '');
throw new ServiceUnavailableException(
`Deploy agent rejected request (${response.status})${text ? `: ${text}` : ''}`,
);
}
const updated = await markDeploy('started');
return {
status: 'accepted' as const,
slug,
host: domain.host,
message: 'Deploy started on websites server',
lastDeployedAt: updated.lastDeployedAt?.toISOString() ?? null,
lastDeployStatus: updated.lastDeployStatus,
};
}
async update(domainIdRaw: string, dto: UpdateDomainAdminDto, actor: AuthUser) {
await this.assertSuperAdmin(actor);
@@ -0,0 +1,21 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { InternalSslService } from './internal-ssl.service';
import { SslSyncTokenGuard } from './ssl-sync-token.guard';
@Controller('internal/ssl')
@UseGuards(SslSyncTokenGuard)
export class InternalSslController {
constructor(private readonly service: InternalSslService) {}
/** Dashboards VPS cert sync: active apex → business./customer. hosts + admin. */
@Get('hosts')
listHosts() {
return this.service.listDashboardHosts();
}
/** API VPS cert sync: central api host + api.{apex} per active domain. */
@Get('api-hosts')
listApiHosts() {
return this.service.listApiHosts();
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { InternalSslController } from './internal-ssl.controller';
import { InternalSslService } from './internal-ssl.service';
import { SslSyncTokenGuard } from './ssl-sync-token.guard';
@Module({
controllers: [InternalSslController],
providers: [InternalSslService, SslSyncTokenGuard],
})
export class InternalSslModule {}
+56
View File
@@ -0,0 +1,56 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class InternalSslService {
constructor(
private readonly prisma: PrismaService,
private readonly config: ConfigService,
) {}
private async listActiveApexHosts(): Promise<string[]> {
const domains = await this.prisma.domain.findMany({
where: { isActive: true },
select: { host: true },
orderBy: { host: 'asc' },
});
const hosts: string[] = [];
for (const { host } of domains) {
const apex = host.trim().toLowerCase();
if (apex) hosts.push(apex);
}
return hosts;
}
/** Dashboards VPS: manage + business./customer. per active apex. */
async listDashboardHosts(): Promise<{ hosts: string[] }> {
const adminHost =
this.config.get<string>('DASHBOARD_ADMIN_HOST')?.trim() || 'manage.meshkee.com';
const hosts = new Set<string>([adminHost]);
for (const apex of await this.listActiveApexHosts()) {
hosts.add(`business.${apex}`);
hosts.add(`customer.${apex}`);
}
return { hosts: [...hosts].sort() };
}
/**
* API VPS: central api host + api.{apex} aliases for each active domain.
* Same Nest process; nginx terminates TLS for every name on this list.
*/
async listApiHosts(): Promise<{ hosts: string[] }> {
const centralHost =
this.config.get<string>('CENTRAL_API_HOST')?.trim() || 'api.meshkee.com';
const hosts = new Set<string>([centralHost]);
for (const apex of await this.listActiveApexHosts()) {
hosts.add(`api.${apex}`);
}
return { hosts: [...hosts].sort() };
}
}
+35
View File
@@ -0,0 +1,35 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { timingSafeEqual } from 'crypto';
import { Request } from 'express';
@Injectable()
export class SslSyncTokenGuard implements CanActivate {
constructor(private readonly config: ConfigService) {}
canActivate(context: ExecutionContext): boolean {
const expected = this.config.get<string>('SSL_SYNC_TOKEN')?.trim();
if (!expected) {
throw new UnauthorizedException('SSL sync is not configured');
}
const req = context.switchToHttp().getRequest<Request>();
const provided = String(req.headers['x-ssl-sync-token'] ?? '').trim();
if (!provided || provided.length !== expected.length) {
throw new UnauthorizedException('Invalid SSL sync token');
}
const a = Buffer.from(provided);
const b = Buffer.from(expected);
if (!timingSafeEqual(a, b)) {
throw new UnauthorizedException('Invalid SSL sync token');
}
return true;
}
}
+15 -3
View File
@@ -1,12 +1,21 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { RequestMethod, ValidationPipe } from '@nestjs/common';
import { NestExpressApplication } from '@nestjs/platform-express';
import { AppModule } from './app.module';
import { BigIntSerializerInterceptor } from './common/interceptors/bigint-serializer.interceptor';
import { resolveWebsiteDocsRoot } from './website-docs/website-docs.paths';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const app = await NestFactory.create<NestExpressApplication>(AppModule);
// Public storefront docs — no /api/v1 prefix, no auth
app.setGlobalPrefix('api/v1', {
exclude: [
{ path: 'docs/website', method: RequestMethod.GET },
{ path: 'docs/website/:fileName', method: RequestMethod.GET },
],
});
app.setGlobalPrefix('api/v1');
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
@@ -20,6 +29,9 @@ async function bootstrap() {
const port = process.env.PORT ?? 3000;
await app.listen(port);
console.log(`API running on http://localhost:${port}/api/v1`);
console.log(
`Website API docs: http://localhost:${port}/docs/website (root=${resolveWebsiteDocsRoot()})`,
);
}
bootstrap();
+43
View File
@@ -0,0 +1,43 @@
# Meshkee Website API — AI / designer brief
Copy everything below into a new AI chat when building a Meshkee storefront.
---
## System context (paste this)
You are building a **Meshkee business website (storefront)**. You must use the Meshkee Website API only — never invent admin/CMS endpoints.
**Canonical docs (always prefer these):**
- Hub: https://api.meshkee.com/docs/website
- OpenAPI: https://api.meshkee.com/docs/website/openapi.json
- Postman: https://api.meshkee.com/docs/website/Meshkee-Website-API.postman_collection.json
**API base URL:** `https://api.meshkee.com/api/v1`
(Optional alias if configured: `https://api.<WEBSITE_DOMAIN>/api/v1` — same backend.)
**This websites apex domain:** `<WEBSITE_DOMAIN>`
(example: `sanihome.ir` — no `www.`, no `api.`, no `customer.`, no `business.`)
### Hard rules
1. Resolve tenant first: `GET /tenants/<WEBSITE_DOMAIN>` → save `businessId` from `id`.
2. All public content uses `/tenants/<WEBSITE_DOMAIN>/...` (no auth).
3. Cart, orders, favorites use `/businesses/<businessId>/...` with `Authorization: Bearer <accessToken>`.
4. Customer register body must include `"domain": "<WEBSITE_DOMAIN>"`.
5. Cell numbers are E.164 (`+98912...`).
6. Do not call dashboard/CMS routes (`/businesses/.../products` write APIs, media upload, domain-admin, etc.).
### Typical bootstrap sequence
1. `GET /tenants/{domain}` → branding + `businessId`
2. Homepage: business-info, sliders, category-groups, brand-groups, store-specials
3. Catalog: categories, products, store-items
4. Auth: register/login → store tokens
5. Cart checkout with `addressId` or inline `shippingAddress` + `payment`
If OpenAPI and this brief conflict, **OpenAPI wins**.
---
## What to tell each website team
Replace `<WEBSITE_DOMAIN>` once per project. Everything else is global — same Postman, same OpenAPI, same base URL.
@@ -0,0 +1,37 @@
{
"id": "meshkee-website-api-global",
"name": "Meshkee Website API — Global",
"values": [
{
"key": "baseUrl",
"value": "https://api.meshkee.com/api/v1",
"type": "default",
"enabled": true
},
{
"key": "domain",
"value": "YOUR_WEBSITE_DOMAIN",
"type": "default",
"enabled": true
},
{
"key": "businessId",
"value": "",
"type": "default",
"enabled": true
},
{
"key": "accessToken",
"value": "",
"type": "secret",
"enabled": true
},
{
"key": "refreshToken",
"value": "",
"type": "secret",
"enabled": true
}
],
"_postman_variable_scope": "environment"
}
File diff suppressed because it is too large Load Diff
+109
View File
@@ -0,0 +1,109 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Meshkee Website API</title>
<style>
:root {
--bg: #0f1419;
--panel: #1a222c;
--text: #e8eef4;
--muted: #9aa8b5;
--accent: #3d9cf0;
--line: #2a3542;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
background: radial-gradient(1200px 600px at 10% -10%, #1b3a57 0%, var(--bg) 55%);
color: var(--text);
line-height: 1.55;
}
main {
max-width: 760px;
margin: 0 auto;
padding: 3rem 1.25rem 4rem;
}
h1 { font-size: 2rem; margin: 0 0 0.5rem; letter-spacing: -0.02em; }
h2 { font-size: 1.15rem; margin: 2rem 0 0.75rem; }
p, li { color: var(--muted); }
strong { color: var(--text); }
code {
font-family: "IBM Plex Mono", ui-monospace, monospace;
background: #0b1015;
padding: 0.1rem 0.35rem;
border-radius: 4px;
color: #cde3f7;
font-size: 0.92em;
}
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 12px;
padding: 1rem 1.1rem;
margin: 1rem 0;
}
a.btn {
display: inline-block;
margin: 0.35rem 0.5rem 0.35rem 0;
padding: 0.65rem 1rem;
border-radius: 8px;
background: var(--accent);
color: #061018;
text-decoration: none;
font-weight: 600;
}
a.btn.secondary {
background: transparent;
color: var(--text);
border: 1px solid var(--line);
}
.eyebrow { color: var(--accent); font-size: 0.85rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; }
</style>
</head>
<body>
<main>
<div class="eyebrow">Meshkee · Global storefront contract</div>
<h1>Website API</h1>
<p>
One API for <strong>every</strong> Meshkee business website. Not tied to a single domain.
Set your sites apex host (e.g. <code>sanihome.ir</code>) and reuse the same endpoints.
</p>
<div class="panel">
<p style="margin:0 0 0.75rem"><strong>Global links</strong> (share these with designers &amp; AI tools):</p>
<a class="btn" href="./openapi.json">OpenAPI JSON</a>
<a class="btn secondary" href="./Meshkee-Website-API.postman_collection.json">Download Postman</a>
<a class="btn secondary" href="./AI_PROMPT.md">AI prompt</a>
</div>
<h2>Base URL</h2>
<p><code>https://api.meshkee.com/api/v1</code></p>
<p>Optional per-site alias (same backend): <code>https://api.&lt;domain&gt;/api/v1</code></p>
<h2>How tenants work</h2>
<ol>
<li>Variable <code>domain</code> = website apex only (no <code>www</code>/<code>api</code>/<code>customer</code>/<code>business</code>).</li>
<li><code>GET /tenants/{domain}</code><code>businessId</code>.</li>
<li>Public pages: <code>/tenants/{domain}/...</code> (no auth).</li>
<li>Cart / orders / favorites: <code>/businesses/{businessId}/...</code> + Bearer JWT.</li>
</ol>
<h2>For a new website AI / designer</h2>
<ol>
<li>Open <a href="./AI_PROMPT.md">AI_PROMPT.md</a> and paste it into the AI chat.</li>
<li>Replace <code>&lt;WEBSITE_DOMAIN&gt;</code> with that sites apex.</li>
<li>Import the Postman collection (set <code>domain</code>, run Resolve tenant).</li>
<li>Or feed <code>openapi.json</code> to the AI / codegen tool.</li>
</ol>
<h2>Import Postman</h2>
<p>
Postman → Import → Link → paste<br />
<code>https://api.meshkee.com/docs/website/Meshkee-Website-API.postman_collection.json</code>
</p>
</main>
</body>
</html>
+901
View File
@@ -0,0 +1,901 @@
{
"openapi": "3.0.3",
"info": {
"title": "Meshkee Website API",
"version": "1.0.0",
"description": "Global storefront API for every Meshkee business website.\n\n**Not domain-specific.** Replace `{domain}` with the website apex (e.g. `sanihome.ir`).\n\n**Base URL:** `https://api.meshkee.com/api/v1` (or `https://api.{domain}/api/v1` if that alias is configured).\n\n**Tenant rule:** public content uses `/tenants/{domain}/...`. After login, cart/orders/favorites use `/businesses/{businessId}/...` with Bearer JWT.\n\n**Docs:** https://api.meshkee.com/docs/website"
},
"servers": [
{
"url": "https://api.meshkee.com/api/v1",
"description": "Production (central) — use this for all websites"
},
{
"url": "https://api.{domain}/api/v1",
"description": "Optional per-site alias (same backend). {domain} = website apex",
"variables": {
"domain": {
"default": "example.com"
}
}
}
],
"tags": [
{ "name": "Tenant" },
{ "name": "Homepage" },
{ "name": "Categories" },
{ "name": "Products" },
{ "name": "Store" },
{ "name": "Blogs" },
{ "name": "Portfolios" },
{ "name": "Comments" },
{ "name": "Expert Reviews" },
{ "name": "Contact" },
{ "name": "Auth" },
{ "name": "Addresses" },
{ "name": "Cities" },
{ "name": "Cart" },
{ "name": "Orders" },
{ "name": "Favorites" }
],
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
}
},
"parameters": {
"domain": {
"name": "domain",
"in": "path",
"required": true,
"description": "Website apex host only (e.g. sanihome.ir). No www/api/customer/business prefix.",
"schema": { "type": "string", "example": "example.com" }
},
"businessId": {
"name": "businessId",
"in": "path",
"required": true,
"description": "From GET /tenants/{domain} → id",
"schema": { "type": "string" }
}
}
},
"paths": {
"/tenants/{domain}": {
"get": {
"tags": ["Tenant"],
"summary": "Resolve website domain → business",
"parameters": [{ "$ref": "#/components/parameters/domain" }],
"responses": {
"200": {
"description": "Business branding",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"id": { "type": "string" },
"name": { "type": "string" },
"nameFa": { "type": "string" },
"slug": { "type": "string" },
"domain": { "type": "string" },
"primaryColor": { "type": "string", "nullable": true },
"logoUrl": { "type": "string", "nullable": true },
"faviconUrl": { "type": "string", "nullable": true }
}
}
}
}
}
}
}
},
"/tenants/{domain}/website/business-info": {
"get": {
"tags": ["Homepage"],
"summary": "About, contacts, addresses, social",
"parameters": [{ "$ref": "#/components/parameters/domain" }],
"responses": { "200": { "description": "Business public profile" } }
}
},
"/tenants/{domain}/website/sliders": {
"get": {
"tags": ["Homepage"],
"summary": "Homepage sliders + slides",
"parameters": [{ "$ref": "#/components/parameters/domain" }],
"responses": { "200": { "description": "{ items: Slider[] }" } }
}
},
"/tenants/{domain}/website/category-groups": {
"get": {
"tags": ["Homepage"],
"summary": "Homepage category groups",
"parameters": [{ "$ref": "#/components/parameters/domain" }],
"responses": { "200": { "description": "{ items: CategoryGroup[] }" } }
}
},
"/tenants/{domain}/website/brand-groups": {
"get": {
"tags": ["Homepage"],
"summary": "Homepage brand groups",
"parameters": [{ "$ref": "#/components/parameters/domain" }],
"responses": { "200": { "description": "{ items: BrandGroup[] }" } }
}
},
"/tenants/{domain}/store-specials": {
"get": {
"tags": ["Homepage", "Store"],
"summary": "Active store specials",
"parameters": [{ "$ref": "#/components/parameters/domain" }],
"responses": { "200": { "description": "{ items: StoreSpecial[] }" } }
}
},
"/tenants/{domain}/categories": {
"get": {
"tags": ["Categories"],
"summary": "Public categories",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{
"name": "entityType",
"in": "query",
"schema": {
"type": "string",
"enum": ["product", "blog", "portfolio"],
"default": "product"
}
}
],
"responses": { "200": { "description": "{ items: Category[] }" } }
}
},
"/tenants/{domain}/products": {
"get": {
"tags": ["Products"],
"summary": "List published products",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "page", "in": "query", "schema": { "type": "integer" } },
{ "name": "pageSize", "in": "query", "schema": { "type": "integer", "default": 12 } },
{ "name": "name", "in": "query", "schema": { "type": "string" } },
{ "name": "categoryId", "in": "query", "schema": { "type": "string" } },
{ "name": "brandId", "in": "query", "schema": { "type": "string" } },
{ "name": "tag", "in": "query", "schema": { "type": "string" } },
{ "name": "inStore", "in": "query", "schema": { "type": "boolean" } }
],
"responses": { "200": { "description": "{ items, total, page, pageSize }" } }
}
},
"/tenants/{domain}/products/{slug}": {
"get": {
"tags": ["Products"],
"summary": "Product by slug",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ product }" } }
}
},
"/tenants/{domain}/products/{slug}/variations": {
"get": {
"tags": ["Products"],
"summary": "Product variation options",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ variations }" } }
}
},
"/tenants/{domain}/products/{slug}/technical-info": {
"get": {
"tags": ["Products"],
"summary": "Product technical specs",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ form, values }" } }
}
},
"/tenants/{domain}/store-items": {
"get": {
"tags": ["Store"],
"summary": "List sellable variants",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "page", "in": "query", "schema": { "type": "integer" } },
{ "name": "pageSize", "in": "query", "schema": { "type": "integer", "default": 20 } },
{ "name": "categoryId", "in": "query", "schema": { "type": "string" } },
{ "name": "brandId", "in": "query", "schema": { "type": "string" } },
{ "name": "productId", "in": "query", "schema": { "type": "string" } },
{ "name": "name", "in": "query", "schema": { "type": "string" } },
{ "name": "inStock", "in": "query", "schema": { "type": "boolean" } },
{ "name": "isFestival", "in": "query", "schema": { "type": "boolean" } },
{ "name": "minPrice", "in": "query", "schema": { "type": "number" } },
{ "name": "maxPrice", "in": "query", "schema": { "type": "number" } }
],
"responses": { "200": { "description": "{ items, total, page, pageSize }" } }
}
},
"/tenants/{domain}/store-items/by-product/{productId}": {
"get": {
"tags": ["Store"],
"summary": "Variants for one product",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "productId", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ storeItem }" } }
}
},
"/tenants/{domain}/store-items/{variantId}": {
"get": {
"tags": ["Store"],
"summary": "One variant",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "variantId", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ variant }" } }
}
},
"/tenants/{domain}/blogs": {
"get": {
"tags": ["Blogs"],
"summary": "List published blogs",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "page", "in": "query", "schema": { "type": "integer" } },
{ "name": "pageSize", "in": "query", "schema": { "type": "integer", "default": 12 } },
{ "name": "type", "in": "query", "schema": { "type": "string", "enum": ["news", "article", "blog"] } },
{ "name": "categoryId", "in": "query", "schema": { "type": "string" } },
{ "name": "title", "in": "query", "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ items, total, page, pageSize }" } }
}
},
"/tenants/{domain}/blogs/{slug}": {
"get": {
"tags": ["Blogs"],
"summary": "Blog by slug",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ blog }" } }
}
},
"/tenants/{domain}/blogs/{blogId}/comments": {
"get": {
"tags": ["Blogs", "Comments"],
"summary": "Approved blog comments",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "blogId", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ items }" } }
},
"post": {
"tags": ["Blogs", "Comments"],
"summary": "Submit blog comment",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "blogId", "in": "path", "required": true, "schema": { "type": "string" } }
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["authorName", "text"],
"properties": {
"authorName": { "type": "string" },
"authorEmail": { "type": "string" },
"text": { "type": "string" }
}
}
}
}
},
"responses": { "201": { "description": "{ comment, message }" } }
}
},
"/tenants/{domain}/portfolios": {
"get": {
"tags": ["Portfolios"],
"summary": "List published portfolios",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "page", "in": "query", "schema": { "type": "integer" } },
{ "name": "pageSize", "in": "query", "schema": { "type": "integer", "default": 12 } },
{ "name": "categoryId", "in": "query", "schema": { "type": "string" } },
{ "name": "title", "in": "query", "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ items, total, page, pageSize }" } }
}
},
"/tenants/{domain}/portfolios/{slug}": {
"get": {
"tags": ["Portfolios"],
"summary": "Portfolio by slug",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ portfolio }" } }
}
},
"/tenants/{domain}/portfolios/{portfolioId}/comments": {
"get": {
"tags": ["Portfolios", "Comments"],
"summary": "Approved portfolio comments",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "portfolioId", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ items }" } }
},
"post": {
"tags": ["Portfolios", "Comments"],
"summary": "Submit portfolio comment",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "portfolioId", "in": "path", "required": true, "schema": { "type": "string" } }
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["authorName", "text"],
"properties": {
"authorName": { "type": "string" },
"authorEmail": { "type": "string" },
"text": { "type": "string" }
}
}
}
}
},
"responses": { "201": { "description": "{ comment, message }" } }
}
},
"/tenants/{domain}/comments": {
"get": {
"tags": ["Comments"],
"summary": "List approved comments for any entity",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{
"name": "entityType",
"in": "query",
"required": true,
"schema": { "type": "string", "enum": ["product", "blog", "portfolio"] }
},
{ "name": "entityId", "in": "query", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ items }" } }
},
"post": {
"tags": ["Comments"],
"summary": "Submit comment (product/blog/portfolio)",
"parameters": [{ "$ref": "#/components/parameters/domain" }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["entityType", "entityId", "authorName", "text"],
"properties": {
"entityType": { "type": "string", "enum": ["product", "blog", "portfolio"] },
"entityId": { "type": "string" },
"authorName": { "type": "string" },
"authorEmail": { "type": "string" },
"text": { "type": "string" }
}
}
}
}
},
"responses": { "201": { "description": "{ comment, message }" } }
}
},
"/tenants/{domain}/expert-reviews": {
"get": {
"tags": ["Expert Reviews"],
"summary": "Approved expert reviews for a product",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "productId", "in": "query", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ items }" } }
},
"post": {
"tags": ["Expert Reviews"],
"summary": "Submit expert review",
"parameters": [{ "$ref": "#/components/parameters/domain" }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["productId", "authorName", "rate", "positivePoints", "negativePoints", "text"],
"properties": {
"productId": { "type": "string" },
"authorName": { "type": "string" },
"authorEmail": { "type": "string" },
"rate": { "type": "integer", "minimum": 1, "maximum": 10 },
"positivePoints": { "type": "array", "items": { "type": "string" } },
"negativePoints": { "type": "array", "items": { "type": "string" } },
"text": { "type": "string" }
}
}
}
}
},
"responses": { "201": { "description": "{ review, message }" } }
}
},
"/tenants/{domain}/contact-submissions": {
"post": {
"tags": ["Contact"],
"summary": "Contact form",
"parameters": [{ "$ref": "#/components/parameters/domain" }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["title", "name", "text"],
"properties": {
"title": { "type": "string" },
"name": { "type": "string" },
"email": { "type": "string" },
"cellNumber": { "type": "string" },
"text": { "type": "string" }
}
}
}
}
},
"responses": { "201": { "description": "{ submission, message }" } }
}
},
"/auth/register": {
"post": {
"tags": ["Auth"],
"summary": "Register customer on a website",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["cellNumber", "password", "firstName", "lastName", "domain"],
"properties": {
"cellNumber": { "type": "string", "description": "E.164 e.g. +98912..." },
"password": { "type": "string", "minLength": 8 },
"firstName": { "type": "string" },
"lastName": { "type": "string" },
"email": { "type": "string" },
"domain": { "type": "string", "description": "Same website apex as {domain}" }
}
}
}
}
},
"responses": { "201": { "description": "{ user, accessToken, refreshToken, registeredBusiness }" } }
}
},
"/auth/login": {
"post": {
"tags": ["Auth"],
"summary": "Login",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["cellNumber", "password"],
"properties": {
"cellNumber": { "type": "string" },
"password": { "type": "string" }
}
}
}
}
},
"responses": { "200": { "description": "{ user, accessToken, refreshToken }" } }
}
},
"/auth/refresh": {
"post": {
"tags": ["Auth"],
"summary": "Refresh tokens",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["refreshToken"],
"properties": { "refreshToken": { "type": "string" } }
}
}
}
},
"responses": { "200": { "description": "{ user, accessToken, refreshToken }" } }
}
},
"/auth/me": {
"get": {
"tags": ["Auth"],
"summary": "Current user",
"security": [{ "bearerAuth": [] }],
"responses": { "200": { "description": "{ user }" } }
}
},
"/auth/profile": {
"patch": {
"tags": ["Auth"],
"summary": "Update profile",
"security": [{ "bearerAuth": [] }],
"responses": { "200": { "description": "{ message, user }" } }
}
},
"/auth/change-password": {
"post": {
"tags": ["Auth"],
"summary": "Change password",
"security": [{ "bearerAuth": [] }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["currentPassword", "newPassword"],
"properties": {
"currentPassword": { "type": "string" },
"newPassword": { "type": "string", "minLength": 8 }
}
}
}
}
},
"responses": { "200": { "description": "{ message }" } }
}
},
"/auth/send-otp": {
"post": {
"tags": ["Auth"],
"summary": "Send OTP SMS",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["cellNumber"],
"properties": { "cellNumber": { "type": "string" } }
}
}
}
},
"responses": { "200": { "description": "{ enabled, message, expiresInSeconds? }" } }
}
},
"/auth/verify-otp": {
"post": {
"tags": ["Auth"],
"summary": "Verify OTP",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["cellNumber", "code"],
"properties": {
"cellNumber": { "type": "string" },
"code": { "type": "string", "minLength": 6, "maxLength": 6 }
}
}
}
}
},
"responses": { "200": { "description": "{ enabled, verified, message }" } }
}
},
"/auth/addresses": {
"get": {
"tags": ["Addresses"],
"summary": "List my shipping addresses",
"security": [{ "bearerAuth": [] }],
"responses": { "200": { "description": "{ items }" } }
},
"post": {
"tags": ["Addresses"],
"summary": "Create address",
"security": [{ "bearerAuth": [] }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["province", "city", "address"],
"properties": {
"label": { "type": "string" },
"province": { "type": "string" },
"city": { "type": "string" },
"address": { "type": "string" },
"postalCode": { "type": "string" },
"landline": { "type": "string" }
}
}
}
}
},
"responses": { "201": { "description": "{ address }" } }
}
},
"/auth/addresses/{addressId}": {
"patch": {
"tags": ["Addresses"],
"summary": "Update address",
"security": [{ "bearerAuth": [] }],
"parameters": [
{ "name": "addressId", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ address }" } }
},
"delete": {
"tags": ["Addresses"],
"summary": "Delete address",
"security": [{ "bearerAuth": [] }],
"parameters": [
{ "name": "addressId", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ message }" } }
}
},
"/cities": {
"get": {
"tags": ["Cities"],
"summary": "Location tree (countries / provinces / cities)",
"parameters": [
{
"name": "level",
"in": "query",
"schema": { "type": "string", "enum": ["country", "province", "city"] }
},
{ "name": "parentId", "in": "query", "schema": { "type": "string" } },
{ "name": "parentSlug", "in": "query", "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ items }" } }
}
},
"/cities/{cityId}": {
"get": {
"tags": ["Cities"],
"summary": "Get one location node",
"parameters": [
{ "name": "cityId", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ city }" } }
}
},
"/businesses/{businessId}/cart": {
"get": {
"tags": ["Cart"],
"summary": "Get cart",
"security": [{ "bearerAuth": [] }],
"parameters": [{ "$ref": "#/components/parameters/businessId" }],
"responses": { "200": { "description": "{ cart }" } }
},
"delete": {
"tags": ["Cart"],
"summary": "Clear cart",
"security": [{ "bearerAuth": [] }],
"parameters": [{ "$ref": "#/components/parameters/businessId" }],
"responses": { "200": { "description": "{ message, cart }" } }
}
},
"/businesses/{businessId}/cart/items": {
"post": {
"tags": ["Cart"],
"summary": "Add variant to cart",
"security": [{ "bearerAuth": [] }],
"parameters": [{ "$ref": "#/components/parameters/businessId" }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["storeItemVariantId"],
"properties": {
"storeItemVariantId": { "type": "string" },
"quantity": { "type": "integer", "minimum": 1, "default": 1 }
}
}
}
}
},
"responses": { "201": { "description": "{ message, cart }" } }
}
},
"/businesses/{businessId}/cart/items/{itemId}": {
"patch": {
"tags": ["Cart"],
"summary": "Update cart line quantity",
"security": [{ "bearerAuth": [] }],
"parameters": [
{ "$ref": "#/components/parameters/businessId" },
{ "name": "itemId", "in": "path", "required": true, "schema": { "type": "string" } }
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["quantity"],
"properties": { "quantity": { "type": "integer", "minimum": 1 } }
}
}
}
},
"responses": { "200": { "description": "{ message, cart }" } }
},
"delete": {
"tags": ["Cart"],
"summary": "Remove cart line",
"security": [{ "bearerAuth": [] }],
"parameters": [
{ "$ref": "#/components/parameters/businessId" },
{ "name": "itemId", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ message, cart }" } }
}
},
"/businesses/{businessId}/cart/checkout": {
"post": {
"tags": ["Cart"],
"summary": "Checkout → create order",
"security": [{ "bearerAuth": [] }],
"parameters": [{ "$ref": "#/components/parameters/businessId" }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["payment"],
"properties": {
"addressId": { "type": "string" },
"shippingAddress": {
"type": "object",
"properties": {
"province": { "type": "string" },
"city": { "type": "string" },
"address": { "type": "string" },
"postalCode": { "type": "string" },
"landline": { "type": "string" }
}
},
"customerNotes": { "type": "string" },
"payment": {
"type": "object",
"required": ["type"],
"properties": {
"type": {
"type": "string",
"enum": ["pos", "cash", "transfer", "e_payment_gate"]
},
"posType": { "type": "string" },
"transferAccount": { "type": "string" },
"transferRefNumber": { "type": "string" },
"gatewayType": { "type": "string" },
"notes": { "type": "string" }
}
}
}
}
}
}
},
"responses": { "201": { "description": "{ message, order }" } }
}
},
"/businesses/{businessId}/orders": {
"get": {
"tags": ["Orders"],
"summary": "My orders",
"security": [{ "bearerAuth": [] }],
"parameters": [
{ "$ref": "#/components/parameters/businessId" },
{ "name": "page", "in": "query", "schema": { "type": "integer" } },
{ "name": "pageSize", "in": "query", "schema": { "type": "integer", "default": 20 } },
{
"name": "status",
"in": "query",
"schema": {
"type": "string",
"enum": ["pending", "confirmed", "processing", "shipped", "delivered", "cancelled"]
}
}
],
"responses": { "200": { "description": "{ items, total, page, pageSize }" } }
}
},
"/businesses/{businessId}/orders/{orderId}": {
"get": {
"tags": ["Orders"],
"summary": "My order detail",
"security": [{ "bearerAuth": [] }],
"parameters": [
{ "$ref": "#/components/parameters/businessId" },
{ "name": "orderId", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ order }" } }
}
},
"/businesses/{businessId}/favorites": {
"get": {
"tags": ["Favorites"],
"summary": "List favorites",
"security": [{ "bearerAuth": [] }],
"parameters": [
{ "$ref": "#/components/parameters/businessId" },
{ "name": "page", "in": "query", "schema": { "type": "integer" } },
{ "name": "pageSize", "in": "query", "schema": { "type": "integer", "default": 20 } }
],
"responses": { "200": { "description": "{ items, total, page, pageSize }" } }
},
"post": {
"tags": ["Favorites"],
"summary": "Add favorite",
"security": [{ "bearerAuth": [] }],
"parameters": [{ "$ref": "#/components/parameters/businessId" }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["productId"],
"properties": { "productId": { "type": "string" } }
}
}
}
},
"responses": { "201": { "description": "{ favorite, message }" } }
}
},
"/businesses/{businessId}/favorites/{productId}": {
"delete": {
"tags": ["Favorites"],
"summary": "Remove favorite",
"security": [{ "bearerAuth": [] }],
"parameters": [
{ "$ref": "#/components/parameters/businessId" },
{ "name": "productId", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ message }" } }
}
}
}
}
@@ -0,0 +1,58 @@
import {
Controller,
Get,
NotFoundException,
Param,
Res,
} from '@nestjs/common';
import type { Response } from 'express';
import { createReadStream, existsSync } from 'fs';
import { basename, extname, join } from 'path';
import { resolveWebsiteDocsRoot } from './website-docs.paths';
const ALLOWED_FILES = new Set([
'index.html',
'openapi.json',
'AI_PROMPT.md',
'Meshkee-Website-API.postman_collection.json',
'Meshkee-Website-API.global.postman_environment.json',
]);
const CONTENT_TYPES: Record<string, string> = {
'.html': 'text/html; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.md': 'text/markdown; charset=utf-8',
};
@Controller('docs/website')
export class WebsiteDocsController {
private readonly root = resolveWebsiteDocsRoot();
@Get()
getIndex(@Res() res: Response) {
return this.sendFile(res, 'index.html');
}
@Get(':fileName')
getFile(@Param('fileName') fileName: string, @Res() res: Response) {
const safe = basename(fileName);
if (!ALLOWED_FILES.has(safe)) {
throw new NotFoundException(`Unknown docs file: ${fileName}`);
}
return this.sendFile(res, safe);
}
private sendFile(res: Response, fileName: string) {
const filePath = join(this.root, fileName);
if (!existsSync(filePath)) {
throw new NotFoundException(
`Website docs not found on server (${fileName}). Deploy docs/website-api/ with the API.`,
);
}
const type = CONTENT_TYPES[extname(fileName)] ?? 'application/octet-stream';
res.setHeader('Content-Type', type);
res.setHeader('Cache-Control', 'public, max-age=300');
createReadStream(filePath).pipe(res);
}
}
+7
View File
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { WebsiteDocsController } from './website-docs.controller';
@Module({
controllers: [WebsiteDocsController],
})
export class WebsiteDocsModule {}
+19
View File
@@ -0,0 +1,19 @@
import { existsSync } from 'fs';
import { join } from 'path';
/** Resolve docs folder in prod (`dist/website-docs/static`) and repo `docs/website-api`. */
export function resolveWebsiteDocsRoot(): string {
const candidates = [
join(__dirname, 'static'),
join(process.cwd(), 'docs', 'website-api'),
join(process.cwd(), 'src', 'website-docs', 'static'),
];
for (const candidate of candidates) {
if (existsSync(join(candidate, 'index.html'))) {
return candidate;
}
}
return candidates[0];
}