feat: SMS campaigns for people or leads with filters and batching

Add audience tabs, province/city filters, select-all ids, dynamic SMS tokens, and 50-sized create/send chunks.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-17 20:54:32 +03:30
co-authored by Cursor
parent 32c722f42a
commit d62e004e90
10 changed files with 1040 additions and 131 deletions
+32
View File
@@ -231,6 +231,34 @@ export class LeadsController {
return new StreamableFile(buffer);
}
@Get('ids')
listIds(
@CurrentUser() user: Actor,
@Query('q') q?: string,
@Query('stageId') stageId?: string,
@Query('assigneeId') assigneeId?: string,
@Query('city') city?: string,
@Query('region') region?: string,
@Query('trade') trade?: string,
@Query('source') source?: string,
@Query('unassigned') unassigned?: string,
@Query('archived') archived?: string,
@Query('hasMobile') hasMobile?: string,
) {
return this.leads.listIds(user, {
q,
stageId,
assigneeId,
city,
region,
trade,
source,
unassigned: unassigned === '1' || unassigned === 'true',
archived: archived === '1' || archived === 'true',
hasMobile: hasMobile === '1' || hasMobile === 'true',
});
}
@Get()
list(
@CurrentUser() user: Actor,
@@ -240,11 +268,13 @@ export class LeadsController {
@Query('stageId') stageId?: string,
@Query('assigneeId') assigneeId?: string,
@Query('city') city?: string,
@Query('region') region?: string,
@Query('trade') trade?: string,
@Query('source') source?: string,
@Query('unassigned') unassigned?: string,
@Query('archived') archived?: string,
@Query('sort') sort?: string,
@Query('hasMobile') hasMobile?: string,
) {
return this.leads.list(user, {
q,
@@ -253,11 +283,13 @@ export class LeadsController {
stageId,
assigneeId,
city,
region,
trade,
source,
unassigned: unassigned === '1' || unassigned === 'true',
archived: archived === '1' || archived === 'true',
sort,
hasMobile: hasMobile === '1' || hasMobile === 'true',
});
}
+73 -11
View File
@@ -11,6 +11,7 @@ import * as path from 'node:path';
import { randomUUID } from 'node:crypto';
import type {
ConvertLeadResult,
FilteredIdsResponse,
LeadBoardColumnDto,
LeadDto,
LeadDuplicateCheckResult,
@@ -18,6 +19,7 @@ import type {
LeadStatsDto,
PaginatedResponse,
} from '@mento/shared';
import { FILTERED_IDS_MAX } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import { SmsService } from '../common/sms.service';
import { renderSmsText } from '../common/sms-text';
@@ -105,25 +107,21 @@ export class LeadsService {
});
}
async list(
private leadListWhere(
actor: Actor,
opts: {
q?: string;
page?: number;
limit?: number;
stageId?: string;
assigneeId?: string;
city?: string;
region?: string;
trade?: string;
source?: string;
unassigned?: boolean;
archived?: boolean;
sort?: string;
hasMobile?: boolean;
},
): Promise<PaginatedResponse<LeadDto>> {
await this.stages.ensureDefaults(actor.organizationId);
const page = Math.max(1, opts.page ?? 1);
const limit = Math.min(100, Math.max(1, opts.limit ?? 20));
): Prisma.LeadWhereInput {
const search = buildSearchWhere<Prisma.LeadWhereInput>(opts.q, (t) => [
{ title: ci(t) },
{ contactName: ci(t) },
@@ -135,8 +133,12 @@ export class LeadsService {
{ instagram: ci(t) },
{ source: ci(t) },
]);
const where: Prisma.LeadWhereInput = {
const and: Prisma.LeadWhereInput[] = [];
if (search) and.push(search);
if (opts.hasMobile) {
and.push({ mobile: { not: '' } });
}
return {
organizationId: actor.organizationId,
...leadAccessWhere(actor),
archivedAt: opts.archived ? { not: null } : null,
@@ -144,12 +146,39 @@ export class LeadsService {
...(opts.assigneeId ? { assigneeId: opts.assigneeId } : {}),
...(opts.unassigned ? { assigneeId: null } : {}),
...(opts.city ? { city: { contains: opts.city, mode: 'insensitive' } } : {}),
...(opts.region
? { region: { contains: opts.region, mode: 'insensitive' } }
: {}),
...(opts.trade ? { trade: { contains: opts.trade, mode: 'insensitive' } } : {}),
...(opts.source
? { source: { contains: opts.source, mode: 'insensitive' } }
: {}),
...(search ? { AND: [search] } : {}),
...(and.length ? { AND: and } : {}),
};
}
async list(
actor: Actor,
opts: {
q?: string;
page?: number;
limit?: number;
stageId?: string;
assigneeId?: string;
city?: string;
region?: string;
trade?: string;
source?: string;
unassigned?: boolean;
archived?: boolean;
sort?: string;
hasMobile?: boolean;
},
): Promise<PaginatedResponse<LeadDto>> {
await this.stages.ensureDefaults(actor.organizationId);
const page = Math.max(1, opts.page ?? 1);
const limit = Math.min(100, Math.max(1, opts.limit ?? 20));
const where = this.leadListWhere(actor, opts);
const orderBy: Prisma.LeadOrderByWithRelationInput =
opts.sort === 'follow_up'
@@ -179,6 +208,39 @@ export class LeadsService {
};
}
async listIds(
actor: Actor,
opts: {
q?: string;
stageId?: string;
assigneeId?: string;
city?: string;
region?: string;
trade?: string;
source?: string;
unassigned?: boolean;
archived?: boolean;
hasMobile?: boolean;
},
max = FILTERED_IDS_MAX,
): Promise<FilteredIdsResponse> {
await this.stages.ensureDefaults(actor.organizationId);
const where = this.leadListWhere(actor, opts);
const total = await this.prisma.lead.count({ where });
if (total > max) {
throw new BadRequestException(
`تعداد نتایج (${total}) از سقف مجاز بیشتر است؛ فیلتر را محدودتر کنید.`,
);
}
const rows = await this.prisma.lead.findMany({
where,
select: { id: true },
orderBy: { updatedAt: 'desc' },
take: max,
});
return { ids: rows.map((r) => r.id), total };
}
async board(actor: Actor): Promise<LeadBoardColumnDto[]> {
const stages = await this.stages.list(actor.organizationId);
const boardStages = stages.filter((s) => s.showOnBoard);