godcrm/backend/services/ImportService.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

257 lines
7.9 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* ImportService - Import functionality for GOD CRM
* ADR-020: Export/Import — Quick Backup & Restore
*
* Supports:
* - Table import (create new or replace existing)
* - Project import with multiple tables
* - Space import with multiple projects
*/
import { dbRun, dbGet, dbAll, toBool, withTransactionAsync } from '../database/connection.js';
import { apiLogger } from '../utils/logger.js';
/**
* Generate URL-friendly slug from text
* @param {string} text - Text to slugify
* @returns {string} Slug
*/
function slugify(text) {
const cyrillicToLatin = {
'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo', 'ж': 'zh',
'з': 'z', 'и': 'i', 'й': 'y', 'к': 'k', 'л': 'l', 'м': 'm', 'н': 'n', 'о': 'o',
'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u', 'ф': 'f', 'х': 'kh', 'ц': 'ts',
'ч': 'ch', 'ш': 'sh', 'щ': 'shch', 'ъ': '', 'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu', 'я': 'ya'
};
return text
.toLowerCase()
.split('')
.map(char => cyrillicToLatin[char] || char)
.join('')
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim();
}
/**
* ImportService class for handling data imports
*/
export class ImportService {
/**
* Import a table from export data
* @param {number} projectId - Target project ID
* @param {Object} data - Exported table data
* @param {Object} options - { mode: 'create' | 'replace', newName?, targetTableId? }
* @returns {Promise<Object>} Import result
*/
static async importTable(projectId, data, options = {}) {
const { mode = 'create', newName, targetTableId } = options;
// Validate data type
if (data.type !== 'table') {
throw new Error(`Invalid data type: expected 'table', got '${data.type}'`);
}
// Wrap import in transaction for atomicity
return await withTransactionAsync(async (trx) => {
let tableId;
if (mode === 'create') {
// Create new table
const tableName = newName || data.table.name;
const result = await trx.run(`
INSERT INTO universal_tables (project_id, name, description, icon, is_system)
VALUES (?, ?, ?, ?, ?)
`, [
projectId,
tableName,
data.table.description || null,
data.table.icon || '📊',
toBool(false)
]);
tableId = result.lastInsertRowid || result.lastID;
// Create columns
for (const col of data.columns) {
await trx.run(`
INSERT INTO table_columns (
table_id, column_name, display_name, type, config,
is_required, is_visible, order_index, default_value,
formula, is_readonly, width
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
tableId,
col.name,
col.displayName || col.name,
col.columnType || 'text',
col.config ? JSON.stringify(col.config) : null,
toBool(col.isRequired || false),
toBool(col.isVisible !== false),
col.orderIndex || 0,
col.defaultValue || null,
col.formula || null,
toBool(col.isReadonly || false),
col.width || null
]);
}
} else if (mode === 'replace' && targetTableId) {
// Clear existing rows
tableId = targetTableId;
await trx.run('DELETE FROM table_rows WHERE table_id = ?', [tableId]);
} else {
throw new Error('Invalid import mode or missing targetTableId for replace mode');
}
// Import rows
let rowsImported = 0;
for (const row of data.rows || []) {
// Remove internal metadata from row data
const { _id, _created_at, _updated_at, ...rowData } = row;
// Generate unique base_id
const baseId = 'imp_' + Date.now() + '_' + Math.random().toString(36).substring(2, 8);
await trx.run(`
INSERT INTO table_rows (table_id, base_id, data, created_by)
VALUES (?, ?, ?, ?)
`, [tableId, baseId, JSON.stringify(rowData), null]);
rowsImported++;
}
apiLogger.info({ tableId, rowsImported, columnsCreated: data.columns.length }, 'Table imported');
return {
tableId,
rowsImported,
columnsCreated: data.columns.length
};
}); // end withTransactionAsync
}
/**
* Import a project from export data
* @param {number} spaceId - Target space ID
* @param {Object} data - Exported project data
* @param {Object} options - { mode: 'create' | 'merge', newName? }
* @returns {Promise<Object>} Import result
*/
static async importProject(spaceId, data, options = {}) {
const { mode = 'create', newName } = options;
// Validate data type
if (data.type !== 'project') {
throw new Error(`Invalid data type: expected 'project', got '${data.type}'`);
}
// Get space owner for project creation
const space = await dbGet('SELECT owner_id FROM spaces WHERE id = ?', [spaceId]);
if (!space) {
throw new Error(`Space not found: ${spaceId}`);
}
// Wrap project+documents creation in transaction
const projectName = newName || data.project.name;
const txResult = await withTransactionAsync(async (trx) => {
const projectResult = await trx.run(`
INSERT INTO projects (space_id, owner_id, name, description, icon, type)
VALUES (?, ?, ?, ?, ?, ?)
`, [
spaceId,
space.owner_id,
projectName,
data.project.description || null,
data.project.icon || '📁',
data.project.type || 'business'
]);
const projectId = projectResult.lastInsertRowid || projectResult.lastID;
// Import documents within transaction
let documentsImported = 0;
for (const doc of data.documents || []) {
await trx.run(`
INSERT INTO documents (project_id, title, content, status)
VALUES (?, ?, ?, ?)
`, [
projectId,
doc.title,
JSON.stringify(doc.content || {}),
doc.status || 'published'
]);
documentsImported++;
}
return { projectId, documentsImported };
}); // end withTransactionAsync
// Import tables (each table has its own transaction)
let tablesImported = 0;
for (const tableData of data.tables || []) {
await this.importTable(txResult.projectId, tableData, { mode: 'create' });
tablesImported++;
}
apiLogger.info({ projectId: txResult.projectId, tablesImported, documentsImported: txResult.documentsImported }, 'Project imported');
return {
projectId: txResult.projectId,
tablesImported,
documentsImported: txResult.documentsImported
};
}
/**
* Import a space from export data
* @param {Object} data - Exported space data
* @param {Object} options - { newName?, ownerId }
* @returns {Promise<Object>} Import result
*/
static async importSpace(data, options = {}) {
const { newName, ownerId } = options;
// Validate data type
if (data.type !== 'space') {
throw new Error(`Invalid data type: expected 'space', got '${data.type}'`);
}
if (!ownerId) {
throw new Error('ownerId is required for space import');
}
// Create space
const spaceName = newName || data.space.name;
const spaceResult = await dbRun(`
INSERT INTO spaces (owner_id, name, description, type)
VALUES (?, ?, ?, ?)
`, [
ownerId,
spaceName,
data.space.description || null,
data.space.type || 'business'
]);
const spaceId = spaceResult.lastInsertRowid || spaceResult.lastID;
// Import projects
let projectsImported = 0;
for (const projectData of data.projects || []) {
await this.importProject(spaceId, projectData, { mode: 'create' });
projectsImported++;
}
apiLogger.info({ spaceId, projectsImported }, 'Space imported');
return {
spaceId,
projectsImported
};
}
}
export default ImportService;