feat(activities): let every assignee finish their own share

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-09 20:48:43 +03:30
co-authored by Cursor
parent dae3ae3f7e
commit da851bd9e5
10 changed files with 479 additions and 51 deletions
@@ -0,0 +1,6 @@
-- CreateEnum
CREATE TYPE "ActivityCompletionMode" AS ENUM ('any', 'all');
-- AlterTable
ALTER TABLE "Activity" ADD COLUMN "completionMode" "ActivityCompletionMode" NOT NULL DEFAULT 'any',
ADD COLUMN "completedByIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
+9
View File
@@ -33,6 +33,11 @@ enum ActivityStatus {
canceled
}
enum ActivityCompletionMode {
any
all
}
enum ActivityPriority {
low
medium
@@ -883,6 +888,10 @@ model Activity {
/// زمان ارسال یادآور؛ با تغییر reminderAt پاک می‌شود
reminderSentAt DateTime?
completedAt DateTime?
/// any = با انجام یکی بسته می‌شود؛ all = هر مسئول باید سهم خودش را بزند
completionMode ActivityCompletionMode @default(any)
/// مسئولانی که سهم خود را در حالت all ثبت کرده‌اند
completedByIds String[] @default([])
tags String[] @default([])
personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
@@ -438,8 +438,16 @@ export class ActivitiesController {
}
@Patch(':id/complete')
complete(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.activitiesService.complete(user.organizationId, id, user);
complete(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Query('force') force?: string,
@Query('undo') undo?: string,
) {
return this.activitiesService.complete(user.organizationId, id, user, {
force: force === '1' || force === 'true',
undo: undo === '1' || undo === 'true',
});
}
@Delete(':id')
+182 -30
View File
@@ -52,6 +52,28 @@ type ActivityActor = Pick<
'userId' | 'role' | 'priorLens' | 'priorCutoff' | 'leadsAccess' | 'teamPeerIds'
>;
function assigneeIdList(row: {
assigneeId?: string | null;
assignees?: Array<{ id: string }>;
}): string[] {
const ids = row.assignees?.map((item) => item.id) ?? [];
if (ids.length) return [...new Set(ids)];
return row.assigneeId ? [row.assigneeId] : [];
}
function canForceClose(
actor: { userId: string; role: string },
createdById: string | null | undefined,
): boolean {
return actor.role === 'owner' || actor.role === 'admin' || actor.userId === createdById;
}
function reconcileAllShares(assigneeIds: string[], completedByIds: string[]) {
const kept = [...new Set(completedByIds.filter((id) => assigneeIds.includes(id)))];
const done = assigneeIds.length > 0 && assigneeIds.every((id) => kept.includes(id));
return { status: done ? ('done' as const) : ('open' as const), completedByIds: kept };
}
/** stageId = شناسهٔ مرحله یا 'all'؛ وقتی ست باشد فیلتر زمانی وضعیت را تحمیل نمی‌کند (جز عقب‌افتاده) */
type StageScope = { stageId: string };
@@ -82,6 +104,8 @@ type ActivityRow = {
reminderNotifySms: boolean;
reminderSentAt: Date | null;
completedAt: Date | null;
completionMode?: 'any' | 'all';
completedByIds?: string[];
importSource?: string;
promotedFromPriorAt?: Date | null;
tags: string[];
@@ -186,22 +210,39 @@ export class ActivitiesService {
return { stages, types };
}
/** کارهای بازِ «من» که سهم‌شان ثبت شده، از میز کار شخص خارج می‌شوند. */
private deskScope(
actor: ActivityActor,
desk?: 'mine' | 'team',
): Prisma.ActivityWhereInput {
if (desk !== 'mine') return activityAccessWhere(actor);
return {
AND: [
{
OR: [
{ assigneeId: actor.userId },
{ assignees: { some: { id: actor.userId } } },
{ participantIds: { has: actor.userId } },
],
},
{
NOT: {
completionMode: 'all',
status: 'open',
completedByIds: { has: actor.userId },
},
},
],
};
}
async summary(
organizationId: string,
actor: ActivityActor,
desk?: 'mine' | 'team',
): Promise<ActivitySummaryDto> {
const { start, end } = dayBounds();
const scope: Prisma.ActivityWhereInput =
desk === 'mine'
? {
OR: [
{ assigneeId: actor.userId },
{ assignees: { some: { id: actor.userId } } },
{ participantIds: { has: actor.userId } },
],
}
: activityAccessWhere(actor);
const scope = this.deskScope(actor, desk);
const prior = activityPriorWhere(actor.priorLens, actor.priorCutoff);
const [todayCount, overdueCount] = await Promise.all([
this.prisma.activity.count({
@@ -534,8 +575,24 @@ export class ActivitiesService {
const stage = await this.stages.get(organizationId, stageId);
const existing = await this.prisma.activity.findFirst({
where: { id, organizationId },
select: { status: true, completedAt: true },
select: {
status: true,
completedAt: true,
completionMode: true,
completedByIds: true,
createdById: true,
assigneeId: true,
assignees: { select: { id: true } },
},
});
if (
stage.kind === 'done' &&
existing?.completionMode === 'all' &&
assigneeIdList(existing).length > 1 &&
!canForceClose(actor, existing.createdById)
) {
return this.complete(organizationId, id, actor);
}
const row = await this.prisma.activity.update({
where: { id },
data: {
@@ -550,6 +607,9 @@ export class ActivitiesService {
: stage.kind === 'open'
? null
: undefined,
...(stage.kind === 'open' && existing?.status !== 'open'
? { completedByIds: [] }
: {}),
},
include,
});
@@ -675,6 +735,8 @@ export class ActivitiesService {
assignees: assigneeIds.length
? { connect: assigneeIds.map((id) => ({ id })) }
: undefined,
completionMode: dto.completionMode === 'all' ? 'all' : 'any',
completedByIds: [],
createdById: userId,
},
include,
@@ -743,7 +805,17 @@ export class ActivitiesService {
const existing = await this.prisma.activity.findFirst({
where: { id, organizationId },
select: { status: true, type: true, typeDefId: true, fields: true, participantIds: true },
select: {
status: true,
type: true,
typeDefId: true,
fields: true,
participantIds: true,
completionMode: true,
completedByIds: true,
assigneeId: true,
assignees: { select: { id: true } },
},
});
const data: Prisma.ActivityUncheckedUpdateInput = {};
if (dto.title !== undefined) data.title = dto.title.trim();
@@ -812,6 +884,7 @@ export class ActivitiesService {
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
if ('leadId' in dto) data.leadId = emptyToNull(dto.leadId ?? null);
let assigneeConnect: Array<{ id: string }> | undefined;
let nextAssigneeIds: string[] | undefined;
if (dto.assigneeIds !== undefined || 'assigneeId' in dto) {
const current = await this.prisma.activity.findFirst({
where: { id, organizationId },
@@ -839,6 +912,7 @@ export class ActivitiesService {
);
data.assigneeId = assigneeIds[0]!;
assigneeConnect = assigneeIds.map((uid) => ({ id: uid }));
nextAssigneeIds = assigneeIds;
} else {
const assigneeId = emptyToNull(dto.assigneeId ?? null);
if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId);
@@ -852,12 +926,41 @@ export class ActivitiesService {
data.assigneeId = assigneeId;
if (assigneeId) assigneeConnect = [{ id: assigneeId }];
else assigneeConnect = [];
nextAssigneeIds = assigneeId ? [assigneeId] : [];
}
}
if (dto.status !== undefined) {
data.status = dto.status;
data.completedAt =
dto.status === 'done' ? new Date() : dto.status === 'open' ? null : undefined;
const modeChanged = dto.completionMode === 'any' || dto.completionMode === 'all';
const nextMode = modeChanged ? dto.completionMode! : (existing?.completionMode ?? 'any');
if (modeChanged) data.completionMode = nextMode;
const prevAssigneeIds = assigneeIdList(existing ?? {});
const nextIds = nextAssigneeIds ?? prevAssigneeIds;
const explicitDone = dto.status === 'done';
const explicitReopen = dto.status === 'open' && existing?.status !== 'open';
const explicitCancel = dto.status === 'canceled';
if (explicitDone) {
data.status = 'done';
data.completedAt = new Date();
} else if (explicitReopen) {
data.status = 'open';
data.completedAt = null;
data.completedByIds = [];
} else if (explicitCancel) {
data.status = 'canceled';
} else if (nextMode === 'any' && modeChanged) {
data.completedByIds = [];
} else if (nextMode === 'all' && (modeChanged || nextAssigneeIds)) {
const reconciled = reconcileAllShares(nextIds, existing?.completedByIds ?? []);
data.completedByIds = reconciled.completedByIds;
const finishedStaysFinished = existing?.status === 'done' && !nextAssigneeIds;
if (existing?.status !== 'canceled' && !finishedStaysFinished) {
if (reconciled.status === 'done' && existing?.status !== 'done') {
data.status = 'done';
data.completedAt = new Date();
} else if (reconciled.status === 'open' && existing?.status === 'done') {
data.status = 'open';
data.completedAt = null;
}
}
}
const row = await this.prisma.activity.update({
@@ -881,7 +984,7 @@ export class ActivitiesService {
addedParticipants.filter((uid) => !assigneeSet.has(uid)),
);
}
if (dto.status === 'done' && existing?.status !== 'done') {
if (data.status === 'done' && existing?.status !== 'done') {
await this.fireDone(organizationId, row);
}
return this.toDto(row, await this.ctx(organizationId));
@@ -891,18 +994,74 @@ export class ActivitiesService {
organizationId: string,
id: string,
actor: ActivityActor,
options: { force?: boolean; undo?: boolean } = {},
): Promise<ActivityDto> {
await this.ensureExists(organizationId, id, actor);
const existing = await this.prisma.activity.findFirst({
where: { id, organizationId },
select: { status: true },
select: {
status: true,
completionMode: true,
completedByIds: true,
createdById: true,
assigneeId: true,
assignees: { select: { id: true } },
},
});
if (!existing) throw new NotFoundException('فعالیت پیدا نشد');
if (existing.status === 'canceled') {
throw new BadRequestException('فعالیت لغو شده است');
}
const assignees = assigneeIdList(existing);
const allowedForce = canForceClose(actor, existing.createdById);
if (options.force && !allowedForce) {
throw new ForbiddenException('بستن فعالیت برای همه فقط برای سازنده یا مدیر ممکن است');
}
if (options.undo) {
if (existing.completionMode !== 'all' || existing.status !== 'open') {
throw new BadRequestException('سهم انجام‌شده را فقط تا وقتی فعالیت باز است می‌توان لغو کرد');
}
const row = await this.prisma.activity.update({
where: { id },
data: {
completedByIds: existing.completedByIds.filter((uid) => uid !== actor.userId),
},
include,
});
return this.toDto(row, await this.ctx(organizationId));
}
const personal =
existing.completionMode === 'all' && assignees.length > 1 && !options.force;
if (!personal) {
const row = await this.prisma.activity.update({
where: { id },
data: { status: 'done', completedAt: new Date() },
include,
});
if (existing.status !== 'done') await this.fireDone(organizationId, row);
return this.toDto(row, await this.ctx(organizationId));
}
if (!assignees.includes(actor.userId)) {
throw new ForbiddenException('فقط مسئول این فعالیت می‌تواند سهم خودش را ثبت کند');
}
const reconciled = reconcileAllShares(assignees, [
...existing.completedByIds,
actor.userId,
]);
const row = await this.prisma.activity.update({
where: { id },
data: { status: 'done', completedAt: new Date() },
data: {
completedByIds: reconciled.completedByIds,
status: reconciled.status,
completedAt: reconciled.status === 'done' ? new Date() : null,
},
include,
});
if (existing?.status !== 'done') {
if (reconciled.status === 'done' && existing.status !== 'done') {
await this.fireDone(organizationId, row);
}
return this.toDto(row, await this.ctx(organizationId));
@@ -1108,16 +1267,7 @@ export class ActivitiesService {
typeRows: TypeRow[] = [],
): Prisma.ActivityWhereInput {
const { start, end } = dayBounds();
const scope: Prisma.ActivityWhereInput =
desk === 'mine'
? {
OR: [
{ assigneeId: actor.userId },
{ assignees: { some: { id: actor.userId } } },
{ participantIds: { has: actor.userId } },
],
}
: activityAccessWhere(actor);
const scope = this.deskScope(actor, desk);
const facetAssignee = facets?.assigneeId?.trim();
const facetTypeDef = facets?.typeDefId?.trim();
const facetTitle = facets?.title?.trim();
@@ -1418,6 +1568,8 @@ export class ActivitiesService {
reminderNotifySms: row.reminderNotifySms,
reminderSentAt: row.reminderSentAt?.toISOString() ?? null,
completedAt: row.completedAt?.toISOString() ?? null,
completionMode: row.completionMode ?? 'any',
completedByIds: row.completedByIds ?? [],
importSource: row.importSource ?? 'manual',
promotedFromPriorAt: row.promotedFromPriorAt?.toISOString() ?? null,
tags: row.tags ?? [],
+8
View File
@@ -384,6 +384,10 @@ export class CreateActivityDto {
@IsArray()
@IsString({ each: true })
assigneeIds?: string[];
@IsOptional()
@IsIn(['any', 'all'])
completionMode?: 'any' | 'all';
}
export class UpdateActivityDto {
@@ -484,4 +488,8 @@ export class UpdateActivityDto {
@IsArray()
@IsString({ each: true })
assigneeIds?: string[];
@IsOptional()
@IsIn(['any', 'all'])
completionMode?: 'any' | 'all';
}