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
@@ -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,