Files
CRM_Montzar/apps/api/src/routines/routines.service.ts
T

499 lines
18 KiB
TypeScript

import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
RoutineCategoryDto,
RoutineContextDto,
RoutineDto,
} from '@mento/shared';
import type { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope';
import { elevated } from '../common/record-scope';
import {
ChangeRoutineAssigneeDto,
ListRoutinesQueryDto,
SaveRoutineCategoryDto,
SaveRoutineDto,
} from './dto';
import { RoutineGeneratorService } from './routine-generator.service';
import { routineInclude, toCategoryDto, toRoutineDto } from './routine-mapper';
import {
type RoutineScope,
assertManager,
canManage,
resolveRoutineScope,
} from './routine-scope';
import { addDays, dayKeyToDate, tehranDateTime, tehranDayKey } from './routine-dates';
const DEFAULT_CATEGORIES: Array<{ name: string; color: string }> = [
{ name: 'فروش', color: '#14b8a6' },
{ name: 'پیگیری', color: '#3b82f6' },
{ name: 'لید', color: '#8b5cf6' },
{ name: 'مشتری', color: '#f59e0b' },
{ name: 'گزارش‌دهی', color: '#f43f5e' },
];
@Injectable()
export class RoutinesService {
constructor(
private readonly prisma: PrismaService,
private readonly generator: RoutineGeneratorService,
) {}
scope(actor: Actor): Promise<RoutineScope> {
return resolveRoutineScope(this.prisma, actor);
}
/* ───────── دسته‌بندی‌ها ───────── */
async categories(organizationId: string): Promise<RoutineCategoryDto[]> {
let rows = await this.prisma.routineCategory.findMany({
where: { organizationId },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
});
if (!rows.length) {
await this.prisma.routineCategory.createMany({
data: DEFAULT_CATEGORIES.map((c, i) => ({ organizationId, ...c, sortOrder: i })),
skipDuplicates: true,
});
rows = await this.prisma.routineCategory.findMany({
where: { organizationId },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
});
}
return rows.map(toCategoryDto);
}
async createCategory(actor: Actor, dto: SaveRoutineCategoryDto) {
assertManager(await this.scope(actor));
try {
const row = await this.prisma.routineCategory.create({
data: {
organizationId: actor.organizationId,
name: dto.name.trim(),
color: dto.color || '#14b8a6',
sortOrder: dto.sortOrder ?? 99,
},
});
return toCategoryDto(row);
} catch {
throw new BadRequestException('دسته‌بندی با این نام وجود دارد');
}
}
async updateCategory(actor: Actor, id: string, dto: SaveRoutineCategoryDto) {
assertManager(await this.scope(actor));
await this.findCategory(actor.organizationId, id);
const row = await this.prisma.routineCategory.update({
where: { id },
data: {
name: dto.name.trim(),
...(dto.color ? { color: dto.color } : {}),
...(dto.sortOrder != null ? { sortOrder: dto.sortOrder } : {}),
},
});
return toCategoryDto(row);
}
async deleteCategory(actor: Actor, id: string) {
assertManager(await this.scope(actor));
await this.findCategory(actor.organizationId, id);
await this.prisma.routineCategory.delete({ where: { id } });
return { ok: true };
}
private async findCategory(organizationId: string, id: string) {
const row = await this.prisma.routineCategory.findFirst({ where: { id, organizationId } });
if (!row) throw new NotFoundException('دسته‌بندی پیدا نشد');
return row;
}
/* ───────── زمینه (کاربران، تیم‌ها) ───────── */
async context(actor: Actor): Promise<RoutineContextDto> {
const scope = await this.scope(actor);
const ids = scope.isManager ? scope.manageableUserIds : scope.viewableUserIds;
const [members, teams, categories] = await Promise.all([
this.prisma.membership.findMany({
where: {
organizationId: actor.organizationId,
isActive: true,
user: { isActive: true },
...(ids ? { userId: { in: ids } } : {}),
},
select: { user: { select: { id: true, fullName: true } } },
orderBy: { user: { fullName: 'asc' } },
}),
scope.isManager
? this.prisma.team.findMany({
where: {
organizationId: actor.organizationId,
...(elevated(actor.role)
? {}
: { members: { some: { userId: actor.userId, role: 'manager' } } }),
},
select: { id: true, name: true },
orderBy: { name: 'asc' },
})
: Promise.resolve([]),
this.categories(actor.organizationId),
]);
return {
isManager: scope.isManager,
users: members.map((m) => m.user),
teams,
categories,
};
}
/* ───────── روتین‌ها ───────── */
async list(actor: Actor, query: ListRoutinesQueryDto): Promise<RoutineDto[]> {
const scope = await this.scope(actor);
const where: Prisma.RoutineWhereInput = {
organizationId: actor.organizationId,
deletedAt: null,
};
if (query.scope === 'all') {
assertManager(scope);
if (scope.manageableUserIds) {
where.OR = [
{ assigneeId: { in: scope.manageableUserIds } },
{ team: { members: { some: { userId: actor.userId, role: 'manager' } } } },
];
}
} else {
where.OR = [
{ assigneeId: actor.userId },
{ assigneeId: null, team: { members: { some: { userId: actor.userId } } } },
];
where.isActive = true;
}
if (query.q?.trim()) {
where.title = { contains: query.q.trim(), mode: 'insensitive' };
}
const rows = await this.prisma.routine.findMany({
where,
include: routineInclude,
orderBy: [{ isActive: 'desc' }, { timeOfDay: 'asc' }, { createdAt: 'desc' }],
});
const execution = await this.executionMap(
rows.map((r) => r.id),
query.scope === 'all' ? undefined : actor.userId,
);
return rows.map((r) => toRoutineDto(r, execution.get(r.id)));
}
async get(actor: Actor, id: string): Promise<RoutineDto> {
const scope = await this.scope(actor);
const row = await this.findRoutine(actor.organizationId, id);
const isOwnRoutine = row.assigneeId === actor.userId;
if (!isOwnRoutine) assertManager(scope);
const execution = await this.executionMap([row.id]);
return toRoutineDto(row, execution.get(row.id));
}
async create(actor: Actor, dto: SaveRoutineDto): Promise<RoutineDto> {
const scope = await this.scope(actor);
assertManager(scope);
const data = await this.buildData(actor, scope, dto);
const row = await this.prisma.routine.create({
data: {
...data,
organizationId: actor.organizationId,
createdById: actor.userId,
} as Prisma.RoutineUncheckedCreateInput,
include: routineInclude,
});
await this.generator.generateForDay(tehranDayKey(), { routineId: row.id });
return toRoutineDto(row);
}
async update(actor: Actor, id: string, dto: SaveRoutineDto): Promise<RoutineDto> {
const scope = await this.scope(actor);
assertManager(scope);
const existing = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, existing);
const data = await this.buildData(actor, scope, dto);
const row = await this.prisma.routine.update({
where: { id },
data: data as Prisma.RoutineUncheckedUpdateInput,
include: routineInclude,
});
await this.syncTodayTasks(row.id);
const execution = await this.executionMap([row.id]);
return toRoutineDto(row, execution.get(row.id));
}
async toggle(actor: Actor, id: string, isActive: boolean): Promise<RoutineDto> {
const scope = await this.scope(actor);
assertManager(scope);
const existing = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, existing);
const row = await this.prisma.routine.update({
where: { id },
data: { isActive },
include: routineInclude,
});
await this.syncTodayTasks(row.id);
const execution = await this.executionMap([row.id]);
return toRoutineDto(row, execution.get(row.id));
}
async changeAssignee(
actor: Actor,
id: string,
dto: ChangeRoutineAssigneeDto,
): Promise<RoutineDto> {
const scope = await this.scope(actor);
assertManager(scope);
const existing = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, existing);
const target = await this.resolveTarget(actor, scope, dto.assigneeId, dto.teamId);
await this.cancelTodayPending(id, 'تغییر مسئول روتین');
const row = await this.prisma.routine.update({
where: { id },
data: target,
include: routineInclude,
});
await this.generator.generateForDay(tehranDayKey(), { routineId: id });
const execution = await this.executionMap([row.id]);
return toRoutineDto(row, execution.get(row.id));
}
async duplicate(actor: Actor, id: string): Promise<RoutineDto> {
const scope = await this.scope(actor);
assertManager(scope);
const src = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, src);
const {
id: _id,
createdAt: _c,
updatedAt: _u,
category: _cat,
assignee: _a,
team: _t,
deletedAt: _d,
...rest
} = src;
const row = await this.prisma.routine.create({
data: {
...rest,
title: `${src.title} (کپی)`.slice(0, 200),
isActive: false,
createdById: actor.userId,
},
include: routineInclude,
});
return toRoutineDto(row);
}
async remove(actor: Actor, id: string) {
const scope = await this.scope(actor);
assertManager(scope);
const existing = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, existing);
await this.prisma.routine.update({
where: { id },
data: { deletedAt: new Date(), isActive: false },
});
await this.cancelTodayPending(id, 'حذف روتین');
return { ok: true };
}
/* ───────── کمکی ───────── */
/** درصد اجرای روتین در ۳۰ روز اخیر (Taskهای در انتظار امروز حساب نمی‌شوند) */
async executionMap(
routineIds: string[],
assigneeId?: string,
): Promise<Map<string, { total: number; done: number }>> {
const map = new Map<string, { total: number; done: number }>();
if (!routineIds.length) return map;
const today = tehranDayKey();
const groups = await this.prisma.routineTask.groupBy({
by: ['routineId', 'status'],
where: {
routineId: { in: routineIds },
scheduledFor: { gte: dayKeyToDate(addDays(today, -29)), lte: dayKeyToDate(today) },
status: { in: ['completed', 'overdue', 'skipped'] },
...(assigneeId ? { assigneeId } : {}),
},
_count: { _all: true },
});
for (const g of groups) {
if (!g.routineId) continue;
const cur = map.get(g.routineId) ?? { total: 0, done: 0 };
cur.total += g._count._all;
if (g.status === 'completed') cur.done += g._count._all;
map.set(g.routineId, cur);
}
return map;
}
private async findRoutine(organizationId: string, id: string) {
const row = await this.prisma.routine.findFirst({
where: { id, organizationId, deletedAt: null },
include: routineInclude,
});
if (!row) throw new NotFoundException('روتین پیدا نشد');
return row;
}
private assertCanManageRoutine(
scope: RoutineScope,
row: { assigneeId: string | null; teamId: string | null; createdById: string | null },
) {
if (scope.manageableUserIds === null) return;
if (row.assigneeId && canManage(scope, row.assigneeId)) return;
if (row.createdById === scope.userId) return;
throw new ForbiddenException('اجازه مدیریت این روتین را ندارید');
}
private async resolveTarget(
actor: Actor,
scope: RoutineScope,
assigneeId?: string | null,
teamId?: string | null,
): Promise<{ assigneeId: string | null; teamId: string | null }> {
if (assigneeId) {
if (!canManage(scope, assigneeId)) {
throw new ForbiddenException('اجازه تعریف روتین برای این کاربر را ندارید');
}
const member = await this.prisma.membership.findFirst({
where: { organizationId: actor.organizationId, userId: assigneeId, isActive: true },
select: { id: true },
});
if (!member) throw new BadRequestException('مسئول انتخاب‌شده عضو سازمان نیست');
return { assigneeId, teamId: null };
}
if (teamId) {
const team = await this.prisma.team.findFirst({
where: {
id: teamId,
organizationId: actor.organizationId,
...(elevated(actor.role)
? {}
: { members: { some: { userId: actor.userId, role: 'manager' } } }),
},
select: { id: true },
});
if (!team) throw new BadRequestException('تیم انتخاب‌شده معتبر نیست');
return { assigneeId: null, teamId };
}
throw new BadRequestException('مسئول یا تیم روتین را انتخاب کنید');
}
private async buildData(actor: Actor, scope: RoutineScope, dto: SaveRoutineDto) {
const target = await this.resolveTarget(actor, scope, dto.assigneeId, dto.teamId);
if (dto.categoryId) await this.findCategory(actor.organizationId, dto.categoryId);
if (dto.endDate && dto.endDate < dto.startDate) {
throw new BadRequestException('تاریخ پایان نباید قبل از تاریخ شروع باشد');
}
const weekdays = [...new Set(dto.weekdays ?? [])].sort((a, b) => a - b);
const specificDates = [...new Set(dto.specificDates ?? [])].sort();
if (['weekdays', 'biweekly'].includes(dto.recurrence) && !weekdays.length) {
throw new BadRequestException('حداقل یک روز هفته را انتخاب کنید');
}
if (dto.recurrence === 'every_n_days' && !dto.intervalDays) {
throw new BadRequestException('فاصله تکرار (روز) را وارد کنید');
}
if (dto.recurrence === 'specific_dates' && !specificDates.length) {
throw new BadRequestException('حداقل یک تاریخ انتخاب کنید');
}
return {
title: dto.title.trim(),
description: dto.description?.trim() || null,
...target,
categoryId: dto.categoryId || null,
priority: dto.priority ?? 'medium',
timeOfDay: dto.timeOfDay,
durationMinutes: dto.durationMinutes ?? null,
startDate: dayKeyToDate(dto.startDate),
endDate: dto.endDate ? dayKeyToDate(dto.endDate) : null,
recurrence: dto.recurrence,
weekdays,
intervalDays: dto.recurrence === 'every_n_days' ? dto.intervalDays ?? 1 : null,
monthDay: dto.recurrence === 'monthly' ? dto.monthDay ?? null : null,
specificDates: dto.recurrence === 'specific_dates' ? specificDates.map(dayKeyToDate) : [],
isMandatory: dto.isMandatory ?? true,
allowSkip: dto.allowSkip ?? false,
skipReasonRequired: dto.skipReasonRequired ?? true,
reportRequired: dto.reportRequired ?? false,
allowAfterDeadline: dto.allowAfterDeadline ?? true,
carryOverOverdue: dto.carryOverOverdue ?? false,
entityLink: dto.entityLink ?? 'none',
reminderMinutesBefore: dto.reminderMinutesBefore ?? null,
isActive: dto.isActive ?? true,
};
}
/** همگام کردن Task امروزِ هنوز شروع‌نشده با تغییرات روتین */
private async syncTodayTasks(routineId: string) {
const routine = await this.prisma.routine.findUnique({ where: { id: routineId } });
if (!routine) return;
if (!routine.isActive || routine.deletedAt) {
await this.cancelTodayPending(routineId, 'غیرفعال شدن روتین');
return;
}
const today = tehranDayKey();
const pending = await this.prisma.routineTask.findMany({
where: { routineId, scheduledFor: dayKeyToDate(today), status: 'todo' },
select: { id: true },
});
if (pending.length) {
const dueAt = tehranDateTime(today, routine.timeOfDay);
await this.prisma.routineTask.updateMany({
where: { id: { in: pending.map((p) => p.id) } },
data: {
title: routine.title,
description: routine.description,
categoryId: routine.categoryId,
priority: routine.priority,
isMandatory: routine.isMandatory,
dueAt,
...(routine.reminderMinutesBefore != null
? {
reminderAt: new Date(dueAt.getTime() - routine.reminderMinutesBefore * 60_000),
reminderSentAt: null,
}
: {}),
},
});
}
await this.generator.generateForDay(today, { routineId });
}
private async cancelTodayPending(routineId: string, note: string) {
const rows = await this.prisma.routineTask.findMany({
where: {
routineId,
scheduledFor: dayKeyToDate(tehranDayKey()),
status: { in: ['todo', 'in_progress'] },
},
select: { id: true, organizationId: true, status: true },
});
if (!rows.length) return;
await this.prisma.$transaction([
this.prisma.routineTask.updateMany({
where: { id: { in: rows.map((r) => r.id) } },
data: { status: 'cancelled' },
}),
this.prisma.routineTaskEvent.createMany({
data: rows.map((r) => ({
organizationId: r.organizationId,
taskId: r.id,
type: 'status_changed',
fromStatus: r.status,
toStatus: 'cancelled' as const,
note,
})),
}),
]);
}
}