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>
288 lines
7.2 KiB
TypeScript
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();
|
|
}
|
|
}
|