Customer-linked tasks stay on their record and no longer appear in today, calendar, score, or reminders. Co-authored-by: Cursor <cursoragent@cursor.com>
621 lines
23 KiB
TypeScript
621 lines
23 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
ForbiddenException,
|
|
Injectable,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import type {
|
|
RoutineCalendarDto,
|
|
RoutineTaskDetailDto,
|
|
RoutineTaskDto,
|
|
RoutineTaskSource,
|
|
RoutineTaskStatus,
|
|
RoutineTodayDto,
|
|
RoutineUserSettingsDto,
|
|
} from '@mento/shared';
|
|
import { Prisma } from '@prisma/client';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { NotificationsService } from '../notifications/notifications.service';
|
|
import { type Actor, routineTaskAccessWhere } from '../common/record-scope';
|
|
import type {
|
|
CreateRoutineTaskDto,
|
|
RoutineEntityQueryDto,
|
|
RoutineRangeQueryDto,
|
|
RoutineUserSettingsUpdateDto,
|
|
UpdateRoutineTaskDto,
|
|
} from './dto';
|
|
import {
|
|
type RoutineScope,
|
|
assigneeFilter,
|
|
canManage,
|
|
canView,
|
|
resolveRoutineScope,
|
|
} from './routine-scope';
|
|
import {
|
|
type RoutineTaskRow,
|
|
routineTaskInclude,
|
|
toEventDto,
|
|
toTaskDto,
|
|
} from './routine-mapper';
|
|
import { DEFAULT_REMIND_MINUTES } from './routine-generator.service';
|
|
import { RoutineStatsService, effectiveTotal } from './routine-stats.service';
|
|
import { GamificationService } from '../gamification/gamification.service';
|
|
import {
|
|
addDays,
|
|
dayKeyToDate,
|
|
isDayKey,
|
|
tehranDateTime,
|
|
tehranDayKey,
|
|
} from './routine-dates';
|
|
import { deskRoutineTaskWhere, internalRoutineTaskWhere } from './internal-routine';
|
|
|
|
const OPEN_STATUSES: RoutineTaskStatus[] = ['todo', 'in_progress', 'overdue'];
|
|
|
|
@Injectable()
|
|
export class RoutineTasksService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly notifications: NotificationsService,
|
|
private readonly stats: RoutineStatsService,
|
|
private readonly game: GamificationService,
|
|
) {}
|
|
|
|
scope(actor: Actor): Promise<RoutineScope> {
|
|
return resolveRoutineScope(this.prisma, actor);
|
|
}
|
|
|
|
private deepWhere(actor: Actor): Prisma.RoutineTaskWhereInput {
|
|
return routineTaskAccessWhere(actor);
|
|
}
|
|
|
|
/* ───────── خواندن ───────── */
|
|
|
|
async today(actor: Actor, userId?: string, date?: string): Promise<RoutineTodayDto> {
|
|
const scope = await this.scope(actor);
|
|
const target = userId || actor.userId;
|
|
if (!canView(scope, target)) throw new ForbiddenException('دسترسی ندارید');
|
|
const day = date && isDayKey(date) ? date : tehranDayKey();
|
|
const dayDate = dayKeyToDate(day);
|
|
const deep = this.deepWhere(actor);
|
|
|
|
const [todayRows, overdueRows, score] = await Promise.all([
|
|
this.prisma.routineTask.findMany({
|
|
where: {
|
|
organizationId: actor.organizationId,
|
|
assigneeId: target,
|
|
scheduledFor: dayDate,
|
|
status: { not: 'cancelled' },
|
|
...internalRoutineTaskWhere,
|
|
...deep,
|
|
},
|
|
include: routineTaskInclude,
|
|
orderBy: [{ dueAt: 'asc' }, { createdAt: 'asc' }],
|
|
}),
|
|
this.prisma.routineTask.findMany({
|
|
where: {
|
|
organizationId: actor.organizationId,
|
|
assigneeId: target,
|
|
status: 'overdue',
|
|
scheduledFor: { lt: dayDate },
|
|
...internalRoutineTaskWhere,
|
|
...deep,
|
|
},
|
|
include: { ...routineTaskInclude, routine: { select: { ...routineTaskInclude.routine.select, carryOverOverdue: true } } },
|
|
orderBy: [{ dueAt: 'asc' }],
|
|
take: 100,
|
|
}),
|
|
this.stats.score(actor.organizationId, target, day),
|
|
]);
|
|
|
|
const carried = overdueRows.filter((r) => r.routine?.carryOverOverdue);
|
|
const overdue = overdueRows.filter((r) => !r.routine?.carryOverOverdue);
|
|
|
|
const counts = { total: 0, completed: 0, overdue: 0, skipped: 0, cancelled: 0, pending: 0 };
|
|
for (const r of todayRows) {
|
|
counts.total += 1;
|
|
if (r.status === 'completed') counts.completed += 1;
|
|
else if (r.status === 'skipped') counts.skipped += 1;
|
|
else if (r.status === 'overdue') counts.overdue += 1;
|
|
else counts.pending += 1;
|
|
}
|
|
const eff = effectiveTotal(counts);
|
|
return {
|
|
date: day,
|
|
tasks: [...todayRows, ...carried].map((r) => toTaskDto(r as RoutineTaskRow)),
|
|
overdue: overdue.map((r) => toTaskDto(r as RoutineTaskRow)),
|
|
stats: {
|
|
total: eff,
|
|
completed: counts.completed,
|
|
remaining: counts.pending,
|
|
overdue: counts.overdue + overdueRows.length,
|
|
percent: eff > 0 ? Math.round((counts.completed / eff) * 100) : null,
|
|
},
|
|
score,
|
|
};
|
|
}
|
|
|
|
/** شمارش کارهای باقیمانده امروز برای Badge منو */
|
|
async badge(actor: Actor): Promise<{ remaining: number; overdue: number }> {
|
|
const day = dayKeyToDate(tehranDayKey());
|
|
const [remaining, overdue] = await Promise.all([
|
|
this.prisma.routineTask.count({
|
|
where: {
|
|
organizationId: actor.organizationId,
|
|
assigneeId: actor.userId,
|
|
scheduledFor: day,
|
|
status: { in: ['todo', 'in_progress'] },
|
|
...internalRoutineTaskWhere,
|
|
},
|
|
}),
|
|
this.prisma.routineTask.count({
|
|
where: {
|
|
organizationId: actor.organizationId,
|
|
assigneeId: actor.userId,
|
|
status: 'overdue',
|
|
...internalRoutineTaskWhere,
|
|
},
|
|
}),
|
|
]);
|
|
return { remaining, overdue };
|
|
}
|
|
|
|
async overdue(actor: Actor, userId?: string): Promise<RoutineTaskDto[]> {
|
|
const scope = await this.scope(actor);
|
|
const filter =
|
|
userId === 'all' ? assigneeFilter(scope) : assigneeFilter(scope, userId || actor.userId);
|
|
const rows = await this.prisma.routineTask.findMany({
|
|
where: {
|
|
organizationId: actor.organizationId,
|
|
status: 'overdue',
|
|
...internalRoutineTaskWhere,
|
|
...filter,
|
|
...this.deepWhere(actor),
|
|
},
|
|
include: routineTaskInclude,
|
|
orderBy: [{ dueAt: 'asc' }],
|
|
take: 300,
|
|
});
|
|
return rows.map(toTaskDto);
|
|
}
|
|
|
|
async calendar(actor: Actor, q: RoutineRangeQueryDto): Promise<RoutineCalendarDto> {
|
|
const scope = await this.scope(actor);
|
|
const to = q.to || tehranDayKey();
|
|
let from = q.from || addDays(to, -30);
|
|
if (from > to) from = to;
|
|
const filter = assigneeFilter(scope, q.userId || actor.userId);
|
|
const where: Prisma.RoutineTaskWhereInput = {
|
|
organizationId: actor.organizationId,
|
|
scheduledFor: { gte: dayKeyToDate(from), lte: dayKeyToDate(to) },
|
|
...filter,
|
|
...this.deepWhere(actor),
|
|
...deskRoutineTaskWhere(q.source),
|
|
...(q.categoryId ? { categoryId: q.categoryId } : {}),
|
|
...(q.status ? { status: q.status } : {}),
|
|
};
|
|
const rows = await this.prisma.routineTask.findMany({
|
|
where,
|
|
include: routineTaskInclude,
|
|
orderBy: [{ scheduledFor: 'asc' }, { dueAt: 'asc' }],
|
|
take: 3000,
|
|
});
|
|
return {
|
|
days: this.stats.toCalendarDays(rows, from, to),
|
|
tasks: rows.map(toTaskDto),
|
|
};
|
|
}
|
|
|
|
async byEntity(actor: Actor, q: RoutineEntityQueryDto): Promise<RoutineTaskDto[]> {
|
|
const or: Prisma.RoutineTaskWhereInput[] = [];
|
|
if (q.leadId) or.push({ leadId: q.leadId });
|
|
if (q.personId) or.push({ personId: q.personId });
|
|
if (q.companyId) or.push({ companyId: q.companyId });
|
|
if (q.dealId) or.push({ dealId: q.dealId });
|
|
if (!or.length) return [];
|
|
const scope = await this.scope(actor);
|
|
const rows = await this.prisma.routineTask.findMany({
|
|
where: {
|
|
organizationId: actor.organizationId,
|
|
OR: or,
|
|
status: { not: 'cancelled' },
|
|
...assigneeFilter(scope),
|
|
...this.deepWhere(actor),
|
|
},
|
|
include: routineTaskInclude,
|
|
orderBy: [{ scheduledFor: 'desc' }, { dueAt: 'desc' }],
|
|
take: 100,
|
|
});
|
|
return rows.map(toTaskDto);
|
|
}
|
|
|
|
async detail(actor: Actor, id: string): Promise<RoutineTaskDetailDto> {
|
|
const scope = await this.scope(actor);
|
|
const row = await this.findTask(actor.organizationId, id);
|
|
if (!canView(scope, row.assigneeId)) throw new ForbiddenException('دسترسی ندارید');
|
|
const deep = this.deepWhere(actor);
|
|
if (Object.keys(deep).length) {
|
|
const allowed = await this.prisma.routineTask.findFirst({
|
|
where: { id, organizationId: actor.organizationId, ...deep },
|
|
select: { id: true },
|
|
});
|
|
if (!allowed) throw new ForbiddenException('دسترسی ندارید');
|
|
}
|
|
const events = await this.prisma.routineTaskEvent.findMany({
|
|
where: { taskId: id },
|
|
include: { actor: { select: { id: true, fullName: true } } },
|
|
orderBy: { createdAt: 'asc' },
|
|
});
|
|
return { ...toTaskDto(row), events: events.map(toEventDto) };
|
|
}
|
|
|
|
/* ───────── ایجاد / ویرایش ───────── */
|
|
|
|
async create(actor: Actor, dto: CreateRoutineTaskDto): Promise<RoutineTaskDto> {
|
|
const scope = await this.scope(actor);
|
|
const assigneeId = dto.assigneeId || actor.userId;
|
|
if (assigneeId !== actor.userId && !canManage(scope, assigneeId)) {
|
|
throw new ForbiddenException('اجازه ثبت کار برای این کاربر را ندارید');
|
|
}
|
|
await this.assertMember(actor.organizationId, assigneeId);
|
|
await this.assertRefs(actor.organizationId, dto);
|
|
|
|
const { scheduledFor, dueAt } = this.resolveWhen(dto.dueAt, dto.scheduledFor);
|
|
const source: RoutineTaskSource =
|
|
dto.source ??
|
|
(dto.activityId
|
|
? 'follow_up'
|
|
: dto.leadId
|
|
? 'lead'
|
|
: dto.personId || dto.companyId
|
|
? 'customer'
|
|
: dto.dealId
|
|
? 'deal'
|
|
: 'manual');
|
|
const reminderAt = dto.dueAt ? await this.defaultReminder(actor.organizationId, assigneeId, dueAt) : null;
|
|
|
|
const row = await this.prisma.routineTask.create({
|
|
data: {
|
|
organizationId: actor.organizationId,
|
|
assigneeId,
|
|
title: dto.title.trim(),
|
|
description: dto.description?.trim() || null,
|
|
priority: dto.priority ?? 'medium',
|
|
categoryId: dto.categoryId || null,
|
|
isMandatory: dto.isMandatory ?? false,
|
|
source,
|
|
scheduledFor: dayKeyToDate(scheduledFor),
|
|
dueAt,
|
|
reminderAt,
|
|
status: dueAt.getTime() < Date.now() ? 'overdue' : 'todo',
|
|
leadId: dto.leadId || null,
|
|
personId: dto.personId || null,
|
|
companyId: dto.companyId || null,
|
|
dealId: dto.dealId || null,
|
|
invoiceId: dto.invoiceId || null,
|
|
activityId: dto.activityId || null,
|
|
createdById: actor.userId,
|
|
events: {
|
|
create: {
|
|
organizationId: actor.organizationId,
|
|
actorId: actor.userId,
|
|
type: 'created',
|
|
toStatus: 'todo',
|
|
},
|
|
},
|
|
},
|
|
include: routineTaskInclude,
|
|
});
|
|
|
|
if (assigneeId !== actor.userId) {
|
|
await this.notifications.create({
|
|
organizationId: actor.organizationId,
|
|
userId: assigneeId,
|
|
type: 'routine_task_assigned',
|
|
title: 'کار جدید برای شما ثبت شد',
|
|
body: row.title,
|
|
link: `/routines?task=${row.id}`,
|
|
entityType: 'routine_task',
|
|
entityId: row.id,
|
|
});
|
|
}
|
|
return toTaskDto(row);
|
|
}
|
|
|
|
async createFromActivity(_actor: Actor, _activityId: string): Promise<RoutineTaskDto> {
|
|
throw new BadRequestException(
|
|
'پیگیری مشتری در کارهای روتین ثبت نمیشود. کارهای روتین فقط برای کار داخلی تیم است.',
|
|
);
|
|
}
|
|
|
|
async update(actor: Actor, id: string, dto: UpdateRoutineTaskDto): Promise<RoutineTaskDto> {
|
|
const { scope, row } = await this.loadForEdit(actor, id);
|
|
await this.assertRefs(actor.organizationId, dto);
|
|
const data: Prisma.RoutineTaskUncheckedUpdateInput = {};
|
|
const events: Array<{ type: string; note?: string }> = [];
|
|
|
|
if (dto.title !== undefined) data.title = dto.title.trim();
|
|
if (dto.description !== undefined) data.description = dto.description?.trim() || null;
|
|
if (dto.priority) data.priority = dto.priority;
|
|
if (dto.categoryId !== undefined) data.categoryId = dto.categoryId || null;
|
|
if (dto.isMandatory !== undefined) data.isMandatory = dto.isMandatory;
|
|
for (const key of ['leadId', 'personId', 'companyId', 'dealId', 'invoiceId'] as const) {
|
|
if (dto[key] !== undefined) data[key] = dto[key] || null;
|
|
}
|
|
if (dto.assigneeId && dto.assigneeId !== row.assigneeId) {
|
|
if (!canManage(scope, dto.assigneeId)) {
|
|
throw new ForbiddenException('اجازه ارجاع به این کاربر را ندارید');
|
|
}
|
|
await this.assertMember(actor.organizationId, dto.assigneeId);
|
|
data.assigneeId = dto.assigneeId;
|
|
events.push({ type: 'reassigned' });
|
|
}
|
|
if (dto.dueAt !== undefined || dto.scheduledFor !== undefined) {
|
|
const when = this.resolveWhen(dto.dueAt, dto.scheduledFor);
|
|
data.scheduledFor = dayKeyToDate(when.scheduledFor);
|
|
data.dueAt = when.dueAt;
|
|
data.reminderAt = await this.defaultReminder(
|
|
actor.organizationId,
|
|
(data.assigneeId as string) ?? row.assigneeId,
|
|
when.dueAt,
|
|
);
|
|
data.reminderSentAt = null;
|
|
if (row.status === 'overdue' && when.dueAt.getTime() > Date.now()) data.status = 'todo';
|
|
events.push({ type: 'rescheduled' });
|
|
}
|
|
if (!events.length) events.push({ type: 'updated' });
|
|
|
|
const updated = await this.saveWithEvents(actor, row, data, events);
|
|
return toTaskDto(updated);
|
|
}
|
|
|
|
/* ───────── عملیات وضعیت ───────── */
|
|
|
|
async complete(actor: Actor, id: string, note?: string | null) {
|
|
const { row } = await this.loadForEdit(actor, id);
|
|
if (row.status === 'completed') {
|
|
if (!note?.trim()) return toTaskDto(row);
|
|
const updated = await this.saveWithEvents(actor, row, { resultNote: note.trim() }, [
|
|
{ type: 'note', note: note.trim() },
|
|
]);
|
|
return toTaskDto(updated);
|
|
}
|
|
const rules = toTaskDto(row).rules;
|
|
if (row.status === 'cancelled') throw new BadRequestException('کار لغو شده قابل انجام نیست');
|
|
if (row.status === 'overdue' && !rules.allowAfterDeadline) {
|
|
throw new BadRequestException('انجام این کار بعد از مهلت مجاز نیست');
|
|
}
|
|
if (rules.reportRequired && !note?.trim()) {
|
|
throw new BadRequestException('ثبت گزارش/نتیجه برای این کار الزامی است');
|
|
}
|
|
const updated = await this.saveWithEvents(
|
|
actor,
|
|
row,
|
|
{ status: 'completed', completedAt: new Date(), resultNote: note?.trim() || row.resultNote },
|
|
[{ type: 'status_changed', note: note?.trim() || undefined }],
|
|
);
|
|
this.game.emit({
|
|
organizationId: actor.organizationId,
|
|
userId: row.assigneeId,
|
|
event: row.routineId ? 'ROUTINE_COMPLETED' : 'TASK_COMPLETED',
|
|
entityType: 'routine_task',
|
|
entityId: row.id,
|
|
entityTitle: row.title,
|
|
facts: {
|
|
onTime: row.status !== 'overdue' && (!row.dueAt || Date.now() <= row.dueAt.getTime()),
|
|
isMandatory: row.isMandatory,
|
|
priority: row.priority,
|
|
},
|
|
});
|
|
return toTaskDto(updated);
|
|
}
|
|
|
|
async uncomplete(actor: Actor, id: string) {
|
|
const { row } = await this.loadForEdit(actor, id);
|
|
if (!['completed', 'skipped', 'cancelled'].includes(row.status)) return toTaskDto(row);
|
|
const status: RoutineTaskStatus =
|
|
row.dueAt && row.dueAt.getTime() < Date.now() ? 'overdue' : 'todo';
|
|
const updated = await this.saveWithEvents(
|
|
actor,
|
|
row,
|
|
{ status, completedAt: null, skipReason: null },
|
|
[{ type: 'status_changed' }],
|
|
);
|
|
return toTaskDto(updated);
|
|
}
|
|
|
|
async start(actor: Actor, id: string) {
|
|
const { row } = await this.loadForEdit(actor, id);
|
|
if (row.status !== 'todo') return toTaskDto(row);
|
|
const updated = await this.saveWithEvents(
|
|
actor,
|
|
row,
|
|
{ status: 'in_progress', startedAt: new Date() },
|
|
[{ type: 'status_changed' }],
|
|
);
|
|
return toTaskDto(updated);
|
|
}
|
|
|
|
async skip(actor: Actor, id: string, reason?: string | null) {
|
|
const { row } = await this.loadForEdit(actor, id);
|
|
const rules = toTaskDto(row).rules;
|
|
if (!OPEN_STATUSES.includes(row.status)) throw new BadRequestException('این کار باز نیست');
|
|
if (!rules.allowSkip) throw new BadRequestException('رد کردن این کار مجاز نیست');
|
|
if (rules.skipReasonRequired && !reason?.trim()) {
|
|
throw new BadRequestException('ثبت دلیل رد کردن الزامی است');
|
|
}
|
|
const updated = await this.saveWithEvents(
|
|
actor,
|
|
row,
|
|
{ status: 'skipped', skipReason: reason?.trim() || null },
|
|
[{ type: 'status_changed', note: reason?.trim() || undefined }],
|
|
);
|
|
return toTaskDto(updated);
|
|
}
|
|
|
|
async cancel(actor: Actor, id: string, reason?: string | null) {
|
|
const { row } = await this.loadForEdit(actor, id);
|
|
if (row.status === 'cancelled') return toTaskDto(row);
|
|
const updated = await this.saveWithEvents(actor, row, { status: 'cancelled' }, [
|
|
{ type: 'status_changed', note: reason?.trim() || undefined },
|
|
]);
|
|
return toTaskDto(updated);
|
|
}
|
|
|
|
async reschedule(actor: Actor, id: string, dueAtIso: string) {
|
|
return this.update(actor, id, { dueAt: dueAtIso });
|
|
}
|
|
|
|
async moveToToday(actor: Actor, id: string) {
|
|
const { row } = await this.loadForEdit(actor, id);
|
|
const today = tehranDayKey();
|
|
const time = row.dueAt
|
|
? new Intl.DateTimeFormat('en-GB', {
|
|
timeZone: 'Asia/Tehran',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hourCycle: 'h23',
|
|
}).format(row.dueAt)
|
|
: '23:59';
|
|
let dueAt = tehranDateTime(today, time);
|
|
if (dueAt.getTime() < Date.now()) dueAt = tehranDateTime(today, '23:59');
|
|
return this.update(actor, id, { dueAt: dueAt.toISOString() });
|
|
}
|
|
|
|
/* ───────── تنظیمات اعلان ───────── */
|
|
|
|
async getSettings(actor: Actor): Promise<RoutineUserSettingsDto> {
|
|
const row = await this.prisma.routineUserSettings.findUnique({
|
|
where: { organizationId_userId: { organizationId: actor.organizationId, userId: actor.userId } },
|
|
});
|
|
return {
|
|
remindBeforeMinutes: row ? row.remindBeforeMinutes : DEFAULT_REMIND_MINUTES,
|
|
endOfDaySummary: row?.endOfDaySummary ?? true,
|
|
overdueAlert: row?.overdueAlert ?? true,
|
|
summaryHour: row?.summaryHour ?? 16,
|
|
};
|
|
}
|
|
|
|
async updateSettings(actor: Actor, dto: RoutineUserSettingsUpdateDto) {
|
|
const data = {
|
|
...(dto.remindBeforeMinutes !== undefined ? { remindBeforeMinutes: dto.remindBeforeMinutes } : {}),
|
|
...(dto.endOfDaySummary !== undefined ? { endOfDaySummary: dto.endOfDaySummary } : {}),
|
|
...(dto.overdueAlert !== undefined ? { overdueAlert: dto.overdueAlert } : {}),
|
|
...(dto.summaryHour !== undefined ? { summaryHour: dto.summaryHour } : {}),
|
|
};
|
|
await this.prisma.routineUserSettings.upsert({
|
|
where: { organizationId_userId: { organizationId: actor.organizationId, userId: actor.userId } },
|
|
create: { organizationId: actor.organizationId, userId: actor.userId, ...data },
|
|
update: data,
|
|
});
|
|
return this.getSettings(actor);
|
|
}
|
|
|
|
/* ───────── کمکی ───────── */
|
|
|
|
private async findTask(organizationId: string, id: string): Promise<RoutineTaskRow> {
|
|
const row = await this.prisma.routineTask.findFirst({
|
|
where: { id, organizationId },
|
|
include: routineTaskInclude,
|
|
});
|
|
if (!row) throw new NotFoundException('کار پیدا نشد');
|
|
return row;
|
|
}
|
|
|
|
private async loadForEdit(actor: Actor, id: string) {
|
|
const scope = await this.scope(actor);
|
|
const row = await this.findTask(actor.organizationId, id);
|
|
if (row.assigneeId !== actor.userId && !canManage(scope, row.assigneeId)) {
|
|
throw new ForbiddenException('اجازه تغییر این کار را ندارید');
|
|
}
|
|
return { scope, row };
|
|
}
|
|
|
|
private async saveWithEvents(
|
|
actor: Actor,
|
|
row: RoutineTaskRow,
|
|
data: Prisma.RoutineTaskUncheckedUpdateInput,
|
|
events: Array<{ type: string; note?: string }>,
|
|
): Promise<RoutineTaskRow> {
|
|
const nextStatus = (data.status as RoutineTaskStatus | undefined) ?? row.status;
|
|
try {
|
|
const [updated] = await this.prisma.$transaction([
|
|
this.prisma.routineTask.update({
|
|
where: { id: row.id },
|
|
data,
|
|
include: routineTaskInclude,
|
|
}),
|
|
this.prisma.routineTaskEvent.createMany({
|
|
data: events.map((e) => ({
|
|
organizationId: actor.organizationId,
|
|
taskId: row.id,
|
|
actorId: actor.userId,
|
|
type: e.type,
|
|
fromStatus: nextStatus !== row.status ? row.status : null,
|
|
toStatus: nextStatus !== row.status ? nextStatus : null,
|
|
note: e.note ?? null,
|
|
})),
|
|
}),
|
|
]);
|
|
return updated;
|
|
} catch (err) {
|
|
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
|
|
throw new BadRequestException('این روتین برای آن روز کار دیگری دارد');
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
private resolveWhen(dueAtIso?: string | null, scheduledFor?: string | null) {
|
|
if (dueAtIso) {
|
|
const dueAt = new Date(dueAtIso);
|
|
if (Number.isNaN(dueAt.getTime())) throw new BadRequestException('زمان نامعتبر است');
|
|
return { dueAt, scheduledFor: tehranDayKey(dueAt) };
|
|
}
|
|
const day = scheduledFor && isDayKey(scheduledFor) ? scheduledFor : tehranDayKey();
|
|
return { dueAt: tehranDateTime(day, '23:59'), scheduledFor: day };
|
|
}
|
|
|
|
private async defaultReminder(organizationId: string, userId: string, dueAt: Date) {
|
|
const settings = await this.prisma.routineUserSettings.findUnique({
|
|
where: { organizationId_userId: { organizationId, userId } },
|
|
select: { remindBeforeMinutes: true },
|
|
});
|
|
const minutes = settings ? settings.remindBeforeMinutes : DEFAULT_REMIND_MINUTES;
|
|
return minutes != null ? new Date(dueAt.getTime() - minutes * 60_000) : null;
|
|
}
|
|
|
|
private async assertMember(organizationId: string, userId: string) {
|
|
const m = await this.prisma.membership.findFirst({
|
|
where: { organizationId, userId, isActive: true },
|
|
select: { id: true },
|
|
});
|
|
if (!m) throw new BadRequestException('مسئول انتخابشده عضو سازمان نیست');
|
|
}
|
|
|
|
private async assertRefs(
|
|
organizationId: string,
|
|
dto: {
|
|
categoryId?: string | null;
|
|
leadId?: string | null;
|
|
personId?: string | null;
|
|
companyId?: string | null;
|
|
dealId?: string | null;
|
|
invoiceId?: string | null;
|
|
activityId?: string | null;
|
|
},
|
|
) {
|
|
const checks: Array<[string | null | undefined, () => Promise<number>, string]> = [
|
|
[dto.categoryId, () => this.prisma.routineCategory.count({ where: { id: dto.categoryId!, organizationId } }), 'دستهبندی'],
|
|
[dto.leadId, () => this.prisma.lead.count({ where: { id: dto.leadId!, organizationId } }), 'لید'],
|
|
[dto.personId, () => this.prisma.person.count({ where: { id: dto.personId!, organizationId } }), 'شخص'],
|
|
[dto.companyId, () => this.prisma.company.count({ where: { id: dto.companyId!, organizationId } }), 'شرکت'],
|
|
[dto.dealId, () => this.prisma.deal.count({ where: { id: dto.dealId!, organizationId } }), 'معامله'],
|
|
[dto.invoiceId, () => this.prisma.invoice.count({ where: { id: dto.invoiceId!, organizationId } }), 'فاکتور'],
|
|
[dto.activityId, () => this.prisma.activity.count({ where: { id: dto.activityId!, organizationId } }), 'پیگیری'],
|
|
];
|
|
for (const [value, count, label] of checks) {
|
|
if (value && !(await count())) throw new BadRequestException(`${label} انتخابشده معتبر نیست`);
|
|
}
|
|
}
|
|
}
|