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:
2026-08-30 09:42:07 +03:30
co-authored by Cursor
parent 2e6c4779ab
commit c2bfa7749d
29 changed files with 3126 additions and 9 deletions
+60
View File
@@ -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);
}
}
+12
View File
@@ -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 {}
+266
View File
@@ -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;
}