Ship loyalty club and form builder so production pull gets the new screens.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { LoyaltyModule } from '../loyalty/loyalty.module';
|
||||
import { CompaniesController } from './companies.controller';
|
||||
import { CompaniesService } from './companies.service';
|
||||
import { PeopleController } from './people.controller';
|
||||
@@ -6,8 +7,10 @@ import { PeopleService } from './people.service';
|
||||
import { ContactsExcelService } from './contacts-excel.service';
|
||||
|
||||
@Module({
|
||||
imports: [LoyaltyModule],
|
||||
controllers: [PeopleController, CompaniesController],
|
||||
providers: [PeopleService, CompaniesService, ContactsExcelService],
|
||||
exports: [PeopleService, CompaniesService],
|
||||
})
|
||||
export class ContactsModule {}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
ownerWhere,
|
||||
resolveOwnerId,
|
||||
} from '../common/record-scope';
|
||||
import { LoyaltyService } from '../loyalty/loyalty.service';
|
||||
|
||||
const personInclude = {
|
||||
company: { select: { id: true, name: true } },
|
||||
@@ -62,6 +63,7 @@ export class PeopleService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly companies: CompaniesService,
|
||||
private readonly loyalty: LoyaltyService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
@@ -146,7 +148,12 @@ export class PeopleService {
|
||||
data,
|
||||
include: personInclude,
|
||||
});
|
||||
return this.toDto(row);
|
||||
await this.loyalty.applyWelcomePoints(actor.organizationId, row.id);
|
||||
const fresh = await this.prisma.person.findFirst({
|
||||
where: { id: row.id },
|
||||
include: personInclude,
|
||||
});
|
||||
return this.toDto(fresh ?? row);
|
||||
}
|
||||
|
||||
async update(
|
||||
|
||||
@@ -11,11 +11,12 @@ import {
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { LEAD_FORM_FIELD_TYPES } from '@mento/shared';
|
||||
|
||||
const FIELD_TYPES = ['text', 'email', 'phone', 'textarea'] as const;
|
||||
const MAP_TO = [
|
||||
'person.firstName',
|
||||
'person.lastName',
|
||||
'person.fullName',
|
||||
'person.email',
|
||||
'person.mobile',
|
||||
'person.phone',
|
||||
@@ -23,6 +24,7 @@ const MAP_TO = [
|
||||
'deal.title',
|
||||
'deal.notes',
|
||||
'notes',
|
||||
'custom',
|
||||
] as const;
|
||||
|
||||
export class LeadFormFieldInputDto {
|
||||
@@ -36,8 +38,8 @@ export class LeadFormFieldInputDto {
|
||||
@MaxLength(120)
|
||||
label!: string;
|
||||
|
||||
@IsIn(FIELD_TYPES)
|
||||
fieldType!: (typeof FIELD_TYPES)[number];
|
||||
@IsIn([...LEAD_FORM_FIELD_TYPES])
|
||||
fieldType!: (typeof LEAD_FORM_FIELD_TYPES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@@ -48,6 +50,17 @@ export class LeadFormFieldInputDto {
|
||||
|
||||
@IsOptional()
|
||||
position?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(160)
|
||||
placeholder?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@ArrayMaxSize(40)
|
||||
options?: string[];
|
||||
}
|
||||
|
||||
export class CreateLeadFormDto {
|
||||
@@ -56,6 +69,11 @@ export class CreateLeadFormDto {
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(400)
|
||||
description?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
createPerson?: boolean;
|
||||
@@ -89,11 +107,21 @@ export class CreateLeadFormDto {
|
||||
@ArrayMaxSize(50)
|
||||
assigneeIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
submitLabel?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
submitColor?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => LeadFormFieldInputDto)
|
||||
@ArrayMaxSize(30)
|
||||
@ArrayMaxSize(40)
|
||||
fields?: LeadFormFieldInputDto[];
|
||||
}
|
||||
|
||||
@@ -104,6 +132,11 @@ export class UpdateLeadFormDto {
|
||||
@MaxLength(120)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(400)
|
||||
description?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
@@ -141,11 +174,21 @@ export class UpdateLeadFormDto {
|
||||
@ArrayMaxSize(50)
|
||||
assigneeIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
submitLabel?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
submitColor?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => LeadFormFieldInputDto)
|
||||
@ArrayMaxSize(30)
|
||||
@ArrayMaxSize(40)
|
||||
fields?: LeadFormFieldInputDto[];
|
||||
}
|
||||
|
||||
|
||||
@@ -6,19 +6,30 @@ import {
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Res,
|
||||
StreamableFile,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import type { Response } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { CreateLeadFormDto, UpdateLeadFormDto } from './dto';
|
||||
import { LeadFormsService } from './lead-forms.service';
|
||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||
|
||||
@Controller('lead-forms')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
export class LeadFormsController {
|
||||
constructor(private readonly leadFormsService: LeadFormsService) {}
|
||||
constructor(
|
||||
private readonly leadFormsService: LeadFormsService,
|
||||
private readonly files: LocalFileStorage,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@Roles('owner', 'admin')
|
||||
@@ -54,6 +65,51 @@ export class LeadFormsController {
|
||||
return this.leadFormsService.update(user.organizationId, id, dto);
|
||||
}
|
||||
|
||||
@Get(':id/submissions')
|
||||
@Roles('owner', 'admin')
|
||||
submissions(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.leadFormsService.listSubmissions(user.organizationId, id);
|
||||
}
|
||||
|
||||
@Post(':id/cover')
|
||||
@Roles('owner', 'admin')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', { storage: memoryStorage(), limits: { fileSize: 4_000_000 } }),
|
||||
)
|
||||
uploadCover(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Param('id') id: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
return this.leadFormsService.saveCover(user.organizationId, id, file);
|
||||
}
|
||||
|
||||
@Delete(':id/cover')
|
||||
@Roles('owner', 'admin')
|
||||
removeCover(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.leadFormsService.removeCover(user.organizationId, id);
|
||||
}
|
||||
|
||||
@Get(':id/cover')
|
||||
@Roles('owner', 'admin')
|
||||
async cover(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Param('id') id: string,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
const form = await this.leadFormsService.get(user.organizationId, id);
|
||||
const row = await this.leadFormsService.getByToken(form.publicToken);
|
||||
if (!row?.coverStorageKey) return { ok: false };
|
||||
res.set({ 'Content-Type': 'image/jpeg' });
|
||||
return new StreamableFile(this.files.openReadStream(row.coverStorageKey));
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Roles('owner', 'admin')
|
||||
async remove(
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||
import {
|
||||
emptyToNull,
|
||||
normalizeEmail,
|
||||
@@ -21,44 +22,40 @@ import {
|
||||
|
||||
const DEFAULT_FIELDS: LeadFormFieldInput[] = [
|
||||
{
|
||||
key: 'firstName',
|
||||
label: 'نام',
|
||||
key: 'fullName',
|
||||
label: 'نام و نام خانوادگی',
|
||||
fieldType: 'text',
|
||||
required: true,
|
||||
mapTo: 'person.firstName',
|
||||
mapTo: 'person.fullName',
|
||||
placeholder: 'مثلاً علی منتظر',
|
||||
position: 0,
|
||||
},
|
||||
{
|
||||
key: 'lastName',
|
||||
label: 'نام خانوادگی',
|
||||
fieldType: 'text',
|
||||
required: true,
|
||||
mapTo: 'person.lastName',
|
||||
position: 1,
|
||||
},
|
||||
{
|
||||
key: 'mobile',
|
||||
label: 'موبایل',
|
||||
label: 'شماره تماس',
|
||||
fieldType: 'phone',
|
||||
required: true,
|
||||
mapTo: 'person.mobile',
|
||||
placeholder: '۰۹۱۲۱۲۳۴۵۶۷',
|
||||
position: 1,
|
||||
},
|
||||
{
|
||||
key: 'subject',
|
||||
label: 'موضوع درخواست',
|
||||
fieldType: 'select',
|
||||
required: true,
|
||||
mapTo: 'deal.title',
|
||||
options: ['مشاوره فروش', 'پشتیبانی', 'همکاری'],
|
||||
position: 2,
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
label: 'ایمیل',
|
||||
fieldType: 'email',
|
||||
required: false,
|
||||
mapTo: 'person.email',
|
||||
position: 3,
|
||||
},
|
||||
{
|
||||
key: 'message',
|
||||
label: 'پیام',
|
||||
label: 'توضیحات',
|
||||
fieldType: 'textarea',
|
||||
required: false,
|
||||
mapTo: 'deal.notes',
|
||||
position: 4,
|
||||
placeholder: 'شرح درخواست خود را بنویسید',
|
||||
position: 3,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -66,15 +63,24 @@ type FormRow = Prisma.LeadFormGetPayload<{ include: { fields: true } }>;
|
||||
|
||||
@Injectable()
|
||||
export class LeadFormsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly files: LocalFileStorage,
|
||||
) {}
|
||||
|
||||
async list(organizationId: string): Promise<LeadFormDto[]> {
|
||||
const rows = await this.prisma.leadForm.findMany({
|
||||
where: { organizationId },
|
||||
include: { fields: { orderBy: { position: 'asc' } } },
|
||||
include: {
|
||||
fields: { orderBy: { position: 'asc' } },
|
||||
_count: { select: { submissions: true } },
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
return rows.map((row) => this.toDto(row));
|
||||
return rows.map((row) => ({
|
||||
...this.toDto(row),
|
||||
submissionCount: row._count.submissions,
|
||||
}));
|
||||
}
|
||||
|
||||
async get(organizationId: string, id: string): Promise<LeadFormDto> {
|
||||
@@ -94,6 +100,7 @@ export class LeadFormsService {
|
||||
data: {
|
||||
organizationId,
|
||||
name: dto.name.trim(),
|
||||
description: emptyToNull(dto.description ?? null),
|
||||
createPerson: dto.createPerson ?? true,
|
||||
createDeal: dto.createDeal ?? true,
|
||||
pipelineId: emptyToNull(dto.pipelineId ?? null),
|
||||
@@ -103,15 +110,10 @@ export class LeadFormsService {
|
||||
'پیام شما ثبت شد. بهزودی با شما تماس میگیریم.',
|
||||
redirectUrl: emptyToNull(dto.redirectUrl ?? null),
|
||||
assigneeIds: dto.assigneeIds ?? [],
|
||||
submitLabel: dto.submitLabel?.trim() || 'ارسال درخواست',
|
||||
submitColor: dto.submitColor?.trim() || '#14b8a6',
|
||||
fields: {
|
||||
create: fields.map((f, index) => ({
|
||||
key: f.key!,
|
||||
label: f.label,
|
||||
fieldType: f.fieldType,
|
||||
required: f.required ?? false,
|
||||
mapTo: f.mapTo,
|
||||
position: f.position ?? index,
|
||||
})),
|
||||
create: fields.map((f, index) => fieldCreateData(f, index)),
|
||||
},
|
||||
},
|
||||
include: { fields: { orderBy: { position: 'asc' } } },
|
||||
@@ -134,6 +136,13 @@ export class LeadFormsService {
|
||||
|
||||
const data: Prisma.LeadFormUpdateInput = {};
|
||||
if (dto.name !== undefined) data.name = dto.name.trim();
|
||||
if ('description' in dto) data.description = emptyToNull(dto.description ?? null);
|
||||
if (dto.submitLabel !== undefined) {
|
||||
data.submitLabel = dto.submitLabel.trim() || 'ارسال درخواست';
|
||||
}
|
||||
if (dto.submitColor !== undefined) {
|
||||
data.submitColor = dto.submitColor.trim() || '#14b8a6';
|
||||
}
|
||||
if (dto.isActive !== undefined) data.isActive = dto.isActive;
|
||||
if (dto.createPerson !== undefined) data.createPerson = dto.createPerson;
|
||||
if (dto.createDeal !== undefined) data.createDeal = dto.createDeal;
|
||||
@@ -156,14 +165,7 @@ export class LeadFormsService {
|
||||
data: {
|
||||
...data,
|
||||
fields: {
|
||||
create: fields.map((f, index) => ({
|
||||
key: f.key!,
|
||||
label: f.label,
|
||||
fieldType: f.fieldType,
|
||||
required: f.required ?? false,
|
||||
mapTo: f.mapTo,
|
||||
position: f.position ?? index,
|
||||
})),
|
||||
create: fields.map((f, index) => fieldCreateData(f, index)),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -188,13 +190,24 @@ export class LeadFormsService {
|
||||
if (!row) throw new NotFoundException('فرم پیدا نشد یا غیرفعال است');
|
||||
return {
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
thankYouMessage: row.thankYouMessage,
|
||||
fields: row.fields.map((f) => ({
|
||||
key: f.key,
|
||||
label: f.label,
|
||||
fieldType: f.fieldType,
|
||||
required: f.required,
|
||||
})),
|
||||
submitLabel: row.submitLabel || 'ارسال درخواست',
|
||||
submitColor: row.submitColor || '#14b8a6',
|
||||
hasCover: Boolean(row.coverStorageKey),
|
||||
coverPath: row.coverStorageKey
|
||||
? `/api/public/forms/${row.publicToken}/cover`
|
||||
: null,
|
||||
fields: row.fields
|
||||
.filter((f) => f.fieldType !== 'heading' || true)
|
||||
.map((f) => ({
|
||||
key: f.key,
|
||||
label: f.label,
|
||||
fieldType: f.fieldType,
|
||||
required: f.required,
|
||||
placeholder: f.placeholder,
|
||||
options: asOptions(f.options),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -209,6 +222,10 @@ export class LeadFormsService {
|
||||
if (!form) throw new NotFoundException('فرم پیدا نشد یا غیرفعال است');
|
||||
|
||||
const mapped = mapValues(form.fields, values);
|
||||
const extras = collectExtras(form.fields, values);
|
||||
if (extras) {
|
||||
mapped.notes = [mapped.notes, extras].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
if (form.createPerson) {
|
||||
if (!mapped.firstName?.trim() && !mapped.lastName?.trim()) {
|
||||
@@ -239,6 +256,14 @@ export class LeadFormsService {
|
||||
);
|
||||
}
|
||||
|
||||
await this.prisma.leadFormSubmission.create({
|
||||
data: {
|
||||
formId: form.id,
|
||||
values: values as Prisma.InputJsonValue,
|
||||
personId,
|
||||
},
|
||||
});
|
||||
|
||||
if (form.createDeal) {
|
||||
await this.createDeal(form, mapped, personId, actorId, ownerId);
|
||||
} else if (personId && mapped.notes) {
|
||||
@@ -410,10 +435,70 @@ export class LeadFormsService {
|
||||
}
|
||||
}
|
||||
|
||||
async listSubmissions(organizationId: string, id: string) {
|
||||
await this.findInOrg(organizationId, id);
|
||||
const rows = await this.prisma.leadFormSubmission.findMany({
|
||||
where: { formId: id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 200,
|
||||
});
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
values: asStringRecord(r.values),
|
||||
personId: r.personId,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
async saveCover(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
file: { buffer: Buffer; originalname: string },
|
||||
) {
|
||||
if (!file?.buffer) throw new BadRequestException('فایل کاور ارسال نشده');
|
||||
const row = await this.findInOrg(organizationId, id);
|
||||
const ext = (file.originalname.split('.').pop() || 'jpg')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, '')
|
||||
.slice(0, 6);
|
||||
const key = `lead-forms/${id}/cover.${ext || 'jpg'}`;
|
||||
await this.files.save(key, file.buffer);
|
||||
if (row.coverStorageKey && row.coverStorageKey !== key) {
|
||||
await this.files.remove(row.coverStorageKey);
|
||||
}
|
||||
await this.prisma.leadForm.update({
|
||||
where: { id },
|
||||
data: { coverStorageKey: key },
|
||||
});
|
||||
return this.get(organizationId, id);
|
||||
}
|
||||
|
||||
async removeCover(organizationId: string, id: string) {
|
||||
const row = await this.findInOrg(organizationId, id);
|
||||
if (row.coverStorageKey) await this.files.remove(row.coverStorageKey);
|
||||
await this.prisma.leadForm.update({
|
||||
where: { id },
|
||||
data: { coverStorageKey: null },
|
||||
});
|
||||
return this.get(organizationId, id);
|
||||
}
|
||||
|
||||
coverKey(row: FormRow) {
|
||||
return row.coverStorageKey;
|
||||
}
|
||||
|
||||
async getByToken(token: string, onlyActive = false) {
|
||||
return this.prisma.leadForm.findFirst({
|
||||
where: { publicToken: token, ...(onlyActive ? { isActive: true } : {}) },
|
||||
include: { fields: true },
|
||||
});
|
||||
}
|
||||
|
||||
private toDto(row: FormRow): LeadFormDto {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
publicToken: row.publicToken,
|
||||
isActive: row.isActive,
|
||||
createPerson: row.createPerson,
|
||||
@@ -423,6 +508,9 @@ export class LeadFormsService {
|
||||
thankYouMessage: row.thankYouMessage,
|
||||
redirectUrl: row.redirectUrl,
|
||||
assigneeIds: row.assigneeIds,
|
||||
submitLabel: row.submitLabel || 'ارسال درخواست',
|
||||
submitColor: row.submitColor || '#14b8a6',
|
||||
hasCover: Boolean(row.coverStorageKey),
|
||||
fields: row.fields.map((f) => ({
|
||||
id: f.id,
|
||||
key: f.key,
|
||||
@@ -431,6 +519,8 @@ export class LeadFormsService {
|
||||
required: f.required,
|
||||
mapTo: f.mapTo,
|
||||
position: f.position,
|
||||
placeholder: f.placeholder,
|
||||
options: asOptions(f.options),
|
||||
})),
|
||||
publicPath: `/f/${row.publicToken}`,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
@@ -452,17 +542,30 @@ type MappedValues = {
|
||||
};
|
||||
|
||||
function mapValues(
|
||||
fields: Array<{ key: string; mapTo: string; required: boolean; label: string }>,
|
||||
fields: Array<{
|
||||
key: string;
|
||||
mapTo: string;
|
||||
required: boolean;
|
||||
label: string;
|
||||
fieldType?: string;
|
||||
}>,
|
||||
values: Record<string, string>,
|
||||
): MappedValues {
|
||||
const mapped: MappedValues = {};
|
||||
for (const field of fields) {
|
||||
if (field.fieldType === 'heading') continue;
|
||||
const raw = (values?.[field.key] ?? '').toString().trim();
|
||||
if (field.required && !raw) {
|
||||
throw new BadRequestException(`فیلد «${field.label}» الزامی است`);
|
||||
}
|
||||
if (!raw) continue;
|
||||
switch (field.mapTo) {
|
||||
case 'person.fullName': {
|
||||
const parts = raw.split(/\s+/).filter(Boolean);
|
||||
mapped.firstName = parts[0] || raw;
|
||||
mapped.lastName = parts.slice(1).join(' ') || '—';
|
||||
break;
|
||||
}
|
||||
case 'person.firstName':
|
||||
mapped.firstName = raw;
|
||||
break;
|
||||
@@ -515,6 +618,47 @@ function normalizeFields(fields: LeadFormFieldInput[]): LeadFormFieldInput[] {
|
||||
});
|
||||
}
|
||||
|
||||
function fieldCreateData(f: LeadFormFieldInput, index: number) {
|
||||
return {
|
||||
key: f.key!,
|
||||
label: f.label,
|
||||
fieldType: f.fieldType,
|
||||
required: f.required ?? false,
|
||||
mapTo: f.mapTo,
|
||||
position: f.position ?? index,
|
||||
placeholder: emptyToNull(f.placeholder ?? null),
|
||||
options: f.options?.length ? f.options : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function asOptions(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map((v) => String(v).trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function asStringRecord(value: unknown): Record<string, string> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||
const out: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (v == null) continue;
|
||||
out[k] = typeof v === 'string' ? v : JSON.stringify(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function collectExtras(
|
||||
fields: Array<{ key: string; mapTo: string; label: string; fieldType: string }>,
|
||||
values: Record<string, string>,
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
for (const field of fields) {
|
||||
if (field.fieldType === 'heading' || field.mapTo !== 'custom') continue;
|
||||
const raw = (values?.[field.key] ?? '').toString().trim();
|
||||
if (raw) lines.push(`${field.label}: ${raw}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function slugKey(label: string): string {
|
||||
return label
|
||||
.trim()
|
||||
|
||||
@@ -1,16 +1,41 @@
|
||||
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Post,
|
||||
Res,
|
||||
StreamableFile,
|
||||
} from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { SubmitLeadFormDto } from './dto';
|
||||
import { LeadFormsService } from './lead-forms.service';
|
||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||
|
||||
@Controller('public/forms')
|
||||
export class PublicLeadFormsController {
|
||||
constructor(private readonly leadFormsService: LeadFormsService) {}
|
||||
constructor(
|
||||
private readonly leadFormsService: LeadFormsService,
|
||||
private readonly files: LocalFileStorage,
|
||||
) {}
|
||||
|
||||
@Get(':token')
|
||||
get(@Param('token') token: string) {
|
||||
return this.leadFormsService.getPublic(token);
|
||||
}
|
||||
|
||||
@Get(':token/cover')
|
||||
async cover(
|
||||
@Param('token') token: string,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
const row = await this.leadFormsService.getByToken(token);
|
||||
if (!row?.coverStorageKey) throw new NotFoundException('کاور پیدا نشد');
|
||||
res.set({ 'Content-Type': 'image/jpeg', 'Cache-Control': 'public, max-age=3600' });
|
||||
return new StreamableFile(this.files.openReadStream(row.coverStorageKey));
|
||||
}
|
||||
|
||||
@Post(':token/submit')
|
||||
submit(@Param('token') token: string, @Body() dto: SubmitLeadFormDto) {
|
||||
return this.leadFormsService.submit(token, dto.values ?? {});
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
@@ -58,3 +61,111 @@ export class AdjustLoyaltyPointsDto {
|
||||
@MaxLength(200)
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export class BulkAdjustLoyaltyDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsString({ each: true })
|
||||
personIds!: string[];
|
||||
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
delta!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export class LoyaltyMessageDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsString({ each: true })
|
||||
personIds!: string[];
|
||||
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(500)
|
||||
text!: string;
|
||||
}
|
||||
|
||||
export class UpdateLoyaltySettingsDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
welcomePoints?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
notifyOnAdjust?: boolean;
|
||||
}
|
||||
|
||||
export class CreateLoyaltyRewardDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(80)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
@MaxLength(240)
|
||||
description?: string | null;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
pointsCost!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
export class UpdateLoyaltyRewardDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(80)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
@MaxLength(240)
|
||||
description?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
pointsCost?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
export class RedeemLoyaltyRewardDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
personId!: string;
|
||||
}
|
||||
|
||||
@@ -7,8 +7,11 @@ import {
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
StreamableFile,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
@@ -16,7 +19,13 @@ import { RolesGuard } from '../auth/roles.guard';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import {
|
||||
AdjustLoyaltyPointsDto,
|
||||
BulkAdjustLoyaltyDto,
|
||||
CreateLoyaltyRewardDto,
|
||||
CreateLoyaltyTierDto,
|
||||
LoyaltyMessageDto,
|
||||
RedeemLoyaltyRewardDto,
|
||||
UpdateLoyaltyRewardDto,
|
||||
UpdateLoyaltySettingsDto,
|
||||
UpdateLoyaltyTierDto,
|
||||
} from './dto';
|
||||
import { LoyaltyService } from './loyalty.service';
|
||||
@@ -26,6 +35,25 @@ import { LoyaltyService } from './loyalty.service';
|
||||
export class LoyaltyController {
|
||||
constructor(private readonly loyalty: LoyaltyService) {}
|
||||
|
||||
@Get('stats')
|
||||
stats(@CurrentUser() user: Actor) {
|
||||
return this.loyalty.getStats(user);
|
||||
}
|
||||
|
||||
@Get('settings')
|
||||
settings(@CurrentUser() user: Actor) {
|
||||
return this.loyalty.getSettings(user.organizationId);
|
||||
}
|
||||
|
||||
@Patch('settings')
|
||||
@Roles('owner', 'admin')
|
||||
updateSettings(
|
||||
@CurrentUser() user: Actor,
|
||||
@Body() dto: UpdateLoyaltySettingsDto,
|
||||
) {
|
||||
return this.loyalty.updateSettings(user.organizationId, dto);
|
||||
}
|
||||
|
||||
@Get('tiers')
|
||||
listTiers(@CurrentUser() user: Actor) {
|
||||
return this.loyalty.listTiers(user.organizationId);
|
||||
@@ -57,9 +85,50 @@ export class LoyaltyController {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Get('members/export')
|
||||
async exportMembers(
|
||||
@CurrentUser() user: Actor,
|
||||
@Query('q') q: string | undefined,
|
||||
@Query('tierId') tierId: string | undefined,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
const buffer = await this.loyalty.exportMembers(user, q, tierId);
|
||||
res.set({
|
||||
'Content-Type':
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'Content-Disposition': 'attachment; filename="mento-loyalty-members.xlsx"',
|
||||
});
|
||||
return new StreamableFile(buffer);
|
||||
}
|
||||
|
||||
@Get('members')
|
||||
listMembers(@CurrentUser() user: Actor, @Query('q') q?: string) {
|
||||
return this.loyalty.listMembers(user, q);
|
||||
listMembers(
|
||||
@CurrentUser() user: Actor,
|
||||
@Query('q') q?: string,
|
||||
@Query('tierId') tierId?: string,
|
||||
) {
|
||||
return this.loyalty.listMembers(user, q, tierId);
|
||||
}
|
||||
|
||||
@Get('members/:personId/ledger')
|
||||
listLedger(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('personId') personId: string,
|
||||
) {
|
||||
return this.loyalty.listLedger(user, personId);
|
||||
}
|
||||
|
||||
@Post('members/bulk-adjust')
|
||||
bulkAdjust(
|
||||
@CurrentUser() user: Actor,
|
||||
@Body() dto: BulkAdjustLoyaltyDto,
|
||||
) {
|
||||
return this.loyalty.bulkAdjust(user, dto.personIds, dto.delta, dto.reason);
|
||||
}
|
||||
|
||||
@Post('members/message')
|
||||
sendMessage(@CurrentUser() user: Actor, @Body() dto: LoyaltyMessageDto) {
|
||||
return this.loyalty.sendMessage(user, dto.personIds, dto.text);
|
||||
}
|
||||
|
||||
@Post('members/:personId/adjust')
|
||||
@@ -68,6 +137,46 @@ export class LoyaltyController {
|
||||
@Param('personId') personId: string,
|
||||
@Body() dto: AdjustLoyaltyPointsDto,
|
||||
) {
|
||||
return this.loyalty.adjustPoints(user, personId, dto.delta);
|
||||
return this.loyalty.adjustPoints(user, personId, dto.delta, dto.reason);
|
||||
}
|
||||
|
||||
@Get('rewards')
|
||||
listRewards(@CurrentUser() user: Actor) {
|
||||
return this.loyalty.listRewards(user.organizationId);
|
||||
}
|
||||
|
||||
@Post('rewards')
|
||||
@Roles('owner', 'admin')
|
||||
createReward(
|
||||
@CurrentUser() user: Actor,
|
||||
@Body() dto: CreateLoyaltyRewardDto,
|
||||
) {
|
||||
return this.loyalty.createReward(user.organizationId, dto);
|
||||
}
|
||||
|
||||
@Patch('rewards/:id')
|
||||
@Roles('owner', 'admin')
|
||||
updateReward(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateLoyaltyRewardDto,
|
||||
) {
|
||||
return this.loyalty.updateReward(user.organizationId, id, dto);
|
||||
}
|
||||
|
||||
@Delete('rewards/:id')
|
||||
@Roles('owner', 'admin')
|
||||
async deleteReward(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
await this.loyalty.deleteReward(user.organizationId, id);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Post('rewards/:id/redeem')
|
||||
redeem(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: RedeemLoyaltyRewardDto,
|
||||
) {
|
||||
return this.loyalty.redeemReward(user, id, dto.personId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { SmsService } from '../common/sms.service';
|
||||
import { LoyaltyController } from './loyalty.controller';
|
||||
import { LoyaltyService } from './loyalty.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [LoyaltyController],
|
||||
providers: [LoyaltyService],
|
||||
providers: [LoyaltyService, SmsService],
|
||||
exports: [LoyaltyService],
|
||||
})
|
||||
export class LoyaltyModule {}
|
||||
|
||||
@@ -4,23 +4,72 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
CreateLoyaltyRewardRequest,
|
||||
CreateLoyaltyTierRequest,
|
||||
LoyaltyLedgerDto,
|
||||
LoyaltyMemberDto,
|
||||
LoyaltyMessageResult,
|
||||
LoyaltyRewardDto,
|
||||
LoyaltySettingsDto,
|
||||
LoyaltyStatsDto,
|
||||
LoyaltyTierDto,
|
||||
UpdateLoyaltyRewardRequest,
|
||||
UpdateLoyaltySettingsRequest,
|
||||
UpdateLoyaltyTierRequest,
|
||||
} from '@mento/shared';
|
||||
import ExcelJS from 'exceljs';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { type Actor, ownerWhere } from '../common/record-scope';
|
||||
import { SmsService } from '../common/sms.service';
|
||||
|
||||
const DEFAULT_TIERS = [
|
||||
{ name: 'برنزی', minPoints: 0, color: '#b87333' },
|
||||
{ name: 'نقرهای', minPoints: 500, color: '#9aa0a6' },
|
||||
{ name: 'طلایی', minPoints: 2000, color: '#d4af37' },
|
||||
{ name: 'برنزی', minPoints: 0, color: '#c9843a' },
|
||||
{ name: 'نقرهای', minPoints: 500, color: '#8b95a5' },
|
||||
{ name: 'طلایی', minPoints: 2000, color: '#d4a017' },
|
||||
] as const;
|
||||
|
||||
const DEFAULT_REWARDS = [
|
||||
{
|
||||
name: 'تخفیف ویژه',
|
||||
description: 'اعمال روی فاکتور بعدی',
|
||||
pointsCost: 500,
|
||||
color: '#14b8a6',
|
||||
},
|
||||
{
|
||||
name: 'مشاوره رایگان',
|
||||
description: 'یک جلسه مشاوره اختصاصی',
|
||||
pointsCost: 1000,
|
||||
color: '#8b5cf6',
|
||||
},
|
||||
{
|
||||
name: 'هدیه باشگاه',
|
||||
description: 'هدیه منتخب باشگاه مشتریان',
|
||||
pointsCost: 2000,
|
||||
color: '#eab308',
|
||||
},
|
||||
] as const;
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
@Injectable()
|
||||
export class LoyaltyService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly sms: SmsService,
|
||||
) {}
|
||||
|
||||
async ensureDefaults(organizationId: string): Promise<void> {
|
||||
await Promise.all([
|
||||
this.ensureDefaultTiers(organizationId),
|
||||
this.ensureDefaultRewards(organizationId),
|
||||
this.ensureSettings(organizationId),
|
||||
]);
|
||||
const unassigned = await this.prisma.person.count({
|
||||
where: { organizationId, loyaltyTierId: null },
|
||||
});
|
||||
if (unassigned > 0) await this.recomputeAllTiers(organizationId);
|
||||
}
|
||||
|
||||
async ensureDefaultTiers(organizationId: string): Promise<void> {
|
||||
const count = await this.prisma.loyaltyTier.count({
|
||||
@@ -38,11 +87,167 @@ export class LoyaltyService {
|
||||
});
|
||||
}
|
||||
|
||||
async ensureDefaultRewards(organizationId: string): Promise<void> {
|
||||
const count = await this.prisma.loyaltyReward.count({
|
||||
where: { organizationId },
|
||||
});
|
||||
if (count > 0) return;
|
||||
await this.prisma.loyaltyReward.createMany({
|
||||
data: DEFAULT_REWARDS.map((r, position) => ({
|
||||
organizationId,
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
pointsCost: r.pointsCost,
|
||||
color: r.color,
|
||||
position,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
async ensureSettings(organizationId: string) {
|
||||
const existing = await this.prisma.loyaltySettings.findUnique({
|
||||
where: { organizationId },
|
||||
});
|
||||
if (existing) return existing;
|
||||
return this.prisma.loyaltySettings.create({
|
||||
data: { organizationId },
|
||||
});
|
||||
}
|
||||
|
||||
async getSettings(organizationId: string): Promise<LoyaltySettingsDto> {
|
||||
const row = await this.ensureSettings(organizationId);
|
||||
return {
|
||||
enabled: row.enabled,
|
||||
welcomePoints: row.welcomePoints,
|
||||
notifyOnAdjust: row.notifyOnAdjust,
|
||||
};
|
||||
}
|
||||
|
||||
async updateSettings(
|
||||
organizationId: string,
|
||||
dto: UpdateLoyaltySettingsRequest,
|
||||
): Promise<LoyaltySettingsDto> {
|
||||
await this.ensureSettings(organizationId);
|
||||
const row = await this.prisma.loyaltySettings.update({
|
||||
where: { organizationId },
|
||||
data: {
|
||||
...(dto.enabled !== undefined ? { enabled: dto.enabled } : {}),
|
||||
...(dto.welcomePoints !== undefined
|
||||
? { welcomePoints: Math.max(0, Math.floor(dto.welcomePoints)) }
|
||||
: {}),
|
||||
...(dto.notifyOnAdjust !== undefined
|
||||
? { notifyOnAdjust: dto.notifyOnAdjust }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
return {
|
||||
enabled: row.enabled,
|
||||
welcomePoints: row.welcomePoints,
|
||||
notifyOnAdjust: row.notifyOnAdjust,
|
||||
};
|
||||
}
|
||||
|
||||
async getStats(actor: Actor): Promise<LoyaltyStatsDto> {
|
||||
await this.ensureDefaults(actor.organizationId);
|
||||
const scope = {
|
||||
organizationId: actor.organizationId,
|
||||
...ownerWhere(actor),
|
||||
};
|
||||
const now = new Date();
|
||||
const monthStart = startOfMonth(now);
|
||||
const prevMonthStart = startOfMonth(addMonths(monthStart, -1));
|
||||
const prevMonthEnd = monthStart;
|
||||
const retentionFrom = new Date(now.getTime() - 90 * DAY_MS);
|
||||
const prevRetentionEnd = monthStart;
|
||||
const prevRetentionFrom = new Date(prevRetentionEnd.getTime() - 90 * DAY_MS);
|
||||
|
||||
const [
|
||||
memberCount,
|
||||
membersAtMonthStart,
|
||||
pointsThis,
|
||||
pointsPrev,
|
||||
rewardsThis,
|
||||
rewardsPrev,
|
||||
people,
|
||||
] = await Promise.all([
|
||||
this.prisma.person.count({ where: scope }),
|
||||
this.prisma.person.count({
|
||||
where: { ...scope, createdAt: { lt: monthStart } },
|
||||
}),
|
||||
this.prisma.loyaltyLedger.aggregate({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
delta: { gt: 0 },
|
||||
createdAt: { gte: monthStart },
|
||||
},
|
||||
_sum: { delta: true },
|
||||
}),
|
||||
this.prisma.loyaltyLedger.aggregate({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
delta: { gt: 0 },
|
||||
createdAt: { gte: prevMonthStart, lt: prevMonthEnd },
|
||||
},
|
||||
_sum: { delta: true },
|
||||
}),
|
||||
this.prisma.loyaltyRedemption.count({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
createdAt: { gte: monthStart },
|
||||
},
|
||||
}),
|
||||
this.prisma.loyaltyRedemption.count({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
createdAt: { gte: prevMonthStart, lt: prevMonthEnd },
|
||||
},
|
||||
}),
|
||||
this.prisma.person.findMany({
|
||||
where: scope,
|
||||
select: { id: true, updatedAt: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const lastMap = await this.lastActivityMap(
|
||||
actor.organizationId,
|
||||
people.map((p) => p.id),
|
||||
);
|
||||
const activeNow = people.filter((p) => {
|
||||
const at = lastMap.get(p.id) ?? p.updatedAt;
|
||||
return at >= retentionFrom;
|
||||
}).length;
|
||||
const activePrev = people.filter((p) => {
|
||||
const at = lastMap.get(p.id) ?? p.updatedAt;
|
||||
return at >= prevRetentionFrom && at < prevRetentionEnd;
|
||||
}).length;
|
||||
|
||||
const retentionRate = memberCount
|
||||
? Math.round((activeNow / memberCount) * 100)
|
||||
: 0;
|
||||
const prevRetention = membersAtMonthStart
|
||||
? Math.round((activePrev / membersAtMonthStart) * 100)
|
||||
: 0;
|
||||
|
||||
const pointsIssued = pointsThis._sum.delta ?? 0;
|
||||
const pointsPrevVal = pointsPrev._sum.delta ?? 0;
|
||||
|
||||
return {
|
||||
memberCount,
|
||||
memberDeltaPct: pctChange(memberCount, membersAtMonthStart),
|
||||
pointsIssued,
|
||||
pointsIssuedDeltaPct: pctChange(pointsIssued, pointsPrevVal),
|
||||
rewardsUsed: rewardsThis,
|
||||
rewardsUsedDeltaPct: pctChange(rewardsThis, rewardsPrev),
|
||||
retentionRate,
|
||||
retentionDeltaPct: pctChange(retentionRate, prevRetention),
|
||||
};
|
||||
}
|
||||
|
||||
async listTiers(organizationId: string): Promise<LoyaltyTierDto[]> {
|
||||
await this.ensureDefaultTiers(organizationId);
|
||||
await this.ensureDefaults(organizationId);
|
||||
const rows = await this.prisma.loyaltyTier.findMany({
|
||||
where: { organizationId },
|
||||
orderBy: [{ minPoints: 'asc' }, { position: 'asc' }],
|
||||
orderBy: [{ minPoints: 'desc' }, { position: 'asc' }],
|
||||
include: { _count: { select: { people: true } } },
|
||||
});
|
||||
return rows.map((r) => ({
|
||||
@@ -59,7 +264,7 @@ export class LoyaltyService {
|
||||
organizationId: string,
|
||||
dto: CreateLoyaltyTierRequest,
|
||||
): Promise<LoyaltyTierDto> {
|
||||
await this.ensureDefaultTiers(organizationId);
|
||||
await this.ensureDefaults(organizationId);
|
||||
const maxPos = await this.prisma.loyaltyTier.aggregate({
|
||||
where: { organizationId },
|
||||
_max: { position: true },
|
||||
@@ -132,23 +337,11 @@ export class LoyaltyService {
|
||||
async listMembers(
|
||||
actor: Actor,
|
||||
q?: string,
|
||||
tierId?: string,
|
||||
): Promise<LoyaltyMemberDto[]> {
|
||||
await this.ensureDefaultTiers(actor.organizationId);
|
||||
await this.ensureDefaults(actor.organizationId);
|
||||
const rows = await this.prisma.person.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
...ownerWhere(actor),
|
||||
...(q?.trim()
|
||||
? {
|
||||
OR: [
|
||||
{ firstName: { contains: q.trim(), mode: 'insensitive' } },
|
||||
{ lastName: { contains: q.trim(), mode: 'insensitive' } },
|
||||
{ mobile: { contains: q.trim() } },
|
||||
{ email: { contains: q.trim(), mode: 'insensitive' } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
where: this.memberWhere(actor, q, tierId),
|
||||
include: {
|
||||
company: { select: { id: true, name: true } },
|
||||
loyaltyTier: {
|
||||
@@ -156,16 +349,71 @@ export class LoyaltyService {
|
||||
},
|
||||
},
|
||||
orderBy: [{ loyaltyPoints: 'desc' }, { lastName: 'asc' }],
|
||||
take: 200,
|
||||
take: 400,
|
||||
});
|
||||
const lastMap = await this.lastActivityMap(
|
||||
actor.organizationId,
|
||||
rows.map((r) => r.id),
|
||||
);
|
||||
return rows.map((r) => this.toMemberDto(r, lastMap.get(r.id) ?? r.updatedAt));
|
||||
}
|
||||
|
||||
async exportMembers(
|
||||
actor: Actor,
|
||||
q?: string,
|
||||
tierId?: string,
|
||||
): Promise<Buffer> {
|
||||
const members = await this.listMembers(actor, q, tierId);
|
||||
const wb = new ExcelJS.Workbook();
|
||||
const sheet = wb.addWorksheet('اعضای باشگاه');
|
||||
sheet.columns = [
|
||||
{ header: 'نام', key: 'fullName', width: 24 },
|
||||
{ header: 'شرکت', key: 'company', width: 24 },
|
||||
{ header: 'موبایل', key: 'mobile', width: 16 },
|
||||
{ header: 'ایمیل', key: 'email', width: 28 },
|
||||
{ header: 'سطح', key: 'tier', width: 14 },
|
||||
{ header: 'امتیاز', key: 'points', width: 12 },
|
||||
{ header: 'آخرین فعالیت', key: 'lastActivity', width: 22 },
|
||||
];
|
||||
for (const m of members) {
|
||||
sheet.addRow({
|
||||
fullName: m.fullName,
|
||||
company: m.company?.name ?? '',
|
||||
mobile: m.mobile ?? '',
|
||||
email: m.email ?? '',
|
||||
tier: m.loyaltyTier?.name ?? '',
|
||||
points: m.loyaltyPoints,
|
||||
lastActivity: m.lastActivityAt ?? '',
|
||||
});
|
||||
}
|
||||
const buf = await wb.xlsx.writeBuffer();
|
||||
return Buffer.from(buf);
|
||||
}
|
||||
|
||||
async listLedger(
|
||||
actor: Actor,
|
||||
personId: string,
|
||||
): Promise<LoyaltyLedgerDto[]> {
|
||||
const person = await this.prisma.person.findFirst({
|
||||
where: {
|
||||
id: personId,
|
||||
organizationId: actor.organizationId,
|
||||
...ownerWhere(actor),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (!person) throw new NotFoundException('شخص پیدا نشد');
|
||||
const rows = await this.prisma.loyaltyLedger.findMany({
|
||||
where: { personId, organizationId: actor.organizationId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 80,
|
||||
});
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
fullName: `${r.firstName} ${r.lastName}`.trim(),
|
||||
mobile: r.mobile,
|
||||
email: r.email,
|
||||
loyaltyPoints: r.loyaltyPoints,
|
||||
loyaltyTier: r.loyaltyTier,
|
||||
company: r.company,
|
||||
delta: r.delta,
|
||||
reason: r.reason,
|
||||
source: r.source,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -173,6 +421,8 @@ export class LoyaltyService {
|
||||
actor: Actor,
|
||||
personId: string,
|
||||
delta: number,
|
||||
reason?: string,
|
||||
source = 'manual',
|
||||
): Promise<LoyaltyMemberDto> {
|
||||
if (!Number.isFinite(delta) || delta === 0) {
|
||||
throw new BadRequestException('مقدار امتیاز نامعتبر است');
|
||||
@@ -186,27 +436,79 @@ export class LoyaltyService {
|
||||
});
|
||||
if (!person) throw new NotFoundException('شخص پیدا نشد');
|
||||
|
||||
const next = Math.max(0, person.loyaltyPoints + Math.trunc(delta));
|
||||
const tierId = await this.resolveTierId(actor.organizationId, next);
|
||||
const updated = await this.prisma.person.update({
|
||||
where: { id: personId },
|
||||
data: { loyaltyPoints: next, loyaltyTierId: tierId },
|
||||
include: {
|
||||
company: { select: { id: true, name: true } },
|
||||
loyaltyTier: {
|
||||
select: { id: true, name: true, minPoints: true, color: true },
|
||||
},
|
||||
const updated = await this.applyDelta(
|
||||
actor.organizationId,
|
||||
personId,
|
||||
Math.trunc(delta),
|
||||
reason,
|
||||
source,
|
||||
);
|
||||
const settings = await this.ensureSettings(actor.organizationId);
|
||||
if (settings.notifyOnAdjust && updated.mobile) {
|
||||
const sign = delta > 0 ? '+' : '';
|
||||
void this.sms.sendSmsDetailed(
|
||||
updated.mobile,
|
||||
`باشگاه مشتریان: ${sign}${Math.trunc(delta)} امتیاز. موجودی: ${updated.loyaltyPoints}`,
|
||||
);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
async bulkAdjust(
|
||||
actor: Actor,
|
||||
personIds: string[],
|
||||
delta: number,
|
||||
reason?: string,
|
||||
): Promise<{ updated: number }> {
|
||||
const unique = [...new Set(personIds.filter(Boolean))];
|
||||
if (!unique.length) throw new BadRequestException('عضوی انتخاب نشده');
|
||||
if (!Number.isFinite(delta) || delta === 0) {
|
||||
throw new BadRequestException('مقدار امتیاز نامعتبر است');
|
||||
}
|
||||
let updated = 0;
|
||||
for (const id of unique) {
|
||||
try {
|
||||
await this.adjustPoints(actor, id, delta, reason, 'bulk');
|
||||
updated += 1;
|
||||
} catch {
|
||||
/* skip inaccessible */
|
||||
}
|
||||
}
|
||||
return { updated };
|
||||
}
|
||||
|
||||
async sendMessage(
|
||||
actor: Actor,
|
||||
personIds: string[],
|
||||
text: string,
|
||||
): Promise<LoyaltyMessageResult> {
|
||||
const unique = [...new Set(personIds.filter(Boolean))];
|
||||
if (!unique.length) throw new BadRequestException('عضوی انتخاب نشده');
|
||||
const body = text.trim();
|
||||
if (body.length < 2) throw new BadRequestException('متن پیام کوتاه است');
|
||||
|
||||
const people = await this.prisma.person.findMany({
|
||||
where: {
|
||||
id: { in: unique },
|
||||
organizationId: actor.organizationId,
|
||||
...ownerWhere(actor),
|
||||
},
|
||||
select: { id: true, mobile: true },
|
||||
});
|
||||
return {
|
||||
id: updated.id,
|
||||
fullName: `${updated.firstName} ${updated.lastName}`.trim(),
|
||||
mobile: updated.mobile,
|
||||
email: updated.email,
|
||||
loyaltyPoints: updated.loyaltyPoints,
|
||||
loyaltyTier: updated.loyaltyTier,
|
||||
company: updated.company,
|
||||
};
|
||||
let sent = 0;
|
||||
let skipped = 0;
|
||||
let failed = 0;
|
||||
for (const p of people) {
|
||||
if (!p.mobile) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
const result = await this.sms.sendSmsDetailed(p.mobile, body);
|
||||
if (result.ok) sent += 1;
|
||||
else failed += 1;
|
||||
}
|
||||
skipped += Math.max(0, unique.length - people.length);
|
||||
return { sent, skipped, failed };
|
||||
}
|
||||
|
||||
/** برای اتوماسیون — بدون ACL مالکیت */
|
||||
@@ -214,18 +516,38 @@ export class LoyaltyService {
|
||||
organizationId: string,
|
||||
personId: string,
|
||||
points: number,
|
||||
reason = 'اتوماسیون',
|
||||
): Promise<void> {
|
||||
if (!personId || !points) return;
|
||||
const person = await this.prisma.person.findFirst({
|
||||
where: { id: personId, organizationId },
|
||||
});
|
||||
if (!person) return;
|
||||
const next = Math.max(0, person.loyaltyPoints + Math.trunc(points));
|
||||
const tierId = await this.resolveTierId(organizationId, next);
|
||||
await this.prisma.person.update({
|
||||
where: { id: personId },
|
||||
data: { loyaltyPoints: next, loyaltyTierId: tierId },
|
||||
});
|
||||
await this.applyDelta(organizationId, personId, Math.trunc(points), reason, 'automation');
|
||||
}
|
||||
|
||||
async applyWelcomePoints(organizationId: string, personId: string) {
|
||||
const settings = await this.ensureSettings(organizationId);
|
||||
if (!settings.enabled || settings.welcomePoints <= 0) {
|
||||
const person = await this.prisma.person.findFirst({
|
||||
where: { id: personId, organizationId },
|
||||
select: { loyaltyPoints: true },
|
||||
});
|
||||
if (!person) return;
|
||||
const tierId = await this.resolveTierId(organizationId, person.loyaltyPoints);
|
||||
await this.prisma.person.update({
|
||||
where: { id: personId },
|
||||
data: { loyaltyTierId: tierId },
|
||||
});
|
||||
return;
|
||||
}
|
||||
await this.applyDelta(
|
||||
organizationId,
|
||||
personId,
|
||||
settings.welcomePoints,
|
||||
'امتیاز خوشآمدگویی',
|
||||
'welcome',
|
||||
);
|
||||
}
|
||||
|
||||
async resolveTierId(
|
||||
@@ -240,6 +562,208 @@ export class LoyaltyService {
|
||||
return tier?.id ?? null;
|
||||
}
|
||||
|
||||
async listRewards(organizationId: string): Promise<LoyaltyRewardDto[]> {
|
||||
await this.ensureDefaults(organizationId);
|
||||
const rows = await this.prisma.loyaltyReward.findMany({
|
||||
where: { organizationId },
|
||||
orderBy: [{ position: 'asc' }, { pointsCost: 'asc' }],
|
||||
include: { _count: { select: { redemptions: true } } },
|
||||
});
|
||||
return rows.map((r) => this.toRewardDto(r));
|
||||
}
|
||||
|
||||
async createReward(
|
||||
organizationId: string,
|
||||
dto: CreateLoyaltyRewardRequest,
|
||||
): Promise<LoyaltyRewardDto> {
|
||||
await this.ensureDefaults(organizationId);
|
||||
const maxPos = await this.prisma.loyaltyReward.aggregate({
|
||||
where: { organizationId },
|
||||
_max: { position: true },
|
||||
});
|
||||
const row = await this.prisma.loyaltyReward.create({
|
||||
data: {
|
||||
organizationId,
|
||||
name: dto.name.trim(),
|
||||
description: emptyToNull(dto.description),
|
||||
pointsCost: Math.max(1, Math.floor(dto.pointsCost)),
|
||||
isActive: dto.isActive ?? true,
|
||||
color: emptyToNull(dto.color),
|
||||
position: (maxPos._max.position ?? -1) + 1,
|
||||
},
|
||||
include: { _count: { select: { redemptions: true } } },
|
||||
});
|
||||
return this.toRewardDto(row);
|
||||
}
|
||||
|
||||
async updateReward(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
dto: UpdateLoyaltyRewardRequest,
|
||||
): Promise<LoyaltyRewardDto> {
|
||||
const existing = await this.prisma.loyaltyReward.findFirst({
|
||||
where: { id, organizationId },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('پاداش پیدا نشد');
|
||||
const row = await this.prisma.loyaltyReward.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
|
||||
...('description' in dto
|
||||
? { description: emptyToNull(dto.description ?? null) }
|
||||
: {}),
|
||||
...(dto.pointsCost !== undefined
|
||||
? { pointsCost: Math.max(1, Math.floor(dto.pointsCost)) }
|
||||
: {}),
|
||||
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
|
||||
...('color' in dto ? { color: emptyToNull(dto.color ?? null) } : {}),
|
||||
},
|
||||
include: { _count: { select: { redemptions: true } } },
|
||||
});
|
||||
return this.toRewardDto(row);
|
||||
}
|
||||
|
||||
async deleteReward(organizationId: string, id: string): Promise<void> {
|
||||
const existing = await this.prisma.loyaltyReward.findFirst({
|
||||
where: { id, organizationId },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('پاداش پیدا نشد');
|
||||
const used = await this.prisma.loyaltyRedemption.count({
|
||||
where: { rewardId: id },
|
||||
});
|
||||
if (used > 0) {
|
||||
await this.prisma.loyaltyReward.update({
|
||||
where: { id },
|
||||
data: { isActive: false },
|
||||
});
|
||||
return;
|
||||
}
|
||||
await this.prisma.loyaltyReward.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async redeemReward(
|
||||
actor: Actor,
|
||||
rewardId: string,
|
||||
personId: string,
|
||||
): Promise<LoyaltyMemberDto> {
|
||||
const reward = await this.prisma.loyaltyReward.findFirst({
|
||||
where: { id: rewardId, organizationId: actor.organizationId },
|
||||
});
|
||||
if (!reward || !reward.isActive) {
|
||||
throw new NotFoundException('پاداش پیدا نشد');
|
||||
}
|
||||
const person = await this.prisma.person.findFirst({
|
||||
where: {
|
||||
id: personId,
|
||||
organizationId: actor.organizationId,
|
||||
...ownerWhere(actor),
|
||||
},
|
||||
});
|
||||
if (!person) throw new NotFoundException('شخص پیدا نشد');
|
||||
if (person.loyaltyPoints < reward.pointsCost) {
|
||||
throw new BadRequestException('امتیاز عضو برای این پاداش کافی نیست');
|
||||
}
|
||||
|
||||
await this.prisma.loyaltyRedemption.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
personId,
|
||||
rewardId,
|
||||
pointsSpent: reward.pointsCost,
|
||||
},
|
||||
});
|
||||
return this.applyDelta(
|
||||
actor.organizationId,
|
||||
personId,
|
||||
-reward.pointsCost,
|
||||
`پاداش: ${reward.name}`,
|
||||
'reward',
|
||||
);
|
||||
}
|
||||
|
||||
private async applyDelta(
|
||||
organizationId: string,
|
||||
personId: string,
|
||||
delta: number,
|
||||
reason: string | undefined,
|
||||
source: string,
|
||||
): Promise<LoyaltyMemberDto> {
|
||||
const person = await this.prisma.person.findFirst({
|
||||
where: { id: personId, organizationId },
|
||||
});
|
||||
if (!person) throw new NotFoundException('شخص پیدا نشد');
|
||||
const next = Math.max(0, person.loyaltyPoints + delta);
|
||||
const tierId = await this.resolveTierId(organizationId, next);
|
||||
const [updated] = await this.prisma.$transaction([
|
||||
this.prisma.person.update({
|
||||
where: { id: personId },
|
||||
data: { loyaltyPoints: next, loyaltyTierId: tierId },
|
||||
include: {
|
||||
company: { select: { id: true, name: true } },
|
||||
loyaltyTier: {
|
||||
select: { id: true, name: true, minPoints: true, color: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.loyaltyLedger.create({
|
||||
data: {
|
||||
organizationId,
|
||||
personId,
|
||||
delta,
|
||||
reason: emptyToNull(reason),
|
||||
source,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
return this.toMemberDto(updated, updated.updatedAt);
|
||||
}
|
||||
|
||||
private memberWhere(
|
||||
actor: Actor,
|
||||
q?: string,
|
||||
tierId?: string,
|
||||
): Prisma.PersonWhereInput {
|
||||
const query = q?.trim();
|
||||
return {
|
||||
organizationId: actor.organizationId,
|
||||
...ownerWhere(actor),
|
||||
...(tierId ? { loyaltyTierId: tierId } : {}),
|
||||
...(query
|
||||
? {
|
||||
OR: [
|
||||
{ firstName: { contains: query, mode: 'insensitive' } },
|
||||
{ lastName: { contains: query, mode: 'insensitive' } },
|
||||
{ mobile: { contains: query } },
|
||||
{ email: { contains: query, mode: 'insensitive' } },
|
||||
{ company: { name: { contains: query, mode: 'insensitive' } } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
private async lastActivityMap(organizationId: string, personIds: string[]) {
|
||||
const map = new Map<string, Date>();
|
||||
if (!personIds.length) return map;
|
||||
const rows = await this.prisma.activity.groupBy({
|
||||
by: ['personId'],
|
||||
where: {
|
||||
organizationId,
|
||||
personId: { in: personIds },
|
||||
},
|
||||
_max: { updatedAt: true, completedAt: true, createdAt: true },
|
||||
});
|
||||
for (const r of rows) {
|
||||
if (!r.personId) continue;
|
||||
const dates = [r._max.updatedAt, r._max.completedAt, r._max.createdAt].filter(
|
||||
(d): d is Date => !!d,
|
||||
);
|
||||
if (!dates.length) continue;
|
||||
map.set(r.personId, new Date(Math.max(...dates.map((d) => d.getTime()))));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private async recomputeAllTiers(organizationId: string) {
|
||||
const people = await this.prisma.person.findMany({
|
||||
where: { organizationId },
|
||||
@@ -257,6 +781,53 @@ export class LoyaltyService {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private toMemberDto(
|
||||
r: {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
mobile: string | null;
|
||||
email: string | null;
|
||||
loyaltyPoints: number;
|
||||
loyaltyTier?: LoyaltyMemberDto['loyaltyTier'];
|
||||
company?: LoyaltyMemberDto['company'];
|
||||
},
|
||||
lastActivity: Date,
|
||||
): LoyaltyMemberDto {
|
||||
return {
|
||||
id: r.id,
|
||||
fullName: `${r.firstName} ${r.lastName}`.trim(),
|
||||
mobile: r.mobile,
|
||||
email: r.email,
|
||||
loyaltyPoints: r.loyaltyPoints,
|
||||
loyaltyTier: r.loyaltyTier ?? null,
|
||||
company: r.company ?? null,
|
||||
lastActivityAt: lastActivity.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private toRewardDto(r: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
pointsCost: number;
|
||||
isActive: boolean;
|
||||
position: number;
|
||||
color: string | null;
|
||||
_count: { redemptions: number };
|
||||
}): LoyaltyRewardDto {
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
pointsCost: r.pointsCost,
|
||||
isActive: r.isActive,
|
||||
position: r.position,
|
||||
color: r.color,
|
||||
redemptionCount: r._count.redemptions,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function emptyToNull(v?: string | null) {
|
||||
@@ -264,3 +835,17 @@ function emptyToNull(v?: string | null) {
|
||||
const t = v.trim();
|
||||
return t ? t : null;
|
||||
}
|
||||
|
||||
function startOfMonth(d: Date) {
|
||||
return new Date(d.getFullYear(), d.getMonth(), 1, 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
function addMonths(d: Date, n: number) {
|
||||
return new Date(d.getFullYear(), d.getMonth() + n, 1, 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
function pctChange(current: number, previous: number): number | null {
|
||||
if (previous <= 0 && current <= 0) return 0;
|
||||
if (previous <= 0) return current > 0 ? 100 : 0;
|
||||
return Math.round(((current - previous) / previous) * 100);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user