[TEMPLATE-SAAS-BE] chore: Update analytics and audit modules
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
7cb64656fd
commit
bb556a7789
@ -18,10 +18,9 @@ describe('AnalyticsController', () => {
|
|||||||
const mockUserId = '550e8400-e29b-41d4-a716-446655440002';
|
const mockUserId = '550e8400-e29b-41d4-a716-446655440002';
|
||||||
|
|
||||||
const mockUser: RequestUser = {
|
const mockUser: RequestUser = {
|
||||||
sub: mockUserId,
|
id: mockUserId,
|
||||||
tenant_id: mockTenantId,
|
tenant_id: mockTenantId,
|
||||||
email: 'test@example.com',
|
email: 'test@example.com',
|
||||||
role: 'admin',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockUserMetrics: UserMetricsDto = {
|
const mockUserMetrics: UserMetricsDto = {
|
||||||
@ -267,10 +266,9 @@ describe('AnalyticsController', () => {
|
|||||||
describe('Multi-tenant isolation', () => {
|
describe('Multi-tenant isolation', () => {
|
||||||
it('should always use tenant_id from authenticated user', async () => {
|
it('should always use tenant_id from authenticated user', async () => {
|
||||||
const anotherUser: RequestUser = {
|
const anotherUser: RequestUser = {
|
||||||
sub: 'another-user-id',
|
id: 'another-user-id',
|
||||||
tenant_id: 'another-tenant-id',
|
tenant_id: 'another-tenant-id',
|
||||||
email: 'other@example.com',
|
email: 'other@example.com',
|
||||||
role: 'user',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
service.getUserMetrics.mockResolvedValue(mockUserMetrics);
|
service.getUserMetrics.mockResolvedValue(mockUserMetrics);
|
||||||
|
|||||||
@ -374,7 +374,7 @@ describe('AnalyticsService', () => {
|
|||||||
it('should detect trial period', async () => {
|
it('should detect trial period', async () => {
|
||||||
subscriptionRepo.findOne.mockResolvedValue({
|
subscriptionRepo.findOne.mockResolvedValue({
|
||||||
...mockSubscription,
|
...mockSubscription,
|
||||||
status: SubscriptionStatus.TRIAL,
|
status: SubscriptionStatus.TRIALING,
|
||||||
} as Subscription);
|
} as Subscription);
|
||||||
invoiceRepo.find.mockResolvedValue([]);
|
invoiceRepo.find.mockResolvedValue([]);
|
||||||
|
|
||||||
@ -427,8 +427,8 @@ describe('AnalyticsService', () => {
|
|||||||
{ action: 'UPDATE', count: '30' },
|
{ action: 'UPDATE', count: '30' },
|
||||||
])
|
])
|
||||||
.mockResolvedValueOnce([
|
.mockResolvedValueOnce([
|
||||||
{ entity_type: 'User', count: '25' },
|
{ resource_type: 'User', count: '25' },
|
||||||
{ entity_type: 'Invoice', count: '25' },
|
{ resource_type: 'Invoice', count: '25' },
|
||||||
])
|
])
|
||||||
.mockResolvedValueOnce([
|
.mockResolvedValueOnce([
|
||||||
{ entityType: 'User', count: '25' },
|
{ entityType: 'User', count: '25' },
|
||||||
|
|||||||
@ -190,7 +190,7 @@ export class AnalyticsService {
|
|||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
// Calculate days until expiration
|
// Calculate days until expiration
|
||||||
const daysUntilExpiration = subscription
|
const daysUntilExpiration = subscription?.current_period_end
|
||||||
? Math.max(0, Math.ceil(
|
? Math.max(0, Math.ceil(
|
||||||
(new Date(subscription.current_period_end).getTime() - Date.now()) / (1000 * 60 * 60 * 24)
|
(new Date(subscription.current_period_end).getTime() - Date.now()) / (1000 * 60 * 60 * 24)
|
||||||
))
|
))
|
||||||
@ -210,7 +210,7 @@ export class AnalyticsService {
|
|||||||
totalDue: Math.round(totalDue * 100) / 100,
|
totalDue: Math.round(totalDue * 100) / 100,
|
||||||
averageInvoiceAmount: Math.round(averageInvoiceAmount * 100) / 100,
|
averageInvoiceAmount: Math.round(averageInvoiceAmount * 100) / 100,
|
||||||
daysUntilExpiration,
|
daysUntilExpiration,
|
||||||
isTrialPeriod: subscription?.status === SubscriptionStatus.TRIAL,
|
isTrialPeriod: subscription?.status === SubscriptionStatus.TRIALING,
|
||||||
revenueTrend: Math.round(revenueTrend * 100) / 100,
|
revenueTrend: Math.round(revenueTrend * 100) / 100,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -255,19 +255,19 @@ export class AnalyticsService {
|
|||||||
actionsByType[row.action] = parseInt(row.count, 10);
|
actionsByType[row.action] = parseInt(row.count, 10);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Actions by entity type
|
// Actions by resource type
|
||||||
const actionsByEntityQuery = await this.auditLogRepo
|
const actionsByEntityQuery = await this.auditLogRepo
|
||||||
.createQueryBuilder('audit')
|
.createQueryBuilder('audit')
|
||||||
.select('audit.entity_type', 'entity_type')
|
.select('audit.resource_type', 'resource_type')
|
||||||
.addSelect('COUNT(*)', 'count')
|
.addSelect('COUNT(*)', 'count')
|
||||||
.where('audit.tenant_id = :tenantId', { tenantId })
|
.where('audit.tenant_id = :tenantId', { tenantId })
|
||||||
.andWhere('audit.created_at BETWEEN :startDate AND :endDate', { startDate, endDate })
|
.andWhere('audit.created_at BETWEEN :startDate AND :endDate', { startDate, endDate })
|
||||||
.groupBy('audit.entity_type')
|
.groupBy('audit.resource_type')
|
||||||
.getRawMany();
|
.getRawMany();
|
||||||
|
|
||||||
const actionsByEntity: Record<string, number> = {};
|
const actionsByEntity: Record<string, number> = {};
|
||||||
actionsByEntityQuery.forEach((row) => {
|
actionsByEntityQuery.forEach((row) => {
|
||||||
actionsByEntity[row.entity_type] = parseInt(row.count, 10);
|
actionsByEntity[row.resource_type] = parseInt(row.count, 10);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Peak usage hour
|
// Peak usage hour
|
||||||
@ -307,11 +307,11 @@ export class AnalyticsService {
|
|||||||
// Top entities
|
// Top entities
|
||||||
const topEntitiesQuery = await this.auditLogRepo
|
const topEntitiesQuery = await this.auditLogRepo
|
||||||
.createQueryBuilder('audit')
|
.createQueryBuilder('audit')
|
||||||
.select('audit.entity_type', 'entityType')
|
.select('audit.resource_type', 'entityType')
|
||||||
.addSelect('COUNT(*)', 'count')
|
.addSelect('COUNT(*)', 'count')
|
||||||
.where('audit.tenant_id = :tenantId', { tenantId })
|
.where('audit.tenant_id = :tenantId', { tenantId })
|
||||||
.andWhere('audit.created_at BETWEEN :startDate AND :endDate', { startDate, endDate })
|
.andWhere('audit.created_at BETWEEN :startDate AND :endDate', { startDate, endDate })
|
||||||
.groupBy('audit.entity_type')
|
.groupBy('audit.resource_type')
|
||||||
.orderBy('count', 'DESC')
|
.orderBy('count', 'DESC')
|
||||||
.limit(5)
|
.limit(5)
|
||||||
.getRawMany();
|
.getRawMany();
|
||||||
|
|||||||
@ -34,8 +34,8 @@ describe('AuditController', () => {
|
|||||||
tenant_id: mockTenantId,
|
tenant_id: mockTenantId,
|
||||||
user_id: mockUserId,
|
user_id: mockUserId,
|
||||||
action: AuditAction.CREATE,
|
action: AuditAction.CREATE,
|
||||||
entity_type: 'user',
|
resource_type: 'user',
|
||||||
entity_id: 'user-001',
|
resource_id: 'user-001',
|
||||||
new_values: { email: 'test@example.com' },
|
new_values: { email: 'test@example.com' },
|
||||||
changed_fields: ['email'],
|
changed_fields: ['email'],
|
||||||
ip_address: '192.168.1.1',
|
ip_address: '192.168.1.1',
|
||||||
@ -155,8 +155,8 @@ describe('AuditController', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should filter audit logs by entity_type', async () => {
|
it('should filter audit logs by resource_type', async () => {
|
||||||
const query: QueryAuditLogsDto = { entity_type: 'user' };
|
const query: QueryAuditLogsDto = { resource_type: 'user' };
|
||||||
auditService.queryAuditLogs.mockResolvedValue(mockPaginatedAuditLogs);
|
auditService.queryAuditLogs.mockResolvedValue(mockPaginatedAuditLogs);
|
||||||
|
|
||||||
const result = await controller.queryAuditLogs(mockRequestUser, query);
|
const result = await controller.queryAuditLogs(mockRequestUser, query);
|
||||||
@ -168,9 +168,9 @@ describe('AuditController', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should filter audit logs by entity_id', async () => {
|
it('should filter audit logs by resource_id', async () => {
|
||||||
const query: QueryAuditLogsDto = {
|
const query: QueryAuditLogsDto = {
|
||||||
entity_id: '550e8400-e29b-41d4-a716-446655440003',
|
resource_id: '550e8400-e29b-41d4-a716-446655440003',
|
||||||
};
|
};
|
||||||
auditService.queryAuditLogs.mockResolvedValue(mockPaginatedAuditLogs);
|
auditService.queryAuditLogs.mockResolvedValue(mockPaginatedAuditLogs);
|
||||||
|
|
||||||
@ -224,7 +224,7 @@ describe('AuditController', () => {
|
|||||||
const query: QueryAuditLogsDto = {
|
const query: QueryAuditLogsDto = {
|
||||||
user_id: mockUserId,
|
user_id: mockUserId,
|
||||||
action: AuditAction.UPDATE,
|
action: AuditAction.UPDATE,
|
||||||
entity_type: 'document',
|
resource_type: 'document',
|
||||||
from_date: '2026-01-01',
|
from_date: '2026-01-01',
|
||||||
page: 1,
|
page: 1,
|
||||||
limit: 50,
|
limit: 50,
|
||||||
|
|||||||
@ -18,8 +18,8 @@ describe('AuditService', () => {
|
|||||||
tenant_id: mockTenantId,
|
tenant_id: mockTenantId,
|
||||||
user_id: mockUserId,
|
user_id: mockUserId,
|
||||||
action: AuditAction.CREATE,
|
action: AuditAction.CREATE,
|
||||||
entity_type: 'user',
|
resource_type: 'user',
|
||||||
entity_id: 'user-001',
|
resource_id: 'user-001',
|
||||||
new_values: { email: 'test@example.com' },
|
new_values: { email: 'test@example.com' },
|
||||||
changed_fields: ['email'],
|
changed_fields: ['email'],
|
||||||
ip_address: '192.168.1.1',
|
ip_address: '192.168.1.1',
|
||||||
@ -83,8 +83,8 @@ describe('AuditService', () => {
|
|||||||
tenant_id: mockTenantId,
|
tenant_id: mockTenantId,
|
||||||
user_id: mockUserId,
|
user_id: mockUserId,
|
||||||
action: AuditAction.CREATE,
|
action: AuditAction.CREATE,
|
||||||
entity_type: 'user',
|
resource_type: 'user',
|
||||||
entity_id: 'user-001',
|
resource_id: 'user-001',
|
||||||
new_values: { email: 'test@example.com' },
|
new_values: { email: 'test@example.com' },
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -101,8 +101,8 @@ describe('AuditService', () => {
|
|||||||
tenant_id: mockTenantId,
|
tenant_id: mockTenantId,
|
||||||
user_id: mockUserId,
|
user_id: mockUserId,
|
||||||
action: AuditAction.UPDATE,
|
action: AuditAction.UPDATE,
|
||||||
entity_type: 'user',
|
resource_type: 'user',
|
||||||
entity_id: 'user-001',
|
resource_id: 'user-001',
|
||||||
old_values: { email: 'old@example.com', name: 'Old Name' },
|
old_values: { email: 'old@example.com', name: 'Old Name' },
|
||||||
new_values: { email: 'new@example.com', name: 'Old Name' },
|
new_values: { email: 'new@example.com', name: 'Old Name' },
|
||||||
});
|
});
|
||||||
@ -121,8 +121,8 @@ describe('AuditService', () => {
|
|||||||
await service.createAuditLog({
|
await service.createAuditLog({
|
||||||
tenant_id: mockTenantId,
|
tenant_id: mockTenantId,
|
||||||
action: AuditAction.DELETE,
|
action: AuditAction.DELETE,
|
||||||
entity_type: 'user',
|
resource_type: 'user',
|
||||||
entity_id: 'user-001',
|
resource_id: 'user-001',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(auditLogRepo.create).toHaveBeenCalledWith(
|
expect(auditLogRepo.create).toHaveBeenCalledWith(
|
||||||
@ -140,7 +140,7 @@ describe('AuditService', () => {
|
|||||||
tenant_id: mockTenantId,
|
tenant_id: mockTenantId,
|
||||||
user_id: mockUserId,
|
user_id: mockUserId,
|
||||||
action: AuditAction.READ,
|
action: AuditAction.READ,
|
||||||
entity_type: 'document',
|
resource_type: 'document',
|
||||||
ip_address: '192.168.1.1',
|
ip_address: '192.168.1.1',
|
||||||
user_agent: 'Mozilla/5.0',
|
user_agent: 'Mozilla/5.0',
|
||||||
endpoint: '/api/documents/1',
|
endpoint: '/api/documents/1',
|
||||||
@ -217,7 +217,7 @@ describe('AuditService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should filter by entity_type', async () => {
|
it('should filter by resource_type', async () => {
|
||||||
const qb = {
|
const qb = {
|
||||||
where: jest.fn().mockReturnThis(),
|
where: jest.fn().mockReturnThis(),
|
||||||
andWhere: jest.fn().mockReturnThis(),
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
@ -228,10 +228,10 @@ describe('AuditService', () => {
|
|||||||
};
|
};
|
||||||
auditLogRepo.createQueryBuilder.mockReturnValue(qb as any);
|
auditLogRepo.createQueryBuilder.mockReturnValue(qb as any);
|
||||||
|
|
||||||
await service.queryAuditLogs(mockTenantId, { entity_type: 'user' });
|
await service.queryAuditLogs(mockTenantId, { resource_type: 'user' });
|
||||||
|
|
||||||
expect(qb.andWhere).toHaveBeenCalledWith('audit.entity_type = :entity_type', {
|
expect(qb.andWhere).toHaveBeenCalledWith('audit.resource_type = :resource_type', {
|
||||||
entity_type: 'user',
|
resource_type: 'user',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -318,8 +318,8 @@ describe('AuditService', () => {
|
|||||||
expect(auditLogRepo.find).toHaveBeenCalledWith({
|
expect(auditLogRepo.find).toHaveBeenCalledWith({
|
||||||
where: {
|
where: {
|
||||||
tenant_id: mockTenantId,
|
tenant_id: mockTenantId,
|
||||||
entity_type: 'user',
|
resource_type: 'user',
|
||||||
entity_id: 'user-001',
|
resource_id: 'user-001',
|
||||||
},
|
},
|
||||||
order: { created_at: 'DESC' },
|
order: { created_at: 'DESC' },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -23,15 +23,15 @@ export class QueryAuditLogsDto {
|
|||||||
@IsEnum(AuditAction)
|
@IsEnum(AuditAction)
|
||||||
action?: AuditAction;
|
action?: AuditAction;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Filter by entity type' })
|
@ApiPropertyOptional({ description: 'Filter by resource type' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
entity_type?: string;
|
resource_type?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Filter by entity ID' })
|
@ApiPropertyOptional({ description: 'Filter by resource ID' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
entity_id?: string;
|
resource_id?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Start date filter (ISO 8601)' })
|
@ApiPropertyOptional({ description: 'Start date filter (ISO 8601)' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@ -6,6 +6,9 @@ import {
|
|||||||
Index,
|
Index,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audit action enum - matches DDL audit.action_type
|
||||||
|
*/
|
||||||
export enum AuditAction {
|
export enum AuditAction {
|
||||||
CREATE = 'create',
|
CREATE = 'create',
|
||||||
UPDATE = 'update',
|
UPDATE = 'update',
|
||||||
@ -17,10 +20,39 @@ export enum AuditAction {
|
|||||||
IMPORT = 'import',
|
IMPORT = 'import',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audit severity enum - matches DDL audit.severity
|
||||||
|
*/
|
||||||
|
export enum AuditSeverity {
|
||||||
|
INFO = 'info',
|
||||||
|
WARNING = 'warning',
|
||||||
|
ERROR = 'error',
|
||||||
|
CRITICAL = 'critical',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actor type for audit logs
|
||||||
|
*/
|
||||||
|
export enum ActorType {
|
||||||
|
USER = 'user',
|
||||||
|
SYSTEM = 'system',
|
||||||
|
API_KEY = 'api_key',
|
||||||
|
WEBHOOK = 'webhook',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AuditLog Entity
|
||||||
|
* Maps to audit.audit_logs DDL table
|
||||||
|
* Comprehensive audit trail for compliance
|
||||||
|
*
|
||||||
|
* Note: Uses old_values/new_values/changed_fields structure instead of
|
||||||
|
* single 'changes' JSONB for better query flexibility and filtering.
|
||||||
|
*/
|
||||||
@Entity({ name: 'audit_logs', schema: 'audit' })
|
@Entity({ name: 'audit_logs', schema: 'audit' })
|
||||||
@Index(['tenant_id', 'created_at'])
|
@Index(['tenant_id', 'created_at'])
|
||||||
@Index(['entity_type', 'entity_id'])
|
@Index(['resource_type', 'resource_id'])
|
||||||
@Index(['user_id', 'created_at'])
|
@Index(['user_id', 'created_at'])
|
||||||
|
@Index(['tenant_id', 'action', 'created_at'])
|
||||||
export class AuditLog {
|
export class AuditLog {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
id: string;
|
id: string;
|
||||||
@ -28,52 +60,82 @@ export class AuditLog {
|
|||||||
@Column({ type: 'uuid' })
|
@Column({ type: 'uuid' })
|
||||||
tenant_id: string;
|
tenant_id: string;
|
||||||
|
|
||||||
|
// Actor information
|
||||||
@Column({ type: 'uuid', nullable: true })
|
@Column({ type: 'uuid', nullable: true })
|
||||||
user_id: string;
|
user_id: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||||
|
user_email: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 50, default: ActorType.USER })
|
||||||
|
actor_type: string;
|
||||||
|
|
||||||
|
// Action
|
||||||
@Column({
|
@Column({
|
||||||
type: 'enum',
|
type: 'enum',
|
||||||
enum: AuditAction,
|
enum: AuditAction,
|
||||||
})
|
})
|
||||||
action: AuditAction;
|
action: AuditAction;
|
||||||
|
|
||||||
|
// Resource identification (aligned with DDL naming)
|
||||||
@Column({ type: 'varchar', length: 100 })
|
@Column({ type: 'varchar', length: 100 })
|
||||||
entity_type: string;
|
resource_type: string;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: true })
|
|
||||||
entity_id: string;
|
|
||||||
|
|
||||||
@Column({ type: 'jsonb', nullable: true })
|
|
||||||
old_values: Record<string, any>;
|
|
||||||
|
|
||||||
@Column({ type: 'jsonb', nullable: true })
|
|
||||||
new_values: Record<string, any>;
|
|
||||||
|
|
||||||
@Column({ type: 'jsonb', nullable: true })
|
|
||||||
changed_fields: string[];
|
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 45, nullable: true })
|
|
||||||
ip_address: string;
|
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 500, nullable: true })
|
|
||||||
user_agent: string;
|
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||||
endpoint: string;
|
resource_id: string | null;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 10, nullable: true })
|
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||||
http_method: string;
|
resource_name: string | null;
|
||||||
|
|
||||||
@Column({ type: 'smallint', nullable: true })
|
// Change tracking (Entity structure - more flexible for queries)
|
||||||
response_status: number;
|
@Column({ type: 'jsonb', nullable: true })
|
||||||
|
old_values: Record<string, any> | null;
|
||||||
@Column({ type: 'integer', nullable: true })
|
|
||||||
duration_ms: number;
|
|
||||||
|
|
||||||
@Column({ type: 'text', nullable: true })
|
|
||||||
description: string;
|
|
||||||
|
|
||||||
@Column({ type: 'jsonb', nullable: true })
|
@Column({ type: 'jsonb', nullable: true })
|
||||||
|
new_values: Record<string, any> | null;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', nullable: true })
|
||||||
|
changed_fields: string[] | null;
|
||||||
|
|
||||||
|
// Severity
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: AuditSeverity,
|
||||||
|
default: AuditSeverity.INFO,
|
||||||
|
})
|
||||||
|
severity: AuditSeverity;
|
||||||
|
|
||||||
|
// Context
|
||||||
|
@Column({ type: 'inet', nullable: true })
|
||||||
|
ip_address: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
user_agent: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||||
|
request_id: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true })
|
||||||
|
session_id: string | null;
|
||||||
|
|
||||||
|
// HTTP context (for API audit)
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||||
|
endpoint: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 10, nullable: true })
|
||||||
|
http_method: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'smallint', nullable: true })
|
||||||
|
response_status: number | null;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', nullable: true })
|
||||||
|
duration_ms: number | null;
|
||||||
|
|
||||||
|
// Description and metadata
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', default: {} })
|
||||||
metadata: Record<string, any>;
|
metadata: Record<string, any>;
|
||||||
|
|
||||||
@CreateDateColumn({ type: 'timestamp with time zone' })
|
@CreateDateColumn({ type: 'timestamp with time zone' })
|
||||||
|
|||||||
@ -120,8 +120,8 @@ export class AuditInterceptor implements NestInterceptor {
|
|||||||
tenant_id: tenantId,
|
tenant_id: tenantId,
|
||||||
user_id: request.user?.sub || request.user?.id,
|
user_id: request.user?.sub || request.user?.id,
|
||||||
action,
|
action,
|
||||||
entity_type: entityType || this.inferEntityFromPath(request.path),
|
resource_type: entityType || this.inferEntityFromPath(request.path),
|
||||||
entity_id: request.params?.id,
|
resource_id: request.params?.id,
|
||||||
old_values: request.body?._oldValues, // If provided by controller
|
old_values: request.body?._oldValues, // If provided by controller
|
||||||
new_values: this.sanitizeBody(request.body),
|
new_values: this.sanitizeBody(request.body),
|
||||||
ip_address: this.getClientIp(request),
|
ip_address: this.getClientIp(request),
|
||||||
|
|||||||
@ -11,8 +11,8 @@ export interface CreateAuditLogParams {
|
|||||||
tenant_id: string;
|
tenant_id: string;
|
||||||
user_id?: string;
|
user_id?: string;
|
||||||
action: AuditAction;
|
action: AuditAction;
|
||||||
entity_type: string;
|
resource_type: string;
|
||||||
entity_id?: string;
|
resource_id?: string;
|
||||||
old_values?: Record<string, any>;
|
old_values?: Record<string, any>;
|
||||||
new_values?: Record<string, any>;
|
new_values?: Record<string, any>;
|
||||||
ip_address?: string;
|
ip_address?: string;
|
||||||
@ -66,7 +66,7 @@ export class AuditService {
|
|||||||
tenantId: string,
|
tenantId: string,
|
||||||
query: QueryAuditLogsDto,
|
query: QueryAuditLogsDto,
|
||||||
): Promise<PaginatedResult<AuditLog>> {
|
): Promise<PaginatedResult<AuditLog>> {
|
||||||
const { user_id, action, entity_type, entity_id, from_date, to_date, page = 1, limit = 20 } = query;
|
const { user_id, action, resource_type, resource_id, from_date, to_date, page = 1, limit = 20 } = query;
|
||||||
|
|
||||||
const queryBuilder = this.auditLogRepository
|
const queryBuilder = this.auditLogRepository
|
||||||
.createQueryBuilder('audit')
|
.createQueryBuilder('audit')
|
||||||
@ -80,12 +80,12 @@ export class AuditService {
|
|||||||
queryBuilder.andWhere('audit.action = :action', { action });
|
queryBuilder.andWhere('audit.action = :action', { action });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entity_type) {
|
if (resource_type) {
|
||||||
queryBuilder.andWhere('audit.entity_type = :entity_type', { entity_type });
|
queryBuilder.andWhere('audit.resource_type = :resource_type', { resource_type });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entity_id) {
|
if (resource_id) {
|
||||||
queryBuilder.andWhere('audit.entity_id = :entity_id', { entity_id });
|
queryBuilder.andWhere('audit.resource_id = :resource_id', { resource_id });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (from_date && to_date) {
|
if (from_date && to_date) {
|
||||||
@ -129,14 +129,14 @@ export class AuditService {
|
|||||||
*/
|
*/
|
||||||
async getEntityAuditHistory(
|
async getEntityAuditHistory(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
entityType: string,
|
resourceType: string,
|
||||||
entityId: string,
|
resourceId: string,
|
||||||
): Promise<AuditLog[]> {
|
): Promise<AuditLog[]> {
|
||||||
return this.auditLogRepository.find({
|
return this.auditLogRepository.find({
|
||||||
where: {
|
where: {
|
||||||
tenant_id: tenantId,
|
tenant_id: tenantId,
|
||||||
entity_type: entityType,
|
resource_type: resourceType,
|
||||||
entity_id: entityId,
|
resource_id: resourceId,
|
||||||
},
|
},
|
||||||
order: { created_at: 'DESC' },
|
order: { created_at: 'DESC' },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { AuthService } from '../services/auth.service';
|
import { AuthService } from '../services/auth.service';
|
||||||
import { User, Session, Token } from '../entities';
|
import { User, Session, Token, SessionStatus } from '../entities';
|
||||||
|
|
||||||
// Mock bcrypt
|
// Mock bcrypt
|
||||||
jest.mock('bcrypt');
|
jest.mock('bcrypt');
|
||||||
@ -210,8 +210,8 @@ describe('AuthService', () => {
|
|||||||
await service.logout(mockUser.id!, 'session_token');
|
await service.logout(mockUser.id!, 'session_token');
|
||||||
|
|
||||||
expect(sessionRepository.update).toHaveBeenCalledWith(
|
expect(sessionRepository.update).toHaveBeenCalledWith(
|
||||||
{ user_id: mockUser.id, session_token: 'session_token' },
|
{ user_id: mockUser.id, token_hash: 'session_token' },
|
||||||
{ is_active: false },
|
{ status: SessionStatus.REVOKED },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -224,7 +224,7 @@ describe('AuthService', () => {
|
|||||||
|
|
||||||
expect(sessionRepository.update).toHaveBeenCalledWith(
|
expect(sessionRepository.update).toHaveBeenCalledWith(
|
||||||
{ user_id: mockUser.id },
|
{ user_id: mockUser.id },
|
||||||
{ is_active: false },
|
{ status: SessionStatus.REVOKED },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -422,7 +422,7 @@ describe('AuthService', () => {
|
|||||||
user_id: mockUser.id,
|
user_id: mockUser.id,
|
||||||
tenant_id: mockTenantId,
|
tenant_id: mockTenantId,
|
||||||
refresh_token_hash: 'hashed_refresh_token',
|
refresh_token_hash: 'hashed_refresh_token',
|
||||||
is_active: true,
|
status: SessionStatus.ACTIVE,
|
||||||
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days from now
|
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days from now
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -527,7 +527,7 @@ describe('AuthService', () => {
|
|||||||
|
|
||||||
expect(sessionRepository.update).toHaveBeenCalledWith(
|
expect(sessionRepository.update).toHaveBeenCalledWith(
|
||||||
{ id: expiredSession.id },
|
{ id: expiredSession.id },
|
||||||
{ is_active: false },
|
{ status: SessionStatus.EXPIRED },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -805,7 +805,7 @@ describe('AuthService', () => {
|
|||||||
|
|
||||||
expect(sessionRepository.update).toHaveBeenCalledWith(
|
expect(sessionRepository.update).toHaveBeenCalledWith(
|
||||||
{ user_id: mockToken.user_id },
|
{ user_id: mockToken.user_id },
|
||||||
{ is_active: false },
|
{ status: SessionStatus.REVOKED },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,22 @@ import {
|
|||||||
Index,
|
Index,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session status enum - matches DDL auth.session_status
|
||||||
|
*/
|
||||||
|
export enum SessionStatus {
|
||||||
|
ACTIVE = 'active',
|
||||||
|
REVOKED = 'revoked',
|
||||||
|
EXPIRED = 'expired',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session Entity
|
||||||
|
* Maps to auth.sessions DDL table
|
||||||
|
* Manages user authentication sessions with device info
|
||||||
|
*/
|
||||||
@Entity({ schema: 'auth', name: 'sessions' })
|
@Entity({ schema: 'auth', name: 'sessions' })
|
||||||
|
@Index(['tenant_id', 'user_id'])
|
||||||
export class Session {
|
export class Session {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
id: string;
|
id: string;
|
||||||
@ -19,14 +34,20 @@ export class Session {
|
|||||||
@Index()
|
@Index()
|
||||||
tenant_id: string;
|
tenant_id: string;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 64 })
|
@Column({ type: 'varchar', length: 255, unique: true })
|
||||||
@Index({ unique: true })
|
token_hash: string;
|
||||||
session_token: string;
|
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 64, nullable: true })
|
@Column({ type: 'varchar', length: 64, nullable: true })
|
||||||
refresh_token_hash: string | null;
|
refresh_token_hash: string | null;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 45, nullable: true })
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: SessionStatus,
|
||||||
|
default: SessionStatus.ACTIVE,
|
||||||
|
})
|
||||||
|
status: SessionStatus;
|
||||||
|
|
||||||
|
@Column({ type: 'inet', nullable: true })
|
||||||
ip_address: string | null;
|
ip_address: string | null;
|
||||||
|
|
||||||
@Column({ type: 'text', nullable: true })
|
@Column({ type: 'text', nullable: true })
|
||||||
@ -35,14 +56,29 @@ export class Session {
|
|||||||
@Column({ type: 'varchar', length: 50, nullable: true })
|
@Column({ type: 'varchar', length: 50, nullable: true })
|
||||||
device_type: string | null;
|
device_type: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 200, nullable: true })
|
||||||
|
device_name: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||||
|
browser: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||||
|
os: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 200, nullable: true })
|
||||||
|
location: string | null;
|
||||||
|
|
||||||
@Column({ type: 'timestamp with time zone' })
|
@Column({ type: 'timestamp with time zone' })
|
||||||
expires_at: Date;
|
expires_at: Date;
|
||||||
|
|
||||||
@Column({ type: 'timestamp with time zone', nullable: true })
|
@Column({ type: 'timestamp with time zone', nullable: true })
|
||||||
last_activity_at: Date | null;
|
last_activity_at: Date | null;
|
||||||
|
|
||||||
@Column({ type: 'boolean', default: true })
|
@Column({ type: 'timestamp with time zone', nullable: true })
|
||||||
is_active: boolean;
|
revoked_at: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||||
|
revoked_reason: string | null;
|
||||||
|
|
||||||
@CreateDateColumn({ type: 'timestamp with time zone' })
|
@CreateDateColumn({ type: 'timestamp with time zone' })
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import { JwtService } from '@nestjs/jwt';
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
import { User, Session, Token } from '../entities';
|
import { User, Session, Token, SessionStatus } from '../entities';
|
||||||
import { RegisterDto, LoginDto, ChangePasswordDto } from '../dto';
|
import { RegisterDto, LoginDto, ChangePasswordDto } from '../dto';
|
||||||
|
|
||||||
export interface AuthResponse {
|
export interface AuthResponse {
|
||||||
@ -139,8 +139,8 @@ export class AuthService {
|
|||||||
*/
|
*/
|
||||||
async logout(userId: string, sessionToken: string): Promise<void> {
|
async logout(userId: string, sessionToken: string): Promise<void> {
|
||||||
await this.sessionRepository.update(
|
await this.sessionRepository.update(
|
||||||
{ user_id: userId, session_token: sessionToken },
|
{ user_id: userId, token_hash: sessionToken },
|
||||||
{ is_active: false },
|
{ status: SessionStatus.REVOKED },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -150,7 +150,7 @@ export class AuthService {
|
|||||||
async logoutAll(userId: string): Promise<void> {
|
async logoutAll(userId: string): Promise<void> {
|
||||||
await this.sessionRepository.update(
|
await this.sessionRepository.update(
|
||||||
{ user_id: userId },
|
{ user_id: userId },
|
||||||
{ is_active: false },
|
{ status: SessionStatus.REVOKED },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -183,7 +183,7 @@ export class AuthService {
|
|||||||
where: {
|
where: {
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
refresh_token_hash: refreshTokenHash,
|
refresh_token_hash: refreshTokenHash,
|
||||||
is_active: true,
|
status: SessionStatus.ACTIVE,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -193,7 +193,7 @@ export class AuthService {
|
|||||||
|
|
||||||
// Check if session expired
|
// Check if session expired
|
||||||
if (new Date() > session.expires_at) {
|
if (new Date() > session.expires_at) {
|
||||||
await this.sessionRepository.update({ id: session.id }, { is_active: false });
|
await this.sessionRepository.update({ id: session.id }, { status: SessionStatus.EXPIRED });
|
||||||
throw new UnauthorizedException('Sesión expirada');
|
throw new UnauthorizedException('Sesión expirada');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -434,14 +434,14 @@ export class AuthService {
|
|||||||
await this.sessionRepository.save({
|
await this.sessionRepository.save({
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
tenant_id: user.tenant_id,
|
tenant_id: user.tenant_id,
|
||||||
session_token: sessionToken,
|
token_hash: sessionToken,
|
||||||
refresh_token_hash: refreshTokenHash,
|
refresh_token_hash: refreshTokenHash,
|
||||||
ip_address: ip || null,
|
ip_address: ip || null,
|
||||||
user_agent: userAgent || null,
|
user_agent: userAgent || null,
|
||||||
device_type: this.detectDeviceType(userAgent),
|
device_type: this.detectDeviceType(userAgent),
|
||||||
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||||
last_activity_at: new Date(),
|
last_activity_at: new Date(),
|
||||||
is_active: true,
|
status: SessionStatus.ACTIVE,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -26,12 +26,20 @@ describe('BillingService - Edge Cases', () => {
|
|||||||
tenant_id: mockTenantId,
|
tenant_id: mockTenantId,
|
||||||
plan_id: mockPlanId,
|
plan_id: mockPlanId,
|
||||||
plan: null,
|
plan: null,
|
||||||
|
stripe_subscription_id: null,
|
||||||
|
stripe_customer_id: null,
|
||||||
status: SubscriptionStatus.ACTIVE,
|
status: SubscriptionStatus.ACTIVE,
|
||||||
|
interval: null,
|
||||||
current_period_start: new Date('2026-01-01'),
|
current_period_start: new Date('2026-01-01'),
|
||||||
current_period_end: new Date('2026-02-01'),
|
current_period_end: new Date('2026-02-01'),
|
||||||
|
trial_start: null,
|
||||||
trial_end: null,
|
trial_end: null,
|
||||||
cancelled_at: null,
|
cancel_at: null,
|
||||||
external_subscription_id: '',
|
canceled_at: null,
|
||||||
|
cancel_reason: null,
|
||||||
|
price_amount: null,
|
||||||
|
currency: 'USD',
|
||||||
|
external_subscription_id: null,
|
||||||
payment_provider: 'stripe',
|
payment_provider: 'stripe',
|
||||||
metadata: {},
|
metadata: {},
|
||||||
created_at: new Date(),
|
created_at: new Date(),
|
||||||
@ -55,6 +63,7 @@ describe('BillingService - Edge Cases', () => {
|
|||||||
pdf_url: null,
|
pdf_url: null,
|
||||||
line_items: [{ description: 'Pro Plan', quantity: 1, unit_price: 100, amount: 100 }],
|
line_items: [{ description: 'Pro Plan', quantity: 1, unit_price: 100, amount: 100 }],
|
||||||
billing_details: null as unknown as { name?: string; email?: string; address?: string; tax_id?: string },
|
billing_details: null as unknown as { name?: string; email?: string; address?: string; tax_id?: string },
|
||||||
|
items: [],
|
||||||
created_at: new Date(),
|
created_at: new Date(),
|
||||||
updated_at: new Date(),
|
updated_at: new Date(),
|
||||||
...overrides,
|
...overrides,
|
||||||
@ -131,7 +140,7 @@ describe('BillingService - Edge Cases', () => {
|
|||||||
farFutureTrial.setFullYear(farFutureTrial.getFullYear() + 1);
|
farFutureTrial.setFullYear(farFutureTrial.getFullYear() + 1);
|
||||||
|
|
||||||
const trialSub = createMockSubscription({
|
const trialSub = createMockSubscription({
|
||||||
status: SubscriptionStatus.TRIAL,
|
status: SubscriptionStatus.TRIALING,
|
||||||
trial_end: farFutureTrial,
|
trial_end: farFutureTrial,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -147,7 +156,7 @@ describe('BillingService - Edge Cases', () => {
|
|||||||
|
|
||||||
const result = await service.createSubscription(dto);
|
const result = await service.createSubscription(dto);
|
||||||
|
|
||||||
expect(result.status).toBe(SubscriptionStatus.TRIAL);
|
expect(result.status).toBe(SubscriptionStatus.TRIALING);
|
||||||
expect(result.trial_end).toEqual(farFutureTrial);
|
expect(result.trial_end).toEqual(farFutureTrial);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -205,7 +214,7 @@ describe('BillingService - Edge Cases', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const cancelledSub = createMockSubscription({
|
const cancelledSub = createMockSubscription({
|
||||||
cancelled_at: new Date(),
|
canceled_at: new Date(),
|
||||||
metadata: {
|
metadata: {
|
||||||
existing_key: 'existing_value',
|
existing_key: 'existing_value',
|
||||||
cancellation_reason: 'Too expensive',
|
cancellation_reason: 'Too expensive',
|
||||||
@ -226,13 +235,13 @@ describe('BillingService - Edge Cases', () => {
|
|||||||
|
|
||||||
it('should cancel trial subscription immediately', async () => {
|
it('should cancel trial subscription immediately', async () => {
|
||||||
const trialSub = createMockSubscription({
|
const trialSub = createMockSubscription({
|
||||||
status: SubscriptionStatus.TRIAL,
|
status: SubscriptionStatus.TRIALING,
|
||||||
trial_end: new Date('2026-01-20'),
|
trial_end: new Date('2026-01-20'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const cancelledSub = createMockSubscription({
|
const cancelledSub = createMockSubscription({
|
||||||
status: SubscriptionStatus.CANCELLED,
|
status: SubscriptionStatus.CANCELLED,
|
||||||
cancelled_at: new Date(),
|
canceled_at: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
subscriptionRepo.findOne.mockResolvedValue(trialSub);
|
subscriptionRepo.findOne.mockResolvedValue(trialSub);
|
||||||
@ -252,7 +261,7 @@ describe('BillingService - Edge Cases', () => {
|
|||||||
|
|
||||||
subscriptionRepo.findOne.mockResolvedValue(activeSub);
|
subscriptionRepo.findOne.mockResolvedValue(activeSub);
|
||||||
subscriptionRepo.save.mockImplementation((sub) =>
|
subscriptionRepo.save.mockImplementation((sub) =>
|
||||||
Promise.resolve({ ...sub, cancelled_at: new Date() } as Subscription),
|
Promise.resolve({ ...sub, canceled_at: new Date() } as Subscription),
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await service.cancelSubscription(mockTenantId, {
|
const result = await service.cancelSubscription(mockTenantId, {
|
||||||
@ -260,7 +269,7 @@ describe('BillingService - Edge Cases', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.status).toBe(SubscriptionStatus.ACTIVE);
|
expect(result.status).toBe(SubscriptionStatus.ACTIVE);
|
||||||
expect(result.cancelled_at).toBeDefined();
|
expect(result.canceled_at).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -887,7 +896,7 @@ describe('BillingService - Edge Cases', () => {
|
|||||||
|
|
||||||
it('should return active for trial subscription', async () => {
|
it('should return active for trial subscription', async () => {
|
||||||
const trialSub = createMockSubscription({
|
const trialSub = createMockSubscription({
|
||||||
status: SubscriptionStatus.TRIAL,
|
status: SubscriptionStatus.TRIALING,
|
||||||
current_period_end: new Date('2026-02-01'),
|
current_period_end: new Date('2026-02-01'),
|
||||||
trial_end: new Date('2026-01-15'),
|
trial_end: new Date('2026-01-15'),
|
||||||
});
|
});
|
||||||
@ -897,7 +906,7 @@ describe('BillingService - Edge Cases', () => {
|
|||||||
const result = await service.checkSubscriptionStatus(mockTenantId);
|
const result = await service.checkSubscriptionStatus(mockTenantId);
|
||||||
|
|
||||||
expect(result.isActive).toBe(true);
|
expect(result.isActive).toBe(true);
|
||||||
expect(result.status).toBe(SubscriptionStatus.TRIAL);
|
expect(result.status).toBe(SubscriptionStatus.TRIALING);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -118,7 +118,7 @@ describe('BillingService', () => {
|
|||||||
it('should create trial subscription when trial_end provided', async () => {
|
it('should create trial subscription when trial_end provided', async () => {
|
||||||
const trialSub = {
|
const trialSub = {
|
||||||
...mockSubscription,
|
...mockSubscription,
|
||||||
status: SubscriptionStatus.TRIAL,
|
status: SubscriptionStatus.TRIALING,
|
||||||
trial_end: new Date('2026-01-15'),
|
trial_end: new Date('2026-01-15'),
|
||||||
};
|
};
|
||||||
subscriptionRepo.create.mockReturnValue(trialSub as Subscription);
|
subscriptionRepo.create.mockReturnValue(trialSub as Subscription);
|
||||||
@ -133,7 +133,7 @@ describe('BillingService', () => {
|
|||||||
|
|
||||||
const result = await service.createSubscription(dto);
|
const result = await service.createSubscription(dto);
|
||||||
|
|
||||||
expect(result.status).toBe(SubscriptionStatus.TRIAL);
|
expect(result.status).toBe(SubscriptionStatus.TRIALING);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -189,7 +189,7 @@ describe('BillingService', () => {
|
|||||||
subscriptionRepo.save.mockResolvedValue({
|
subscriptionRepo.save.mockResolvedValue({
|
||||||
...mockSubscription,
|
...mockSubscription,
|
||||||
status: SubscriptionStatus.CANCELLED,
|
status: SubscriptionStatus.CANCELLED,
|
||||||
cancelled_at: new Date(),
|
canceled_at: new Date(),
|
||||||
} as Subscription);
|
} as Subscription);
|
||||||
|
|
||||||
const result = await service.cancelSubscription(mockTenantId, {
|
const result = await service.cancelSubscription(mockTenantId, {
|
||||||
@ -198,12 +198,12 @@ describe('BillingService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.status).toBe(SubscriptionStatus.CANCELLED);
|
expect(result.status).toBe(SubscriptionStatus.CANCELLED);
|
||||||
expect(result.cancelled_at).toBeDefined();
|
expect(result.canceled_at).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should schedule cancellation at period end', async () => {
|
it('should schedule cancellation at period end', async () => {
|
||||||
const activeSub = { ...mockSubscription, status: SubscriptionStatus.ACTIVE };
|
const activeSub = { ...mockSubscription, status: SubscriptionStatus.ACTIVE };
|
||||||
const savedSub = { ...activeSub, cancelled_at: new Date() };
|
const savedSub = { ...activeSub, canceled_at: new Date() };
|
||||||
subscriptionRepo.findOne.mockResolvedValue(activeSub as Subscription);
|
subscriptionRepo.findOne.mockResolvedValue(activeSub as Subscription);
|
||||||
subscriptionRepo.save.mockResolvedValue(savedSub as Subscription);
|
subscriptionRepo.save.mockResolvedValue(savedSub as Subscription);
|
||||||
|
|
||||||
@ -211,7 +211,7 @@ describe('BillingService', () => {
|
|||||||
immediately: false,
|
immediately: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.cancelled_at).toBeDefined();
|
expect(result.canceled_at).toBeDefined();
|
||||||
expect(result.status).toBe(SubscriptionStatus.ACTIVE);
|
expect(result.status).toBe(SubscriptionStatus.ACTIVE);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -539,14 +539,14 @@ describe('BillingService', () => {
|
|||||||
|
|
||||||
subscriptionRepo.findOne.mockResolvedValue({
|
subscriptionRepo.findOne.mockResolvedValue({
|
||||||
...mockSubscription,
|
...mockSubscription,
|
||||||
status: SubscriptionStatus.TRIAL,
|
status: SubscriptionStatus.TRIALING,
|
||||||
current_period_end: futureDate,
|
current_period_end: futureDate,
|
||||||
} as Subscription);
|
} as Subscription);
|
||||||
|
|
||||||
const result = await service.checkSubscriptionStatus(mockTenantId);
|
const result = await service.checkSubscriptionStatus(mockTenantId);
|
||||||
|
|
||||||
expect(result.isActive).toBe(true);
|
expect(result.isActive).toBe(true);
|
||||||
expect(result.status).toBe(SubscriptionStatus.TRIAL);
|
expect(result.status).toBe(SubscriptionStatus.TRIALING);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -752,7 +752,7 @@ describe('StripeService', () => {
|
|||||||
id: 'local-sub-123',
|
id: 'local-sub-123',
|
||||||
tenant_id: mockTenantId,
|
tenant_id: mockTenantId,
|
||||||
external_subscription_id: mockSubscriptionId,
|
external_subscription_id: mockSubscriptionId,
|
||||||
status: SubscriptionStatus.TRIAL,
|
status: SubscriptionStatus.TRIALING,
|
||||||
};
|
};
|
||||||
|
|
||||||
const event: Stripe.Event = {
|
const event: Stripe.Event = {
|
||||||
@ -854,7 +854,7 @@ describe('StripeService', () => {
|
|||||||
expect(subscriptionRepo.save).toHaveBeenCalledWith(
|
expect(subscriptionRepo.save).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
status: SubscriptionStatus.CANCELLED,
|
status: SubscriptionStatus.CANCELLED,
|
||||||
cancelled_at: expect.any(Date),
|
canceled_at: expect.any(Date),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@ -1383,7 +1383,7 @@ describe('StripeService', () => {
|
|||||||
describe('mapStripeStatus', () => {
|
describe('mapStripeStatus', () => {
|
||||||
it('should map trialing to TRIAL', () => {
|
it('should map trialing to TRIAL', () => {
|
||||||
const result = (service as any).mapStripeStatus('trialing');
|
const result = (service as any).mapStripeStatus('trialing');
|
||||||
expect(result).toBe(SubscriptionStatus.TRIAL);
|
expect(result).toBe(SubscriptionStatus.TRIALING);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should map active to ACTIVE', () => {
|
it('should map active to ACTIVE', () => {
|
||||||
@ -1408,7 +1408,7 @@ describe('StripeService', () => {
|
|||||||
|
|
||||||
it('should map incomplete to TRIAL', () => {
|
it('should map incomplete to TRIAL', () => {
|
||||||
const result = (service as any).mapStripeStatus('incomplete');
|
const result = (service as any).mapStripeStatus('incomplete');
|
||||||
expect(result).toBe(SubscriptionStatus.TRIAL);
|
expect(result).toBe(SubscriptionStatus.TRIALING);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should map incomplete_expired to EXPIRED', () => {
|
it('should map incomplete_expired to EXPIRED', () => {
|
||||||
|
|||||||
@ -4,7 +4,10 @@ import {
|
|||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
CreateDateColumn,
|
CreateDateColumn,
|
||||||
Index,
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
|
import { Invoice } from './invoice.entity';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* InvoiceItem Entity
|
* InvoiceItem Entity
|
||||||
@ -20,6 +23,10 @@ export class InvoiceItem {
|
|||||||
@Index()
|
@Index()
|
||||||
invoice_id: string;
|
invoice_id: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Invoice, (invoice) => invoice.items, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'invoice_id' })
|
||||||
|
invoice: Invoice;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 500 })
|
@Column({ type: 'varchar', length: 500 })
|
||||||
description: string;
|
description: string;
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,9 @@ import {
|
|||||||
Column,
|
Column,
|
||||||
CreateDateColumn,
|
CreateDateColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
|
OneToMany,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
|
import { InvoiceItem } from './invoice-item.entity';
|
||||||
|
|
||||||
export enum InvoiceStatus {
|
export enum InvoiceStatus {
|
||||||
DRAFT = 'draft',
|
DRAFT = 'draft',
|
||||||
@ -75,6 +77,10 @@ export class Invoice {
|
|||||||
tax_id?: string;
|
tax_id?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Relation to invoice items
|
||||||
|
@OneToMany(() => InvoiceItem, (item) => item.invoice, { cascade: true })
|
||||||
|
items: InvoiceItem[];
|
||||||
|
|
||||||
@CreateDateColumn({ type: 'timestamp with time zone' })
|
@CreateDateColumn({ type: 'timestamp with time zone' })
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
|
|
||||||
|
|||||||
@ -6,23 +6,41 @@ import {
|
|||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
ManyToOne,
|
ManyToOne,
|
||||||
JoinColumn,
|
JoinColumn,
|
||||||
|
Index,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { Plan } from './plan.entity';
|
import { Plan } from './plan.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscription status enum - matches DDL tenants.subscription_status
|
||||||
|
*/
|
||||||
export enum SubscriptionStatus {
|
export enum SubscriptionStatus {
|
||||||
TRIAL = 'trial',
|
TRIALING = 'trialing',
|
||||||
ACTIVE = 'active',
|
ACTIVE = 'active',
|
||||||
PAST_DUE = 'past_due',
|
PAST_DUE = 'past_due',
|
||||||
CANCELLED = 'cancelled',
|
CANCELLED = 'cancelled',
|
||||||
EXPIRED = 'expired',
|
EXPIRED = 'expired',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Billing interval enum - matches DDL billing.billing_interval
|
||||||
|
*/
|
||||||
|
export enum BillingInterval {
|
||||||
|
MONTH = 'month',
|
||||||
|
YEAR = 'year',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscription Entity
|
||||||
|
* Maps to billing.subscriptions DDL table
|
||||||
|
* Manages tenant subscriptions with Stripe integration
|
||||||
|
*/
|
||||||
@Entity({ name: 'subscriptions', schema: 'billing' })
|
@Entity({ name: 'subscriptions', schema: 'billing' })
|
||||||
|
@Index(['tenant_id'])
|
||||||
export class Subscription {
|
export class Subscription {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
@Column({ type: 'uuid' })
|
@Column({ type: 'uuid', unique: true })
|
||||||
tenant_id: string;
|
tenant_id: string;
|
||||||
|
|
||||||
@Column({ type: 'uuid' })
|
@Column({ type: 'uuid' })
|
||||||
@ -32,32 +50,68 @@ export class Subscription {
|
|||||||
@JoinColumn({ name: 'plan_id' })
|
@JoinColumn({ name: 'plan_id' })
|
||||||
plan: Plan | null;
|
plan: Plan | null;
|
||||||
|
|
||||||
|
// Stripe integration
|
||||||
|
@Column({ type: 'varchar', length: 255, unique: true, nullable: true })
|
||||||
|
@Index()
|
||||||
|
stripe_subscription_id: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||||
|
stripe_customer_id: string | null;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'enum',
|
type: 'enum',
|
||||||
enum: SubscriptionStatus,
|
enum: SubscriptionStatus,
|
||||||
default: SubscriptionStatus.TRIAL,
|
default: SubscriptionStatus.TRIALING,
|
||||||
})
|
})
|
||||||
status: SubscriptionStatus;
|
status: SubscriptionStatus;
|
||||||
|
|
||||||
@Column({ type: 'timestamp with time zone' })
|
@Column({
|
||||||
current_period_start: Date;
|
type: 'enum',
|
||||||
|
enum: BillingInterval,
|
||||||
|
default: BillingInterval.MONTH,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
interval: BillingInterval | null;
|
||||||
|
|
||||||
@Column({ type: 'timestamp with time zone' })
|
// Current billing period
|
||||||
current_period_end: Date;
|
@Column({ type: 'timestamp with time zone', nullable: true })
|
||||||
|
current_period_start: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp with time zone', nullable: true })
|
||||||
|
current_period_end: Date | null;
|
||||||
|
|
||||||
|
// Trial period
|
||||||
|
@Column({ type: 'timestamp with time zone', nullable: true })
|
||||||
|
trial_start: Date | null;
|
||||||
|
|
||||||
@Column({ type: 'timestamp with time zone', nullable: true })
|
@Column({ type: 'timestamp with time zone', nullable: true })
|
||||||
trial_end: Date | null;
|
trial_end: Date | null;
|
||||||
|
|
||||||
|
// Cancellation
|
||||||
@Column({ type: 'timestamp with time zone', nullable: true })
|
@Column({ type: 'timestamp with time zone', nullable: true })
|
||||||
cancelled_at: Date | null;
|
cancel_at: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp with time zone', nullable: true })
|
||||||
|
canceled_at: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 500, nullable: true })
|
||||||
|
cancel_reason: string | null;
|
||||||
|
|
||||||
|
// Pricing at subscription time
|
||||||
|
@Column({ type: 'decimal', precision: 10, scale: 2, nullable: true })
|
||||||
|
price_amount: number | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 3, default: 'USD' })
|
||||||
|
currency: string;
|
||||||
|
|
||||||
|
// Legacy/compatibility fields
|
||||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||||
external_subscription_id: string;
|
external_subscription_id: string | null;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 50, nullable: true })
|
@Column({ type: 'varchar', length: 50, nullable: true })
|
||||||
payment_provider: string;
|
payment_provider: string | null;
|
||||||
|
|
||||||
@Column({ type: 'jsonb', nullable: true })
|
@Column({ type: 'jsonb', default: {} })
|
||||||
metadata: Record<string, any>;
|
metadata: Record<string, any>;
|
||||||
|
|
||||||
@CreateDateColumn({ type: 'timestamp with time zone' })
|
@CreateDateColumn({ type: 'timestamp with time zone' })
|
||||||
|
|||||||
@ -35,7 +35,7 @@ export class BillingService {
|
|||||||
const subscription = this.subscriptionRepo.create({
|
const subscription = this.subscriptionRepo.create({
|
||||||
tenant_id: dto.tenant_id,
|
tenant_id: dto.tenant_id,
|
||||||
plan_id: dto.plan_id,
|
plan_id: dto.plan_id,
|
||||||
status: dto.trial_end ? SubscriptionStatus.TRIAL : SubscriptionStatus.ACTIVE,
|
status: dto.trial_end ? SubscriptionStatus.TRIALING : SubscriptionStatus.ACTIVE,
|
||||||
current_period_start: now,
|
current_period_start: now,
|
||||||
current_period_end: periodEnd,
|
current_period_end: periodEnd,
|
||||||
trial_end: dto.trial_end ? new Date(dto.trial_end) : null,
|
trial_end: dto.trial_end ? new Date(dto.trial_end) : null,
|
||||||
@ -74,7 +74,7 @@ export class BillingService {
|
|||||||
throw new NotFoundException('Subscription not found');
|
throw new NotFoundException('Subscription not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
subscription.cancelled_at = new Date();
|
subscription.canceled_at = new Date();
|
||||||
|
|
||||||
if (dto.immediately) {
|
if (dto.immediately) {
|
||||||
subscription.status = SubscriptionStatus.CANCELLED;
|
subscription.status = SubscriptionStatus.CANCELLED;
|
||||||
@ -112,10 +112,12 @@ export class BillingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const newPeriodEnd = new Date(subscription.current_period_end);
|
const newPeriodEnd = subscription.current_period_end
|
||||||
|
? new Date(subscription.current_period_end)
|
||||||
|
: new Date();
|
||||||
newPeriodEnd.setMonth(newPeriodEnd.getMonth() + 1);
|
newPeriodEnd.setMonth(newPeriodEnd.getMonth() + 1);
|
||||||
|
|
||||||
subscription.current_period_start = subscription.current_period_end;
|
subscription.current_period_start = subscription.current_period_end ?? new Date();
|
||||||
subscription.current_period_end = newPeriodEnd;
|
subscription.current_period_end = newPeriodEnd;
|
||||||
subscription.status = SubscriptionStatus.ACTIVE;
|
subscription.status = SubscriptionStatus.ACTIVE;
|
||||||
|
|
||||||
@ -336,14 +338,16 @@ export class BillingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const periodEnd = new Date(subscription.current_period_end);
|
const periodEnd = subscription.current_period_end
|
||||||
|
? new Date(subscription.current_period_end)
|
||||||
|
: new Date();
|
||||||
const daysRemaining = Math.ceil(
|
const daysRemaining = Math.ceil(
|
||||||
(periodEnd.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
|
(periodEnd.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
|
||||||
);
|
);
|
||||||
|
|
||||||
const isActive = [
|
const isActive = [
|
||||||
SubscriptionStatus.ACTIVE,
|
SubscriptionStatus.ACTIVE,
|
||||||
SubscriptionStatus.TRIAL,
|
SubscriptionStatus.TRIALING,
|
||||||
].includes(subscription.status);
|
].includes(subscription.status);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -360,7 +360,7 @@ export class StripeService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (stripeSubscription.canceled_at) {
|
if (stripeSubscription.canceled_at) {
|
||||||
subscription.cancelled_at = new Date(stripeSubscription.canceled_at * 1000);
|
subscription.canceled_at = new Date(stripeSubscription.canceled_at * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
subscription.metadata = {
|
subscription.metadata = {
|
||||||
@ -380,7 +380,7 @@ export class StripeService implements OnModuleInit {
|
|||||||
|
|
||||||
if (subscription) {
|
if (subscription) {
|
||||||
subscription.status = SubscriptionStatus.CANCELLED;
|
subscription.status = SubscriptionStatus.CANCELLED;
|
||||||
subscription.cancelled_at = new Date();
|
subscription.canceled_at = new Date();
|
||||||
await this.subscriptionRepo.save(subscription);
|
await this.subscriptionRepo.save(subscription);
|
||||||
this.logger.log(`Cancelled subscription ${stripeSubscription.id}`);
|
this.logger.log(`Cancelled subscription ${stripeSubscription.id}`);
|
||||||
}
|
}
|
||||||
@ -517,12 +517,12 @@ export class StripeService implements OnModuleInit {
|
|||||||
|
|
||||||
private mapStripeStatus(stripeStatus: Stripe.Subscription.Status): SubscriptionStatus {
|
private mapStripeStatus(stripeStatus: Stripe.Subscription.Status): SubscriptionStatus {
|
||||||
const statusMap: Record<Stripe.Subscription.Status, SubscriptionStatus> = {
|
const statusMap: Record<Stripe.Subscription.Status, SubscriptionStatus> = {
|
||||||
trialing: SubscriptionStatus.TRIAL,
|
trialing: SubscriptionStatus.TRIALING,
|
||||||
active: SubscriptionStatus.ACTIVE,
|
active: SubscriptionStatus.ACTIVE,
|
||||||
past_due: SubscriptionStatus.PAST_DUE,
|
past_due: SubscriptionStatus.PAST_DUE,
|
||||||
canceled: SubscriptionStatus.CANCELLED,
|
canceled: SubscriptionStatus.CANCELLED,
|
||||||
unpaid: SubscriptionStatus.PAST_DUE,
|
unpaid: SubscriptionStatus.PAST_DUE,
|
||||||
incomplete: SubscriptionStatus.TRIAL,
|
incomplete: SubscriptionStatus.TRIALING,
|
||||||
incomplete_expired: SubscriptionStatus.EXPIRED,
|
incomplete_expired: SubscriptionStatus.EXPIRED,
|
||||||
paused: SubscriptionStatus.CANCELLED,
|
paused: SubscriptionStatus.CANCELLED,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -201,7 +201,7 @@ describe('OnboardingService', () => {
|
|||||||
tenant_id: mockTenantId,
|
tenant_id: mockTenantId,
|
||||||
user_id: mockUserId,
|
user_id: mockUserId,
|
||||||
action: 'update',
|
action: 'update',
|
||||||
entity_type: 'tenant',
|
resource_type: 'tenant',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(emailService.sendTemplateEmail).toHaveBeenCalledWith(
|
expect(emailService.sendTemplateEmail).toHaveBeenCalledWith(
|
||||||
|
|||||||
@ -119,8 +119,8 @@ export class OnboardingService {
|
|||||||
tenant_id: tenantId,
|
tenant_id: tenantId,
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
action: AuditAction.UPDATE,
|
action: AuditAction.UPDATE,
|
||||||
entity_type: 'tenant',
|
resource_type: 'tenant',
|
||||||
entity_id: tenantId,
|
resource_id: tenantId,
|
||||||
old_values: { status: oldStatus },
|
old_values: { status: oldStatus },
|
||||||
new_values: { status: 'active' },
|
new_values: { status: 'active' },
|
||||||
description: 'Onboarding completed',
|
description: 'Onboarding completed',
|
||||||
|
|||||||
@ -49,7 +49,7 @@ export class ExcelService {
|
|||||||
{ header: 'Period Start', key: 'period_start', width: 20 },
|
{ header: 'Period Start', key: 'period_start', width: 20 },
|
||||||
{ header: 'Period End', key: 'period_end', width: 20 },
|
{ header: 'Period End', key: 'period_end', width: 20 },
|
||||||
{ header: 'Trial End', key: 'trial_end', width: 20 },
|
{ header: 'Trial End', key: 'trial_end', width: 20 },
|
||||||
{ header: 'Cancelled At', key: 'cancelled_at', width: 20 },
|
{ header: 'Canceled At', key: 'canceled_at', width: 20 },
|
||||||
{ header: 'Created At', key: 'created_at', width: 20 },
|
{ header: 'Created At', key: 'created_at', width: 20 },
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -62,7 +62,7 @@ export class ExcelService {
|
|||||||
period_start: sub.current_period_start ? new Date(sub.current_period_start).toLocaleString() : '',
|
period_start: sub.current_period_start ? new Date(sub.current_period_start).toLocaleString() : '',
|
||||||
period_end: sub.current_period_end ? new Date(sub.current_period_end).toLocaleString() : '',
|
period_end: sub.current_period_end ? new Date(sub.current_period_end).toLocaleString() : '',
|
||||||
trial_end: sub.trial_end ? new Date(sub.trial_end).toLocaleString() : '',
|
trial_end: sub.trial_end ? new Date(sub.trial_end).toLocaleString() : '',
|
||||||
cancelled_at: sub.cancelled_at ? new Date(sub.cancelled_at).toLocaleString() : '',
|
canceled_at: sub.canceled_at ? new Date(sub.canceled_at).toLocaleString() : '',
|
||||||
created_at: sub.created_at ? new Date(sub.created_at).toLocaleString() : '',
|
created_at: sub.created_at ? new Date(sub.created_at).toLocaleString() : '',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -76,8 +76,8 @@ export class ExcelService {
|
|||||||
const columns = [
|
const columns = [
|
||||||
{ header: 'ID', key: 'id', width: 40 },
|
{ header: 'ID', key: 'id', width: 40 },
|
||||||
{ header: 'Action', key: 'action', width: 15 },
|
{ header: 'Action', key: 'action', width: 15 },
|
||||||
{ header: 'Entity Type', key: 'entity_type', width: 20 },
|
{ header: 'Resource Type', key: 'resource_type', width: 20 },
|
||||||
{ header: 'Entity ID', key: 'entity_id', width: 40 },
|
{ header: 'Resource ID', key: 'resource_id', width: 40 },
|
||||||
{ header: 'User ID', key: 'user_id', width: 40 },
|
{ header: 'User ID', key: 'user_id', width: 40 },
|
||||||
{ header: 'IP Address', key: 'ip_address', width: 15 },
|
{ header: 'IP Address', key: 'ip_address', width: 15 },
|
||||||
{ header: 'Endpoint', key: 'endpoint', width: 30 },
|
{ header: 'Endpoint', key: 'endpoint', width: 30 },
|
||||||
@ -91,8 +91,8 @@ export class ExcelService {
|
|||||||
const data = logs.map(log => ({
|
const data = logs.map(log => ({
|
||||||
id: log.id,
|
id: log.id,
|
||||||
action: log.action,
|
action: log.action,
|
||||||
entity_type: log.entity_type,
|
resource_type: log.resource_type,
|
||||||
entity_id: log.entity_id || '',
|
resource_id: log.resource_id || '',
|
||||||
user_id: log.user_id || '',
|
user_id: log.user_id || '',
|
||||||
ip_address: log.ip_address || '',
|
ip_address: log.ip_address || '',
|
||||||
endpoint: log.endpoint || '',
|
endpoint: log.endpoint || '',
|
||||||
|
|||||||
@ -49,14 +49,14 @@ export class PdfService {
|
|||||||
async generateAuditReport(logs: AuditLog[]): Promise<Buffer> {
|
async generateAuditReport(logs: AuditLog[]): Promise<Buffer> {
|
||||||
return this.generatePdf('Audit Report', logs, [
|
return this.generatePdf('Audit Report', logs, [
|
||||||
{ header: 'Action', key: 'action', width: 80 },
|
{ header: 'Action', key: 'action', width: 80 },
|
||||||
{ header: 'Entity Type', key: 'entity_type', width: 100 },
|
{ header: 'Resource Type', key: 'resource_type', width: 100 },
|
||||||
{ header: 'Entity ID', key: 'entity_id', width: 100 },
|
{ header: 'Resource ID', key: 'resource_id', width: 100 },
|
||||||
{ header: 'User ID', key: 'user_id', width: 100 },
|
{ header: 'User ID', key: 'user_id', width: 100 },
|
||||||
{ header: 'Created At', key: 'created_at', width: 120 },
|
{ header: 'Created At', key: 'created_at', width: 120 },
|
||||||
], (log: AuditLog) => ({
|
], (log: AuditLog) => ({
|
||||||
action: log.action,
|
action: log.action,
|
||||||
entity_type: log.entity_type,
|
resource_type: log.resource_type,
|
||||||
entity_id: this.truncateString(log.entity_id || 'N/A', 15),
|
resource_id: this.truncateString(log.resource_id || 'N/A', 15),
|
||||||
user_id: this.truncateString(log.user_id || 'N/A', 15),
|
user_id: this.truncateString(log.user_id || 'N/A', 15),
|
||||||
created_at: log.created_at ? new Date(log.created_at).toLocaleString() : 'N/A',
|
created_at: log.created_at ? new Date(log.created_at).toLocaleString() : 'N/A',
|
||||||
}));
|
}));
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user