feat: Add complete module structure for ERP backend
- Add modules: ai, audit, billing-usage, biometrics, branches, dashboard, feature-flags, invoices, mcp, mobile, notifications, partners, payment-terminals, products, profiles, purchases, reports, sales, storage, warehouses, webhooks, whatsapp - Add controllers, DTOs, entities, and services for each module - Add shared services and utilities Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
12fb6eeee8
commit
ca07b4268d
503
src/app.integration.ts
Normal file
503
src/app.integration.ts
Normal file
@ -0,0 +1,503 @@
|
|||||||
|
/**
|
||||||
|
* Application Integration
|
||||||
|
*
|
||||||
|
* Integrates all modules and configures the application
|
||||||
|
*/
|
||||||
|
|
||||||
|
import express, { Express, Router } from 'express';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
// Import modules
|
||||||
|
import { ProfilesModule } from './modules/profiles';
|
||||||
|
import { BranchesModule } from './modules/branches';
|
||||||
|
import { BillingUsageModule } from './modules/billing-usage';
|
||||||
|
import { PaymentTerminalsModule } from './modules/payment-terminals';
|
||||||
|
|
||||||
|
// Import new business modules
|
||||||
|
import { PartnersModule } from './modules/partners';
|
||||||
|
import { ProductsModule } from './modules/products';
|
||||||
|
import { WarehousesModule } from './modules/warehouses';
|
||||||
|
import { InventoryModule } from './modules/inventory';
|
||||||
|
import { SalesModule } from './modules/sales';
|
||||||
|
import { PurchasesModule } from './modules/purchases';
|
||||||
|
import { InvoicesModule } from './modules/invoices';
|
||||||
|
import { ReportsModule } from './modules/reports';
|
||||||
|
import { DashboardModule } from './modules/dashboard';
|
||||||
|
|
||||||
|
// Import entities from all modules for TypeORM
|
||||||
|
import {
|
||||||
|
Person,
|
||||||
|
UserProfile,
|
||||||
|
ProfileTool,
|
||||||
|
ProfileModule,
|
||||||
|
UserProfileAssignment,
|
||||||
|
} from './modules/profiles/entities';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Device,
|
||||||
|
BiometricCredential,
|
||||||
|
DeviceSession,
|
||||||
|
DeviceActivityLog,
|
||||||
|
} from './modules/biometrics/entities';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Branch,
|
||||||
|
UserBranchAssignment,
|
||||||
|
BranchSchedule,
|
||||||
|
BranchPaymentTerminal,
|
||||||
|
} from './modules/branches/entities';
|
||||||
|
|
||||||
|
import {
|
||||||
|
MobileSession,
|
||||||
|
OfflineSyncQueue,
|
||||||
|
PushToken,
|
||||||
|
PaymentTransaction,
|
||||||
|
} from './modules/mobile/entities';
|
||||||
|
|
||||||
|
import {
|
||||||
|
SubscriptionPlan,
|
||||||
|
TenantSubscription,
|
||||||
|
UsageTracking,
|
||||||
|
Invoice as BillingInvoice,
|
||||||
|
InvoiceItem as BillingInvoiceItem,
|
||||||
|
} from './modules/billing-usage/entities';
|
||||||
|
|
||||||
|
// Import entities from new business modules
|
||||||
|
import {
|
||||||
|
Partner,
|
||||||
|
PartnerAddress,
|
||||||
|
PartnerContact,
|
||||||
|
PartnerBankAccount,
|
||||||
|
} from './modules/partners/entities';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ProductCategory,
|
||||||
|
Product,
|
||||||
|
ProductPrice,
|
||||||
|
ProductSupplier,
|
||||||
|
} from './modules/products/entities';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Warehouse,
|
||||||
|
WarehouseLocation,
|
||||||
|
WarehouseZone,
|
||||||
|
} from './modules/warehouses/entities';
|
||||||
|
|
||||||
|
import {
|
||||||
|
StockLevel,
|
||||||
|
StockMovement,
|
||||||
|
InventoryCount,
|
||||||
|
InventoryCountLine,
|
||||||
|
TransferOrder,
|
||||||
|
TransferOrderLine,
|
||||||
|
} from './modules/inventory/entities';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Quotation,
|
||||||
|
QuotationItem,
|
||||||
|
SalesOrder,
|
||||||
|
SalesOrderItem,
|
||||||
|
} from './modules/sales/entities';
|
||||||
|
|
||||||
|
import {
|
||||||
|
PurchaseOrder,
|
||||||
|
PurchaseOrderItem,
|
||||||
|
PurchaseReceipt,
|
||||||
|
PurchaseReceiptItem,
|
||||||
|
} from './modules/purchases/entities';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Invoice,
|
||||||
|
InvoiceItem,
|
||||||
|
Payment,
|
||||||
|
PaymentAllocation,
|
||||||
|
} from './modules/invoices/entities';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all entities for TypeORM configuration
|
||||||
|
*/
|
||||||
|
export function getAllEntities() {
|
||||||
|
return [
|
||||||
|
// Profiles
|
||||||
|
Person,
|
||||||
|
UserProfile,
|
||||||
|
ProfileTool,
|
||||||
|
ProfileModule,
|
||||||
|
UserProfileAssignment,
|
||||||
|
// Biometrics
|
||||||
|
Device,
|
||||||
|
BiometricCredential,
|
||||||
|
DeviceSession,
|
||||||
|
DeviceActivityLog,
|
||||||
|
// Branches
|
||||||
|
Branch,
|
||||||
|
UserBranchAssignment,
|
||||||
|
BranchSchedule,
|
||||||
|
BranchPaymentTerminal,
|
||||||
|
// Mobile
|
||||||
|
MobileSession,
|
||||||
|
OfflineSyncQueue,
|
||||||
|
PushToken,
|
||||||
|
PaymentTransaction,
|
||||||
|
// Billing
|
||||||
|
SubscriptionPlan,
|
||||||
|
TenantSubscription,
|
||||||
|
UsageTracking,
|
||||||
|
BillingInvoice,
|
||||||
|
BillingInvoiceItem,
|
||||||
|
// Partners
|
||||||
|
Partner,
|
||||||
|
PartnerAddress,
|
||||||
|
PartnerContact,
|
||||||
|
PartnerBankAccount,
|
||||||
|
// Products
|
||||||
|
ProductCategory,
|
||||||
|
Product,
|
||||||
|
ProductPrice,
|
||||||
|
ProductSupplier,
|
||||||
|
// Warehouses
|
||||||
|
Warehouse,
|
||||||
|
WarehouseLocation,
|
||||||
|
WarehouseZone,
|
||||||
|
// Inventory
|
||||||
|
StockLevel,
|
||||||
|
StockMovement,
|
||||||
|
InventoryCount,
|
||||||
|
InventoryCountLine,
|
||||||
|
TransferOrder,
|
||||||
|
TransferOrderLine,
|
||||||
|
// Sales
|
||||||
|
Quotation,
|
||||||
|
QuotationItem,
|
||||||
|
SalesOrder,
|
||||||
|
SalesOrderItem,
|
||||||
|
// Purchases
|
||||||
|
PurchaseOrder,
|
||||||
|
PurchaseOrderItem,
|
||||||
|
PurchaseReceipt,
|
||||||
|
PurchaseReceiptItem,
|
||||||
|
// Invoices
|
||||||
|
Invoice,
|
||||||
|
InvoiceItem,
|
||||||
|
Payment,
|
||||||
|
PaymentAllocation,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Module configuration options
|
||||||
|
*/
|
||||||
|
export interface ModuleOptions {
|
||||||
|
profiles?: {
|
||||||
|
enabled: boolean;
|
||||||
|
basePath?: string;
|
||||||
|
};
|
||||||
|
branches?: {
|
||||||
|
enabled: boolean;
|
||||||
|
basePath?: string;
|
||||||
|
};
|
||||||
|
billing?: {
|
||||||
|
enabled: boolean;
|
||||||
|
basePath?: string;
|
||||||
|
};
|
||||||
|
payments?: {
|
||||||
|
enabled: boolean;
|
||||||
|
basePath?: string;
|
||||||
|
};
|
||||||
|
partners?: {
|
||||||
|
enabled: boolean;
|
||||||
|
basePath?: string;
|
||||||
|
};
|
||||||
|
products?: {
|
||||||
|
enabled: boolean;
|
||||||
|
basePath?: string;
|
||||||
|
};
|
||||||
|
warehouses?: {
|
||||||
|
enabled: boolean;
|
||||||
|
basePath?: string;
|
||||||
|
};
|
||||||
|
inventory?: {
|
||||||
|
enabled: boolean;
|
||||||
|
basePath?: string;
|
||||||
|
};
|
||||||
|
sales?: {
|
||||||
|
enabled: boolean;
|
||||||
|
basePath?: string;
|
||||||
|
};
|
||||||
|
purchases?: {
|
||||||
|
enabled: boolean;
|
||||||
|
basePath?: string;
|
||||||
|
};
|
||||||
|
invoices?: {
|
||||||
|
enabled: boolean;
|
||||||
|
basePath?: string;
|
||||||
|
};
|
||||||
|
reports?: {
|
||||||
|
enabled: boolean;
|
||||||
|
basePath?: string;
|
||||||
|
};
|
||||||
|
dashboard?: {
|
||||||
|
enabled: boolean;
|
||||||
|
basePath?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default module options
|
||||||
|
*/
|
||||||
|
const defaultModuleOptions: ModuleOptions = {
|
||||||
|
profiles: { enabled: true, basePath: '/api' },
|
||||||
|
branches: { enabled: true, basePath: '/api' },
|
||||||
|
billing: { enabled: true, basePath: '/api' },
|
||||||
|
payments: { enabled: true, basePath: '/api' },
|
||||||
|
partners: { enabled: true, basePath: '/api' },
|
||||||
|
products: { enabled: true, basePath: '/api' },
|
||||||
|
warehouses: { enabled: true, basePath: '/api' },
|
||||||
|
inventory: { enabled: true, basePath: '/api' },
|
||||||
|
sales: { enabled: true, basePath: '/api' },
|
||||||
|
purchases: { enabled: true, basePath: '/api' },
|
||||||
|
invoices: { enabled: true, basePath: '/api' },
|
||||||
|
reports: { enabled: true, basePath: '/api' },
|
||||||
|
dashboard: { enabled: true, basePath: '/api' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize and integrate all modules
|
||||||
|
*/
|
||||||
|
export function initializeModules(
|
||||||
|
app: Express,
|
||||||
|
dataSource: DataSource,
|
||||||
|
options: ModuleOptions = {}
|
||||||
|
): void {
|
||||||
|
const config = { ...defaultModuleOptions, ...options };
|
||||||
|
|
||||||
|
// Initialize Profiles Module
|
||||||
|
if (config.profiles?.enabled) {
|
||||||
|
const profilesModule = new ProfilesModule({
|
||||||
|
dataSource,
|
||||||
|
basePath: config.profiles.basePath,
|
||||||
|
});
|
||||||
|
app.use(profilesModule.router);
|
||||||
|
console.log('✅ Profiles module initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Branches Module
|
||||||
|
if (config.branches?.enabled) {
|
||||||
|
const branchesModule = new BranchesModule({
|
||||||
|
dataSource,
|
||||||
|
basePath: config.branches.basePath,
|
||||||
|
});
|
||||||
|
app.use(branchesModule.router);
|
||||||
|
console.log('✅ Branches module initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Billing Module
|
||||||
|
if (config.billing?.enabled) {
|
||||||
|
const billingModule = new BillingUsageModule({
|
||||||
|
dataSource,
|
||||||
|
basePath: config.billing.basePath,
|
||||||
|
});
|
||||||
|
app.use(billingModule.router);
|
||||||
|
console.log('✅ Billing module initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Payment Terminals Module
|
||||||
|
if (config.payments?.enabled) {
|
||||||
|
const paymentModule = new PaymentTerminalsModule({
|
||||||
|
dataSource,
|
||||||
|
basePath: config.payments.basePath,
|
||||||
|
});
|
||||||
|
app.use(paymentModule.router);
|
||||||
|
console.log('✅ Payment Terminals module initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Partners Module
|
||||||
|
if (config.partners?.enabled) {
|
||||||
|
const partnersModule = new PartnersModule({
|
||||||
|
dataSource,
|
||||||
|
basePath: config.partners.basePath,
|
||||||
|
});
|
||||||
|
app.use(partnersModule.router);
|
||||||
|
console.log('✅ Partners module initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Products Module
|
||||||
|
if (config.products?.enabled) {
|
||||||
|
const productsModule = new ProductsModule({
|
||||||
|
dataSource,
|
||||||
|
basePath: config.products.basePath,
|
||||||
|
});
|
||||||
|
app.use(productsModule.router);
|
||||||
|
console.log('✅ Products module initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Warehouses Module
|
||||||
|
if (config.warehouses?.enabled) {
|
||||||
|
const warehousesModule = new WarehousesModule({
|
||||||
|
dataSource,
|
||||||
|
basePath: config.warehouses.basePath,
|
||||||
|
});
|
||||||
|
app.use(warehousesModule.router);
|
||||||
|
console.log('✅ Warehouses module initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Inventory Module
|
||||||
|
if (config.inventory?.enabled) {
|
||||||
|
const inventoryModule = new InventoryModule({
|
||||||
|
dataSource,
|
||||||
|
basePath: config.inventory.basePath,
|
||||||
|
});
|
||||||
|
app.use(inventoryModule.router);
|
||||||
|
console.log('✅ Inventory module initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Sales Module
|
||||||
|
if (config.sales?.enabled) {
|
||||||
|
const salesModule = new SalesModule({
|
||||||
|
dataSource,
|
||||||
|
basePath: config.sales.basePath,
|
||||||
|
});
|
||||||
|
app.use(salesModule.router);
|
||||||
|
console.log('✅ Sales module initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Purchases Module
|
||||||
|
if (config.purchases?.enabled) {
|
||||||
|
const purchasesModule = new PurchasesModule({
|
||||||
|
dataSource,
|
||||||
|
basePath: config.purchases.basePath,
|
||||||
|
});
|
||||||
|
app.use(purchasesModule.router);
|
||||||
|
console.log('✅ Purchases module initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Invoices Module
|
||||||
|
if (config.invoices?.enabled) {
|
||||||
|
const invoicesModule = new InvoicesModule({
|
||||||
|
dataSource,
|
||||||
|
basePath: config.invoices.basePath,
|
||||||
|
});
|
||||||
|
app.use(invoicesModule.router);
|
||||||
|
console.log('✅ Invoices module initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Reports Module
|
||||||
|
if (config.reports?.enabled) {
|
||||||
|
const reportsModule = new ReportsModule({
|
||||||
|
dataSource,
|
||||||
|
basePath: config.reports.basePath,
|
||||||
|
});
|
||||||
|
app.use(reportsModule.router);
|
||||||
|
console.log('✅ Reports module initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Dashboard Module
|
||||||
|
if (config.dashboard?.enabled) {
|
||||||
|
const dashboardModule = new DashboardModule({
|
||||||
|
dataSource,
|
||||||
|
basePath: config.dashboard.basePath,
|
||||||
|
});
|
||||||
|
app.use(dashboardModule.router);
|
||||||
|
console.log('✅ Dashboard module initialized');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create TypeORM DataSource configuration
|
||||||
|
*/
|
||||||
|
export function createDataSourceConfig(options: {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
database: string;
|
||||||
|
ssl?: boolean;
|
||||||
|
logging?: boolean;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
type: 'postgres' as const,
|
||||||
|
host: options.host,
|
||||||
|
port: options.port,
|
||||||
|
username: options.username,
|
||||||
|
password: options.password,
|
||||||
|
database: options.database,
|
||||||
|
ssl: options.ssl ? { rejectUnauthorized: false } : false,
|
||||||
|
logging: options.logging ?? false,
|
||||||
|
entities: getAllEntities(),
|
||||||
|
synchronize: false, // Use migrations instead
|
||||||
|
migrations: ['src/migrations/*.ts'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example application setup
|
||||||
|
*/
|
||||||
|
export async function createApplication(dataSourceConfig: any): Promise<Express> {
|
||||||
|
// Create Express app
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
|
// CORS middleware (configure for production)
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
res.header('Access-Control-Allow-Origin', '*');
|
||||||
|
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS');
|
||||||
|
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Tenant-ID');
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
res.sendStatus(200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize database
|
||||||
|
const dataSource = new DataSource(dataSourceConfig);
|
||||||
|
await dataSource.initialize();
|
||||||
|
console.log('✅ Database connected');
|
||||||
|
|
||||||
|
// Initialize all modules
|
||||||
|
initializeModules(app, dataSource);
|
||||||
|
|
||||||
|
// Health check endpoint
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
status: 'healthy',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
modules: {
|
||||||
|
profiles: true,
|
||||||
|
branches: true,
|
||||||
|
billing: true,
|
||||||
|
payments: true,
|
||||||
|
partners: true,
|
||||||
|
products: true,
|
||||||
|
warehouses: true,
|
||||||
|
inventory: true,
|
||||||
|
sales: true,
|
||||||
|
purchases: true,
|
||||||
|
invoices: true,
|
||||||
|
reports: true,
|
||||||
|
dashboard: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error handling middleware
|
||||||
|
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
|
console.error('Error:', err);
|
||||||
|
res.status(err.status || 500).json({
|
||||||
|
error: err.message || 'Internal Server Error',
|
||||||
|
code: err.code || 'INTERNAL_ERROR',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
getAllEntities,
|
||||||
|
initializeModules,
|
||||||
|
createDataSourceConfig,
|
||||||
|
createApplication,
|
||||||
|
};
|
||||||
66
src/modules/ai/ai.module.ts
Normal file
66
src/modules/ai/ai.module.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { AIService } from './services';
|
||||||
|
import { AIController } from './controllers';
|
||||||
|
import {
|
||||||
|
AIModel,
|
||||||
|
AIPrompt,
|
||||||
|
AIConversation,
|
||||||
|
AIMessage,
|
||||||
|
AIUsageLog,
|
||||||
|
AITenantQuota,
|
||||||
|
} from './entities';
|
||||||
|
|
||||||
|
export interface AIModuleOptions {
|
||||||
|
dataSource: DataSource;
|
||||||
|
basePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AIModule {
|
||||||
|
public router: Router;
|
||||||
|
public aiService: AIService;
|
||||||
|
private dataSource: DataSource;
|
||||||
|
private basePath: string;
|
||||||
|
|
||||||
|
constructor(options: AIModuleOptions) {
|
||||||
|
this.dataSource = options.dataSource;
|
||||||
|
this.basePath = options.basePath || '';
|
||||||
|
this.router = Router();
|
||||||
|
this.initializeServices();
|
||||||
|
this.initializeRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeServices(): void {
|
||||||
|
const modelRepository = this.dataSource.getRepository(AIModel);
|
||||||
|
const conversationRepository = this.dataSource.getRepository(AIConversation);
|
||||||
|
const messageRepository = this.dataSource.getRepository(AIMessage);
|
||||||
|
const promptRepository = this.dataSource.getRepository(AIPrompt);
|
||||||
|
const usageLogRepository = this.dataSource.getRepository(AIUsageLog);
|
||||||
|
const quotaRepository = this.dataSource.getRepository(AITenantQuota);
|
||||||
|
|
||||||
|
this.aiService = new AIService(
|
||||||
|
modelRepository,
|
||||||
|
conversationRepository,
|
||||||
|
messageRepository,
|
||||||
|
promptRepository,
|
||||||
|
usageLogRepository,
|
||||||
|
quotaRepository
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeRoutes(): void {
|
||||||
|
const aiController = new AIController(this.aiService);
|
||||||
|
this.router.use(`${this.basePath}/ai`, aiController.router);
|
||||||
|
}
|
||||||
|
|
||||||
|
static getEntities(): Function[] {
|
||||||
|
return [
|
||||||
|
AIModel,
|
||||||
|
AIPrompt,
|
||||||
|
AIConversation,
|
||||||
|
AIMessage,
|
||||||
|
AIUsageLog,
|
||||||
|
AITenantQuota,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
381
src/modules/ai/controllers/ai.controller.ts
Normal file
381
src/modules/ai/controllers/ai.controller.ts
Normal file
@ -0,0 +1,381 @@
|
|||||||
|
import { Request, Response, NextFunction, Router } from 'express';
|
||||||
|
import { AIService, ConversationFilters } from '../services/ai.service';
|
||||||
|
|
||||||
|
export class AIController {
|
||||||
|
public router: Router;
|
||||||
|
|
||||||
|
constructor(private readonly aiService: AIService) {
|
||||||
|
this.router = Router();
|
||||||
|
this.initializeRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeRoutes(): void {
|
||||||
|
// Models
|
||||||
|
this.router.get('/models', this.findAllModels.bind(this));
|
||||||
|
this.router.get('/models/:id', this.findModel.bind(this));
|
||||||
|
this.router.get('/models/code/:code', this.findModelByCode.bind(this));
|
||||||
|
this.router.get('/models/provider/:provider', this.findModelsByProvider.bind(this));
|
||||||
|
this.router.get('/models/type/:type', this.findModelsByType.bind(this));
|
||||||
|
|
||||||
|
// Prompts
|
||||||
|
this.router.get('/prompts', this.findAllPrompts.bind(this));
|
||||||
|
this.router.get('/prompts/:id', this.findPrompt.bind(this));
|
||||||
|
this.router.get('/prompts/code/:code', this.findPromptByCode.bind(this));
|
||||||
|
this.router.post('/prompts', this.createPrompt.bind(this));
|
||||||
|
this.router.patch('/prompts/:id', this.updatePrompt.bind(this));
|
||||||
|
|
||||||
|
// Conversations
|
||||||
|
this.router.get('/conversations', this.findConversations.bind(this));
|
||||||
|
this.router.get('/conversations/user/:userId', this.findUserConversations.bind(this));
|
||||||
|
this.router.get('/conversations/:id', this.findConversation.bind(this));
|
||||||
|
this.router.post('/conversations', this.createConversation.bind(this));
|
||||||
|
this.router.patch('/conversations/:id', this.updateConversation.bind(this));
|
||||||
|
this.router.post('/conversations/:id/archive', this.archiveConversation.bind(this));
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
this.router.get('/conversations/:conversationId/messages', this.findMessages.bind(this));
|
||||||
|
this.router.post('/conversations/:conversationId/messages', this.addMessage.bind(this));
|
||||||
|
this.router.get('/conversations/:conversationId/tokens', this.getConversationTokenCount.bind(this));
|
||||||
|
|
||||||
|
// Usage & Quotas
|
||||||
|
this.router.post('/usage', this.logUsage.bind(this));
|
||||||
|
this.router.get('/usage/stats', this.getUsageStats.bind(this));
|
||||||
|
this.router.get('/quotas', this.getTenantQuota.bind(this));
|
||||||
|
this.router.patch('/quotas', this.updateTenantQuota.bind(this));
|
||||||
|
this.router.get('/quotas/check', this.checkQuotaAvailable.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// MODELS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
private async findAllModels(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const models = await this.aiService.findAllModels();
|
||||||
|
res.json({ data: models, total: models.length });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findModel(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const model = await this.aiService.findModel(id);
|
||||||
|
|
||||||
|
if (!model) {
|
||||||
|
res.status(404).json({ error: 'Model not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: model });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findModelByCode(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { code } = req.params;
|
||||||
|
const model = await this.aiService.findModelByCode(code);
|
||||||
|
|
||||||
|
if (!model) {
|
||||||
|
res.status(404).json({ error: 'Model not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: model });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findModelsByProvider(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { provider } = req.params;
|
||||||
|
const models = await this.aiService.findModelsByProvider(provider);
|
||||||
|
res.json({ data: models, total: models.length });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findModelsByType(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { type } = req.params;
|
||||||
|
const models = await this.aiService.findModelsByType(type);
|
||||||
|
res.json({ data: models, total: models.length });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// PROMPTS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
private async findAllPrompts(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const prompts = await this.aiService.findAllPrompts(tenantId);
|
||||||
|
res.json({ data: prompts, total: prompts.length });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findPrompt(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const prompt = await this.aiService.findPrompt(id);
|
||||||
|
|
||||||
|
if (!prompt) {
|
||||||
|
res.status(404).json({ error: 'Prompt not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: prompt });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findPromptByCode(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { code } = req.params;
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const prompt = await this.aiService.findPromptByCode(code, tenantId);
|
||||||
|
|
||||||
|
if (!prompt) {
|
||||||
|
res.status(404).json({ error: 'Prompt not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment usage count
|
||||||
|
await this.aiService.incrementPromptUsage(prompt.id);
|
||||||
|
|
||||||
|
res.json({ data: prompt });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createPrompt(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = req.headers['x-user-id'] as string;
|
||||||
|
|
||||||
|
const prompt = await this.aiService.createPrompt(tenantId, req.body, userId);
|
||||||
|
res.status(201).json({ data: prompt });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updatePrompt(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const userId = req.headers['x-user-id'] as string;
|
||||||
|
|
||||||
|
const prompt = await this.aiService.updatePrompt(id, req.body, userId);
|
||||||
|
|
||||||
|
if (!prompt) {
|
||||||
|
res.status(404).json({ error: 'Prompt not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: prompt });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// CONVERSATIONS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
private async findConversations(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const filters: ConversationFilters = {
|
||||||
|
userId: req.query.userId as string,
|
||||||
|
modelId: req.query.modelId as string,
|
||||||
|
status: req.query.status as string,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (req.query.startDate) filters.startDate = new Date(req.query.startDate as string);
|
||||||
|
if (req.query.endDate) filters.endDate = new Date(req.query.endDate as string);
|
||||||
|
|
||||||
|
const limit = parseInt(req.query.limit as string) || 50;
|
||||||
|
|
||||||
|
const conversations = await this.aiService.findConversations(tenantId, filters, limit);
|
||||||
|
res.json({ data: conversations, total: conversations.length });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findUserConversations(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const { userId } = req.params;
|
||||||
|
const limit = parseInt(req.query.limit as string) || 20;
|
||||||
|
|
||||||
|
const conversations = await this.aiService.findUserConversations(tenantId, userId, limit);
|
||||||
|
res.json({ data: conversations, total: conversations.length });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findConversation(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const conversation = await this.aiService.findConversation(id);
|
||||||
|
|
||||||
|
if (!conversation) {
|
||||||
|
res.status(404).json({ error: 'Conversation not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: conversation });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createConversation(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = req.headers['x-user-id'] as string;
|
||||||
|
|
||||||
|
const conversation = await this.aiService.createConversation(tenantId, userId, req.body);
|
||||||
|
res.status(201).json({ data: conversation });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateConversation(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const conversation = await this.aiService.updateConversation(id, req.body);
|
||||||
|
|
||||||
|
if (!conversation) {
|
||||||
|
res.status(404).json({ error: 'Conversation not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: conversation });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async archiveConversation(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const archived = await this.aiService.archiveConversation(id);
|
||||||
|
|
||||||
|
if (!archived) {
|
||||||
|
res.status(404).json({ error: 'Conversation not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: { success: true } });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// MESSAGES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
private async findMessages(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { conversationId } = req.params;
|
||||||
|
const messages = await this.aiService.findMessages(conversationId);
|
||||||
|
res.json({ data: messages, total: messages.length });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async addMessage(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { conversationId } = req.params;
|
||||||
|
const message = await this.aiService.addMessage(conversationId, req.body);
|
||||||
|
res.status(201).json({ data: message });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getConversationTokenCount(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { conversationId } = req.params;
|
||||||
|
const tokenCount = await this.aiService.getConversationTokenCount(conversationId);
|
||||||
|
res.json({ data: { tokenCount } });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// USAGE & QUOTAS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
private async logUsage(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const log = await this.aiService.logUsage(tenantId, req.body);
|
||||||
|
res.status(201).json({ data: log });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getUsageStats(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const startDate = new Date(req.query.startDate as string || Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||||
|
const endDate = new Date(req.query.endDate as string || Date.now());
|
||||||
|
|
||||||
|
const stats = await this.aiService.getUsageStats(tenantId, startDate, endDate);
|
||||||
|
res.json({ data: stats });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getTenantQuota(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const quota = await this.aiService.getTenantQuota(tenantId);
|
||||||
|
res.json({ data: quota });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateTenantQuota(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const quota = await this.aiService.updateTenantQuota(tenantId, req.body);
|
||||||
|
res.json({ data: quota });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkQuotaAvailable(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const result = await this.aiService.checkQuotaAvailable(tenantId);
|
||||||
|
res.json({ data: result });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/modules/ai/controllers/index.ts
Normal file
1
src/modules/ai/controllers/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { AIController } from './ai.controller';
|
||||||
343
src/modules/ai/dto/ai.dto.ts
Normal file
343
src/modules/ai/dto/ai.dto.ts
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsBoolean,
|
||||||
|
IsNumber,
|
||||||
|
IsArray,
|
||||||
|
IsObject,
|
||||||
|
IsUUID,
|
||||||
|
MaxLength,
|
||||||
|
MinLength,
|
||||||
|
Min,
|
||||||
|
Max,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// PROMPT DTOs
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export class CreatePromptDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
@MaxLength(50)
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
@MaxLength(100)
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(30)
|
||||||
|
category?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
systemPrompt: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
userPromptTemplate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
variables?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Max(2)
|
||||||
|
temperature?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
maxTokens?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
stopSequences?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
modelParameters?: Record<string, any>;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
allowedModels?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdatePromptDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(30)
|
||||||
|
category?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
systemPrompt?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
userPromptTemplate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
variables?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Max(2)
|
||||||
|
temperature?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
maxTokens?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
stopSequences?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
modelParameters?: Record<string, any>;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// CONVERSATION DTOs
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export class CreateConversationDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
modelId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
promptId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
systemPrompt?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Max(2)
|
||||||
|
temperature?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
maxTokens?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
context?: Record<string, any>;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateConversationDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
systemPrompt?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Max(2)
|
||||||
|
temperature?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
maxTokens?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
context?: Record<string, any>;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// MESSAGE DTOs
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export class AddMessageDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
role: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
modelCode?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
promptTokens?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
completionTokens?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
totalTokens?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(30)
|
||||||
|
finishReason?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
latencyMs?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// USAGE DTOs
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export class LogUsageDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
userId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
conversationId?: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
modelId: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
usageType: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
inputTokens: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
outputTokens: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
costUsd?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
latencyMs?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
wasSuccessful?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
errorMessage?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// QUOTA DTOs
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export class UpdateQuotaDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
maxRequestsPerMonth?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
maxTokensPerMonth?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
maxSpendPerMonth?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
maxRequestsPerDay?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
maxTokensPerDay?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
allowedModels?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
blockedModels?: string[];
|
||||||
|
}
|
||||||
9
src/modules/ai/dto/index.ts
Normal file
9
src/modules/ai/dto/index.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export {
|
||||||
|
CreatePromptDto,
|
||||||
|
UpdatePromptDto,
|
||||||
|
CreateConversationDto,
|
||||||
|
UpdateConversationDto,
|
||||||
|
AddMessageDto,
|
||||||
|
LogUsageDto,
|
||||||
|
UpdateQuotaDto,
|
||||||
|
} from './ai.dto';
|
||||||
92
src/modules/ai/entities/completion.entity.ts
Normal file
92
src/modules/ai/entities/completion.entity.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { AIModel } from './model.entity';
|
||||||
|
import { AIPrompt } from './prompt.entity';
|
||||||
|
|
||||||
|
export type CompletionStatus = 'pending' | 'processing' | 'completed' | 'failed';
|
||||||
|
|
||||||
|
@Entity({ name: 'completions', schema: 'ai' })
|
||||||
|
export class AICompletion {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'user_id', type: 'uuid', nullable: true })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'prompt_id', type: 'uuid', nullable: true })
|
||||||
|
promptId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'prompt_code', type: 'varchar', length: 100, nullable: true })
|
||||||
|
promptCode: string;
|
||||||
|
|
||||||
|
@Column({ name: 'model_id', type: 'uuid', nullable: true })
|
||||||
|
modelId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'input_text', type: 'text' })
|
||||||
|
inputText: string;
|
||||||
|
|
||||||
|
@Column({ name: 'input_variables', type: 'jsonb', default: {} })
|
||||||
|
inputVariables: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({ name: 'output_text', type: 'text', nullable: true })
|
||||||
|
outputText: string;
|
||||||
|
|
||||||
|
@Column({ name: 'prompt_tokens', type: 'int', nullable: true })
|
||||||
|
promptTokens: number;
|
||||||
|
|
||||||
|
@Column({ name: 'completion_tokens', type: 'int', nullable: true })
|
||||||
|
completionTokens: number;
|
||||||
|
|
||||||
|
@Column({ name: 'total_tokens', type: 'int', nullable: true })
|
||||||
|
totalTokens: number;
|
||||||
|
|
||||||
|
@Column({ name: 'cost', type: 'decimal', precision: 10, scale: 6, nullable: true })
|
||||||
|
cost: number;
|
||||||
|
|
||||||
|
@Column({ name: 'latency_ms', type: 'int', nullable: true })
|
||||||
|
latencyMs: number;
|
||||||
|
|
||||||
|
@Column({ name: 'finish_reason', type: 'varchar', length: 30, nullable: true })
|
||||||
|
finishReason: string;
|
||||||
|
|
||||||
|
@Column({ name: 'status', type: 'varchar', length: 20, default: 'pending' })
|
||||||
|
status: CompletionStatus;
|
||||||
|
|
||||||
|
@Column({ name: 'error_message', type: 'text', nullable: true })
|
||||||
|
errorMessage: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'context_type', type: 'varchar', length: 50, nullable: true })
|
||||||
|
contextType: string;
|
||||||
|
|
||||||
|
@Column({ name: 'context_id', type: 'uuid', nullable: true })
|
||||||
|
contextId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'metadata', type: 'jsonb', default: {} })
|
||||||
|
metadata: Record<string, any>;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => AIModel, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'model_id' })
|
||||||
|
model: AIModel;
|
||||||
|
|
||||||
|
@ManyToOne(() => AIPrompt, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'prompt_id' })
|
||||||
|
prompt: AIPrompt;
|
||||||
|
}
|
||||||
160
src/modules/ai/entities/conversation.entity.ts
Normal file
160
src/modules/ai/entities/conversation.entity.ts
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
OneToMany,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { AIModel } from './model.entity';
|
||||||
|
|
||||||
|
export type ConversationStatus = 'active' | 'archived' | 'deleted';
|
||||||
|
export type MessageRole = 'system' | 'user' | 'assistant' | 'function';
|
||||||
|
export type FinishReason = 'stop' | 'length' | 'function_call' | 'content_filter';
|
||||||
|
|
||||||
|
@Entity({ name: 'conversations', schema: 'ai' })
|
||||||
|
export class AIConversation {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'user_id', type: 'uuid' })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'title', type: 'varchar', length: 255, nullable: true })
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@Column({ name: 'summary', type: 'text', nullable: true })
|
||||||
|
summary: string;
|
||||||
|
|
||||||
|
@Column({ name: 'context_type', type: 'varchar', length: 50, nullable: true })
|
||||||
|
contextType: string;
|
||||||
|
|
||||||
|
@Column({ name: 'context_data', type: 'jsonb', default: {} })
|
||||||
|
contextData: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({ name: 'model_id', type: 'uuid', nullable: true })
|
||||||
|
modelId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'prompt_id', type: 'uuid', nullable: true })
|
||||||
|
promptId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'status', type: 'varchar', length: 20, default: 'active' })
|
||||||
|
status: ConversationStatus;
|
||||||
|
|
||||||
|
@Column({ name: 'is_pinned', type: 'boolean', default: false })
|
||||||
|
isPinned: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'message_count', type: 'int', default: 0 })
|
||||||
|
messageCount: number;
|
||||||
|
|
||||||
|
@Column({ name: 'total_tokens', type: 'int', default: 0 })
|
||||||
|
totalTokens: number;
|
||||||
|
|
||||||
|
@Column({ name: 'total_cost', type: 'decimal', precision: 10, scale: 4, default: 0 })
|
||||||
|
totalCost: number;
|
||||||
|
|
||||||
|
@Column({ name: 'metadata', type: 'jsonb', default: {} })
|
||||||
|
metadata: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({ name: 'tags', type: 'text', array: true, default: [] })
|
||||||
|
tags: string[];
|
||||||
|
|
||||||
|
@Column({ name: 'last_message_at', type: 'timestamptz', nullable: true })
|
||||||
|
lastMessageAt: Date;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => AIModel, { onDelete: 'SET NULL' })
|
||||||
|
@JoinColumn({ name: 'model_id' })
|
||||||
|
model: AIModel;
|
||||||
|
|
||||||
|
@OneToMany(() => AIMessage, (message) => message.conversation)
|
||||||
|
messages: AIMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity({ name: 'messages', schema: 'ai' })
|
||||||
|
export class AIMessage {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'conversation_id', type: 'uuid' })
|
||||||
|
conversationId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'role', type: 'varchar', length: 20 })
|
||||||
|
role: MessageRole;
|
||||||
|
|
||||||
|
@Column({ name: 'content', type: 'text' })
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
@Column({ name: 'function_name', type: 'varchar', length: 100, nullable: true })
|
||||||
|
functionName: string;
|
||||||
|
|
||||||
|
@Column({ name: 'function_arguments', type: 'jsonb', nullable: true })
|
||||||
|
functionArguments: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({ name: 'function_result', type: 'jsonb', nullable: true })
|
||||||
|
functionResult: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({ name: 'model_id', type: 'uuid', nullable: true })
|
||||||
|
modelId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'model_response_id', type: 'varchar', length: 255, nullable: true })
|
||||||
|
modelResponseId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'prompt_tokens', type: 'int', nullable: true })
|
||||||
|
promptTokens: number;
|
||||||
|
|
||||||
|
@Column({ name: 'completion_tokens', type: 'int', nullable: true })
|
||||||
|
completionTokens: number;
|
||||||
|
|
||||||
|
@Column({ name: 'total_tokens', type: 'int', nullable: true })
|
||||||
|
totalTokens: number;
|
||||||
|
|
||||||
|
@Column({ name: 'cost', type: 'decimal', precision: 10, scale: 6, nullable: true })
|
||||||
|
cost: number;
|
||||||
|
|
||||||
|
@Column({ name: 'latency_ms', type: 'int', nullable: true })
|
||||||
|
latencyMs: number;
|
||||||
|
|
||||||
|
@Column({ name: 'finish_reason', type: 'varchar', length: 30, nullable: true })
|
||||||
|
finishReason: FinishReason;
|
||||||
|
|
||||||
|
@Column({ name: 'metadata', type: 'jsonb', default: {} })
|
||||||
|
metadata: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({ name: 'feedback_rating', type: 'int', nullable: true })
|
||||||
|
feedbackRating: number;
|
||||||
|
|
||||||
|
@Column({ name: 'feedback_text', type: 'text', nullable: true })
|
||||||
|
feedbackText: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => AIConversation, (conversation) => conversation.messages, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'conversation_id' })
|
||||||
|
conversation: AIConversation;
|
||||||
|
|
||||||
|
@ManyToOne(() => AIModel, { onDelete: 'SET NULL' })
|
||||||
|
@JoinColumn({ name: 'model_id' })
|
||||||
|
model: AIModel;
|
||||||
|
}
|
||||||
77
src/modules/ai/entities/embedding.entity.ts
Normal file
77
src/modules/ai/entities/embedding.entity.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { AIModel } from './model.entity';
|
||||||
|
|
||||||
|
@Entity({ name: 'embeddings', schema: 'ai' })
|
||||||
|
export class AIEmbedding {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'content', type: 'text' })
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'content_hash', type: 'varchar', length: 64, nullable: true })
|
||||||
|
contentHash: string;
|
||||||
|
|
||||||
|
// Note: If pgvector is enabled, use 'vector' type instead of 'jsonb'
|
||||||
|
@Column({ name: 'embedding_json', type: 'jsonb', nullable: true })
|
||||||
|
embeddingJson: number[];
|
||||||
|
|
||||||
|
@Column({ name: 'model_id', type: 'uuid', nullable: true })
|
||||||
|
modelId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'model_name', type: 'varchar', length: 100, nullable: true })
|
||||||
|
modelName: string;
|
||||||
|
|
||||||
|
@Column({ name: 'dimensions', type: 'int', nullable: true })
|
||||||
|
dimensions: number;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'entity_type', type: 'varchar', length: 100, nullable: true })
|
||||||
|
entityType: string;
|
||||||
|
|
||||||
|
@Column({ name: 'entity_id', type: 'uuid', nullable: true })
|
||||||
|
entityId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'metadata', type: 'jsonb', default: {} })
|
||||||
|
metadata: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({ name: 'tags', type: 'text', array: true, default: [] })
|
||||||
|
tags: string[];
|
||||||
|
|
||||||
|
@Column({ name: 'chunk_index', type: 'int', nullable: true })
|
||||||
|
chunkIndex: number;
|
||||||
|
|
||||||
|
@Column({ name: 'chunk_total', type: 'int', nullable: true })
|
||||||
|
chunkTotal: number;
|
||||||
|
|
||||||
|
@Column({ name: 'parent_embedding_id', type: 'uuid', nullable: true })
|
||||||
|
parentEmbeddingId: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => AIModel, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'model_id' })
|
||||||
|
model: AIModel;
|
||||||
|
|
||||||
|
@ManyToOne(() => AIEmbedding, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'parent_embedding_id' })
|
||||||
|
parentEmbedding: AIEmbedding;
|
||||||
|
}
|
||||||
7
src/modules/ai/entities/index.ts
Normal file
7
src/modules/ai/entities/index.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export { AIModel, AIProvider, ModelType } from './model.entity';
|
||||||
|
export { AIConversation, AIMessage, ConversationStatus, MessageRole, FinishReason } from './conversation.entity';
|
||||||
|
export { AIPrompt, PromptCategory } from './prompt.entity';
|
||||||
|
export { AIUsageLog, AITenantQuota, UsageType } from './usage.entity';
|
||||||
|
export { AICompletion, CompletionStatus } from './completion.entity';
|
||||||
|
export { AIEmbedding } from './embedding.entity';
|
||||||
|
export { AIKnowledgeBase, KnowledgeSourceType, KnowledgeContentType } from './knowledge-base.entity';
|
||||||
98
src/modules/ai/entities/knowledge-base.entity.ts
Normal file
98
src/modules/ai/entities/knowledge-base.entity.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
Unique,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { AIEmbedding } from './embedding.entity';
|
||||||
|
|
||||||
|
export type KnowledgeSourceType = 'manual' | 'document' | 'website' | 'api';
|
||||||
|
export type KnowledgeContentType = 'faq' | 'documentation' | 'policy' | 'procedure';
|
||||||
|
|
||||||
|
@Entity({ name: 'knowledge_base', schema: 'ai' })
|
||||||
|
@Unique(['tenantId', 'code'])
|
||||||
|
export class AIKnowledgeBase {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid', nullable: true })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'code', type: 'varchar', length: 100 })
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@Column({ name: 'name', type: 'varchar', length: 200 })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ name: 'description', type: 'text', nullable: true })
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
@Column({ name: 'source_type', type: 'varchar', length: 30, nullable: true })
|
||||||
|
sourceType: KnowledgeSourceType;
|
||||||
|
|
||||||
|
@Column({ name: 'source_url', type: 'text', nullable: true })
|
||||||
|
sourceUrl: string;
|
||||||
|
|
||||||
|
@Column({ name: 'source_file_id', type: 'uuid', nullable: true })
|
||||||
|
sourceFileId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'content', type: 'text' })
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
@Column({ name: 'content_type', type: 'varchar', length: 50, nullable: true })
|
||||||
|
contentType: KnowledgeContentType;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'category', type: 'varchar', length: 100, nullable: true })
|
||||||
|
category: string;
|
||||||
|
|
||||||
|
@Column({ name: 'subcategory', type: 'varchar', length: 100, nullable: true })
|
||||||
|
subcategory: string;
|
||||||
|
|
||||||
|
@Column({ name: 'tags', type: 'text', array: true, default: [] })
|
||||||
|
tags: string[];
|
||||||
|
|
||||||
|
@Column({ name: 'embedding_id', type: 'uuid', nullable: true })
|
||||||
|
embeddingId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'priority', type: 'int', default: 0 })
|
||||||
|
priority: number;
|
||||||
|
|
||||||
|
@Column({ name: 'relevance_score', type: 'decimal', precision: 5, scale: 4, nullable: true })
|
||||||
|
relevanceScore: number;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'is_verified', type: 'boolean', default: false })
|
||||||
|
isVerified: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'verified_by', type: 'uuid', nullable: true })
|
||||||
|
verifiedBy: string;
|
||||||
|
|
||||||
|
@Column({ name: 'verified_at', type: 'timestamptz', nullable: true })
|
||||||
|
verifiedAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'metadata', type: 'jsonb', default: {} })
|
||||||
|
metadata: Record<string, any>;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||||
|
createdBy: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => AIEmbedding, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'embedding_id' })
|
||||||
|
embedding: AIEmbedding;
|
||||||
|
}
|
||||||
78
src/modules/ai/entities/model.entity.ts
Normal file
78
src/modules/ai/entities/model.entity.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export type AIProvider = 'openai' | 'anthropic' | 'google' | 'azure' | 'local';
|
||||||
|
export type ModelType = 'chat' | 'completion' | 'embedding' | 'image' | 'audio';
|
||||||
|
|
||||||
|
@Entity({ name: 'models', schema: 'ai' })
|
||||||
|
export class AIModel {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index({ unique: true })
|
||||||
|
@Column({ name: 'code', type: 'varchar', length: 100 })
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@Column({ name: 'name', type: 'varchar', length: 200 })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ name: 'description', type: 'text', nullable: true })
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'provider', type: 'varchar', length: 50 })
|
||||||
|
provider: AIProvider;
|
||||||
|
|
||||||
|
@Column({ name: 'model_id', type: 'varchar', length: 100 })
|
||||||
|
modelId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'model_type', type: 'varchar', length: 30 })
|
||||||
|
modelType: ModelType;
|
||||||
|
|
||||||
|
@Column({ name: 'max_tokens', type: 'int', nullable: true })
|
||||||
|
maxTokens: number;
|
||||||
|
|
||||||
|
@Column({ name: 'supports_functions', type: 'boolean', default: false })
|
||||||
|
supportsFunctions: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'supports_vision', type: 'boolean', default: false })
|
||||||
|
supportsVision: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'supports_streaming', type: 'boolean', default: true })
|
||||||
|
supportsStreaming: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'input_cost_per_1k', type: 'decimal', precision: 10, scale: 6, nullable: true })
|
||||||
|
inputCostPer1k: number;
|
||||||
|
|
||||||
|
@Column({ name: 'output_cost_per_1k', type: 'decimal', precision: 10, scale: 6, nullable: true })
|
||||||
|
outputCostPer1k: number;
|
||||||
|
|
||||||
|
@Column({ name: 'rate_limit_rpm', type: 'int', nullable: true })
|
||||||
|
rateLimitRpm: number;
|
||||||
|
|
||||||
|
@Column({ name: 'rate_limit_tpm', type: 'int', nullable: true })
|
||||||
|
rateLimitTpm: number;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'is_default', type: 'boolean', default: false })
|
||||||
|
isDefault: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'metadata', type: 'jsonb', default: {} })
|
||||||
|
metadata: Record<string, any>;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
110
src/modules/ai/entities/prompt.entity.ts
Normal file
110
src/modules/ai/entities/prompt.entity.ts
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
Unique,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { AIModel } from './model.entity';
|
||||||
|
|
||||||
|
export type PromptCategory = 'assistant' | 'analysis' | 'generation' | 'extraction';
|
||||||
|
|
||||||
|
@Entity({ name: 'prompts', schema: 'ai' })
|
||||||
|
@Unique(['tenantId', 'code', 'version'])
|
||||||
|
export class AIPrompt {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid', nullable: true })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'code', type: 'varchar', length: 100 })
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@Column({ name: 'name', type: 'varchar', length: 200 })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ name: 'description', type: 'text', nullable: true })
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'category', type: 'varchar', length: 50, nullable: true })
|
||||||
|
category: PromptCategory;
|
||||||
|
|
||||||
|
@Column({ name: 'system_prompt', type: 'text', nullable: true })
|
||||||
|
systemPrompt: string;
|
||||||
|
|
||||||
|
@Column({ name: 'user_prompt_template', type: 'text' })
|
||||||
|
userPromptTemplate: string;
|
||||||
|
|
||||||
|
@Column({ name: 'model_id', type: 'uuid', nullable: true })
|
||||||
|
modelId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'temperature', type: 'decimal', precision: 3, scale: 2, default: 0.7 })
|
||||||
|
temperature: number;
|
||||||
|
|
||||||
|
@Column({ name: 'max_tokens', type: 'int', nullable: true })
|
||||||
|
maxTokens: number;
|
||||||
|
|
||||||
|
@Column({ name: 'top_p', type: 'decimal', precision: 3, scale: 2, nullable: true })
|
||||||
|
topP: number;
|
||||||
|
|
||||||
|
@Column({ name: 'frequency_penalty', type: 'decimal', precision: 3, scale: 2, nullable: true })
|
||||||
|
frequencyPenalty: number;
|
||||||
|
|
||||||
|
@Column({ name: 'presence_penalty', type: 'decimal', precision: 3, scale: 2, nullable: true })
|
||||||
|
presencePenalty: number;
|
||||||
|
|
||||||
|
@Column({ name: 'required_variables', type: 'text', array: true, default: [] })
|
||||||
|
requiredVariables: string[];
|
||||||
|
|
||||||
|
@Column({ name: 'variable_schema', type: 'jsonb', default: {} })
|
||||||
|
variableSchema: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({ name: 'functions', type: 'jsonb', default: [] })
|
||||||
|
functions: Record<string, any>[];
|
||||||
|
|
||||||
|
@Column({ name: 'version', type: 'int', default: 1 })
|
||||||
|
version: number;
|
||||||
|
|
||||||
|
@Column({ name: 'is_latest', type: 'boolean', default: true })
|
||||||
|
isLatest: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'parent_version_id', type: 'uuid', nullable: true })
|
||||||
|
parentVersionId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'is_system', type: 'boolean', default: false })
|
||||||
|
isSystem: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'usage_count', type: 'int', default: 0 })
|
||||||
|
usageCount: number;
|
||||||
|
|
||||||
|
@Column({ name: 'avg_tokens_used', type: 'int', nullable: true })
|
||||||
|
avgTokensUsed: number;
|
||||||
|
|
||||||
|
@Column({ name: 'avg_latency_ms', type: 'int', nullable: true })
|
||||||
|
avgLatencyMs: number;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||||
|
createdBy: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => AIModel, { onDelete: 'SET NULL' })
|
||||||
|
@JoinColumn({ name: 'model_id' })
|
||||||
|
model: AIModel;
|
||||||
|
}
|
||||||
120
src/modules/ai/entities/usage.entity.ts
Normal file
120
src/modules/ai/entities/usage.entity.ts
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
Unique,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export type UsageType = 'chat' | 'completion' | 'embedding' | 'image';
|
||||||
|
|
||||||
|
@Entity({ name: 'usage_logs', schema: 'ai' })
|
||||||
|
export class AIUsageLog {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'user_id', type: 'uuid', nullable: true })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'model_id', type: 'uuid', nullable: true })
|
||||||
|
modelId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'model_name', type: 'varchar', length: 100, nullable: true })
|
||||||
|
modelName: string;
|
||||||
|
|
||||||
|
@Column({ name: 'provider', type: 'varchar', length: 50, nullable: true })
|
||||||
|
provider: string;
|
||||||
|
|
||||||
|
@Column({ name: 'usage_type', type: 'varchar', length: 30 })
|
||||||
|
usageType: UsageType;
|
||||||
|
|
||||||
|
@Column({ name: 'prompt_tokens', type: 'int', default: 0 })
|
||||||
|
promptTokens: number;
|
||||||
|
|
||||||
|
@Column({ name: 'completion_tokens', type: 'int', default: 0 })
|
||||||
|
completionTokens: number;
|
||||||
|
|
||||||
|
@Column({ name: 'total_tokens', type: 'int', default: 0 })
|
||||||
|
totalTokens: number;
|
||||||
|
|
||||||
|
@Column({ name: 'cost', type: 'decimal', precision: 10, scale: 6, default: 0 })
|
||||||
|
cost: number;
|
||||||
|
|
||||||
|
@Column({ name: 'conversation_id', type: 'uuid', nullable: true })
|
||||||
|
conversationId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'completion_id', type: 'uuid', nullable: true })
|
||||||
|
completionId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'request_id', type: 'varchar', length: 255, nullable: true })
|
||||||
|
requestId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'usage_date', type: 'date', default: () => 'CURRENT_DATE' })
|
||||||
|
usageDate: Date;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'usage_month', type: 'varchar', length: 7, nullable: true })
|
||||||
|
usageMonth: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity({ name: 'tenant_quotas', schema: 'ai' })
|
||||||
|
@Unique(['tenantId', 'quotaMonth'])
|
||||||
|
export class AITenantQuota {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'monthly_token_limit', type: 'int', nullable: true })
|
||||||
|
monthlyTokenLimit: number;
|
||||||
|
|
||||||
|
@Column({ name: 'monthly_request_limit', type: 'int', nullable: true })
|
||||||
|
monthlyRequestLimit: number;
|
||||||
|
|
||||||
|
@Column({ name: 'monthly_cost_limit', type: 'decimal', precision: 10, scale: 2, nullable: true })
|
||||||
|
monthlyCostLimit: number;
|
||||||
|
|
||||||
|
@Column({ name: 'current_tokens', type: 'int', default: 0 })
|
||||||
|
currentTokens: number;
|
||||||
|
|
||||||
|
@Column({ name: 'current_requests', type: 'int', default: 0 })
|
||||||
|
currentRequests: number;
|
||||||
|
|
||||||
|
@Column({ name: 'current_cost', type: 'decimal', precision: 10, scale: 4, default: 0 })
|
||||||
|
currentCost: number;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'quota_month', type: 'varchar', length: 7 })
|
||||||
|
quotaMonth: string;
|
||||||
|
|
||||||
|
@Column({ name: 'is_exceeded', type: 'boolean', default: false })
|
||||||
|
isExceeded: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'exceeded_at', type: 'timestamptz', nullable: true })
|
||||||
|
exceededAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'alert_threshold_percent', type: 'int', default: 80 })
|
||||||
|
alertThresholdPercent: number;
|
||||||
|
|
||||||
|
@Column({ name: 'alert_sent_at', type: 'timestamptz', nullable: true })
|
||||||
|
alertSentAt: Date;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
5
src/modules/ai/index.ts
Normal file
5
src/modules/ai/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export { AIModule, AIModuleOptions } from './ai.module';
|
||||||
|
export * from './entities';
|
||||||
|
export * from './services';
|
||||||
|
export * from './controllers';
|
||||||
|
export * from './dto';
|
||||||
384
src/modules/ai/services/ai.service.ts
Normal file
384
src/modules/ai/services/ai.service.ts
Normal file
@ -0,0 +1,384 @@
|
|||||||
|
import { Repository, FindOptionsWhere, LessThan, MoreThanOrEqual } from 'typeorm';
|
||||||
|
import { AIModel, AIConversation, AIMessage, AIPrompt, AIUsageLog, AITenantQuota } from '../entities';
|
||||||
|
|
||||||
|
export interface ConversationFilters {
|
||||||
|
userId?: string;
|
||||||
|
modelId?: string;
|
||||||
|
status?: string;
|
||||||
|
startDate?: Date;
|
||||||
|
endDate?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AIService {
|
||||||
|
constructor(
|
||||||
|
private readonly modelRepository: Repository<AIModel>,
|
||||||
|
private readonly conversationRepository: Repository<AIConversation>,
|
||||||
|
private readonly messageRepository: Repository<AIMessage>,
|
||||||
|
private readonly promptRepository: Repository<AIPrompt>,
|
||||||
|
private readonly usageLogRepository: Repository<AIUsageLog>,
|
||||||
|
private readonly quotaRepository: Repository<AITenantQuota>
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// MODELS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async findAllModels(): Promise<AIModel[]> {
|
||||||
|
return this.modelRepository.find({
|
||||||
|
where: { isActive: true },
|
||||||
|
order: { provider: 'ASC', name: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findModel(id: string): Promise<AIModel | null> {
|
||||||
|
return this.modelRepository.findOne({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findModelByCode(code: string): Promise<AIModel | null> {
|
||||||
|
return this.modelRepository.findOne({ where: { code } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findModelsByProvider(provider: string): Promise<AIModel[]> {
|
||||||
|
return this.modelRepository.find({
|
||||||
|
where: { provider: provider as any, isActive: true },
|
||||||
|
order: { name: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findModelsByType(modelType: string): Promise<AIModel[]> {
|
||||||
|
return this.modelRepository.find({
|
||||||
|
where: { modelType: modelType as any, isActive: true },
|
||||||
|
order: { name: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// PROMPTS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async findAllPrompts(tenantId?: string): Promise<AIPrompt[]> {
|
||||||
|
if (tenantId) {
|
||||||
|
return this.promptRepository.find({
|
||||||
|
where: [{ tenantId, isActive: true }, { isSystem: true, isActive: true }],
|
||||||
|
order: { category: 'ASC', name: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.promptRepository.find({
|
||||||
|
where: { isActive: true },
|
||||||
|
order: { category: 'ASC', name: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findPrompt(id: string): Promise<AIPrompt | null> {
|
||||||
|
return this.promptRepository.findOne({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findPromptByCode(code: string, tenantId?: string): Promise<AIPrompt | null> {
|
||||||
|
if (tenantId) {
|
||||||
|
// Try tenant-specific first, then system prompt
|
||||||
|
const tenantPrompt = await this.promptRepository.findOne({
|
||||||
|
where: { code, tenantId, isActive: true },
|
||||||
|
});
|
||||||
|
if (tenantPrompt) return tenantPrompt;
|
||||||
|
|
||||||
|
return this.promptRepository.findOne({
|
||||||
|
where: { code, isSystem: true, isActive: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.promptRepository.findOne({ where: { code, isActive: true } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPrompt(
|
||||||
|
tenantId: string,
|
||||||
|
data: Partial<AIPrompt>,
|
||||||
|
createdBy?: string
|
||||||
|
): Promise<AIPrompt> {
|
||||||
|
const prompt = this.promptRepository.create({
|
||||||
|
...data,
|
||||||
|
tenantId,
|
||||||
|
createdBy,
|
||||||
|
version: 1,
|
||||||
|
});
|
||||||
|
return this.promptRepository.save(prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePrompt(
|
||||||
|
id: string,
|
||||||
|
data: Partial<AIPrompt>,
|
||||||
|
updatedBy?: string
|
||||||
|
): Promise<AIPrompt | null> {
|
||||||
|
const prompt = await this.findPrompt(id);
|
||||||
|
if (!prompt) return null;
|
||||||
|
|
||||||
|
if (prompt.isSystem) {
|
||||||
|
throw new Error('Cannot update system prompts');
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(prompt, data, { updatedBy, version: prompt.version + 1 });
|
||||||
|
return this.promptRepository.save(prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
async incrementPromptUsage(id: string): Promise<void> {
|
||||||
|
await this.promptRepository
|
||||||
|
.createQueryBuilder()
|
||||||
|
.update()
|
||||||
|
.set({
|
||||||
|
usageCount: () => 'usage_count + 1',
|
||||||
|
lastUsedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where('id = :id', { id })
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// CONVERSATIONS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async findConversations(
|
||||||
|
tenantId: string,
|
||||||
|
filters: ConversationFilters = {},
|
||||||
|
limit: number = 50
|
||||||
|
): Promise<AIConversation[]> {
|
||||||
|
const where: FindOptionsWhere<AIConversation> = { tenantId };
|
||||||
|
|
||||||
|
if (filters.userId) where.userId = filters.userId;
|
||||||
|
if (filters.modelId) where.modelId = filters.modelId;
|
||||||
|
if (filters.status) where.status = filters.status as any;
|
||||||
|
|
||||||
|
return this.conversationRepository.find({
|
||||||
|
where,
|
||||||
|
order: { updatedAt: 'DESC' },
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findConversation(id: string): Promise<AIConversation | null> {
|
||||||
|
return this.conversationRepository.findOne({
|
||||||
|
where: { id },
|
||||||
|
relations: ['messages'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findUserConversations(
|
||||||
|
tenantId: string,
|
||||||
|
userId: string,
|
||||||
|
limit: number = 20
|
||||||
|
): Promise<AIConversation[]> {
|
||||||
|
return this.conversationRepository.find({
|
||||||
|
where: { tenantId, userId },
|
||||||
|
order: { updatedAt: 'DESC' },
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createConversation(
|
||||||
|
tenantId: string,
|
||||||
|
userId: string,
|
||||||
|
data: Partial<AIConversation>
|
||||||
|
): Promise<AIConversation> {
|
||||||
|
const conversation = this.conversationRepository.create({
|
||||||
|
...data,
|
||||||
|
tenantId,
|
||||||
|
userId,
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
return this.conversationRepository.save(conversation);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateConversation(
|
||||||
|
id: string,
|
||||||
|
data: Partial<AIConversation>
|
||||||
|
): Promise<AIConversation | null> {
|
||||||
|
const conversation = await this.conversationRepository.findOne({ where: { id } });
|
||||||
|
if (!conversation) return null;
|
||||||
|
|
||||||
|
Object.assign(conversation, data);
|
||||||
|
return this.conversationRepository.save(conversation);
|
||||||
|
}
|
||||||
|
|
||||||
|
async archiveConversation(id: string): Promise<boolean> {
|
||||||
|
const result = await this.conversationRepository.update(id, { status: 'archived' });
|
||||||
|
return (result.affected ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// MESSAGES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async findMessages(conversationId: string): Promise<AIMessage[]> {
|
||||||
|
return this.messageRepository.find({
|
||||||
|
where: { conversationId },
|
||||||
|
order: { createdAt: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async addMessage(conversationId: string, data: Partial<AIMessage>): Promise<AIMessage> {
|
||||||
|
const message = this.messageRepository.create({
|
||||||
|
...data,
|
||||||
|
conversationId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const savedMessage = await this.messageRepository.save(message);
|
||||||
|
|
||||||
|
// Update conversation
|
||||||
|
await this.conversationRepository
|
||||||
|
.createQueryBuilder()
|
||||||
|
.update()
|
||||||
|
.set({
|
||||||
|
messageCount: () => 'message_count + 1',
|
||||||
|
totalTokens: () => `total_tokens + ${data.totalTokens || 0}`,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where('id = :id', { id: conversationId })
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return savedMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getConversationTokenCount(conversationId: string): Promise<number> {
|
||||||
|
const result = await this.messageRepository
|
||||||
|
.createQueryBuilder('message')
|
||||||
|
.select('SUM(message.total_tokens)', 'total')
|
||||||
|
.where('message.conversation_id = :conversationId', { conversationId })
|
||||||
|
.getRawOne();
|
||||||
|
|
||||||
|
return parseInt(result?.total) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// USAGE & QUOTAS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async logUsage(tenantId: string, data: Partial<AIUsageLog>): Promise<AIUsageLog> {
|
||||||
|
const log = this.usageLogRepository.create({
|
||||||
|
...data,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
return this.usageLogRepository.save(log);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUsageStats(
|
||||||
|
tenantId: string,
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date
|
||||||
|
): Promise<{
|
||||||
|
totalRequests: number;
|
||||||
|
totalInputTokens: number;
|
||||||
|
totalOutputTokens: number;
|
||||||
|
totalCost: number;
|
||||||
|
byModel: Record<string, { requests: number; tokens: number; cost: number }>;
|
||||||
|
}> {
|
||||||
|
const stats = await this.usageLogRepository
|
||||||
|
.createQueryBuilder('log')
|
||||||
|
.select('COUNT(*)', 'totalRequests')
|
||||||
|
.addSelect('SUM(log.input_tokens)', 'totalInputTokens')
|
||||||
|
.addSelect('SUM(log.output_tokens)', 'totalOutputTokens')
|
||||||
|
.addSelect('SUM(log.cost_usd)', 'totalCost')
|
||||||
|
.where('log.tenant_id = :tenantId', { tenantId })
|
||||||
|
.andWhere('log.created_at BETWEEN :startDate AND :endDate', { startDate, endDate })
|
||||||
|
.getRawOne();
|
||||||
|
|
||||||
|
const byModelStats = await this.usageLogRepository
|
||||||
|
.createQueryBuilder('log')
|
||||||
|
.select('log.model_id', 'modelId')
|
||||||
|
.addSelect('COUNT(*)', 'requests')
|
||||||
|
.addSelect('SUM(log.input_tokens + log.output_tokens)', 'tokens')
|
||||||
|
.addSelect('SUM(log.cost_usd)', 'cost')
|
||||||
|
.where('log.tenant_id = :tenantId', { tenantId })
|
||||||
|
.andWhere('log.created_at BETWEEN :startDate AND :endDate', { startDate, endDate })
|
||||||
|
.groupBy('log.model_id')
|
||||||
|
.getRawMany();
|
||||||
|
|
||||||
|
const byModel: Record<string, { requests: number; tokens: number; cost: number }> = {};
|
||||||
|
for (const stat of byModelStats) {
|
||||||
|
byModel[stat.modelId] = {
|
||||||
|
requests: parseInt(stat.requests) || 0,
|
||||||
|
tokens: parseInt(stat.tokens) || 0,
|
||||||
|
cost: parseFloat(stat.cost) || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalRequests: parseInt(stats?.totalRequests) || 0,
|
||||||
|
totalInputTokens: parseInt(stats?.totalInputTokens) || 0,
|
||||||
|
totalOutputTokens: parseInt(stats?.totalOutputTokens) || 0,
|
||||||
|
totalCost: parseFloat(stats?.totalCost) || 0,
|
||||||
|
byModel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTenantQuota(tenantId: string): Promise<AITenantQuota | null> {
|
||||||
|
return this.quotaRepository.findOne({ where: { tenantId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateTenantQuota(
|
||||||
|
tenantId: string,
|
||||||
|
data: Partial<AITenantQuota>
|
||||||
|
): Promise<AITenantQuota> {
|
||||||
|
let quota = await this.getTenantQuota(tenantId);
|
||||||
|
|
||||||
|
if (!quota) {
|
||||||
|
quota = this.quotaRepository.create({
|
||||||
|
tenantId,
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
Object.assign(quota, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.quotaRepository.save(quota);
|
||||||
|
}
|
||||||
|
|
||||||
|
async incrementQuotaUsage(
|
||||||
|
tenantId: string,
|
||||||
|
requestCount: number,
|
||||||
|
tokenCount: number,
|
||||||
|
costUsd: number
|
||||||
|
): Promise<void> {
|
||||||
|
await this.quotaRepository
|
||||||
|
.createQueryBuilder()
|
||||||
|
.update()
|
||||||
|
.set({
|
||||||
|
currentRequestsMonth: () => `current_requests_month + ${requestCount}`,
|
||||||
|
currentTokensMonth: () => `current_tokens_month + ${tokenCount}`,
|
||||||
|
currentSpendMonth: () => `current_spend_month + ${costUsd}`,
|
||||||
|
})
|
||||||
|
.where('tenant_id = :tenantId', { tenantId })
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkQuotaAvailable(tenantId: string): Promise<{
|
||||||
|
available: boolean;
|
||||||
|
reason?: string;
|
||||||
|
}> {
|
||||||
|
const quota = await this.getTenantQuota(tenantId);
|
||||||
|
if (!quota) return { available: true };
|
||||||
|
|
||||||
|
if (quota.maxRequestsPerMonth && quota.currentRequestsMonth >= quota.maxRequestsPerMonth) {
|
||||||
|
return { available: false, reason: 'Monthly request limit reached' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quota.maxTokensPerMonth && quota.currentTokensMonth >= quota.maxTokensPerMonth) {
|
||||||
|
return { available: false, reason: 'Monthly token limit reached' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quota.maxSpendPerMonth && quota.currentSpendMonth >= quota.maxSpendPerMonth) {
|
||||||
|
return { available: false, reason: 'Monthly spend limit reached' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { available: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetMonthlyQuotas(): Promise<number> {
|
||||||
|
const result = await this.quotaRepository.update(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
currentRequestsMonth: 0,
|
||||||
|
currentTokensMonth: 0,
|
||||||
|
currentSpendMonth: 0,
|
||||||
|
lastResetAt: new Date(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return result.affected ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/modules/ai/services/index.ts
Normal file
1
src/modules/ai/services/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { AIService, ConversationFilters } from './ai.service';
|
||||||
70
src/modules/audit/audit.module.ts
Normal file
70
src/modules/audit/audit.module.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { AuditService } from './services';
|
||||||
|
import { AuditController } from './controllers';
|
||||||
|
import {
|
||||||
|
AuditLog,
|
||||||
|
EntityChange,
|
||||||
|
LoginHistory,
|
||||||
|
SensitiveDataAccess,
|
||||||
|
DataExport,
|
||||||
|
PermissionChange,
|
||||||
|
ConfigChange,
|
||||||
|
} from './entities';
|
||||||
|
|
||||||
|
export interface AuditModuleOptions {
|
||||||
|
dataSource: DataSource;
|
||||||
|
basePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AuditModule {
|
||||||
|
public router: Router;
|
||||||
|
public auditService: AuditService;
|
||||||
|
private dataSource: DataSource;
|
||||||
|
private basePath: string;
|
||||||
|
|
||||||
|
constructor(options: AuditModuleOptions) {
|
||||||
|
this.dataSource = options.dataSource;
|
||||||
|
this.basePath = options.basePath || '';
|
||||||
|
this.router = Router();
|
||||||
|
this.initializeServices();
|
||||||
|
this.initializeRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeServices(): void {
|
||||||
|
const auditLogRepository = this.dataSource.getRepository(AuditLog);
|
||||||
|
const entityChangeRepository = this.dataSource.getRepository(EntityChange);
|
||||||
|
const loginHistoryRepository = this.dataSource.getRepository(LoginHistory);
|
||||||
|
const sensitiveDataAccessRepository = this.dataSource.getRepository(SensitiveDataAccess);
|
||||||
|
const dataExportRepository = this.dataSource.getRepository(DataExport);
|
||||||
|
const permissionChangeRepository = this.dataSource.getRepository(PermissionChange);
|
||||||
|
const configChangeRepository = this.dataSource.getRepository(ConfigChange);
|
||||||
|
|
||||||
|
this.auditService = new AuditService(
|
||||||
|
auditLogRepository,
|
||||||
|
entityChangeRepository,
|
||||||
|
loginHistoryRepository,
|
||||||
|
sensitiveDataAccessRepository,
|
||||||
|
dataExportRepository,
|
||||||
|
permissionChangeRepository,
|
||||||
|
configChangeRepository
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeRoutes(): void {
|
||||||
|
const auditController = new AuditController(this.auditService);
|
||||||
|
this.router.use(`${this.basePath}/audit`, auditController.router);
|
||||||
|
}
|
||||||
|
|
||||||
|
static getEntities(): Function[] {
|
||||||
|
return [
|
||||||
|
AuditLog,
|
||||||
|
EntityChange,
|
||||||
|
LoginHistory,
|
||||||
|
SensitiveDataAccess,
|
||||||
|
DataExport,
|
||||||
|
PermissionChange,
|
||||||
|
ConfigChange,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
342
src/modules/audit/controllers/audit.controller.ts
Normal file
342
src/modules/audit/controllers/audit.controller.ts
Normal file
@ -0,0 +1,342 @@
|
|||||||
|
import { Request, Response, NextFunction, Router } from 'express';
|
||||||
|
import { AuditService, AuditLogFilters } from '../services/audit.service';
|
||||||
|
|
||||||
|
export class AuditController {
|
||||||
|
public router: Router;
|
||||||
|
|
||||||
|
constructor(private readonly auditService: AuditService) {
|
||||||
|
this.router = Router();
|
||||||
|
this.initializeRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeRoutes(): void {
|
||||||
|
// Audit Logs
|
||||||
|
this.router.get('/logs', this.findAuditLogs.bind(this));
|
||||||
|
this.router.get('/logs/entity/:entityType/:entityId', this.findAuditLogsByEntity.bind(this));
|
||||||
|
this.router.post('/logs', this.createAuditLog.bind(this));
|
||||||
|
|
||||||
|
// Entity Changes
|
||||||
|
this.router.get('/changes/:entityType/:entityId', this.findEntityChanges.bind(this));
|
||||||
|
this.router.get('/changes/:entityType/:entityId/version/:version', this.getEntityVersion.bind(this));
|
||||||
|
this.router.post('/changes', this.createEntityChange.bind(this));
|
||||||
|
|
||||||
|
// Login History
|
||||||
|
this.router.get('/logins/user/:userId', this.findLoginHistory.bind(this));
|
||||||
|
this.router.get('/logins/user/:userId/active-sessions', this.getActiveSessionsCount.bind(this));
|
||||||
|
this.router.post('/logins', this.createLoginHistory.bind(this));
|
||||||
|
this.router.post('/logins/:sessionId/logout', this.markSessionLogout.bind(this));
|
||||||
|
|
||||||
|
// Sensitive Data Access
|
||||||
|
this.router.get('/sensitive-access', this.findSensitiveDataAccess.bind(this));
|
||||||
|
this.router.post('/sensitive-access', this.logSensitiveDataAccess.bind(this));
|
||||||
|
|
||||||
|
// Data Exports
|
||||||
|
this.router.get('/exports', this.findUserDataExports.bind(this));
|
||||||
|
this.router.get('/exports/:id', this.findDataExport.bind(this));
|
||||||
|
this.router.post('/exports', this.createDataExport.bind(this));
|
||||||
|
this.router.patch('/exports/:id/status', this.updateDataExportStatus.bind(this));
|
||||||
|
|
||||||
|
// Permission Changes
|
||||||
|
this.router.get('/permission-changes', this.findPermissionChanges.bind(this));
|
||||||
|
this.router.post('/permission-changes', this.logPermissionChange.bind(this));
|
||||||
|
|
||||||
|
// Config Changes
|
||||||
|
this.router.get('/config-changes', this.findConfigChanges.bind(this));
|
||||||
|
this.router.post('/config-changes', this.logConfigChange.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// AUDIT LOGS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
private async findAuditLogs(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const filters: AuditLogFilters = {
|
||||||
|
userId: req.query.userId as string,
|
||||||
|
entityType: req.query.entityType as string,
|
||||||
|
action: req.query.action as string,
|
||||||
|
category: req.query.category as string,
|
||||||
|
ipAddress: req.query.ipAddress as string,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (req.query.startDate) filters.startDate = new Date(req.query.startDate as string);
|
||||||
|
if (req.query.endDate) filters.endDate = new Date(req.query.endDate as string);
|
||||||
|
|
||||||
|
const page = parseInt(req.query.page as string) || 1;
|
||||||
|
const limit = parseInt(req.query.limit as string) || 50;
|
||||||
|
|
||||||
|
const result = await this.auditService.findAuditLogs(tenantId, filters, { page, limit });
|
||||||
|
res.json({ data: result.data, total: result.total, page, limit });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findAuditLogsByEntity(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const { entityType, entityId } = req.params;
|
||||||
|
|
||||||
|
const logs = await this.auditService.findAuditLogsByEntity(tenantId, entityType, entityId);
|
||||||
|
res.json({ data: logs, total: logs.length });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createAuditLog(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const log = await this.auditService.createAuditLog(tenantId, req.body);
|
||||||
|
res.status(201).json({ data: log });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// ENTITY CHANGES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
private async findEntityChanges(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const { entityType, entityId } = req.params;
|
||||||
|
|
||||||
|
const changes = await this.auditService.findEntityChanges(tenantId, entityType, entityId);
|
||||||
|
res.json({ data: changes, total: changes.length });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getEntityVersion(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const { entityType, entityId, version } = req.params;
|
||||||
|
|
||||||
|
const change = await this.auditService.getEntityVersion(
|
||||||
|
tenantId,
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
parseInt(version)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!change) {
|
||||||
|
res.status(404).json({ error: 'Version not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: change });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createEntityChange(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const change = await this.auditService.createEntityChange(tenantId, req.body);
|
||||||
|
res.status(201).json({ data: change });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// LOGIN HISTORY
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
private async findLoginHistory(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const { userId } = req.params;
|
||||||
|
const limit = parseInt(req.query.limit as string) || 20;
|
||||||
|
|
||||||
|
const history = await this.auditService.findLoginHistory(userId, tenantId, limit);
|
||||||
|
res.json({ data: history, total: history.length });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getActiveSessionsCount(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { userId } = req.params;
|
||||||
|
const count = await this.auditService.getActiveSessionsCount(userId);
|
||||||
|
res.json({ data: { activeSessions: count } });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createLoginHistory(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const login = await this.auditService.createLoginHistory(req.body);
|
||||||
|
res.status(201).json({ data: login });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async markSessionLogout(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { sessionId } = req.params;
|
||||||
|
const marked = await this.auditService.markSessionLogout(sessionId);
|
||||||
|
|
||||||
|
if (!marked) {
|
||||||
|
res.status(404).json({ error: 'Session not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: { success: true } });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// SENSITIVE DATA ACCESS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
private async findSensitiveDataAccess(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const filters: { userId?: string; dataType?: string; startDate?: Date; endDate?: Date } = {
|
||||||
|
userId: req.query.userId as string,
|
||||||
|
dataType: req.query.dataType as string,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (req.query.startDate) filters.startDate = new Date(req.query.startDate as string);
|
||||||
|
if (req.query.endDate) filters.endDate = new Date(req.query.endDate as string);
|
||||||
|
|
||||||
|
const access = await this.auditService.findSensitiveDataAccess(tenantId, filters);
|
||||||
|
res.json({ data: access, total: access.length });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async logSensitiveDataAccess(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const access = await this.auditService.logSensitiveDataAccess(tenantId, req.body);
|
||||||
|
res.status(201).json({ data: access });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// DATA EXPORTS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
private async findUserDataExports(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = req.headers['x-user-id'] as string;
|
||||||
|
|
||||||
|
const exports = await this.auditService.findUserDataExports(tenantId, userId);
|
||||||
|
res.json({ data: exports, total: exports.length });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findDataExport(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const exportRecord = await this.auditService.findDataExport(id);
|
||||||
|
|
||||||
|
if (!exportRecord) {
|
||||||
|
res.status(404).json({ error: 'Export not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: exportRecord });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createDataExport(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const exportRecord = await this.auditService.createDataExport(tenantId, req.body);
|
||||||
|
res.status(201).json({ data: exportRecord });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateDataExportStatus(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { status, ...updates } = req.body;
|
||||||
|
|
||||||
|
const exportRecord = await this.auditService.updateDataExportStatus(id, status, updates);
|
||||||
|
|
||||||
|
if (!exportRecord) {
|
||||||
|
res.status(404).json({ error: 'Export not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: exportRecord });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// PERMISSION CHANGES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
private async findPermissionChanges(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const targetUserId = req.query.targetUserId as string;
|
||||||
|
|
||||||
|
const changes = await this.auditService.findPermissionChanges(tenantId, targetUserId);
|
||||||
|
res.json({ data: changes, total: changes.length });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async logPermissionChange(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const change = await this.auditService.logPermissionChange(tenantId, req.body);
|
||||||
|
res.status(201).json({ data: change });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// CONFIG CHANGES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
private async findConfigChanges(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const configType = req.query.configType as string;
|
||||||
|
|
||||||
|
const changes = await this.auditService.findConfigChanges(tenantId, configType);
|
||||||
|
res.json({ data: changes, total: changes.length });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async logConfigChange(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const change = await this.auditService.logConfigChange(tenantId, req.body);
|
||||||
|
res.status(201).json({ data: change });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/modules/audit/controllers/index.ts
Normal file
1
src/modules/audit/controllers/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { AuditController } from './audit.controller';
|
||||||
346
src/modules/audit/dto/audit.dto.ts
Normal file
346
src/modules/audit/dto/audit.dto.ts
Normal file
@ -0,0 +1,346 @@
|
|||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsBoolean,
|
||||||
|
IsNumber,
|
||||||
|
IsArray,
|
||||||
|
IsObject,
|
||||||
|
IsUUID,
|
||||||
|
IsEnum,
|
||||||
|
IsIP,
|
||||||
|
MaxLength,
|
||||||
|
MinLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// AUDIT LOG DTOs
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export class CreateAuditLogDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
userId?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
action: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(30)
|
||||||
|
category?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
entityType?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
entityId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
oldValues?: Record<string, any>;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
newValues?: Record<string, any>;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(45)
|
||||||
|
ipAddress?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
userAgent?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
requestId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// ENTITY CHANGE DTOs
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export class CreateEntityChangeDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
entityType: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
entityId: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
changeType: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
changedBy?: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
version: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
changedFields?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
previousData?: Record<string, any>;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
newData?: Record<string, any>;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
changeReason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// LOGIN HISTORY DTOs
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export class CreateLoginHistoryDto {
|
||||||
|
@IsUUID()
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
tenantId?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
status: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(30)
|
||||||
|
authMethod?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(30)
|
||||||
|
mfaMethod?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
mfaUsed?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(45)
|
||||||
|
ipAddress?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
userAgent?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
deviceFingerprint?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
location?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
sessionId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
failureReason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// SENSITIVE DATA ACCESS DTOs
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export class CreateSensitiveDataAccessDto {
|
||||||
|
@IsUUID()
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
dataType: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
accessType: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
entityType?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
entityId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
fieldsAccessed?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
accessReason?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
wasExported?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(45)
|
||||||
|
ipAddress?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// DATA EXPORT DTOs
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export class CreateDataExportDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(30)
|
||||||
|
exportType: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
format: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
entities?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
filters?: Record<string, any>;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
fields?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
exportReason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateDataExportStatusDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
status: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
filePath?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
fileSize?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
recordCount?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// PERMISSION CHANGE DTOs
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export class CreatePermissionChangeDto {
|
||||||
|
@IsUUID()
|
||||||
|
targetUserId: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
changedBy: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
changeType: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(30)
|
||||||
|
scope: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
resourceType?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
resourceId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
previousPermissions?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
newPermissions?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
changeReason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// CONFIG CHANGE DTOs
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export class CreateConfigChangeDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(30)
|
||||||
|
configType: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
configKey: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
changedBy: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
version: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
previousValue?: Record<string, any>;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
newValue?: Record<string, any>;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
changeReason?: string;
|
||||||
|
}
|
||||||
10
src/modules/audit/dto/index.ts
Normal file
10
src/modules/audit/dto/index.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export {
|
||||||
|
CreateAuditLogDto,
|
||||||
|
CreateEntityChangeDto,
|
||||||
|
CreateLoginHistoryDto,
|
||||||
|
CreateSensitiveDataAccessDto,
|
||||||
|
CreateDataExportDto,
|
||||||
|
UpdateDataExportStatusDto,
|
||||||
|
CreatePermissionChangeDto,
|
||||||
|
CreateConfigChangeDto,
|
||||||
|
} from './audit.dto';
|
||||||
108
src/modules/audit/entities/audit-log.entity.ts
Normal file
108
src/modules/audit/entities/audit-log.entity.ts
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export type AuditAction = 'create' | 'read' | 'update' | 'delete' | 'login' | 'logout' | 'export';
|
||||||
|
export type AuditCategory = 'data' | 'auth' | 'system' | 'config' | 'billing';
|
||||||
|
export type AuditStatus = 'success' | 'failure' | 'partial';
|
||||||
|
|
||||||
|
@Entity({ name: 'audit_logs', schema: 'audit' })
|
||||||
|
export class AuditLog {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'user_id', type: 'uuid', nullable: true })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'user_email', type: 'varchar', length: 255, nullable: true })
|
||||||
|
userEmail: string;
|
||||||
|
|
||||||
|
@Column({ name: 'user_name', type: 'varchar', length: 200, nullable: true })
|
||||||
|
userName: string;
|
||||||
|
|
||||||
|
@Column({ name: 'session_id', type: 'uuid', nullable: true })
|
||||||
|
sessionId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'impersonator_id', type: 'uuid', nullable: true })
|
||||||
|
impersonatorId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'action', type: 'varchar', length: 50 })
|
||||||
|
action: AuditAction;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'action_category', type: 'varchar', length: 50, nullable: true })
|
||||||
|
actionCategory: AuditCategory;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'resource_type', type: 'varchar', length: 100 })
|
||||||
|
resourceType: string;
|
||||||
|
|
||||||
|
@Column({ name: 'resource_id', type: 'uuid', nullable: true })
|
||||||
|
resourceId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'resource_name', type: 'varchar', length: 255, nullable: true })
|
||||||
|
resourceName: string;
|
||||||
|
|
||||||
|
@Column({ name: 'old_values', type: 'jsonb', nullable: true })
|
||||||
|
oldValues: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({ name: 'new_values', type: 'jsonb', nullable: true })
|
||||||
|
newValues: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({ name: 'changed_fields', type: 'text', array: true, nullable: true })
|
||||||
|
changedFields: string[];
|
||||||
|
|
||||||
|
@Column({ name: 'ip_address', type: 'inet', nullable: true })
|
||||||
|
ipAddress: string;
|
||||||
|
|
||||||
|
@Column({ name: 'user_agent', type: 'text', nullable: true })
|
||||||
|
userAgent: string;
|
||||||
|
|
||||||
|
@Column({ name: 'device_info', type: 'jsonb', default: {} })
|
||||||
|
deviceInfo: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({ name: 'location', type: 'jsonb', default: {} })
|
||||||
|
location: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({ name: 'request_id', type: 'varchar', length: 100, nullable: true })
|
||||||
|
requestId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'request_method', type: 'varchar', length: 10, nullable: true })
|
||||||
|
requestMethod: string;
|
||||||
|
|
||||||
|
@Column({ name: 'request_path', type: 'text', nullable: true })
|
||||||
|
requestPath: string;
|
||||||
|
|
||||||
|
@Column({ name: 'request_params', type: 'jsonb', default: {} })
|
||||||
|
requestParams: Record<string, any>;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'status', type: 'varchar', length: 20, default: 'success' })
|
||||||
|
status: AuditStatus;
|
||||||
|
|
||||||
|
@Column({ name: 'error_message', type: 'text', nullable: true })
|
||||||
|
errorMessage: string;
|
||||||
|
|
||||||
|
@Column({ name: 'duration_ms', type: 'int', nullable: true })
|
||||||
|
durationMs: number;
|
||||||
|
|
||||||
|
@Column({ name: 'metadata', type: 'jsonb', default: {} })
|
||||||
|
metadata: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({ name: 'tags', type: 'text', array: true, default: [] })
|
||||||
|
tags: string[];
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
47
src/modules/audit/entities/config-change.entity.ts
Normal file
47
src/modules/audit/entities/config-change.entity.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export type ConfigType = 'tenant_settings' | 'user_settings' | 'system_settings' | 'feature_flags';
|
||||||
|
|
||||||
|
@Entity({ name: 'config_changes', schema: 'audit' })
|
||||||
|
export class ConfigChange {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid', nullable: true })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'changed_by', type: 'uuid' })
|
||||||
|
changedBy: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'config_type', type: 'varchar', length: 50 })
|
||||||
|
configType: ConfigType;
|
||||||
|
|
||||||
|
@Column({ name: 'config_key', type: 'varchar', length: 100 })
|
||||||
|
configKey: string;
|
||||||
|
|
||||||
|
@Column({ name: 'config_path', type: 'text', nullable: true })
|
||||||
|
configPath: string;
|
||||||
|
|
||||||
|
@Column({ name: 'old_value', type: 'jsonb', nullable: true })
|
||||||
|
oldValue: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({ name: 'new_value', type: 'jsonb', nullable: true })
|
||||||
|
newValue: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({ name: 'reason', type: 'text', nullable: true })
|
||||||
|
reason: string;
|
||||||
|
|
||||||
|
@Column({ name: 'ticket_id', type: 'varchar', length: 50, nullable: true })
|
||||||
|
ticketId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'changed_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
|
changedAt: Date;
|
||||||
|
}
|
||||||
80
src/modules/audit/entities/data-export.entity.ts
Normal file
80
src/modules/audit/entities/data-export.entity.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export type ExportType = 'report' | 'backup' | 'gdpr_request' | 'bulk_export';
|
||||||
|
export type ExportFormat = 'csv' | 'xlsx' | 'pdf' | 'json';
|
||||||
|
export type ExportStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'expired';
|
||||||
|
|
||||||
|
@Entity({ name: 'data_exports', schema: 'audit' })
|
||||||
|
export class DataExport {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'user_id', type: 'uuid' })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'export_type', type: 'varchar', length: 50 })
|
||||||
|
exportType: ExportType;
|
||||||
|
|
||||||
|
@Column({ name: 'export_format', type: 'varchar', length: 20, nullable: true })
|
||||||
|
exportFormat: ExportFormat;
|
||||||
|
|
||||||
|
@Column({ name: 'entity_types', type: 'text', array: true })
|
||||||
|
entityTypes: string[];
|
||||||
|
|
||||||
|
@Column({ name: 'filters', type: 'jsonb', default: {} })
|
||||||
|
filters: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({ name: 'date_range_start', type: 'timestamptz', nullable: true })
|
||||||
|
dateRangeStart: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'date_range_end', type: 'timestamptz', nullable: true })
|
||||||
|
dateRangeEnd: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'record_count', type: 'int', nullable: true })
|
||||||
|
recordCount: number;
|
||||||
|
|
||||||
|
@Column({ name: 'file_size_bytes', type: 'bigint', nullable: true })
|
||||||
|
fileSizeBytes: number;
|
||||||
|
|
||||||
|
@Column({ name: 'file_hash', type: 'varchar', length: 64, nullable: true })
|
||||||
|
fileHash: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'status', type: 'varchar', length: 20, default: 'pending' })
|
||||||
|
status: ExportStatus;
|
||||||
|
|
||||||
|
@Column({ name: 'download_url', type: 'text', nullable: true })
|
||||||
|
downloadUrl: string;
|
||||||
|
|
||||||
|
@Column({ name: 'download_expires_at', type: 'timestamptz', nullable: true })
|
||||||
|
downloadExpiresAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'download_count', type: 'int', default: 0 })
|
||||||
|
downloadCount: number;
|
||||||
|
|
||||||
|
@Column({ name: 'ip_address', type: 'inet', nullable: true })
|
||||||
|
ipAddress: string;
|
||||||
|
|
||||||
|
@Column({ name: 'user_agent', type: 'text', nullable: true })
|
||||||
|
userAgent: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'requested_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
|
requestedAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
|
||||||
|
completedAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'expires_at', type: 'timestamptz', nullable: true })
|
||||||
|
expiresAt: Date;
|
||||||
|
}
|
||||||
55
src/modules/audit/entities/entity-change.entity.ts
Normal file
55
src/modules/audit/entities/entity-change.entity.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export type ChangeType = 'create' | 'update' | 'delete' | 'restore';
|
||||||
|
|
||||||
|
@Entity({ name: 'entity_changes', schema: 'audit' })
|
||||||
|
export class EntityChange {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'entity_type', type: 'varchar', length: 100 })
|
||||||
|
entityType: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'entity_id', type: 'uuid' })
|
||||||
|
entityId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'entity_name', type: 'varchar', length: 255, nullable: true })
|
||||||
|
entityName: string;
|
||||||
|
|
||||||
|
@Column({ name: 'version', type: 'int', default: 1 })
|
||||||
|
version: number;
|
||||||
|
|
||||||
|
@Column({ name: 'previous_version', type: 'int', nullable: true })
|
||||||
|
previousVersion: number;
|
||||||
|
|
||||||
|
@Column({ name: 'data_snapshot', type: 'jsonb' })
|
||||||
|
dataSnapshot: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({ name: 'changes', type: 'jsonb', default: [] })
|
||||||
|
changes: Record<string, any>[];
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'changed_by', type: 'uuid', nullable: true })
|
||||||
|
changedBy: string;
|
||||||
|
|
||||||
|
@Column({ name: 'change_reason', type: 'text', nullable: true })
|
||||||
|
changeReason: string;
|
||||||
|
|
||||||
|
@Column({ name: 'change_type', type: 'varchar', length: 20 })
|
||||||
|
changeType: ChangeType;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'changed_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
|
changedAt: Date;
|
||||||
|
}
|
||||||
7
src/modules/audit/entities/index.ts
Normal file
7
src/modules/audit/entities/index.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export { AuditLog, AuditAction, AuditCategory, AuditStatus } from './audit-log.entity';
|
||||||
|
export { EntityChange, ChangeType } from './entity-change.entity';
|
||||||
|
export { LoginHistory, LoginStatus, AuthMethod, MfaMethod } from './login-history.entity';
|
||||||
|
export { SensitiveDataAccess, DataType, AccessType } from './sensitive-data-access.entity';
|
||||||
|
export { DataExport, ExportType, ExportFormat, ExportStatus } from './data-export.entity';
|
||||||
|
export { PermissionChange, PermissionChangeType, PermissionScope } from './permission-change.entity';
|
||||||
|
export { ConfigChange, ConfigType } from './config-change.entity';
|
||||||
106
src/modules/audit/entities/login-history.entity.ts
Normal file
106
src/modules/audit/entities/login-history.entity.ts
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export type LoginStatus = 'success' | 'failed' | 'blocked' | 'mfa_required' | 'mfa_failed';
|
||||||
|
export type AuthMethod = 'password' | 'sso' | 'oauth' | 'mfa' | 'magic_link' | 'biometric';
|
||||||
|
export type MfaMethod = 'totp' | 'sms' | 'email' | 'push';
|
||||||
|
|
||||||
|
@Entity({ name: 'login_history', schema: 'audit' })
|
||||||
|
export class LoginHistory {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid', nullable: true })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'user_id', type: 'uuid', nullable: true })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'email', type: 'varchar', length: 255, nullable: true })
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@Column({ name: 'username', type: 'varchar', length: 100, nullable: true })
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'status', type: 'varchar', length: 20 })
|
||||||
|
status: LoginStatus;
|
||||||
|
|
||||||
|
@Column({ name: 'auth_method', type: 'varchar', length: 30, nullable: true })
|
||||||
|
authMethod: AuthMethod;
|
||||||
|
|
||||||
|
@Column({ name: 'oauth_provider', type: 'varchar', length: 30, nullable: true })
|
||||||
|
oauthProvider: string;
|
||||||
|
|
||||||
|
@Column({ name: 'mfa_method', type: 'varchar', length: 20, nullable: true })
|
||||||
|
mfaMethod: MfaMethod;
|
||||||
|
|
||||||
|
@Column({ name: 'mfa_verified', type: 'boolean', nullable: true })
|
||||||
|
mfaVerified: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'device_id', type: 'uuid', nullable: true })
|
||||||
|
deviceId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'device_fingerprint', type: 'varchar', length: 255, nullable: true })
|
||||||
|
deviceFingerprint: string;
|
||||||
|
|
||||||
|
@Column({ name: 'device_type', type: 'varchar', length: 30, nullable: true })
|
||||||
|
deviceType: string;
|
||||||
|
|
||||||
|
@Column({ name: 'device_os', type: 'varchar', length: 50, nullable: true })
|
||||||
|
deviceOs: string;
|
||||||
|
|
||||||
|
@Column({ name: 'device_browser', type: 'varchar', length: 50, nullable: true })
|
||||||
|
deviceBrowser: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'ip_address', type: 'inet', nullable: true })
|
||||||
|
ipAddress: string;
|
||||||
|
|
||||||
|
@Column({ name: 'user_agent', type: 'text', nullable: true })
|
||||||
|
userAgent: string;
|
||||||
|
|
||||||
|
@Column({ name: 'country_code', type: 'varchar', length: 2, nullable: true })
|
||||||
|
countryCode: string;
|
||||||
|
|
||||||
|
@Column({ name: 'city', type: 'varchar', length: 100, nullable: true })
|
||||||
|
city: string;
|
||||||
|
|
||||||
|
@Column({ name: 'latitude', type: 'decimal', precision: 10, scale: 8, nullable: true })
|
||||||
|
latitude: number;
|
||||||
|
|
||||||
|
@Column({ name: 'longitude', type: 'decimal', precision: 11, scale: 8, nullable: true })
|
||||||
|
longitude: number;
|
||||||
|
|
||||||
|
@Column({ name: 'risk_score', type: 'int', nullable: true })
|
||||||
|
riskScore: number;
|
||||||
|
|
||||||
|
@Column({ name: 'risk_factors', type: 'jsonb', default: [] })
|
||||||
|
riskFactors: string[];
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'is_suspicious', type: 'boolean', default: false })
|
||||||
|
isSuspicious: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'is_new_device', type: 'boolean', default: false })
|
||||||
|
isNewDevice: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'is_new_location', type: 'boolean', default: false })
|
||||||
|
isNewLocation: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'failure_reason', type: 'varchar', length: 100, nullable: true })
|
||||||
|
failureReason: string;
|
||||||
|
|
||||||
|
@Column({ name: 'failure_count', type: 'int', nullable: true })
|
||||||
|
failureCount: number;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'attempted_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
|
attemptedAt: Date;
|
||||||
|
}
|
||||||
63
src/modules/audit/entities/permission-change.entity.ts
Normal file
63
src/modules/audit/entities/permission-change.entity.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export type PermissionChangeType = 'role_assigned' | 'role_revoked' | 'permission_granted' | 'permission_revoked';
|
||||||
|
export type PermissionScope = 'global' | 'tenant' | 'branch';
|
||||||
|
|
||||||
|
@Entity({ name: 'permission_changes', schema: 'audit' })
|
||||||
|
export class PermissionChange {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'changed_by', type: 'uuid' })
|
||||||
|
changedBy: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'target_user_id', type: 'uuid' })
|
||||||
|
targetUserId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'target_user_email', type: 'varchar', length: 255, nullable: true })
|
||||||
|
targetUserEmail: string;
|
||||||
|
|
||||||
|
@Column({ name: 'change_type', type: 'varchar', length: 30 })
|
||||||
|
changeType: PermissionChangeType;
|
||||||
|
|
||||||
|
@Column({ name: 'role_id', type: 'uuid', nullable: true })
|
||||||
|
roleId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'role_code', type: 'varchar', length: 50, nullable: true })
|
||||||
|
roleCode: string;
|
||||||
|
|
||||||
|
@Column({ name: 'permission_id', type: 'uuid', nullable: true })
|
||||||
|
permissionId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'permission_code', type: 'varchar', length: 100, nullable: true })
|
||||||
|
permissionCode: string;
|
||||||
|
|
||||||
|
@Column({ name: 'branch_id', type: 'uuid', nullable: true })
|
||||||
|
branchId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'scope', type: 'varchar', length: 30, nullable: true })
|
||||||
|
scope: PermissionScope;
|
||||||
|
|
||||||
|
@Column({ name: 'previous_roles', type: 'text', array: true, nullable: true })
|
||||||
|
previousRoles: string[];
|
||||||
|
|
||||||
|
@Column({ name: 'previous_permissions', type: 'text', array: true, nullable: true })
|
||||||
|
previousPermissions: string[];
|
||||||
|
|
||||||
|
@Column({ name: 'reason', type: 'text', nullable: true })
|
||||||
|
reason: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'changed_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
|
changedAt: Date;
|
||||||
|
}
|
||||||
62
src/modules/audit/entities/sensitive-data-access.entity.ts
Normal file
62
src/modules/audit/entities/sensitive-data-access.entity.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export type DataType = 'pii' | 'financial' | 'medical' | 'credentials';
|
||||||
|
export type AccessType = 'view' | 'export' | 'modify' | 'decrypt';
|
||||||
|
|
||||||
|
@Entity({ name: 'sensitive_data_access', schema: 'audit' })
|
||||||
|
export class SensitiveDataAccess {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'user_id', type: 'uuid' })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'session_id', type: 'uuid', nullable: true })
|
||||||
|
sessionId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'data_type', type: 'varchar', length: 100 })
|
||||||
|
dataType: DataType;
|
||||||
|
|
||||||
|
@Column({ name: 'data_category', type: 'varchar', length: 100, nullable: true })
|
||||||
|
dataCategory: string;
|
||||||
|
|
||||||
|
@Column({ name: 'entity_type', type: 'varchar', length: 100, nullable: true })
|
||||||
|
entityType: string;
|
||||||
|
|
||||||
|
@Column({ name: 'entity_id', type: 'uuid', nullable: true })
|
||||||
|
entityId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'access_type', type: 'varchar', length: 30 })
|
||||||
|
accessType: AccessType;
|
||||||
|
|
||||||
|
@Column({ name: 'access_reason', type: 'text', nullable: true })
|
||||||
|
accessReason: string;
|
||||||
|
|
||||||
|
@Column({ name: 'ip_address', type: 'inet', nullable: true })
|
||||||
|
ipAddress: string;
|
||||||
|
|
||||||
|
@Column({ name: 'user_agent', type: 'text', nullable: true })
|
||||||
|
userAgent: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'was_authorized', type: 'boolean', default: true })
|
||||||
|
wasAuthorized: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'denial_reason', type: 'text', nullable: true })
|
||||||
|
denialReason: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'accessed_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
|
accessedAt: Date;
|
||||||
|
}
|
||||||
5
src/modules/audit/index.ts
Normal file
5
src/modules/audit/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export { AuditModule, AuditModuleOptions } from './audit.module';
|
||||||
|
export * from './entities';
|
||||||
|
export * from './services';
|
||||||
|
export * from './controllers';
|
||||||
|
export * from './dto';
|
||||||
303
src/modules/audit/services/audit.service.ts
Normal file
303
src/modules/audit/services/audit.service.ts
Normal file
@ -0,0 +1,303 @@
|
|||||||
|
import { Repository, FindOptionsWhere, Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
||||||
|
import {
|
||||||
|
AuditLog,
|
||||||
|
EntityChange,
|
||||||
|
LoginHistory,
|
||||||
|
SensitiveDataAccess,
|
||||||
|
DataExport,
|
||||||
|
PermissionChange,
|
||||||
|
ConfigChange,
|
||||||
|
} from '../entities';
|
||||||
|
|
||||||
|
export interface AuditLogFilters {
|
||||||
|
userId?: string;
|
||||||
|
entityType?: string;
|
||||||
|
action?: string;
|
||||||
|
category?: string;
|
||||||
|
startDate?: Date;
|
||||||
|
endDate?: Date;
|
||||||
|
ipAddress?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginationOptions {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AuditService {
|
||||||
|
constructor(
|
||||||
|
private readonly auditLogRepository: Repository<AuditLog>,
|
||||||
|
private readonly entityChangeRepository: Repository<EntityChange>,
|
||||||
|
private readonly loginHistoryRepository: Repository<LoginHistory>,
|
||||||
|
private readonly sensitiveDataAccessRepository: Repository<SensitiveDataAccess>,
|
||||||
|
private readonly dataExportRepository: Repository<DataExport>,
|
||||||
|
private readonly permissionChangeRepository: Repository<PermissionChange>,
|
||||||
|
private readonly configChangeRepository: Repository<ConfigChange>
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// AUDIT LOGS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async createAuditLog(tenantId: string, data: Partial<AuditLog>): Promise<AuditLog> {
|
||||||
|
const log = this.auditLogRepository.create({
|
||||||
|
...data,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
return this.auditLogRepository.save(log);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAuditLogs(
|
||||||
|
tenantId: string,
|
||||||
|
filters: AuditLogFilters = {},
|
||||||
|
pagination: PaginationOptions = {}
|
||||||
|
): Promise<{ data: AuditLog[]; total: number }> {
|
||||||
|
const { page = 1, limit = 50 } = pagination;
|
||||||
|
const where: FindOptionsWhere<AuditLog> = { tenantId };
|
||||||
|
|
||||||
|
if (filters.userId) where.userId = filters.userId;
|
||||||
|
if (filters.entityType) where.entityType = filters.entityType;
|
||||||
|
if (filters.action) where.action = filters.action as any;
|
||||||
|
if (filters.category) where.category = filters.category as any;
|
||||||
|
if (filters.ipAddress) where.ipAddress = filters.ipAddress;
|
||||||
|
|
||||||
|
if (filters.startDate && filters.endDate) {
|
||||||
|
where.createdAt = Between(filters.startDate, filters.endDate);
|
||||||
|
} else if (filters.startDate) {
|
||||||
|
where.createdAt = MoreThanOrEqual(filters.startDate);
|
||||||
|
} else if (filters.endDate) {
|
||||||
|
where.createdAt = LessThanOrEqual(filters.endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [data, total] = await this.auditLogRepository.findAndCount({
|
||||||
|
where,
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { data, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAuditLogsByEntity(
|
||||||
|
tenantId: string,
|
||||||
|
entityType: string,
|
||||||
|
entityId: string
|
||||||
|
): Promise<AuditLog[]> {
|
||||||
|
return this.auditLogRepository.find({
|
||||||
|
where: { tenantId, entityType, entityId },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// ENTITY CHANGES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async createEntityChange(tenantId: string, data: Partial<EntityChange>): Promise<EntityChange> {
|
||||||
|
const change = this.entityChangeRepository.create({
|
||||||
|
...data,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
return this.entityChangeRepository.save(change);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findEntityChanges(
|
||||||
|
tenantId: string,
|
||||||
|
entityType: string,
|
||||||
|
entityId: string
|
||||||
|
): Promise<EntityChange[]> {
|
||||||
|
return this.entityChangeRepository.find({
|
||||||
|
where: { tenantId, entityType, entityId },
|
||||||
|
order: { changedAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEntityVersion(
|
||||||
|
tenantId: string,
|
||||||
|
entityType: string,
|
||||||
|
entityId: string,
|
||||||
|
version: number
|
||||||
|
): Promise<EntityChange | null> {
|
||||||
|
return this.entityChangeRepository.findOne({
|
||||||
|
where: { tenantId, entityType, entityId, version },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// LOGIN HISTORY
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async createLoginHistory(data: Partial<LoginHistory>): Promise<LoginHistory> {
|
||||||
|
const login = this.loginHistoryRepository.create(data);
|
||||||
|
return this.loginHistoryRepository.save(login);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findLoginHistory(
|
||||||
|
userId: string,
|
||||||
|
tenantId?: string,
|
||||||
|
limit: number = 20
|
||||||
|
): Promise<LoginHistory[]> {
|
||||||
|
const where: FindOptionsWhere<LoginHistory> = { userId };
|
||||||
|
if (tenantId) where.tenantId = tenantId;
|
||||||
|
|
||||||
|
return this.loginHistoryRepository.find({
|
||||||
|
where,
|
||||||
|
order: { loginAt: 'DESC' },
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getActiveSessionsCount(userId: string): Promise<number> {
|
||||||
|
return this.loginHistoryRepository.count({
|
||||||
|
where: { userId, logoutAt: undefined, status: 'success' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async markSessionLogout(sessionId: string): Promise<boolean> {
|
||||||
|
const result = await this.loginHistoryRepository.update(
|
||||||
|
{ sessionId },
|
||||||
|
{ logoutAt: new Date() }
|
||||||
|
);
|
||||||
|
return (result.affected ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// SENSITIVE DATA ACCESS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async logSensitiveDataAccess(
|
||||||
|
tenantId: string,
|
||||||
|
data: Partial<SensitiveDataAccess>
|
||||||
|
): Promise<SensitiveDataAccess> {
|
||||||
|
const access = this.sensitiveDataAccessRepository.create({
|
||||||
|
...data,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
return this.sensitiveDataAccessRepository.save(access);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findSensitiveDataAccess(
|
||||||
|
tenantId: string,
|
||||||
|
filters: { userId?: string; dataType?: string; startDate?: Date; endDate?: Date } = {}
|
||||||
|
): Promise<SensitiveDataAccess[]> {
|
||||||
|
const where: FindOptionsWhere<SensitiveDataAccess> = { tenantId };
|
||||||
|
|
||||||
|
if (filters.userId) where.userId = filters.userId;
|
||||||
|
if (filters.dataType) where.dataType = filters.dataType as any;
|
||||||
|
|
||||||
|
if (filters.startDate && filters.endDate) {
|
||||||
|
where.accessedAt = Between(filters.startDate, filters.endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.sensitiveDataAccessRepository.find({
|
||||||
|
where,
|
||||||
|
order: { accessedAt: 'DESC' },
|
||||||
|
take: 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// DATA EXPORTS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async createDataExport(tenantId: string, data: Partial<DataExport>): Promise<DataExport> {
|
||||||
|
const exportRecord = this.dataExportRepository.create({
|
||||||
|
...data,
|
||||||
|
tenantId,
|
||||||
|
status: 'pending',
|
||||||
|
});
|
||||||
|
return this.dataExportRepository.save(exportRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findDataExport(id: string): Promise<DataExport | null> {
|
||||||
|
return this.dataExportRepository.findOne({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findUserDataExports(tenantId: string, userId: string): Promise<DataExport[]> {
|
||||||
|
return this.dataExportRepository.find({
|
||||||
|
where: { tenantId, requestedBy: userId },
|
||||||
|
order: { requestedAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateDataExportStatus(
|
||||||
|
id: string,
|
||||||
|
status: string,
|
||||||
|
updates: Partial<DataExport> = {}
|
||||||
|
): Promise<DataExport | null> {
|
||||||
|
const exportRecord = await this.findDataExport(id);
|
||||||
|
if (!exportRecord) return null;
|
||||||
|
|
||||||
|
exportRecord.status = status as any;
|
||||||
|
Object.assign(exportRecord, updates);
|
||||||
|
|
||||||
|
if (status === 'completed') {
|
||||||
|
exportRecord.completedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.dataExportRepository.save(exportRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// PERMISSION CHANGES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async logPermissionChange(
|
||||||
|
tenantId: string,
|
||||||
|
data: Partial<PermissionChange>
|
||||||
|
): Promise<PermissionChange> {
|
||||||
|
const change = this.permissionChangeRepository.create({
|
||||||
|
...data,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
return this.permissionChangeRepository.save(change);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findPermissionChanges(
|
||||||
|
tenantId: string,
|
||||||
|
targetUserId?: string
|
||||||
|
): Promise<PermissionChange[]> {
|
||||||
|
const where: FindOptionsWhere<PermissionChange> = { tenantId };
|
||||||
|
if (targetUserId) where.targetUserId = targetUserId;
|
||||||
|
|
||||||
|
return this.permissionChangeRepository.find({
|
||||||
|
where,
|
||||||
|
order: { changedAt: 'DESC' },
|
||||||
|
take: 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// CONFIG CHANGES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async logConfigChange(tenantId: string, data: Partial<ConfigChange>): Promise<ConfigChange> {
|
||||||
|
const change = this.configChangeRepository.create({
|
||||||
|
...data,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
return this.configChangeRepository.save(change);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findConfigChanges(tenantId: string, configType?: string): Promise<ConfigChange[]> {
|
||||||
|
const where: FindOptionsWhere<ConfigChange> = { tenantId };
|
||||||
|
if (configType) where.configType = configType as any;
|
||||||
|
|
||||||
|
return this.configChangeRepository.find({
|
||||||
|
where,
|
||||||
|
order: { changedAt: 'DESC' },
|
||||||
|
take: 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getConfigVersion(
|
||||||
|
tenantId: string,
|
||||||
|
configKey: string,
|
||||||
|
version: number
|
||||||
|
): Promise<ConfigChange | null> {
|
||||||
|
return this.configChangeRepository.findOne({
|
||||||
|
where: { tenantId, configKey, version },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/modules/audit/services/index.ts
Normal file
1
src/modules/audit/services/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { AuditService, AuditLogFilters, PaginationOptions } from './audit.service';
|
||||||
60
src/modules/billing-usage/billing-usage.module.ts
Normal file
60
src/modules/billing-usage/billing-usage.module.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* Billing Usage Module
|
||||||
|
*
|
||||||
|
* Module registration for billing and usage tracking
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import {
|
||||||
|
SubscriptionPlansController,
|
||||||
|
SubscriptionsController,
|
||||||
|
UsageController,
|
||||||
|
InvoicesController,
|
||||||
|
} from './controllers';
|
||||||
|
|
||||||
|
export interface BillingUsageModuleOptions {
|
||||||
|
dataSource: DataSource;
|
||||||
|
basePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BillingUsageModule {
|
||||||
|
public router: Router;
|
||||||
|
private subscriptionPlansController: SubscriptionPlansController;
|
||||||
|
private subscriptionsController: SubscriptionsController;
|
||||||
|
private usageController: UsageController;
|
||||||
|
private invoicesController: InvoicesController;
|
||||||
|
|
||||||
|
constructor(options: BillingUsageModuleOptions) {
|
||||||
|
const { dataSource, basePath = '/billing' } = options;
|
||||||
|
|
||||||
|
this.router = Router();
|
||||||
|
|
||||||
|
// Initialize controllers
|
||||||
|
this.subscriptionPlansController = new SubscriptionPlansController(dataSource);
|
||||||
|
this.subscriptionsController = new SubscriptionsController(dataSource);
|
||||||
|
this.usageController = new UsageController(dataSource);
|
||||||
|
this.invoicesController = new InvoicesController(dataSource);
|
||||||
|
|
||||||
|
// Register routes
|
||||||
|
this.router.use(`${basePath}/subscription-plans`, this.subscriptionPlansController.router);
|
||||||
|
this.router.use(`${basePath}/subscriptions`, this.subscriptionsController.router);
|
||||||
|
this.router.use(`${basePath}/usage`, this.usageController.router);
|
||||||
|
this.router.use(`${basePath}/invoices`, this.invoicesController.router);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all entities for this module (for TypeORM configuration)
|
||||||
|
*/
|
||||||
|
static getEntities() {
|
||||||
|
return [
|
||||||
|
require('./entities/subscription-plan.entity').SubscriptionPlan,
|
||||||
|
require('./entities/tenant-subscription.entity').TenantSubscription,
|
||||||
|
require('./entities/usage-tracking.entity').UsageTracking,
|
||||||
|
require('./entities/invoice.entity').Invoice,
|
||||||
|
require('./entities/invoice-item.entity').InvoiceItem,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BillingUsageModule;
|
||||||
8
src/modules/billing-usage/controllers/index.ts
Normal file
8
src/modules/billing-usage/controllers/index.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Billing Usage Controllers Index
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { SubscriptionPlansController } from './subscription-plans.controller';
|
||||||
|
export { SubscriptionsController } from './subscriptions.controller';
|
||||||
|
export { UsageController } from './usage.controller';
|
||||||
|
export { InvoicesController } from './invoices.controller';
|
||||||
258
src/modules/billing-usage/controllers/invoices.controller.ts
Normal file
258
src/modules/billing-usage/controllers/invoices.controller.ts
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
/**
|
||||||
|
* Invoices Controller
|
||||||
|
*
|
||||||
|
* REST API endpoints for invoice management
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { InvoicesService } from '../services';
|
||||||
|
import {
|
||||||
|
CreateInvoiceDto,
|
||||||
|
UpdateInvoiceDto,
|
||||||
|
RecordPaymentDto,
|
||||||
|
VoidInvoiceDto,
|
||||||
|
RefundInvoiceDto,
|
||||||
|
GenerateInvoiceDto,
|
||||||
|
InvoiceFilterDto,
|
||||||
|
} from '../dto';
|
||||||
|
|
||||||
|
export class InvoicesController {
|
||||||
|
public router: Router;
|
||||||
|
private service: InvoicesService;
|
||||||
|
|
||||||
|
constructor(dataSource: DataSource) {
|
||||||
|
this.router = Router();
|
||||||
|
this.service = new InvoicesService(dataSource);
|
||||||
|
this.initializeRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeRoutes(): void {
|
||||||
|
// Stats
|
||||||
|
this.router.get('/stats', this.getStats.bind(this));
|
||||||
|
|
||||||
|
// List and search
|
||||||
|
this.router.get('/', this.getAll.bind(this));
|
||||||
|
this.router.get('/tenant/:tenantId', this.getByTenant.bind(this));
|
||||||
|
this.router.get('/:id', this.getById.bind(this));
|
||||||
|
this.router.get('/number/:invoiceNumber', this.getByNumber.bind(this));
|
||||||
|
|
||||||
|
// Create
|
||||||
|
this.router.post('/', this.create.bind(this));
|
||||||
|
this.router.post('/generate', this.generate.bind(this));
|
||||||
|
|
||||||
|
// Update
|
||||||
|
this.router.put('/:id', this.update.bind(this));
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
this.router.post('/:id/send', this.send.bind(this));
|
||||||
|
this.router.post('/:id/payment', this.recordPayment.bind(this));
|
||||||
|
this.router.post('/:id/void', this.void.bind(this));
|
||||||
|
this.router.post('/:id/refund', this.refund.bind(this));
|
||||||
|
|
||||||
|
// Batch operations
|
||||||
|
this.router.post('/mark-overdue', this.markOverdue.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /invoices/stats
|
||||||
|
* Get invoice statistics
|
||||||
|
*/
|
||||||
|
private async getStats(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { tenantId } = req.query;
|
||||||
|
const stats = await this.service.getStats(tenantId as string);
|
||||||
|
res.json({ data: stats });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /invoices
|
||||||
|
* Get all invoices with filters
|
||||||
|
*/
|
||||||
|
private async getAll(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const filter: InvoiceFilterDto = {
|
||||||
|
tenantId: req.query.tenantId as string,
|
||||||
|
status: req.query.status as any,
|
||||||
|
dateFrom: req.query.dateFrom ? new Date(req.query.dateFrom as string) : undefined,
|
||||||
|
dateTo: req.query.dateTo ? new Date(req.query.dateTo as string) : undefined,
|
||||||
|
overdue: req.query.overdue === 'true',
|
||||||
|
limit: req.query.limit ? parseInt(req.query.limit as string) : undefined,
|
||||||
|
offset: req.query.offset ? parseInt(req.query.offset as string) : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await this.service.findAll(filter);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /invoices/tenant/:tenantId
|
||||||
|
* Get invoices for specific tenant
|
||||||
|
*/
|
||||||
|
private async getByTenant(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const result = await this.service.findAll({
|
||||||
|
tenantId: req.params.tenantId,
|
||||||
|
limit: req.query.limit ? parseInt(req.query.limit as string) : 50,
|
||||||
|
offset: req.query.offset ? parseInt(req.query.offset as string) : 0,
|
||||||
|
});
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /invoices/:id
|
||||||
|
* Get invoice by ID
|
||||||
|
*/
|
||||||
|
private async getById(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const invoice = await this.service.findById(req.params.id);
|
||||||
|
|
||||||
|
if (!invoice) {
|
||||||
|
res.status(404).json({ error: 'Invoice not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: invoice });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /invoices/number/:invoiceNumber
|
||||||
|
* Get invoice by number
|
||||||
|
*/
|
||||||
|
private async getByNumber(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const invoice = await this.service.findByNumber(req.params.invoiceNumber);
|
||||||
|
|
||||||
|
if (!invoice) {
|
||||||
|
res.status(404).json({ error: 'Invoice not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: invoice });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /invoices
|
||||||
|
* Create invoice manually
|
||||||
|
*/
|
||||||
|
private async create(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const dto: CreateInvoiceDto = req.body;
|
||||||
|
const invoice = await this.service.create(dto);
|
||||||
|
res.status(201).json({ data: invoice });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /invoices/generate
|
||||||
|
* Generate invoice from subscription
|
||||||
|
*/
|
||||||
|
private async generate(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const dto: GenerateInvoiceDto = req.body;
|
||||||
|
const invoice = await this.service.generateFromSubscription(dto);
|
||||||
|
res.status(201).json({ data: invoice });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /invoices/:id
|
||||||
|
* Update invoice
|
||||||
|
*/
|
||||||
|
private async update(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const dto: UpdateInvoiceDto = req.body;
|
||||||
|
const invoice = await this.service.update(req.params.id, dto);
|
||||||
|
res.json({ data: invoice });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /invoices/:id/send
|
||||||
|
* Send invoice to customer
|
||||||
|
*/
|
||||||
|
private async send(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const invoice = await this.service.send(req.params.id);
|
||||||
|
res.json({ data: invoice });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /invoices/:id/payment
|
||||||
|
* Record payment on invoice
|
||||||
|
*/
|
||||||
|
private async recordPayment(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const dto: RecordPaymentDto = req.body;
|
||||||
|
const invoice = await this.service.recordPayment(req.params.id, dto);
|
||||||
|
res.json({ data: invoice });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /invoices/:id/void
|
||||||
|
* Void an invoice
|
||||||
|
*/
|
||||||
|
private async void(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const dto: VoidInvoiceDto = req.body;
|
||||||
|
const invoice = await this.service.void(req.params.id, dto);
|
||||||
|
res.json({ data: invoice });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /invoices/:id/refund
|
||||||
|
* Refund an invoice
|
||||||
|
*/
|
||||||
|
private async refund(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const dto: RefundInvoiceDto = req.body;
|
||||||
|
const invoice = await this.service.refund(req.params.id, dto);
|
||||||
|
res.json({ data: invoice });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /invoices/mark-overdue
|
||||||
|
* Mark all overdue invoices (scheduled job endpoint)
|
||||||
|
*/
|
||||||
|
private async markOverdue(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const count = await this.service.markOverdueInvoices();
|
||||||
|
res.json({ data: { markedOverdue: count } });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,168 @@
|
|||||||
|
/**
|
||||||
|
* Subscription Plans Controller
|
||||||
|
*
|
||||||
|
* REST API endpoints for subscription plan management
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { SubscriptionPlansService } from '../services';
|
||||||
|
import { CreateSubscriptionPlanDto, UpdateSubscriptionPlanDto } from '../dto';
|
||||||
|
|
||||||
|
export class SubscriptionPlansController {
|
||||||
|
public router: Router;
|
||||||
|
private service: SubscriptionPlansService;
|
||||||
|
|
||||||
|
constructor(dataSource: DataSource) {
|
||||||
|
this.router = Router();
|
||||||
|
this.service = new SubscriptionPlansService(dataSource);
|
||||||
|
this.initializeRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeRoutes(): void {
|
||||||
|
// Public routes
|
||||||
|
this.router.get('/public', this.getPublicPlans.bind(this));
|
||||||
|
this.router.get('/:id/compare/:otherId', this.comparePlans.bind(this));
|
||||||
|
|
||||||
|
// Protected routes (require admin)
|
||||||
|
this.router.get('/', this.getAll.bind(this));
|
||||||
|
this.router.get('/:id', this.getById.bind(this));
|
||||||
|
this.router.post('/', this.create.bind(this));
|
||||||
|
this.router.put('/:id', this.update.bind(this));
|
||||||
|
this.router.delete('/:id', this.delete.bind(this));
|
||||||
|
this.router.patch('/:id/activate', this.activate.bind(this));
|
||||||
|
this.router.patch('/:id/deactivate', this.deactivate.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /subscription-plans/public
|
||||||
|
* Get public plans for pricing page
|
||||||
|
*/
|
||||||
|
private async getPublicPlans(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const plans = await this.service.findPublicPlans();
|
||||||
|
res.json({ data: plans });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /subscription-plans
|
||||||
|
* Get all plans (admin only)
|
||||||
|
*/
|
||||||
|
private async getAll(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { isActive, isPublic, planType } = req.query;
|
||||||
|
|
||||||
|
const plans = await this.service.findAll({
|
||||||
|
isActive: isActive !== undefined ? isActive === 'true' : undefined,
|
||||||
|
isPublic: isPublic !== undefined ? isPublic === 'true' : undefined,
|
||||||
|
planType: planType as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ data: plans });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /subscription-plans/:id
|
||||||
|
* Get plan by ID
|
||||||
|
*/
|
||||||
|
private async getById(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const plan = await this.service.findById(req.params.id);
|
||||||
|
|
||||||
|
if (!plan) {
|
||||||
|
res.status(404).json({ error: 'Plan not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: plan });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /subscription-plans
|
||||||
|
* Create new plan
|
||||||
|
*/
|
||||||
|
private async create(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const dto: CreateSubscriptionPlanDto = req.body;
|
||||||
|
const plan = await this.service.create(dto);
|
||||||
|
res.status(201).json({ data: plan });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /subscription-plans/:id
|
||||||
|
* Update plan
|
||||||
|
*/
|
||||||
|
private async update(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const dto: UpdateSubscriptionPlanDto = req.body;
|
||||||
|
const plan = await this.service.update(req.params.id, dto);
|
||||||
|
res.json({ data: plan });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /subscription-plans/:id
|
||||||
|
* Delete plan (soft delete)
|
||||||
|
*/
|
||||||
|
private async delete(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.service.delete(req.params.id);
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /subscription-plans/:id/activate
|
||||||
|
* Activate plan
|
||||||
|
*/
|
||||||
|
private async activate(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const plan = await this.service.setActive(req.params.id, true);
|
||||||
|
res.json({ data: plan });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /subscription-plans/:id/deactivate
|
||||||
|
* Deactivate plan
|
||||||
|
*/
|
||||||
|
private async deactivate(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const plan = await this.service.setActive(req.params.id, false);
|
||||||
|
res.json({ data: plan });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /subscription-plans/:id/compare/:otherId
|
||||||
|
* Compare two plans
|
||||||
|
*/
|
||||||
|
private async comparePlans(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const comparison = await this.service.comparePlans(req.params.id, req.params.otherId);
|
||||||
|
res.json({ data: comparison });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,232 @@
|
|||||||
|
/**
|
||||||
|
* Subscriptions Controller
|
||||||
|
*
|
||||||
|
* REST API endpoints for tenant subscription management
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { SubscriptionsService } from '../services';
|
||||||
|
import {
|
||||||
|
CreateTenantSubscriptionDto,
|
||||||
|
UpdateTenantSubscriptionDto,
|
||||||
|
CancelSubscriptionDto,
|
||||||
|
ChangePlanDto,
|
||||||
|
SetPaymentMethodDto,
|
||||||
|
} from '../dto';
|
||||||
|
|
||||||
|
export class SubscriptionsController {
|
||||||
|
public router: Router;
|
||||||
|
private service: SubscriptionsService;
|
||||||
|
|
||||||
|
constructor(dataSource: DataSource) {
|
||||||
|
this.router = Router();
|
||||||
|
this.service = new SubscriptionsService(dataSource);
|
||||||
|
this.initializeRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeRoutes(): void {
|
||||||
|
// Stats (admin)
|
||||||
|
this.router.get('/stats', this.getStats.bind(this));
|
||||||
|
|
||||||
|
// Tenant subscription
|
||||||
|
this.router.get('/tenant/:tenantId', this.getByTenant.bind(this));
|
||||||
|
this.router.post('/', this.create.bind(this));
|
||||||
|
this.router.put('/:id', this.update.bind(this));
|
||||||
|
|
||||||
|
// Subscription actions
|
||||||
|
this.router.post('/:id/cancel', this.cancel.bind(this));
|
||||||
|
this.router.post('/:id/reactivate', this.reactivate.bind(this));
|
||||||
|
this.router.post('/:id/change-plan', this.changePlan.bind(this));
|
||||||
|
this.router.post('/:id/payment-method', this.setPaymentMethod.bind(this));
|
||||||
|
this.router.post('/:id/renew', this.renew.bind(this));
|
||||||
|
this.router.post('/:id/suspend', this.suspend.bind(this));
|
||||||
|
this.router.post('/:id/activate', this.activate.bind(this));
|
||||||
|
|
||||||
|
// Alerts/expiring
|
||||||
|
this.router.get('/expiring', this.getExpiring.bind(this));
|
||||||
|
this.router.get('/trials-ending', this.getTrialsEnding.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /subscriptions/stats
|
||||||
|
* Get subscription statistics
|
||||||
|
*/
|
||||||
|
private async getStats(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const stats = await this.service.getStats();
|
||||||
|
res.json({ data: stats });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /subscriptions/tenant/:tenantId
|
||||||
|
* Get subscription by tenant ID
|
||||||
|
*/
|
||||||
|
private async getByTenant(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const subscription = await this.service.findByTenantId(req.params.tenantId);
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
res.status(404).json({ error: 'Subscription not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: subscription });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /subscriptions
|
||||||
|
* Create new subscription
|
||||||
|
*/
|
||||||
|
private async create(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const dto: CreateTenantSubscriptionDto = req.body;
|
||||||
|
const subscription = await this.service.create(dto);
|
||||||
|
res.status(201).json({ data: subscription });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /subscriptions/:id
|
||||||
|
* Update subscription
|
||||||
|
*/
|
||||||
|
private async update(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const dto: UpdateTenantSubscriptionDto = req.body;
|
||||||
|
const subscription = await this.service.update(req.params.id, dto);
|
||||||
|
res.json({ data: subscription });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /subscriptions/:id/cancel
|
||||||
|
* Cancel subscription
|
||||||
|
*/
|
||||||
|
private async cancel(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const dto: CancelSubscriptionDto = req.body;
|
||||||
|
const subscription = await this.service.cancel(req.params.id, dto);
|
||||||
|
res.json({ data: subscription });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /subscriptions/:id/reactivate
|
||||||
|
* Reactivate cancelled subscription
|
||||||
|
*/
|
||||||
|
private async reactivate(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const subscription = await this.service.reactivate(req.params.id);
|
||||||
|
res.json({ data: subscription });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /subscriptions/:id/change-plan
|
||||||
|
* Change subscription plan
|
||||||
|
*/
|
||||||
|
private async changePlan(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const dto: ChangePlanDto = req.body;
|
||||||
|
const subscription = await this.service.changePlan(req.params.id, dto);
|
||||||
|
res.json({ data: subscription });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /subscriptions/:id/payment-method
|
||||||
|
* Set payment method
|
||||||
|
*/
|
||||||
|
private async setPaymentMethod(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const dto: SetPaymentMethodDto = req.body;
|
||||||
|
const subscription = await this.service.setPaymentMethod(req.params.id, dto);
|
||||||
|
res.json({ data: subscription });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /subscriptions/:id/renew
|
||||||
|
* Renew subscription
|
||||||
|
*/
|
||||||
|
private async renew(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const subscription = await this.service.renew(req.params.id);
|
||||||
|
res.json({ data: subscription });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /subscriptions/:id/suspend
|
||||||
|
* Suspend subscription
|
||||||
|
*/
|
||||||
|
private async suspend(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const subscription = await this.service.suspend(req.params.id);
|
||||||
|
res.json({ data: subscription });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /subscriptions/:id/activate
|
||||||
|
* Activate subscription
|
||||||
|
*/
|
||||||
|
private async activate(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const subscription = await this.service.activate(req.params.id);
|
||||||
|
res.json({ data: subscription });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /subscriptions/expiring
|
||||||
|
* Get subscriptions expiring soon
|
||||||
|
*/
|
||||||
|
private async getExpiring(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const days = parseInt(req.query.days as string) || 7;
|
||||||
|
const subscriptions = await this.service.findExpiringSoon(days);
|
||||||
|
res.json({ data: subscriptions });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /subscriptions/trials-ending
|
||||||
|
* Get trials ending soon
|
||||||
|
*/
|
||||||
|
private async getTrialsEnding(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const days = parseInt(req.query.days as string) || 3;
|
||||||
|
const subscriptions = await this.service.findTrialsEndingSoon(days);
|
||||||
|
res.json({ data: subscriptions });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
173
src/modules/billing-usage/controllers/usage.controller.ts
Normal file
173
src/modules/billing-usage/controllers/usage.controller.ts
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* Usage Controller
|
||||||
|
*
|
||||||
|
* REST API endpoints for usage tracking
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { UsageTrackingService } from '../services';
|
||||||
|
import { RecordUsageDto, UpdateUsageDto, IncrementUsageDto, UsageMetrics } from '../dto';
|
||||||
|
|
||||||
|
export class UsageController {
|
||||||
|
public router: Router;
|
||||||
|
private service: UsageTrackingService;
|
||||||
|
|
||||||
|
constructor(dataSource: DataSource) {
|
||||||
|
this.router = Router();
|
||||||
|
this.service = new UsageTrackingService(dataSource);
|
||||||
|
this.initializeRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeRoutes(): void {
|
||||||
|
// Current usage
|
||||||
|
this.router.get('/tenant/:tenantId/current', this.getCurrentUsage.bind(this));
|
||||||
|
this.router.get('/tenant/:tenantId/summary', this.getUsageSummary.bind(this));
|
||||||
|
this.router.get('/tenant/:tenantId/limits', this.checkLimits.bind(this));
|
||||||
|
|
||||||
|
// Usage history
|
||||||
|
this.router.get('/tenant/:tenantId/history', this.getUsageHistory.bind(this));
|
||||||
|
this.router.get('/tenant/:tenantId/report', this.getUsageReport.bind(this));
|
||||||
|
|
||||||
|
// Record usage
|
||||||
|
this.router.post('/', this.recordUsage.bind(this));
|
||||||
|
this.router.put('/:id', this.updateUsage.bind(this));
|
||||||
|
this.router.post('/increment', this.incrementMetric.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /usage/tenant/:tenantId/current
|
||||||
|
* Get current usage for tenant
|
||||||
|
*/
|
||||||
|
private async getCurrentUsage(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const usage = await this.service.getCurrentUsage(req.params.tenantId);
|
||||||
|
res.json({ data: usage });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /usage/tenant/:tenantId/summary
|
||||||
|
* Get usage summary with limits
|
||||||
|
*/
|
||||||
|
private async getUsageSummary(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const summary = await this.service.getUsageSummary(req.params.tenantId);
|
||||||
|
res.json({ data: summary });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /usage/tenant/:tenantId/limits
|
||||||
|
* Check if tenant exceeds limits
|
||||||
|
*/
|
||||||
|
private async checkLimits(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const limits = await this.service.checkLimits(req.params.tenantId);
|
||||||
|
res.json({ data: limits });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /usage/tenant/:tenantId/history
|
||||||
|
* Get usage history
|
||||||
|
*/
|
||||||
|
private async getUsageHistory(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { startDate, endDate } = req.query;
|
||||||
|
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
res.status(400).json({ error: 'startDate and endDate are required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = await this.service.getUsageHistory(
|
||||||
|
req.params.tenantId,
|
||||||
|
new Date(startDate as string),
|
||||||
|
new Date(endDate as string)
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ data: history });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /usage/tenant/:tenantId/report
|
||||||
|
* Get usage report
|
||||||
|
*/
|
||||||
|
private async getUsageReport(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { startDate, endDate, granularity } = req.query;
|
||||||
|
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
res.status(400).json({ error: 'startDate and endDate are required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = await this.service.getUsageReport(
|
||||||
|
req.params.tenantId,
|
||||||
|
new Date(startDate as string),
|
||||||
|
new Date(endDate as string),
|
||||||
|
(granularity as 'daily' | 'weekly' | 'monthly') || 'monthly'
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ data: report });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /usage
|
||||||
|
* Record usage for period
|
||||||
|
*/
|
||||||
|
private async recordUsage(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const dto: RecordUsageDto = req.body;
|
||||||
|
const usage = await this.service.recordUsage(dto);
|
||||||
|
res.status(201).json({ data: usage });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /usage/:id
|
||||||
|
* Update usage record
|
||||||
|
*/
|
||||||
|
private async updateUsage(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const dto: UpdateUsageDto = req.body;
|
||||||
|
const usage = await this.service.update(req.params.id, dto);
|
||||||
|
res.json({ data: usage });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /usage/increment
|
||||||
|
* Increment a specific metric
|
||||||
|
*/
|
||||||
|
private async incrementMetric(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const dto: IncrementUsageDto = req.body;
|
||||||
|
await this.service.incrementMetric(
|
||||||
|
dto.tenantId,
|
||||||
|
dto.metric as keyof UsageMetrics,
|
||||||
|
dto.amount || 1
|
||||||
|
);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/modules/billing-usage/dto/create-invoice.dto.ts
Normal file
75
src/modules/billing-usage/dto/create-invoice.dto.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* Create Invoice DTO
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { InvoiceStatus, InvoiceItemType } from '../entities';
|
||||||
|
|
||||||
|
export class CreateInvoiceDto {
|
||||||
|
tenantId: string;
|
||||||
|
subscriptionId?: string;
|
||||||
|
invoiceDate?: Date;
|
||||||
|
periodStart: Date;
|
||||||
|
periodEnd: Date;
|
||||||
|
billingName?: string;
|
||||||
|
billingEmail?: string;
|
||||||
|
billingAddress?: Record<string, any>;
|
||||||
|
taxId?: string;
|
||||||
|
dueDate: Date;
|
||||||
|
currency?: string;
|
||||||
|
notes?: string;
|
||||||
|
internalNotes?: string;
|
||||||
|
items: CreateInvoiceItemDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateInvoiceItemDto {
|
||||||
|
itemType: InvoiceItemType;
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
unitPrice: number;
|
||||||
|
discountPercent?: number;
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateInvoiceDto {
|
||||||
|
billingName?: string;
|
||||||
|
billingEmail?: string;
|
||||||
|
billingAddress?: Record<string, any>;
|
||||||
|
taxId?: string;
|
||||||
|
dueDate?: Date;
|
||||||
|
notes?: string;
|
||||||
|
internalNotes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RecordPaymentDto {
|
||||||
|
amount: number;
|
||||||
|
paymentMethod: string;
|
||||||
|
paymentReference?: string;
|
||||||
|
paymentDate?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VoidInvoiceDto {
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RefundInvoiceDto {
|
||||||
|
amount?: number;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GenerateInvoiceDto {
|
||||||
|
tenantId: string;
|
||||||
|
subscriptionId: string;
|
||||||
|
periodStart: Date;
|
||||||
|
periodEnd: Date;
|
||||||
|
includeUsageCharges?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InvoiceFilterDto {
|
||||||
|
tenantId?: string;
|
||||||
|
status?: InvoiceStatus;
|
||||||
|
dateFrom?: Date;
|
||||||
|
dateTo?: Date;
|
||||||
|
overdue?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Create Subscription Plan DTO
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PlanType } from '../entities';
|
||||||
|
|
||||||
|
export class CreateSubscriptionPlanDto {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
planType?: PlanType;
|
||||||
|
baseMonthlyPrice: number;
|
||||||
|
baseAnnualPrice?: number;
|
||||||
|
setupFee?: number;
|
||||||
|
maxUsers?: number;
|
||||||
|
maxBranches?: number;
|
||||||
|
storageGb?: number;
|
||||||
|
apiCallsMonthly?: number;
|
||||||
|
includedModules?: string[];
|
||||||
|
includedPlatforms?: string[];
|
||||||
|
features?: Record<string, boolean>;
|
||||||
|
isActive?: boolean;
|
||||||
|
isPublic?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateSubscriptionPlanDto {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
baseMonthlyPrice?: number;
|
||||||
|
baseAnnualPrice?: number;
|
||||||
|
setupFee?: number;
|
||||||
|
maxUsers?: number;
|
||||||
|
maxBranches?: number;
|
||||||
|
storageGb?: number;
|
||||||
|
apiCallsMonthly?: number;
|
||||||
|
includedModules?: string[];
|
||||||
|
includedPlatforms?: string[];
|
||||||
|
features?: Record<string, boolean>;
|
||||||
|
isActive?: boolean;
|
||||||
|
isPublic?: boolean;
|
||||||
|
}
|
||||||
57
src/modules/billing-usage/dto/create-subscription.dto.ts
Normal file
57
src/modules/billing-usage/dto/create-subscription.dto.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* Create Tenant Subscription DTO
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BillingCycle, SubscriptionStatus } from '../entities';
|
||||||
|
|
||||||
|
export class CreateTenantSubscriptionDto {
|
||||||
|
tenantId: string;
|
||||||
|
planId: string;
|
||||||
|
billingCycle?: BillingCycle;
|
||||||
|
currentPeriodStart?: Date;
|
||||||
|
currentPeriodEnd?: Date;
|
||||||
|
billingEmail?: string;
|
||||||
|
billingName?: string;
|
||||||
|
billingAddress?: Record<string, any>;
|
||||||
|
taxId?: string;
|
||||||
|
currentPrice: number;
|
||||||
|
discountPercent?: number;
|
||||||
|
discountReason?: string;
|
||||||
|
contractedUsers?: number;
|
||||||
|
contractedBranches?: number;
|
||||||
|
autoRenew?: boolean;
|
||||||
|
// Trial
|
||||||
|
startWithTrial?: boolean;
|
||||||
|
trialDays?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateTenantSubscriptionDto {
|
||||||
|
planId?: string;
|
||||||
|
billingCycle?: BillingCycle;
|
||||||
|
billingEmail?: string;
|
||||||
|
billingName?: string;
|
||||||
|
billingAddress?: Record<string, any>;
|
||||||
|
taxId?: string;
|
||||||
|
currentPrice?: number;
|
||||||
|
discountPercent?: number;
|
||||||
|
discountReason?: string;
|
||||||
|
contractedUsers?: number;
|
||||||
|
contractedBranches?: number;
|
||||||
|
autoRenew?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CancelSubscriptionDto {
|
||||||
|
reason?: string;
|
||||||
|
cancelImmediately?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ChangePlanDto {
|
||||||
|
newPlanId: string;
|
||||||
|
effectiveDate?: Date;
|
||||||
|
prorateBilling?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SetPaymentMethodDto {
|
||||||
|
paymentMethodId: string;
|
||||||
|
paymentProvider: string;
|
||||||
|
}
|
||||||
8
src/modules/billing-usage/dto/index.ts
Normal file
8
src/modules/billing-usage/dto/index.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Billing Usage DTOs Index
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './create-subscription-plan.dto';
|
||||||
|
export * from './create-subscription.dto';
|
||||||
|
export * from './create-invoice.dto';
|
||||||
|
export * from './usage-tracking.dto';
|
||||||
90
src/modules/billing-usage/dto/usage-tracking.dto.ts
Normal file
90
src/modules/billing-usage/dto/usage-tracking.dto.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* Usage Tracking DTO
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class RecordUsageDto {
|
||||||
|
tenantId: string;
|
||||||
|
periodStart: Date;
|
||||||
|
periodEnd: Date;
|
||||||
|
activeUsers?: number;
|
||||||
|
peakConcurrentUsers?: number;
|
||||||
|
usersByProfile?: Record<string, number>;
|
||||||
|
usersByPlatform?: Record<string, number>;
|
||||||
|
activeBranches?: number;
|
||||||
|
storageUsedGb?: number;
|
||||||
|
documentsCount?: number;
|
||||||
|
apiCalls?: number;
|
||||||
|
apiErrors?: number;
|
||||||
|
salesCount?: number;
|
||||||
|
salesAmount?: number;
|
||||||
|
invoicesGenerated?: number;
|
||||||
|
mobileSessions?: number;
|
||||||
|
offlineSyncs?: number;
|
||||||
|
paymentTransactions?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateUsageDto {
|
||||||
|
activeUsers?: number;
|
||||||
|
peakConcurrentUsers?: number;
|
||||||
|
usersByProfile?: Record<string, number>;
|
||||||
|
usersByPlatform?: Record<string, number>;
|
||||||
|
activeBranches?: number;
|
||||||
|
storageUsedGb?: number;
|
||||||
|
documentsCount?: number;
|
||||||
|
apiCalls?: number;
|
||||||
|
apiErrors?: number;
|
||||||
|
salesCount?: number;
|
||||||
|
salesAmount?: number;
|
||||||
|
invoicesGenerated?: number;
|
||||||
|
mobileSessions?: number;
|
||||||
|
offlineSyncs?: number;
|
||||||
|
paymentTransactions?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IncrementUsageDto {
|
||||||
|
tenantId: string;
|
||||||
|
metric: keyof UsageMetrics;
|
||||||
|
amount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageMetrics {
|
||||||
|
apiCalls: number;
|
||||||
|
apiErrors: number;
|
||||||
|
salesCount: number;
|
||||||
|
salesAmount: number;
|
||||||
|
invoicesGenerated: number;
|
||||||
|
mobileSessions: number;
|
||||||
|
offlineSyncs: number;
|
||||||
|
paymentTransactions: number;
|
||||||
|
documentsCount: number;
|
||||||
|
storageUsedGb: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UsageReportDto {
|
||||||
|
tenantId: string;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
granularity?: 'daily' | 'weekly' | 'monthly';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UsageSummaryDto {
|
||||||
|
tenantId: string;
|
||||||
|
currentUsers: number;
|
||||||
|
currentBranches: number;
|
||||||
|
currentStorageGb: number;
|
||||||
|
apiCallsThisMonth: number;
|
||||||
|
salesThisMonth: number;
|
||||||
|
salesAmountThisMonth: number;
|
||||||
|
limits: {
|
||||||
|
maxUsers: number;
|
||||||
|
maxBranches: number;
|
||||||
|
maxStorageGb: number;
|
||||||
|
maxApiCalls: number;
|
||||||
|
};
|
||||||
|
percentages: {
|
||||||
|
usersUsed: number;
|
||||||
|
branchesUsed: number;
|
||||||
|
storageUsed: number;
|
||||||
|
apiCallsUsed: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
72
src/modules/billing-usage/entities/billing-alert.entity.ts
Normal file
72
src/modules/billing-usage/entities/billing-alert.entity.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export type BillingAlertType =
|
||||||
|
| 'usage_limit'
|
||||||
|
| 'payment_due'
|
||||||
|
| 'payment_failed'
|
||||||
|
| 'trial_ending'
|
||||||
|
| 'subscription_ending';
|
||||||
|
|
||||||
|
export type AlertSeverity = 'info' | 'warning' | 'critical';
|
||||||
|
export type AlertStatus = 'active' | 'acknowledged' | 'resolved';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entidad para alertas de facturacion y limites de uso.
|
||||||
|
* Mapea a billing.billing_alerts (DDL: 05-billing-usage.sql)
|
||||||
|
*/
|
||||||
|
@Entity({ name: 'billing_alerts', schema: 'billing' })
|
||||||
|
export class BillingAlert {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
// Tipo de alerta
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'alert_type', type: 'varchar', length: 30 })
|
||||||
|
alertType: BillingAlertType;
|
||||||
|
|
||||||
|
// Detalles
|
||||||
|
@Column({ type: 'varchar', length: 200 })
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
message: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, default: 'info' })
|
||||||
|
severity: AlertSeverity;
|
||||||
|
|
||||||
|
// Estado
|
||||||
|
@Index()
|
||||||
|
@Column({ type: 'varchar', length: 20, default: 'active' })
|
||||||
|
status: AlertStatus;
|
||||||
|
|
||||||
|
// Notificacion
|
||||||
|
@Column({ name: 'notified_at', type: 'timestamptz', nullable: true })
|
||||||
|
notifiedAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'acknowledged_at', type: 'timestamptz', nullable: true })
|
||||||
|
acknowledgedAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'acknowledged_by', type: 'uuid', nullable: true })
|
||||||
|
acknowledgedBy: string;
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
@Column({ type: 'jsonb', default: {} })
|
||||||
|
metadata: Record<string, any>;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
8
src/modules/billing-usage/entities/index.ts
Normal file
8
src/modules/billing-usage/entities/index.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export { SubscriptionPlan, PlanType } from './subscription-plan.entity';
|
||||||
|
export { TenantSubscription, BillingCycle, SubscriptionStatus } from './tenant-subscription.entity';
|
||||||
|
export { UsageTracking } from './usage-tracking.entity';
|
||||||
|
export { UsageEvent, EventCategory } from './usage-event.entity';
|
||||||
|
export { Invoice, InvoiceStatus } from './invoice.entity';
|
||||||
|
export { InvoiceItem, InvoiceItemType } from './invoice-item.entity';
|
||||||
|
export { BillingPaymentMethod, PaymentProvider, PaymentMethodType } from './payment-method.entity';
|
||||||
|
export { BillingAlert, BillingAlertType, AlertSeverity, AlertStatus } from './billing-alert.entity';
|
||||||
65
src/modules/billing-usage/entities/invoice-item.entity.ts
Normal file
65
src/modules/billing-usage/entities/invoice-item.entity.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Invoice } from './invoice.entity';
|
||||||
|
|
||||||
|
export type InvoiceItemType = 'subscription' | 'user' | 'profile' | 'overage' | 'addon';
|
||||||
|
|
||||||
|
@Entity({ name: 'invoice_items', schema: 'billing' })
|
||||||
|
export class InvoiceItem {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'invoice_id', type: 'uuid' })
|
||||||
|
invoiceId: string;
|
||||||
|
|
||||||
|
// Descripcion
|
||||||
|
@Column({ type: 'varchar', length: 500 })
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'item_type', type: 'varchar', length: 30 })
|
||||||
|
itemType: InvoiceItemType;
|
||||||
|
|
||||||
|
// Cantidades
|
||||||
|
@Column({ type: 'integer', default: 1 })
|
||||||
|
quantity: number;
|
||||||
|
|
||||||
|
@Column({ name: 'unit_price', type: 'decimal', precision: 12, scale: 2 })
|
||||||
|
unitPrice: number;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 12, scale: 2 })
|
||||||
|
subtotal: number;
|
||||||
|
|
||||||
|
// Detalles adicionales
|
||||||
|
@Column({ name: 'profile_code', type: 'varchar', length: 10, nullable: true })
|
||||||
|
profileCode: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, nullable: true })
|
||||||
|
platform: string;
|
||||||
|
|
||||||
|
@Column({ name: 'period_start', type: 'date', nullable: true })
|
||||||
|
periodStart: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'period_end', type: 'date', nullable: true })
|
||||||
|
periodEnd: Date;
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
@Column({ type: 'jsonb', default: {} })
|
||||||
|
metadata: Record<string, any>;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
// Relaciones
|
||||||
|
@ManyToOne(() => Invoice, (invoice) => invoice.items, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'invoice_id' })
|
||||||
|
invoice: Invoice;
|
||||||
|
}
|
||||||
121
src/modules/billing-usage/entities/invoice.entity.ts
Normal file
121
src/modules/billing-usage/entities/invoice.entity.ts
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
OneToMany,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { InvoiceItem } from './invoice-item.entity';
|
||||||
|
|
||||||
|
export type InvoiceStatus = 'draft' | 'sent' | 'paid' | 'partial' | 'overdue' | 'void' | 'refunded';
|
||||||
|
|
||||||
|
@Entity({ name: 'invoices', schema: 'billing' })
|
||||||
|
export class Invoice {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'subscription_id', type: 'uuid', nullable: true })
|
||||||
|
subscriptionId: string;
|
||||||
|
|
||||||
|
// Numero de factura
|
||||||
|
@Index({ unique: true })
|
||||||
|
@Column({ name: 'invoice_number', type: 'varchar', length: 30 })
|
||||||
|
invoiceNumber: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'invoice_date', type: 'date' })
|
||||||
|
invoiceDate: Date;
|
||||||
|
|
||||||
|
// Periodo facturado
|
||||||
|
@Column({ name: 'period_start', type: 'date' })
|
||||||
|
periodStart: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'period_end', type: 'date' })
|
||||||
|
periodEnd: Date;
|
||||||
|
|
||||||
|
// Cliente
|
||||||
|
@Column({ name: 'billing_name', type: 'varchar', length: 200, nullable: true })
|
||||||
|
billingName: string;
|
||||||
|
|
||||||
|
@Column({ name: 'billing_email', type: 'varchar', length: 255, nullable: true })
|
||||||
|
billingEmail: string;
|
||||||
|
|
||||||
|
@Column({ name: 'billing_address', type: 'jsonb', default: {} })
|
||||||
|
billingAddress: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({ name: 'tax_id', type: 'varchar', length: 20, nullable: true })
|
||||||
|
taxId: string;
|
||||||
|
|
||||||
|
// Montos
|
||||||
|
@Column({ type: 'decimal', precision: 12, scale: 2 })
|
||||||
|
subtotal: number;
|
||||||
|
|
||||||
|
@Column({ name: 'tax_amount', type: 'decimal', precision: 12, scale: 2, default: 0 })
|
||||||
|
taxAmount: number;
|
||||||
|
|
||||||
|
@Column({ name: 'discount_amount', type: 'decimal', precision: 12, scale: 2, default: 0 })
|
||||||
|
discountAmount: number;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 12, scale: 2 })
|
||||||
|
total: number;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 3, default: 'MXN' })
|
||||||
|
currency: string;
|
||||||
|
|
||||||
|
// Estado
|
||||||
|
@Index()
|
||||||
|
@Column({ type: 'varchar', length: 20, default: 'draft' })
|
||||||
|
status: InvoiceStatus;
|
||||||
|
|
||||||
|
// Fechas de pago
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'due_date', type: 'date' })
|
||||||
|
dueDate: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'paid_at', type: 'timestamptz', nullable: true })
|
||||||
|
paidAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'paid_amount', type: 'decimal', precision: 12, scale: 2, default: 0 })
|
||||||
|
paidAmount: number;
|
||||||
|
|
||||||
|
// Detalles de pago
|
||||||
|
@Column({ name: 'payment_method', type: 'varchar', length: 30, nullable: true })
|
||||||
|
paymentMethod: string;
|
||||||
|
|
||||||
|
@Column({ name: 'payment_reference', type: 'varchar', length: 100, nullable: true })
|
||||||
|
paymentReference: string;
|
||||||
|
|
||||||
|
// CFDI (para Mexico)
|
||||||
|
@Column({ name: 'cfdi_uuid', type: 'varchar', length: 36, nullable: true })
|
||||||
|
cfdiUuid: string;
|
||||||
|
|
||||||
|
@Column({ name: 'cfdi_xml', type: 'text', nullable: true })
|
||||||
|
cfdiXml: string;
|
||||||
|
|
||||||
|
@Column({ name: 'cfdi_pdf_url', type: 'text', nullable: true })
|
||||||
|
cfdiPdfUrl: string;
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
notes: string;
|
||||||
|
|
||||||
|
@Column({ name: 'internal_notes', type: 'text', nullable: true })
|
||||||
|
internalNotes: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
// Relaciones
|
||||||
|
@OneToMany(() => InvoiceItem, (item) => item.invoice, { cascade: true })
|
||||||
|
items: InvoiceItem[];
|
||||||
|
}
|
||||||
85
src/modules/billing-usage/entities/payment-method.entity.ts
Normal file
85
src/modules/billing-usage/entities/payment-method.entity.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
DeleteDateColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export type PaymentProvider = 'stripe' | 'mercadopago' | 'bank_transfer';
|
||||||
|
export type PaymentMethodType = 'card' | 'bank_account' | 'wallet';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entidad para metodos de pago guardados por tenant.
|
||||||
|
* Almacena informacion tokenizada/encriptada de metodos de pago.
|
||||||
|
* Mapea a billing.payment_methods (DDL: 05-billing-usage.sql)
|
||||||
|
*/
|
||||||
|
@Entity({ name: 'payment_methods', schema: 'billing' })
|
||||||
|
export class BillingPaymentMethod {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
// Proveedor
|
||||||
|
@Index()
|
||||||
|
@Column({ type: 'varchar', length: 30 })
|
||||||
|
provider: PaymentProvider;
|
||||||
|
|
||||||
|
// Tipo
|
||||||
|
@Column({ name: 'method_type', type: 'varchar', length: 20 })
|
||||||
|
methodType: PaymentMethodType;
|
||||||
|
|
||||||
|
// Datos tokenizados del proveedor
|
||||||
|
@Column({ name: 'provider_customer_id', type: 'varchar', length: 255, nullable: true })
|
||||||
|
providerCustomerId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'provider_method_id', type: 'varchar', length: 255, nullable: true })
|
||||||
|
providerMethodId: string;
|
||||||
|
|
||||||
|
// Display info (no sensible)
|
||||||
|
@Column({ name: 'display_name', type: 'varchar', length: 100, nullable: true })
|
||||||
|
displayName: string;
|
||||||
|
|
||||||
|
@Column({ name: 'card_brand', type: 'varchar', length: 20, nullable: true })
|
||||||
|
cardBrand: string;
|
||||||
|
|
||||||
|
@Column({ name: 'card_last_four', type: 'varchar', length: 4, nullable: true })
|
||||||
|
cardLastFour: string;
|
||||||
|
|
||||||
|
@Column({ name: 'card_exp_month', type: 'integer', nullable: true })
|
||||||
|
cardExpMonth: number;
|
||||||
|
|
||||||
|
@Column({ name: 'card_exp_year', type: 'integer', nullable: true })
|
||||||
|
cardExpYear: number;
|
||||||
|
|
||||||
|
@Column({ name: 'bank_name', type: 'varchar', length: 100, nullable: true })
|
||||||
|
bankName: string;
|
||||||
|
|
||||||
|
@Column({ name: 'bank_last_four', type: 'varchar', length: 4, nullable: true })
|
||||||
|
bankLastFour: string;
|
||||||
|
|
||||||
|
// Estado
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'is_default', type: 'boolean', default: false })
|
||||||
|
isDefault: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'is_verified', type: 'boolean', default: false })
|
||||||
|
isVerified: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||||
|
deletedAt: Date;
|
||||||
|
}
|
||||||
@ -0,0 +1,83 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
DeleteDateColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export type PlanType = 'saas' | 'on_premise' | 'hybrid';
|
||||||
|
|
||||||
|
@Entity({ name: 'subscription_plans', schema: 'billing' })
|
||||||
|
export class SubscriptionPlan {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
// Identificacion
|
||||||
|
@Index({ unique: true })
|
||||||
|
@Column({ type: 'varchar', length: 30 })
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100 })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
// Tipo
|
||||||
|
@Column({ name: 'plan_type', type: 'varchar', length: 20, default: 'saas' })
|
||||||
|
planType: PlanType;
|
||||||
|
|
||||||
|
// Precios base
|
||||||
|
@Column({ name: 'base_monthly_price', type: 'decimal', precision: 12, scale: 2, default: 0 })
|
||||||
|
baseMonthlyPrice: number;
|
||||||
|
|
||||||
|
@Column({ name: 'base_annual_price', type: 'decimal', precision: 12, scale: 2, nullable: true })
|
||||||
|
baseAnnualPrice: number;
|
||||||
|
|
||||||
|
@Column({ name: 'setup_fee', type: 'decimal', precision: 12, scale: 2, default: 0 })
|
||||||
|
setupFee: number;
|
||||||
|
|
||||||
|
// Limites base
|
||||||
|
@Column({ name: 'max_users', type: 'integer', default: 5 })
|
||||||
|
maxUsers: number;
|
||||||
|
|
||||||
|
@Column({ name: 'max_branches', type: 'integer', default: 1 })
|
||||||
|
maxBranches: number;
|
||||||
|
|
||||||
|
@Column({ name: 'storage_gb', type: 'integer', default: 10 })
|
||||||
|
storageGb: number;
|
||||||
|
|
||||||
|
@Column({ name: 'api_calls_monthly', type: 'integer', default: 10000 })
|
||||||
|
apiCallsMonthly: number;
|
||||||
|
|
||||||
|
// Modulos incluidos
|
||||||
|
@Column({ name: 'included_modules', type: 'text', array: true, default: [] })
|
||||||
|
includedModules: string[];
|
||||||
|
|
||||||
|
// Plataformas incluidas
|
||||||
|
@Column({ name: 'included_platforms', type: 'text', array: true, default: ['web'] })
|
||||||
|
includedPlatforms: string[];
|
||||||
|
|
||||||
|
// Features
|
||||||
|
@Column({ type: 'jsonb', default: {} })
|
||||||
|
features: Record<string, boolean>;
|
||||||
|
|
||||||
|
// Estado
|
||||||
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'is_public', type: 'boolean', default: true })
|
||||||
|
isPublic: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||||
|
deletedAt: Date;
|
||||||
|
}
|
||||||
117
src/modules/billing-usage/entities/tenant-subscription.entity.ts
Normal file
117
src/modules/billing-usage/entities/tenant-subscription.entity.ts
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
Unique,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { SubscriptionPlan } from './subscription-plan.entity';
|
||||||
|
|
||||||
|
export type BillingCycle = 'monthly' | 'annual';
|
||||||
|
export type SubscriptionStatus = 'trial' | 'active' | 'past_due' | 'cancelled' | 'suspended';
|
||||||
|
|
||||||
|
@Entity({ name: 'tenant_subscriptions', schema: 'billing' })
|
||||||
|
@Unique(['tenantId'])
|
||||||
|
export class TenantSubscription {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'plan_id', type: 'uuid' })
|
||||||
|
planId: string;
|
||||||
|
|
||||||
|
// Periodo
|
||||||
|
@Column({ name: 'billing_cycle', type: 'varchar', length: 20, default: 'monthly' })
|
||||||
|
billingCycle: BillingCycle;
|
||||||
|
|
||||||
|
@Column({ name: 'current_period_start', type: 'timestamptz' })
|
||||||
|
currentPeriodStart: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'current_period_end', type: 'timestamptz' })
|
||||||
|
currentPeriodEnd: Date;
|
||||||
|
|
||||||
|
// Estado
|
||||||
|
@Index()
|
||||||
|
@Column({ type: 'varchar', length: 20, default: 'active' })
|
||||||
|
status: SubscriptionStatus;
|
||||||
|
|
||||||
|
// Trial
|
||||||
|
@Column({ name: 'trial_start', type: 'timestamptz', nullable: true })
|
||||||
|
trialStart: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'trial_end', type: 'timestamptz', nullable: true })
|
||||||
|
trialEnd: Date;
|
||||||
|
|
||||||
|
// Configuracion de facturacion
|
||||||
|
@Column({ name: 'billing_email', type: 'varchar', length: 255, nullable: true })
|
||||||
|
billingEmail: string;
|
||||||
|
|
||||||
|
@Column({ name: 'billing_name', type: 'varchar', length: 200, nullable: true })
|
||||||
|
billingName: string;
|
||||||
|
|
||||||
|
@Column({ name: 'billing_address', type: 'jsonb', default: {} })
|
||||||
|
billingAddress: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({ name: 'tax_id', type: 'varchar', length: 20, nullable: true })
|
||||||
|
taxId: string; // RFC para Mexico
|
||||||
|
|
||||||
|
// Metodo de pago
|
||||||
|
@Column({ name: 'payment_method_id', type: 'uuid', nullable: true })
|
||||||
|
paymentMethodId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'payment_provider', type: 'varchar', length: 30, nullable: true })
|
||||||
|
paymentProvider: string; // stripe, mercadopago, bank_transfer
|
||||||
|
|
||||||
|
// Precios actuales
|
||||||
|
@Column({ name: 'current_price', type: 'decimal', precision: 12, scale: 2 })
|
||||||
|
currentPrice: number;
|
||||||
|
|
||||||
|
@Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 })
|
||||||
|
discountPercent: number;
|
||||||
|
|
||||||
|
@Column({ name: 'discount_reason', type: 'varchar', length: 100, nullable: true })
|
||||||
|
discountReason: string;
|
||||||
|
|
||||||
|
// Uso contratado
|
||||||
|
@Column({ name: 'contracted_users', type: 'integer', nullable: true })
|
||||||
|
contractedUsers: number;
|
||||||
|
|
||||||
|
@Column({ name: 'contracted_branches', type: 'integer', nullable: true })
|
||||||
|
contractedBranches: number;
|
||||||
|
|
||||||
|
// Facturacion automatica
|
||||||
|
@Column({ name: 'auto_renew', type: 'boolean', default: true })
|
||||||
|
autoRenew: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'next_invoice_date', type: 'date', nullable: true })
|
||||||
|
nextInvoiceDate: Date;
|
||||||
|
|
||||||
|
// Cancelacion
|
||||||
|
@Column({ name: 'cancel_at_period_end', type: 'boolean', default: false })
|
||||||
|
cancelAtPeriodEnd: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true })
|
||||||
|
cancelledAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'cancellation_reason', type: 'text', nullable: true })
|
||||||
|
cancellationReason: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
// Relaciones
|
||||||
|
@ManyToOne(() => SubscriptionPlan)
|
||||||
|
@JoinColumn({ name: 'plan_id' })
|
||||||
|
plan: SubscriptionPlan;
|
||||||
|
}
|
||||||
73
src/modules/billing-usage/entities/usage-event.entity.ts
Normal file
73
src/modules/billing-usage/entities/usage-event.entity.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export type EventCategory = 'user' | 'api' | 'storage' | 'transaction' | 'mobile';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entidad para eventos de uso en tiempo real.
|
||||||
|
* Utilizada para calculo de billing y tracking granular.
|
||||||
|
* Mapea a billing.usage_events (DDL: 05-billing-usage.sql)
|
||||||
|
*/
|
||||||
|
@Entity({ name: 'usage_events', schema: 'billing' })
|
||||||
|
export class UsageEvent {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'user_id', type: 'uuid', nullable: true })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'device_id', type: 'uuid', nullable: true })
|
||||||
|
deviceId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'branch_id', type: 'uuid', nullable: true })
|
||||||
|
branchId: string;
|
||||||
|
|
||||||
|
// Evento
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'event_type', type: 'varchar', length: 50 })
|
||||||
|
eventType: string; // login, api_call, document_upload, sale, invoice, sync
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'event_category', type: 'varchar', length: 30 })
|
||||||
|
eventCategory: EventCategory;
|
||||||
|
|
||||||
|
// Detalles
|
||||||
|
@Column({ name: 'profile_code', type: 'varchar', length: 10, nullable: true })
|
||||||
|
profileCode: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, nullable: true })
|
||||||
|
platform: string;
|
||||||
|
|
||||||
|
@Column({ name: 'resource_id', type: 'uuid', nullable: true })
|
||||||
|
resourceId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'resource_type', type: 'varchar', length: 50, nullable: true })
|
||||||
|
resourceType: string;
|
||||||
|
|
||||||
|
// Metricas
|
||||||
|
@Column({ type: 'integer', default: 1 })
|
||||||
|
quantity: number;
|
||||||
|
|
||||||
|
@Column({ name: 'bytes_used', type: 'bigint', default: 0 })
|
||||||
|
bytesUsed: number;
|
||||||
|
|
||||||
|
@Column({ name: 'duration_ms', type: 'integer', nullable: true })
|
||||||
|
durationMs: number;
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
@Column({ type: 'jsonb', default: {} })
|
||||||
|
metadata: Record<string, any>;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
91
src/modules/billing-usage/entities/usage-tracking.entity.ts
Normal file
91
src/modules/billing-usage/entities/usage-tracking.entity.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
Unique,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity({ name: 'usage_tracking', schema: 'billing' })
|
||||||
|
@Unique(['tenantId', 'periodStart'])
|
||||||
|
export class UsageTracking {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
// Periodo
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'period_start', type: 'date' })
|
||||||
|
periodStart: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'period_end', type: 'date' })
|
||||||
|
periodEnd: Date;
|
||||||
|
|
||||||
|
// Usuarios
|
||||||
|
@Column({ name: 'active_users', type: 'integer', default: 0 })
|
||||||
|
activeUsers: number;
|
||||||
|
|
||||||
|
@Column({ name: 'peak_concurrent_users', type: 'integer', default: 0 })
|
||||||
|
peakConcurrentUsers: number;
|
||||||
|
|
||||||
|
// Por perfil
|
||||||
|
@Column({ name: 'users_by_profile', type: 'jsonb', default: {} })
|
||||||
|
usersByProfile: Record<string, number>; // {"ADM": 2, "VNT": 5, "ALM": 3}
|
||||||
|
|
||||||
|
// Por plataforma
|
||||||
|
@Column({ name: 'users_by_platform', type: 'jsonb', default: {} })
|
||||||
|
usersByPlatform: Record<string, number>; // {"web": 8, "mobile": 5, "desktop": 0}
|
||||||
|
|
||||||
|
// Sucursales
|
||||||
|
@Column({ name: 'active_branches', type: 'integer', default: 0 })
|
||||||
|
activeBranches: number;
|
||||||
|
|
||||||
|
// Storage
|
||||||
|
@Column({ name: 'storage_used_gb', type: 'decimal', precision: 10, scale: 2, default: 0 })
|
||||||
|
storageUsedGb: number;
|
||||||
|
|
||||||
|
@Column({ name: 'documents_count', type: 'integer', default: 0 })
|
||||||
|
documentsCount: number;
|
||||||
|
|
||||||
|
// API
|
||||||
|
@Column({ name: 'api_calls', type: 'integer', default: 0 })
|
||||||
|
apiCalls: number;
|
||||||
|
|
||||||
|
@Column({ name: 'api_errors', type: 'integer', default: 0 })
|
||||||
|
apiErrors: number;
|
||||||
|
|
||||||
|
// Transacciones
|
||||||
|
@Column({ name: 'sales_count', type: 'integer', default: 0 })
|
||||||
|
salesCount: number;
|
||||||
|
|
||||||
|
@Column({ name: 'sales_amount', type: 'decimal', precision: 14, scale: 2, default: 0 })
|
||||||
|
salesAmount: number;
|
||||||
|
|
||||||
|
@Column({ name: 'invoices_generated', type: 'integer', default: 0 })
|
||||||
|
invoicesGenerated: number;
|
||||||
|
|
||||||
|
// Mobile
|
||||||
|
@Column({ name: 'mobile_sessions', type: 'integer', default: 0 })
|
||||||
|
mobileSessions: number;
|
||||||
|
|
||||||
|
@Column({ name: 'offline_syncs', type: 'integer', default: 0 })
|
||||||
|
offlineSyncs: number;
|
||||||
|
|
||||||
|
@Column({ name: 'payment_transactions', type: 'integer', default: 0 })
|
||||||
|
paymentTransactions: number;
|
||||||
|
|
||||||
|
// Calculado
|
||||||
|
@Column({ name: 'total_billable_amount', type: 'decimal', precision: 12, scale: 2, default: 0 })
|
||||||
|
totalBillableAmount: number;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
18
src/modules/billing-usage/index.ts
Normal file
18
src/modules/billing-usage/index.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Billing Usage Module Index
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Module
|
||||||
|
export { BillingUsageModule, BillingUsageModuleOptions } from './billing-usage.module';
|
||||||
|
|
||||||
|
// Entities
|
||||||
|
export * from './entities';
|
||||||
|
|
||||||
|
// DTOs
|
||||||
|
export * from './dto';
|
||||||
|
|
||||||
|
// Services
|
||||||
|
export * from './services';
|
||||||
|
|
||||||
|
// Controllers
|
||||||
|
export * from './controllers';
|
||||||
8
src/modules/billing-usage/services/index.ts
Normal file
8
src/modules/billing-usage/services/index.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Billing Usage Services Index
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { SubscriptionPlansService } from './subscription-plans.service';
|
||||||
|
export { SubscriptionsService } from './subscriptions.service';
|
||||||
|
export { UsageTrackingService } from './usage-tracking.service';
|
||||||
|
export { InvoicesService } from './invoices.service';
|
||||||
471
src/modules/billing-usage/services/invoices.service.ts
Normal file
471
src/modules/billing-usage/services/invoices.service.ts
Normal file
@ -0,0 +1,471 @@
|
|||||||
|
/**
|
||||||
|
* Invoices Service
|
||||||
|
*
|
||||||
|
* Service for managing invoices
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Repository, DataSource, MoreThanOrEqual, LessThanOrEqual } from 'typeorm';
|
||||||
|
import { Invoice, InvoiceItem, InvoiceStatus, TenantSubscription, UsageTracking } from '../entities';
|
||||||
|
import {
|
||||||
|
CreateInvoiceDto,
|
||||||
|
UpdateInvoiceDto,
|
||||||
|
RecordPaymentDto,
|
||||||
|
VoidInvoiceDto,
|
||||||
|
RefundInvoiceDto,
|
||||||
|
GenerateInvoiceDto,
|
||||||
|
InvoiceFilterDto,
|
||||||
|
} from '../dto';
|
||||||
|
|
||||||
|
export class InvoicesService {
|
||||||
|
private invoiceRepository: Repository<Invoice>;
|
||||||
|
private itemRepository: Repository<InvoiceItem>;
|
||||||
|
private subscriptionRepository: Repository<TenantSubscription>;
|
||||||
|
private usageRepository: Repository<UsageTracking>;
|
||||||
|
|
||||||
|
constructor(private dataSource: DataSource) {
|
||||||
|
this.invoiceRepository = dataSource.getRepository(Invoice);
|
||||||
|
this.itemRepository = dataSource.getRepository(InvoiceItem);
|
||||||
|
this.subscriptionRepository = dataSource.getRepository(TenantSubscription);
|
||||||
|
this.usageRepository = dataSource.getRepository(UsageTracking);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create invoice manually
|
||||||
|
*/
|
||||||
|
async create(dto: CreateInvoiceDto): Promise<Invoice> {
|
||||||
|
const invoiceNumber = await this.generateInvoiceNumber();
|
||||||
|
|
||||||
|
// Calculate totals
|
||||||
|
let subtotal = 0;
|
||||||
|
for (const item of dto.items) {
|
||||||
|
const itemTotal = item.quantity * item.unitPrice;
|
||||||
|
const discount = itemTotal * ((item.discountPercent || 0) / 100);
|
||||||
|
subtotal += itemTotal - discount;
|
||||||
|
}
|
||||||
|
|
||||||
|
const taxAmount = subtotal * 0.16; // 16% IVA for Mexico
|
||||||
|
const total = subtotal + taxAmount;
|
||||||
|
|
||||||
|
const invoice = this.invoiceRepository.create({
|
||||||
|
tenantId: dto.tenantId,
|
||||||
|
subscriptionId: dto.subscriptionId,
|
||||||
|
invoiceNumber,
|
||||||
|
invoiceDate: dto.invoiceDate || new Date(),
|
||||||
|
periodStart: dto.periodStart,
|
||||||
|
periodEnd: dto.periodEnd,
|
||||||
|
billingName: dto.billingName,
|
||||||
|
billingEmail: dto.billingEmail,
|
||||||
|
billingAddress: dto.billingAddress || {},
|
||||||
|
taxId: dto.taxId,
|
||||||
|
subtotal,
|
||||||
|
taxAmount,
|
||||||
|
discountAmount: 0,
|
||||||
|
total,
|
||||||
|
currency: dto.currency || 'MXN',
|
||||||
|
status: 'draft',
|
||||||
|
dueDate: dto.dueDate,
|
||||||
|
notes: dto.notes,
|
||||||
|
internalNotes: dto.internalNotes,
|
||||||
|
});
|
||||||
|
|
||||||
|
const savedInvoice = await this.invoiceRepository.save(invoice);
|
||||||
|
|
||||||
|
// Create items
|
||||||
|
for (const itemDto of dto.items) {
|
||||||
|
const itemTotal = itemDto.quantity * itemDto.unitPrice;
|
||||||
|
const discount = itemTotal * ((itemDto.discountPercent || 0) / 100);
|
||||||
|
|
||||||
|
const item = this.itemRepository.create({
|
||||||
|
invoiceId: savedInvoice.id,
|
||||||
|
itemType: itemDto.itemType,
|
||||||
|
description: itemDto.description,
|
||||||
|
quantity: itemDto.quantity,
|
||||||
|
unitPrice: itemDto.unitPrice,
|
||||||
|
discountPercent: itemDto.discountPercent || 0,
|
||||||
|
subtotal: itemTotal - discount,
|
||||||
|
metadata: itemDto.metadata || {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.itemRepository.save(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.findById(savedInvoice.id) as Promise<Invoice>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate invoice automatically from subscription
|
||||||
|
*/
|
||||||
|
async generateFromSubscription(dto: GenerateInvoiceDto): Promise<Invoice> {
|
||||||
|
const subscription = await this.subscriptionRepository.findOne({
|
||||||
|
where: { id: dto.subscriptionId },
|
||||||
|
relations: ['plan'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
throw new Error('Subscription not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: CreateInvoiceDto['items'] = [];
|
||||||
|
|
||||||
|
// Base subscription fee
|
||||||
|
items.push({
|
||||||
|
itemType: 'subscription',
|
||||||
|
description: `Suscripcion ${subscription.plan.name} - ${subscription.billingCycle === 'annual' ? 'Anual' : 'Mensual'}`,
|
||||||
|
quantity: 1,
|
||||||
|
unitPrice: Number(subscription.currentPrice),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Include usage charges if requested
|
||||||
|
if (dto.includeUsageCharges) {
|
||||||
|
const usage = await this.usageRepository.findOne({
|
||||||
|
where: {
|
||||||
|
tenantId: dto.tenantId,
|
||||||
|
periodStart: dto.periodStart,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (usage) {
|
||||||
|
// Extra users
|
||||||
|
const extraUsers = Math.max(
|
||||||
|
0,
|
||||||
|
usage.activeUsers - (subscription.contractedUsers || subscription.plan.maxUsers)
|
||||||
|
);
|
||||||
|
if (extraUsers > 0) {
|
||||||
|
items.push({
|
||||||
|
itemType: 'overage',
|
||||||
|
description: `Usuarios adicionales (${extraUsers})`,
|
||||||
|
quantity: extraUsers,
|
||||||
|
unitPrice: 10, // $10 per extra user
|
||||||
|
metadata: { metric: 'extra_users' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extra branches
|
||||||
|
const extraBranches = Math.max(
|
||||||
|
0,
|
||||||
|
usage.activeBranches - (subscription.contractedBranches || subscription.plan.maxBranches)
|
||||||
|
);
|
||||||
|
if (extraBranches > 0) {
|
||||||
|
items.push({
|
||||||
|
itemType: 'overage',
|
||||||
|
description: `Sucursales adicionales (${extraBranches})`,
|
||||||
|
quantity: extraBranches,
|
||||||
|
unitPrice: 20, // $20 per extra branch
|
||||||
|
metadata: { metric: 'extra_branches' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extra storage
|
||||||
|
const extraStorageGb = Math.max(
|
||||||
|
0,
|
||||||
|
Number(usage.storageUsedGb) - subscription.plan.storageGb
|
||||||
|
);
|
||||||
|
if (extraStorageGb > 0) {
|
||||||
|
items.push({
|
||||||
|
itemType: 'overage',
|
||||||
|
description: `Almacenamiento adicional (${extraStorageGb} GB)`,
|
||||||
|
quantity: Math.ceil(extraStorageGb),
|
||||||
|
unitPrice: 0.5, // $0.50 per GB
|
||||||
|
metadata: { metric: 'extra_storage' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate due date (15 days from invoice date)
|
||||||
|
const dueDate = new Date();
|
||||||
|
dueDate.setDate(dueDate.getDate() + 15);
|
||||||
|
|
||||||
|
return this.create({
|
||||||
|
tenantId: dto.tenantId,
|
||||||
|
subscriptionId: dto.subscriptionId,
|
||||||
|
periodStart: dto.periodStart,
|
||||||
|
periodEnd: dto.periodEnd,
|
||||||
|
billingName: subscription.billingName,
|
||||||
|
billingEmail: subscription.billingEmail,
|
||||||
|
billingAddress: subscription.billingAddress,
|
||||||
|
taxId: subscription.taxId,
|
||||||
|
dueDate,
|
||||||
|
items,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find invoice by ID
|
||||||
|
*/
|
||||||
|
async findById(id: string): Promise<Invoice | null> {
|
||||||
|
return this.invoiceRepository.findOne({
|
||||||
|
where: { id },
|
||||||
|
relations: ['items'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find invoice by number
|
||||||
|
*/
|
||||||
|
async findByNumber(invoiceNumber: string): Promise<Invoice | null> {
|
||||||
|
return this.invoiceRepository.findOne({
|
||||||
|
where: { invoiceNumber },
|
||||||
|
relations: ['items'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find invoices with filters
|
||||||
|
*/
|
||||||
|
async findAll(filter: InvoiceFilterDto): Promise<{ data: Invoice[]; total: number }> {
|
||||||
|
const query = this.invoiceRepository
|
||||||
|
.createQueryBuilder('invoice')
|
||||||
|
.leftJoinAndSelect('invoice.items', 'items');
|
||||||
|
|
||||||
|
if (filter.tenantId) {
|
||||||
|
query.andWhere('invoice.tenantId = :tenantId', { tenantId: filter.tenantId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.status) {
|
||||||
|
query.andWhere('invoice.status = :status', { status: filter.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.dateFrom) {
|
||||||
|
query.andWhere('invoice.invoiceDate >= :dateFrom', { dateFrom: filter.dateFrom });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.dateTo) {
|
||||||
|
query.andWhere('invoice.invoiceDate <= :dateTo', { dateTo: filter.dateTo });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.overdue) {
|
||||||
|
query.andWhere('invoice.dueDate < :now', { now: new Date() });
|
||||||
|
query.andWhere("invoice.status IN ('sent', 'partial')");
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = await query.getCount();
|
||||||
|
|
||||||
|
query.orderBy('invoice.invoiceDate', 'DESC');
|
||||||
|
|
||||||
|
if (filter.limit) {
|
||||||
|
query.take(filter.limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.offset) {
|
||||||
|
query.skip(filter.offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await query.getMany();
|
||||||
|
|
||||||
|
return { data, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update invoice
|
||||||
|
*/
|
||||||
|
async update(id: string, dto: UpdateInvoiceDto): Promise<Invoice> {
|
||||||
|
const invoice = await this.findById(id);
|
||||||
|
if (!invoice) {
|
||||||
|
throw new Error('Invoice not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoice.status !== 'draft') {
|
||||||
|
throw new Error('Only draft invoices can be updated');
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(invoice, dto);
|
||||||
|
return this.invoiceRepository.save(invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send invoice
|
||||||
|
*/
|
||||||
|
async send(id: string): Promise<Invoice> {
|
||||||
|
const invoice = await this.findById(id);
|
||||||
|
if (!invoice) {
|
||||||
|
throw new Error('Invoice not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoice.status !== 'draft') {
|
||||||
|
throw new Error('Only draft invoices can be sent');
|
||||||
|
}
|
||||||
|
|
||||||
|
invoice.status = 'sent';
|
||||||
|
// TODO: Send email notification to billing email
|
||||||
|
|
||||||
|
return this.invoiceRepository.save(invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record payment
|
||||||
|
*/
|
||||||
|
async recordPayment(id: string, dto: RecordPaymentDto): Promise<Invoice> {
|
||||||
|
const invoice = await this.findById(id);
|
||||||
|
if (!invoice) {
|
||||||
|
throw new Error('Invoice not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoice.status === 'void' || invoice.status === 'refunded') {
|
||||||
|
throw new Error('Cannot record payment for voided or refunded invoice');
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPaidAmount = Number(invoice.paidAmount) + dto.amount;
|
||||||
|
const total = Number(invoice.total);
|
||||||
|
|
||||||
|
invoice.paidAmount = newPaidAmount;
|
||||||
|
invoice.paymentMethod = dto.paymentMethod;
|
||||||
|
invoice.paymentReference = dto.paymentReference;
|
||||||
|
|
||||||
|
if (newPaidAmount >= total) {
|
||||||
|
invoice.status = 'paid';
|
||||||
|
invoice.paidAt = dto.paymentDate || new Date();
|
||||||
|
} else if (newPaidAmount > 0) {
|
||||||
|
invoice.status = 'partial';
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.invoiceRepository.save(invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Void invoice
|
||||||
|
*/
|
||||||
|
async void(id: string, dto: VoidInvoiceDto): Promise<Invoice> {
|
||||||
|
const invoice = await this.findById(id);
|
||||||
|
if (!invoice) {
|
||||||
|
throw new Error('Invoice not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoice.status === 'paid' || invoice.status === 'refunded') {
|
||||||
|
throw new Error('Cannot void paid or refunded invoice');
|
||||||
|
}
|
||||||
|
|
||||||
|
invoice.status = 'void';
|
||||||
|
invoice.internalNotes = `${invoice.internalNotes || ''}\n\nVoided: ${dto.reason}`.trim();
|
||||||
|
|
||||||
|
return this.invoiceRepository.save(invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refund invoice
|
||||||
|
*/
|
||||||
|
async refund(id: string, dto: RefundInvoiceDto): Promise<Invoice> {
|
||||||
|
const invoice = await this.findById(id);
|
||||||
|
if (!invoice) {
|
||||||
|
throw new Error('Invoice not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoice.status !== 'paid' && invoice.status !== 'partial') {
|
||||||
|
throw new Error('Only paid invoices can be refunded');
|
||||||
|
}
|
||||||
|
|
||||||
|
const refundAmount = dto.amount || Number(invoice.paidAmount);
|
||||||
|
|
||||||
|
if (refundAmount > Number(invoice.paidAmount)) {
|
||||||
|
throw new Error('Refund amount cannot exceed paid amount');
|
||||||
|
}
|
||||||
|
|
||||||
|
invoice.status = 'refunded';
|
||||||
|
invoice.internalNotes =
|
||||||
|
`${invoice.internalNotes || ''}\n\nRefunded: ${refundAmount} - ${dto.reason}`.trim();
|
||||||
|
|
||||||
|
// TODO: Process actual refund through payment provider
|
||||||
|
|
||||||
|
return this.invoiceRepository.save(invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark overdue invoices
|
||||||
|
*/
|
||||||
|
async markOverdueInvoices(): Promise<number> {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const result = await this.invoiceRepository
|
||||||
|
.createQueryBuilder()
|
||||||
|
.update(Invoice)
|
||||||
|
.set({ status: 'overdue' })
|
||||||
|
.where("status IN ('sent', 'partial')")
|
||||||
|
.andWhere('dueDate < :now', { now })
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return result.affected || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get invoice statistics
|
||||||
|
*/
|
||||||
|
async getStats(tenantId?: string): Promise<{
|
||||||
|
total: number;
|
||||||
|
byStatus: Record<InvoiceStatus, number>;
|
||||||
|
totalRevenue: number;
|
||||||
|
pendingAmount: number;
|
||||||
|
overdueAmount: number;
|
||||||
|
}> {
|
||||||
|
const query = this.invoiceRepository.createQueryBuilder('invoice');
|
||||||
|
|
||||||
|
if (tenantId) {
|
||||||
|
query.where('invoice.tenantId = :tenantId', { tenantId });
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoices = await query.getMany();
|
||||||
|
|
||||||
|
const byStatus: Record<InvoiceStatus, number> = {
|
||||||
|
draft: 0,
|
||||||
|
sent: 0,
|
||||||
|
paid: 0,
|
||||||
|
partial: 0,
|
||||||
|
overdue: 0,
|
||||||
|
void: 0,
|
||||||
|
refunded: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let totalRevenue = 0;
|
||||||
|
let pendingAmount = 0;
|
||||||
|
let overdueAmount = 0;
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
for (const invoice of invoices) {
|
||||||
|
byStatus[invoice.status]++;
|
||||||
|
|
||||||
|
if (invoice.status === 'paid') {
|
||||||
|
totalRevenue += Number(invoice.paidAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoice.status === 'sent' || invoice.status === 'partial') {
|
||||||
|
const pending = Number(invoice.total) - Number(invoice.paidAmount);
|
||||||
|
pendingAmount += pending;
|
||||||
|
|
||||||
|
if (invoice.dueDate < now) {
|
||||||
|
overdueAmount += pending;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: invoices.length,
|
||||||
|
byStatus,
|
||||||
|
totalRevenue,
|
||||||
|
pendingAmount,
|
||||||
|
overdueAmount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate unique invoice number
|
||||||
|
*/
|
||||||
|
private async generateInvoiceNumber(): Promise<string> {
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
|
||||||
|
// Get last invoice number for this month
|
||||||
|
const lastInvoice = await this.invoiceRepository
|
||||||
|
.createQueryBuilder('invoice')
|
||||||
|
.where('invoice.invoiceNumber LIKE :pattern', { pattern: `INV-${year}${month}%` })
|
||||||
|
.orderBy('invoice.invoiceNumber', 'DESC')
|
||||||
|
.getOne();
|
||||||
|
|
||||||
|
let sequence = 1;
|
||||||
|
if (lastInvoice) {
|
||||||
|
const lastSequence = parseInt(lastInvoice.invoiceNumber.slice(-4), 10);
|
||||||
|
sequence = lastSequence + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `INV-${year}${month}-${String(sequence).padStart(4, '0')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
200
src/modules/billing-usage/services/subscription-plans.service.ts
Normal file
200
src/modules/billing-usage/services/subscription-plans.service.ts
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
/**
|
||||||
|
* Subscription Plans Service
|
||||||
|
*
|
||||||
|
* Service for managing subscription plans
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Repository, DataSource } from 'typeorm';
|
||||||
|
import { SubscriptionPlan, PlanType } from '../entities';
|
||||||
|
import { CreateSubscriptionPlanDto, UpdateSubscriptionPlanDto } from '../dto';
|
||||||
|
|
||||||
|
export class SubscriptionPlansService {
|
||||||
|
private planRepository: Repository<SubscriptionPlan>;
|
||||||
|
|
||||||
|
constructor(private dataSource: DataSource) {
|
||||||
|
this.planRepository = dataSource.getRepository(SubscriptionPlan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new subscription plan
|
||||||
|
*/
|
||||||
|
async create(dto: CreateSubscriptionPlanDto): Promise<SubscriptionPlan> {
|
||||||
|
// Check if code already exists
|
||||||
|
const existing = await this.planRepository.findOne({
|
||||||
|
where: { code: dto.code },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new Error(`Plan with code ${dto.code} already exists`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const plan = this.planRepository.create({
|
||||||
|
code: dto.code,
|
||||||
|
name: dto.name,
|
||||||
|
description: dto.description,
|
||||||
|
planType: dto.planType || 'saas',
|
||||||
|
baseMonthlyPrice: dto.baseMonthlyPrice,
|
||||||
|
baseAnnualPrice: dto.baseAnnualPrice,
|
||||||
|
setupFee: dto.setupFee || 0,
|
||||||
|
maxUsers: dto.maxUsers || 5,
|
||||||
|
maxBranches: dto.maxBranches || 1,
|
||||||
|
storageGb: dto.storageGb || 10,
|
||||||
|
apiCallsMonthly: dto.apiCallsMonthly || 10000,
|
||||||
|
includedModules: dto.includedModules || [],
|
||||||
|
includedPlatforms: dto.includedPlatforms || ['web'],
|
||||||
|
features: dto.features || {},
|
||||||
|
isActive: dto.isActive !== false,
|
||||||
|
isPublic: dto.isPublic !== false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.planRepository.save(plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all plans
|
||||||
|
*/
|
||||||
|
async findAll(options?: {
|
||||||
|
isActive?: boolean;
|
||||||
|
isPublic?: boolean;
|
||||||
|
planType?: PlanType;
|
||||||
|
}): Promise<SubscriptionPlan[]> {
|
||||||
|
const query = this.planRepository.createQueryBuilder('plan');
|
||||||
|
|
||||||
|
if (options?.isActive !== undefined) {
|
||||||
|
query.andWhere('plan.isActive = :isActive', { isActive: options.isActive });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.isPublic !== undefined) {
|
||||||
|
query.andWhere('plan.isPublic = :isPublic', { isPublic: options.isPublic });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.planType) {
|
||||||
|
query.andWhere('plan.planType = :planType', { planType: options.planType });
|
||||||
|
}
|
||||||
|
|
||||||
|
return query.orderBy('plan.baseMonthlyPrice', 'ASC').getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find public plans (for pricing page)
|
||||||
|
*/
|
||||||
|
async findPublicPlans(): Promise<SubscriptionPlan[]> {
|
||||||
|
return this.findAll({ isActive: true, isPublic: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find plan by ID
|
||||||
|
*/
|
||||||
|
async findById(id: string): Promise<SubscriptionPlan | null> {
|
||||||
|
return this.planRepository.findOne({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find plan by code
|
||||||
|
*/
|
||||||
|
async findByCode(code: string): Promise<SubscriptionPlan | null> {
|
||||||
|
return this.planRepository.findOne({ where: { code } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a plan
|
||||||
|
*/
|
||||||
|
async update(id: string, dto: UpdateSubscriptionPlanDto): Promise<SubscriptionPlan> {
|
||||||
|
const plan = await this.findById(id);
|
||||||
|
if (!plan) {
|
||||||
|
throw new Error('Plan not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(plan, dto);
|
||||||
|
return this.planRepository.save(plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft delete a plan
|
||||||
|
*/
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
const plan = await this.findById(id);
|
||||||
|
if (!plan) {
|
||||||
|
throw new Error('Plan not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if plan has active subscriptions
|
||||||
|
const subscriptionCount = await this.dataSource
|
||||||
|
.createQueryBuilder()
|
||||||
|
.select('COUNT(*)')
|
||||||
|
.from('billing.tenant_subscriptions', 'ts')
|
||||||
|
.where('ts.plan_id = :planId', { planId: id })
|
||||||
|
.andWhere("ts.status IN ('active', 'trial')")
|
||||||
|
.getRawOne();
|
||||||
|
|
||||||
|
if (parseInt(subscriptionCount.count) > 0) {
|
||||||
|
throw new Error('Cannot delete plan with active subscriptions');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.planRepository.softDelete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activate/deactivate a plan
|
||||||
|
*/
|
||||||
|
async setActive(id: string, isActive: boolean): Promise<SubscriptionPlan> {
|
||||||
|
return this.update(id, { isActive });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare two plans
|
||||||
|
*/
|
||||||
|
async comparePlans(
|
||||||
|
planId1: string,
|
||||||
|
planId2: string
|
||||||
|
): Promise<{
|
||||||
|
plan1: SubscriptionPlan;
|
||||||
|
plan2: SubscriptionPlan;
|
||||||
|
differences: Record<string, { plan1: any; plan2: any }>;
|
||||||
|
}> {
|
||||||
|
const [plan1, plan2] = await Promise.all([
|
||||||
|
this.findById(planId1),
|
||||||
|
this.findById(planId2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!plan1 || !plan2) {
|
||||||
|
throw new Error('One or both plans not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldsToCompare = [
|
||||||
|
'baseMonthlyPrice',
|
||||||
|
'baseAnnualPrice',
|
||||||
|
'maxUsers',
|
||||||
|
'maxBranches',
|
||||||
|
'storageGb',
|
||||||
|
'apiCallsMonthly',
|
||||||
|
];
|
||||||
|
|
||||||
|
const differences: Record<string, { plan1: any; plan2: any }> = {};
|
||||||
|
|
||||||
|
for (const field of fieldsToCompare) {
|
||||||
|
if ((plan1 as any)[field] !== (plan2 as any)[field]) {
|
||||||
|
differences[field] = {
|
||||||
|
plan1: (plan1 as any)[field],
|
||||||
|
plan2: (plan2 as any)[field],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare included modules
|
||||||
|
const modules1 = new Set(plan1.includedModules);
|
||||||
|
const modules2 = new Set(plan2.includedModules);
|
||||||
|
const modulesDiff = {
|
||||||
|
onlyInPlan1: plan1.includedModules.filter((m) => !modules2.has(m)),
|
||||||
|
onlyInPlan2: plan2.includedModules.filter((m) => !modules1.has(m)),
|
||||||
|
};
|
||||||
|
if (modulesDiff.onlyInPlan1.length > 0 || modulesDiff.onlyInPlan2.length > 0) {
|
||||||
|
differences.includedModules = {
|
||||||
|
plan1: modulesDiff.onlyInPlan1,
|
||||||
|
plan2: modulesDiff.onlyInPlan2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { plan1, plan2, differences };
|
||||||
|
}
|
||||||
|
}
|
||||||
384
src/modules/billing-usage/services/subscriptions.service.ts
Normal file
384
src/modules/billing-usage/services/subscriptions.service.ts
Normal file
@ -0,0 +1,384 @@
|
|||||||
|
/**
|
||||||
|
* Subscriptions Service
|
||||||
|
*
|
||||||
|
* Service for managing tenant subscriptions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Repository, DataSource } from 'typeorm';
|
||||||
|
import {
|
||||||
|
TenantSubscription,
|
||||||
|
SubscriptionPlan,
|
||||||
|
BillingCycle,
|
||||||
|
SubscriptionStatus,
|
||||||
|
} from '../entities';
|
||||||
|
import {
|
||||||
|
CreateTenantSubscriptionDto,
|
||||||
|
UpdateTenantSubscriptionDto,
|
||||||
|
CancelSubscriptionDto,
|
||||||
|
ChangePlanDto,
|
||||||
|
SetPaymentMethodDto,
|
||||||
|
} from '../dto';
|
||||||
|
|
||||||
|
export class SubscriptionsService {
|
||||||
|
private subscriptionRepository: Repository<TenantSubscription>;
|
||||||
|
private planRepository: Repository<SubscriptionPlan>;
|
||||||
|
|
||||||
|
constructor(private dataSource: DataSource) {
|
||||||
|
this.subscriptionRepository = dataSource.getRepository(TenantSubscription);
|
||||||
|
this.planRepository = dataSource.getRepository(SubscriptionPlan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new subscription
|
||||||
|
*/
|
||||||
|
async create(dto: CreateTenantSubscriptionDto): Promise<TenantSubscription> {
|
||||||
|
// Check if tenant already has a subscription
|
||||||
|
const existing = await this.subscriptionRepository.findOne({
|
||||||
|
where: { tenantId: dto.tenantId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new Error('Tenant already has a subscription');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate plan exists
|
||||||
|
const plan = await this.planRepository.findOne({ where: { id: dto.planId } });
|
||||||
|
if (!plan) {
|
||||||
|
throw new Error('Plan not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const currentPeriodStart = dto.currentPeriodStart || now;
|
||||||
|
const currentPeriodEnd =
|
||||||
|
dto.currentPeriodEnd || this.calculatePeriodEnd(currentPeriodStart, dto.billingCycle || 'monthly');
|
||||||
|
|
||||||
|
const subscription = this.subscriptionRepository.create({
|
||||||
|
tenantId: dto.tenantId,
|
||||||
|
planId: dto.planId,
|
||||||
|
billingCycle: dto.billingCycle || 'monthly',
|
||||||
|
currentPeriodStart,
|
||||||
|
currentPeriodEnd,
|
||||||
|
status: dto.startWithTrial ? 'trial' : 'active',
|
||||||
|
billingEmail: dto.billingEmail,
|
||||||
|
billingName: dto.billingName,
|
||||||
|
billingAddress: dto.billingAddress || {},
|
||||||
|
taxId: dto.taxId,
|
||||||
|
currentPrice: dto.currentPrice,
|
||||||
|
discountPercent: dto.discountPercent || 0,
|
||||||
|
discountReason: dto.discountReason,
|
||||||
|
contractedUsers: dto.contractedUsers || plan.maxUsers,
|
||||||
|
contractedBranches: dto.contractedBranches || plan.maxBranches,
|
||||||
|
autoRenew: dto.autoRenew !== false,
|
||||||
|
nextInvoiceDate: currentPeriodEnd,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set trial dates if starting with trial
|
||||||
|
if (dto.startWithTrial) {
|
||||||
|
subscription.trialStart = now;
|
||||||
|
subscription.trialEnd = new Date(now.getTime() + (dto.trialDays || 14) * 24 * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.subscriptionRepository.save(subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find subscription by tenant ID
|
||||||
|
*/
|
||||||
|
async findByTenantId(tenantId: string): Promise<TenantSubscription | null> {
|
||||||
|
return this.subscriptionRepository.findOne({
|
||||||
|
where: { tenantId },
|
||||||
|
relations: ['plan'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find subscription by ID
|
||||||
|
*/
|
||||||
|
async findById(id: string): Promise<TenantSubscription | null> {
|
||||||
|
return this.subscriptionRepository.findOne({
|
||||||
|
where: { id },
|
||||||
|
relations: ['plan'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update subscription
|
||||||
|
*/
|
||||||
|
async update(id: string, dto: UpdateTenantSubscriptionDto): Promise<TenantSubscription> {
|
||||||
|
const subscription = await this.findById(id);
|
||||||
|
if (!subscription) {
|
||||||
|
throw new Error('Subscription not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// If changing plan, validate it exists
|
||||||
|
if (dto.planId && dto.planId !== subscription.planId) {
|
||||||
|
const plan = await this.planRepository.findOne({ where: { id: dto.planId } });
|
||||||
|
if (!plan) {
|
||||||
|
throw new Error('Plan not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(subscription, dto);
|
||||||
|
return this.subscriptionRepository.save(subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel subscription
|
||||||
|
*/
|
||||||
|
async cancel(id: string, dto: CancelSubscriptionDto): Promise<TenantSubscription> {
|
||||||
|
const subscription = await this.findById(id);
|
||||||
|
if (!subscription) {
|
||||||
|
throw new Error('Subscription not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscription.status === 'cancelled') {
|
||||||
|
throw new Error('Subscription is already cancelled');
|
||||||
|
}
|
||||||
|
|
||||||
|
subscription.cancellationReason = dto.reason;
|
||||||
|
subscription.cancelledAt = new Date();
|
||||||
|
|
||||||
|
if (dto.cancelImmediately) {
|
||||||
|
subscription.status = 'cancelled';
|
||||||
|
} else {
|
||||||
|
subscription.cancelAtPeriodEnd = true;
|
||||||
|
subscription.autoRenew = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.subscriptionRepository.save(subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactivate cancelled subscription
|
||||||
|
*/
|
||||||
|
async reactivate(id: string): Promise<TenantSubscription> {
|
||||||
|
const subscription = await this.findById(id);
|
||||||
|
if (!subscription) {
|
||||||
|
throw new Error('Subscription not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscription.status !== 'cancelled' && !subscription.cancelAtPeriodEnd) {
|
||||||
|
throw new Error('Subscription is not cancelled');
|
||||||
|
}
|
||||||
|
|
||||||
|
subscription.status = 'active';
|
||||||
|
subscription.cancelAtPeriodEnd = false;
|
||||||
|
subscription.cancellationReason = null as any;
|
||||||
|
subscription.cancelledAt = null as any;
|
||||||
|
subscription.autoRenew = true;
|
||||||
|
|
||||||
|
return this.subscriptionRepository.save(subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change subscription plan
|
||||||
|
*/
|
||||||
|
async changePlan(id: string, dto: ChangePlanDto): Promise<TenantSubscription> {
|
||||||
|
const subscription = await this.findById(id);
|
||||||
|
if (!subscription) {
|
||||||
|
throw new Error('Subscription not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPlan = await this.planRepository.findOne({ where: { id: dto.newPlanId } });
|
||||||
|
if (!newPlan) {
|
||||||
|
throw new Error('New plan not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate new price
|
||||||
|
const newPrice =
|
||||||
|
subscription.billingCycle === 'annual' && newPlan.baseAnnualPrice
|
||||||
|
? newPlan.baseAnnualPrice
|
||||||
|
: newPlan.baseMonthlyPrice;
|
||||||
|
|
||||||
|
// Apply existing discount if any
|
||||||
|
const discountedPrice = newPrice * (1 - (subscription.discountPercent || 0) / 100);
|
||||||
|
|
||||||
|
subscription.planId = dto.newPlanId;
|
||||||
|
subscription.currentPrice = discountedPrice;
|
||||||
|
subscription.contractedUsers = newPlan.maxUsers;
|
||||||
|
subscription.contractedBranches = newPlan.maxBranches;
|
||||||
|
|
||||||
|
// If effective immediately and prorate, calculate adjustment
|
||||||
|
// This would typically create a credit/debit memo
|
||||||
|
|
||||||
|
return this.subscriptionRepository.save(subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set payment method
|
||||||
|
*/
|
||||||
|
async setPaymentMethod(id: string, dto: SetPaymentMethodDto): Promise<TenantSubscription> {
|
||||||
|
const subscription = await this.findById(id);
|
||||||
|
if (!subscription) {
|
||||||
|
throw new Error('Subscription not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
subscription.paymentMethodId = dto.paymentMethodId;
|
||||||
|
subscription.paymentProvider = dto.paymentProvider;
|
||||||
|
|
||||||
|
return this.subscriptionRepository.save(subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renew subscription (for periodic billing)
|
||||||
|
*/
|
||||||
|
async renew(id: string): Promise<TenantSubscription> {
|
||||||
|
const subscription = await this.findById(id);
|
||||||
|
if (!subscription) {
|
||||||
|
throw new Error('Subscription not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!subscription.autoRenew) {
|
||||||
|
throw new Error('Subscription auto-renew is disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscription.cancelAtPeriodEnd) {
|
||||||
|
subscription.status = 'cancelled';
|
||||||
|
return this.subscriptionRepository.save(subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate new period
|
||||||
|
const newPeriodStart = subscription.currentPeriodEnd;
|
||||||
|
const newPeriodEnd = this.calculatePeriodEnd(newPeriodStart, subscription.billingCycle);
|
||||||
|
|
||||||
|
subscription.currentPeriodStart = newPeriodStart;
|
||||||
|
subscription.currentPeriodEnd = newPeriodEnd;
|
||||||
|
subscription.nextInvoiceDate = newPeriodEnd;
|
||||||
|
|
||||||
|
// Reset trial status if was in trial
|
||||||
|
if (subscription.status === 'trial') {
|
||||||
|
subscription.status = 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.subscriptionRepository.save(subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark subscription as past due
|
||||||
|
*/
|
||||||
|
async markPastDue(id: string): Promise<TenantSubscription> {
|
||||||
|
return this.updateStatus(id, 'past_due');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suspend subscription
|
||||||
|
*/
|
||||||
|
async suspend(id: string): Promise<TenantSubscription> {
|
||||||
|
return this.updateStatus(id, 'suspended');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activate subscription (from suspended or past_due)
|
||||||
|
*/
|
||||||
|
async activate(id: string): Promise<TenantSubscription> {
|
||||||
|
return this.updateStatus(id, 'active');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update subscription status
|
||||||
|
*/
|
||||||
|
private async updateStatus(id: string, status: SubscriptionStatus): Promise<TenantSubscription> {
|
||||||
|
const subscription = await this.findById(id);
|
||||||
|
if (!subscription) {
|
||||||
|
throw new Error('Subscription not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
subscription.status = status;
|
||||||
|
return this.subscriptionRepository.save(subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find subscriptions expiring soon
|
||||||
|
*/
|
||||||
|
async findExpiringSoon(days: number = 7): Promise<TenantSubscription[]> {
|
||||||
|
const futureDate = new Date();
|
||||||
|
futureDate.setDate(futureDate.getDate() + days);
|
||||||
|
|
||||||
|
return this.subscriptionRepository
|
||||||
|
.createQueryBuilder('sub')
|
||||||
|
.leftJoinAndSelect('sub.plan', 'plan')
|
||||||
|
.where('sub.currentPeriodEnd <= :futureDate', { futureDate })
|
||||||
|
.andWhere("sub.status IN ('active', 'trial')")
|
||||||
|
.andWhere('sub.cancelAtPeriodEnd = false')
|
||||||
|
.orderBy('sub.currentPeriodEnd', 'ASC')
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find subscriptions with trials ending soon
|
||||||
|
*/
|
||||||
|
async findTrialsEndingSoon(days: number = 3): Promise<TenantSubscription[]> {
|
||||||
|
const futureDate = new Date();
|
||||||
|
futureDate.setDate(futureDate.getDate() + days);
|
||||||
|
|
||||||
|
return this.subscriptionRepository
|
||||||
|
.createQueryBuilder('sub')
|
||||||
|
.leftJoinAndSelect('sub.plan', 'plan')
|
||||||
|
.where("sub.status = 'trial'")
|
||||||
|
.andWhere('sub.trialEnd <= :futureDate', { futureDate })
|
||||||
|
.orderBy('sub.trialEnd', 'ASC')
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate period end date based on billing cycle
|
||||||
|
*/
|
||||||
|
private calculatePeriodEnd(start: Date, cycle: BillingCycle): Date {
|
||||||
|
const end = new Date(start);
|
||||||
|
if (cycle === 'annual') {
|
||||||
|
end.setFullYear(end.getFullYear() + 1);
|
||||||
|
} else {
|
||||||
|
end.setMonth(end.getMonth() + 1);
|
||||||
|
}
|
||||||
|
return end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get subscription statistics
|
||||||
|
*/
|
||||||
|
async getStats(): Promise<{
|
||||||
|
total: number;
|
||||||
|
byStatus: Record<SubscriptionStatus, number>;
|
||||||
|
byPlan: Record<string, number>;
|
||||||
|
totalMRR: number;
|
||||||
|
totalARR: number;
|
||||||
|
}> {
|
||||||
|
const subscriptions = await this.subscriptionRepository.find({
|
||||||
|
relations: ['plan'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const byStatus: Record<SubscriptionStatus, number> = {
|
||||||
|
trial: 0,
|
||||||
|
active: 0,
|
||||||
|
past_due: 0,
|
||||||
|
cancelled: 0,
|
||||||
|
suspended: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const byPlan: Record<string, number> = {};
|
||||||
|
let totalMRR = 0;
|
||||||
|
|
||||||
|
for (const sub of subscriptions) {
|
||||||
|
byStatus[sub.status]++;
|
||||||
|
|
||||||
|
const planCode = sub.plan?.code || 'unknown';
|
||||||
|
byPlan[planCode] = (byPlan[planCode] || 0) + 1;
|
||||||
|
|
||||||
|
if (sub.status === 'active' || sub.status === 'trial') {
|
||||||
|
const monthlyPrice =
|
||||||
|
sub.billingCycle === 'annual'
|
||||||
|
? Number(sub.currentPrice) / 12
|
||||||
|
: Number(sub.currentPrice);
|
||||||
|
totalMRR += monthlyPrice;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: subscriptions.length,
|
||||||
|
byStatus,
|
||||||
|
byPlan,
|
||||||
|
totalMRR,
|
||||||
|
totalARR: totalMRR * 12,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
381
src/modules/billing-usage/services/usage-tracking.service.ts
Normal file
381
src/modules/billing-usage/services/usage-tracking.service.ts
Normal file
@ -0,0 +1,381 @@
|
|||||||
|
/**
|
||||||
|
* Usage Tracking Service
|
||||||
|
*
|
||||||
|
* Service for tracking and reporting usage metrics
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Repository, DataSource, Between, MoreThanOrEqual, LessThanOrEqual } from 'typeorm';
|
||||||
|
import { UsageTracking, TenantSubscription, SubscriptionPlan } from '../entities';
|
||||||
|
import { RecordUsageDto, UpdateUsageDto, UsageMetrics, UsageSummaryDto } from '../dto';
|
||||||
|
|
||||||
|
export class UsageTrackingService {
|
||||||
|
private usageRepository: Repository<UsageTracking>;
|
||||||
|
private subscriptionRepository: Repository<TenantSubscription>;
|
||||||
|
private planRepository: Repository<SubscriptionPlan>;
|
||||||
|
|
||||||
|
constructor(private dataSource: DataSource) {
|
||||||
|
this.usageRepository = dataSource.getRepository(UsageTracking);
|
||||||
|
this.subscriptionRepository = dataSource.getRepository(TenantSubscription);
|
||||||
|
this.planRepository = dataSource.getRepository(SubscriptionPlan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record usage for a period
|
||||||
|
*/
|
||||||
|
async recordUsage(dto: RecordUsageDto): Promise<UsageTracking> {
|
||||||
|
// Check if record exists for this tenant/period
|
||||||
|
const existing = await this.usageRepository.findOne({
|
||||||
|
where: {
|
||||||
|
tenantId: dto.tenantId,
|
||||||
|
periodStart: dto.periodStart,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Update existing record
|
||||||
|
return this.update(existing.id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
const usage = this.usageRepository.create({
|
||||||
|
tenantId: dto.tenantId,
|
||||||
|
periodStart: dto.periodStart,
|
||||||
|
periodEnd: dto.periodEnd,
|
||||||
|
activeUsers: dto.activeUsers || 0,
|
||||||
|
peakConcurrentUsers: dto.peakConcurrentUsers || 0,
|
||||||
|
usersByProfile: dto.usersByProfile || {},
|
||||||
|
usersByPlatform: dto.usersByPlatform || {},
|
||||||
|
activeBranches: dto.activeBranches || 0,
|
||||||
|
storageUsedGb: dto.storageUsedGb || 0,
|
||||||
|
documentsCount: dto.documentsCount || 0,
|
||||||
|
apiCalls: dto.apiCalls || 0,
|
||||||
|
apiErrors: dto.apiErrors || 0,
|
||||||
|
salesCount: dto.salesCount || 0,
|
||||||
|
salesAmount: dto.salesAmount || 0,
|
||||||
|
invoicesGenerated: dto.invoicesGenerated || 0,
|
||||||
|
mobileSessions: dto.mobileSessions || 0,
|
||||||
|
offlineSyncs: dto.offlineSyncs || 0,
|
||||||
|
paymentTransactions: dto.paymentTransactions || 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate billable amount
|
||||||
|
usage.totalBillableAmount = await this.calculateBillableAmount(dto.tenantId, usage);
|
||||||
|
|
||||||
|
return this.usageRepository.save(usage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update usage record
|
||||||
|
*/
|
||||||
|
async update(id: string, dto: UpdateUsageDto): Promise<UsageTracking> {
|
||||||
|
const usage = await this.usageRepository.findOne({ where: { id } });
|
||||||
|
if (!usage) {
|
||||||
|
throw new Error('Usage record not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(usage, dto);
|
||||||
|
usage.totalBillableAmount = await this.calculateBillableAmount(usage.tenantId, usage);
|
||||||
|
|
||||||
|
return this.usageRepository.save(usage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increment a specific metric
|
||||||
|
*/
|
||||||
|
async incrementMetric(
|
||||||
|
tenantId: string,
|
||||||
|
metric: keyof UsageMetrics,
|
||||||
|
amount: number = 1
|
||||||
|
): Promise<void> {
|
||||||
|
const currentPeriod = this.getCurrentPeriodDates();
|
||||||
|
|
||||||
|
let usage = await this.usageRepository.findOne({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
periodStart: currentPeriod.start,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!usage) {
|
||||||
|
usage = await this.recordUsage({
|
||||||
|
tenantId,
|
||||||
|
periodStart: currentPeriod.start,
|
||||||
|
periodEnd: currentPeriod.end,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment the specific metric
|
||||||
|
(usage as any)[metric] = ((usage as any)[metric] || 0) + amount;
|
||||||
|
|
||||||
|
await this.usageRepository.save(usage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current usage for tenant
|
||||||
|
*/
|
||||||
|
async getCurrentUsage(tenantId: string): Promise<UsageTracking | null> {
|
||||||
|
const currentPeriod = this.getCurrentPeriodDates();
|
||||||
|
|
||||||
|
return this.usageRepository.findOne({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
periodStart: currentPeriod.start,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get usage history for tenant
|
||||||
|
*/
|
||||||
|
async getUsageHistory(
|
||||||
|
tenantId: string,
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date
|
||||||
|
): Promise<UsageTracking[]> {
|
||||||
|
return this.usageRepository.find({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
periodStart: MoreThanOrEqual(startDate),
|
||||||
|
periodEnd: LessThanOrEqual(endDate),
|
||||||
|
},
|
||||||
|
order: { periodStart: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get usage summary with limits comparison
|
||||||
|
*/
|
||||||
|
async getUsageSummary(tenantId: string): Promise<UsageSummaryDto> {
|
||||||
|
const subscription = await this.subscriptionRepository.findOne({
|
||||||
|
where: { tenantId },
|
||||||
|
relations: ['plan'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
throw new Error('Subscription not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUsage = await this.getCurrentUsage(tenantId);
|
||||||
|
const plan = subscription.plan;
|
||||||
|
|
||||||
|
const summary: UsageSummaryDto = {
|
||||||
|
tenantId,
|
||||||
|
currentUsers: currentUsage?.activeUsers || 0,
|
||||||
|
currentBranches: currentUsage?.activeBranches || 0,
|
||||||
|
currentStorageGb: Number(currentUsage?.storageUsedGb || 0),
|
||||||
|
apiCallsThisMonth: currentUsage?.apiCalls || 0,
|
||||||
|
salesThisMonth: currentUsage?.salesCount || 0,
|
||||||
|
salesAmountThisMonth: Number(currentUsage?.salesAmount || 0),
|
||||||
|
limits: {
|
||||||
|
maxUsers: subscription.contractedUsers || plan.maxUsers,
|
||||||
|
maxBranches: subscription.contractedBranches || plan.maxBranches,
|
||||||
|
maxStorageGb: plan.storageGb,
|
||||||
|
maxApiCalls: plan.apiCallsMonthly,
|
||||||
|
},
|
||||||
|
percentages: {
|
||||||
|
usersUsed: 0,
|
||||||
|
branchesUsed: 0,
|
||||||
|
storageUsed: 0,
|
||||||
|
apiCallsUsed: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate percentages
|
||||||
|
summary.percentages.usersUsed =
|
||||||
|
Math.round((summary.currentUsers / summary.limits.maxUsers) * 100);
|
||||||
|
summary.percentages.branchesUsed =
|
||||||
|
Math.round((summary.currentBranches / summary.limits.maxBranches) * 100);
|
||||||
|
summary.percentages.storageUsed =
|
||||||
|
Math.round((summary.currentStorageGb / summary.limits.maxStorageGb) * 100);
|
||||||
|
summary.percentages.apiCallsUsed =
|
||||||
|
Math.round((summary.apiCallsThisMonth / summary.limits.maxApiCalls) * 100);
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if tenant exceeds limits
|
||||||
|
*/
|
||||||
|
async checkLimits(tenantId: string): Promise<{
|
||||||
|
exceeds: boolean;
|
||||||
|
violations: string[];
|
||||||
|
warnings: string[];
|
||||||
|
}> {
|
||||||
|
const summary = await this.getUsageSummary(tenantId);
|
||||||
|
const violations: string[] = [];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
// Check hard limits
|
||||||
|
if (summary.currentUsers > summary.limits.maxUsers) {
|
||||||
|
violations.push(`Users: ${summary.currentUsers}/${summary.limits.maxUsers}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary.currentBranches > summary.limits.maxBranches) {
|
||||||
|
violations.push(`Branches: ${summary.currentBranches}/${summary.limits.maxBranches}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary.currentStorageGb > summary.limits.maxStorageGb) {
|
||||||
|
violations.push(
|
||||||
|
`Storage: ${summary.currentStorageGb}GB/${summary.limits.maxStorageGb}GB`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check warnings (80% threshold)
|
||||||
|
if (summary.percentages.usersUsed >= 80 && summary.percentages.usersUsed < 100) {
|
||||||
|
warnings.push(`Users at ${summary.percentages.usersUsed}% capacity`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary.percentages.branchesUsed >= 80 && summary.percentages.branchesUsed < 100) {
|
||||||
|
warnings.push(`Branches at ${summary.percentages.branchesUsed}% capacity`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary.percentages.storageUsed >= 80 && summary.percentages.storageUsed < 100) {
|
||||||
|
warnings.push(`Storage at ${summary.percentages.storageUsed}% capacity`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary.percentages.apiCallsUsed >= 80 && summary.percentages.apiCallsUsed < 100) {
|
||||||
|
warnings.push(`API calls at ${summary.percentages.apiCallsUsed}% capacity`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
exceeds: violations.length > 0,
|
||||||
|
violations,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get usage report
|
||||||
|
*/
|
||||||
|
async getUsageReport(
|
||||||
|
tenantId: string,
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date,
|
||||||
|
granularity: 'daily' | 'weekly' | 'monthly' = 'monthly'
|
||||||
|
): Promise<{
|
||||||
|
tenantId: string;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
granularity: string;
|
||||||
|
data: UsageTracking[];
|
||||||
|
totals: {
|
||||||
|
apiCalls: number;
|
||||||
|
salesCount: number;
|
||||||
|
salesAmount: number;
|
||||||
|
mobileSessions: number;
|
||||||
|
paymentTransactions: number;
|
||||||
|
};
|
||||||
|
averages: {
|
||||||
|
activeUsers: number;
|
||||||
|
activeBranches: number;
|
||||||
|
storageUsedGb: number;
|
||||||
|
};
|
||||||
|
}> {
|
||||||
|
const data = await this.getUsageHistory(tenantId, startDate, endDate);
|
||||||
|
|
||||||
|
// Calculate totals
|
||||||
|
const totals = {
|
||||||
|
apiCalls: 0,
|
||||||
|
salesCount: 0,
|
||||||
|
salesAmount: 0,
|
||||||
|
mobileSessions: 0,
|
||||||
|
paymentTransactions: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let totalUsers = 0;
|
||||||
|
let totalBranches = 0;
|
||||||
|
let totalStorage = 0;
|
||||||
|
|
||||||
|
for (const record of data) {
|
||||||
|
totals.apiCalls += record.apiCalls;
|
||||||
|
totals.salesCount += record.salesCount;
|
||||||
|
totals.salesAmount += Number(record.salesAmount);
|
||||||
|
totals.mobileSessions += record.mobileSessions;
|
||||||
|
totals.paymentTransactions += record.paymentTransactions;
|
||||||
|
|
||||||
|
totalUsers += record.activeUsers;
|
||||||
|
totalBranches += record.activeBranches;
|
||||||
|
totalStorage += Number(record.storageUsedGb);
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = data.length || 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
tenantId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
granularity,
|
||||||
|
data,
|
||||||
|
totals,
|
||||||
|
averages: {
|
||||||
|
activeUsers: Math.round(totalUsers / count),
|
||||||
|
activeBranches: Math.round(totalBranches / count),
|
||||||
|
storageUsedGb: Math.round((totalStorage / count) * 100) / 100,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate billable amount based on usage
|
||||||
|
*/
|
||||||
|
private async calculateBillableAmount(
|
||||||
|
tenantId: string,
|
||||||
|
usage: UsageTracking
|
||||||
|
): Promise<number> {
|
||||||
|
const subscription = await this.subscriptionRepository.findOne({
|
||||||
|
where: { tenantId },
|
||||||
|
relations: ['plan'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let billableAmount = Number(subscription.currentPrice);
|
||||||
|
|
||||||
|
// Add overage charges if applicable
|
||||||
|
const plan = subscription.plan;
|
||||||
|
|
||||||
|
// Extra users
|
||||||
|
const extraUsers = Math.max(0, usage.activeUsers - (subscription.contractedUsers || plan.maxUsers));
|
||||||
|
if (extraUsers > 0) {
|
||||||
|
// Assume $10 per extra user per month
|
||||||
|
billableAmount += extraUsers * 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extra branches
|
||||||
|
const extraBranches = Math.max(
|
||||||
|
0,
|
||||||
|
usage.activeBranches - (subscription.contractedBranches || plan.maxBranches)
|
||||||
|
);
|
||||||
|
if (extraBranches > 0) {
|
||||||
|
// Assume $20 per extra branch per month
|
||||||
|
billableAmount += extraBranches * 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extra storage
|
||||||
|
const extraStorageGb = Math.max(0, Number(usage.storageUsedGb) - plan.storageGb);
|
||||||
|
if (extraStorageGb > 0) {
|
||||||
|
// Assume $0.50 per extra GB
|
||||||
|
billableAmount += extraStorageGb * 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extra API calls
|
||||||
|
const extraApiCalls = Math.max(0, usage.apiCalls - plan.apiCallsMonthly);
|
||||||
|
if (extraApiCalls > 0) {
|
||||||
|
// Assume $0.001 per extra API call
|
||||||
|
billableAmount += extraApiCalls * 0.001;
|
||||||
|
}
|
||||||
|
|
||||||
|
return billableAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current period dates (first and last day of current month)
|
||||||
|
*/
|
||||||
|
private getCurrentPeriodDates(): { start: Date; end: Date } {
|
||||||
|
const now = new Date();
|
||||||
|
const start = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||||
|
return { start, end };
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,81 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
DeleteDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
Unique,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Device, BiometricType } from './device.entity';
|
||||||
|
|
||||||
|
@Entity({ name: 'biometric_credentials', schema: 'auth' })
|
||||||
|
@Unique(['deviceId', 'credentialId'])
|
||||||
|
export class BiometricCredential {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'device_id', type: 'uuid' })
|
||||||
|
deviceId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'user_id', type: 'uuid' })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
// Tipo de biometrico
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'biometric_type', type: 'varchar', length: 50 })
|
||||||
|
biometricType: BiometricType;
|
||||||
|
|
||||||
|
// Credencial (public key para WebAuthn/FIDO2)
|
||||||
|
@Column({ name: 'credential_id', type: 'text' })
|
||||||
|
credentialId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'public_key', type: 'text' })
|
||||||
|
publicKey: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, default: 'ES256' })
|
||||||
|
algorithm: string;
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
@Column({ name: 'credential_name', type: 'varchar', length: 100, nullable: true })
|
||||||
|
credentialName: string; // "Huella indice derecho", "Face ID iPhone"
|
||||||
|
|
||||||
|
@Column({ name: 'is_primary', type: 'boolean', default: false })
|
||||||
|
isPrimary: boolean;
|
||||||
|
|
||||||
|
// Estado
|
||||||
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'last_used_at', type: 'timestamptz', nullable: true })
|
||||||
|
lastUsedAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'use_count', type: 'integer', default: 0 })
|
||||||
|
useCount: number;
|
||||||
|
|
||||||
|
// Seguridad
|
||||||
|
@Column({ name: 'failed_attempts', type: 'integer', default: 0 })
|
||||||
|
failedAttempts: number;
|
||||||
|
|
||||||
|
@Column({ name: 'locked_until', type: 'timestamptz', nullable: true })
|
||||||
|
lockedUntil: Date;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||||
|
deletedAt: Date;
|
||||||
|
|
||||||
|
// Relaciones
|
||||||
|
@ManyToOne(() => Device, (device) => device.biometricCredentials, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'device_id' })
|
||||||
|
device: Device;
|
||||||
|
}
|
||||||
@ -0,0 +1,50 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export type ActivityType = 'login' | 'logout' | 'biometric_auth' | 'location_update' | 'app_open' | 'app_close';
|
||||||
|
export type ActivityStatus = 'success' | 'failed' | 'blocked';
|
||||||
|
|
||||||
|
@Entity({ name: 'device_activity_log', schema: 'auth' })
|
||||||
|
export class DeviceActivityLog {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'device_id', type: 'uuid' })
|
||||||
|
deviceId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'user_id', type: 'uuid', nullable: true })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
// Actividad
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'activity_type', type: 'varchar', length: 50 })
|
||||||
|
activityType: ActivityType;
|
||||||
|
|
||||||
|
@Column({ name: 'activity_status', type: 'varchar', length: 20 })
|
||||||
|
activityStatus: ActivityStatus;
|
||||||
|
|
||||||
|
// Detalles
|
||||||
|
@Column({ type: 'jsonb', default: {} })
|
||||||
|
details: Record<string, any>;
|
||||||
|
|
||||||
|
// Ubicacion
|
||||||
|
@Column({ name: 'ip_address', type: 'inet', nullable: true })
|
||||||
|
ipAddress: string;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 10, scale: 8, nullable: true })
|
||||||
|
latitude: number;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 11, scale: 8, nullable: true })
|
||||||
|
longitude: number;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
84
src/modules/biometrics/entities/device-session.entity.ts
Normal file
84
src/modules/biometrics/entities/device-session.entity.ts
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Device } from './device.entity';
|
||||||
|
|
||||||
|
export type AuthMethod = 'password' | 'biometric' | 'oauth' | 'mfa';
|
||||||
|
|
||||||
|
@Entity({ name: 'device_sessions', schema: 'auth' })
|
||||||
|
export class DeviceSession {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'device_id', type: 'uuid' })
|
||||||
|
deviceId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'user_id', type: 'uuid' })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid', nullable: true })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
// Tokens
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'access_token_hash', type: 'varchar', length: 255 })
|
||||||
|
accessTokenHash: string;
|
||||||
|
|
||||||
|
@Column({ name: 'refresh_token_hash', type: 'varchar', length: 255, nullable: true })
|
||||||
|
refreshTokenHash: string;
|
||||||
|
|
||||||
|
// Metodo de autenticacion
|
||||||
|
@Column({ name: 'auth_method', type: 'varchar', length: 50 })
|
||||||
|
authMethod: AuthMethod;
|
||||||
|
|
||||||
|
// Validez
|
||||||
|
@Column({ name: 'issued_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
|
issuedAt: Date;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'expires_at', type: 'timestamptz' })
|
||||||
|
expiresAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'refresh_expires_at', type: 'timestamptz', nullable: true })
|
||||||
|
refreshExpiresAt: Date;
|
||||||
|
|
||||||
|
// Estado
|
||||||
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'revoked_at', type: 'timestamptz', nullable: true })
|
||||||
|
revokedAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'revoked_reason', type: 'varchar', length: 100, nullable: true })
|
||||||
|
revokedReason: string;
|
||||||
|
|
||||||
|
// Ubicacion
|
||||||
|
@Column({ name: 'ip_address', type: 'inet', nullable: true })
|
||||||
|
ipAddress: string;
|
||||||
|
|
||||||
|
@Column({ name: 'user_agent', type: 'text', nullable: true })
|
||||||
|
userAgent: string;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 10, scale: 8, nullable: true })
|
||||||
|
latitude: number;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 11, scale: 8, nullable: true })
|
||||||
|
longitude: number;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
// Relaciones
|
||||||
|
@ManyToOne(() => Device, (device) => device.sessions, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'device_id' })
|
||||||
|
device: Device;
|
||||||
|
}
|
||||||
121
src/modules/biometrics/entities/device.entity.ts
Normal file
121
src/modules/biometrics/entities/device.entity.ts
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
DeleteDateColumn,
|
||||||
|
Index,
|
||||||
|
OneToMany,
|
||||||
|
Unique,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { BiometricCredential } from './biometric-credential.entity';
|
||||||
|
import { DeviceSession } from './device-session.entity';
|
||||||
|
|
||||||
|
export type DevicePlatform = 'ios' | 'android' | 'web' | 'desktop';
|
||||||
|
export type BiometricType = 'fingerprint' | 'face_id' | 'face_recognition' | 'iris';
|
||||||
|
|
||||||
|
@Entity({ name: 'devices', schema: 'auth' })
|
||||||
|
@Unique(['userId', 'deviceUuid'])
|
||||||
|
export class Device {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'user_id', type: 'uuid' })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid', nullable: true })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
// Identificacion del dispositivo
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'device_uuid', type: 'varchar', length: 100 })
|
||||||
|
deviceUuid: string;
|
||||||
|
|
||||||
|
@Column({ name: 'device_name', type: 'varchar', length: 100, nullable: true })
|
||||||
|
deviceName: string;
|
||||||
|
|
||||||
|
@Column({ name: 'device_model', type: 'varchar', length: 100, nullable: true })
|
||||||
|
deviceModel: string;
|
||||||
|
|
||||||
|
@Column({ name: 'device_brand', type: 'varchar', length: 50, nullable: true })
|
||||||
|
deviceBrand: string;
|
||||||
|
|
||||||
|
// Plataforma
|
||||||
|
@Index()
|
||||||
|
@Column({ type: 'varchar', length: 20 })
|
||||||
|
platform: DevicePlatform;
|
||||||
|
|
||||||
|
@Column({ name: 'platform_version', type: 'varchar', length: 20, nullable: true })
|
||||||
|
platformVersion: string;
|
||||||
|
|
||||||
|
@Column({ name: 'app_version', type: 'varchar', length: 20, nullable: true })
|
||||||
|
appVersion: string;
|
||||||
|
|
||||||
|
// Estado
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'is_trusted', type: 'boolean', default: false })
|
||||||
|
isTrusted: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'trust_level', type: 'integer', default: 0 })
|
||||||
|
trustLevel: number; // 0=none, 1=low, 2=medium, 3=high
|
||||||
|
|
||||||
|
// Biometricos habilitados
|
||||||
|
@Column({ name: 'biometric_enabled', type: 'boolean', default: false })
|
||||||
|
biometricEnabled: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'biometric_type', type: 'varchar', length: 50, nullable: true })
|
||||||
|
biometricType: BiometricType;
|
||||||
|
|
||||||
|
// Push notifications
|
||||||
|
@Column({ name: 'push_token', type: 'text', nullable: true })
|
||||||
|
pushToken: string;
|
||||||
|
|
||||||
|
@Column({ name: 'push_token_updated_at', type: 'timestamptz', nullable: true })
|
||||||
|
pushTokenUpdatedAt: Date;
|
||||||
|
|
||||||
|
// Ubicacion ultima conocida
|
||||||
|
@Column({ name: 'last_latitude', type: 'decimal', precision: 10, scale: 8, nullable: true })
|
||||||
|
lastLatitude: number;
|
||||||
|
|
||||||
|
@Column({ name: 'last_longitude', type: 'decimal', precision: 11, scale: 8, nullable: true })
|
||||||
|
lastLongitude: number;
|
||||||
|
|
||||||
|
@Column({ name: 'last_location_at', type: 'timestamptz', nullable: true })
|
||||||
|
lastLocationAt: Date;
|
||||||
|
|
||||||
|
// Seguridad
|
||||||
|
@Column({ name: 'last_ip_address', type: 'inet', nullable: true })
|
||||||
|
lastIpAddress: string;
|
||||||
|
|
||||||
|
@Column({ name: 'last_user_agent', type: 'text', nullable: true })
|
||||||
|
lastUserAgent: string;
|
||||||
|
|
||||||
|
// Registro
|
||||||
|
@Column({ name: 'first_seen_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
|
firstSeenAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'last_seen_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
|
lastSeenAt: Date;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||||
|
deletedAt: Date;
|
||||||
|
|
||||||
|
// Relaciones
|
||||||
|
@OneToMany(() => BiometricCredential, (credential) => credential.device)
|
||||||
|
biometricCredentials: BiometricCredential[];
|
||||||
|
|
||||||
|
@OneToMany(() => DeviceSession, (session) => session.device)
|
||||||
|
sessions: DeviceSession[];
|
||||||
|
}
|
||||||
4
src/modules/biometrics/entities/index.ts
Normal file
4
src/modules/biometrics/entities/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export { Device, DevicePlatform, BiometricType } from './device.entity';
|
||||||
|
export { BiometricCredential } from './biometric-credential.entity';
|
||||||
|
export { DeviceSession, AuthMethod } from './device-session.entity';
|
||||||
|
export { DeviceActivityLog, ActivityType, ActivityStatus } from './device-activity-log.entity';
|
||||||
48
src/modules/branches/branches.module.ts
Normal file
48
src/modules/branches/branches.module.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { BranchesService } from './services';
|
||||||
|
import { BranchesController } from './controllers';
|
||||||
|
import { Branch, UserBranchAssignment, BranchSchedule, BranchPaymentTerminal } from './entities';
|
||||||
|
|
||||||
|
export interface BranchesModuleOptions {
|
||||||
|
dataSource: DataSource;
|
||||||
|
basePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BranchesModule {
|
||||||
|
public router: Router;
|
||||||
|
public branchesService: BranchesService;
|
||||||
|
private dataSource: DataSource;
|
||||||
|
private basePath: string;
|
||||||
|
|
||||||
|
constructor(options: BranchesModuleOptions) {
|
||||||
|
this.dataSource = options.dataSource;
|
||||||
|
this.basePath = options.basePath || '';
|
||||||
|
this.router = Router();
|
||||||
|
this.initializeServices();
|
||||||
|
this.initializeRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeServices(): void {
|
||||||
|
const branchRepository = this.dataSource.getRepository(Branch);
|
||||||
|
const assignmentRepository = this.dataSource.getRepository(UserBranchAssignment);
|
||||||
|
const scheduleRepository = this.dataSource.getRepository(BranchSchedule);
|
||||||
|
const terminalRepository = this.dataSource.getRepository(BranchPaymentTerminal);
|
||||||
|
|
||||||
|
this.branchesService = new BranchesService(
|
||||||
|
branchRepository,
|
||||||
|
assignmentRepository,
|
||||||
|
scheduleRepository,
|
||||||
|
terminalRepository
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeRoutes(): void {
|
||||||
|
const branchesController = new BranchesController(this.branchesService);
|
||||||
|
this.router.use(`${this.basePath}/branches`, branchesController.router);
|
||||||
|
}
|
||||||
|
|
||||||
|
static getEntities(): Function[] {
|
||||||
|
return [Branch, UserBranchAssignment, BranchSchedule, BranchPaymentTerminal];
|
||||||
|
}
|
||||||
|
}
|
||||||
364
src/modules/branches/controllers/branches.controller.ts
Normal file
364
src/modules/branches/controllers/branches.controller.ts
Normal file
@ -0,0 +1,364 @@
|
|||||||
|
import { Request, Response, NextFunction, Router } from 'express';
|
||||||
|
import { BranchesService } from '../services/branches.service';
|
||||||
|
import { CreateBranchDto, UpdateBranchDto, AssignUserToBranchDto, CreateBranchScheduleDto } from '../dto';
|
||||||
|
|
||||||
|
export class BranchesController {
|
||||||
|
public router: Router;
|
||||||
|
|
||||||
|
constructor(private readonly branchesService: BranchesService) {
|
||||||
|
this.router = Router();
|
||||||
|
this.initializeRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeRoutes(): void {
|
||||||
|
// Branch CRUD
|
||||||
|
this.router.get('/', this.findAll.bind(this));
|
||||||
|
this.router.get('/hierarchy', this.getHierarchy.bind(this));
|
||||||
|
this.router.get('/main', this.getMainBranch.bind(this));
|
||||||
|
this.router.get('/nearby', this.findNearbyBranches.bind(this));
|
||||||
|
this.router.get('/:id', this.findOne.bind(this));
|
||||||
|
this.router.get('/code/:code', this.findByCode.bind(this));
|
||||||
|
this.router.post('/', this.create.bind(this));
|
||||||
|
this.router.patch('/:id', this.update.bind(this));
|
||||||
|
this.router.delete('/:id', this.delete.bind(this));
|
||||||
|
this.router.post('/:id/set-main', this.setAsMainBranch.bind(this));
|
||||||
|
|
||||||
|
// Hierarchy
|
||||||
|
this.router.get('/:id/children', this.getChildren.bind(this));
|
||||||
|
this.router.get('/:id/parents', this.getParents.bind(this));
|
||||||
|
|
||||||
|
// User Assignments
|
||||||
|
this.router.post('/assign', this.assignUser.bind(this));
|
||||||
|
this.router.delete('/assign/:userId/:branchId', this.unassignUser.bind(this));
|
||||||
|
this.router.get('/user/:userId', this.getUserBranches.bind(this));
|
||||||
|
this.router.get('/user/:userId/primary', this.getPrimaryBranch.bind(this));
|
||||||
|
this.router.get('/:id/users', this.getBranchUsers.bind(this));
|
||||||
|
|
||||||
|
// Geofencing
|
||||||
|
this.router.post('/validate-geofence', this.validateGeofence.bind(this));
|
||||||
|
|
||||||
|
// Schedules
|
||||||
|
this.router.get('/:id/schedules', this.getSchedules.bind(this));
|
||||||
|
this.router.post('/:id/schedules', this.addSchedule.bind(this));
|
||||||
|
this.router.get('/:id/is-open', this.isOpenNow.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// BRANCH CRUD
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
private async findAll(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const { search, branchType, isActive, parentId, limit, offset } = req.query;
|
||||||
|
|
||||||
|
const result = await this.branchesService.findAll(tenantId, {
|
||||||
|
search: search as string,
|
||||||
|
branchType: branchType as string,
|
||||||
|
isActive: isActive ? isActive === 'true' : undefined,
|
||||||
|
parentId: parentId as string,
|
||||||
|
limit: limit ? parseInt(limit as string, 10) : undefined,
|
||||||
|
offset: offset ? parseInt(offset as string, 10) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findOne(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const branch = await this.branchesService.findOne(id);
|
||||||
|
|
||||||
|
if (!branch) {
|
||||||
|
res.status(404).json({ error: 'Branch not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: branch });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findByCode(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const { code } = req.params;
|
||||||
|
const branch = await this.branchesService.findByCode(tenantId, code);
|
||||||
|
|
||||||
|
if (!branch) {
|
||||||
|
res.status(404).json({ error: 'Branch not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: branch });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async create(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = req.headers['x-user-id'] as string;
|
||||||
|
const dto: CreateBranchDto = req.body;
|
||||||
|
|
||||||
|
const branch = await this.branchesService.create(tenantId, dto, userId);
|
||||||
|
res.status(201).json({ data: branch });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async update(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const userId = req.headers['x-user-id'] as string;
|
||||||
|
const dto: UpdateBranchDto = req.body;
|
||||||
|
|
||||||
|
const branch = await this.branchesService.update(id, dto, userId);
|
||||||
|
|
||||||
|
if (!branch) {
|
||||||
|
res.status(404).json({ error: 'Branch not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: branch });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async delete(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const deleted = await this.branchesService.delete(id);
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
res.status(404).json({ error: 'Branch not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// HIERARCHY
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
private async getHierarchy(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const hierarchy = await this.branchesService.getHierarchy(tenantId);
|
||||||
|
res.json({ data: hierarchy });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getChildren(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { recursive } = req.query;
|
||||||
|
const children = await this.branchesService.getChildren(id, recursive === 'true');
|
||||||
|
res.json({ data: children });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getParents(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const parents = await this.branchesService.getParents(id);
|
||||||
|
res.json({ data: parents });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// USER ASSIGNMENTS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
private async assignUser(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const assignedBy = req.headers['x-user-id'] as string;
|
||||||
|
const dto: AssignUserToBranchDto = req.body;
|
||||||
|
|
||||||
|
const assignment = await this.branchesService.assignUser(tenantId, dto, assignedBy);
|
||||||
|
res.status(201).json({ data: assignment });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async unassignUser(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { userId, branchId } = req.params;
|
||||||
|
const unassigned = await this.branchesService.unassignUser(userId, branchId);
|
||||||
|
|
||||||
|
if (!unassigned) {
|
||||||
|
res.status(404).json({ error: 'Assignment not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getUserBranches(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { userId } = req.params;
|
||||||
|
const branches = await this.branchesService.getUserBranches(userId);
|
||||||
|
res.json({ data: branches });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getPrimaryBranch(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { userId } = req.params;
|
||||||
|
const branch = await this.branchesService.getPrimaryBranch(userId);
|
||||||
|
|
||||||
|
if (!branch) {
|
||||||
|
res.status(404).json({ error: 'No primary branch found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: branch });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getBranchUsers(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const users = await this.branchesService.getBranchUsers(id);
|
||||||
|
res.json({ data: users });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// GEOFENCING
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
private async validateGeofence(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { branchId, latitude, longitude } = req.body;
|
||||||
|
|
||||||
|
const result = await this.branchesService.validateGeofence(branchId, latitude, longitude);
|
||||||
|
res.json({ data: result });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findNearbyBranches(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const { latitude, longitude, radius } = req.query;
|
||||||
|
|
||||||
|
if (!latitude || !longitude) {
|
||||||
|
res.status(400).json({ error: 'Latitude and longitude are required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const branches = await this.branchesService.findNearbyBranches(
|
||||||
|
tenantId,
|
||||||
|
parseFloat(latitude as string),
|
||||||
|
parseFloat(longitude as string),
|
||||||
|
radius ? parseInt(radius as string, 10) : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ data: branches });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// SCHEDULES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
private async getSchedules(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const schedules = await this.branchesService.getSchedules(id);
|
||||||
|
res.json({ data: schedules });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async addSchedule(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const dto: CreateBranchScheduleDto = req.body;
|
||||||
|
|
||||||
|
const schedule = await this.branchesService.addSchedule(id, dto);
|
||||||
|
res.status(201).json({ data: schedule });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async isOpenNow(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const isOpen = await this.branchesService.isOpenNow(id);
|
||||||
|
res.json({ data: { isOpen } });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// MAIN BRANCH
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
private async getMainBranch(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const branch = await this.branchesService.getMainBranch(tenantId);
|
||||||
|
|
||||||
|
if (!branch) {
|
||||||
|
res.status(404).json({ error: 'No main branch found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: branch });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async setAsMainBranch(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const branch = await this.branchesService.setAsMainBranch(id);
|
||||||
|
|
||||||
|
if (!branch) {
|
||||||
|
res.status(404).json({ error: 'Branch not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: branch });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/modules/branches/controllers/index.ts
Normal file
1
src/modules/branches/controllers/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { BranchesController } from './branches.controller';
|
||||||
100
src/modules/branches/dto/branch-schedule.dto.ts
Normal file
100
src/modules/branches/dto/branch-schedule.dto.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsBoolean,
|
||||||
|
IsNumber,
|
||||||
|
IsArray,
|
||||||
|
IsEnum,
|
||||||
|
MaxLength,
|
||||||
|
IsDateString,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { ScheduleType } from '../entities/branch-schedule.entity';
|
||||||
|
|
||||||
|
class ShiftDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
start: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
end: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateBranchScheduleDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(['regular', 'holiday', 'special'])
|
||||||
|
scheduleType?: ScheduleType;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
dayOfWeek?: number; // 0=domingo, 1=lunes, ..., 6=sabado
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
specificDate?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
openTime: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
closeTime: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => ShiftDto)
|
||||||
|
shifts?: ShiftDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateBranchScheduleDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(['regular', 'holiday', 'special'])
|
||||||
|
scheduleType?: ScheduleType;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
dayOfWeek?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
specificDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
openTime?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
closeTime?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => ShiftDto)
|
||||||
|
shifts?: ShiftDto[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
265
src/modules/branches/dto/create-branch.dto.ts
Normal file
265
src/modules/branches/dto/create-branch.dto.ts
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsBoolean,
|
||||||
|
IsNumber,
|
||||||
|
IsObject,
|
||||||
|
IsUUID,
|
||||||
|
IsArray,
|
||||||
|
MaxLength,
|
||||||
|
MinLength,
|
||||||
|
IsEnum,
|
||||||
|
IsLatitude,
|
||||||
|
IsLongitude,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { BranchType } from '../entities/branch.entity';
|
||||||
|
|
||||||
|
export class CreateBranchDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
@MaxLength(20)
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
@MaxLength(100)
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
shortName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(['headquarters', 'regional', 'store', 'warehouse', 'office', 'factory'])
|
||||||
|
branchType?: BranchType;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
parentId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
phone?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
email?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
managerId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
addressLine1?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
addressLine2?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
city?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
state?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
postalCode?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(3)
|
||||||
|
country?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
latitude?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
longitude?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
geofenceRadius?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
geofenceEnabled?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
timezone?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(3)
|
||||||
|
currency?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isMain?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
operatingHours?: Record<string, { open: string; close: string }>;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
settings?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateBranchDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
@MaxLength(100)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
shortName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(['headquarters', 'regional', 'store', 'warehouse', 'office', 'factory'])
|
||||||
|
branchType?: BranchType;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
parentId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
phone?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
email?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
managerId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
addressLine1?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
addressLine2?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
city?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
state?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
postalCode?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(3)
|
||||||
|
country?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
latitude?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
longitude?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
geofenceRadius?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
geofenceEnabled?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
timezone?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(3)
|
||||||
|
currency?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isMain?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
operatingHours?: Record<string, { open: string; close: string }>;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
settings?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AssignUserToBranchDto {
|
||||||
|
@IsUUID()
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
branchId: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(['primary', 'secondary', 'temporary', 'floating'])
|
||||||
|
assignmentType?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(['manager', 'supervisor', 'staff'])
|
||||||
|
branchRole?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
permissions?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
validUntil?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ValidateGeofenceDto {
|
||||||
|
@IsUUID()
|
||||||
|
branchId: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
latitude: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
longitude: number;
|
||||||
|
}
|
||||||
11
src/modules/branches/dto/index.ts
Normal file
11
src/modules/branches/dto/index.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
export {
|
||||||
|
CreateBranchDto,
|
||||||
|
UpdateBranchDto,
|
||||||
|
AssignUserToBranchDto,
|
||||||
|
ValidateGeofenceDto,
|
||||||
|
} from './create-branch.dto';
|
||||||
|
|
||||||
|
export {
|
||||||
|
CreateBranchScheduleDto,
|
||||||
|
UpdateBranchScheduleDto,
|
||||||
|
} from './branch-schedule.dto';
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
OneToOne,
|
||||||
|
JoinColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Branch } from './branch.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuración de inventario por sucursal.
|
||||||
|
* Mapea a core.branch_inventory_settings (DDL: 03-core-branches.sql)
|
||||||
|
*/
|
||||||
|
@Entity({ name: 'branch_inventory_settings', schema: 'core' })
|
||||||
|
export class BranchInventorySettings {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'branch_id', type: 'uuid' })
|
||||||
|
branchId: string;
|
||||||
|
|
||||||
|
@OneToOne(() => Branch, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'branch_id' })
|
||||||
|
branch: Branch;
|
||||||
|
|
||||||
|
// Almacén asociado (referencia externa a inventory.warehouses)
|
||||||
|
@Column({ name: 'warehouse_id', type: 'uuid', nullable: true })
|
||||||
|
warehouseId: string;
|
||||||
|
|
||||||
|
// Configuración de stock
|
||||||
|
@Column({ name: 'default_stock_min', type: 'integer', default: 0 })
|
||||||
|
defaultStockMin: number;
|
||||||
|
|
||||||
|
@Column({ name: 'default_stock_max', type: 'integer', default: 1000 })
|
||||||
|
defaultStockMax: number;
|
||||||
|
|
||||||
|
@Column({ name: 'auto_reorder_enabled', type: 'boolean', default: false })
|
||||||
|
autoReorderEnabled: boolean;
|
||||||
|
|
||||||
|
// Configuración de precios (referencia externa a sales.price_lists)
|
||||||
|
@Column({ name: 'price_list_id', type: 'uuid', nullable: true })
|
||||||
|
priceListId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'allow_price_override', type: 'boolean', default: false })
|
||||||
|
allowPriceOverride: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'max_discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 })
|
||||||
|
maxDiscountPercent: number;
|
||||||
|
|
||||||
|
// Configuración de impuestos
|
||||||
|
@Column({ name: 'tax_config', type: 'jsonb', default: {} })
|
||||||
|
taxConfig: Record<string, any>;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
@ -0,0 +1,77 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
Unique,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Branch } from './branch.entity';
|
||||||
|
|
||||||
|
export type TerminalProvider = 'clip' | 'mercadopago' | 'stripe';
|
||||||
|
export type HealthStatus = 'healthy' | 'degraded' | 'offline' | 'unknown';
|
||||||
|
|
||||||
|
@Entity({ name: 'branch_payment_terminals', schema: 'core' })
|
||||||
|
@Unique(['branchId', 'terminalProvider', 'terminalId'])
|
||||||
|
export class BranchPaymentTerminal {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'branch_id', type: 'uuid' })
|
||||||
|
branchId: string;
|
||||||
|
|
||||||
|
// Terminal
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'terminal_provider', type: 'varchar', length: 30 })
|
||||||
|
terminalProvider: TerminalProvider;
|
||||||
|
|
||||||
|
@Column({ name: 'terminal_id', type: 'varchar', length: 100 })
|
||||||
|
terminalId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'terminal_name', type: 'varchar', length: 100, nullable: true })
|
||||||
|
terminalName: string;
|
||||||
|
|
||||||
|
// Credenciales (encriptadas)
|
||||||
|
@Column({ type: 'jsonb', default: {} })
|
||||||
|
credentials: Record<string, any>;
|
||||||
|
|
||||||
|
// Configuracion
|
||||||
|
@Column({ name: 'is_primary', type: 'boolean', default: false })
|
||||||
|
isPrimary: boolean;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
// Limites
|
||||||
|
@Column({ name: 'daily_limit', type: 'decimal', precision: 12, scale: 2, nullable: true })
|
||||||
|
dailyLimit: number;
|
||||||
|
|
||||||
|
@Column({ name: 'transaction_limit', type: 'decimal', precision: 12, scale: 2, nullable: true })
|
||||||
|
transactionLimit: number;
|
||||||
|
|
||||||
|
// Ultima actividad
|
||||||
|
@Column({ name: 'last_transaction_at', type: 'timestamptz', nullable: true })
|
||||||
|
lastTransactionAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'last_health_check_at', type: 'timestamptz', nullable: true })
|
||||||
|
lastHealthCheckAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'health_status', type: 'varchar', length: 20, default: 'unknown' })
|
||||||
|
healthStatus: HealthStatus;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
// Relaciones
|
||||||
|
@ManyToOne(() => Branch, (branch) => branch.paymentTerminals, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'branch_id' })
|
||||||
|
branch: Branch;
|
||||||
|
}
|
||||||
73
src/modules/branches/entities/branch-schedule.entity.ts
Normal file
73
src/modules/branches/entities/branch-schedule.entity.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Branch } from './branch.entity';
|
||||||
|
|
||||||
|
export type ScheduleType = 'regular' | 'holiday' | 'special';
|
||||||
|
|
||||||
|
@Entity({ name: 'branch_schedules', schema: 'core' })
|
||||||
|
export class BranchSchedule {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'branch_id', type: 'uuid' })
|
||||||
|
branchId: string;
|
||||||
|
|
||||||
|
// Identificacion
|
||||||
|
@Column({ type: 'varchar', length: 100 })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
// Tipo
|
||||||
|
@Column({ name: 'schedule_type', type: 'varchar', length: 30, default: 'regular' })
|
||||||
|
scheduleType: ScheduleType;
|
||||||
|
|
||||||
|
// Dia de la semana (0=domingo, 1=lunes, ..., 6=sabado) o fecha especifica
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'day_of_week', type: 'integer', nullable: true })
|
||||||
|
dayOfWeek: number;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'specific_date', type: 'date', nullable: true })
|
||||||
|
specificDate: Date;
|
||||||
|
|
||||||
|
// Horarios
|
||||||
|
@Column({ name: 'open_time', type: 'time' })
|
||||||
|
openTime: string;
|
||||||
|
|
||||||
|
@Column({ name: 'close_time', type: 'time' })
|
||||||
|
closeTime: string;
|
||||||
|
|
||||||
|
// Turnos (si aplica)
|
||||||
|
@Column({ type: 'jsonb', default: [] })
|
||||||
|
shifts: Array<{
|
||||||
|
name: string;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
// Estado
|
||||||
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
// Relaciones
|
||||||
|
@ManyToOne(() => Branch, (branch) => branch.schedules, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'branch_id' })
|
||||||
|
branch: Branch;
|
||||||
|
}
|
||||||
158
src/modules/branches/entities/branch.entity.ts
Normal file
158
src/modules/branches/entities/branch.entity.ts
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
DeleteDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
OneToMany,
|
||||||
|
JoinColumn,
|
||||||
|
Unique,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { UserBranchAssignment } from './user-branch-assignment.entity';
|
||||||
|
import { BranchSchedule } from './branch-schedule.entity';
|
||||||
|
import { BranchPaymentTerminal } from './branch-payment-terminal.entity';
|
||||||
|
|
||||||
|
export type BranchType = 'headquarters' | 'regional' | 'store' | 'warehouse' | 'office' | 'factory';
|
||||||
|
|
||||||
|
@Entity({ name: 'branches', schema: 'core' })
|
||||||
|
@Unique(['tenantId', 'code'])
|
||||||
|
export class Branch {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'parent_id', type: 'uuid', nullable: true })
|
||||||
|
parentId: string;
|
||||||
|
|
||||||
|
// Identificacion
|
||||||
|
@Index()
|
||||||
|
@Column({ type: 'varchar', length: 20 })
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100 })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ name: 'short_name', type: 'varchar', length: 50, nullable: true })
|
||||||
|
shortName: string;
|
||||||
|
|
||||||
|
// Tipo
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'branch_type', type: 'varchar', length: 30, default: 'store' })
|
||||||
|
branchType: BranchType;
|
||||||
|
|
||||||
|
// Contacto
|
||||||
|
@Column({ type: 'varchar', length: 20, nullable: true })
|
||||||
|
phone: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@Column({ name: 'manager_id', type: 'uuid', nullable: true })
|
||||||
|
managerId: string;
|
||||||
|
|
||||||
|
// Direccion
|
||||||
|
@Column({ name: 'address_line1', type: 'varchar', length: 200, nullable: true })
|
||||||
|
addressLine1: string;
|
||||||
|
|
||||||
|
@Column({ name: 'address_line2', type: 'varchar', length: 200, nullable: true })
|
||||||
|
addressLine2: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||||
|
city: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||||
|
state: string;
|
||||||
|
|
||||||
|
@Column({ name: 'postal_code', type: 'varchar', length: 20, nullable: true })
|
||||||
|
postalCode: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 3, default: 'MEX' })
|
||||||
|
country: string;
|
||||||
|
|
||||||
|
// Geolocalizacion
|
||||||
|
@Column({ type: 'decimal', precision: 10, scale: 8, nullable: true })
|
||||||
|
latitude: number;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 11, scale: 8, nullable: true })
|
||||||
|
longitude: number;
|
||||||
|
|
||||||
|
@Column({ name: 'geofence_radius', type: 'integer', default: 100 })
|
||||||
|
geofenceRadius: number; // Radio en metros
|
||||||
|
|
||||||
|
@Column({ name: 'geofence_enabled', type: 'boolean', default: true })
|
||||||
|
geofenceEnabled: boolean;
|
||||||
|
|
||||||
|
// Configuracion
|
||||||
|
@Column({ type: 'varchar', length: 50, default: 'America/Mexico_City' })
|
||||||
|
timezone: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 3, default: 'MXN' })
|
||||||
|
currency: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'is_main', type: 'boolean', default: false })
|
||||||
|
isMain: boolean; // Sucursal principal/matriz
|
||||||
|
|
||||||
|
// Horarios de operacion
|
||||||
|
@Column({ name: 'operating_hours', type: 'jsonb', default: {} })
|
||||||
|
operatingHours: Record<string, { open: string; close: string }>;
|
||||||
|
|
||||||
|
// Configuraciones especificas
|
||||||
|
@Column({ type: 'jsonb', default: {} })
|
||||||
|
settings: {
|
||||||
|
allowPos?: boolean;
|
||||||
|
allowWarehouse?: boolean;
|
||||||
|
allowCheckIn?: boolean;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Jerarquia (path materializado)
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'hierarchy_path', type: 'text', nullable: true })
|
||||||
|
hierarchyPath: string;
|
||||||
|
|
||||||
|
@Column({ name: 'hierarchy_level', type: 'integer', default: 0 })
|
||||||
|
hierarchyLevel: number;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||||
|
createdBy: string;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
||||||
|
updatedBy: string;
|
||||||
|
|
||||||
|
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||||
|
deletedAt: Date;
|
||||||
|
|
||||||
|
// Relaciones
|
||||||
|
@ManyToOne(() => Branch, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'parent_id' })
|
||||||
|
parent: Branch;
|
||||||
|
|
||||||
|
@OneToMany(() => Branch, (branch) => branch.parent)
|
||||||
|
children: Branch[];
|
||||||
|
|
||||||
|
@OneToMany(() => UserBranchAssignment, (assignment) => assignment.branch)
|
||||||
|
userAssignments: UserBranchAssignment[];
|
||||||
|
|
||||||
|
@OneToMany(() => BranchSchedule, (schedule) => schedule.branch)
|
||||||
|
schedules: BranchSchedule[];
|
||||||
|
|
||||||
|
@OneToMany(() => BranchPaymentTerminal, (terminal) => terminal.branch)
|
||||||
|
paymentTerminals: BranchPaymentTerminal[];
|
||||||
|
}
|
||||||
5
src/modules/branches/entities/index.ts
Normal file
5
src/modules/branches/entities/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export { Branch, BranchType } from './branch.entity';
|
||||||
|
export { UserBranchAssignment, AssignmentType, BranchRole } from './user-branch-assignment.entity';
|
||||||
|
export { BranchSchedule, ScheduleType } from './branch-schedule.entity';
|
||||||
|
export { BranchPaymentTerminal, TerminalProvider, HealthStatus } from './branch-payment-terminal.entity';
|
||||||
|
export { BranchInventorySettings } from './branch-inventory-settings.entity';
|
||||||
@ -0,0 +1,72 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
Unique,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Branch } from './branch.entity';
|
||||||
|
|
||||||
|
export type AssignmentType = 'primary' | 'secondary' | 'temporary' | 'floating';
|
||||||
|
export type BranchRole = 'manager' | 'supervisor' | 'staff';
|
||||||
|
|
||||||
|
@Entity({ name: 'user_branch_assignments', schema: 'core' })
|
||||||
|
@Unique(['userId', 'branchId', 'assignmentType'])
|
||||||
|
export class UserBranchAssignment {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'user_id', type: 'uuid' })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'branch_id', type: 'uuid' })
|
||||||
|
branchId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
// Tipo de asignacion
|
||||||
|
@Column({ name: 'assignment_type', type: 'varchar', length: 30, default: 'primary' })
|
||||||
|
assignmentType: AssignmentType;
|
||||||
|
|
||||||
|
// Rol en la sucursal
|
||||||
|
@Column({ name: 'branch_role', type: 'varchar', length: 50, nullable: true })
|
||||||
|
branchRole: BranchRole;
|
||||||
|
|
||||||
|
// Permisos especificos
|
||||||
|
@Column({ type: 'jsonb', default: [] })
|
||||||
|
permissions: string[];
|
||||||
|
|
||||||
|
// Vigencia (para asignaciones temporales)
|
||||||
|
@Column({ name: 'valid_from', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
|
validFrom: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'valid_until', type: 'timestamptz', nullable: true })
|
||||||
|
validUntil: Date;
|
||||||
|
|
||||||
|
// Estado
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||||
|
createdBy: string;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
// Relaciones
|
||||||
|
@ManyToOne(() => Branch, (branch) => branch.userAssignments, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'branch_id' })
|
||||||
|
branch: Branch;
|
||||||
|
}
|
||||||
5
src/modules/branches/index.ts
Normal file
5
src/modules/branches/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export { BranchesModule, BranchesModuleOptions } from './branches.module';
|
||||||
|
export * from './entities';
|
||||||
|
export * from './services';
|
||||||
|
export * from './controllers';
|
||||||
|
export * from './dto';
|
||||||
435
src/modules/branches/services/branches.service.ts
Normal file
435
src/modules/branches/services/branches.service.ts
Normal file
@ -0,0 +1,435 @@
|
|||||||
|
import { Repository, FindOptionsWhere, ILike, IsNull, In } from 'typeorm';
|
||||||
|
import { Branch, UserBranchAssignment, BranchSchedule, BranchPaymentTerminal } from '../entities';
|
||||||
|
import { CreateBranchDto, UpdateBranchDto, AssignUserToBranchDto, CreateBranchScheduleDto } from '../dto';
|
||||||
|
|
||||||
|
export interface BranchSearchParams {
|
||||||
|
search?: string;
|
||||||
|
branchType?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
parentId?: string;
|
||||||
|
includeChildren?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BranchesService {
|
||||||
|
constructor(
|
||||||
|
private readonly branchRepository: Repository<Branch>,
|
||||||
|
private readonly assignmentRepository: Repository<UserBranchAssignment>,
|
||||||
|
private readonly scheduleRepository: Repository<BranchSchedule>,
|
||||||
|
private readonly terminalRepository: Repository<BranchPaymentTerminal>
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// BRANCH CRUD
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async findAll(tenantId: string, params: BranchSearchParams = {}): Promise<{ data: Branch[]; total: number }> {
|
||||||
|
const { search, branchType, isActive, parentId, limit = 50, offset = 0 } = params;
|
||||||
|
|
||||||
|
const where: FindOptionsWhere<Branch> = { tenantId };
|
||||||
|
|
||||||
|
if (branchType) where.branchType = branchType as any;
|
||||||
|
if (isActive !== undefined) where.isActive = isActive;
|
||||||
|
if (parentId) where.parentId = parentId;
|
||||||
|
if (parentId === null) where.parentId = IsNull();
|
||||||
|
|
||||||
|
const queryBuilder = this.branchRepository
|
||||||
|
.createQueryBuilder('branch')
|
||||||
|
.where('branch.tenant_id = :tenantId', { tenantId })
|
||||||
|
.leftJoinAndSelect('branch.schedules', 'schedules')
|
||||||
|
.leftJoinAndSelect('branch.paymentTerminals', 'terminals');
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
queryBuilder.andWhere('(branch.name ILIKE :search OR branch.code ILIKE :search OR branch.city ILIKE :search)', {
|
||||||
|
search: `%${search}%`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (branchType) {
|
||||||
|
queryBuilder.andWhere('branch.branch_type = :branchType', { branchType });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isActive !== undefined) {
|
||||||
|
queryBuilder.andWhere('branch.is_active = :isActive', { isActive });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parentId) {
|
||||||
|
queryBuilder.andWhere('branch.parent_id = :parentId', { parentId });
|
||||||
|
} else if (parentId === null) {
|
||||||
|
queryBuilder.andWhere('branch.parent_id IS NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
queryBuilder.orderBy('branch.hierarchy_path', 'ASC').addOrderBy('branch.name', 'ASC');
|
||||||
|
|
||||||
|
const total = await queryBuilder.getCount();
|
||||||
|
const data = await queryBuilder.skip(offset).take(limit).getMany();
|
||||||
|
|
||||||
|
return { data, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: string): Promise<Branch | null> {
|
||||||
|
return this.branchRepository.findOne({
|
||||||
|
where: { id },
|
||||||
|
relations: ['parent', 'children', 'schedules', 'paymentTerminals', 'userAssignments'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByCode(tenantId: string, code: string): Promise<Branch | null> {
|
||||||
|
return this.branchRepository.findOne({
|
||||||
|
where: { tenantId, code },
|
||||||
|
relations: ['schedules', 'paymentTerminals'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(tenantId: string, dto: CreateBranchDto, createdBy?: string): Promise<Branch> {
|
||||||
|
// Check for duplicate code
|
||||||
|
const existing = await this.findByCode(tenantId, dto.code);
|
||||||
|
if (existing) {
|
||||||
|
throw new Error(`Branch with code '${dto.code}' already exists`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build hierarchy path
|
||||||
|
let hierarchyPath = `/${dto.code}`;
|
||||||
|
let hierarchyLevel = 0;
|
||||||
|
|
||||||
|
if (dto.parentId) {
|
||||||
|
const parent = await this.findOne(dto.parentId);
|
||||||
|
if (!parent) {
|
||||||
|
throw new Error('Parent branch not found');
|
||||||
|
}
|
||||||
|
hierarchyPath = `${parent.hierarchyPath}/${dto.code}`;
|
||||||
|
hierarchyLevel = parent.hierarchyLevel + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const branch = this.branchRepository.create({
|
||||||
|
...dto,
|
||||||
|
tenantId,
|
||||||
|
hierarchyPath,
|
||||||
|
hierarchyLevel,
|
||||||
|
createdBy,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.branchRepository.save(branch);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateBranchDto, updatedBy?: string): Promise<Branch | null> {
|
||||||
|
const branch = await this.findOne(id);
|
||||||
|
if (!branch) return null;
|
||||||
|
|
||||||
|
// If changing parent, update hierarchy
|
||||||
|
if (dto.parentId !== undefined && dto.parentId !== branch.parentId) {
|
||||||
|
if (dto.parentId) {
|
||||||
|
const newParent = await this.findOne(dto.parentId);
|
||||||
|
if (!newParent) {
|
||||||
|
throw new Error('New parent branch not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for circular reference
|
||||||
|
if (newParent.hierarchyPath.includes(`/${branch.code}/`) || newParent.id === branch.id) {
|
||||||
|
throw new Error('Cannot create circular reference in branch hierarchy');
|
||||||
|
}
|
||||||
|
|
||||||
|
branch.hierarchyPath = `${newParent.hierarchyPath}/${branch.code}`;
|
||||||
|
branch.hierarchyLevel = newParent.hierarchyLevel + 1;
|
||||||
|
} else {
|
||||||
|
branch.hierarchyPath = `/${branch.code}`;
|
||||||
|
branch.hierarchyLevel = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update children hierarchy paths
|
||||||
|
await this.updateChildrenHierarchy(branch);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(branch, dto, { updatedBy });
|
||||||
|
return this.branchRepository.save(branch);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateChildrenHierarchy(parent: Branch): Promise<void> {
|
||||||
|
const children = await this.branchRepository.find({
|
||||||
|
where: { parentId: parent.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const child of children) {
|
||||||
|
child.hierarchyPath = `${parent.hierarchyPath}/${child.code}`;
|
||||||
|
child.hierarchyLevel = parent.hierarchyLevel + 1;
|
||||||
|
await this.branchRepository.save(child);
|
||||||
|
await this.updateChildrenHierarchy(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<boolean> {
|
||||||
|
const branch = await this.findOne(id);
|
||||||
|
if (!branch) return false;
|
||||||
|
|
||||||
|
// Check if has children
|
||||||
|
const childrenCount = await this.branchRepository.count({ where: { parentId: id } });
|
||||||
|
if (childrenCount > 0) {
|
||||||
|
throw new Error('Cannot delete branch with children. Delete children first or move them to another parent.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.branchRepository.softDelete(id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// HIERARCHY
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async getHierarchy(tenantId: string): Promise<Branch[]> {
|
||||||
|
const branches = await this.branchRepository.find({
|
||||||
|
where: { tenantId, isActive: true },
|
||||||
|
order: { hierarchyPath: 'ASC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.buildTree(branches);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildTree(branches: Branch[], parentId: string | null = null): Branch[] {
|
||||||
|
return branches
|
||||||
|
.filter((b) => b.parentId === parentId)
|
||||||
|
.map((branch) => ({
|
||||||
|
...branch,
|
||||||
|
children: this.buildTree(branches, branch.id),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getChildren(branchId: string, recursive: boolean = false): Promise<Branch[]> {
|
||||||
|
if (!recursive) {
|
||||||
|
return this.branchRepository.find({
|
||||||
|
where: { parentId: branchId, isActive: true },
|
||||||
|
order: { name: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = await this.findOne(branchId);
|
||||||
|
if (!parent) return [];
|
||||||
|
|
||||||
|
return this.branchRepository
|
||||||
|
.createQueryBuilder('branch')
|
||||||
|
.where('branch.hierarchy_path LIKE :path', { path: `${parent.hierarchyPath}/%` })
|
||||||
|
.andWhere('branch.is_active = true')
|
||||||
|
.orderBy('branch.hierarchy_path', 'ASC')
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getParents(branchId: string): Promise<Branch[]> {
|
||||||
|
const branch = await this.findOne(branchId);
|
||||||
|
if (!branch || !branch.hierarchyPath) return [];
|
||||||
|
|
||||||
|
const codes = branch.hierarchyPath.split('/').filter((c) => c && c !== branch.code);
|
||||||
|
if (codes.length === 0) return [];
|
||||||
|
|
||||||
|
return this.branchRepository.find({
|
||||||
|
where: { tenantId: branch.tenantId, code: In(codes) },
|
||||||
|
order: { hierarchyLevel: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// USER ASSIGNMENTS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async assignUser(tenantId: string, dto: AssignUserToBranchDto, assignedBy?: string): Promise<UserBranchAssignment> {
|
||||||
|
// Check if branch exists
|
||||||
|
const branch = await this.findOne(dto.branchId);
|
||||||
|
if (!branch || branch.tenantId !== tenantId) {
|
||||||
|
throw new Error('Branch not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for existing assignment of same type
|
||||||
|
const existing = await this.assignmentRepository.findOne({
|
||||||
|
where: {
|
||||||
|
userId: dto.userId,
|
||||||
|
branchId: dto.branchId,
|
||||||
|
assignmentType: (dto.assignmentType as any) ?? 'primary',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Update existing
|
||||||
|
Object.assign(existing, {
|
||||||
|
branchRole: dto.branchRole ?? existing.branchRole,
|
||||||
|
permissions: dto.permissions ?? existing.permissions,
|
||||||
|
validUntil: dto.validUntil ? new Date(dto.validUntil) : existing.validUntil,
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
return this.assignmentRepository.save(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignment = this.assignmentRepository.create({
|
||||||
|
...dto,
|
||||||
|
tenantId,
|
||||||
|
validUntil: dto.validUntil ? new Date(dto.validUntil) : undefined,
|
||||||
|
createdBy: assignedBy,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
return this.assignmentRepository.save(assignment);
|
||||||
|
}
|
||||||
|
|
||||||
|
async unassignUser(userId: string, branchId: string): Promise<boolean> {
|
||||||
|
const result = await this.assignmentRepository.update({ userId, branchId }, { isActive: false });
|
||||||
|
return (result.affected ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserBranches(userId: string): Promise<Branch[]> {
|
||||||
|
const assignments = await this.assignmentRepository.find({
|
||||||
|
where: { userId, isActive: true },
|
||||||
|
relations: ['branch'],
|
||||||
|
});
|
||||||
|
|
||||||
|
return assignments.map((a) => a.branch).filter((b) => b != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBranchUsers(branchId: string): Promise<UserBranchAssignment[]> {
|
||||||
|
return this.assignmentRepository.find({
|
||||||
|
where: { branchId, isActive: true },
|
||||||
|
order: { branchRole: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPrimaryBranch(userId: string): Promise<Branch | null> {
|
||||||
|
const assignment = await this.assignmentRepository.findOne({
|
||||||
|
where: { userId, assignmentType: 'primary' as any, isActive: true },
|
||||||
|
relations: ['branch'],
|
||||||
|
});
|
||||||
|
|
||||||
|
return assignment?.branch ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// GEOFENCING
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async validateGeofence(branchId: string, latitude: number, longitude: number): Promise<{ valid: boolean; distance: number }> {
|
||||||
|
const branch = await this.findOne(branchId);
|
||||||
|
if (!branch) {
|
||||||
|
throw new Error('Branch not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!branch.geofenceEnabled) {
|
||||||
|
return { valid: true, distance: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!branch.latitude || !branch.longitude) {
|
||||||
|
return { valid: true, distance: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate distance using Haversine formula
|
||||||
|
const R = 6371000; // Earth's radius in meters
|
||||||
|
const dLat = this.toRad(latitude - branch.latitude);
|
||||||
|
const dLon = this.toRad(longitude - branch.longitude);
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||||
|
Math.cos(this.toRad(branch.latitude)) * Math.cos(this.toRad(latitude)) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
const distance = R * c;
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: distance <= branch.geofenceRadius,
|
||||||
|
distance: Math.round(distance),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toRad(deg: number): number {
|
||||||
|
return deg * (Math.PI / 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findNearbyBranches(tenantId: string, latitude: number, longitude: number, radiusMeters: number = 5000): Promise<Branch[]> {
|
||||||
|
// Use PostgreSQL's earthdistance extension if available, otherwise calculate in app
|
||||||
|
const branches = await this.branchRepository.find({
|
||||||
|
where: { tenantId, isActive: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return branches
|
||||||
|
.filter((b) => {
|
||||||
|
if (!b.latitude || !b.longitude) return false;
|
||||||
|
const result = this.calculateDistance(latitude, longitude, b.latitude, b.longitude);
|
||||||
|
return result <= radiusMeters;
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
const distA = this.calculateDistance(latitude, longitude, a.latitude!, a.longitude!);
|
||||||
|
const distB = this.calculateDistance(latitude, longitude, b.latitude!, b.longitude!);
|
||||||
|
return distA - distB;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||||
|
const R = 6371000;
|
||||||
|
const dLat = this.toRad(lat2 - lat1);
|
||||||
|
const dLon = this.toRad(lon2 - lon1);
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||||
|
Math.cos(this.toRad(lat1)) * Math.cos(this.toRad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
return R * c;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// SCHEDULES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async addSchedule(branchId: string, dto: CreateBranchScheduleDto): Promise<BranchSchedule> {
|
||||||
|
const schedule = this.scheduleRepository.create({
|
||||||
|
...dto,
|
||||||
|
branchId,
|
||||||
|
specificDate: dto.specificDate ? new Date(dto.specificDate) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.scheduleRepository.save(schedule);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSchedules(branchId: string): Promise<BranchSchedule[]> {
|
||||||
|
return this.scheduleRepository.find({
|
||||||
|
where: { branchId, isActive: true },
|
||||||
|
order: { dayOfWeek: 'ASC', specificDate: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async isOpenNow(branchId: string): Promise<boolean> {
|
||||||
|
const schedules = await this.getSchedules(branchId);
|
||||||
|
const now = new Date();
|
||||||
|
const dayOfWeek = now.getDay();
|
||||||
|
const currentTime = now.toTimeString().slice(0, 5);
|
||||||
|
|
||||||
|
// Check for specific date schedule first
|
||||||
|
const today = now.toISOString().slice(0, 10);
|
||||||
|
const specificSchedule = schedules.find((s) => s.specificDate?.toISOString().slice(0, 10) === today);
|
||||||
|
|
||||||
|
if (specificSchedule) {
|
||||||
|
return currentTime >= specificSchedule.openTime && currentTime <= specificSchedule.closeTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check regular schedule
|
||||||
|
const regularSchedule = schedules.find((s) => s.dayOfWeek === dayOfWeek && s.scheduleType === 'regular');
|
||||||
|
|
||||||
|
if (regularSchedule) {
|
||||||
|
return currentTime >= regularSchedule.openTime && currentTime <= regularSchedule.closeTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// MAIN BRANCH
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async getMainBranch(tenantId: string): Promise<Branch | null> {
|
||||||
|
return this.branchRepository.findOne({
|
||||||
|
where: { tenantId, isMain: true, isActive: true },
|
||||||
|
relations: ['schedules', 'paymentTerminals'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async setAsMainBranch(branchId: string): Promise<Branch | null> {
|
||||||
|
const branch = await this.findOne(branchId);
|
||||||
|
if (!branch) return null;
|
||||||
|
|
||||||
|
// Unset current main branch
|
||||||
|
await this.branchRepository.update({ tenantId: branch.tenantId, isMain: true }, { isMain: false });
|
||||||
|
|
||||||
|
// Set new main branch
|
||||||
|
branch.isMain = true;
|
||||||
|
return this.branchRepository.save(branch);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/modules/branches/services/index.ts
Normal file
1
src/modules/branches/services/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { BranchesService, BranchSearchParams } from './branches.service';
|
||||||
96
src/modules/dashboard/controllers/index.ts
Normal file
96
src/modules/dashboard/controllers/index.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import { Request, Response, NextFunction, Router } from 'express';
|
||||||
|
import { DashboardService } from '../services';
|
||||||
|
|
||||||
|
export class DashboardController {
|
||||||
|
public router: Router;
|
||||||
|
|
||||||
|
constructor(private readonly dashboardService: DashboardService) {
|
||||||
|
this.router = Router();
|
||||||
|
|
||||||
|
this.router.get('/', this.getDashboard.bind(this));
|
||||||
|
this.router.get('/kpis', this.getKPIs.bind(this));
|
||||||
|
this.router.get('/activity', this.getActivity.bind(this));
|
||||||
|
this.router.get('/sales-chart', this.getSalesChart.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getDashboard(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
if (!tenantId) {
|
||||||
|
res.status(400).json({ error: 'Tenant ID required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [kpis, activity, salesChart] = await Promise.all([
|
||||||
|
this.dashboardService.getKPIs(tenantId),
|
||||||
|
this.dashboardService.getActivity(tenantId),
|
||||||
|
this.dashboardService.getSalesChart(tenantId, 'month'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
kpis,
|
||||||
|
activity,
|
||||||
|
salesChart,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getKPIs(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
if (!tenantId) {
|
||||||
|
res.status(400).json({ error: 'Tenant ID required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const kpis = await this.dashboardService.getKPIs(tenantId);
|
||||||
|
res.json({ data: kpis });
|
||||||
|
} catch (e) {
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getActivity(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
if (!tenantId) {
|
||||||
|
res.status(400).json({ error: 'Tenant ID required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { limit } = req.query;
|
||||||
|
const activity = await this.dashboardService.getActivity(
|
||||||
|
tenantId,
|
||||||
|
limit ? parseInt(limit as string) : 5
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ data: activity });
|
||||||
|
} catch (e) {
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getSalesChart(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
if (!tenantId) {
|
||||||
|
res.status(400).json({ error: 'Tenant ID required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { period } = req.query;
|
||||||
|
const validPeriods = ['week', 'month', 'year'];
|
||||||
|
const selectedPeriod = validPeriods.includes(period as string)
|
||||||
|
? (period as 'week' | 'month' | 'year')
|
||||||
|
: 'month';
|
||||||
|
|
||||||
|
const chartData = await this.dashboardService.getSalesChart(tenantId, selectedPeriod);
|
||||||
|
res.json({ data: chartData });
|
||||||
|
} catch (e) {
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/modules/dashboard/dashboard.module.ts
Normal file
38
src/modules/dashboard/dashboard.module.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { DashboardService } from './services';
|
||||||
|
import { DashboardController } from './controllers';
|
||||||
|
|
||||||
|
export interface DashboardModuleOptions {
|
||||||
|
dataSource: DataSource;
|
||||||
|
basePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DashboardModule {
|
||||||
|
public router: Router;
|
||||||
|
public dashboardService: DashboardService;
|
||||||
|
private dataSource: DataSource;
|
||||||
|
private basePath: string;
|
||||||
|
|
||||||
|
constructor(options: DashboardModuleOptions) {
|
||||||
|
this.dataSource = options.dataSource;
|
||||||
|
this.basePath = options.basePath || '';
|
||||||
|
this.router = Router();
|
||||||
|
this.initializeServices();
|
||||||
|
this.initializeRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeServices(): void {
|
||||||
|
this.dashboardService = new DashboardService(this.dataSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeRoutes(): void {
|
||||||
|
const dashboardController = new DashboardController(this.dashboardService);
|
||||||
|
this.router.use(`${this.basePath}/dashboard`, dashboardController.router);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dashboard module doesn't have its own entities - it uses data from other modules
|
||||||
|
static getEntities(): Function[] {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/modules/dashboard/index.ts
Normal file
3
src/modules/dashboard/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { DashboardModule, DashboardModuleOptions } from './dashboard.module';
|
||||||
|
export { DashboardService } from './services';
|
||||||
|
export { DashboardController } from './controllers';
|
||||||
386
src/modules/dashboard/services/index.ts
Normal file
386
src/modules/dashboard/services/index.ts
Normal file
@ -0,0 +1,386 @@
|
|||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
export interface DashboardKPIs {
|
||||||
|
sales: {
|
||||||
|
todayRevenue: number;
|
||||||
|
monthRevenue: number;
|
||||||
|
todayOrders: number;
|
||||||
|
monthOrders: number;
|
||||||
|
pendingOrders: number;
|
||||||
|
};
|
||||||
|
inventory: {
|
||||||
|
totalProducts: number;
|
||||||
|
lowStockItems: number;
|
||||||
|
outOfStockItems: number;
|
||||||
|
pendingMovements: number;
|
||||||
|
};
|
||||||
|
invoices: {
|
||||||
|
pendingInvoices: number;
|
||||||
|
overdueInvoices: number;
|
||||||
|
totalReceivable: number;
|
||||||
|
totalPayable: number;
|
||||||
|
};
|
||||||
|
partners: {
|
||||||
|
totalCustomers: number;
|
||||||
|
totalSuppliers: number;
|
||||||
|
newCustomersMonth: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardActivity {
|
||||||
|
recentOrders: any[];
|
||||||
|
recentInvoices: any[];
|
||||||
|
recentMovements: any[];
|
||||||
|
alerts: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DashboardService {
|
||||||
|
constructor(private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
|
async getKPIs(tenantId: string): Promise<DashboardKPIs> {
|
||||||
|
const [sales, inventory, invoices, partners] = await Promise.all([
|
||||||
|
this.getSalesKPIs(tenantId),
|
||||||
|
this.getInventoryKPIs(tenantId),
|
||||||
|
this.getInvoiceKPIs(tenantId),
|
||||||
|
this.getPartnerKPIs(tenantId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { sales, inventory, invoices, partners };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getSalesKPIs(tenantId: string): Promise<DashboardKPIs['sales']> {
|
||||||
|
const today = new Date();
|
||||||
|
const startOfDay = new Date(today.setHours(0, 0, 0, 0));
|
||||||
|
const startOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Today's sales
|
||||||
|
const todayQuery = `
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(total), 0) as revenue,
|
||||||
|
COUNT(*) as orders
|
||||||
|
FROM sales.sales_orders
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND status NOT IN ('cancelled', 'draft')
|
||||||
|
AND order_date >= $2
|
||||||
|
`;
|
||||||
|
const todayResult = await this.dataSource.query(todayQuery, [tenantId, startOfDay]);
|
||||||
|
|
||||||
|
// Month's sales
|
||||||
|
const monthQuery = `
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(total), 0) as revenue,
|
||||||
|
COUNT(*) as orders
|
||||||
|
FROM sales.sales_orders
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND status NOT IN ('cancelled', 'draft')
|
||||||
|
AND order_date >= $2
|
||||||
|
`;
|
||||||
|
const monthResult = await this.dataSource.query(monthQuery, [tenantId, startOfMonth]);
|
||||||
|
|
||||||
|
// Pending orders
|
||||||
|
const pendingQuery = `
|
||||||
|
SELECT COUNT(*) as count
|
||||||
|
FROM sales.sales_orders
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND status IN ('confirmed', 'processing')
|
||||||
|
`;
|
||||||
|
const pendingResult = await this.dataSource.query(pendingQuery, [tenantId]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
todayRevenue: parseFloat(todayResult[0]?.revenue) || 0,
|
||||||
|
monthRevenue: parseFloat(monthResult[0]?.revenue) || 0,
|
||||||
|
todayOrders: parseInt(todayResult[0]?.orders) || 0,
|
||||||
|
monthOrders: parseInt(monthResult[0]?.orders) || 0,
|
||||||
|
pendingOrders: parseInt(pendingResult[0]?.count) || 0,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
todayRevenue: 0,
|
||||||
|
monthRevenue: 0,
|
||||||
|
todayOrders: 0,
|
||||||
|
monthOrders: 0,
|
||||||
|
pendingOrders: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getInventoryKPIs(tenantId: string): Promise<DashboardKPIs['inventory']> {
|
||||||
|
try {
|
||||||
|
const stockQuery = `
|
||||||
|
SELECT
|
||||||
|
COUNT(DISTINCT product_id) as total_products,
|
||||||
|
COUNT(CASE WHEN quantity_on_hand <= 0 THEN 1 END) as out_of_stock,
|
||||||
|
COUNT(CASE WHEN quantity_on_hand > 0 AND quantity_on_hand <= 10 THEN 1 END) as low_stock
|
||||||
|
FROM inventory.stock_levels
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
`;
|
||||||
|
const stockResult = await this.dataSource.query(stockQuery, [tenantId]);
|
||||||
|
|
||||||
|
const movementsQuery = `
|
||||||
|
SELECT COUNT(*) as count
|
||||||
|
FROM inventory.stock_movements
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND status = 'draft'
|
||||||
|
`;
|
||||||
|
const movementsResult = await this.dataSource.query(movementsQuery, [tenantId]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalProducts: parseInt(stockResult[0]?.total_products) || 0,
|
||||||
|
lowStockItems: parseInt(stockResult[0]?.low_stock) || 0,
|
||||||
|
outOfStockItems: parseInt(stockResult[0]?.out_of_stock) || 0,
|
||||||
|
pendingMovements: parseInt(movementsResult[0]?.count) || 0,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
totalProducts: 0,
|
||||||
|
lowStockItems: 0,
|
||||||
|
outOfStockItems: 0,
|
||||||
|
pendingMovements: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getInvoiceKPIs(tenantId: string): Promise<DashboardKPIs['invoices']> {
|
||||||
|
try {
|
||||||
|
const invoicesQuery = `
|
||||||
|
SELECT
|
||||||
|
COUNT(CASE WHEN status IN ('validated', 'sent') THEN 1 END) as pending,
|
||||||
|
COUNT(CASE WHEN status IN ('validated', 'sent', 'partial') AND due_date < CURRENT_DATE THEN 1 END) as overdue,
|
||||||
|
COALESCE(SUM(CASE WHEN invoice_type = 'sale' AND status IN ('validated', 'sent', 'partial') THEN (total - amount_paid) END), 0) as receivable,
|
||||||
|
COALESCE(SUM(CASE WHEN invoice_type = 'purchase' AND status IN ('validated', 'sent', 'partial') THEN (total - amount_paid) END), 0) as payable
|
||||||
|
FROM billing.invoices
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
`;
|
||||||
|
const result = await this.dataSource.query(invoicesQuery, [tenantId]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pendingInvoices: parseInt(result[0]?.pending) || 0,
|
||||||
|
overdueInvoices: parseInt(result[0]?.overdue) || 0,
|
||||||
|
totalReceivable: parseFloat(result[0]?.receivable) || 0,
|
||||||
|
totalPayable: parseFloat(result[0]?.payable) || 0,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
pendingInvoices: 0,
|
||||||
|
overdueInvoices: 0,
|
||||||
|
totalReceivable: 0,
|
||||||
|
totalPayable: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getPartnerKPIs(tenantId: string): Promise<DashboardKPIs['partners']> {
|
||||||
|
const startOfMonth = new Date(new Date().getFullYear(), new Date().getMonth(), 1);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const partnersQuery = `
|
||||||
|
SELECT
|
||||||
|
COUNT(CASE WHEN partner_type IN ('customer', 'both') THEN 1 END) as customers,
|
||||||
|
COUNT(CASE WHEN partner_type IN ('supplier', 'both') THEN 1 END) as suppliers,
|
||||||
|
COUNT(CASE WHEN partner_type IN ('customer', 'both') AND created_at >= $2 THEN 1 END) as new_customers
|
||||||
|
FROM partners.partners
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
`;
|
||||||
|
const result = await this.dataSource.query(partnersQuery, [tenantId, startOfMonth]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalCustomers: parseInt(result[0]?.customers) || 0,
|
||||||
|
totalSuppliers: parseInt(result[0]?.suppliers) || 0,
|
||||||
|
newCustomersMonth: parseInt(result[0]?.new_customers) || 0,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
totalCustomers: 0,
|
||||||
|
totalSuppliers: 0,
|
||||||
|
newCustomersMonth: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getActivity(tenantId: string, limit: number = 5): Promise<DashboardActivity> {
|
||||||
|
const [recentOrders, recentInvoices, recentMovements, alerts] = await Promise.all([
|
||||||
|
this.getRecentOrders(tenantId, limit),
|
||||||
|
this.getRecentInvoices(tenantId, limit),
|
||||||
|
this.getRecentMovements(tenantId, limit),
|
||||||
|
this.getAlerts(tenantId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { recentOrders, recentInvoices, recentMovements, alerts };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getRecentOrders(tenantId: string, limit: number): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
order_number,
|
||||||
|
partner_name,
|
||||||
|
total,
|
||||||
|
status,
|
||||||
|
order_date,
|
||||||
|
created_at
|
||||||
|
FROM sales.sales_orders
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $2
|
||||||
|
`;
|
||||||
|
return await this.dataSource.query(query, [tenantId, limit]);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getRecentInvoices(tenantId: string, limit: number): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
invoice_number,
|
||||||
|
invoice_type,
|
||||||
|
partner_name,
|
||||||
|
total,
|
||||||
|
status,
|
||||||
|
invoice_date,
|
||||||
|
created_at
|
||||||
|
FROM billing.invoices
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $2
|
||||||
|
`;
|
||||||
|
return await this.dataSource.query(query, [tenantId, limit]);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getRecentMovements(tenantId: string, limit: number): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
movement_number,
|
||||||
|
movement_type,
|
||||||
|
product_id,
|
||||||
|
quantity,
|
||||||
|
status,
|
||||||
|
created_at
|
||||||
|
FROM inventory.stock_movements
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $2
|
||||||
|
`;
|
||||||
|
return await this.dataSource.query(query, [tenantId, limit]);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAlerts(tenantId: string): Promise<any[]> {
|
||||||
|
const alerts: any[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Low stock alerts
|
||||||
|
const lowStockQuery = `
|
||||||
|
SELECT COUNT(*) as count
|
||||||
|
FROM inventory.stock_levels
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND quantity_on_hand > 0
|
||||||
|
AND quantity_on_hand <= 10
|
||||||
|
`;
|
||||||
|
const lowStockResult = await this.dataSource.query(lowStockQuery, [tenantId]);
|
||||||
|
const lowStockCount = parseInt(lowStockResult[0]?.count) || 0;
|
||||||
|
|
||||||
|
if (lowStockCount > 0) {
|
||||||
|
alerts.push({
|
||||||
|
type: 'warning',
|
||||||
|
category: 'inventory',
|
||||||
|
message: `${lowStockCount} productos con stock bajo`,
|
||||||
|
count: lowStockCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overdue invoices alerts
|
||||||
|
const overdueQuery = `
|
||||||
|
SELECT COUNT(*) as count
|
||||||
|
FROM billing.invoices
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND invoice_type = 'sale'
|
||||||
|
AND status IN ('validated', 'sent', 'partial')
|
||||||
|
AND due_date < CURRENT_DATE
|
||||||
|
`;
|
||||||
|
const overdueResult = await this.dataSource.query(overdueQuery, [tenantId]);
|
||||||
|
const overdueCount = parseInt(overdueResult[0]?.count) || 0;
|
||||||
|
|
||||||
|
if (overdueCount > 0) {
|
||||||
|
alerts.push({
|
||||||
|
type: 'error',
|
||||||
|
category: 'invoices',
|
||||||
|
message: `${overdueCount} facturas vencidas`,
|
||||||
|
count: overdueCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending orders alerts
|
||||||
|
const pendingOrdersQuery = `
|
||||||
|
SELECT COUNT(*) as count
|
||||||
|
FROM sales.sales_orders
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND status IN ('confirmed')
|
||||||
|
AND order_date < CURRENT_DATE - INTERVAL '3 days'
|
||||||
|
`;
|
||||||
|
const pendingResult = await this.dataSource.query(pendingOrdersQuery, [tenantId]);
|
||||||
|
const pendingCount = parseInt(pendingResult[0]?.count) || 0;
|
||||||
|
|
||||||
|
if (pendingCount > 0) {
|
||||||
|
alerts.push({
|
||||||
|
type: 'warning',
|
||||||
|
category: 'sales',
|
||||||
|
message: `${pendingCount} pedidos pendientes hace mas de 3 dias`,
|
||||||
|
count: pendingCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore errors - tables might not exist yet
|
||||||
|
}
|
||||||
|
|
||||||
|
return alerts;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSalesChart(
|
||||||
|
tenantId: string,
|
||||||
|
period: 'week' | 'month' | 'year' = 'month'
|
||||||
|
): Promise<{ labels: string[]; data: number[] }> {
|
||||||
|
try {
|
||||||
|
const intervals = {
|
||||||
|
week: { interval: '7 days', format: 'Dy', group: 'day' },
|
||||||
|
month: { interval: '30 days', format: 'DD', group: 'day' },
|
||||||
|
year: { interval: '12 months', format: 'Mon', group: 'month' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = intervals[period];
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(order_date, '${config.format}') as label,
|
||||||
|
COALESCE(SUM(total), 0) as total
|
||||||
|
FROM sales.sales_orders
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND status NOT IN ('cancelled', 'draft')
|
||||||
|
AND order_date >= CURRENT_DATE - INTERVAL '${config.interval}'
|
||||||
|
GROUP BY DATE_TRUNC('${config.group}', order_date), TO_CHAR(order_date, '${config.format}')
|
||||||
|
ORDER BY DATE_TRUNC('${config.group}', order_date)
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await this.dataSource.query(query, [tenantId]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels: result.map((r: any) => r.label),
|
||||||
|
data: result.map((r: any) => parseFloat(r.total) || 0),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { labels: [], data: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,367 @@
|
|||||||
|
import { Request, Response, NextFunction, Router } from 'express';
|
||||||
|
import { FeatureFlagsService } from '../services/feature-flags.service';
|
||||||
|
|
||||||
|
export class FeatureFlagsController {
|
||||||
|
public router: Router;
|
||||||
|
|
||||||
|
constructor(private readonly featureFlagsService: FeatureFlagsService) {
|
||||||
|
this.router = Router();
|
||||||
|
this.initializeRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeRoutes(): void {
|
||||||
|
// Flag CRUD
|
||||||
|
this.router.get('/flags', this.findAllFlags.bind(this));
|
||||||
|
this.router.get('/flags/all', this.findAllFlagsIncludingInactive.bind(this));
|
||||||
|
this.router.get('/flags/tags/:tags', this.findFlagsByTags.bind(this));
|
||||||
|
this.router.get('/flags/:id', this.findFlagById.bind(this));
|
||||||
|
this.router.get('/flags/code/:code', this.findFlagByCode.bind(this));
|
||||||
|
this.router.post('/flags', this.createFlag.bind(this));
|
||||||
|
this.router.patch('/flags/:id', this.updateFlag.bind(this));
|
||||||
|
this.router.delete('/flags/:id', this.deleteFlag.bind(this));
|
||||||
|
this.router.patch('/flags/:id/toggle', this.toggleFlag.bind(this));
|
||||||
|
this.router.get('/flags/:id/stats', this.getFlagStats.bind(this));
|
||||||
|
|
||||||
|
// Tenant Overrides
|
||||||
|
this.router.get('/flags/:flagId/overrides', this.findOverridesForFlag.bind(this));
|
||||||
|
this.router.get('/tenants/:tenantId/overrides', this.findOverridesForTenant.bind(this));
|
||||||
|
this.router.get('/overrides/:id', this.findOverrideById.bind(this));
|
||||||
|
this.router.post('/overrides', this.createOverride.bind(this));
|
||||||
|
this.router.patch('/overrides/:id', this.updateOverride.bind(this));
|
||||||
|
this.router.delete('/overrides/:id', this.deleteOverride.bind(this));
|
||||||
|
|
||||||
|
// Evaluation
|
||||||
|
this.router.get('/evaluate/:code', this.evaluateFlag.bind(this));
|
||||||
|
this.router.post('/evaluate', this.evaluateFlags.bind(this));
|
||||||
|
this.router.get('/is-enabled/:code', this.isEnabled.bind(this));
|
||||||
|
|
||||||
|
// Maintenance
|
||||||
|
this.router.post('/maintenance/cleanup', this.cleanupExpiredOverrides.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// FLAGS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
private async findAllFlags(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const flags = await this.featureFlagsService.findAllFlags();
|
||||||
|
res.json({ data: flags, total: flags.length });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findAllFlagsIncludingInactive(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const flags = await this.featureFlagsService.findAllFlagsIncludingInactive();
|
||||||
|
res.json({ data: flags, total: flags.length });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findFlagById(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const flag = await this.featureFlagsService.findFlagById(id);
|
||||||
|
|
||||||
|
if (!flag) {
|
||||||
|
res.status(404).json({ error: 'Flag not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: flag });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findFlagByCode(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { code } = req.params;
|
||||||
|
const flag = await this.featureFlagsService.findFlagByCode(code);
|
||||||
|
|
||||||
|
if (!flag) {
|
||||||
|
res.status(404).json({ error: 'Flag not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: flag });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findFlagsByTags(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { tags } = req.params;
|
||||||
|
const tagList = tags.split(',');
|
||||||
|
const flags = await this.featureFlagsService.findFlagsByTags(tagList);
|
||||||
|
res.json({ data: flags, total: flags.length });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createFlag(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const userId = req.headers['x-user-id'] as string;
|
||||||
|
const flag = await this.featureFlagsService.createFlag(req.body, userId);
|
||||||
|
res.status(201).json({ data: flag });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateFlag(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const userId = req.headers['x-user-id'] as string;
|
||||||
|
const flag = await this.featureFlagsService.updateFlag(id, req.body, userId);
|
||||||
|
|
||||||
|
if (!flag) {
|
||||||
|
res.status(404).json({ error: 'Flag not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: flag });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deleteFlag(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { soft } = req.query;
|
||||||
|
const userId = req.headers['x-user-id'] as string;
|
||||||
|
|
||||||
|
let result: boolean;
|
||||||
|
if (soft === 'true') {
|
||||||
|
const flag = await this.featureFlagsService.softDeleteFlag(id, userId);
|
||||||
|
result = flag !== null;
|
||||||
|
} else {
|
||||||
|
result = await this.featureFlagsService.deleteFlag(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
res.status(404).json({ error: 'Flag not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async toggleFlag(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { enabled } = req.body;
|
||||||
|
const userId = req.headers['x-user-id'] as string;
|
||||||
|
|
||||||
|
const flag = await this.featureFlagsService.toggleFlag(id, enabled, userId);
|
||||||
|
|
||||||
|
if (!flag) {
|
||||||
|
res.status(404).json({ error: 'Flag not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: flag });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getFlagStats(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const stats = await this.featureFlagsService.getFlagStats(id);
|
||||||
|
|
||||||
|
if (!stats.flag) {
|
||||||
|
res.status(404).json({ error: 'Flag not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: stats });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// OVERRIDES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
private async findOverridesForFlag(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { flagId } = req.params;
|
||||||
|
const overrides = await this.featureFlagsService.findOverridesForFlag(flagId);
|
||||||
|
res.json({ data: overrides, total: overrides.length });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findOverridesForTenant(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { tenantId } = req.params;
|
||||||
|
const overrides = await this.featureFlagsService.findOverridesForTenant(tenantId);
|
||||||
|
res.json({ data: overrides, total: overrides.length });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findOverrideById(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const override = await this.featureFlagsService.findOverrideById(id);
|
||||||
|
|
||||||
|
if (!override) {
|
||||||
|
res.status(404).json({ error: 'Override not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: override });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createOverride(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const userId = req.headers['x-user-id'] as string;
|
||||||
|
const override = await this.featureFlagsService.createOverride(req.body, userId);
|
||||||
|
res.status(201).json({ data: override });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateOverride(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const override = await this.featureFlagsService.updateOverride(id, req.body);
|
||||||
|
|
||||||
|
if (!override) {
|
||||||
|
res.status(404).json({ error: 'Override not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: override });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deleteOverride(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const deleted = await this.featureFlagsService.deleteOverride(id);
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
res.status(404).json({ error: 'Override not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// EVALUATION
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
private async evaluateFlag(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { code } = req.params;
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string || req.query.tenantId as string;
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
res.status(400).json({ error: 'tenantId is required (header x-tenant-id or query param)' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.featureFlagsService.evaluateFlag(code, tenantId);
|
||||||
|
res.json({ data: result });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async evaluateFlags(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { flagCodes, tenantId } = req.body;
|
||||||
|
|
||||||
|
if (!flagCodes || !Array.isArray(flagCodes)) {
|
||||||
|
res.status(400).json({ error: 'flagCodes array is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
res.status(400).json({ error: 'tenantId is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await this.featureFlagsService.evaluateFlags(flagCodes, tenantId);
|
||||||
|
res.json({ data: results, total: results.length });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async isEnabled(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { code } = req.params;
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string || req.query.tenantId as string;
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
res.status(400).json({ error: 'tenantId is required (header x-tenant-id or query param)' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabled = await this.featureFlagsService.isEnabled(code, tenantId);
|
||||||
|
res.json({ data: { code, enabled } });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// MAINTENANCE
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
private async cleanupExpiredOverrides(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const count = await this.featureFlagsService.cleanupExpiredOverrides();
|
||||||
|
res.json({ data: { cleanedUp: count } });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/modules/feature-flags/controllers/index.ts
Normal file
1
src/modules/feature-flags/controllers/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { FeatureFlagsController } from './feature-flags.controller';
|
||||||
53
src/modules/feature-flags/dto/feature-flag.dto.ts
Normal file
53
src/modules/feature-flags/dto/feature-flag.dto.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
// =====================================================
|
||||||
|
// DTOs: Feature Flags
|
||||||
|
// Modulo: MGN-019
|
||||||
|
// Version: 1.0.0
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
export interface CreateFlagDto {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
rolloutPercentage?: number;
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateFlagDto {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
rolloutPercentage?: number;
|
||||||
|
tags?: string[];
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTenantOverrideDto {
|
||||||
|
flagId: string;
|
||||||
|
tenantId: string;
|
||||||
|
enabled: boolean;
|
||||||
|
reason?: string;
|
||||||
|
expiresAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateTenantOverrideDto {
|
||||||
|
enabled?: boolean;
|
||||||
|
reason?: string;
|
||||||
|
expiresAt?: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvaluateFlagDto {
|
||||||
|
flagCode: string;
|
||||||
|
tenantId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvaluateFlagsDto {
|
||||||
|
flagCodes: string[];
|
||||||
|
tenantId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlagEvaluationResult {
|
||||||
|
code: string;
|
||||||
|
enabled: boolean;
|
||||||
|
source: 'override' | 'global' | 'rollout' | 'default';
|
||||||
|
}
|
||||||
1
src/modules/feature-flags/dto/index.ts
Normal file
1
src/modules/feature-flags/dto/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './feature-flag.dto';
|
||||||
57
src/modules/feature-flags/entities/flag.entity.ts
Normal file
57
src/modules/feature-flags/entities/flag.entity.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
Unique,
|
||||||
|
OneToMany,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { TenantOverride } from './tenant-override.entity';
|
||||||
|
|
||||||
|
@Entity({ name: 'flags', schema: 'feature_flags' })
|
||||||
|
@Unique(['code'])
|
||||||
|
export class Flag {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'code', type: 'varchar', length: 50 })
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@Column({ name: 'name', type: 'varchar', length: 100 })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ name: 'description', type: 'text', nullable: true })
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'enabled', type: 'boolean', default: false })
|
||||||
|
enabled: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'rollout_percentage', type: 'int', default: 100 })
|
||||||
|
rolloutPercentage: number;
|
||||||
|
|
||||||
|
@Column({ name: 'tags', type: 'text', array: true, nullable: true })
|
||||||
|
tags: string[];
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||||
|
createdBy: string;
|
||||||
|
|
||||||
|
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
||||||
|
updatedBy: string;
|
||||||
|
|
||||||
|
@OneToMany(() => TenantOverride, (override) => override.flag)
|
||||||
|
overrides: TenantOverride[];
|
||||||
|
}
|
||||||
2
src/modules/feature-flags/entities/index.ts
Normal file
2
src/modules/feature-flags/entities/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { Flag } from './flag.entity';
|
||||||
|
export { TenantOverride } from './tenant-override.entity';
|
||||||
50
src/modules/feature-flags/entities/tenant-override.entity.ts
Normal file
50
src/modules/feature-flags/entities/tenant-override.entity.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
Unique,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Flag } from './flag.entity';
|
||||||
|
|
||||||
|
@Entity({ name: 'tenant_overrides', schema: 'feature_flags' })
|
||||||
|
@Unique(['flagId', 'tenantId'])
|
||||||
|
export class TenantOverride {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'flag_id', type: 'uuid' })
|
||||||
|
flagId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'enabled', type: 'boolean' })
|
||||||
|
enabled: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'reason', type: 'text', nullable: true })
|
||||||
|
reason: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'expires_at', type: 'timestamptz', nullable: true })
|
||||||
|
expiresAt: Date;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||||
|
createdBy: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Flag, (flag) => flag.overrides, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'flag_id' })
|
||||||
|
flag: Flag;
|
||||||
|
}
|
||||||
44
src/modules/feature-flags/feature-flags.module.ts
Normal file
44
src/modules/feature-flags/feature-flags.module.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { FeatureFlagsService } from './services';
|
||||||
|
import { FeatureFlagsController } from './controllers';
|
||||||
|
import { Flag, TenantOverride } from './entities';
|
||||||
|
|
||||||
|
export interface FeatureFlagsModuleOptions {
|
||||||
|
dataSource: DataSource;
|
||||||
|
basePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FeatureFlagsModule {
|
||||||
|
public router: Router;
|
||||||
|
public featureFlagsService: FeatureFlagsService;
|
||||||
|
private dataSource: DataSource;
|
||||||
|
private basePath: string;
|
||||||
|
|
||||||
|
constructor(options: FeatureFlagsModuleOptions) {
|
||||||
|
this.dataSource = options.dataSource;
|
||||||
|
this.basePath = options.basePath || '';
|
||||||
|
this.router = Router();
|
||||||
|
this.initializeServices();
|
||||||
|
this.initializeRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeServices(): void {
|
||||||
|
const flagRepository = this.dataSource.getRepository(Flag);
|
||||||
|
const overrideRepository = this.dataSource.getRepository(TenantOverride);
|
||||||
|
|
||||||
|
this.featureFlagsService = new FeatureFlagsService(
|
||||||
|
flagRepository,
|
||||||
|
overrideRepository
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeRoutes(): void {
|
||||||
|
const featureFlagsController = new FeatureFlagsController(this.featureFlagsService);
|
||||||
|
this.router.use(`${this.basePath}/feature-flags`, featureFlagsController.router);
|
||||||
|
}
|
||||||
|
|
||||||
|
static getEntities(): Function[] {
|
||||||
|
return [Flag, TenantOverride];
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/modules/feature-flags/index.ts
Normal file
5
src/modules/feature-flags/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export { FeatureFlagsModule, FeatureFlagsModuleOptions } from './feature-flags.module';
|
||||||
|
export { FeatureFlagsService } from './services';
|
||||||
|
export { FeatureFlagsController } from './controllers';
|
||||||
|
export { Flag, TenantOverride } from './entities';
|
||||||
|
export * from './dto';
|
||||||
345
src/modules/feature-flags/services/feature-flags.service.ts
Normal file
345
src/modules/feature-flags/services/feature-flags.service.ts
Normal file
@ -0,0 +1,345 @@
|
|||||||
|
import { Repository, In } from 'typeorm';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
import { Flag, TenantOverride } from '../entities';
|
||||||
|
import {
|
||||||
|
CreateFlagDto,
|
||||||
|
UpdateFlagDto,
|
||||||
|
CreateTenantOverrideDto,
|
||||||
|
UpdateTenantOverrideDto,
|
||||||
|
FlagEvaluationResult,
|
||||||
|
} from '../dto';
|
||||||
|
|
||||||
|
export class FeatureFlagsService {
|
||||||
|
constructor(
|
||||||
|
private readonly flagRepository: Repository<Flag>,
|
||||||
|
private readonly overrideRepository: Repository<TenantOverride>
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// FLAGS - CRUD
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async findAllFlags(): Promise<Flag[]> {
|
||||||
|
return this.flagRepository.find({
|
||||||
|
where: { isActive: true },
|
||||||
|
order: { code: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAllFlagsIncludingInactive(): Promise<Flag[]> {
|
||||||
|
return this.flagRepository.find({
|
||||||
|
order: { code: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findFlagById(id: string): Promise<Flag | null> {
|
||||||
|
return this.flagRepository.findOne({
|
||||||
|
where: { id },
|
||||||
|
relations: ['overrides'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findFlagByCode(code: string): Promise<Flag | null> {
|
||||||
|
return this.flagRepository.findOne({
|
||||||
|
where: { code },
|
||||||
|
relations: ['overrides'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findFlagsByTags(tags: string[]): Promise<Flag[]> {
|
||||||
|
return this.flagRepository
|
||||||
|
.createQueryBuilder('flag')
|
||||||
|
.where('flag.is_active = true')
|
||||||
|
.andWhere('flag.tags && :tags', { tags })
|
||||||
|
.orderBy('flag.code', 'ASC')
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
async createFlag(data: CreateFlagDto, createdBy?: string): Promise<Flag> {
|
||||||
|
const flag = this.flagRepository.create({
|
||||||
|
...data,
|
||||||
|
createdBy,
|
||||||
|
});
|
||||||
|
return this.flagRepository.save(flag);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateFlag(
|
||||||
|
id: string,
|
||||||
|
data: UpdateFlagDto,
|
||||||
|
updatedBy?: string
|
||||||
|
): Promise<Flag | null> {
|
||||||
|
const flag = await this.flagRepository.findOne({ where: { id } });
|
||||||
|
if (!flag) return null;
|
||||||
|
|
||||||
|
Object.assign(flag, data, { updatedBy });
|
||||||
|
return this.flagRepository.save(flag);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteFlag(id: string): Promise<boolean> {
|
||||||
|
const result = await this.flagRepository.delete(id);
|
||||||
|
return (result.affected ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async softDeleteFlag(id: string, updatedBy?: string): Promise<Flag | null> {
|
||||||
|
return this.updateFlag(id, { isActive: false }, updatedBy);
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleFlag(id: string, enabled: boolean, updatedBy?: string): Promise<Flag | null> {
|
||||||
|
return this.updateFlag(id, { enabled }, updatedBy);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// TENANT OVERRIDES - CRUD
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async findOverridesForFlag(flagId: string): Promise<TenantOverride[]> {
|
||||||
|
return this.overrideRepository.find({
|
||||||
|
where: { flagId },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOverridesForTenant(tenantId: string): Promise<TenantOverride[]> {
|
||||||
|
return this.overrideRepository.find({
|
||||||
|
where: { tenantId },
|
||||||
|
relations: ['flag'],
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOverride(flagId: string, tenantId: string): Promise<TenantOverride | null> {
|
||||||
|
return this.overrideRepository.findOne({
|
||||||
|
where: { flagId, tenantId },
|
||||||
|
relations: ['flag'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOverrideById(id: string): Promise<TenantOverride | null> {
|
||||||
|
return this.overrideRepository.findOne({
|
||||||
|
where: { id },
|
||||||
|
relations: ['flag'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createOverride(
|
||||||
|
data: CreateTenantOverrideDto,
|
||||||
|
createdBy?: string
|
||||||
|
): Promise<TenantOverride> {
|
||||||
|
const override = this.overrideRepository.create({
|
||||||
|
...data,
|
||||||
|
createdBy,
|
||||||
|
});
|
||||||
|
return this.overrideRepository.save(override);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateOverride(
|
||||||
|
id: string,
|
||||||
|
data: UpdateTenantOverrideDto
|
||||||
|
): Promise<TenantOverride | null> {
|
||||||
|
const override = await this.overrideRepository.findOne({ where: { id } });
|
||||||
|
if (!override) return null;
|
||||||
|
|
||||||
|
Object.assign(override, data);
|
||||||
|
return this.overrideRepository.save(override);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteOverride(id: string): Promise<boolean> {
|
||||||
|
const result = await this.overrideRepository.delete(id);
|
||||||
|
return (result.affected ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteOverrideByFlagAndTenant(flagId: string, tenantId: string): Promise<boolean> {
|
||||||
|
const result = await this.overrideRepository.delete({ flagId, tenantId });
|
||||||
|
return (result.affected ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// FLAG EVALUATION
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates a single flag for a tenant.
|
||||||
|
* Priority: tenant override > global flag > rollout > default (false)
|
||||||
|
*/
|
||||||
|
async evaluateFlag(flagCode: string, tenantId: string): Promise<FlagEvaluationResult> {
|
||||||
|
// 1. Find the flag
|
||||||
|
const flag = await this.flagRepository.findOne({
|
||||||
|
where: { code: flagCode, isActive: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!flag) {
|
||||||
|
return { code: flagCode, enabled: false, source: 'default' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check tenant override
|
||||||
|
const override = await this.overrideRepository.findOne({
|
||||||
|
where: { flagId: flag.id, tenantId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (override) {
|
||||||
|
// Check if override is expired
|
||||||
|
if (!override.expiresAt || override.expiresAt > new Date()) {
|
||||||
|
return { code: flagCode, enabled: override.enabled, source: 'override' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Check global flag state
|
||||||
|
if (!flag.enabled) {
|
||||||
|
return { code: flagCode, enabled: false, source: 'global' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Evaluate rollout percentage
|
||||||
|
if (flag.rolloutPercentage >= 100) {
|
||||||
|
return { code: flagCode, enabled: true, source: 'global' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flag.rolloutPercentage <= 0) {
|
||||||
|
return { code: flagCode, enabled: false, source: 'rollout' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Deterministic hash-based rollout
|
||||||
|
const bucket = this.calculateBucket(flagCode, tenantId);
|
||||||
|
const enabled = bucket < flag.rolloutPercentage;
|
||||||
|
|
||||||
|
return { code: flagCode, enabled, source: 'rollout' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates multiple flags for a tenant in a single call.
|
||||||
|
* More efficient than calling evaluateFlag multiple times.
|
||||||
|
*/
|
||||||
|
async evaluateFlags(
|
||||||
|
flagCodes: string[],
|
||||||
|
tenantId: string
|
||||||
|
): Promise<FlagEvaluationResult[]> {
|
||||||
|
// Get all requested flags in one query
|
||||||
|
const flags = await this.flagRepository.find({
|
||||||
|
where: { code: In(flagCodes), isActive: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const flagMap = new Map(flags.map((f) => [f.code, f]));
|
||||||
|
|
||||||
|
// Get all overrides for this tenant and these flags in one query
|
||||||
|
const flagIds = flags.map((f) => f.id);
|
||||||
|
const overrides = await this.overrideRepository.find({
|
||||||
|
where: { flagId: In(flagIds), tenantId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const overrideMap = new Map(overrides.map((o) => [o.flagId, o]));
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// Evaluate each flag
|
||||||
|
return flagCodes.map((code) => {
|
||||||
|
const flag = flagMap.get(code);
|
||||||
|
|
||||||
|
if (!flag) {
|
||||||
|
return { code, enabled: false, source: 'default' as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
const override = overrideMap.get(flag.id);
|
||||||
|
|
||||||
|
if (override && (!override.expiresAt || override.expiresAt > now)) {
|
||||||
|
return { code, enabled: override.enabled, source: 'override' as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!flag.enabled) {
|
||||||
|
return { code, enabled: false, source: 'global' as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flag.rolloutPercentage >= 100) {
|
||||||
|
return { code, enabled: true, source: 'global' as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flag.rolloutPercentage <= 0) {
|
||||||
|
return { code, enabled: false, source: 'rollout' as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
const bucket = this.calculateBucket(code, tenantId);
|
||||||
|
const enabled = bucket < flag.rolloutPercentage;
|
||||||
|
|
||||||
|
return { code, enabled, source: 'rollout' as const };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick boolean check for a single flag.
|
||||||
|
*/
|
||||||
|
async isEnabled(flagCode: string, tenantId: string): Promise<boolean> {
|
||||||
|
const result = await this.evaluateFlag(flagCode, tenantId);
|
||||||
|
return result.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// MAINTENANCE
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes expired overrides from the database.
|
||||||
|
* Should be called periodically via cron job.
|
||||||
|
*/
|
||||||
|
async cleanupExpiredOverrides(): Promise<number> {
|
||||||
|
const now = new Date();
|
||||||
|
const result = await this.overrideRepository
|
||||||
|
.createQueryBuilder()
|
||||||
|
.delete()
|
||||||
|
.where('expires_at IS NOT NULL')
|
||||||
|
.andWhere('expires_at < :now', { now })
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return result.affected ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets statistics for a flag including override counts.
|
||||||
|
*/
|
||||||
|
async getFlagStats(flagId: string): Promise<{
|
||||||
|
flag: Flag | null;
|
||||||
|
overrideCount: number;
|
||||||
|
enabledOverrides: number;
|
||||||
|
disabledOverrides: number;
|
||||||
|
}> {
|
||||||
|
const flag = await this.flagRepository.findOne({ where: { id: flagId } });
|
||||||
|
|
||||||
|
if (!flag) {
|
||||||
|
return {
|
||||||
|
flag: null,
|
||||||
|
overrideCount: 0,
|
||||||
|
enabledOverrides: 0,
|
||||||
|
disabledOverrides: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const overrides = await this.overrideRepository
|
||||||
|
.createQueryBuilder('o')
|
||||||
|
.where('o.flag_id = :flagId', { flagId })
|
||||||
|
.andWhere('(o.expires_at IS NULL OR o.expires_at > :now)', { now })
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
const enabledOverrides = overrides.filter((o) => o.enabled).length;
|
||||||
|
const disabledOverrides = overrides.filter((o) => !o.enabled).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
flag,
|
||||||
|
overrideCount: overrides.length,
|
||||||
|
enabledOverrides,
|
||||||
|
disabledOverrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// PRIVATE HELPERS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates a deterministic bucket (0-99) for rollout evaluation.
|
||||||
|
* Uses MD5 hash of flag code + tenant ID for consistent results.
|
||||||
|
*/
|
||||||
|
private calculateBucket(flagCode: string, tenantId: string): number {
|
||||||
|
const input = `${flagCode}:${tenantId}`;
|
||||||
|
const hash = createHash('md5').update(input).digest('hex');
|
||||||
|
// Take first 8 chars of hash and convert to number
|
||||||
|
const num = parseInt(hash.substring(0, 8), 16);
|
||||||
|
return Math.abs(num % 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/modules/feature-flags/services/index.ts
Normal file
1
src/modules/feature-flags/services/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { FeatureFlagsService } from './feature-flags.service';
|
||||||
1
src/modules/inventory/controllers/index.ts
Normal file
1
src/modules/inventory/controllers/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { InventoryController } from './inventory.controller';
|
||||||
342
src/modules/inventory/controllers/inventory.controller.ts
Normal file
342
src/modules/inventory/controllers/inventory.controller.ts
Normal file
@ -0,0 +1,342 @@
|
|||||||
|
import { Request, Response, NextFunction, Router } from 'express';
|
||||||
|
import { InventoryService } from '../services/inventory.service';
|
||||||
|
import {
|
||||||
|
CreateStockMovementDto,
|
||||||
|
AdjustStockDto,
|
||||||
|
TransferStockDto,
|
||||||
|
ReserveStockDto,
|
||||||
|
} from '../dto';
|
||||||
|
|
||||||
|
export class InventoryController {
|
||||||
|
public router: Router;
|
||||||
|
|
||||||
|
constructor(private readonly inventoryService: InventoryService) {
|
||||||
|
this.router = Router();
|
||||||
|
this.initializeRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeRoutes(): void {
|
||||||
|
// Stock Levels
|
||||||
|
this.router.get('/stock', this.getStockLevels.bind(this));
|
||||||
|
this.router.get('/stock/product/:productId', this.getStockByProduct.bind(this));
|
||||||
|
this.router.get('/stock/warehouse/:warehouseId', this.getStockByWarehouse.bind(this));
|
||||||
|
this.router.get(
|
||||||
|
'/stock/available/:productId/:warehouseId',
|
||||||
|
this.getAvailableStock.bind(this)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Movements
|
||||||
|
this.router.get('/movements', this.getMovements.bind(this));
|
||||||
|
this.router.get('/movements/:id', this.getMovement.bind(this));
|
||||||
|
this.router.post('/movements', this.createMovement.bind(this));
|
||||||
|
this.router.post('/movements/:id/confirm', this.confirmMovement.bind(this));
|
||||||
|
this.router.post('/movements/:id/cancel', this.cancelMovement.bind(this));
|
||||||
|
|
||||||
|
// Operations
|
||||||
|
this.router.post('/adjust', this.adjustStock.bind(this));
|
||||||
|
this.router.post('/transfer', this.transferStock.bind(this));
|
||||||
|
this.router.post('/reserve', this.reserveStock.bind(this));
|
||||||
|
this.router.post('/release', this.releaseReservation.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Stock Levels ====================
|
||||||
|
|
||||||
|
private async getStockLevels(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
if (!tenantId) {
|
||||||
|
res.status(400).json({ error: 'Tenant ID is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
productId,
|
||||||
|
warehouseId,
|
||||||
|
locationId,
|
||||||
|
lotNumber,
|
||||||
|
hasStock,
|
||||||
|
lowStock,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
const result = await this.inventoryService.getStockLevels({
|
||||||
|
tenantId,
|
||||||
|
productId: productId as string,
|
||||||
|
warehouseId: warehouseId as string,
|
||||||
|
locationId: locationId as string,
|
||||||
|
lotNumber: lotNumber as string,
|
||||||
|
hasStock: hasStock ? hasStock === 'true' : undefined,
|
||||||
|
lowStock: lowStock ? lowStock === 'true' : undefined,
|
||||||
|
limit: limit ? parseInt(limit as string, 10) : undefined,
|
||||||
|
offset: offset ? parseInt(offset as string, 10) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getStockByProduct(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
if (!tenantId) {
|
||||||
|
res.status(400).json({ error: 'Tenant ID is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { productId } = req.params;
|
||||||
|
const stock = await this.inventoryService.getStockByProduct(productId, tenantId);
|
||||||
|
res.json({ data: stock });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getStockByWarehouse(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
if (!tenantId) {
|
||||||
|
res.status(400).json({ error: 'Tenant ID is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { warehouseId } = req.params;
|
||||||
|
const stock = await this.inventoryService.getStockByWarehouse(warehouseId, tenantId);
|
||||||
|
res.json({ data: stock });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAvailableStock(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
if (!tenantId) {
|
||||||
|
res.status(400).json({ error: 'Tenant ID is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { productId, warehouseId } = req.params;
|
||||||
|
const available = await this.inventoryService.getAvailableStock(
|
||||||
|
productId,
|
||||||
|
warehouseId,
|
||||||
|
tenantId
|
||||||
|
);
|
||||||
|
res.json({ data: { available } });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Movements ====================
|
||||||
|
|
||||||
|
private async getMovements(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
if (!tenantId) {
|
||||||
|
res.status(400).json({ error: 'Tenant ID is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
movementType,
|
||||||
|
productId,
|
||||||
|
warehouseId,
|
||||||
|
status,
|
||||||
|
referenceType,
|
||||||
|
referenceId,
|
||||||
|
fromDate,
|
||||||
|
toDate,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
const result = await this.inventoryService.getMovements({
|
||||||
|
tenantId,
|
||||||
|
movementType: movementType as string,
|
||||||
|
productId: productId as string,
|
||||||
|
warehouseId: warehouseId as string,
|
||||||
|
status: status as 'draft' | 'confirmed' | 'cancelled',
|
||||||
|
referenceType: referenceType as string,
|
||||||
|
referenceId: referenceId as string,
|
||||||
|
fromDate: fromDate ? new Date(fromDate as string) : undefined,
|
||||||
|
toDate: toDate ? new Date(toDate as string) : undefined,
|
||||||
|
limit: limit ? parseInt(limit as string, 10) : undefined,
|
||||||
|
offset: offset ? parseInt(offset as string, 10) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getMovement(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
if (!tenantId) {
|
||||||
|
res.status(400).json({ error: 'Tenant ID is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const movement = await this.inventoryService.getMovement(id, tenantId);
|
||||||
|
|
||||||
|
if (!movement) {
|
||||||
|
res.status(404).json({ error: 'Movement not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: movement });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createMovement(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = req.headers['x-user-id'] as string;
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
res.status(400).json({ error: 'Tenant ID is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateStockMovementDto = req.body;
|
||||||
|
const movement = await this.inventoryService.createMovement(tenantId, dto, userId);
|
||||||
|
res.status(201).json({ data: movement });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async confirmMovement(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = req.headers['x-user-id'] as string;
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
res.status(400).json({ error: 'Tenant ID is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const movement = await this.inventoryService.confirmMovement(id, tenantId, userId);
|
||||||
|
|
||||||
|
if (!movement) {
|
||||||
|
res.status(404).json({ error: 'Movement not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: movement });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async cancelMovement(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
res.status(400).json({ error: 'Tenant ID is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const movement = await this.inventoryService.cancelMovement(id, tenantId);
|
||||||
|
|
||||||
|
if (!movement) {
|
||||||
|
res.status(404).json({ error: 'Movement not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: movement });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Operations ====================
|
||||||
|
|
||||||
|
private async adjustStock(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = req.headers['x-user-id'] as string;
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
res.status(400).json({ error: 'Tenant ID is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: AdjustStockDto = req.body;
|
||||||
|
const movement = await this.inventoryService.adjustStock(tenantId, dto, userId);
|
||||||
|
res.status(201).json({ data: movement });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async transferStock(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
const userId = req.headers['x-user-id'] as string;
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
res.status(400).json({ error: 'Tenant ID is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: TransferStockDto = req.body;
|
||||||
|
const movement = await this.inventoryService.transferStock(tenantId, dto, userId);
|
||||||
|
res.status(201).json({ data: movement });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async reserveStock(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
res.status(400).json({ error: 'Tenant ID is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: ReserveStockDto = req.body;
|
||||||
|
await this.inventoryService.reserveStock(tenantId, dto);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async releaseReservation(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.headers['x-tenant-id'] as string;
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
res.status(400).json({ error: 'Tenant ID is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { productId, warehouseId, quantity } = req.body;
|
||||||
|
await this.inventoryService.releaseReservation(productId, warehouseId, quantity, tenantId);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
192
src/modules/inventory/dto/create-inventory.dto.ts
Normal file
192
src/modules/inventory/dto/create-inventory.dto.ts
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsNumber,
|
||||||
|
IsUUID,
|
||||||
|
IsDateString,
|
||||||
|
MaxLength,
|
||||||
|
IsEnum,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateStockMovementDto {
|
||||||
|
@IsEnum(['receipt', 'shipment', 'transfer', 'adjustment', 'return', 'production', 'consumption'])
|
||||||
|
movementType:
|
||||||
|
| 'receipt'
|
||||||
|
| 'shipment'
|
||||||
|
| 'transfer'
|
||||||
|
| 'adjustment'
|
||||||
|
| 'return'
|
||||||
|
| 'production'
|
||||||
|
| 'consumption';
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
productId: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
sourceWarehouseId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
sourceLocationId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
destWarehouseId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
destLocationId?: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
quantity: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
uom?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
lotNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
serialNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
expiryDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
unitCost?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(30)
|
||||||
|
referenceType?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
referenceId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
referenceNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
reason?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AdjustStockDto {
|
||||||
|
@IsUUID()
|
||||||
|
productId: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
warehouseId: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
locationId?: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
newQuantity: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
lotNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
serialNumber?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
reason: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TransferStockDto {
|
||||||
|
@IsUUID()
|
||||||
|
productId: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
sourceWarehouseId: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
sourceLocationId?: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
destWarehouseId: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
destLocationId?: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
quantity: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
lotNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
serialNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReserveStockDto {
|
||||||
|
@IsUUID()
|
||||||
|
productId: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
warehouseId: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
locationId?: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
quantity: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
lotNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(30)
|
||||||
|
referenceType?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
referenceId?: string;
|
||||||
|
}
|
||||||
6
src/modules/inventory/dto/index.ts
Normal file
6
src/modules/inventory/dto/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export {
|
||||||
|
CreateStockMovementDto,
|
||||||
|
AdjustStockDto,
|
||||||
|
TransferStockDto,
|
||||||
|
ReserveStockDto,
|
||||||
|
} from './create-inventory.dto';
|
||||||
@ -1,11 +1,6 @@
|
|||||||
// Export all inventory entities
|
export { StockLevel } from './stock-level.entity';
|
||||||
export * from './product.entity.js';
|
export { StockMovement } from './stock-movement.entity';
|
||||||
export * from './warehouse.entity.js';
|
export { InventoryCount } from './inventory-count.entity';
|
||||||
export * from './location.entity.js';
|
export { InventoryCountLine } from './inventory-count-line.entity';
|
||||||
export * from './stock-quant.entity.js';
|
export { TransferOrder } from './transfer-order.entity';
|
||||||
export * from './lot.entity.js';
|
export { TransferOrderLine } from './transfer-order-line.entity';
|
||||||
export * from './picking.entity.js';
|
|
||||||
export * from './stock-move.entity.js';
|
|
||||||
export * from './inventory-adjustment.entity.js';
|
|
||||||
export * from './inventory-adjustment-line.entity.js';
|
|
||||||
export * from './stock-valuation-layer.entity.js';
|
|
||||||
|
|||||||
@ -0,0 +1,56 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm';
|
||||||
|
import { InventoryCount } from './inventory-count.entity';
|
||||||
|
|
||||||
|
@Entity({ name: 'inventory_count_lines', schema: 'inventory' })
|
||||||
|
export class InventoryCountLine {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'count_id', type: 'uuid' })
|
||||||
|
countId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => InventoryCount, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'count_id' })
|
||||||
|
count: InventoryCount;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'product_id', type: 'uuid' })
|
||||||
|
productId: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'location_id', type: 'uuid', nullable: true })
|
||||||
|
locationId?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'system_quantity', type: 'decimal', precision: 15, scale: 4, nullable: true })
|
||||||
|
systemQuantity?: number;
|
||||||
|
|
||||||
|
@Column({ name: 'counted_quantity', type: 'decimal', precision: 15, scale: 4, nullable: true })
|
||||||
|
countedQuantity?: number;
|
||||||
|
|
||||||
|
// Note: difference is GENERATED in DDL, but we calculate it in app layer
|
||||||
|
@Column({ name: 'lot_number', type: 'varchar', length: 50, nullable: true })
|
||||||
|
lotNumber?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'serial_number', type: 'varchar', length: 50, nullable: true })
|
||||||
|
serialNumber?: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'is_counted', type: 'boolean', default: false })
|
||||||
|
isCounted: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'counted_at', type: 'timestamptz', nullable: true })
|
||||||
|
countedAt?: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'counted_by', type: 'uuid', nullable: true })
|
||||||
|
countedBy?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
notes?: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user