godcrm/backend/services/ExportService.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

390 lines
10 KiB
JavaScript

/**
* ExportService - Export functionality for GOD CRM
* ADR-020: Export/Import — Quick Backup & Restore
*
* Supports:
* - Table export (full, schema_only, sanitized)
* - Project export with multiple tables
* - Space export with multiple projects
* - Sensitive column detection
* - Access control validation
*/
import { dbAll, dbGet, safeJsonParse } from '../database/connection.js';
import { apiLogger } from '../utils/logger.js';
// Sensitive column types
const SENSITIVE_TYPES = ['password', 'apiKey', 'token', 'secret'];
// Sensitive column name patterns
const SENSITIVE_PATTERNS = [
/password/i,
/api.?key/i,
/secret/i,
/token/i,
/credential/i,
/private.?key/i
];
// Current version for export metadata
const GODCRM_VERSION = '3.0.0';
/**
* ExportService class for handling data exports
*/
export class ExportService {
/**
* Detect sensitive columns in a table
* Checks both column type and column name patterns
* @param {number} tableId - Table ID
* @returns {Promise<{hasSensitive: boolean, columns: Array}>}
*/
static async detectSensitiveColumns(tableId) {
const columns = await dbAll(
'SELECT id, column_name as name, type FROM table_columns WHERE table_id = ? ORDER BY order_index',
[tableId]
);
const sensitive = [];
for (const col of columns) {
// Check by type
if (SENSITIVE_TYPES.includes(col.type)) {
sensitive.push({
name: col.name,
type: col.type,
reason: 'Column type is sensitive'
});
continue;
}
// Check by name pattern
for (const pattern of SENSITIVE_PATTERNS) {
if (pattern.test(col.name)) {
sensitive.push({
name: col.name,
type: col.type,
reason: 'Column name matches sensitive pattern'
});
break;
}
}
}
return {
hasSensitive: sensitive.length > 0,
columns: sensitive
};
}
/**
* Export a single table
* @param {number} tableId - Table ID
* @param {Object} options - { mode: 'full' | 'schema_only' | 'sanitized' }
* @returns {Promise<Object>} Export data
*/
static async exportTable(tableId, options = {}) {
const { mode = 'full' } = options;
// Get table info
const table = await dbGet(
'SELECT id, name, description, icon, project_id FROM universal_tables WHERE id = ?',
[tableId]
);
if (!table) {
throw new Error(`Table not found: ${tableId}`);
}
// Get columns
const columns = await dbAll(`
SELECT
id, table_id, column_name as name, display_name, type as column_type,
config, is_required, is_visible, order_index, default_value,
formula, is_readonly, width
FROM table_columns
WHERE table_id = ?
ORDER BY order_index
`, [tableId]);
// Get rows (if not schema_only)
let rows = [];
if (mode !== 'schema_only') {
const rawRows = await dbAll(
'SELECT id, data, created_at, updated_at FROM table_rows WHERE table_id = ? ORDER BY id',
[tableId]
);
rows = rawRows.map(r => ({
_id: r.id,
_created_at: r.created_at,
_updated_at: r.updated_at,
...safeJsonParse(r.data)
}));
}
// Sanitize if needed
let sanitizedColumns = [];
if (mode === 'sanitized') {
const sensitiveInfo = await this.detectSensitiveColumns(tableId);
sanitizedColumns = sensitiveInfo.columns.map(c => c.name);
rows = rows.map(row => {
const sanitized = { ...row };
for (const colName of sanitizedColumns) {
if (colName in sanitized) {
sanitized[colName] = null;
}
}
return sanitized;
});
}
return {
type: 'table',
table: {
id: table.id,
name: table.name,
description: table.description,
icon: table.icon
},
columns: columns.map(c => ({
id: c.id,
name: c.name,
displayName: c.display_name,
columnType: c.column_type,
config: safeJsonParse(c.config),
isRequired: Boolean(c.is_required),
isVisible: Boolean(c.is_visible),
orderIndex: c.order_index,
defaultValue: c.default_value,
formula: c.formula,
isReadonly: Boolean(c.is_readonly),
width: c.width
})),
rows,
meta: {
mode,
exported_at: new Date().toISOString(),
row_count: rows.length,
column_count: columns.length,
sanitizedColumns: mode === 'sanitized' ? sanitizedColumns : undefined,
godcrm_version: GODCRM_VERSION
}
};
}
/**
* Export a project with all its tables
* @param {number} projectId - Project ID
* @param {Object} options - { tables: {name: mode}, includeDocuments: boolean }
* @returns {Promise<Object>} Export data
*/
static async exportProject(projectId, options = {}) {
const { tables = {}, includeDocuments = true } = options;
// Get project info
const project = await dbGet(
'SELECT id, name, description, icon, type, space_id FROM projects WHERE id = ?',
[projectId]
);
if (!project) {
throw new Error(`Project not found: ${projectId}`);
}
// Get all tables
const allTables = await dbAll(
'SELECT id, name FROM universal_tables WHERE project_id = ? ORDER BY created_at',
[projectId]
);
const exportedTables = [];
for (const table of allTables) {
// Determine mode for this table
let mode = tables[table.name] || tables['*'] || 'full';
if (mode === 'exclude') {
continue;
}
const exported = await this.exportTable(table.id, { mode });
exportedTables.push(exported);
}
// Get documents if requested (gracefully handle missing table)
let documents = [];
if (includeDocuments) {
try {
const rawDocs = await dbAll(
'SELECT id, title, content, status, created_at, updated_at FROM documents WHERE project_id = ? ORDER BY id',
[projectId]
);
documents = rawDocs.map(d => ({
id: d.id,
title: d.title,
content: safeJsonParse(d.content),
status: d.status,
created_at: d.created_at,
updated_at: d.updated_at
}));
} catch (e) {
// Documents table might not exist
documents = [];
}
}
return {
type: 'project',
project: {
id: project.id,
name: project.name,
description: project.description,
icon: project.icon,
type: project.type
},
tables: exportedTables,
documents,
meta: {
exported_at: new Date().toISOString(),
table_count: exportedTables.length,
document_count: documents.length,
godcrm_version: GODCRM_VERSION
}
};
}
/**
* Export an entire space with all projects
* @param {number} spaceId - Space ID
* @param {Object} options - { projects: {...}, includeSettings: boolean }
* @param {Object} context - { userId } for access control
* @returns {Promise<Object>} Export data
*/
static async exportSpace(spaceId, options = {}, context = {}) {
const { userId } = context;
// Access control check
if (userId) {
const canExport = await this.canExport(userId, 'space', spaceId);
if (!canExport) {
throw new Error('Access denied: only admin or owner can export');
}
}
const { projects = {}, includeSettings = true } = options;
// Get space info
const space = await dbGet(
'SELECT id, name, description, type, settings FROM spaces WHERE id = ?',
[spaceId]
);
if (!space) {
throw new Error(`Space not found: ${spaceId}`);
}
// Get all projects
const allProjects = await dbAll(
'SELECT id, name FROM projects WHERE space_id = ? ORDER BY created_at',
[spaceId]
);
const exportedProjects = [];
for (const project of allProjects) {
const projectOptions = projects[project.name] || { tables: { '*': 'full' } };
const exported = await this.exportProject(project.id, {
tables: projectOptions.tables || { '*': 'full' },
includeDocuments: projectOptions.includeDocuments !== false
});
exportedProjects.push(exported);
}
return {
type: 'space',
space: {
id: space.id,
name: space.name,
description: space.description,
type: space.type
},
projects: exportedProjects,
settings: includeSettings ? safeJsonParse(space.settings) : undefined,
meta: {
exported_at: new Date().toISOString(),
project_count: exportedProjects.length,
godcrm_version: GODCRM_VERSION
}
};
}
/**
* Check if user can export a resource
* Only space owner and admin can export
* @param {number} userId - User ID
* @param {string} resourceType - 'table' | 'project' | 'space'
* @param {number} resourceId - Resource ID
* @returns {Promise<boolean>}
*/
static async canExport(userId, resourceType, resourceId) {
let spaceId;
switch (resourceType) {
case 'table': {
const table = await dbGet(
'SELECT project_id FROM universal_tables WHERE id = ?',
[resourceId]
);
if (!table) return false;
const tableProject = await dbGet(
'SELECT space_id FROM projects WHERE id = ?',
[table.project_id]
);
if (!tableProject) return false;
spaceId = tableProject.space_id;
break;
}
case 'project': {
const project = await dbGet(
'SELECT space_id FROM projects WHERE id = ?',
[resourceId]
);
if (!project) return false;
spaceId = project.space_id;
break;
}
case 'space':
spaceId = resourceId;
break;
default:
return false;
}
// Check if user is owner of the space
const space = await dbGet(
'SELECT owner_id FROM spaces WHERE id = ?',
[spaceId]
);
if (!space) return false;
// Owner can always export
if (space.owner_id === userId) {
return true;
}
// TODO: Check space_members table for admin role when implemented
// For now, only owner can export
return false;
}
}
export default ExportService;