/** * 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; changes?: Record[]; 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) {} /** * Record a new entity change */ async create( ctx: ServiceContext, dto: CreateEntityChangeDto, ): Promise { // 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); return this.repository.save(change) as Promise; } /** * Get change history for an entity */ async getHistory( ctx: ServiceContext, entityType: string, entityId: string, page = 1, limit = 20, ): Promise> { 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 { 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 { 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 { 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> { 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> { return this.findWithFilters(ctx, { changedBy: userId }, page, limit); } /** * Get recent changes */ async getRecentChanges( ctx: ServiceContext, days = 7, limit = 100, ): Promise { 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 { 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(); } }