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
@@ -0,0 +1,159 @@
-- CreateTable
CREATE TABLE IF NOT EXISTS "LoyaltySettings" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"welcomePoints" INTEGER NOT NULL DEFAULT 0,
"notifyOnAdjust" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LoyaltySettings_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE IF NOT EXISTS "LoyaltyLedger" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"personId" TEXT NOT NULL,
"delta" INTEGER NOT NULL,
"reason" TEXT,
"source" TEXT NOT NULL DEFAULT 'manual',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LoyaltyLedger_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE IF NOT EXISTS "LoyaltyReward" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"pointsCost" INTEGER NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"position" INTEGER NOT NULL DEFAULT 0,
"color" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LoyaltyReward_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE IF NOT EXISTS "LoyaltyRedemption" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"personId" TEXT NOT NULL,
"rewardId" TEXT NOT NULL,
"pointsSpent" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LoyaltyRedemption_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX IF NOT EXISTS "LoyaltySettings_organizationId_key" ON "LoyaltySettings"("organizationId");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "LoyaltyLedger_organizationId_createdAt_idx" ON "LoyaltyLedger"("organizationId", "createdAt");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "LoyaltyLedger_personId_createdAt_idx" ON "LoyaltyLedger"("personId", "createdAt");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "LoyaltyReward_organizationId_position_idx" ON "LoyaltyReward"("organizationId", "position");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "LoyaltyRedemption_organizationId_createdAt_idx" ON "LoyaltyRedemption"("organizationId", "createdAt");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "LoyaltyRedemption_personId_idx" ON "LoyaltyRedemption"("personId");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "LoyaltyRedemption_rewardId_idx" ON "LoyaltyRedemption"("rewardId");
-- AddForeignKey
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'LoyaltySettings_organizationId_fkey'
) THEN
ALTER TABLE "LoyaltySettings"
ADD CONSTRAINT "LoyaltySettings_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'LoyaltyLedger_organizationId_fkey'
) THEN
ALTER TABLE "LoyaltyLedger"
ADD CONSTRAINT "LoyaltyLedger_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'LoyaltyLedger_personId_fkey'
) THEN
ALTER TABLE "LoyaltyLedger"
ADD CONSTRAINT "LoyaltyLedger_personId_fkey"
FOREIGN KEY ("personId") REFERENCES "Person"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'LoyaltyReward_organizationId_fkey'
) THEN
ALTER TABLE "LoyaltyReward"
ADD CONSTRAINT "LoyaltyReward_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'LoyaltyRedemption_organizationId_fkey'
) THEN
ALTER TABLE "LoyaltyRedemption"
ADD CONSTRAINT "LoyaltyRedemption_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'LoyaltyRedemption_personId_fkey'
) THEN
ALTER TABLE "LoyaltyRedemption"
ADD CONSTRAINT "LoyaltyRedemption_personId_fkey"
FOREIGN KEY ("personId") REFERENCES "Person"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'LoyaltyRedemption_rewardId_fkey'
) THEN
ALTER TABLE "LoyaltyRedemption"
ADD CONSTRAINT "LoyaltyRedemption_rewardId_fkey"
FOREIGN KEY ("rewardId") REFERENCES "LoyaltyReward"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;
END IF;
END $$;
@@ -0,0 +1,31 @@
ALTER TABLE "LeadForm" ADD COLUMN IF NOT EXISTS "description" TEXT;
ALTER TABLE "LeadForm" ADD COLUMN IF NOT EXISTS "submitLabel" TEXT NOT NULL DEFAULT 'ارسال درخواست';
ALTER TABLE "LeadForm" ADD COLUMN IF NOT EXISTS "submitColor" TEXT NOT NULL DEFAULT '#14b8a6';
ALTER TABLE "LeadForm" ADD COLUMN IF NOT EXISTS "coverStorageKey" TEXT;
ALTER TABLE "LeadFormField" ADD COLUMN IF NOT EXISTS "placeholder" TEXT;
ALTER TABLE "LeadFormField" ADD COLUMN IF NOT EXISTS "options" JSONB;
CREATE TABLE IF NOT EXISTS "LeadFormSubmission" (
"id" TEXT NOT NULL,
"formId" TEXT NOT NULL,
"values" JSONB NOT NULL,
"personId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LeadFormSubmission_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "LeadFormSubmission_formId_createdAt_idx" ON "LeadFormSubmission"("formId", "createdAt");
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'LeadFormSubmission_formId_fkey'
) THEN
ALTER TABLE "LeadFormSubmission"
ADD CONSTRAINT "LeadFormSubmission_formId_fkey"
FOREIGN KEY ("formId") REFERENCES "LeadForm"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
+93 -10
View File
@@ -161,7 +161,11 @@ model Organization {
portalOtps PortalOtp[]
projectBoards ProjectBoard[]
projectCards ProjectCard[]
loyaltyTiers LoyaltyTier[]
loyaltyTiers LoyaltyTier[]
loyaltySettings LoyaltySettings?
loyaltyLedgers LoyaltyLedger[]
loyaltyRewards LoyaltyReward[]
loyaltyRedemptions LoyaltyRedemption[]
automationRules AutomationRule[]
memoryItems MemoryItem[]
noteAiSuggestions NoteAiSuggestion[]
@@ -256,6 +260,8 @@ model Person {
loyaltyPoints Int @default(0)
loyaltyTierId String?
loyaltyTier LoyaltyTier? @relation(fields: [loyaltyTierId], references: [id], onDelete: SetNull)
loyaltyLedgers LoyaltyLedger[]
loyaltyRedemptions LoyaltyRedemption[]
deals Deal[]
activities Activity[]
smartNotes Note[]
@@ -581,6 +587,7 @@ model LeadForm {
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
name String
description String?
publicToken String @unique @default(cuid())
isActive Boolean @default(true)
createPerson Boolean @default(true)
@@ -591,7 +598,11 @@ model LeadForm {
redirectUrl String?
assigneeIds String[] @default([])
roundRobinIndex Int @default(0)
submitLabel String @default("ارسال درخواست")
submitColor String @default("#14b8a6")
coverStorageKey String?
fields LeadFormField[]
submissions LeadFormSubmission[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -599,20 +610,33 @@ model LeadForm {
}
model LeadFormField {
id String @id @default(cuid())
formId String
form LeadForm @relation(fields: [formId], references: [id], onDelete: Cascade)
key String
label String
fieldType String @default("text")
required Boolean @default(false)
mapTo String
position Int @default(0)
id String @id @default(cuid())
formId String
form LeadForm @relation(fields: [formId], references: [id], onDelete: Cascade)
key String
label String
fieldType String @default("text")
required Boolean @default(false)
mapTo String
position Int @default(0)
placeholder String?
options Json?
@@unique([formId, key])
@@index([formId, position])
}
model LeadFormSubmission {
id String @id @default(cuid())
formId String
form LeadForm @relation(fields: [formId], references: [id], onDelete: Cascade)
values Json
personId String?
createdAt DateTime @default(now())
@@index([formId, createdAt])
}
model Quote {
id String @id @default(cuid())
organizationId String
@@ -958,6 +982,65 @@ model LoyaltyTier {
@@index([organizationId, minPoints])
}
model LoyaltySettings {
id String @id @default(cuid())
organizationId String @unique
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
enabled Boolean @default(true)
welcomePoints Int @default(0)
notifyOnAdjust Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model LoyaltyLedger {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
personId String
person Person @relation(fields: [personId], references: [id], onDelete: Cascade)
delta Int
reason String?
source String @default("manual")
createdAt DateTime @default(now())
@@index([organizationId, createdAt])
@@index([personId, createdAt])
}
model LoyaltyReward {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
name String
description String?
pointsCost Int
isActive Boolean @default(true)
position Int @default(0)
color String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
redemptions LoyaltyRedemption[]
@@index([organizationId, position])
}
model LoyaltyRedemption {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
personId String
person Person @relation(fields: [personId], references: [id], onDelete: Cascade)
rewardId String
reward LoyaltyReward @relation(fields: [rewardId], references: [id], onDelete: Restrict)
pointsSpent Int
createdAt DateTime @default(now())
@@index([organizationId, createdAt])
@@index([personId])
@@index([rewardId])
}
model AutomationRule {
id String @id @default(cuid())
organizationId String
+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);
}