Files
CRM_Montzar/apps/api/src/activities/activities.service.ts
T
2026-09-13 19:11:47 +03:30

509 lines
15 KiB
TypeScript

import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
ActivityDto,
ActivityFilter,
ActivitySummaryDto,
CreateActivityRequest,
PaginatedResponse,
UpdateActivityRequest,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import { AutomationsService } from '../automations/automations.service';
import { MessageAutomationsService } from '../message-automations/message-automations.service';
import { type Actor, assigneeWhere, ownerWhere } from '../common/record-scope';
type ActivityActor = Pick<Actor, 'userId' | 'role'>;
type ActivityRow = {
id: string;
type: ActivityDto['type'];
status: ActivityDto['status'];
priority: ActivityDto['priority'];
title: string;
description: string | null;
dueAt: Date | null;
durationMinutes: number | null;
reminderAt: Date | null;
reminderNotifyApp: boolean;
reminderNotifySms: boolean;
completedAt: Date | null;
createdAt: Date;
updatedAt: Date;
person: { id: string; firstName: string; lastName: string } | null;
company: { id: string; name: string } | null;
deal: { id: string; title: string } | null;
assignee: { id: string; fullName: string } | null;
createdBy: { id: string; fullName: string } | null;
};
const include = {
person: { select: { id: true, firstName: true, lastName: true } },
company: { select: { id: true, name: true } },
deal: { select: { id: true, title: true } },
assignee: { select: { id: true, fullName: true } },
createdBy: { select: { id: true, fullName: true } },
} satisfies Prisma.ActivityInclude;
@Injectable()
export class ActivitiesService {
constructor(
private readonly prisma: PrismaService,
private readonly notifications: NotificationsService,
private readonly automations: AutomationsService,
private readonly messageAutomations: MessageAutomationsService,
) {}
async summary(
organizationId: string,
actor: ActivityActor,
): Promise<ActivitySummaryDto> {
const { start, end } = dayBounds();
const scope = assigneeWhere(actor);
const [today, overdue, upcoming] = await Promise.all([
this.prisma.activity.findMany({
where: {
organizationId,
...scope,
status: 'open',
dueAt: { gte: start, lt: end },
},
include,
orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
take: 20,
}),
this.prisma.activity.findMany({
where: {
organizationId,
...scope,
status: 'open',
dueAt: { lt: start },
},
include,
orderBy: [{ dueAt: 'asc' }],
take: 20,
}),
this.prisma.activity.findMany({
where: {
organizationId,
...scope,
status: 'open',
OR: [{ dueAt: { gte: end } }, { dueAt: null }],
},
include,
orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
take: 10,
}),
]);
return {
today: today.map((row) => this.toDto(row)),
overdue: overdue.map((row) => this.toDto(row)),
upcoming: upcoming.map((row) => this.toDto(row)),
todayCount: today.length,
overdueCount: overdue.length,
};
}
async list(
organizationId: string,
actor: ActivityActor,
filter: ActivityFilter = 'today',
q?: string,
page = 1,
limit = 50,
entity?: {
personId?: string;
companyId?: string;
dealId?: string;
},
range?: {
from?: string;
to?: string;
},
): Promise<PaginatedResponse<ActivityDto>> {
const where = this.buildWhere(
organizationId,
actor,
filter,
q,
entity,
range,
);
const take = Math.min(Math.max(limit, 1), 200);
const [rows, total] = await this.prisma.$transaction([
this.prisma.activity.findMany({
where,
include,
orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
skip: (page - 1) * take,
take,
}),
this.prisma.activity.count({ where }),
]);
return {
items: rows.map((row) => this.toDto(row)),
total,
page,
limit: take,
};
}
async get(
organizationId: string,
id: string,
actor: ActivityActor,
): Promise<ActivityDto> {
const row = await this.prisma.activity.findFirst({
where: { id, organizationId, ...assigneeWhere(actor) },
include,
});
if (!row) throw new NotFoundException('فعالیت پیدا نشد');
return this.toDto(row);
}
async create(
organizationId: string,
userId: string,
dto: CreateActivityRequest,
actor?: ActivityActor,
): Promise<ActivityDto> {
const linkActor = actor ?? { userId, role: 'owner' };
await this.assertLinks(organizationId, linkActor, dto);
const assigneeId = emptyToNull(dto.assigneeId ?? null) ?? userId;
if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId);
const row = await this.prisma.activity.create({
data: {
organizationId,
title: dto.title.trim(),
type: dto.type ?? 'task',
priority: dto.priority ?? 'medium',
description: emptyToNull(dto.description),
dueAt: dto.dueAt ? new Date(dto.dueAt) : null,
durationMinutes: dto.durationMinutes ?? null,
reminderAt: dto.reminderAt ? new Date(dto.reminderAt) : null,
reminderNotifyApp: dto.reminderNotifyApp ?? true,
reminderNotifySms: dto.reminderNotifySms ?? false,
personId: emptyToNull(dto.personId ?? null),
companyId: emptyToNull(dto.companyId ?? null),
dealId: emptyToNull(dto.dealId ?? null),
assigneeId,
createdById: userId,
},
include,
});
if (assigneeId) {
await this.notifications.create({
organizationId,
userId: assigneeId,
type: 'activity_assigned',
title: 'فعالیت جدید برای شما',
body: row.title,
link: `/activities/${row.id}`,
entityType: 'activity',
entityId: row.id,
});
}
await this.messageAutomations.onEvent('activity_created', {
organizationId,
entityType: 'activity',
entityId: row.id,
title: row.title,
personId: row.person?.id ?? null,
companyId: row.company?.id ?? null,
dealId: row.deal?.id ?? null,
assigneeId: row.assignee?.id ?? null,
ownerId: row.assignee?.id ?? null,
});
return this.toDto(row);
}
async update(
organizationId: string,
id: string,
actor: ActivityActor,
dto: UpdateActivityRequest,
): Promise<ActivityDto> {
await this.ensureExists(organizationId, id, actor);
await this.assertLinks(organizationId, actor, dto);
const data: Prisma.ActivityUncheckedUpdateInput = {};
if (dto.title !== undefined) data.title = dto.title.trim();
if (dto.type !== undefined) data.type = dto.type;
if (dto.priority !== undefined) data.priority = dto.priority;
if ('description' in dto) data.description = emptyToNull(dto.description);
if ('dueAt' in dto) data.dueAt = dto.dueAt ? new Date(dto.dueAt) : null;
if ('durationMinutes' in dto) data.durationMinutes = dto.durationMinutes ?? null;
if ('reminderAt' in dto) data.reminderAt = dto.reminderAt ? new Date(dto.reminderAt) : null;
if (dto.reminderNotifyApp !== undefined) data.reminderNotifyApp = dto.reminderNotifyApp;
if (dto.reminderNotifySms !== undefined) data.reminderNotifySms = dto.reminderNotifySms;
if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null);
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
if ('assigneeId' in dto) {
const assigneeId = emptyToNull(dto.assigneeId ?? null);
if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId);
data.assigneeId = assigneeId;
}
if (dto.status !== undefined) {
data.status = dto.status;
data.completedAt =
dto.status === 'done' ? new Date() : dto.status === 'open' ? null : undefined;
}
const existing = await this.prisma.activity.findFirst({
where: { id, organizationId },
select: { status: true },
});
const row = await this.prisma.activity.update({
where: { id },
data,
include,
});
if (dto.status === 'done' && existing?.status !== 'done') {
await this.fireDone(organizationId, row);
}
return this.toDto(row);
}
async complete(
organizationId: string,
id: string,
actor: ActivityActor,
): Promise<ActivityDto> {
await this.ensureExists(organizationId, id, actor);
const existing = await this.prisma.activity.findFirst({
where: { id, organizationId },
select: { status: true },
});
const row = await this.prisma.activity.update({
where: { id },
data: { status: 'done', completedAt: new Date() },
include,
});
if (existing?.status !== 'done') {
await this.fireDone(organizationId, row);
}
return this.toDto(row);
}
async remove(
organizationId: string,
id: string,
actor: ActivityActor,
): Promise<void> {
await this.ensureExists(organizationId, id, actor);
await this.prisma.activity.delete({ where: { id } });
}
private buildWhere(
organizationId: string,
actor: ActivityActor,
filter: ActivityFilter,
q?: string,
entity?: {
personId?: string;
companyId?: string;
dealId?: string;
},
range?: {
from?: string;
to?: string;
},
): Prisma.ActivityWhereInput {
const { start, end } = dayBounds();
const base: Prisma.ActivityWhereInput = {
organizationId,
...assigneeWhere(actor),
...(entity?.personId ? { personId: entity.personId } : {}),
...(entity?.companyId ? { companyId: entity.companyId } : {}),
...(entity?.dealId ? { dealId: entity.dealId } : {}),
...(q?.trim()
? {
OR: [
{ title: { contains: q.trim(), mode: 'insensitive' } },
{ description: { contains: q.trim(), mode: 'insensitive' } },
{
person: {
OR: [
{ firstName: { contains: q.trim(), mode: 'insensitive' } },
{ lastName: { contains: q.trim(), mode: 'insensitive' } },
],
},
},
{ deal: { title: { contains: q.trim(), mode: 'insensitive' } } },
],
}
: {}),
};
const fromDate = range?.from ? new Date(range.from) : null;
const toDate = range?.to ? new Date(range.to) : null;
if (fromDate || toDate) {
return {
...base,
dueAt: {
...(fromDate && !Number.isNaN(fromDate.getTime())
? { gte: fromDate }
: {}),
...(toDate && !Number.isNaN(toDate.getTime()) ? { lt: toDate } : {}),
},
};
}
switch (filter) {
case 'today':
return { ...base, status: 'open', dueAt: { gte: start, lt: end } };
case 'overdue':
return { ...base, status: 'open', dueAt: { lt: start } };
case 'upcoming':
return {
...base,
status: 'open',
OR: [{ dueAt: { gte: end } }, { dueAt: null }],
};
case 'done':
return { ...base, status: 'done' };
case 'all':
default:
return base;
}
}
private async fireDone(
organizationId: string,
row: {
id: string;
title: string;
person: { id: string } | null;
company: { id: string } | null;
deal: { id: string } | null;
assignee: { id: string } | null;
},
) {
const ctx = {
organizationId,
entityType: 'activity' as const,
entityId: row.id,
title: row.title,
personId: row.person?.id ?? null,
companyId: row.company?.id ?? null,
dealId: row.deal?.id ?? null,
assigneeId: row.assignee?.id ?? null,
ownerId: row.assignee?.id ?? null,
};
await this.automations.runTrigger('activity_done', ctx);
await this.messageAutomations.onEvent('activity_done', ctx);
}
private async ensureExists(
organizationId: string,
id: string,
actor: ActivityActor,
) {
const found = await this.prisma.activity.findFirst({
where: { id, organizationId, ...assigneeWhere(actor) },
select: { id: true },
});
if (!found) throw new NotFoundException('فعالیت پیدا نشد');
}
private async assertUserInOrg(organizationId: string, userId: string) {
const user = await this.prisma.user.findFirst({
where: { id: userId, organizationId },
select: { id: true },
});
if (!user) throw new BadRequestException('مسئول انتخاب‌شده معتبر نیست');
}
private async assertLinks(
organizationId: string,
actor: ActivityActor,
dto: {
personId?: string | null;
companyId?: string | null;
dealId?: string | null;
},
) {
const personId = emptyToNull(dto.personId ?? null);
const companyId = emptyToNull(dto.companyId ?? null);
const dealId = emptyToNull(dto.dealId ?? null);
const scope = ownerWhere(actor);
if (personId) {
const person = await this.prisma.person.findFirst({
where: { id: personId, organizationId, ...scope },
select: { id: true },
});
if (!person) throw new BadRequestException('شخص انتخاب‌شده معتبر نیست');
}
if (companyId) {
const company = await this.prisma.company.findFirst({
where: { id: companyId, organizationId, ...scope },
select: { id: true },
});
if (!company) throw new BadRequestException('شرکت انتخاب‌شده معتبر نیست');
}
if (dealId) {
const deal = await this.prisma.deal.findFirst({
where: { id: dealId, organizationId, ...scope },
select: { id: true },
});
if (!deal) throw new BadRequestException('معامله انتخاب‌شده معتبر نیست');
}
}
private toDto(row: ActivityRow): ActivityDto {
return {
id: row.id,
type: row.type,
status: row.status,
priority: row.priority,
title: row.title,
description: row.description,
dueAt: row.dueAt?.toISOString() ?? null,
durationMinutes: row.durationMinutes,
reminderAt: row.reminderAt?.toISOString() ?? null,
reminderNotifyApp: row.reminderNotifyApp,
reminderNotifySms: row.reminderNotifySms,
completedAt: row.completedAt?.toISOString() ?? null,
person: row.person
? {
id: row.person.id,
fullName: `${row.person.firstName} ${row.person.lastName}`.trim(),
}
: null,
company: row.company,
deal: row.deal,
assignee: row.assignee,
createdBy: row.createdBy,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}
function emptyToNull(value?: string | null): string | null {
if (value == null) return null;
const trimmed = value.trim();
return trimmed.length ? trimmed : null;
}
function dayBounds(now = new Date()) {
const start = new Date(now);
start.setHours(0, 0, 0, 0);
const end = new Date(start);
end.setDate(end.getDate() + 1);
return { start, end };
}