diff --git a/apps/api/prisma/migrations/20261020180000_activity_boards/migration.sql b/apps/api/prisma/migrations/20261020180000_activity_boards/migration.sql new file mode 100644 index 0000000..c3ae202 --- /dev/null +++ b/apps/api/prisma/migrations/20261020180000_activity_boards/migration.sql @@ -0,0 +1,57 @@ +-- CreateTable +CREATE TABLE "ActivityBoard" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "isDefault" BOOLEAN NOT NULL DEFAULT false, + "position" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ActivityBoard_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ActivityBoard_organizationId_name_key" ON "ActivityBoard"("organizationId", "name"); + +-- CreateIndex +CREATE INDEX "ActivityBoard_organizationId_position_idx" ON "ActivityBoard"("organizationId", "position"); + +-- AddForeignKey +ALTER TABLE "ActivityBoard" ADD CONSTRAINT "ActivityBoard_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Backfill: one default board per organization that already has activity stages +INSERT INTO "ActivityBoard" ("id", "organizationId", "name", "isDefault", "position", "createdAt", "updatedAt") +SELECT 'actb_' || md5(s."organizationId" || clock_timestamp()::text), s."organizationId", 'کارهای اصلی', true, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +FROM (SELECT DISTINCT "organizationId" FROM "ActivityStage") AS s; + +-- AlterTable: stages belong to a board +ALTER TABLE "ActivityStage" ADD COLUMN "boardId" TEXT; + +UPDATE "ActivityStage" AS st +SET "boardId" = b."id" +FROM "ActivityBoard" AS b +WHERE b."organizationId" = st."organizationId" AND b."isDefault" = true; + +ALTER TABLE "ActivityStage" ALTER COLUMN "boardId" SET NOT NULL; + +-- Stage names are unique per board (not per organization) +DROP INDEX "ActivityStage_organizationId_name_key"; + +-- CreateIndex +CREATE UNIQUE INDEX "ActivityStage_boardId_name_key" ON "ActivityStage"("boardId", "name"); + +-- CreateIndex +CREATE INDEX "ActivityStage_boardId_position_idx" ON "ActivityStage"("boardId", "position"); + +-- AddForeignKey +ALTER TABLE "ActivityStage" ADD CONSTRAINT "ActivityStage_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "ActivityBoard"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AlterTable: activities optionally point at a board (null = default board) +ALTER TABLE "Activity" ADD COLUMN "boardId" TEXT; + +-- CreateIndex +CREATE INDEX "Activity_boardId_idx" ON "Activity"("boardId"); + +-- AddForeignKey +ALTER TABLE "Activity" ADD CONSTRAINT "Activity_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "ActivityBoard"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 4160431..4d7e0b6 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -224,6 +224,7 @@ model Organization { reservationAppointments ReservationAppointment[] leads Lead[] leadStages LeadStage[] + activityBoards ActivityBoard[] activityStages ActivityStage[] activityTypeDefs ActivityTypeDef[] leadEvents LeadEvent[] @@ -686,10 +687,29 @@ model Deal { } /// مراحل/ستون‌های فعالیت (تب و کانبان) — kind وضعیت فعالیت داخل این مرحله را تعیین می‌کند +/// گروه فعالیت (مثل مسیر فروش برای معاملات): هر گروه ستون‌های خودش را دارد +model ActivityBoard { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + name String + isDefault Boolean @default(false) + position Int @default(0) + stages ActivityStage[] + activities Activity[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([organizationId, name]) + @@index([organizationId, position]) +} + model ActivityStage { id String @id @default(cuid()) organizationId String organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + boardId String + board ActivityBoard @relation(fields: [boardId], references: [id], onDelete: Cascade) name String position Int @default(0) color String @default("green") @@ -698,8 +718,9 @@ model ActivityStage { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - @@unique([organizationId, name]) + @@unique([boardId, name]) @@index([organizationId, position]) + @@index([boardId, position]) } /// نوع فعالیت قابل تعریف توسط سازمان با فیلدهای اختصاصی @@ -764,6 +785,9 @@ model Activity { createdBy User? @relation("ActivityCreator", fields: [createdById], references: [id], onDelete: SetNull) notes Note[] routineTasks RoutineTask[] + /// گروه فعالیت؛ null = گروه پیش‌فرض محیط + boardId String? + board ActivityBoard? @relation(fields: [boardId], references: [id], onDelete: SetNull) /// مرحله (ستون کانبان)؛ null یا مرحلهٔ ناهم‌نوع با status → اولین مرحلهٔ هم‌نوع stageId String? stage ActivityStage? @relation(fields: [stageId], references: [id], onDelete: SetNull) @@ -781,6 +805,7 @@ model Activity { updatedAt DateTime @updatedAt @@index([stageId]) + @@index([boardId]) @@index([typeDefId]) @@index([leadId]) @@index([organizationId, status, dueAt]) diff --git a/apps/api/src/activities/activities.controller.ts b/apps/api/src/activities/activities.controller.ts index 7c76807..481c9d2 100644 --- a/apps/api/src/activities/activities.controller.ts +++ b/apps/api/src/activities/activities.controller.ts @@ -27,14 +27,17 @@ import { ActivitiesExcelService } from './activities-excel.service'; import { ActivityStagesService } from './activity-stages.service'; import { ActivityTypesService } from './activity-types.service'; import { + CreateActivityBoardDto, CreateActivityDto, CreateActivityStageDto, CreateActivityTypeDto, ImportActivityRowsDto, ListActivitiesQueryDto, + ListActivityStagesQueryDto, MoveActivityStageDto, ReorderActivityStagesDto, ReorderActivityTypesDto, + UpdateActivityBoardDto, UpdateActivityDto, UpdateActivityStageDto, UpdateActivityTypeDto, @@ -65,9 +68,40 @@ export class ActivitiesController { return this.activitiesService.summary(user.organizationId, user, scope); } + @Get('boards') + listBoards(@CurrentUser() user: Actor) { + return this.stagesService.listBoards(user.organizationId); + } + + @Post('boards') + @UseGuards(RolesGuard) + @Roles('owner', 'admin') + createBoard(@CurrentUser() user: Actor, @Body() dto: CreateActivityBoardDto) { + return this.stagesService.createBoard(user.organizationId, dto.name); + } + + @Patch('boards/:id') + @UseGuards(RolesGuard) + @Roles('owner', 'admin') + updateBoard( + @CurrentUser() user: Actor, + @Param('id') id: string, + @Body() dto: UpdateActivityBoardDto, + ) { + return this.stagesService.updateBoard(user.organizationId, id, dto); + } + + @Delete('boards/:id') + @UseGuards(RolesGuard) + @Roles('owner', 'admin') + async removeBoard(@CurrentUser() user: Actor, @Param('id') id: string) { + await this.stagesService.removeBoard(user.organizationId, id); + return { ok: true }; + } + @Get('stages') - listStages(@CurrentUser() user: Actor) { - return this.stagesService.list(user.organizationId); + listStages(@CurrentUser() user: Actor, @Query() query: ListActivityStagesQueryDto) { + return this.stagesService.list(user.organizationId, query.boardId); } @Post('stages') @@ -157,6 +191,7 @@ export class ActivitiesController { typeDefId: query.typeDefId, status: query.status, assigneeId: query.assigneeId, + boardId: query.boardId, }, ); } @@ -189,6 +224,7 @@ export class ActivitiesController { typeDefId: query.typeDefId, status: query.status, assigneeId: query.assigneeId, + boardId: query.boardId, }, query.stageId ? { stageId: query.stageId } : undefined, ); diff --git a/apps/api/src/activities/activities.service.ts b/apps/api/src/activities/activities.service.ts index 59d8616..887ec8a 100644 --- a/apps/api/src/activities/activities.service.ts +++ b/apps/api/src/activities/activities.service.ts @@ -30,14 +30,14 @@ import { GamificationService } from '../gamification/gamification.service'; import { tehranDayKey } from '../routines/routine-dates'; import { buildSearchWhere, ci } from '../common/search'; import { activityPriorWhere } from '../common/prior-archive'; -import { ActivityStagesService, type StageDefaults } from './activity-stages.service'; +import { ActivityStagesService, type BoardStageContext } from './activity-stages.service'; import { ActivityTypesService, type SystemTypes, type TypeRow, } from './activity-types.service'; -type DtoContext = { stages?: StageDefaults; types?: SystemTypes }; +type DtoContext = { stages?: BoardStageContext; types?: SystemTypes }; type ActivityActor = Pick; @@ -49,6 +49,8 @@ type ListFacets = { typeDefId?: string; status?: ActivityDto['status']; assigneeId?: string; + /** گروه فعالیت؛ خالی = همهٔ گروه‌ها */ + boardId?: string; }; type ActivityRow = { @@ -77,14 +79,15 @@ type ActivityRow = { assignee: { id: string; fullName: string } | null; assignees: Array<{ id: string; fullName: string }>; createdBy: { id: string; fullName: string } | null; - stage?: { id: string; kind: ActivityDto['status'] } | null; + boardId?: string | null; + stage?: { id: string; kind: ActivityDto['status']; boardId: string } | null; typeDef?: { id: string; name: string; icon: string; color: string } | null; fields?: Prisma.JsonValue | null; participantIds?: string[]; }; const include = { - stage: { select: { id: true, kind: true } }, + stage: { select: { id: true, kind: true, boardId: true } }, typeDef: { select: { id: true, name: true, icon: true, color: true } }, person: { select: { id: true, firstName: true, lastName: true } }, company: { select: { id: true, name: true } }, @@ -109,7 +112,7 @@ export class ActivitiesService { private async ctx(organizationId: string): Promise { const [stages, types] = await Promise.all([ - this.stages.defaults(organizationId), + this.stages.boardContext(organizationId), this.types.system(organizationId), ]); return { stages, types }; @@ -255,13 +258,20 @@ export class ActivitiesService { stageScope, typeRows, ); + const boardCtx = await this.stages.boardContext(organizationId); + if (facets?.boardId) { + const boardId = await this.stages.resolveBoardId(organizationId, facets.boardId); + (where.AND as Prisma.ActivityWhereInput[]).push( + this.stages.boardWhere(boardId, boardCtx.defaultBoardId), + ); + } if (stageScope && stageScope.stageId !== 'all') { (where.AND as Prisma.ActivityWhereInput[]).push( await this.stages.whereForStage(organizationId, stageScope.stageId), ); } const ctx: DtoContext = { - stages: await this.stages.defaults(organizationId), + stages: boardCtx, types: this.types.systemOf(typeRows), }; const take = Math.min(Math.max(limit, 1), 200); @@ -293,7 +303,9 @@ export class ActivitiesService { desk?: 'mine' | 'team', facets?: ListFacets, ): Promise { - const rows = await this.stages.rows(organizationId); + const boardId = await this.stages.resolveBoardId(organizationId, facets?.boardId); + const defaultBoard = await this.stages.ensureDefaultBoard(organizationId); + const rows = await this.stages.rows(organizationId, boardId); const defaults = this.stages.defaultsOf(rows); const kindOf = new Map(rows.map((r) => [r.id, r.kind])); const where = this.buildWhere( @@ -308,6 +320,9 @@ export class ActivitiesService { { stageId: 'all' }, await this.types.rows(organizationId), ); + (where.AND as Prisma.ActivityWhereInput[]).push( + this.stages.boardWhere(boardId, defaultBoard.id), + ); const groups = await this.prisma.activity.groupBy({ by: ['status', 'stageId'], where, @@ -341,6 +356,7 @@ export class ActivitiesService { where: { id }, data: { stageId: stage.id, + boardId: stage.boardId, status: stage.kind, completedAt: stage.kind === 'done' @@ -401,6 +417,12 @@ export class ActivitiesService { const initialStage = initialStageId ? await this.stages.get(organizationId, initialStageId) : null; + const requestedBoardId = emptyToNull(dto.boardId ?? null); + const boardId = initialStage + ? initialStage.boardId + : requestedBoardId + ? await this.stages.resolveBoardId(organizationId, requestedBoardId) + : null; const resolved = await this.types.resolve(organizationId, { typeDefId: dto.typeDefId, type: dto.type, @@ -414,6 +436,7 @@ export class ActivitiesService { const row = await this.prisma.activity.create({ data: { organizationId, + boardId, ...(initialStage ? { stageId: initialStage.id, @@ -513,6 +536,19 @@ export class ActivitiesService { }); const data: Prisma.ActivityUncheckedUpdateInput = {}; if (dto.title !== undefined) data.title = dto.title.trim(); + if ('boardId' in dto) { + const nextBoardId = await this.stages.resolveBoardId(organizationId, dto.boardId); + const current = await this.prisma.activity.findUnique({ + where: { id }, + select: { boardId: true }, + }); + const defaultBoard = await this.stages.ensureDefaultBoard(organizationId); + if (nextBoardId !== (current?.boardId ?? defaultBoard.id)) { + // ستون قبلی مال گروه دیگر است؛ فعالیت به ستون اولِ هم‌وضعیت گروه جدید می‌رود + data.boardId = nextBoardId; + data.stageId = null; + } + } let addedParticipants: string[] = []; const typeChanged = 'typeDefId' in dto || dto.type !== undefined; if (typeChanged || dto.fields !== undefined) { @@ -922,6 +958,7 @@ export class ActivitiesService { ? (row.fields as unknown as ActivityFieldValues) : {}; return { + boardId: this.stages.effectiveBoardId(row, ctx?.stages), stageId: this.stages.effectiveStageId(row, ctx?.stages), typeDef: row.typeDef ? this.types.toSummary(row.typeDef) diff --git a/apps/api/src/activities/activity-stages.service.ts b/apps/api/src/activities/activity-stages.service.ts index 5a62a58..7cf77cc 100644 --- a/apps/api/src/activities/activity-stages.service.ts +++ b/apps/api/src/activities/activity-stages.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import type { + ActivityBoardDto, ActivityStageColor, ActivityStageDto, ActivityStatus, @@ -8,6 +9,8 @@ import { ACTIVITY_STAGE_COLORS } from '@mento/shared'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; +const DEFAULT_BOARD_NAME = 'کارهای اصلی'; + const DEFAULT_STAGES: Array<{ name: string; color: ActivityStageColor; kind: ActivityStatus }> = [ { name: 'در صف', color: 'orange', kind: 'open' }, { name: 'در حال انجام', color: 'blue', kind: 'open' }, @@ -19,24 +22,84 @@ const KINDS: ActivityStatus[] = ['open', 'done', 'canceled']; type StageRow = { id: string; + boardId: string; name: string; position: number; color: string; kind: ActivityStatus; }; +type BoardRow = { + id: string; + name: string; + isDefault: boolean; + position: number; +}; + +const STAGE_SELECT = { + id: true, + boardId: true, + name: true, + position: true, + color: true, + kind: true, +} satisfies Prisma.ActivityStageSelect; + +const BOARD_SELECT = { + id: true, + name: true, + isDefault: true, + position: true, +} satisfies Prisma.ActivityBoardSelect; + export type StageDefaults = Record; +/** پیش‌فرض‌های هر گروه برای محاسبهٔ «ستون مؤثر» فعالیت‌ها */ +export type BoardStageContext = { + defaultBoardId: string; + defaults: Map; +}; + @Injectable() export class ActivityStagesService { constructor(private readonly prisma: PrismaService) {} - async ensureDefaults(organizationId: string): Promise { - const count = await this.prisma.activityStage.count({ where: { organizationId } }); + // —— گروه‌ها —— + + /** گروه پیش‌فرض محیط را (با ستون‌های اولیه) تضمین می‌کند */ + async ensureDefaultBoard(organizationId: string): Promise { + let board = await this.prisma.activityBoard.findFirst({ + where: { organizationId, isDefault: true }, + select: BOARD_SELECT, + }); + if (!board) { + board = await this.prisma.activityBoard + .create({ + data: { organizationId, name: DEFAULT_BOARD_NAME, isDefault: true, position: 0 }, + select: BOARD_SELECT, + }) + .catch(async (e) => { + // ساخت هم‌زمان در دو درخواست + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { + return this.prisma.activityBoard.findFirstOrThrow({ + where: { organizationId, isDefault: true }, + select: BOARD_SELECT, + }); + } + throw e; + }); + } + await this.ensureBoardStages(organizationId, board.id); + return board; + } + + private async ensureBoardStages(organizationId: string, boardId: string) { + const count = await this.prisma.activityStage.count({ where: { boardId } }); if (count > 0) return; await this.prisma.activityStage.createMany({ data: DEFAULT_STAGES.map((s, i) => ({ organizationId, + boardId, name: s.name, color: s.color, kind: s.kind, @@ -46,17 +109,112 @@ export class ActivityStagesService { }); } - async rows(organizationId: string): Promise { - await this.ensureDefaults(organizationId); - return this.prisma.activityStage.findMany({ + async listBoards(organizationId: string): Promise { + await this.ensureDefaultBoard(organizationId); + const rows = await this.prisma.activityBoard.findMany({ where: { organizationId }, + orderBy: [{ isDefault: 'desc' }, { position: 'asc' }, { createdAt: 'asc' }], + select: BOARD_SELECT, + }); + return rows.map((b) => ({ ...b })); + } + + /** شناسهٔ گروه معتبر؛ خالی = گروه پیش‌فرض */ + async resolveBoardId(organizationId: string, boardId?: string | null): Promise { + const id = boardId?.trim(); + if (!id) return (await this.ensureDefaultBoard(organizationId)).id; + const board = await this.prisma.activityBoard.findFirst({ + where: { id, organizationId }, + select: { id: true }, + }); + if (!board) throw new BadRequestException('گروه فعالیت انتخاب‌شده معتبر نیست'); + return board.id; + } + + async createBoard(organizationId: string, name: string): Promise { + await this.ensureDefaultBoard(organizationId); + const max = await this.prisma.activityBoard.aggregate({ + where: { organizationId }, + _max: { position: true }, + }); + try { + const board = await this.prisma.activityBoard.create({ + data: { + organizationId, + name: this.cleanBoardName(name), + position: (max._max.position ?? 0) + 1, + }, + select: BOARD_SELECT, + }); + await this.ensureBoardStages(organizationId, board.id); + return { ...board }; + } catch (e) { + throw this.mapBoardError(e); + } + } + + async updateBoard( + organizationId: string, + id: string, + dto: { name?: string }, + ): Promise { + await this.getBoard(organizationId, id); + try { + const board = await this.prisma.activityBoard.update({ + where: { id }, + data: dto.name != null ? { name: this.cleanBoardName(dto.name) } : {}, + select: BOARD_SELECT, + }); + return { ...board }; + } catch (e) { + throw this.mapBoardError(e); + } + } + + /** فعالیت‌های گروه حذف‌شده به گروه پیش‌فرض (ستون اولِ هم‌وضعیت) منتقل می‌شوند */ + async removeBoard(organizationId: string, id: string): Promise { + const board = await this.getBoard(organizationId, id); + if (board.isDefault) { + throw new BadRequestException('گروه پیش‌فرض قابل حذف نیست'); + } + await this.prisma.$transaction([ + this.prisma.activity.updateMany({ + where: { organizationId, boardId: id }, + data: { boardId: null, stageId: null }, + }), + this.prisma.activityBoard.delete({ where: { id } }), + ]); + } + + private async getBoard(organizationId: string, id: string): Promise { + const board = await this.prisma.activityBoard.findFirst({ + where: { id, organizationId }, + select: BOARD_SELECT, + }); + if (!board) throw new NotFoundException('گروه فعالیت پیدا نشد'); + return board; + } + + /** شرط Prisma برای فعالیت‌های یک گروه (فعالیت بدون گروه = گروه پیش‌فرض) */ + boardWhere(boardId: string, defaultBoardId: string): Prisma.ActivityWhereInput { + return boardId === defaultBoardId + ? { OR: [{ boardId }, { boardId: null }] } + : { boardId }; + } + + // —— ستون‌ها —— + + async rows(organizationId: string, boardId?: string | null): Promise { + const resolved = await this.resolveBoardId(organizationId, boardId); + return this.prisma.activityStage.findMany({ + where: { organizationId, boardId: resolved }, orderBy: [{ position: 'asc' }, { createdAt: 'asc' }], - select: { id: true, name: true, position: true, color: true, kind: true }, + select: STAGE_SELECT, }); } - async list(organizationId: string): Promise { - return (await this.rows(organizationId)).map((r) => this.toDto(r)); + async list(organizationId: string, boardId?: string | null): Promise { + return (await this.rows(organizationId, boardId)).map((r) => this.toDto(r)); } /** اولین مرحلهٔ هر نوع = مقصد فعالیت‌های بدون مرحله یا با مرحلهٔ ناهم‌نوع */ @@ -68,8 +226,23 @@ export class ActivityStagesService { return out; } - async defaults(organizationId: string): Promise { - return this.defaultsOf(await this.rows(organizationId)); + /** پیش‌فرض‌های ستون برای همهٔ گروه‌های محیط */ + async boardContext(organizationId: string): Promise { + const defaultBoard = await this.ensureDefaultBoard(organizationId); + const rows = await this.prisma.activityStage.findMany({ + where: { organizationId }, + orderBy: [{ position: 'asc' }, { createdAt: 'asc' }], + select: STAGE_SELECT, + }); + const byBoard = new Map(); + for (const r of rows) { + const list = byBoard.get(r.boardId) ?? []; + list.push(r); + byBoard.set(r.boardId, list); + } + const defaults = new Map(); + for (const [boardId, list] of byBoard) defaults.set(boardId, this.defaultsOf(list)); + return { defaultBoardId: defaultBoard.id, defaults }; } /** شرط Prisma برای فعالیت‌هایی که «مؤثراً» در این مرحله هستند */ @@ -77,33 +250,54 @@ export class ActivityStagesService { organizationId: string, stageId: string, ): Promise { - const rows = await this.rows(organizationId); - const stage = rows.find((r) => r.id === stageId); - if (!stage) throw new BadRequestException('مرحلهٔ انتخاب‌شده معتبر نیست'); - const isDefault = this.defaultsOf(rows)[stage.kind] === stage.id; - if (!isDefault) return { status: stage.kind, stageId: stage.id }; + const stage = await this.get(organizationId, stageId); + const defaultBoard = await this.ensureDefaultBoard(organizationId); + const boardRows = await this.rows(organizationId, stage.boardId); + const inBoard = this.boardWhere(stage.boardId, defaultBoard.id); + const isDefault = this.defaultsOf(boardRows)[stage.kind] === stage.id; + if (!isDefault) { + return { AND: [inBoard, { status: stage.kind, stageId: stage.id }] }; + } return { - status: stage.kind, - OR: [ - { stageId: stage.id }, - { stageId: null }, - { stage: { kind: { not: stage.kind } } }, + AND: [ + inBoard, + { status: stage.kind }, + { + OR: [ + { stageId: stage.id }, + { stageId: null }, + { stage: { kind: { not: stage.kind } } }, + { stage: { boardId: { not: stage.boardId } } }, + ], + }, ], }; } + effectiveBoardId(row: { boardId?: string | null }, ctx?: BoardStageContext): string | null { + return row.boardId ?? ctx?.defaultBoardId ?? null; + } + effectiveStageId( - row: { status: ActivityStatus; stage?: { id: string; kind: ActivityStatus } | null }, - defaults?: StageDefaults, + row: { + status: ActivityStatus; + boardId?: string | null; + stage?: { id: string; kind: ActivityStatus; boardId: string } | null; + }, + ctx?: BoardStageContext, ): string | null { - if (row.stage && row.stage.kind === row.status) return row.stage.id; - return defaults?.[row.status] ?? null; + const boardId = this.effectiveBoardId(row, ctx); + if (row.stage && row.stage.kind === row.status && (!boardId || row.stage.boardId === boardId)) { + return row.stage.id; + } + if (!ctx || !boardId) return null; + return ctx.defaults.get(boardId)?.[row.status] ?? null; } async get(organizationId: string, id: string): Promise { const row = await this.prisma.activityStage.findFirst({ where: { id, organizationId }, - select: { id: true, name: true, position: true, color: true, kind: true }, + select: STAGE_SELECT, }); if (!row) throw new NotFoundException('مرحله پیدا نشد'); return row; @@ -111,22 +305,25 @@ export class ActivityStagesService { async create( organizationId: string, - dto: { name: string; color?: string; kind?: ActivityStatus }, + dto: { boardId?: string; name: string; color?: string; kind?: ActivityStatus }, ): Promise { - await this.ensureDefaults(organizationId); + const boardId = await this.resolveBoardId(organizationId, dto.boardId); + await this.ensureBoardStages(organizationId, boardId); const max = await this.prisma.activityStage.aggregate({ - where: { organizationId }, + where: { boardId }, _max: { position: true }, }); try { const row = await this.prisma.activityStage.create({ data: { organizationId, + boardId, name: this.cleanName(dto.name), color: this.cleanColor(dto.color), kind: dto.kind && KINDS.includes(dto.kind) ? dto.kind : 'open', position: (max._max.position ?? -1) + 1, }, + select: STAGE_SELECT, }); return this.toDto(row); } catch (e) { @@ -142,7 +339,7 @@ export class ActivityStagesService { const stage = await this.get(organizationId, id); const nextKind = dto.kind && KINDS.includes(dto.kind) ? dto.kind : stage.kind; if (nextKind !== stage.kind) { - await this.assertNotLastOfKind(organizationId, stage); + await this.assertNotLastOfKind(stage); } try { const row = await this.prisma.$transaction(async (tx) => { @@ -160,6 +357,7 @@ export class ActivityStagesService { ...(dto.color != null ? { color: this.cleanColor(dto.color) } : {}), kind: nextKind, }, + select: STAGE_SELECT, }); }); return this.toDto(row); @@ -168,34 +366,45 @@ export class ActivityStagesService { } } + /** ترتیب ستون‌های یک گروه؛ همهٔ شناسه‌ها باید از همان گروه باشند */ async reorder(organizationId: string, stageIds: string[]): Promise { + if (!stageIds.length) return []; + const rows = await this.prisma.activityStage.findMany({ + where: { organizationId, id: { in: stageIds } }, + select: { id: true, boardId: true }, + }); + const boardIds = new Set(rows.map((r) => r.boardId)); + if (rows.length !== new Set(stageIds).size || boardIds.size !== 1) { + throw new BadRequestException('ستون‌های ارسالی باید همه از یک گروه باشند'); + } + const [boardId] = [...boardIds]; await this.prisma.$transaction( stageIds.map((id, position) => this.prisma.activityStage.updateMany({ - where: { id, organizationId }, + where: { id, organizationId, boardId }, data: { position }, }), ), ); - return this.list(organizationId); + return this.list(organizationId, boardId); } async remove(organizationId: string, id: string): Promise { const stage = await this.get(organizationId, id); - await this.assertNotLastOfKind(organizationId, stage); - // onDelete: SetNull → فعالیت‌ها به اولین مرحلهٔ هم‌نوع وضعیتشان منتقل می‌شوند + await this.assertNotLastOfKind(stage); + // onDelete: SetNull → فعالیت‌ها به اولین مرحلهٔ هم‌نوع وضعیتشان در همان گروه منتقل می‌شوند await this.prisma.activityStage.delete({ where: { id } }); } - private async assertNotLastOfKind(organizationId: string, stage: StageRow) { + private async assertNotLastOfKind(stage: StageRow) { const sameKind = await this.prisma.activityStage.count({ - where: { organizationId, kind: stage.kind }, + where: { boardId: stage.boardId, kind: stage.kind }, }); if (sameKind <= 1) { const label = stage.kind === 'done' ? 'انجام‌شده' : stage.kind === 'canceled' ? 'لغو شده' : 'باز'; throw new BadRequestException( - `حداقل یک ستون از نوع «${label}» لازم است؛ اول یک ستون دیگر از همین نوع بسازید`, + `حداقل یک ستون از نوع «${label}» در هر گروه لازم است؛ اول یک ستون دیگر از همین نوع بسازید`, ); } } @@ -207,6 +416,13 @@ export class ActivityStagesService { return name; } + private cleanBoardName(raw: string): string { + const name = raw?.trim() ?? ''; + if (name.length < 2) throw new BadRequestException('نام گروه حداقل ۲ کاراکتر است'); + if (name.length > 80) throw new BadRequestException('نام گروه حداکثر ۸۰ کاراکتر است'); + return name; + } + private cleanColor(raw?: string): ActivityStageColor { return ACTIVITY_STAGE_COLORS.includes(raw as ActivityStageColor) ? (raw as ActivityStageColor) @@ -215,7 +431,14 @@ export class ActivityStagesService { private mapError(e: unknown) { if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { - return new BadRequestException('ستونی با این نام وجود دارد'); + return new BadRequestException('ستونی با این نام در این گروه وجود دارد'); + } + return e; + } + + private mapBoardError(e: unknown) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { + return new BadRequestException('گروهی با این نام وجود دارد'); } return e; } @@ -223,6 +446,7 @@ export class ActivityStagesService { private toDto(row: StageRow): ActivityStageDto { return { id: row.id, + boardId: row.boardId, name: row.name, position: row.position, color: this.cleanColor(row.color), diff --git a/apps/api/src/activities/dto.ts b/apps/api/src/activities/dto.ts index 3228b21..6395dae 100644 --- a/apps/api/src/activities/dto.ts +++ b/apps/api/src/activities/dto.ts @@ -79,6 +79,11 @@ export class ListActivitiesQueryDto { @IsString() stageId?: string; + /** گروه فعالیت؛ خالی = همهٔ گروه‌ها (در شمارش ستون‌ها = گروه پیش‌فرض) */ + @IsOptional() + @IsString() + boardId?: string; + @IsOptional() @IsString() personId?: string; @@ -121,7 +126,33 @@ export class ListActivitiesQueryDto { const STAGE_COLORS = ['green', 'orange', 'purple', 'blue', 'rose', 'slate'] as const; +export class CreateActivityBoardDto { + @IsString() + @MinLength(2) + @MaxLength(80) + name!: string; +} + +export class UpdateActivityBoardDto { + @IsOptional() + @IsString() + @MinLength(2) + @MaxLength(80) + name?: string; +} + +export class ListActivityStagesQueryDto { + @IsOptional() + @IsString() + boardId?: string; +} + export class CreateActivityStageDto { + /** خالی = گروه پیش‌فرض */ + @IsOptional() + @IsString() + boardId?: string; + @IsString() @MinLength(1) @MaxLength(80) @@ -229,6 +260,11 @@ export class CreateActivityDto { @MaxLength(200) title!: string; + @IsOptional() + @ValidateIf((_, v) => v !== '' && v != null) + @IsString() + boardId?: string | null; + @IsOptional() @ValidateIf((_, v) => v !== '' && v != null) @IsString() @@ -319,6 +355,12 @@ export class UpdateActivityDto { @MaxLength(200) title?: string; + /** null/خالی = گروه پیش‌فرض */ + @IsOptional() + @ValidateIf((_, v) => v !== '' && v != null) + @IsString() + boardId?: string | null; + @IsOptional() @IsIn(ACTIVITY_TYPES) type?: (typeof ACTIVITY_TYPES)[number]; diff --git a/apps/web/src/components/activities/ActivitiesKanban.vue b/apps/web/src/components/activities/ActivitiesKanban.vue index 244f9cd..3a24ea0 100644 --- a/apps/web/src/components/activities/ActivitiesKanban.vue +++ b/apps/web/src/components/activities/ActivitiesKanban.vue @@ -380,7 +380,7 @@ async function onAddStage(name: string) { if (stageBusy.value) return; stageBusy.value = true; try { - await api.post('/activities/stages', { name, kind: 'open' }); + await api.post('/activities/stages', { name, kind: 'open', boardId: props.params.boardId }); emit('stagesChanged'); } catch (e) { emit('error', apiMessage(e, 'افزودن ستون ناموفق بود.')); diff --git a/apps/web/src/components/activities/ActivityStagesManager.vue b/apps/web/src/components/activities/ActivityStagesManager.vue index 7116ce7..005468a 100644 --- a/apps/web/src/components/activities/ActivityStagesManager.vue +++ b/apps/web/src/components/activities/ActivityStagesManager.vue @@ -20,6 +20,9 @@ import UiIcon from '@/components/UiIcon.vue'; const props = defineProps<{ open: boolean; stages: ActivityStageDto[]; + /** ستون‌های جدید در همین گروه ساخته می‌شوند */ + boardId?: string; + boardName?: string; }>(); const emit = defineEmits<{ @@ -34,7 +37,7 @@ type Draft = { name: string; color: ActivityStageColor; kind: ActivityStatus }; const drafts = ref>({}); const busyId = ref(''); const error = ref(''); -const createForm = reactive>({ +const createForm = reactive>>({ name: '', color: 'blue', kind: 'open', @@ -140,7 +143,11 @@ async function create() { busyId.value = 'create'; error.value = ''; try { - await api.post('/activities/stages', { ...createForm, name: createForm.name.trim() }); + await api.post('/activities/stages', { + ...createForm, + name: createForm.name.trim(), + boardId: props.boardId, + }); createForm.name = ''; emit('changed'); } catch (e) { @@ -152,7 +159,11 @@ async function create() {