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:
@@ -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;
|
||||
@@ -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('معامله انتخابشده معتبر نیست');
|
||||
|
||||
@@ -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 } } },
|
||||
],
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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('معامله معتبر نیست');
|
||||
|
||||
Reference in New Issue
Block a user