From f4295e780c11f3e6a0bfcd56a8c7934bb56adf7f Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Tue, 4 Aug 2026 11:09:21 +0330 Subject: [PATCH] Add partner SMS gateway via Gama SendQuick and document it. 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 --- .env.example | 10 +- docs/PROJECT_CONTEXT.md | 8 +- docs/website-api/AI_PROMPT.md | 1 + ...eshkee-Website-API.postman_collection.json | 32 ++++ docs/website-api/SMS.md | 85 +++++++++ docs/website-api/index.html | 12 ++ docs/website-api/openapi.json | 65 ++++++- src/app.module.ts | 2 + src/auth/sms.service.ts | 179 ++++++++++++++++-- src/public-sms/dto/send-public-sms.dto.ts | 19 ++ src/public-sms/public-sms.controller.ts | 20 ++ src/public-sms/public-sms.module.ts | 12 ++ src/public-sms/public-sms.service.ts | 73 +++++++ src/public-sms/sms-partner.guard.ts | 48 +++++ src/public-sms/sms-partners.util.ts | 41 ++++ src/redis/redis.service.ts | 16 ++ src/website-docs/static/AI_PROMPT.md | 1 + ...eshkee-Website-API.postman_collection.json | 32 ++++ src/website-docs/static/SMS.md | 85 +++++++++ src/website-docs/static/index.html | 12 ++ src/website-docs/static/openapi.json | 65 ++++++- src/website-docs/website-docs.controller.ts | 1 + 22 files changed, 803 insertions(+), 16 deletions(-) create mode 100644 docs/website-api/SMS.md create mode 100644 src/public-sms/dto/send-public-sms.dto.ts create mode 100644 src/public-sms/public-sms.controller.ts create mode 100644 src/public-sms/public-sms.module.ts create mode 100644 src/public-sms/public-sms.service.ts create mode 100644 src/public-sms/sms-partner.guard.ts create mode 100644 src/public-sms/sms-partners.util.ts create mode 100644 src/website-docs/static/SMS.md diff --git a/.env.example b/.env.example index b690430..04aec4c 100644 --- a/.env.example +++ b/.env.example @@ -21,8 +21,16 @@ JWT_REFRESH_SECRET=change-me-refresh-secret-min-32-chars-long JWT_ACCESS_EXPIRES_IN=15m JWT_REFRESH_EXPIRES_IN=7d -# SMS (set to true when SMS provider API is ready) +# SMS — Gama (گاما) SendQuick via service shortcode SMS_ENABLED=false +SMS_GAMA_BASE_URL=https://sms.igama.ir/api/v1 +SMS_GAMA_USERNAME= +SMS_GAMA_PASSWORD= +SMS_GAMA_SOURCE_SERVICE=5000110005 +# Optional later: SMS_GAMA_SOURCE_ADVERTISE=500099000005 +# Partner gateway: domain:apiKey pairs, comma-separated (www. is stripped) +# Example: SMS_PARTNERS=baloutpastry.com:replace-with-long-random-secret +SMS_PARTNERS= # Object storage (Parspack / S3-compatible) # Bucket id is the Parspack account id. Object keys live under meshkee/... diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index d13b6a4..5d45035 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -1,7 +1,7 @@ # Meshkee CMS API — Project Context > Living reference for developers and AI assistants working on this codebase. -> Last updated: August 1, 2026 +> Last updated: August 4, 2026 ## What This Project Is @@ -79,6 +79,7 @@ src/ ├── 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) @@ -361,6 +362,8 @@ Each resource typically has: `read`, `create`, `update`, `delete` (+ `publish` f - Registration resolves tenant by `domain` → creates/links user → assigns `customer` role - OTP stored in Redis (`otp:{cellNumber}`), 5-min TTL; disabled when `SMS_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/send` with `X-Api-Key` + body `{ domain, to, message }`; partners configured in `SMS_PARTNERS` (`domain:apiKey` pairs). Rate limits: 30/partner/min and 5/destination/min. Not part of storefront website-api docs. --- @@ -537,7 +540,7 @@ See `.env.example` for the full list. Key groups: | Redis | `REDIS_URL`, `REDIS_HOST`, `REDIS_PORT` | | API | `PORT` | | JWT | `JWT_ACCESS_SECRET`, `JWT_REFRESH_SECRET`, `JWT_*_EXPIRES_IN` | -| SMS | `SMS_ENABLED` | +| 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` | @@ -563,6 +566,7 @@ See `.env.example` for the full list. Key groups: - Category variations & technical forms - Tenant resolution by domain - RBAC with granular permissions +- Partner SMS gateway (`POST /public/sms/send`) + Gama SendQuick integration ### Planned / partial diff --git a/docs/website-api/AI_PROMPT.md b/docs/website-api/AI_PROMPT.md index 686b918..5b0ddeb 100644 --- a/docs/website-api/AI_PROMPT.md +++ b/docs/website-api/AI_PROMPT.md @@ -26,6 +26,7 @@ You are building a **Meshkee business website (storefront)**. You must use the M 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.). +7. **Partner SMS** (`POST /public/sms/send`) is for external partner backends with an issued `X-Api-Key` only — not for normal storefront UI. See https://api.meshkee.com/docs/website/SMS.md ### Typical bootstrap sequence 1. `GET /tenants/{domain}` → branding + `businessId` diff --git a/docs/website-api/Meshkee-Website-API.postman_collection.json b/docs/website-api/Meshkee-Website-API.postman_collection.json index 33df96f..b1f86c2 100644 --- a/docs/website-api/Meshkee-Website-API.postman_collection.json +++ b/docs/website-api/Meshkee-Website-API.postman_collection.json @@ -100,6 +100,10 @@ { "key": "brandId", "value": "" + }, + { + "key": "smsApiKey", + "value": "" } ], "item": [ @@ -1669,6 +1673,34 @@ } } ] + }, + { + "name": "Partner SMS", + "description": "Server-to-server SMS for allowlisted partner domains. Set collection variable `smsApiKey`. Docs: https://api.meshkee.com/docs/website/SMS.md", + "item": [ + { + "name": "Send SMS", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-Api-Key", + "value": "{{smsApiKey}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"domain\": \"baloutpastry.com\",\n \"to\": \"09127004945\",\n \"message\": \"سفارش شما به شماره ی ۱۲۱۱۳۲۲ اماده می باشد.\"\n}" + }, + "url": "{{baseUrl}}/public/sms/send", + "description": "Requires allowlisted domain + matching API key. Not for browser JS." + } + } + ] } ] } diff --git a/docs/website-api/SMS.md b/docs/website-api/SMS.md new file mode 100644 index 0000000..f7405ae --- /dev/null +++ b/docs/website-api/SMS.md @@ -0,0 +1,85 @@ +# Partner SMS gateway + +Server-to-server SMS via Meshkee → Gama (گاما). Use this when a **non-Meshkee** (or partner) backend needs to send SMS through the shared Meshkee account. + +**Not for browser/storefront JavaScript.** Never put the API key in frontend code. + +Hub: https://api.meshkee.com/docs/website +Endpoint docs also in OpenAPI / Postman under **Partner SMS**. + +--- + +## Endpoint + +```http +POST https://api.meshkee.com/api/v1/public/sms/send +Content-Type: application/json +X-Api-Key: +``` + +### Body + +| Field | Required | Description | +|-------|----------|-------------| +| `domain` | yes | Allowlisted partner apex, e.g. `baloutpastry.com` (`www.` is stripped) | +| `to` | yes | Mobile: `09…`, `9…`, `+989…`, or `989…` | +| `message` | yes | Free text, max 700 characters | + +### Example (Balout) + +```bash +curl -sS -X POST 'https://api.meshkee.com/api/v1/public/sms/send' \ + -H 'Content-Type: application/json' \ + -H 'X-Api-Key: ' \ + -d '{ + "domain": "baloutpastry.com", + "to": "09127004945", + "message": "سفارش شما به شماره ی ۱۲۱۱۳۲۲ اماده می باشد." + }' +``` + +### Success (200) + +```json +{ + "success": true, + "serverId": "1136923081051406337" +} +``` + +`serverId` is the Gama message id (delivery tracking). + +### Errors + +| HTTP | Meaning | +|------|---------| +| 400 | Invalid phone or empty/too-long message | +| 401 | Missing/invalid `X-Api-Key` or domain not allowlisted | +| 429 | Rate limit: **30**/partner/minute or **5**/destination/minute | +| 503 | SMS disabled, missing provider config, or Gama unreachable | + +--- + +## Auth model + +1. Meshkee configures `SMS_PARTNERS=domain:apiKey,...` on the API server. +2. Partner backend sends `X-Api-Key` + matching `domain` in the JSON body. +3. Key must match that domain (timing-safe compare). `www.baloutpastry.com` normalizes to `baloutpastry.com`. + +First allowlisted partner: **baloutpastry.com**. + +--- + +## Sender line (v1) + +Uses the **service** shortcode only (`SendQuick`). Advertising / bulk / OTP pattern APIs are not exposed yet. + +--- + +## Rules for partner backends + +1. Call from your **server** only (Balout API → Meshkee API). +2. Keep the API key in server env / secrets — never in the website frontend. +3. Prefer short transactional messages (order ready, OTP-style text, etc.). +4. Respect rate limits; backoff on `429`. +5. Meshkee websites that already use customer auth OTP go through Meshkee’s own auth/SMS path — they do **not** need this partner endpoint. diff --git a/docs/website-api/index.html b/docs/website-api/index.html index 9191fb0..d49338c 100644 --- a/docs/website-api/index.html +++ b/docs/website-api/index.html @@ -77,6 +77,7 @@ OpenAPI JSON Download Postman AI prompt + Partner SMS

Base URL

@@ -104,6 +105,17 @@ Postman → Import → Link → paste
https://api.meshkee.com/docs/website/Meshkee-Website-API.postman_collection.json

+ +

Partner SMS gateway

+

+ External backends (e.g. Balout) can send transactional SMS through Meshkee → Gama. + Server-to-server only — API key per allowlisted domain. See + SMS.md. +

+
+

POST /api/v1/public/sms/send

+

Header X-Api-Key + body { domain, to, message }

+
diff --git a/docs/website-api/openapi.json b/docs/website-api/openapi.json index b473517..87048ce 100644 --- a/docs/website-api/openapi.json +++ b/docs/website-api/openapi.json @@ -36,7 +36,8 @@ { "name": "Cities" }, { "name": "Cart" }, { "name": "Orders" }, - { "name": "Favorites" } + { "name": "Favorites" }, + { "name": "Partner SMS" } ], "components": { "securitySchemes": { @@ -44,6 +45,12 @@ "type": "http", "scheme": "bearer", "bearerFormat": "JWT" + }, + "apiKeyAuth": { + "type": "apiKey", + "in": "header", + "name": "X-Api-Key", + "description": "Partner SMS API key (server-to-server only). Issued per allowlisted domain." } }, "parameters": { @@ -897,6 +904,62 @@ ], "responses": { "200": { "description": "{ message }" } } } + }, + "/public/sms/send": { + "post": { + "tags": ["Partner SMS"], + "summary": "Send SMS via Meshkee (partner gateway)", + "description": "Server-to-server only. For external/partner backends (e.g. Balout) that need to send SMS through Meshkee → Gama. Not for browser/storefront JS. Requires an allowlisted `domain` + matching `X-Api-Key`. See /docs/website/SMS.md.", + "security": [{ "apiKeyAuth": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["domain", "to", "message"], + "properties": { + "domain": { + "type": "string", + "example": "baloutpastry.com", + "description": "Allowlisted partner apex (www. is stripped)" + }, + "to": { + "type": "string", + "example": "09127004945", + "description": "Iranian mobile: 09…, 9…, +989…, or 989…" + }, + "message": { + "type": "string", + "maxLength": 700, + "example": "سفارش شما به شماره ی ۱۲۱۱۳۲۲ اماده می باشد." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Accepted by Gama", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { "type": "boolean", "example": true }, + "serverId": { "type": "string", "example": "1136923081051406337" } + } + } + } + } + }, + "400": { "description": "Invalid phone or message" }, + "401": { "description": "Missing/invalid X-Api-Key or domain" }, + "429": { "description": "Rate limited (30/partner/min or 5/destination/min)" }, + "503": { "description": "SMS disabled or provider unreachable" } + } + } } } } diff --git a/src/app.module.ts b/src/app.module.ts index bc65f3a..9b64bde 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -34,6 +34,7 @@ import { InternalSslModule } from './internal-ssl/internal-ssl.module'; import { WebsiteDocsModule } from './website-docs/website-docs.module'; import { InvoicesModule } from './invoices/invoices.module'; import { LegacyMysqlModule } from './legacy-mysql/legacy-mysql.module'; +import { PublicSmsModule } from './public-sms/public-sms.module'; @Module({ imports: [ @@ -72,6 +73,7 @@ import { LegacyMysqlModule } from './legacy-mysql/legacy-mysql.module'; WebsiteModule, WebsiteDocsModule, InvoicesModule, + PublicSmsModule, ], }) export class AppModule {} diff --git a/src/auth/sms.service.ts b/src/auth/sms.service.ts index 9bc82e9..4e16372 100644 --- a/src/auth/sms.service.ts +++ b/src/auth/sms.service.ts @@ -1,6 +1,22 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + Logger, + ServiceUnavailableException, +} from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +export type SmsSendResult = { + serverId: string; +}; + +type GamaQuickResponse = { + success?: boolean; + message?: string | null; + errors?: unknown; + body?: number | string | null; +}; + @Injectable() export class SmsService { private readonly logger = new Logger(SmsService.name); @@ -11,27 +27,168 @@ export class SmsService { return this.config.get('SMS_ENABLED', 'false') === 'true'; } - async sendVerificationCode(cellNumber: string, code: string): Promise { + async sendVerificationCode(cellNumber: string, code: string): Promise { if (!this.isEnabled()) { this.logger.warn( `SMS disabled — verification code for ${cellNumber} not sent (code: ${code})`, ); - return; + return { serverId: 'disabled' }; } - // TODO: integrate real SMS provider when API credentials are available - this.logger.log(`Sending SMS verification code to ${cellNumber}`); - throw new Error('SMS provider is not configured yet'); + const message = `کد تایید شما: ${code}`; + return this.sendMessage(cellNumber, message); } - async sendMessage(cellNumber: string, message: string): Promise { + async sendMessage(cellNumber: string, message: string): Promise { if (!this.isEnabled()) { this.logger.warn(`SMS disabled — message for ${cellNumber} not sent: ${message}`); - return; + return { serverId: 'disabled' }; } - // TODO: integrate real SMS provider when API credentials are available - this.logger.log(`Sending SMS message to ${cellNumber}: ${message}`); - throw new Error('SMS provider is not configured yet'); + const text = message.trim(); + if (!text) { + throw new BadRequestException('Message is empty'); + } + if (text.length > 700) { + throw new BadRequestException('Message is too long (max 700 characters)'); + } + + const destination = toGamaMsisdn(cellNumber); + if (!destination) { + throw new BadRequestException('Invalid cellphone number'); + } + + return this.sendQuick(destination, text); + } + + private async sendQuick(destination: string, message: string): Promise { + const username = this.config.get('SMS_GAMA_USERNAME')?.trim(); + const password = this.config.get('SMS_GAMA_PASSWORD')?.trim(); + const source = this.config.get('SMS_GAMA_SOURCE_SERVICE')?.trim(); + const baseUrl = ( + this.config.get('SMS_GAMA_BASE_URL') ?? 'https://sms.igama.ir/api/v1' + ).replace(/\/$/, ''); + + if (!username || !password || !source) { + this.logger.error('SMS provider credentials are not configured'); + throw new ServiceUnavailableException('SMS provider is not configured yet'); + } + + const url = `${baseUrl}/send/quick`; + const masked = maskMsisdn(destination); + + let response: Response; + try { + response = await fetch(url, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + username, + password, + message, + source, + destination, + delay: 0, + expire: null, + }), + signal: AbortSignal.timeout(15_000), + }); + } catch (err) { + this.logger.error( + `Gama SendQuick network error for ${masked}: ${err instanceof Error ? err.message : err}`, + ); + throw new ServiceUnavailableException('SMS provider is unreachable'); + } + + let payload: GamaQuickResponse; + try { + payload = (await response.json()) as GamaQuickResponse; + } catch { + this.logger.error(`Gama SendQuick returned non-JSON for ${masked} (HTTP ${response.status})`); + throw new ServiceUnavailableException('SMS provider returned an invalid response'); + } + + if (!response.ok || !payload.success) { + const providerMessage = + typeof payload.message === 'string' && payload.message.trim() + ? payload.message.trim() + : `HTTP ${response.status}`; + this.logger.warn(`Gama SendQuick failed for ${masked}: ${providerMessage}`); + throw mapGamaError(providerMessage); + } + + const serverId = String(payload.body ?? ''); + if (!serverId) { + this.logger.warn(`Gama SendQuick succeeded without serverId for ${masked}`); + throw new ServiceUnavailableException('SMS provider returned an empty server id'); + } + + this.logger.log(`SMS sent to ${masked} via Gama (serverId=${serverId})`); + return { serverId }; } } + +/** Accept 09… / 9… / +989… / 989… → Gama MSISDN `989…` (no plus). */ +export function toGamaMsisdn(raw: string): string | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + + const digits = trimmed.replace(/\D/g, ''); + if (!digits) return null; + + let msisdn = digits; + if (msisdn.startsWith('0') && msisdn.length === 11) { + msisdn = `98${msisdn.slice(1)}`; + } else if (msisdn.length === 10 && msisdn.startsWith('9')) { + msisdn = `98${msisdn}`; + } else if (msisdn.startsWith('98') && msisdn.length === 12) { + // already MSISDN + } else if (trimmed.startsWith('+') && digits.length >= 10 && digits.length <= 15) { + msisdn = digits; + } else { + return null; + } + + if (!/^98\d{10}$/.test(msisdn)) { + return null; + } + + return msisdn; +} + +export function maskMsisdn(msisdn: string): string { + if (msisdn.length < 6) return '***'; + return `${msisdn.slice(0, 4)}****${msisdn.slice(-3)}`; +} + +function mapGamaError(message: string): Error { + const lower = message.toLowerCase(); + if ( + lower.includes('authentication failed') || + lower.includes('access denied') || + lower.includes('inactive user') + ) { + return new ServiceUnavailableException('SMS provider authentication failed'); + } + if ( + lower.includes('credit is not enough') || + lower.includes('expired account') + ) { + return new ServiceUnavailableException('SMS provider credit or account issue'); + } + if ( + lower.includes('cellphone') || + lower.includes('valid cellphone') || + lower.includes('invalid orig') || + lower.includes('arguments') + ) { + return new BadRequestException('Invalid SMS destination or sender'); + } + if (lower.includes('message is empty')) { + return new BadRequestException('Message is empty'); + } + return new ServiceUnavailableException('SMS provider rejected the request'); +} diff --git a/src/public-sms/dto/send-public-sms.dto.ts b/src/public-sms/dto/send-public-sms.dto.ts new file mode 100644 index 0000000..ba978fc --- /dev/null +++ b/src/public-sms/dto/send-public-sms.dto.ts @@ -0,0 +1,19 @@ +import { IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator'; + +export class SendPublicSmsDto { + @IsString() + @IsNotEmpty() + @MaxLength(253) + domain!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(32) + to!: string; + + @IsString() + @IsNotEmpty() + @MinLength(1) + @MaxLength(700) + message!: string; +} diff --git a/src/public-sms/public-sms.controller.ts b/src/public-sms/public-sms.controller.ts new file mode 100644 index 0000000..58035c3 --- /dev/null +++ b/src/public-sms/public-sms.controller.ts @@ -0,0 +1,20 @@ +import { Body, Controller, Post, Req, UseGuards } from '@nestjs/common'; +import { Request } from 'express'; +import { SendPublicSmsDto } from './dto/send-public-sms.dto'; +import { PublicSmsService } from './public-sms.service'; +import { SMS_PARTNER_DOMAIN_KEY, SmsPartnerGuard } from './sms-partner.guard'; + +@Controller('public/sms') +export class PublicSmsController { + constructor(private readonly service: PublicSmsService) {} + + @Post('send') + @UseGuards(SmsPartnerGuard) + send( + @Body() dto: SendPublicSmsDto, + @Req() req: Request & { [SMS_PARTNER_DOMAIN_KEY]?: string }, + ) { + const partnerDomain = req[SMS_PARTNER_DOMAIN_KEY] ?? dto.domain; + return this.service.send(partnerDomain, dto); + } +} diff --git a/src/public-sms/public-sms.module.ts b/src/public-sms/public-sms.module.ts new file mode 100644 index 0000000..7a8146a --- /dev/null +++ b/src/public-sms/public-sms.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { PublicSmsController } from './public-sms.controller'; +import { PublicSmsService } from './public-sms.service'; +import { SmsPartnerGuard } from './sms-partner.guard'; + +@Module({ + imports: [AuthModule], + controllers: [PublicSmsController], + providers: [PublicSmsService, SmsPartnerGuard], +}) +export class PublicSmsModule {} diff --git a/src/public-sms/public-sms.service.ts b/src/public-sms/public-sms.service.ts new file mode 100644 index 0000000..1645bb4 --- /dev/null +++ b/src/public-sms/public-sms.service.ts @@ -0,0 +1,73 @@ +import { + HttpException, + HttpStatus, + Injectable, + Logger, + ServiceUnavailableException, +} from '@nestjs/common'; +import { RedisService } from '../redis/redis.service'; +import { SmsService, toGamaMsisdn } from '../auth/sms.service'; +import { SendPublicSmsDto } from './dto/send-public-sms.dto'; + +const PARTNER_LIMIT_PER_MINUTE = 30; +const DESTINATION_LIMIT_PER_MINUTE = 5; +const WINDOW_SECONDS = 60; + +@Injectable() +export class PublicSmsService { + private readonly logger = new Logger(PublicSmsService.name); + + constructor( + private readonly sms: SmsService, + private readonly redis: RedisService, + ) {} + + async send(partnerDomain: string, dto: SendPublicSmsDto) { + if (!this.sms.isEnabled()) { + throw new ServiceUnavailableException('SMS is currently disabled'); + } + + const msisdn = toGamaMsisdn(dto.to); + if (!msisdn) { + throw new HttpException('Invalid cellphone number', HttpStatus.BAD_REQUEST); + } + + await this.assertRateLimits(partnerDomain, msisdn); + + const result = await this.sms.sendMessage(msisdn, dto.message); + this.logger.log( + `Partner SMS accepted domain=${partnerDomain} serverId=${result.serverId}`, + ); + + return { + success: true, + serverId: result.serverId, + }; + } + + private async assertRateLimits(domain: string, msisdn: string): Promise { + const partnerOk = await this.redis.incrementWithLimit( + `sms:rl:partner:${domain}`, + PARTNER_LIMIT_PER_MINUTE, + WINDOW_SECONDS, + ); + if (!partnerOk) { + throw new HttpException( + 'Too many SMS requests for this partner. Try again later.', + HttpStatus.TOO_MANY_REQUESTS, + ); + } + + const destOk = await this.redis.incrementWithLimit( + `sms:rl:dest:${msisdn}`, + DESTINATION_LIMIT_PER_MINUTE, + WINDOW_SECONDS, + ); + if (!destOk) { + throw new HttpException( + 'Too many SMS requests for this destination. Try again later.', + HttpStatus.TOO_MANY_REQUESTS, + ); + } + } +} diff --git a/src/public-sms/sms-partner.guard.ts b/src/public-sms/sms-partner.guard.ts new file mode 100644 index 0000000..a93875d --- /dev/null +++ b/src/public-sms/sms-partner.guard.ts @@ -0,0 +1,48 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Request } from 'express'; +import { + normalizePartnerDomain, + parseSmsPartners, + timingSafeEqualString, +} from './sms-partners.util'; + +export const SMS_PARTNER_DOMAIN_KEY = 'smsPartnerDomain'; + +@Injectable() +export class SmsPartnerGuard implements CanActivate { + constructor(private readonly config: ConfigService) {} + + canActivate(context: ExecutionContext): boolean { + const partners = parseSmsPartners(this.config.get('SMS_PARTNERS')); + if (partners.size === 0) { + throw new UnauthorizedException('SMS partner gateway is not configured'); + } + + const req = context.switchToHttp().getRequest(); + const providedKey = String(req.headers['x-api-key'] ?? '').trim(); + if (!providedKey) { + throw new UnauthorizedException('Missing X-Api-Key'); + } + + const body = (req.body ?? {}) as { domain?: unknown }; + const domain = normalizePartnerDomain(String(body.domain ?? '')); + if (!domain) { + throw new UnauthorizedException('Missing or invalid domain'); + } + + const expectedKey = partners.get(domain); + if (!expectedKey || !timingSafeEqualString(providedKey, expectedKey)) { + throw new UnauthorizedException('Invalid API key or domain'); + } + + (req as Request & { [SMS_PARTNER_DOMAIN_KEY]?: string })[SMS_PARTNER_DOMAIN_KEY] = + domain; + return true; + } +} diff --git a/src/public-sms/sms-partners.util.ts b/src/public-sms/sms-partners.util.ts new file mode 100644 index 0000000..7263c5d --- /dev/null +++ b/src/public-sms/sms-partners.util.ts @@ -0,0 +1,41 @@ +import { timingSafeEqual } from 'crypto'; + +/** Lowercase host, strip protocol/path/port, strip leading www. */ +export function normalizePartnerDomain(raw: string): string { + let value = raw.trim().toLowerCase(); + if (!value) return ''; + + value = value.replace(/^https?:\/\//, ''); + value = value.split('/')[0] ?? ''; + value = value.split(':')[0] ?? ''; + if (value.startsWith('www.')) { + value = value.slice(4); + } + return value; +} + +/** Parse `domain:apiKey,domain2:apiKey2` into a map keyed by normalized domain. */ +export function parseSmsPartners(raw: string | undefined | null): Map { + const map = new Map(); + if (!raw?.trim()) return map; + + for (const part of raw.split(',')) { + const entry = part.trim(); + if (!entry) continue; + const idx = entry.indexOf(':'); + if (idx <= 0) continue; + const domain = normalizePartnerDomain(entry.slice(0, idx)); + const apiKey = entry.slice(idx + 1).trim(); + if (domain && apiKey) { + map.set(domain, apiKey); + } + } + return map; +} + +export function timingSafeEqualString(a: string, b: string): boolean { + const left = Buffer.from(a); + const right = Buffer.from(b); + if (left.length !== right.length) return false; + return timingSafeEqual(left, right); +} diff --git a/src/redis/redis.service.ts b/src/redis/redis.service.ts index bf08bb7..df720d4 100644 --- a/src/redis/redis.service.ts +++ b/src/redis/redis.service.ts @@ -21,4 +21,20 @@ export class RedisService { async deleteOtp(cellNumber: string): Promise { await this.redis.del(`otp:${cellNumber}`); } + + /** + * Sliding fixed-window counter. Returns true if the call is within `limit` + * for the given key over `windowSeconds`. + */ + async incrementWithLimit( + key: string, + limit: number, + windowSeconds: number, + ): Promise { + const count = await this.redis.incr(key); + if (count === 1) { + await this.redis.expire(key, windowSeconds); + } + return count <= limit; + } } diff --git a/src/website-docs/static/AI_PROMPT.md b/src/website-docs/static/AI_PROMPT.md index 686b918..5b0ddeb 100644 --- a/src/website-docs/static/AI_PROMPT.md +++ b/src/website-docs/static/AI_PROMPT.md @@ -26,6 +26,7 @@ You are building a **Meshkee business website (storefront)**. You must use the M 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.). +7. **Partner SMS** (`POST /public/sms/send`) is for external partner backends with an issued `X-Api-Key` only — not for normal storefront UI. See https://api.meshkee.com/docs/website/SMS.md ### Typical bootstrap sequence 1. `GET /tenants/{domain}` → branding + `businessId` diff --git a/src/website-docs/static/Meshkee-Website-API.postman_collection.json b/src/website-docs/static/Meshkee-Website-API.postman_collection.json index 33df96f..b1f86c2 100644 --- a/src/website-docs/static/Meshkee-Website-API.postman_collection.json +++ b/src/website-docs/static/Meshkee-Website-API.postman_collection.json @@ -100,6 +100,10 @@ { "key": "brandId", "value": "" + }, + { + "key": "smsApiKey", + "value": "" } ], "item": [ @@ -1669,6 +1673,34 @@ } } ] + }, + { + "name": "Partner SMS", + "description": "Server-to-server SMS for allowlisted partner domains. Set collection variable `smsApiKey`. Docs: https://api.meshkee.com/docs/website/SMS.md", + "item": [ + { + "name": "Send SMS", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-Api-Key", + "value": "{{smsApiKey}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"domain\": \"baloutpastry.com\",\n \"to\": \"09127004945\",\n \"message\": \"سفارش شما به شماره ی ۱۲۱۱۳۲۲ اماده می باشد.\"\n}" + }, + "url": "{{baseUrl}}/public/sms/send", + "description": "Requires allowlisted domain + matching API key. Not for browser JS." + } + } + ] } ] } diff --git a/src/website-docs/static/SMS.md b/src/website-docs/static/SMS.md new file mode 100644 index 0000000..f7405ae --- /dev/null +++ b/src/website-docs/static/SMS.md @@ -0,0 +1,85 @@ +# Partner SMS gateway + +Server-to-server SMS via Meshkee → Gama (گاما). Use this when a **non-Meshkee** (or partner) backend needs to send SMS through the shared Meshkee account. + +**Not for browser/storefront JavaScript.** Never put the API key in frontend code. + +Hub: https://api.meshkee.com/docs/website +Endpoint docs also in OpenAPI / Postman under **Partner SMS**. + +--- + +## Endpoint + +```http +POST https://api.meshkee.com/api/v1/public/sms/send +Content-Type: application/json +X-Api-Key: +``` + +### Body + +| Field | Required | Description | +|-------|----------|-------------| +| `domain` | yes | Allowlisted partner apex, e.g. `baloutpastry.com` (`www.` is stripped) | +| `to` | yes | Mobile: `09…`, `9…`, `+989…`, or `989…` | +| `message` | yes | Free text, max 700 characters | + +### Example (Balout) + +```bash +curl -sS -X POST 'https://api.meshkee.com/api/v1/public/sms/send' \ + -H 'Content-Type: application/json' \ + -H 'X-Api-Key: ' \ + -d '{ + "domain": "baloutpastry.com", + "to": "09127004945", + "message": "سفارش شما به شماره ی ۱۲۱۱۳۲۲ اماده می باشد." + }' +``` + +### Success (200) + +```json +{ + "success": true, + "serverId": "1136923081051406337" +} +``` + +`serverId` is the Gama message id (delivery tracking). + +### Errors + +| HTTP | Meaning | +|------|---------| +| 400 | Invalid phone or empty/too-long message | +| 401 | Missing/invalid `X-Api-Key` or domain not allowlisted | +| 429 | Rate limit: **30**/partner/minute or **5**/destination/minute | +| 503 | SMS disabled, missing provider config, or Gama unreachable | + +--- + +## Auth model + +1. Meshkee configures `SMS_PARTNERS=domain:apiKey,...` on the API server. +2. Partner backend sends `X-Api-Key` + matching `domain` in the JSON body. +3. Key must match that domain (timing-safe compare). `www.baloutpastry.com` normalizes to `baloutpastry.com`. + +First allowlisted partner: **baloutpastry.com**. + +--- + +## Sender line (v1) + +Uses the **service** shortcode only (`SendQuick`). Advertising / bulk / OTP pattern APIs are not exposed yet. + +--- + +## Rules for partner backends + +1. Call from your **server** only (Balout API → Meshkee API). +2. Keep the API key in server env / secrets — never in the website frontend. +3. Prefer short transactional messages (order ready, OTP-style text, etc.). +4. Respect rate limits; backoff on `429`. +5. Meshkee websites that already use customer auth OTP go through Meshkee’s own auth/SMS path — they do **not** need this partner endpoint. diff --git a/src/website-docs/static/index.html b/src/website-docs/static/index.html index 9191fb0..d49338c 100644 --- a/src/website-docs/static/index.html +++ b/src/website-docs/static/index.html @@ -77,6 +77,7 @@ OpenAPI JSON Download Postman AI prompt + Partner SMS

Base URL

@@ -104,6 +105,17 @@ Postman → Import → Link → paste
https://api.meshkee.com/docs/website/Meshkee-Website-API.postman_collection.json

+ +

Partner SMS gateway

+

+ External backends (e.g. Balout) can send transactional SMS through Meshkee → Gama. + Server-to-server only — API key per allowlisted domain. See + SMS.md. +

+
+

POST /api/v1/public/sms/send

+

Header X-Api-Key + body { domain, to, message }

+
diff --git a/src/website-docs/static/openapi.json b/src/website-docs/static/openapi.json index b473517..87048ce 100644 --- a/src/website-docs/static/openapi.json +++ b/src/website-docs/static/openapi.json @@ -36,7 +36,8 @@ { "name": "Cities" }, { "name": "Cart" }, { "name": "Orders" }, - { "name": "Favorites" } + { "name": "Favorites" }, + { "name": "Partner SMS" } ], "components": { "securitySchemes": { @@ -44,6 +45,12 @@ "type": "http", "scheme": "bearer", "bearerFormat": "JWT" + }, + "apiKeyAuth": { + "type": "apiKey", + "in": "header", + "name": "X-Api-Key", + "description": "Partner SMS API key (server-to-server only). Issued per allowlisted domain." } }, "parameters": { @@ -897,6 +904,62 @@ ], "responses": { "200": { "description": "{ message }" } } } + }, + "/public/sms/send": { + "post": { + "tags": ["Partner SMS"], + "summary": "Send SMS via Meshkee (partner gateway)", + "description": "Server-to-server only. For external/partner backends (e.g. Balout) that need to send SMS through Meshkee → Gama. Not for browser/storefront JS. Requires an allowlisted `domain` + matching `X-Api-Key`. See /docs/website/SMS.md.", + "security": [{ "apiKeyAuth": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["domain", "to", "message"], + "properties": { + "domain": { + "type": "string", + "example": "baloutpastry.com", + "description": "Allowlisted partner apex (www. is stripped)" + }, + "to": { + "type": "string", + "example": "09127004945", + "description": "Iranian mobile: 09…, 9…, +989…, or 989…" + }, + "message": { + "type": "string", + "maxLength": 700, + "example": "سفارش شما به شماره ی ۱۲۱۱۳۲۲ اماده می باشد." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Accepted by Gama", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { "type": "boolean", "example": true }, + "serverId": { "type": "string", "example": "1136923081051406337" } + } + } + } + } + }, + "400": { "description": "Invalid phone or message" }, + "401": { "description": "Missing/invalid X-Api-Key or domain" }, + "429": { "description": "Rate limited (30/partner/min or 5/destination/min)" }, + "503": { "description": "SMS disabled or provider unreachable" } + } + } } } } diff --git a/src/website-docs/website-docs.controller.ts b/src/website-docs/website-docs.controller.ts index 1324870..2569716 100644 --- a/src/website-docs/website-docs.controller.ts +++ b/src/website-docs/website-docs.controller.ts @@ -14,6 +14,7 @@ const ALLOWED_FILES = new Set([ 'index.html', 'openapi.json', 'AI_PROMPT.md', + 'SMS.md', 'Meshkee-Website-API.postman_collection.json', 'Meshkee-Website-API.global.postman_environment.json', ]);