Add deal co-assignees and saved filters on people/pipeline lists.

Deals keep a primary owner plus shared assignees with matching visibility; Contacts and Pipeline can save and reuse filter presets.
This commit is contained in:
2026-09-27 14:25:32 +03:30
parent 00f8b91d78
commit c06c92b333
19 changed files with 553 additions and 20 deletions
@@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "_DealAssignees" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_DealAssignees_AB_pkey" PRIMARY KEY ("A","B")
);
-- CreateIndex
CREATE INDEX "_DealAssignees_B_index" ON "_DealAssignees"("B");
-- AddForeignKey
ALTER TABLE "_DealAssignees" ADD CONSTRAINT "_DealAssignees_A_fkey" FOREIGN KEY ("A") REFERENCES "Deal"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_DealAssignees" ADD CONSTRAINT "_DealAssignees_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+2
View File
@@ -224,6 +224,7 @@ model User {
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
ownedDeals Deal[] @relation("DealOwner")
assignedDeals Deal[] @relation("DealAssignees")
ownedQuotes Quote[] @relation("QuoteOwner")
ownedInvoices Invoice[] @relation("InvoiceOwner")
ownedPeople Person[] @relation("PersonOwner")
@@ -509,6 +510,7 @@ model Deal {
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
ownerId String?
owner User? @relation("DealOwner", fields: [ownerId], references: [id], onDelete: SetNull)
assignees User[] @relation("DealAssignees")
position Int @default(0)
activities Activity[]
smartNotes Note[]
@@ -16,7 +16,7 @@ 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, ownerWhere } from '../common/record-scope';
import { type Actor, assigneeWhere, dealAccessWhere, ownerWhere } from '../common/record-scope';
import { normalizeTags } from '../common/tags';
type ActivityActor = Pick<Actor, 'userId' | 'role'>;
@@ -460,7 +460,7 @@ export class ActivitiesService {
}
if (dealId) {
const deal = await this.prisma.deal.findFirst({
where: { id: dealId, organizationId, ...scope },
where: { id: dealId, organizationId, ...dealAccessWhere(actor) },
select: { id: true },
});
if (!deal) throw new BadRequestException('معامله انتخاب‌شده معتبر نیست');
+22 -1
View File
@@ -48,6 +48,20 @@ export function ownerWhere(
return { ownerId: { in: ids } };
}
/** معامله: مالک اصلی یا یکی از مسئولان مشترک در محدوده دید */
export function dealAccessWhere(
actor: Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds'>,
): Prisma.DealWhereInput {
const ids = scopedOwnerIds(actor);
if (!ids) return {};
return {
OR: [
{ ownerId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
],
};
}
/** فعالیت‌ها بر اساس مسئول */
export function assigneeWhere(
actor: Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds'>,
@@ -80,7 +94,14 @@ export function noteAccessWhere(
{
OR: [
{ authorId: { in: ids } },
{ deal: { ownerId: { in: ids } } },
{
deal: {
OR: [
{ ownerId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
],
},
},
{ person: { ownerId: { in: ids } } },
{ company: { ownerId: { in: ids } } },
],
+2 -1
View File
@@ -22,6 +22,7 @@ import {
type Actor,
noteAccessWhere,
ownerWhere,
dealAccessWhere,
} from '../common/record-scope';
type NoteActor = Pick<
@@ -579,7 +580,7 @@ export class NotesService {
}
if (dealId) {
const deal = await this.prisma.deal.findFirst({
where: { id: dealId, organizationId, ...scope },
where: { id: dealId, organizationId, ...dealAccessWhere(actor) },
select: { id: true },
});
if (!deal) throw new BadRequestException('معامله انتخاب‌شده معتبر نیست');
@@ -3,7 +3,7 @@ import type { DealStatus, ImportContactsResult } from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope';
import { ownerWhere, resolveOwnerId } from '../common/record-scope';
import { dealAccessWhere, resolveOwnerId } from '../common/record-scope';
import { toJsonCustomFields } from '../common/custom-fields';
import { normalizePhone } from '../contacts/normalize';
import {
@@ -102,7 +102,7 @@ export class DealsExcelService {
async export(actor: Actor): Promise<Buffer> {
const rows = await this.prisma.deal.findMany({
where: { organizationId: actor.organizationId, ...ownerWhere(actor) },
where: { organizationId: actor.organizationId, ...dealAccessWhere(actor) },
include: {
person: {
select: { id: true, firstName: true, lastName: true, mobile: true },
+51 -5
View File
@@ -21,6 +21,7 @@ import { WebhooksService } from '../public-api/webhooks.service';
import {
type Actor,
canReassignOwner,
dealAccessWhere,
ownerWhere,
resolveOwnerId,
} from '../common/record-scope';
@@ -29,10 +30,14 @@ const dealInclude = {
person: { select: { id: true, firstName: true, lastName: true } },
company: { select: { id: true, name: true } },
owner: { select: { id: true, fullName: true } },
assignees: { select: { id: true, fullName: true } },
lineItems: { orderBy: { position: 'asc' as const } },
} satisfies Prisma.DealInclude;
type DealActor = Pick<Actor, 'userId' | 'role'>;
type DealActor = Pick<
Actor,
'userId' | 'role' | 'dataVisibility' | 'visibleUserIds'
>;
@Injectable()
export class DealsService {
@@ -93,6 +98,10 @@ export class DealsService {
if (dto.lineItems?.length) {
await this.assertProducts(organizationId, dto.lineItems);
}
const assigneeIds = await this.normalizeAssigneeIds(
organizationId,
dto.assigneeIds,
);
const maxPos = await this.prisma.deal.aggregate({
where: { organizationId, stageId: stage.id },
@@ -133,6 +142,9 @@ export class DealsService {
companyId: emptyToNull(dto.companyId ?? null),
ownerId: resolveOwnerId(actor, dto.ownerId),
position: (maxPos._max.position ?? -1) + 1,
...(assigneeIds.length
? { assignees: { connect: assigneeIds.map((id) => ({ id })) } }
: {}),
...(dto.lineItems?.length
? {
lineItems: {
@@ -219,6 +231,15 @@ export class DealsService {
data.ownerId = resolveOwnerId(actor, dto.ownerId);
}
let assigneeConnect: { id: string }[] | undefined;
if (dto.assigneeIds !== undefined) {
const assigneeIds = await this.normalizeAssigneeIds(
organizationId,
dto.assigneeIds,
);
assigneeConnect = assigneeIds.map((uid) => ({ id: uid }));
}
if (dto.lineItems !== undefined) {
const total = sumLineItems(dto.lineItems);
data.amount =
@@ -248,7 +269,15 @@ export class DealsService {
});
}
}
await tx.deal.update({ where: { id }, data });
await tx.deal.update({
where: { id },
data: {
...data,
...(assigneeConnect
? { assignees: { set: assigneeConnect } }
: {}),
},
});
});
const updated = await this.get(organizationId, id, actor);
@@ -271,7 +300,7 @@ export class DealsService {
dto: MoveDealRequest,
): Promise<DealDto> {
const deal = await this.prisma.deal.findFirst({
where: { id, organizationId, ...ownerWhere(actor) },
where: { id, organizationId, ...dealAccessWhere(actor) },
select: {
id: true,
title: true,
@@ -377,7 +406,7 @@ export class DealsService {
actor: DealActor,
) {
const found = await this.prisma.deal.findFirst({
where: { id, organizationId, ...ownerWhere(actor) },
where: { id, organizationId, ...dealAccessWhere(actor) },
select: { id: true },
});
if (!found) throw new NotFoundException('معامله پیدا نشد');
@@ -389,13 +418,30 @@ export class DealsService {
actor: DealActor,
) {
const row = await this.prisma.deal.findFirst({
where: { id, organizationId, ...ownerWhere(actor) },
where: { id, organizationId, ...dealAccessWhere(actor) },
include: dealInclude,
});
if (!row) throw new NotFoundException('معامله پیدا نشد');
return row;
}
private async normalizeAssigneeIds(
organizationId: string,
raw?: string[] | null,
): Promise<string[]> {
const ids = [
...new Set((raw ?? []).map((id) => id.trim()).filter(Boolean)),
];
if (!ids.length) return [];
const count = await this.prisma.user.count({
where: { organizationId, id: { in: ids }, isActive: true },
});
if (count !== ids.length) {
throw new BadRequestException('یکی از مسئولان انتخاب‌شده معتبر نیست');
}
return ids;
}
private async assertProducts(
organizationId: string,
items: DealLineItemInput[],
+10
View File
@@ -138,6 +138,11 @@ export class CreateDealDto {
@IsString()
ownerId?: string | null;
@IsOptional()
@IsArray()
@IsString({ each: true })
assigneeIds?: string[];
@IsOptional()
@IsObject()
customFields?: Record<string, unknown>;
@@ -216,6 +221,11 @@ export class UpdateDealDto {
@IsString()
ownerId?: string | null;
@IsOptional()
@IsArray()
@IsString({ each: true })
assigneeIds?: string[];
@IsOptional()
@IsObject()
customFields?: Record<string, unknown>;
@@ -13,6 +13,7 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { Roles } from '../auth/roles.decorator';
import { RolesGuard } from '../auth/roles.guard';
import type { Actor } from '../common/record-scope';
import {
CreatePipelineDto,
CreatePipelineStageDto,
@@ -33,13 +34,10 @@ export class PipelinesController {
@Get('board')
board(
@CurrentUser() user: { organizationId: string; userId: string; role: string },
@CurrentUser() user: Actor,
@Query('pipelineId') pipelineId?: string,
) {
return this.pipelinesService.getBoard(user.organizationId, pipelineId, {
userId: user.userId,
role: user.role,
});
return this.pipelinesService.getBoard(user.organizationId, pipelineId, user);
}
@Post()
+9 -3
View File
@@ -12,7 +12,7 @@ import type {
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { asCustomFields } from '../common/custom-fields';
import { type Actor, ownerWhere } from '../common/record-scope';
import { type Actor, dealAccessWhere } from '../common/record-scope';
import { decimalToBase, loadOrgRates } from '../common/currency';
const DEFAULT_STAGES = [
@@ -46,6 +46,7 @@ type DealRow = {
person: { id: string; firstName: string; lastName: string } | null;
company: { id: string; name: string } | null;
owner: { id: string; fullName: string } | null;
assignees?: Array<{ id: string; fullName: string }>;
lineItems?: Array<{
id: string;
productId: string | null;
@@ -236,7 +237,10 @@ export class PipelinesService {
async getBoard(
organizationId: string,
pipelineId?: string,
actor?: Pick<Actor, 'userId' | 'role'>,
actor?: Pick<
Actor,
'userId' | 'role' | 'dataVisibility' | 'visibleUserIds'
>,
): Promise<PipelineBoardDto> {
const pipeline = pipelineId
? await this.prisma.pipeline.findFirst({
@@ -261,12 +265,13 @@ export class PipelinesService {
where: {
organizationId,
pipelineId: resolved.id,
...(actor ? ownerWhere(actor) : {}),
...(actor ? dealAccessWhere(actor) : {}),
},
include: {
person: { select: { id: true, firstName: true, lastName: true } },
company: { select: { id: true, name: true } },
owner: { select: { id: true, fullName: true } },
assignees: { select: { id: true, fullName: true } },
},
orderBy: [{ position: 'asc' }, { updatedAt: 'desc' }],
});
@@ -332,6 +337,7 @@ export class PipelinesService {
: null,
company: row.company,
owner: row.owner,
assignees: row.assignees ?? [],
lineItems: (row.lineItems ?? []).map((item) => {
const quantity = Number(item.quantity);
const unitPrice = Number(item.unitPrice);
@@ -16,6 +16,7 @@ import { PrismaService } from '../prisma/prisma.service';
import {
type Actor,
canReassignOwner,
dealAccessWhere,
ownerWhere,
resolveOwnerId,
} from '../common/record-scope';
@@ -506,7 +507,7 @@ export class ProjectBoardsService {
}
if (dealId) {
const d = await this.prisma.deal.findFirst({
where: { id: dealId, organizationId, ...ownerWhere(actor) },
where: { id: dealId, organizationId, ...dealAccessWhere(actor) },
select: { id: true },
});
if (!d) throw new BadRequestException('معامله معتبر نیست');