godcrm/backend/routes/v3/documents/_helpers.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

275 lines
15 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.

// Documents: shared helpers, constants, and column templates
import { dbAll, dbGet, dbRun, isPostgres, safeJsonParse } from '../../../database/connection.js';
import { generateBaseId } from '../../../utils/baseId.js';
import { apiLogger } from '../../../utils/logger.js';
import { success, created, error, badRequest, notFound, forbidden, unauthorized } from '../../../utils/response.js';
import { getEffectiveRole, ACCESS_LEVEL_VALUES } from '../../../services/EffectiveRoleService.js';
// === ADR-105: Read-only enforcement helper ===
/**
* Check if the current user has at least editor-level access for a project.
* Returns true if access is allowed, false if denied.
* If denied, sends a 403 response.
*/
export async function requireEditorAccess(req, res, projectId) {
const userId = req.user?.id;
if (!userId) {
forbidden(res, 'Authentication required');
return false;
}
try {
const { effectiveRole } = await getEffectiveRole(userId, { projectId: Number(projectId) });
const effectiveValue = ACCESS_LEVEL_VALUES[effectiveRole] ?? 0;
const editorValue = ACCESS_LEVEL_VALUES['editor'];
if (effectiveValue < editorValue) {
apiLogger.warn({ userId, projectId, effectiveRole }, '[Documents] Mutation denied: insufficient access (ADR-105)');
forbidden(res, 'Insufficient permissions. Editor access or above is required to modify documents.');
return false;
}
} catch (err) {
apiLogger.error({ err, userId, projectId }, '[Documents] Error checking effective role');
}
return true;
}
// === HELPER FUNCTIONS ===
/** Build structure JSON from document sections */
export function buildDocumentStructure(h2Sections, title, description = '') {
return {
version: 2,
title: title || '',
description: description || '',
sections: h2Sections.map((section, index) => ({
id: section.id,
order: index + 1,
children: (section.children || []).map(child => child.id),
footer: null,
collapsed: false
})),
footer: null,
links: []
};
}
// Cyrillic to Latin transliteration map
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',
'А': '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'
};
export function transliterate(text) {
return text.split('').map(char => cyrillicToLatin[char] || char).join('');
}
export function slugify(text) {
return transliterate(text)
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim();
}
// === COLUMN TEMPLATES FOR DOCUMENTS v4 ===
export const REGISTRY_COLUMNS = [
{ column_name: 'name', display_name: 'Name', type: 'text', order_index: 1 },
{ column_name: 'description', display_name: 'Description', type: 'text', order_index: 2 },
{ column_name: 'slug', display_name: 'Slug', type: 'text', order_index: 3 },
{ column_name: 'table_id', display_name: 'Table ID', type: 'number', order_index: 4 },
{ column_name: 'icon', display_name: 'Icon', type: 'text', order_index: 5 },
{ column_name: 'category', display_name: 'Category', type: 'select', order_index: 6, config: { options: ['API', 'Frontend', 'Backend', 'DevOps', 'Guide', 'Other'] } },
// Legacy text status kept during transition (frontend fallback). ADR-0001 §5.
{ column_name: 'status', display_name: 'Status (legacy)', type: 'select', order_index: 7, config: { options: ['draft', 'review', 'approved', 'ready', 'published', 'archived', 'regressed-published'] } },
{ column_name: 'order_index', display_name: 'Order', type: 'number', order_index: 8 },
// Canonical status (relation → _doc_statuses). Resolved at creation time in createTableColumns.
{ column_name: 'status_id', display_name: 'Status', type: 'relation', order_index: 9, config: { target_table_id: null, display_column: 'label', icon: '🏷️' } },
// Plan approval checkbox (Gate A — ADR-0003). Per-criterion verify is separate.
{ column_name: 'verified', display_name: 'Verified (Plan)', type: 'checkbox', order_index: 10, config: {} }
];
export const ATOMS_COLUMNS = [
{ column_name: 'key', display_name: 'Key', type: 'text', order_index: 1 },
{ column_name: 'title', display_name: 'Title', type: 'text', order_index: 2 },
{ column_name: 'content', display_name: 'Content', type: 'text', order_index: 3 },
{ column_name: 'type', display_name: 'Type', type: 'select', order_index: 4, config: { options: ['endpoint', 'concept', 'howto', 'code', 'reference', 'component', 'hook', 'store'] } },
{ column_name: 'http_method', display_name: 'HTTP Method', type: 'select', order_index: 5, config: { options: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] } },
{ column_name: 'http_path', display_name: 'HTTP Path', type: 'text', order_index: 6 },
{ column_name: 'code', display_name: 'Code', type: 'text', order_index: 7 },
{ column_name: 'tags', display_name: 'Tags', type: 'multi-select', order_index: 8, config: { options: [] } },
{ column_name: 'document_ids', display_name: 'Documents', type: 'relation', order_index: 10, config: { relation_table: null } }
];
// Per-project `_doc_statuses` seed. Keep slug list in sync with the legacy
// `status` select options in REGISTRY_COLUMNS and with any status-aware backend
// (statusResolver, releaseGate). Mirrors rows present in table 7341 (Architecture v2).
export const DOC_STATUSES_COLUMNS = [
{ column_name: 'slug', display_name: 'Slug', type: 'text', order_index: 1, config: { icon: '🔖' } },
{ column_name: 'label', display_name: 'Label', type: 'text', order_index: 2, config: { icon: '🏷️' } },
{ column_name: 'icon', display_name: 'Icon', type: 'text', order_index: 3, config: { icon: '🎨' } },
{ column_name: 'color', display_name: 'Color', type: 'text', order_index: 4, config: { icon: '🎨' } },
{ column_name: 'order', display_name: 'Order', type: 'number', order_index: 5, config: { icon: '🔢' } },
{ column_name: 'description', display_name: 'Description', type: 'text', order_index: 6, config: { icon: '📝' } },
];
export const DOC_STATUS_SEED = [
{ slug: 'draft', label: 'Draft', icon: '📝', color: 'yellow', order: 10, description: 'Черновик. В работе у автора. Не показывать вне команды.' },
{ slug: 'review', label: 'In Review', icon: '👀', color: 'purple', order: 20, description: 'На ревью/роасте. Собирает возражения.' },
{ slug: 'approved', label: 'Approved', icon: '✅', color: 'blue', order: 30, description: 'Одобрено критиками. План принят, но ещё не верифицирован хозяином.' },
{ slug: 'ready', label: 'Ready', icon: '🚀', color: 'cyan', order: 40, description: 'Готов к релизу. Все BDD-критерии зелёные.' },
{ slug: 'published', label: 'Published', icon: '🌐', color: 'green', order: 50, description: 'Опубликовано/выкачено. Действует как закон.' },
{ slug: 'regressed-published', label: 'Regressed (Published)', icon: '⚠️', color: 'orange', order: 55, description: 'Опубликованный документ регрессировал — критерии сломаны, требуется рероаст хозяина.' },
{ slug: 'archived', label: 'Archived', icon: '🗃️', color: 'gray', order: 60, description: 'Архив. Исторический интерес, силы не имеет.' },
];
export const DOCUMENT_TABLE_COLUMNS = [
// `integer: true` enforces integer-only on the order field — frontend regression
// had been writing fractional values (.5) which broke `ORDER BY data->>'order'::integer`.
{ column_name: 'order', display_name: 'Order', type: 'number', order_index: 1, config: { step: 10, integer: true } },
// ADR-0003 widget-embed Phase 1: level enum realigned with UI values.
// 'widget' is the new embed level carrying a widget_ref + settings_override.
// 'ticket' dropped 2026-04-27: deprecated in favor of widget-embedded tickets.
{ column_name: 'level', display_name: 'Level', type: 'select', order_index: 2, config: { options: ['h1', 'h2', 'h3', 'text', 'atom', 'image', 'divider', 'page_break', 'widget'] } },
{ column_name: 'content_en', display_name: 'Content (EN)', type: 'text', order_index: 3, config: { is_default_language: true, cellFormat: { mode: 'markdown' } } },
{ column_name: 'comment', display_name: 'Comment', type: 'text', order_index: 4, config: { hidden_in_view: true } },
{ column_name: 'type', display_name: 'Type', type: 'select', order_index: 5, config: { options: ['reference', 'endpoint', 'concept', 'howto', 'code'] } },
{ column_name: 'atom_ref', display_name: 'Atom Ref', type: 'relation', order_index: 6, config: { relation_table: null } },
// task_ref / ticket_ref dropped 2026-04-27: 0 non-empty values across 662 doc tables.
// Use widget-embedded tickets (widget_ref) instead.
{ column_name: 'is_collapsed', display_name: 'Collapsed', type: 'boolean', order_index: 9 },
// ADR-0003 widget-embed Phase 1: pointer to `widgets.id` for level='widget' rows.
{ column_name: 'widget_ref', display_name: 'Widget Ref', type: 'relation', order_index: 10, config: { relation_table: null, description: 'ADR-0003: embedded widget (widgets.id)' } },
// ADR-0003 widget-embed Phase 1: JSON blob of preset-local overrides for the embedded widget (nullable).
{ column_name: 'settings_override', display_name: 'Settings Override', type: 'text', order_index: 11, config: { hidden_in_view: true, description: 'ADR-0003: JSON overrides for embedded widget' } }
];
export const LANGUAGE_NAMES = {
en: 'English', ru: 'Русский', de: 'Deutsch', fr: 'Français', es: 'Español',
it: 'Italiano', pt: 'Português', zh: '中文', ja: '日本語', ko: '한국어',
ar: 'العربية', he: 'עברית', uk: 'Українська', pl: 'Polski', nl: 'Nederlands',
};
/** Ensure language column exists in a table */
export async function ensureLanguageColumn(tableId, langCode) {
if (!langCode || langCode.length < 2) return false;
const columnName = `content_${langCode}`;
const existing = await dbGet(
`SELECT id FROM table_columns WHERE table_id = ? AND column_name = ?`,
[tableId, columnName]
);
if (existing) return false;
const maxOrder = await dbGet(
`SELECT MAX(order_index) as max_order FROM table_columns WHERE table_id = ?`,
[tableId]
);
const nextOrder = (maxOrder?.max_order || 0) + 1;
const langName = LANGUAGE_NAMES[langCode] || langCode.toUpperCase();
await dbRun(
`INSERT INTO table_columns (table_id, column_name, display_name, type, order_index, is_visible, config)
VALUES (?, ?, ?, 'text', ?, 1, ?)`,
[tableId, columnName, `Content (${langName})`, nextOrder, JSON.stringify({ translation: true, language: langCode, cellFormat: { mode: 'markdown' } })]
);
apiLogger.info(`[Documents v4] Created language column ${columnName} for table ${tableId}`);
return true;
}
/**
* Idempotently ensure a project has its own `_doc_statuses` reference table,
* seeded with the canonical 7-status set. Returns { tableId, rowIds: {slug: id} }.
* Per ADR-0001 §5 refinement: every project with a docs widget owns its own
* editable statuses table; no cross-project sharing.
*/
export async function ensureDocStatusesForProject(projectId, userId = null) {
if (!projectId) throw new Error('ensureDocStatusesForProject: projectId required');
let existing = await dbGet(
`SELECT id FROM universal_tables WHERE project_id = ? AND name = '_doc_statuses' LIMIT 1`,
[projectId]
);
let tableId;
if (existing) {
tableId = existing.id;
} else {
const baseId = generateBaseId();
const result = await dbRun(
`INSERT INTO universal_tables (project_id, name, display_name, icon, base_id, created_by, show_in_nav)
VALUES (?, '_doc_statuses', 'Document Statuses', '🏷️', ?, ?, 1)`,
[projectId, baseId, userId]
);
tableId = result.lastInsertRowid;
for (const col of DOC_STATUSES_COLUMNS) {
await dbRun(
`INSERT INTO table_columns (table_id, column_name, display_name, type, order_index, is_visible, config)
VALUES (?, ?, ?, ?, ?, 1, ?)`,
[tableId, col.column_name, col.display_name, col.type, col.order_index, col.config ? JSON.stringify(col.config) : null]
);
}
apiLogger.info(`[Documents v4] Created _doc_statuses for project ${projectId}: table_id=${tableId}`);
}
const existingRows = await dbAll(`SELECT id, data FROM table_rows WHERE table_id = ?`, [tableId]);
const slugToId = {};
for (const r of existingRows) {
const d = typeof r.data === 'string' ? JSON.parse(r.data) : (r.data || {});
if (d.slug) slugToId[String(d.slug)] = r.id;
}
for (const seed of DOC_STATUS_SEED) {
if (slugToId[seed.slug]) continue;
const baseId = generateBaseId();
const result = await dbRun(
`INSERT INTO table_rows (table_id, base_id, data, created_by) VALUES (?, ?, ?, ?)`,
[tableId, baseId, JSON.stringify(seed), userId]
);
slugToId[seed.slug] = result.lastInsertRowid;
}
return { tableId, rowIds: slugToId };
}
/** Create columns for a table. Pass projectId when creating a registry so `status_id`
* resolves to that project's own `_doc_statuses` (creates one if missing). */
export async function createTableColumns(tableId, columns, atomsTableId = null, projectId = null) {
let statusesTableId = null;
for (const col of columns) {
let config = col.config ? JSON.stringify(col.config) : null;
if (col.column_name === 'atom_ref' && atomsTableId) {
config = JSON.stringify({ ...col.config, relation_table: atomsTableId });
}
if (col.column_name === 'document_ids' && atomsTableId) {
config = JSON.stringify({ ...col.config, relation_table: null });
}
if (col.column_name === 'status_id') {
if (statusesTableId === null) {
if (projectId) {
const { tableId: dstId } = await ensureDocStatusesForProject(projectId, null);
statusesTableId = dstId;
} else {
const row = await dbGet(`SELECT id FROM universal_tables WHERE name = '_doc_statuses' LIMIT 1`);
statusesTableId = row?.id || 0;
}
}
if (statusesTableId) {
config = JSON.stringify({ ...(col.config || {}), target_table_id: statusesTableId, display_column: 'label' });
}
}
await dbRun(
`INSERT INTO table_columns (table_id, column_name, display_name, type, order_index, is_visible, config)
VALUES (?, ?, ?, ?, ?, 1, ?)`,
[tableId, col.column_name, col.display_name, col.type, col.order_index, config]
);
}
}
// Re-export database utilities for controllers
export { dbAll, dbGet, dbRun, isPostgres, safeJsonParse } from '../../../database/connection.js';
export { generateBaseId } from '../../../utils/baseId.js';
export { apiLogger } from '../../../utils/logger.js';
export { success, created, error, badRequest, notFound, forbidden, unauthorized } from '../../../utils/response.js';