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:
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user