499 lines
18 KiB
TypeScript
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,
|
|
})),
|
|
}),
|
|
]);
|
|
}
|
|
}
|