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:
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user