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:
Eric Gullickson
2026-01-11 19:30:43 -06:00
parent e6af7ed5d5
commit ffadc48b4f
2 changed files with 295 additions and 0 deletions

View File

@@ -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);
}
}

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