mirror of
https://git.meshkee.com/Meshkee/backend.git
synced 2026-08-11 22:30:59 +04:30
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 <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
267a218c26
commit
f4295e780c
+9
-1
@@ -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/...
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ You are building a **Meshkee business website (storefront)**. You must use the M
|
||||
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.).
|
||||
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`
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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: <partner-secret>
|
||||
```
|
||||
|
||||
### 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: <YOUR_PARTNER_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.
|
||||
@@ -77,6 +77,7 @@
|
||||
<a class="btn" href="/docs/website/openapi.json">OpenAPI JSON</a>
|
||||
<a class="btn secondary" href="/docs/website/Meshkee-Website-API.postman_collection.json">Download Postman</a>
|
||||
<a class="btn secondary" href="/docs/website/AI_PROMPT.md">AI prompt</a>
|
||||
<a class="btn secondary" href="/docs/website/SMS.md">Partner SMS</a>
|
||||
</div>
|
||||
|
||||
<h2>Base URL</h2>
|
||||
@@ -104,6 +105,17 @@
|
||||
Postman → Import → Link → paste<br />
|
||||
<code>https://api.meshkee.com/docs/website/Meshkee-Website-API.postman_collection.json</code>
|
||||
</p>
|
||||
|
||||
<h2>Partner SMS gateway</h2>
|
||||
<p>
|
||||
External backends (e.g. Balout) can send transactional SMS through Meshkee → Gama.
|
||||
Server-to-server only — API key per allowlisted domain. See
|
||||
<a href="/docs/website/SMS.md">SMS.md</a>.
|
||||
</p>
|
||||
<div class="panel">
|
||||
<p style="margin:0"><code>POST /api/v1/public/sms/send</code></p>
|
||||
<p style="margin:0.5rem 0 0">Header <code>X-Api-Key</code> + body <code>{ domain, to, message }</code></p>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
+168
-11
@@ -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<string>('SMS_ENABLED', 'false') === 'true';
|
||||
}
|
||||
|
||||
async sendVerificationCode(cellNumber: string, code: string): Promise<void> {
|
||||
async sendVerificationCode(cellNumber: string, code: string): Promise<SmsSendResult> {
|
||||
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<void> {
|
||||
async sendMessage(cellNumber: string, message: string): Promise<SmsSendResult> {
|
||||
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<SmsSendResult> {
|
||||
const username = this.config.get<string>('SMS_GAMA_USERNAME')?.trim();
|
||||
const password = this.config.get<string>('SMS_GAMA_PASSWORD')?.trim();
|
||||
const source = this.config.get<string>('SMS_GAMA_SOURCE_SERVICE')?.trim();
|
||||
const baseUrl = (
|
||||
this.config.get<string>('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');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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<void> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string>('SMS_PARTNERS'));
|
||||
if (partners.size === 0) {
|
||||
throw new UnauthorizedException('SMS partner gateway is not configured');
|
||||
}
|
||||
|
||||
const req = context.switchToHttp().getRequest<Request>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
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);
|
||||
}
|
||||
@@ -21,4 +21,20 @@ export class RedisService {
|
||||
async deleteOtp(cellNumber: string): Promise<void> {
|
||||
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<boolean> {
|
||||
const count = await this.redis.incr(key);
|
||||
if (count === 1) {
|
||||
await this.redis.expire(key, windowSeconds);
|
||||
}
|
||||
return count <= limit;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ You are building a **Meshkee business website (storefront)**. You must use the M
|
||||
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.).
|
||||
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`
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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: <partner-secret>
|
||||
```
|
||||
|
||||
### 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: <YOUR_PARTNER_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.
|
||||
@@ -77,6 +77,7 @@
|
||||
<a class="btn" href="/docs/website/openapi.json">OpenAPI JSON</a>
|
||||
<a class="btn secondary" href="/docs/website/Meshkee-Website-API.postman_collection.json">Download Postman</a>
|
||||
<a class="btn secondary" href="/docs/website/AI_PROMPT.md">AI prompt</a>
|
||||
<a class="btn secondary" href="/docs/website/SMS.md">Partner SMS</a>
|
||||
</div>
|
||||
|
||||
<h2>Base URL</h2>
|
||||
@@ -104,6 +105,17 @@
|
||||
Postman → Import → Link → paste<br />
|
||||
<code>https://api.meshkee.com/docs/website/Meshkee-Website-API.postman_collection.json</code>
|
||||
</p>
|
||||
|
||||
<h2>Partner SMS gateway</h2>
|
||||
<p>
|
||||
External backends (e.g. Balout) can send transactional SMS through Meshkee → Gama.
|
||||
Server-to-server only — API key per allowlisted domain. See
|
||||
<a href="/docs/website/SMS.md">SMS.md</a>.
|
||||
</p>
|
||||
<div class="panel">
|
||||
<p style="margin:0"><code>POST /api/v1/public/sms/send</code></p>
|
||||
<p style="margin:0.5rem 0 0">Header <code>X-Api-Key</code> + body <code>{ domain, to, message }</code></p>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user