First commit - backup RDOC

This commit is contained in:
2026-02-20 07:20:32 -03:00
commit b7415f0586
259 changed files with 51707 additions and 0 deletions

View 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
View 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
View 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();