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