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

334 lines
9.7 KiB
JavaScript

/**
* ModuleService - ADR-065
* Manages sidebar modules (separate table with FK on widgets)
* Module = widget registered in sidebar with module-specific metadata
*/
import { dbRun, dbGet, dbAll } from '../database/connection.js';
import { logger } from '../utils/logger.js';
import { registerWidgetSafe } from './WidgetLibraryService.js';
const VALID_ACCESS_LEVELS = ['admin', 'member', 'viewer'];
/**
* Get all modules for a space (sidebar list)
* @param {number} spaceId - Space ID
* @returns {Promise<Array>} Modules with widget data
*/
export async function getModulesBySpace(spaceId) {
const modules = await dbAll(`
SELECT
m.id as module_id,
m.widget_id,
m.space_id,
m.sidebar_order,
m.sidebar_icon,
m.access_level,
m.is_pinned,
m.is_default,
m.created_at as module_created_at,
m.updated_at as module_updated_at,
w.id,
w.dashboard_id,
w.widget_type,
w.preset_name,
w.title,
w.description,
w.icon,
w.config,
w.position,
w.is_visible,
w.order_index
FROM modules m
JOIN widgets w ON w.id = m.widget_id
WHERE m.space_id = ?
ORDER BY m.is_pinned DESC, m.sidebar_order ASC, w.id ASC
`, [spaceId]);
return modules.map(row => ({
module_id: row.module_id,
widget_id: row.widget_id,
space_id: row.space_id,
sidebar_order: row.sidebar_order,
sidebar_icon: row.sidebar_icon,
access_level: row.access_level,
is_pinned: row.is_pinned,
is_default: row.is_default,
widget: {
id: row.id,
dashboard_id: row.dashboard_id,
widget_type: row.widget_type,
preset_name: row.preset_name,
title: row.title,
description: row.description,
icon: row.icon,
config: typeof row.config === 'string' ? JSON.parse(row.config) : row.config,
position: typeof row.position === 'string' ? JSON.parse(row.position) : row.position,
is_visible: row.is_visible,
order_index: row.order_index
}
}));
}
/**
* Get module by ID
* @param {number} moduleId - Module ID
* @returns {Promise<object|null>}
*/
export async function getModuleById(moduleId) {
const row = await dbGet(`
SELECT
m.id as module_id,
m.widget_id,
m.space_id,
m.sidebar_order,
m.sidebar_icon,
m.access_level,
m.is_pinned,
m.is_default,
w.id,
w.dashboard_id,
w.widget_type,
w.preset_name,
w.title,
w.description,
w.icon,
w.config,
w.position,
w.is_visible
FROM modules m
JOIN widgets w ON w.id = m.widget_id
WHERE m.id = ?
`, [moduleId]);
if (!row) return null;
return {
module_id: row.module_id,
widget_id: row.widget_id,
space_id: row.space_id,
sidebar_order: row.sidebar_order,
sidebar_icon: row.sidebar_icon,
access_level: row.access_level,
is_pinned: row.is_pinned,
is_default: row.is_default,
widget: {
id: row.id,
dashboard_id: row.dashboard_id,
widget_type: row.widget_type,
preset_name: row.preset_name,
title: row.title,
description: row.description,
icon: row.icon,
config: typeof row.config === 'string' ? JSON.parse(row.config) : row.config,
position: typeof row.position === 'string' ? JSON.parse(row.position) : row.position,
is_visible: row.is_visible
}
};
}
/**
* Register a widget as a sidebar module
* @param {object} data - { widget_id, space_id, sidebar_order, sidebar_icon, access_level }
* @returns {Promise<object>} Created module
*/
export async function createModule(data) {
const {
widget_id,
space_id,
sidebar_order = 0,
sidebar_icon = null,
access_level = 'member',
is_pinned = false,
is_default = false
} = data;
if (!widget_id) throw new Error('widget_id is required');
if (!space_id) throw new Error('space_id is required');
if (access_level && !VALID_ACCESS_LEVELS.includes(access_level)) {
throw new Error(`access_level must be one of: ${VALID_ACCESS_LEVELS.join(', ')}`);
}
// Check widget exists
const widget = await dbGet('SELECT id FROM widgets WHERE id = ?', [widget_id]);
if (!widget) throw new Error('Widget not found');
// Check space exists
const space = await dbGet('SELECT id FROM spaces WHERE id = ?', [space_id]);
if (!space) throw new Error('Space not found');
// Check widget not already a module
const existing = await dbGet('SELECT id FROM modules WHERE widget_id = ?', [widget_id]);
if (existing) throw new Error('Widget is already registered as a module');
const result = await dbRun(`
INSERT INTO modules (widget_id, space_id, sidebar_order, sidebar_icon, access_level, is_pinned, is_default)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, [widget_id, space_id, sidebar_order, sidebar_icon, access_level, is_pinned ? 1 : 0, is_default ? 1 : 0]);
const moduleId = result.lastInsertRowid || result.lastID;
logger.debug({ moduleId, widget_id, space_id }, 'Module created');
// ADR-073: Auto-register widget in widget_library for Widget Picker
try {
await registerWidgetSafe(widget_id, space_id, {
is_public: false,
tags: []
});
} catch (err) {
// Non-critical error, log and continue
logger.warn({ widget_id, space_id, error: err.message }, 'Failed to auto-register widget in library');
}
return await getModuleById(moduleId);
}
/**
* Update module metadata
* @param {number} moduleId - Module ID
* @param {object} updates - Fields to update
* @returns {Promise<object>} Updated module
*/
export async function updateModule(moduleId, updates) {
const mod = await getModuleById(moduleId);
if (!mod) throw new Error('Module not found');
const allowedFields = ['sidebar_order', 'sidebar_icon', 'access_level', 'is_pinned', 'is_default'];
const updateFields = [];
const updateValues = [];
for (const [key, value] of Object.entries(updates)) {
if (allowedFields.includes(key)) {
if (key === 'access_level' && !VALID_ACCESS_LEVELS.includes(value)) {
throw new Error(`access_level must be one of: ${VALID_ACCESS_LEVELS.join(', ')}`);
}
updateFields.push(`${key} = ?`);
if (key === 'is_pinned' || key === 'is_default') {
updateValues.push(value ? 1 : 0);
} else {
updateValues.push(value);
}
}
}
if (updateFields.length === 0) return mod;
updateFields.push('updated_at = CURRENT_TIMESTAMP');
updateValues.push(moduleId);
await dbRun(
`UPDATE modules SET ${updateFields.join(', ')} WHERE id = ?`,
updateValues
);
return await getModuleById(moduleId);
}
/**
* Get all widgets from projects in a space (not just modules)
* Used by Widget Picker to show all available widgets for dashboard
* @param {number} spaceId - Space ID
* @returns {Promise<Array>} Widgets with module info
*/
export async function getWidgetsBySpace(spaceId) {
const widgets = await dbAll(`
SELECT
w.id as widget_id,
w.dashboard_id,
w.widget_type,
w.preset_name,
w.title,
w.description,
w.icon,
w.config,
w.position,
w.is_visible,
w.order_index,
p.id as project_id,
p.name as project_name,
p.icon as project_icon,
m.id as module_id,
m.sidebar_order,
m.sidebar_icon,
m.is_pinned,
m.is_default
FROM widgets w
JOIN dashboards d ON d.id = w.dashboard_id
JOIN projects p ON p.id = d.project_id
LEFT JOIN modules m ON m.widget_id = w.id
WHERE p.space_id = $1
ORDER BY m.is_pinned DESC NULLS LAST, m.sidebar_order ASC NULLS LAST, w.title ASC
`, [spaceId]);
return widgets.map(row => ({
widget_id: row.widget_id,
dashboard_id: row.dashboard_id,
widget_type: row.widget_type,
preset_name: row.preset_name,
title: row.title,
description: row.description,
icon: row.icon,
config: typeof row.config === 'string' ? JSON.parse(row.config) : row.config,
position: typeof row.position === 'string' ? JSON.parse(row.position) : row.position,
is_visible: row.is_visible,
project_id: row.project_id,
project_name: row.project_name,
project_icon: row.project_icon,
is_module: !!row.module_id,
module_id: row.module_id,
is_pinned: row.is_pinned || false,
}));
}
/**
* Get tables with show_in_nav for a space (from all projects)
* Used in Widget Picker to show tables as widget sources
* @param {number} spaceId - Space ID
* @returns {Promise<Array>} Tables with project info
*/
export async function getTablesBySpace(spaceId) {
const tables = await dbAll(`
SELECT
ut.id as table_id,
ut.name,
ut.icon,
ut.description,
ut.is_system,
ut.show_in_nav,
p.id as project_id,
p.name as project_name,
p.icon as project_icon,
(SELECT COUNT(*) FROM table_rows tr WHERE tr.table_id = ut.id) as row_count
FROM universal_tables ut
JOIN projects p ON p.id = ut.project_id
WHERE p.space_id = $1
AND ut.show_in_nav = 1
AND ut.name NOT LIKE 'doc_%'
ORDER BY p.name ASC, ut.name ASC
`, [spaceId]);
return tables.map(row => ({
table_id: row.table_id,
name: row.name,
icon: row.icon,
description: row.description,
is_system: !!row.is_system,
row_count: parseInt(row.row_count, 10) || 0,
project_id: row.project_id,
project_name: row.project_name,
project_icon: row.project_icon,
}));
}
/**
* Unregister widget from sidebar (delete module record, widget remains)
* @param {number} moduleId - Module ID
*/
export async function deleteModule(moduleId) {
const mod = await dbGet('SELECT id FROM modules WHERE id = ?', [moduleId]);
if (!mod) throw new Error('Module not found');
await dbRun('DELETE FROM modules WHERE id = ?', [moduleId]);
logger.debug({ moduleId }, 'Module deleted');
}