Configuring access rights in the CRM Super Admin

This commit is contained in:
2026-09-20 11:24:14 +03:30
parent 04d37a9d12
commit 4a9fe0b07c
29 changed files with 1235 additions and 35 deletions
@@ -0,0 +1,64 @@
-- AlterTable
ALTER TABLE "Organization" ADD COLUMN IF NOT EXISTS "enabledModules" TEXT[] DEFAULT ARRAY[]::TEXT[];
-- CreateTable
CREATE TABLE IF NOT EXISTS "BillingAccount" (
"id" TEXT NOT NULL,
"ownerUserId" TEXT NOT NULL,
"planCode" TEXT NOT NULL DEFAULT 'trial',
"status" TEXT NOT NULL DEFAULT 'active',
"startsAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
"maxOrganizations" INTEGER NOT NULL DEFAULT 1,
"externalPaymentRef" TEXT,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BillingAccount_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "BillingAccount_ownerUserId_key" ON "BillingAccount"("ownerUserId");
CREATE INDEX IF NOT EXISTS "BillingAccount_status_idx" ON "BillingAccount"("status");
CREATE INDEX IF NOT EXISTS "BillingAccount_expiresAt_idx" ON "BillingAccount"("expiresAt");
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'BillingAccount_ownerUserId_fkey'
) THEN
ALTER TABLE "BillingAccount"
ADD CONSTRAINT "BillingAccount_ownerUserId_fkey"
FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
-- Backfill: full module catalog for existing orgs (empty → all)
UPDATE "Organization"
SET "enabledModules" = ARRAY[
'customers','pipeline','products','invoices','quotes','commissions','sales_forecast',
'activities','tickets','workflow_automations','message_automations','lead_forms',
'project_boards','inventory','cheques','attachments','loyalty','reports','notes',
'tahesab','custom_fields','currencies','team_settings','faq'
]::TEXT[]
WHERE cardinality("enabledModules") = 0;
-- Backfill BillingAccount for every active owner membership (15-day trial)
INSERT INTO "BillingAccount" (
"id", "ownerUserId", "planCode", "status", "startsAt", "expiresAt",
"maxOrganizations", "createdAt", "updatedAt"
)
SELECT
md5(random()::text || clock_timestamp()::text || m."userId"),
m."userId",
'trial',
'active',
NOW(),
NOW() + INTERVAL '15 days',
GREATEST(1, COUNT(*)::int),
NOW(),
NOW()
FROM "Membership" m
WHERE m."role" = 'owner' AND m."isActive" = true
GROUP BY m."userId"
ON CONFLICT ("ownerUserId") DO NOTHING;
+25
View File
@@ -147,6 +147,8 @@ model Organization {
businessField String? businessField String?
baseCurrency String @default("IRR") baseCurrency String @default("IRR")
enabledCurrencies String[] @default(["IRR", "USD", "EUR"]) enabledCurrencies String[] @default(["IRR", "USD", "EUR"])
/// ماژول‌های فعال منو برای این محیط
enabledModules String[] @default([])
isActive Boolean @default(true) isActive Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -230,6 +232,7 @@ model User {
ticketMessages TicketMessage[] @relation("TicketMessageAuthor") ticketMessages TicketMessage[] @relation("TicketMessageAuthor")
confirmedMemories MemoryItem[] @relation("MemoryConfirmedBy") confirmedMemories MemoryItem[] @relation("MemoryConfirmedBy")
memberships Membership[] memberships Membership[]
billingAccount BillingAccount?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -293,6 +296,28 @@ model PlatformAdmin {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
/// اشتراک SaaS روی مالک حساب (نه روی هر عضویت)
model BillingAccount {
id String @id @default(cuid())
ownerUserId String @unique
owner User @relation(fields: [ownerUserId], references: [id], onDelete: Cascade)
/// trial | monthly | quarterly | semiannual | yearly | custom
planCode String @default("trial")
/// active | expired | suspended
status String @default("active")
startsAt DateTime @default(now())
expiresAt DateTime
maxOrganizations Int @default(1)
/// رزرو برای درگاه/لندینگ بعدی
externalPaymentRef String?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status])
@@index([expiresAt])
}
model Company { model Company {
id String @id @default(cuid()) id String @id @default(cuid())
organizationId String organizationId String
+38 -4
View File
@@ -44,9 +44,19 @@ async function seedPlatformAdmin() {
return; return;
} }
const passwordHash = await bcrypt.hash(password, 10);
const existingByMobile = await prisma.platformAdmin.findUnique({ where: { mobile } }); const existingByMobile = await prisma.platformAdmin.findUnique({ where: { mobile } });
if (existingByMobile) { if (existingByMobile) {
console.log('Platform admin exists:', mobile); await prisma.platformAdmin.update({
where: { id: existingByMobile.id },
data: {
passwordHash,
fullName: envName,
isActive: true,
...(email ? { email } : {}),
},
});
console.log('Platform admin password refreshed:', mobile);
return; return;
} }
if (email) { if (email) {
@@ -54,14 +64,38 @@ async function seedPlatformAdmin() {
if (existingByEmail) { if (existingByEmail) {
await prisma.platformAdmin.update({ await prisma.platformAdmin.update({
where: { id: existingByEmail.id }, where: { id: existingByEmail.id },
data: { mobile, ...(envName ? { fullName: envName } : {}) }, data: {
mobile,
passwordHash,
fullName: envName,
isActive: true,
},
}); });
console.log('Platform admin linked mobile:', mobile, '→', email); console.log('Platform admin linked mobile + password refreshed:', mobile, '→', email);
return; return;
} }
} }
const passwordHash = await bcrypt.hash(password, 10); const orphans = await prisma.platformAdmin.findMany({
where: { mobile: null },
take: 2,
});
if (orphans.length === 1) {
const orphan = orphans[0]!;
await prisma.platformAdmin.update({
where: { id: orphan.id },
data: {
mobile,
passwordHash,
fullName: envName,
isActive: true,
...(email ? { email } : {}),
},
});
console.log('Platform admin orphan linked mobile:', mobile);
return;
}
await prisma.platformAdmin.create({ await prisma.platformAdmin.create({
data: { data: {
mobile, mobile,
+2
View File
@@ -33,6 +33,7 @@ import { TahesabModule } from './integrations/tahesab/tahesab.module';
import { InventoryModule } from './inventory/inventory.module'; import { InventoryModule } from './inventory/inventory.module';
import { FinanceModule } from './finance/finance.module'; import { FinanceModule } from './finance/finance.module';
import { PlatformModule } from './platform/platform.module'; import { PlatformModule } from './platform/platform.module';
import { BillingModule } from './billing/billing.module';
@Module({ @Module({
imports: [ imports: [
@@ -45,6 +46,7 @@ import { PlatformModule } from './platform/platform.module';
PrismaModule, PrismaModule,
StorageModule, StorageModule,
HealthModule, HealthModule,
BillingModule,
AuthModule, AuthModule,
PlatformModule, PlatformModule,
UsersModule, UsersModule,
+2
View File
@@ -3,6 +3,7 @@ import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport'; import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { MembershipsModule } from '../memberships/memberships.module'; import { MembershipsModule } from '../memberships/memberships.module';
import { BillingModule } from '../billing/billing.module';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { JwtStrategy } from './jwt.strategy'; import { JwtStrategy } from './jwt.strategy';
@@ -13,6 +14,7 @@ import { SmsService } from '../common/sms.service';
imports: [ imports: [
ConfigModule, ConfigModule,
MembershipsModule, MembershipsModule,
BillingModule,
PassportModule.register({ defaultStrategy: 'jwt' }), PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({ JwtModule.registerAsync({
imports: [ConfigModule], imports: [ConfigModule],
+33 -2
View File
@@ -10,12 +10,19 @@ import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs'; import * as bcrypt from 'bcryptjs';
import * as path from 'path'; 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 { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { SmsService } from '../common/sms.service'; import { SmsService } from '../common/sms.service';
import { LocalFileStorage } from '../storage/local-file.storage'; import { LocalFileStorage } from '../storage/local-file.storage';
import { MembershipsService } from '../memberships/memberships.service'; import { MembershipsService } from '../memberships/memberships.service';
import { BillingService } from '../billing/billing.service';
import { import {
LoginDto, LoginDto,
LoginOtpRequestDto, LoginOtpRequestDto,
@@ -97,6 +104,7 @@ export class AuthService {
private readonly sms: SmsService, private readonly sms: SmsService,
private readonly files: LocalFileStorage, private readonly files: LocalFileStorage,
private readonly memberships: MembershipsService, private readonly memberships: MembershipsService,
private readonly billing: BillingService,
) {} ) {}
/** /**
@@ -260,6 +268,11 @@ export class AuthService {
organizationId: organization.id, organizationId: organization.id,
role: 'owner', 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({ await this.prisma.authChallenge.update({
where: { id: challenge.id }, where: { id: challenge.id },
@@ -578,6 +591,9 @@ export class AuthService {
if (!user || user.isActive === false) { if (!user || user.isActive === false) {
throw new UnauthorizedException('حساب کاربری غیرفعال است'); throw new UnauthorizedException('حساب کاربری غیرفعال است');
} }
const subscription = await this.billing.requireActiveForOrganization(
organizationId,
);
await this.memberships.syncUserLastOrg( await this.memberships.syncUserLastOrg(
userId, userId,
organizationId, organizationId,
@@ -597,6 +613,7 @@ export class AuthService {
user: await this.toAuthUserDto( user: await this.toAuthUserDto(
{ ...user, role: membership.role, organizationId }, { ...user, role: membership.role, organizationId },
organizationId, organizationId,
subscription,
), ),
}; };
} }
@@ -611,19 +628,29 @@ export class AuthService {
organizationId: string; organizationId: string;
}, },
organizationId: string, organizationId: string,
subscription?: SubscriptionSummaryDto | null,
): Promise<AuthUserDto> { ): Promise<AuthUserDto> {
const membership = await this.prisma.membership.findFirst({ const membership = await this.prisma.membership.findFirst({
where: { userId: user.id, organizationId, isActive: true }, 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 role = membership?.role ?? user.role;
const orgName = membership?.organization.name; const orgName = membership?.organization.name;
const sub =
subscription ??
(await this.billing.requireActiveForOrganization(organizationId));
return this.toAuthUser({ return this.toAuthUser({
...user, ...user,
role, role,
organizationId, organizationId,
organizationName: orgName, organizationName: orgName,
avatarStorageKey: await this.readAvatarKey(user.id), avatarStorageKey: await this.readAvatarKey(user.id),
enabledModules: this.billing.normalizeModules(
membership?.organization.enabledModules,
),
subscription: sub,
}); });
} }
@@ -708,6 +735,8 @@ export class AuthService {
organizationId: string; organizationId: string;
organizationName?: string; organizationName?: string;
avatarStorageKey?: string | null; avatarStorageKey?: string | null;
enabledModules?: OrgModuleKey[];
subscription?: SubscriptionSummaryDto | null;
}): AuthUserDto { }): AuthUserDto {
return { return {
id: user.id, id: user.id,
@@ -718,6 +747,8 @@ export class AuthService {
organizationName: user.organizationName, organizationName: user.organizationName,
hasAvatar: Boolean(user.avatarStorageKey), hasAvatar: Boolean(user.avatarStorageKey),
...(user.mobile ? { mobile: user.mobile } : {}), ...(user.mobile ? { mobile: user.mobile } : {}),
...(user.enabledModules ? { enabledModules: user.enabledModules } : {}),
...(user.subscription ? { subscription: user.subscription } : {}),
}; };
} }
} }
+3
View File
@@ -2,6 +2,7 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt'; import { ExtractJwt, Strategy } from 'passport-jwt';
import { BillingService } from '../billing/billing.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
export type JwtPayload = { export type JwtPayload = {
@@ -15,6 +16,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
constructor( constructor(
config: ConfigService, config: ConfigService,
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly billing: BillingService,
) { ) {
super({ super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
@@ -53,6 +55,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
if (!membership) { if (!membership) {
throw new UnauthorizedException(); throw new UnauthorizedException();
} }
await this.billing.requireActiveForOrganization(membership.organizationId);
return { return {
userId: user.id, userId: user.id,
organizationId: membership.organizationId, organizationId: membership.organizationId,
+10
View File
@@ -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 {}
+287
View File
@@ -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);
+32
View File
@@ -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;
}
}
+4 -1
View File
@@ -18,13 +18,16 @@ import type { Response } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator'; import { CurrentUser } from '../auth/current-user.decorator';
import type { Actor } from '../common/record-scope'; 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 { CreatePersonDto, ListQueryDto, UpdatePersonDto } from './dto';
import { PeopleService } from './people.service'; import { PeopleService } from './people.service';
import { ContactsExcelService } from './contacts-excel.service'; import { ContactsExcelService } from './contacts-excel.service';
import { brand } from '@mento/shared'; import { brand } from '@mento/shared';
@Controller('people') @Controller('people')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard, OrgModuleGuard)
@RequireOrgModule('customers')
export class PeopleController { export class PeopleController {
constructor( constructor(
private readonly peopleService: PeopleService, private readonly peopleService: PeopleService,
@@ -12,6 +12,8 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator'; import { CurrentUser } from '../auth/current-user.decorator';
import { Roles } from '../auth/roles.decorator'; import { Roles } from '../auth/roles.decorator';
import { RolesGuard } from '../auth/roles.guard'; import { RolesGuard } from '../auth/roles.guard';
import { OrgModuleGuard } from '../billing/org-module.guard';
import { RequireOrgModule } from '../billing/org-module.decorator';
import { import {
CreateMessageAutomationRuleDto, CreateMessageAutomationRuleDto,
UpdateMessageAutomationRuleDto, UpdateMessageAutomationRuleDto,
@@ -19,7 +21,8 @@ import {
import { MessageAutomationsService } from './message-automations.service'; import { MessageAutomationsService } from './message-automations.service';
@Controller('message-automations') @Controller('message-automations')
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
@RequireOrgModule('message_automations')
@Roles('owner', 'admin') @Roles('owner', 'admin')
export class MessageAutomationsController { export class MessageAutomationsController {
constructor(private readonly messages: MessageAutomationsService) {} constructor(private readonly messages: MessageAutomationsService) {}
@@ -3,11 +3,18 @@ import { AuthModule } from '../auth/auth.module';
import { MembershipsModule } from '../memberships/memberships.module'; import { MembershipsModule } from '../memberships/memberships.module';
import { OrgRolesModule } from '../org-roles/org-roles.module'; import { OrgRolesModule } from '../org-roles/org-roles.module';
import { PipelinesModule } from '../pipelines/pipelines.module'; import { PipelinesModule } from '../pipelines/pipelines.module';
import { BillingModule } from '../billing/billing.module';
import { OrganizationsController } from './organizations.controller'; import { OrganizationsController } from './organizations.controller';
import { OrganizationsService } from './organizations.service'; import { OrganizationsService } from './organizations.service';
@Module({ @Module({
imports: [AuthModule, MembershipsModule, OrgRolesModule, PipelinesModule], imports: [
AuthModule,
MembershipsModule,
OrgRolesModule,
PipelinesModule,
BillingModule,
],
controllers: [OrganizationsController], controllers: [OrganizationsController],
providers: [OrganizationsService], providers: [OrganizationsService],
}) })
@@ -15,6 +15,7 @@ import { isKnownCurrency } from '../common/currency';
import { MembershipsService } from '../memberships/memberships.service'; import { MembershipsService } from '../memberships/memberships.service';
import { OrgRolesService } from '../org-roles/org-roles.service'; import { OrgRolesService } from '../org-roles/org-roles.service';
import { PipelinesService } from '../pipelines/pipelines.service'; import { PipelinesService } from '../pipelines/pipelines.service';
import { BillingService } from '../billing/billing.service';
function slugify(input: string): string { function slugify(input: string): string {
return ( return (
@@ -34,6 +35,7 @@ export class OrganizationsService {
private readonly memberships: MembershipsService, private readonly memberships: MembershipsService,
private readonly orgRoles: OrgRolesService, private readonly orgRoles: OrgRolesService,
private readonly pipelines: PipelinesService, private readonly pipelines: PipelinesService,
private readonly billing: BillingService,
) {} ) {}
async createForUser( async createForUser(
@@ -57,6 +59,8 @@ export class OrganizationsService {
throw new UnauthorizedException(); throw new UnauthorizedException();
} }
await this.billing.assertCanCreateOrganization(userId);
const baseSlug = slugify(name); const baseSlug = slugify(name);
let slug = baseSlug; let slug = baseSlug;
let i = 1; let i = 1;
@@ -69,6 +73,7 @@ export class OrganizationsService {
name, name,
slug, slug,
businessField, businessField,
enabledModules: this.billing.defaultModulesForNewOrg(),
}, },
}); });
await this.memberships.createMembership({ await this.memberships.createMembership({
@@ -76,6 +81,7 @@ export class OrganizationsService {
organizationId: organization.id, organizationId: organization.id,
role: 'owner', role: 'owner',
}); });
await this.billing.ensureTrialForOwner(userId);
await this.orgRoles.ensureDefaultRoles(organization.id); await this.orgRoles.ensureDefaultRoles(organization.id);
await this.pipelines.ensureDefaultPipeline(organization.id); await this.pipelines.ensureDefaultPipeline(organization.id);
return { id: organization.id }; return { id: organization.id };
@@ -219,6 +225,7 @@ export class OrganizationsService {
businessField: string | null; businessField: string | null;
baseCurrency: string; baseCurrency: string;
enabledCurrencies: string[]; enabledCurrencies: string[];
enabledModules?: string[];
exchangeRates: Array<{ exchangeRates: Array<{
currency: string; currency: string;
rateToBase: Prisma.Decimal; rateToBase: Prisma.Decimal;
@@ -235,6 +242,7 @@ export class OrganizationsService {
: null, : null,
baseCurrency: org.baseCurrency, baseCurrency: org.baseCurrency,
enabledCurrencies: org.enabledCurrencies, enabledCurrencies: org.enabledCurrencies,
enabledModules: this.billing.normalizeModules(org.enabledModules),
exchangeRates: org.exchangeRates.map((r) => ({ exchangeRates: org.exchangeRates.map((r) => ({
currency: r.currency, currency: r.currency,
rateToBase: Number(r.rateToBase), rateToBase: Number(r.rateToBase),
+4 -1
View File
@@ -18,13 +18,16 @@ import type { Response } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator'; import { CurrentUser } from '../auth/current-user.decorator';
import type { Actor } from '../common/record-scope'; 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 { CreateDealDto, MoveDealDto, UpdateDealDto } from './dto';
import { DealsService } from './deals.service'; import { DealsService } from './deals.service';
import { DealsExcelService } from './deals-excel.service'; import { DealsExcelService } from './deals-excel.service';
import { brand } from '@mento/shared'; import { brand } from '@mento/shared';
@Controller('deals') @Controller('deals')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard, OrgModuleGuard)
@RequireOrgModule('pipeline')
export class DealsController { export class DealsController {
constructor( constructor(
private readonly dealsService: DealsService, private readonly dealsService: DealsService,
+32
View File
@@ -1,10 +1,13 @@
import { import {
IsArray,
IsBoolean, IsBoolean,
IsEmail, IsEmail,
IsIn, IsIn,
IsInt,
IsOptional, IsOptional,
IsString, IsString,
MaxLength, MaxLength,
Min,
MinLength, MinLength,
ValidateIf, ValidateIf,
} from 'class-validator'; } from 'class-validator';
@@ -119,3 +122,32 @@ export class ResetPlatformUserPasswordDto {
@MaxLength(120) @MaxLength(120)
password!: string; 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'; } from '@nestjs/common';
import { CurrentUser } from '../auth/current-user.decorator'; import { CurrentUser } from '../auth/current-user.decorator';
import { import {
ApplyBillingPlanDto,
CreatePlatformAdminDto, CreatePlatformAdminDto,
CreatePlatformOrganizationDto, CreatePlatformOrganizationDto,
ResetPlatformAdminPasswordDto, ResetPlatformAdminPasswordDto,
ResetPlatformUserPasswordDto, ResetPlatformUserPasswordDto,
UpdateOrgModulesDto,
UpdatePlatformMembershipRoleDto, UpdatePlatformMembershipRoleDto,
UpdatePlatformOrganizationDto, UpdatePlatformOrganizationDto,
UpdatePlatformStatusDto, UpdatePlatformStatusDto,
@@ -105,6 +107,19 @@ export class PlatformController {
return this.platform.resetUserPassword(id, dto); 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') @Patch('memberships/:id')
setMembershipActive( setMembershipActive(
@Param('id') id: string, @Param('id') id: string,
+2
View File
@@ -4,6 +4,7 @@ import { AuthModule } from '../auth/auth.module';
import { MembershipsModule } from '../memberships/memberships.module'; import { MembershipsModule } from '../memberships/memberships.module';
import { OrgRolesModule } from '../org-roles/org-roles.module'; import { OrgRolesModule } from '../org-roles/org-roles.module';
import { PipelinesModule } from '../pipelines/pipelines.module'; import { PipelinesModule } from '../pipelines/pipelines.module';
import { BillingModule } from '../billing/billing.module';
import { PlatformAuthController } from './platform-auth.controller'; import { PlatformAuthController } from './platform-auth.controller';
import { PlatformAuthGuard } from './platform-auth.guard'; import { PlatformAuthGuard } from './platform-auth.guard';
import { PlatformAuthService } from './platform-auth.service'; import { PlatformAuthService } from './platform-auth.service';
@@ -17,6 +18,7 @@ import { PlatformService } from './platform.service';
MembershipsModule, MembershipsModule,
OrgRolesModule, OrgRolesModule,
PipelinesModule, PipelinesModule,
BillingModule,
PassportModule, PassportModule,
], ],
controllers: [PlatformAuthController, PlatformController], controllers: [PlatformAuthController, PlatformController],
+52
View File
@@ -18,12 +18,15 @@ import { normalizeMobile } from '../auth/auth.service';
import { MembershipsService } from '../memberships/memberships.service'; import { MembershipsService } from '../memberships/memberships.service';
import { OrgRolesService } from '../org-roles/org-roles.service'; import { OrgRolesService } from '../org-roles/org-roles.service';
import { PipelinesService } from '../pipelines/pipelines.service'; import { PipelinesService } from '../pipelines/pipelines.service';
import { BillingService } from '../billing/billing.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import type { import type {
ApplyBillingPlanDto,
CreatePlatformAdminDto, CreatePlatformAdminDto,
CreatePlatformOrganizationDto, CreatePlatformOrganizationDto,
ResetPlatformAdminPasswordDto, ResetPlatformAdminPasswordDto,
ResetPlatformUserPasswordDto, ResetPlatformUserPasswordDto,
UpdateOrgModulesDto,
UpdatePlatformOrganizationDto, UpdatePlatformOrganizationDto,
} from './dto'; } from './dto';
@@ -45,6 +48,7 @@ export class PlatformService {
private readonly memberships: MembershipsService, private readonly memberships: MembershipsService,
private readonly orgRoles: OrgRolesService, private readonly orgRoles: OrgRolesService,
private readonly pipelines: PipelinesService, private readonly pipelines: PipelinesService,
private readonly billing: BillingService,
) {} ) {}
async stats(): Promise<PlatformStatsDto> { async stats(): Promise<PlatformStatsDto> {
@@ -61,12 +65,18 @@ export class PlatformService {
this.prisma.user.count({ where: { isActive: true } }), this.prisma.user.count({ where: { isActive: true } }),
this.prisma.membership.count(), this.prisma.membership.count(),
]); ]);
const [expiringSoonCount, expiredBillingCount] = await Promise.all([
this.billing.countExpiringSoon(),
this.billing.countExpired(),
]);
return { return {
organizationCount, organizationCount,
activeOrganizationCount, activeOrganizationCount,
userCount, userCount,
activeUserCount, activeUserCount,
membershipCount, membershipCount,
expiringSoonCount,
expiredBillingCount,
}; };
} }
@@ -89,6 +99,7 @@ export class PlatformService {
businessField: true, businessField: true,
isActive: true, isActive: true,
createdAt: true, createdAt: true,
enabledModules: true,
_count: { _count: {
select: { select: {
memberships: true, memberships: true,
@@ -111,6 +122,7 @@ export class PlatformService {
businessField: true, businessField: true,
isActive: true, isActive: true,
createdAt: true, createdAt: true,
enabledModules: true,
_count: { _count: {
select: { select: {
memberships: true, memberships: true,
@@ -213,6 +225,7 @@ export class PlatformService {
name, name,
slug, slug,
businessField, businessField,
enabledModules: this.billing.defaultModulesForNewOrg(),
users: { users: {
create: { create: {
email: ownerEmail, email: ownerEmail,
@@ -238,6 +251,7 @@ export class PlatformService {
organizationId: organization.id, organizationId: organization.id,
role: 'owner', role: 'owner',
}); });
await this.billing.ensureTrialForOwner(owner.id, { maxOrganizations: 1 });
await this.orgRoles.ensureDefaultRoles(organization.id); await this.orgRoles.ensureDefaultRoles(organization.id);
await this.pipelines.ensureDefaultPipeline(organization.id); await this.pipelines.ensureDefaultPipeline(organization.id);
@@ -331,6 +345,7 @@ export class PlatformService {
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: 300, take: 300,
include: { include: {
billingAccount: true,
memberships: { memberships: {
include: { include: {
organization: { organization: {
@@ -356,12 +371,33 @@ export class PlatformService {
}, },
orderBy: { createdAt: 'asc' }, orderBy: { createdAt: 'asc' },
}, },
billingAccount: true,
}, },
}); });
if (!row) throw new NotFoundException('کاربر پیدا نشد'); if (!row) throw new NotFoundException('کاربر پیدا نشد');
return this.userToDto(row); 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> { async setUserActive(id: string, isActive: boolean): Promise<PlatformUserDto> {
const existing = await this.prisma.user.findUnique({ const existing = await this.prisma.user.findUnique({
where: { id }, where: { id },
@@ -609,6 +645,7 @@ export class PlatformService {
businessField: string | null; businessField: string | null;
isActive: boolean; isActive: boolean;
createdAt: Date; createdAt: Date;
enabledModules?: string[];
_count: { memberships: number; people: number; deals: number }; _count: { memberships: number; people: number; deals: number };
}): PlatformOrganizationDto { }): PlatformOrganizationDto {
return { return {
@@ -621,6 +658,7 @@ export class PlatformService {
memberCount: row._count.memberships, memberCount: row._count.memberships,
personCount: row._count.people, personCount: row._count.people,
dealCount: row._count.deals, dealCount: row._count.deals,
enabledModules: this.billing.normalizeModules(row.enabledModules),
}; };
} }
@@ -632,6 +670,17 @@ export class PlatformService {
isActive: boolean; isActive: boolean;
lastLoginAt: Date | null; lastLoginAt: Date | null;
createdAt: Date; 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<{ memberships: Array<{
id: string; id: string;
role: PlatformUserDto['memberships'][number]['role']; role: PlatformUserDto['memberships'][number]['role'];
@@ -647,6 +696,9 @@ export class PlatformService {
isActive: row.isActive, isActive: row.isActive,
lastLoginAt: row.lastLoginAt?.toISOString() ?? null, lastLoginAt: row.lastLoginAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(), createdAt: row.createdAt.toISOString(),
billing: row.billingAccount
? this.billing.toDto(row.billingAccount)
: null,
memberships: row.memberships.map((m) => ({ memberships: row.memberships.map((m) => ({
membershipId: m.id, membershipId: m.id,
organizationId: m.organization.id, organizationId: m.organization.id,
+4 -1
View File
@@ -68,7 +68,9 @@ function onLogout() {
min-height: 100dvh; min-height: 100dvh;
display: grid; display: grid;
grid-template-columns: 240px minmax(0, 1fr); grid-template-columns: 240px minmax(0, 1fr);
background: var(--bg); background:
radial-gradient(ellipse 60% 40% at 100% 0%, rgba(45, 212, 191, 0.08), transparent 55%),
var(--bg);
} }
.sidebar { .sidebar {
@@ -78,6 +80,7 @@ function onLogout() {
padding: 1.15rem 0.9rem; padding: 1.15rem 0.9rem;
background: #fff; background: #fff;
border-inline-end: 1px solid var(--line); border-inline-end: 1px solid var(--line);
box-shadow: 8px 0 24px rgba(15, 23, 42, 0.03);
} }
.brand { .brand {
+60 -5
View File
@@ -79,17 +79,43 @@ const moreSectionActive = computed(
settingsActive.value, settingsActive.value,
); );
const enabledModules = computed(() => auth.user?.enabledModules ?? null);
const subscription = computed(() => auth.user?.subscription ?? null);
function moduleAllowed(module?: string) {
if (!module) return true;
if (!enabledModules.value) return true;
return enabledModules.value.includes(module as never);
}
const openHub = computed<NavHubDef | null>(() => { const openHub = computed<NavHubDef | null>(() => {
if (!openHubKey.value) return null; if (!openHubKey.value) return null;
const hub = NAV_HUBS.find((item) => item.key === openHubKey.value); const hub = NAV_HUBS.find((item) => item.key === openHubKey.value);
if (!hub) return null; if (!hub) return null;
return { return {
...hub, ...hub,
cards: hub.cards.filter((card) => !card.admin || canSettings.value), cards: hub.cards.filter(
shortcuts: hub.shortcuts.filter((item) => !item.admin || canSettings.value), (card) =>
(!card.admin || canSettings.value) && moduleAllowed(card.module),
),
shortcuts: hub.shortcuts.filter(
(item) =>
(!item.admin || canSettings.value) && moduleAllowed(item.module),
),
}; };
}); });
const hubVisible = computed(() => {
const map: Record<string, boolean> = {};
for (const hub of NAV_HUBS) {
map[hub.key] = hub.cards.some(
(card) =>
(!card.admin || canSettings.value) && moduleAllowed(card.module),
);
}
return map;
});
function givenName(fullName: string): string { function givenName(fullName: string): string {
const parts = fullName.trim().split(/\s+/).filter(Boolean); const parts = fullName.trim().split(/\s+/).filter(Boolean);
if (parts.length <= 1) return fullName.trim(); if (parts.length <= 1) return fullName.trim();
@@ -368,7 +394,7 @@ onUnmounted(() => {
<span>داشبورد</span> <span>داشبورد</span>
</RouterLink> </RouterLink>
<div class="nav-fold" :class="{ active: customersActive || openHubKey === 'customers' }"> <div class="nav-fold" v-if="hubVisible.customers" :class="{ active: customersActive || openHubKey === 'customers' }">
<button <button
class="nav-item nav-fold-btn" class="nav-item nav-fold-btn"
type="button" type="button"
@@ -383,7 +409,7 @@ onUnmounted(() => {
</button> </button>
</div> </div>
<div class="nav-fold" :class="{ active: salesActive || openHubKey === 'sales' }"> <div class="nav-fold" v-if="hubVisible.sales" :class="{ active: salesActive || openHubKey === 'sales' }">
<button <button
class="nav-item nav-fold-btn" class="nav-item nav-fold-btn"
type="button" type="button"
@@ -398,7 +424,7 @@ onUnmounted(() => {
</button> </button>
</div> </div>
<div class="nav-fold" :class="{ active: activitiesActive || openHubKey === 'activities' }"> <div class="nav-fold" v-if="hubVisible.activities" :class="{ active: activitiesActive || openHubKey === 'activities' }">
<button <button
class="nav-item nav-fold-btn" class="nav-item nav-fold-btn"
type="button" type="button"
@@ -600,6 +626,17 @@ onUnmounted(() => {
<RouterLink class="round-btn" :to="{ name: 'tickets' }" title="پیام‌ها"> <RouterLink class="round-btn" :to="{ name: 'tickets' }" title="پیام‌ها">
<UiIcon name="mail" :size="18" /> <UiIcon name="mail" :size="18" />
</RouterLink> </RouterLink>
<div
v-if="subscription"
class="sub-chip"
:class="{ warn: subscription.warn }"
:title="`انقضا: ${subscription.expiresAt}`"
>
<UiIcon name="clock" :size="15" />
<span>
{{ toFaDigits(subscription.daysRemaining) }} روز باقی‌مانده
</span>
</div>
<div class="date-chip"> <div class="date-chip">
<UiIcon name="calendar" :size="16" /> <UiIcon name="calendar" :size="16" />
<span>{{ todayLabel }}</span> <span>{{ todayLabel }}</span>
@@ -1601,6 +1638,24 @@ onUnmounted(() => {
height: 34px; height: 34px;
} }
.sub-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.7rem;
border-radius: 999px;
background: var(--accent-subtle);
color: var(--accent-deep);
font-size: 0.78rem;
font-weight: 800;
white-space: nowrap;
}
.sub-chip.warn {
background: color-mix(in srgb, var(--danger) 12%, white);
color: var(--danger);
}
.date-chip { .date-chip {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
+57 -14
View File
@@ -1,3 +1,5 @@
import type { OrgModuleKey } from '@mento/shared';
export type NavHubKey = export type NavHubKey =
| 'customers' | 'customers'
| 'sales' | 'sales'
@@ -43,6 +45,7 @@ export type NavHubCard = {
icon: NavIconName; icon: NavIconName;
tint: NavHubTint; tint: NavHubTint;
admin?: boolean; admin?: boolean;
module?: OrgModuleKey;
}; };
export type NavHubShortcut = { export type NavHubShortcut = {
@@ -50,6 +53,7 @@ export type NavHubShortcut = {
title: string; title: string;
icon: NavIconName; icon: NavIconName;
admin?: boolean; admin?: boolean;
module?: OrgModuleKey;
}; };
export type NavHubDef = { export type NavHubDef = {
@@ -70,8 +74,8 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'users', icon: 'users',
tint: 'mint', tint: 'mint',
shortcuts: [ shortcuts: [
{ to: '/contacts/people/new', title: 'شخص جدید', icon: 'user-plus' }, { to: '/contacts/people/new', title: 'شخص جدید', icon: 'user-plus', module: 'customers' },
{ to: '/contacts/companies/new', title: 'شرکت جدید', icon: 'building' }, { to: '/contacts/companies/new', title: 'شرکت جدید', icon: 'building', module: 'customers' },
], ],
cards: [ cards: [
{ {
@@ -80,6 +84,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'مخاطبان حقیقی و پرونده‌های مشتری', desc: 'مخاطبان حقیقی و پرونده‌های مشتری',
icon: 'user', icon: 'user',
tint: 'mint', tint: 'mint',
module: 'customers',
}, },
{ {
to: '/contacts?tab=companies', to: '/contacts?tab=companies',
@@ -87,6 +92,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'سازمان‌ها و مشتریان حقوقی', desc: 'سازمان‌ها و مشتریان حقوقی',
icon: 'building', icon: 'building',
tint: 'violet', tint: 'violet',
module: 'customers',
}, },
], ],
}, },
@@ -97,8 +103,8 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'briefcase', icon: 'briefcase',
tint: 'mint', tint: 'mint',
shortcuts: [ shortcuts: [
{ to: '/deals/new', title: 'معامله جدید', icon: 'plus' }, { to: '/deals/new', title: 'معامله جدید', icon: 'plus', module: 'pipeline' },
{ to: '/quotes/new', title: 'پیش‌فاکتور جدید', icon: 'document' }, { to: '/quotes/new', title: 'پیش‌فاکتور جدید', icon: 'document', module: 'quotes' },
], ],
cards: [ cards: [
{ {
@@ -107,6 +113,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'سرنخ‌ها، معاملات و مراحل فروش', desc: 'سرنخ‌ها، معاملات و مراحل فروش',
icon: 'filter', icon: 'filter',
tint: 'mint', tint: 'mint',
module: 'pipeline',
}, },
{ {
to: '/settings/products', to: '/settings/products',
@@ -115,6 +122,7 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'box', icon: 'box',
tint: 'peach', tint: 'peach',
admin: true, admin: true,
module: 'products',
}, },
{ {
to: '/invoices', to: '/invoices',
@@ -122,6 +130,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'صدور فاکتور و پیگیری پرداخت‌ها', desc: 'صدور فاکتور و پیگیری پرداخت‌ها',
icon: 'mail', icon: 'mail',
tint: 'sky', tint: 'sky',
module: 'invoices',
}, },
{ {
to: '/quotes', to: '/quotes',
@@ -129,6 +138,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'پیشنهاد قیمت و پیگیری تایید', desc: 'پیشنهاد قیمت و پیگیری تایید',
icon: 'document', icon: 'document',
tint: 'amber', tint: 'amber',
module: 'quotes',
}, },
{ {
to: '/commissions', to: '/commissions',
@@ -136,6 +146,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'سهم فروشندگان از فاکتورهای صادرشده', desc: 'سهم فروشندگان از فاکتورهای صادرشده',
icon: 'dollar', icon: 'dollar',
tint: 'rose', tint: 'rose',
module: 'commissions',
}, },
{ {
to: '/sales/forecast', to: '/sales/forecast',
@@ -143,6 +154,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'تعیین اهداف و تحلیل عملکرد فروش', desc: 'تعیین اهداف و تحلیل عملکرد فروش',
icon: 'target', icon: 'target',
tint: 'mint', tint: 'mint',
module: 'sales_forecast',
}, },
], ],
}, },
@@ -153,8 +165,8 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'check', icon: 'check',
tint: 'sky', tint: 'sky',
shortcuts: [ shortcuts: [
{ to: '/activities/new', title: 'فعالیت جدید', icon: 'plus' }, { to: '/activities/new', title: 'فعالیت جدید', icon: 'plus', module: 'activities' },
{ to: '/activities/calendar', title: 'تقویم', icon: 'calendar' }, { to: '/activities/calendar', title: 'تقویم', icon: 'calendar', module: 'activities' },
], ],
cards: [ cards: [
{ {
@@ -163,6 +175,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'کارها، تماس‌ها و پیگیری‌های جاری', desc: 'کارها، تماس‌ها و پیگیری‌های جاری',
icon: 'check', icon: 'check',
tint: 'mint', tint: 'mint',
module: 'activities',
}, },
{ {
to: '/activities/calendar', to: '/activities/calendar',
@@ -170,6 +183,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'نمای روز، هفته و زمان‌بندی کارها', desc: 'نمای روز، هفته و زمان‌بندی کارها',
icon: 'calendar', icon: 'calendar',
tint: 'violet', tint: 'violet',
module: 'activities',
}, },
], ],
}, },
@@ -180,8 +194,8 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'chat', icon: 'chat',
tint: 'peach', tint: 'peach',
shortcuts: [ shortcuts: [
{ to: '/tickets/new', title: 'تیکت جدید', icon: 'plus' }, { to: '/tickets/new', title: 'تیکت جدید', icon: 'plus', module: 'tickets' },
{ to: '/tickets', title: 'تیکت‌ها', icon: 'chat' }, { to: '/tickets', title: 'تیکت‌ها', icon: 'chat', module: 'tickets' },
], ],
cards: [ cards: [
{ {
@@ -190,6 +204,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'درخواست‌ها و پیام‌های پشتیبانی', desc: 'درخواست‌ها و پیام‌های پشتیبانی',
icon: 'chat', icon: 'chat',
tint: 'peach', tint: 'peach',
module: 'tickets',
}, },
], ],
}, },
@@ -200,8 +215,18 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'sparkle', icon: 'sparkle',
tint: 'violet', tint: 'violet',
shortcuts: [ shortcuts: [
{ to: '/settings/automations', title: 'قانون گردش کار', icon: 'layers' }, {
{ to: '/settings/message-automations', title: 'قانون پیامک', icon: 'send' }, to: '/settings/automations',
title: 'قانون گردش کار',
icon: 'layers',
module: 'workflow_automations',
},
{
to: '/settings/message-automations',
title: 'قانون پیامک',
icon: 'send',
module: 'message_automations',
},
], ],
cards: [ cards: [
{ {
@@ -211,6 +236,7 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'layers', icon: 'layers',
tint: 'violet', tint: 'violet',
admin: true, admin: true,
module: 'workflow_automations',
}, },
{ {
to: '/settings/message-automations', to: '/settings/message-automations',
@@ -219,6 +245,7 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'send', icon: 'send',
tint: 'sky', tint: 'sky',
admin: true, admin: true,
module: 'message_automations',
}, },
], ],
}, },
@@ -229,8 +256,8 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'apps-plus', icon: 'apps-plus',
tint: 'violet', tint: 'violet',
shortcuts: [ shortcuts: [
{ to: '/project-cards/new', title: 'کارت پروژه جدید', icon: 'plus' }, { to: '/project-cards/new', title: 'کارت پروژه جدید', icon: 'plus', module: 'project_boards' },
{ to: '/cheques', title: 'ثبت چک', icon: 'document' }, { to: '/cheques', title: 'ثبت چک', icon: 'document', module: 'cheques' },
], ],
cards: [ cards: [
{ {
@@ -240,6 +267,7 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'layers', icon: 'layers',
tint: 'mint', tint: 'mint',
admin: true, admin: true,
module: 'lead_forms',
}, },
{ {
to: '/projects', to: '/projects',
@@ -247,6 +275,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'کارت‌ها و مراحل اجرای کار', desc: 'کارت‌ها و مراحل اجرای کار',
icon: 'flag', icon: 'flag',
tint: 'violet', tint: 'violet',
module: 'project_boards',
}, },
{ {
to: '/inventory', to: '/inventory',
@@ -254,6 +283,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'موجودی کالا و گردش داخلی', desc: 'موجودی کالا و گردش داخلی',
icon: 'box', icon: 'box',
tint: 'peach', tint: 'peach',
module: 'inventory',
}, },
{ {
to: '/cheques', to: '/cheques',
@@ -261,6 +291,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'چک‌های دریافتی و پرداختی', desc: 'چک‌های دریافتی و پرداختی',
icon: 'document', icon: 'document',
tint: 'sky', tint: 'sky',
module: 'cheques',
}, },
{ {
to: '/attachments', to: '/attachments',
@@ -268,6 +299,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'فایل‌های متصل به یادداشت‌ها', desc: 'فایل‌های متصل به یادداشت‌ها',
icon: 'document', icon: 'document',
tint: 'amber', tint: 'amber',
module: 'attachments',
}, },
], ],
}, },
@@ -278,8 +310,13 @@ export const NAV_HUBS: NavHubDef[] = [
icon: 'settings', icon: 'settings',
tint: 'sky', tint: 'sky',
shortcuts: [ shortcuts: [
{ to: '/settings/team#settings-team', title: 'تیم و نقش‌ها', icon: 'users' }, {
{ to: '/settings/tahesab', title: 'ته‌حساب', icon: 'chart' }, to: '/settings/team#settings-team',
title: 'تیم و نقش‌ها',
icon: 'users',
module: 'team_settings',
},
{ to: '/settings/tahesab', title: 'ته‌حساب', icon: 'chart', module: 'tahesab' },
], ],
cards: [ cards: [
{ {
@@ -288,6 +325,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'نام سازمان و تنظیمات پایه', desc: 'نام سازمان و تنظیمات پایه',
icon: 'building', icon: 'building',
tint: 'mint', tint: 'mint',
module: 'team_settings',
}, },
{ {
to: '/settings/team#settings-team', to: '/settings/team#settings-team',
@@ -295,6 +333,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'اعضا، دسترسی و نقش‌ها', desc: 'اعضا، دسترسی و نقش‌ها',
icon: 'users', icon: 'users',
tint: 'violet', tint: 'violet',
module: 'team_settings',
}, },
{ {
to: '/settings/custom-fields', to: '/settings/custom-fields',
@@ -302,6 +341,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'فیلدهای اضافه روی پرونده‌ها', desc: 'فیلدهای اضافه روی پرونده‌ها',
icon: 'pencil', icon: 'pencil',
tint: 'peach', tint: 'peach',
module: 'custom_fields',
}, },
{ {
to: '/settings/currencies', to: '/settings/currencies',
@@ -309,6 +349,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'ارز پایه و نرخ تبدیل', desc: 'ارز پایه و نرخ تبدیل',
icon: 'dollar', icon: 'dollar',
tint: 'sky', tint: 'sky',
module: 'currencies',
}, },
{ {
to: '/settings/tahesab', to: '/settings/tahesab',
@@ -316,6 +357,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'اتصال مالی و تنظیمات حسابداری', desc: 'اتصال مالی و تنظیمات حسابداری',
icon: 'chart', icon: 'chart',
tint: 'amber', tint: 'amber',
module: 'tahesab',
}, },
{ {
to: '/settings/faq', to: '/settings/faq',
@@ -323,6 +365,7 @@ export const NAV_HUBS: NavHubDef[] = [
desc: 'پرسش‌های پرتکرار سازمان', desc: 'پرسش‌های پرتکرار سازمان',
icon: 'lightbulb', icon: 'lightbulb',
tint: 'rose', tint: 'rose',
module: 'faq',
}, },
], ],
}, },
+23 -1
View File
@@ -65,6 +65,18 @@ onMounted(() => {
<strong>{{ toFaDigits(stats.membershipCount) }}</strong> <strong>{{ toFaDigits(stats.membershipCount) }}</strong>
<small>نقش در محیط‌ها</small> <small>نقش در محیط‌ها</small>
</article> </article>
<article class="kpi warn">
<span class="ico"><UiIcon name="clock" :size="20" /></span>
<p>در آستانه انقضا</p>
<strong>{{ toFaDigits(stats.expiringSoonCount ?? 0) }}</strong>
<small>کمتر از ۳ روز</small>
</article>
<article class="kpi danger">
<span class="ico"><UiIcon name="warning" :size="20" /></span>
<p>منقضی</p>
<strong>{{ toFaDigits(stats.expiredBillingCount ?? 0) }}</strong>
<small>نیاز به تمدید</small>
</article>
</div> </div>
<div class="shortcuts"> <div class="shortcuts">
@@ -125,10 +137,20 @@ onMounted(() => {
.kpis { .kpis {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.85rem; gap: 0.85rem;
} }
.kpi.warn .ico {
background: color-mix(in srgb, #f59e0b 18%, white);
color: #b45309;
}
.kpi.danger .ico {
background: color-mix(in srgb, var(--danger) 14%, white);
color: var(--danger);
}
.kpi { .kpi {
background: #fff; background: #fff;
border: 1px solid #eef2f7; border: 1px solid #eef2f7;
+120 -1
View File
@@ -3,11 +3,18 @@ import { onMounted, reactive, ref } from 'vue';
import axios from 'axios'; import axios from 'axios';
import type { import type {
CreatePlatformOrganizationRequest, CreatePlatformOrganizationRequest,
OrgModuleKey,
PlatformOrganizationDto, PlatformOrganizationDto,
UpdatePlatformOrganizationRequest, UpdatePlatformOrganizationRequest,
UserRole, UserRole,
} from '@mento/shared'; } from '@mento/shared';
import { USER_ROLE_LABELS, USER_ROLES, isBusinessField } from '@mento/shared'; import {
ORG_MODULE_GROUPS,
ORG_MODULE_LABELS,
USER_ROLE_LABELS,
USER_ROLES,
isBusinessField,
} from '@mento/shared';
import { platformApi } from '@/lib/platform-api'; import { platformApi } from '@/lib/platform-api';
import { BUSINESS_FIELDS, BUSINESS_FIELD_LABELS } from '@/lib/business-fields'; import { BUSINESS_FIELDS, BUSINESS_FIELD_LABELS } from '@/lib/business-fields';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali'; import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
@@ -40,6 +47,9 @@ const editForm = reactive({
businessField: '' as string, businessField: '' as string,
}); });
const moduleDraft = ref<OrgModuleKey[]>([]);
const savingModules = ref(false);
function fieldLabel(value?: string | null) { function fieldLabel(value?: string | null) {
if (!value) return '—'; if (!value) return '—';
return isBusinessField(value) ? BUSINESS_FIELD_LABELS[value] : value; return isBusinessField(value) ? BUSINESS_FIELD_LABELS[value] : value;
@@ -92,6 +102,7 @@ async function toggleOpen(org: PlatformOrganizationDto) {
`/platform/organizations/${org.id}`, `/platform/organizations/${org.id}`,
); );
detail.value = data; detail.value = data;
moduleDraft.value = [...(data.enabledModules ?? [])];
} catch (e) { } catch (e) {
error.value = apiError(e, 'بارگذاری اعضا ناموفق بود'); error.value = apiError(e, 'بارگذاری اعضا ناموفق بود');
} }
@@ -222,6 +233,7 @@ async function setMembershipRole(membershipId: string, role: UserRole) {
`/platform/organizations/${openId.value}`, `/platform/organizations/${openId.value}`,
); );
detail.value = data; detail.value = data;
moduleDraft.value = [...(data.enabledModules ?? [])];
} }
} catch (e) { } catch (e) {
error.value = apiError(e, 'تغییر نقش ناموفق بود'); error.value = apiError(e, 'تغییر نقش ناموفق بود');
@@ -230,6 +242,41 @@ async function setMembershipRole(membershipId: string, role: UserRole) {
} }
} }
function toggleModule(key: OrgModuleKey) {
if (moduleDraft.value.includes(key)) {
moduleDraft.value = moduleDraft.value.filter((k) => k !== key);
} else {
moduleDraft.value = [...moduleDraft.value, key];
}
}
function selectGroup(keys: OrgModuleKey[], on: boolean) {
if (on) {
moduleDraft.value = [...new Set([...moduleDraft.value, ...keys])];
} else {
moduleDraft.value = moduleDraft.value.filter((k) => !keys.includes(k));
}
}
async function saveModules(orgId: string) {
savingModules.value = true;
error.value = '';
success.value = '';
try {
const { data } = await platformApi.patch<PlatformOrganizationDto>(
`/platform/organizations/${orgId}/modules`,
{ enabledModules: moduleDraft.value },
);
detail.value = data;
moduleDraft.value = [...(data.enabledModules ?? [])];
success.value = 'ماژول‌های محیط ذخیره شد';
} catch (e) {
error.value = apiError(e, 'ذخیره ماژول‌ها ناموفق بود');
} finally {
savingModules.value = false;
}
}
onMounted(() => { onMounted(() => {
void load(); void load();
}); });
@@ -355,6 +402,47 @@ onMounted(() => {
</form> </form>
<div v-if="openId === org.id" class="detail"> <div v-if="openId === org.id" class="detail">
<div class="modules">
<div class="modules-head">
<strong>ماژول‌های فعال</strong>
<button
class="btn"
type="button"
:disabled="savingModules"
@click="saveModules(org.id)"
>
{{ savingModules ? 'ذخیره…' : 'ذخیره ماژول‌ها' }}
</button>
</div>
<div v-for="group in ORG_MODULE_GROUPS" :key="group.title" class="mod-group">
<div class="mod-group-head">
<span>{{ group.title }}</span>
<button
class="btn btn-ghost"
type="button"
@click="selectGroup(group.keys, true)"
>
همه
</button>
<button
class="btn btn-ghost"
type="button"
@click="selectGroup(group.keys, false)"
>
هیچ‌کدام
</button>
</div>
<label v-for="key in group.keys" :key="key" class="mod-item">
<input
type="checkbox"
:checked="moduleDraft.includes(key)"
@change="toggleModule(key)"
/>
{{ ORG_MODULE_LABELS[key] }}
</label>
</div>
</div>
<p v-if="!detail?.members?.length" class="muted">عضوی ثبت نشده.</p> <p v-if="!detail?.members?.length" class="muted">عضوی ثبت نشده.</p>
<ul v-else> <ul v-else>
<li v-for="member in detail.members" :key="member.membershipId"> <li v-for="member in detail.members" :key="member.membershipId">
@@ -572,6 +660,37 @@ label span {
grid-column: 1 / -1; grid-column: 1 / -1;
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
padding-top: 0.75rem; padding-top: 0.75rem;
display: grid;
gap: 1rem;
}
.modules {
display: grid;
gap: 0.75rem;
padding: 0.85rem;
border-radius: 12px;
background: #f8fafc;
border: 1px solid var(--line);
}
.modules-head,
.mod-group-head {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.mod-group {
display: grid;
gap: 0.35rem;
}
.mod-item {
display: flex;
align-items: center;
gap: 0.45rem;
font-size: 0.88rem;
} }
.detail ul { .detail ul {
+70 -2
View File
@@ -1,8 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import axios from 'axios'; import axios from 'axios';
import type { PlatformUserDto, UserRole } from '@mento/shared'; import type {
import { USER_ROLE_LABELS, USER_ROLES } from '@mento/shared'; ApplyBillingPlanRequest,
BillingPlanCode,
PlatformUserDto,
UserRole,
} from '@mento/shared';
import {
BILLING_PLAN_CODES,
BILLING_PLAN_LABELS,
USER_ROLE_LABELS,
USER_ROLES,
} from '@mento/shared';
import { platformApi } from '@/lib/platform-api'; import { platformApi } from '@/lib/platform-api';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali'; import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
@@ -121,6 +131,25 @@ async function setMembershipRole(userId: string, membershipId: string, role: Use
} }
} }
async function applyPlan(userId: string, planCode: BillingPlanCode) {
busyId.value = `plan-${userId}-${planCode}`;
error.value = '';
success.value = '';
try {
const body: ApplyBillingPlanRequest = { planCode };
const { data } = await platformApi.post<PlatformUserDto>(
`/platform/users/${userId}/billing/apply-plan`,
body,
);
items.value = items.value.map((row) => (row.id === userId ? data : row));
success.value = `پلن «${BILLING_PLAN_LABELS[planCode]}» اعمال شد`;
} catch (e) {
error.value = apiError(e, 'اعمال پلن ناموفق بود');
} finally {
busyId.value = '';
}
}
onMounted(() => { onMounted(() => {
void load(); void load();
}); });
@@ -201,6 +230,29 @@ onMounted(() => {
</form> </form>
<div v-if="openId === user.id" class="detail"> <div v-if="openId === user.id" class="detail">
<div class="billing">
<strong>اشتراک</strong>
<p v-if="user.billing" class="muted">
{{ BILLING_PLAN_LABELS[user.billing.planCode] }}
· {{ user.billing.status }}
· {{ toFaDigits(user.billing.daysRemaining) }} روز
· سقف محیط {{ toFaDigits(user.billing.maxOrganizations) }}
</p>
<p v-else class="muted">هنوز اشتراک ثبت نشده — با اعمال پلن ساخته می‌شود.</p>
<div class="plan-row">
<button
v-for="code in BILLING_PLAN_CODES.filter((c) => c !== 'custom')"
:key="code"
class="btn btn-ghost"
type="button"
:disabled="busyId === `plan-${user.id}-${code}`"
@click="applyPlan(user.id, code)"
>
{{ BILLING_PLAN_LABELS[code] }}
</button>
</div>
</div>
<p v-if="!user.memberships.length" class="muted">عضو هیچ محیطی نیست.</p> <p v-if="!user.memberships.length" class="muted">عضو هیچ محیطی نیست.</p>
<ul v-else> <ul v-else>
<li v-for="mem in user.memberships" :key="mem.membershipId"> <li v-for="mem in user.memberships" :key="mem.membershipId">
@@ -412,6 +464,22 @@ onMounted(() => {
font-size: 0.8rem; font-size: 0.8rem;
} }
.billing {
margin-bottom: 0.85rem;
padding: 0.75rem;
border-radius: 12px;
background: #f8fafc;
border: 1px solid var(--line);
display: grid;
gap: 0.45rem;
}
.plan-row {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.member-actions { .member-actions {
display: flex; display: flex;
gap: 0.45rem; gap: 0.45rem;
+5 -1
View File
@@ -210,7 +210,11 @@ cd /var/www/crm/apps/api
npx prisma db seed npx prisma db seed
``` ```
اگر حساب با همان موبایل از قبل باشد، seed آن را عوض نمی‌کند. ادمین‌های بعدی را از داخل پنل (`/adm/admins`) اضافه کنید. اگر حساب با همان موبایل از قبل باشد، seed رمز را هم از `.env` به‌روز می‌کند. ادمین‌های بعدی را از داخل پنل (`/adm/admins`) اضافه کنید.
### اشتراک و ماژول‌ها
بعد از این نسخه، `prisma migrate deploy` جدول `BillingAccount` و ستون `enabledModules` را می‌سازد و برای مالکان فعلی trial **۱۵ روزه** می‌سازد. از `/adm/users` پلن را تمدید کنید و از `/adm/organizations` ماژول هر محیط را تنظیم کنید.
3. ورود: `https://crm.montzar.ir/adm` → داشبورد `/adm/dashboard` 3. ورود: `https://crm.montzar.ir/adm` → داشبورد `/adm/dashboard`
توکن سوپرادمین جدا از JWT کاربران سازمانی است. توکن سوپرادمین جدا از JWT کاربران سازمانی است.
@@ -0,0 +1,45 @@
# BillingAccount + Modules Implementation Plan
> **For agentic workers:** Execute task-by-task. Checkboxes track progress.
**Goal:** Owner-level `BillingAccount` (plans/expiry/max orgs), per-org `enabledModules`, CRM enforcement + remaining-days UI, `/adm` controls and polish.
**Architecture:** Shared module catalog; Prisma `BillingAccount` + `Organization.enabledModules`; billing checks on login/org-create; module guards on API + filtered nav; platform admin APIs/UI for plans and modules.
**Tech Stack:** NestJS, Prisma, Vue 3, `@mento/shared`, existing CRM CSS tokens.
## Global Constraints
- Manual activation only (no payment gateway).
- Existing owners: trial 15 days; `maxOrganizations >=` current owned org count.
- Expiry: block CRM login; header days remaining; warn ≤3 days.
- Vue + CRM CSS only for `/adm`.
---
## Task 1: Shared catalog + DTOs
- [ ] Add `ORG_MODULE_KEYS`, labels, plan presets, billing DTOs to `packages/shared/src/index.ts`
- [ ] Extend org/login DTOs with `enabledModules` + `subscription` summary
## Task 2: Prisma
- [ ] `BillingAccount` model; `Organization.enabledModules String[]`
- [ ] Migration SQL + data backfill (trial 15d, full modules)
## Task 3: Billing service + auth/org enforcement
- [ ] `BillingService` resolve/require active for org; apply plan; ensure account
- [ ] Hook login / switch org / create org
- [ ] Module require helper for key controllers (or lightweight guard)
## Task 4: Platform API + /adm UI
- [ ] Endpoints: get/apply billing, patch org modules
- [ ] Adm users: billing card; adm orgs: module checklist; light shell polish
## Task 5: Web CRM
- [ ] Filter nav hubs by modules
- [ ] Header subscription chip + warn
- [ ] Router guard for disabled modules
+219
View File
@@ -37,6 +37,220 @@ export interface AuthUserDto {
organizationId: string; organizationId: string;
organizationName?: string; organizationName?: string;
hasAvatar?: boolean; hasAvatar?: boolean;
enabledModules?: OrgModuleKey[];
subscription?: SubscriptionSummaryDto | null;
}
export type BillingPlanCode =
| "trial"
| "monthly"
| "quarterly"
| "semiannual"
| "yearly"
| "custom";
export type BillingAccountStatus = "active" | "expired" | "suspended";
export const BILLING_PLAN_CODES: BillingPlanCode[] = [
"trial",
"monthly",
"quarterly",
"semiannual",
"yearly",
"custom",
];
export const BILLING_PLAN_LABELS: Record<BillingPlanCode, string> = {
trial: "دمو ۱۵ روزه",
monthly: "ماهانه",
quarterly: "سه‌ماهه",
semiannual: "شش‌ماهه",
yearly: "یکساله",
custom: "سفارشی",
};
/** مدت پیش‌فرض پلن‌ها (روز) — custom ندارد */
export const BILLING_PLAN_DAYS: Record<Exclude<BillingPlanCode, "custom">, number> = {
trial: 15,
monthly: 30,
quarterly: 90,
semiannual: 180,
yearly: 365,
};
export const BILLING_PLAN_DEFAULT_MAX_ORGS: Record<
Exclude<BillingPlanCode, "custom">,
number
> = {
trial: 1,
monthly: 3,
quarterly: 5,
semiannual: 10,
yearly: 20,
};
export type OrgModuleKey =
| "customers"
| "pipeline"
| "products"
| "invoices"
| "quotes"
| "commissions"
| "sales_forecast"
| "activities"
| "tickets"
| "workflow_automations"
| "message_automations"
| "lead_forms"
| "project_boards"
| "inventory"
| "cheques"
| "attachments"
| "loyalty"
| "reports"
| "notes"
| "tahesab"
| "custom_fields"
| "currencies"
| "team_settings"
| "faq";
export const ORG_MODULE_KEYS: OrgModuleKey[] = [
"customers",
"pipeline",
"products",
"invoices",
"quotes",
"commissions",
"sales_forecast",
"activities",
"tickets",
"workflow_automations",
"message_automations",
"lead_forms",
"project_boards",
"inventory",
"cheques",
"attachments",
"loyalty",
"reports",
"notes",
"tahesab",
"custom_fields",
"currencies",
"team_settings",
"faq",
];
export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = {
customers: "مشتریان",
pipeline: "کاریز فروش",
products: "محصولات",
invoices: "فاکتورها",
quotes: "پیش‌فاکتورها",
commissions: "پورسانت",
sales_forecast: "هدف و پیش‌بینی",
activities: "فعالیت‌ها",
tickets: "تیکت‌ها",
workflow_automations: "اتوماسیون گردش کار",
message_automations: "اتوماسیون پیامک",
lead_forms: "فرم‌ساز سرنخ",
project_boards: "کاریز پروژه",
inventory: "انبار",
cheques: "چک‌ها",
attachments: "پیوست‌ها",
loyalty: "باشگاه مشتریان",
reports: "گزارش‌ها",
notes: "یادداشت‌ها",
tahesab: "ته‌حساب",
custom_fields: "فیلدهای سفارشی",
currencies: "ارزها",
team_settings: "تیم و تنظیمات سازمان",
faq: "FAQ",
};
export const ORG_MODULE_GROUPS: Array<{
title: string;
keys: OrgModuleKey[];
}> = [
{ title: "مشتریان", keys: ["customers"] },
{
title: "فروش",
keys: [
"pipeline",
"products",
"invoices",
"quotes",
"commissions",
"sales_forecast",
],
},
{ title: "فعالیت‌ها", keys: ["activities", "notes"] },
{ title: "پشتیبانی", keys: ["tickets", "faq"] },
{
title: "اتوماسیون",
keys: ["workflow_automations", "message_automations"],
},
{
title: "ابزارها",
keys: [
"lead_forms",
"project_boards",
"inventory",
"cheques",
"attachments",
"loyalty",
"reports",
],
},
{
title: "تنظیمات",
keys: ["team_settings", "custom_fields", "currencies", "tahesab"],
},
];
export function isOrgModuleKey(value: string): value is OrgModuleKey {
return (ORG_MODULE_KEYS as readonly string[]).includes(value);
}
export function defaultEnabledModules(): OrgModuleKey[] {
return [...ORG_MODULE_KEYS];
}
export interface SubscriptionSummaryDto {
planCode: BillingPlanCode;
status: BillingAccountStatus;
startsAt: string;
expiresAt: string;
maxOrganizations: number;
daysRemaining: number;
warn: boolean;
}
export interface BillingAccountDto {
id: string;
ownerUserId: string;
planCode: BillingPlanCode;
status: BillingAccountStatus;
startsAt: string;
expiresAt: string;
maxOrganizations: number;
externalPaymentRef?: string | null;
notes?: string | null;
daysRemaining: number;
warn: boolean;
}
export interface ApplyBillingPlanRequest {
planCode: BillingPlanCode;
maxOrganizations?: number;
expiresAt?: string;
notes?: string | null;
status?: BillingAccountStatus;
}
export interface UpdateOrgModulesRequest {
enabledModules: OrgModuleKey[];
} }
export interface OrgUserDto { export interface OrgUserDto {
@@ -245,6 +459,7 @@ export interface OrganizationDto {
baseCurrency: string; baseCurrency: string;
enabledCurrencies: string[]; enabledCurrencies: string[];
exchangeRates: ExchangeRateDto[]; exchangeRates: ExchangeRateDto[];
enabledModules: OrgModuleKey[];
} }
export interface UpdateOrganizationRequest { export interface UpdateOrganizationRequest {
@@ -2552,6 +2767,8 @@ export interface PlatformStatsDto {
userCount: number; userCount: number;
activeUserCount: number; activeUserCount: number;
membershipCount: number; membershipCount: number;
expiringSoonCount?: number;
expiredBillingCount?: number;
} }
export interface PlatformOrgMemberDto { export interface PlatformOrgMemberDto {
@@ -2575,6 +2792,7 @@ export interface PlatformOrganizationDto {
memberCount: number; memberCount: number;
personCount: number; personCount: number;
dealCount: number; dealCount: number;
enabledModules?: OrgModuleKey[];
members?: PlatformOrgMemberDto[]; members?: PlatformOrgMemberDto[];
} }
@@ -2596,6 +2814,7 @@ export interface PlatformUserDto {
lastLoginAt?: string | null; lastLoginAt?: string | null;
createdAt: string; createdAt: string;
memberships: PlatformUserMembershipDto[]; memberships: PlatformUserMembershipDto[];
billing?: BillingAccountDto | null;
} }
export interface UpdatePlatformStatusRequest { export interface UpdatePlatformStatusRequest {