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
+17
View File
@@ -63,6 +63,23 @@ export class ListQueryDto {
@IsString()
@MaxLength(40)
importSource?: string;
/** استان شرکت مرتبط */
@IsOptional()
@IsString()
@MaxLength(80)
province?: string;
/** شهر/شهرستان شرکت مرتبط */
@IsOptional()
@IsString()
@MaxLength(80)
city?: string;
/** فقط کسانی که موبایل دارند */
@IsOptional()
@IsIn(['1', 'true', '0', 'false'])
hasMobile?: string;
}
class CompanyExtraFieldsDto {
@@ -43,6 +43,10 @@ export class PeopleController {
@Get()
list(@CurrentUser() user: Actor, @Query() query: ListQueryDto) {
const hasMobile =
query.hasMobile === '1' || query.hasMobile === 'true'
? true
: undefined;
return this.peopleService.list(
user,
query.q,
@@ -52,10 +56,28 @@ export class PeopleController {
mood: query.mood,
sort: query.sort,
importSource: query.importSource,
province: query.province,
city: query.city,
hasMobile,
},
);
}
@Get('ids')
listIds(@CurrentUser() user: Actor, @Query() query: ListQueryDto) {
const hasMobile =
query.hasMobile === '1' || query.hasMobile === 'true'
? true
: undefined;
return this.peopleService.listIds(user, query.q, {
mood: query.mood,
importSource: query.importSource,
province: query.province,
city: query.city,
hasMobile,
});
}
@Get('source-stats')
sourceStats(@CurrentUser() user: Actor) {
return this.peopleService.sourceStats(user);
+86 -9
View File
@@ -10,12 +10,14 @@ import {
} from '@nestjs/common';
import type {
CreatePersonRequest,
FilteredIdsResponse,
PaginatedResponse,
PersonDto,
PersonImportSourceStatDto,
PersonMood,
UpdatePersonRequest,
} from '@mento/shared';
import { FILTERED_IDS_MAX } from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { CompaniesService } from './companies.service';
@@ -49,7 +51,7 @@ import {
} from './person-source';
const personInclude = {
company: { select: { id: true, name: true } },
company: { select: { id: true, name: true, province: true, city: true } },
owner: { select: { id: true, fullName: true } },
loyaltyTier: {
select: { id: true, name: true, minPoints: true, color: true },
@@ -82,7 +84,12 @@ type PersonRow = {
loyaltyPoints: number;
createdAt: Date;
updatedAt: Date;
company: { id: string; name: string } | null;
company: {
id: string;
name: string;
province: string | null;
city: string | null;
} | null;
owner: { id: string; fullName: string } | null;
loyaltyTier: {
id: string;
@@ -106,17 +113,17 @@ export class PeopleService implements OnModuleInit {
private readonly sms: SmsService,
) {}
async list(
private personListWhere(
actor: Actor,
q?: string,
page = 1,
limit = 20,
opts?: {
mood?: PersonMood;
sort?: 'name' | 'newest';
importSource?: string;
province?: string;
city?: string;
hasMobile?: boolean;
},
): Promise<PaginatedResponse<PersonDto>> {
): Prisma.PersonWhereInput {
const sourceFilter = opts?.importSource?.trim()
? normalizePersonImportSource(opts.importSource)
: undefined;
@@ -131,14 +138,56 @@ export class PeopleService implements OnModuleInit {
{ sourceRef: ci(t) },
{ previousSourceRef: ci(t) },
{ company: { name: ci(t) } },
{ company: { province: ci(t) } },
{ company: { city: ci(t) } },
]);
const where: Prisma.PersonWhereInput = {
const province = opts?.province?.trim();
const city = opts?.city?.trim();
const and: Prisma.PersonWhereInput[] = [];
if (search) and.push(search);
if (province) {
and.push({
OR: [
{ company: { province: { contains: province, mode: 'insensitive' } } },
{ company: { city: { contains: province, mode: 'insensitive' } } },
],
});
}
if (city) {
and.push({
OR: [
{ company: { city: { contains: city, mode: 'insensitive' } } },
{ company: { province: { contains: city, mode: 'insensitive' } } },
],
});
}
if (opts?.hasMobile) {
and.push({ mobile: { not: null } }, { NOT: { mobile: '' } });
}
return {
organizationId: actor.organizationId,
...ownerWhere(actor),
...(opts?.mood ? { mood: opts.mood } : {}),
...(sourceFilter ? { importSource: sourceFilter } : {}),
...(search ? { AND: [search] } : {}),
...(and.length ? { AND: and } : {}),
};
}
async list(
actor: Actor,
q?: string,
page = 1,
limit = 20,
opts?: {
mood?: PersonMood;
sort?: 'name' | 'newest';
importSource?: string;
province?: string;
city?: string;
hasMobile?: boolean;
},
): Promise<PaginatedResponse<PersonDto>> {
const where = this.personListWhere(actor, q, opts);
const [rows, total] = await this.prisma.$transaction([
this.prisma.person.findMany({
@@ -162,6 +211,34 @@ export class PeopleService implements OnModuleInit {
};
}
async listIds(
actor: Actor,
q?: string,
opts?: {
mood?: PersonMood;
importSource?: string;
province?: string;
city?: string;
hasMobile?: boolean;
},
max = FILTERED_IDS_MAX,
): Promise<FilteredIdsResponse> {
const where = this.personListWhere(actor, q, opts);
const total = await this.prisma.person.count({ where });
if (total > max) {
throw new BadRequestException(
`تعداد نتایج (${total}) از سقف مجاز بیشتر است؛ فیلتر را محدودتر کنید.`,
);
}
const rows = await this.prisma.person.findMany({
where,
select: { id: true },
orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }],
take: max,
});
return { ids: rows.map((r) => r.id), total };
}
async sourceStats(actor: Actor): Promise<PersonImportSourceStatDto[]> {
const rows = await this.prisma.person.groupBy({
by: ['importSource'],