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>
505 lines
14 KiB
TypeScript
505 lines
14 KiB
TypeScript
/**
|
|
* Offline Sync Service
|
|
*
|
|
* Service for managing offline data synchronization queue and conflict resolution
|
|
*
|
|
* @module Mobile
|
|
*/
|
|
|
|
import { Repository, DataSource } from 'typeorm';
|
|
import { OfflineSyncQueue } from '../entities/offline-sync-queue.entity';
|
|
import { SyncConflict, ConflictType } from '../entities/sync-conflict.entity';
|
|
import {
|
|
CreateSyncQueueItemDto,
|
|
ProcessSyncBatchDto,
|
|
ResolveSyncConflictDto,
|
|
SyncQueueResponseDto,
|
|
SyncConflictResponseDto,
|
|
SyncFilterDto,
|
|
SyncResultDto,
|
|
SyncStatusDto,
|
|
} from '../dto/offline-sync.dto';
|
|
import { ServiceContext } from './mobile-session.service';
|
|
|
|
export class OfflineSyncService {
|
|
private queueRepository: Repository<OfflineSyncQueue>;
|
|
private conflictRepository: Repository<SyncConflict>;
|
|
|
|
constructor(dataSource: DataSource) {
|
|
this.queueRepository = dataSource.getRepository(OfflineSyncQueue);
|
|
this.conflictRepository = dataSource.getRepository(SyncConflict);
|
|
}
|
|
|
|
/**
|
|
* Add item to sync queue
|
|
*/
|
|
async queueItem(ctx: ServiceContext, dto: CreateSyncQueueItemDto): Promise<OfflineSyncQueue> {
|
|
const item = this.queueRepository.create({
|
|
userId: dto.userId,
|
|
deviceId: dto.deviceId,
|
|
tenantId: ctx.tenantId,
|
|
sessionId: dto.sessionId,
|
|
entityType: dto.entityType,
|
|
entityId: dto.entityId,
|
|
operation: dto.operation,
|
|
payload: dto.payload,
|
|
metadata: dto.metadata || {},
|
|
sequenceNumber: dto.sequenceNumber,
|
|
dependsOn: dto.dependsOn,
|
|
status: 'pending',
|
|
retryCount: 0,
|
|
maxRetries: 3,
|
|
});
|
|
|
|
return this.queueRepository.save(item);
|
|
}
|
|
|
|
/**
|
|
* Queue batch of items
|
|
*/
|
|
async queueBatch(ctx: ServiceContext, dto: ProcessSyncBatchDto): Promise<OfflineSyncQueue[]> {
|
|
const items = dto.items.map(itemDto => this.queueRepository.create({
|
|
userId: itemDto.userId,
|
|
deviceId: itemDto.deviceId,
|
|
tenantId: ctx.tenantId,
|
|
sessionId: itemDto.sessionId,
|
|
entityType: itemDto.entityType,
|
|
entityId: itemDto.entityId,
|
|
operation: itemDto.operation,
|
|
payload: itemDto.payload,
|
|
metadata: itemDto.metadata || {},
|
|
sequenceNumber: itemDto.sequenceNumber,
|
|
dependsOn: itemDto.dependsOn,
|
|
status: 'pending',
|
|
retryCount: 0,
|
|
maxRetries: 3,
|
|
}));
|
|
|
|
return this.queueRepository.save(items);
|
|
}
|
|
|
|
/**
|
|
* Find queue item by ID
|
|
*/
|
|
async findById(ctx: ServiceContext, id: string): Promise<OfflineSyncQueue | null> {
|
|
return this.queueRepository.findOne({
|
|
where: { id, tenantId: ctx.tenantId },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Find queue items with filters
|
|
*/
|
|
async findAll(
|
|
ctx: ServiceContext,
|
|
filter: SyncFilterDto
|
|
): Promise<{ data: OfflineSyncQueue[]; total: number }> {
|
|
const query = this.queueRepository
|
|
.createQueryBuilder('item')
|
|
.where('item.tenantId = :tenantId', { tenantId: ctx.tenantId });
|
|
|
|
if (filter.userId) {
|
|
query.andWhere('item.userId = :userId', { userId: filter.userId });
|
|
}
|
|
|
|
if (filter.deviceId) {
|
|
query.andWhere('item.deviceId = :deviceId', { deviceId: filter.deviceId });
|
|
}
|
|
|
|
if (filter.sessionId) {
|
|
query.andWhere('item.sessionId = :sessionId', { sessionId: filter.sessionId });
|
|
}
|
|
|
|
if (filter.entityType) {
|
|
query.andWhere('item.entityType = :entityType', { entityType: filter.entityType });
|
|
}
|
|
|
|
if (filter.status) {
|
|
query.andWhere('item.status = :status', { status: filter.status });
|
|
}
|
|
|
|
const total = await query.getCount();
|
|
|
|
query.orderBy('item.sequenceNumber', 'ASC');
|
|
|
|
if (filter.limit) {
|
|
query.take(filter.limit);
|
|
}
|
|
|
|
if (filter.offset) {
|
|
query.skip(filter.offset);
|
|
}
|
|
|
|
const data = await query.getMany();
|
|
|
|
return { data, total };
|
|
}
|
|
|
|
/**
|
|
* Get pending items for processing
|
|
*/
|
|
async getPendingItems(ctx: ServiceContext, deviceId: string, limit: number = 100): Promise<OfflineSyncQueue[]> {
|
|
return this.queueRepository.find({
|
|
where: {
|
|
tenantId: ctx.tenantId,
|
|
deviceId,
|
|
status: 'pending',
|
|
},
|
|
order: { sequenceNumber: 'ASC' },
|
|
take: limit,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Process sync queue for device
|
|
*/
|
|
async processQueue(ctx: ServiceContext, deviceId: string): Promise<SyncResultDto> {
|
|
const items = await this.getPendingItems(ctx, deviceId);
|
|
|
|
let processed = 0;
|
|
let failed = 0;
|
|
let conflicts = 0;
|
|
const errors: { id: string; error: string }[] = [];
|
|
|
|
for (const item of items) {
|
|
// Check if item depends on another that hasn't been processed
|
|
if (item.dependsOn) {
|
|
const dependency = await this.findById(ctx, item.dependsOn);
|
|
if (dependency && dependency.status !== 'completed') {
|
|
continue; // Skip, dependency not processed yet
|
|
}
|
|
}
|
|
|
|
try {
|
|
const result = await this.processItem(ctx, item);
|
|
|
|
if (result.success) {
|
|
processed++;
|
|
} else if (result.conflict) {
|
|
conflicts++;
|
|
} else {
|
|
failed++;
|
|
errors.push({ id: item.id, error: result.error || 'Unknown error' });
|
|
}
|
|
} catch (error: any) {
|
|
failed++;
|
|
errors.push({ id: item.id, error: error.message });
|
|
|
|
// Update item with error
|
|
item.status = 'failed';
|
|
item.lastError = error.message;
|
|
item.retryCount++;
|
|
await this.queueRepository.save(item);
|
|
}
|
|
}
|
|
|
|
return {
|
|
success: failed === 0 && conflicts === 0,
|
|
processed,
|
|
failed,
|
|
conflicts,
|
|
errors: errors.length > 0 ? errors : undefined,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Process single item
|
|
*/
|
|
async processItem(
|
|
ctx: ServiceContext,
|
|
item: OfflineSyncQueue
|
|
): Promise<{ success: boolean; conflict?: boolean; error?: string }> {
|
|
item.status = 'processing';
|
|
await this.queueRepository.save(item);
|
|
|
|
try {
|
|
// Simulate processing based on operation
|
|
// In production, this would call the actual entity service
|
|
|
|
// Check for conflicts (simulated)
|
|
const hasConflict = await this.checkForConflict(ctx, item);
|
|
|
|
if (hasConflict) {
|
|
item.status = 'conflict';
|
|
await this.queueRepository.save(item);
|
|
|
|
// Create conflict record
|
|
await this.createConflict(ctx, item, hasConflict);
|
|
|
|
return { success: false, conflict: true };
|
|
}
|
|
|
|
// Apply change (simulated)
|
|
await this.applyChange(item);
|
|
|
|
item.status = 'completed';
|
|
item.processedAt = new Date();
|
|
await this.queueRepository.save(item);
|
|
|
|
return { success: true };
|
|
} catch (error: any) {
|
|
item.status = 'failed';
|
|
item.lastError = error.message;
|
|
item.retryCount++;
|
|
await this.queueRepository.save(item);
|
|
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retry failed items
|
|
*/
|
|
async retryFailedItems(ctx: ServiceContext, deviceId: string): Promise<number> {
|
|
const result = await this.queueRepository.update(
|
|
{
|
|
tenantId: ctx.tenantId,
|
|
deviceId,
|
|
status: 'failed',
|
|
},
|
|
{ status: 'pending' }
|
|
);
|
|
|
|
return result.affected || 0;
|
|
}
|
|
|
|
/**
|
|
* Get sync status for device
|
|
*/
|
|
async getSyncStatus(ctx: ServiceContext, deviceId: string): Promise<SyncStatusDto> {
|
|
const counts = await this.queueRepository
|
|
.createQueryBuilder('item')
|
|
.select('item.status', 'status')
|
|
.addSelect('COUNT(*)', 'count')
|
|
.where('item.tenantId = :tenantId', { tenantId: ctx.tenantId })
|
|
.andWhere('item.deviceId = :deviceId', { deviceId })
|
|
.groupBy('item.status')
|
|
.getRawMany();
|
|
|
|
const statusCounts: Record<string, number> = {};
|
|
for (const row of counts) {
|
|
statusCounts[row.status] = parseInt(row.count, 10);
|
|
}
|
|
|
|
const lastProcessed = await this.queueRepository.findOne({
|
|
where: { tenantId: ctx.tenantId, deviceId, status: 'completed' },
|
|
order: { processedAt: 'DESC' },
|
|
});
|
|
|
|
return {
|
|
pendingCount: statusCounts['pending'] || 0,
|
|
processingCount: statusCounts['processing'] || 0,
|
|
failedCount: statusCounts['failed'] || 0,
|
|
conflictCount: statusCounts['conflict'] || 0,
|
|
lastSyncAt: lastProcessed?.processedAt,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get conflicts for device
|
|
*/
|
|
async getConflicts(ctx: ServiceContext, deviceId?: string): Promise<SyncConflict[]> {
|
|
const query = this.conflictRepository
|
|
.createQueryBuilder('conflict')
|
|
.leftJoinAndSelect('conflict.syncQueue', 'syncQueue')
|
|
.where('conflict.tenantId = :tenantId', { tenantId: ctx.tenantId })
|
|
.andWhere('conflict.resolution IS NULL');
|
|
|
|
if (deviceId) {
|
|
query.andWhere('syncQueue.deviceId = :deviceId', { deviceId });
|
|
}
|
|
|
|
return query.getMany();
|
|
}
|
|
|
|
/**
|
|
* Get conflict by ID
|
|
*/
|
|
async getConflictById(ctx: ServiceContext, id: string): Promise<SyncConflict | null> {
|
|
return this.conflictRepository.findOne({
|
|
where: { id, tenantId: ctx.tenantId },
|
|
relations: ['syncQueue'],
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Resolve conflict
|
|
*/
|
|
async resolveConflict(
|
|
ctx: ServiceContext,
|
|
id: string,
|
|
dto: ResolveSyncConflictDto
|
|
): Promise<SyncConflict> {
|
|
const conflict = await this.getConflictById(ctx, id);
|
|
if (!conflict) {
|
|
throw new Error('Conflict not found');
|
|
}
|
|
|
|
if (conflict.resolution) {
|
|
throw new Error('Conflict already resolved');
|
|
}
|
|
|
|
conflict.resolution = dto.resolution;
|
|
conflict.mergedData = dto.mergedData || {};
|
|
conflict.resolvedBy = ctx.userId || '';
|
|
conflict.resolvedAt = new Date();
|
|
|
|
await this.conflictRepository.save(conflict);
|
|
|
|
// Update queue item based on resolution
|
|
if (conflict.syncQueue) {
|
|
const queueItem = conflict.syncQueue;
|
|
|
|
switch (dto.resolution) {
|
|
case 'local_wins':
|
|
// Re-process with local data
|
|
queueItem.status = 'pending';
|
|
queueItem.conflictResolution = 'local_wins';
|
|
break;
|
|
case 'server_wins':
|
|
// Mark as completed (server data already in place)
|
|
queueItem.status = 'completed';
|
|
queueItem.conflictResolution = 'server_wins';
|
|
queueItem.processedAt = new Date();
|
|
break;
|
|
case 'merged':
|
|
// Update payload with merged data and re-process
|
|
queueItem.payload = dto.mergedData || conflict.localData;
|
|
queueItem.status = 'pending';
|
|
queueItem.conflictResolution = 'merged';
|
|
break;
|
|
case 'manual':
|
|
// Mark as completed (handled manually)
|
|
queueItem.status = 'completed';
|
|
queueItem.conflictResolution = 'manual';
|
|
queueItem.processedAt = new Date();
|
|
break;
|
|
}
|
|
|
|
queueItem.conflictData = {
|
|
conflictId: conflict.id,
|
|
resolution: dto.resolution,
|
|
resolvedAt: conflict.resolvedAt,
|
|
};
|
|
queueItem.conflictResolvedAt = conflict.resolvedAt;
|
|
|
|
await this.queueRepository.save(queueItem);
|
|
}
|
|
|
|
return conflict;
|
|
}
|
|
|
|
/**
|
|
* Delete processed items older than specified days
|
|
*/
|
|
async cleanupOldItems(ctx: ServiceContext, olderThanDays: number = 30): Promise<number> {
|
|
const cutoffDate = new Date();
|
|
cutoffDate.setDate(cutoffDate.getDate() - olderThanDays);
|
|
|
|
const result = await this.queueRepository
|
|
.createQueryBuilder()
|
|
.delete()
|
|
.where('tenantId = :tenantId', { tenantId: ctx.tenantId })
|
|
.andWhere('status = :status', { status: 'completed' })
|
|
.andWhere('processedAt < :cutoffDate', { cutoffDate })
|
|
.execute();
|
|
|
|
return result.affected || 0;
|
|
}
|
|
|
|
/**
|
|
* Check for conflict (simulated)
|
|
*/
|
|
private async checkForConflict(
|
|
_ctx: ServiceContext,
|
|
item: OfflineSyncQueue
|
|
): Promise<{ type: ConflictType; serverData: Record<string, any> } | null> {
|
|
// In production, this would check if the server version has changed
|
|
// since the client made the offline change
|
|
|
|
// Simulate 5% conflict rate
|
|
if (Math.random() > 0.95) {
|
|
return {
|
|
type: 'data_conflict',
|
|
serverData: { ...item.payload, serverModified: true },
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Create conflict record
|
|
*/
|
|
private async createConflict(
|
|
ctx: ServiceContext,
|
|
item: OfflineSyncQueue,
|
|
conflictInfo: { type: ConflictType; serverData: Record<string, any> }
|
|
): Promise<SyncConflict> {
|
|
const conflict = this.conflictRepository.create({
|
|
syncQueueId: item.id,
|
|
userId: item.userId,
|
|
tenantId: ctx.tenantId,
|
|
conflictType: conflictInfo.type,
|
|
localData: item.payload,
|
|
serverData: conflictInfo.serverData,
|
|
});
|
|
|
|
return this.conflictRepository.save(conflict);
|
|
}
|
|
|
|
/**
|
|
* Apply change (simulated)
|
|
*/
|
|
private async applyChange(_item: OfflineSyncQueue): Promise<void> {
|
|
// In production, this would call the actual entity service
|
|
// to create/update/delete the entity
|
|
|
|
// Simulate processing time
|
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
}
|
|
|
|
/**
|
|
* Convert queue item to response DTO
|
|
*/
|
|
toQueueResponseDto(item: OfflineSyncQueue): SyncQueueResponseDto {
|
|
return {
|
|
id: item.id,
|
|
userId: item.userId,
|
|
deviceId: item.deviceId,
|
|
sessionId: item.sessionId,
|
|
entityType: item.entityType,
|
|
entityId: item.entityId,
|
|
operation: item.operation,
|
|
payload: item.payload,
|
|
sequenceNumber: Number(item.sequenceNumber),
|
|
status: item.status,
|
|
retryCount: item.retryCount,
|
|
lastError: item.lastError,
|
|
processedAt: item.processedAt,
|
|
conflictData: item.conflictData,
|
|
conflictResolution: item.conflictResolution,
|
|
createdAt: item.createdAt,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Convert conflict to response DTO
|
|
*/
|
|
toConflictResponseDto(conflict: SyncConflict): SyncConflictResponseDto {
|
|
return {
|
|
id: conflict.id,
|
|
syncQueueId: conflict.syncQueueId,
|
|
userId: conflict.userId,
|
|
conflictType: conflict.conflictType,
|
|
localData: conflict.localData,
|
|
serverData: conflict.serverData,
|
|
resolution: conflict.resolution,
|
|
mergedData: conflict.mergedData,
|
|
resolvedBy: conflict.resolvedBy,
|
|
resolvedAt: conflict.resolvedAt,
|
|
createdAt: conflict.createdAt,
|
|
};
|
|
}
|
|
}
|