Add customer Excel import/export and Gama bulk SMS sending.
Business SMS uses optional settings.sms.senderNumber (else advertising shortcode) with SendQuick for one recipient and SendBulk for quantity. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
cf459807d8
commit
b1dcecc468
+2
-1
@@ -30,7 +30,8 @@ SMS_GAMA_BASE_URL=https://sms.igama.ir/api/v1
|
|||||||
SMS_GAMA_USERNAME=
|
SMS_GAMA_USERNAME=
|
||||||
SMS_GAMA_PASSWORD=
|
SMS_GAMA_PASSWORD=
|
||||||
SMS_GAMA_SOURCE_SERVICE=5000110005
|
SMS_GAMA_SOURCE_SERVICE=5000110005
|
||||||
# Optional later: SMS_GAMA_SOURCE_ADVERTISE=500099000005
|
# Optional later: SMS_GAMA_SOURCE_ADVERTISE=5000990009
|
||||||
|
SMS_GAMA_SOURCE_ADVERTISE=5000990009
|
||||||
# Partner gateway: domain:apiKey pairs, comma-separated (www. is stripped)
|
# Partner gateway: domain:apiKey pairs, comma-separated (www. is stripped)
|
||||||
# Example: SMS_PARTNERS=baloutpastry.com:replace-with-long-random-secret
|
# Example: SMS_PARTNERS=baloutpastry.com:replace-with-long-random-secret
|
||||||
SMS_PARTNERS=
|
SMS_PARTNERS=
|
||||||
|
|||||||
+211
-7
@@ -10,6 +10,22 @@ export type SmsSendResult = {
|
|||||||
serverId: string;
|
serverId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SmsBulkSendResult = {
|
||||||
|
serverIds: string[];
|
||||||
|
recipientCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SmsSendOptions = {
|
||||||
|
/** Gama shortcode override. When omitted, uses SMS_GAMA_SOURCE_SERVICE. */
|
||||||
|
source?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Default Meshkee advertising shortcode when a business has no own SMS number. */
|
||||||
|
export const DEFAULT_ADVERTISING_SMS_SOURCE = '5000990009';
|
||||||
|
|
||||||
|
/** Gama batch size limit is undocumented; keep chunks conservative. */
|
||||||
|
const GAMA_BULK_CHUNK_SIZE = 100;
|
||||||
|
|
||||||
type GamaQuickResponse = {
|
type GamaQuickResponse = {
|
||||||
success?: boolean;
|
success?: boolean;
|
||||||
message?: string | null;
|
message?: string | null;
|
||||||
@@ -17,6 +33,13 @@ type GamaQuickResponse = {
|
|||||||
body?: number | string | null;
|
body?: number | string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type GamaBulkResponse = {
|
||||||
|
success?: boolean;
|
||||||
|
message?: string | null;
|
||||||
|
errors?: unknown;
|
||||||
|
body?: Array<number | string> | null;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SmsService {
|
export class SmsService {
|
||||||
private readonly logger = new Logger(SmsService.name);
|
private readonly logger = new Logger(SmsService.name);
|
||||||
@@ -27,6 +50,14 @@ export class SmsService {
|
|||||||
return this.config.get<string>('SMS_ENABLED', 'false') === 'true';
|
return this.config.get<string>('SMS_ENABLED', 'false') === 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Advertising / customer-broadcast fallback shortcode. */
|
||||||
|
getAdvertisingSource(): string {
|
||||||
|
return (
|
||||||
|
this.config.get<string>('SMS_GAMA_SOURCE_ADVERTISE')?.trim() ||
|
||||||
|
DEFAULT_ADVERTISING_SMS_SOURCE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async sendVerificationCode(
|
async sendVerificationCode(
|
||||||
cellNumber: string,
|
cellNumber: string,
|
||||||
code: string,
|
code: string,
|
||||||
@@ -46,7 +77,11 @@ export class SmsService {
|
|||||||
return this.sendMessage(cellNumber, message);
|
return this.sendMessage(cellNumber, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendMessage(cellNumber: string, message: string): Promise<SmsSendResult> {
|
async sendMessage(
|
||||||
|
cellNumber: string,
|
||||||
|
message: string,
|
||||||
|
options?: SmsSendOptions,
|
||||||
|
): Promise<SmsSendResult> {
|
||||||
if (!this.isEnabled()) {
|
if (!this.isEnabled()) {
|
||||||
this.logger.warn(`SMS disabled — message for ${cellNumber} not sent: ${message}`);
|
this.logger.warn(`SMS disabled — message for ${cellNumber} not sent: ${message}`);
|
||||||
return { serverId: 'disabled' };
|
return { serverId: 'disabled' };
|
||||||
@@ -65,11 +100,78 @@ export class SmsService {
|
|||||||
throw new BadRequestException('Invalid cellphone number');
|
throw new BadRequestException('Invalid cellphone number');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sourceOverride = options?.source?.trim() || undefined;
|
||||||
|
|
||||||
|
// Local proxy path has no per-sender shortcode unless we pass source through.
|
||||||
if (this.isProxyConfigured()) {
|
if (this.isProxyConfigured()) {
|
||||||
return this.sendViaProxy(destination, text);
|
return this.sendViaProxy(destination, text, sourceOverride);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.sendQuick(destination, text);
|
return this.sendQuick(destination, text, sourceOverride);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One message → many destinations via Gama SendBulk (`/send/bulk`).
|
||||||
|
* Falls back to sequential SendQuick / proxy when only one number or proxy is configured.
|
||||||
|
*/
|
||||||
|
async sendBulkMessage(
|
||||||
|
cellNumbers: string[],
|
||||||
|
message: string,
|
||||||
|
options?: SmsSendOptions,
|
||||||
|
): Promise<SmsBulkSendResult> {
|
||||||
|
if (!this.isEnabled()) {
|
||||||
|
this.logger.warn(
|
||||||
|
`SMS disabled — bulk message for ${cellNumbers.length} recipients not sent: ${message}`,
|
||||||
|
);
|
||||||
|
return { serverIds: ['disabled'], recipientCount: cellNumbers.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = message.trim();
|
||||||
|
if (!text) {
|
||||||
|
throw new BadRequestException('Message is empty');
|
||||||
|
}
|
||||||
|
if (text.length > 700) {
|
||||||
|
throw new BadRequestException('Message is too long (max 700 characters)');
|
||||||
|
}
|
||||||
|
|
||||||
|
const destinations: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const raw of cellNumbers) {
|
||||||
|
const msisdn = toGamaMsisdn(raw);
|
||||||
|
if (!msisdn || seen.has(msisdn)) continue;
|
||||||
|
seen.add(msisdn);
|
||||||
|
destinations.push(msisdn);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (destinations.length === 0) {
|
||||||
|
throw new BadRequestException('No valid cellphone numbers to send to');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceOverride = options?.source?.trim() || undefined;
|
||||||
|
|
||||||
|
if (destinations.length === 1) {
|
||||||
|
const single = await this.sendMessage(destinations[0], text, options);
|
||||||
|
return { serverIds: [single.serverId], recipientCount: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local proxy only supports single destination — send sequentially.
|
||||||
|
if (this.isProxyConfigured()) {
|
||||||
|
const serverIds: string[] = [];
|
||||||
|
for (const destination of destinations) {
|
||||||
|
const result = await this.sendViaProxy(destination, text, sourceOverride);
|
||||||
|
serverIds.push(result.serverId);
|
||||||
|
}
|
||||||
|
return { serverIds, recipientCount: destinations.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverIds: string[] = [];
|
||||||
|
for (let i = 0; i < destinations.length; i += GAMA_BULK_CHUNK_SIZE) {
|
||||||
|
const chunk = destinations.slice(i, i + GAMA_BULK_CHUNK_SIZE);
|
||||||
|
const chunkIds = await this.sendBulk(chunk, text, sourceOverride);
|
||||||
|
serverIds.push(...chunkIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { serverIds, recipientCount: destinations.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
private isProxyConfigured(): boolean {
|
private isProxyConfigured(): boolean {
|
||||||
@@ -80,7 +182,11 @@ export class SmsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Local-dev path: deliver via remote Meshkee partner SMS gateway (production reaches Gama). */
|
/** Local-dev path: deliver via remote Meshkee partner SMS gateway (production reaches Gama). */
|
||||||
private async sendViaProxy(destination: string, message: string): Promise<SmsSendResult> {
|
private async sendViaProxy(
|
||||||
|
destination: string,
|
||||||
|
message: string,
|
||||||
|
source?: string,
|
||||||
|
): Promise<SmsSendResult> {
|
||||||
const url = this.config.get<string>('SMS_PROXY_URL')!.trim();
|
const url = this.config.get<string>('SMS_PROXY_URL')!.trim();
|
||||||
const apiKey = this.config.get<string>('SMS_PROXY_API_KEY')!.trim();
|
const apiKey = this.config.get<string>('SMS_PROXY_API_KEY')!.trim();
|
||||||
const domain = this.config.get<string>('SMS_PROXY_DOMAIN')!.trim();
|
const domain = this.config.get<string>('SMS_PROXY_DOMAIN')!.trim();
|
||||||
@@ -99,6 +205,7 @@ export class SmsService {
|
|||||||
domain,
|
domain,
|
||||||
to: destination,
|
to: destination,
|
||||||
message,
|
message,
|
||||||
|
...(source ? { source } : {}),
|
||||||
}),
|
}),
|
||||||
signal: AbortSignal.timeout(20_000),
|
signal: AbortSignal.timeout(20_000),
|
||||||
});
|
});
|
||||||
@@ -144,10 +251,16 @@ export class SmsService {
|
|||||||
return { serverId };
|
return { serverId };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async sendQuick(destination: string, message: string): Promise<SmsSendResult> {
|
private async sendQuick(
|
||||||
|
destination: string,
|
||||||
|
message: string,
|
||||||
|
sourceOverride?: string,
|
||||||
|
): Promise<SmsSendResult> {
|
||||||
const username = this.config.get<string>('SMS_GAMA_USERNAME')?.trim();
|
const username = this.config.get<string>('SMS_GAMA_USERNAME')?.trim();
|
||||||
const password = this.config.get<string>('SMS_GAMA_PASSWORD')?.trim();
|
const password = this.config.get<string>('SMS_GAMA_PASSWORD')?.trim();
|
||||||
const source = this.config.get<string>('SMS_GAMA_SOURCE_SERVICE')?.trim();
|
const source =
|
||||||
|
sourceOverride?.trim() ||
|
||||||
|
this.config.get<string>('SMS_GAMA_SOURCE_SERVICE')?.trim();
|
||||||
const baseUrl = (
|
const baseUrl = (
|
||||||
this.config.get<string>('SMS_GAMA_BASE_URL') ?? 'https://sms.igama.ir/api/v1'
|
this.config.get<string>('SMS_GAMA_BASE_URL') ?? 'https://sms.igama.ir/api/v1'
|
||||||
).replace(/\/$/, '');
|
).replace(/\/$/, '');
|
||||||
@@ -209,9 +322,97 @@ export class SmsService {
|
|||||||
throw new ServiceUnavailableException('SMS provider returned an empty server id');
|
throw new ServiceUnavailableException('SMS provider returned an empty server id');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`SMS sent to ${masked} via Gama (serverId=${serverId})`);
|
this.logger.log(
|
||||||
|
`SMS sent to ${masked} via Gama source=${source} (serverId=${serverId})`,
|
||||||
|
);
|
||||||
return { serverId };
|
return { serverId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Gama SendBulk — one message, many MSISDNs (`destinations` array). */
|
||||||
|
private async sendBulk(
|
||||||
|
destinations: string[],
|
||||||
|
message: string,
|
||||||
|
sourceOverride?: string,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const username = this.config.get<string>('SMS_GAMA_USERNAME')?.trim();
|
||||||
|
const password = this.config.get<string>('SMS_GAMA_PASSWORD')?.trim();
|
||||||
|
const source =
|
||||||
|
sourceOverride?.trim() ||
|
||||||
|
this.config.get<string>('SMS_GAMA_SOURCE_SERVICE')?.trim();
|
||||||
|
const baseUrl = (
|
||||||
|
this.config.get<string>('SMS_GAMA_BASE_URL') ?? 'https://sms.igama.ir/api/v1'
|
||||||
|
).replace(/\/$/, '');
|
||||||
|
|
||||||
|
if (!username || !password || !source) {
|
||||||
|
this.logger.error('SMS provider credentials are not configured');
|
||||||
|
throw new ServiceUnavailableException('SMS provider is not configured yet');
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${baseUrl}/send/bulk`;
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
message,
|
||||||
|
source,
|
||||||
|
destinations,
|
||||||
|
delay: 0,
|
||||||
|
expire: null,
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`Gama SendBulk network error for ${destinations.length} recipients: ${
|
||||||
|
err instanceof Error ? err.message : err
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
throw new ServiceUnavailableException('SMS provider is unreachable');
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: GamaBulkResponse;
|
||||||
|
try {
|
||||||
|
payload = (await response.json()) as GamaBulkResponse;
|
||||||
|
} catch {
|
||||||
|
this.logger.error(
|
||||||
|
`Gama SendBulk returned non-JSON for ${destinations.length} recipients (HTTP ${response.status})`,
|
||||||
|
);
|
||||||
|
throw new ServiceUnavailableException('SMS provider returned an invalid response');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok || !payload.success) {
|
||||||
|
const providerMessage =
|
||||||
|
typeof payload.message === 'string' && payload.message.trim()
|
||||||
|
? payload.message.trim()
|
||||||
|
: `HTTP ${response.status}`;
|
||||||
|
this.logger.warn(
|
||||||
|
`Gama SendBulk failed for ${destinations.length} recipients: ${providerMessage}`,
|
||||||
|
);
|
||||||
|
throw mapGamaError(providerMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = Array.isArray(payload.body) ? payload.body : [];
|
||||||
|
const serverIds = body.map((id) => String(id)).filter(Boolean);
|
||||||
|
if (serverIds.length === 0) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Gama SendBulk succeeded without serverIds for ${destinations.length} recipients`,
|
||||||
|
);
|
||||||
|
throw new ServiceUnavailableException('SMS provider returned an empty server id');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`SMS bulk sent to ${destinations.length} via Gama source=${source} (ids=${serverIds.length})`,
|
||||||
|
);
|
||||||
|
return serverIds;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Accept 09… / 9… / +989… / 989… → Gama MSISDN `989…` (no plus). */
|
/** Accept 09… / 9… / +989… / 989… → Gama MSISDN `989…` (no plus). */
|
||||||
@@ -273,5 +474,8 @@ function mapGamaError(message: string): Error {
|
|||||||
if (lower.includes('message is empty')) {
|
if (lower.includes('message is empty')) {
|
||||||
return new BadRequestException('Message is empty');
|
return new BadRequestException('Message is empty');
|
||||||
}
|
}
|
||||||
|
if (lower.includes('maximum amount') || lower.includes('batch')) {
|
||||||
|
return new BadRequestException('Too many SMS destinations in one batch');
|
||||||
|
}
|
||||||
return new ServiceUnavailableException('SMS provider rejected the request');
|
return new ServiceUnavailableException('SMS provider rejected the request');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,10 +26,13 @@ import { LegacyPurgeService } from './legacy-purge.service';
|
|||||||
import { normalizeBusinessPrimaryColorId } from '../business-settings/business-primary-colors';
|
import { normalizeBusinessPrimaryColorId } from '../business-settings/business-primary-colors';
|
||||||
import type { BusinessPrimaryColorId } from '../business-settings/business-primary-colors';
|
import type { BusinessPrimaryColorId } from '../business-settings/business-primary-colors';
|
||||||
import {
|
import {
|
||||||
|
mergeBusinessSettings,
|
||||||
|
normalizeBusinessSettings,
|
||||||
normalizeDashboardLocale,
|
normalizeDashboardLocale,
|
||||||
normalizeDashboardThemeMode,
|
normalizeDashboardThemeMode,
|
||||||
normalizeEnabledBusinessModules,
|
normalizeEnabledBusinessModules,
|
||||||
normalizeHomeCharts,
|
normalizeHomeCharts,
|
||||||
|
normalizeSmsSenderNumber,
|
||||||
toPrismaJson,
|
toPrismaJson,
|
||||||
} from '../business-settings/business-settings.util';
|
} from '../business-settings/business-settings.util';
|
||||||
import type {
|
import type {
|
||||||
@@ -77,6 +80,7 @@ type BusinessRow = {
|
|||||||
themeMode: string | null;
|
themeMode: string | null;
|
||||||
enabledModules: unknown;
|
enabledModules: unknown;
|
||||||
homeCharts: unknown;
|
homeCharts: unknown;
|
||||||
|
smsSenderNumber: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function slugify(value: string) {
|
function slugify(value: string) {
|
||||||
@@ -161,7 +165,8 @@ export class BusinessAdminService {
|
|||||||
b.settings->'branding'->>'defaultLocale' AS "defaultLocale",
|
b.settings->'branding'->>'defaultLocale' AS "defaultLocale",
|
||||||
b.settings->'branding'->>'themeMode' AS "themeMode",
|
b.settings->'branding'->>'themeMode' AS "themeMode",
|
||||||
b.settings->'modules'->'enabled' AS "enabledModules",
|
b.settings->'modules'->'enabled' AS "enabledModules",
|
||||||
b.settings->'modules'->'charts' AS "homeCharts"
|
b.settings->'modules'->'charts' AS "homeCharts",
|
||||||
|
b.settings->'sms'->>'senderNumber' AS "smsSenderNumber"
|
||||||
FROM businesses b
|
FROM businesses b
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT d.id, d.host, d.ssl_enabled, d.git_repo_url, d.deploy_slug
|
SELECT d.id, d.host, d.ssl_enabled, d.git_repo_url, d.deploy_slug
|
||||||
@@ -222,6 +227,7 @@ export class BusinessAdminService {
|
|||||||
enabledModules,
|
enabledModules,
|
||||||
moduleCount: enabledModules.length,
|
moduleCount: enabledModules.length,
|
||||||
homeCharts,
|
homeCharts,
|
||||||
|
smsSenderNumber: normalizeSmsSenderNumber(item.smsSenderNumber),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
total: totalRow[0]?.total ?? 0,
|
total: totalRow[0]?.total ?? 0,
|
||||||
@@ -448,7 +454,33 @@ export class BusinessAdminService {
|
|||||||
await this.assertOldBusinessIdAvailable(BigInt(dto.oldBusinessId), businessId);
|
await this.assertOldBusinessIdAvailable(BigInt(dto.oldBusinessId), businessId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dto.smsSenderNumber !== undefined) {
|
||||||
|
if (
|
||||||
|
dto.smsSenderNumber !== null &&
|
||||||
|
dto.smsSenderNumber !== '' &&
|
||||||
|
!normalizeSmsSenderNumber(dto.smsSenderNumber)
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'smsSenderNumber must be an 8–16 digit shortcode',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await this.prisma.$transaction(async (tx) => {
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
const nextSettings =
|
||||||
|
dto.smsSenderNumber !== undefined
|
||||||
|
? toPrismaJson(
|
||||||
|
mergeBusinessSettings(normalizeBusinessSettings(business.settings), {
|
||||||
|
sms: {
|
||||||
|
senderNumber:
|
||||||
|
dto.smsSenderNumber === null || dto.smsSenderNumber === ''
|
||||||
|
? null
|
||||||
|
: normalizeSmsSenderNumber(dto.smsSenderNumber),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
await tx.business.update({
|
await tx.business.update({
|
||||||
where: { id: businessId },
|
where: { id: businessId },
|
||||||
data: {
|
data: {
|
||||||
@@ -470,6 +502,7 @@ export class BusinessAdminService {
|
|||||||
: new Date(dto.annualRenewalAt),
|
: new Date(dto.annualRenewalAt),
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
...(nextSettings !== undefined ? { settings: nextSettings } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1238,6 +1271,7 @@ export class BusinessAdminService {
|
|||||||
annualRenewalAt: Date | null;
|
annualRenewalAt: Date | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
settings: unknown;
|
||||||
categoryAssignments: {
|
categoryAssignments: {
|
||||||
category: {
|
category: {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
@@ -1267,6 +1301,7 @@ export class BusinessAdminService {
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const owner = business.businessUsers[0]?.user ?? null;
|
const owner = business.businessUsers[0]?.user ?? null;
|
||||||
|
const settings = normalizeBusinessSettings(business.settings);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: business.id,
|
id: business.id,
|
||||||
@@ -1280,6 +1315,7 @@ export class BusinessAdminService {
|
|||||||
annualRenewalAt: business.annualRenewalAt,
|
annualRenewalAt: business.annualRenewalAt,
|
||||||
createdAt: business.createdAt,
|
createdAt: business.createdAt,
|
||||||
updatedAt: business.updatedAt,
|
updatedAt: business.updatedAt,
|
||||||
|
smsSenderNumber: settings.sms.senderNumber,
|
||||||
categories: business.categoryAssignments.map((a) => ({
|
categories: business.categoryAssignments.map((a) => ({
|
||||||
id: a.category.id,
|
id: a.category.id,
|
||||||
name: a.category.name,
|
name: a.category.name,
|
||||||
|
|||||||
@@ -59,4 +59,16 @@ export class UpdateBusinessDto {
|
|||||||
@ValidateIf((_, value) => value !== null)
|
@ValidateIf((_, value) => value !== null)
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
annualRenewalAt?: string | null;
|
annualRenewalAt?: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Business SMS shortcode for customer messages (e.g. 5000123456).
|
||||||
|
* Empty string or null clears it (falls back to Meshkee advertising number).
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateIf((_, value) => value !== null && value !== '')
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^\d{8,16}$/, {
|
||||||
|
message: 'smsSenderNumber must be an 8–16 digit shortcode',
|
||||||
|
})
|
||||||
|
smsSenderNumber?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,6 +195,12 @@ export type ModulesSettings = {
|
|||||||
charts: [HomeChartId, HomeChartId];
|
charts: [HomeChartId, HomeChartId];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Per-business SMS sender (Gama shortcode). Empty → Meshkee advertising number. */
|
||||||
|
export type SmsSettings = {
|
||||||
|
/** Outbound shortcode for customer SMS, e.g. "5000123456". */
|
||||||
|
senderNumber: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
/** Top-level business settings stored in `businesses.settings` JSONB. */
|
/** Top-level business settings stored in `businesses.settings` JSONB. */
|
||||||
export type BusinessSettings = {
|
export type BusinessSettings = {
|
||||||
branding: BrandingSettings;
|
branding: BrandingSettings;
|
||||||
@@ -202,6 +208,7 @@ export type BusinessSettings = {
|
|||||||
store: StoreSettings;
|
store: StoreSettings;
|
||||||
modules: ModulesSettings;
|
modules: ModulesSettings;
|
||||||
website: WebsiteSettings;
|
website: WebsiteSettings;
|
||||||
|
sms: SmsSettings;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_ORDER_PROCESS_STEPS: OrderProcessStep[] = [
|
export const DEFAULT_ORDER_PROCESS_STEPS: OrderProcessStep[] = [
|
||||||
@@ -316,4 +323,7 @@ export const DEFAULT_BUSINESS_SETTINGS: BusinessSettings = {
|
|||||||
website: {
|
website: {
|
||||||
specialProductsSource: 'store_item',
|
specialProductsSource: 'store_item',
|
||||||
},
|
},
|
||||||
|
sms: {
|
||||||
|
senderNumber: null,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -121,6 +121,16 @@ function readString(value: unknown, fallback = '') {
|
|||||||
return typeof value === 'string' ? value.trim() : fallback;
|
return typeof value === 'string' ? value.trim() : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Gama / Iranian service shortcodes are numeric (e.g. 5000123456). */
|
||||||
|
export function normalizeSmsSenderNumber(value: unknown): string | null {
|
||||||
|
if (value == null) return null;
|
||||||
|
if (typeof value !== 'string' && typeof value !== 'number') return null;
|
||||||
|
const digits = String(value).replace(/\D/g, '');
|
||||||
|
if (!digits) return null;
|
||||||
|
if (digits.length < 8 || digits.length > 16) return null;
|
||||||
|
return digits;
|
||||||
|
}
|
||||||
|
|
||||||
function isPaymentGatewayId(value: unknown): value is PaymentGatewayId {
|
function isPaymentGatewayId(value: unknown): value is PaymentGatewayId {
|
||||||
return (
|
return (
|
||||||
typeof value === 'string' &&
|
typeof value === 'string' &&
|
||||||
@@ -380,6 +390,7 @@ export function normalizeBusinessSettings(raw: unknown): BusinessSettings {
|
|||||||
const store = isRecord(source.store) ? source.store : {};
|
const store = isRecord(source.store) ? source.store : {};
|
||||||
const modules = isRecord(source.modules) ? source.modules : {};
|
const modules = isRecord(source.modules) ? source.modules : {};
|
||||||
const website = isRecord(source.website) ? source.website : {};
|
const website = isRecord(source.website) ? source.website : {};
|
||||||
|
const sms = isRecord(source.sms) ? source.sms : {};
|
||||||
const hasModulesKey = Object.prototype.hasOwnProperty.call(source, 'modules');
|
const hasModulesKey = Object.prototype.hasOwnProperty.call(source, 'modules');
|
||||||
const enabledModules = hasModulesKey
|
const enabledModules = hasModulesKey
|
||||||
? Array.isArray(modules.enabled)
|
? Array.isArray(modules.enabled)
|
||||||
@@ -431,6 +442,9 @@ export function normalizeBusinessSettings(raw: unknown): BusinessSettings {
|
|||||||
),
|
),
|
||||||
sitemapConfig: normalizeWebsiteSitemapConfig(website.sitemapConfig),
|
sitemapConfig: normalizeWebsiteSitemapConfig(website.sitemapConfig),
|
||||||
},
|
},
|
||||||
|
sms: {
|
||||||
|
senderNumber: normalizeSmsSenderNumber(sms.senderNumber),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,6 +496,12 @@ export function mergeBusinessSettings(
|
|||||||
? patch.website.sitemapConfig
|
? patch.website.sitemapConfig
|
||||||
: current.website.sitemapConfig,
|
: current.website.sitemapConfig,
|
||||||
},
|
},
|
||||||
|
sms: {
|
||||||
|
senderNumber:
|
||||||
|
patch.sms?.senderNumber !== undefined
|
||||||
|
? patch.sms.senderNumber
|
||||||
|
: current.sms.senderNumber,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,340 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import ExcelJS from 'exceljs';
|
||||||
|
|
||||||
|
export const CUSTOMERS_SHEET_NAME = 'Customers';
|
||||||
|
|
||||||
|
export const CUSTOMER_EXCEL_HEADERS = {
|
||||||
|
phone: 'phone',
|
||||||
|
firstName: 'first_name',
|
||||||
|
lastName: 'last_name',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type HeaderKey = keyof typeof CUSTOMER_EXCEL_HEADERS;
|
||||||
|
|
||||||
|
const HEADER_ALIASES: Record<string, HeaderKey> = {
|
||||||
|
phone: 'phone',
|
||||||
|
cell: 'phone',
|
||||||
|
cell_number: 'phone',
|
||||||
|
cellnumber: 'phone',
|
||||||
|
mobile: 'phone',
|
||||||
|
'mobile number': 'phone',
|
||||||
|
'phone number': 'phone',
|
||||||
|
شماره: 'phone',
|
||||||
|
'شماره موبایل': 'phone',
|
||||||
|
موبایل: 'phone',
|
||||||
|
first_name: 'firstName',
|
||||||
|
firstname: 'firstName',
|
||||||
|
'first name': 'firstName',
|
||||||
|
name: 'firstName',
|
||||||
|
نام: 'firstName',
|
||||||
|
last_name: 'lastName',
|
||||||
|
lastname: 'lastName',
|
||||||
|
'last name': 'lastName',
|
||||||
|
'نام خانوادگی': 'lastName',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹';
|
||||||
|
const ARABIC_DIGITS = '٠١٢٣٤٥٦٧٨٩';
|
||||||
|
const E164_RE = /^\+[1-9]\d{6,14}$/;
|
||||||
|
|
||||||
|
export interface CustomerExcelRow {
|
||||||
|
phone: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedCustomerExcelRow {
|
||||||
|
rowNumber: number;
|
||||||
|
phone: string;
|
||||||
|
firstName: string | null;
|
||||||
|
lastName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildCustomersExcel(rows: CustomerExcelRow[]): Promise<Buffer> {
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
workbook.creator = 'Meshkee';
|
||||||
|
workbook.created = new Date();
|
||||||
|
|
||||||
|
const sheet = workbook.addWorksheet(CUSTOMERS_SHEET_NAME);
|
||||||
|
sheet.columns = [
|
||||||
|
{ header: CUSTOMER_EXCEL_HEADERS.phone, key: 'phone', width: 16 },
|
||||||
|
{ header: CUSTOMER_EXCEL_HEADERS.firstName, key: 'firstName', width: 18 },
|
||||||
|
{ header: CUSTOMER_EXCEL_HEADERS.lastName, key: 'lastName', width: 20 },
|
||||||
|
{ width: 3 },
|
||||||
|
{ header: 'دستورالعمل', width: 48 },
|
||||||
|
{ header: 'Instructions', width: 48 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const header = sheet.getRow(1);
|
||||||
|
header.height = 22;
|
||||||
|
header.font = { bold: true, size: 11 };
|
||||||
|
header.alignment = { vertical: 'middle' };
|
||||||
|
for (let col = 1; col <= 3; col += 1) {
|
||||||
|
const cell = header.getCell(col);
|
||||||
|
cell.fill = {
|
||||||
|
type: 'pattern',
|
||||||
|
pattern: 'solid',
|
||||||
|
fgColor: { argb: 'FFE8EEF7' },
|
||||||
|
};
|
||||||
|
cell.font = { bold: true, size: 11 };
|
||||||
|
cell.alignment = { vertical: 'middle' };
|
||||||
|
}
|
||||||
|
for (let col = 5; col <= 6; col += 1) {
|
||||||
|
const cell = header.getCell(col);
|
||||||
|
cell.font = { bold: true, size: 11 };
|
||||||
|
cell.alignment = { vertical: 'middle' };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const added = sheet.addRow({
|
||||||
|
phone: formatPhoneForExcel(row.phone),
|
||||||
|
firstName: row.firstName,
|
||||||
|
lastName: row.lastName,
|
||||||
|
});
|
||||||
|
added.height = 20;
|
||||||
|
added.font = { size: 11 };
|
||||||
|
|
||||||
|
const phoneCell = added.getCell('phone');
|
||||||
|
phoneCell.numFmt = '@';
|
||||||
|
phoneCell.alignment = { vertical: 'middle', readingOrder: 'ltr', horizontal: 'left' };
|
||||||
|
|
||||||
|
const firstNameCell = added.getCell('firstName');
|
||||||
|
firstNameCell.alignment = {
|
||||||
|
vertical: 'middle',
|
||||||
|
readingOrder: looksPersian(row.firstName) ? 'rtl' : 'ltr',
|
||||||
|
};
|
||||||
|
|
||||||
|
const lastNameCell = added.getCell('lastName');
|
||||||
|
lastNameCell.alignment = {
|
||||||
|
vertical: 'middle',
|
||||||
|
readingOrder: looksPersian(row.lastName) ? 'rtl' : 'ltr',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
writeInstructionColumns(sheet);
|
||||||
|
sheet.views = [{ state: 'frozen', ySplit: 1, rightToLeft: false }];
|
||||||
|
|
||||||
|
const buffer = await workbook.xlsx.writeBuffer();
|
||||||
|
return Buffer.from(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseCustomersExcel(buffer: Buffer): Promise<ParsedCustomerExcelRow[]> {
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
await workbook.xlsx.load(buffer as unknown as Parameters<ExcelJS.Xlsx['load']>[0]);
|
||||||
|
|
||||||
|
const sheet =
|
||||||
|
workbook.getWorksheet(CUSTOMERS_SHEET_NAME) ?? workbook.worksheets[0];
|
||||||
|
|
||||||
|
if (!sheet) {
|
||||||
|
throw new BadRequestException('Excel file has no worksheet.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerMap = readHeaderMap(sheet);
|
||||||
|
const phoneCol = headerMap.phone;
|
||||||
|
if (!phoneCol) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Excel is missing required column: phone (or cell / mobile / شماره).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstNameCol = headerMap.firstName;
|
||||||
|
const lastNameCol = headerMap.lastName;
|
||||||
|
const rows: ParsedCustomerExcelRow[] = [];
|
||||||
|
const seenPhones = new Set<string>();
|
||||||
|
|
||||||
|
sheet.eachRow((row, rowNumber) => {
|
||||||
|
if (rowNumber === 1) return;
|
||||||
|
|
||||||
|
const rawPhone = cellText(row.getCell(phoneCol).value);
|
||||||
|
const firstName = firstNameCol
|
||||||
|
? optionalName(cellText(row.getCell(firstNameCol).value))
|
||||||
|
: null;
|
||||||
|
const lastName = lastNameCol
|
||||||
|
? optionalName(cellText(row.getCell(lastNameCol).value))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!rawPhone && !firstName && !lastName) return;
|
||||||
|
|
||||||
|
if (!rawPhone) {
|
||||||
|
throw new BadRequestException(`Row ${rowNumber}: phone is required.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const phone = normalizeCellToE164(rawPhone);
|
||||||
|
if (!phone) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Row ${rowNumber}: phone "${rawPhone}" is not a valid mobile number.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seenPhones.has(phone)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Row ${rowNumber}: duplicate phone "${phone}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
seenPhones.add(phone);
|
||||||
|
|
||||||
|
rows.push({ rowNumber, phone, firstName, lastName });
|
||||||
|
});
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
throw new BadRequestException('Excel has no customer rows to import.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.length > 10_000) {
|
||||||
|
throw new BadRequestException('Excel has too many rows (max 10,000).');
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function customersExcelFilename(now = new Date()) {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-GB', {
|
||||||
|
timeZone: 'Asia/Tehran',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hourCycle: 'h23',
|
||||||
|
}).formatToParts(now);
|
||||||
|
|
||||||
|
const pick = (type: Intl.DateTimeFormatPartTypes) =>
|
||||||
|
parts.find((part) => part.type === type)?.value ?? '';
|
||||||
|
|
||||||
|
const date = `${pick('year')}-${pick('month')}-${pick('day')}`;
|
||||||
|
const time = `${pick('hour')}-${pick('minute')}`;
|
||||||
|
return `customers - ${date} - ${time}.xlsx`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize Iranian/local input to E.164 (e.g. +989121111111). */
|
||||||
|
export function normalizeCellToE164(input: string): string | null {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
if (E164_RE.test(trimmed)) return trimmed;
|
||||||
|
|
||||||
|
const digits = toLatinDigits(trimmed).replace(/\D/g, '');
|
||||||
|
if (!digits) return null;
|
||||||
|
|
||||||
|
if (digits.startsWith('98') && digits.length >= 12) {
|
||||||
|
const value = `+${digits}`;
|
||||||
|
return E164_RE.test(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (digits.startsWith('0') && digits.length >= 11) {
|
||||||
|
const value = `+98${digits.slice(1)}`;
|
||||||
|
return E164_RE.test(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (digits.length === 10 && digits.startsWith('9')) {
|
||||||
|
const value = `+98${digits}`;
|
||||||
|
return E164_RE.test(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^[1-9]\d{6,14}$/.test(digits)) {
|
||||||
|
const value = `+${digits}`;
|
||||||
|
return E164_RE.test(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeInstructionColumns(sheet: ExcelJS.Worksheet) {
|
||||||
|
const faLines = [
|
||||||
|
'فقط ستون phone الزامی است.',
|
||||||
|
'نام و نام خانوادگی اختیاریاند (برای شخصیسازی پیامک).',
|
||||||
|
'رمز عبور لازم نیست — حسابهای جدید با رمز تصادفی ساخته میشوند.',
|
||||||
|
'فرمت شماره: 0912… یا +98912…',
|
||||||
|
'شمارههای تکراری در فایل یا مشتریهای موجود نادیده گرفته میشوند.',
|
||||||
|
'این فایل برای افزودن مشتری جهت ارسال پیامک است.',
|
||||||
|
];
|
||||||
|
const enLines = [
|
||||||
|
'Only the phone column is required.',
|
||||||
|
'first_name / last_name are optional (for SMS personalization).',
|
||||||
|
'No password column — new accounts get a random unusable password.',
|
||||||
|
'Phone format: 0912… or +98912…',
|
||||||
|
'Duplicates in the file or already-linked customers are skipped.',
|
||||||
|
'Use this file to add customers for SMS sending.',
|
||||||
|
];
|
||||||
|
|
||||||
|
faLines.forEach((line, index) => {
|
||||||
|
const row = sheet.getRow(index + 2);
|
||||||
|
if (!row.height || row.height < 20) {
|
||||||
|
row.height = 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
const faCell = row.getCell(5);
|
||||||
|
faCell.value = line;
|
||||||
|
faCell.font = { size: 11 };
|
||||||
|
faCell.alignment = { wrapText: true, vertical: 'top', readingOrder: 'rtl' };
|
||||||
|
|
||||||
|
const enCell = row.getCell(6);
|
||||||
|
enCell.value = enLines[index] ?? '';
|
||||||
|
enCell.font = { size: 11 };
|
||||||
|
enCell.alignment = { wrapText: true, vertical: 'top', readingOrder: 'ltr' };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prefer local 09… display for Iranian numbers in the spreadsheet. */
|
||||||
|
function formatPhoneForExcel(phone: string) {
|
||||||
|
if (phone.startsWith('+98') && phone.length === 13) {
|
||||||
|
return `0${phone.slice(3)}`;
|
||||||
|
}
|
||||||
|
return phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksPersian(value: string) {
|
||||||
|
return /[\u0600-\u06FF]/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readHeaderMap(sheet: ExcelJS.Worksheet): Partial<Record<HeaderKey, number>> {
|
||||||
|
const headerRow = sheet.getRow(1);
|
||||||
|
const map: Partial<Record<HeaderKey, number>> = {};
|
||||||
|
|
||||||
|
headerRow.eachCell((cell, colNumber) => {
|
||||||
|
const key = HEADER_ALIASES[normalizeHeader(cellText(cell.value))];
|
||||||
|
if (key && !map[key]) {
|
||||||
|
map[key] = colNumber;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHeader(value: string) {
|
||||||
|
return value.trim().toLowerCase().replace(/\s+/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalName(value: string): string | null {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed ? trimmed.slice(0, 100) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellText(value: ExcelJS.CellValue): string {
|
||||||
|
if (value == null || value === '') return '';
|
||||||
|
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||||
|
return String(value).trim();
|
||||||
|
}
|
||||||
|
if (value instanceof Date) return '';
|
||||||
|
if (typeof value === 'object' && 'text' in value && typeof value.text === 'string') {
|
||||||
|
return value.text.trim();
|
||||||
|
}
|
||||||
|
if (typeof value === 'object' && 'richText' in value && Array.isArray(value.richText)) {
|
||||||
|
return value.richText.map((part) => part.text).join('').trim();
|
||||||
|
}
|
||||||
|
if (typeof value === 'object' && 'result' in value) {
|
||||||
|
return cellText(value.result as ExcelJS.CellValue);
|
||||||
|
}
|
||||||
|
return String(value).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLatinDigits(value: string) {
|
||||||
|
return [...value]
|
||||||
|
.map((char) => {
|
||||||
|
const persian = PERSIAN_DIGITS.indexOf(char);
|
||||||
|
if (persian >= 0) return String(persian);
|
||||||
|
const arabic = ARABIC_DIGITS.indexOf(char);
|
||||||
|
if (arabic >= 0) return String(arabic);
|
||||||
|
return char;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
@@ -7,8 +8,13 @@ import {
|
|||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
|
StreamableFile,
|
||||||
|
UploadedFile,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { memoryStorage } from 'multer';
|
||||||
import { AuthUser } from '../auth/auth.types';
|
import { AuthUser } from '../auth/auth.types';
|
||||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
||||||
@@ -16,9 +22,12 @@ import { BusinessPermissionGuard } from '../auth/guards/business-permission.guar
|
|||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { DailyActivityQueryDto } from '../common/dto/daily-activity-query.dto';
|
import { DailyActivityQueryDto } from '../common/dto/daily-activity-query.dto';
|
||||||
import { CustomersService } from './customers.service';
|
import { CustomersService } from './customers.service';
|
||||||
|
import { customersExcelFilename } from './customers-excel';
|
||||||
import { CreateCustomerDto } from './dto/create-customer.dto';
|
import { CreateCustomerDto } from './dto/create-customer.dto';
|
||||||
import { ListCustomersDto } from './dto/list-customers.dto';
|
import { ListCustomersDto } from './dto/list-customers.dto';
|
||||||
import { SearchCustomersDto } from './dto/search-customers.dto';
|
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 { UpdateCustomerDto } from './dto/update-customer.dto';
|
||||||
|
|
||||||
@Controller('businesses/:businessId/customers')
|
@Controller('businesses/:businessId/customers')
|
||||||
@@ -56,6 +65,40 @@ export class CustomersController {
|
|||||||
return this.service.search(businessId, query, user);
|
return this.service.search(businessId, query, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('export')
|
||||||
|
@RequireBusinessPermission('orders.read')
|
||||||
|
async exportExcel(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
const buffer = await this.service.exportExcel(businessId, user);
|
||||||
|
const filename = customersExcelFilename();
|
||||||
|
return new StreamableFile(buffer, {
|
||||||
|
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
disposition: `attachment; filename="${filename}"; filename*=UTF-8''${encodeURIComponent(filename)}`,
|
||||||
|
length: buffer.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('import')
|
||||||
|
@RequireBusinessPermission('orders.create')
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor('file', {
|
||||||
|
storage: memoryStorage(),
|
||||||
|
limits: { fileSize: 8 * 1024 * 1024 },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
importExcel(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
if (!file) {
|
||||||
|
throw new BadRequestException('Excel file is required.');
|
||||||
|
}
|
||||||
|
return this.service.importExcel(businessId, file, user);
|
||||||
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RequireBusinessPermission('orders.create')
|
@RequireBusinessPermission('orders.create')
|
||||||
create(
|
create(
|
||||||
@@ -66,6 +109,28 @@ export class CustomersController {
|
|||||||
return this.service.create(businessId, body, user);
|
return this.service.create(businessId, body, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Bulk / quantity SMS via Gama SendBulk. Empty userIds → all enabled customers. */
|
||||||
|
@Post('sms')
|
||||||
|
@RequireBusinessPermission('orders.update')
|
||||||
|
sendBulkSms(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Body() body: SendCustomersBulkSmsDto,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.sendBulkSms(businessId, body, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':userId/sms')
|
||||||
|
@RequireBusinessPermission('orders.update')
|
||||||
|
sendSms(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Param('userId') userId: string,
|
||||||
|
@Body() body: SendCustomerSmsDto,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.sendSms(businessId, userId, body, user);
|
||||||
|
}
|
||||||
|
|
||||||
@Patch(':userId')
|
@Patch(':userId')
|
||||||
@RequireBusinessPermission('orders.update')
|
@RequireBusinessPermission('orders.update')
|
||||||
update(
|
update(
|
||||||
|
|||||||
@@ -1,16 +1,25 @@
|
|||||||
import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException, ServiceUnavailableException } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
import { AuthUser } from '../auth/auth.types';
|
import { AuthUser } from '../auth/auth.types';
|
||||||
import { PermissionsService } from '../auth/permissions.service';
|
import { PermissionsService } from '../auth/permissions.service';
|
||||||
|
import { SmsService } from '../auth/sms.service';
|
||||||
import {
|
import {
|
||||||
buildDualDailyActivitySeries,
|
buildDualDailyActivitySeries,
|
||||||
startOfLocalDay,
|
startOfLocalDay,
|
||||||
} from '../common/daily-activity';
|
} from '../common/daily-activity';
|
||||||
|
import { normalizeBusinessSettings } from '../business-settings/business-settings.util';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import {
|
||||||
|
buildCustomersExcel,
|
||||||
|
parseCustomersExcel,
|
||||||
|
} from './customers-excel';
|
||||||
import { CreateCustomerDto } from './dto/create-customer.dto';
|
import { CreateCustomerDto } from './dto/create-customer.dto';
|
||||||
import { ListCustomersDto } from './dto/list-customers.dto';
|
import { ListCustomersDto } from './dto/list-customers.dto';
|
||||||
import { SearchCustomersDto } from './dto/search-customers.dto';
|
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 { UpdateCustomerDto } from './dto/update-customer.dto';
|
||||||
|
|
||||||
type CustomerListRow = {
|
type CustomerListRow = {
|
||||||
@@ -33,6 +42,7 @@ export class CustomersService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly permissions: PermissionsService,
|
private readonly permissions: PermissionsService,
|
||||||
|
private readonly sms: SmsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async list(
|
async list(
|
||||||
@@ -231,6 +241,210 @@ export class CustomersService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async exportExcel(businessIdRaw: string, actor: AuthUser) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'orders.read');
|
||||||
|
|
||||||
|
const rows = await this.prisma.$queryRaw<
|
||||||
|
{
|
||||||
|
cellNumber: string;
|
||||||
|
firstName: string | null;
|
||||||
|
lastName: string | null;
|
||||||
|
}[]
|
||||||
|
>(Prisma.sql`
|
||||||
|
SELECT
|
||||||
|
u.cell_number AS "cellNumber",
|
||||||
|
u.first_name AS "firstName",
|
||||||
|
u.last_name AS "lastName"
|
||||||
|
FROM business_customers bc
|
||||||
|
JOIN users u ON u.id = bc.user_id
|
||||||
|
WHERE bc.business_id = ${businessId}
|
||||||
|
ORDER BY bc.created_at DESC, u.id DESC
|
||||||
|
`);
|
||||||
|
|
||||||
|
return buildCustomersExcel(
|
||||||
|
rows.map((row) => ({
|
||||||
|
phone: row.cellNumber,
|
||||||
|
firstName: row.firstName?.trim() ?? '',
|
||||||
|
lastName: row.lastName?.trim() ?? '',
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async importExcel(
|
||||||
|
businessIdRaw: string,
|
||||||
|
file: Express.Multer.File | undefined,
|
||||||
|
actor: AuthUser,
|
||||||
|
) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'orders.create');
|
||||||
|
|
||||||
|
if (!file?.buffer?.length) {
|
||||||
|
throw new BadRequestException('Excel file is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = file.originalname?.toLowerCase() ?? '';
|
||||||
|
if (name && !name.endsWith('.xlsx')) {
|
||||||
|
throw new BadRequestException('Please upload an .xlsx Excel file.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const business = await this.prisma.business.findUnique({
|
||||||
|
where: { id: businessId },
|
||||||
|
});
|
||||||
|
if (!business?.isActive) {
|
||||||
|
throw new NotFoundException('Business not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const customerRole = await this.prisma.role.findUnique({
|
||||||
|
where: { slug: 'customer' },
|
||||||
|
});
|
||||||
|
if (!customerRole) {
|
||||||
|
throw new Error('Customer role is missing. Run database migrations first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await parseCustomersExcel(file.buffer);
|
||||||
|
const phones = rows.map((row) => row.phone);
|
||||||
|
|
||||||
|
const existingUsers = await this.prisma.user.findMany({
|
||||||
|
where: { cellNumber: { in: phones } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
cellNumber: true,
|
||||||
|
cellVerifiedAt: true,
|
||||||
|
businessCustomers: {
|
||||||
|
where: { businessId },
|
||||||
|
select: { id: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const userByPhone = new Map(
|
||||||
|
existingUsers.map((user) => [user.cellNumber, user]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const existingUserIds = existingUsers.map((user) => user.id);
|
||||||
|
const staffMemberships =
|
||||||
|
existingUserIds.length === 0
|
||||||
|
? []
|
||||||
|
: await this.prisma.businessUser.findMany({
|
||||||
|
where: {
|
||||||
|
businessId,
|
||||||
|
userId: { in: existingUserIds },
|
||||||
|
},
|
||||||
|
select: { userId: true },
|
||||||
|
});
|
||||||
|
const staffUserIds = new Set(
|
||||||
|
staffMemberships.map((row) => row.userId.toString()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let created = 0;
|
||||||
|
let linked = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
const skipSamples: string[] = [];
|
||||||
|
|
||||||
|
const noteSkip = (message: string) => {
|
||||||
|
skipped += 1;
|
||||||
|
if (skipSamples.length < 12) {
|
||||||
|
skipSamples.push(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const placeholderPasswordHash = await bcrypt.hash(
|
||||||
|
randomBytes(32).toString('hex'),
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const existing = userByPhone.get(row.phone);
|
||||||
|
|
||||||
|
if (existing?.businessCustomers.length) {
|
||||||
|
noteSkip(`Row ${row.rowNumber}: already a customer (${row.phone}).`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing && staffUserIds.has(existing.id.toString())) {
|
||||||
|
noteSkip(
|
||||||
|
`Row ${row.rowNumber}: user is staff of this business (${row.phone}).`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wasNew = !existing;
|
||||||
|
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
let userId = existing?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
const account = await tx.user.create({
|
||||||
|
data: {
|
||||||
|
cellNumber: row.phone,
|
||||||
|
passwordHash: placeholderPasswordHash,
|
||||||
|
firstName: row.firstName,
|
||||||
|
lastName: row.lastName,
|
||||||
|
cellVerifiedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
userId = account.id;
|
||||||
|
userByPhone.set(row.phone, {
|
||||||
|
id: account.id,
|
||||||
|
cellNumber: row.phone,
|
||||||
|
cellVerifiedAt: account.cellVerifiedAt,
|
||||||
|
businessCustomers: [{ id: BigInt(0) }],
|
||||||
|
});
|
||||||
|
} else if (existing && !existing.cellVerifiedAt) {
|
||||||
|
await tx.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { cellVerifiedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.businessCustomer.create({
|
||||||
|
data: {
|
||||||
|
businessId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasCustomerRole = await tx.userRole.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_roleId: {
|
||||||
|
userId,
|
||||||
|
roleId: customerRole.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasCustomerRole) {
|
||||||
|
await tx.userRole.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
roleId: customerRole.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = userByPhone.get(row.phone);
|
||||||
|
if (cached && cached.businessCustomers.length === 0) {
|
||||||
|
cached.businessCustomers = [{ id: BigInt(0) }];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (wasNew) {
|
||||||
|
created += 1;
|
||||||
|
} else {
|
||||||
|
linked += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: 'Customers imported.',
|
||||||
|
total: rows.length,
|
||||||
|
created,
|
||||||
|
linked,
|
||||||
|
skipped,
|
||||||
|
skipSamples,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async create(
|
async create(
|
||||||
businessIdRaw: string,
|
businessIdRaw: string,
|
||||||
dto: CreateCustomerDto,
|
dto: CreateCustomerDto,
|
||||||
@@ -463,6 +677,156 @@ export class CustomersService {
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async sendSms(
|
||||||
|
businessIdRaw: string,
|
||||||
|
userIdRaw: string,
|
||||||
|
dto: SendCustomerSmsDto,
|
||||||
|
actor: AuthUser,
|
||||||
|
) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
const userId = BigInt(userIdRaw);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'orders.update');
|
||||||
|
|
||||||
|
const membership = await this.prisma.businessCustomer.findUnique({
|
||||||
|
where: {
|
||||||
|
businessId_userId: { businessId, userId },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
business: { select: { settings: true, isActive: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!membership || !membership.business.isActive) {
|
||||||
|
throw new NotFoundException('Customer not found for this business');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.sms.isEnabled()) {
|
||||||
|
return {
|
||||||
|
enabled: false,
|
||||||
|
message: 'SMS is disabled. Message was not sent.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = normalizeBusinessSettings(membership.business.settings);
|
||||||
|
const source =
|
||||||
|
settings.sms.senderNumber?.trim() || this.sms.getAdvertisingSource();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.sms.sendMessage(
|
||||||
|
membership.user.cellNumber,
|
||||||
|
dto.message.trim(),
|
||||||
|
{ source },
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
message: 'Message sent successfully',
|
||||||
|
serverId: result.serverId,
|
||||||
|
source,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
if (
|
||||||
|
err instanceof BadRequestException ||
|
||||||
|
err instanceof ServiceUnavailableException
|
||||||
|
) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
throw new ServiceUnavailableException('Unable to send SMS');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk SMS (Gama SendBulk). Empty/omitted userIds → all enabled business customers.
|
||||||
|
* Selected userIds may include team members shown on the customers list.
|
||||||
|
*/
|
||||||
|
async sendBulkSms(
|
||||||
|
businessIdRaw: string,
|
||||||
|
dto: SendCustomersBulkSmsDto,
|
||||||
|
actor: AuthUser,
|
||||||
|
) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'orders.update');
|
||||||
|
|
||||||
|
const business = await this.prisma.business.findUnique({
|
||||||
|
where: { id: businessId },
|
||||||
|
select: { settings: true, isActive: true },
|
||||||
|
});
|
||||||
|
if (!business?.isActive) {
|
||||||
|
throw new NotFoundException('Business not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedIds = (dto.userIds ?? [])
|
||||||
|
.map((id) => id.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
let cellNumbers: string[];
|
||||||
|
|
||||||
|
if (selectedIds.length > 0) {
|
||||||
|
const userIds = selectedIds.map((id) => {
|
||||||
|
try {
|
||||||
|
return BigInt(id);
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestException('Invalid user id');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = await this.prisma.$queryRaw<{ cellNumber: string }[]>(Prisma.sql`
|
||||||
|
SELECT DISTINCT u.cell_number AS "cellNumber"
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN business_customers bc
|
||||||
|
ON bc.user_id = u.id AND bc.business_id = ${businessId}
|
||||||
|
LEFT JOIN business_users bu
|
||||||
|
ON bu.user_id = u.id AND bu.business_id = ${businessId}
|
||||||
|
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);
|
||||||
|
} else {
|
||||||
|
const rows = await this.prisma.businessCustomer.findMany({
|
||||||
|
where: { businessId, isEnabled: true },
|
||||||
|
select: { user: { select: { cellNumber: true } } },
|
||||||
|
});
|
||||||
|
cellNumbers = rows.map((r) => r.user.cellNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cellNumbers.length === 0) {
|
||||||
|
throw new BadRequestException('No recipients found to send SMS');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.sms.isEnabled()) {
|
||||||
|
return {
|
||||||
|
enabled: false,
|
||||||
|
message: 'SMS is disabled. Message was not sent.',
|
||||||
|
recipientCount: cellNumbers.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = normalizeBusinessSettings(business.settings);
|
||||||
|
const source =
|
||||||
|
settings.sms.senderNumber?.trim() || this.sms.getAdvertisingSource();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.sms.sendBulkMessage(cellNumbers, dto.message.trim(), {
|
||||||
|
source,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
message: 'Message sent successfully',
|
||||||
|
recipientCount: result.recipientCount,
|
||||||
|
serverIds: result.serverIds,
|
||||||
|
source,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
if (
|
||||||
|
err instanceof BadRequestException ||
|
||||||
|
err instanceof ServiceUnavailableException
|
||||||
|
) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
throw new ServiceUnavailableException('Unable to send SMS');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private formatLabel(user: {
|
private formatLabel(user: {
|
||||||
firstName: string | null;
|
firstName: string | null;
|
||||||
lastName: string | null;
|
lastName: string | null;
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class SendCustomerSmsDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(700)
|
||||||
|
message!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
|
IsArray,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MaxLength,
|
||||||
|
MinLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class SendCustomersBulkSmsDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(700)
|
||||||
|
message!: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When omitted or empty → send to all enabled business customers.
|
||||||
|
* When provided → only those user ids that belong to this business.
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(5000)
|
||||||
|
@IsString({ each: true })
|
||||||
|
@Type(() => String)
|
||||||
|
userIds?: string[];
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator';
|
import { IsNotEmpty, IsOptional, IsString, Matches, MaxLength, MinLength } from 'class-validator';
|
||||||
|
|
||||||
export class SendPublicSmsDto {
|
export class SendPublicSmsDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -16,4 +16,12 @@ export class SendPublicSmsDto {
|
|||||||
@MinLength(1)
|
@MinLength(1)
|
||||||
@MaxLength(700)
|
@MaxLength(700)
|
||||||
message!: string;
|
message!: string;
|
||||||
|
|
||||||
|
/** Optional Gama shortcode override (digits only). */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^\d{8,16}$/, {
|
||||||
|
message: 'source must be an 8–16 digit shortcode',
|
||||||
|
})
|
||||||
|
source?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ export class PublicSmsService {
|
|||||||
|
|
||||||
await this.assertRateLimits(partnerDomain, msisdn);
|
await this.assertRateLimits(partnerDomain, msisdn);
|
||||||
|
|
||||||
const result = await this.sms.sendMessage(msisdn, dto.message);
|
const result = await this.sms.sendMessage(msisdn, dto.message, {
|
||||||
|
source: dto.source?.trim() || undefined,
|
||||||
|
});
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Partner SMS accepted domain=${partnerDomain} serverId=${result.serverId}`,
|
`Partner SMS accepted domain=${partnerDomain} serverId=${result.serverId}`,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user