|
|
|
@@ -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 ?? [],
|
|
|
|
|