Configuring access rights in the CRM Super Admin
This commit is contained in:
@@ -33,6 +33,7 @@ import { TahesabModule } from './integrations/tahesab/tahesab.module';
|
||||
import { InventoryModule } from './inventory/inventory.module';
|
||||
import { FinanceModule } from './finance/finance.module';
|
||||
import { PlatformModule } from './platform/platform.module';
|
||||
import { BillingModule } from './billing/billing.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -45,6 +46,7 @@ import { PlatformModule } from './platform/platform.module';
|
||||
PrismaModule,
|
||||
StorageModule,
|
||||
HealthModule,
|
||||
BillingModule,
|
||||
AuthModule,
|
||||
PlatformModule,
|
||||
UsersModule,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { MembershipsModule } from '../memberships/memberships.module';
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
@@ -13,6 +14,7 @@ import { SmsService } from '../common/sms.service';
|
||||
imports: [
|
||||
ConfigModule,
|
||||
MembershipsModule,
|
||||
BillingModule,
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
|
||||
@@ -10,12 +10,19 @@ import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import * as path from 'path';
|
||||
import type { AuthUserDto, LoginResponse, LoginResult } from '@mento/shared';
|
||||
import type {
|
||||
AuthUserDto,
|
||||
LoginResponse,
|
||||
LoginResult,
|
||||
OrgModuleKey,
|
||||
SubscriptionSummaryDto,
|
||||
} 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 { BillingService } from '../billing/billing.service';
|
||||
import {
|
||||
LoginDto,
|
||||
LoginOtpRequestDto,
|
||||
@@ -97,6 +104,7 @@ export class AuthService {
|
||||
private readonly sms: SmsService,
|
||||
private readonly files: LocalFileStorage,
|
||||
private readonly memberships: MembershipsService,
|
||||
private readonly billing: BillingService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -260,6 +268,11 @@ export class AuthService {
|
||||
organizationId: organization.id,
|
||||
role: 'owner',
|
||||
});
|
||||
await this.billing.ensureTrialForOwner(owner.id, { maxOrganizations: 1 });
|
||||
await this.prisma.organization.update({
|
||||
where: { id: organization.id },
|
||||
data: { enabledModules: this.billing.defaultModulesForNewOrg() },
|
||||
});
|
||||
|
||||
await this.prisma.authChallenge.update({
|
||||
where: { id: challenge.id },
|
||||
@@ -578,6 +591,9 @@ export class AuthService {
|
||||
if (!user || user.isActive === false) {
|
||||
throw new UnauthorizedException('حساب کاربری غیرفعال است');
|
||||
}
|
||||
const subscription = await this.billing.requireActiveForOrganization(
|
||||
organizationId,
|
||||
);
|
||||
await this.memberships.syncUserLastOrg(
|
||||
userId,
|
||||
organizationId,
|
||||
@@ -597,6 +613,7 @@ export class AuthService {
|
||||
user: await this.toAuthUserDto(
|
||||
{ ...user, role: membership.role, organizationId },
|
||||
organizationId,
|
||||
subscription,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -611,19 +628,29 @@ export class AuthService {
|
||||
organizationId: string;
|
||||
},
|
||||
organizationId: string,
|
||||
subscription?: SubscriptionSummaryDto | null,
|
||||
): Promise<AuthUserDto> {
|
||||
const membership = await this.prisma.membership.findFirst({
|
||||
where: { userId: user.id, organizationId, isActive: true },
|
||||
include: { organization: { select: { name: true } } },
|
||||
include: {
|
||||
organization: { select: { name: true, enabledModules: true } },
|
||||
},
|
||||
});
|
||||
const role = membership?.role ?? user.role;
|
||||
const orgName = membership?.organization.name;
|
||||
const sub =
|
||||
subscription ??
|
||||
(await this.billing.requireActiveForOrganization(organizationId));
|
||||
return this.toAuthUser({
|
||||
...user,
|
||||
role,
|
||||
organizationId,
|
||||
organizationName: orgName,
|
||||
avatarStorageKey: await this.readAvatarKey(user.id),
|
||||
enabledModules: this.billing.normalizeModules(
|
||||
membership?.organization.enabledModules,
|
||||
),
|
||||
subscription: sub,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -708,6 +735,8 @@ export class AuthService {
|
||||
organizationId: string;
|
||||
organizationName?: string;
|
||||
avatarStorageKey?: string | null;
|
||||
enabledModules?: OrgModuleKey[];
|
||||
subscription?: SubscriptionSummaryDto | null;
|
||||
}): AuthUserDto {
|
||||
return {
|
||||
id: user.id,
|
||||
@@ -718,6 +747,8 @@ export class AuthService {
|
||||
organizationName: user.organizationName,
|
||||
hasAvatar: Boolean(user.avatarStorageKey),
|
||||
...(user.mobile ? { mobile: user.mobile } : {}),
|
||||
...(user.enabledModules ? { enabledModules: user.enabledModules } : {}),
|
||||
...(user.subscription ? { subscription: user.subscription } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export type JwtPayload = {
|
||||
@@ -15,6 +16,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly billing: BillingService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
@@ -53,6 +55,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
if (!membership) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
await this.billing.requireActiveForOrganization(membership.organizationId);
|
||||
return {
|
||||
userId: user.id,
|
||||
organizationId: membership.organizationId,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { BillingService } from './billing.service';
|
||||
import { OrgModuleGuard } from './org-module.guard';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [BillingService, OrgModuleGuard],
|
||||
exports: [BillingService, OrgModuleGuard],
|
||||
})
|
||||
export class BillingModule {}
|
||||
@@ -0,0 +1,287 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
ApplyBillingPlanRequest,
|
||||
BillingAccountDto,
|
||||
BillingAccountStatus,
|
||||
BillingPlanCode,
|
||||
OrgModuleKey,
|
||||
SubscriptionSummaryDto,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
BILLING_PLAN_DAYS,
|
||||
BILLING_PLAN_DEFAULT_MAX_ORGS,
|
||||
defaultEnabledModules,
|
||||
isOrgModuleKey,
|
||||
} from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
function daysRemaining(expiresAt: Date, now = new Date()): number {
|
||||
const ms = expiresAt.getTime() - now.getTime();
|
||||
return Math.max(0, Math.ceil(ms / (24 * 60 * 60 * 1000)));
|
||||
}
|
||||
|
||||
function toSummary(row: {
|
||||
planCode: string;
|
||||
status: string;
|
||||
startsAt: Date;
|
||||
expiresAt: Date;
|
||||
maxOrganizations: number;
|
||||
}): SubscriptionSummaryDto {
|
||||
const remaining = daysRemaining(row.expiresAt);
|
||||
const status = (row.status as BillingAccountStatus) ?? 'active';
|
||||
return {
|
||||
planCode: row.planCode as BillingPlanCode,
|
||||
status,
|
||||
startsAt: row.startsAt.toISOString(),
|
||||
expiresAt: row.expiresAt.toISOString(),
|
||||
maxOrganizations: row.maxOrganizations,
|
||||
daysRemaining: remaining,
|
||||
warn: remaining > 0 && remaining <= 3,
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BillingService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
toDto(row: {
|
||||
id: string;
|
||||
ownerUserId: string;
|
||||
planCode: string;
|
||||
status: string;
|
||||
startsAt: Date;
|
||||
expiresAt: Date;
|
||||
maxOrganizations: number;
|
||||
externalPaymentRef: string | null;
|
||||
notes: string | null;
|
||||
}): BillingAccountDto {
|
||||
const summary = toSummary(row);
|
||||
return {
|
||||
id: row.id,
|
||||
ownerUserId: row.ownerUserId,
|
||||
...summary,
|
||||
externalPaymentRef: row.externalPaymentRef,
|
||||
notes: row.notes,
|
||||
};
|
||||
}
|
||||
|
||||
async getByOwnerUserId(ownerUserId: string) {
|
||||
return this.prisma.billingAccount.findUnique({ where: { ownerUserId } });
|
||||
}
|
||||
|
||||
async ensureTrialForOwner(
|
||||
ownerUserId: string,
|
||||
opts?: { maxOrganizations?: number },
|
||||
) {
|
||||
const existing = await this.getByOwnerUserId(ownerUserId);
|
||||
if (existing) return existing;
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + 15 * 24 * 60 * 60 * 1000);
|
||||
return this.prisma.billingAccount.create({
|
||||
data: {
|
||||
ownerUserId,
|
||||
planCode: 'trial',
|
||||
status: 'active',
|
||||
startsAt: now,
|
||||
expiresAt,
|
||||
maxOrganizations: Math.max(1, opts?.maxOrganizations ?? 1),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** اشتراک محیط = BillingAccount اولین مالک فعال */
|
||||
async resolveForOrganization(organizationId: string) {
|
||||
const owner = await this.prisma.membership.findFirst({
|
||||
where: {
|
||||
organizationId,
|
||||
role: 'owner',
|
||||
isActive: true,
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (!owner) return null;
|
||||
let account = await this.getByOwnerUserId(owner.userId);
|
||||
if (!account) {
|
||||
account = await this.ensureTrialForOwner(owner.userId);
|
||||
}
|
||||
return account;
|
||||
}
|
||||
|
||||
async requireActiveForOrganization(organizationId: string): Promise<SubscriptionSummaryDto> {
|
||||
const account = await this.resolveForOrganization(organizationId);
|
||||
if (!account) {
|
||||
throw new UnauthorizedException('اشتراک این محیط یافت نشد');
|
||||
}
|
||||
return this.assertActive(account);
|
||||
}
|
||||
|
||||
async requireActiveForOwner(ownerUserId: string): Promise<SubscriptionSummaryDto> {
|
||||
let account = await this.getByOwnerUserId(ownerUserId);
|
||||
if (!account) {
|
||||
account = await this.ensureTrialForOwner(ownerUserId);
|
||||
}
|
||||
return this.assertActive(account);
|
||||
}
|
||||
|
||||
private async assertActive(account: {
|
||||
id: string;
|
||||
planCode: string;
|
||||
status: string;
|
||||
startsAt: Date;
|
||||
expiresAt: Date;
|
||||
maxOrganizations: number;
|
||||
}): Promise<SubscriptionSummaryDto> {
|
||||
const now = new Date();
|
||||
if (account.status === 'suspended') {
|
||||
throw new UnauthorizedException('اشتراک این حساب معلق شده است');
|
||||
}
|
||||
if (account.status === 'expired' || account.expiresAt.getTime() < now.getTime()) {
|
||||
if (account.status !== 'expired') {
|
||||
await this.prisma.billingAccount.update({
|
||||
where: { id: account.id },
|
||||
data: { status: 'expired' },
|
||||
});
|
||||
}
|
||||
throw new UnauthorizedException('اشتراک منقضی شده است');
|
||||
}
|
||||
return toSummary(account);
|
||||
}
|
||||
|
||||
async assertCanCreateOrganization(ownerUserId: string) {
|
||||
const summary = await this.requireActiveForOwner(ownerUserId);
|
||||
const ownedCount = await this.prisma.membership.count({
|
||||
where: { userId: ownerUserId, role: 'owner', isActive: true },
|
||||
});
|
||||
if (ownedCount >= summary.maxOrganizations) {
|
||||
throw new ForbiddenException(
|
||||
`سقف تعداد محیط برای این اشتراک ${summary.maxOrganizations} است`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async applyPlan(
|
||||
ownerUserId: string,
|
||||
dto: ApplyBillingPlanRequest,
|
||||
): Promise<BillingAccountDto> {
|
||||
const planCode = dto.planCode;
|
||||
const now = new Date();
|
||||
let expiresAt: Date;
|
||||
let maxOrganizations: number;
|
||||
|
||||
if (planCode === 'custom') {
|
||||
if (!dto.expiresAt) {
|
||||
throw new BadRequestException('برای پلن سفارشی تاریخ انقضا لازم است');
|
||||
}
|
||||
expiresAt = new Date(dto.expiresAt);
|
||||
if (Number.isNaN(expiresAt.getTime())) {
|
||||
throw new BadRequestException('تاریخ انقضا معتبر نیست');
|
||||
}
|
||||
maxOrganizations = Math.max(1, dto.maxOrganizations ?? 1);
|
||||
} else {
|
||||
const days = BILLING_PLAN_DAYS[planCode];
|
||||
expiresAt = dto.expiresAt
|
||||
? new Date(dto.expiresAt)
|
||||
: new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
|
||||
if (Number.isNaN(expiresAt.getTime())) {
|
||||
throw new BadRequestException('تاریخ انقضا معتبر نیست');
|
||||
}
|
||||
maxOrganizations = Math.max(
|
||||
1,
|
||||
dto.maxOrganizations ?? BILLING_PLAN_DEFAULT_MAX_ORGS[planCode],
|
||||
);
|
||||
}
|
||||
|
||||
const status: BillingAccountStatus =
|
||||
dto.status ??
|
||||
(expiresAt.getTime() < now.getTime() ? 'expired' : 'active');
|
||||
|
||||
const existing = await this.getByOwnerUserId(ownerUserId);
|
||||
const row = existing
|
||||
? await this.prisma.billingAccount.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
planCode,
|
||||
status,
|
||||
startsAt: existing.startsAt ?? now,
|
||||
expiresAt,
|
||||
maxOrganizations,
|
||||
...(dto.notes !== undefined ? { notes: dto.notes } : {}),
|
||||
},
|
||||
})
|
||||
: await this.prisma.billingAccount.create({
|
||||
data: {
|
||||
ownerUserId,
|
||||
planCode,
|
||||
status,
|
||||
startsAt: now,
|
||||
expiresAt,
|
||||
maxOrganizations,
|
||||
notes: dto.notes ?? null,
|
||||
},
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async setOrgModules(organizationId: string, modules: string[]) {
|
||||
const cleaned = [
|
||||
...new Set(
|
||||
modules.filter(isOrgModuleKey),
|
||||
),
|
||||
] as OrgModuleKey[];
|
||||
await this.prisma.organization.update({
|
||||
where: { id: organizationId },
|
||||
data: { enabledModules: cleaned },
|
||||
});
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
normalizeModules(raw: string[] | null | undefined): OrgModuleKey[] {
|
||||
if (!raw?.length) return defaultEnabledModules();
|
||||
const cleaned = raw.filter(isOrgModuleKey) as OrgModuleKey[];
|
||||
return cleaned.length ? cleaned : defaultEnabledModules();
|
||||
}
|
||||
|
||||
async requireModule(organizationId: string, module: OrgModuleKey) {
|
||||
const org = await this.prisma.organization.findUnique({
|
||||
where: { id: organizationId },
|
||||
select: { enabledModules: true },
|
||||
});
|
||||
if (!org) throw new ForbiddenException('محیط پیدا نشد');
|
||||
const enabled = this.normalizeModules(org.enabledModules);
|
||||
if (!enabled.includes(module)) {
|
||||
throw new ForbiddenException('این ماژول برای این محیط فعال نیست');
|
||||
}
|
||||
}
|
||||
|
||||
defaultModulesForNewOrg(): OrgModuleKey[] {
|
||||
return defaultEnabledModules();
|
||||
}
|
||||
|
||||
async countExpiringSoon() {
|
||||
const now = new Date();
|
||||
const in3 = new Date(now.getTime() + 3 * 24 * 60 * 60 * 1000);
|
||||
return this.prisma.billingAccount.count({
|
||||
where: {
|
||||
status: 'active',
|
||||
expiresAt: { gt: now, lte: in3 },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async countExpired() {
|
||||
return this.prisma.billingAccount.count({
|
||||
where: {
|
||||
OR: [
|
||||
{ status: 'expired' },
|
||||
{ status: 'active', expiresAt: { lt: new Date() } },
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import type { OrgModuleKey } from '@mento/shared';
|
||||
|
||||
export const ORG_MODULE_KEY = 'org_module';
|
||||
|
||||
export const RequireOrgModule = (module: OrgModuleKey) =>
|
||||
SetMetadata(ORG_MODULE_KEY, module);
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { OrgModuleKey } from '@mento/shared';
|
||||
import { BillingService } from './billing.service';
|
||||
import { ORG_MODULE_KEY } from './org-module.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class OrgModuleGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly billing: BillingService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const module = this.reflector.getAllAndOverride<OrgModuleKey | undefined>(
|
||||
ORG_MODULE_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
if (!module) return true;
|
||||
const req = context.switchToHttp().getRequest<{
|
||||
user?: { organizationId?: string };
|
||||
}>();
|
||||
const organizationId = req.user?.organizationId;
|
||||
if (!organizationId) return false;
|
||||
await this.billing.requireModule(organizationId, module);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -18,13 +18,16 @@ import type { Response } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { OrgModuleGuard } from '../billing/org-module.guard';
|
||||
import { RequireOrgModule } from '../billing/org-module.decorator';
|
||||
import { CreatePersonDto, ListQueryDto, UpdatePersonDto } from './dto';
|
||||
import { PeopleService } from './people.service';
|
||||
import { ContactsExcelService } from './contacts-excel.service';
|
||||
import { brand } from '@mento/shared';
|
||||
|
||||
@Controller('people')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, OrgModuleGuard)
|
||||
@RequireOrgModule('customers')
|
||||
export class PeopleController {
|
||||
constructor(
|
||||
private readonly peopleService: PeopleService,
|
||||
|
||||
@@ -12,6 +12,8 @@ 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 { OrgModuleGuard } from '../billing/org-module.guard';
|
||||
import { RequireOrgModule } from '../billing/org-module.decorator';
|
||||
import {
|
||||
CreateMessageAutomationRuleDto,
|
||||
UpdateMessageAutomationRuleDto,
|
||||
@@ -19,7 +21,8 @@ import {
|
||||
import { MessageAutomationsService } from './message-automations.service';
|
||||
|
||||
@Controller('message-automations')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
|
||||
@RequireOrgModule('message_automations')
|
||||
@Roles('owner', 'admin')
|
||||
export class MessageAutomationsController {
|
||||
constructor(private readonly messages: MessageAutomationsService) {}
|
||||
|
||||
@@ -3,11 +3,18 @@ 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 { BillingModule } from '../billing/billing.module';
|
||||
import { OrganizationsController } from './organizations.controller';
|
||||
import { OrganizationsService } from './organizations.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, MembershipsModule, OrgRolesModule, PipelinesModule],
|
||||
imports: [
|
||||
AuthModule,
|
||||
MembershipsModule,
|
||||
OrgRolesModule,
|
||||
PipelinesModule,
|
||||
BillingModule,
|
||||
],
|
||||
controllers: [OrganizationsController],
|
||||
providers: [OrganizationsService],
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ 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';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
|
||||
function slugify(input: string): string {
|
||||
return (
|
||||
@@ -34,6 +35,7 @@ export class OrganizationsService {
|
||||
private readonly memberships: MembershipsService,
|
||||
private readonly orgRoles: OrgRolesService,
|
||||
private readonly pipelines: PipelinesService,
|
||||
private readonly billing: BillingService,
|
||||
) {}
|
||||
|
||||
async createForUser(
|
||||
@@ -57,6 +59,8 @@ export class OrganizationsService {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
await this.billing.assertCanCreateOrganization(userId);
|
||||
|
||||
const baseSlug = slugify(name);
|
||||
let slug = baseSlug;
|
||||
let i = 1;
|
||||
@@ -69,6 +73,7 @@ export class OrganizationsService {
|
||||
name,
|
||||
slug,
|
||||
businessField,
|
||||
enabledModules: this.billing.defaultModulesForNewOrg(),
|
||||
},
|
||||
});
|
||||
await this.memberships.createMembership({
|
||||
@@ -76,6 +81,7 @@ export class OrganizationsService {
|
||||
organizationId: organization.id,
|
||||
role: 'owner',
|
||||
});
|
||||
await this.billing.ensureTrialForOwner(userId);
|
||||
await this.orgRoles.ensureDefaultRoles(organization.id);
|
||||
await this.pipelines.ensureDefaultPipeline(organization.id);
|
||||
return { id: organization.id };
|
||||
@@ -219,6 +225,7 @@ export class OrganizationsService {
|
||||
businessField: string | null;
|
||||
baseCurrency: string;
|
||||
enabledCurrencies: string[];
|
||||
enabledModules?: string[];
|
||||
exchangeRates: Array<{
|
||||
currency: string;
|
||||
rateToBase: Prisma.Decimal;
|
||||
@@ -235,6 +242,7 @@ export class OrganizationsService {
|
||||
: null,
|
||||
baseCurrency: org.baseCurrency,
|
||||
enabledCurrencies: org.enabledCurrencies,
|
||||
enabledModules: this.billing.normalizeModules(org.enabledModules),
|
||||
exchangeRates: org.exchangeRates.map((r) => ({
|
||||
currency: r.currency,
|
||||
rateToBase: Number(r.rateToBase),
|
||||
|
||||
@@ -18,13 +18,16 @@ import type { Response } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { OrgModuleGuard } from '../billing/org-module.guard';
|
||||
import { RequireOrgModule } from '../billing/org-module.decorator';
|
||||
import { CreateDealDto, MoveDealDto, UpdateDealDto } from './dto';
|
||||
import { DealsService } from './deals.service';
|
||||
import { DealsExcelService } from './deals-excel.service';
|
||||
import { brand } from '@mento/shared';
|
||||
|
||||
@Controller('deals')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, OrgModuleGuard)
|
||||
@RequireOrgModule('pipeline')
|
||||
export class DealsController {
|
||||
constructor(
|
||||
private readonly dealsService: DealsService,
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEmail,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
@@ -119,3 +122,32 @@ export class ResetPlatformUserPasswordDto {
|
||||
@MaxLength(120)
|
||||
password!: string;
|
||||
}
|
||||
|
||||
export class ApplyBillingPlanDto {
|
||||
@IsIn(['trial', 'monthly', 'quarterly', 'semiannual', 'yearly', 'custom'])
|
||||
planCode!: 'trial' | 'monthly' | 'quarterly' | 'semiannual' | 'yearly' | 'custom';
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
maxOrganizations?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
expiresAt?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
notes?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['active', 'expired', 'suspended'])
|
||||
status?: 'active' | 'expired' | 'suspended';
|
||||
}
|
||||
|
||||
export class UpdateOrgModulesDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
enabledModules!: string[];
|
||||
}
|
||||
|
||||
@@ -10,10 +10,12 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import {
|
||||
ApplyBillingPlanDto,
|
||||
CreatePlatformAdminDto,
|
||||
CreatePlatformOrganizationDto,
|
||||
ResetPlatformAdminPasswordDto,
|
||||
ResetPlatformUserPasswordDto,
|
||||
UpdateOrgModulesDto,
|
||||
UpdatePlatformMembershipRoleDto,
|
||||
UpdatePlatformOrganizationDto,
|
||||
UpdatePlatformStatusDto,
|
||||
@@ -105,6 +107,19 @@ export class PlatformController {
|
||||
return this.platform.resetUserPassword(id, dto);
|
||||
}
|
||||
|
||||
@Post('users/:id/billing/apply-plan')
|
||||
applyBillingPlan(@Param('id') id: string, @Body() dto: ApplyBillingPlanDto) {
|
||||
return this.platform.applyBillingPlan(id, dto);
|
||||
}
|
||||
|
||||
@Patch('organizations/:id/modules')
|
||||
updateOrganizationModules(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateOrgModulesDto,
|
||||
) {
|
||||
return this.platform.updateOrganizationModules(id, dto);
|
||||
}
|
||||
|
||||
@Patch('memberships/:id')
|
||||
setMembershipActive(
|
||||
@Param('id') id: string,
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 { BillingModule } from '../billing/billing.module';
|
||||
import { PlatformAuthController } from './platform-auth.controller';
|
||||
import { PlatformAuthGuard } from './platform-auth.guard';
|
||||
import { PlatformAuthService } from './platform-auth.service';
|
||||
@@ -17,6 +18,7 @@ import { PlatformService } from './platform.service';
|
||||
MembershipsModule,
|
||||
OrgRolesModule,
|
||||
PipelinesModule,
|
||||
BillingModule,
|
||||
PassportModule,
|
||||
],
|
||||
controllers: [PlatformAuthController, PlatformController],
|
||||
|
||||
@@ -18,12 +18,15 @@ import { normalizeMobile } from '../auth/auth.service';
|
||||
import { MembershipsService } from '../memberships/memberships.service';
|
||||
import { OrgRolesService } from '../org-roles/org-roles.service';
|
||||
import { PipelinesService } from '../pipelines/pipelines.service';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type {
|
||||
ApplyBillingPlanDto,
|
||||
CreatePlatformAdminDto,
|
||||
CreatePlatformOrganizationDto,
|
||||
ResetPlatformAdminPasswordDto,
|
||||
ResetPlatformUserPasswordDto,
|
||||
UpdateOrgModulesDto,
|
||||
UpdatePlatformOrganizationDto,
|
||||
} from './dto';
|
||||
|
||||
@@ -45,6 +48,7 @@ export class PlatformService {
|
||||
private readonly memberships: MembershipsService,
|
||||
private readonly orgRoles: OrgRolesService,
|
||||
private readonly pipelines: PipelinesService,
|
||||
private readonly billing: BillingService,
|
||||
) {}
|
||||
|
||||
async stats(): Promise<PlatformStatsDto> {
|
||||
@@ -61,12 +65,18 @@ export class PlatformService {
|
||||
this.prisma.user.count({ where: { isActive: true } }),
|
||||
this.prisma.membership.count(),
|
||||
]);
|
||||
const [expiringSoonCount, expiredBillingCount] = await Promise.all([
|
||||
this.billing.countExpiringSoon(),
|
||||
this.billing.countExpired(),
|
||||
]);
|
||||
return {
|
||||
organizationCount,
|
||||
activeOrganizationCount,
|
||||
userCount,
|
||||
activeUserCount,
|
||||
membershipCount,
|
||||
expiringSoonCount,
|
||||
expiredBillingCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -89,6 +99,7 @@ export class PlatformService {
|
||||
businessField: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
enabledModules: true,
|
||||
_count: {
|
||||
select: {
|
||||
memberships: true,
|
||||
@@ -111,6 +122,7 @@ export class PlatformService {
|
||||
businessField: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
enabledModules: true,
|
||||
_count: {
|
||||
select: {
|
||||
memberships: true,
|
||||
@@ -213,6 +225,7 @@ export class PlatformService {
|
||||
name,
|
||||
slug,
|
||||
businessField,
|
||||
enabledModules: this.billing.defaultModulesForNewOrg(),
|
||||
users: {
|
||||
create: {
|
||||
email: ownerEmail,
|
||||
@@ -238,6 +251,7 @@ export class PlatformService {
|
||||
organizationId: organization.id,
|
||||
role: 'owner',
|
||||
});
|
||||
await this.billing.ensureTrialForOwner(owner.id, { maxOrganizations: 1 });
|
||||
await this.orgRoles.ensureDefaultRoles(organization.id);
|
||||
await this.pipelines.ensureDefaultPipeline(organization.id);
|
||||
|
||||
@@ -331,6 +345,7 @@ export class PlatformService {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 300,
|
||||
include: {
|
||||
billingAccount: true,
|
||||
memberships: {
|
||||
include: {
|
||||
organization: {
|
||||
@@ -356,12 +371,33 @@ export class PlatformService {
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
},
|
||||
billingAccount: true,
|
||||
},
|
||||
});
|
||||
if (!row) throw new NotFoundException('کاربر پیدا نشد');
|
||||
return this.userToDto(row);
|
||||
}
|
||||
|
||||
async applyBillingPlan(userId: string, dto: ApplyBillingPlanDto) {
|
||||
const existing = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('کاربر پیدا نشد');
|
||||
await this.billing.applyPlan(userId, dto);
|
||||
return this.getUser(userId);
|
||||
}
|
||||
|
||||
async updateOrganizationModules(id: string, dto: UpdateOrgModulesDto) {
|
||||
const existing = await this.prisma.organization.findUnique({
|
||||
where: { id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('محیط پیدا نشد');
|
||||
await this.billing.setOrgModules(id, dto.enabledModules ?? []);
|
||||
return this.getOrganization(id);
|
||||
}
|
||||
|
||||
async setUserActive(id: string, isActive: boolean): Promise<PlatformUserDto> {
|
||||
const existing = await this.prisma.user.findUnique({
|
||||
where: { id },
|
||||
@@ -609,6 +645,7 @@ export class PlatformService {
|
||||
businessField: string | null;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
enabledModules?: string[];
|
||||
_count: { memberships: number; people: number; deals: number };
|
||||
}): PlatformOrganizationDto {
|
||||
return {
|
||||
@@ -621,6 +658,7 @@ export class PlatformService {
|
||||
memberCount: row._count.memberships,
|
||||
personCount: row._count.people,
|
||||
dealCount: row._count.deals,
|
||||
enabledModules: this.billing.normalizeModules(row.enabledModules),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -632,6 +670,17 @@ export class PlatformService {
|
||||
isActive: boolean;
|
||||
lastLoginAt: Date | null;
|
||||
createdAt: Date;
|
||||
billingAccount?: {
|
||||
id: string;
|
||||
ownerUserId: string;
|
||||
planCode: string;
|
||||
status: string;
|
||||
startsAt: Date;
|
||||
expiresAt: Date;
|
||||
maxOrganizations: number;
|
||||
externalPaymentRef: string | null;
|
||||
notes: string | null;
|
||||
} | null;
|
||||
memberships: Array<{
|
||||
id: string;
|
||||
role: PlatformUserDto['memberships'][number]['role'];
|
||||
@@ -647,6 +696,9 @@ export class PlatformService {
|
||||
isActive: row.isActive,
|
||||
lastLoginAt: row.lastLoginAt?.toISOString() ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
billing: row.billingAccount
|
||||
? this.billing.toDto(row.billingAccount)
|
||||
: null,
|
||||
memberships: row.memberships.map((m) => ({
|
||||
membershipId: m.id,
|
||||
organizationId: m.organization.id,
|
||||
|
||||
Reference in New Issue
Block a user