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:
2026-09-06 13:50:05 +03:30
co-authored by Cursor
parent 175f1fa440
commit 820c0780d8
21 changed files with 3658 additions and 893 deletions
+3
View File
@@ -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 {}
+8 -1
View File
@@ -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(
+48 -5
View File
@@ -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(
+192 -48
View File
@@ -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 ?? {});
+111
View File
@@ -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;
}
+112 -3
View File
@@ -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);
}
}
+2 -1
View File
@@ -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 {}
+639 -54
View File
@@ -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);
}