feat(customers): add optional group assignees with OR visibility for people, companies, and birthdays

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-12 12:59:49 +03:30
co-authored by Cursor
parent 453ff1a5f6
commit b583858088
17 changed files with 847 additions and 53 deletions
@@ -0,0 +1,28 @@
-- مسئولان اختیاری روی گروه‌های مشتری
CREATE TABLE IF NOT EXISTS "_CustomerGroupAssignees" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS "_CustomerGroupAssignees_AB_unique" ON "_CustomerGroupAssignees"("A", "B");
CREATE INDEX IF NOT EXISTS "_CustomerGroupAssignees_B_index" ON "_CustomerGroupAssignees"("B");
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = '_CustomerGroupAssignees_A_fkey'
) THEN
ALTER TABLE "_CustomerGroupAssignees"
ADD CONSTRAINT "_CustomerGroupAssignees_A_fkey"
FOREIGN KEY ("A") REFERENCES "CustomerGroup"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = '_CustomerGroupAssignees_B_fkey'
) THEN
ALTER TABLE "_CustomerGroupAssignees"
ADD CONSTRAINT "_CustomerGroupAssignees_B_fkey"
FOREIGN KEY ("B") REFERENCES "User"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
+3
View File
@@ -306,6 +306,7 @@ model User {
ownedInvoices Invoice[] @relation("InvoiceOwner")
ownedPeople Person[] @relation("PersonOwner")
ownedCompanies Company[] @relation("CompanyOwner")
assignedCustomerGroups CustomerGroup[] @relation("CustomerGroupAssignees")
financialPartyDocuments FinancialDocument[] @relation("FinancialDocumentParty")
ownedProjectCards ProjectCard[] @relation("ProjectCardOwner")
activities Activity[] @relation("ActivityAssignee")
@@ -576,6 +577,8 @@ model CustomerGroup {
children CustomerGroup[] @relation("CustomerGroupTree")
people PersonGroupLink[]
companies CompanyGroupLink[]
/// مسئولان گروه — اختیاری؛ اعضای گروه را مثل مشتریان خود می‌بینند
assignees User[] @relation("CustomerGroupAssignees")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
+20
View File
@@ -135,6 +135,23 @@ function orParts<T>(parts: T[]): T | { OR: T[] } | { id: string } {
return parts.length === 1 ? parts[0]! : { OR: parts };
}
/** اعضای گروه‌هایی که کاربر مسئولشان است */
function personViaGroupAssignee(userId: string): Prisma.PersonWhereInput {
return {
groupLinks: {
some: { group: { assignees: { some: { id: userId } } } },
},
};
}
function companyViaGroupAssignee(userId: string): Prisma.CompanyWhereInput {
return {
groupLinks: {
some: { group: { assignees: { some: { id: userId } } } },
},
};
}
export function customersPersonWhere(
actor: Pick<Actor, 'userId' | 'role' | 'customersAccess' | 'teamPeerIds'>,
): Prisma.PersonWhereInput {
@@ -149,6 +166,8 @@ export function customersPersonWhere(
});
if (w) parts.push(w);
}
// مسئول گروه: اتحاد با محدودیت نقش (مالک خودم یا عضو گروه تحت مسئولیت)
parts.push(personViaGroupAssignee(actor.userId));
return orParts(parts);
}
@@ -188,6 +207,7 @@ export function customersCompanyWhere(
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
}
parts.push(companyViaGroupAssignee(actor.userId));
return orParts(parts);
}
+1
View File
@@ -60,6 +60,7 @@ export function selectMonthBirthdays(people: BirthdayRow[], now = new Date(), pr
const upcoming = matched.filter((row) => row.day >= today.jd).sort(byDay);
const passed = matched.filter((row) => row.day < today.jd).sort(byDay);
return {
year: today.jy,
month: today.jm,
monthName: jalaliMonthName(today.jm),
today: today.jd,
@@ -169,6 +169,7 @@ export class CompaniesService {
fromTahesab: Boolean(g.tahesabGid),
personCount: 0,
companyCount: countMap.get(g.id) ?? 0,
assignees: [],
}));
}
+2
View File
@@ -3,6 +3,7 @@ import { LoyaltyModule } from '../loyalty/loyalty.module';
import { AutomationsModule } from '../automations/automations.module';
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
import { PublicApiModule } from '../public-api/public-api.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { CompaniesController } from './companies.controller';
import { CompaniesService } from './companies.service';
import { PeopleController } from './people.controller';
@@ -20,6 +21,7 @@ import { CustomerGroupsModule } from '../customer-groups/customer-groups.module'
PublicApiModule,
BehaviorModule,
CustomerGroupsModule,
NotificationsModule,
],
controllers: [PeopleController, CompaniesController],
providers: [PeopleService, CompaniesService, ContactsExcelService, SameMobileMergeService],
+5
View File
@@ -23,6 +23,11 @@ export class BirthdaySmsDto {
@MinLength(1)
@MaxLength(500)
text!: string;
/** تأیید ارسال مجدد وقتی قبلاً مسئول دیگری تبریک فرستاده */
@IsOptional()
@IsBoolean()
force?: boolean;
}
export class BulkDeletePeopleDto {
+2 -1
View File
@@ -139,6 +139,7 @@ export class PeopleController {
}
@Get('birthdays')
@RequirePermission('customers', { reads: true })
birthdays(@CurrentUser() user: Actor) {
return this.peopleService.birthdaysThisMonth(user);
}
@@ -171,7 +172,7 @@ export class PeopleController {
@Param('id') id: string,
@Body() dto: BirthdaySmsDto,
) {
return this.peopleService.sendBirthdaySms(user, id, dto.text);
return this.peopleService.sendBirthdaySms(user, id, dto.text, Boolean(dto.force));
}
@Get(':id')
+80 -5
View File
@@ -22,6 +22,7 @@ import {
FILTERED_IDS_MAX,
PERSON_MOODS,
applyPersonGenderTitle,
canViewAllRecords,
splitPersonHonorific,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
@@ -48,6 +49,7 @@ import {
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
import { LoyaltyService } from '../loyalty/loyalty.service';
import { AutomationsService } from '../automations/automations.service';
import { NotificationsService } from '../notifications/notifications.service';
import { MessageAutomationsService } from '../message-automations/message-automations.service';
import { WebhooksService } from '../public-api/webhooks.service';
import { GamificationService } from '../gamification/gamification.service';
@@ -157,6 +159,7 @@ export class PeopleService implements OnModuleInit {
private readonly outboundSms: OutboundSmsLogService,
private readonly behaviorAccess: BehaviorAccessService,
private readonly groups: CustomerGroupsService,
private readonly notifications: NotificationsService,
) {}
private personListWhere(
@@ -349,6 +352,7 @@ export class PeopleService implements OnModuleInit {
fromTahesab: Boolean(g.tahesabGid),
personCount: countMap.get(g.id) ?? 0,
companyCount: 0,
assignees: [],
}));
}
@@ -684,8 +688,8 @@ export class PeopleService implements OnModuleInit {
const rows = await this.prisma.person.findMany({
where: {
organizationId: actor.organizationId,
ownerId: actor.userId,
birthDate: { not: null },
...customersPersonWhere(actor),
},
select: {
id: true,
@@ -732,13 +736,17 @@ export class PeopleService implements OnModuleInit {
};
}
async sendBirthdaySms(actor: Actor, personId: string, text: string) {
async sendBirthdaySms(actor: Actor, personId: string, text: string, force = false) {
const body = text.trim();
if (!body || body.length > 500) {
throw new BadRequestException('متن پیامک را تا ۵۰۰ نویسه بنویسید.');
}
const person = await this.prisma.person.findFirst({
where: { id: personId, organizationId: actor.organizationId },
where: {
id: personId,
organizationId: actor.organizationId,
...customersPersonWhere(actor),
},
select: {
id: true,
firstName: true,
@@ -748,11 +756,29 @@ export class PeopleService implements OnModuleInit {
ownerId: true,
birthDate: true,
company: { select: { name: true } },
groupLinks: {
select: {
group: {
select: {
assignees: { select: { id: true, fullName: true } },
},
},
},
},
},
});
if (!person) throw new NotFoundException('مشتری پیدا نشد');
if (person.ownerId !== actor.userId) {
throw new ForbiddenException('فقط مسئول این مشتری می‌تواند پیامک تبریک بفرستد.');
const assigneeIds = new Set(
person.groupLinks.flatMap((link) => link.group.assignees.map((a) => a.id)),
);
const canSend =
canViewAllRecords(actor.role) ||
person.ownerId === actor.userId ||
assigneeIds.has(actor.userId);
if (!canSend) {
throw new ForbiddenException(
'فقط مالک مشتری یا مسئول گروه او می‌تواند پیامک تبریک بفرستد.',
);
}
if (!person.birthDate) {
throw new BadRequestException('برای این مشتری تاریخ تولد ثبت نشده است.');
@@ -774,6 +800,32 @@ export class PeopleService implements OnModuleInit {
const mobile = person.mobile || person.phone;
if (!mobile) throw new BadRequestException('برای این مشتری شماره موبایل ثبت نشده است.');
const fullName = `${person.firstName} ${person.lastName}`.trim();
const birthdayKey = `birthday:${inMonth.year}-${inMonth.month}`;
if (!force) {
const prior = await this.prisma.outboundSmsLog.findFirst({
where: {
organizationId: actor.organizationId,
personId: person.id,
status: 'sent',
sourceId: birthdayKey,
NOT: { actorUserId: actor.userId },
},
orderBy: { createdAt: 'desc' },
select: { actorUserId: true },
});
if (prior) {
const priorUser = prior.actorUserId
? await this.prisma.user.findFirst({
where: { id: prior.actorUserId },
select: { fullName: true },
})
: null;
const who = priorUser?.fullName?.trim() || 'مسئول دیگری';
throw new ConflictException(
`${who} قبلاً پیامک تبریک این مشتری را فرستاده است. آیا می‌خواهید دوباره بفرستید؟`,
);
}
}
const message = renderSmsText(body, {
name: fullName,
phone: mobile,
@@ -793,6 +845,7 @@ export class PeopleService implements OnModuleInit {
mobile,
body: message,
source: 'manual',
sourceId: birthdayKey,
status: result.ok ? 'sent' : 'failed',
error: result.ok ? null : result.reason,
actorUserId: actor.userId,
@@ -806,6 +859,28 @@ export class PeopleService implements OnModuleInit {
: 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.',
);
}
const senderName = (
await this.prisma.user.findFirst({
where: { id: actor.userId },
select: { fullName: true },
})
)?.fullName?.trim() || 'یکی از مسئولان';
const peers = [...assigneeIds].filter((id) => id !== actor.userId);
await Promise.all(
peers.map((userId) =>
this.notifications.create({
organizationId: actor.organizationId,
userId,
type: 'birthday_sms_sent',
title: 'پیامک تبریک تولد ارسال شد',
body: `${senderName} برای «${fullName}» پیامک تبریک فرستاد. اگر لازم است می‌توانید دوباره بفرستید.`,
link: `/contacts/people/${person.id}`,
entityType: 'person',
entityId: person.id,
dedupe: false,
}),
),
);
return { ok: true };
}
@@ -8,7 +8,14 @@ import {
Post,
UseGuards,
} from '@nestjs/common';
import { IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
import {
IsArray,
IsIn,
IsOptional,
IsString,
MaxLength,
MinLength,
} from 'class-validator';
import { CUSTOMER_GROUP_COLORS, type CustomerGroupColor } from '@mento/shared';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../auth/roles.guard';
@@ -26,6 +33,11 @@ class CreateCustomerGroupDto {
@IsOptional()
@IsIn([...CUSTOMER_GROUP_COLORS])
color?: CustomerGroupColor;
@IsOptional()
@IsArray()
@IsString({ each: true })
assigneeIds?: string[];
}
class UpdateCustomerGroupDto {
@@ -43,6 +55,11 @@ class UpdateCustomerGroupDto {
@IsOptional()
@IsString()
parentId?: string | null;
@IsOptional()
@IsArray()
@IsString({ each: true })
assigneeIds?: string[];
}
@Controller('customer-groups')
@@ -51,26 +51,21 @@ export class CustomerGroupsService {
include: {
_count: { select: { people: true, companies: true } },
parent: { select: { name: true } },
assignees: { select: { id: true, fullName: true }, orderBy: { fullName: 'asc' } },
},
});
return rows.map((r) => ({
id: r.id,
name: r.name,
color: r.color,
position: r.position,
fromTahesab: Boolean(r.tahesabGid),
personCount: r._count.people,
companyCount: r._count.companies,
parentId: r.parentId,
parentName: r.parent?.name ?? null,
}));
return rows.map((r) => this.toDto(r));
}
async create(
organizationId: string,
dto: { name: string; color?: CustomerGroupColor },
dto: { name: string; color?: CustomerGroupColor; assigneeIds?: string[] },
): Promise<CustomerGroupDto> {
const name = this.cleanName(dto.name);
const assigneeIds =
dto.assigneeIds !== undefined
? await this.validAssigneeIds(organizationId, dto.assigneeIds)
: [];
const max = await this.prisma.customerGroup.aggregate({
where: { organizationId },
_max: { position: true },
@@ -82,6 +77,9 @@ export class CustomerGroupsService {
name,
color: this.cleanColor(dto.color),
position: (max._max.position ?? -1) + 1,
...(assigneeIds.length
? { assignees: { connect: assigneeIds.map((id) => ({ id })) } }
: {}),
},
});
} catch (e) {
@@ -93,7 +91,12 @@ export class CustomerGroupsService {
async update(
organizationId: string,
id: string,
dto: { name?: string; color?: CustomerGroupColor; parentId?: string | null },
dto: {
name?: string;
color?: CustomerGroupColor;
parentId?: string | null;
assigneeIds?: string[];
},
): Promise<CustomerGroupDto> {
await this.require(organizationId, id);
const parentId =
@@ -101,6 +104,10 @@ export class CustomerGroupsService {
if (parentId !== undefined) {
await this.assertParent(organizationId, id, parentId);
}
const assigneeIds =
dto.assigneeIds !== undefined
? await this.validAssigneeIds(organizationId, dto.assigneeIds)
: undefined;
try {
await this.prisma.customerGroup.update({
where: { id },
@@ -108,6 +115,9 @@ export class CustomerGroupsService {
...(dto.name != null ? { name: this.cleanName(dto.name) } : {}),
...(dto.color != null ? { color: this.cleanColor(dto.color) } : {}),
...(parentId !== undefined ? { parentId } : {}),
...(assigneeIds !== undefined
? { assignees: { set: assigneeIds.map((uid) => ({ id: uid })) } }
: {}),
},
});
} catch (e) {
@@ -317,6 +327,48 @@ export class CustomerGroupsService {
return link?.group.name ?? null;
}
private toDto(r: {
id: string;
name: string;
color: string;
position: number;
tahesabGid: string | null;
parentId: string | null;
parent?: { name: string } | null;
_count: { people: number; companies: number };
assignees: Array<{ id: string; fullName: string }>;
}): CustomerGroupDto {
return {
id: r.id,
name: r.name,
color: r.color,
position: r.position,
fromTahesab: Boolean(r.tahesabGid),
personCount: r._count.people,
companyCount: r._count.companies,
parentId: r.parentId,
parentName: r.parent?.name ?? null,
assignees: r.assignees.map((a) => ({ id: a.id, fullName: a.fullName })),
};
}
private async validAssigneeIds(organizationId: string, ids: string[]): Promise<string[]> {
const unique = [...new Set((ids ?? []).map((id) => id?.trim()).filter(Boolean))];
if (!unique.length) return [];
const rows = await this.prisma.user.findMany({
where: {
id: { in: unique },
isActive: true,
memberships: { some: { organizationId, isActive: true } },
},
select: { id: true },
});
if (rows.length !== unique.length) {
throw new BadRequestException('یکی از مسئولان انتخاب‌شده معتبر نیست');
}
return rows.map((r) => r.id);
}
private async require(organizationId: string, id: string) {
const row = await this.prisma.customerGroup.findFirst({
where: { id, organizationId },
+52 -1
View File
@@ -1,6 +1,11 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { leadsDeepWhere, resolveSectionAccess } from '../src/common/data-access';
import {
customersCompanyWhere,
customersPersonWhere,
leadsDeepWhere,
resolveSectionAccess,
} from '../src/common/data-access';
import {
noteAccessWhere,
normalizeDataVisibility,
@@ -122,3 +127,49 @@ describe('عمق داده وقتی نقش فقط بعضی بخش‌ها را ذ
assert.deepEqual(leads.section, { mode: 'rules', rules: [{ owners: ['self'] }] });
});
});
describe('مسئول گروه مشتری روی عمق داده', () => {
const selfAccess = { mode: 'rules' as const, rules: [{ owners: ['self' as const] }] };
it('محدودیت self با OR مسئول گروه برای شخص', () => {
const where = customersPersonWhere({
userId: 'u1',
role: 'sales',
customersAccess: selfAccess,
teamPeerIds: null,
});
assert.deepEqual(where, {
OR: [
{ ownerId: 'u1' },
{ groupLinks: { some: { group: { assignees: { some: { id: 'u1' } } } } } },
],
});
});
it('محدودیت self با OR مسئول گروه برای شرکت', () => {
const where = customersCompanyWhere({
userId: 'u1',
role: 'sales',
customersAccess: selfAccess,
teamPeerIds: null,
});
assert.deepEqual(where, {
OR: [
{ ownerId: 'u1' },
{ groupLinks: { some: { group: { assignees: { some: { id: 'u1' } } } } } },
],
});
});
it('بدون محدودیت عمقی، فیلتر اضافه نمی‌شود', () => {
assert.deepEqual(
customersPersonWhere({
userId: 'u1',
role: 'sales',
customersAccess: { mode: 'all', rules: [] },
teamPeerIds: null,
}),
{},
);
});
});