diff --git a/apps/api/prisma/migrations/20260915190000_memberships/migration.sql b/apps/api/prisma/migrations/20260915190000_memberships/migration.sql new file mode 100644 index 0000000..699e7bf --- /dev/null +++ b/apps/api/prisma/migrations/20260915190000_memberships/migration.sql @@ -0,0 +1,55 @@ +-- CreateTable +CREATE TABLE IF NOT EXISTS "Membership" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "role" "UserRole" NOT NULL DEFAULT 'sales', + "orgRoleId" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "commissionPercent" DECIMAL(8,2) NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Membership_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX IF NOT EXISTS "Membership_userId_organizationId_key" ON "Membership"("userId", "organizationId"); +CREATE INDEX IF NOT EXISTS "Membership_organizationId_role_idx" ON "Membership"("organizationId", "role"); +CREATE INDEX IF NOT EXISTS "Membership_userId_idx" ON "Membership"("userId"); +CREATE INDEX IF NOT EXISTS "Membership_orgRoleId_idx" ON "Membership"("orgRoleId"); + +ALTER TABLE "Membership" DROP CONSTRAINT IF EXISTS "Membership_userId_fkey"; +ALTER TABLE "Membership" ADD CONSTRAINT "Membership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "Membership" DROP CONSTRAINT IF EXISTS "Membership_organizationId_fkey"; +ALTER TABLE "Membership" ADD CONSTRAINT "Membership_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "Membership" DROP CONSTRAINT IF EXISTS "Membership_orgRoleId_fkey"; +ALTER TABLE "Membership" ADD CONSTRAINT "Membership_orgRoleId_fkey" FOREIGN KEY ("orgRoleId") REFERENCES "OrgRole"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +INSERT INTO "Membership" ( + "id", + "userId", + "organizationId", + "role", + "orgRoleId", + "isActive", + "commissionPercent", + "createdAt", + "updatedAt" +) +SELECT + md5(random()::text || clock_timestamp()::text || u."id")::text, + u."id", + u."organizationId", + u."role", + u."orgRoleId", + COALESCE(u."isActive", true), + COALESCE(u."commissionPercent", 0), + u."createdAt", + CURRENT_TIMESTAMP +FROM "User" u +WHERE NOT EXISTS ( + SELECT 1 FROM "Membership" m + WHERE m."userId" = u."id" AND m."organizationId" = u."organizationId" +); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index c08dcb1..0b1827d 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -150,6 +150,7 @@ model Organization { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt users User[] + memberships Membership[] orgRoles OrgRole[] companies Company[] people Person[] @@ -227,6 +228,7 @@ model User { reportedTickets Ticket[] @relation("TicketReporter") ticketMessages TicketMessage[] @relation("TicketMessageAuthor") confirmedMemories MemoryItem[] @relation("MemoryConfirmedBy") + memberships Membership[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -248,6 +250,7 @@ model OrgRole { /// owner | admin | sales | support — نقش‌های سیستمی قابل حذف نیستند systemKey String? users User[] + memberships Membership[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -255,6 +258,26 @@ model OrgRole { @@index([organizationId]) } +model Membership { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + role UserRole @default(sales) + orgRoleId String? + orgRole OrgRole? @relation(fields: [orgRoleId], references: [id], onDelete: SetNull) + isActive Boolean @default(true) + commissionPercent Decimal @default(0) @db.Decimal(8, 2) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([userId, organizationId]) + @@index([organizationId, role]) + @@index([userId]) + @@index([orgRoleId]) +} + model Company { id String @id @default(cuid()) organizationId String diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 57775bc..c95bad0 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -139,6 +139,27 @@ async function main() { console.log('Seeded admin user:', adminEmail, '/ demo1234'); } + async function ensureMembership( + userId: string, + organizationId: string, + role: 'owner' | 'admin' | 'sales' | 'support', + ) { + await prisma.membership.upsert({ + where: { userId_organizationId: { userId, organizationId } }, + create: { userId, organizationId, role }, + update: { role }, + }); + } + + const ownerRow = await prisma.user.findFirst({ + where: { email, organizationId: org.id }, + }); + if (ownerRow) await ensureMembership(ownerRow.id, org.id, 'owner'); + const salesRow = await prisma.user.findFirst({ where: { email: salesEmail } }); + if (salesRow) await ensureMembership(salesRow.id, org.id, 'sales'); + const adminRow = await prisma.user.findFirst({ where: { email: adminEmail } }); + if (adminRow) await ensureMembership(adminRow.id, org.id, 'admin'); + let peopleCount = await prisma.person.count({ where: { organizationId: org.id }, }); diff --git a/apps/api/src/activities/activities-excel.service.ts b/apps/api/src/activities/activities-excel.service.ts index 444b43f..c236d25 100644 --- a/apps/api/src/activities/activities-excel.service.ts +++ b/apps/api/src/activities/activities-excel.service.ts @@ -272,7 +272,10 @@ export class ActivitiesExcelService { const n = name?.trim(); if (!n) return null; const row = await this.prisma.user.findFirst({ - where: { organizationId, fullName: { contains: n, mode: 'insensitive' } }, + where: { + fullName: { contains: n, mode: 'insensitive' }, + memberships: { some: { organizationId } }, + }, select: { id: true }, }); return row?.id ?? null; diff --git a/apps/api/src/activities/activities.service.ts b/apps/api/src/activities/activities.service.ts index c82f87e..dcc0f5a 100644 --- a/apps/api/src/activities/activities.service.ts +++ b/apps/api/src/activities/activities.service.ts @@ -420,7 +420,7 @@ export class ActivitiesService { private async assertUserInOrg(organizationId: string, userId: string) { const user = await this.prisma.user.findFirst({ - where: { id: userId, organizationId }, + where: { id: userId, memberships: { some: { organizationId } } }, select: { id: true }, }); if (!user) throw new BadRequestException('مسئول انتخاب‌شده معتبر نیست'); diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 20d4a3f..700313d 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -19,6 +19,8 @@ import { LoginOtpVerifyDto, RegisterOrganizationDto, RegisterVerifyOtpDto, + SelectOrganizationDto, + SwitchOrganizationDto, UpdateProfileDto, } from './dto'; import { JwtAuthGuard } from './jwt-auth.guard'; @@ -58,16 +60,36 @@ export class AuthController { return this.authService.verifyLoginOtp(dto); } + @Post('select-organization') + selectOrganization(@Body() dto: SelectOrganizationDto) { + return this.authService.selectOrganization(dto.selectionToken, dto.organizationId); + } + + @UseGuards(JwtAuthGuard) + @Post('switch-organization') + switchOrganization( + @CurrentUser() user: AuthActor, + @Body() dto: SwitchOrganizationDto, + ) { + return this.authService.switchOrganization(user.userId, dto.organizationId); + } + + @UseGuards(JwtAuthGuard) + @Get('organizations') + listOrganizations(@CurrentUser() user: AuthActor) { + return this.authService.listMyOrganizations(user.userId); + } + @UseGuards(JwtAuthGuard) @Get('me') me(@CurrentUser() user: AuthActor) { - return this.authService.me(user.userId); + return this.authService.me(user.userId, user.organizationId); } @UseGuards(JwtAuthGuard) @Patch('me') updateMe(@CurrentUser() user: AuthActor, @Body() dto: UpdateProfileDto) { - return this.authService.updateMe(user.userId, dto); + return this.authService.updateMe(user.userId, user.organizationId, dto); } /** عکس پروفایل کاربر جاری */ @@ -96,6 +118,6 @@ export class AuthController { @UseGuards(JwtAuthGuard) @Delete('me/avatar') removeAvatar(@CurrentUser() user: AuthActor) { - return this.authService.removeAvatar(user.userId); + return this.authService.removeAvatar(user.userId, user.organizationId); } } diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index 09ab0bc..d03650b 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; import { ConfigModule, ConfigService } from '@nestjs/config'; +import { MembershipsModule } from '../memberships/memberships.module'; import { AuthService } from './auth.service'; import { AuthController } from './auth.controller'; import { JwtStrategy } from './jwt.strategy'; @@ -11,6 +12,7 @@ import { SmsService } from '../common/sms.service'; @Module({ imports: [ ConfigModule, + MembershipsModule, PassportModule.register({ defaultStrategy: 'jwt' }), JwtModule.registerAsync({ imports: [ConfigModule], diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 1e63461..d65de97 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -10,11 +10,12 @@ import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcryptjs'; import * as path from 'path'; -import type { AuthUserDto, LoginResponse } from '@mento/shared'; +import type { AuthUserDto, LoginResponse, LoginResult } from '@mento/shared'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { SmsService } from '../common/sms.service'; import { LocalFileStorage } from '../storage/local-file.storage'; +import { MembershipsService } from '../memberships/memberships.service'; import { LoginDto, LoginOtpRequestDto, @@ -95,6 +96,7 @@ export class AuthService { private readonly config: ConfigService, private readonly sms: SmsService, private readonly files: LocalFileStorage, + private readonly memberships: MembershipsService, ) {} /** @@ -249,6 +251,16 @@ export class AuthService { include: { users: true }, }); + const owner = organization.users[0]; + if (!owner) { + throw new BadRequestException('ساخت حساب ناموفق بود'); + } + await this.memberships.createMembership({ + userId: owner.id, + organizationId: organization.id, + role: 'owner', + }); + await this.prisma.authChallenge.update({ where: { id: challenge.id }, data: { @@ -258,11 +270,11 @@ export class AuthService { }, }); - return this.buildLoginResponse(organization.users[0]); + return this.issueLogin(organization.users[0]!.id, organization.id); } /** ورود با رمز: ایمیل یا موبایل + رمز → JWT (بدون OTP) */ - async loginWithPassword(dto: LoginDto): Promise { + async loginWithPassword(dto: LoginDto): Promise { const identifier = (dto.identifier ?? dto.email ?? '').trim(); const isEmail = looksLikeEmail(identifier); const email = isEmail ? identifier.toLowerCase() : null; @@ -287,7 +299,7 @@ export class AuthService { throw new UnauthorizedException('اطلاعات ورود نادرست است'); } - return this.buildLoginResponse(user); + return this.finishLogin(user.id); } /** ورود اختیاری با موبایل + OTP (بدون رمز) — فقط کاربران ثبت‌شده */ @@ -332,7 +344,7 @@ export class AuthService { }; } - async verifyLoginOtp(dto: LoginOtpVerifyDto): Promise { + async verifyLoginOtp(dto: LoginOtpVerifyDto): Promise { const challenge = await this.prisma.authChallenge.findUnique({ where: { id: dto.challengeId }, }); @@ -374,18 +386,19 @@ export class AuthService { }); } - return this.buildLoginResponse(user); + return this.finishLogin(user.id); } - async me(userId: string): Promise { + async me(userId: string, organizationId: string): Promise { + await this.memberships.requireActive(userId, organizationId); const user = await this.prisma.user.findUnique({ where: { id: userId } }); if (!user) { throw new UnauthorizedException(); } - return this.toAuthUserDto(user); + return this.toAuthUserDto(user, organizationId); } - async updateMe(userId: string, dto: UpdateProfileDto): Promise { + async updateMe(userId: string, organizationId: string, dto: UpdateProfileDto): Promise { const fullName = dto.fullName.trim(); if (fullName.length < 2) { throw new BadRequestException('نام باید حداقل دو حرف باشد'); @@ -403,7 +416,7 @@ export class AuthService { if (email !== existing.email) { const taken = await this.prisma.user.findFirst({ - where: { organizationId: existing.organizationId, email, id: { not: userId } }, + where: { email, id: { not: userId } }, }); if (taken) { throw new ConflictException('این ایمیل قبلاً ثبت شده است'); @@ -423,7 +436,7 @@ export class AuthService { where: { id: userId }, data: { fullName, email, mobile }, }); - return this.toAuthUserDto(user); + return this.toAuthUserDto(user, organizationId); } catch (error) { if ( error instanceof Prisma.PrismaClientKnownRequestError && @@ -464,10 +477,10 @@ export class AuthService { await this.files.remove(prevKey); } await this.writeAvatarKey(userId, storageKey); - return this.toAuthUserDto(existing); + return this.toAuthUserDto(existing, organizationId); } - async removeAvatar(userId: string): Promise { + async removeAvatar(userId: string, organizationId: string): Promise { const existing = await this.prisma.user.findUnique({ where: { id: userId } }); if (!existing) { throw new UnauthorizedException(); @@ -475,7 +488,7 @@ export class AuthService { const prevKey = await this.readAvatarKey(userId); if (prevKey) await this.files.remove(prevKey); await this.writeAvatarKey(userId, null); - return this.toAuthUserDto(existing); + return this.toAuthUserDto(existing, organizationId); } async openAvatar(userId: string) { @@ -510,16 +523,106 @@ export class AuthService { `; } - private async toAuthUserDto(user: { - id: string; - email: string; - mobile?: string | null; - fullName: string; - role: AuthUserDto['role']; - organizationId: string; - }): Promise { + async listMyOrganizations(userId: string) { + const rows = await this.memberships.listActive(userId); + return this.memberships.toChoices(rows); + } + + async selectOrganization( + selectionToken: string, + organizationId: string, + ): Promise { + let payload: { sub?: string; typ?: string }; + try { + payload = this.jwt.verify<{ sub?: string; typ?: string }>(selectionToken); + } catch { + throw new UnauthorizedException('نشست انتخاب محیط منقضی شده است'); + } + if (payload.typ !== 'org-select' || !payload.sub) { + throw new UnauthorizedException('توکن انتخاب محیط نامعتبر است'); + } + return this.issueLogin(payload.sub, organizationId); + } + + async switchOrganization( + userId: string, + organizationId: string, + ): Promise { + return this.issueLogin(userId, organizationId); + } + + private async finishLogin(userId: string): Promise { + const rows = await this.memberships.listActive(userId); + if (!rows.length) { + throw new UnauthorizedException('به هیچ محیطی دسترسی ندارید'); + } + if (rows.length === 1) { + return this.issueLogin(userId, rows[0]!.organization.id); + } + return { + requiresOrganizationChoice: true, + selectionToken: this.jwt.sign( + { sub: userId, typ: 'org-select' }, + { expiresIn: '10m' }, + ), + organizations: this.memberships.toChoices(rows), + }; + } + + private async issueLogin( + userId: string, + organizationId: string, + ): Promise { + const membership = await this.memberships.requireActive(userId, organizationId); + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user || user.isActive === false) { + throw new UnauthorizedException('حساب کاربری غیرفعال است'); + } + await this.memberships.syncUserLastOrg( + userId, + organizationId, + membership.role, + ); + await this.prisma.user.update({ + where: { id: userId }, + data: { lastLoginAt: new Date() }, + }); + const accessToken = this.jwt.sign({ + sub: userId, + organizationId, + role: membership.role, + }); + return { + accessToken, + user: await this.toAuthUserDto( + { ...user, role: membership.role, organizationId }, + organizationId, + ), + }; + } + + private async toAuthUserDto( + user: { + id: string; + email: string; + mobile?: string | null; + fullName: string; + role: AuthUserDto['role']; + organizationId: string; + }, + organizationId: string, + ): Promise { + const membership = await this.prisma.membership.findFirst({ + where: { userId: user.id, organizationId, isActive: true }, + include: { organization: { select: { name: true } } }, + }); + const role = membership?.role ?? user.role; + const orgName = membership?.organization.name; return this.toAuthUser({ ...user, + role, + organizationId, + organizationName: orgName, avatarStorageKey: await this.readAvatarKey(user.id), }); } @@ -596,29 +699,6 @@ export class AuthService { this.logger.log(`[auth-otp-sms] ${mobile} => ${code}`); } - private async buildLoginResponse(user: { - id: string; - email: string; - mobile?: string | null; - fullName: string; - role: AuthUserDto['role']; - organizationId: string; - }): Promise { - const payload = { - sub: user.id, - organizationId: user.organizationId, - role: user.role, - }; - await this.prisma.user.update({ - where: { id: user.id }, - data: { lastLoginAt: new Date() }, - }); - return { - accessToken: this.jwt.sign(payload), - user: await this.toAuthUserDto(user), - }; - } - private toAuthUser(user: { id: string; email: string; @@ -626,6 +706,7 @@ export class AuthService { fullName: string; role: AuthUserDto['role']; organizationId: string; + organizationName?: string; avatarStorageKey?: string | null; }): AuthUserDto { return { @@ -634,6 +715,7 @@ export class AuthService { fullName: user.fullName, role: user.role, organizationId: user.organizationId, + organizationName: user.organizationName, hasAvatar: Boolean(user.avatarStorageKey), ...(user.mobile ? { mobile: user.mobile } : {}), }; diff --git a/apps/api/src/auth/dto.ts b/apps/api/src/auth/dto.ts index 869b9a5..03e9478 100644 --- a/apps/api/src/auth/dto.ts +++ b/apps/api/src/auth/dto.ts @@ -107,3 +107,19 @@ export class UpdateProfileDto { }) mobile!: string; } + +export class SelectOrganizationDto { + @IsString() + @MinLength(10) + selectionToken!: string; + + @IsString() + @MinLength(1) + organizationId!: string; +} + +export class SwitchOrganizationDto { + @IsString() + @MinLength(1) + organizationId!: string; +} diff --git a/apps/api/src/auth/jwt.strategy.ts b/apps/api/src/auth/jwt.strategy.ts index 8261724..3ca78ff 100644 --- a/apps/api/src/auth/jwt.strategy.ts +++ b/apps/api/src/auth/jwt.strategy.ts @@ -24,20 +24,34 @@ export class JwtStrategy extends PassportStrategy(Strategy) { } async validate(payload: JwtPayload & { typ?: string; mobile?: string }) { - if (payload.typ === 'portal') { + if (payload.typ === 'portal' || payload.typ === 'org-select') { + throw new UnauthorizedException(); + } + if (!payload.sub || !payload.organizationId) { throw new UnauthorizedException(); } const user = await this.prisma.user.findUnique({ where: { id: payload.sub }, - select: { id: true, organizationId: true, role: true, isActive: true }, + select: { id: true, isActive: true }, }); if (!user || user.isActive === false) { throw new UnauthorizedException(); } + const membership = await this.prisma.membership.findFirst({ + where: { + userId: user.id, + organizationId: payload.organizationId, + isActive: true, + }, + select: { organizationId: true, role: true }, + }); + if (!membership) { + throw new UnauthorizedException(); + } return { userId: user.id, - organizationId: user.organizationId, - role: user.role, + organizationId: membership.organizationId, + role: membership.role, }; } } diff --git a/apps/api/src/automations/automations.service.ts b/apps/api/src/automations/automations.service.ts index 292a4bf..f0feabf 100644 --- a/apps/api/src/automations/automations.service.ts +++ b/apps/api/src/automations/automations.service.ts @@ -350,7 +350,10 @@ export class AutomationsService { if (wantSms) { const user = await this.prisma.user.findFirst({ - where: { id: userId, organizationId: ctx.organizationId }, + where: { + id: userId, + memberships: { some: { organizationId: ctx.organizationId } }, + }, select: { mobile: true }, }); await this.sendAutomationSms(user?.mobile, smsText); diff --git a/apps/api/src/finance/cheque-reminders.service.ts b/apps/api/src/finance/cheque-reminders.service.ts index 1b1ea5c..4d30e64 100644 --- a/apps/api/src/finance/cheque-reminders.service.ts +++ b/apps/api/src/finance/cheque-reminders.service.ts @@ -96,10 +96,24 @@ export class ChequeRemindersService { const staff = await this.prisma.user.findMany({ where: { - organizationId: row.organizationId, OR: [ - { role: { in: ['owner', 'admin'] } }, - ...(row.ownerId ? [{ id: row.ownerId }] : []), + { + memberships: { + some: { + organizationId: row.organizationId, + role: { in: ['owner', 'admin'] }, + isActive: true, + }, + }, + }, + ...(row.ownerId + ? [ + { + id: row.ownerId, + memberships: { some: { organizationId: row.organizationId } }, + }, + ] + : []), ], }, select: { id: true, mobile: true }, diff --git a/apps/api/src/finance/commissions.service.ts b/apps/api/src/finance/commissions.service.ts index e84d48f..e392da5 100644 --- a/apps/api/src/finance/commissions.service.ts +++ b/apps/api/src/finance/commissions.service.ts @@ -75,7 +75,17 @@ export class CommissionsService { const fixed = invoice.commissionFixedAmount; const overridePercent = invoice.commissionPercent; - const defaultPercent = Number(invoice.owner?.commissionPercent ?? 0); + const membership = invoice.ownerId + ? await this.prisma.membership.findFirst({ + where: { + userId: invoice.ownerId, + organizationId, + isActive: true, + }, + select: { commissionPercent: true }, + }) + : null; + const defaultPercent = Number(membership?.commissionPercent ?? 0); const percent = fixed != null ? payable > 0 diff --git a/apps/api/src/lead-forms/lead-forms.service.ts b/apps/api/src/lead-forms/lead-forms.service.ts index 39ce02b..7053df4 100644 --- a/apps/api/src/lead-forms/lead-forms.service.ts +++ b/apps/api/src/lead-forms/lead-forms.service.ts @@ -242,7 +242,11 @@ export class LeadFormsService { ownerId ?? ( await this.prisma.user.findFirst({ - where: { organizationId: form.organizationId, role: 'owner' }, + where: { + memberships: { + some: { organizationId: form.organizationId, role: 'owner' }, + }, + }, select: { id: true }, }) )?.id; @@ -447,7 +451,10 @@ export class LeadFormsService { ) { if (!assigneeIds?.length) return; const count = await this.prisma.user.count({ - where: { organizationId, id: { in: assigneeIds } }, + where: { + id: { in: assigneeIds }, + memberships: { some: { organizationId } }, + }, }); if (count !== assigneeIds.length) { throw new BadRequestException('یکی از مسئولان انتخاب‌شده معتبر نیست'); diff --git a/apps/api/src/memberships/memberships.module.ts b/apps/api/src/memberships/memberships.module.ts new file mode 100644 index 0000000..0d59ad9 --- /dev/null +++ b/apps/api/src/memberships/memberships.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { MembershipsService } from './memberships.service'; + +@Module({ + providers: [MembershipsService], + exports: [MembershipsService], +}) +export class MembershipsModule {} diff --git a/apps/api/src/memberships/memberships.service.ts b/apps/api/src/memberships/memberships.service.ts new file mode 100644 index 0000000..9ef165c --- /dev/null +++ b/apps/api/src/memberships/memberships.service.ts @@ -0,0 +1,161 @@ +import { + ForbiddenException, + Injectable, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import type { OrganizationMembershipDto, UserRole } from '@mento/shared'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; + +export type MembershipActor = { + userId: string; + organizationId: string; + role: UserRole; +}; + +@Injectable() +export class MembershipsService { + constructor(private readonly prisma: PrismaService) {} + + async listActive(userId: string) { + return this.prisma.membership.findMany({ + where: { userId, isActive: true }, + include: { + organization: { select: { id: true, name: true } }, + orgRole: { select: { name: true } }, + }, + orderBy: { createdAt: 'asc' }, + }); + } + + async requireActive( + userId: string, + organizationId: string, + ): Promise { + const row = await this.prisma.membership.findFirst({ + where: { userId, organizationId, isActive: true }, + select: { userId: true, organizationId: true, role: true }, + }); + if (!row) { + throw new ForbiddenException('به این محیط دسترسی ندارید'); + } + return row; + } + + toChoices( + rows: Array<{ + organization: { id: string; name: string }; + role: UserRole; + orgRole: { name: string } | null; + }>, + ): OrganizationMembershipDto[] { + return rows.map((row) => ({ + id: row.organization.id, + name: row.organization.name, + role: row.role, + orgRoleName: row.orgRole?.name, + })); + } + + async createMembership(data: { + userId: string; + organizationId: string; + role: UserRole; + orgRoleId?: string | null; + commissionPercent?: number; + isActive?: boolean; + }) { + return this.prisma.membership.create({ + data: { + userId: data.userId, + organizationId: data.organizationId, + role: data.role, + orgRoleId: data.orgRoleId ?? null, + isActive: data.isActive ?? true, + commissionPercent: + data.commissionPercent == null + ? undefined + : new Prisma.Decimal(data.commissionPercent), + }, + }); + } + + async findInOrg(organizationId: string, userId: string) { + return this.prisma.membership.findFirst({ + where: { organizationId, userId }, + include: { + user: true, + orgRole: { select: { id: true, name: true, color: true } }, + }, + }); + } + + async listInOrg(organizationId: string) { + return this.prisma.membership.findMany({ + where: { organizationId }, + include: { + user: true, + orgRole: { select: { id: true, name: true, color: true } }, + }, + orderBy: [{ user: { lastLoginAt: 'desc' } }, { user: { fullName: 'asc' } }], + }); + } + + async countActiveOwners(organizationId: string, exceptUserId?: string) { + return this.prisma.membership.count({ + where: { + organizationId, + role: 'owner', + isActive: true, + ...(exceptUserId ? { userId: { not: exceptUserId } } : {}), + }, + }); + } + + async syncUserLastOrg( + userId: string, + organizationId: string, + role: UserRole, + orgRoleId?: string | null, + ) { + await this.prisma.user.update({ + where: { id: userId }, + data: { + organizationId, + role, + ...(orgRoleId !== undefined ? { orgRoleId } : {}), + }, + }); + } + + async findUserInOrgById(organizationId: string, userId: string) { + const row = await this.findInOrg(organizationId, userId); + if (!row) throw new NotFoundException('کاربر پیدا نشد'); + return row; + } + + async findUserInOrgByIdentity( + organizationId: string, + where: { id?: string; email?: string; mobile?: string }, + ) { + const user = await this.prisma.user.findFirst({ + where: { + ...(where.id ? { id: where.id } : {}), + ...(where.email ? { email: where.email } : {}), + ...(where.mobile ? { mobile: where.mobile } : {}), + }, + select: { id: true }, + }); + if (!user) return null; + return this.prisma.membership.findFirst({ + where: { organizationId, userId: user.id }, + include: { user: true }, + }); + } +} + +export function unauthorizedIfMissing(value: T | null | undefined): T { + if (!value) throw new UnauthorizedException(); + return value; +} diff --git a/apps/api/src/message-automations/message-automations.service.ts b/apps/api/src/message-automations/message-automations.service.ts index d63f7a7..718046e 100644 --- a/apps/api/src/message-automations/message-automations.service.ts +++ b/apps/api/src/message-automations/message-automations.service.ts @@ -384,7 +384,10 @@ export class MessageAutomationsService { } if (userId && wantSms) { const user = await this.prisma.user.findFirst({ - where: { id: userId, organizationId: job.organizationId }, + where: { + id: userId, + memberships: { some: { organizationId: job.organizationId } }, + }, select: { mobile: true }, }); await this.sendSms(user?.mobile, smsText); diff --git a/apps/api/src/org-roles/org-roles.service.ts b/apps/api/src/org-roles/org-roles.service.ts index ca5e713..3aa108b 100644 --- a/apps/api/src/org-roles/org-roles.service.ts +++ b/apps/api/src/org-roles/org-roles.service.ts @@ -23,13 +23,24 @@ type RoleRow = { color: string; permissions: string[]; systemKey: string | null; - users: Array<{ - id: string; - fullName: string; - avatarStorageKey: string | null; + memberships: Array<{ + user: { + id: string; + fullName: string; + avatarStorageKey: string | null; + }; }>; }; +const ROLE_MEMBERS_INCLUDE = { + memberships: { + include: { + user: { select: { id: true, fullName: true, avatarStorageKey: true } }, + }, + orderBy: { createdAt: 'asc' as const }, + }, +}; + @Injectable() export class OrgRolesService { constructor(private readonly prisma: PrismaService) {} @@ -38,12 +49,7 @@ export class OrgRolesService { await this.ensureDefaultRoles(organizationId); const rows = await this.prisma.orgRole.findMany({ where: { organizationId }, - include: { - users: { - select: { id: true, fullName: true, avatarStorageKey: true }, - orderBy: { fullName: 'asc' }, - }, - }, + include: ROLE_MEMBERS_INCLUDE, orderBy: [{ systemKey: 'asc' }, { createdAt: 'asc' }], }); return rows.map((row) => this.toDto(row)); @@ -73,12 +79,7 @@ export class OrgRolesService { color: dto.color ?? 'teal', permissions: dto.permissions?.length ? dto.permissions : [...ALL_ROLE_PERMISSIONS], }, - include: { - users: { - select: { id: true, fullName: true, avatarStorageKey: true }, - orderBy: { fullName: 'asc' }, - }, - }, + include: ROLE_MEMBERS_INCLUDE, }); return this.toDto(row); } catch (e) { @@ -122,12 +123,7 @@ export class OrgRolesService { const row = await this.prisma.orgRole.update({ where: { id: existing.id }, data, - include: { - users: { - select: { id: true, fullName: true, avatarStorageKey: true }, - orderBy: { fullName: 'asc' }, - }, - }, + include: ROLE_MEMBERS_INCLUDE, }); return this.toDto(row); } catch (e) { @@ -189,15 +185,15 @@ export class OrgRolesService { byKey.set(seed.systemKey, created); } - const unassigned = await this.prisma.user.findMany({ + const unassigned = await this.prisma.membership.findMany({ where: { organizationId, orgRoleId: null }, select: { id: true, role: true }, }); - for (const user of unassigned) { - const role = byKey.get(user.role); + for (const membership of unassigned) { + const role = byKey.get(membership.role); if (!role) continue; - await this.prisma.user.update({ - where: { id: user.id }, + await this.prisma.membership.update({ + where: { id: membership.id }, data: { orgRoleId: role.id }, }); } @@ -253,11 +249,11 @@ export class OrgRolesService { color: row.color, permissions: sanitizePermissions(row.permissions), systemKey, - userCount: row.users.length, - members: row.users.map((user) => ({ - id: user.id, - fullName: user.fullName, - hasAvatar: Boolean(user.avatarStorageKey), + userCount: row.memberships.length, + members: row.memberships.map((membership) => ({ + id: membership.user.id, + fullName: membership.user.fullName, + hasAvatar: Boolean(membership.user.avatarStorageKey), })), }; } diff --git a/apps/api/src/organizations/dto.ts b/apps/api/src/organizations/dto.ts index dd5bc11..5fd09d9 100644 --- a/apps/api/src/organizations/dto.ts +++ b/apps/api/src/organizations/dto.ts @@ -45,6 +45,17 @@ export class UpdateOrgCurrenciesDto { rates?: ExchangeRateInputDto[]; } +export class CreateOrganizationDto { + @IsString() + @MinLength(2) + @MaxLength(120) + name!: string; + + @IsOptional() + @IsIn([...BUSINESS_FIELDS]) + businessField?: (typeof BUSINESS_FIELDS)[number]; +} + export class UpdateOrganizationDto { @IsOptional() @IsString() diff --git a/apps/api/src/organizations/organizations.controller.ts b/apps/api/src/organizations/organizations.controller.ts index a9a3145..10e8331 100644 --- a/apps/api/src/organizations/organizations.controller.ts +++ b/apps/api/src/organizations/organizations.controller.ts @@ -1,15 +1,32 @@ -import { Body, Controller, Get, Patch, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Patch, Post, UseGuards } from '@nestjs/common'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { RolesGuard } from '../auth/roles.guard'; import { Roles } from '../auth/roles.decorator'; import { CurrentUser } from '../auth/current-user.decorator'; -import { UpdateOrgCurrenciesDto, UpdateOrganizationDto } from './dto'; +import { AuthService } from '../auth/auth.service'; +import { + CreateOrganizationDto, + UpdateOrgCurrenciesDto, + UpdateOrganizationDto, +} from './dto'; import { OrganizationsService } from './organizations.service'; @Controller('organizations') @UseGuards(JwtAuthGuard, RolesGuard) export class OrganizationsController { - constructor(private readonly organizationsService: OrganizationsService) {} + constructor( + private readonly organizationsService: OrganizationsService, + private readonly authService: AuthService, + ) {} + + @Post() + async create( + @CurrentUser() user: { userId: string }, + @Body() dto: CreateOrganizationDto, + ) { + const org = await this.organizationsService.createForUser(user.userId, dto); + return this.authService.switchOrganization(user.userId, org.id); + } @Get('current') current(@CurrentUser() user: { organizationId: string }) { diff --git a/apps/api/src/organizations/organizations.module.ts b/apps/api/src/organizations/organizations.module.ts index b18a788..4b4da7d 100644 --- a/apps/api/src/organizations/organizations.module.ts +++ b/apps/api/src/organizations/organizations.module.ts @@ -1,8 +1,13 @@ import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { MembershipsModule } from '../memberships/memberships.module'; +import { OrgRolesModule } from '../org-roles/org-roles.module'; +import { PipelinesModule } from '../pipelines/pipelines.module'; import { OrganizationsController } from './organizations.controller'; import { OrganizationsService } from './organizations.service'; @Module({ + imports: [AuthModule, MembershipsModule, OrgRolesModule, PipelinesModule], controllers: [OrganizationsController], providers: [OrganizationsService], }) diff --git a/apps/api/src/organizations/organizations.service.ts b/apps/api/src/organizations/organizations.service.ts index 12901a1..99cd17d 100644 --- a/apps/api/src/organizations/organizations.service.ts +++ b/apps/api/src/organizations/organizations.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, Injectable, NotFoundException, + UnauthorizedException, } from '@nestjs/common'; import type { OrganizationDto, @@ -11,10 +12,74 @@ import { CURRENCY_CATALOG, isBusinessField } from '@mento/shared'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { isKnownCurrency } from '../common/currency'; +import { MembershipsService } from '../memberships/memberships.service'; +import { OrgRolesService } from '../org-roles/org-roles.service'; +import { PipelinesService } from '../pipelines/pipelines.service'; + +function slugify(input: string): string { + return ( + input + .trim() + .toLowerCase() + .replace(/[^\w\u0600-\u06FF]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 48) || `org-${Date.now()}` + ); +} @Injectable() export class OrganizationsService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly memberships: MembershipsService, + private readonly orgRoles: OrgRolesService, + private readonly pipelines: PipelinesService, + ) {} + + async createForUser( + userId: string, + dto: { name: string; businessField?: string }, + ): Promise<{ id: string }> { + const name = dto.name.trim(); + if (name.length < 2) { + throw new BadRequestException('نام محیط معتبر نیست'); + } + const businessField = + dto.businessField && isBusinessField(dto.businessField) + ? dto.businessField + : 'personal'; + + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { id: true, isActive: true }, + }); + if (!user || user.isActive === false) { + throw new UnauthorizedException(); + } + + const baseSlug = slugify(name); + let slug = baseSlug; + let i = 1; + while (await this.prisma.organization.findUnique({ where: { slug } })) { + slug = `${baseSlug}-${i++}`; + } + + const organization = await this.prisma.organization.create({ + data: { + name, + slug, + businessField, + }, + }); + await this.memberships.createMembership({ + userId, + organizationId: organization.id, + role: 'owner', + }); + await this.orgRoles.ensureDefaultRoles(organization.id); + await this.pipelines.ensureDefaultPipeline(organization.id); + return { id: organization.id }; + } async getCurrent(organizationId: string): Promise { const org = await this.prisma.organization.findUnique({ diff --git a/apps/api/src/pipelines/deals-excel.service.ts b/apps/api/src/pipelines/deals-excel.service.ts index 85d5fc6..0b78551 100644 --- a/apps/api/src/pipelines/deals-excel.service.ts +++ b/apps/api/src/pipelines/deals-excel.service.ts @@ -311,7 +311,10 @@ export class DealsExcelService { const n = name?.trim(); if (!n) return null; const row = await this.prisma.user.findFirst({ - where: { organizationId, fullName: { contains: n, mode: 'insensitive' } }, + where: { + fullName: { contains: n, mode: 'insensitive' }, + memberships: { some: { organizationId } }, + }, select: { id: true }, }); return row?.id ?? null; diff --git a/apps/api/src/portal/portal.service.ts b/apps/api/src/portal/portal.service.ts index f77d771..3c9b62e 100644 --- a/apps/api/src/portal/portal.service.ts +++ b/apps/api/src/portal/portal.service.ts @@ -234,8 +234,13 @@ export class PortalService { const owner = (await this.prisma.user.findFirst({ where: { - organizationId: actor.organizationId, - role: { in: ['owner', 'admin', 'support'] }, + memberships: { + some: { + organizationId: actor.organizationId, + role: { in: ['owner', 'admin', 'support'] }, + isActive: true, + }, + }, }, orderBy: { createdAt: 'asc' }, })) ?? null; diff --git a/apps/api/src/project-boards/project-cards-excel.service.ts b/apps/api/src/project-boards/project-cards-excel.service.ts index 7866c1b..ed1ca1c 100644 --- a/apps/api/src/project-boards/project-cards-excel.service.ts +++ b/apps/api/src/project-boards/project-cards-excel.service.ts @@ -258,7 +258,10 @@ export class ProjectCardsExcelService { const n = name?.trim(); if (!n) return null; const row = await this.prisma.user.findFirst({ - where: { organizationId, fullName: { contains: n, mode: 'insensitive' } }, + where: { + fullName: { contains: n, mode: 'insensitive' }, + memberships: { some: { organizationId } }, + }, select: { id: true }, }); return row?.id ?? null; diff --git a/apps/api/src/reports/reports.service.ts b/apps/api/src/reports/reports.service.ts index 4241e7d..a555e72 100644 --- a/apps/api/src/reports/reports.service.ts +++ b/apps/api/src/reports/reports.service.ts @@ -129,7 +129,7 @@ export class ReportsService { select: { mood: true, createdAt: true }, }), this.prisma.user.findMany({ - where: { organizationId: orgId }, + where: { memberships: { some: { organizationId: orgId } } }, select: { id: true, fullName: true }, }), this.prisma.invoice.findMany({ diff --git a/apps/api/src/sales/sales.service.ts b/apps/api/src/sales/sales.service.ts index 28078b3..cf04435 100644 --- a/apps/api/src/sales/sales.service.ts +++ b/apps/api/src/sales/sales.service.ts @@ -425,7 +425,10 @@ export class SalesService { return null; } const user = await this.prisma.user.findFirst({ - where: { id: requested, organizationId: actor.organizationId }, + where: { + id: requested, + memberships: { some: { organizationId: actor.organizationId } }, + }, select: { id: true }, }); if (!user) throw new BadRequestException('کاربر هدف معتبر نیست'); diff --git a/apps/api/src/tickets/tickets.service.ts b/apps/api/src/tickets/tickets.service.ts index 38ee321..a80bd58 100644 --- a/apps/api/src/tickets/tickets.service.ts +++ b/apps/api/src/tickets/tickets.service.ts @@ -432,7 +432,7 @@ export class TicketsService { private async assertUser(organizationId: string, userId: string) { const user = await this.prisma.user.findFirst({ - where: { id: userId, organizationId }, + where: { id: userId, memberships: { some: { organizationId } } }, select: { id: true }, }); if (!user) throw new BadRequestException('کاربر مسئول معتبر نیست'); diff --git a/apps/api/src/users/dto.ts b/apps/api/src/users/dto.ts index 584f11d..1d8099b 100644 --- a/apps/api/src/users/dto.ts +++ b/apps/api/src/users/dto.ts @@ -10,6 +10,7 @@ import { MaxLength, Min, MinLength, + ValidateIf, } from 'class-validator'; const INVITE_ROLES = ['admin', 'sales', 'support'] as const; @@ -27,10 +28,12 @@ export class InviteUserDto { @MaxLength(120) fullName!: string; + @IsOptional() + @ValidateIf((_, value) => typeof value === 'string' && value.length > 0) @IsString() @MinLength(8) @MaxLength(72) - password!: string; + password?: string; @IsOptional() @IsIn(INVITE_ROLES) diff --git a/apps/api/src/users/users.module.ts b/apps/api/src/users/users.module.ts index 25aaf5a..fa052aa 100644 --- a/apps/api/src/users/users.module.ts +++ b/apps/api/src/users/users.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; +import { MembershipsModule } from '../memberships/memberships.module'; import { OrgRolesModule } from '../org-roles/org-roles.module'; import { UsersController } from './users.controller'; import { UsersService } from './users.service'; @Module({ - imports: [AuthModule, OrgRolesModule], + imports: [AuthModule, OrgRolesModule, MembershipsModule], controllers: [UsersController], providers: [UsersService], exports: [UsersService], diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts index e9de29b..1a6b2e9 100644 --- a/apps/api/src/users/users.service.ts +++ b/apps/api/src/users/users.service.ts @@ -11,6 +11,7 @@ import { Prisma } from '@prisma/client'; import { normalizeMobile } from '../auth/auth.service'; import { PrismaService } from '../prisma/prisma.service'; import { OrgRolesService } from '../org-roles/org-roles.service'; +import { MembershipsService } from '../memberships/memberships.service'; import { LocalFileStorage } from '../storage/local-file.storage'; import * as path from 'path'; @@ -26,19 +27,22 @@ const AVATAR_EXT: Record = { gif: 'image/gif', }; -type UserRow = { - id: string; - email: string; - mobile: string | null; - fullName: string; +type MembershipRow = { role: UserRole; + orgRoleId: string | null; + isActive: boolean; commissionPercent: { toString(): string } | number | null; createdAt: Date; - isActive: boolean; - lastLoginAt: Date | null; - orgRoleId: string | null; - avatarStorageKey: string | null; orgRole: { id: string; name: string; color: string } | null; + user: { + id: string; + email: string; + mobile: string | null; + fullName: string; + lastLoginAt: Date | null; + avatarStorageKey: string | null; + organizationId: string; + }; }; @Injectable() @@ -46,17 +50,14 @@ export class UsersService { constructor( private readonly prisma: PrismaService, private readonly orgRoles: OrgRolesService, + private readonly memberships: MembershipsService, private readonly files: LocalFileStorage, ) {} async list(organizationId: string): Promise { await this.orgRoles.ensureDefaultRoles(organizationId); - const rows = await this.prisma.user.findMany({ - where: { organizationId }, - include: { orgRole: { select: { id: true, name: true, color: true } } }, - orderBy: [{ lastLoginAt: 'desc' }, { fullName: 'asc' }], - }); - return rows.map((u) => this.toDto(u)); + const rows = await this.memberships.listInOrg(organizationId); + return rows.map((row) => this.toDto(row)); } async invite( @@ -73,19 +74,6 @@ export class UsersService { throw new BadRequestException('شماره موبایل معتبر نیست'); } - const existingMobile = await this.prisma.user.findFirst({ where: { mobile } }); - if (existingMobile) { - throw new ConflictException('این شماره موبایل قبلاً ثبت شده است'); - } - - const email = inviteEmailFromMobile(mobile); - const existingEmail = await this.prisma.user.findFirst({ - where: { organizationId, email }, - }); - if (existingEmail) { - throw new ConflictException('این شماره موبایل قبلاً ثبت شده است'); - } - const resolved = await this.orgRoles.resolveInviteRole(organizationId, { orgRoleId: dto.orgRoleId, role: dto.role, @@ -94,9 +82,27 @@ export class UsersService { throw new ForbiddenException('فقط مالک می‌تواند نقش مدیر سیستم را واگذار کند'); } - const passwordHash = await bcrypt.hash(dto.password, 10); + const existingUser = await this.prisma.user.findFirst({ where: { mobile } }); + if (existingUser) { + return this.addExistingUser(organizationId, existingUser.id, resolved); + } + + const password = dto.password?.trim() ?? ''; + if (password.length < 8) { + throw new BadRequestException( + 'برای کاربر جدید رمز عبور حداقل ۸ کاراکتر لازم است', + ); + } + + const email = inviteEmailFromMobile(mobile); + const existingEmail = await this.prisma.user.findFirst({ where: { email } }); + if (existingEmail) { + throw new ConflictException('این شماره موبایل قبلاً ثبت شده است'); + } + + const passwordHash = await bcrypt.hash(password, 10); try { - const row = await this.prisma.user.create({ + const created = await this.prisma.user.create({ data: { organizationId, email, @@ -107,9 +113,14 @@ export class UsersService { orgRoleId: resolved.orgRoleId, isActive: true, }, - include: { orgRole: { select: { id: true, name: true, color: true } } }, }); - return this.toDto(row); + await this.memberships.createMembership({ + userId: created.id, + organizationId, + role: resolved.role, + orgRoleId: resolved.orgRoleId, + }); + return this.dtoOf(organizationId, created.id); } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { throw new ConflictException('این شماره موبایل قبلاً ثبت شده است'); @@ -130,16 +141,18 @@ export class UsersService { if (percent < 0 || percent > 100) { throw new BadRequestException('درصد پورسانت باید بین ۰ تا ۱۰۰ باشد'); } - const target = await this.prisma.user.findFirst({ - where: { id: targetId, organizationId }, - }); - if (!target) throw new NotFoundException('کاربر پیدا نشد'); - const row = await this.prisma.user.update({ + const target = await this.memberships.findUserInOrgById(organizationId, targetId); + await this.prisma.membership.update({ where: { id: target.id }, data: { commissionPercent: new Prisma.Decimal(percent) }, - include: { orgRole: { select: { id: true, name: true, color: true } } }, }); - return this.toDto(row); + if (target.user.organizationId === organizationId) { + await this.prisma.user.update({ + where: { id: target.user.id }, + data: { commissionPercent: new Prisma.Decimal(percent) }, + }); + } + return this.dtoOf(organizationId, targetId); } async updateRole( @@ -152,10 +165,7 @@ export class UsersService { throw new ForbiddenException('فقط مالک یا مدیر می‌تواند نقش را تغییر دهد'); } - const target = await this.prisma.user.findFirst({ - where: { id: targetId, organizationId }, - }); - if (!target) throw new NotFoundException('کاربر پیدا نشد'); + const target = await this.memberships.findUserInOrgById(organizationId, targetId); let nextRole = dto.role ?? target.role; let nextOrgRoleId = dto.orgRoleId ?? target.orgRoleId; @@ -186,22 +196,30 @@ export class UsersService { } if (target.role === 'owner' && nextRole !== 'owner') { - const owners = await this.prisma.user.count({ - where: { organizationId, role: 'owner' }, - }); - if (owners <= 1) { + const owners = await this.memberships.countActiveOwners( + organizationId, + target.userId, + ); + if (owners < 1) { throw new BadRequestException( 'حداقل یک مالک برای سازمان لازم است؛ ابتدا مالک دیگری تعیین کنید', ); } } - const row = await this.prisma.user.update({ + await this.prisma.membership.update({ where: { id: target.id }, data: { role: nextRole, orgRoleId: nextOrgRoleId }, - include: { orgRole: { select: { id: true, name: true, color: true } } }, }); - return this.toDto(row); + if (target.user.organizationId === organizationId) { + await this.memberships.syncUserLastOrg( + target.userId, + organizationId, + nextRole, + nextOrgRoleId, + ); + } + return this.dtoOf(organizationId, targetId); } async updateStatus( @@ -216,58 +234,97 @@ export class UsersService { if (actor.userId === targetId && !isActive) { throw new BadRequestException('نمی‌توانید حساب خودتان را غیرفعال کنید'); } - const target = await this.prisma.user.findFirst({ - where: { id: targetId, organizationId }, - }); - if (!target) throw new NotFoundException('کاربر پیدا نشد'); + const target = await this.memberships.findUserInOrgById(organizationId, targetId); if (target.role === 'owner' && !isActive && actor.role !== 'owner') { throw new ForbiddenException('مدیر نمی‌تواند مالک را غیرفعال کند'); } if (target.role === 'owner' && !isActive) { - const activeOwners = await this.prisma.user.count({ - where: { organizationId, role: 'owner', isActive: true, id: { not: targetId } }, - }); + const activeOwners = await this.memberships.countActiveOwners( + organizationId, + targetId, + ); if (activeOwners < 1) { throw new BadRequestException('حداقل یک مالک فعال لازم است'); } } - const row = await this.prisma.user.update({ + await this.prisma.membership.update({ where: { id: target.id }, data: { isActive }, - include: { orgRole: { select: { id: true, name: true, color: true } } }, }); - return this.toDto(row); + return this.dtoOf(organizationId, targetId); } async openAvatar(organizationId: string, userId: string) { - const user = await this.prisma.user.findFirst({ - where: { id: userId, organizationId }, - select: { id: true, avatarStorageKey: true }, - }); - if (!user?.avatarStorageKey) { + const membership = await this.memberships.findInOrg(organizationId, userId); + if (!membership) throw new NotFoundException('کاربر پیدا نشد'); + const key = membership.user.avatarStorageKey; + if (!key) { throw new NotFoundException('عکس پروفایل تنظیم نشده'); } - const ext = path.extname(user.avatarStorageKey).slice(1).toLowerCase(); + const ext = path.extname(key).slice(1).toLowerCase(); const mime = AVATAR_EXT[ext] ?? 'image/jpeg'; return { - stream: this.files.openReadStream(user.avatarStorageKey), + stream: this.files.openReadStream(key), mimeType: mime, }; } - private toDto(row: UserRow): OrgUserDto { + private async addExistingUser( + organizationId: string, + userId: string, + resolved: { role: UserRole; orgRoleId: string }, + ): Promise { + const existing = await this.prisma.membership.findFirst({ + where: { userId, organizationId }, + }); + if (existing?.isActive) { + throw new ConflictException('این کاربر هم‌اکنون عضو این محیط است'); + } + if (existing) { + await this.prisma.membership.update({ + where: { id: existing.id }, + data: { + isActive: true, + role: resolved.role, + orgRoleId: resolved.orgRoleId, + }, + }); + return this.dtoOf(organizationId, userId); + } + try { + await this.memberships.createMembership({ + userId, + organizationId, + role: resolved.role, + orgRoleId: resolved.orgRoleId, + }); + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { + throw new ConflictException('این کاربر هم‌اکنون عضو این محیط است'); + } + throw e; + } + return this.dtoOf(organizationId, userId); + } + + private async dtoOf(organizationId: string, userId: string): Promise { + const row = await this.memberships.findUserInOrgById(organizationId, userId); + return this.toDto(row); + } + + private toDto(row: MembershipRow & { userId?: string }): OrgUserDto { return { - id: row.id, - email: row.email, - mobile: row.mobile ?? null, - fullName: row.fullName, + id: row.user.id, + email: row.user.email, + mobile: row.user.mobile ?? null, + fullName: row.user.fullName, role: row.role, orgRoleId: row.orgRoleId, orgRoleName: row.orgRole?.name, orgRoleColor: row.orgRole?.color ?? this.orgRoles.defaultColorFor(row.role), isActive: row.isActive, - lastLoginAt: row.lastLoginAt ? row.lastLoginAt.toISOString() : null, - hasAvatar: Boolean(row.avatarStorageKey), + lastLoginAt: row.user.lastLoginAt ? row.user.lastLoginAt.toISOString() : null, + hasAvatar: Boolean(row.user.avatarStorageKey), commissionPercent: Number(row.commissionPercent ?? 0), createdAt: row.createdAt.toISOString(), }; diff --git a/apps/web/src/components/OrgSwitcher.vue b/apps/web/src/components/OrgSwitcher.vue new file mode 100644 index 0000000..62e1607 --- /dev/null +++ b/apps/web/src/components/OrgSwitcher.vue @@ -0,0 +1,352 @@ + + + + + diff --git a/apps/web/src/layouts/AppLayout.vue b/apps/web/src/layouts/AppLayout.vue index c3a76a9..4caf264 100644 --- a/apps/web/src/layouts/AppLayout.vue +++ b/apps/web/src/layouts/AppLayout.vue @@ -7,7 +7,7 @@ import { formatJalaliShort, toFaDigits } from '@/lib/jalali'; import UiIcon from '@/components/UiIcon.vue'; import MentoLogo from '@/components/brand/MentoLogo.vue'; import QuickCreate from '@/components/QuickCreate.vue'; -import BrandName from '@/components/brand/BrandName.vue'; +import OrgSwitcher from '@/components/OrgSwitcher.vue'; import NavHubPanel from '@/components/nav/NavHubPanel.vue'; import { NAV_HUBS, type NavHubDef, type NavHubKey } from '@/lib/nav-hubs'; import type { @@ -523,10 +523,13 @@ onUnmounted(() => {

- +
+ + +