First commit - backup RDOC
This commit is contained in:
210
src/services/conflictResolver.ts
Normal file
210
src/services/conflictResolver.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Serviço de Resolução de Conflitos
|
||||
*
|
||||
* Gerencia conflitos de dados quando múltiplos dispositivos
|
||||
* modificam os mesmos registros offline.
|
||||
*/
|
||||
|
||||
export type ConflictStrategy = 'last-write-wins' | 'manual' | 'merge';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export interface DataConflict<T = any> {
|
||||
id: string;
|
||||
table: string;
|
||||
localVersion: T;
|
||||
remoteVersion: T;
|
||||
localTimestamp: number;
|
||||
remoteTimestamp: number;
|
||||
strategy: ConflictStrategy;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export interface ConflictResolution<T = any> {
|
||||
resolved: boolean;
|
||||
data: T;
|
||||
strategy: ConflictStrategy;
|
||||
requiresManualReview: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve conflitos entre versões local e remota de dados
|
||||
*/
|
||||
export class ConflictResolver {
|
||||
/**
|
||||
* Detecta se há conflito entre versões local e remota
|
||||
*/
|
||||
static detectConflict<T extends { updated_at?: string; id: string }>(
|
||||
localData: T,
|
||||
remoteData: T
|
||||
): boolean {
|
||||
if (!localData.updated_at || !remoteData.updated_at) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const localTime = new Date(localData.updated_at).getTime();
|
||||
const remoteTime = new Date(remoteData.updated_at).getTime();
|
||||
|
||||
// Conflito se ambos foram modificados e os timestamps são diferentes
|
||||
return Math.abs(localTime - remoteTime) > 1000; // Tolerância de 1 segundo
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve conflito usando estratégia last-write-wins
|
||||
*/
|
||||
static resolveLastWriteWins<T extends { updated_at?: string }>(
|
||||
conflict: DataConflict<T>
|
||||
): ConflictResolution<T> {
|
||||
const useLocal = conflict.localTimestamp > conflict.remoteTimestamp;
|
||||
|
||||
return {
|
||||
resolved: true,
|
||||
data: useLocal ? conflict.localVersion : conflict.remoteVersion,
|
||||
strategy: 'last-write-wins',
|
||||
requiresManualReview: false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tenta fazer merge automático de campos não conflitantes
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
static resolveMerge<T extends Record<string, any>>(
|
||||
conflict: DataConflict<T>
|
||||
): ConflictResolution<T> {
|
||||
const merged = { ...conflict.remoteVersion };
|
||||
const conflicts: string[] = [];
|
||||
|
||||
// Comparar cada campo
|
||||
for (const key in conflict.localVersion) {
|
||||
const localValue = conflict.localVersion[key];
|
||||
const remoteValue = conflict.remoteVersion[key];
|
||||
|
||||
// Se os valores são diferentes, há conflito
|
||||
if (JSON.stringify(localValue) !== JSON.stringify(remoteValue)) {
|
||||
// Usar valor mais recente
|
||||
if (conflict.localTimestamp > conflict.remoteTimestamp) {
|
||||
merged[key] = localValue;
|
||||
}
|
||||
conflicts.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
resolved: true,
|
||||
data: merged as T,
|
||||
strategy: 'merge',
|
||||
requiresManualReview: conflicts.length > 3 // Muitos conflitos = revisão manual
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Marca conflito para resolução manual
|
||||
*/
|
||||
static requireManualResolution<T>(
|
||||
conflict: DataConflict<T>
|
||||
): ConflictResolution<T> {
|
||||
return {
|
||||
resolved: false,
|
||||
data: conflict.localVersion, // Temporariamente usa versão local
|
||||
strategy: 'manual',
|
||||
requiresManualReview: true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve conflito usando a estratégia apropriada
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
static resolve<T extends Record<string, any>>(
|
||||
conflict: DataConflict<T>
|
||||
): ConflictResolution<T> {
|
||||
switch (conflict.strategy) {
|
||||
case 'last-write-wins':
|
||||
return this.resolveLastWriteWins(conflict);
|
||||
|
||||
case 'merge':
|
||||
return this.resolveMerge(conflict);
|
||||
|
||||
case 'manual':
|
||||
return this.requireManualResolution(conflict);
|
||||
|
||||
default:
|
||||
// Padrão: last-write-wins
|
||||
return this.resolveLastWriteWins(conflict);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cria um objeto de conflito a partir de dados local e remoto
|
||||
*/
|
||||
static createConflict<T extends { updated_at?: string; id: string }>(
|
||||
table: string,
|
||||
localData: T,
|
||||
remoteData: T,
|
||||
strategy: ConflictStrategy = 'last-write-wins'
|
||||
): DataConflict<T> {
|
||||
return {
|
||||
id: localData.id,
|
||||
table,
|
||||
localVersion: localData,
|
||||
remoteVersion: remoteData,
|
||||
localTimestamp: localData.updated_at
|
||||
? new Date(localData.updated_at).getTime()
|
||||
: Date.now(),
|
||||
remoteTimestamp: remoteData.updated_at
|
||||
? new Date(remoteData.updated_at).getTime()
|
||||
: Date.now(),
|
||||
strategy
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Armazena conflitos não resolvidos para revisão manual
|
||||
*/
|
||||
export class ConflictStore {
|
||||
private static STORAGE_KEY = 'rdo_unresolved_conflicts';
|
||||
|
||||
/**
|
||||
* Salva conflito não resolvido
|
||||
*/
|
||||
static saveUnresolvedConflict(conflict: DataConflict): void {
|
||||
const conflicts = this.getUnresolvedConflicts();
|
||||
conflicts.push({
|
||||
...conflict,
|
||||
savedAt: Date.now()
|
||||
});
|
||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(conflicts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtém todos os conflitos não resolvidos
|
||||
*/
|
||||
static getUnresolvedConflicts(): Array<DataConflict & { savedAt: number }> {
|
||||
const stored = localStorage.getItem(this.STORAGE_KEY);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove conflito resolvido
|
||||
*/
|
||||
static removeConflict(conflictId: string): void {
|
||||
const conflicts = this.getUnresolvedConflicts();
|
||||
const filtered = conflicts.filter(c => c.id !== conflictId);
|
||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(filtered));
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpa todos os conflitos
|
||||
*/
|
||||
static clearAll(): void {
|
||||
localStorage.removeItem(this.STORAGE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Conta conflitos não resolvidos
|
||||
*/
|
||||
static count(): number {
|
||||
return this.getUnresolvedConflicts().length;
|
||||
}
|
||||
}
|
||||
259
src/services/mfaService.ts
Normal file
259
src/services/mfaService.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Serviço de Multi-Factor Authentication (MFA)
|
||||
*
|
||||
* Gerencia TOTP (Time-based One-Time Password) usando Supabase Auth
|
||||
*/
|
||||
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
export interface MFAEnrollment {
|
||||
id: string;
|
||||
type: 'totp';
|
||||
friendlyName: string;
|
||||
qrCode: string;
|
||||
secret: string;
|
||||
uri: string;
|
||||
}
|
||||
|
||||
export interface BackupCode {
|
||||
code: string;
|
||||
used: boolean;
|
||||
}
|
||||
|
||||
export class MFAService {
|
||||
/**
|
||||
* Inicia o processo de enrollment do MFA
|
||||
* Gera QR Code e secret para configuração no authenticator app
|
||||
*/
|
||||
static async enroll(friendlyName: string = 'Authenticator'): Promise<{
|
||||
data: MFAEnrollment | null;
|
||||
error: string | null;
|
||||
}> {
|
||||
try {
|
||||
const { data, error } = await supabase.auth.mfa.enroll({
|
||||
factorType: 'totp',
|
||||
friendlyName,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
if (!data) {
|
||||
throw new Error('Nenhum dado retornado do enrollment');
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
id: data.id,
|
||||
type: data.type as 'totp',
|
||||
friendlyName: data.friendly_name || friendlyName,
|
||||
qrCode: data.totp.qr_code,
|
||||
secret: data.totp.secret,
|
||||
uri: data.totp.uri,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
data: null,
|
||||
error: err instanceof Error ? err.message : 'Erro ao iniciar MFA',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica o código TOTP e completa o enrollment
|
||||
*/
|
||||
static async verify(factorId: string, code: string): Promise<{
|
||||
success: boolean;
|
||||
error: string | null;
|
||||
}> {
|
||||
try {
|
||||
const { error } = await supabase.auth.mfa.challengeAndVerify({
|
||||
factorId,
|
||||
code,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
error: null,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : 'Código inválido',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cria um challenge para verificação MFA durante login
|
||||
*/
|
||||
static async challenge(factorId: string): Promise<{
|
||||
challengeId: string | null;
|
||||
error: string | null;
|
||||
}> {
|
||||
try {
|
||||
const { data, error } = await supabase.auth.mfa.challenge({
|
||||
factorId,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return {
|
||||
challengeId: data.id,
|
||||
error: null,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
challengeId: null,
|
||||
error: err instanceof Error ? err.message : 'Erro ao criar challenge',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica código TOTP durante login
|
||||
*/
|
||||
static async verifyChallenge(factorId: string, challengeId: string, code: string): Promise<{
|
||||
success: boolean;
|
||||
error: string | null;
|
||||
}> {
|
||||
try {
|
||||
const { error } = await supabase.auth.mfa.verify({
|
||||
factorId,
|
||||
challengeId,
|
||||
code,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
error: null,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : 'Código inválido',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista todos os fatores MFA do usuário
|
||||
*/
|
||||
static async listFactors(): Promise<{
|
||||
factors: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
friendlyName: string;
|
||||
status: string;
|
||||
}>;
|
||||
error: string | null;
|
||||
}> {
|
||||
try {
|
||||
const { data, error } = await supabase.auth.mfa.listFactors();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const factors = (data?.totp || []).map(factor => ({
|
||||
id: factor.id,
|
||||
type: 'totp',
|
||||
friendlyName: factor.friendly_name || 'Authenticator',
|
||||
status: factor.status,
|
||||
}));
|
||||
|
||||
return {
|
||||
factors,
|
||||
error: null,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
factors: [],
|
||||
error: err instanceof Error ? err.message : 'Erro ao listar fatores',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove um fator MFA
|
||||
*/
|
||||
static async unenroll(factorId: string): Promise<{
|
||||
success: boolean;
|
||||
error: string | null;
|
||||
}> {
|
||||
try {
|
||||
const { error } = await supabase.auth.mfa.unenroll({
|
||||
factorId,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
error: null,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : 'Erro ao remover MFA',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gera códigos de backup (simulado - Supabase não tem API nativa)
|
||||
* Em produção, você deve implementar isso no backend
|
||||
*/
|
||||
static generateBackupCodes(count: number = 10): BackupCode[] {
|
||||
const codes: BackupCode[] = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
// Gera código de 8 dígitos
|
||||
const code = Math.random().toString(36).substring(2, 10).toUpperCase();
|
||||
codes.push({
|
||||
code,
|
||||
used: false,
|
||||
});
|
||||
}
|
||||
|
||||
return codes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica se usuário tem MFA ativado
|
||||
*/
|
||||
static async hasMFA(): Promise<boolean> {
|
||||
const { factors } = await this.listFactors();
|
||||
return factors.some(f => f.status === 'verified');
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtém o nível de garantia de autenticação (AAL)
|
||||
*/
|
||||
static async getAAL(): Promise<{
|
||||
currentLevel: 'aal1' | 'aal2' | null;
|
||||
nextLevel: 'aal1' | 'aal2' | null;
|
||||
error: string | null;
|
||||
}> {
|
||||
try {
|
||||
const { data, error } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return {
|
||||
currentLevel: data.currentLevel as 'aal1' | 'aal2',
|
||||
nextLevel: data.nextLevel as 'aal1' | 'aal2',
|
||||
error: null,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
currentLevel: null,
|
||||
nextLevel: null,
|
||||
error: err instanceof Error ? err.message : 'Erro ao obter AAL',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
454
src/services/syncService.ts
Normal file
454
src/services/syncService.ts
Normal file
@@ -0,0 +1,454 @@
|
||||
import { db, type PendingRDO, type SyncOperation } from '../db/db';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { ConflictResolver, ConflictStore, type DataConflict } from './conflictResolver';
|
||||
|
||||
/**
|
||||
* Configurações de retry
|
||||
*/
|
||||
const RETRY_CONFIG = {
|
||||
maxRetries: 5,
|
||||
initialDelay: 1000, // 1 segundo
|
||||
maxDelay: 30000, // 30 segundos
|
||||
backoffMultiplier: 2
|
||||
};
|
||||
|
||||
/**
|
||||
* Serviço de Sincronização Offline
|
||||
*
|
||||
* Gerencia a sincronização de dados entre o banco local (Dexie)
|
||||
* e o Supabase quando a conexão é restabelecida.
|
||||
*/
|
||||
export class SyncService {
|
||||
private isSyncing = false;
|
||||
private syncListeners: Array<(status: SyncStatus) => void> = [];
|
||||
|
||||
constructor() {
|
||||
// Escutar eventos de conexão
|
||||
window.addEventListener('online', () => this.processQueue());
|
||||
}
|
||||
|
||||
/**
|
||||
* Adiciona listener para eventos de sincronização
|
||||
*/
|
||||
onSyncStatusChange(callback: (status: SyncStatus) => void): () => void {
|
||||
this.syncListeners.push(callback);
|
||||
return () => {
|
||||
this.syncListeners = this.syncListeners.filter(cb => cb !== callback);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifica listeners sobre mudança de status
|
||||
*/
|
||||
private notifyListeners(status: SyncStatus): void {
|
||||
this.syncListeners.forEach(callback => callback(status));
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica se está online
|
||||
*/
|
||||
private get isOnline(): boolean {
|
||||
return navigator.onLine;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processa a fila de operações pendentes
|
||||
*/
|
||||
async processQueue(): Promise<void> {
|
||||
if (!this.isOnline || this.isSyncing) return;
|
||||
|
||||
try {
|
||||
this.isSyncing = true;
|
||||
console.log('🔄 Iniciando sincronização offline...');
|
||||
|
||||
this.notifyListeners({
|
||||
status: 'syncing',
|
||||
message: 'Sincronizando dados...',
|
||||
progress: 0
|
||||
});
|
||||
|
||||
// Processar fila de operações genéricas
|
||||
await this.processSyncQueue();
|
||||
|
||||
// Processar RDOs pendentes
|
||||
await this.processPendingRDOs();
|
||||
|
||||
console.log('✅ Sincronização concluída!');
|
||||
this.notifyListeners({
|
||||
status: 'success',
|
||||
message: 'Sincronização concluída',
|
||||
progress: 100
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erro na sincronização:', error);
|
||||
this.notifyListeners({
|
||||
status: 'error',
|
||||
message: `Erro: ${error instanceof Error ? error.message : 'Desconhecido'}`,
|
||||
progress: 0
|
||||
});
|
||||
} finally {
|
||||
this.isSyncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processa fila de operações genéricas (INSERT/UPDATE/DELETE)
|
||||
*/
|
||||
private async processSyncQueue(): Promise<void> {
|
||||
const operations = await db.syncQueue
|
||||
.orderBy('timestamp')
|
||||
.toArray();
|
||||
|
||||
if (operations.length === 0) return;
|
||||
|
||||
console.log(`📋 Processando ${operations.length} operações...`);
|
||||
|
||||
for (const [index, operation] of operations.entries()) {
|
||||
const progress = ((index + 1) / operations.length) * 50; // 50% do progresso total
|
||||
this.notifyListeners({
|
||||
status: 'syncing',
|
||||
message: `Sincronizando operação ${index + 1}/${operations.length}`,
|
||||
progress
|
||||
});
|
||||
|
||||
await this.syncOperation(operation);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sincroniza uma operação individual com retry
|
||||
*/
|
||||
private async syncOperation(operation: SyncOperation): Promise<void> {
|
||||
let retries = 0;
|
||||
let delay = RETRY_CONFIG.initialDelay;
|
||||
|
||||
while (retries <= RETRY_CONFIG.maxRetries) {
|
||||
try {
|
||||
await this.executeSyncOperation(operation);
|
||||
|
||||
// Sucesso: remove da fila
|
||||
await db.syncQueue.delete(operation.id!);
|
||||
console.log(`✅ Operação ${operation.type} em ${operation.table} sincronizada`);
|
||||
return;
|
||||
|
||||
} catch (error) {
|
||||
retries++;
|
||||
console.warn(`⚠️ Tentativa ${retries}/${RETRY_CONFIG.maxRetries} falhou:`, error);
|
||||
|
||||
if (retries > RETRY_CONFIG.maxRetries) {
|
||||
// Falha definitiva: atualizar retry count
|
||||
await db.syncQueue.update(operation.id!, {
|
||||
retryCount: retries
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Aguardar antes de tentar novamente (exponential backoff)
|
||||
await this.sleep(Math.min(delay, RETRY_CONFIG.maxDelay));
|
||||
delay *= RETRY_CONFIG.backoffMultiplier;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executa uma operação de sincronização
|
||||
*/
|
||||
private async executeSyncOperation(operation: SyncOperation): Promise<void> {
|
||||
const { type, table, data } = operation;
|
||||
|
||||
switch (type) {
|
||||
case 'INSERT': {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { error: insertError } = await supabase
|
||||
.from(table)
|
||||
.insert(data as any);
|
||||
if (insertError) throw insertError;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'UPDATE': {
|
||||
// Verificar conflitos antes de atualizar
|
||||
if (data.id) {
|
||||
await this.checkAndResolveConflict(table, data);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { error: updateError } = await supabase
|
||||
.from(table)
|
||||
.update(data as any)
|
||||
.eq('id', data.id);
|
||||
if (updateError) throw updateError;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'DELETE': {
|
||||
const { error: deleteError } = await supabase
|
||||
.from(table)
|
||||
.delete()
|
||||
.eq('id', data.id);
|
||||
if (deleteError) throw deleteError;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica e resolve conflitos de dados
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private async checkAndResolveConflict(table: string, localData: any): Promise<void> {
|
||||
// Buscar versão remota atual
|
||||
const { data: remoteData, error } = await supabase
|
||||
.from(table)
|
||||
.select('*')
|
||||
.eq('id', localData.id)
|
||||
.single();
|
||||
|
||||
if (error || !remoteData) return; // Sem conflito se não existe remotamente
|
||||
|
||||
// Detectar conflito
|
||||
if (ConflictResolver.detectConflict(localData, remoteData)) {
|
||||
console.warn(`⚠️ Conflito detectado em ${table}:`, localData.id);
|
||||
|
||||
// Criar objeto de conflito
|
||||
const conflict: DataConflict = ConflictResolver.createConflict(
|
||||
table,
|
||||
localData,
|
||||
remoteData,
|
||||
'last-write-wins' // Estratégia padrão
|
||||
);
|
||||
|
||||
// Resolver conflito
|
||||
const resolution = ConflictResolver.resolve(conflict);
|
||||
|
||||
if (resolution.requiresManualReview) {
|
||||
// Salvar para revisão manual
|
||||
ConflictStore.saveUnresolvedConflict(conflict);
|
||||
console.warn(`⚠️ Conflito requer revisão manual: ${table}:${localData.id}`);
|
||||
} else {
|
||||
// Usar dados resolvidos
|
||||
Object.assign(localData, resolution.data);
|
||||
console.log(`✅ Conflito resolvido automaticamente (${resolution.strategy})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processa RDOs pendentes
|
||||
*/
|
||||
private async processPendingRDOs(): Promise<void> {
|
||||
const pendingRDOs = await db.pendingRDOs
|
||||
.where('status')
|
||||
.equals('pending')
|
||||
.toArray();
|
||||
|
||||
if (pendingRDOs.length === 0) {
|
||||
console.log('✅ Nenhum RDO pendente.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`📋 Processando ${pendingRDOs.length} RDOs pendentes...`);
|
||||
|
||||
for (const [index, item] of pendingRDOs.entries()) {
|
||||
const progress = 50 + ((index + 1) / pendingRDOs.length) * 50; // 50-100% do progresso
|
||||
this.notifyListeners({
|
||||
status: 'syncing',
|
||||
message: `Sincronizando RDO ${index + 1}/${pendingRDOs.length}`,
|
||||
progress
|
||||
});
|
||||
|
||||
await this.syncRDO(item);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Envia um RDO específico para o Supabase
|
||||
*/
|
||||
private async syncRDO(item: PendingRDO): Promise<void> {
|
||||
let retries = 0;
|
||||
let delay = RETRY_CONFIG.initialDelay;
|
||||
|
||||
while (retries <= RETRY_CONFIG.maxRetries) {
|
||||
try {
|
||||
// Atualiza status para 'syncing'
|
||||
await db.pendingRDOs.update(item.id!, { status: 'syncing' });
|
||||
|
||||
// Validar integridade dos dados
|
||||
this.validateRDOPayload(item.payload);
|
||||
|
||||
const { payload } = item;
|
||||
|
||||
// Separar dados do header e tabelas relacionadas
|
||||
const rdoHeader = { ...(payload.rdo as Record<string, unknown>) };
|
||||
delete rdoHeader.atividades;
|
||||
delete rdoHeader.mao_obra;
|
||||
delete rdoHeader.equipamentos;
|
||||
delete rdoHeader.ocorrencias;
|
||||
delete rdoHeader.fotos;
|
||||
|
||||
// 1. Inserir RDO Header
|
||||
const { data: rdoData, error: rdoError } = await supabase
|
||||
.from('rdos')
|
||||
.upsert(rdoHeader as any)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (rdoError) throw rdoError;
|
||||
if (!rdoData) throw new Error('Não foi possível recuperar o RDO inserido');
|
||||
|
||||
const realRdoId = (rdoData as any).id;
|
||||
|
||||
// 2. Inserir Relacionados
|
||||
const promises = [];
|
||||
|
||||
// Atividades
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const atividadesList = payload.atividades as any[];
|
||||
if (Array.isArray(atividadesList) && atividadesList.length) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const atividades = atividadesList.map((a: any) => ({ ...a, rdo_id: realRdoId }));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
promises.push(supabase.from('rdo_atividades').upsert(atividades as any));
|
||||
}
|
||||
|
||||
// Mão de Obra
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const maoObraList = payload.mao_obra as any[];
|
||||
if (Array.isArray(maoObraList) && maoObraList.length) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const maoObra = maoObraList.map((m: any) => ({ ...m, rdo_id: realRdoId }));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
promises.push(supabase.from('rdo_mao_obra').upsert(maoObra as any));
|
||||
}
|
||||
|
||||
// Upload de Fotos
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const fotosList = payload.fotos as any[];
|
||||
if (Array.isArray(fotosList) && fotosList.length) {
|
||||
const uploadPromises = fotosList.map(async (file: File) => {
|
||||
const fileName = `${Date.now()}_${file.name.replace(/[^a-zA-Z0-9.]/g, '_')}`;
|
||||
const filePath = `${realRdoId}/${fileName}`;
|
||||
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('rdo-photos')
|
||||
.upload(filePath, file);
|
||||
|
||||
if (uploadError) throw uploadError;
|
||||
|
||||
const { data: { publicUrl } } = supabase.storage
|
||||
.from('rdo-photos')
|
||||
.getPublicUrl(filePath);
|
||||
|
||||
return {
|
||||
rdo_id: realRdoId,
|
||||
nome_arquivo: file.name,
|
||||
tipo_arquivo: file.type,
|
||||
tamanho_bytes: file.size,
|
||||
url_storage: publicUrl
|
||||
};
|
||||
});
|
||||
|
||||
const anexosParaInserir = await Promise.all(uploadPromises);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
promises.push(supabase.from('rdo_anexos').upsert(anexosParaInserir as any));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
// Sucesso: Remove do Dexie
|
||||
await db.pendingRDOs.delete(item.id!);
|
||||
console.log(`✅ RDO ${item.uuid} sincronizado com sucesso.`);
|
||||
return;
|
||||
|
||||
} catch (error) {
|
||||
retries++;
|
||||
console.warn(`⚠️ Tentativa ${retries}/${RETRY_CONFIG.maxRetries} falhou para RDO ${item.uuid}:`, error);
|
||||
|
||||
if (retries > RETRY_CONFIG.maxRetries) {
|
||||
// Falha definitiva
|
||||
await db.pendingRDOs.update(item.id!, { status: 'failed' });
|
||||
console.error(`❌ RDO ${item.uuid} falhou após ${retries} tentativas`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Aguardar antes de tentar novamente
|
||||
await this.sleep(Math.min(delay, RETRY_CONFIG.maxDelay));
|
||||
delay *= RETRY_CONFIG.backoffMultiplier;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida integridade do payload do RDO
|
||||
*/
|
||||
private validateRDOPayload(payload: Record<string, unknown>): void {
|
||||
if (!payload.rdo) {
|
||||
throw new Error('Payload inválido: campo "rdo" ausente');
|
||||
}
|
||||
|
||||
const rdo = payload.rdo as Record<string, unknown>;
|
||||
|
||||
if (!rdo.obra_id) {
|
||||
throw new Error('Payload inválido: "obra_id" ausente');
|
||||
}
|
||||
|
||||
if (!rdo.data_relatorio) {
|
||||
throw new Error('Payload inválido: "data_relatorio" ausente');
|
||||
}
|
||||
|
||||
// Validações adicionais conforme necessário
|
||||
}
|
||||
|
||||
/**
|
||||
* Aguarda um período de tempo
|
||||
*/
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Método público para forçar sincronização manual
|
||||
*/
|
||||
async forceSync(): Promise<void> {
|
||||
await this.processQueue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtém estatísticas de sincronização
|
||||
*/
|
||||
async getSyncStats(): Promise<SyncStats> {
|
||||
const pendingRDOs = await db.pendingRDOs.count();
|
||||
const syncQueue = await db.syncQueue.count();
|
||||
const unresolvedConflicts = ConflictStore.count();
|
||||
|
||||
return {
|
||||
pendingRDOs,
|
||||
pendingOperations: syncQueue,
|
||||
unresolvedConflicts,
|
||||
isOnline: this.isOnline,
|
||||
isSyncing: this.isSyncing
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tipos auxiliares
|
||||
*/
|
||||
export interface SyncStatus {
|
||||
status: 'idle' | 'syncing' | 'success' | 'error';
|
||||
message: string;
|
||||
progress: number; // 0-100
|
||||
}
|
||||
|
||||
export interface SyncStats {
|
||||
pendingRDOs: number;
|
||||
pendingOperations: number;
|
||||
unresolvedConflicts: number;
|
||||
isOnline: boolean;
|
||||
isSyncing: boolean;
|
||||
}
|
||||
|
||||
// Instância singleton
|
||||
export const syncService = new SyncService();
|
||||
|
||||
Reference in New Issue
Block a user