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; /** 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 { 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 { 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> { 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 { 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 = 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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(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 { 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 = { 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 }; }