erp-construccion-backend-v2/src/modules/audit/services/entity-change.service.ts
Adrian Flores Cortes 100c5a6588 feat(modules): implement 13 backend modules for 100% completion
Implemented modules:
- audit: 8 services (GDPR compliance, retention policies, sensitive data)
- billing-usage: 8 services, 6 controllers (subscription management, usage tracking)
- biometrics: 3 services, 3 controllers (offline auth, device sync, lockout)
- core: 6 services (sequence, currency, UoM, payment-terms, geography)
- feature-flags: 3 services, 3 controllers (rollout strategies, A/B testing)
- fiscal: 7 services, 7 controllers (SAT/Mexican tax compliance)
- mobile: 4 services, 4 controllers (offline-first, sync queue, device management)
- partners: 6 services, 6 controllers (unified customers/suppliers, credit limits)
- profiles: 5 services, 3 controllers (avatar upload, preferences, completion)
- warehouses: 3 services, 3 controllers (zones, hierarchical locations)
- webhooks: 5 services, 5 controllers (HMAC signatures, retry logic)
- whatsapp: 5 services, 5 controllers (business API integration, templates)

Total: 154 files, ~43K lines of new backend code

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 01:54:23 -06:00

288 lines
7.2 KiB
TypeScript

/**
* EntityChange Service
* Data modification versioning and change history tracking.
*
* @module Audit
*/
import { Repository } from 'typeorm';
import { EntityChange, ChangeType } from '../entities/entity-change.entity';
import { ServiceContext, PaginatedResult } from './audit-log.service';
export interface CreateEntityChangeDto {
entityType: string;
entityId: string;
entityName?: string;
dataSnapshot: Record<string, any>;
changes?: Record<string, any>[];
changedBy?: string;
changeReason?: string;
changeType: ChangeType;
}
export interface EntityChangeFilters {
entityType?: string;
entityId?: string;
changedBy?: string;
changeType?: ChangeType;
dateFrom?: Date;
dateTo?: Date;
}
export interface EntityChangeComparison {
version1: EntityChange;
version2: EntityChange;
differences: {
field: string;
oldValue: any;
newValue: any;
}[];
}
export class EntityChangeService {
constructor(private readonly repository: Repository<EntityChange>) {}
/**
* Record a new entity change
*/
async create(
ctx: ServiceContext,
dto: CreateEntityChangeDto,
): Promise<EntityChange> {
// Get the latest version for this entity
const latestChange = await this.repository
.createQueryBuilder('ec')
.where('ec.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('ec.entity_type = :entityType', { entityType: dto.entityType })
.andWhere('ec.entity_id = :entityId', { entityId: dto.entityId })
.orderBy('ec.version', 'DESC')
.getOne();
const version = latestChange ? latestChange.version + 1 : 1;
const previousVersion = latestChange ? latestChange.version : undefined;
const change = this.repository.create({
tenantId: ctx.tenantId,
entityType: dto.entityType,
entityId: dto.entityId,
entityName: dto.entityName,
version,
previousVersion,
dataSnapshot: dto.dataSnapshot,
changes: dto.changes || [],
changedBy: dto.changedBy || ctx.userId,
changeReason: dto.changeReason,
changeType: dto.changeType,
} as Partial<EntityChange>);
return this.repository.save(change) as Promise<EntityChange>;
}
/**
* Get change history for an entity
*/
async getHistory(
ctx: ServiceContext,
entityType: string,
entityId: string,
page = 1,
limit = 20,
): Promise<PaginatedResult<EntityChange>> {
const skip = (page - 1) * limit;
const qb = this.repository
.createQueryBuilder('ec')
.where('ec.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('ec.entity_type = :entityType', { entityType })
.andWhere('ec.entity_id = :entityId', { entityId })
.orderBy('ec.version', 'DESC')
.skip(skip)
.take(limit);
const [data, total] = await qb.getManyAndCount();
return {
data,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
/**
* Get a specific version of an entity
*/
async getVersion(
ctx: ServiceContext,
entityType: string,
entityId: string,
version: number,
): Promise<EntityChange | null> {
return this.repository.findOne({
where: {
tenantId: ctx.tenantId,
entityType,
entityId,
version,
},
});
}
/**
* Get the latest version of an entity
*/
async getLatestVersion(
ctx: ServiceContext,
entityType: string,
entityId: string,
): Promise<EntityChange | null> {
return this.repository
.createQueryBuilder('ec')
.where('ec.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('ec.entity_type = :entityType', { entityType })
.andWhere('ec.entity_id = :entityId', { entityId })
.orderBy('ec.version', 'DESC')
.getOne();
}
/**
* Compare two versions of an entity
*/
async compareVersions(
ctx: ServiceContext,
entityType: string,
entityId: string,
version1: number,
version2: number,
): Promise<EntityChangeComparison | null> {
const [v1, v2] = await Promise.all([
this.getVersion(ctx, entityType, entityId, version1),
this.getVersion(ctx, entityType, entityId, version2),
]);
if (!v1 || !v2) {
return null;
}
const differences: { field: string; oldValue: any; newValue: any }[] = [];
const allKeys = new Set([
...Object.keys(v1.dataSnapshot),
...Object.keys(v2.dataSnapshot),
]);
for (const key of allKeys) {
const oldValue = v1.dataSnapshot[key];
const newValue = v2.dataSnapshot[key];
if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) {
differences.push({
field: key,
oldValue,
newValue,
});
}
}
return {
version1: v1,
version2: v2,
differences,
};
}
/**
* Find changes with filters
*/
async findWithFilters(
ctx: ServiceContext,
filters: EntityChangeFilters,
page = 1,
limit = 50,
): Promise<PaginatedResult<EntityChange>> {
const qb = this.repository
.createQueryBuilder('ec')
.where('ec.tenant_id = :tenantId', { tenantId: ctx.tenantId });
if (filters.entityType) {
qb.andWhere('ec.entity_type = :entityType', { entityType: filters.entityType });
}
if (filters.entityId) {
qb.andWhere('ec.entity_id = :entityId', { entityId: filters.entityId });
}
if (filters.changedBy) {
qb.andWhere('ec.changed_by = :changedBy', { changedBy: filters.changedBy });
}
if (filters.changeType) {
qb.andWhere('ec.change_type = :changeType', { changeType: filters.changeType });
}
if (filters.dateFrom) {
qb.andWhere('ec.changed_at >= :dateFrom', { dateFrom: filters.dateFrom });
}
if (filters.dateTo) {
qb.andWhere('ec.changed_at <= :dateTo', { dateTo: filters.dateTo });
}
const skip = (page - 1) * limit;
qb.orderBy('ec.changed_at', 'DESC').skip(skip).take(limit);
const [data, total] = await qb.getManyAndCount();
return {
data,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
/**
* Get changes by user
*/
async findByUser(
ctx: ServiceContext,
userId: string,
page = 1,
limit = 50,
): Promise<PaginatedResult<EntityChange>> {
return this.findWithFilters(ctx, { changedBy: userId }, page, limit);
}
/**
* Get recent changes
*/
async getRecentChanges(
ctx: ServiceContext,
days = 7,
limit = 100,
): Promise<EntityChange[]> {
const dateFrom = new Date();
dateFrom.setDate(dateFrom.getDate() - days);
return this.repository
.createQueryBuilder('ec')
.where('ec.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('ec.changed_at >= :dateFrom', { dateFrom })
.orderBy('ec.changed_at', 'DESC')
.take(limit)
.getMany();
}
/**
* Get version count for an entity
*/
async getVersionCount(
ctx: ServiceContext,
entityType: string,
entityId: string,
): Promise<number> {
return this.repository
.createQueryBuilder('ec')
.where('ec.tenant_id = :tenantId', { tenantId: ctx.tenantId })
.andWhere('ec.entity_type = :entityType', { entityType })
.andWhere('ec.entity_id = :entityId', { entityId })
.getCount();
}
}