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:
co-authored by
Cursor
parent
4623061a4f
commit
4a1d60dbef
+2
-2
@@ -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=
|
||||
|
||||
@@ -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.';
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,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],
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user