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

615 lines
22 KiB
JavaScript

/**
* space/crud.js — Space CRUD operations
*
* Extracted from SpaceService.js.
* Handles createSpace, getSpaceById, getSpacesByUser, updateSpace, deleteSpace.
*/
import { dbRun, dbGet, dbAll, toBool, safeJsonParse } from '../../database/connection.js';
import { apiLogger } from '../../utils/logger.js';
import { checkUserSpaceAccess, checkUserAccessViaTableV2 } from './access.js';
/**
* Valid space types
*/
const SPACE_TYPES = ['business', 'personal', 'admin'];
/**
* Default theme colors
*/
const DEFAULT_THEME = {
primary: '#0ea5e9',
secondary: '#8b5cf6',
tertiary: '#10b981'
};
/**
* Validate space data
* @param {object} spaceData - Space data to validate
* @throws {Error} If validation fails
*/
function validateSpaceData(spaceData) {
const { owner_id, name, type } = spaceData;
if (!owner_id) {
throw new Error('owner_id is required');
}
if (!name) {
throw new Error('name is required');
}
if (!type || !SPACE_TYPES.includes(type)) {
throw new Error(`Invalid space type. Must be: ${SPACE_TYPES.join(', ')}`);
}
}
/**
* Create a new space
* @param {object} spaceData - { owner_id, name, description?, icon?, type, theme_*, settings? }
* @returns {Promise<object>} Created space
*/
export async function createSpace(spaceData) {
const {
owner_id,
name,
description = null,
icon = '\ud83d\udcc1',
type,
theme_primary = DEFAULT_THEME.primary,
theme_secondary = DEFAULT_THEME.secondary,
theme_tertiary = DEFAULT_THEME.tertiary,
settings = null
} = spaceData;
// Validation
validateSpaceData(spaceData);
// Create space
const result = await dbRun(`
INSERT INTO spaces (owner_id, name, description, icon, type, theme_primary, theme_secondary, theme_tertiary, settings)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
owner_id,
name,
description,
icon,
type,
theme_primary,
theme_secondary,
theme_tertiary,
settings ? JSON.stringify(settings) : null
]);
const spaceId = result.lastInsertRowid || result.lastID;
// Auto-create space dashboard
await createSpaceDashboard(spaceId, name);
// ADR-0079: personal spaces no longer get an auto-created Password Manager.
// The starter pack (StarterPackService) owns the single auto-project ("Home").
// Password Manager remains available on demand via the system-tables creator.
return await getSpaceById(spaceId);
}
/**
* Create default dashboard for space
* @param {number} spaceId - Space ID
* @param {string} spaceName - Space name
* @returns {Promise<object>} Created dashboard
*/
async function createSpaceDashboard(spaceId, spaceName) {
const result = await dbRun(`
INSERT INTO dashboards (space_id, name, icon, is_default, order_index)
VALUES (?, ?, ?, ?, ?)
`, [
spaceId,
`${spaceName} Overview`,
'\ud83d\udcca',
toBool(true),
0
]);
return result;
}
/**
* Get all spaces for a user
* @param {number} userId - User ID
* @param {string} userRole - User role (admin, owner, etc)
* @returns {Promise<array>} Array of spaces with counts
*/
export async function getSpacesByUser(userId, userRole = 'user') {
// ADR-0009 Phase 1 (C-7): Eliminate N+1 by pre-filtering spaces in SQL
// and batch-loading explicit permissions + per-space user_rows counts.
//
// Old behaviour: SELECT all 5392 spaces, then run 3-5 DB calls per row in JS.
// New behaviour for non-admins: SELECT only spaces owned by user OR visible
// via `user_access_permissions` OR with `visibility='open'` OR type='admin'
// (the old JS filter also included `accessControl.members` legacy format and
// table-based access — those still need per-space work, so we keep the full
// scan for admins/owners and for spaces with access_control set).
const isPrivileged = userRole === 'owner' || userRole === 'admin';
// Preload explicit grants for this user in ONE query.
// Schema: user_access_permissions(user_id, space_id, access_level, ...)
// (Note: the prior implementation used a query with non-existent
// `resource_type/resource_id/permission` columns and silently swallowed
// the error — this was a latent bug; we now query the correct columns.)
let explicitByResourceId = new Map();
try {
const grants = await dbAll(
`SELECT space_id, access_level FROM user_access_permissions
WHERE user_id = ? AND space_id IS NOT NULL AND access_level != 'denied'`,
[userId]
);
for (const g of grants) {
// Keep keys as strings to match prior behaviour (accepting either shape).
explicitByResourceId.set(String(g.space_id), g.access_level);
explicitByResourceId.set(Number(g.space_id), g.access_level);
}
} catch {
// If the table is missing or query fails, fall through with empty map.
}
// For non-privileged users, narrow candidate spaces in SQL.
// We include: owned spaces, explicitly-granted spaces, open-visibility
// spaces, admin-type spaces (privileged paths short-circuit), personal
// spaces owned by us (already covered by owner_id), and *any* space with
// access_control set (access is decided per-space below — still a narrow
// set: ~9 rows today vs. 5392).
let spacesSql;
let spacesParams;
if (isPrivileged) {
// Admins/owners: see everything (preserve prior behaviour).
spacesSql = `
SELECT
s.*,
COALESCE((SELECT COUNT(*) FROM projects p WHERE p.space_id = s.id), 0) as projects_count,
COALESCE((SELECT COUNT(*) FROM dashboards d WHERE d.space_id = s.id), 0) as dashboards_count
FROM spaces s
ORDER BY
CASE s.type
WHEN 'personal' THEN 1
WHEN 'admin' THEN 2
WHEN 'business' THEN 3
ELSE 4
END,
s.created_at DESC
`;
spacesParams = [];
} else {
const grantedSpaceIds = [];
for (const key of explicitByResourceId.keys()) {
if (typeof key === 'number') grantedSpaceIds.push(key);
}
spacesSql = `
SELECT
s.*,
COALESCE((SELECT COUNT(*) FROM projects p WHERE p.space_id = s.id), 0) as projects_count,
COALESCE((SELECT COUNT(*) FROM dashboards d WHERE d.space_id = s.id), 0) as dashboards_count
FROM spaces s
WHERE
s.owner_id = ?
OR s.id = ANY(?::int[])
OR s.visibility = 'open'
OR s.access_control IS NOT NULL
ORDER BY
CASE s.type
WHEN 'personal' THEN 1
WHEN 'admin' THEN 2
WHEN 'business' THEN 3
ELSE 4
END,
s.created_at DESC
`;
spacesParams = [userId, grantedSpaceIds];
}
const allSpaces = await dbAll(spacesSql, spacesParams);
// Collect all accessControls with usersTableId so we can batch-load table_rows.
// Many spaces share zero table-backed ACLs; the set is typically tiny (~9).
const spacesWithAccessControl = [];
for (const space of allSpaces) {
const accessControl = space.access_control
? (typeof space.access_control === 'string' ? safeJsonParse(space.access_control) : space.access_control)
: null;
if (accessControl?.enabled) {
const usersTableId = accessControl.usersTableId || accessControl.users_table_id;
if (usersTableId) {
spacesWithAccessControl.push({ space, accessControl, usersTableId });
}
}
}
// Batch-load ALL table_rows we will need, in ONE query, grouped by table_id.
const rowsByTableId = new Map();
const distinctUsersTableIds = Array.from(
new Set(spacesWithAccessControl.map(x => x.usersTableId).filter(Boolean))
);
if (distinctUsersTableIds.length > 0) {
try {
const batchRows = await dbAll(
'SELECT id, table_id, data FROM table_rows WHERE table_id = ANY(?::int[])',
[distinctUsersTableIds]
);
for (const row of batchRows) {
const key = row.table_id;
if (!rowsByTableId.has(key)) rowsByTableId.set(key, []);
rowsByTableId.get(key).push(row);
}
} catch {
// Fall back to per-space fetches below if batch fails.
}
}
// Batch-load projects for every candidate space in ONE query; cap per-space
// to 20 rows to match prior LIMIT 20 semantics. For admins, candidate set
// can be 5392 spaces — this replaces 5392 DB calls with 1.
const projectsBySpaceId = new Map();
if (allSpaces.length > 0) {
try {
const candidateSpaceIds = allSpaces.map(s => s.id);
const allProjects = await dbAll(
`SELECT id, space_id, name, icon, created_at
FROM projects
WHERE space_id = ANY(?::int[])
ORDER BY created_at DESC`,
[candidateSpaceIds]
);
for (const p of allProjects) {
const list = projectsBySpaceId.get(p.space_id) || [];
if (list.length < 20) {
list.push({ id: p.id, name: p.name, icon: p.icon });
projectsBySpaceId.set(p.space_id, list);
}
}
} catch {
// Fall through to per-space fetch inside the loop.
}
}
// Filter spaces based on access
const accessibleSpaces = [];
for (const space of allSpaces) {
// Handle both string and object JSON (JSONB auto-parsed by driver)
const accessControl = space.access_control
? (typeof space.access_control === 'string' ? safeJsonParse(space.access_control) : space.access_control)
: null;
// Precompute user access level for this space — cache the
// checkUserAccessViaTableV2 result so the subsequent hasAccess check
// can reuse it instead of repeating the query.
let userAccessLevel = 'viewer';
let cachedTableResult = null;
if (space.owner_id === userId) {
userAccessLevel = 'owner_owner';
} else if (accessControl?.enabled) {
cachedTableResult = await checkUserAccessViaTableV2(userId, accessControl);
if (cachedTableResult.allowed && cachedTableResult.accessLevel) {
userAccessLevel = cachedTableResult.accessLevel;
} else if (cachedTableResult.accessLevel === 'denied') {
userAccessLevel = 'denied';
}
} else if (userRole === 'owner' || userRole === 'admin') {
userAccessLevel = 'admin';
}
// ADR-105: AC2 — Refine access level from user_access_permissions for open/invited spaces
// Uses the pre-loaded map (no per-row DB call).
if (userAccessLevel === 'viewer' && space.owner_id !== userId) {
const explicit = explicitByResourceId.get(space.id) || explicitByResourceId.get(String(space.id));
if (explicit) {
userAccessLevel = explicit;
}
}
// Check access — short-circuit cheap cases we can decide from memory
// so we avoid the per-space explicit-perm DB lookup inside
// checkUserSpaceAccess (already pre-loaded above). Reuse the cached
// checkUserAccessViaTableV2 result for enabled-AC spaces.
let hasAccess;
if (space.owner_id === userId) {
hasAccess = true;
} else if (isPrivileged && space.type === 'admin') {
hasAccess = true;
} else if (explicitByResourceId.has(space.id) || explicitByResourceId.has(String(space.id))) {
hasAccess = true;
} else if (space.type === 'personal') {
hasAccess = false;
} else if (space.visibility === 'open') {
hasAccess = true;
} else if (!accessControl) {
hasAccess = false;
} else if (accessControl.members && Array.isArray(accessControl.members)) {
// Legacy format — members array.
hasAccess = accessControl.members.some(m => m.user_id === userId);
} else if (accessControl.enabled && (accessControl.users_table_id || accessControl.usersTableId)) {
// New/legacy table-based ACL — result already computed above.
hasAccess = !!cachedTableResult?.allowed;
} else {
// Unknown shape — fall through to the full check to preserve behaviour.
hasAccess = await checkUserSpaceAccess(userId, userRole, space, accessControl);
}
if (hasAccess) {
// Handle both string and object JSON (JSONB auto-parsed by driver) for settings
const parsedSettings = space.settings
? (typeof space.settings === 'string' ? safeJsonParse(space.settings) : space.settings)
: null;
// Calculate users by roles from linked users table if access control is enabled
let users_count = 1; // Owner always counts
let users_by_roles = { owners: 1, admins: 0, editors: 0, viewers: 0 }; // Owner included
// Support both camelCase (usersTableId) and snake_case (users_table_id) from DB
const usersTableId = accessControl?.usersTableId || accessControl?.users_table_id;
if (accessControl?.enabled && usersTableId) {
try {
const role_column_id = accessControl.role_column_id;
const role_mappings = accessControl.role_mappings || [];
// Prefer pre-loaded rows (batched) — fall back to per-table fetch if missing.
let userRows = rowsByTableId.get(Number(usersTableId)) || rowsByTableId.get(usersTableId);
if (!userRows) {
userRows = await dbAll(
'SELECT id, table_id, data FROM table_rows WHERE table_id = ?',
[usersTableId]
);
}
// Count by roles
const roleCounts = { owners: 0, admins: 0, editors: 0, viewers: 0 };
let unmappedUsers = 0;
for (const row of userRows) {
const data = typeof row.data === 'string' ? safeJsonParse(row.data) : row.data;
if (!data) continue;
// Get role value from data
const roleValue = data[role_column_id] || data.role;
if (!roleValue) {
// User without role - count as viewer
roleCounts.viewers++;
continue;
}
// Find mapping for this role value
const mapping = role_mappings.find(m => m.columnValue === roleValue);
if (mapping && mapping.accessLevel !== 'denied') {
const level = mapping.accessLevel;
if (level === 'owner') roleCounts.owners++;
else if (level === 'admin') roleCounts.admins++;
else if (level === 'editor') roleCounts.editors++;
else if (level === 'viewer') roleCounts.viewers++;
} else if (!mapping) {
// Role not in mapping - count as viewer (default access)
unmappedUsers++;
}
}
// Add unmapped users as viewers
roleCounts.viewers += unmappedUsers;
// Space owner is not in the table, add manually
users_by_roles = {
owners: roleCounts.owners + 1, // +1 for space owner
admins: roleCounts.admins,
editors: roleCounts.editors,
viewers: roleCounts.viewers
};
users_count = users_by_roles.owners + users_by_roles.admins + users_by_roles.editors + users_by_roles.viewers;
} catch (e) {
// Fallback to defaults if error
}
}
// Get projects for this space (all for scrollable list)
// Prefer batched map — falls back to per-space if batch failed.
let projects = projectsBySpaceId.get(space.id);
if (!projects) {
try {
projects = await dbAll(
'SELECT id, name, icon FROM projects WHERE space_id = ? ORDER BY created_at DESC LIMIT 20',
[space.id]
);
} catch (e) {
projects = [];
}
}
// Get users from linked table for display (all for scrollable list)
let users = [];
if (accessControl?.enabled && usersTableId) {
try {
const name_column_id = accessControl.name_column_id || accessControl.userNameColumn;
const role_column_id = accessControl.role_column_id;
const user_id_column =
accessControl.user_id_column ||
accessControl.userIdColumn ||
'system_user_id';
const role_mappings = accessControl.role_mappings || [];
// Prefer pre-loaded rows (batched); fall back to per-table fetch.
let userRows = rowsByTableId.get(Number(usersTableId)) || rowsByTableId.get(usersTableId);
if (userRows) {
userRows = userRows.slice(0, 50);
} else {
userRows = await dbAll(
'SELECT id, data FROM table_rows WHERE table_id = ? LIMIT 50',
[usersTableId]
);
}
for (const row of userRows) {
const data = typeof row.data === 'string' ? safeJsonParse(row.data) : row.data;
if (!data) continue;
// Try to get name from configured column, or common patterns
// Column ID could be numeric key like "1534"
let name = null;
if (name_column_id && data[name_column_id]) {
name = data[name_column_id];
} else {
// Try common patterns - look for name-like fields
name = data.name || data.email || data['\u0418\u043c\u044f'] || null;
// If not found, try to find a column that looks like name (string value, not email)
if (!name) {
for (const key of Object.keys(data)) {
const val = data[key];
if (typeof val === 'string' && val.length > 0 && val.length < 100 &&
!val.includes('@') && !/^\d+$/.test(val) &&
!['owner', 'admin', 'editor', 'viewer', 'denied', 'true', 'false'].includes(val.toLowerCase())) {
// This looks like a name
name = val;
break;
}
}
}
}
name = name || data.email || 'Unknown';
const roleValue = data[role_column_id] || data.role;
const mapping = role_mappings.find(m => m.columnValue === roleValue);
const role = mapping?.accessLevel || 'viewer';
// System user id — used by frontend to filter spaces by member.
// Try configured user_id column first, then `system_user_id` fallback.
const rawSysId = data[user_id_column] ?? data.system_user_id;
let system_user_id = null;
if (rawSysId !== undefined && rawSysId !== null && rawSysId !== '') {
const n = Number(rawSysId);
if (Number.isFinite(n)) system_user_id = n;
}
users.push({ id: row.id, name, role, system_user_id });
}
} catch (e) {
// Ignore error
}
}
// Parse tickets_config (JSONB may be auto-parsed or string)
const parsedTicketsConfig = space.tickets_config
? (typeof space.tickets_config === 'string' ? safeJsonParse(space.tickets_config) : space.tickets_config)
: null;
// Parse files_config (JSONB may be auto-parsed or string)
const parsedFilesConfig = space.files_config
? (typeof space.files_config === 'string' ? safeJsonParse(space.files_config) : space.files_config)
: null;
// Parse favorites_config (JSONB may be auto-parsed or string)
const parsedFavoritesConfig = space.favorites_config
? (typeof space.favorites_config === 'string' ? safeJsonParse(space.favorites_config) : space.favorites_config)
: null;
accessibleSpaces.push({
...space,
settings: parsedSettings,
access_control: accessControl,
tickets_config: parsedTicketsConfig,
files_config: parsedFilesConfig,
favorites_config: parsedFavoritesConfig,
user_access_level: userAccessLevel,
users_count,
users_by_roles,
projects,
users
});
}
}
return accessibleSpaces;
}
/**
* Get space by ID
* @param {number} spaceId - Space ID
* @returns {Promise<object|null>} Space object or null
*/
export async function getSpaceById(spaceId) {
const space = await dbGet('SELECT * FROM spaces WHERE id = ?', [spaceId]);
if (!space) {
return null;
}
return {
...space,
settings: space.settings ? safeJsonParse(space.settings) : null,
access_control: space.access_control ? safeJsonParse(space.access_control) : null,
tickets_config: space.tickets_config ? safeJsonParse(space.tickets_config) : null,
files_config: space.files_config ? safeJsonParse(space.files_config) : null,
favorites_config: space.favorites_config ? safeJsonParse(space.favorites_config) : null
};
}
/**
* Update space
* @param {number} spaceId - Space ID
* @param {object} updates - Fields to update
* @returns {Promise<object>} Updated space
*/
export async function updateSpace(spaceId, updates) {
// Verify space exists
const space = await getSpaceById(spaceId);
if (!space) {
throw new Error('Space not found');
}
const allowedFields = ['name', 'description', 'icon', 'theme_primary', 'theme_secondary', 'theme_tertiary', 'settings', 'access_control', 'tickets_config', 'files_config', 'favorites_config'];
const updatesFields = Object.keys(updates).filter(key => allowedFields.includes(key) && updates[key] !== undefined);
if (updatesFields.length === 0) {
return space;
}
const setClause = updatesFields.map(field => `${field} = ?`).join(', ');
const values = updatesFields.map(field => {
if ((field === 'settings' || field === 'access_control' || field === 'tickets_config' || field === 'files_config' || field === 'favorites_config') && updates[field]) {
// Normalize to object first, then serialize once
// This prevents double-serialization when frontend sends JSON.stringify'd string
let obj = updates[field];
if (typeof obj === 'string') {
try {
obj = JSON.parse(obj);
} catch {
// Not valid JSON string — wrap as-is
}
}
return JSON.stringify(obj);
}
return updates[field];
});
await dbRun(
`UPDATE spaces SET ${setClause}, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
[...values, spaceId]
);
return await getSpaceById(spaceId);
}
/**
* Delete space by ID
* @param {number} spaceId - Space ID
* @returns {Promise<void>}
*/
export async function deleteSpace(spaceId) {
// Verify space exists
const space = await getSpaceById(spaceId);
if (!space) {
throw new Error('Space not found');
}
// Delete space (CASCADE will handle projects and dashboards)
await dbRun('DELETE FROM spaces WHERE id = ?', [spaceId]);
}