From 13668c4ba4b74db91c5e063b7e66c2dd16101cd2 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Wed, 7 Oct 2026 12:29:23 +0330 Subject: [PATCH] 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 --- .../migration.sql | 37 + apps/api/prisma/schema.prisma | 33 + .../src/activities/activities.controller.ts | 45 + apps/api/src/activities/activities.module.ts | 10 +- apps/api/src/activities/activities.service.ts | 188 +++- .../src/activities/activity-types.service.ts | 649 ++++++++++++++ apps/api/src/activities/dto.ts | 86 ++ apps/api/src/common/record-scope.ts | 15 + .../activities/ActivitiesKanban.vue | 11 +- .../activities/ActivityFieldsEditor.vue | 674 ++++++++++++++ .../activities/ActivityFieldsView.vue | 263 ++++++ .../activities/ActivityTypesManager.vue | 821 ++++++++++++++++++ apps/web/src/lib/activity-types.ts | 111 +++ apps/web/src/views/ActivitiesView.vue | 173 +++- apps/web/src/views/ActivityDetailView.vue | 99 ++- apps/web/src/views/ActivityFormView.vue | 144 ++- packages/shared/src/index.ts | 159 ++++ 17 files changed, 3371 insertions(+), 147 deletions(-) create mode 100644 apps/api/prisma/migrations/20261007100000_activity_type_defs/migration.sql create mode 100644 apps/api/src/activities/activity-types.service.ts create mode 100644 apps/web/src/components/activities/ActivityFieldsEditor.vue create mode 100644 apps/web/src/components/activities/ActivityFieldsView.vue create mode 100644 apps/web/src/components/activities/ActivityTypesManager.vue create mode 100644 apps/web/src/lib/activity-types.ts diff --git a/apps/api/prisma/migrations/20261007100000_activity_type_defs/migration.sql b/apps/api/prisma/migrations/20261007100000_activity_type_defs/migration.sql new file mode 100644 index 0000000..7d46906 --- /dev/null +++ b/apps/api/prisma/migrations/20261007100000_activity_type_defs/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 4d0000d..87a93e6 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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]) diff --git a/apps/api/src/activities/activities.controller.ts b/apps/api/src/activities/activities.controller.ts index 831a687..268c70b 100644 --- a/apps/api/src/activities/activities.controller.ts +++ b/apps/api/src/activities/activities.controller.ts @@ -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, }, diff --git a/apps/api/src/activities/activities.module.ts b/apps/api/src/activities/activities.module.ts index f67e796..fb69c95 100644 --- a/apps/api/src/activities/activities.module.ts +++ b/apps/api/src/activities/activities.module.ts @@ -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 {} diff --git a/apps/api/src/activities/activities.service.ts b/apps/api/src/activities/activities.service.ts index 2ef0eb2..1a261f9 100644 --- a/apps/api/src/activities/activities.service.ts +++ b/apps/api/src/activities/activities.service.ts @@ -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; @@ -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 { + const [stages, types] = await Promise.all([ + this.stages.defaults(organizationId), + this.types.system(organizationId), + ]); + return { stages, types }; + } + async summary( organizationId: string, actor: ActivityActor, ): Promise { 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> { + 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 { 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 = { + 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(); diff --git a/apps/api/src/activities/activity-types.service.ts b/apps/api/src/activities/activity-types.service.ts new file mode 100644 index 0000000..8762ca0 --- /dev/null +++ b/apps/api/src/activities/activity-types.service.ts @@ -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; + +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 { + 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 { + await this.ensureDefaults(organizationId); + return this.prisma.activityTypeDef.findMany({ + where: { organizationId }, + orderBy: [{ position: 'asc' }, { createdAt: 'asc' }], + select, + }); + } + + async list(organizationId: string): Promise { + 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 { + 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 { + 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) : {}; + 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 { + 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 { + 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 { + 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 { + 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): 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 { + 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; + 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; + 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(); + 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); +} diff --git a/apps/api/src/activities/dto.ts b/apps/api/src/activities/dto.ts index 7ec0e8a..6ec35bc 100644 --- a/apps/api/src/activities/dto.ts +++ b/apps/api/src/activities/dto.ts @@ -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]; diff --git a/apps/api/src/common/record-scope.ts b/apps/api/src/common/record-scope.ts index 39b18f7..a1bef18 100644 --- a/apps/api/src/common/record-scope.ts +++ b/apps/api/src/common/record-scope.ts @@ -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, diff --git a/apps/web/src/components/activities/ActivitiesKanban.vue b/apps/web/src/components/activities/ActivitiesKanban.vue index bb3ab37..50f27ae 100644 --- a/apps/web/src/components/activities/ActivitiesKanban.vue +++ b/apps/web/src/components/activities/ActivitiesKanban.vue @@ -5,10 +5,10 @@ import axios from 'axios'; import type { ActivityDto, ActivityStageDto, - ActivityType, PaginatedResponse, } from '@mento/shared'; import { api } from '@/lib/api'; +import { activityTypeView } from '@/lib/activity-types'; import { calendarDaysBetween, formatClockFa, formatJalaliDay, startOfLocalDay, toFaDigits } from '@/lib/jalali'; import PipelineBoard from '@/components/pipeline/PipelineBoard.vue'; import DealCardOverlay from '@/components/pipeline/DealCardOverlay.vue'; @@ -59,13 +59,6 @@ const moving = ref(false); const stageBusy = ref(false); const localOrder = ref(null); -const typeLabel: Record = { - call: 'تماس', - meeting: 'جلسه', - task: 'وظیفه', - note: 'پیام', -}; - const KIND_BADGE: Record = { open: undefined, done: 'انجام‌شده', @@ -109,7 +102,7 @@ function toCard(item: ActivityDto, stageId: string): PipelineDeal { return { id: item.id, title: item.title, - description: [typeLabel[item.type], party].filter(Boolean).join(' · '), + description: [activityTypeView(item).name, party].filter(Boolean).join(' · '), amount: null, date: dateText(item), initials: initialsFromTitle(party || item.title), diff --git a/apps/web/src/components/activities/ActivityFieldsEditor.vue b/apps/web/src/components/activities/ActivityFieldsEditor.vue new file mode 100644 index 0000000..7b5dfa2 --- /dev/null +++ b/apps/web/src/components/activities/ActivityFieldsEditor.vue @@ -0,0 +1,674 @@ + + +