From ffadc48b4f4df8a13d125d83d999b64783791e8e Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 11 Jan 2026 19:30:43 -0600 Subject: [PATCH] feat: add archive extraction and validation service (refs #26) Implement archive service to extract and validate user data import archives. Validates manifest structure, data files, and ensures archive format compatibility with export feature. - user-import.types.ts: Type definitions for import feature - user-import-archive.service.ts: Archive extraction and validation - Validates manifest version (1.0.0) and required fields - Validates all data files exist and contain valid JSON - Temp directory pattern mirrors export (/tmp/user-import-work) - Cleanup method for archive directories Co-Authored-By: Claude Sonnet 4.5 --- .../domain/user-import-archive.service.ts | 232 ++++++++++++++++++ .../user-import/domain/user-import.types.ts | 63 +++++ 2 files changed, 295 insertions(+) create mode 100644 backend/src/features/user-import/domain/user-import-archive.service.ts create mode 100644 backend/src/features/user-import/domain/user-import.types.ts diff --git a/backend/src/features/user-import/domain/user-import-archive.service.ts b/backend/src/features/user-import/domain/user-import-archive.service.ts new file mode 100644 index 0000000..74b0623 --- /dev/null +++ b/backend/src/features/user-import/domain/user-import-archive.service.ts @@ -0,0 +1,232 @@ +/** + * @ai-summary Service for extracting and validating user data import archives + * @ai-context Extracts tar.gz archives and validates manifest and data files + */ + +import * as fsp from 'fs/promises'; +import * as path from 'path'; +import * as tar from 'tar'; +import { logger } from '../../../core/logging/logger'; +import { USER_IMPORT_CONFIG, ImportManifest, ImportValidationResult } from './user-import.types'; + +export class UserImportArchiveService { + private readonly tempPath: string; + + constructor() { + this.tempPath = USER_IMPORT_CONFIG.tempPath; + } + + /** + * Extracts and validates a user data import archive + */ + async extractAndValidate( + archivePath: string, + userId: string + ): Promise { + const timestamp = Date.now(); + const workDir = path.join(this.tempPath, `import-${userId}-${timestamp}`); + + try { + // Create working directory + await fsp.mkdir(workDir, { recursive: true }); + + logger.info('Extracting import archive', { userId, archivePath, workDir }); + + // Extract tar.gz archive + await tar.extract({ + file: archivePath, + cwd: workDir, + strict: true, + }); + + // Validate extracted structure + const manifestPath = path.join(workDir, 'manifest.json'); + const dataPath = path.join(workDir, 'data'); + + // Check manifest exists + try { + await fsp.access(manifestPath); + } catch { + return { + valid: false, + errors: ['Missing manifest.json in archive'], + }; + } + + // Parse and validate manifest + const manifestContent = await fsp.readFile(manifestPath, 'utf-8'); + let manifest: ImportManifest; + + try { + manifest = JSON.parse(manifestContent); + } catch (error) { + return { + valid: false, + errors: ['Invalid JSON in manifest.json'], + }; + } + + // Validate manifest structure + const manifestErrors = this.validateManifest(manifest); + if (manifestErrors.length > 0) { + return { + valid: false, + errors: manifestErrors, + }; + } + + // Check data directory exists + try { + await fsp.access(dataPath); + } catch { + return { + valid: false, + errors: ['Missing data directory in archive'], + }; + } + + // Validate data files + const dataFileErrors = await this.validateDataFiles(dataPath); + if (dataFileErrors.length > 0) { + return { + valid: false, + errors: dataFileErrors, + }; + } + + logger.info('Import archive validated successfully', { userId, workDir }); + + return { + valid: true, + errors: [], + manifest, + extractedPath: workDir, + }; + } catch (error) { + logger.error('Error extracting/validating import archive', { + userId, + error: error instanceof Error ? error.message : String(error), + }); + + // Cleanup on error + try { + await fsp.rm(workDir, { recursive: true, force: true }); + } catch { + // Cleanup failed, but already handling error + } + + return { + valid: false, + errors: [`Archive extraction failed: ${error instanceof Error ? error.message : 'Unknown error'}`], + }; + } + } + + /** + * Cleans up extracted archive directory + */ + async cleanup(extractedPath: string): Promise { + try { + await fsp.rm(extractedPath, { recursive: true, force: true }); + logger.info('Cleaned up import work directory', { extractedPath }); + } catch (error) { + logger.warn('Failed to cleanup import work directory', { + extractedPath, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + /** + * Validates manifest structure and required fields + */ + private validateManifest(manifest: any): string[] { + const errors: string[] = []; + + if (!manifest.version) { + errors.push('Manifest missing version field'); + } else if (manifest.version !== USER_IMPORT_CONFIG.supportedVersion) { + errors.push(`Unsupported archive version: ${manifest.version} (expected ${USER_IMPORT_CONFIG.supportedVersion})`); + } + + if (!manifest.createdAt) { + errors.push('Manifest missing createdAt field'); + } + + if (!manifest.userId) { + errors.push('Manifest missing userId field'); + } + + if (!manifest.contents || typeof manifest.contents !== 'object') { + errors.push('Manifest missing or invalid contents field'); + } else { + // Validate contents structure + const requiredContentFields = [ + 'vehicles', + 'fuelLogs', + 'documents', + 'maintenanceRecords', + 'maintenanceSchedules', + ]; + for (const field of requiredContentFields) { + if (!manifest.contents[field] || typeof manifest.contents[field].count !== 'number') { + errors.push(`Manifest contents missing or invalid ${field} field`); + } + } + } + + if (!manifest.files || typeof manifest.files !== 'object') { + errors.push('Manifest missing or invalid files field'); + } + + if (!Array.isArray(manifest.warnings)) { + errors.push('Manifest warnings field must be an array'); + } + + return errors; + } + + /** + * Validates that all required data files exist and contain valid JSON + */ + private async validateDataFiles(dataPath: string): Promise { + const errors: string[] = []; + const requiredFiles = [ + 'vehicles.json', + 'fuel-logs.json', + 'documents.json', + 'maintenance-records.json', + 'maintenance-schedules.json', + ]; + + for (const filename of requiredFiles) { + const filePath = path.join(dataPath, filename); + + try { + await fsp.access(filePath); + } catch { + errors.push(`Missing required data file: ${filename}`); + continue; + } + + // Validate JSON structure + try { + const content = await fsp.readFile(filePath, 'utf-8'); + JSON.parse(content); + } catch { + errors.push(`Invalid JSON in data file: ${filename}`); + } + } + + return errors; + } + + /** + * Reads and parses a data file from the extracted archive + */ + async readDataFile(extractedPath: string, filename: string): Promise { + const filePath = path.join(extractedPath, 'data', filename); + const content = await fsp.readFile(filePath, 'utf-8'); + return JSON.parse(content); + } +} diff --git a/backend/src/features/user-import/domain/user-import.types.ts b/backend/src/features/user-import/domain/user-import.types.ts new file mode 100644 index 0000000..4995b5b --- /dev/null +++ b/backend/src/features/user-import/domain/user-import.types.ts @@ -0,0 +1,63 @@ +/** + * @ai-summary User import types and constants + * @ai-context Types for user data import feature + */ + +export interface ImportManifest { + version: string; + createdAt: string; + applicationVersion?: string; + userId: string; + contents: { + vehicles: { count: number; withImages: number }; + fuelLogs: { count: number }; + documents: { count: number; withFiles: number }; + maintenanceRecords: { count: number }; + maintenanceSchedules: { count: number }; + }; + files: { + vehicleImages: number; + documentFiles: number; + totalSizeBytes: number; + }; + warnings: string[]; +} + +export interface ImportValidationResult { + valid: boolean; + errors: string[]; + manifest?: ImportManifest; + extractedPath?: string; +} + +export interface ImportPreview { + manifest: ImportManifest; + conflicts: { + vehicles: number; // Count of VINs that already exist + }; + sampleRecords: { + vehicles?: any[]; + fuelLogs?: any[]; + documents?: any[]; + maintenanceRecords?: any[]; + maintenanceSchedules?: any[]; + }; +} + +export interface ImportResult { + success: boolean; + mode: 'merge' | 'replace'; + summary: { + imported: number; + updated: number; + skipped: number; + errors: string[]; + }; + warnings: string[]; +} + +export const USER_IMPORT_CONFIG = { + tempPath: '/tmp/user-import-work', + supportedVersion: '1.0.0', + chunkSize: 100, // Records per batch +} as const;