feat(activities): split customer column, wider search, custom activity types with type-specific fields

- Table: separate assignee and customer columns; search by assignee and activity type
- ActivityTypeDef model with per-type field definitions (text, select, checklist, participants, ...)
- Default types incl. meeting subject/participants and task checklist; admins manage types
- Participants see activities in their lists and get notified

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-07 12:29:23 +03:30
co-authored by Cursor
parent aa2f39256a
commit 13668c4ba4
17 changed files with 3371 additions and 147 deletions
@@ -0,0 +1,37 @@
-- AlterTable
ALTER TABLE "Activity" ADD COLUMN "fields" JSONB,
ADD COLUMN "participantIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
ADD COLUMN "typeDefId" TEXT;
-- CreateTable
CREATE TABLE "ActivityTypeDef" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"baseType" "ActivityType" NOT NULL DEFAULT 'task',
"icon" TEXT NOT NULL DEFAULT 'check',
"color" TEXT NOT NULL DEFAULT 'green',
"fields" JSONB NOT NULL DEFAULT '[]',
"position" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"isSystem" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ActivityTypeDef_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ActivityTypeDef_organizationId_position_idx" ON "ActivityTypeDef"("organizationId", "position");
-- CreateIndex
CREATE UNIQUE INDEX "ActivityTypeDef_organizationId_name_key" ON "ActivityTypeDef"("organizationId", "name");
-- CreateIndex
CREATE INDEX "Activity_typeDefId_idx" ON "Activity"("typeDefId");
-- AddForeignKey
ALTER TABLE "ActivityTypeDef" ADD CONSTRAINT "ActivityTypeDef_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_typeDefId_fkey" FOREIGN KEY ("typeDefId") REFERENCES "ActivityTypeDef"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+33
View File
@@ -207,6 +207,7 @@ model Organization {
leads Lead[]
leadStages LeadStage[]
activityStages ActivityStage[]
activityTypeDefs ActivityTypeDef[]
leadEvents LeadEvent[]
leadImportJobs LeadImportJob[]
conversationSettings ConversationSettings?
@@ -646,6 +647,30 @@ model ActivityStage {
@@index([organizationId, position])
}
/// نوع فعالیت قابل تعریف توسط سازمان با فیلدهای اختصاصی
model ActivityTypeDef {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
name String
/// نوع پایه — مبنای گزارش‌ها، یادآورها و سازگاری
baseType ActivityType @default(task)
icon String @default("check")
color String @default("green")
/// تعریف فیلدها: [{ key, label, type, options?, required?, placeholder? }]
fields Json @default("[]")
position Int @default(0)
isActive Boolean @default(true)
/// نوع اصلیِ هر baseType — حذف‌شدنی نیست
isSystem Boolean @default(false)
activities Activity[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([organizationId, name])
@@index([organizationId, position])
}
model Activity {
id String @id @default(cuid())
organizationId String
@@ -683,10 +708,18 @@ model Activity {
/// مرحله (ستون کانبان)؛ null یا مرحلهٔ ناهم‌نوع با status → اولین مرحلهٔ هم‌نوع
stageId String?
stage ActivityStage? @relation(fields: [stageId], references: [id], onDelete: SetNull)
/// نوع سفارشی؛ null → نوع اصلیِ همان type
typeDefId String?
typeDef ActivityTypeDef? @relation(fields: [typeDefId], references: [id], onDelete: SetNull)
/// مقادیر فیلدهای اختصاصی نوع (کلید فیلد → مقدار)
fields Json?
/// کاربران شرکت‌کننده؛ فعالیت در لیست آن‌ها هم دیده می‌شود
participantIds String[] @default([])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([stageId])
@@index([typeDefId])
@@index([organizationId, status, dueAt])
@@index([organizationId, assigneeId])
@@index([organizationId, createdById])
@@ -25,15 +25,19 @@ import type { Actor } from '../common/record-scope';
import { ActivitiesService } from './activities.service';
import { ActivitiesExcelService } from './activities-excel.service';
import { ActivityStagesService } from './activity-stages.service';
import { ActivityTypesService } from './activity-types.service';
import {
CreateActivityDto,
CreateActivityStageDto,
CreateActivityTypeDto,
ImportActivityRowsDto,
ListActivitiesQueryDto,
MoveActivityStageDto,
ReorderActivityStagesDto,
ReorderActivityTypesDto,
UpdateActivityDto,
UpdateActivityStageDto,
UpdateActivityTypeDto,
} from './dto';
import { brand } from '@mento/shared';
@@ -49,6 +53,7 @@ export class ActivitiesController {
private readonly activitiesService: ActivitiesService,
private readonly excelService: ActivitiesExcelService,
private readonly stagesService: ActivityStagesService,
private readonly typesService: ActivityTypesService,
) {}
@Get('summary')
@@ -94,6 +99,44 @@ export class ActivitiesController {
return { ok: true };
}
@Get('types')
listTypes(@CurrentUser() user: Actor) {
return this.typesService.list(user.organizationId);
}
@Post('types')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
createType(@CurrentUser() user: Actor, @Body() dto: CreateActivityTypeDto) {
return this.typesService.create(user.organizationId, dto);
}
@Patch('types/reorder')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
reorderTypes(@CurrentUser() user: Actor, @Body() dto: ReorderActivityTypesDto) {
return this.typesService.reorder(user.organizationId, dto.typeIds);
}
@Patch('types/:id')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
updateType(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() dto: UpdateActivityTypeDto,
) {
return this.typesService.update(user.organizationId, id, dto);
}
@Delete('types/:id')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
async removeType(@CurrentUser() user: Actor, @Param('id') id: string) {
await this.typesService.remove(user.organizationId, id);
return { ok: true };
}
@Get('stage-counts')
stageCounts(
@CurrentUser() user: Actor,
@@ -107,6 +150,7 @@ export class ActivitiesController {
query.desk,
{
type: query.type,
typeDefId: query.typeDefId,
status: query.status,
assigneeId: query.assigneeId,
},
@@ -137,6 +181,7 @@ export class ActivitiesController {
query.desk,
{
type: query.type,
typeDefId: query.typeDefId,
status: query.status,
assigneeId: query.assigneeId,
},
+8 -2
View File
@@ -3,6 +3,7 @@ import { ActivitiesController } from './activities.controller';
import { ActivitiesService } from './activities.service';
import { ActivitiesExcelService } from './activities-excel.service';
import { ActivityStagesService } from './activity-stages.service';
import { ActivityTypesService } from './activity-types.service';
import { NotificationsModule } from '../notifications/notifications.module';
import { AutomationsModule } from '../automations/automations.module';
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
@@ -10,7 +11,12 @@ import { MessageAutomationsModule } from '../message-automations/message-automat
@Module({
imports: [NotificationsModule, AutomationsModule, MessageAutomationsModule],
controllers: [ActivitiesController],
providers: [ActivitiesService, ActivitiesExcelService, ActivityStagesService],
exports: [ActivitiesService, ActivityStagesService],
providers: [
ActivitiesService,
ActivitiesExcelService,
ActivityStagesService,
ActivityTypesService,
],
exports: [ActivitiesService, ActivityStagesService, ActivityTypesService],
})
export class ActivitiesModule {}
+165 -23
View File
@@ -5,6 +5,7 @@ import {
} from '@nestjs/common';
import type {
ActivityDto,
ActivityFieldValues,
ActivityFilter,
ActivitySummaryDto,
ActivityStageCountsDto,
@@ -17,10 +18,22 @@ import { PrismaService } from '../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import { AutomationsService } from '../automations/automations.service';
import { MessageAutomationsService } from '../message-automations/message-automations.service';
import { type Actor, assigneeWhere, dealAccessWhere, ownerWhere } from '../common/record-scope';
import {
type Actor,
activityAccessWhere,
dealAccessWhere,
ownerWhere,
} from '../common/record-scope';
import { normalizeTags } from '../common/tags';
import { buildSearchWhere, ci } from '../common/search';
import { ActivityStagesService, type StageDefaults } from './activity-stages.service';
import {
ActivityTypesService,
type SystemTypes,
type TypeRow,
} from './activity-types.service';
type DtoContext = { stages?: StageDefaults; types?: SystemTypes };
type ActivityActor = Pick<Actor, 'userId' | 'role'>;
@@ -29,6 +42,7 @@ type StageScope = { stageId: string };
type ListFacets = {
type?: ActivityDto['type'];
typeDefId?: string;
status?: ActivityDto['status'];
assigneeId?: string;
};
@@ -57,10 +71,14 @@ type ActivityRow = {
assignees: Array<{ id: string; fullName: string }>;
createdBy: { id: string; fullName: string } | null;
stage?: { id: string; kind: ActivityDto['status'] } | null;
typeDef?: { id: string; name: string; icon: string; color: string } | null;
fields?: Prisma.JsonValue | null;
participantIds?: string[];
};
const include = {
stage: { select: { id: true, kind: true } },
typeDef: { select: { id: true, name: true, icon: true, color: true } },
person: { select: { id: true, firstName: true, lastName: true } },
company: { select: { id: true, name: true } },
deal: { select: { id: true, title: true } },
@@ -77,14 +95,23 @@ export class ActivitiesService {
private readonly automations: AutomationsService,
private readonly messageAutomations: MessageAutomationsService,
private readonly stages: ActivityStagesService,
private readonly types: ActivityTypesService,
) {}
private async ctx(organizationId: string): Promise<DtoContext> {
const [stages, types] = await Promise.all([
this.stages.defaults(organizationId),
this.types.system(organizationId),
]);
return { stages, types };
}
async summary(
organizationId: string,
actor: ActivityActor,
): Promise<ActivitySummaryDto> {
const { start, end } = dayBounds();
const scope = assigneeWhere(actor);
const scope = activityAccessWhere(actor);
const [todayCount, overdueCount] = await Promise.all([
this.prisma.activity.count({
where: {
@@ -139,10 +166,11 @@ export class ActivitiesService {
}),
]);
const ctx = await this.ctx(organizationId);
return {
today: today.map((row) => this.toDto(row)),
overdue: overdue.map((row) => this.toDto(row)),
upcoming: upcoming.map((row) => this.toDto(row)),
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,
};
@@ -168,6 +196,7 @@ export class ActivitiesService {
facets?: ListFacets,
stageScope?: StageScope,
): Promise<PaginatedResponse<ActivityDto>> {
const typeRows = await this.types.rows(organizationId);
const where = this.buildWhere(
organizationId,
actor,
@@ -178,13 +207,17 @@ export class ActivitiesService {
desk,
facets,
stageScope,
typeRows,
);
if (stageScope && stageScope.stageId !== 'all') {
(where.AND as Prisma.ActivityWhereInput[]).push(
await this.stages.whereForStage(organizationId, stageScope.stageId),
);
}
const defaults = await this.stages.defaults(organizationId);
const ctx: DtoContext = {
stages: await this.stages.defaults(organizationId),
types: this.types.systemOf(typeRows),
};
const take = Math.min(Math.max(limit, 1), 200);
const [rows, total] = await this.prisma.$transaction([
this.prisma.activity.findMany({
@@ -198,7 +231,7 @@ export class ActivitiesService {
]);
return {
items: rows.map((row) => this.toDto(row, defaults)),
items: rows.map((row) => this.toDto(row, ctx)),
total,
page,
limit: take,
@@ -227,6 +260,7 @@ export class ActivitiesService {
desk,
facets,
{ stageId: 'all' },
await this.types.rows(organizationId),
);
const groups = await this.prisma.activity.groupBy({
by: ['status', 'stageId'],
@@ -276,7 +310,7 @@ export class ActivitiesService {
if (stage.kind === 'done' && existing?.status !== 'done') {
await this.fireDone(organizationId, row);
}
return this.toDto(row, await this.stages.defaults(organizationId));
return this.toDto(row, await this.ctx(organizationId));
}
async get(
@@ -285,11 +319,19 @@ export class ActivitiesService {
actor: ActivityActor,
): Promise<ActivityDto> {
const row = await this.prisma.activity.findFirst({
where: { id, organizationId, ...assigneeWhere(actor) },
where: { id, organizationId, ...activityAccessWhere(actor) },
include,
});
if (!row) throw new NotFoundException('فعالیت پیدا نشد');
return this.toDto(row, await this.stages.defaults(organizationId));
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(
@@ -313,6 +355,15 @@ export class ActivitiesService {
const initialStage = initialStageId
? await this.stages.get(organizationId, initialStageId)
: null;
const resolved = await this.types.resolve(organizationId, {
typeDefId: dto.typeDefId,
type: dto.type,
});
const { fields, participantIds } = await this.types.sanitizeValues(
organizationId,
resolved.fields,
dto.fields,
);
const row = await this.prisma.activity.create({
data: {
@@ -325,7 +376,10 @@ export class ActivitiesService {
}
: {}),
title: dto.title.trim(),
type: dto.type ?? 'task',
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,
@@ -358,6 +412,11 @@ export class ActivitiesService {
entityId: row.id,
});
}
await this.notifyParticipants(
organizationId,
row,
participantIds.filter((uid) => !assigneeIds.includes(uid) && uid !== userId),
);
await this.messageAutomations.onEvent('activity_created', {
organizationId,
@@ -374,7 +433,7 @@ export class ActivitiesService {
await this.fireDone(organizationId, row);
}
return this.toDto(row, await this.stages.defaults(organizationId));
return this.toDto(row, await this.ctx(organizationId));
}
async update(
@@ -386,9 +445,33 @@ export class ActivitiesService {
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 (dto.type !== undefined) data.type = dto.type;
let addedParticipants: string[] = [];
const typeChanged = 'typeDefId' in dto || dto.type !== undefined;
if (typeChanged || dto.fields !== undefined) {
const resolved = await this.types.resolve(
organizationId,
typeChanged
? { typeDefId: dto.typeDefId, type: dto.type }
: { typeDefId: existing?.typeDefId, type: existing?.type },
);
const { fields, participantIds } = await this.types.sanitizeValues(
organizationId,
resolved.fields,
dto.fields ?? existing?.fields,
);
data.type = resolved.baseType;
data.typeDefId = resolved.typeDefId;
data.fields = fields as Prisma.InputJsonValue;
data.participantIds = participantIds;
const before = new Set(existing?.participantIds ?? []);
addedParticipants = participantIds.filter((uid) => !before.has(uid) && uid !== actor.userId);
}
if (dto.priority !== undefined) data.priority = dto.priority;
if ('description' in dto) data.description = emptyToNull(dto.description);
if ('dueAt' in dto) data.dueAt = dto.dueAt ? new Date(dto.dueAt) : null;
@@ -434,10 +517,6 @@ export class ActivitiesService {
dto.status === 'done' ? new Date() : dto.status === 'open' ? null : undefined;
}
const existing = await this.prisma.activity.findFirst({
where: { id, organizationId },
select: { status: true },
});
const row = await this.prisma.activity.update({
where: { id },
data: {
@@ -448,10 +527,21 @@ export class ActivitiesService {
},
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.stages.defaults(organizationId));
return this.toDto(row, await this.ctx(organizationId));
}
async complete(
@@ -472,7 +562,7 @@ export class ActivitiesService {
if (existing?.status !== 'done') {
await this.fireDone(organizationId, row);
}
return this.toDto(row, await this.stages.defaults(organizationId));
return this.toDto(row, await this.ctx(organizationId));
}
async remove(
@@ -501,6 +591,7 @@ export class ActivitiesService {
desk?: 'mine' | 'team',
facets?: ListFacets,
stageScope?: StageScope,
typeRows: TypeRow[] = [],
): Prisma.ActivityWhereInput {
const { start, end } = dayBounds();
const scope: Prisma.ActivityWhereInput =
@@ -509,13 +600,16 @@ export class ActivitiesService {
OR: [
{ assigneeId: actor.userId },
{ assignees: { some: { id: actor.userId } } },
{ participantIds: { has: actor.userId } },
],
}
: assigneeWhere(actor);
: activityAccessWhere(actor);
const facetAssignee = facets?.assigneeId?.trim();
const facetTypeDef = facets?.typeDefId?.trim();
const andParts: Prisma.ActivityWhereInput[] = [
scope,
...(facets?.type ? [{ type: facets.type }] : []),
...(facetTypeDef ? [this.types.whereForType(typeRows, facetTypeDef)] : []),
...(facets?.status ? [{ status: facets.status }] : []),
...(facetAssignee
? [
@@ -547,6 +641,10 @@ export class ActivitiesService {
},
{ 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);
const base: Prisma.ActivityWhereInput = {
@@ -633,7 +731,7 @@ export class ActivitiesService {
actor: ActivityActor,
) {
const found = await this.prisma.activity.findFirst({
where: { id, organizationId, ...assigneeWhere(actor) },
where: { id, organizationId, ...activityAccessWhere(actor) },
select: { id: true },
});
if (!found) throw new NotFoundException('فعالیت پیدا نشد');
@@ -684,9 +782,37 @@ export class ActivitiesService {
}
}
private toDto(row: ActivityRow, defaults?: StageDefaults): ActivityDto {
private async notifyParticipants(
organizationId: string,
row: { id: string; title: string },
userIds: string[],
) {
for (const uid of new Set(userIds)) {
await this.notifications.create({
organizationId,
userId: uid,
type: 'activity_assigned',
title: 'شما به‌عنوان شرکت‌کننده اضافه شدید',
body: row.title,
link: `/activities/${row.id}`,
entityType: 'activity',
entityId: row.id,
});
}
}
private toDto(row: ActivityRow, ctx?: DtoContext): ActivityDto {
const fields =
row.fields && typeof row.fields === 'object' && !Array.isArray(row.fields)
? (row.fields as unknown as ActivityFieldValues)
: {};
return {
stageId: this.stages.effectiveStageId(row, defaults),
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,
@@ -741,6 +867,22 @@ export class ActivitiesService {
}
}
const TYPE_SEARCH_TERMS: Record<ActivityDto['type'], string[]> = {
call: ['تماس', 'تلفن', 'call'],
meeting: ['جلسه', 'ملاقات', 'meeting'],
task: ['وظیفه', 'تسک', 'task'],
note: ['پیام', 'یادداشت', 'note'],
};
/** جستجوی «جلسه» یا «تماس» → فیلتر نوع فعالیت */
function typesMatching(term: string): ActivityDto['type'][] {
const t = term.trim().toLowerCase();
if (t.length < 2) return [];
return (Object.keys(TYPE_SEARCH_TERMS) as ActivityDto['type'][]).filter((type) =>
TYPE_SEARCH_TERMS[type].some((label) => label.includes(t) || t.includes(label)),
);
}
function emptyToNull(value?: string | null): string | null {
if (value == null) return null;
const trimmed = value.trim();
@@ -0,0 +1,649 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import type {
ActivityChecklistItem,
ActivityFieldDef,
ActivityFieldType,
ActivityFieldValue,
ActivityFieldValues,
ActivityParticipantsValue,
ActivityStageColor,
ActivityType,
ActivityTypeDefDto,
ActivityTypeIcon,
ActivityTypeSummaryDto,
CreateActivityTypeRequest,
UpdateActivityTypeRequest,
} from '@mento/shared';
import {
ACTIVITY_FIELD_TYPES,
ACTIVITY_FIELD_TYPES_WITH_OPTIONS,
ACTIVITY_STAGE_COLORS,
ACTIVITY_TYPE_ICONS,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
const BASE_TYPES: ActivityType[] = ['call', 'meeting', 'task', 'note'];
const MAX_FIELDS = 30;
const MAX_OPTIONS = 50;
const MAX_CHECKLIST = 100;
const MAX_PARTICIPANTS = 100;
type Seed = {
name: string;
baseType: ActivityType;
icon: ActivityTypeIcon;
color: ActivityStageColor;
isSystem?: boolean;
fields: ActivityFieldDef[];
};
const DEFAULT_TYPES: Seed[] = [
{
name: 'تماس',
baseType: 'call',
icon: 'phone',
color: 'green',
isSystem: true,
fields: [
{ key: 'call_direction', label: 'جهت تماس', type: 'select', options: ['خروجی', 'ورودی'] },
{
key: 'call_result',
label: 'نتیجه تماس',
type: 'select',
options: [
'پاسخ داد',
'پاسخ نداد',
'مشغول بود',
'خاموش / در دسترس نبود',
'شماره اشتباه',
'نیاز به پیگیری مجدد',
],
},
],
},
{
name: 'جلسه',
baseType: 'meeting',
icon: 'users',
color: 'purple',
isSystem: true,
fields: [
{
key: 'meeting_subject',
label: 'موضوع جلسه',
type: 'text',
placeholder: 'مثلاً: بررسی پیش‌فاکتور و شرایط پرداخت',
},
{ key: 'meeting_participants', label: 'شرکت‌کنندگان', type: 'participants' },
{
key: 'meeting_location',
label: 'محل برگزاری / لینک جلسه',
type: 'text',
placeholder: 'آدرس یا لینک جلسهٔ آنلاین',
},
{ key: 'meeting_minutes', label: 'صورت‌جلسه', type: 'textarea' },
],
},
{
name: 'وظیفه',
baseType: 'task',
icon: 'check',
color: 'orange',
isSystem: true,
fields: [{ key: 'task_checklist', label: 'لیست کارها', type: 'checklist' }],
},
{
name: 'پیام',
baseType: 'note',
icon: 'chat',
color: 'blue',
isSystem: true,
fields: [
{
key: 'message_channel',
label: 'کانال ارسال',
type: 'select',
options: ['پیامک', 'واتساپ', 'تلگرام', 'ایتا', 'بله', 'ایمیل', 'دایرکت اینستاگرام'],
},
],
},
{
name: 'بازدید حضوری',
baseType: 'meeting',
icon: 'pin',
color: 'rose',
fields: [
{ key: 'visit_address', label: 'آدرس محل بازدید', type: 'text' },
{ key: 'visit_participants', label: 'همراهان بازدید', type: 'participants' },
{ key: 'visit_result', label: 'نتیجهٔ بازدید', type: 'textarea' },
],
},
{
name: 'ارائه / دمو محصول',
baseType: 'meeting',
icon: 'star',
color: 'purple',
fields: [
{ key: 'demo_product', label: 'محصول / خدمت', type: 'text' },
{ key: 'demo_participants', label: 'شرکت‌کنندگان', type: 'participants' },
{ key: 'demo_link', label: 'لینک جلسه', type: 'url' },
{
key: 'demo_result',
label: 'نتیجهٔ دمو',
type: 'select',
options: ['علاقه‌مند', 'نیاز به بررسی بیشتر', 'درخواست پیش‌فاکتور', 'عدم علاقه'],
},
],
},
{
name: 'پیگیری',
baseType: 'call',
icon: 'bell',
color: 'orange',
fields: [
{
key: 'followup_reason',
label: 'دلیل پیگیری',
type: 'select',
options: ['پیش‌فاکتور', 'پرداخت', 'ارسال کالا', 'پشتیبانی', 'تمدید قرارداد', 'سایر'],
},
{ key: 'followup_result', label: 'نتیجهٔ پیگیری', type: 'textarea' },
{ key: 'followup_next', label: 'تاریخ پیگیری بعدی', type: 'date' },
],
},
{
name: 'ایمیل',
baseType: 'note',
icon: 'mail',
color: 'slate',
fields: [
{ key: 'email_subject', label: 'موضوع ایمیل', type: 'text' },
{ key: 'email_to', label: 'گیرنده', type: 'text', placeholder: 'example@domain.com' },
],
},
];
export type TypeRow = {
id: string;
name: string;
baseType: ActivityType;
icon: string;
color: string;
fields: Prisma.JsonValue;
position: number;
isActive: boolean;
isSystem: boolean;
};
/** نوع اصلی هر baseType — مقصد فعالیت‌های بدون typeDefId */
export type SystemTypes = Record<ActivityType, ActivityTypeSummaryDto | null>;
export type ResolvedType = {
/** مقداری که در Activity.typeDefId ذخیره می‌شود (نوع اصلی → null) */
typeDefId: string | null;
baseType: ActivityType;
fields: ActivityFieldDef[];
};
const select = {
id: true,
name: true,
baseType: true,
icon: true,
color: true,
fields: true,
position: true,
isActive: true,
isSystem: true,
} satisfies Prisma.ActivityTypeDefSelect;
@Injectable()
export class ActivityTypesService {
constructor(private readonly prisma: PrismaService) {}
async ensureDefaults(organizationId: string): Promise<void> {
const count = await this.prisma.activityTypeDef.count({ where: { organizationId } });
if (count > 0) return;
await this.prisma.activityTypeDef.createMany({
data: DEFAULT_TYPES.map((t, i) => ({
organizationId,
name: t.name,
baseType: t.baseType,
icon: t.icon,
color: t.color,
isSystem: Boolean(t.isSystem),
fields: t.fields as unknown as Prisma.InputJsonValue,
position: i,
})),
skipDuplicates: true,
});
}
async rows(organizationId: string): Promise<TypeRow[]> {
await this.ensureDefaults(organizationId);
return this.prisma.activityTypeDef.findMany({
where: { organizationId },
orderBy: [{ position: 'asc' }, { createdAt: 'asc' }],
select,
});
}
async list(organizationId: string): Promise<ActivityTypeDefDto[]> {
return (await this.rows(organizationId)).map((r) => this.toDto(r));
}
systemOf(rows: TypeRow[]): SystemTypes {
const out: SystemTypes = { call: null, meeting: null, task: null, note: null };
for (const r of rows) {
if (r.isSystem && !out[r.baseType]) out[r.baseType] = this.toSummary(r);
}
return out;
}
async system(organizationId: string): Promise<SystemTypes> {
return this.systemOf(await this.rows(organizationId));
}
/** فیلتر Prisma برای فعالیت‌هایی که «مؤثراً» از این نوع هستند */
whereForType(rows: TypeRow[], typeDefId: string): Prisma.ActivityWhereInput {
const def = rows.find((r) => r.id === typeDefId);
if (!def) return { id: '__none__' };
if (!def.isSystem) return { typeDefId: def.id };
return { OR: [{ typeDefId: def.id }, { typeDefId: null, type: def.baseType }] };
}
/** جستجوی نام نوع → شرط‌های OR */
searchByName(rows: TypeRow[], term: string): Prisma.ActivityWhereInput[] {
const t = term.trim();
if (t.length < 2) return [];
return rows
.filter((r) => r.name.includes(t))
.map((r) => this.whereForType(rows, r.id));
}
/**
* نوع مؤثر برای ذخیره: typeDefId صریح، یا نوع اصلیِ type.
* نوع اصلی با typeDefId=null ذخیره می‌شود تا با داده‌های قدیمی یکسان باشد.
*/
async resolve(
organizationId: string,
input: { typeDefId?: string | null; type?: ActivityType | null },
): Promise<ResolvedType> {
const rows = await this.rows(organizationId);
const requested = input.typeDefId?.trim();
if (requested) {
const def = rows.find((r) => r.id === requested);
if (!def) throw new BadRequestException('نوع فعالیت انتخاب‌شده معتبر نیست');
return {
typeDefId: def.isSystem ? null : def.id,
baseType: def.baseType,
fields: this.parseFields(def.fields),
};
}
const baseType = input.type && BASE_TYPES.includes(input.type) ? input.type : 'task';
const sys = rows.find((r) => r.isSystem && r.baseType === baseType);
return { typeDefId: null, baseType, fields: sys ? this.parseFields(sys.fields) : [] };
}
/**
* پاک‌سازی مقادیر بر اساس تعریف فیلدها؛ فقط کلیدهای همین نوع نگه داشته می‌شوند.
* participantIds = کاربران معتبر همهٔ فیلدهای شرکت‌کنندگان.
*/
async sanitizeValues(
organizationId: string,
defs: ActivityFieldDef[],
raw: unknown,
): Promise<{ fields: ActivityFieldValues; participantIds: string[] }> {
const input =
raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {};
const out: ActivityFieldValues = {};
const participantFields: Array<{ key: string; value: ActivityParticipantsValue }> = [];
for (const def of defs) {
const value = this.cleanValue(def, input[def.key]);
if (value === null) {
if (def.required && def.type !== 'checkbox') {
throw new BadRequestException(`فیلد «${def.label}» را تکمیل کنید`);
}
continue;
}
if (def.type === 'participants') {
participantFields.push({ key: def.key, value: value as ActivityParticipantsValue });
}
out[def.key] = value;
}
const userIds = [...new Set(participantFields.flatMap((p) => p.value.userIds))];
const personIds = [...new Set(participantFields.flatMap((p) => p.value.people.map((x) => x.id)))];
const [validUsers, validPeople] = await Promise.all([
userIds.length
? this.prisma.user.findMany({
where: { id: { in: userIds }, memberships: { some: { organizationId } } },
select: { id: true },
})
: Promise.resolve([]),
personIds.length
? this.prisma.person.findMany({
where: { id: { in: personIds }, organizationId },
select: { id: true, firstName: true, lastName: true },
})
: Promise.resolve([]),
]);
const userSet = new Set(validUsers.map((u) => u.id));
const personNames = new Map(
validPeople.map((p) => [p.id, `${p.firstName} ${p.lastName}`.trim()]),
);
for (const p of participantFields) {
const next: ActivityParticipantsValue = {
userIds: p.value.userIds.filter((id) => userSet.has(id)),
people: p.value.people
.filter((x) => personNames.has(x.id))
.map((x) => ({ id: x.id, name: personNames.get(x.id) || x.name })),
guests: p.value.guests,
};
const def = defs.find((d) => d.key === p.key)!;
if (!next.userIds.length && !next.people.length && !next.guests.length) {
if (def.required) throw new BadRequestException(`فیلد «${def.label}» را تکمیل کنید`);
delete out[p.key];
} else {
out[p.key] = next;
}
}
const participantIds = [
...new Set(
Object.values(out)
.filter((v): v is ActivityParticipantsValue => isParticipants(v))
.flatMap((v) => v.userIds),
),
];
return { fields: out, participantIds };
}
async create(
organizationId: string,
dto: CreateActivityTypeRequest,
): Promise<ActivityTypeDefDto> {
await this.ensureDefaults(organizationId);
const max = await this.prisma.activityTypeDef.aggregate({
where: { organizationId },
_max: { position: true },
});
try {
const row = await this.prisma.activityTypeDef.create({
data: {
organizationId,
name: this.cleanName(dto.name),
baseType: this.cleanBase(dto.baseType),
icon: this.cleanIcon(dto.icon),
color: this.cleanColor(dto.color),
fields: this.cleanFieldDefs(dto.fields ?? []) as unknown as Prisma.InputJsonValue,
position: (max._max.position ?? -1) + 1,
},
select,
});
return this.toDto(row);
} catch (e) {
throw this.mapError(e);
}
}
async update(
organizationId: string,
id: string,
dto: UpdateActivityTypeRequest,
): Promise<ActivityTypeDefDto> {
const current = await this.get(organizationId, id);
if (current.isSystem && dto.baseType && dto.baseType !== current.baseType) {
throw new BadRequestException('نوع پایهٔ انواع اصلی قابل تغییر نیست');
}
if (current.isSystem && dto.isActive === false) {
throw new BadRequestException('انواع اصلی را نمی‌توان غیرفعال کرد');
}
const nextBase = dto.baseType ? this.cleanBase(dto.baseType) : current.baseType;
try {
const row = await this.prisma.$transaction(async (tx) => {
if (nextBase !== current.baseType) {
await tx.activity.updateMany({
where: { organizationId, typeDefId: id },
data: { type: nextBase },
});
}
return tx.activityTypeDef.update({
where: { id },
data: {
...(dto.name != null ? { name: this.cleanName(dto.name) } : {}),
...(dto.icon != null ? { icon: this.cleanIcon(dto.icon) } : {}),
...(dto.color != null ? { color: this.cleanColor(dto.color) } : {}),
...(dto.fields != null
? { fields: this.cleanFieldDefs(dto.fields) as unknown as Prisma.InputJsonValue }
: {}),
...(dto.isActive != null ? { isActive: dto.isActive } : {}),
baseType: nextBase,
},
select,
});
});
return this.toDto(row);
} catch (e) {
throw this.mapError(e);
}
}
async reorder(organizationId: string, typeIds: string[]): Promise<ActivityTypeDefDto[]> {
await this.prisma.$transaction(
typeIds.map((id, position) =>
this.prisma.activityTypeDef.updateMany({
where: { id, organizationId },
data: { position },
}),
),
);
return this.list(organizationId);
}
async remove(organizationId: string, id: string): Promise<void> {
const def = await this.get(organizationId, id);
if (def.isSystem) throw new BadRequestException('انواع اصلی قابل حذف نیستند');
// onDelete: SetNull → فعالیت‌ها به نوع اصلیِ نوع پایهٔ خود برمی‌گردند
await this.prisma.activityTypeDef.delete({ where: { id } });
}
toSummary(row: Pick<TypeRow, 'id' | 'name' | 'icon' | 'color'>): ActivityTypeSummaryDto {
return {
id: row.id,
name: row.name,
icon: this.cleanIcon(row.icon),
color: this.cleanColor(row.color),
};
}
parseFields(raw: Prisma.JsonValue): ActivityFieldDef[] {
if (!Array.isArray(raw)) return [];
return raw.filter(
(f): f is ActivityFieldDef & Prisma.JsonObject =>
Boolean(f) && typeof f === 'object' && !Array.isArray(f) && typeof (f as { key?: unknown }).key === 'string',
) as ActivityFieldDef[];
}
private async get(organizationId: string, id: string): Promise<TypeRow> {
const row = await this.prisma.activityTypeDef.findFirst({
where: { id, organizationId },
select,
});
if (!row) throw new NotFoundException('نوع فعالیت پیدا نشد');
return row;
}
private cleanValue(def: ActivityFieldDef, raw: unknown): ActivityFieldValue {
switch (def.type) {
case 'text':
return cleanText(raw, 500);
case 'textarea':
return cleanText(raw, 5000);
case 'url':
return cleanText(raw, 1000);
case 'number': {
if (raw === '' || raw == null) return null;
const n = typeof raw === 'number' ? raw : Number(String(raw).replace(/,/g, ''));
return Number.isFinite(n) ? n : null;
}
case 'date': {
const s = cleanText(raw, 40);
return s && /^\d{4}-\d{2}-\d{2}/.test(s) && !Number.isNaN(new Date(s).getTime())
? s
: null;
}
case 'select': {
const s = cleanText(raw, 200);
return s && (def.options ?? []).includes(s) ? s : null;
}
case 'multiselect': {
if (!Array.isArray(raw)) return null;
const opts = new Set(def.options ?? []);
const list = [...new Set(raw.filter((v): v is string => typeof v === 'string' && opts.has(v)))];
return list.length ? list : null;
}
case 'checkbox':
return raw === true ? true : raw === false ? false : null;
case 'checklist': {
if (!Array.isArray(raw)) return null;
const items: ActivityChecklistItem[] = [];
for (const it of raw.slice(0, MAX_CHECKLIST)) {
if (!it || typeof it !== 'object') continue;
const o = it as Record<string, unknown>;
const text = cleanText(o.text, 300);
if (!text) continue;
const id = typeof o.id === 'string' && o.id.trim() ? o.id.trim().slice(0, 64) : randomUUID();
items.push({ id, text, done: o.done === true });
}
return items.length ? items : null;
}
case 'participants': {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const o = raw as Record<string, unknown>;
const userIds = Array.isArray(o.userIds)
? [...new Set(o.userIds.filter((v): v is string => typeof v === 'string' && Boolean(v.trim())))]
: [];
const people = Array.isArray(o.people)
? o.people
.filter(
(p): p is { id: string; name?: unknown } =>
Boolean(p) && typeof p === 'object' && typeof (p as { id?: unknown }).id === 'string',
)
.map((p) => ({ id: p.id, name: cleanText(p.name, 200) ?? '' }))
: [];
const guests = Array.isArray(o.guests)
? [
...new Set(
o.guests
.map((g) => cleanText(g, 120))
.filter((g): g is string => Boolean(g)),
),
]
: [];
if (!userIds.length && !people.length && !guests.length) return null;
return {
userIds: userIds.slice(0, MAX_PARTICIPANTS),
people: people.slice(0, MAX_PARTICIPANTS),
guests: guests.slice(0, MAX_PARTICIPANTS),
};
}
default:
return null;
}
}
private cleanFieldDefs(raw: ActivityFieldDef[]): ActivityFieldDef[] {
if (!Array.isArray(raw)) throw new BadRequestException('تعریف فیلدها نامعتبر است');
if (raw.length > MAX_FIELDS) {
throw new BadRequestException(`حداکثر ${MAX_FIELDS} فیلد برای هر نوع مجاز است`);
}
const keys = new Set<string>();
return raw.map((f, i) => {
const label = typeof f?.label === 'string' ? f.label.trim() : '';
if (!label) throw new BadRequestException(`عنوان فیلد ${i + 1} لازم است`);
if (label.length > 80) throw new BadRequestException('عنوان فیلد حداکثر ۸۰ کاراکتر است');
const type = ACTIVITY_FIELD_TYPES.includes(f.type as ActivityFieldType)
? (f.type as ActivityFieldType)
: null;
if (!type) throw new BadRequestException(`نوع فیلد «${label}» معتبر نیست`);
let key = typeof f.key === 'string' ? f.key.trim().toLowerCase() : '';
if (!/^[a-z][a-z0-9_]{0,39}$/.test(key)) key = `f_${randomUUID().slice(0, 8)}`;
if (keys.has(key)) throw new BadRequestException(`کلید فیلد «${label}» تکراری است`);
keys.add(key);
const def: ActivityFieldDef = { key, label, type };
if (ACTIVITY_FIELD_TYPES_WITH_OPTIONS.includes(type)) {
const options = [
...new Set(
(Array.isArray(f.options) ? f.options : [])
.map((o) => (typeof o === 'string' ? o.trim() : ''))
.filter(Boolean)
.map((o) => o.slice(0, 200)),
),
].slice(0, MAX_OPTIONS);
if (!options.length) {
throw new BadRequestException(`برای فیلد «${label}» حداقل یک گزینه تعریف کنید`);
}
def.options = options;
}
if (f.required) def.required = true;
const placeholder = typeof f.placeholder === 'string' ? f.placeholder.trim().slice(0, 200) : '';
if (placeholder) def.placeholder = placeholder;
return def;
});
}
private cleanName(raw: string): string {
const name = raw?.trim() ?? '';
if (!name) throw new BadRequestException('نام نوع فعالیت لازم است');
if (name.length > 60) throw new BadRequestException('نام نوع فعالیت حداکثر ۶۰ کاراکتر است');
return name;
}
private cleanBase(raw?: ActivityType): ActivityType {
return raw && BASE_TYPES.includes(raw) ? raw : 'task';
}
private cleanIcon(raw?: string): ActivityTypeIcon {
return (ACTIVITY_TYPE_ICONS as readonly string[]).includes(raw ?? '')
? (raw as ActivityTypeIcon)
: 'check';
}
private cleanColor(raw?: string): ActivityStageColor {
return ACTIVITY_STAGE_COLORS.includes(raw as ActivityStageColor)
? (raw as ActivityStageColor)
: 'green';
}
private mapError(e: unknown) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
return new BadRequestException('نوع فعالیتی با این نام وجود دارد');
}
return e;
}
private toDto(row: TypeRow): ActivityTypeDefDto {
return {
...this.toSummary(row),
baseType: row.baseType,
fields: this.parseFields(row.fields),
position: row.position,
isActive: row.isActive,
isSystem: row.isSystem,
};
}
}
function cleanText(raw: unknown, max: number): string | null {
if (typeof raw !== 'string' && typeof raw !== 'number') return null;
const s = String(raw).trim();
return s ? s.slice(0, max) : null;
}
function isParticipants(v: unknown): v is ActivityParticipantsValue {
return Boolean(v) && typeof v === 'object' && !Array.isArray(v) && Array.isArray((v as ActivityParticipantsValue).userIds);
}
+86
View File
@@ -6,6 +6,7 @@ import {
IsDateString,
IsIn,
IsInt,
IsObject,
IsOptional,
IsString,
Max,
@@ -14,6 +15,8 @@ import {
MinLength,
ValidateIf,
} from 'class-validator';
import type { ActivityFieldDef, ActivityFieldValues, ActivityTypeIcon } from '@mento/shared';
import { ACTIVITY_TYPE_ICONS } from '@mento/shared';
/** تکه‌های JSON از ایمپورت مرورگرگرا */
export class ImportActivityRowsDto {
@@ -66,6 +69,11 @@ export class ListActivitiesQueryDto {
@IsString()
assigneeId?: string;
/** نوع فعالیت (تعریف‌شده در سازمان) */
@IsOptional()
@IsString()
typeDefId?: string;
/** شناسهٔ مرحله یا 'all' — حالت تب/کانبان مراحل */
@IsOptional()
@IsString()
@@ -151,6 +159,66 @@ export class MoveActivityStageDto {
stageId!: string;
}
/** محتوای fields در سرویس بر اساس تعریف نوع اعتبارسنجی می‌شود */
export class CreateActivityTypeDto {
@IsString()
@MinLength(1)
@MaxLength(60)
name!: string;
@IsOptional()
@IsIn(ACTIVITY_TYPES)
baseType?: (typeof ACTIVITY_TYPES)[number];
@IsOptional()
@IsIn(ACTIVITY_TYPE_ICONS)
icon?: ActivityTypeIcon;
@IsOptional()
@IsIn(STAGE_COLORS)
color?: (typeof STAGE_COLORS)[number];
@IsOptional()
@IsArray()
@Allow()
fields?: ActivityFieldDef[];
}
export class UpdateActivityTypeDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(60)
name?: string;
@IsOptional()
@IsIn(ACTIVITY_TYPES)
baseType?: (typeof ACTIVITY_TYPES)[number];
@IsOptional()
@IsIn(ACTIVITY_TYPE_ICONS)
icon?: ActivityTypeIcon;
@IsOptional()
@IsIn(STAGE_COLORS)
color?: (typeof STAGE_COLORS)[number];
@IsOptional()
@IsArray()
@Allow()
fields?: ActivityFieldDef[];
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class ReorderActivityTypesDto {
@IsArray()
@IsString({ each: true })
typeIds!: string[];
}
export class CreateActivityDto {
@IsString()
@MinLength(2)
@@ -166,6 +234,15 @@ export class CreateActivityDto {
@IsIn(ACTIVITY_TYPES)
type?: (typeof ACTIVITY_TYPES)[number];
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
typeDefId?: string | null;
@IsOptional()
@IsObject()
fields?: ActivityFieldValues;
@IsOptional()
@IsIn(ACTIVITY_PRIORITIES)
priority?: (typeof ACTIVITY_PRIORITIES)[number];
@@ -242,6 +319,15 @@ export class UpdateActivityDto {
@IsIn(ACTIVITY_TYPES)
type?: (typeof ACTIVITY_TYPES)[number];
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
typeDefId?: string | null;
@IsOptional()
@IsObject()
fields?: ActivityFieldValues;
@IsOptional()
@IsIn(ACTIVITY_STATUSES)
status?: (typeof ACTIVITY_STATUSES)[number];
+15
View File
@@ -124,6 +124,21 @@ export function assigneeWhere(
};
}
/** دسترسی به فعالیت: مسئولان یا شرکت‌کنندگان (فیلد شرکت‌کنندگان) در محدوده دید */
export function activityAccessWhere(
actor: ScopedActor,
): Prisma.ActivityWhereInput {
const ids = scopedOwnerIds(actor, 'activities');
if (!ids) return {};
return {
OR: [
{ assigneeId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
{ participantIds: { hasSome: ids } },
],
};
}
/** یادداشت: نویسنده یا پرونده‌های تحت مالکیت در محدوده دید + محرمانگی شخصی */
export function noteAccessWhere(
actor: ScopedActor,