import { BadRequestException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import type { ActivityDto, ActivityFieldValues, ActivityFilter, ActivityImageDto, ActivitySummaryDto, ActivityStageCountsDto, CreateActivityRequest, PaginatedResponse, UpdateActivityRequest, } from '@mento/shared'; import { Prisma } from '@prisma/client'; import { randomUUID } from 'node:crypto'; import * as path from 'node:path'; import { LocalFileStorage } from '../storage/local-file.storage'; import { StorageQuotaService } from '../billing/storage-quota.service'; 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 { assertAssigneeSet } from '../common/assign-access'; import { placeId, sameIds, writePositions } from '../common/column-order'; 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 { leadsDeepWhere } from '../common/data-access'; import { ActivityStagesService, type BoardStageContext } from './activity-stages.service'; import { ActivityTypesService, type SystemTypes, type TypeRow, } from './activity-types.service'; type DtoContext = { stages?: BoardStageContext; types?: SystemTypes }; type ActivityActor = Pick< Actor, 'userId' | 'role' | 'priorLens' | 'priorCutoff' | 'leadsAccess' | 'teamPeerIds' >; function assigneeIdList(row: { assigneeId?: string | null; assignees?: Array<{ id: string }>; }): string[] { const ids = row.assignees?.map((item) => item.id) ?? []; if (ids.length) return [...new Set(ids)]; return row.assigneeId ? [row.assigneeId] : []; } function canForceClose( actor: { userId: string; role: string }, createdById: string | null | undefined, ): boolean { return actor.role === 'owner' || actor.role === 'admin' || actor.userId === createdById; } function canMutateActivity( actor: { userId: string; role: string }, row: { createdById?: string | null; editPolicy?: 'creator' | 'assignees' | null; assigneeId?: string | null; assignees?: Array<{ id: string }>; }, ): boolean { if (actor.role === 'owner' || actor.role === 'admin') return true; if (row.createdById && actor.userId === row.createdById) return true; if ((row.editPolicy ?? 'creator') !== 'assignees') return false; return assigneeIdList(row).includes(actor.userId); } /** ثبت نظر / تعویق — برای همهٔ مسئولان، حتی وقتی ویرایش فقط با ایجادکننده است */ function canCollaborateActivity( actor: { userId: string; role: string }, row: { createdById?: string | null; assigneeId?: string | null; assignees?: Array<{ id: string }>; }, ): boolean { if (actor.role === 'owner' || actor.role === 'admin') return true; if (row.createdById && actor.userId === row.createdById) return true; return assigneeIdList(row).includes(actor.userId); } /** فقط dueAt / description → تعویق و ثبت نظر */ function isCollaboratorPatch(dto: UpdateActivityRequest): boolean { const keys = Object.keys(dto).filter( (key) => (dto as Record)[key] !== undefined, ); return keys.length > 0 && keys.every((key) => key === 'dueAt' || key === 'description'); } function reconcileAllShares(assigneeIds: string[], completedByIds: string[]) { const kept = [...new Set(completedByIds.filter((id) => assigneeIds.includes(id)))]; const done = assigneeIds.length > 0 && assigneeIds.every((id) => kept.includes(id)); return { status: done ? ('done' as const) : ('open' as const), completedByIds: kept }; } /** stageId = شناسهٔ مرحله یا 'all'؛ وقتی ست باشد فیلتر زمانی وضعیت را تحمیل نمی‌کند (جز عقب‌افتاده) */ type StageScope = { stageId: string }; type ListFacets = { type?: ActivityDto['type']; typeDefId?: string; status?: ActivityDto['status']; assigneeId?: string; title?: string; customer?: string; /** set = زمان شروع دارد، unset = ندارد */ due?: 'set' | 'unset'; priority?: ActivityDto['priority']; /** گروه فعالیت؛ خالی = همهٔ گروه‌ها */ boardId?: 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; completionMode?: 'any' | 'all'; completedByIds?: string[]; editPolicy?: 'creator' | 'assignees'; 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; boardId?: string | null; stage?: { id: string; kind: ActivityDto['status']; boardId: string } | null; typeDef?: { id: string; name: string; icon: string; color: string } | null; fields?: Prisma.JsonValue | null; participantIds?: string[]; images?: Array<{ id: string; fileName: string; mimeType: string; sizeBytes: number; createdAt: Date; }>; }; const ACTIVITY_IMAGE_MAX_BYTES = 200 * 1024; const ACTIVITY_IMAGE_MAX_COUNT = 8; const ACTIVITY_IMAGE_MIME = new Set(['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif']); const ACTIVITY_VOICE_MAX_BYTES = 2 * 1024 * 1024; const ACTIVITY_VOICE_MAX_COUNT = 5; const ACTIVITY_VOICE_MIME = new Set(['audio/webm', 'audio/ogg', 'audio/mp4', 'audio/mpeg', 'audio/mp3', 'audio/aac', 'audio/x-m4a', 'audio/m4a', 'audio/wav', 'audio/x-wav']); const ACTIVITY_FILE_MAX_BYTES = 10 * 1024 * 1024; const ACTIVITY_FILE_MAX_COUNT = 8; const ACTIVITY_FILE_MIME = new Set([ 'application/pdf', 'text/plain', 'text/csv', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'application/zip', 'application/x-zip-compressed', ]); const ACTIVITY_EXT_MIME: Record = { pdf: 'application/pdf', txt: 'text/plain', csv: 'text/csv', doc: 'application/msword', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', xls: 'application/vnd.ms-excel', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ppt: 'application/vnd.ms-powerpoint', pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', zip: 'application/zip', jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', webm: 'audio/webm', ogg: 'audio/ogg', mp3: 'audio/mpeg', m4a: 'audio/mp4', wav: 'audio/wav', }; const include = { stage: { select: { id: true, kind: true, boardId: 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 readonly files: LocalFileStorage, private readonly storage: StorageQuotaService, ) {} private async ctx(organizationId: string): Promise { const [stages, types] = await Promise.all([ this.stages.boardContext(organizationId), this.types.system(organizationId), ]); return { stages, types }; } /** کارهای بازِ «من» که سهم‌شان ثبت شده، از میز کار شخص خارج می‌شوند. */ private deskScope( actor: ActivityActor, desk?: 'mine' | 'team', ): Prisma.ActivityWhereInput { if (desk !== 'mine') return activityAccessWhere(actor); return { AND: [ { OR: [ { assigneeId: actor.userId }, { assignees: { some: { id: actor.userId } } }, { participantIds: { has: actor.userId } }, ], }, { NOT: { completionMode: 'all', status: 'open', completedByIds: { has: actor.userId }, }, }, ], }; } async summary( organizationId: string, actor: ActivityActor, desk?: 'mine' | 'team', ): Promise { const { start, end } = dayBounds(); const scope = this.deskScope(actor, desk); 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 { where, ctx } = await this.listScope( organizationId, actor, filter, q, entity, range, desk, facets, stageScope, ); const take = Math.min(Math.max(limit, 1), 200); const [rows, total] = await this.prisma.$transaction([ this.prisma.activity.findMany({ where, include, // روی پروندهٔ لید، تازه‌ترین (از جمله پیگیری آینده) اول بیاید تا از سقف صفحه نیفتد orderBy: this.listOrder(entity, stageScope), skip: (page - 1) * take, take, }), this.prisma.activity.count({ where }), ]); return { items: rows.map((row) => this.toDto(row, ctx)), total, page, limit: take, }; } /** * شناسه‌ها با همان فیلتر و ترتیب لیست (زمان شروع صعودی). * برای ورق‌زدن جزئیات؛ سقف دارد تا یک زبانهٔ خیلی شلوغ کل پاسخ را بزرگ نکند. */ async orderedIds( organizationId: string, actor: ActivityActor, filter: ActivityFilter = 'today', q?: string, entity?: { personId?: string; companyId?: string; dealId?: string; leadId?: string; }, range?: { from?: string; to?: string; }, desk?: 'mine' | 'team', facets?: ListFacets, stageScope?: StageScope, ): Promise<{ ids: string[]; total: number; truncated: boolean }> { const cap = 2000; const { where } = await this.listScope( organizationId, actor, filter, q, entity, range, desk, facets, stageScope, ); const [rows, total] = await this.prisma.$transaction([ this.prisma.activity.findMany({ where, select: { id: true }, orderBy: this.listOrder(entity, stageScope), take: cap, }), this.prisma.activity.count({ where }), ]); return { ids: rows.map((row) => row.id), total, truncated: total > rows.length }; } private async listScope( organizationId: string, actor: ActivityActor, filter: ActivityFilter, q: string | undefined, entity: | { personId?: string; companyId?: string; dealId?: string; leadId?: string; } | undefined, range: { from?: string; to?: string } | undefined, desk: 'mine' | 'team' | undefined, facets: ListFacets | undefined, stageScope: StageScope | undefined, ): Promise<{ where: Prisma.ActivityWhereInput; ctx: DtoContext }> { const typeRows = await this.types.rows(organizationId); const where = this.buildWhere( organizationId, actor, filter, q, entity, range, desk, facets, stageScope, typeRows, ); const boardCtx = await this.stages.boardContext(organizationId); if (facets?.boardId) { const boardId = await this.stages.resolveBoardId(organizationId, facets.boardId); await this.stages.assertCanAccessBoard(organizationId, boardId, actor); (where.AND as Prisma.ActivityWhereInput[]).push( this.stages.boardWhere(boardId, boardCtx.defaultBoardId), ); } if (stageScope && stageScope.stageId !== 'all') { (where.AND as Prisma.ActivityWhereInput[]).push( await this.stages.whereForStage(organizationId, stageScope.stageId), ); } return { where, ctx: { stages: boardCtx, types: this.types.systemOf(typeRows) }, }; } /** تعداد فعالیت هر مرحله با همان فیلترهای لیست (زمان، جستجو، میز، نوع، کارشناس) */ async stageCounts( organizationId: string, actor: ActivityActor, filter: ActivityFilter, q?: string, desk?: 'mine' | 'team', facets?: ListFacets, ): Promise { const boardId = await this.stages.resolveBoardId(organizationId, facets?.boardId); await this.stages.assertCanAccessBoard(organizationId, boardId, actor); const defaultBoard = await this.stages.ensureDefaultBoard(organizationId); const rows = await this.stages.rows(organizationId, boardId); 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), ); (where.AND as Prisma.ActivityWhereInput[]).push( this.stages.boardWhere(boardId, defaultBoard.id), ); 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 }; } private listOrder( entity?: { leadId?: string }, stageScope?: StageScope, ): Prisma.ActivityOrderByWithRelationInput[] { if (entity?.leadId) return [{ createdAt: 'desc' }]; if (stageScope && stageScope.stageId !== 'all') { return [{ boardPosition: 'asc' }, { dueAt: 'asc' }, { createdAt: 'desc' }]; } return [{ dueAt: 'asc' }, { createdAt: 'desc' }]; } private async reorderStage( organizationId: string, stageId: string, activityId: string, index: number, ) { const where = await this.stages.whereForStage(organizationId, stageId); const rows = await this.prisma.activity.findMany({ where, select: { id: true }, orderBy: [{ boardPosition: 'asc' }, { dueAt: 'asc' }, { createdAt: 'desc' }], }); const currentIds = rows.map((row) => row.id); const placed = placeId(currentIds, activityId, index); if (sameIds(currentIds, placed)) return; await writePositions( (ops) => this.prisma.$transaction(ops), placed, (id, boardPosition) => this.prisma.activity.update({ where: { id }, data: { boardPosition } }), ); } async moveToStage( organizationId: string, id: string, actor: ActivityActor, stageId: string, position?: number, ): 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, completionMode: true, completedByIds: true, createdById: true, assigneeId: true, assignees: { select: { id: true } }, }, }); if ( stage.kind === 'done' && existing?.completionMode === 'all' && assigneeIdList(existing).length > 1 && !canForceClose(actor, existing.createdById) ) { return this.complete(organizationId, id, actor); } const row = await this.prisma.activity.update({ where: { id }, data: { stageId: stage.id, boardId: stage.boardId, status: stage.kind, completedAt: stage.kind === 'done' ? existing?.status === 'done' ? existing.completedAt ?? new Date() : new Date() : stage.kind === 'open' ? null : undefined, ...(stage.kind === 'open' && existing?.status !== 'open' ? { completedByIds: [] } : {}), }, include, }); if (position != null) await this.reorderStage(organizationId, stage.id, id, position); 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: { ...include, images: { select: { id: true, fileName: true, mimeType: true, sizeBytes: true, createdAt: true, }, orderBy: { createdAt: 'asc' }, }, }, }); 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], ); await assertAssigneeSet( this.prisma, { userId, role: linkActor.role, organizationId }, 'activities', assigneeIds, ); const assigneeId = assigneeIds[0] ?? userId; const initialStageId = emptyToNull(dto.stageId ?? null); const initialStage = initialStageId ? await this.stages.get(organizationId, initialStageId) : null; const requestedBoardId = emptyToNull(dto.boardId ?? null); const boardId = initialStage ? initialStage.boardId : requestedBoardId ? await this.stages.resolveBoardId(organizationId, requestedBoardId) : null; if (boardId) await this.stages.assertCanAccessBoard(organizationId, boardId, linkActor); 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, ); await assertAssigneeSet( this.prisma, { userId, role: linkActor.role, organizationId }, 'activities', participantIds, ); const row = await this.prisma.activity.create({ data: { organizationId, boardId, ...(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), leadId: emptyToNull(dto.leadId ?? null), assigneeId, assignees: assigneeIds.length ? { connect: assigneeIds.map((id) => ({ id })) } : undefined, completionMode: dto.completionMode === 'all' ? 'all' : 'any', completedByIds: [], editPolicy: dto.editPolicy === 'assignees' ? 'assignees' : 'creator', 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, completionMode: true, completedByIds: true, editPolicy: true, createdById: true, assigneeId: true, assignees: { select: { id: true } }, }, }); if (!existing) { throw new ForbiddenException('شما مجوز ویرایش این فعالیت را ندارید'); } const collabOnly = isCollaboratorPatch(dto); if (collabOnly) { if (!canCollaborateActivity(actor, existing)) { throw new ForbiddenException('شما مجوز ثبت نظر یا تعویق این فعالیت را ندارید'); } } else if (!canMutateActivity(actor, existing)) { throw new ForbiddenException('شما مجوز ویرایش این فعالیت را ندارید'); } const data: Prisma.ActivityUncheckedUpdateInput = {}; if (dto.title !== undefined) data.title = dto.title.trim(); if ('boardId' in dto) { const nextBoardId = await this.stages.resolveBoardId(organizationId, dto.boardId); const current = await this.prisma.activity.findUnique({ where: { id }, select: { boardId: true }, }); const defaultBoard = await this.stages.ensureDefaultBoard(organizationId); await this.stages.assertCanAccessBoard(organizationId, nextBoardId, actor); if (nextBoardId !== (current?.boardId ?? defaultBoard.id)) { // ستون قبلی مال گروه دیگر است؛ فعالیت به ستون اولِ هم‌وضعیت گروه جدید می‌رود data.boardId = nextBoardId; data.stageId = null; } } 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; await assertAssigneeSet( this.prisma, { userId: actor.userId, role: actor.role, organizationId }, 'activities', participantIds, existing?.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); if ('leadId' in dto) data.leadId = emptyToNull(dto.leadId ?? null); let assigneeConnect: Array<{ id: string }> | undefined; let nextAssigneeIds: string[] | undefined; if (dto.assigneeIds !== undefined || 'assigneeId' in dto) { const current = await this.prisma.activity.findFirst({ where: { id, organizationId }, select: { assigneeId: true, assignees: { select: { id: true } } }, }); const previous = current?.assignees.length ? current.assignees.map((item) => item.id) : current?.assigneeId ? [current.assigneeId] : []; if (dto.assigneeIds !== undefined) { const assigneeIds = await this.normalizeAssigneeIds( organizationId, dto.assigneeIds, ); if (!assigneeIds.length) { throw new BadRequestException('حداقل یک مسئول لازم است'); } await assertAssigneeSet( this.prisma, { userId: actor.userId, role: actor.role, organizationId }, 'activities', assigneeIds, previous, ); data.assigneeId = assigneeIds[0]!; assigneeConnect = assigneeIds.map((uid) => ({ id: uid })); nextAssigneeIds = assigneeIds; } else { const assigneeId = emptyToNull(dto.assigneeId ?? null); if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId); await assertAssigneeSet( this.prisma, { userId: actor.userId, role: actor.role, organizationId }, 'activities', [assigneeId], previous, ); data.assigneeId = assigneeId; if (assigneeId) assigneeConnect = [{ id: assigneeId }]; else assigneeConnect = []; nextAssigneeIds = assigneeId ? [assigneeId] : []; } } const modeChanged = dto.completionMode === 'any' || dto.completionMode === 'all'; const nextMode = modeChanged ? dto.completionMode! : (existing?.completionMode ?? 'any'); if (modeChanged) data.completionMode = nextMode; if (dto.editPolicy === 'creator' || dto.editPolicy === 'assignees') { data.editPolicy = dto.editPolicy; } const prevAssigneeIds = assigneeIdList(existing ?? {}); const nextIds = nextAssigneeIds ?? prevAssigneeIds; const explicitDone = dto.status === 'done'; const explicitReopen = dto.status === 'open' && existing?.status !== 'open'; const explicitCancel = dto.status === 'canceled'; if (explicitDone) { data.status = 'done'; data.completedAt = new Date(); } else if (explicitReopen) { data.status = 'open'; data.completedAt = null; data.completedByIds = []; } else if (explicitCancel) { data.status = 'canceled'; } else if (nextMode === 'any' && modeChanged) { data.completedByIds = []; } else if (nextMode === 'all' && (modeChanged || nextAssigneeIds)) { const reconciled = reconcileAllShares(nextIds, existing?.completedByIds ?? []); data.completedByIds = reconciled.completedByIds; const finishedStaysFinished = existing?.status === 'done' && !nextAssigneeIds; if (existing?.status !== 'canceled' && !finishedStaysFinished) { if (reconciled.status === 'done' && existing?.status !== 'done') { data.status = 'done'; data.completedAt = new Date(); } else if (reconciled.status === 'open' && existing?.status === 'done') { data.status = 'open'; data.completedAt = null; } } } 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 (data.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, options: { force?: boolean; undo?: boolean } = {}, ): Promise { await this.ensureExists(organizationId, id, actor); const existing = await this.prisma.activity.findFirst({ where: { id, organizationId }, select: { status: true, completionMode: true, completedByIds: true, createdById: true, assigneeId: true, assignees: { select: { id: true } }, }, }); if (!existing) throw new NotFoundException('فعالیت پیدا نشد'); if (existing.status === 'canceled') { throw new BadRequestException('فعالیت لغو شده است'); } const assignees = assigneeIdList(existing); const allowedForce = canForceClose(actor, existing.createdById); if (options.force && !allowedForce) { throw new ForbiddenException('بستن فعالیت برای همه فقط برای سازنده یا مدیر ممکن است'); } if (options.undo) { if (existing.completionMode !== 'all' || existing.status !== 'open') { throw new BadRequestException('سهم انجام‌شده را فقط تا وقتی فعالیت باز است می‌توان لغو کرد'); } const row = await this.prisma.activity.update({ where: { id }, data: { completedByIds: existing.completedByIds.filter((uid) => uid !== actor.userId), }, include, }); return this.toDto(row, await this.ctx(organizationId)); } const personal = existing.completionMode === 'all' && assignees.length > 1 && !options.force; if (!personal) { 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)); } if (!assignees.includes(actor.userId)) { throw new ForbiddenException('فقط مسئول این فعالیت می‌تواند سهم خودش را ثبت کند'); } const reconciled = reconcileAllShares(assignees, [ ...existing.completedByIds, actor.userId, ]); const row = await this.prisma.activity.update({ where: { id }, data: { completedByIds: reconciled.completedByIds, status: reconciled.status, completedAt: reconciled.status === 'done' ? new Date() : null, }, include, }); if (reconciled.status === 'done' && 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); const existing = await this.prisma.activity.findFirst({ where: { id, organizationId }, select: { createdById: true, editPolicy: true, assigneeId: true, assignees: { select: { id: true } }, }, }); if (!existing || !canMutateActivity(actor, existing)) { throw new ForbiddenException('شما مجوز حذف این فعالیت را ندارید'); } const images = await this.prisma.activityImage.findMany({ where: { activityId: id, organizationId }, select: { storageKey: true, sizeBytes: true }, }); const bytes = images.reduce((sum, image) => sum + image.sizeBytes, 0); if (bytes) await this.storage.addLocal(organizationId, -bytes); await this.prisma.activity.delete({ where: { id } }); for (const image of images) { await this.files.remove(image.storageKey); } } async addImage( organizationId: string, userId: string, activityId: string, file: Express.Multer.File, actor: ActivityActor, ): Promise { await this.ensureExists(organizationId, activityId, actor); const gate = await this.prisma.activity.findFirst({ where: { id: activityId, organizationId }, select: { createdById: true, editPolicy: true, assigneeId: true, assignees: { select: { id: true } }, }, }); // پیوست در مسیر ثبت نظر — مسئولان هم مجازند حتی با editPolicy=creator if (!gate || !canCollaborateActivity(actor, gate)) { throw new ForbiddenException('شما مجوز ثبت نظر یا پیوست برای این فعالیت را ندارید'); } if (!file?.buffer?.length) { throw new BadRequestException('فایلی ارسال نشده است'); } const namedExt = path.extname(file.originalname || '').slice(1).toLowerCase(); let mimeType = (file.mimetype || '').toLowerCase().split(';')[0]?.trim() || ''; if (!mimeType || mimeType === 'application/octet-stream') { mimeType = ACTIVITY_EXT_MIME[namedExt] || mimeType; } const isVoice = mimeType.startsWith('audio/') || ACTIVITY_VOICE_MIME.has(mimeType); const isImage = ACTIVITY_IMAGE_MIME.has(mimeType); const isFile = ACTIVITY_FILE_MIME.has(mimeType); if (!isVoice && !isImage && !isFile) { throw new BadRequestException('این نوع فایل مجاز نیست.'); } if (isVoice) { if (file.size > ACTIVITY_VOICE_MAX_BYTES) { throw new BadRequestException('حجم صوت باید زیر ۲ مگابایت باشد'); } const voiceCount = await this.prisma.activityImage.count({ where: { activityId, mimeType: { startsWith: 'audio/' } }, }); if (voiceCount >= ACTIVITY_VOICE_MAX_COUNT) { throw new BadRequestException(`حداکثر ${ACTIVITY_VOICE_MAX_COUNT} صوت برای هر فعالیت مجاز است`); } } else if (isImage) { if (file.size > ACTIVITY_IMAGE_MAX_BYTES) { throw new BadRequestException('حجم عکس باید زیر 200 کیلوبایت باشد'); } const count = await this.prisma.activityImage.count({ where: { activityId, mimeType: { startsWith: 'image/' } }, }); if (count >= ACTIVITY_IMAGE_MAX_COUNT) { throw new BadRequestException(`حداکثر ${ACTIVITY_IMAGE_MAX_COUNT} عکس برای هر فعالیت مجاز است`); } } else { if (file.size > ACTIVITY_FILE_MAX_BYTES) { throw new BadRequestException('حجم فایل باید زیر ۱۰ مگابایت باشد'); } const fileCount = await this.prisma.activityImage.count({ where: { activityId, NOT: { OR: [{ mimeType: { startsWith: 'image/' } }, { mimeType: { startsWith: 'audio/' } }], }, }, }); if (fileCount >= ACTIVITY_FILE_MAX_COUNT) { throw new BadRequestException(`حداکثر ${ACTIVITY_FILE_MAX_COUNT} فایل برای هر فعالیت مجاز است`); } } const fileName = sanitizeActivityImageName(file.originalname); const ext = path.extname(fileName).slice(0, 12) || (mimeType === 'audio/ogg' ? '.ogg' : mimeType === 'audio/mp4' ? '.m4a' : isVoice ? '.webm' : '.jpg'); const imageId = randomUUID().replace(/-/g, '').slice(0, 24); const storageKey = path .join(organizationId, 'activities', activityId, `${imageId}${ext}`) .replace(/\\/g, '/'); await this.storage.assertCanAdd(organizationId, file.size); await this.files.save(storageKey, file.buffer); try { const row = await this.prisma.activityImage.create({ data: { id: imageId, organizationId, activityId, fileName, mimeType: mimeType === 'image/jpg' ? 'image/jpeg' : mimeType, sizeBytes: file.size, storageKey, uploadedById: userId, }, }); await this.storage.addLocal(organizationId, file.size); return { id: row.id, fileName: row.fileName, mimeType: row.mimeType, sizeBytes: row.sizeBytes, createdAt: row.createdAt.toISOString(), }; } catch (err) { await this.files.remove(storageKey); throw err; } } async removeImage( organizationId: string, activityId: string, imageId: string, actor: ActivityActor, ): Promise { await this.ensureExists(organizationId, activityId, actor); const gate = await this.prisma.activity.findFirst({ where: { id: activityId, organizationId }, select: { createdById: true, editPolicy: true, assigneeId: true, assignees: { select: { id: true } }, }, }); const row = await this.prisma.activityImage.findFirst({ where: { id: imageId, activityId, organizationId }, }); if (!row) throw new NotFoundException('فایل پیدا نشد'); const canMutate = !!gate && canMutateActivity(actor, gate); const canRemoveOwn = !!gate && canCollaborateActivity(actor, gate) && row.uploadedById === actor.userId; if (!canMutate && !canRemoveOwn) { throw new ForbiddenException('شما مجوز حذف این پیوست را ندارید'); } await this.files.remove(row.storageKey); await this.prisma.activityImage.delete({ where: { id: row.id } }); await this.storage.addLocal(organizationId, -row.sizeBytes); } async openImage( organizationId: string, activityId: string, imageId: string, actor: ActivityActor, ): Promise<{ stream: ReturnType; fileName: string; mimeType: string }> { await this.ensureExists(organizationId, activityId, actor); const row = await this.prisma.activityImage.findFirst({ where: { id: imageId, activityId, organizationId }, }); if (!row) throw new NotFoundException('فایل پیدا نشد'); return { stream: this.files.openReadStream(row.storageKey), fileName: row.fileName, mimeType: row.mimeType, }; } 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 = this.deskScope(actor, desk); const facetAssignee = facets?.assigneeId?.trim(); const facetTypeDef = facets?.typeDefId?.trim(); const facetTitle = facets?.title?.trim(); const facetCustomer = facets?.customer?.trim(); const facetPriority = facets?.priority; const andParts: Prisma.ActivityWhereInput[] = [ scope, ...(facets?.type ? [{ type: facets.type }] : []), ...(facetTypeDef ? [this.types.whereForType(typeRows, facetTypeDef)] : []), ...(facets?.status ? [{ status: facets.status }] : []), ...(facetPriority ? [{ priority: facetPriority }] : []), ...(facetTitle ? [{ title: ci(facetTitle) }] : []), ...(facetCustomer ? [this.customerWhere(facetCustomer)] : []), ...(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 this.withDue( { ...base, dueAt: { ...(fromDate && !Number.isNaN(fromDate.getTime()) ? { gte: fromDate } : {}), ...(toDate && !Number.isNaN(toDate.getTime()) ? { lt: toDate } : {}), }, }, facets?.due, ); } // 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 this.withDue( { ...base, ...impliedOpen, dueAt: { gte: start, lt: end } }, facets?.due, ); case 'overdue': // عقب‌افتاده ذاتاً یعنی باز و موعدگذشته return this.withDue( { ...base, ...(facets?.status ? { status: facets.status } : { status: 'open' as const }), dueAt: { lt: start }, }, facets?.due, ); case 'upcoming': return this.withDue( { ...base, ...impliedOpen, OR: [{ dueAt: { gte: end } }, { dueAt: null }], }, facets?.due, ); case 'done': return this.withDue({ ...base, ...impliedDone }, facets?.due); case 'all': default: return this.withDue(base, facets?.due); } } /** نام شخص (چندکلمه‌ای) یا نام شرکت */ private customerWhere(term: string): Prisma.ActivityWhereInput { const parts = term.split(/\s+/).filter(Boolean); const person: Prisma.PersonWhereInput = parts.length <= 1 ? { OR: [{ firstName: ci(term) }, { lastName: ci(term) }] } : { AND: parts.map((part) => ({ OR: [{ firstName: ci(part) }, { lastName: ci(part) }], })), }; return { OR: [{ person }, { company: { name: ci(term) } }], }; } /** وجود یا نبود زمان شروع، روی هر فیلتر زمانی */ private withDue( where: Prisma.ActivityWhereInput, due?: 'set' | 'unset', ): Prisma.ActivityWhereInput { if (due !== 'set' && due !== 'unset') return where; return { AND: [where, due === 'set' ? { dueAt: { not: null } } : { dueAt: null }] }; } 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; leadId?: string | null; }, ) { const personId = emptyToNull(dto.personId ?? null); const companyId = emptyToNull(dto.companyId ?? null); const dealId = emptyToNull(dto.dealId ?? null); const leadId = emptyToNull(dto.leadId ?? 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('معامله انتخاب‌شده معتبر نیست'); } if (leadId) { const lead = await this.prisma.lead.findFirst({ where: { id: leadId, organizationId, ...leadsDeepWhere(actor) }, select: { id: true }, }); if (!lead) 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 { boardId: this.stages.effectiveBoardId(row, ctx?.stages), 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, images: row.images?.map((image) => ({ id: image.id, fileName: image.fileName, mimeType: image.mimeType, sizeBytes: image.sizeBytes, createdAt: image.createdAt.toISOString(), })), 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, completionMode: row.completionMode ?? 'any', completedByIds: row.completedByIds ?? [], editPolicy: row.editPolicy ?? 'creator', 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: { id: { in: ids }, isActive: true, memberships: { some: { organizationId, 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 sanitizeActivityImageName(raw: string): string { const base = path.basename(raw || 'photo.jpg').replace(/[<>:"/\\|?*\x00-\x1f]/g, '_'); const trimmed = base.trim() || 'photo.jpg'; return trimmed.slice(0, 180); } 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 }; }