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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<ImportValidationResult> {
|
||||
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<void> {
|
||||
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<string[]> {
|
||||
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<T>(extractedPath: string, filename: string): Promise<T[]> {
|
||||
const filePath = path.join(extractedPath, 'data', filename);
|
||||
const content = await fsp.readFile(filePath, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
}
|
||||
}
|
||||
63
backend/src/features/user-import/domain/user-import.types.ts
Normal file
63
backend/src/features/user-import/domain/user-import.types.ts
Normal file
@@ -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;
|
||||
Reference in New Issue
Block a user