From 820c0780d82964e877d7cc9172db2a7fd7491926 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Sun, 6 Sep 2026 13:50:05 +0330 Subject: [PATCH] Ship loyalty club and form builder so production pull gets the new screens. Co-authored-by: Cursor --- .../migration.sql | 159 ++ .../migration.sql | 31 + apps/api/prisma/schema.prisma | 103 +- apps/api/src/contacts/contacts.module.ts | 3 + apps/api/src/contacts/people.service.ts | 9 +- apps/api/src/lead-forms/dto.ts | 53 +- .../src/lead-forms/lead-forms.controller.ts | 58 +- apps/api/src/lead-forms/lead-forms.service.ts | 240 ++- .../public-lead-forms.controller.ts | 29 +- apps/api/src/loyalty/dto.ts | 111 ++ apps/api/src/loyalty/loyalty.controller.ts | 115 +- apps/api/src/loyalty/loyalty.module.ts | 3 +- apps/api/src/loyalty/loyalty.service.ts | 693 +++++++- apps/web/src/components/QuickCreate.vue | 2 +- apps/web/src/components/UiIcon.vue | 28 +- apps/web/src/router/index.ts | 6 + apps/web/src/views/LeadFormBuilderView.vue | 652 ++++++++ apps/web/src/views/LeadFormPublicView.vue | 220 +-- apps/web/src/views/LeadFormsSettingsView.vue | 433 +---- apps/web/src/views/LoyaltyClubView.vue | 1484 +++++++++++++---- packages/shared/src/index.ts | 119 +- 21 files changed, 3658 insertions(+), 893 deletions(-) create mode 100644 apps/api/prisma/migrations/20260906120000_loyalty_club_infra/migration.sql create mode 100644 apps/api/prisma/migrations/20260906140000_lead_form_builder/migration.sql create mode 100644 apps/web/src/views/LeadFormBuilderView.vue diff --git a/apps/api/prisma/migrations/20260906120000_loyalty_club_infra/migration.sql b/apps/api/prisma/migrations/20260906120000_loyalty_club_infra/migration.sql new file mode 100644 index 0000000..814690c --- /dev/null +++ b/apps/api/prisma/migrations/20260906120000_loyalty_club_infra/migration.sql @@ -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 $$; diff --git a/apps/api/prisma/migrations/20260906140000_lead_form_builder/migration.sql b/apps/api/prisma/migrations/20260906140000_lead_form_builder/migration.sql new file mode 100644 index 0000000..51b9f4c --- /dev/null +++ b/apps/api/prisma/migrations/20260906140000_lead_form_builder/migration.sql @@ -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 $$; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 96d5aa2..c5ae895 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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 diff --git a/apps/api/src/contacts/contacts.module.ts b/apps/api/src/contacts/contacts.module.ts index 4dc9345..ac0770b 100644 --- a/apps/api/src/contacts/contacts.module.ts +++ b/apps/api/src/contacts/contacts.module.ts @@ -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 {} + diff --git a/apps/api/src/contacts/people.service.ts b/apps/api/src/contacts/people.service.ts index c5c8a53..82e46ca 100644 --- a/apps/api/src/contacts/people.service.ts +++ b/apps/api/src/contacts/people.service.ts @@ -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( diff --git a/apps/api/src/lead-forms/dto.ts b/apps/api/src/lead-forms/dto.ts index 2e72555..62ec1a7 100644 --- a/apps/api/src/lead-forms/dto.ts +++ b/apps/api/src/lead-forms/dto.ts @@ -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[]; } diff --git a/apps/api/src/lead-forms/lead-forms.controller.ts b/apps/api/src/lead-forms/lead-forms.controller.ts index 44df335..3572922 100644 --- a/apps/api/src/lead-forms/lead-forms.controller.ts +++ b/apps/api/src/lead-forms/lead-forms.controller.ts @@ -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( diff --git a/apps/api/src/lead-forms/lead-forms.service.ts b/apps/api/src/lead-forms/lead-forms.service.ts index 825ecda..95830da 100644 --- a/apps/api/src/lead-forms/lead-forms.service.ts +++ b/apps/api/src/lead-forms/lead-forms.service.ts @@ -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 { 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 { @@ -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, ): 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 { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + 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 { + 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() diff --git a/apps/api/src/lead-forms/public-lead-forms.controller.ts b/apps/api/src/lead-forms/public-lead-forms.controller.ts index ba22cc8..7ddb55e 100644 --- a/apps/api/src/lead-forms/public-lead-forms.controller.ts +++ b/apps/api/src/lead-forms/public-lead-forms.controller.ts @@ -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 ?? {}); diff --git a/apps/api/src/loyalty/dto.ts b/apps/api/src/loyalty/dto.ts index 61c0ce7..963e421 100644 --- a/apps/api/src/loyalty/dto.ts +++ b/apps/api/src/loyalty/dto.ts @@ -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; +} diff --git a/apps/api/src/loyalty/loyalty.controller.ts b/apps/api/src/loyalty/loyalty.controller.ts index 50e1b71..9c0b691 100644 --- a/apps/api/src/loyalty/loyalty.controller.ts +++ b/apps/api/src/loyalty/loyalty.controller.ts @@ -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); } } diff --git a/apps/api/src/loyalty/loyalty.module.ts b/apps/api/src/loyalty/loyalty.module.ts index d432750..3080236 100644 --- a/apps/api/src/loyalty/loyalty.module.ts +++ b/apps/api/src/loyalty/loyalty.module.ts @@ -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 {} diff --git a/apps/api/src/loyalty/loyalty.service.ts b/apps/api/src/loyalty/loyalty.service.ts index f4fb00b..e63daf1 100644 --- a/apps/api/src/loyalty/loyalty.service.ts +++ b/apps/api/src/loyalty/loyalty.service.ts @@ -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 { + 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 { const count = await this.prisma.loyaltyTier.count({ @@ -38,11 +87,167 @@ export class LoyaltyService { }); } + async ensureDefaultRewards(organizationId: string): Promise { + 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 { + const row = await this.ensureSettings(organizationId); + return { + enabled: row.enabled, + welcomePoints: row.welcomePoints, + notifyOnAdjust: row.notifyOnAdjust, + }; + } + + async updateSettings( + organizationId: string, + dto: UpdateLoyaltySettingsRequest, + ): Promise { + 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 { + 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 { - 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 { - 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 { - 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 { + 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 { + 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 { 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 { + 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 { 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); + 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); +} diff --git a/apps/web/src/components/QuickCreate.vue b/apps/web/src/components/QuickCreate.vue index fd8848d..e814da0 100644 --- a/apps/web/src/components/QuickCreate.vue +++ b/apps/web/src/components/QuickCreate.vue @@ -437,7 +437,7 @@ onBeforeUnmount(() => { .fab :deep(.ico) { display: block; - stroke-width: 2.2; + stroke-width: 1.55; filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.15)); } diff --git a/apps/web/src/components/UiIcon.vue b/apps/web/src/components/UiIcon.vue index 1c81481..787b08c 100644 --- a/apps/web/src/components/UiIcon.vue +++ b/apps/web/src/components/UiIcon.vue @@ -54,7 +54,10 @@ defineProps<{ | 'eye' | 'truck' | 'grip' - | 'apps-plus'; + | 'apps-plus' + | 'crown' + | 'gift' + | 'medal'; size?: number; }>(); @@ -315,8 +318,27 @@ defineProps<{ + + + diff --git a/apps/web/src/router/index.ts b/apps/web/src/router/index.ts index 7ab5265..4ad14fc 100644 --- a/apps/web/src/router/index.ts +++ b/apps/web/src/router/index.ts @@ -180,6 +180,12 @@ export const router = createRouter({ component: () => import('@/views/LeadFormsSettingsView.vue'), meta: { roles: ['owner', 'admin'] as UserRole[] }, }, + { + path: 'settings/lead-forms/:id', + name: 'lead-form-builder', + component: () => import('@/views/LeadFormBuilderView.vue'), + meta: { roles: ['owner', 'admin'] as UserRole[] }, + }, { path: 'settings/team', name: 'team-settings', diff --git a/apps/web/src/views/LeadFormBuilderView.vue b/apps/web/src/views/LeadFormBuilderView.vue new file mode 100644 index 0000000..fedec73 --- /dev/null +++ b/apps/web/src/views/LeadFormBuilderView.vue @@ -0,0 +1,652 @@ + + +