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
+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);
}