Expose POST /public/sms/send with API key + domain allowlist for external backends like Balout, wire Meshkee OTP/message sends to Gama, and publish Partner SMS docs on /docs/website. Co-authored-by: Cursor <cursoragent@cursor.com>
26 KiB
Meshkee CMS API — Project Context
Living reference for developers and AI assistants working on this codebase. Last updated: August 4, 2026
What This Project Is
Meshkee CMS API (meshkee-cms-api) is a multi-tenant backend for Meshkee business websites. Each business gets its own domain, content (products, categories, media), team members, and customer registrations. Platform super admins manage businesses and domains; business owners and staff manage per-business content through a permission-based dashboard.
API base URL: /api/v1
Tech Stack
| Layer | Technology |
|---|---|
| Runtime | Node.js, TypeScript (ES2021, strict) |
| Framework | NestJS 11 |
| ORM | Prisma 6 (prisma db pull — schema is introspected, not migrated via Prisma) |
| Database | PostgreSQL 16 |
| Cache | Redis 7 (OTP storage) |
| Auth | JWT (access + refresh), Passport, bcrypt |
| Validation | class-validator + class-transformer |
| File storage | S3-compatible (Parspack), Sharp for image processing |
Architecture
Multi-tenancy model
Platform (super admin)
└── Business (tenant root)
├── Domains (host → business resolution)
├── Team members (BusinessUser + Role)
├── Customers (BusinessCustomer)
├── Categories (per entityType: product | blog | portfolio)
├── Products
│ └── Store items (product variants — price & stock per variation combo)
├── Media library
└── Settings (JSON)
- Tenant resolution:
GET /tenants/:hostresolves a domain to a business (public, no auth). - Business-scoped APIs: Most CMS routes use
businesses/:businessId/...with JWT + business permission checks. - Platform APIs: User/business/domain management requires
super_admin(checked in services).
Two "category" concepts
| Concept | Table | Purpose |
|---|---|---|
| Content Category | categories |
Per-business taxonomy for products, blogs, portfolios |
| Business Category | business_categories |
Platform-wide taxonomy classifying businesses (Retail, Creative, etc.) |
Do not confuse them when reading or writing code.
Directory Structure
src/
├── main.ts # Bootstrap, global prefix, pipes, interceptors
├── app.module.ts # Root module wiring
├── auth/ # JWT, OTP/SMS, permissions, guards, decorators
├── users/ # Super-admin user management
├── roles/ # Global + team role listing
├── business-admin/ # Super-admin business CRUD + domains
├── business-team/ # Per-business team invite/manage
├── tenant/ # Domain → business resolution (public)
├── domain-admin/ # Super-admin domain management
├── categories/ # Content categories, variations, technical forms
├── products/ # Products, variants, technical info
├── cart/ # Customer shopping cart
├── orders/ # Customer checkout + admin order management
├── media/ # Upload/list/update/delete media
├── storage/ # S3 driver abstraction
├── website-docs/ # Public website API docs pack
├── invoices/ # Platform invoices + item templates (super-admin; business-ready schema)
├── public-sms/ # Partner SMS gateway (API key + domain allowlist → Gama)
├── prisma/ # PrismaModule + PrismaService
├── redis/ # Redis client + OTP helpers
└── common/ # Shared interceptors (BigInt serialization)
prisma/schema.prisma # Introspected from PostgreSQL (source of truth after migrations)
database/
├── migrations/ # Raw SQL migrations (numbered)
├── seeds/ # Dev sample data + production super-admin
├── migrate.sh, seed.sh, setup.sh
postman/ # API collection for manual testing
NestJS module pattern
Each feature follows: *.module.ts → *.controller.ts → *.service.ts → dto/
Getting Started
cp .env.example .env
docker compose up -d # Postgres (auto-runs migrations on first init) + Redis
./database/seed.sh # Sample dev data
npm install
npm run prisma:generate
npm run start:dev # http://localhost:3000/api/v1
Dev credentials (after 001_sample_data.sql seed): any seeded user, password password.
Example super admin: +989121111111 / password
Database & Migrations
Workflow
- Write a new SQL file in
database/migrations/(e.g.012_feature.sql). - Apply via
./database/migrate.shordocker execinto Postgres. - Run
npm run prisma:pullto syncprisma/schema.prisma. - Run
npm run prisma:generateto regenerate the client.
Important: This project does not use Prisma Migrate. SQL migrations are the authoritative schema history. Prisma schema is maintained separately via introspection.
Migration files
| File | Purpose |
|---|---|
001_initial_schema.sql |
Users, businesses, domains |
002_phone_permissions_content_media.sql |
RBAC, media, products, blogs, portfolios |
003_categories.sql |
Content categories + assignments |
004_user_types_and_business_members.sql |
Super admin, business owner, customer roles |
005_business_team_roles.sql |
Team roles (admin, editor, viewer) |
006_business_categories.sql |
Platform business category taxonomy |
006_user_profile.sql |
User profile JSON |
007_business_i18n_fields.sql |
Business Persian name |
007_domain_expiry_active.sql |
Domain expiry/active flags |
008_remove_name_en.sql |
Schema cleanup |
009_categories_name_fa.sql |
Category name_fa |
010_category_variations.sql |
Category variations, product variants (store items) |
011_category_technical_forms.sql |
Technical forms + product values |
012_comments.sql |
Product comments |
013_expert_reviews.sql |
Expert reviews |
014_addresses.sql |
User/business street addresses |
015_business_profile.sql |
Business profile fields |
015_cities.sql |
Location cities (country → province → city) |
016_product_variation_values.sql |
Product-level variation option selections (input for store items) |
017_product_variant_festival.sql |
Festival flag on store items |
018_product_variant_reward_points.sql |
Reward points on store items |
019_cart_and_orders.sql |
Shopping cart, orders, order items + order permissions |
020_store_items_and_variants.sql |
store_items + store_item_variants (replaces product_variants) |
036_invoices.sql |
Invoices, invoice items, invoice item templates + permissions |
037_invoice_name.sql |
Optional invoices.name |
038_invoice_templates.sql |
Full invoice templates + key points/accounts on invoices |
039_invoice_account_holder.sql |
account_holder_name on invoice / template accounts |
040_invoice_public_id.sql |
Opaque public_id for unguessable public invoice links |
049_user_name_en.sql |
Optional users.first_name_en / last_name_en for EN display names |
Docker mounts ./database/migrations into Postgres init — migrations run automatically only on first volume creation. Use migrate.sh for subsequent migrations.
Domain Model
Enums
| Enum | Values |
|---|---|
MediaEntityType |
product, blog, portfolio |
ContentStatus |
draft, published, archived |
VariationType |
color, size, custom |
OrderStatus |
pending, confirmed, processing, shipped, delivered, cancelled |
OrderSource |
website, admin |
InvoiceOwnerScope |
platform, business |
InvoiceStatus |
draft, issued, approved, paid, cancelled |
TechnicalFieldType |
text, textarea, select, multi_select |
MediaType |
image, video |
Core relationships
Business 1──* Domain
Business 1──* Category (entityType: product|blog|portfolio)
Business 1──* Product
Business 1──* Media
Category 1──* CategoryVariation 1──* CategoryVariationOption
Category 1──0..1 CategoryTechnicalForm 1──* CategoryTechnicalFormField
CategoryTechnicalFormField 1──* CategoryTechnicalFormFieldOption
Product *──0..1 Category (via CategoryAssignment)
Product 1──* ProductVariationValue → CategoryVariationOption (which options this product offers)
Product 1──* ProductVariationValue → CategoryVariationOption (which options this product offers)
Product 1──0..1 StoreItem (one shop listing per product)
StoreItem 1──* StoreItemVariant (purchasable SKUs: price, stock, variation combo)
StoreItemVariant 1──* StoreItemVariantSelection → CategoryVariationOption
Product 1──* ProductTechnicalFieldValue → CategoryTechnicalFormField
Business 1──* Cart (per customer) 1──* CartItem → ProductVariant
Business 1──* Order 1──* OrderItem → ProductVariant (snapshot on order)
User 1──* Cart, Order (as customer)
Business 1──* Invoice (billed party) 1──* InvoiceItem
InvoiceItemTemplate (platform or per-business predefined lines)
Invoice.issuedBy → User
Media 1──* MediaAttachment (polymorphic: entityType + entityId)
Branding & dashboard locale
businesses.settings.branding.defaultLocale is 'fa' | 'en' (default fa). Exposed on tenant resolve and editable from super-admin. Dashboards apply it once on load.
Optional EN name fields: users.first_name_en / last_name_en (migration 049).
Dashboard activity (CMS only — not website-facing)
| Method | Path | Description |
|---|---|---|
| GET | /businesses/:businessId/orders/activity?days=30 |
Dual daily series: orders + cart-item creates |
| GET | /businesses/:businessId/customers/activity?days=30 |
Dual daily series: registrations + active logins (last_login_at) |
Lightweight GROUP BY counts for business home charts. Requires orders.read.
API Reference
All routes are prefixed with /api/v1.
Public
| Method | Path | Description |
|---|---|---|
| POST | /auth/register |
Customer registration by domain |
| POST | /auth/login |
Cell + password |
| POST | /auth/refresh |
Refresh token |
| POST | /auth/send-otp |
Send OTP (Redis-backed) |
| POST | /auth/verify-otp |
Verify OTP |
| GET | /tenants/:host |
Resolve business from domain |
| GET | /tenants/:host/store-specials |
Active store specials |
| GET | /tenants/:host/website/category-groups |
Homepage category rows |
| GET | /tenants/:host/website/brand-groups |
Homepage brand rows |
| GET | /tenants/:host/website/sliders |
Homepage sliders with slides |
Authenticated (JWT)
| Method | Path | Access |
|---|---|---|
| GET | /auth/me |
Any user |
| PATCH | /auth/profile |
Any user |
| POST | /auth/change-password |
Any user |
| GET | /roles?scope=global|team |
Super admin / team.read |
| GET | /business-categories |
Super admin or business_categories.read |
Super admin only (service-level check)
| Module | Base path |
|---|---|
| Users | /users |
| Businesses | /businesses |
| Domains | /domains |
Business-scoped (JWT + BusinessPermissionGuard)
Pattern: /businesses/:businessId/<resource>
| Module | Base path | Key permissions |
|---|---|---|
| Team | /team |
business.team.* |
| Media | /media |
media.* |
| Categories | /categories |
categories.* |
| Products | /products |
products.* |
| Website | /website/category-groups, /website/brand-groups, /website/sliders |
website.* |
Categories — notable sub-routes
| Method | Path | Description |
|---|---|---|
| GET | /categories/color-presets |
Predefined color palette |
| GET/PUT | /categories/:id/variations |
Category variation options |
| GET/PUT | /categories/:id/technical-form |
Technical form definition |
Products — notable sub-routes
| Method | Path | Description |
|---|---|---|
| GET/PUT | /products/:id/variations |
Which category variation options apply to this product |
| GET/POST/PATCH/DELETE | /products/:id/variants |
Removed — use /store-items |
| GET/PUT | /products/:id/technical-info |
Product technical data |
Cart (customer — JWT, must be business customer)
| Method | Path | Description |
|---|---|---|
| GET | /cart |
Get current user's cart |
| POST | /cart/items |
Add store item variant to cart (storeItemVariantId, quantity) |
| PATCH | /cart/items/:itemId |
Update cart item quantity |
| DELETE | /cart/items/:itemId |
Remove cart item |
| DELETE | /cart |
Clear cart |
| POST | /cart/checkout |
Place order from cart (requires addressId or shippingAddress) |
Orders
| Method | Path | Access | Description |
|---|---|---|---|
| GET | /orders |
Customer (own) or orders.read (all) |
List orders |
| GET | /orders/activity |
Admin orders.read |
Daily order + cart-add counts |
| GET | /orders/:orderId |
Customer (own) or orders.read |
Order detail |
| POST | /orders |
orders.create |
Admin: create order for a customer |
| PATCH | /orders/:orderId |
orders.update |
Admin: update status / admin notes |
Auth & RBAC
Global user roles
| Role slug | Dashboard | Notes |
|---|---|---|
super_admin |
super_admin |
Full platform access |
business_owner |
business |
Assigned to business owners |
business_staff |
business |
Legacy; team uses per-business roles |
customer |
customer |
Website registrants |
Per-business team roles
Assigned via business_users.role_id:
| Role | Slug | Typical access |
|---|---|---|
| Owner | isOwner=true |
All business_owner permissions |
| Admin | admin |
Full content + team read |
| Editor | editor |
Create/edit/publish content |
| Viewer | viewer |
Read-only |
Permission groups (seeded)
business.*, domains.*, products.*, blogs.*, portfolios.*, media.*, categories.*, users.*, roles.manage, business.team.*, business_categories.*
Each resource typically has: read, create, update, delete (+ publish for content).
Guards & decorators
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
@RequireBusinessPermission('products.read')
JwtAuthGuard— validates Bearer JWT (type: 'access')BusinessPermissionGuard— checks permission forbusinessIdroute paramPermissionsService—isSuperAdmin(),hasBusinessPermission()- Super admins bypass business permission checks
@CurrentUser()injectsAuthUserinto handlers
Auth flow notes
- Registration resolves tenant by
domain→ creates/links user → assignscustomerrole - OTP stored in Redis (
otp:{cellNumber}), 5-min TTL; disabled whenSMS_ENABLED=false - JWT payload:
sub,cellNumber,roles,dashboard,type - SMS provider: Gama (
sms.igama.ir) SendQuick via service shortcode (SMS_GAMA_*) - Partner gateway (external sites like Balout):
POST /api/v1/public/sms/sendwithX-Api-Key+ body{ domain, to, message }; partners configured inSMS_PARTNERS(domain:apiKeypairs). Rate limits: 30/partner/min and 5/destination/min. Not part of storefront website-api docs.
Key Features
Products
- CRUD with slug, status, featured media, gallery attachments
- One category per product (via
CategoryAssignment) - Content JSON:
{ nameFa, html }; metadata JSON:{ tags } - i18n: Persian name in
content.nameFa, summary indescription
Category variations (product categories)
- Types:
color(preset palette),size,custom - One color + one size per category (DB partial unique indexes)
PUT /categories/:id/variationsreplaces all variations (delete-all + recreate)- Color presets:
GET /categories/color-presets(15 named colors with hex)
Store (shop listings)
The store turns CMS products into purchasable items using a two-level model:
Product variations → which options this product offers (product_variation_values)
Store item → one shop listing per product (store_items)
Store item variants → purchasable SKUs with price/stock per combination (store_item_variants)
| Layer | Table | API | Purpose |
|---|---|---|---|
| Product variations | product_variation_values |
PUT /products/:id/variations |
Which category variation options apply to this product |
| Store item | store_items |
GET /store-items/by-product/:productId |
One listing per product in the shop |
| Store item variants | store_item_variants + store_item_variant_selections |
POST /store-items, PUT /store-items/sync |
Purchasable combinations with price & stock |
Store item variant fields (store_item_variants):
| Field | DB column | Notes |
|---|---|---|
sku |
sku |
Optional merchant SKU |
price |
price |
Selling price (NUMERIC(12,2)) |
compareAtPrice |
compare_at_price |
Optional strike-through / was-price |
stockQuantity |
stock_quantity |
Integer ≥ 0; null = untracked |
isActive |
is_active |
Whether variant is available for purchase |
selections |
store_item_variant_selections |
Exactly one CategoryVariationOption per category variation |
Rules:
- One
store_itemsrow per product per business. - Create variants under the store item after setting product variation values.
- Each variant picks one option per variation; the combination must be unique per store item.
- Cart and orders reference
storeItemVariantId(not the product directly).
Example — batch create variants for a product:
POST /businesses/:businessId/store-items
{
"productId": "1",
"items": [
{
"selections": [
{ "variationId": "1", "optionId": "1" },
{ "variationId": "2", "optionId": "5" }
],
"price": 99.99,
"stockQuantity": 10
}
]
}
Example — update a variant:
PATCH /businesses/:businessId/store-items/variants/:variantId
{
"price": 89.99,
"stockQuantity": 25
}
Technical forms (product categories)
- One form per product category
- Field types:
text,textarea,select,multi_select PUT /categories/:id/technical-form— define/replace form fieldsPUT /products/:id/technical-info— fill product values (validated against category form)- Product must have a category assignment
Example — define form:
PUT /businesses/:businessId/categories/:categoryId/technical-form
{
"fields": [
{ "label": "Weight", "type": "text", "isRequired": true },
{ "label": "Material", "type": "select", "options": ["Cotton", "Polyester"] },
{ "label": "Features", "type": "multi_select", "options": ["Waterproof", "Breathable"] }
]
}
Example — fill product data:
PUT /businesses/:businessId/products/:productId/technical-info
{
"values": [
{ "fieldKey": "weight", "value": "500g" },
{ "fieldKey": "material", "value": "cotton" },
{ "fieldKey": "features", "value": ["waterproof", "breathable"] }
]
}
Field keys are auto-slugified from labels (e.g. "Weight" → "weight").
Media
- Multipart upload to Parspack S3 via Sharp processing
- Object keys:
meshkee/businesses/{businessId}/media/{uuid}{ext}(library),meshkee/businesses/{businessId}/brand/favicon-*.png(derived favicons) - Polymorphic attachments to products (and future blog/portfolio entities)
- Featured image on products via
featuredMediaId
Coding Conventions
DTOs
class-validatordecorators on all request bodies and query params@Type(() => Number)for query param coercion- Slug format:
^[a-z0-9]+(?:-[a-z0-9]+)*$ - Cell numbers: E.164
^\+[1-9]\d{6,14}$
Services
- Inject
PrismaService,PermissionsService - Convert route param IDs with
BigInt(idRaw) - Private
assertPermission()/assertSuperAdmin()helpers - Use
$transactionfor multi-step writes - Replace semantics for nested resources (variations, technical forms) — delete-all then recreate
Error handling
Use Nest exceptions: NotFoundException, ForbiddenException, BadRequestException, ConflictException, UnauthorizedException.
Serialization
- Global
BigIntSerializerInterceptorconverts BigInt → Number in JSON responses - Services use private
serialize()methods for consistent output shapes - IDs returned as strings in API responses
Global validation pipe (main.ts)
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
})
Environment Variables
See .env.example for the full list. Key groups:
| Group | Variables |
|---|---|
| Database | DATABASE_URL, POSTGRES_* |
| Redis | REDIS_URL, REDIS_HOST, REDIS_PORT |
| API | PORT |
| JWT | JWT_ACCESS_SECRET, JWT_REFRESH_SECRET, JWT_*_EXPIRES_IN |
| SMS | SMS_ENABLED, SMS_GAMA_BASE_URL, SMS_GAMA_USERNAME, SMS_GAMA_PASSWORD, SMS_GAMA_SOURCE_SERVICE, SMS_PARTNERS |
| S3 | S3_ENDPOINT, S3_BUCKET, S3_PUBLIC_URL, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY |
| Legacy MySQL (WillaEngine migrate) | OLD_MYSQL_HOST, OLD_MYSQL_PORT, OLD_MYSQL_USER, OLD_MYSQL_PASSWORD, OLD_MYSQL_DATABASE |
| Legacy S3 source (media copy) | OLD_S3_ENDPOINT, OLD_S3_BUCKET, OLD_S3_PUBLIC_URL, OLD_S3_ACCESS_KEY_ID, OLD_S3_SECRET_ACCESS_KEY |
| Media | MEDIA_MAX_FILE_SIZE_MB |
Implemented vs Planned
Implemented
- Multi-tenant auth (register, login, OTP, profile)
- Super admin: users, businesses, domains, system business categories
- Super admin: selective migrate-from-old + purge-data (portfolio categories + portfolios; oversized images resized to max 1280×1280; purge removes portfolios + images)
- Business team management
- Media upload (S3 + Sharp)
- Categories (all entity types in DB; API supports
entityTypefilter) - Products CRUD
- Product variation values (which options a product offers)
- Store items / product variants (price, stock, SKU)
- Shopping cart + checkout + orders (customer + admin)
- Product technical info
- Category variations & technical forms
- Tenant resolution by domain
- RBAC with granular permissions
- Partner SMS gateway (
POST /public/sms/send) + Gama SendQuick integration
Planned / partial
| Feature | DB | Permissions | API | Prisma |
|---|---|---|---|---|
| Blogs | Yes | Yes | No | No model |
| Portfolios | Yes | Yes | Partial (migrate-from-old) | Yes |
| Customer dashboard | Partial | No | Register only | Yes |
| SMS provider | — | — | Stub | — |
| Store checkout (cart, orders) | Yes | Yes | Yes | Yes |
| Customer favorites | — | favorites.* seeded |
No | No |
Invoices (platform / super-admin)
Super admins issue invoices to a business. Schema is ready for future business-scoped issuing (owner_scope=business).
Tables
| Table | Purpose |
|---|---|
invoice_item_templates |
Predefined line items (owner_scope platform | business) |
invoice_templates |
Full blueprints: name, top_text |
invoice_template_items / _key_points / _accounts |
Nested template content |
invoices |
Invoice header (business_id = billed party, optional name, top_text, notes, invoice_template_id, status) |
invoice_items / invoice_key_points / invoice_accounts |
Issued invoice nested content |
API (super_admin only today)
| Method | Path |
|---|---|
| GET/POST | /invoice-item-templates |
| PATCH/DELETE | /invoice-item-templates/:templateId |
| GET/POST | /invoice-templates |
| GET/PATCH/DELETE | /invoice-templates/:templateId |
| GET/POST | /businesses/:businessId/invoices |
| GET/PUT/PATCH/DELETE | /businesses/:businessId/invoices/:invoiceId (PUT = content; PATCH = status; content locked when approved) |
| GET | /public/invoices/:publicId (no auth; issued/approved/paid; opaque 12-digit id) |
| POST | /public/invoices/:publicId/approve (no auth; issued → approved) |
Auth (admin routes): JwtAuthGuard + service assertSuperAdmin.
Serialized platform invoices include publicId + publicUrl: https://{INVOICE_PUBLIC_DOMAIN}/invoices/{publicId} (default meshkee.com), or {INVOICE_PUBLIC_BASE_URL}/invoices/{publicId} when set. Accounts include optional accountHolderName.
Public HTML viewer lives in the dashboards super-admin SPA (/invoices/:publicId); API serves JSON via /public/invoices/:publicId (sequential PK is not accepted).
Permissions seeded for future business dashboard: invoices.*, invoice_templates.*.
Module: src/invoices/ · Migrations: 036 … 041
Testing
Postman collection: postman/Meshkee-CMS-Auth.postman_collection.json
Variables: baseUrl, accessToken, refreshToken, domain, businessId, categoryId, productId, variantId, variationId, optionId, cartItemId, orderId
Store workflow in Postman: Business Categories → set variations → Business Products → create product → set product variations → create store item (variant) with price & stock → Website - Cart (login as customer) → add to cart → checkout → Business Orders (login as owner) to manage.
Common Tasks for Contributors
Add a new API feature
- Write SQL migration in
database/migrations/ - Apply migration, then
npm run prisma:pull && npm run prisma:generate - Create module:
src/<feature>/with controller, service, DTOs - Register in
app.module.ts - Add permissions to migration if business-scoped
- Update this document
Add a business-scoped endpoint
- Controller:
@Controller('businesses/:businessId/...') - Guards:
@UseGuards(JwtAuthGuard, BusinessPermissionGuard) - Permission:
@RequireBusinessPermission('resource.action') - Service:
assertPermission(businessId, actor.id, 'resource.action')(defense in depth)
Add a nested replace resource (like variations)
Follow the pattern in CategoryVariationsService / CategoryTechnicalFormService:
GETreturns current statePUTvalidates input, deletes all existing, recreates in a transaction
Related Files
| Purpose | Path |
|---|---|
| Prisma schema | prisma/schema.prisma |
| Env template | .env.example (INVOICE_PUBLIC_DOMAIN / optional INVOICE_PUBLIC_BASE_URL) |
| Invoices module | src/invoices/ |
| Invoice migrations | database/migrations/036_invoices.sql … 040_invoice_public_id.sql |
| Docker services | docker-compose.yml |
| Dev seed data | database/seeds/001_sample_data.sql |
| Postman | postman/Meshkee-CMS-Auth.postman_collection.json |