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