1504 lines
49 KiB
TypeScript
1504 lines
49 KiB
TypeScript
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'
|
|
>;
|
|
|
|
/** 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';
|
|
/** گروه فعالیت؛ خالی = همهٔ گروهها */
|
|
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;
|
|
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<string, string> = {
|
|
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<DtoContext> {
|
|
const [stages, types] = await Promise.all([
|
|
this.stages.boardContext(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 { 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<ActivityStageCountsDto> {
|
|
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<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 };
|
|
}
|
|
|
|
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<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,
|
|
boardId: stage.boardId,
|
|
status: stage.kind,
|
|
completedAt:
|
|
stage.kind === 'done'
|
|
? existing?.status === 'done'
|
|
? existing.completedAt ?? new Date()
|
|
: new Date()
|
|
: stage.kind === 'open'
|
|
? null
|
|
: undefined,
|
|
},
|
|
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<ActivityDto> {
|
|
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<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],
|
|
);
|
|
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,
|
|
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();
|
|
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;
|
|
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 }));
|
|
} 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 = [];
|
|
}
|
|
}
|
|
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);
|
|
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<ActivityImageDto> {
|
|
await this.ensureExists(organizationId, activityId, actor);
|
|
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<void> {
|
|
await this.ensureExists(organizationId, activityId, actor);
|
|
const row = await this.prisma.activityImage.findFirst({
|
|
where: { id: imageId, activityId, organizationId },
|
|
});
|
|
if (!row) throw new NotFoundException('فایل پیدا نشد');
|
|
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<LocalFileStorage['openReadStream']>; 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: 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 facetTitle = facets?.title?.trim();
|
|
const facetCustomer = facets?.customer?.trim();
|
|
const andParts: Prisma.ActivityWhereInput[] = [
|
|
scope,
|
|
...(facets?.type ? [{ type: facets.type }] : []),
|
|
...(facetTypeDef ? [this.types.whereForType(typeRows, facetTypeDef)] : []),
|
|
...(facets?.status ? [{ status: facets.status }] : []),
|
|
...(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<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 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,
|
|
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: {
|
|
id: { in: ids },
|
|
isActive: true,
|
|
memberships: { some: { organizationId, 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 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 };
|
|
}
|