Add project board, loyalty club, and if/then automations.
Separate modules for post-sales kanban, person points/tiers, and deal-won/ticket-resolved rules without overlapping the sales pipeline. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateLoyaltyTierDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(80)
|
||||
name!: string;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
minPoints!: number;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
export class UpdateLoyaltyTierDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(80)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
minPoints?: number;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
export class AdjustLoyaltyPointsDto {
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
delta!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
reason?: string;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
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 type { Actor } from '../common/record-scope';
|
||||
import {
|
||||
AdjustLoyaltyPointsDto,
|
||||
CreateLoyaltyTierDto,
|
||||
UpdateLoyaltyTierDto,
|
||||
} from './dto';
|
||||
import { LoyaltyService } from './loyalty.service';
|
||||
|
||||
@Controller('loyalty')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
export class LoyaltyController {
|
||||
constructor(private readonly loyalty: LoyaltyService) {}
|
||||
|
||||
@Get('tiers')
|
||||
listTiers(@CurrentUser() user: Actor) {
|
||||
return this.loyalty.listTiers(user.organizationId);
|
||||
}
|
||||
|
||||
@Post('tiers')
|
||||
@Roles('owner', 'admin')
|
||||
createTier(
|
||||
@CurrentUser() user: Actor,
|
||||
@Body() dto: CreateLoyaltyTierDto,
|
||||
) {
|
||||
return this.loyalty.createTier(user.organizationId, dto);
|
||||
}
|
||||
|
||||
@Patch('tiers/:id')
|
||||
@Roles('owner', 'admin')
|
||||
updateTier(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateLoyaltyTierDto,
|
||||
) {
|
||||
return this.loyalty.updateTier(user.organizationId, id, dto);
|
||||
}
|
||||
|
||||
@Delete('tiers/:id')
|
||||
@Roles('owner', 'admin')
|
||||
async deleteTier(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
await this.loyalty.deleteTier(user.organizationId, id);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Get('members')
|
||||
listMembers(@CurrentUser() user: Actor, @Query('q') q?: string) {
|
||||
return this.loyalty.listMembers(user, q);
|
||||
}
|
||||
|
||||
@Post('members/:personId/adjust')
|
||||
adjust(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('personId') personId: string,
|
||||
@Body() dto: AdjustLoyaltyPointsDto,
|
||||
) {
|
||||
return this.loyalty.adjustPoints(user, personId, dto.delta);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { LoyaltyController } from './loyalty.controller';
|
||||
import { LoyaltyService } from './loyalty.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [LoyaltyController],
|
||||
providers: [LoyaltyService],
|
||||
exports: [LoyaltyService],
|
||||
})
|
||||
export class LoyaltyModule {}
|
||||
@@ -0,0 +1,266 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
CreateLoyaltyTierRequest,
|
||||
LoyaltyMemberDto,
|
||||
LoyaltyTierDto,
|
||||
UpdateLoyaltyTierRequest,
|
||||
} from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { type Actor, ownerWhere } from '../common/record-scope';
|
||||
|
||||
const DEFAULT_TIERS = [
|
||||
{ name: 'برنزی', minPoints: 0, color: '#b87333' },
|
||||
{ name: 'نقرهای', minPoints: 500, color: '#9aa0a6' },
|
||||
{ name: 'طلایی', minPoints: 2000, color: '#d4af37' },
|
||||
] as const;
|
||||
|
||||
@Injectable()
|
||||
export class LoyaltyService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async ensureDefaultTiers(organizationId: string): Promise<void> {
|
||||
const count = await this.prisma.loyaltyTier.count({
|
||||
where: { organizationId },
|
||||
});
|
||||
if (count > 0) return;
|
||||
await this.prisma.loyaltyTier.createMany({
|
||||
data: DEFAULT_TIERS.map((t, position) => ({
|
||||
organizationId,
|
||||
name: t.name,
|
||||
minPoints: t.minPoints,
|
||||
color: t.color,
|
||||
position,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
async listTiers(organizationId: string): Promise<LoyaltyTierDto[]> {
|
||||
await this.ensureDefaultTiers(organizationId);
|
||||
const rows = await this.prisma.loyaltyTier.findMany({
|
||||
where: { organizationId },
|
||||
orderBy: [{ minPoints: 'asc' }, { position: 'asc' }],
|
||||
include: { _count: { select: { people: true } } },
|
||||
});
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
minPoints: r.minPoints,
|
||||
position: r.position,
|
||||
color: r.color,
|
||||
memberCount: r._count.people,
|
||||
}));
|
||||
}
|
||||
|
||||
async createTier(
|
||||
organizationId: string,
|
||||
dto: CreateLoyaltyTierRequest,
|
||||
): Promise<LoyaltyTierDto> {
|
||||
await this.ensureDefaultTiers(organizationId);
|
||||
const maxPos = await this.prisma.loyaltyTier.aggregate({
|
||||
where: { organizationId },
|
||||
_max: { position: true },
|
||||
});
|
||||
const row = await this.prisma.loyaltyTier.create({
|
||||
data: {
|
||||
organizationId,
|
||||
name: dto.name.trim(),
|
||||
minPoints: Math.max(0, Math.floor(dto.minPoints)),
|
||||
color: emptyToNull(dto.color),
|
||||
position: (maxPos._max.position ?? -1) + 1,
|
||||
},
|
||||
});
|
||||
await this.recomputeAllTiers(organizationId);
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
minPoints: row.minPoints,
|
||||
position: row.position,
|
||||
color: row.color,
|
||||
memberCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async updateTier(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
dto: UpdateLoyaltyTierRequest,
|
||||
): Promise<LoyaltyTierDto> {
|
||||
const existing = await this.prisma.loyaltyTier.findFirst({
|
||||
where: { id, organizationId },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('سطح پیدا نشد');
|
||||
|
||||
const row = await this.prisma.loyaltyTier.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
|
||||
...(dto.minPoints !== undefined
|
||||
? { minPoints: Math.max(0, Math.floor(dto.minPoints)) }
|
||||
: {}),
|
||||
...('color' in dto ? { color: emptyToNull(dto.color ?? null) } : {}),
|
||||
},
|
||||
include: { _count: { select: { people: true } } },
|
||||
});
|
||||
await this.recomputeAllTiers(organizationId);
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
minPoints: row.minPoints,
|
||||
position: row.position,
|
||||
color: row.color,
|
||||
memberCount: row._count.people,
|
||||
};
|
||||
}
|
||||
|
||||
async deleteTier(organizationId: string, id: string): Promise<void> {
|
||||
const existing = await this.prisma.loyaltyTier.findFirst({
|
||||
where: { id, organizationId },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('سطح پیدا نشد');
|
||||
await this.prisma.person.updateMany({
|
||||
where: { organizationId, loyaltyTierId: id },
|
||||
data: { loyaltyTierId: null },
|
||||
});
|
||||
await this.prisma.loyaltyTier.delete({ where: { id } });
|
||||
await this.recomputeAllTiers(organizationId);
|
||||
}
|
||||
|
||||
async listMembers(
|
||||
actor: Actor,
|
||||
q?: string,
|
||||
): Promise<LoyaltyMemberDto[]> {
|
||||
await this.ensureDefaultTiers(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' } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
include: {
|
||||
company: { select: { id: true, name: true } },
|
||||
loyaltyTier: {
|
||||
select: { id: true, name: true, minPoints: true, color: true },
|
||||
},
|
||||
},
|
||||
orderBy: [{ loyaltyPoints: 'desc' }, { lastName: 'asc' }],
|
||||
take: 200,
|
||||
});
|
||||
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,
|
||||
}));
|
||||
}
|
||||
|
||||
async adjustPoints(
|
||||
actor: Actor,
|
||||
personId: string,
|
||||
delta: number,
|
||||
): Promise<LoyaltyMemberDto> {
|
||||
if (!Number.isFinite(delta) || delta === 0) {
|
||||
throw new BadRequestException('مقدار امتیاز نامعتبر است');
|
||||
}
|
||||
const person = await this.prisma.person.findFirst({
|
||||
where: {
|
||||
id: personId,
|
||||
organizationId: actor.organizationId,
|
||||
...ownerWhere(actor),
|
||||
},
|
||||
});
|
||||
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 },
|
||||
},
|
||||
},
|
||||
});
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/** برای اتوماسیون — بدون ACL مالکیت */
|
||||
async addPointsByOrg(
|
||||
organizationId: string,
|
||||
personId: string,
|
||||
points: number,
|
||||
): 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 },
|
||||
});
|
||||
}
|
||||
|
||||
async resolveTierId(
|
||||
organizationId: string,
|
||||
points: number,
|
||||
): Promise<string | null> {
|
||||
await this.ensureDefaultTiers(organizationId);
|
||||
const tier = await this.prisma.loyaltyTier.findFirst({
|
||||
where: { organizationId, minPoints: { lte: points } },
|
||||
orderBy: { minPoints: 'desc' },
|
||||
});
|
||||
return tier?.id ?? null;
|
||||
}
|
||||
|
||||
private async recomputeAllTiers(organizationId: string) {
|
||||
const people = await this.prisma.person.findMany({
|
||||
where: { organizationId },
|
||||
select: { id: true, loyaltyPoints: true },
|
||||
});
|
||||
const tiers = await this.prisma.loyaltyTier.findMany({
|
||||
where: { organizationId },
|
||||
orderBy: { minPoints: 'desc' },
|
||||
});
|
||||
for (const p of people) {
|
||||
const tier = tiers.find((t) => t.minPoints <= p.loyaltyPoints) ?? null;
|
||||
await this.prisma.person.update({
|
||||
where: { id: p.id },
|
||||
data: { loyaltyTierId: tier?.id ?? null },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function emptyToNull(v?: string | null) {
|
||||
if (v == null) return null;
|
||||
const t = v.trim();
|
||||
return t ? t : null;
|
||||
}
|
||||
Reference in New Issue
Block a user