feat: group activities into boards with their own stages, like sales pipelines
Existing stages and activities move into a default board per environment; new activities pick a board in the form.
This commit is contained in:
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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<Actor, 'userId' | 'role' | 'priorLens' | 'priorCutoff'>;
|
||||
|
||||
@@ -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<DtoContext> {
|
||||
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<ActivityStageCountsDto> {
|
||||
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)
|
||||
|
||||
@@ -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<ActivityStatus, string | null>;
|
||||
|
||||
/** پیشفرضهای هر گروه برای محاسبهٔ «ستون مؤثر» فعالیتها */
|
||||
export type BoardStageContext = {
|
||||
defaultBoardId: string;
|
||||
defaults: Map<string, StageDefaults>;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ActivityStagesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async ensureDefaults(organizationId: string): Promise<void> {
|
||||
const count = await this.prisma.activityStage.count({ where: { organizationId } });
|
||||
// —— گروهها ——
|
||||
|
||||
/** گروه پیشفرض محیط را (با ستونهای اولیه) تضمین میکند */
|
||||
async ensureDefaultBoard(organizationId: string): Promise<BoardRow> {
|
||||
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<StageRow[]> {
|
||||
await this.ensureDefaults(organizationId);
|
||||
return this.prisma.activityStage.findMany({
|
||||
async listBoards(organizationId: string): Promise<ActivityBoardDto[]> {
|
||||
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<string> {
|
||||
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<ActivityBoardDto> {
|
||||
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<ActivityBoardDto> {
|
||||
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<void> {
|
||||
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<BoardRow> {
|
||||
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<StageRow[]> {
|
||||
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<ActivityStageDto[]> {
|
||||
return (await this.rows(organizationId)).map((r) => this.toDto(r));
|
||||
async list(organizationId: string, boardId?: string | null): Promise<ActivityStageDto[]> {
|
||||
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<StageDefaults> {
|
||||
return this.defaultsOf(await this.rows(organizationId));
|
||||
/** پیشفرضهای ستون برای همهٔ گروههای محیط */
|
||||
async boardContext(organizationId: string): Promise<BoardStageContext> {
|
||||
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<string, StageRow[]>();
|
||||
for (const r of rows) {
|
||||
const list = byBoard.get(r.boardId) ?? [];
|
||||
list.push(r);
|
||||
byBoard.set(r.boardId, list);
|
||||
}
|
||||
const defaults = new Map<string, StageDefaults>();
|
||||
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<Prisma.ActivityWhereInput> {
|
||||
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<StageRow> {
|
||||
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<ActivityStageDto> {
|
||||
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<ActivityStageDto[]> {
|
||||
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<void> {
|
||||
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),
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user