Files
CRM_Montzar/apps/api/src/activities/activities.service.ts
T
MahdiandCursor 3fb5f32ac3 fix: align activity badge and person profile with prior lens
Count nav badge for desk=mine, always apply activity prior filter on person records, and stop loading prior=all deals on profiles.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 18:56:44 +03:30

1017 lines
32 KiB
TypeScript

import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
ActivityDto,
ActivityFieldValues,
ActivityFilter,
ActivitySummaryDto,
ActivityStageCountsDto,
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,
activityAccessWhere,
dealAccessWhere,
ownerWhere,
} from '../common/record-scope';
import { normalizeTags } from '../common/tags';
import { GamificationService } from '../gamification/gamification.service';
import { tehranDayKey } from '../routines/routine-dates';
import { buildSearchWhere, ci } from '../common/search';
import { activityPriorWhere } from '../common/prior-archive';
import { ActivityStagesService, type StageDefaults } from './activity-stages.service';
import {
ActivityTypesService,
type SystemTypes,
type TypeRow,
} from './activity-types.service';
type DtoContext = { stages?: StageDefaults; types?: SystemTypes };
type ActivityActor = Pick<Actor, 'userId' | 'role' | 'priorLens' | 'priorCutoff'>;
/** stageId = شناسهٔ مرحله یا 'all'؛ وقتی ست باشد فیلتر زمانی وضعیت را تحمیل نمی‌کند (جز عقب‌افتاده) */
type StageScope = { stageId: string };
type ListFacets = {
type?: ActivityDto['type'];
typeDefId?: string;
status?: ActivityDto['status'];
assigneeId?: string;
};
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;
reminderSentAt: Date | null;
completedAt: Date | null;
importSource?: string;
promotedFromPriorAt?: Date | null;
tags: string[];
createdAt: Date;
updatedAt: Date;
person: { id: string; firstName: string; lastName: string } | null;
company: { id: string; name: string } | null;
deal: { id: string; title: string } | null;
lead?: { id: string; title: string } | null;
assignee: { id: string; fullName: string } | null;
assignees: Array<{ id: string; fullName: string }>;
createdBy: { id: string; fullName: string } | null;
stage?: { id: string; kind: ActivityDto['status'] } | null;
typeDef?: { id: string; name: string; icon: string; color: string } | null;
fields?: Prisma.JsonValue | null;
participantIds?: string[];
};
const include = {
stage: { select: { id: true, kind: true } },
typeDef: { select: { id: true, name: true, icon: true, color: true } },
person: { select: { id: true, firstName: true, lastName: true } },
company: { select: { id: true, name: true } },
deal: { select: { id: true, title: true } },
lead: { select: { id: true, title: true } },
assignee: { select: { id: true, fullName: true } },
assignees: { select: { id: true, fullName: true }, orderBy: { fullName: 'asc' as const } },
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,
private readonly stages: ActivityStagesService,
private readonly types: ActivityTypesService,
private readonly game: GamificationService,
) {}
private async ctx(organizationId: string): Promise<DtoContext> {
const [stages, types] = await Promise.all([
this.stages.defaults(organizationId),
this.types.system(organizationId),
]);
return { stages, types };
}
async summary(
organizationId: string,
actor: ActivityActor,
desk?: 'mine' | 'team',
): Promise<ActivitySummaryDto> {
const { start, end } = dayBounds();
const scope: Prisma.ActivityWhereInput =
desk === 'mine'
? {
OR: [
{ assigneeId: actor.userId },
{ assignees: { some: { id: actor.userId } } },
{ participantIds: { has: actor.userId } },
],
}
: activityAccessWhere(actor);
const prior = activityPriorWhere(actor.priorLens, actor.priorCutoff);
const [todayCount, overdueCount] = await Promise.all([
this.prisma.activity.count({
where: {
AND: [
{
organizationId,
...scope,
status: 'open',
dueAt: { gte: start, lt: end },
},
prior,
],
},
}),
this.prisma.activity.count({
where: {
AND: [
{
organizationId,
...scope,
status: 'open',
dueAt: { lt: start },
},
prior,
],
},
}),
]);
const [today, overdue, upcoming] = await Promise.all([
this.prisma.activity.findMany({
where: {
AND: [
{
organizationId,
...scope,
status: 'open',
dueAt: { gte: start, lt: end },
},
prior,
],
},
include,
orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
take: 20,
}),
this.prisma.activity.findMany({
where: {
AND: [
{
organizationId,
...scope,
status: 'open',
dueAt: { lt: start },
},
prior,
],
},
include,
orderBy: [{ dueAt: 'asc' }],
take: 20,
}),
this.prisma.activity.findMany({
where: {
AND: [
{
organizationId,
...scope,
status: 'open',
OR: [{ dueAt: { gte: end } }, { dueAt: null }],
},
prior,
],
},
include,
orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
take: 10,
}),
]);
const ctx = await this.ctx(organizationId);
return {
today: today.map((row) => this.toDto(row, ctx)),
overdue: overdue.map((row) => this.toDto(row, ctx)),
upcoming: upcoming.map((row) => this.toDto(row, ctx)),
todayCount,
overdueCount,
};
}
async list(
organizationId: string,
actor: ActivityActor,
filter: ActivityFilter = 'today',
q?: string,
page = 1,
limit = 50,
entity?: {
personId?: string;
companyId?: string;
dealId?: string;
leadId?: string;
},
range?: {
from?: string;
to?: string;
},
desk?: 'mine' | 'team',
facets?: ListFacets,
stageScope?: StageScope,
): Promise<PaginatedResponse<ActivityDto>> {
const typeRows = await this.types.rows(organizationId);
const where = this.buildWhere(
organizationId,
actor,
filter,
q,
entity,
range,
desk,
facets,
stageScope,
typeRows,
);
if (stageScope && stageScope.stageId !== 'all') {
(where.AND as Prisma.ActivityWhereInput[]).push(
await this.stages.whereForStage(organizationId, stageScope.stageId),
);
}
const ctx: DtoContext = {
stages: await this.stages.defaults(organizationId),
types: this.types.systemOf(typeRows),
};
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, ctx)),
total,
page,
limit: take,
};
}
/** تعداد فعالیت هر مرحله با همان فیلترهای لیست (زمان، جستجو، میز، نوع، کارشناس) */
async stageCounts(
organizationId: string,
actor: ActivityActor,
filter: ActivityFilter,
q?: string,
desk?: 'mine' | 'team',
facets?: ListFacets,
): Promise<ActivityStageCountsDto> {
const rows = await this.stages.rows(organizationId);
const defaults = this.stages.defaultsOf(rows);
const kindOf = new Map(rows.map((r) => [r.id, r.kind]));
const where = this.buildWhere(
organizationId,
actor,
filter,
q,
undefined,
undefined,
desk,
facets,
{ stageId: 'all' },
await this.types.rows(organizationId),
);
const groups = await this.prisma.activity.groupBy({
by: ['status', 'stageId'],
where,
_count: { _all: true },
});
const counts: Record<string, number> = Object.fromEntries(rows.map((r) => [r.id, 0]));
let total = 0;
for (const g of groups) {
const n = g._count._all;
total += n;
const target =
g.stageId && kindOf.get(g.stageId) === g.status ? g.stageId : defaults[g.status];
if (target) counts[target] = (counts[target] ?? 0) + n;
}
return { counts, total };
}
async moveToStage(
organizationId: string,
id: string,
actor: ActivityActor,
stageId: string,
): Promise<ActivityDto> {
await this.ensureExists(organizationId, id, actor);
const stage = await this.stages.get(organizationId, stageId);
const existing = await this.prisma.activity.findFirst({
where: { id, organizationId },
select: { status: true, completedAt: true },
});
const row = await this.prisma.activity.update({
where: { id },
data: {
stageId: stage.id,
status: stage.kind,
completedAt:
stage.kind === 'done'
? existing?.status === 'done'
? existing.completedAt ?? new Date()
: new Date()
: stage.kind === 'open'
? null
: undefined,
},
include,
});
if (stage.kind === 'done' && existing?.status !== 'done') {
await this.fireDone(organizationId, row);
}
return this.toDto(row, await this.ctx(organizationId));
}
async get(
organizationId: string,
id: string,
actor: ActivityActor,
): Promise<ActivityDto> {
const row = await this.prisma.activity.findFirst({
where: { id, organizationId, ...activityAccessWhere(actor) },
include,
});
if (!row) throw new NotFoundException('فعالیت پیدا نشد');
const dto = this.toDto(row, await this.ctx(organizationId));
if (row.participantIds.length) {
dto.participantUsers = await this.prisma.user.findMany({
where: { id: { in: row.participantIds } },
select: { id: true, fullName: true },
orderBy: { fullName: 'asc' },
});
}
return dto;
}
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 assigneeIds = await this.normalizeAssigneeIds(
organizationId,
dto.assigneeIds?.length
? dto.assigneeIds
: dto.assigneeId
? [dto.assigneeId]
: [userId],
);
const assigneeId = assigneeIds[0] ?? userId;
const initialStageId = emptyToNull(dto.stageId ?? null);
const initialStage = initialStageId
? await this.stages.get(organizationId, initialStageId)
: null;
const resolved = await this.types.resolve(organizationId, {
typeDefId: dto.typeDefId,
type: dto.type,
});
const { fields, participantIds } = await this.types.sanitizeValues(
organizationId,
resolved.fields,
dto.fields,
);
const row = await this.prisma.activity.create({
data: {
organizationId,
...(initialStage
? {
stageId: initialStage.id,
status: initialStage.kind,
completedAt: initialStage.kind === 'done' ? new Date() : null,
}
: {}),
title: dto.title.trim(),
type: resolved.baseType,
typeDefId: resolved.typeDefId,
fields: fields as Prisma.InputJsonValue,
participantIds,
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,
tags: normalizeTags(dto.tags),
personId: emptyToNull(dto.personId ?? null),
companyId: emptyToNull(dto.companyId ?? null),
dealId: emptyToNull(dto.dealId ?? null),
assigneeId,
assignees: assigneeIds.length
? { connect: assigneeIds.map((id) => ({ id })) }
: undefined,
createdById: userId,
},
include,
});
for (const uid of assigneeIds) {
await this.notifications.create({
organizationId,
userId: uid,
type: 'activity_assigned',
title: 'فعالیت جدید برای شما',
body: row.title,
link: `/activities/${row.id}`,
entityType: 'activity',
entityId: row.id,
});
}
await this.notifyParticipants(
organizationId,
row,
participantIds.filter((uid) => !assigneeIds.includes(uid) && uid !== userId),
);
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,
});
const gameBase = {
organizationId,
userId,
entityType: 'activity',
entityId: row.id,
entityTitle: row.title,
facts: {
activityType: row.type,
typeName: row.typeDef?.name ?? null,
priority: row.priority,
hasDescription: !!row.description?.trim(),
},
};
this.game.emit({ ...gameBase, event: 'ACTIVITY_CREATED' });
if (row.type === 'call') this.game.emit({ ...gameBase, event: 'CALL_LOGGED' });
if (row.type === 'meeting') this.game.emit({ ...gameBase, event: 'MEETING_CREATED' });
if (initialStage?.kind === 'done') {
await this.fireDone(organizationId, row);
}
return this.toDto(row, await this.ctx(organizationId));
}
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 existing = await this.prisma.activity.findFirst({
where: { id, organizationId },
select: { status: true, type: true, typeDefId: true, fields: true, participantIds: true },
});
const data: Prisma.ActivityUncheckedUpdateInput = {};
if (dto.title !== undefined) data.title = dto.title.trim();
let addedParticipants: string[] = [];
const typeChanged = 'typeDefId' in dto || dto.type !== undefined;
if (typeChanged || dto.fields !== undefined) {
const resolved = await this.types.resolve(
organizationId,
typeChanged
? { typeDefId: dto.typeDefId, type: dto.type }
: { typeDefId: existing?.typeDefId, type: existing?.type },
);
const { fields, participantIds } = await this.types.sanitizeValues(
organizationId,
resolved.fields,
dto.fields ?? existing?.fields,
);
data.type = resolved.baseType;
data.typeDefId = resolved.typeDefId;
data.fields = fields as Prisma.InputJsonValue;
data.participantIds = participantIds;
const before = new Set(existing?.participantIds ?? []);
addedParticipants = participantIds.filter((uid) => !before.has(uid) && uid !== actor.userId);
}
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) {
const nextReminder = dto.reminderAt ? new Date(dto.reminderAt) : null;
const current = await this.prisma.activity.findUnique({
where: { id },
select: { reminderAt: true },
});
const prevMs = current?.reminderAt?.getTime() ?? null;
const nextMs = nextReminder?.getTime() ?? null;
data.reminderAt = nextReminder;
if (prevMs !== nextMs) data.reminderSentAt = null;
}
if (dto.reminderNotifyApp !== undefined) data.reminderNotifyApp = dto.reminderNotifyApp;
if (dto.reminderNotifySms !== undefined) data.reminderNotifySms = dto.reminderNotifySms;
if ('tags' in dto) data.tags = normalizeTags(dto.tags);
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);
let assigneeConnect: Array<{ id: string }> | undefined;
if (dto.assigneeIds !== undefined) {
const assigneeIds = await this.normalizeAssigneeIds(
organizationId,
dto.assigneeIds,
);
if (!assigneeIds.length) {
throw new BadRequestException('حداقل یک مسئول لازم است');
}
data.assigneeId = assigneeIds[0]!;
assigneeConnect = assigneeIds.map((uid) => ({ id: uid }));
} else if ('assigneeId' in dto) {
const assigneeId = emptyToNull(dto.assigneeId ?? null);
if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId);
data.assigneeId = assigneeId;
if (assigneeId) assigneeConnect = [{ id: assigneeId }];
else assigneeConnect = [];
}
if (dto.status !== undefined) {
data.status = dto.status;
data.completedAt =
dto.status === 'done' ? new Date() : dto.status === 'open' ? null : undefined;
}
const row = await this.prisma.activity.update({
where: { id },
data: {
...data,
...(assigneeConnect
? { assignees: { set: assigneeConnect } }
: {}),
},
include,
});
if (addedParticipants.length) {
const assigneeSet = new Set([
...(row.assignee ? [row.assignee.id] : []),
...row.assignees.map((a) => a.id),
]);
await this.notifyParticipants(
organizationId,
row,
addedParticipants.filter((uid) => !assigneeSet.has(uid)),
);
}
if (dto.status === 'done' && existing?.status !== 'done') {
await this.fireDone(organizationId, row);
}
return this.toDto(row, await this.ctx(organizationId));
}
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, await this.ctx(organizationId));
}
async promoteFromPrior(actor: Actor, id: string): Promise<ActivityDto> {
if (
(actor.priorLens !== 'current' && actor.priorLens !== 'archive') ||
!actor.priorCutoff
) {
throw new ForbiddenException('بایگانی اطلاعات قبل برای شما فعال نیست.');
}
const row = await this.prisma.activity.findFirst({
where: {
id,
organizationId: actor.organizationId,
...activityAccessWhere(actor),
},
select: { id: true, createdAt: true, promotedFromPriorAt: true },
});
if (!row) throw new NotFoundException('فعالیت پیدا نشد');
if (row.promotedFromPriorAt) {
throw new BadRequestException('این فعالیت قبلاً منتقل شده است.');
}
if (row.createdAt >= actor.priorCutoff) {
throw new BadRequestException('این فعالیت در بایگانی نیست.');
}
const updated = await this.prisma.activity.update({
where: { id },
data: { promotedFromPriorAt: new Date() },
include,
});
return this.toDto(updated, await this.ctx(actor.organizationId));
}
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;
leadId?: string;
},
range?: {
from?: string;
to?: string;
},
desk?: 'mine' | 'team',
facets?: ListFacets,
stageScope?: StageScope,
typeRows: TypeRow[] = [],
): Prisma.ActivityWhereInput {
const { start, end } = dayBounds();
const scope: Prisma.ActivityWhereInput =
desk === 'mine'
? {
OR: [
{ assigneeId: actor.userId },
{ assignees: { some: { id: actor.userId } } },
{ participantIds: { has: actor.userId } },
],
}
: activityAccessWhere(actor);
const facetAssignee = facets?.assigneeId?.trim();
const facetTypeDef = facets?.typeDefId?.trim();
const andParts: Prisma.ActivityWhereInput[] = [
scope,
...(facets?.type ? [{ type: facets.type }] : []),
...(facetTypeDef ? [this.types.whereForType(typeRows, facetTypeDef)] : []),
...(facets?.status ? [{ status: facets.status }] : []),
...(facetAssignee
? [
{
OR: [
{ assigneeId: facetAssignee },
{ assignees: { some: { id: facetAssignee } } },
],
},
]
: []),
...(entity?.personId ? [{ personId: entity.personId }] : []),
...(entity?.companyId ? [{ companyId: entity.companyId }] : []),
...(entity?.dealId ? [{ dealId: entity.dealId }] : []),
...(entity?.leadId ? [{ leadId: entity.leadId }] : []),
];
const search = buildSearchWhere<Prisma.ActivityWhereInput>(q, (t) => [
{ title: ci(t) },
{ description: ci(t) },
{ tags: { has: t.replace(/^#/, '') } },
{
person: {
OR: [
{ firstName: ci(t) },
{ lastName: ci(t) },
{ mobile: { contains: t } },
{ phone: { contains: t } },
],
},
},
{ company: { name: ci(t) } },
{ deal: { title: ci(t) } },
{ assignee: { fullName: ci(t) } },
{ assignees: { some: { fullName: ci(t) } } },
...typesMatching(t).map((type) => ({ type })),
...this.types.searchByName(typeRows, t),
]);
if (search) andParts.push(search);
andParts.push(activityPriorWhere(actor.priorLens, actor.priorCutoff));
const base: Prisma.ActivityWhereInput = {
organizationId,
AND: andParts,
};
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 } : {}),
},
};
}
// Explicit status facet overrides the status implied by time tabs.
// With stage tabs/columns the stage decides status; time filters only narrow dueAt.
const impliedOpen = facets?.status
? { status: facets.status }
: stageScope
? {}
: { status: 'open' as const };
const impliedDone = facets?.status ? { status: facets.status } : { status: 'done' as const };
switch (filter) {
case 'today':
return { ...base, ...impliedOpen, dueAt: { gte: start, lt: end } };
case 'overdue':
// عقب‌افتاده ذاتاً یعنی باز و موعدگذشته
return {
...base,
...(facets?.status ? { status: facets.status } : { status: 'open' as const }),
dueAt: { lt: start },
};
case 'upcoming':
return {
...base,
...impliedOpen,
OR: [{ dueAt: { gte: end } }, { dueAt: null }],
};
case 'done':
return { ...base, ...impliedDone };
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;
type: string;
priority: string;
dueAt: Date | null;
typeDef?: { name: string } | null;
},
) {
this.game.emit({
organizationId,
userId: row.assignee?.id,
event: 'TASK_COMPLETED',
entityType: 'activity',
entityId: row.id,
entityTitle: row.title,
facts: {
activityType: row.type,
typeName: row.typeDef?.name ?? null,
priority: row.priority,
onTime: !row.dueAt || tehranDayKey() <= tehranDayKey(row.dueAt),
},
});
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, ...activityAccessWhere(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, memberships: { some: { 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, ...dealAccessWhere(actor) },
select: { id: true },
});
if (!deal) throw new BadRequestException('معامله انتخاب‌شده معتبر نیست');
}
}
private async notifyParticipants(
organizationId: string,
row: { id: string; title: string },
userIds: string[],
) {
for (const uid of new Set(userIds)) {
await this.notifications.create({
organizationId,
userId: uid,
type: 'activity_assigned',
title: 'شما به‌عنوان شرکت‌کننده اضافه شدید',
body: row.title,
link: `/activities/${row.id}`,
entityType: 'activity',
entityId: row.id,
});
}
}
private toDto(row: ActivityRow, ctx?: DtoContext): ActivityDto {
const fields =
row.fields && typeof row.fields === 'object' && !Array.isArray(row.fields)
? (row.fields as unknown as ActivityFieldValues)
: {};
return {
stageId: this.stages.effectiveStageId(row, ctx?.stages),
typeDef: row.typeDef
? this.types.toSummary(row.typeDef)
: (ctx?.types?.[row.type] ?? null),
fields,
participantIds: row.participantIds ?? [],
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,
reminderSentAt: row.reminderSentAt?.toISOString() ?? null,
completedAt: row.completedAt?.toISOString() ?? null,
importSource: row.importSource ?? 'manual',
promotedFromPriorAt: row.promotedFromPriorAt?.toISOString() ?? null,
tags: row.tags ?? [],
person: row.person
? {
id: row.person.id,
fullName: `${row.person.firstName} ${row.person.lastName}`.trim(),
}
: null,
company: row.company,
deal: row.deal,
lead: row.lead ?? null,
assignee: row.assignee,
assignees: (() => {
const list = [...(row.assignees ?? [])];
if (row.assignee && !list.some((a) => a.id === row.assignee!.id)) {
list.unshift(row.assignee);
}
return list;
})(),
createdBy: row.createdBy,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
private async normalizeAssigneeIds(
organizationId: string,
raw?: string[] | null,
): Promise<string[]> {
const ids = [
...new Set((raw ?? []).map((id) => id.trim()).filter(Boolean)),
];
if (!ids.length) return [];
const count = await this.prisma.user.count({
where: { organizationId, id: { in: ids }, isActive: true },
});
if (count !== ids.length) {
throw new BadRequestException('یکی از مسئولان انتخاب‌شده معتبر نیست');
}
return ids;
}
}
const TYPE_SEARCH_TERMS: Record<ActivityDto['type'], string[]> = {
call: ['تماس', 'تلفن', 'call'],
meeting: ['جلسه', 'ملاقات', 'meeting'],
task: ['وظیفه', 'تسک', 'task'],
note: ['پیام', 'یادداشت', 'note'],
};
/** جستجوی «جلسه» یا «تماس» → فیلتر نوع فعالیت */
function typesMatching(term: string): ActivityDto['type'][] {
const t = term.trim().toLowerCase();
if (t.length < 2) return [];
return (Object.keys(TYPE_SEARCH_TERMS) as ActivityDto['type'][]).filter((type) =>
TYPE_SEARCH_TERMS[type].some((label) => label.includes(t) || t.includes(label)),
);
}
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 };
}