From a1ac677b3aa069687e401a2cdbd3e4ba7cc5e46c Mon Sep 17 00:00:00 2001 From: Mahdi Date: Thu, 12 Nov 2026 20:15:21 +0330 Subject: [PATCH] Combine data-access and contact list facets with OR so each selected dimension grants visibility. Co-authored-by: Cursor --- apps/api/src/common/data-access.ts | 203 ++++++++++-------- apps/api/src/contacts/people.service.ts | 16 +- .../src/components/ContactsFilterPanel.vue | 12 ++ apps/web/src/components/DataAccessEditor.vue | 8 +- packages/shared/src/index.ts | 4 +- 5 files changed, 139 insertions(+), 104 deletions(-) diff --git a/apps/api/src/common/data-access.ts b/apps/api/src/common/data-access.ts index 475a96d..48fc6e6 100644 --- a/apps/api/src/common/data-access.ts +++ b/apps/api/src/common/data-access.ts @@ -105,27 +105,33 @@ export function resolveAllSectionAccess(input: { return out; } +/** ابعاد پرشدهٔ یک قانون با OR؛ خالی = null */ +function unionFacets(facets: T[]): T | { OR: T[] } | null { + if (!facets.length) return null; + return facets.length === 1 ? facets[0]! : { OR: facets }; +} + export function buildContactRuleWhere( rule: DataAccessRule, ctx: { userId: string; teamPeerIds: string[] | null }, ): Prisma.PersonWhereInput | null { - const and: Prisma.PersonWhereInput[] = []; - const ids = ownerIdList(rule, ctx); + const facets: Prisma.PersonWhereInput[] = []; if (rule.owners?.length) { - if (!ids.length) return null; - and.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } }); + const ids = ownerIdList(rule, ctx); + if (ids.length) { + facets.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } }); + } } // groupIds دیگر فیلتر دسترسی نیست؛ دید گروه فقط از مسئول‌گروه (personViaGroupAssignee) if (rule.sources?.length) { - and.push({ + facets.push({ OR: [{ importSource: { in: rule.sources } }, { sourceRef: { in: rule.sources } }], }); } if (rule.moods?.length) { - and.push({ mood: { in: rule.moods } }); + facets.push({ mood: { in: rule.moods } }); } - if (!and.length) return null; - return and.length === 1 ? and[0]! : { AND: and }; + return unionFacets(facets); } function orParts(parts: T[]): T | { OR: T[] } | { id: string } { @@ -177,26 +183,26 @@ export function customersCompanyWhere( if (!sectionHasDataAccessLimits(section)) return {}; const parts: Prisma.CompanyWhereInput[] = []; for (const rule of section.rules ?? []) { - const and: Prisma.CompanyWhereInput[] = []; + // حال مشتری روی شرکت معنا ندارد؛ فقط مالک/منبع + if (rule.moods?.length && !rule.owners?.length && !rule.sources?.length) continue; + const facets: Prisma.CompanyWhereInput[] = []; if (rule.owners?.length) { const ids = ownerIdList(rule, { userId: actor.userId, teamPeerIds: actor.teamPeerIds ?? null, }); - if (!ids.length) continue; - and.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } }); + if (ids.length) { + facets.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } }); + } } // groupIds دیگر فیلتر دسترسی نیست؛ دید گروه فقط از مسئول‌گروه (companyViaGroupAssignee) if (rule.sources?.length) { - and.push({ + facets.push({ OR: [{ importSource: { in: rule.sources } }, { sourceRef: { in: rule.sources } }], }); } - if (rule.moods?.length && !rule.owners?.length && !rule.sources?.length) { - continue; - } - if (!and.length) continue; - parts.push(and.length === 1 ? and[0]! : { AND: and }); + const w = unionFacets(facets); + if (w) parts.push(w); } parts.push(companyViaGroupAssignee(actor.userId)); return orParts(parts); @@ -210,27 +216,28 @@ export function leadsDeepWhere( if (!sectionHasDataAccessLimits(section)) return {}; const parts: Prisma.LeadWhereInput[] = []; for (const rule of section.rules ?? []) { - const and: Prisma.LeadWhereInput[] = []; + const facets: Prisma.LeadWhereInput[] = []; if (rule.owners?.length) { const ids = ownerIdList(rule, { userId: actor.userId, teamPeerIds: actor.teamPeerIds ?? null, }); - if (!ids.length) continue; - and.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } }); + if (ids.length) { + facets.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } }); + } } if (rule.stageIds?.length) { - and.push({ stageId: { in: rule.stageIds } }); + facets.push({ stageId: { in: rule.stageIds } }); } if (rule.sources?.length) { - and.push({ + facets.push({ OR: rule.sources.map((s) => ({ source: { equals: s, mode: 'insensitive' as const }, })), }); } - if (!and.length) continue; - parts.push(and.length === 1 ? and[0]! : { AND: and }); + const w = unionFacets(facets); + if (w) parts.push(w); } return orParts(parts); } @@ -243,28 +250,29 @@ export function dealsDeepWhere( if (!sectionHasDataAccessLimits(section)) return {}; const parts: Prisma.DealWhereInput[] = []; for (const rule of section.rules ?? []) { - const and: Prisma.DealWhereInput[] = []; + const facets: Prisma.DealWhereInput[] = []; if (rule.owners?.length) { const ids = ownerIdList(rule, { userId: actor.userId, teamPeerIds: actor.teamPeerIds ?? null, }); - if (!ids.length) continue; - and.push({ - OR: [ - { ownerId: { in: ids } }, - { assignees: { some: { id: { in: ids } } } }, - ], - }); + if (ids.length) { + facets.push({ + OR: [ + { ownerId: { in: ids } }, + { assignees: { some: { id: { in: ids } } } }, + ], + }); + } } if (rule.pipelineIds?.length) { - and.push({ pipelineId: { in: rule.pipelineIds } }); + facets.push({ pipelineId: { in: rule.pipelineIds } }); } if (rule.stageIds?.length) { - and.push({ stageId: { in: rule.stageIds } }); + facets.push({ stageId: { in: rule.stageIds } }); } - if (!and.length) continue; - parts.push(and.length === 1 ? and[0]! : { AND: and }); + const w = unionFacets(facets); + if (w) parts.push(w); } return orParts(parts); } @@ -277,32 +285,33 @@ export function activitiesDeepWhere( if (!sectionHasDataAccessLimits(section)) return {}; const parts: Prisma.ActivityWhereInput[] = []; for (const rule of section.rules ?? []) { - const and: Prisma.ActivityWhereInput[] = []; + const facets: Prisma.ActivityWhereInput[] = []; if (rule.owners?.length) { const ids = ownerIdList(rule, { userId: actor.userId, teamPeerIds: actor.teamPeerIds ?? null, }); - if (!ids.length) continue; - and.push({ - OR: [ - { assigneeId: { in: ids } }, - { assignees: { some: { id: { in: ids } } } }, - { participantIds: { hasSome: ids } }, - ], - }); + if (ids.length) { + facets.push({ + OR: [ + { assigneeId: { in: ids } }, + { assignees: { some: { id: { in: ids } } } }, + { participantIds: { hasSome: ids } }, + ], + }); + } } if (rule.boardIds?.length) { - and.push({ boardId: { in: rule.boardIds } }); + facets.push({ boardId: { in: rule.boardIds } }); } if (rule.stageIds?.length) { - and.push({ stageId: { in: rule.stageIds } }); + facets.push({ stageId: { in: rule.stageIds } }); } if (rule.typeIds?.length) { - and.push({ typeDefId: { in: rule.typeIds } }); + facets.push({ typeDefId: { in: rule.typeIds } }); } - if (!and.length) continue; - parts.push(and.length === 1 ? and[0]! : { AND: and }); + const w = unionFacets(facets); + if (w) parts.push(w); } return orParts(parts); } @@ -321,34 +330,35 @@ export function notesDeepWhere( if (!sectionHasDataAccessLimits(section)) return personalGate; const parts: Prisma.NoteWhereInput[] = []; for (const rule of section.rules ?? []) { - const and: Prisma.NoteWhereInput[] = []; + const facets: Prisma.NoteWhereInput[] = []; if (rule.owners?.length) { const ids = ownerIdList(rule, { userId: actor.userId, teamPeerIds: actor.teamPeerIds ?? null, }); - if (!ids.length) continue; - and.push({ - OR: [ - { authorId: { in: ids } }, - { - deal: { - OR: [ - { ownerId: { in: ids } }, - { assignees: { some: { id: { in: ids } } } }, - ], + if (ids.length) { + facets.push({ + OR: [ + { authorId: { in: ids } }, + { + deal: { + OR: [ + { ownerId: { in: ids } }, + { assignees: { some: { id: { in: ids } } } }, + ], + }, }, - }, - { person: { ownerId: { in: ids } } }, - { company: { ownerId: { in: ids } } }, - ], - }); + { person: { ownerId: { in: ids } } }, + { company: { ownerId: { in: ids } } }, + ], + }); + } } if (rule.sources?.length) { - and.push({ noteType: { in: rule.sources } }); + facets.push({ noteType: { in: rule.sources } }); } - if (!and.length) continue; - parts.push(and.length === 1 ? and[0]! : { AND: and }); + const w = unionFacets(facets); + if (w) parts.push(w); } return { AND: [personalGate, orParts(parts)], @@ -363,26 +373,27 @@ export function ticketsDeepWhere( if (!sectionHasDataAccessLimits(section)) return {}; const parts: Prisma.TicketWhereInput[] = []; for (const rule of section.rules ?? []) { - const and: Prisma.TicketWhereInput[] = []; + const facets: Prisma.TicketWhereInput[] = []; if (rule.owners?.length) { const ids = ownerIdList(rule, { userId: actor.userId, teamPeerIds: actor.teamPeerIds ?? null, }); - if (!ids.length) continue; - and.push({ - OR: [{ assigneeId: { in: ids } }, { reporterId: { in: ids } }], - }); + if (ids.length) { + facets.push({ + OR: [{ assigneeId: { in: ids } }, { reporterId: { in: ids } }], + }); + } } if (rule.sources?.length) { - and.push({ + facets.push({ status: { in: rule.sources as Array<'open' | 'in_progress' | 'waiting' | 'resolved' | 'closed'>, }, }); } - if (!and.length) continue; - parts.push(and.length === 1 ? and[0]! : { AND: and }); + const w = unionFacets(facets); + if (w) parts.push(w); } return orParts(parts); } @@ -395,28 +406,29 @@ export function projectsDeepWhere( if (!sectionHasDataAccessLimits(section)) return {}; const parts: Prisma.ProjectCardWhereInput[] = []; for (const rule of section.rules ?? []) { - const and: Prisma.ProjectCardWhereInput[] = []; + const facets: Prisma.ProjectCardWhereInput[] = []; if (rule.owners?.length) { const ids = ownerIdList(rule, { userId: actor.userId, teamPeerIds: actor.teamPeerIds ?? null, }); - if (!ids.length) continue; - and.push({ - OR: [ - { ownerId: { in: ids } }, - { assignees: { some: { id: { in: ids } } } }, - ], - }); + if (ids.length) { + facets.push({ + OR: [ + { ownerId: { in: ids } }, + { assignees: { some: { id: { in: ids } } } }, + ], + }); + } } if (rule.boardIds?.length) { - and.push({ boardId: { in: rule.boardIds } }); + facets.push({ boardId: { in: rule.boardIds } }); } if (rule.stageIds?.length) { - and.push({ stageId: { in: rule.stageIds } }); + facets.push({ stageId: { in: rule.stageIds } }); } - if (!and.length) continue; - parts.push(and.length === 1 ? and[0]! : { AND: and }); + const w = unionFacets(facets); + if (w) parts.push(w); } return orParts(parts); } @@ -429,20 +441,21 @@ export function routinesDeepWhere( if (!sectionHasDataAccessLimits(section)) return {}; const parts: Prisma.RoutineTaskWhereInput[] = []; for (const rule of section.rules ?? []) { - const and: Prisma.RoutineTaskWhereInput[] = []; + const facets: Prisma.RoutineTaskWhereInput[] = []; if (rule.owners?.length) { const ids = ownerIdList(rule, { userId: actor.userId, teamPeerIds: actor.teamPeerIds ?? null, }); - if (!ids.length) continue; - and.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } }); + if (ids.length) { + facets.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } }); + } } if (rule.typeIds?.length) { - and.push({ categoryId: { in: rule.typeIds } }); + facets.push({ categoryId: { in: rule.typeIds } }); } - if (!and.length) continue; - parts.push(and.length === 1 ? and[0]! : { AND: and }); + const w = unionFacets(facets); + if (w) parts.push(w); } return orParts(parts); } diff --git a/apps/api/src/contacts/people.service.ts b/apps/api/src/contacts/people.service.ts index 0849b02..2366ed0 100644 --- a/apps/api/src/contacts/people.service.ts +++ b/apps/api/src/contacts/people.service.ts @@ -219,15 +219,25 @@ export class PeopleService implements OnModuleInit { } const disc = discFilter(opts?.disc); if (disc) and.push(disc); - if (groupIds.length) and.push({ groupLinks: { some: { groupId: { in: groupIds } } } }); + // گروه / منبع / حال: اتحاد (هر بُعد کافی است)؛ داخل هر بُعد چند مقدار با in = OR + const facets: Prisma.PersonWhereInput[] = []; + if (groupIds.length) { + facets.push({ groupLinks: { some: { groupId: { in: groupIds } } } }); + } + if (moods.length) { + facets.push({ mood: { in: moods } }); + } + if (sources.length) { + facets.push({ importSource: { in: sources } }); + } + if (facets.length === 1) and.push(facets[0]!); + else if (facets.length > 1) and.push({ OR: facets }); return { AND: [ { organizationId: actor.organizationId, ...customersPersonWhere(actor), }, - ...(moods.length ? [{ mood: { in: moods } }] : []), - ...(sources.length ? [{ importSource: { in: sources } }] : []), ...and, ], }; diff --git a/apps/web/src/components/ContactsFilterPanel.vue b/apps/web/src/components/ContactsFilterPanel.vue index 066fe14..80ec9b4 100644 --- a/apps/web/src/components/ContactsFilterPanel.vue +++ b/apps/web/src/components/ContactsFilterPanel.vue @@ -126,6 +126,9 @@ function apply() { {{ hasSort ? 'فیلتر و مرتب‌سازی' : 'فیلترها' }} +

+ گروه، منبع و وضعیت با هم جمع می‌شوند؛ هر کدام کافی است (بدون تکرار). +

@@ -264,6 +267,15 @@ function apply() { border-bottom: 1px solid var(--line); } +.facet-hint { + margin: 0; + padding: 0.35rem 0.9rem 0.55rem; + font-size: var(--fs-sm); + color: #64748b; + line-height: 1.45; + border-bottom: 1px solid var(--line); +} + .panel-foot { border-top: 1px solid var(--line); justify-content: flex-end; diff --git a/apps/web/src/components/DataAccessEditor.vue b/apps/web/src/components/DataAccessEditor.vue index 05c7198..b415694 100644 --- a/apps/web/src/components/DataAccessEditor.vue +++ b/apps/web/src/components/DataAccessEditor.vue @@ -449,11 +449,11 @@ const filterSummary = computed(() => {