🚀 Feature: Implement Global Super Admin access & bypass tenant filtering

This commit is contained in:
Hamza-Ayed
2026-04-22 22:47:53 +03:00
parent 944c82730d
commit 2f238e19c2
9 changed files with 128 additions and 84 deletions

View File

@@ -27,43 +27,43 @@ export class CompaniesController {
constructor(private companiesService: CompaniesService) {}
@Post()
@Roles(UserRole.ADMIN, UserRole.ACCOUNTANT)
@Roles(UserRole.ADMIN, UserRole.ACCOUNTANT, UserRole.SUPER_ADMIN)
async create(@CurrentUser() user: any, @Body() dto: any) {
return this.companiesService.create(user.tenantId, dto);
return this.companiesService.create(user, dto);
}
@Get()
async findAll(@CurrentUser() user: any) {
return this.companiesService.findAll(user.tenantId);
return this.companiesService.findAll(user);
}
@Get(':id')
async findOne(@CurrentUser() user: any, @Param('id') id: string) {
return this.companiesService.findOne(user.tenantId, id);
return this.companiesService.findOne(user, id);
}
@Patch(':id')
@Roles(UserRole.ADMIN, UserRole.ACCOUNTANT)
@Roles(UserRole.ADMIN, UserRole.ACCOUNTANT, UserRole.SUPER_ADMIN)
async update(
@CurrentUser() user: any,
@Param('id') id: string,
@Body() dto: any,
) {
return this.companiesService.update(user.tenantId, id, dto);
return this.companiesService.update(user, id, dto);
}
/**
* ربط بيانات جو فوترة المشفرة
*/
@Put(':id/jofotara')
@Roles(UserRole.ADMIN)
@Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN)
async setJoFotara(
@CurrentUser() user: any,
@Param('id') id: string,
@Body() dto: { clientId: string; secretKey: string },
) {
return this.companiesService.setJoFotaraCredentials(
user.tenantId,
user,
id,
dto.clientId,
dto.secretKey,

View File

@@ -14,6 +14,7 @@ import {
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Company } from './entities/company.entity';
import { UserRole } from '../users/enums/role.enum';
import { EncryptionService } from '../../services/encryption/encryption.service';
import { SubscriptionsService } from '../subscriptions/subscription.service';
@@ -30,7 +31,8 @@ export class CompaniesService {
/**
* إنشاء شركة جديدة مع التحقق من حدود الاشتراك
*/
async create(tenantId: string, dto: any): Promise<Company> {
async create(user: any, dto: any): Promise<Company> {
const tenantId = user.tenantId;
// 1. Check subscription limits
const canCreate = await this.subscriptionsService.checkCompanyLimit(tenantId);
if (!canCreate) {
@@ -48,9 +50,12 @@ export class CompaniesService {
/**
* قائمة الشركات التابعة للمكتب
*/
async findAll(tenantId: string): Promise<Company[]> {
async findAll(user: any): Promise<Company[]> {
const isSuperAdmin = user.role === UserRole.SUPER_ADMIN;
const filter = isSuperAdmin ? { is_active: true } : { tenant_id: user.tenantId, is_active: true };
return this.companyRepository.find({
where: { tenant_id: tenantId, is_active: true },
where: filter,
order: { created_at: 'DESC' },
});
}
@@ -58,9 +63,12 @@ export class CompaniesService {
/**
* تفاصيل شركة محددة
*/
async findOne(tenantId: string, id: string): Promise<Company> {
async findOne(user: any, id: string): Promise<Company> {
const isSuperAdmin = user.role === UserRole.SUPER_ADMIN;
const filter = isSuperAdmin ? { id } : { id, tenant_id: user.tenantId };
const company = await this.companyRepository.findOne({
where: { id, tenant_id: tenantId },
where: filter,
});
if (!company) throw new NotFoundException('Company not found');
return company;
@@ -69,8 +77,8 @@ export class CompaniesService {
/**
* تحديث بيانات شركة
*/
async update(tenantId: string, id: string, dto: any): Promise<Company> {
const company = await this.findOne(tenantId, id);
async update(user: any, id: string, dto: any): Promise<Company> {
const company = await this.findOne(user, id);
Object.assign(company, dto);
return this.companyRepository.save(company);
}
@@ -79,12 +87,12 @@ export class CompaniesService {
* حفظ مفاتيح جو فوترة (مُشفرة)
*/
async setJoFotaraCredentials(
tenantId: string,
user: any,
id: string,
clientId: string,
secretKey: string,
): Promise<void> {
const company = await this.findOne(tenantId, id);
const company = await this.findOne(user, id);
company.jofotara_client_id_encrypted = this.encryptionService.encrypt(clientId);
company.jofotara_secret_key_encrypted = this.encryptionService.encrypt(secretKey);
@@ -95,9 +103,12 @@ export class CompaniesService {
/**
* الحصول على المفاتيح (مفكوك تشفيرها) — للاستخدام الداخلي فقط
*/
async getDecryptedCredentials(tenantId: string, id: string) {
async getDecryptedCredentials(user: any, id: string) {
const isSuperAdmin = user.role === UserRole.SUPER_ADMIN;
const filter = isSuperAdmin ? { id } : { id, tenant_id: user.tenantId };
const company = await this.companyRepository.findOne({
where: { id, tenant_id: tenantId },
where: filter,
select: ['jofotara_client_id_encrypted', 'jofotara_secret_key_encrypted'],
});

View File

@@ -10,16 +10,16 @@ export class DashboardController {
@Get('stats')
async getStats(@CurrentUser() user: any) {
return this.dashboardService.getStats(user.tenantId);
return this.dashboardService.getStats(user);
}
@Get('multi-entity')
async getMultiEntityStats(@CurrentUser() user: any) {
return this.dashboardService.getMultiEntityStats(user.tenantId);
return this.dashboardService.getMultiEntityStats(user);
}
@Get('risk-invoices')
async getRiskInvoices(@CurrentUser() user: any) {
return this.dashboardService.getRiskInvoices(user.tenantId);
return this.dashboardService.getRiskInvoices(user);
}
}

View File

@@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Between } from 'typeorm';
import { Invoice, InvoiceStatus } from '../invoices/entities/invoice.entity';
import { Company } from '../companies/entities/company.entity';
import { UserRole } from '../users/enums/role.enum';
@Injectable()
export class DashboardService {
@@ -13,23 +14,29 @@ export class DashboardService {
private companyRepository: Repository<Company>,
) {}
async getStats(tenantId: string) {
async getStats(user: any) {
const isSuperAdmin = user.role === UserRole.SUPER_ADMIN;
const filter = isSuperAdmin ? {} : { tenant_id: user.tenantId };
const totalInvoices = await this.invoiceRepository.count({
where: { tenant_id: tenantId },
where: filter,
});
const approvedInvoices = await this.invoiceRepository.count({
where: { tenant_id: tenantId, status: InvoiceStatus.APPROVED },
where: { ...filter, status: InvoiceStatus.APPROVED },
});
// Using QueryBuilder for better control
const statuses = await this.invoiceRepository
const query = this.invoiceRepository
.createQueryBuilder('invoice')
.select('status')
.addSelect('COUNT(*)', 'count')
.where('invoice.tenant_id = :tenantId', { tenantId })
.groupBy('status')
.getRawMany();
.addSelect('COUNT(*)', 'count');
if (!isSuperAdmin) {
query.where('invoice.tenant_id = :tenantId', { tenantId: user.tenantId });
}
const statuses = await query.groupBy('status').getRawMany();
const statusMap = statuses.reduce((acc, curr) => {
acc[curr.status] = parseInt(curr.count);
@@ -37,12 +44,12 @@ export class DashboardService {
}, {});
const companiesCount = await this.companyRepository.count({
where: { tenant_id: tenantId },
where: filter,
});
// Calculate total tax (mock logic for now, should sum up tax fields)
const invoices = await this.invoiceRepository.find({
where: { tenant_id: tenantId, status: InvoiceStatus.APPROVED },
where: { ...filter, status: InvoiceStatus.APPROVED },
select: ['tax_amount'],
});
@@ -50,7 +57,7 @@ export class DashboardService {
// Get recent activities (last 5 invoices)
const recentInvoices = await this.invoiceRepository.find({
where: { tenant_id: tenantId },
where: filter,
order: { created_at: 'DESC' },
take: 5,
relations: ['company'],
@@ -81,9 +88,12 @@ export class DashboardService {
* جلب إحصائيات مجمعة لكل الشركات (Elite Accountant View)
* Get summarized stats for all companies under a tenant
*/
async getMultiEntityStats(tenantId: string) {
async getMultiEntityStats(user: any) {
const isSuperAdmin = user.role === UserRole.SUPER_ADMIN;
const filter = isSuperAdmin ? {} : { tenant_id: user.tenantId };
const companies = await this.companyRepository.find({
where: { tenant_id: tenantId },
where: filter,
});
const companyStats = await Promise.all(
@@ -133,11 +143,14 @@ export class DashboardService {
/**
* جلب الفواتير التي بها مخاطر (فشل التحقق أو مرفوضة) عبر كافة الشركات
*/
async getRiskInvoices(tenantId: string) {
async getRiskInvoices(user: any) {
const isSuperAdmin = user.role === UserRole.SUPER_ADMIN;
const filter = isSuperAdmin ? {} : { tenant_id: user.tenantId };
return this.invoiceRepository.find({
where: [
{ tenant_id: tenantId, status: InvoiceStatus.VALIDATION_FAILED },
{ tenant_id: tenantId, status: InvoiceStatus.REJECTED },
{ ...filter, status: InvoiceStatus.VALIDATION_FAILED },
{ ...filter, status: InvoiceStatus.REJECTED },
],
relations: ['company'],
order: { updated_at: 'DESC' },

View File

@@ -40,7 +40,7 @@ export class InvoicesController {
*/
@Get()
async findAllByTenant(@CurrentUser() user: any) {
return this.invoicesService.findAllByTenant(user.tenantId);
return this.invoicesService.findAllByTenant(user);
}
/**
@@ -48,14 +48,14 @@ export class InvoicesController {
* Upload an invoice file for a specific company
*/
@Post('upload/:companyId')
@Roles(UserRole.ADMIN, UserRole.ACCOUNTANT)
@Roles(UserRole.ADMIN, UserRole.ACCOUNTANT, UserRole.SUPER_ADMIN)
@UseInterceptors(FileInterceptor('file'))
async upload(
@CurrentUser() user: any,
@Param('companyId', ParseUUIDPipe) companyId: string,
@UploadedFile() file: Express.Multer.File,
) {
return this.invoicesService.upload(user.tenantId, companyId, file);
return this.invoicesService.upload(user, companyId, file);
}
/**
@@ -67,7 +67,7 @@ export class InvoicesController {
@CurrentUser() user: any,
@Param('companyId', ParseUUIDPipe) companyId: string,
) {
return this.invoicesService.findAll(user.tenantId, companyId);
return this.invoicesService.findAll(user, companyId);
}
/**
@@ -79,7 +79,7 @@ export class InvoicesController {
@CurrentUser() user: any,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.invoicesService.findOne(user.tenantId, id);
return this.invoicesService.findOne(user, id);
}
/**
@@ -90,9 +90,9 @@ export class InvoicesController {
* Submit an invoice to the official JoFotara portal
*/
@Post(':id/submit')
@Roles(UserRole.ADMIN, UserRole.ACCOUNTANT)
@Roles(UserRole.ADMIN, UserRole.ACCOUNTANT, UserRole.SUPER_ADMIN)
async submit(@CurrentUser() user: any, @Param('id', ParseUUIDPipe) id: string) {
return this.invoicesService.submitToJoFotara(user.tenantId, id);
return this.invoicesService.submitToJoFotara(user, id);
}
/**
@@ -100,9 +100,9 @@ export class InvoicesController {
* Permanently delete an invoice
*/
@Post(':id/delete') // Using POST for delete to match frontend request style or use standard DELETE
@Roles(UserRole.ADMIN, UserRole.ACCOUNTANT)
@Roles(UserRole.ADMIN, UserRole.ACCOUNTANT, UserRole.SUPER_ADMIN)
async remove(@CurrentUser() user: any, @Param('id', ParseUUIDPipe) id: string) {
return this.invoicesService.remove(user.tenantId, id);
return this.invoicesService.remove(user, id);
}
/**
@@ -115,11 +115,11 @@ export class InvoicesController {
@Param('id', ParseUUIDPipe) id: string,
@Res({ passthrough: true }) res: Response,
) {
const streamableFile = await this.invoicesService.getFile(user.tenantId, id);
const streamableFile = await this.invoicesService.getFile(user, id);
// We need to determine the content type to ensure it opens inline in the browser (iframe)
// We can fetch the invoice details first to get the extension.
const invoice = await this.invoicesService.findOne(user.tenantId, id);
const invoice = await this.invoicesService.findOne(user, id);
let mimeType = 'application/pdf'; // Default fallback
if (invoice.original_file_path) {
const ext = invoice.original_file_path.split('.').pop()?.toLowerCase();

View File

@@ -25,6 +25,7 @@ import * as path from 'path';
import { InjectQueue } from '@nestjs/bull';
import { Queue } from 'bull';
import { Invoice, InvoiceStatus } from './entities/invoice.entity';
import { UserRole } from '../users/enums/role.enum';
import { LocalStorageService } from '../../services/storage/local-storage.service';
import { SubscriptionsService } from '../subscriptions/subscription.service';
import { UBLGeneratorService } from './ubl-generator.service';
@@ -52,10 +53,11 @@ export class InvoicesService {
* Upload a new invoice and initiate extraction/validation
*/
async upload(
tenantId: string,
user: any,
companyId: string,
file: Express.Multer.File,
): Promise<Invoice> {
const tenantId = user.tenantId;
const canUpload = await this.subscriptionsService.checkInvoiceLimit(tenantId);
if (!canUpload) {
throw new ForbiddenException('Monthly invoice limit reached for your current plan');
@@ -95,9 +97,12 @@ export class InvoicesService {
* قائمة الفواتير لشركة محددة
* Find all invoices for a specific company
*/
async findAll(tenantId: string, companyId: string): Promise<Invoice[]> {
async findAll(user: any, companyId: string): Promise<Invoice[]> {
const isSuperAdmin = user.role === UserRole.SUPER_ADMIN;
const filter = isSuperAdmin ? { company_id: companyId } : { tenant_id: user.tenantId, company_id: companyId };
return this.invoiceRepository.find({
where: { tenant_id: tenantId, company_id: companyId },
where: filter,
order: { created_at: 'DESC' },
});
}
@@ -106,9 +111,12 @@ export class InvoicesService {
* قائمة جميع الفواتير للمكتب (المستأجر)
* Find all invoices for the entire tenant (accounting office)
*/
async findAllByTenant(tenantId: string): Promise<Invoice[]> {
async findAllByTenant(user: any): Promise<Invoice[]> {
const isSuperAdmin = user.role === UserRole.SUPER_ADMIN;
const filter = isSuperAdmin ? {} : { tenant_id: user.tenantId };
return this.invoiceRepository.find({
where: { tenant_id: tenantId },
where: filter,
relations: ['company'],
order: { created_at: 'DESC' },
});
@@ -118,9 +126,12 @@ export class InvoicesService {
* تفاصيل فاتورة محددة مع بنودها
* Get detailed information for a specific invoice including its lines
*/
async findOne(tenantId: string, id: string): Promise<Invoice> {
async findOne(user: any, id: string): Promise<Invoice> {
const isSuperAdmin = user.role === UserRole.SUPER_ADMIN;
const filter = isSuperAdmin ? { id } : { id, tenant_id: user.tenantId };
const invoice = await this.invoiceRepository.findOne({
where: { id, tenant_id: tenantId },
where: filter,
relations: ['lines'],
});
if (!invoice) throw new NotFoundException('Invoice not found');
@@ -131,8 +142,8 @@ export class InvoicesService {
* تحديث بيانات الفاتورة يدوياً
* Manually update invoice data (only if not already submitted)
*/
async update(tenantId: string, id: string, updateData: any): Promise<Invoice> {
const invoice = await this.findOne(tenantId, id);
async update(user: any, id: string, updateData: any): Promise<Invoice> {
const invoice = await this.findOne(user, id);
if (invoice.status === InvoiceStatus.APPROVED || invoice.status === InvoiceStatus.SUBMITTING) {
throw new ForbiddenException('Cannot edit already approved or submitted invoice');
@@ -146,15 +157,15 @@ export class InvoicesService {
* إرسال الفاتورة إلى بوابة جو فوترة الحكومية (ISTD)
* Submit the validated invoice to the official JoFotara portal
*/
async submitToJoFotara(tenantId: string, id: string): Promise<any> {
const invoice = await this.findOne(tenantId, id);
async submitToJoFotara(user: any, id: string): Promise<any> {
const invoice = await this.findOne(user, id);
if (invoice.status !== InvoiceStatus.VALIDATED && invoice.status !== InvoiceStatus.VALIDATION_FAILED && invoice.status !== InvoiceStatus.EXTRACTED) {
throw new ForbiddenException('Invoice must be validated or extracted before submission');
}
const company = await this.companiesService.findOne(tenantId, invoice.company_id);
const credentials = await this.companiesService.getDecryptedCredentials(tenantId, invoice.company_id);
const company = await this.companiesService.findOne(user, invoice.company_id);
const credentials = await this.companiesService.getDecryptedCredentials(user, invoice.company_id);
const xml = this.ublGenerator.generateXML(invoice, company);
@@ -182,8 +193,8 @@ export class InvoicesService {
* حذف الفاتورة والملف التابع لها نهائياً
* Permanently delete an invoice and its associated file from storage
*/
async remove(tenantId: string, id: string): Promise<void> {
const invoice = await this.findOne(tenantId, id);
async remove(user: any, id: string): Promise<void> {
const invoice = await this.findOne(user, id);
// 1. Delete file if exists
if (invoice.original_file_path) {
@@ -198,8 +209,8 @@ export class InvoicesService {
* الحصول على الملف كـ Stream لتحميله أو عرضه
* Get the original invoice file as a streamable file for download/view
*/
async getFile(tenantId: string, id: string): Promise<StreamableFile> {
const invoice = await this.findOne(tenantId, id);
async getFile(user: any, id: string): Promise<StreamableFile> {
const invoice = await this.findOne(user, id);
if (!invoice.original_file_path) {
throw new NotFoundException('Invoice file not found');
}

View File

@@ -9,4 +9,5 @@ export enum UserRole {
ACCOUNTANT = 'accountant', // محاسب (قادر على رفع الفواتير وإدارتها لكل الشركات)
CLIENT = 'client', // عميل (قادر على رفع وإدارة فواتير شركته فقط)
VIEWER = 'viewer', // مشاهد (قادر على الاطلاع فقط)
SUPER_ADMIN = 'super_admin', // مدير النظام العام (قادر على رؤية كل شيء)
}

View File

@@ -26,25 +26,25 @@ export class UsersController {
constructor(private usersService: UsersService) {}
@Post()
@Roles(UserRole.ADMIN)
@Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN)
async create(@CurrentUser() user: any, @Body() dto: any) {
return this.usersService.create(user.tenantId, dto);
return this.usersService.create(user, dto);
}
@Get()
async findAll(@CurrentUser() user: any) {
return this.usersService.findAll(user.tenantId);
return this.usersService.findAll(user);
}
@Get(':id')
async findOne(@CurrentUser() user: any, @Param('id') id: string) {
return this.usersService.findOne(user.tenantId, id);
return this.usersService.findOne(user, id);
}
@Delete(':id')
@Roles(UserRole.ADMIN)
@Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN)
async remove(@CurrentUser() user: any, @Param('id') id: string) {
return this.usersService.remove(user.tenantId, id, user.id);
return this.usersService.remove(user, id, user.id);
}
@Post('profile')

View File

@@ -9,6 +9,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { User } from './entities/user.entity';
import { UserRole } from './enums/role.enum';
@Injectable()
export class UsersService {
@@ -20,7 +21,8 @@ export class UsersService {
/**
* إضافة مستخدم لمكتب محاسبة
*/
async create(tenantId: string, dto: any): Promise<User> {
async create(user: any, dto: any): Promise<User> {
const tenantId = user.tenantId;
const normalizedEmail = dto.email?.trim().toLowerCase();
const existing = await this.userRepository.findOne({
where: { email: normalizedEmail, tenant_id: tenantId },
@@ -32,22 +34,25 @@ export class UsersService {
const passwordHash = await bcrypt.hash(dto.password, 12);
const user = this.userRepository.create({
const newUser = this.userRepository.create({
...dto,
email: normalizedEmail,
password_hash: passwordHash,
tenant_id: tenantId,
} as Partial<User>);
return this.userRepository.save(user);
return this.userRepository.save(newUser);
}
/**
* قائمة مستخدمي المكتب
*/
async findAll(tenantId: string): Promise<User[]> {
async findAll(user: any): Promise<User[]> {
const isSuperAdmin = user.role === UserRole.SUPER_ADMIN;
const filter = isSuperAdmin ? { is_active: true } : { tenant_id: user.tenantId, is_active: true };
return this.userRepository.find({
where: { tenant_id: tenantId, is_active: true },
where: filter,
order: { created_at: 'ASC' },
});
}
@@ -55,22 +60,25 @@ export class UsersService {
/**
* تفاصيل مستخدم
*/
async findOne(tenantId: string, id: string): Promise<User> {
const user = await this.userRepository.findOne({
where: { id, tenant_id: tenantId },
async findOne(user: any, id: string): Promise<User> {
const isSuperAdmin = user.role === UserRole.SUPER_ADMIN;
const filter = isSuperAdmin ? { id } : { id, tenant_id: user.tenantId };
const foundUser = await this.userRepository.findOne({
where: filter,
});
if (!user) throw new NotFoundException('User not found');
return user;
if (!foundUser) throw new NotFoundException('User not found');
return foundUser;
}
/**
* تعطيل مستخدم
*/
async remove(tenantId: string, id: string, currentUserId: string): Promise<void> {
async remove(user: any, id: string, currentUserId: string): Promise<void> {
if (id === currentUserId) {
throw new ConflictException('لا يمكنك تعطيل حسابك الشخصي');
}
const user = await this.findOne(tenantId, id);
await this.findOne(user, id);
await this.userRepository.update(id, { is_active: false });
}