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 * from './product.entity.js';
|
||||
export * from './warehouse.entity.js';
|
||||
export * from './location.entity.js';
|
||||
export * from './stock-quant.entity.js';
|
||||
export * from './lot.entity.js';
|
||||
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';
|
||||
export { StockLevel } from './stock-level.entity';
|
||||
export { StockMovement } from './stock-movement.entity';
|
||||
export { InventoryCount } from './inventory-count.entity';
|
||||
export { InventoryCountLine } from './inventory-count-line.entity';
|
||||
export { TransferOrder } from './transfer-order.entity';
|
||||
export { TransferOrderLine } from './transfer-order-line.entity';
|
||||
|
||||
@ -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