Add SMS templates, prepaid IRT billing, and advertising shortcode 9000590009.

Charge ceil(len/64)*400 IRT per recipient across OTP, customer, and partner SMS; seed new businesses with 500k IRT; default partner sends to the advertising line.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-08 16:24:15 +03:30
co-authored by Cursor
parent 4623061a4f
commit 4a1d60dbef
29 changed files with 1170 additions and 61 deletions
+2 -2
View File
@@ -30,8 +30,8 @@ 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=5000990009
SMS_GAMA_SOURCE_ADVERTISE=5000990009
# Optional: advertising / unverified-template shortcode (default 9000590009)
SMS_GAMA_SOURCE_ADVERTISE=9000590009
# Partner gateway: domain:apiKey pairs, comma-separated (www. is stripped)
# Example: SMS_PARTNERS=baloutpastry.com:replace-with-long-random-secret
SMS_PARTNERS=
+36
View File
@@ -0,0 +1,36 @@
-- SMS templates per business (verified by super-admin → Meshkee service shortcode).
CREATE TABLE IF NOT EXISTS sms_templates (
id BIGSERIAL PRIMARY KEY,
business_id BIGINT NOT NULL REFERENCES businesses (id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
body TEXT NOT NULL,
is_verified BOOLEAN NOT NULL DEFAULT FALSE,
verified_at TIMESTAMPTZ NULL,
verified_by BIGINT NULL REFERENCES users (id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_sms_templates_business_id
ON sms_templates (business_id);
CREATE INDEX IF NOT EXISTS idx_sms_templates_business_verified
ON sms_templates (business_id, is_verified);
CREATE INDEX IF NOT EXISTS idx_sms_templates_created_at
ON sms_templates (created_at DESC);
CREATE OR REPLACE FUNCTION sms_templates_set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS sms_templates_set_updated_at ON sms_templates;
CREATE TRIGGER sms_templates_set_updated_at
BEFORE UPDATE ON sms_templates
FOR EACH ROW
EXECUTE FUNCTION sms_templates_set_updated_at();
@@ -0,0 +1,7 @@
-- Per-business prepaid SMS credit (messages). Deducted when customer SMS is sent.
ALTER TABLE businesses
ADD COLUMN IF NOT EXISTS sms_balance INTEGER NOT NULL DEFAULT 0;
COMMENT ON COLUMN businesses.sms_balance IS
'Prepaid SMS message credits for this business; decremented on customer SMS send.';
@@ -0,0 +1,21 @@
-- SMS template verification workflow: free | pending | verified
ALTER TABLE sms_templates
ADD COLUMN IF NOT EXISTS verification_status VARCHAR(32) NOT NULL DEFAULT 'free';
UPDATE sms_templates
SET verification_status = 'verified'
WHERE is_verified = TRUE;
ALTER TABLE sms_templates
DROP CONSTRAINT IF EXISTS sms_templates_verification_status_check;
ALTER TABLE sms_templates
ADD CONSTRAINT sms_templates_verification_status_check
CHECK (verification_status IN ('free', 'pending', 'verified'));
CREATE INDEX IF NOT EXISTS idx_sms_templates_verification_status
ON sms_templates (verification_status);
CREATE INDEX IF NOT EXISTS idx_sms_templates_business_status
ON sms_templates (business_id, verification_status);
@@ -0,0 +1,6 @@
-- SMS balance is prepaid IRT (not message counts).
-- Billing: ceil(message_length / 64) * 400 IRT per recipient
-- (login OTP, customer SMS, partner/public SMS, etc.).
COMMENT ON COLUMN businesses.sms_balance IS
'Prepaid SMS balance in IRT. Each 64-character segment costs 400 IRT per recipient.';
+1 -1
View File
@@ -453,7 +453,7 @@ Each resource typically has: `read`, `create`, `update`, `delete` (+ `publish` f
- Dashboard SSO: `POST /auth/handoff` (JWT) stores a one-time Redis ticket (`sso:handoff:{ticket}`, 60s). Business dashboard calls `POST /auth/handoff/consume` and receives tokens.
- 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.
- Partner gateway (external sites like Balout): `POST /api/v1/public/sms/send` with `X-Api-Key` + body `{ domain, to, message, source? }`; default sender advertising `9000590009`; partners configured in `SMS_PARTNERS` (`domain:apiKey` pairs). Rate limits: 30/partner/min and 5/destination/min. Not part of storefront website-api docs.
---
+3 -1
View File
@@ -24,6 +24,7 @@ X-Api-Key: <partner-secret>
| `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 |
| `source` | no | Gama shortcode override (8–16 digits). Default: advertising `9000590009` |
### Example (Balout)
@@ -72,7 +73,8 @@ First allowlisted partner: **baloutpastry.com**.
## Sender line (v1)
Uses the **service** shortcode only (`SendQuick`). Advertising / bulk / OTP pattern APIs are not exposed yet.
Default sender is the **advertising** shortcode (`9000590009` / `SMS_GAMA_SOURCE_ADVERTISE`).
Pass `source` to override (e.g. a business-owned shortcode). OTP/login on Meshkee dashboards uses the **service** shortcode (`5000110005`) via `/auth/send-otp`, not this partner endpoint.
---
+25
View File
@@ -50,6 +50,7 @@ model User {
transactions Transaction[] @relation("TransactionCustomer")
userProducts UserProduct[]
userRoles UserRole[]
smsTemplatesVerified SmsTemplate[] @relation("SmsTemplateVerifier")
@@index([cellNumber], map: "idx_users_cell_number")
@@map("users")
@@ -69,6 +70,28 @@ model UserTitle {
@@map("user_titles")
}
model SmsTemplate {
id BigInt @id @default(autoincrement())
businessId BigInt @map("business_id")
name String @db.VarChar(255)
body String @db.Text
verificationStatus String @default("free") @map("verification_status") @db.VarChar(32)
isVerified Boolean @default(false) @map("is_verified")
verifiedAt DateTime? @map("verified_at") @db.Timestamptz(6)
verifiedBy BigInt? @map("verified_by")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
verifier User? @relation("SmsTemplateVerifier", fields: [verifiedBy], references: [id], onDelete: SetNull, onUpdate: NoAction)
@@index([businessId], map: "idx_sms_templates_business_id")
@@index([businessId, isVerified], map: "idx_sms_templates_business_verified")
@@index([verificationStatus], map: "idx_sms_templates_verification_status")
@@index([businessId, verificationStatus], map: "idx_sms_templates_business_status")
@@index([createdAt(sort: Desc)], map: "idx_sms_templates_created_at")
@@map("sms_templates")
}
model Business {
id BigInt @id @default(autoincrement())
name String @db.VarChar(255)
@@ -89,12 +112,14 @@ model Business {
faviconMediaId BigInt? @map("favicon_media_id")
oldBusinessId BigInt? @map("old_business_id")
annualRenewalAt DateTime? @map("annual_renewal_at") @db.Timestamptz(6)
smsBalance Int @default(0) @map("sms_balance")
addresses Address[]
blogs blogs[]
brands Brand[]
categoryAssignments BusinessCategoryAssignment[]
businessCustomers BusinessCustomer[]
businessUsers BusinessUser[]
smsTemplates SmsTemplate[]
faviconMedia Media? @relation("BusinessFavicon", fields: [faviconMediaId], references: [id], onUpdate: NoAction)
logoMedia Media? @relation("BusinessLogo", fields: [logoMediaId], references: [id], onUpdate: NoAction)
logoDarkMedia Media? @relation("BusinessLogoDark", fields: [logoDarkMediaId], references: [id], onUpdate: NoAction)
+2
View File
@@ -44,6 +44,7 @@ import { LegacyMysqlModule } from './legacy-mysql/legacy-mysql.module';
import { PublicSmsModule } from './public-sms/public-sms.module';
import { PaymentsModule } from './payments/payments.module';
import { SitemapModule } from './sitemap/sitemap.module';
import { SmsTemplatesModule } from './sms-templates/sms-templates.module';
import { TorobModule } from './torob/torob.module';
@Module({
@@ -93,6 +94,7 @@ import { TorobModule } from './torob/torob.module';
AiPromptsModule,
PublicSmsModule,
SitemapModule,
SmsTemplatesModule,
TorobModule,
],
})
+9 -1
View File
@@ -9,6 +9,7 @@ import { AuthService } from './auth.service';
import { BusinessPermissionGuard } from './guards/business-permission.guard';
import { PermissionsService } from './permissions.service';
import { SmsService } from './sms.service';
import { SmsBillingService } from './sms-billing.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { UserAddressesService } from './user-addresses.service';
@@ -33,10 +34,17 @@ import { UserAddressesService } from './user-addresses.service';
AuthService,
UserAddressesService,
SmsService,
SmsBillingService,
PermissionsService,
BusinessPermissionGuard,
JwtStrategy,
],
exports: [AuthService, PermissionsService, BusinessPermissionGuard, SmsService],
exports: [
AuthService,
PermissionsService,
BusinessPermissionGuard,
SmsService,
SmsBillingService,
],
})
export class AuthModule {}
+18 -11
View File
@@ -28,6 +28,7 @@ import { UpdateProfileDto } from './dto/update-profile.dto';
import { PermissionsService } from './permissions.service';
import { parseUserProfile } from './profile.util';
import { SmsService } from './sms.service';
import { SmsBillingService } from './sms-billing.service';
const OTP_TTL_SECONDS = 300;
/** Pending cross-site password change (longer than OTP so resend still works). */
@@ -44,6 +45,7 @@ export class AuthService {
private readonly config: ConfigService,
private readonly redis: RedisService,
private readonly sms: SmsService,
private readonly smsBilling: SmsBillingService,
private readonly tenant: TenantService,
private readonly permissions: PermissionsService,
) {}
@@ -403,7 +405,7 @@ export class AuthService {
return { message: 'Password changed successfully' };
}
async sendOtp(cellNumber: string, domain?: string) {
async sendOtp(cellNumber: string, domain: string) {
if (!this.sms.isEnabled()) {
return {
enabled: false,
@@ -420,24 +422,29 @@ export class AuthService {
throw new UnauthorizedException('Cell number is not registered');
}
let businessNameFa: string | undefined;
const host = domain?.trim();
if (host) {
try {
const business = await this.tenant.resolveBusinessByDomain(host);
businessNameFa =
business.nameFa?.trim() || business.name?.trim() || undefined;
} catch {
// Domain may be unknown — still send OTP without branding suffix
}
if (!host) {
throw new BadRequestException(
'Domain is required to send SMS (prepaid balance is billed per business).',
);
}
const business = await this.tenant.resolveBusinessByDomain(host);
const businessNameFa =
business.nameFa?.trim() || business.name?.trim() || undefined;
const code = this.generateOtpCode();
const brand = businessNameFa?.trim();
const message = brand
? `کد تایید شما: ${code}\n${brand}`
: `کد تایید شما: ${code}`;
const charged = await this.smsBilling.charge(business.id, message);
await this.redis.setOtp(cellNumber, code, OTP_TTL_SECONDS);
try {
await this.sms.sendVerificationCode(cellNumber, code, businessNameFa);
await this.sms.sendMessage(cellNumber, message);
} catch (err) {
await this.smsBilling.refund(business.id, charged);
if (err instanceof HttpException) {
throw err;
}
+3 -4
View File
@@ -1,4 +1,4 @@
import { IsOptional, IsString, Matches, MaxLength } from 'class-validator';
import { IsString, Matches, MaxLength } from 'class-validator';
export class SendOtpDto {
@IsString()
@@ -7,9 +7,8 @@ export class SendOtpDto {
})
cellNumber!: string;
/** Tenant host/apex (e.g. sanihome.ir or business.sanihome.ir) — used to brand OTP SMS. */
@IsOptional()
/** Tenant host/apex — required so OTP SMS is billed to the business. */
@IsString()
@MaxLength(253)
domain?: string;
domain!: string;
}
+81
View File
@@ -0,0 +1,81 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { TenantService } from '../tenant/tenant.service';
import { calculateSmsCostIrt } from './sms-billing';
@Injectable()
export class SmsBillingService {
constructor(
private readonly prisma: PrismaService,
private readonly tenant: TenantService,
) {}
costForMessage(message: string, recipientCount = 1): number {
return calculateSmsCostIrt(message.trim().length, recipientCount);
}
/**
* Resolve tenant business from any Meshkee / external domain host.
* Returns null when the host is not linked to an active business.
*/
async findBusinessIdByDomain(host: string | undefined | null): Promise<bigint | null> {
const trimmed = host?.trim();
if (!trimmed) return null;
try {
const business = await this.tenant.resolveBusinessByDomain(trimmed);
return business.id;
} catch {
return null;
}
}
async requireBusinessIdByDomain(host: string): Promise<bigint> {
const id = await this.findBusinessIdByDomain(host);
if (!id) {
throw new NotFoundException(
`No business found for domain: ${host}. Link the domain to a business first.`,
);
}
return id;
}
/** Atomically deduct IRT for a message. Returns charged amount. */
async charge(
businessId: bigint,
message: string,
recipientCount = 1,
): Promise<number> {
return this.chargeAmount(
businessId,
this.costForMessage(message, recipientCount),
);
}
/** Atomically deduct an explicit IRT amount. Returns charged amount. */
async chargeAmount(businessId: bigint, amount: number): Promise<number> {
const cost = Math.floor(amount);
if (cost <= 0) return 0;
const updated = await this.prisma.$executeRaw`
UPDATE businesses
SET sms_balance = sms_balance - ${cost}
WHERE id = ${businessId} AND sms_balance >= ${cost}
`;
if (updated === 0) {
throw new BadRequestException(
'Insufficient SMS balance. Contact Meshkee to top up.',
);
}
return cost;
}
async refund(businessId: bigint, amount: number): Promise<void> {
const credit = Math.floor(amount);
if (credit <= 0) return;
await this.prisma.$executeRaw`
UPDATE businesses
SET sms_balance = sms_balance + ${credit}
WHERE id = ${businessId}
`;
}
}
+20
View File
@@ -0,0 +1,20 @@
/** Prepaid SMS billing: each 64-character segment costs 400 IRT per recipient. */
export const SMS_SEGMENT_CHARS = 64;
export const SMS_COST_PER_SEGMENT_IRT = 400;
export const DEFAULT_NEW_BUSINESS_SMS_BALANCE_IRT = 500_000;
/**
* Cost in IRT for one or more recipients.
* 1–64 chars → 400, 65–128 → 800, etc.
*/
export function calculateSmsCostIrt(
messageLength: number,
recipientCount = 1,
): number {
const length = Math.max(0, Math.floor(messageLength));
const recipients = Math.max(0, Math.floor(recipientCount));
if (recipients === 0 || length === 0) return 0;
const segments = Math.max(1, Math.ceil(length / SMS_SEGMENT_CHARS));
return segments * SMS_COST_PER_SEGMENT_IRT * recipients;
}
+59 -1
View File
@@ -21,7 +21,7 @@ export type SmsSendOptions = {
};
/** Default Meshkee advertising shortcode when a business has no own SMS number. */
export const DEFAULT_ADVERTISING_SMS_SOURCE = '5000990009';
export const DEFAULT_ADVERTISING_SMS_SOURCE = '9000590009';
/** Gama batch size limit is undocumented; keep chunks conservative. */
const GAMA_BULK_CHUNK_SIZE = 100;
@@ -58,6 +58,64 @@ export class SmsService {
);
}
/** Meshkee service shortcode (OTP + verified SMS templates). */
getServiceSource(): string {
return (
this.config.get<string>('SMS_GAMA_SOURCE_SERVICE')?.trim() ||
'5000110005'
);
}
/**
* Best-effort account credit from Gama. Returns null when the provider
* does not expose credit over REST (check the Gama panel instead).
*/
async getAccountCredit(): Promise<{ balance: number; currency?: string } | null> {
if (!this.isEnabled() || this.isProxyConfigured()) {
return null;
}
const username = this.config.get<string>('SMS_GAMA_USERNAME')?.trim();
const password = this.config.get<string>('SMS_GAMA_PASSWORD')?.trim();
const baseUrl = (
this.config.get<string>('SMS_GAMA_BASE_URL') ?? 'https://sms.igama.ir/api/v1'
).replace(/\/$/, '');
if (!username || !password) return null;
// Gama REST docs do not document a credit endpoint; try common paths quietly.
for (const path of ['/account/credit', '/credit', '/user/credit']) {
try {
const response = await fetch(`${baseUrl}${path}`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
signal: AbortSignal.timeout(8_000),
});
if (!response.ok) continue;
const payload = (await response.json()) as {
success?: boolean;
body?: number | { credit?: number; balance?: number };
};
if (!payload.success) continue;
const raw =
typeof payload.body === 'number'
? payload.body
: payload.body?.credit ?? payload.body?.balance;
if (typeof raw === 'number' && Number.isFinite(raw)) {
return { balance: raw };
}
} catch {
// ignore and try next path
}
}
return null;
}
async sendVerificationCode(
cellNumber: string,
code: string,
+13 -1
View File
@@ -9,6 +9,7 @@ import {
import { Prisma } from '@prisma/client';
import * as bcrypt from 'bcrypt';
import { PermissionsService } from '../auth/permissions.service';
import { DEFAULT_NEW_BUSINESS_SMS_BALANCE_IRT } from '../auth/sms-billing';
import { PrismaService } from '../prisma/prisma.service';
import { AuthUser } from '../auth/auth.types';
import { AddDomainDto } from './dto/add-domain.dto';
@@ -81,6 +82,7 @@ type BusinessRow = {
enabledModules: unknown;
homeCharts: unknown;
smsSenderNumber: string | null;
smsBalance: number;
};
function slugify(value: string) {
@@ -166,7 +168,8 @@ export class BusinessAdminService {
b.settings->'branding'->>'themeMode' AS "themeMode",
b.settings->'modules'->'enabled' AS "enabledModules",
b.settings->'modules'->'charts' AS "homeCharts",
b.settings->'sms'->>'senderNumber' AS "smsSenderNumber"
b.settings->'sms'->>'senderNumber' AS "smsSenderNumber",
b.sms_balance AS "smsBalance"
FROM businesses b
LEFT JOIN LATERAL (
SELECT d.id, d.host, d.ssl_enabled, d.git_repo_url, d.deploy_slug
@@ -228,6 +231,7 @@ export class BusinessAdminService {
moduleCount: enabledModules.length,
homeCharts,
smsSenderNumber: normalizeSmsSenderNumber(item.smsSenderNumber),
smsBalance: Number(item.smsBalance ?? 0),
};
}),
total: totalRow[0]?.total ?? 0,
@@ -387,6 +391,7 @@ export class BusinessAdminService {
: new Date(),
oldBusinessId:
dto.oldBusinessId !== undefined ? BigInt(dto.oldBusinessId) : undefined,
smsBalance: DEFAULT_NEW_BUSINESS_SMS_BALANCE_IRT,
settings: toPrismaJson({
...DEFAULT_BUSINESS_SETTINGS,
modules: {
@@ -466,6 +471,10 @@ export class BusinessAdminService {
}
}
if (dto.smsBalance !== undefined && dto.smsBalance < 0) {
throw new BadRequestException('smsBalance cannot be negative');
}
await this.prisma.$transaction(async (tx) => {
const nextSettings =
dto.smsSenderNumber !== undefined
@@ -503,6 +512,7 @@ export class BusinessAdminService {
}
: {}),
...(nextSettings !== undefined ? { settings: nextSettings } : {}),
...(dto.smsBalance !== undefined ? { smsBalance: dto.smsBalance } : {}),
},
});
@@ -1272,6 +1282,7 @@ export class BusinessAdminService {
createdAt: Date;
updatedAt: Date;
settings: unknown;
smsBalance?: number;
categoryAssignments: {
category: {
id: bigint;
@@ -1316,6 +1327,7 @@ export class BusinessAdminService {
createdAt: business.createdAt,
updatedAt: business.updatedAt,
smsSenderNumber: settings.sms.senderNumber,
smsBalance: business.smsBalance ?? 0,
categories: business.categoryAssignments.map((a) => ({
id: a.category.id,
name: a.category.name,
@@ -7,6 +7,7 @@ import {
IsPositive,
IsString,
Matches,
Min,
MinLength,
ValidateIf,
} from 'class-validator';
@@ -71,4 +72,11 @@ export class UpdateBusinessDto {
message: 'smsSenderNumber must be an 8–16 digit shortcode',
})
smsSenderNumber?: string | null;
/** Prepaid SMS message credits for this business (absolute set). */
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
smsBalance?: number;
}
+2 -1
View File
@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { CustomersController } from './customers.controller';
import { CustomersService } from './customers.service';
import { SmsTemplatesModule } from '../sms-templates/sms-templates.module';
@Module({
imports: [AuthModule],
imports: [AuthModule, SmsTemplatesModule],
controllers: [CustomersController],
providers: [CustomersService],
})
+225 -26
View File
@@ -4,6 +4,7 @@ import * as bcrypt from 'bcrypt';
import { randomBytes } from 'crypto';
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import { SmsBillingService } from '../auth/sms-billing.service';
import { SmsService } from '../auth/sms.service';
import {
buildDualDailyActivitySeries,
@@ -21,6 +22,11 @@ import { SearchCustomersDto } from './dto/search-customers.dto';
import { SendCustomerSmsDto } from './dto/send-customer-sms.dto';
import { SendCustomersBulkSmsDto } from './dto/send-customers-bulk-sms.dto';
import { UpdateCustomerDto } from './dto/update-customer.dto';
import { SmsTemplatesService } from '../sms-templates/sms-templates.service';
import {
renderSmsTemplate,
smsTemplateUsesVariables,
} from '../sms-templates/sms-template-render';
type CustomerListRow = {
id: bigint;
@@ -43,6 +49,8 @@ export class CustomersService {
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
private readonly sms: SmsService,
private readonly smsBilling: SmsBillingService,
private readonly smsTemplates: SmsTemplatesService,
) {}
async list(
@@ -692,7 +700,7 @@ export class CustomersService {
businessId_userId: { businessId, userId },
},
include: {
user: true,
user: { include: { title: true } },
business: { select: { settings: true, isActive: true } },
},
});
@@ -708,23 +716,36 @@ export class CustomersService {
};
}
const settings = normalizeBusinessSettings(membership.business.settings);
const source =
settings.sms.senderNumber?.trim() || this.sms.getAdvertisingSource();
const resolved = await this.resolveOutboundSms(
businessId,
membership.business.settings,
dto.message,
dto.templateId,
{
firstName: membership.user.firstName,
lastName: membership.user.lastName,
titleName: membership.user.title?.nameFa || membership.user.title?.nameEn,
},
);
const charged = await this.smsBilling.charge(businessId, resolved.message);
try {
const result = await this.sms.sendMessage(
membership.user.cellNumber,
dto.message.trim(),
{ source },
resolved.message,
{ source: resolved.source },
);
return {
enabled: true,
message: 'Message sent successfully',
serverId: result.serverId,
source,
source: resolved.source,
templateId: resolved.templateId,
templateVerified: resolved.templateVerified,
};
} catch (err) {
await this.smsBilling.refund(businessId, charged);
if (
err instanceof BadRequestException ||
err instanceof ServiceUnavailableException
@@ -736,8 +757,8 @@ export class CustomersService {
}
/**
* Bulk SMS (Gama SendBulk). Empty/omitted userIds → all enabled business customers.
* Selected userIds may include team members shown on the customers list.
* Bulk SMS. Empty/omitted userIds → all enabled business customers.
* With a personalized template, messages are sent per-recipient (not one bulk payload).
*/
async sendBulkSms(
businessIdRaw: string,
@@ -759,7 +780,14 @@ export class CustomersService {
.map((id) => id.trim())
.filter(Boolean);
let cellNumbers: string[];
type Recipient = {
cellNumber: string;
firstName: string | null;
lastName: string | null;
titleName: string | null;
};
let recipients: Recipient[];
if (selectedIds.length > 0) {
const userIds = selectedIds.map((id) => {
@@ -770,9 +798,21 @@ export class CustomersService {
}
});
const rows = await this.prisma.$queryRaw<{ cellNumber: string }[]>(Prisma.sql`
SELECT DISTINCT u.cell_number AS "cellNumber"
const rows = await this.prisma.$queryRaw<
{
cellNumber: string;
firstName: string | null;
lastName: string | null;
titleName: string | null;
}[]
>(Prisma.sql`
SELECT DISTINCT ON (u.id)
u.cell_number AS "cellNumber",
u.first_name AS "firstName",
u.last_name AS "lastName",
COALESCE(ut.name_fa, ut.name_en) AS "titleName"
FROM users u
LEFT JOIN user_titles ut ON ut.id = u.title_id
LEFT JOIN business_customers bc
ON bc.user_id = u.id AND bc.business_id = ${businessId}
LEFT JOIN business_users bu
@@ -780,16 +820,30 @@ export class CustomersService {
WHERE u.id IN (${Prisma.join(userIds)})
AND (bc.id IS NOT NULL OR bu.id IS NOT NULL)
`);
cellNumbers = rows.map((r) => r.cellNumber);
recipients = rows;
} else {
const rows = await this.prisma.businessCustomer.findMany({
where: { businessId, isEnabled: true },
select: { user: { select: { cellNumber: true } } },
select: {
user: {
select: {
cellNumber: true,
firstName: true,
lastName: true,
title: { select: { nameFa: true, nameEn: true } },
},
},
},
});
cellNumbers = rows.map((r) => r.user.cellNumber);
recipients = rows.map((r) => ({
cellNumber: r.user.cellNumber,
firstName: r.user.firstName,
lastName: r.user.lastName,
titleName: r.user.title?.nameFa || r.user.title?.nameEn || null,
}));
}
if (cellNumbers.length === 0) {
if (recipients.length === 0) {
throw new BadRequestException('No recipients found to send SMS');
}
@@ -797,26 +851,117 @@ export class CustomersService {
return {
enabled: false,
message: 'SMS is disabled. Message was not sent.',
recipientCount: cellNumbers.length,
recipientCount: recipients.length,
};
}
const settings = normalizeBusinessSettings(business.settings);
const source =
settings.sms.senderNumber?.trim() || this.sms.getAdvertisingSource();
const sample = await this.resolveOutboundSms(
businessId,
business.settings,
dto.message,
dto.templateId,
{
firstName: recipients[0].firstName,
lastName: recipients[0].lastName,
titleName: recipients[0].titleName,
},
);
const personalized =
Boolean(dto.templateId) || smsTemplateUsesVariables(dto.message);
let charged = 0;
let personalizedPayloads: Array<{
cellNumber: string;
message: string;
source: string;
cost: number;
}> = [];
if (!personalized) {
charged = await this.smsBilling.charge(
businessId,
sample.message,
recipients.length,
);
} else {
personalizedPayloads = [];
let totalCost = 0;
for (const recipient of recipients) {
const resolved = await this.resolveOutboundSms(
businessId,
business.settings,
dto.message,
dto.templateId,
{
firstName: recipient.firstName,
lastName: recipient.lastName,
titleName: recipient.titleName,
},
);
const cost = this.smsBilling.costForMessage(resolved.message);
personalizedPayloads.push({
cellNumber: recipient.cellNumber,
message: resolved.message,
source: resolved.source,
cost,
});
totalCost += cost;
}
charged = await this.smsBilling.chargeAmount(businessId, totalCost);
}
try {
const result = await this.sms.sendBulkMessage(cellNumbers, dto.message.trim(), {
source,
});
if (!personalized) {
const result = await this.sms.sendBulkMessage(
recipients.map((r) => r.cellNumber),
sample.message,
{ source: sample.source },
);
return {
enabled: true,
message: 'Message sent successfully',
recipientCount: result.recipientCount,
serverIds: result.serverIds,
source: sample.source,
templateId: sample.templateId,
templateVerified: sample.templateVerified,
};
}
const serverIds: string[] = [];
for (let i = 0; i < personalizedPayloads.length; i++) {
const payload = personalizedPayloads[i];
try {
const result = await this.sms.sendMessage(
payload.cellNumber,
payload.message,
{ source: payload.source },
);
serverIds.push(result.serverId);
} catch (err) {
const refundAmount = personalizedPayloads
.slice(i)
.reduce((sum, item) => sum + item.cost, 0);
await this.smsBilling.refund(businessId, refundAmount);
charged = 0;
throw err;
}
}
return {
enabled: true,
message: 'Message sent successfully',
recipientCount: result.recipientCount,
serverIds: result.serverIds,
source,
recipientCount: serverIds.length,
serverIds,
source: sample.source,
templateId: sample.templateId,
templateVerified: sample.templateVerified,
};
} catch (err) {
if (charged > 0) {
await this.smsBilling.refund(businessId, charged);
}
if (
err instanceof BadRequestException ||
err instanceof ServiceUnavailableException
@@ -827,6 +972,60 @@ export class CustomersService {
}
}
/**
* Resolve message text + Gama shortcode for customer SMS.
* Verified template → service line (fixed). Unverified template → advertising line.
* Free text → business senderNumber or advertising (unchanged).
*/
private async resolveOutboundSms(
businessId: bigint,
businessSettings: unknown,
messageRaw: string,
templateIdRaw: string | undefined,
vars: {
firstName?: string | null;
lastName?: string | null;
titleName?: string | null;
},
) {
const settings = normalizeBusinessSettings(businessSettings);
let message = messageRaw.trim();
let templateId: string | null = null;
let templateVerified: boolean | null = null;
let source =
settings.sms.senderNumber?.trim() || this.sms.getAdvertisingSource();
if (templateIdRaw?.trim()) {
const template = await this.smsTemplates.getTemplateForSend(
businessId,
BigInt(templateIdRaw.trim()),
);
templateId = template.id.toString();
templateVerified = template.isVerified;
// Verified templates always use the approved body (sender is also fixed).
const draft = template.isVerified
? template.body
: message || template.body;
message = renderSmsTemplate(draft, vars);
source = template.isVerified
? this.sms.getServiceSource()
: this.sms.getAdvertisingSource();
} else if (smsTemplateUsesVariables(message)) {
message = renderSmsTemplate(message, vars);
}
if (!message.trim()) {
throw new BadRequestException('Message is empty');
}
return {
message: message.trim(),
source,
templateId,
templateVerified,
};
}
private formatLabel(user: {
firstName: string | null;
lastName: string | null;
+6 -1
View File
@@ -1,8 +1,13 @@
import { IsString, MaxLength, MinLength } from 'class-validator';
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class SendCustomerSmsDto {
@IsString()
@MinLength(1)
@MaxLength(700)
message!: string;
/** Optional SMS template id — body may still be sent as the rendered text. */
@IsOptional()
@IsString()
templateId?: string;
}
@@ -24,4 +24,8 @@ export class SendCustomersBulkSmsDto {
@IsString({ each: true })
@Type(() => String)
userIds?: string[];
@IsOptional()
@IsString()
templateId?: string;
}
+26 -10
View File
@@ -6,6 +6,7 @@ import {
ServiceUnavailableException,
} from '@nestjs/common';
import { RedisService } from '../redis/redis.service';
import { SmsBillingService } from '../auth/sms-billing.service';
import { SmsService, toGamaMsisdn } from '../auth/sms.service';
import { SendPublicSmsDto } from './dto/send-public-sms.dto';
@@ -19,6 +20,7 @@ export class PublicSmsService {
constructor(
private readonly sms: SmsService,
private readonly smsBilling: SmsBillingService,
private readonly redis: RedisService,
) {}
@@ -34,17 +36,31 @@ export class PublicSmsService {
await this.assertRateLimits(partnerDomain, msisdn);
const result = await this.sms.sendMessage(msisdn, dto.message, {
source: dto.source?.trim() || undefined,
});
this.logger.log(
`Partner SMS accepted domain=${partnerDomain} serverId=${result.serverId}`,
);
const businessId =
await this.smsBilling.requireBusinessIdByDomain(partnerDomain);
const charged = await this.smsBilling.charge(businessId, dto.message);
return {
success: true,
serverId: result.serverId,
};
// Partners / free-text: advertising shortcode unless an explicit source is provided.
// Verified Meshkee templates use the service line via the business customers SMS API.
const source =
dto.source?.trim() || this.sms.getAdvertisingSource();
try {
const result = await this.sms.sendMessage(msisdn, dto.message, {
source,
});
this.logger.log(
`Partner SMS accepted domain=${partnerDomain} source=${source} serverId=${result.serverId} charged=${charged}`,
);
return {
success: true,
serverId: result.serverId,
};
} catch (err) {
await this.smsBilling.refund(businessId, charged);
throw err;
}
}
private async assertRateLimits(domain: string, msisdn: string): Promise<void> {
@@ -0,0 +1,13 @@
import { IsString, MaxLength, MinLength } from 'class-validator';
export class CreateSmsTemplateDto {
@IsString()
@MinLength(1)
@MaxLength(255)
name!: string;
@IsString()
@MinLength(1)
@MaxLength(700)
body!: string;
}
@@ -0,0 +1,15 @@
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class UpdateSmsTemplateDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
name?: string;
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(700)
body?: string;
}
+36
View File
@@ -0,0 +1,36 @@
/** Variables available in SMS templates (double or single braces). */
export const SMS_TEMPLATE_VARIABLES = [
'user.name',
'user.lastname',
'user.title',
] as const;
export type SmsTemplateVarContext = {
firstName?: string | null;
lastName?: string | null;
titleName?: string | null;
};
const VAR_REGEX =
/\{\{\s*(user\.(?:name|lastname|title|firstName|lastName))\s*\}\}|\{(user\.(?:name|lastname|title|firstName|lastName))\}/gi;
export function smsTemplateUsesVariables(body: string): boolean {
return new RegExp(VAR_REGEX.source, 'i').test(body);
}
export function renderSmsTemplate(
body: string,
ctx: SmsTemplateVarContext,
): string {
const first = (ctx.firstName ?? '').trim();
const last = (ctx.lastName ?? '').trim();
const title = (ctx.titleName ?? '').trim();
return body.replace(new RegExp(VAR_REGEX.source, 'gi'), (_full, a?: string, b?: string) => {
const key = (a || b || '').toLowerCase();
if (key === 'user.name' || key === 'user.firstname') return first;
if (key === 'user.lastname') return last;
if (key === 'user.title') return title;
return '';
});
}
@@ -0,0 +1,137 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { AuthUser } from '../auth/auth.types';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CreateSmsTemplateDto } from './dto/create-sms-template.dto';
import { UpdateSmsTemplateDto } from './dto/update-sms-template.dto';
import { SmsTemplatesService } from './sms-templates.service';
@Controller()
@UseGuards(JwtAuthGuard)
export class SmsTemplatesController {
constructor(private readonly service: SmsTemplatesService) {}
@Get('businesses/:businessId/sms/credit')
@UseGuards(BusinessPermissionGuard)
@RequireBusinessPermission('orders.read')
credit(
@Param('businessId') businessId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.creditForBusiness(businessId, user);
}
@Get('businesses/:businessId/sms/templates')
@UseGuards(BusinessPermissionGuard)
@RequireBusinessPermission('orders.read')
listForBusiness(
@Param('businessId') businessId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.listForBusiness(businessId, user);
}
@Post('businesses/:businessId/sms/templates')
@UseGuards(BusinessPermissionGuard)
@RequireBusinessPermission('orders.update')
createForBusiness(
@Param('businessId') businessId: string,
@Body() dto: CreateSmsTemplateDto,
@CurrentUser() user: AuthUser,
) {
return this.service.createForBusiness(businessId, dto, user);
}
@Patch('businesses/:businessId/sms/templates/:templateId')
@UseGuards(BusinessPermissionGuard)
@RequireBusinessPermission('orders.update')
updateForBusiness(
@Param('businessId') businessId: string,
@Param('templateId') templateId: string,
@Body() dto: UpdateSmsTemplateDto,
@CurrentUser() user: AuthUser,
) {
return this.service.updateForBusiness(businessId, templateId, dto, user);
}
@Delete('businesses/:businessId/sms/templates/:templateId')
@HttpCode(200)
@UseGuards(BusinessPermissionGuard)
@RequireBusinessPermission('orders.update')
removeForBusiness(
@Param('businessId') businessId: string,
@Param('templateId') templateId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.removeForBusiness(businessId, templateId, user);
}
@Post('businesses/:businessId/sms/templates/:templateId/request-verification')
@UseGuards(BusinessPermissionGuard)
@RequireBusinessPermission('orders.update')
requestVerification(
@Param('businessId') businessId: string,
@Param('templateId') templateId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.requestVerification(businessId, templateId, user);
}
@Get('sms-templates')
listAll(
@CurrentUser() user: AuthUser,
@Query('status') status?: string,
@Query('verified') verified?: string,
) {
// Prefer status=pending|verified|all; keep verified= for older clients.
let filter: 'free' | 'pending' | 'verified' | 'all' | undefined;
if (status === 'pending' || status === 'verified' || status === 'all') {
filter = status;
} else if (verified === 'true') {
filter = 'verified';
} else if (verified === 'false') {
filter = 'pending';
} else {
filter = 'all';
}
return this.service.listAll(user, filter);
}
@Patch('sms-templates/:templateId')
updateAsAdmin(
@Param('templateId') templateId: string,
@Body() dto: UpdateSmsTemplateDto,
@CurrentUser() user: AuthUser,
) {
return this.service.updateAsAdmin(templateId, dto, user);
}
@Post('sms-templates/:templateId/verify')
verify(
@Param('templateId') templateId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.verify(templateId, user, true);
}
@Post('sms-templates/:templateId/unverify')
unverify(
@Param('templateId') templateId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.verify(templateId, user, false);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { SmsTemplatesController } from './sms-templates.controller';
import { SmsTemplatesService } from './sms-templates.service';
@Module({
imports: [AuthModule],
controllers: [SmsTemplatesController],
providers: [SmsTemplatesService],
exports: [SmsTemplatesService],
})
export class SmsTemplatesModule {}
+377
View File
@@ -0,0 +1,377 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import { PrismaService } from '../prisma/prisma.service';
import { CreateSmsTemplateDto } from './dto/create-sms-template.dto';
import { UpdateSmsTemplateDto } from './dto/update-sms-template.dto';
import { SMS_TEMPLATE_VARIABLES } from './sms-template-render';
export type SmsTemplateVerificationStatus = 'free' | 'pending' | 'verified';
const CLEAR_VERIFICATION = {
verificationStatus: 'free' as const,
isVerified: false,
verifiedAt: null,
verifiedBy: null,
};
@Injectable()
export class SmsTemplatesService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
) {}
async listForBusiness(businessIdRaw: string, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
await this.assertBusinessPermission(businessId, actor.id, 'orders.read');
const items = await this.prisma.smsTemplate.findMany({
where: { businessId },
orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }],
});
return {
items: items.map((row) => this.serialize(row)),
variables: [...SMS_TEMPLATE_VARIABLES],
};
}
async createForBusiness(
businessIdRaw: string,
dto: CreateSmsTemplateDto,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
await this.assertBusinessPermission(businessId, actor.id, 'orders.update');
const created = await this.prisma.smsTemplate.create({
data: {
businessId,
name: dto.name.trim(),
body: dto.body.trim(),
verificationStatus: 'free',
isVerified: false,
},
});
return { template: this.serialize(created) };
}
async updateForBusiness(
businessIdRaw: string,
templateIdRaw: string,
dto: UpdateSmsTemplateDto,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
const templateId = BigInt(templateIdRaw);
await this.assertBusinessPermission(businessId, actor.id, 'orders.update');
const existing = await this.prisma.smsTemplate.findFirst({
where: { id: templateId, businessId },
});
if (!existing) {
throw new NotFoundException('SMS template not found');
}
const name = dto.name?.trim();
const body = dto.body?.trim();
const nameChanged = name !== undefined && name !== existing.name;
const bodyChanged = body !== undefined && body !== existing.body;
const contentChanged = nameChanged || bodyChanged;
const updated = await this.prisma.smsTemplate.update({
where: { id: templateId },
data: {
...(name !== undefined ? { name } : {}),
...(body !== undefined ? { body } : {}),
...(contentChanged ? CLEAR_VERIFICATION : {}),
},
});
return { template: this.serialize(updated) };
}
async requestVerification(
businessIdRaw: string,
templateIdRaw: string,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
const templateId = BigInt(templateIdRaw);
await this.assertBusinessPermission(businessId, actor.id, 'orders.update');
const existing = await this.prisma.smsTemplate.findFirst({
where: { id: templateId, businessId },
});
if (!existing) {
throw new NotFoundException('SMS template not found');
}
if (existing.isVerified || existing.verificationStatus === 'verified') {
throw new BadRequestException('Template is already verified');
}
if (existing.verificationStatus === 'pending') {
return { template: this.serialize(existing) };
}
const updated = await this.prisma.smsTemplate.update({
where: { id: templateId },
data: {
verificationStatus: 'pending',
isVerified: false,
verifiedAt: null,
verifiedBy: null,
},
});
return { template: this.serialize(updated) };
}
async removeForBusiness(
businessIdRaw: string,
templateIdRaw: string,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
const templateId = BigInt(templateIdRaw);
await this.assertBusinessPermission(businessId, actor.id, 'orders.update');
const existing = await this.prisma.smsTemplate.findFirst({
where: { id: templateId, businessId },
});
if (!existing) {
throw new NotFoundException('SMS template not found');
}
await this.prisma.smsTemplate.delete({ where: { id: templateId } });
return { success: true };
}
async creditForBusiness(businessIdRaw: string, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
await this.assertBusinessPermission(businessId, actor.id, 'orders.read');
const business = await this.prisma.business.findUnique({
where: { id: businessId },
select: { smsBalance: true },
});
if (!business) {
throw new NotFoundException('Business not found');
}
return {
balance: business.smsBalance,
credit: business.smsBalance,
currency: null,
message: null,
};
}
/**
* Super-admin list — only pending + verified (free templates stay in business UI only).
*/
async listAll(
actor: AuthUser,
status?: SmsTemplateVerificationStatus | 'all',
) {
await this.assertSuperAdmin(actor);
const statusFilter =
status === 'pending' || status === 'verified'
? status
: undefined;
const items = await this.prisma.smsTemplate.findMany({
where: {
verificationStatus: statusFilter
? statusFilter
: { in: ['pending', 'verified'] },
},
include: {
business: { select: { id: true, name: true, nameFa: true, slug: true } },
},
orderBy: [
{ verificationStatus: 'asc' },
{ updatedAt: 'desc' },
{ id: 'desc' },
],
take: 500,
});
return {
items: items.map((row) => this.serializeAdmin(row)),
variables: [...SMS_TEMPLATE_VARIABLES],
};
}
async verify(templateIdRaw: string, actor: AuthUser, verified: boolean) {
await this.assertSuperAdmin(actor);
const templateId = BigInt(templateIdRaw);
const existing = await this.prisma.smsTemplate.findUnique({
where: { id: templateId },
});
if (!existing) {
throw new NotFoundException('SMS template not found');
}
const updated = await this.prisma.smsTemplate.update({
where: { id: templateId },
data: verified
? {
verificationStatus: 'verified',
isVerified: true,
verifiedAt: new Date(),
verifiedBy: actor.id,
}
: CLEAR_VERIFICATION,
include: {
business: { select: { id: true, name: true, nameFa: true, slug: true } },
},
});
return { template: this.serializeAdmin(updated) };
}
/** Super-admin edit. Content changes clear verification back to free. */
async updateAsAdmin(
templateIdRaw: string,
dto: UpdateSmsTemplateDto,
actor: AuthUser,
) {
await this.assertSuperAdmin(actor);
const templateId = BigInt(templateIdRaw);
const existing = await this.prisma.smsTemplate.findUnique({
where: { id: templateId },
});
if (!existing) {
throw new NotFoundException('SMS template not found');
}
const name = dto.name?.trim();
const body = dto.body?.trim();
const nameChanged = name !== undefined && name !== existing.name;
const bodyChanged = body !== undefined && body !== existing.body;
const contentChanged = nameChanged || bodyChanged;
const updated = await this.prisma.smsTemplate.update({
where: { id: templateId },
data: {
...(name !== undefined ? { name } : {}),
...(body !== undefined ? { body } : {}),
...(contentChanged ? CLEAR_VERIFICATION : {}),
},
include: {
business: { select: { id: true, name: true, nameFa: true, slug: true } },
},
});
return { template: this.serializeAdmin(updated) };
}
async getTemplateForSend(businessId: bigint, templateId: bigint) {
const template = await this.prisma.smsTemplate.findFirst({
where: { id: templateId, businessId },
});
if (!template) {
throw new BadRequestException('SMS template not found');
}
return template;
}
private serializeAdmin(
row: {
id: bigint;
businessId: bigint;
name: string;
body: string;
verificationStatus: string;
isVerified: boolean;
verifiedAt: Date | null;
verifiedBy: bigint | null;
createdAt: Date;
updatedAt: Date;
business: {
id: bigint;
name: string;
nameFa: string | null;
slug: string;
};
},
) {
return {
...this.serialize(row),
business: {
id: row.business.id.toString(),
name: row.business.nameFa?.trim() || row.business.name,
slug: row.business.slug,
},
};
}
private serialize(row: {
id: bigint;
businessId: bigint;
name: string;
body: string;
verificationStatus?: string | null;
isVerified: boolean;
verifiedAt: Date | null;
verifiedBy: bigint | null;
createdAt: Date;
updatedAt: Date;
}) {
const verificationStatus = this.normalizeStatus(
row.verificationStatus,
row.isVerified,
);
return {
id: row.id.toString(),
businessId: row.businessId.toString(),
name: row.name,
body: row.body,
verificationStatus,
isVerified: verificationStatus === 'verified',
verifiedAt: row.verifiedAt?.toISOString() ?? null,
verifiedBy: row.verifiedBy?.toString() ?? null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
private normalizeStatus(
raw: string | null | undefined,
isVerified: boolean,
): SmsTemplateVerificationStatus {
if (raw === 'pending' || raw === 'verified' || raw === 'free') return raw;
return isVerified ? 'verified' : 'free';
}
private async assertBusinessPermission(
businessId: bigint,
userId: bigint,
permission: string,
) {
const allowed = await this.permissions.hasBusinessPermission(
userId,
businessId,
permission,
);
if (!allowed) {
throw new ForbiddenException('Missing permission');
}
}
private async assertSuperAdmin(actor: AuthUser) {
if (!(await this.permissions.isSuperAdmin(actor.id))) {
throw new ForbiddenException('Super admin only');
}
}
}
+3 -1
View File
@@ -24,6 +24,7 @@ X-Api-Key: <partner-secret>
| `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 |
| `source` | no | Gama shortcode override (8–16 digits). Default: advertising `9000590009` |
### Example (Balout)
@@ -72,7 +73,8 @@ First allowlisted partner: **baloutpastry.com**.
## Sender line (v1)
Uses the **service** shortcode only (`SendQuick`). Advertising / bulk / OTP pattern APIs are not exposed yet.
Default sender is the **advertising** shortcode (`9000590009` / `SMS_GAMA_SOURCE_ADVERTISE`).
Pass `source` to override (e.g. a business-owned shortcode). OTP/login on Meshkee dashboards uses the **service** shortcode (`5000110005`) via `/auth/send-otp`, not this partner endpoint.
---