require('dotenv').config({ path: __dirname + '/.env' }); const express = require('express'); const cors = require('cors'); const multer = require('multer'); const path = require('path'); const fs = require('fs'); const bcrypt = require('bcrypt'); const session = require('express-session'); const SqliteStore = require('connect-sqlite3')(session); const nocodb = require('./nocodb'); const crypto = require('crypto'); const { PORT, UPLOADS_DIR, SETTINGS_PATH, DEFAULTS, QUERY_LIMITS, ALL_MODULES, TABLE_NAME_MAP, COMMENT_ENTITY_TYPES } = require('./config'); const { getRecordName, batchResolveNames, parseApproverIds, safeJsonParse, pickBodyFields, sanitizeWhereValue, getUserModules, stripSensitiveFields, getUserTeamIds, getUserVisibilityContext } = require('./helpers'); const notify = require('./notifications'); const app = express(); // ─── HELPERS ──────────────────────────────────────────────────── // Ensure uploads directory exists const uploadsDir = UPLOADS_DIR; if (!fs.existsSync(uploadsDir)) { fs.mkdirSync(uploadsDir, { recursive: true }); } // Trust proxy when behind Nginx if (process.env.NODE_ENV === 'production') app.set('trust proxy', 1); // Middleware app.use(cors({ origin: function(origin, callback) { if (!origin) return callback(null, true); if (process.env.CORS_ORIGIN && origin !== process.env.CORS_ORIGIN) { return callback(new Error('Not allowed by CORS')); } callback(null, true); }, credentials: true })); app.use(express.json()); // Session middleware app.use(session({ store: new SqliteStore({ db: 'sessions.db', dir: __dirname }), secret: (() => { if (process.env.SESSION_SECRET) return process.env.SESSION_SECRET; if (process.env.NODE_ENV === 'production') throw new Error('SESSION_SECRET is required in production'); return 'dev-fallback-secret'; })(), resave: false, saveUninitialized: false, cookie: { secure: process.env.NODE_ENV === 'production', httpOnly: true, maxAge: DEFAULTS.sessionMaxAge } })); // Serve uploaded files app.use('/api/uploads', express.static(uploadsDir)); // ─── APP SETTINGS (persisted to JSON) ──────────────────────────── const defaultSettings = { uploadMaxSizeMB: DEFAULTS.uploadMaxSizeMB }; function loadSettings() { try { return { ...defaultSettings, ...JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf8')) }; } catch (err) { console.error('Load settings error:', err.message); return { ...defaultSettings }; } } function saveSettings(s) { fs.writeFileSync(SETTINGS_PATH, JSON.stringify(s, null, 2)); } let appSettings = loadSettings(); // Multer config — dynamic size limit const decodeOriginalName = (name) => Buffer.from(name, 'latin1').toString('utf8'); const storage = multer.diskStorage({ destination: (req, file, cb) => cb(null, uploadsDir), filename: (req, file, cb) => { file.originalname = decodeOriginalName(file.originalname); const uniqueName = `${Date.now()}-${Math.round(Math.random() * 1e9)}${path.extname(file.originalname)}`; cb(null, uniqueName); } }); // Create upload middleware dynamically so it reads current limit function getUpload() { return multer({ storage, limits: { fileSize: appSettings.uploadMaxSizeMB * 1024 * 1024 } }); } // Wrapper that creates a fresh multer instance per request const dynamicUpload = (fieldName) => (req, res, next) => { getUpload().single(fieldName)(req, res, (err) => { if (err) { if (err.code === 'LIMIT_FILE_SIZE') { return res.status(413).json({ error: `File too large. Maximum size is ${appSettings.uploadMaxSizeMB} MB.`, code: 'FILE_TOO_LARGE', maxSizeMB: appSettings.uploadMaxSizeMB }); } return res.status(400).json({ error: err.message }); } next(); }); }; // ─── AUTH MIDDLEWARE ───────────────────────────────────────────── function requireAuth(req, res, next) { if (!req.session.userId) { return res.status(401).json({ error: 'Authentication required' }); } next(); } function requireRole(...roles) { return (req, res, next) => { if (!req.session.userId) return res.status(401).json({ error: 'Authentication required' }); if (!roles.includes(req.session.userRole)) return res.status(403).json({ error: 'Insufficient permissions' }); next(); }; } function requireOwnerOrRole(table, ...allowedRoles) { const nocoTable = TABLE_NAME_MAP[table]; if (!nocoTable) throw new Error(`requireOwnerOrRole: invalid table "${table}"`); return async (req, res, next) => { if (!req.session.userId) return res.status(401).json({ error: 'Authentication required' }); if (allowedRoles.includes(req.session.userRole)) return next(); try { const row = await nocodb.get(nocoTable, req.params.id); if (!row) return res.status(404).json({ error: 'Not found' }); // Check plain FK fields (Number columns) if (row.created_by_user_id === req.session.userId) return next(); if (row.assigned_to_id && row.assigned_to_id === req.session.userId) return next(); if (row.owner_id && row.owner_id === req.session.userId) return next(); // Manager team-based access: if resource has team_id and manager is in that team if (req.session.userRole === 'manager' && row.team_id) { const myTeamIds = await getUserTeamIds(req.session.userId); if (myTeamIds.has(row.team_id)) return next(); } return res.status(403).json({ error: 'You can only modify your own items' }); } catch (err) { console.error('Owner check error:', err); return res.status(500).json({ error: 'Permission check failed' }); } }; } // ─── FK MIGRATION: Replace link columns with plain Number fields ── const FK_COLUMNS = { Tasks: ['project_id', 'assigned_to_id', 'created_by_user_id'], CampaignTracks: ['campaign_id'], CampaignAssignments: ['campaign_id', 'member_id', 'assigner_id'], Projects: ['brand_id', 'owner_id', 'created_by_user_id', 'team_id'], Campaigns: ['brand_id', 'created_by_user_id', 'team_id'], Posts: ['brand_id', 'assigned_to_id', 'campaign_id', 'track_id', 'created_by_user_id'], Assets: ['brand_id', 'campaign_id', 'uploader_id'], PostAttachments: ['post_id'], TaskAttachments: ['task_id'], Comments: ['user_id'], BudgetEntries: ['campaign_id', 'project_id'], Artefacts: ['project_id', 'campaign_id'], PostVersions: ['post_id', 'created_by_user_id'], PostVersionTexts: ['version_id'], Issues: ['brand_id', 'assigned_to_id', 'team_id'], Users: ['role_id'], Translations: ['brand_id', 'created_by_user_id'], TranslationTexts: ['translation_id'], }; // Maps link column names to FK field names for migration const LINK_TO_FK = { Tasks: { Project: 'project_id', AssignedTo: 'assigned_to_id', CreatedByUser: 'created_by_user_id' }, CampaignTracks: { Campaign: 'campaign_id' }, CampaignAssignments: { Campaign: 'campaign_id', Member: 'member_id', Assigner: 'assigner_id' }, Projects: { Brand: 'brand_id', Owner: 'owner_id', CreatedByUser: 'created_by_user_id' }, Campaigns: { Brand: 'brand_id', CreatedByUser: 'created_by_user_id' }, Posts: { Brand: 'brand_id', AssignedTo: 'assigned_to_id', Campaign: 'campaign_id', Track: 'track_id', CreatedByUser: 'created_by_user_id' }, Assets: { Brand: 'brand_id', Campaign: 'campaign_id', Uploader: 'uploader_id' }, PostAttachments: { Post: 'post_id' }, TaskAttachments: { Task: 'task_id' }, Comments: { User: 'user_id' }, BudgetEntries: { Campaign: 'campaign_id', Project: 'project_id' }, }; // ─── TABLE CREATION: Ensure required tables exist ──────────────── const REQUIRED_TABLES = { Users: [ { title: 'name', uidt: 'SingleLineText' }, { title: 'email', uidt: 'Email' }, { title: 'role', uidt: 'SingleSelect', dtxp: "'superadmin','manager','contributor'" }, { title: 'team_role', uidt: 'SingleLineText' }, { title: 'brands', uidt: 'LongText' }, { title: 'phone', uidt: 'SingleLineText' }, { title: 'avatar', uidt: 'SingleLineText' }, { title: 'tutorial_completed', uidt: 'Checkbox' }, ], Brands: [ { title: 'name', uidt: 'SingleLineText' }, { title: 'name_ar', uidt: 'SingleLineText' }, { title: 'priority', uidt: 'Number' }, { title: 'color', uidt: 'SingleLineText' }, { title: 'icon', uidt: 'SingleLineText' }, { title: 'category', uidt: 'SingleLineText' }, { title: 'logo', uidt: 'SingleLineText' }, ], Campaigns: [ { title: 'name', uidt: 'SingleLineText' }, { title: 'description', uidt: 'LongText' }, { title: 'start_date', uidt: 'Date' }, { title: 'end_date', uidt: 'Date' }, { title: 'status', uidt: 'SingleSelect', dtxp: "'planning','active','paused','completed','cancelled'" }, { title: 'color', uidt: 'SingleLineText' }, { title: 'budget', uidt: 'Decimal' }, { title: 'goals', uidt: 'LongText' }, { title: 'platforms', uidt: 'LongText' }, { title: 'budget_spent', uidt: 'Decimal' }, { title: 'revenue', uidt: 'Decimal' }, { title: 'impressions', uidt: 'Number' }, { title: 'clicks', uidt: 'Number' }, { title: 'conversions', uidt: 'Number' }, { title: 'cost_per_click', uidt: 'Decimal' }, { title: 'notes', uidt: 'LongText' }, { title: 'brand_id', uidt: 'Number' }, { title: 'created_by_user_id', uidt: 'Number' }, ], CampaignTracks: [ { title: 'name', uidt: 'SingleLineText' }, { title: 'type', uidt: 'SingleSelect', dtxp: "'organic_social','paid_social','paid_search','email','seo','influencer','event','other'" }, { title: 'platform', uidt: 'SingleLineText' }, { title: 'budget_allocated', uidt: 'Decimal' }, { title: 'budget_spent', uidt: 'Decimal' }, { title: 'revenue', uidt: 'Decimal' }, { title: 'impressions', uidt: 'Number' }, { title: 'clicks', uidt: 'Number' }, { title: 'conversions', uidt: 'Number' }, { title: 'notes', uidt: 'LongText' }, { title: 'status', uidt: 'SingleSelect', dtxp: "'planned','active','paused','completed'" }, { title: 'campaign_id', uidt: 'Number' }, ], CampaignAssignments: [ { title: 'assigned_at', uidt: 'DateTime' }, { title: 'campaign_id', uidt: 'Number' }, { title: 'member_id', uidt: 'Number' }, { title: 'assigner_id', uidt: 'Number' }, ], Projects: [ { title: 'name', uidt: 'SingleLineText' }, { title: 'description', uidt: 'LongText' }, { title: 'status', uidt: 'SingleSelect', dtxp: "'active','paused','completed','cancelled'" }, { title: 'priority', uidt: 'SingleSelect', dtxp: "'low','medium','high','urgent'" }, { title: 'start_date', uidt: 'Date' }, { title: 'due_date', uidt: 'Date' }, { title: 'brand_id', uidt: 'Number' }, { title: 'owner_id', uidt: 'Number' }, { title: 'created_by_user_id', uidt: 'Number' }, ], Tasks: [ { title: 'title', uidt: 'SingleLineText' }, { title: 'description', uidt: 'LongText' }, { title: 'status', uidt: 'SingleSelect', dtxp: "'todo','in_progress','done'" }, { title: 'priority', uidt: 'SingleSelect', dtxp: "'low','medium','high','urgent'" }, { title: 'start_date', uidt: 'Date' }, { title: 'due_date', uidt: 'Date' }, { title: 'is_personal', uidt: 'Checkbox' }, { title: 'completed_at', uidt: 'DateTime' }, { title: 'project_id', uidt: 'Number' }, { title: 'assigned_to_id', uidt: 'Number' }, { title: 'created_by_user_id', uidt: 'Number' }, ], Posts: [ { title: 'title', uidt: 'SingleLineText' }, { title: 'description', uidt: 'LongText' }, { title: 'status', uidt: 'SingleSelect', dtxp: "'draft','in_review','approved','scheduled','published','rejected'" }, { title: 'platform', uidt: 'SingleLineText' }, { title: 'platforms', uidt: 'LongText' }, { title: 'content_type', uidt: 'SingleLineText' }, { title: 'scheduled_date', uidt: 'DateTime' }, { title: 'published_date', uidt: 'DateTime' }, { title: 'notes', uidt: 'LongText' }, { title: 'publication_links', uidt: 'LongText' }, { title: 'brand_id', uidt: 'Number' }, { title: 'assigned_to_id', uidt: 'Number' }, { title: 'campaign_id', uidt: 'Number' }, { title: 'track_id', uidt: 'Number' }, { title: 'created_by_user_id', uidt: 'Number' }, ], Assets: [ { title: 'filename', uidt: 'SingleLineText' }, { title: 'original_name', uidt: 'SingleLineText' }, { title: 'mime_type', uidt: 'SingleLineText' }, { title: 'size', uidt: 'Number' }, { title: 'tags', uidt: 'LongText' }, { title: 'folder', uidt: 'SingleLineText' }, { title: 'brand_id', uidt: 'Number' }, { title: 'campaign_id', uidt: 'Number' }, { title: 'uploader_id', uidt: 'Number' }, ], PostAttachments: [ { title: 'filename', uidt: 'SingleLineText' }, { title: 'original_name', uidt: 'SingleLineText' }, { title: 'mime_type', uidt: 'SingleLineText' }, { title: 'size', uidt: 'Number' }, { title: 'url', uidt: 'SingleLineText' }, { title: 'post_id', uidt: 'Number' }, ], Comments: [ { title: 'entity_type', uidt: 'SingleLineText' }, { title: 'entity_id', uidt: 'Number' }, { title: 'content', uidt: 'LongText' }, { title: 'user_id', uidt: 'Number' }, ], BudgetEntries: [ { title: 'label', uidt: 'SingleLineText' }, { title: 'amount', uidt: 'Decimal' }, { title: 'source', uidt: 'SingleLineText' }, { title: 'category', uidt: 'SingleLineText' }, { title: 'date_received', uidt: 'Date' }, { title: 'notes', uidt: 'LongText' }, { title: 'campaign_id', uidt: 'Number' }, ], TaskAttachments: [ { title: 'filename', uidt: 'SingleLineText' }, { title: 'original_name', uidt: 'SingleLineText' }, { title: 'mime_type', uidt: 'SingleLineText' }, { title: 'size', uidt: 'Number' }, { title: 'url', uidt: 'SingleLineText' }, { title: 'task_id', uidt: 'Number' }, ], Teams: [ { title: 'name', uidt: 'SingleLineText' }, { title: 'description', uidt: 'LongText' }, ], TeamMembers: [ { title: 'team_id', uidt: 'Number' }, { title: 'user_id', uidt: 'Number' }, ], Artefacts: [ { title: 'title', uidt: 'SingleLineText' }, { title: 'description', uidt: 'LongText' }, { title: 'type', uidt: 'SingleSelect', dtxp: "'copy','design','video','other'" }, { title: 'status', uidt: 'SingleSelect', dtxp: "'draft','pending_review','approved','rejected','revision_requested'" }, { title: 'content', uidt: 'LongText' }, { title: 'feedback', uidt: 'LongText' }, { title: 'approval_token', uidt: 'SingleLineText' }, { title: 'token_expires_at', uidt: 'DateTime' }, { title: 'created_by_user_id', uidt: 'Number' }, { title: 'brand_id', uidt: 'Number' }, { title: 'post_id', uidt: 'Number' }, { title: 'approved_by_name', uidt: 'SingleLineText' }, { title: 'approved_at', uidt: 'DateTime' }, { title: 'current_version', uidt: 'Number' }, { title: 'review_version', uidt: 'Number' }, { title: 'project_id', uidt: 'Number' }, { title: 'campaign_id', uidt: 'Number' }, { title: 'approver_ids', uidt: 'SingleLineText' }, ], ArtefactVersions: [ { title: 'artefact_id', uidt: 'Number' }, { title: 'version_number', uidt: 'Number' }, { title: 'created_by_user_id', uidt: 'Number' }, { title: 'created_at', uidt: 'DateTime' }, { title: 'notes', uidt: 'LongText' }, ], ArtefactVersionTexts: [ { title: 'version_id', uidt: 'Number' }, { title: 'language_code', uidt: 'SingleLineText' }, { title: 'language_label', uidt: 'SingleLineText' }, { title: 'content', uidt: 'LongText' }, ], ArtefactAttachments: [ { title: 'artefact_id', uidt: 'Number' }, { title: 'version_id', uidt: 'Number' }, { title: 'filename', uidt: 'SingleLineText' }, { title: 'original_name', uidt: 'SingleLineText' }, { title: 'mime_type', uidt: 'SingleLineText' }, { title: 'size', uidt: 'Number' }, { title: 'drive_url', uidt: 'SingleLineText' }, ], PostVersions: [ { title: 'post_id', uidt: 'Number' }, { title: 'version_number', uidt: 'Number' }, { title: 'created_by_user_id', uidt: 'Number' }, { title: 'created_at', uidt: 'DateTime' }, { title: 'notes', uidt: 'LongText' }, ], PostVersionTexts: [ { title: 'version_id', uidt: 'Number' }, { title: 'language_code', uidt: 'SingleLineText' }, { title: 'language_label', uidt: 'SingleLineText' }, { title: 'content', uidt: 'LongText' }, ], Issues: [ { title: 'title', uidt: 'SingleLineText' }, { title: 'description', uidt: 'LongText' }, { title: 'type', uidt: 'SingleSelect', dtxp: "'request','correction','complaint','suggestion','other'" }, { title: 'category', uidt: 'SingleLineText' }, { title: 'priority', uidt: 'SingleSelect', dtxp: "'low','medium','high','urgent'" }, { title: 'status', uidt: 'SingleSelect', dtxp: "'new','acknowledged','in_progress','resolved','declined'" }, { title: 'submitter_name', uidt: 'SingleLineText' }, { title: 'submitter_email', uidt: 'SingleLineText' }, { title: 'submitter_phone', uidt: 'SingleLineText' }, { title: 'tracking_token', uidt: 'SingleLineText' }, { title: 'assigned_to_id', uidt: 'Number' }, { title: 'internal_notes', uidt: 'LongText' }, { title: 'resolution_summary', uidt: 'LongText' }, { title: 'created_at', uidt: 'DateTime' }, { title: 'updated_at', uidt: 'DateTime' }, { title: 'resolved_at', uidt: 'DateTime' }, ], IssueUpdates: [ { title: 'issue_id', uidt: 'Number' }, { title: 'message', uidt: 'LongText' }, { title: 'is_public', uidt: 'Checkbox' }, { title: 'author_name', uidt: 'SingleLineText' }, { title: 'author_type', uidt: 'SingleSelect', dtxp: "'staff','submitter'" }, { title: 'created_at', uidt: 'DateTime' }, ], IssueAttachments: [ { title: 'issue_id', uidt: 'Number' }, { title: 'filename', uidt: 'SingleLineText' }, { title: 'original_name', uidt: 'SingleLineText' }, { title: 'mime_type', uidt: 'SingleLineText' }, { title: 'size', uidt: 'Number' }, { title: 'uploaded_by', uidt: 'SingleLineText' }, { title: 'created_at', uidt: 'DateTime' }, ], Roles: [ { title: 'name', uidt: 'SingleLineText' }, { title: 'color', uidt: 'SingleLineText' }, ], Translations: [ { title: 'title', uidt: 'SingleLineText' }, { title: 'description', uidt: 'LongText' }, { title: 'source_language', uidt: 'SingleLineText' }, { title: 'source_content', uidt: 'LongText' }, { title: 'status', uidt: 'SingleSelect', dtxp: "'draft','pending_review','approved','rejected','revision_requested'" }, { title: 'brand_id', uidt: 'Number' }, { title: 'approver_ids', uidt: 'SingleLineText' }, { title: 'approval_token', uidt: 'SingleLineText' }, { title: 'token_expires_at', uidt: 'DateTime' }, { title: 'approved_by_name', uidt: 'SingleLineText' }, { title: 'approved_at', uidt: 'DateTime' }, { title: 'feedback', uidt: 'LongText' }, { title: 'created_by_user_id', uidt: 'Number' }, ], TranslationTexts: [ { title: 'translation_id', uidt: 'Number' }, { title: 'language_code', uidt: 'SingleLineText' }, { title: 'language_label', uidt: 'SingleLineText' }, { title: 'content', uidt: 'LongText' }, ], }; async function ensureRequiredTables() { // Fetch existing tables const res = await fetch(`${nocodb.url}/api/v2/meta/bases/${nocodb.baseId}/tables`, { headers: { 'xc-token': nocodb.token }, }); if (!res.ok) { console.error('Failed to fetch tables for ensureRequiredTables'); return; } const data = await res.json(); const existingTables = new Set((data.list || []).map(t => t.title)); for (const [tableName, columns] of Object.entries(REQUIRED_TABLES)) { if (existingTables.has(tableName)) continue; console.log(` Creating table ${tableName}...`); try { const createRes = await fetch(`${nocodb.url}/api/v2/meta/bases/${nocodb.baseId}/tables`, { method: 'POST', headers: { 'xc-token': nocodb.token, 'Content-Type': 'application/json' }, body: JSON.stringify({ table_name: tableName, title: tableName, columns: [ { title: 'Id', uidt: 'ID' }, ...columns, ], }), }); if (createRes.ok) { console.log(` Created table ${tableName}`); nocodb.clearCache(); // clear table ID cache so it picks up the new table } else { const err = await createRes.text(); console.error(` Failed to create table ${tableName}:`, err); } } catch (err) { console.error(` Failed to create table ${tableName}:`, err.message); } } } // Text/string columns that must exist on tables (not FKs — those are Number type) const TEXT_COLUMNS = { Projects: [{ name: 'thumbnail', uidt: 'SingleLineText' }, { name: 'color', uidt: 'SingleLineText' }], Tasks: [{ name: 'thumbnail', uidt: 'SingleLineText' }, { name: 'color', uidt: 'SingleLineText' }], Users: [ { name: 'modules', uidt: 'LongText' }, { name: 'password_hash', uidt: 'SingleLineText' }, { name: 'reset_token', uidt: 'SingleLineText' }, { name: 'reset_token_expires', uidt: 'SingleLineText' }, { name: 'preferred_language', uidt: 'SingleLineText' }, ], BudgetEntries: [{ name: 'project_id', uidt: 'Number' }, { name: 'destination', uidt: 'SingleLineText' }, { name: 'type', uidt: 'SingleLineText' }], Comments: [{ name: 'version_number', uidt: 'Number' }], Issues: [{ name: 'thumbnail', uidt: 'SingleLineText' }], Artefacts: [{ name: 'approver_ids', uidt: 'SingleLineText' }], Posts: [ { name: 'approver_ids', uidt: 'SingleLineText' }, { name: 'approval_token', uidt: 'SingleLineText' }, { name: 'token_expires_at', uidt: 'SingleLineText' }, { name: 'approved_by_name', uidt: 'SingleLineText' }, { name: 'approved_at', uidt: 'SingleLineText' }, { name: 'feedback', uidt: 'LongText' }, { name: 'current_version', uidt: 'Number' }, { name: 'review_version', uidt: 'Number' }, ], PostAttachments: [{ name: 'version_id', uidt: 'Number' }], }; async function ensureTextColumns() { for (const [table, columns] of Object.entries(TEXT_COLUMNS)) { try { const tableId = await nocodb.resolveTableId(table); const res = await fetch(`${nocodb.url}/api/v2/meta/tables/${tableId}`, { headers: { 'xc-token': nocodb.token }, }); if (!res.ok) continue; const meta = await res.json(); const existingCols = new Set((meta.columns || []).map(c => c.title)); for (const col of columns) { if (!existingCols.has(col.name)) { console.log(` Adding text column ${table}.${col.name} (${col.uidt})...`); const colRes = await fetch(`${nocodb.url}/api/v2/meta/tables/${tableId}/columns`, { method: 'POST', headers: { 'xc-token': nocodb.token, 'Content-Type': 'application/json' }, body: JSON.stringify({ title: col.name, uidt: col.uidt }), }); if (colRes.ok) { console.log(` ✓ Created ${table}.${col.name}`); } else { const errText = await colRes.text().catch(() => ''); console.error(` ✗ Failed to create ${table}.${col.name}: ${colRes.status} ${errText}`); } } else { console.log(` ${table}.${col.name} already exists`); } } } catch (err) { console.error(` Failed to ensure text columns for ${table}:`, err.message); } } } async function ensureFKColumns() { for (const [table, columns] of Object.entries(FK_COLUMNS)) { try { const tableId = await nocodb.resolveTableId(table); const res = await fetch(`${nocodb.url}/api/v2/meta/tables/${tableId}`, { headers: { 'xc-token': nocodb.token }, }); if (!res.ok) continue; const meta = await res.json(); const existingCols = new Set((meta.columns || []).map(c => c.title)); for (const col of columns) { if (!existingCols.has(col)) { console.log(` Adding column ${table}.${col}...`); await fetch(`${nocodb.url}/api/v2/meta/tables/${tableId}/columns`, { method: 'POST', headers: { 'xc-token': nocodb.token, 'Content-Type': 'application/json' }, body: JSON.stringify({ title: col, uidt: 'Number' }), }); } } } catch (err) { console.error(` Failed to ensure columns for ${table}:`, err.message); } } } async function backfillFKs() { for (const [table, linkMap] of Object.entries(LINK_TO_FK)) { try { const linkFields = Object.keys(linkMap); const records = await nocodb.list(table, { limit: QUERY_LIMITS.max, links: linkFields }); let updated = 0; for (const record of records) { const patch = {}; let needsUpdate = false; for (const [linkCol, fkField] of Object.entries(linkMap)) { const val = record?.[linkCol]; const linkedId = Array.isArray(val) && val.length > 0 ? val[0].Id : (val && typeof val === 'object' && val.Id ? val.Id : null); if (linkedId && !record[fkField]) { patch[fkField] = linkedId; needsUpdate = true; } } if (needsUpdate) { await nocodb.update(table, record.Id, patch); updated++; } } if (updated > 0) console.log(` Backfilled ${updated} records in ${table}`); } catch (err) { console.error(` Failed to backfill ${table}:`, err.message); } } } // ─── HEALTH CHECK ────────────────────────────────────────────── app.get('/api/health', async (req, res) => { const checks = { server: true, nocodb: false, smtp: false }; const errors = []; try { await nocodb.resolveTableId('Users'); checks.nocodb = true; } catch (err) { errors.push(`NocoDB: ${err.message}`); } const { getSmtpConfig } = require('./mail'); checks.smtp = !!getSmtpConfig(); if (!checks.smtp) errors.push('SMTP: not configured'); const requiredEnvVars = ['NOCODB_URL', 'NOCODB_TOKEN', 'NOCODB_BASE_ID']; const missingEnv = requiredEnvVars.filter(v => !process.env[v]); if (missingEnv.length > 0) errors.push(`Missing env vars: ${missingEnv.join(', ')}`); const healthy = checks.server && checks.nocodb && missingEnv.length === 0; res.status(healthy ? 200 : 503).json({ status: healthy ? 'healthy' : 'degraded', checks, errors: errors.length > 0 ? errors : undefined, timestamp: new Date().toISOString(), }); }); // ─── SETUP ROUTES ─────────────────────────────────────────────── app.get('/api/setup/status', async (req, res) => { try { const users = await nocodb.list('Users', { limit: 1 }); res.json({ needsSetup: users.length === 0 }); } catch (err) { res.status(500).json({ error: 'Failed to check setup status' }); } }); app.post('/api/setup', async (req, res) => { try { const users = await nocodb.list('Users', { limit: 1 }); if (users.length > 0) return res.status(403).json({ error: 'Setup already completed' }); } catch (err) { return res.status(500).json({ error: 'Failed to check setup status' }); } const { name, email, password } = req.body; if (!name || !email || !password) return res.status(400).json({ error: 'Name, email, and password are required' }); try { const passwordHash = await bcrypt.hash(password, 10); const created = await nocodb.create('Users', { name, email, role: 'superadmin', password_hash: passwordHash }); console.log(`[SETUP] Superadmin created: ${email} (NocoDB Id: ${created.Id})`); res.status(201).json({ message: 'Superadmin account created. You can now log in.' }); } catch (err) { console.error('Setup error:', err); res.status(500).json({ error: 'Failed to create superadmin account' }); } }); // ─── AUTH ROUTES ──────────────────────────────────────────────── app.post('/api/auth/login', async (req, res) => { const { email, password } = req.body; if (!email || !password) return res.status(400).json({ error: 'Email and password are required' }); try { const users = await nocodb.list('Users', { where: `(email,eq,${sanitizeWhereValue(email)})`, limit: 1 }); if (users.length === 0) return res.status(401).json({ error: 'Invalid email or password' }); // nocodb.list() may not return all fields — fetch full record const user = await nocodb.get('Users', users[0].Id); if (!user || !user.password_hash) return res.status(401).json({ error: 'Invalid email or password' }); const valid = await bcrypt.compare(password, user.password_hash); if (!valid) return res.status(401).json({ error: 'Invalid email or password' }); req.session.userId = user.Id; req.session.userEmail = user.email; req.session.userRole = user.role; req.session.userName = user.name; const modules = getUserModules(user, ALL_MODULES); res.json({ user: { id: user.Id, name: user.name, email: user.email, role: user.role, avatar: user.avatar, team_role: user.team_role, tutorial_completed: user.tutorial_completed, preferred_language: user.preferred_language || 'en', profileComplete: !!user.name, modules, }, }); } catch (err) { console.error('Login error:', err); res.status(500).json({ error: 'Login failed' }); } }); app.post('/api/auth/logout', (req, res) => { req.session.destroy(err => { if (err) return res.status(500).json({ error: 'Logout failed' }); res.clearCookie('connect.sid'); res.json({ success: true }); }); }); app.post('/api/auth/forgot-password', async (req, res) => { const { email } = req.body; if (!email) return res.status(400).json({ error: 'Email is required' }); try { const users = await nocodb.list('Users', { where: `(email,eq,${sanitizeWhereValue(email)})`, limit: 1 }); if (users.length > 0) { const user = users[0]; const rawToken = crypto.randomBytes(32).toString('hex'); const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex'); const expires = new Date(Date.now() + 3600000).toISOString(); await nocodb.update('Users', user.Id, { reset_token: tokenHash, reset_token_expires: expires }); const appUrl = process.env.APP_URL || process.env.CORS_ORIGIN || `${req.protocol}://${req.get('host')}`; const resetUrl = `${appUrl}/reset-password?token=${rawToken}`; const { sendMail } = require('./mail'); await sendMail({ to: email, subject: 'Password Reset', html: `

Password Reset

Hello ${user.name || ''},

Click below to reset your password:

Reset Password

This link expires in 1 hour. If you didn't request this, ignore this email.

`, text: `Hello ${user.name || ''},\n\nVisit this link to reset your password:\n${resetUrl}\n\nExpires in 1 hour.`, }); } } catch (err) { console.error('Forgot password error:', err); } // Always return success to prevent email enumeration res.json({ message: 'If an account with that email exists, a reset link has been sent.' }); }); app.post('/api/auth/reset-password', async (req, res) => { const { token, password } = req.body; if (!token || !password) return res.status(400).json({ error: 'Token and password are required' }); if (password.length < 6) return res.status(400).json({ error: 'Password must be at least 6 characters' }); try { const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); const users = await nocodb.list('Users', { where: `(reset_token,eq,${tokenHash})`, limit: 1 }); if (users.length === 0) return res.status(400).json({ error: 'Invalid or expired reset token' }); // nocodb.list() may not return all fields — fetch full record const user = await nocodb.get('Users', users[0].Id); if (!user.reset_token_expires || new Date(user.reset_token_expires) < new Date()) { return res.status(400).json({ error: 'Invalid or expired reset token' }); } const hash = await bcrypt.hash(password, 10); await nocodb.update('Users', user.Id, { password_hash: hash, reset_token: '', reset_token_expires: '' }); res.json({ message: 'Password has been reset. You can now log in.' }); } catch (err) { console.error('Reset password error:', err); res.status(500).json({ error: 'Failed to reset password' }); } }); app.get('/api/auth/me', requireAuth, async (req, res) => { try { const user = await nocodb.get('Users', req.session.userId); if (!user) return res.status(404).json({ error: 'User not found' }); const modules = getUserModules(user, ALL_MODULES); res.json({ Id: user.Id, id: user.Id, name: user.name, email: user.email, role: user.role, avatar: user.avatar, team_role: user.team_role, brands: user.brands, phone: user.phone, tutorial_completed: user.tutorial_completed, preferred_language: user.preferred_language || 'en', CreatedAt: user.CreatedAt, created_at: user.CreatedAt, profileComplete: !!user.name, modules, }); } catch (err) { console.error('Auth/me error:', err); res.status(500).json({ error: 'Failed to fetch user' }); } }); app.get('/api/auth/permissions', requireAuth, (req, res) => { const role = req.session.userRole; const canManage = role === 'superadmin' || role === 'manager'; res.json({ role, canCreateCampaigns: canManage, canEditCampaigns: canManage, canDeleteCampaigns: canManage, canCreateProjects: canManage, canEditProjects: canManage, canDeleteProjects: canManage, canManageFinance: canManage, canManageTeam: canManage, canManageUsers: role === 'superadmin', canAssignCampaigns: canManage, canSetBudget: role === 'superadmin', canCreatePosts: true, canCreateTasks: true, canEditAnyPost: canManage, canDeleteAnyPost: canManage, canEditAnyTask: canManage, canDeleteAnyTask: canManage, }); }); // ─── SELF-SERVICE PROFILE ─────────────────────────────────────── app.get('/api/users/me/profile', requireAuth, async (req, res) => { try { const user = await nocodb.get('Users', req.session.userId); if (!user) return res.status(404).json({ error: 'User not found' }); res.json({ Id: user.Id, id: user.Id, name: user.name, email: user.email, role: user.role, team_role: user.team_role, brands: user.brands, phone: user.phone, avatar: user.avatar, tutorial_completed: user.tutorial_completed, }); } catch (err) { res.status(500).json({ error: 'Failed to fetch profile' }); } }); app.patch('/api/users/me/profile', requireAuth, async (req, res) => { const data = {}; if (req.body.name !== undefined) data.name = req.body.name; if (req.body.phone !== undefined) data.phone = req.body.phone; if (Object.keys(data).length === 0) return res.status(400).json({ error: 'No fields to update' }); try { await nocodb.update('Users', req.session.userId, data); if (data.name) req.session.userName = data.name; const user = await nocodb.get('Users', req.session.userId); res.json({ Id: user.Id, id: user.Id, name: user.name, email: user.email, role: user.role, team_role: user.team_role, brands: user.brands, phone: user.phone, avatar: user.avatar, tutorial_completed: user.tutorial_completed, }); } catch (err) { console.error('Update profile error:', err); res.status(500).json({ error: 'Failed to update profile' }); } }); app.patch('/api/users/me/password', requireAuth, async (req, res) => { const { currentPassword, newPassword } = req.body; if (!currentPassword || !newPassword) return res.status(400).json({ error: 'Current password and new password are required' }); if (newPassword.length < 6) return res.status(400).json({ error: 'New password must be at least 6 characters' }); try { const user = await nocodb.get('Users', req.session.userId); if (!user || !user.password_hash) return res.status(404).json({ error: 'Credentials not found' }); const valid = await bcrypt.compare(currentPassword, user.password_hash); if (!valid) return res.status(401).json({ error: 'Current password is incorrect' }); const hash = await bcrypt.hash(newPassword, 10); await nocodb.update('Users', req.session.userId, { password_hash: hash }); res.json({ message: 'Password updated successfully' }); } catch (err) { console.error('Change password error:', err); res.status(500).json({ error: 'Failed to change password' }); } }); app.patch('/api/users/me/tutorial', requireAuth, async (req, res) => { try { await nocodb.update('Users', req.session.userId, { tutorial_completed: !!req.body.completed }); res.json({ success: true, tutorial_completed: req.body.completed ? 1 : 0 }); } catch (err) { res.status(500).json({ error: 'Failed to update tutorial status' }); } }); app.patch('/api/users/me/language', requireAuth, async (req, res) => { const { language } = req.body; if (!language || !['en', 'ar'].includes(language)) return res.status(400).json({ error: 'Invalid language' }); try { await nocodb.update('Users', req.session.userId, { preferred_language: language }); res.json({ success: true, preferred_language: language }); } catch (err) { res.status(500).json({ error: 'Failed to update language preference' }); } }); // ─── USER MANAGEMENT ──────────────────────────────────────────── app.get('/api/users', requireAuth, async (req, res) => { try { const users = await nocodb.list('Users', { sort: 'name' }); // Enrich with role_name let roles = []; try { roles = await nocodb.list('Roles', { limit: QUERY_LIMITS.medium }); } catch {} const roleMap = {}; for (const r of roles) roleMap[r.Id] = r.name; res.json(stripSensitiveFields(users.map(u => ({ ...u, id: u.Id, _id: u.Id, role_name: u.role_id ? (roleMap[u.role_id] || null) : null, })))); } catch (err) { res.status(500).json({ error: 'Failed to load users' }); } }); // ─── ASSIGNABLE USERS ─────────────────────────────────────────── app.get('/api/users/assignable', requireAuth, async (req, res) => { try { const users = await nocodb.list('Users', { sort: 'name', }); res.json(stripSensitiveFields(users.map(u => ({ ...u, id: u.Id, _id: u.Id })))); } catch (err) { res.status(500).json({ error: 'Failed to load assignable users' }); } }); // ─── TEAM ─────────────────────────────────────────────────────── app.get('/api/users/team', requireAuth, async (req, res) => { try { const users = await nocodb.list('Users', { sort: 'name', }); const skipBrandFilter = req.query.all === 'true' && (req.session.userRole === 'superadmin' || req.session.userRole === 'manager'); let filtered = users; if (req.session.userRole !== 'superadmin' && !skipBrandFilter) { const currentUser = await nocodb.get('Users', req.session.userId); let myBrands = []; try { myBrands = JSON.parse(currentUser?.brands || '[]'); } catch (err) { console.error('Parse user brands:', err.message); } filtered = users.filter(u => { // Always include self, superadmins, and managers if (u.Id === req.session.userId || u.role === 'superadmin' || u.role === 'manager') return true; let theirBrands = []; try { theirBrands = JSON.parse(u.brands || '[]'); } catch (err) { console.error('Parse team brands:', err.message); } return theirBrands.some(b => myBrands.includes(b)); }); } // Attach teams + role_name to each user let allTeamMembers = []; let allTeams = []; let roles = []; try { allTeamMembers = await nocodb.list('TeamMembers', { limit: QUERY_LIMITS.max }); allTeams = await nocodb.list('Teams', { limit: QUERY_LIMITS.medium }); roles = await nocodb.list('Roles', { limit: QUERY_LIMITS.medium }); } catch (err) { console.error('Load teams/roles for user list:', err.message); } const teamMap = {}; for (const t of allTeams) teamMap[t.Id] = t.name; const roleMap = {}; for (const r of roles) roleMap[r.Id] = r.name; res.json(stripSensitiveFields(filtered.map(u => { const userTeamEntries = allTeamMembers.filter(tm => tm.user_id === u.Id); const teams = userTeamEntries.map(tm => ({ id: tm.team_id, name: teamMap[tm.team_id] || 'Unknown' })); return { ...u, id: u.Id, _id: u.Id, teams, role_name: u.role_id ? (roleMap[u.role_id] || null) : null }; }))); } catch (err) { console.error('Team list error:', err); res.status(500).json({ error: 'Failed to load team' }); } }); app.post('/api/users/team', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => { const { name, email, password, team_role, brands, phone, role, role_id, avatar, preferred_language } = req.body; if (!name) return res.status(400).json({ error: 'Name is required' }); if (!email) return res.status(400).json({ error: 'Email is required' }); let userRole = role || 'contributor'; if (req.session.userRole === 'manager' && userRole !== 'contributor') { return res.status(403).json({ error: 'Managers can only create users with contributor permission level' }); } if (userRole && !['superadmin', 'manager', 'contributor'].includes(userRole)) { return res.status(400).json({ error: 'Invalid permission level' }); } try { const existing = await nocodb.list('Users', { where: `(email,eq,${sanitizeWhereValue(email)})`, limit: 1 }); if (existing.length > 0) return res.status(409).json({ error: 'Email already exists' }); const defaultPassword = password || 'changeme123'; const passwordHash = await bcrypt.hash(defaultPassword, 10); const created = await nocodb.create('Users', { name, email, role: userRole, team_role: team_role || null, brands: JSON.stringify(brands || []), phone: phone || null, modules: JSON.stringify(req.body.modules || ALL_MODULES), password_hash: passwordHash, role_id: role_id || null, avatar: avatar || null, preferred_language: preferred_language || 'en', }); const user = await nocodb.get('Users', created.Id); res.status(201).json(stripSensitiveFields({ ...user, id: user.Id, _id: user.Id })); notify.notifyUserInvited({ email, name, password: defaultPassword, inviterName: req.session.userName, lang: preferred_language || 'en' }); } catch (err) { console.error('Create team member error:', err); res.status(500).json({ error: 'Failed to create team member' }); } }); app.patch('/api/users/team/:id', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => { try { const existing = await nocodb.get('Users', req.params.id); if (!existing) return res.status(404).json({ error: 'User not found' }); const data = {}; for (const f of ['name', 'email', 'team_role', 'phone', 'avatar', 'preferred_language']) { if (req.body[f] !== undefined) data[f] = req.body[f]; } if (req.body.brands !== undefined) data.brands = JSON.stringify(req.body.brands); if (req.body.modules !== undefined) data.modules = JSON.stringify(req.body.modules); if (req.body.role_id !== undefined) data.role_id = req.body.role_id; // Only superadmin can change permission level (role field) if (req.body.role !== undefined && req.session.userRole === 'superadmin') { if (!['superadmin', 'manager', 'contributor'].includes(req.body.role)) { return res.status(400).json({ error: 'Invalid permission level' }); } data.role = req.body.role; } // Password change if (req.body.password) { data.password_hash = await bcrypt.hash(req.body.password, 10); } if (Object.keys(data).length === 0) return res.status(400).json({ error: 'No fields to update' }); await nocodb.update('Users', req.params.id, data); const user = await nocodb.get('Users', req.params.id); res.json(stripSensitiveFields({ ...user, id: user.Id, _id: user.Id })); } catch (err) { console.error('Update team error:', err); res.status(500).json({ error: 'Failed to update team member' }); } }); app.delete('/api/users/team/:id', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => { if (Number(req.params.id) === req.session.userId) return res.status(400).json({ error: 'Cannot delete your own account' }); try { const user = await nocodb.get('Users', req.params.id); if (!user) return res.status(404).json({ error: 'User not found' }); await nocodb.delete('Users', req.params.id); res.json({ success: true }); } catch (err) { res.status(500).json({ error: 'Failed to delete team member' }); } }); // ─── LEGACY TEAM API (redirects to Users) ─────────────────────── app.get('/api/team', requireAuth, async (req, res) => { try { const users = await nocodb.list('Users', { sort: 'name' }); res.json(stripSensitiveFields(users.map(u => ({ ...u, id: u.Id, _id: u.Id })))); } catch (err) { res.status(500).json({ error: 'Failed to load team' }); } }); // ─── BRANDS ───────────────────────────────────────────────────── app.get('/api/brands', requireAuth, async (req, res) => { try { const brands = await nocodb.list('Brands', { sort: 'priority,name' }); res.json(brands); } catch (err) { res.status(500).json({ error: 'Failed to load brands' }); } }); app.post('/api/brands', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => { const { name, name_ar, priority, color, icon } = req.body; if (!name) return res.status(400).json({ error: 'Name is required' }); try { const created = await nocodb.create('Brands', { name, name_ar: name_ar || null, priority: priority || 2, color: color || null, icon: icon || null }); const brand = await nocodb.get('Brands', created.Id); res.status(201).json(brand); } catch (err) { res.status(500).json({ error: 'Failed to create brand' }); } }); app.patch('/api/brands/:id', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => { try { const existing = await nocodb.get('Brands', req.params.id); if (!existing) return res.status(404).json({ error: 'Brand not found' }); const data = {}; for (const f of ['name', 'name_ar', 'priority', 'color', 'icon', 'logo']) { if (req.body[f] !== undefined) data[f] = req.body[f]; } if (Object.keys(data).length === 0) return res.status(400).json({ error: 'No fields to update' }); await nocodb.update('Brands', req.params.id, data); const brand = await nocodb.get('Brands', req.params.id); res.json(brand); } catch (err) { res.status(500).json({ error: 'Failed to update brand' }); } }); app.post('/api/brands/:id/logo', requireAuth, requireRole('superadmin', 'manager'), dynamicUpload('file'), async (req, res) => { try { const existing = await nocodb.get('Brands', req.params.id); if (!existing) return res.status(404).json({ error: 'Brand not found' }); if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); await nocodb.update('Brands', req.params.id, { logo: req.file.filename }); const brand = await nocodb.get('Brands', req.params.id); res.json(brand); } catch (err) { res.status(500).json({ error: 'Failed to upload logo' }); } }); app.delete('/api/brands/:id', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => { try { const existing = await nocodb.get('Brands', req.params.id); if (!existing) return res.status(404).json({ error: 'Brand not found' }); await nocodb.delete('Brands', req.params.id); res.json({ success: true }); } catch (err) { res.status(500).json({ error: 'Failed to delete brand' }); } }); // One-time: copy Arabic names from "name" to "name_ar" for brands that have no name_ar yet app.post('/api/brands/migrate-names', requireAuth, requireRole('superadmin'), async (req, res) => { try { const brands = await nocodb.list('Brands', { limit: QUERY_LIMITS.small }); const updates = []; for (const b of brands) { if (!b.name_ar && b.name) { updates.push({ Id: b.Id, name_ar: b.name }); } } if (updates.length > 0) { await nocodb.bulkUpdate('Brands', updates); } res.json({ migrated: updates.length, message: `Copied ${updates.length} brand name(s) to name_ar. Now update the "name" field with English names in NocoDB.` }); } catch (err) { console.error('Brand name migration failed:', err); res.status(500).json({ error: 'Migration failed' }); } }); // ─── POSTS ────────────────────────────────────────────────────── app.get('/api/posts/stats', requireAuth, async (req, res) => { try { const posts = await nocodb.list('Posts', { fields: 'status', limit: QUERY_LIMITS.max }); const result = { total: posts.length }; for (const p of posts) { result[p.status] = (result[p.status] || 0) + 1; } res.json(result); } catch (err) { res.status(500).json({ error: 'Failed to fetch post stats' }); } }); app.get('/api/posts', requireAuth, async (req, res) => { try { const { status, brand_id, assigned_to, platform, campaign_id } = req.query; const whereParts = []; if (status) whereParts.push(`(status,eq,${sanitizeWhereValue(status)})`); if (platform) whereParts.push(`(platform,eq,${sanitizeWhereValue(platform)})`); if (brand_id) whereParts.push(`(brand_id,eq,${sanitizeWhereValue(brand_id)})`); if (assigned_to) whereParts.push(`(assigned_to_id,eq,${sanitizeWhereValue(assigned_to)})`); if (campaign_id) whereParts.push(`(campaign_id,eq,${sanitizeWhereValue(campaign_id)})`); const where = whereParts.length > 0 ? whereParts.join('~and') : undefined; const posts = await nocodb.list('Posts', { where, sort: '-UpdatedAt', limit: QUERY_LIMITS.medium }); // Team-based visibility filtering let filtered = posts; const userId = req.session.userId; if (req.session.userRole === 'manager') { const { teamCampaignIds } = await getUserVisibilityContext(userId); filtered = filtered.filter(p => p.created_by_user_id === userId || p.assigned_to_id === userId || (p.campaign_id && teamCampaignIds.has(p.campaign_id)) || !p.campaign_id ); } else if (req.session.userRole === 'contributor') { filtered = filtered.filter(p => p.created_by_user_id === userId || p.assigned_to_id === userId ); } // Get thumbnails const allAttachments = await nocodb.list('PostAttachments', { where: "(mime_type,like,image/%)", limit: QUERY_LIMITS.max, }); const thumbMap = {}; for (const att of allAttachments) { if (att.post_id && !thumbMap[att.post_id]) thumbMap[att.post_id] = att.url; } // Collect unique IDs for name lookups const brandIds = new Set(), userIds = new Set(), campaignIds = new Set(); for (const p of filtered) { if (p.brand_id) brandIds.add(p.brand_id); if (p.assigned_to_id) userIds.add(p.assigned_to_id); if (p.created_by_user_id) userIds.add(p.created_by_user_id); if (p.campaign_id) campaignIds.add(p.campaign_id); if (p.approver_ids) { for (const id of p.approver_ids.split(',').map(s => s.trim()).filter(Boolean)) { userIds.add(Number(id)); } } } const names = await batchResolveNames({ brand: { table: 'Brands', ids: [...brandIds] }, user: { table: 'Users', ids: [...userIds] }, campaign: { table: 'Campaigns', ids: [...campaignIds] }, }); res.json(filtered.map(p => { const approverIdList = p.approver_ids ? p.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : []; return { ...p, brand_id: p.brand_id, assigned_to: p.assigned_to_id, campaign_id: p.campaign_id, created_by_user_id: p.created_by_user_id, brand_name: names[`brand:${p.brand_id}`] || null, assigned_name: names[`user:${p.assigned_to_id}`] || null, campaign_name: names[`campaign:${p.campaign_id}`] || null, creator_user_name: names[`user:${p.created_by_user_id}`] || null, thumbnail_url: thumbMap[p.Id] || null, approvers: approverIdList.map(id => ({ id: Number(id), name: names[`user:${Number(id)}`] || null })), }; })); } catch (err) { console.error('GET /posts error:', err); res.status(500).json({ error: 'Failed to load posts' }); } }); app.post('/api/posts', requireAuth, async (req, res) => { const { title, description, brand_id, assigned_to, status, platform, platforms, content_type, scheduled_date, notes, campaign_id, approver_ids } = req.body; if (!title) return res.status(400).json({ error: 'Title is required' }); const platformsArr = platforms || (platform ? [platform] : []); try { const created = await nocodb.create('Posts', { title, description: description || null, status: status || 'draft', platform: platformsArr[0] || null, platforms: JSON.stringify(platformsArr), content_type: content_type || null, scheduled_date: scheduled_date || null, notes: notes || null, publication_links: '[]', brand_id: brand_id ? Number(brand_id) : null, assigned_to_id: assigned_to ? Number(assigned_to) : null, campaign_id: campaign_id ? Number(campaign_id) : null, approver_ids: approver_ids || null, created_by_user_id: req.session.userId, }); const post = await nocodb.get('Posts', created.Id); const approverIdList = post.approver_ids ? post.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : []; const approverNames = {}; for (const id of approverIdList) { approverNames[id] = await getRecordName('Users', Number(id)); } res.status(201).json({ ...post, assigned_to: post.assigned_to_id, brand_name: await getRecordName('Brands', post.brand_id), assigned_name: await getRecordName('Users', post.assigned_to_id), campaign_name: await getRecordName('Campaigns', post.campaign_id), creator_user_name: await getRecordName('Users', post.created_by_user_id), approvers: approverIdList.map(id => ({ id: Number(id), name: approverNames[id] || null })), }); } catch (err) { console.error('Create post error:', err); res.status(500).json({ error: 'Failed to create post' }); } }); // Bulk delete posts app.post('/api/posts/bulk-delete', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => { try { const { ids } = req.body; if (!Array.isArray(ids) || ids.length === 0) return res.status(400).json({ error: 'ids array required' }); // Delete related PostAttachments for (const id of ids) { const atts = await nocodb.list('PostAttachments', { where: `(post_id,eq,${sanitizeWhereValue(id)})`, limit: QUERY_LIMITS.large }); if (atts.length > 0) await nocodb.bulkDelete('PostAttachments', atts.map(a => ({ Id: a.Id }))); } await nocodb.bulkDelete('Posts', ids.map(id => ({ Id: id }))); res.json({ deleted: ids.length }); } catch (err) { console.error('Bulk delete posts error:', err); res.status(500).json({ error: 'Failed to bulk delete posts' }); } }); app.patch('/api/posts/:id', requireAuth, requireOwnerOrRole('posts', 'superadmin', 'manager'), async (req, res) => { const { id } = req.params; try { const existing = await nocodb.get('Posts', id); if (!existing) return res.status(404).json({ error: 'Post not found' }); const data = {}; for (const f of ['title', 'description', 'status', 'platform', 'content_type', 'scheduled_date', 'published_date', 'notes']) { if (req.body[f] !== undefined) data[f] = req.body[f]; } if (req.body.platforms !== undefined) { data.platforms = JSON.stringify(req.body.platforms); if (!req.body.platform) data.platform = req.body.platforms[0] || null; } if (req.body.publication_links !== undefined) { data.publication_links = JSON.stringify(req.body.publication_links); } if (req.body.brand_id !== undefined) data.brand_id = req.body.brand_id ? Number(req.body.brand_id) : null; if (req.body.assigned_to !== undefined) data.assigned_to_id = req.body.assigned_to ? Number(req.body.assigned_to) : null; if (req.body.campaign_id !== undefined) data.campaign_id = req.body.campaign_id ? Number(req.body.campaign_id) : null; if (req.body.approver_ids !== undefined) data.approver_ids = req.body.approver_ids || null; // Approval gate: can't skip to approved/scheduled/published without review if (['approved', 'scheduled', 'published'].includes(req.body.status) && existing.status !== 'approved' && req.body.status !== existing.status) { if (req.body.status === 'approved' && existing.status !== 'in_review') { // Only public review can set approved — unless superadmin if (req.session.userRole !== 'superadmin') { return res.status(400).json({ error: 'Post must be approved through the review process' }); } } if (['scheduled', 'published'].includes(req.body.status) && existing.status !== 'approved' && existing.status !== 'scheduled') { if (req.session.userRole !== 'superadmin') { return res.status(400).json({ error: 'Post must be approved before it can be scheduled or published' }); } } } // Publish validation if (req.body.status === 'published') { let currentPlatforms, currentLinks; currentPlatforms = req.body.platforms || safeJsonParse(existing.platforms, []); currentLinks = req.body.publication_links || safeJsonParse(existing.publication_links, []); const missing = currentPlatforms.filter(pl => { const link = currentLinks.find(l => l.platform === pl); return !link || !link.url || !link.url.trim(); }); if (missing.length > 0) { return res.status(400).json({ error: `Cannot publish: missing publication links for: ${missing.join(', ')}`, missingPlatforms: missing }); } if (!req.body.published_date) data.published_date = new Date().toISOString(); } await nocodb.update('Posts', id, data); const post = await nocodb.get('Posts', id); const approverIdList = post.approver_ids ? post.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : []; const approverNames = {}; for (const aid of approverIdList) { approverNames[aid] = await getRecordName('Users', Number(aid)); } res.json({ ...post, assigned_to: post.assigned_to_id, brand_name: await getRecordName('Brands', post.brand_id), assigned_name: await getRecordName('Users', post.assigned_to_id), campaign_name: await getRecordName('Campaigns', post.campaign_id), creator_user_name: await getRecordName('Users', post.created_by_user_id), approvers: approverIdList.map(id => ({ id: Number(id), name: approverNames[id] || null })), }); } catch (err) { console.error('Update post error:', err); res.status(500).json({ error: 'Failed to update post' }); } }); app.delete('/api/posts/:id', requireAuth, requireOwnerOrRole('posts', 'superadmin', 'manager'), async (req, res) => { try { await nocodb.delete('Posts', req.params.id); res.json({ success: true }); } catch (err) { res.status(500).json({ error: 'Failed to delete post' }); } }); // ─── POST ATTACHMENTS ─────────────────────────────────────────── app.get('/api/posts/:id/attachments', requireAuth, async (req, res) => { try { const attachments = await nocodb.list('PostAttachments', { where: `(post_id,eq,${sanitizeWhereValue(req.params.id)})`, sort: '-CreatedAt', limit: QUERY_LIMITS.large, }); res.json(attachments); } catch (err) { res.status(500).json({ error: 'Failed to load attachments' }); } }); app.post('/api/posts/:id/attachments', requireAuth, dynamicUpload('file'), async (req, res) => { if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); try { const post = await nocodb.get('Posts', req.params.id); if (!post) return res.status(404).json({ error: 'Post not found' }); // Contributor check if (req.session.userRole === 'contributor') { if (post.created_by_user_id !== req.session.userId && post.assigned_to_id !== req.session.userId) { fs.unlinkSync(path.join(uploadsDir, req.file.filename)); return res.status(403).json({ error: 'You can only manage attachments on your own posts' }); } } const url = `/api/uploads/${req.file.filename}`; const created = await nocodb.create('PostAttachments', { filename: req.file.filename, original_name: req.file.originalname, mime_type: req.file.mimetype, size: req.file.size, url, post_id: Number(req.params.id), }); const attachment = await nocodb.get('PostAttachments', created.Id); res.status(201).json(attachment); } catch (err) { console.error('Upload attachment error:', err); res.status(500).json({ error: 'Failed to upload attachment' }); } }); app.post('/api/posts/:id/attachments/from-asset', requireAuth, async (req, res) => { const { asset_id } = req.body; if (!asset_id) return res.status(400).json({ error: 'asset_id is required' }); try { const post = await nocodb.get('Posts', req.params.id); if (!post) return res.status(404).json({ error: 'Post not found' }); if (req.session.userRole === 'contributor') { if (post.created_by_user_id !== req.session.userId && post.assigned_to_id !== req.session.userId) { return res.status(403).json({ error: 'You can only manage attachments on your own posts' }); } } const asset = await nocodb.get('Assets', asset_id); if (!asset) return res.status(404).json({ error: 'Asset not found' }); const url = `/api/uploads/${asset.filename}`; const created = await nocodb.create('PostAttachments', { filename: asset.filename, original_name: asset.original_name, mime_type: asset.mime_type, size: asset.size, url, post_id: Number(req.params.id), }); const attachment = await nocodb.get('PostAttachments', created.Id); res.status(201).json(attachment); } catch (err) { res.status(500).json({ error: 'Failed to create attachment from asset' }); } }); app.delete('/api/attachments/:id', requireAuth, async (req, res) => { try { const attachment = await nocodb.get('PostAttachments', req.params.id); if (!attachment) return res.status(404).json({ error: 'Attachment not found' }); // Contributor check: get linked post if (req.session.userRole === 'contributor') { if (attachment.post_id) { const post = await nocodb.get('Posts', attachment.post_id); if (post.created_by_user_id !== req.session.userId && post.assigned_to_id !== req.session.userId) { return res.status(403).json({ error: 'You can only manage attachments on your own posts' }); } } } // Check if file is referenced elsewhere before deleting const allAttachments = await nocodb.list('PostAttachments', { where: `(filename,eq,${attachment.filename})`, limit: 10, }); const allAssets = await nocodb.list('Assets', { where: `(filename,eq,${attachment.filename})`, limit: 10, }); if (allAttachments.length <= 1 && allAssets.length === 0) { const filePath = path.join(uploadsDir, attachment.filename); if (fs.existsSync(filePath)) fs.unlinkSync(filePath); } await nocodb.delete('PostAttachments', req.params.id); res.json({ success: true }); } catch (err) { console.error('Delete attachment error:', err); res.status(500).json({ error: 'Failed to delete attachment' }); } }); // ─── POST REVIEW / APPROVAL ───────────────────────────────────── // Submit post for review — generates approval token app.post('/api/posts/:id/submit-review', requireAuth, requireOwnerOrRole('posts', 'superadmin', 'manager'), async (req, res) => { try { const existing = await nocodb.get('Posts', req.params.id); if (!existing) return res.status(404).json({ error: 'Post not found' }); const token = require('crypto').randomUUID(); const expiresAt = new Date(); expiresAt.setDate(expiresAt.getDate() + DEFAULTS.tokenExpiryDays); const updateData = { approval_token: token, token_expires_at: expiresAt.toISOString(), }; // Only change status to in_review for draft/rejected posts if (!existing.status || existing.status === 'draft' || existing.status === 'rejected') { updateData.status = 'in_review'; updateData.approved_by_name = null; updateData.approved_at = null; updateData.feedback = null; } // Track which version is under review if (existing.current_version) { updateData.review_version = existing.current_version; } await nocodb.update('Posts', req.params.id, updateData); const reviewUrl = `${req.protocol}://${req.get('host')}/review-post/${token}`; res.json({ success: true, token, reviewUrl, expiresAt: expiresAt.toISOString() }); notify.notifyReviewSubmitted({ type: 'post', record: { ...existing, ...updateData }, reviewUrl }); } catch (err) { console.error('Submit post review error:', err); res.status(500).json({ error: 'Failed to submit for review' }); } }); // Public: Get post for review (no auth) app.get('/api/public/review-post/:token', async (req, res) => { try { const posts = await nocodb.list('Posts', { where: `(approval_token,eq,${sanitizeWhereValue(req.params.token)})`, limit: 1, }); if (posts.length === 0) return res.status(404).json({ error: 'Review link not found or expired' }); const post = posts[0]; if (post.token_expires_at && new Date(post.token_expires_at) < new Date()) { return res.status(410).json({ error: 'Review link has expired' }); } // Get attachments const attachments = await nocodb.list('PostAttachments', { where: `(post_id,eq,${post.Id})`, limit: QUERY_LIMITS.large, }); // Resolve approver names const approvers = []; if (post.approver_ids) { for (const id of post.approver_ids.split(',').map(s => s.trim()).filter(Boolean)) { approvers.push({ id: Number(id), name: await getRecordName('Users', Number(id)) }); } } // Get comments let comments = []; try { comments = await nocodb.list('Comments', { where: `(entity_type,eq,post)~and(entity_id,eq,${post.Id})`, sort: 'CreatedAt', limit: QUERY_LIMITS.large, }); } catch {} res.json({ ...post, platforms: safeJsonParse(post.platforms, []), publication_links: safeJsonParse(post.publication_links, []), brand_name: await getRecordName('Brands', post.brand_id), assigned_name: await getRecordName('Users', post.assigned_to_id), creator_name: await getRecordName('Users', post.created_by_user_id), approvers, attachments: attachments.map(a => ({ ...a, url: a.url || `/api/uploads/${a.filename}`, })), comments, }); } catch (err) { console.error('Public post review fetch error:', err); res.status(500).json({ error: 'Failed to load post for review' }); } }); // Public: Approve post app.post('/api/public/review-post/:token/approve', async (req, res) => { const { approved_by_name, feedback } = req.body; try { const posts = await nocodb.list('Posts', { where: `(approval_token,eq,${sanitizeWhereValue(req.params.token)})`, limit: 1, }); if (posts.length === 0) return res.status(404).json({ error: 'Review link not found' }); const post = posts[0]; if (post.token_expires_at && new Date(post.token_expires_at) < new Date()) { return res.status(410).json({ error: 'Review link has expired' }); } await nocodb.update('Posts', post.Id, { status: 'approved', approved_by_name: approved_by_name || 'Anonymous', approved_at: new Date().toISOString(), feedback: feedback || null, }); res.json({ success: true, message: 'Post approved successfully' }); notify.notifyApproved({ type: 'post', record: post, approverName: approved_by_name }); } catch (err) { console.error('Post approve error:', err); res.status(500).json({ error: 'Failed to approve post' }); } }); // Public: Reject post app.post('/api/public/review-post/:token/reject', async (req, res) => { const { approved_by_name, feedback } = req.body; if (!feedback || !feedback.trim()) { return res.status(400).json({ error: 'Feedback is required when rejecting' }); } try { const posts = await nocodb.list('Posts', { where: `(approval_token,eq,${sanitizeWhereValue(req.params.token)})`, limit: 1, }); if (posts.length === 0) return res.status(404).json({ error: 'Review link not found' }); const post = posts[0]; if (post.token_expires_at && new Date(post.token_expires_at) < new Date()) { return res.status(410).json({ error: 'Review link has expired' }); } await nocodb.update('Posts', post.Id, { status: 'rejected', approved_by_name: approved_by_name || 'Anonymous', feedback: feedback || '', }); res.json({ success: true, message: 'Post rejected' }); notify.notifyRejected({ type: 'post', record: post, approverName: approved_by_name, feedback }); } catch (err) { console.error('Post reject error:', err); res.status(500).json({ error: 'Failed to reject post' }); } }); // ─── POST VERSIONS ────────────────────────────────────────────── // List all versions for a post app.get('/api/posts/:id/versions', requireAuth, async (req, res) => { try { const versions = await nocodb.list('PostVersions', { where: `(post_id,eq,${sanitizeWhereValue(req.params.id)})`, sort: 'version_number', limit: QUERY_LIMITS.large, }); const enriched = []; for (const v of versions) { const creatorName = await getRecordName('Users', v.created_by_user_id); enriched.push({ ...v, creator_name: creatorName }); } res.json(enriched); } catch (err) { console.error('List post versions error:', err); res.status(500).json({ error: 'Failed to load versions' }); } }); // Create new version app.post('/api/posts/:id/versions', requireAuth, async (req, res) => { const { notes, copy_from_previous } = req.body; try { const post = await nocodb.get('Posts', req.params.id); if (!post) return res.status(404).json({ error: 'Post not found' }); if (req.session.userRole === 'contributor' && post.created_by_user_id !== req.session.userId && post.assigned_to_id !== req.session.userId) { return res.status(403).json({ error: 'You can only create versions for your own posts' }); } const versions = await nocodb.list('PostVersions', { where: `(post_id,eq,${sanitizeWhereValue(req.params.id)})`, sort: '-version_number', limit: 1, }); const newVersionNumber = versions.length > 0 ? versions[0].version_number + 1 : 1; const created = await nocodb.create('PostVersions', { post_id: Number(req.params.id), version_number: newVersionNumber, created_by_user_id: req.session.userId, created_at: new Date().toISOString(), notes: notes || `Version ${newVersionNumber}`, }); await nocodb.update('Posts', req.params.id, { current_version: newVersionNumber }); // Copy texts from previous version if requested if (copy_from_previous && versions.length > 0) { const prevVersionId = versions[0].Id; const prevTexts = await nocodb.list('PostVersionTexts', { where: `(version_id,eq,${prevVersionId})`, limit: QUERY_LIMITS.large, }); for (const text of prevTexts) { await nocodb.create('PostVersionTexts', { version_id: created.Id, language_code: text.language_code, language_label: text.language_label, content: text.content, }); } } const version = await nocodb.get('PostVersions', created.Id); const creatorName = await getRecordName('Users', version.created_by_user_id); res.status(201).json({ ...version, creator_name: creatorName }); } catch (err) { console.error('Create post version error:', err); res.status(500).json({ error: 'Failed to create version' }); } }); // Get specific version with texts and attachments app.get('/api/posts/:id/versions/:versionId', requireAuth, async (req, res) => { try { const version = await nocodb.get('PostVersions', req.params.versionId); if (!version) return res.status(404).json({ error: 'Version not found' }); if (version.post_id !== Number(req.params.id)) { return res.status(400).json({ error: 'Version does not belong to this post' }); } const [texts, attachments] = await Promise.all([ nocodb.list('PostVersionTexts', { where: `(version_id,eq,${sanitizeWhereValue(req.params.versionId)})`, limit: QUERY_LIMITS.large, }), nocodb.list('PostAttachments', { where: `(version_id,eq,${sanitizeWhereValue(req.params.versionId)})`, limit: QUERY_LIMITS.large, }), ]); const creatorName = await getRecordName('Users', version.created_by_user_id); res.json({ ...version, creator_name: creatorName, texts, attachments: attachments.map(a => ({ ...a, url: a.url || `/api/uploads/${a.filename}`, })), }); } catch (err) { console.error('Get post version error:', err); res.status(500).json({ error: 'Failed to load version' }); } }); // Add/update language text for a version app.post('/api/posts/:id/versions/:versionId/texts', requireAuth, async (req, res) => { const { language_code, language_label, content } = req.body; if (!language_code || !language_label || !content) { return res.status(400).json({ error: 'language_code, language_label, and content are required' }); } try { const post = await nocodb.get('Posts', req.params.id); if (!post) return res.status(404).json({ error: 'Post not found' }); if (req.session.userRole === 'contributor' && post.created_by_user_id !== req.session.userId && post.assigned_to_id !== req.session.userId) { return res.status(403).json({ error: 'You can only manage texts for your own posts' }); } const existing = await nocodb.list('PostVersionTexts', { where: `(version_id,eq,${sanitizeWhereValue(req.params.versionId)})~and(language_code,eq,${sanitizeWhereValue(language_code)})`, limit: 1, }); let text; if (existing.length > 0) { await nocodb.update('PostVersionTexts', existing[0].Id, { language_label, content }); text = await nocodb.get('PostVersionTexts', existing[0].Id); } else { const created = await nocodb.create('PostVersionTexts', { version_id: Number(req.params.versionId), language_code, language_label, content, }); text = await nocodb.get('PostVersionTexts', created.Id); } res.json(text); } catch (err) { console.error('Add/update post text error:', err); res.status(500).json({ error: 'Failed to add/update text' }); } }); // Delete language text app.delete('/api/post-version-texts/:id', requireAuth, async (req, res) => { try { const text = await nocodb.get('PostVersionTexts', req.params.id); if (!text) return res.status(404).json({ error: 'Text not found' }); const version = await nocodb.get('PostVersions', text.version_id); const post = await nocodb.get('Posts', version.post_id); if (req.session.userRole === 'contributor' && post.created_by_user_id !== req.session.userId && post.assigned_to_id !== req.session.userId) { return res.status(403).json({ error: 'You can only manage texts for your own posts' }); } await nocodb.delete('PostVersionTexts', req.params.id); res.json({ success: true }); } catch (err) { console.error('Delete post text error:', err); res.status(500).json({ error: 'Failed to delete text' }); } }); // Upload attachment to specific version app.post('/api/posts/:id/versions/:versionId/attachments', requireAuth, dynamicUpload('file'), async (req, res) => { try { const post = await nocodb.get('Posts', req.params.id); if (!post) return res.status(404).json({ error: 'Post not found' }); if (req.session.userRole === 'contributor' && post.created_by_user_id !== req.session.userId && post.assigned_to_id !== req.session.userId) { if (req.file) fs.unlinkSync(path.join(uploadsDir, req.file.filename)); return res.status(403).json({ error: 'You can only manage attachments on your own posts' }); } if (!req.file) { return res.status(400).json({ error: 'File upload is required' }); } const url = `/api/uploads/${req.file.filename}`; const created = await nocodb.create('PostAttachments', { filename: req.file.filename, original_name: req.file.originalname, mime_type: req.file.mimetype, size: req.file.size, url, post_id: Number(req.params.id), version_id: Number(req.params.versionId), }); const attachment = await nocodb.get('PostAttachments', created.Id); res.status(201).json(attachment); } catch (err) { console.error('Upload post version attachment error:', err); res.status(500).json({ error: 'Failed to upload attachment' }); } }); // ─── ASSETS ───────────────────────────────────────────────────── app.get('/api/assets', requireAuth, async (req, res) => { try { const { folder, tags } = req.query; const whereParts = []; if (folder) whereParts.push(`(folder,eq,${sanitizeWhereValue(folder)})`); if (req.query.brand_id) whereParts.push(`(brand_id,eq,${sanitizeWhereValue(req.query.brand_id)})`); if (req.query.campaign_id) whereParts.push(`(campaign_id,eq,${sanitizeWhereValue(req.query.campaign_id)})`); const where = whereParts.length > 0 ? whereParts.join('~and') : undefined; let assets = await nocodb.list('Assets', { where, sort: '-CreatedAt', limit: QUERY_LIMITS.large }); if (tags) { assets = assets.filter(a => (a.tags || '').includes(tags)); } const brandIds = new Set(), userIds = new Set(), campaignIds = new Set(); for (const a of assets) { if (a.brand_id) brandIds.add(a.brand_id); if (a.campaign_id) campaignIds.add(a.campaign_id); if (a.uploader_id) userIds.add(a.uploader_id); } const names = await batchResolveNames({ brand: { table: 'Brands', ids: [...brandIds] }, campaign: { table: 'Campaigns', ids: [...campaignIds] }, user: { table: 'Users', ids: [...userIds] }, }); res.json(assets.map(a => ({ ...a, brand_name: names[`brand:${a.brand_id}`] || null, campaign_name: names[`campaign:${a.campaign_id}`] || null, uploader_name: names[`user:${a.uploader_id}`] || null, }))); } catch (err) { res.status(500).json({ error: 'Failed to load assets' }); } }); app.post('/api/assets/upload', requireAuth, dynamicUpload('file'), async (req, res) => { if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); try { const { brand_id, campaign_id, uploaded_by, folder, tags } = req.body; const created = await nocodb.create('Assets', { filename: req.file.filename, original_name: req.file.originalname, mime_type: req.file.mimetype, size: req.file.size, tags: tags || '[]', folder: folder || 'general', brand_id: brand_id ? Number(brand_id) : null, campaign_id: campaign_id ? Number(campaign_id) : null, uploader_id: uploaded_by ? Number(uploaded_by) : null, }); const asset = await nocodb.get('Assets', created.Id); res.status(201).json({ ...asset, brand_name: await getRecordName('Brands', asset.brand_id), campaign_name: await getRecordName('Campaigns', asset.campaign_id), uploader_name: await getRecordName('Users', asset.uploader_id), }); } catch (err) { console.error('Upload asset error:', err); res.status(500).json({ error: 'Failed to upload asset' }); } }); // Bulk delete assets app.post('/api/assets/bulk-delete', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => { try { const { ids } = req.body; if (!Array.isArray(ids) || ids.length === 0) return res.status(400).json({ error: 'ids array required' }); for (const id of ids) { const asset = await nocodb.get('Assets', id); if (asset && asset.filename) { const refs = await nocodb.list('PostAttachments', { where: `(filename,eq,${asset.filename})`, limit: 1 }); if (refs.length === 0) { const filePath = path.join(uploadsDir, asset.filename); if (fs.existsSync(filePath)) fs.unlinkSync(filePath); } } } await nocodb.bulkDelete('Assets', ids.map(id => ({ Id: id }))); res.json({ deleted: ids.length }); } catch (err) { console.error('Bulk delete assets error:', err); res.status(500).json({ error: 'Failed to bulk delete assets' }); } }); app.delete('/api/assets/:id', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => { try { const asset = await nocodb.get('Assets', req.params.id); if (!asset) return res.status(404).json({ error: 'Asset not found' }); const refs = await nocodb.list('PostAttachments', { where: `(filename,eq,${asset.filename})`, limit: 1, }); if (refs.length === 0) { const filePath = path.join(uploadsDir, asset.filename); if (fs.existsSync(filePath)) fs.unlinkSync(filePath); } await nocodb.delete('Assets', req.params.id); res.json({ success: true }); } catch (err) { res.status(500).json({ error: 'Failed to delete asset' }); } }); // ─── CAMPAIGNS ────────────────────────────────────────────────── app.get('/api/campaigns', requireAuth, async (req, res) => { try { const { status } = req.query; const whereParts = []; if (status) whereParts.push(`(status,eq,${sanitizeWhereValue(status)})`); const where = whereParts.length > 0 ? whereParts.join('~and') : undefined; let campaigns = await nocodb.list('Campaigns', { where, sort: '-start_date', limit: QUERY_LIMITS.medium }); // Filter by brand if (req.query.brand_id) { campaigns = campaigns.filter(c => c.brand_id === Number(req.query.brand_id)); } // Team-based visibility scoping const userId = req.session.userId; if (req.session.userRole === 'manager') { const myTeamIds = await getUserTeamIds(userId); const myCampaignIds = await getUserCampaignIds(userId); campaigns = campaigns.filter(c => c.created_by_user_id === userId || myCampaignIds.has(c.Id) || (c.team_id && myTeamIds.has(c.team_id)) || !c.team_id ); } else if (req.session.userRole === 'contributor') { const myCampaignIds = await getUserCampaignIds(userId); campaigns = campaigns.filter(c => c.created_by_user_id === userId || myCampaignIds.has(c.Id) ); } // Enrich with names const brandIds = new Set(), userIds = new Set(), teamIds = new Set(); for (const c of campaigns) { if (c.brand_id) brandIds.add(c.brand_id); if (c.created_by_user_id) userIds.add(c.created_by_user_id); if (c.team_id) teamIds.add(c.team_id); } const names = await batchResolveNames({ brand: { table: 'Brands', ids: [...brandIds] }, user: { table: 'Users', ids: [...userIds] }, team: { table: 'Teams', ids: [...teamIds] }, }); res.json(campaigns.map(c => ({ ...c, brand_name: names[`brand:${c.brand_id}`] || null, creator_user_name: names[`user:${c.created_by_user_id}`] || null, team_name: names[`team:${c.team_id}`] || null, }))); } catch (err) { console.error('GET /campaigns error:', err); res.status(500).json({ error: 'Failed to load campaigns' }); } }); app.get('/api/campaigns/:id', requireAuth, async (req, res) => { try { const campaign = await nocodb.get('Campaigns', req.params.id); if (!campaign) return res.status(404).json({ error: 'Campaign not found' }); // Access check if (req.session.userRole !== 'superadmin') { const userId = req.session.userId; if (campaign.created_by_user_id !== userId) { const assignments = await nocodb.list('CampaignAssignments', { limit: QUERY_LIMITS.max }); const hasAccess = assignments.some(a => a.campaign_id === campaign.Id && a.member_id === userId); if (!hasAccess) return res.status(403).json({ error: 'You do not have access to this campaign' }); } } const brandName = await getRecordName('Brands', campaign.brand_id); const teamName = await getRecordName('Teams', campaign.team_id); res.json({ ...campaign, brand_name: brandName, team_name: teamName }); } catch (err) { res.status(500).json({ error: 'Failed to load campaign' }); } }); app.post('/api/campaigns', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => { const { name, description, brand_id, start_date, end_date, status, color, budget, goals, platforms, team_id } = req.body; if (!name) return res.status(400).json({ error: 'Name is required' }); if (!start_date || !end_date) return res.status(400).json({ error: 'Start and end dates are required' }); const effectiveBudget = req.session.userRole === 'superadmin' ? (budget || null) : null; try { const created = await nocodb.create('Campaigns', { name, description: description || null, start_date, end_date, status: status || 'planning', color: color || null, budget: effectiveBudget, goals: goals || null, platforms: JSON.stringify(platforms || []), budget_spent: 0, revenue: 0, impressions: 0, clicks: 0, conversions: 0, cost_per_click: 0, notes: '', brand_id: brand_id ? Number(brand_id) : null, team_id: team_id ? Number(team_id) : null, created_by_user_id: req.session.userId, }); // Auto-assign creator await nocodb.create('CampaignAssignments', { assigned_at: new Date().toISOString(), campaign_id: created.Id, member_id: req.session.userId, assigner_id: req.session.userId, }); const campaign = await nocodb.get('Campaigns', created.Id); res.status(201).json({ ...campaign, brand_name: await getRecordName('Brands', campaign.brand_id), team_name: await getRecordName('Teams', campaign.team_id), }); notify.notifyCampaignCreated({ campaign, creatorUserId: req.session.userId }); } catch (err) { console.error('Create campaign error:', err); res.status(500).json({ error: 'Failed to create campaign' }); } }); app.patch('/api/campaigns/:id', requireAuth, requireOwnerOrRole('campaigns', 'superadmin', 'manager'), async (req, res) => { try { const existing = await nocodb.get('Campaigns', req.params.id); if (!existing) return res.status(404).json({ error: 'Campaign not found' }); const body = { ...req.body }; if (req.session.userRole !== 'superadmin') delete body.budget; const data = {}; for (const f of ['name', 'description', 'start_date', 'end_date', 'status', 'color', 'budget', 'goals', 'budget_spent', 'revenue', 'impressions', 'clicks', 'conversions', 'cost_per_click', 'notes']) { if (body[f] !== undefined) data[f] = body[f]; } if (body.platforms !== undefined) data.platforms = JSON.stringify(body.platforms); if (body.brand_id !== undefined) data.brand_id = body.brand_id ? Number(body.brand_id) : null; if (body.team_id !== undefined) data.team_id = body.team_id ? Number(body.team_id) : null; if (Object.keys(data).length === 0) return res.status(400).json({ error: 'No fields to update' }); await nocodb.update('Campaigns', req.params.id, data); const campaign = await nocodb.get('Campaigns', req.params.id); res.json({ ...campaign, brand_name: await getRecordName('Brands', campaign.brand_id), team_name: await getRecordName('Teams', campaign.team_id), }); } catch (err) { console.error('Update campaign error:', err); res.status(500).json({ error: 'Failed to update campaign' }); } }); app.delete('/api/campaigns/:id', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => { const id = Number(req.params.id); try { // Delete related posts const posts = await nocodb.list('Posts', { where: `(campaign_id,eq,${id})`, limit: QUERY_LIMITS.max }); for (const p of posts) await nocodb.delete('Posts', p.Id); // Delete campaign tracks const tracks = await nocodb.list('CampaignTracks', { where: `(campaign_id,eq,${id})`, limit: QUERY_LIMITS.max }); for (const t of tracks) await nocodb.delete('CampaignTracks', t.Id); // Delete campaign assignments const assignments = await nocodb.list('CampaignAssignments', { where: `(campaign_id,eq,${id})`, limit: QUERY_LIMITS.max }); for (const a of assignments) await nocodb.delete('CampaignAssignments', a.Id); // Unlink assets (clear campaign_id) const assets = await nocodb.list('Assets', { where: `(campaign_id,eq,${id})`, limit: QUERY_LIMITS.max }); for (const a of assets) await nocodb.update('Assets', a.Id, { campaign_id: null }); await nocodb.delete('Campaigns', id); res.json({ success: true }); } catch (err) { console.error('Delete campaign error:', err); res.status(500).json({ error: 'Failed to delete campaign' }); } }); // ─── CAMPAIGN ASSIGNMENTS ─────────────────────────────────────── app.get('/api/campaigns/:id/assignments', requireAuth, async (req, res) => { try { const filtered = await nocodb.list('CampaignAssignments', { where: `(campaign_id,eq,${sanitizeWhereValue(req.params.id)})`, limit: QUERY_LIMITS.max, }); // Enrich with user data (member_id is a plain Number field, not a link) const enriched = []; for (const a of filtered) { const memberId = a.member_id; const assignerId = a.assigner_id; let memberData = {}, assignerName = null; if (memberId) { try { const u = await nocodb.get('Users', memberId); memberData = { user_name: u.name, user_email: u.email, user_avatar: u.avatar, user_role: u.role, user_id: u.Id }; } catch (err) { console.error('Resolve assignment member:', err.message); } } if (assignerId) { try { const u = await nocodb.get('Users', assignerId); assignerName = u.name; } catch (err) { console.error('Resolve assignment assigner:', err.message); } } enriched.push({ ...a, ...memberData, assigned_by_name: assignerName }); } res.json(enriched); } catch (err) { res.status(500).json({ error: 'Failed to load assignments' }); } }); app.post('/api/campaigns/:id/assignments', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => { const { user_ids } = req.body; if (!Array.isArray(user_ids) || user_ids.length === 0) return res.status(400).json({ error: 'user_ids array is required' }); try { const campaign = await nocodb.get('Campaigns', req.params.id); if (!campaign) return res.status(404).json({ error: 'Campaign not found' }); if (req.session.userRole !== 'superadmin' && campaign.created_by_user_id !== req.session.userId) { return res.status(403).json({ error: 'Only the campaign creator or superadmin can assign members' }); } // Check existing assignments to avoid duplicates const existingAssignments = await nocodb.list('CampaignAssignments', { where: `(campaign_id,eq,${sanitizeWhereValue(req.params.id)})`, limit: QUERY_LIMITS.max, }); const existing = new Set(existingAssignments.map(a => a.member_id)); for (const userId of user_ids) { if (existing.has(userId)) continue; await nocodb.create('CampaignAssignments', { assigned_at: new Date().toISOString(), member_id: userId, assigner_id: req.session.userId, campaign_id: Number(req.params.id), }); } // Return updated list const filtered = await nocodb.list('CampaignAssignments', { where: `(campaign_id,eq,${sanitizeWhereValue(req.params.id)})`, limit: QUERY_LIMITS.max, }); const enriched = []; for (const a of filtered) { if (a.member_id) { try { const u = await nocodb.get('Users', a.member_id); enriched.push({ ...a, user_name: u.name, user_email: u.email, user_avatar: u.avatar, user_role: u.role, user_id: u.Id }); } catch (err) { console.error('Resolve assignment member:', err.message); enriched.push(a); } } else { enriched.push(a); } } res.json(enriched); } catch (err) { console.error('Create assignment error:', err); res.status(500).json({ error: 'Failed to create assignment' }); } }); app.delete('/api/campaigns/:id/assignments/:userId', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => { try { const campaign = await nocodb.get('Campaigns', req.params.id); if (!campaign) return res.status(404).json({ error: 'Campaign not found' }); if (req.session.userRole !== 'superadmin' && campaign.created_by_user_id !== req.session.userId) { return res.status(403).json({ error: 'Only the campaign creator or superadmin can remove members' }); } const assignments = await nocodb.list('CampaignAssignments', { where: `(campaign_id,eq,${sanitizeWhereValue(req.params.id)})~and(member_id,eq,${sanitizeWhereValue(req.params.userId)})`, limit: 1, }); if (assignments.length === 0) return res.status(404).json({ error: 'Assignment not found' }); await nocodb.delete('CampaignAssignments', assignments[0].Id); res.json({ success: true }); } catch (err) { res.status(500).json({ error: 'Failed to delete assignment' }); } }); // ─── BUDGET ENTRIES ───────────────────────────────────────────── app.get('/api/budget', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => { try { let entries = await nocodb.list('BudgetEntries', { sort: '-date_received', limit: QUERY_LIMITS.max }); if (req.session.userRole !== 'superadmin') { const userId = req.session.userId; const myCampaignIds = await getUserCampaignIds(userId); entries = entries.filter(e => !e.campaign_id || myCampaignIds.has(e.campaign_id)); } const campaignIds = new Set(); const projectIds = new Set(); for (const e of entries) { if (e.campaign_id) campaignIds.add(e.campaign_id); if (e.project_id) projectIds.add(e.project_id); } const names = await batchResolveNames({ campaign: { table: 'Campaigns', ids: [...campaignIds] }, project: { table: 'Projects', ids: [...projectIds] }, }); res.json(entries.map(e => ({ ...e, campaign_name: names[`campaign:${e.campaign_id}`] || null, project_name: names[`project:${e.project_id}`] || null, }))); } catch (err) { res.status(500).json({ error: 'Failed to load budget entries' }); } }); app.post('/api/budget', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => { const { label, amount, source, destination, campaign_id, project_id, category, date_received, notes, type } = req.body; if (!label || !amount || !date_received) return res.status(400).json({ error: 'Label, amount, and date are required' }); try { const created = await nocodb.create('BudgetEntries', { label, amount, source: source || null, destination: destination || null, category: category || 'marketing', date_received, notes: notes || '', campaign_id: campaign_id ? Number(campaign_id) : null, project_id: project_id ? Number(project_id) : null, type: type || 'income', }); const entry = await nocodb.get('BudgetEntries', created.Id); res.status(201).json({ ...entry, campaign_name: await getRecordName('Campaigns', entry.campaign_id), project_name: await getRecordName('Projects', entry.project_id), }); } catch (err) { res.status(500).json({ error: 'Failed to create budget entry' }); } }); app.patch('/api/budget/:id', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => { try { const existing = await nocodb.get('BudgetEntries', req.params.id); if (!existing) return res.status(404).json({ error: 'Budget entry not found' }); const data = {}; for (const f of ['label', 'amount', 'source', 'destination', 'category', 'date_received', 'notes', 'type']) { if (req.body[f] !== undefined) data[f] = req.body[f]; } if (req.body.campaign_id !== undefined) data.campaign_id = req.body.campaign_id ? Number(req.body.campaign_id) : null; if (req.body.project_id !== undefined) data.project_id = req.body.project_id ? Number(req.body.project_id) : null; if (Object.keys(data).length === 0) return res.status(400).json({ error: 'No fields to update' }); await nocodb.update('BudgetEntries', req.params.id, data); const entry = await nocodb.get('BudgetEntries', req.params.id); res.json({ ...entry, campaign_name: await getRecordName('Campaigns', entry.campaign_id), project_name: await getRecordName('Projects', entry.project_id), }); } catch (err) { res.status(500).json({ error: 'Failed to update budget entry' }); } }); app.delete('/api/budget/:id', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => { try { const existing = await nocodb.get('BudgetEntries', req.params.id); if (!existing) return res.status(404).json({ error: 'Entry not found' }); await nocodb.delete('BudgetEntries', req.params.id); res.json({ success: true }); } catch (err) { res.status(500).json({ error: 'Failed to delete budget entry' }); } }); // Finance summary app.get('/api/finance/summary', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => { try { const isSuperadmin = req.session.userRole === 'superadmin'; const userId = req.session.userId; let campaigns = await nocodb.list('Campaigns', { limit: QUERY_LIMITS.max }); let budgetEntries = await nocodb.list('BudgetEntries', { limit: QUERY_LIMITS.max }); if (!isSuperadmin) { const myCampaignIds = await getUserCampaignIds(userId); campaigns = campaigns.filter(c => myCampaignIds.has(c.Id)); budgetEntries = budgetEntries.filter(e => !e.campaign_id || myCampaignIds.has(e.campaign_id)); } const incomeEntries = budgetEntries.filter(e => (e.type || 'income') === 'income'); const expenseEntries = budgetEntries.filter(e => e.type === 'expense'); const totalIncome = isSuperadmin ? incomeEntries.reduce((sum, e) => sum + (e.amount || 0), 0) : campaigns.reduce((sum, c) => sum + (c.budget || 0), 0); const totalExpenses = expenseEntries.reduce((sum, e) => sum + (e.amount || 0), 0); const totalReceived = totalIncome; const allTracks = await nocodb.list('CampaignTracks', { limit: QUERY_LIMITS.max }); const campaignStats = campaigns.map(c => { const cTracks = allTracks.filter(t => t.campaign_id === c.Id); const cEntries = incomeEntries.filter(e => e.campaign_id && Number(e.campaign_id) === c.Id); const cExpenses = expenseEntries.filter(e => e.campaign_id && Number(e.campaign_id) === c.Id); return { id: c.Id, name: c.name, budget: c.budget, status: c.status, budget_from_entries: cEntries.reduce((s, e) => s + (e.amount || 0), 0), expenses: cExpenses.reduce((s, e) => s + (e.amount || 0), 0), tracks_allocated: cTracks.reduce((s, t) => s + (t.budget_allocated || 0), 0), tracks_spent: cTracks.reduce((s, t) => s + (t.budget_spent || 0), 0), tracks_revenue: cTracks.reduce((s, t) => s + (t.revenue || 0), 0), tracks_impressions: cTracks.reduce((s, t) => s + (t.impressions || 0), 0), tracks_clicks: cTracks.reduce((s, t) => s + (t.clicks || 0), 0), tracks_conversions: cTracks.reduce((s, t) => s + (t.conversions || 0), 0), }; }); const totals = campaignStats.reduce((acc, c) => ({ allocated: acc.allocated + c.tracks_allocated, spent: acc.spent + c.tracks_spent, revenue: acc.revenue + c.tracks_revenue, impressions: acc.impressions + c.tracks_impressions, clicks: acc.clicks + c.tracks_clicks, conversions: acc.conversions + c.tracks_conversions, }), { allocated: 0, spent: 0, revenue: 0, impressions: 0, clicks: 0, conversions: 0 }); const totalCampaignBudget = campaignStats.reduce((s, c) => s + (c.budget || 0), 0); // Project budget breakdown let projects = await nocodb.list('Projects', { limit: QUERY_LIMITS.max }); if (!isSuperadmin) { projects = projects.filter(p => p.owner_id === userId || p.created_by_user_id === userId); } const projectStats = projects.map(p => { const pEntries = incomeEntries.filter(e => e.project_id && Number(e.project_id) === p.Id); const pExpenses = expenseEntries.filter(e => e.project_id && Number(e.project_id) === p.Id); return { id: p.Id, name: p.name, status: p.status, budget_allocated: pEntries.reduce((s, e) => s + (e.amount || 0), 0), expenses: pExpenses.reduce((s, e) => s + (e.amount || 0), 0), }; }); const totalProjectBudget = projectStats.reduce((s, p) => s + p.budget_allocated, 0); const unallocated = totalReceived - totalCampaignBudget - totalProjectBudget; res.json({ totalReceived, ...totals, totalExpenses, remaining: totalReceived - totalCampaignBudget - totalProjectBudget - totals.spent - totalExpenses, roi: totals.spent > 0 ? ((totals.revenue - totals.spent) / totals.spent * 100) : 0, campaigns: campaignStats, projects: projectStats, totalCampaignBudget, totalProjectBudget, unallocated, }); } catch (err) { console.error('Finance summary error:', err); res.status(500).json({ error: 'Failed to load finance summary' }); } }); // ─── CAMPAIGN TRACKS ──────────────────────────────────────────── app.get('/api/campaigns/:id/tracks', requireAuth, async (req, res) => { try { const tracks = await nocodb.list('CampaignTracks', { where: `(campaign_id,eq,${sanitizeWhereValue(req.params.id)})`, sort: 'CreatedAt', limit: QUERY_LIMITS.large, }); res.json(tracks); } catch (err) { res.status(500).json({ error: 'Failed to load tracks' }); } }); app.post('/api/campaigns/:id/tracks', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => { try { const campaign = await nocodb.get('Campaigns', req.params.id); if (!campaign) return res.status(404).json({ error: 'Campaign not found' }); const { name, type, platform, budget_allocated, status, notes } = req.body; const created = await nocodb.create('CampaignTracks', { name: name || null, type: type || 'organic_social', platform: platform || null, budget_allocated: budget_allocated || 0, status: status || 'planned', notes: notes || '', budget_spent: 0, revenue: 0, impressions: 0, clicks: 0, conversions: 0, campaign_id: Number(req.params.id), }); const track = await nocodb.get('CampaignTracks', created.Id); res.status(201).json(track); } catch (err) { res.status(500).json({ error: 'Failed to create track' }); } }); app.patch('/api/tracks/:id', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => { try { const existing = await nocodb.get('CampaignTracks', req.params.id); if (!existing) return res.status(404).json({ error: 'Track not found' }); const data = {}; for (const f of ['name', 'type', 'platform', 'budget_allocated', 'budget_spent', 'revenue', 'impressions', 'clicks', 'conversions', 'notes', 'status']) { if (req.body[f] !== undefined) data[f] = req.body[f]; } if (Object.keys(data).length === 0) return res.status(400).json({ error: 'No fields to update' }); await nocodb.update('CampaignTracks', req.params.id, data); const track = await nocodb.get('CampaignTracks', req.params.id); res.json(track); } catch (err) { res.status(500).json({ error: 'Failed to update track' }); } }); app.delete('/api/tracks/:id', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => { try { const existing = await nocodb.get('CampaignTracks', req.params.id); if (!existing) return res.status(404).json({ error: 'Track not found' }); await nocodb.delete('CampaignTracks', req.params.id); res.json({ success: true }); } catch (err) { res.status(500).json({ error: 'Failed to delete track' }); } }); // Campaign posts app.get('/api/campaigns/:id/posts', requireAuth, async (req, res) => { try { const filtered = await nocodb.list('Posts', { where: `(campaign_id,eq,${sanitizeWhereValue(req.params.id)})`, sort: '-CreatedAt', limit: QUERY_LIMITS.max, }); const allAttachments = await nocodb.list('PostAttachments', { where: "(mime_type,like,image/%)", limit: QUERY_LIMITS.max, }); const thumbMap = {}; for (const att of allAttachments) { if (att.post_id && !thumbMap[att.post_id]) thumbMap[att.post_id] = att.url; } // Collect unique IDs for name lookups const brandIds = new Set(), userIds = new Set(), trackIds = new Set(); for (const p of filtered) { if (p.brand_id) brandIds.add(p.brand_id); if (p.assigned_to_id) userIds.add(p.assigned_to_id); if (p.created_by_user_id) userIds.add(p.created_by_user_id); if (p.track_id) trackIds.add(p.track_id); } const names = await batchResolveNames({ brand: { table: 'Brands', ids: [...brandIds] }, user: { table: 'Users', ids: [...userIds] }, track: { table: 'CampaignTracks', ids: [...trackIds] }, }); const campaignName = await getRecordName('Campaigns', Number(req.params.id)); res.json(filtered.map(p => ({ ...p, assigned_to: p.assigned_to_id, campaign_name: campaignName, brand_name: names[`brand:${p.brand_id}`] || null, assigned_name: names[`user:${p.assigned_to_id}`] || null, creator_user_name: names[`user:${p.created_by_user_id}`] || null, track_name: names[`track:${p.track_id}`] || null, thumbnail_url: thumbMap[p.Id] || null, }))); } catch (err) { res.status(500).json({ error: 'Failed to load campaign posts' }); } }); // ─── PROJECTS ─────────────────────────────────────────────────── app.get('/api/projects', requireAuth, async (req, res) => { try { const { status } = req.query; const whereParts = []; if (status) whereParts.push(`(status,eq,${sanitizeWhereValue(status)})`); if (req.query.brand_id) whereParts.push(`(brand_id,eq,${sanitizeWhereValue(req.query.brand_id)})`); if (req.query.owner_id) whereParts.push(`(owner_id,eq,${sanitizeWhereValue(req.query.owner_id)})`); const where = whereParts.length > 0 ? whereParts.join('~and') : undefined; let projects = await nocodb.list('Projects', { where, sort: '-CreatedAt', limit: QUERY_LIMITS.medium }); // Team-based visibility filtering const userId = req.session.userId; if (req.session.userRole === 'manager') { const myTeamIds = await getUserTeamIds(userId); projects = projects.filter(p => p.created_by_user_id === userId || p.owner_id === userId || (p.team_id && myTeamIds.has(p.team_id)) || !p.team_id ); } else if (req.session.userRole === 'contributor') { projects = projects.filter(p => p.created_by_user_id === userId || p.owner_id === userId ); } const brandIds = new Set(), userIds = new Set(), teamIds = new Set(); for (const p of projects) { if (p.brand_id) brandIds.add(p.brand_id); if (p.owner_id) userIds.add(p.owner_id); if (p.created_by_user_id) userIds.add(p.created_by_user_id); if (p.team_id) teamIds.add(p.team_id); } const names = await batchResolveNames({ brand: { table: 'Brands', ids: [...brandIds] }, user: { table: 'Users', ids: [...userIds] }, team: { table: 'Teams', ids: [...teamIds] }, }); res.json(projects.map(p => ({ ...p, brand_name: names[`brand:${p.brand_id}`] || null, owner_name: names[`user:${p.owner_id}`] || null, creator_user_name: names[`user:${p.created_by_user_id}`] || null, team_name: names[`team:${p.team_id}`] || null, thumbnail_url: p.thumbnail ? `/api/uploads/${p.thumbnail}` : null, }))); } catch (err) { res.status(500).json({ error: 'Failed to load projects' }); } }); app.get('/api/projects/:id', requireAuth, async (req, res) => { try { const project = await nocodb.get('Projects', req.params.id); if (!project) return res.status(404).json({ error: 'Project not found' }); res.json({ ...project, brand_name: await getRecordName('Brands', project.brand_id), owner_name: await getRecordName('Users', project.owner_id), creator_user_name: await getRecordName('Users', project.created_by_user_id), team_name: await getRecordName('Teams', project.team_id), thumbnail_url: project.thumbnail ? `/api/uploads/${project.thumbnail}` : null, }); } catch (err) { res.status(500).json({ error: 'Failed to load project' }); } }); app.post('/api/projects', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => { const { name, description, brand_id, owner_id, status, priority, start_date, due_date, team_id } = req.body; if (!name) return res.status(400).json({ error: 'Name is required' }); try { const created = await nocodb.create('Projects', { name, description: description || null, status: status || 'active', priority: priority || 'medium', start_date: start_date || null, due_date: due_date || null, brand_id: brand_id ? Number(brand_id) : null, owner_id: owner_id ? Number(owner_id) : null, team_id: team_id ? Number(team_id) : null, created_by_user_id: req.session.userId, }); const project = await nocodb.get('Projects', created.Id); res.status(201).json({ ...project, brand_name: await getRecordName('Brands', project.brand_id), owner_name: await getRecordName('Users', project.owner_id), team_name: await getRecordName('Teams', project.team_id), }); } catch (err) { console.error('Create project error:', err); res.status(500).json({ error: 'Failed to create project' }); } }); app.patch('/api/projects/:id', requireAuth, requireOwnerOrRole('projects', 'superadmin', 'manager'), async (req, res) => { const projectId = Number(req.params.id); try { const existing = await nocodb.get('Projects', projectId); if (!existing) return res.status(404).json({ error: 'Project not found' }); const data = {}; for (const f of ['name', 'description', 'status', 'priority', 'start_date', 'due_date', 'color']) { if (req.body[f] !== undefined) data[f] = req.body[f]; } if (req.body.brand_id !== undefined) data.brand_id = req.body.brand_id ? Number(req.body.brand_id) : null; if (req.body.owner_id !== undefined) data.owner_id = req.body.owner_id ? Number(req.body.owner_id) : null; if (req.body.team_id !== undefined) data.team_id = req.body.team_id ? Number(req.body.team_id) : null; if (Object.keys(data).length === 0) { return res.status(400).json({ error: 'No fields to update' }); } await nocodb.update('Projects', projectId, data); const project = await nocodb.get('Projects', projectId); res.json({ ...project, brand_name: await getRecordName('Brands', project.brand_id), owner_name: await getRecordName('Users', project.owner_id), team_name: await getRecordName('Teams', project.team_id), }); } catch (err) { console.error('Update project error:', err); res.status(500).json({ error: 'Failed to update project' }); } }); app.delete('/api/projects/:id', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => { try { const existing = await nocodb.get('Projects', req.params.id); if (!existing) return res.status(404).json({ error: 'Project not found' }); await nocodb.delete('Projects', req.params.id); res.json({ success: true }); } catch (err) { res.status(500).json({ error: 'Failed to delete project' }); } }); app.post('/api/projects/:id/thumbnail', requireAuth, requireRole('superadmin', 'manager'), dynamicUpload('file'), async (req, res) => { try { const existing = await nocodb.get('Projects', req.params.id); if (!existing) return res.status(404).json({ error: 'Project not found' }); if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); await nocodb.update('Projects', req.params.id, { thumbnail: req.file.filename }); const project = await nocodb.get('Projects', req.params.id); res.json(project); } catch (err) { res.status(500).json({ error: 'Failed to upload thumbnail' }); } }); app.delete('/api/projects/:id/thumbnail', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => { try { const existing = await nocodb.get('Projects', req.params.id); if (!existing) return res.status(404).json({ error: 'Project not found' }); await nocodb.update('Projects', req.params.id, { thumbnail: null }); res.json({ success: true }); } catch (err) { res.status(500).json({ error: 'Failed to remove thumbnail' }); } }); // ─── TASKS ────────────────────────────────────────────────────── app.get('/api/tasks', requireAuth, async (req, res) => { try { const { status, is_personal } = req.query; const whereParts = []; if (status) whereParts.push(`(status,eq,${sanitizeWhereValue(status)})`); if (is_personal !== undefined) { whereParts.push(`(is_personal,eq,${is_personal === 'true' || is_personal === '1' ? 'true' : 'false'})`); } if (req.query.project_id) whereParts.push(`(project_id,eq,${sanitizeWhereValue(req.query.project_id)})`); if (req.query.assigned_to) whereParts.push(`(assigned_to_id,eq,${sanitizeWhereValue(req.query.assigned_to)})`); if (req.query.priority) whereParts.push(`(priority,eq,${sanitizeWhereValue(req.query.priority)})`); if (req.query.created_by) whereParts.push(`(created_by_user_id,eq,${sanitizeWhereValue(req.query.created_by)})`); const where = whereParts.length > 0 ? whereParts.join('~and') : undefined; let tasks = await nocodb.list('Tasks', { where, sort: '-CreatedAt', limit: QUERY_LIMITS.max }); // Team-based visibility filtering const userId = req.session.userId; if (req.session.userRole === 'manager') { const { teamProjectIds } = await getUserVisibilityContext(userId); tasks = tasks.filter(t => t.created_by_user_id === userId || t.assigned_to_id === userId || (t.project_id && teamProjectIds.has(t.project_id)) || !t.project_id ); } else if (req.session.userRole === 'contributor') { tasks = tasks.filter(t => t.created_by_user_id === userId || t.assigned_to_id === userId ); } // Post-fetch date range filters if (req.query.due_date_from) { const from = new Date(req.query.due_date_from); tasks = tasks.filter(t => t.due_date && new Date(t.due_date) >= from); } if (req.query.due_date_to) { const to = new Date(req.query.due_date_to); to.setHours(23, 59, 59, 999); tasks = tasks.filter(t => t.due_date && new Date(t.due_date) <= to); } const projectIds = new Set(), userIds = new Set(); for (const t of tasks) { if (t.project_id) projectIds.add(t.project_id); if (t.assigned_to_id) userIds.add(t.assigned_to_id); if (t.created_by_user_id) userIds.add(t.created_by_user_id); } const names = await batchResolveNames({ project: { table: 'Projects', ids: [...projectIds] }, user: { table: 'Users', ids: [...userIds] }, }); // Resolve brand info from projects const projectData = {}; for (const pid of projectIds) { try { const proj = await nocodb.get('Projects', pid); if (proj) { projectData[pid] = { brand_id: proj.brand_id, brand_name: proj.brand_id ? await getRecordName('Brands', proj.brand_id) : null, }; } } catch (err) { /* project may have been deleted — skip silently */ } } // Post-fetch brand filter (brand lives on the project) if (req.query.brand_id) { const brandId = Number(req.query.brand_id); tasks = tasks.filter(t => t.project_id && projectData[t.project_id]?.brand_id === brandId); } // Batch comment counts for all tasks const commentCounts = {}; try { const allComments = await nocodb.list('Comments', { where: '(entity_type,eq,task)', fields: ['entity_id'], limit: QUERY_LIMITS.max, }); for (const c of allComments) { commentCounts[c.entity_id] = (commentCounts[c.entity_id] || 0) + 1; } } catch (err) { console.error('Load task comment counts:', err.message); } res.json(tasks.map(t => ({ ...t, assigned_to: t.assigned_to_id, project_name: names[`project:${t.project_id}`] || null, assigned_name: names[`user:${t.assigned_to_id}`] || null, creator_user_name: names[`user:${t.created_by_user_id}`] || null, brand_id: t.project_id && projectData[t.project_id] ? projectData[t.project_id].brand_id : null, brand_name: t.project_id && projectData[t.project_id] ? projectData[t.project_id].brand_name : null, comment_count: commentCounts[t.Id || t.id] || 0, thumbnail_url: t.thumbnail || null, }))); } catch (err) { console.error('GET /tasks error:', err); res.status(500).json({ error: 'Failed to load tasks' }); } }); app.get('/api/tasks/my/:memberId', requireAuth, async (req, res) => { try { const tasks = await nocodb.list('Tasks', { where: `(is_personal,eq,true)~and(assigned_to_id,eq,${sanitizeWhereValue(req.params.memberId)})`, limit: QUERY_LIMITS.max, }); const projectIds = new Set(), userIds = new Set(); for (const t of tasks) { if (t.project_id) projectIds.add(t.project_id); if (t.created_by_user_id) userIds.add(t.created_by_user_id); } const names = await batchResolveNames({ project: { table: 'Projects', ids: [...projectIds] }, user: { table: 'Users', ids: [...userIds] }, }); res.json(tasks.map(t => ({ ...t, assigned_to: t.assigned_to_id, project_name: names[`project:${t.project_id}`] || null, creator_user_name: names[`user:${t.created_by_user_id}`] || null, }))); } catch (err) { res.status(500).json({ error: 'Failed to load tasks' }); } }); app.post('/api/tasks', requireAuth, async (req, res) => { const { title, description, project_id, assigned_to, status, priority, due_date, start_date, is_personal } = req.body; if (!title) return res.status(400).json({ error: 'Title is required' }); try { const created = await nocodb.create('Tasks', { title, description: description || null, status: status || 'todo', priority: priority || 'medium', start_date: start_date || null, due_date: due_date || null, is_personal: !!is_personal, project_id: project_id ? Number(project_id) : null, assigned_to_id: assigned_to ? Number(assigned_to) : null, created_by_user_id: req.session.userId, }); const task = await nocodb.get('Tasks', created.Id); res.status(201).json({ ...task, assigned_to: task.assigned_to_id, project_name: await getRecordName('Projects', task.project_id), assigned_name: await getRecordName('Users', task.assigned_to_id), creator_user_name: await getRecordName('Users', task.created_by_user_id), }); if (task.assigned_to_id) notify.notifyTaskAssigned({ task, assignerName: req.session.userName }); } catch (err) { console.error('Create task error:', err); res.status(500).json({ error: 'Failed to create task' }); } }); // Bulk delete tasks app.post('/api/tasks/bulk-delete', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => { try { const { ids } = req.body; if (!Array.isArray(ids) || ids.length === 0) return res.status(400).json({ error: 'ids array required' }); for (const id of ids) { const atts = await nocodb.list('TaskAttachments', { where: `(task_id,eq,${sanitizeWhereValue(id)})`, limit: QUERY_LIMITS.large }); if (atts.length > 0) await nocodb.bulkDelete('TaskAttachments', atts.map(a => ({ Id: a.Id }))); } await nocodb.bulkDelete('Tasks', ids.map(id => ({ Id: id }))); res.json({ deleted: ids.length }); } catch (err) { console.error('Bulk delete tasks error:', err); res.status(500).json({ error: 'Failed to bulk delete tasks' }); } }); app.patch('/api/tasks/:id', requireAuth, requireOwnerOrRole('tasks', 'superadmin', 'manager'), async (req, res) => { try { const existing = await nocodb.get('Tasks', req.params.id); if (!existing) return res.status(404).json({ error: 'Task not found' }); const data = {}; for (const f of ['title', 'description', 'status', 'priority', 'start_date', 'due_date', 'color']) { if (req.body[f] !== undefined) data[f] = req.body[f]; } if (req.body.is_personal !== undefined) data.is_personal = !!req.body.is_personal; if (req.body.project_id !== undefined) data.project_id = req.body.project_id ? Number(req.body.project_id) : null; if (req.body.assigned_to !== undefined) data.assigned_to_id = req.body.assigned_to ? Number(req.body.assigned_to) : null; // Handle completed_at if (req.body.status === 'done' && existing.status !== 'done') { data.completed_at = new Date().toISOString(); } else if (req.body.status && req.body.status !== 'done' && existing.status === 'done') { data.completed_at = null; } if (Object.keys(data).length === 0) { return res.status(400).json({ error: 'No fields to update' }); } await nocodb.update('Tasks', req.params.id, data); const task = await nocodb.get('Tasks', req.params.id); res.json({ ...task, assigned_to: task.assigned_to_id, project_name: await getRecordName('Projects', task.project_id), assigned_name: await getRecordName('Users', task.assigned_to_id), creator_user_name: await getRecordName('Users', task.created_by_user_id), }); // Notify on assignment change if (data.assigned_to_id && data.assigned_to_id !== existing.assigned_to_id) { notify.notifyTaskAssigned({ task, assignerName: req.session.userName }); } // Notify on completion if (req.body.status === 'done' && existing.status !== 'done') { notify.notifyTaskCompleted({ task }); } } catch (err) { console.error('Update task error:', err); res.status(500).json({ error: 'Failed to update task' }); } }); app.delete('/api/tasks/:id', requireAuth, requireOwnerOrRole('tasks', 'superadmin', 'manager'), async (req, res) => { try { await nocodb.delete('Tasks', req.params.id); res.json({ success: true }); } catch (err) { res.status(500).json({ error: 'Failed to delete task' }); } }); // ─── TASK ATTACHMENTS ─────────────────────────────────────────── app.get('/api/tasks/:id/attachments', requireAuth, async (req, res) => { try { const attachments = await nocodb.list('TaskAttachments', { where: `(task_id,eq,${sanitizeWhereValue(req.params.id)})`, sort: '-CreatedAt', limit: QUERY_LIMITS.large, }); res.json(attachments); } catch (err) { res.status(500).json({ error: 'Failed to load attachments' }); } }); app.post('/api/tasks/:id/attachments', requireAuth, dynamicUpload('file'), async (req, res) => { if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); try { const task = await nocodb.get('Tasks', req.params.id); if (!task) return res.status(404).json({ error: 'Task not found' }); const url = `/api/uploads/${req.file.filename}`; const created = await nocodb.create('TaskAttachments', { filename: req.file.filename, original_name: req.file.originalname, mime_type: req.file.mimetype, size: req.file.size, url, task_id: Number(req.params.id), }); const attachment = await nocodb.get('TaskAttachments', created.Id); res.status(201).json(attachment); } catch (err) { console.error('Upload task attachment error:', err); res.status(500).json({ error: 'Failed to upload attachment' }); } }); app.delete('/api/task-attachments/:id', requireAuth, async (req, res) => { try { const attachment = await nocodb.get('TaskAttachments', req.params.id); if (!attachment) return res.status(404).json({ error: 'Attachment not found' }); // Check if file is referenced elsewhere before deleting from disk const otherTaskAtts = await nocodb.list('TaskAttachments', { where: `(filename,eq,${attachment.filename})`, limit: 10, }); const otherPostAtts = await nocodb.list('PostAttachments', { where: `(filename,eq,${attachment.filename})`, limit: 10, }); const assets = await nocodb.list('Assets', { where: `(filename,eq,${attachment.filename})`, limit: 10, }); if (otherTaskAtts.length <= 1 && otherPostAtts.length === 0 && assets.length === 0) { const filePath = path.join(uploadsDir, attachment.filename); if (fs.existsSync(filePath)) fs.unlinkSync(filePath); } await nocodb.delete('TaskAttachments', req.params.id); res.json({ success: true }); } catch (err) { console.error('Delete task attachment error:', err); res.status(500).json({ error: 'Failed to delete attachment' }); } }); // Set a task's thumbnail from one of its image attachments app.patch('/api/tasks/:id/thumbnail', requireAuth, async (req, res) => { try { const { attachment_id } = req.body; const task = await nocodb.get('Tasks', req.params.id); if (!task) return res.status(404).json({ error: 'Task not found' }); if (attachment_id) { const att = await nocodb.get('TaskAttachments', attachment_id); if (!att) return res.status(404).json({ error: 'Attachment not found' }); await nocodb.update('Tasks', req.params.id, { thumbnail: att.url || `/api/uploads/${att.filename}` }); } else { await nocodb.update('Tasks', req.params.id, { thumbnail: null }); } const updated = await nocodb.get('Tasks', req.params.id); res.json(updated); } catch (err) { res.status(500).json({ error: 'Failed to set thumbnail' }); } }); // ─── DASHBOARD ────────────────────────────────────────────────── app.get('/api/dashboard', requireAuth, async (req, res) => { try { const isSuperadmin = req.session.userRole === 'superadmin'; const userId = req.session.userId; // Fetch all data in parallel const [allPosts, allCampaigns, allTasks, allProjects, allUsers, allAssignments] = await Promise.all([ nocodb.list('Posts', { limit: QUERY_LIMITS.max }), nocodb.list('Campaigns', { limit: QUERY_LIMITS.max }), nocodb.list('Tasks', { limit: QUERY_LIMITS.max }), nocodb.list('Projects', { limit: QUERY_LIMITS.max }), nocodb.list('Users', { limit: QUERY_LIMITS.large }), nocodb.list('CampaignAssignments', { limit: QUERY_LIMITS.max }), ]); // Build team-based scoping context let myTeamIds = new Set(); let myCampaignIds = new Set(); if (!isSuperadmin) { myTeamIds = await getUserTeamIds(userId); for (const c of allCampaigns) { if (c.created_by_user_id === userId) myCampaignIds.add(c.Id); if (req.session.userRole === 'manager' && c.team_id && myTeamIds.has(c.team_id)) myCampaignIds.add(c.Id); } for (const a of allAssignments) { if (a.member_id === userId && a.campaign_id) { myCampaignIds.add(a.campaign_id); } } } // Build team project IDs for managers let myProjectIds = new Set(); if (!isSuperadmin) { for (const p of allProjects) { if (p.created_by_user_id === userId || p.owner_id === userId) myProjectIds.add(p.Id); if (req.session.userRole === 'manager' && p.team_id && myTeamIds.has(p.team_id)) myProjectIds.add(p.Id); } } // Posts let posts = allPosts; if (!isSuperadmin) { if (req.session.userRole === 'manager') { posts = allPosts.filter(p => p.created_by_user_id === userId || p.assigned_to_id === userId || (p.campaign_id && myCampaignIds.has(p.campaign_id)) || !p.campaign_id ); } else { posts = allPosts.filter(p => p.created_by_user_id === userId || p.assigned_to_id === userId); } } const postsByStatus = {}; for (const p of posts) { postsByStatus[p.status] = (postsByStatus[p.status] || 0) + 1; } // Campaigns let campaigns = allCampaigns; if (!isSuperadmin) { campaigns = allCampaigns.filter(c => myCampaignIds.has(c.Id) || c.created_by_user_id === userId); } const activeCampaigns = campaigns.filter(c => c.status === 'active').length; // Tasks let tasks = allTasks; if (!isSuperadmin) { if (req.session.userRole === 'manager') { tasks = allTasks.filter(t => t.created_by_user_id === userId || t.assigned_to_id === userId || (t.project_id && myProjectIds.has(t.project_id)) || !t.project_id ); } else { tasks = allTasks.filter(t => t.created_by_user_id === userId || t.assigned_to_id === userId ); } } const overdueTasks = tasks.filter(t => t.due_date && new Date(t.due_date) < new Date() && t.status !== 'done').length; const tasksByStatus = {}; for (const t of tasks) { tasksByStatus[t.status] = (tasksByStatus[t.status] || 0) + 1; } // Projects let projects = allProjects; if (!isSuperadmin) { projects = allProjects.filter(p => myProjectIds.has(p.Id) || p.created_by_user_id === userId || p.owner_id === userId); } const activeProjects = projects.filter(p => p.status === 'active').length; // Team workload (superadmin only) const teamWorkload = isSuperadmin ? allUsers.filter(u => u.team_role).map(u => { const userTasks = allTasks.filter(t => t.assigned_to_id === u.Id); const userPosts = allPosts.filter(p => p.assigned_to_id === u.Id); return { id: u.Id, name: u.name, role: u.team_role, active_tasks: userTasks.filter(t => t.status !== 'done').length, completed_tasks: userTasks.filter(t => t.status === 'done').length, active_posts: userPosts.filter(p => !['published', 'rejected'].includes(p.status)).length, }; }).sort((a, b) => b.active_tasks - a.active_tasks) : []; // Recent posts (last 5) const recentPostsRaw = posts.slice(0, 5); const recentPosts = []; for (const p of recentPostsRaw) { recentPosts.push({ ...p, assigned_to: p.assigned_to_id, brand_name: await getRecordName('Brands', p.brand_id), assigned_name: await getRecordName('Users', p.assigned_to_id), }); } // Upcoming campaigns const now = new Date().toISOString().split('T')[0]; const upcomingRaw = campaigns .filter(c => c.end_date >= now) .sort((a, b) => (a.start_date || '').localeCompare(b.start_date || '')) .slice(0, 5); const upcomingCampaigns = []; for (const c of upcomingRaw) { upcomingCampaigns.push({ ...c, brand_name: await getRecordName('Brands', c.brand_id) }); } res.json({ posts: { total: posts.length, byStatus: postsByStatus }, campaigns: { total: campaigns.length, active: activeCampaigns }, tasks: { overdue: overdueTasks, byStatus: tasksByStatus }, projects: { active: activeProjects }, teamWorkload, recentPosts, upcomingCampaigns, }); } catch (err) { console.error('Dashboard error:', err); res.status(500).json({ error: 'Failed to load dashboard' }); } }); // ─── COMMENTS / DISCUSSIONS ───────────────────────────────────── app.get('/api/comments/:entityType/:entityId', requireAuth, async (req, res) => { const { entityType, entityId } = req.params; if (!COMMENT_ENTITY_TYPES.has(entityType)) return res.status(400).json({ error: 'Invalid entity type' }); try { const comments = await nocodb.list('Comments', { where: `(entity_type,eq,${sanitizeWhereValue(entityType)})~and(entity_id,eq,${sanitizeWhereValue(entityId)})`, sort: 'CreatedAt', limit: QUERY_LIMITS.large, }); // Enrich with user data const enriched = []; for (const c of comments) { let userName = null, userAvatar = null; if (c.user_id) { try { const u = await nocodb.get('Users', c.user_id); userName = u.name; userAvatar = u.avatar; } catch (err) { console.error('Resolve comment user:', err.message); } } enriched.push({ ...c, user_name: userName, user_avatar: userAvatar }); } res.json(enriched); } catch (err) { res.status(500).json({ error: 'Failed to load comments' }); } }); app.post('/api/comments/:entityType/:entityId', requireAuth, async (req, res) => { const { entityType, entityId } = req.params; const { content } = req.body; if (!COMMENT_ENTITY_TYPES.has(entityType)) return res.status(400).json({ error: 'Invalid entity type' }); if (!content || !content.trim()) return res.status(400).json({ error: 'Content is required' }); try { const created = await nocodb.create('Comments', { entity_type: entityType, entity_id: Number(entityId), content: content.trim(), user_id: req.session.userId, }); const comment = await nocodb.get('Comments', created.Id); const user = await nocodb.get('Users', req.session.userId); res.status(201).json({ ...comment, user_id: req.session.userId, user_name: user.name, user_avatar: user.avatar, }); } catch (err) { res.status(500).json({ error: 'Failed to create comment' }); } }); app.patch('/api/comments/:id', requireAuth, async (req, res) => { try { const comment = await nocodb.get('Comments', req.params.id); if (!comment) return res.status(404).json({ error: 'Comment not found' }); if (comment.user_id !== req.session.userId) return res.status(403).json({ error: 'You can only edit your own comments' }); const { content } = req.body; if (!content || !content.trim()) return res.status(400).json({ error: 'Content is required' }); await nocodb.update('Comments', req.params.id, { content: content.trim() }); const updated = await nocodb.get('Comments', req.params.id); const user = await nocodb.get('Users', req.session.userId); res.json({ ...updated, user_id: req.session.userId, user_name: user.name, user_avatar: user.avatar }); } catch (err) { res.status(500).json({ error: 'Failed to update comment' }); } }); app.delete('/api/comments/:id', requireAuth, async (req, res) => { try { const comment = await nocodb.get('Comments', req.params.id); if (!comment) return res.status(404).json({ error: 'Comment not found' }); if (comment.user_id !== req.session.userId && req.session.userRole !== 'superadmin' && req.session.userRole !== 'manager') { return res.status(403).json({ error: 'You can only delete your own comments' }); } await nocodb.delete('Comments', req.params.id); res.json({ success: true }); } catch (err) { res.status(500).json({ error: 'Failed to delete comment' }); } }); // ─── SHARED HELPERS ───────────────────────────────────────────── // Get set of campaign IDs a user has access to async function getUserCampaignIds(userId) { const [campaigns, assignments] = await Promise.all([ nocodb.list('Campaigns', { limit: QUERY_LIMITS.max }), nocodb.list('CampaignAssignments', { limit: QUERY_LIMITS.max }), ]); const ids = new Set(); for (const c of campaigns) { if (c.created_by_user_id === userId) ids.add(c.Id); } for (const a of assignments) { if (a.member_id === userId && a.campaign_id) { ids.add(a.campaign_id); } } return ids; } // ─── ERROR HANDLING ───────────────────────────────────────────── // ─── TEAMS ────────────────────────────────────────────────────── app.get('/api/teams', requireAuth, async (req, res) => { try { const teams = await nocodb.list('Teams', { sort: 'name', limit: QUERY_LIMITS.medium }); const members = await nocodb.list('TeamMembers', { limit: QUERY_LIMITS.max }); const result = teams.map(t => { const teamMembers = members.filter(m => m.team_id === t.Id); return { ...t, id: t.Id, _id: t.Id, member_ids: teamMembers.map(m => m.user_id), member_count: teamMembers.length, }; }); res.json(result); } catch (err) { console.error('Teams list error:', err); res.status(500).json({ error: 'Failed to load teams' }); } }); app.post('/api/teams', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => { const { name, description, member_ids } = req.body; if (!name) return res.status(400).json({ error: 'Name is required' }); try { const created = await nocodb.create('Teams', { name, description: description || null }); if (member_ids && member_ids.length > 0) { await nocodb.bulkCreate('TeamMembers', member_ids.map(uid => ({ team_id: created.Id, user_id: uid }))); } const team = await nocodb.get('Teams', created.Id); res.status(201).json({ ...team, id: team.Id, _id: team.Id, member_ids: member_ids || [] }); } catch (err) { console.error('Create team error:', err); res.status(500).json({ error: 'Failed to create team' }); } }); app.patch('/api/teams/:id', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => { try { const existing = await nocodb.get('Teams', req.params.id); if (!existing) return res.status(404).json({ error: 'Team not found' }); const data = {}; if (req.body.name !== undefined) data.name = req.body.name; if (req.body.description !== undefined) data.description = req.body.description; if (Object.keys(data).length > 0) await nocodb.update('Teams', req.params.id, data); // Sync members if provided if (req.body.member_ids !== undefined) { const oldMembers = await nocodb.list('TeamMembers', { where: `(team_id,eq,${sanitizeWhereValue(req.params.id)})`, limit: QUERY_LIMITS.max }); if (oldMembers.length > 0) { await nocodb.bulkDelete('TeamMembers', oldMembers.map(m => ({ Id: m.Id }))); } if (req.body.member_ids.length > 0) { await nocodb.bulkCreate('TeamMembers', req.body.member_ids.map(uid => ({ team_id: Number(req.params.id), user_id: uid }))); } } const team = await nocodb.get('Teams', req.params.id); const members = await nocodb.list('TeamMembers', { where: `(team_id,eq,${sanitizeWhereValue(req.params.id)})`, limit: QUERY_LIMITS.max }); res.json({ ...team, id: team.Id, _id: team.Id, member_ids: members.map(m => m.user_id) }); } catch (err) { console.error('Update team error:', err); res.status(500).json({ error: 'Failed to update team' }); } }); app.delete('/api/teams/:id', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => { try { const existing = await nocodb.get('Teams', req.params.id); if (!existing) return res.status(404).json({ error: 'Team not found' }); const members = await nocodb.list('TeamMembers', { where: `(team_id,eq,${sanitizeWhereValue(req.params.id)})`, limit: QUERY_LIMITS.max }); if (members.length > 0) { await nocodb.bulkDelete('TeamMembers', members.map(m => ({ Id: m.Id }))); } await nocodb.delete('Teams', req.params.id); res.json({ success: true }); } catch (err) { console.error('Delete team error:', err); res.status(500).json({ error: 'Failed to delete team' }); } }); app.post('/api/teams/:id/members', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => { const { user_id } = req.body; if (!user_id) return res.status(400).json({ error: 'user_id is required' }); try { await nocodb.create('TeamMembers', { team_id: Number(req.params.id), user_id: Number(user_id) }); res.json({ success: true }); } catch (err) { res.status(500).json({ error: 'Failed to add member' }); } }); app.delete('/api/teams/:id/members/:userId', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => { try { const entries = await nocodb.list('TeamMembers', { where: `(team_id,eq,${sanitizeWhereValue(req.params.id)})~and(user_id,eq,${sanitizeWhereValue(req.params.userId)})`, limit: 10, }); if (entries.length > 0) { await nocodb.bulkDelete('TeamMembers', entries.map(e => ({ Id: e.Id }))); } res.json({ success: true }); } catch (err) { res.status(500).json({ error: 'Failed to remove member' }); } }); // ─── ARTEFACTS (Content Approval System) ─────────────────────── app.get('/api/artefacts', requireAuth, async (req, res) => { try { const { brand, status, post } = req.query; const whereParts = []; if (brand) whereParts.push(`(brand_id,eq,${sanitizeWhereValue(brand)})`); if (status) whereParts.push(`(status,eq,${sanitizeWhereValue(status)})`); if (post) whereParts.push(`(post_id,eq,${sanitizeWhereValue(post)})`); const where = whereParts.length > 0 ? whereParts.join('~and') : undefined; let artefacts = await nocodb.list('Artefacts', { where, sort: '-UpdatedAt', limit: QUERY_LIMITS.medium }); // Team-based visibility filtering const userId = req.session.userId; if (req.session.userRole === 'manager') { const { teamProjectIds, teamCampaignIds } = await getUserVisibilityContext(userId); artefacts = artefacts.filter(a => a.created_by_user_id === userId || (a.project_id && teamProjectIds.has(a.project_id)) || (a.campaign_id && teamCampaignIds.has(a.campaign_id)) || (!a.project_id && !a.campaign_id) ); } else if (req.session.userRole === 'contributor') { artefacts = artefacts.filter(a => a.created_by_user_id === userId); } // Enrich with names const brandIds = new Set(), userIds = new Set(), postIds = new Set(), projectIds = new Set(), campaignIds = new Set(); for (const a of artefacts) { if (a.brand_id) brandIds.add(a.brand_id); if (a.created_by_user_id) userIds.add(a.created_by_user_id); if (a.post_id) postIds.add(a.post_id); if (a.project_id) projectIds.add(a.project_id); if (a.campaign_id) campaignIds.add(a.campaign_id); // Collect approver user IDs if (a.approver_ids) { for (const id of a.approver_ids.split(',').map(s => s.trim()).filter(Boolean)) { userIds.add(Number(id)); } } } const names = await batchResolveNames({ brand: { table: 'Brands', ids: [...brandIds] }, user: { table: 'Users', ids: [...userIds] }, post: { table: 'Posts', ids: [...postIds] }, project: { table: 'Projects', ids: [...projectIds] }, campaign: { table: 'Campaigns', ids: [...campaignIds] }, }); res.json(artefacts.map(a => { const approverIdList = a.approver_ids ? a.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : []; return { ...a, brand_name: names[`brand:${a.brand_id}`] || null, creator_name: names[`user:${a.created_by_user_id}`] || null, post_title: names[`post:${a.post_id}`] || null, project_name: names[`project:${a.project_id}`] || null, campaign_name: names[`campaign:${a.campaign_id}`] || null, approvers: approverIdList.map(id => ({ id: Number(id), name: names[`user:${Number(id)}`] || null })), }; })); } catch (err) { console.error('GET /artefacts error:', err); res.status(500).json({ error: 'Failed to load artefacts' }); } }); app.post('/api/artefacts', requireAuth, async (req, res) => { const { title, description, type, brand_id, content, project_id, campaign_id, approver_ids } = req.body; if (!title) return res.status(400).json({ error: 'Title is required' }); try { const createData = { title, description: description || null, type: type || 'other', status: 'draft', content: content || null, brand_id: brand_id ? Number(brand_id) : null, project_id: project_id ? Number(project_id) : null, campaign_id: campaign_id ? Number(campaign_id) : null, approver_ids: approver_ids || null, created_by_user_id: req.session.userId, current_version: 1, }; console.log('[POST /artefacts] Creating with:', JSON.stringify({ approver_ids: createData.approver_ids, project_id: createData.project_id, campaign_id: createData.campaign_id })); const created = await nocodb.create('Artefacts', createData); console.log('[POST /artefacts] NocoDB returned:', JSON.stringify({ Id: created.Id, approver_ids: created.approver_ids })); // Auto-create version 1 await nocodb.create('ArtefactVersions', { artefact_id: created.Id, version_number: 1, created_by_user_id: req.session.userId, created_at: new Date().toISOString(), notes: 'Initial version', }); const artefact = await nocodb.get('Artefacts', created.Id); console.log('[POST /artefacts] After re-read:', JSON.stringify({ Id: artefact.Id, approver_ids: artefact.approver_ids })); const approverIdList = artefact.approver_ids ? artefact.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : []; const approvers = []; for (const id of approverIdList) { approvers.push({ id: Number(id), name: await getRecordName('Users', Number(id)) }); } res.status(201).json({ ...artefact, brand_name: await getRecordName('Brands', artefact.brand_id), creator_name: await getRecordName('Users', artefact.created_by_user_id), project_name: await getRecordName('Projects', artefact.project_id), campaign_name: await getRecordName('Campaigns', artefact.campaign_id), approvers, }); } catch (err) { console.error('Create artefact error:', err); res.status(500).json({ error: 'Failed to create artefact' }); } }); // Bulk delete artefacts app.post('/api/artefacts/bulk-delete', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => { try { const { ids } = req.body; if (!Array.isArray(ids) || ids.length === 0) return res.status(400).json({ error: 'ids array required' }); for (const id of ids) { // Delete attachments + files const atts = await nocodb.list('ArtefactAttachments', { where: `(artefact_id,eq,${sanitizeWhereValue(id)})`, limit: QUERY_LIMITS.large }); for (const att of atts) { if (att.filename) { const filePath = path.join(uploadsDir, att.filename); if (fs.existsSync(filePath)) fs.unlinkSync(filePath); } } if (atts.length > 0) await nocodb.bulkDelete('ArtefactAttachments', atts.map(a => ({ Id: a.Id }))); // Delete versions + version texts const versions = await nocodb.list('ArtefactVersions', { where: `(artefact_id,eq,${sanitizeWhereValue(id)})`, limit: QUERY_LIMITS.large }); for (const ver of versions) { const texts = await nocodb.list('ArtefactVersionTexts', { where: `(version_id,eq,${sanitizeWhereValue(ver.Id)})`, limit: QUERY_LIMITS.large }); if (texts.length > 0) await nocodb.bulkDelete('ArtefactVersionTexts', texts.map(t => ({ Id: t.Id }))); } if (versions.length > 0) await nocodb.bulkDelete('ArtefactVersions', versions.map(v => ({ Id: v.Id }))); } await nocodb.bulkDelete('Artefacts', ids.map(id => ({ Id: id }))); res.json({ deleted: ids.length }); } catch (err) { console.error('Bulk delete artefacts error:', err); res.status(500).json({ error: 'Failed to bulk delete artefacts' }); } }); app.patch('/api/artefacts/:id', requireAuth, async (req, res) => { try { const existing = await nocodb.get('Artefacts', req.params.id); if (!existing) return res.status(404).json({ error: 'Artefact not found' }); // Permission check: owner or manager+ if (req.session.userRole === 'contributor' && existing.created_by_user_id !== req.session.userId) { return res.status(403).json({ error: 'You can only modify your own artefacts' }); } const data = {}; for (const f of ['title', 'description', 'type', 'status', 'content', 'feedback']) { if (req.body[f] !== undefined) data[f] = req.body[f]; } if (req.body.brand_id !== undefined) data.brand_id = req.body.brand_id ? Number(req.body.brand_id) : null; if (req.body.post_id !== undefined) data.post_id = req.body.post_id ? Number(req.body.post_id) : null; if (req.body.project_id !== undefined) data.project_id = req.body.project_id ? Number(req.body.project_id) : null; if (req.body.campaign_id !== undefined) data.campaign_id = req.body.campaign_id ? Number(req.body.campaign_id) : null; if (req.body.approver_ids !== undefined) data.approver_ids = req.body.approver_ids || null; if (Object.keys(data).length === 0) return res.status(400).json({ error: 'No fields to update' }); console.log(`[PATCH /artefacts/${req.params.id}] Updating:`, JSON.stringify(data)); await nocodb.update('Artefacts', req.params.id, data); const artefact = await nocodb.get('Artefacts', req.params.id); console.log(`[PATCH /artefacts/${req.params.id}] After re-read: approver_ids=${artefact.approver_ids}`); const approverIdList = artefact.approver_ids ? artefact.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : []; const approvers = []; for (const id of approverIdList) { approvers.push({ id: Number(id), name: await getRecordName('Users', Number(id)) }); } res.json({ ...artefact, brand_name: await getRecordName('Brands', artefact.brand_id), creator_name: await getRecordName('Users', artefact.created_by_user_id), post_title: await getRecordName('Posts', artefact.post_id), project_name: await getRecordName('Projects', artefact.project_id), campaign_name: await getRecordName('Campaigns', artefact.campaign_id), approvers, }); } catch (err) { console.error('Update artefact error:', err); res.status(500).json({ error: 'Failed to update artefact' }); } }); app.delete('/api/artefacts/:id', requireAuth, async (req, res) => { try { const existing = await nocodb.get('Artefacts', req.params.id); if (!existing) return res.status(404).json({ error: 'Artefact not found' }); // Permission check: owner or manager+ if (req.session.userRole === 'contributor' && existing.created_by_user_id !== req.session.userId) { return res.status(403).json({ error: 'You can only delete your own artefacts' }); } if (!['superadmin', 'manager'].includes(req.session.userRole) && existing.created_by_user_id !== req.session.userId) { return res.status(403).json({ error: 'Insufficient permissions' }); } // Delete attachments const attachments = await nocodb.list('ArtefactAttachments', { where: `(artefact_id,eq,${sanitizeWhereValue(req.params.id)})`, limit: QUERY_LIMITS.large, }); for (const att of attachments) { const filePath = path.join(uploadsDir, att.filename); if (fs.existsSync(filePath)) fs.unlinkSync(filePath); await nocodb.delete('ArtefactAttachments', att.Id); } await nocodb.delete('Artefacts', req.params.id); res.json({ success: true }); } catch (err) { console.error('Delete artefact error:', err); res.status(500).json({ error: 'Failed to delete artefact' }); } }); // Submit for review - generates approval token app.post('/api/artefacts/:id/submit-review', requireAuth, async (req, res) => { try { const existing = await nocodb.get('Artefacts', req.params.id); if (!existing) return res.status(404).json({ error: 'Artefact not found' }); if (req.session.userRole === 'contributor' && existing.created_by_user_id !== req.session.userId) { return res.status(403).json({ error: 'You can only submit your own artefacts' }); } const token = require('crypto').randomUUID(); const expiresAt = new Date(); expiresAt.setDate(expiresAt.getDate() + DEFAULTS.tokenExpiryDays); await nocodb.update('Artefacts', req.params.id, { status: 'pending_review', approval_token: token, token_expires_at: expiresAt.toISOString(), review_version: existing.current_version || 1, }); const reviewUrl = `${req.protocol}://${req.get('host')}/review/${token}`; res.json({ success: true, token, reviewUrl, expiresAt: expiresAt.toISOString() }); notify.notifyReviewSubmitted({ type: 'artefact', record: existing, reviewUrl }); } catch (err) { console.error('Submit review error:', err); res.status(500).json({ error: 'Failed to submit for review' }); } }); // Link artefact to post app.post('/api/artefacts/:id/link-post', requireAuth, async (req, res) => { const { post_id } = req.body; if (!post_id) return res.status(400).json({ error: 'post_id is required' }); try { const artefact = await nocodb.get('Artefacts', req.params.id); if (!artefact) return res.status(404).json({ error: 'Artefact not found' }); if (artefact.status !== 'approved') { return res.status(400).json({ error: 'Only approved artefacts can be linked to posts' }); } await nocodb.update('Artefacts', req.params.id, { post_id: Number(post_id) }); const updated = await nocodb.get('Artefacts', req.params.id); res.json({ ...updated, brand_name: await getRecordName('Brands', updated.brand_id), creator_name: await getRecordName('Users', updated.created_by_user_id), post_title: await getRecordName('Posts', updated.post_id), }); } catch (err) { console.error('Link post error:', err); res.status(500).json({ error: 'Failed to link artefact to post' }); } }); // ─── ARTEFACT VERSIONS ────────────────────────────────────────── // List all versions for an artefact app.get('/api/artefacts/:id/versions', requireAuth, async (req, res) => { try { const versions = await nocodb.list('ArtefactVersions', { where: `(artefact_id,eq,${sanitizeWhereValue(req.params.id)})`, sort: 'version_number', limit: QUERY_LIMITS.large, }); // Enrich with creator names const enriched = []; for (const v of versions) { const creatorName = await getRecordName('Users', v.created_by_user_id); enriched.push({ ...v, creator_name: creatorName }); } res.json(enriched); } catch (err) { console.error('List versions error:', err); res.status(500).json({ error: 'Failed to load versions' }); } }); // Create new version app.post('/api/artefacts/:id/versions', requireAuth, async (req, res) => { const { notes, copy_from_previous } = req.body; try { const artefact = await nocodb.get('Artefacts', req.params.id); if (!artefact) return res.status(404).json({ error: 'Artefact not found' }); if (req.session.userRole === 'contributor' && artefact.created_by_user_id !== req.session.userId) { return res.status(403).json({ error: 'You can only create versions for your own artefacts' }); } // Get current max version number const versions = await nocodb.list('ArtefactVersions', { where: `(artefact_id,eq,${sanitizeWhereValue(req.params.id)})`, sort: '-version_number', limit: 1, }); const newVersionNumber = versions.length > 0 ? versions[0].version_number + 1 : 1; const created = await nocodb.create('ArtefactVersions', { artefact_id: Number(req.params.id), version_number: newVersionNumber, created_by_user_id: req.session.userId, created_at: new Date().toISOString(), notes: notes || `Version ${newVersionNumber}`, }); // Update artefact current_version await nocodb.update('Artefacts', req.params.id, { current_version: newVersionNumber }); // Copy texts from previous version if requested (for copy type) if (copy_from_previous && artefact.type === 'copy' && versions.length > 0) { const prevVersionId = versions[0].Id; const prevTexts = await nocodb.list('ArtefactVersionTexts', { where: `(version_id,eq,${prevVersionId})`, limit: QUERY_LIMITS.large, }); for (const text of prevTexts) { await nocodb.create('ArtefactVersionTexts', { version_id: created.Id, language_code: text.language_code, language_label: text.language_label, content: text.content, }); } } const version = await nocodb.get('ArtefactVersions', created.Id); const creatorName = await getRecordName('Users', version.created_by_user_id); res.status(201).json({ ...version, creator_name: creatorName }); } catch (err) { console.error('Create version error:', err); res.status(500).json({ error: 'Failed to create version' }); } }); // Get specific version with texts/attachments app.get('/api/artefacts/:id/versions/:versionId', requireAuth, async (req, res) => { try { const version = await nocodb.get('ArtefactVersions', req.params.versionId); if (!version) return res.status(404).json({ error: 'Version not found' }); if (version.artefact_id !== Number(req.params.id)) { return res.status(400).json({ error: 'Version does not belong to this artefact' }); } // Get texts and attachments const [texts, attachments] = await Promise.all([ nocodb.list('ArtefactVersionTexts', { where: `(version_id,eq,${sanitizeWhereValue(req.params.versionId)})`, limit: QUERY_LIMITS.large, }), nocodb.list('ArtefactAttachments', { where: `(version_id,eq,${sanitizeWhereValue(req.params.versionId)})`, limit: QUERY_LIMITS.large, }), ]); const creatorName = await getRecordName('Users', version.created_by_user_id); res.json({ ...version, creator_name: creatorName, texts, attachments: attachments.map(a => ({ ...a, url: a.drive_url || `/api/uploads/${a.filename}`, })), }); } catch (err) { console.error('Get version error:', err); res.status(500).json({ error: 'Failed to load version' }); } }); // Add/update language entry for version app.post('/api/artefacts/:id/versions/:versionId/texts', requireAuth, async (req, res) => { const { language_code, language_label, content } = req.body; if (!language_code || !language_label || !content) { return res.status(400).json({ error: 'language_code, language_label, and content are required' }); } try { const artefact = await nocodb.get('Artefacts', req.params.id); if (!artefact) return res.status(404).json({ error: 'Artefact not found' }); if (req.session.userRole === 'contributor' && artefact.created_by_user_id !== req.session.userId) { return res.status(403).json({ error: 'You can only manage texts for your own artefacts' }); } // Check if language already exists for this version const existing = await nocodb.list('ArtefactVersionTexts', { where: `(version_id,eq,${sanitizeWhereValue(req.params.versionId)})~and(language_code,eq,${sanitizeWhereValue(language_code)})`, limit: 1, }); let text; if (existing.length > 0) { // Update existing await nocodb.update('ArtefactVersionTexts', existing[0].Id, { language_label, content, }); text = await nocodb.get('ArtefactVersionTexts', existing[0].Id); } else { // Create new const created = await nocodb.create('ArtefactVersionTexts', { version_id: Number(req.params.versionId), language_code, language_label, content, }); text = await nocodb.get('ArtefactVersionTexts', created.Id); } res.json(text); } catch (err) { console.error('Add/update text error:', err); res.status(500).json({ error: 'Failed to add/update text' }); } }); // Delete language entry app.delete('/api/artefact-version-texts/:id', requireAuth, async (req, res) => { try { const text = await nocodb.get('ArtefactVersionTexts', req.params.id); if (!text) return res.status(404).json({ error: 'Text not found' }); // Permission check via version -> artefact const version = await nocodb.get('ArtefactVersions', text.version_id); const artefact = await nocodb.get('Artefacts', version.artefact_id); if (req.session.userRole === 'contributor' && artefact.created_by_user_id !== req.session.userId) { return res.status(403).json({ error: 'You can only manage texts for your own artefacts' }); } await nocodb.delete('ArtefactVersionTexts', req.params.id); res.json({ success: true }); } catch (err) { console.error('Delete text error:', err); res.status(500).json({ error: 'Failed to delete text' }); } }); // Upload attachment to specific version app.post('/api/artefacts/:id/versions/:versionId/attachments', requireAuth, dynamicUpload('file'), async (req, res) => { try { const artefact = await nocodb.get('Artefacts', req.params.id); if (!artefact) return res.status(404).json({ error: 'Artefact not found' }); if (req.session.userRole === 'contributor' && artefact.created_by_user_id !== req.session.userId) { if (req.file) fs.unlinkSync(path.join(uploadsDir, req.file.filename)); return res.status(403).json({ error: 'You can only manage attachments on your own artefacts' }); } const { drive_url } = req.body; // Either file upload or drive URL if (!req.file && !drive_url) { return res.status(400).json({ error: 'Either file upload or drive_url is required' }); } const data = { artefact_id: Number(req.params.id), version_id: Number(req.params.versionId), }; if (req.file) { data.filename = req.file.filename; data.original_name = req.file.originalname; data.mime_type = req.file.mimetype; data.size = req.file.size; } if (drive_url) { data.drive_url = drive_url; data.original_name = 'Google Drive Video'; data.mime_type = 'video/*'; } const created = await nocodb.create('ArtefactAttachments', data); const attachment = await nocodb.get('ArtefactAttachments', created.Id); res.status(201).json({ ...attachment, url: attachment.drive_url || `/api/uploads/${attachment.filename}`, }); } catch (err) { console.error('Upload attachment error:', err); res.status(500).json({ error: 'Failed to upload attachment' }); } }); // Get comments for specific version app.get('/api/artefacts/:id/versions/:versionId/comments', requireAuth, async (req, res) => { try { const version = await nocodb.get('ArtefactVersions', req.params.versionId); if (!version) return res.status(404).json({ error: 'Version not found' }); const comments = await nocodb.list('Comments', { where: `(entity_type,eq,artefact)~and(entity_id,eq,${sanitizeWhereValue(req.params.id)})~and(version_number,eq,${version.version_number})`, sort: 'CreatedAt', limit: QUERY_LIMITS.large, }); // Enrich with user data const enriched = []; for (const c of comments) { let userName = null, userAvatar = null; if (c.user_id) { try { const u = await nocodb.get('Users', c.user_id); userName = u.name; userAvatar = u.avatar; } catch (err) { console.error('Resolve version comment user:', err.message); } } enriched.push({ ...c, user_name: userName, user_avatar: userAvatar }); } res.json(enriched); } catch (err) { console.error('Get comments error:', err); res.status(500).json({ error: 'Failed to load comments' }); } }); // Add comment to specific version app.post('/api/artefacts/:id/versions/:versionId/comments', requireAuth, async (req, res) => { const { content } = req.body; if (!content || !content.trim()) { return res.status(400).json({ error: 'Content is required' }); } try { const version = await nocodb.get('ArtefactVersions', req.params.versionId); if (!version) return res.status(404).json({ error: 'Version not found' }); const created = await nocodb.create('Comments', { entity_type: 'artefact', entity_id: Number(req.params.id), version_number: version.version_number, content: content.trim(), user_id: req.session.userId, }); const comment = await nocodb.get('Comments', created.Id); const user = await nocodb.get('Users', req.session.userId); res.status(201).json({ ...comment, user_id: req.session.userId, user_name: user.name, user_avatar: user.avatar, }); } catch (err) { console.error('Add comment error:', err); res.status(500).json({ error: 'Failed to add comment' }); } }); // Artefact attachments (legacy/general — now version-aware) app.get('/api/artefacts/:id/attachments', requireAuth, async (req, res) => { try { const attachments = await nocodb.list('ArtefactAttachments', { where: `(artefact_id,eq,${sanitizeWhereValue(req.params.id)})`, sort: '-CreatedAt', limit: QUERY_LIMITS.large, }); res.json(attachments.map(a => ({ ...a, url: a.drive_url || `/api/uploads/${a.filename}`, }))); } catch (err) { res.status(500).json({ error: 'Failed to load attachments' }); } }); app.post('/api/artefacts/:id/attachments', requireAuth, dynamicUpload('file'), async (req, res) => { if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); try { const artefact = await nocodb.get('Artefacts', req.params.id); if (!artefact) return res.status(404).json({ error: 'Artefact not found' }); if (req.session.userRole === 'contributor' && artefact.created_by_user_id !== req.session.userId) { fs.unlinkSync(path.join(uploadsDir, req.file.filename)); return res.status(403).json({ error: 'You can only manage attachments on your own artefacts' }); } const created = await nocodb.create('ArtefactAttachments', { filename: req.file.filename, original_name: req.file.originalname, mime_type: req.file.mimetype, size: req.file.size, artefact_id: Number(req.params.id), }); const attachment = await nocodb.get('ArtefactAttachments', created.Id); res.status(201).json({ ...attachment, url: `/api/uploads/${attachment.filename}` }); } catch (err) { console.error('Upload artefact attachment error:', err); res.status(500).json({ error: 'Failed to upload attachment' }); } }); app.delete('/api/artefact-attachments/:id', requireAuth, async (req, res) => { try { const attachment = await nocodb.get('ArtefactAttachments', req.params.id); if (!attachment) return res.status(404).json({ error: 'Attachment not found' }); // Permission check via artefact const artefact = await nocodb.get('Artefacts', attachment.artefact_id); if (req.session.userRole === 'contributor' && artefact.created_by_user_id !== req.session.userId) { return res.status(403).json({ error: 'You can only manage attachments on your own artefacts' }); } const filePath = path.join(uploadsDir, attachment.filename); if (fs.existsSync(filePath)) fs.unlinkSync(filePath); await nocodb.delete('ArtefactAttachments', req.params.id); res.json({ success: true }); } catch (err) { console.error('Delete artefact attachment error:', err); res.status(500).json({ error: 'Failed to delete attachment' }); } }); // ─── PUBLIC ARTEFACT REVIEW (no auth) ────────────────────────── app.get('/api/public/review/:token', async (req, res) => { try { const artefacts = await nocodb.list('Artefacts', { where: `(approval_token,eq,${sanitizeWhereValue(req.params.token)})`, limit: 1, }); if (artefacts.length === 0) { return res.status(404).json({ error: 'Review link not found or expired' }); } const artefact = artefacts[0]; // Check expiration if (artefact.token_expires_at) { const expiresAt = new Date(artefact.token_expires_at); if (expiresAt < new Date()) { return res.status(410).json({ error: 'Review link has expired' }); } } // Get the review version (or fall back to current/latest) const reviewVersionNumber = artefact.review_version || artefact.current_version || 1; // Get the specific version const versions = await nocodb.list('ArtefactVersions', { where: `(artefact_id,eq,${artefact.Id})~and(version_number,eq,${reviewVersionNumber})`, limit: 1, }); let texts = []; let attachments = []; let versionData = null; if (versions.length > 0) { const version = versions[0]; versionData = version; // Get texts and attachments for this version [texts, attachments] = await Promise.all([ nocodb.list('ArtefactVersionTexts', { where: `(version_id,eq,${version.Id})`, limit: QUERY_LIMITS.large, }), nocodb.list('ArtefactAttachments', { where: `(version_id,eq,${version.Id})`, limit: QUERY_LIMITS.large, }), ]); } else { // Legacy support: get all attachments if no version found attachments = await nocodb.list('ArtefactAttachments', { where: `(artefact_id,eq,${artefact.Id})`, limit: QUERY_LIMITS.large, }); } // Get comments for this version const comments = await nocodb.list('Comments', { where: `(entity_type,eq,artefact)~and(entity_id,eq,${artefact.Id})~and(version_number,eq,${reviewVersionNumber})`, sort: 'CreatedAt', limit: QUERY_LIMITS.large, }); // Resolve approver names const approvers = []; if (artefact.approver_ids) { for (const id of artefact.approver_ids.split(',').filter(Boolean)) { approvers.push({ id: Number(id), name: await getRecordName('Users', Number(id)) }); } } res.json({ ...artefact, brand_name: await getRecordName('Brands', artefact.brand_id), creator_name: await getRecordName('Users', artefact.created_by_user_id), approvers, version: versionData, version_number: reviewVersionNumber, texts, attachments: attachments.map(a => ({ ...a, url: a.drive_url || `/api/uploads/${a.filename}`, })), comments, }); } catch (err) { console.error('Public review fetch error:', err); res.status(500).json({ error: 'Failed to load artefact for review' }); } }); app.post('/api/public/review/:token/approve', async (req, res) => { const { approved_by_name } = req.body; try { const artefacts = await nocodb.list('Artefacts', { where: `(approval_token,eq,${sanitizeWhereValue(req.params.token)})`, limit: 1, }); if (artefacts.length === 0) { return res.status(404).json({ error: 'Review link not found' }); } const artefact = artefacts[0]; if (artefact.token_expires_at && new Date(artefact.token_expires_at) < new Date()) { return res.status(410).json({ error: 'Review link has expired' }); } await nocodb.update('Artefacts', artefact.Id, { status: 'approved', approved_by_name: approved_by_name || 'Anonymous', approved_at: new Date().toISOString(), }); res.json({ success: true, message: 'Artefact approved successfully' }); notify.notifyApproved({ type: 'artefact', record: artefact, approverName: approved_by_name }); } catch (err) { console.error('Approve error:', err); res.status(500).json({ error: 'Failed to approve artefact' }); } }); app.post('/api/public/review/:token/reject', async (req, res) => { const { approved_by_name, feedback } = req.body; try { const artefacts = await nocodb.list('Artefacts', { where: `(approval_token,eq,${sanitizeWhereValue(req.params.token)})`, limit: 1, }); if (artefacts.length === 0) { return res.status(404).json({ error: 'Review link not found' }); } const artefact = artefacts[0]; if (artefact.token_expires_at && new Date(artefact.token_expires_at) < new Date()) { return res.status(410).json({ error: 'Review link has expired' }); } await nocodb.update('Artefacts', artefact.Id, { status: 'rejected', approved_by_name: approved_by_name || 'Anonymous', feedback: feedback || '', }); res.json({ success: true, message: 'Artefact rejected' }); notify.notifyRejected({ type: 'artefact', record: artefact, approverName: approved_by_name, feedback }); } catch (err) { console.error('Reject error:', err); res.status(500).json({ error: 'Failed to reject artefact' }); } }); app.post('/api/public/review/:token/revision', async (req, res) => { const { feedback, approved_by_name } = req.body; try { const artefacts = await nocodb.list('Artefacts', { where: `(approval_token,eq,${sanitizeWhereValue(req.params.token)})`, limit: 1, }); if (artefacts.length === 0) { return res.status(404).json({ error: 'Review link not found' }); } const artefact = artefacts[0]; if (artefact.token_expires_at && new Date(artefact.token_expires_at) < new Date()) { return res.status(410).json({ error: 'Review link has expired' }); } await nocodb.update('Artefacts', artefact.Id, { status: 'revision_requested', approved_by_name: approved_by_name || '', feedback: feedback || '', }); res.json({ success: true, message: 'Revision requested' }); notify.notifyRevisionRequested({ record: artefact, approverName: approved_by_name, feedback }); } catch (err) { console.error('Revision request error:', err); res.status(500).json({ error: 'Failed to request revision' }); } }); app.post('/api/public/review/:token/comment', async (req, res) => { const { comment, author_name } = req.body; if (!comment) return res.status(400).json({ error: 'Comment is required' }); try { const artefacts = await nocodb.list('Artefacts', { where: `(approval_token,eq,${sanitizeWhereValue(req.params.token)})`, limit: 1, }); if (artefacts.length === 0) { return res.status(404).json({ error: 'Review link not found' }); } const artefact = artefacts[0]; // Store comment in Comments table (assuming it exists with entity_type/entity_id pattern) try { await nocodb.create('Comments', { content: comment, author_name: author_name || 'Anonymous', entity_type: 'artefact', entity_id: artefact.Id, }); } catch (err) { console.log('Comments table not available, skipping comment storage'); } res.json({ success: true, message: 'Comment added' }); } catch (err) { console.error('Comment error:', err); res.status(500).json({ error: 'Failed to add comment' }); } }); // ─── TRANSLATION MANAGEMENT API ────────────────────────────────── // List translations app.get('/api/translations', requireAuth, async (req, res) => { try { const { brand, status } = req.query; const whereParts = []; if (brand) whereParts.push(`(brand_id,eq,${sanitizeWhereValue(brand)})`); if (status) whereParts.push(`(status,eq,${sanitizeWhereValue(status)})`); const where = whereParts.length > 0 ? whereParts.join('~and') : undefined; let translations = await nocodb.list('Translations', { where, sort: '-UpdatedAt', limit: QUERY_LIMITS.medium }); // Visibility filtering const userId = req.session.userId; if (req.session.userRole === 'contributor') { translations = translations.filter(t => t.created_by_user_id === userId); } // Enrich with names const brandIds = new Set(), userIds = new Set(); for (const t of translations) { if (t.brand_id) brandIds.add(t.brand_id); if (t.created_by_user_id) userIds.add(t.created_by_user_id); if (t.approver_ids) { for (const id of t.approver_ids.split(',').map(s => s.trim()).filter(Boolean)) { userIds.add(Number(id)); } } } const names = await batchResolveNames({ brand: { table: 'Brands', ids: [...brandIds] }, user: { table: 'Users', ids: [...userIds] }, }); // Count translation texts per record const textCounts = {}; try { const allTexts = await nocodb.list('TranslationTexts', { limit: QUERY_LIMITS.large }); for (const tt of allTexts) { textCounts[tt.translation_id] = (textCounts[tt.translation_id] || 0) + 1; } } catch (e) { /* table may not exist yet */ } res.json(translations.map(t => { const approverIdList = t.approver_ids ? t.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : []; return { ...t, brand_name: names[`brand:${t.brand_id}`] || null, creator_name: names[`user:${t.created_by_user_id}`] || null, approvers: approverIdList.map(id => ({ id: Number(id), name: names[`user:${Number(id)}`] || null })), translation_count: textCounts[t.Id] || 0, }; })); } catch (err) { console.error('GET /translations error:', err); res.status(500).json({ error: 'Failed to load translations' }); } }); // Create translation app.post('/api/translations', requireAuth, async (req, res) => { const { title, description, source_language, source_content, brand_id, approver_ids } = req.body; if (!title) return res.status(400).json({ error: 'Title is required' }); if (!source_language) return res.status(400).json({ error: 'Source language is required' }); if (!source_content) return res.status(400).json({ error: 'Source content is required' }); try { const created = await nocodb.create('Translations', { title, description: description || null, source_language, source_content, status: 'draft', brand_id: brand_id ? Number(brand_id) : null, approver_ids: approver_ids || null, created_by_user_id: req.session.userId, }); const record = await nocodb.get('Translations', created.Id); const approverIdList = record.approver_ids ? record.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : []; const approvers = []; for (const id of approverIdList) { approvers.push({ id: Number(id), name: await getRecordName('Users', Number(id)) }); } res.status(201).json({ ...record, brand_name: await getRecordName('Brands', record.brand_id), creator_name: await getRecordName('Users', record.created_by_user_id), approvers, translation_count: 0, }); } catch (err) { console.error('Create translation error:', err); res.status(500).json({ error: 'Failed to create translation' }); } }); // Bulk delete translations (BEFORE /:id) app.post('/api/translations/bulk-delete', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => { try { const { ids } = req.body; if (!Array.isArray(ids) || ids.length === 0) return res.status(400).json({ error: 'ids array required' }); for (const id of ids) { const texts = await nocodb.list('TranslationTexts', { where: `(translation_id,eq,${sanitizeWhereValue(id)})`, limit: QUERY_LIMITS.large }); if (texts.length > 0) await nocodb.bulkDelete('TranslationTexts', texts.map(t => ({ Id: t.Id }))); } await nocodb.bulkDelete('Translations', ids.map(id => ({ Id: id }))); res.json({ deleted: ids.length }); } catch (err) { console.error('Bulk delete translations error:', err); res.status(500).json({ error: 'Failed to bulk delete translations' }); } }); // Update translation app.patch('/api/translations/:id', requireAuth, async (req, res) => { try { const existing = await nocodb.get('Translations', req.params.id); if (!existing) return res.status(404).json({ error: 'Translation not found' }); if (req.session.userRole === 'contributor' && existing.created_by_user_id !== req.session.userId) { return res.status(403).json({ error: 'You can only modify your own translations' }); } const data = {}; for (const f of ['title', 'description', 'source_language', 'source_content', 'status', 'feedback']) { if (req.body[f] !== undefined) data[f] = req.body[f]; } if (req.body.brand_id !== undefined) data.brand_id = req.body.brand_id ? Number(req.body.brand_id) : null; if (req.body.approver_ids !== undefined) data.approver_ids = req.body.approver_ids || null; if (Object.keys(data).length === 0) return res.status(400).json({ error: 'No fields to update' }); await nocodb.update('Translations', req.params.id, data); const record = await nocodb.get('Translations', req.params.id); const approverIdList = record.approver_ids ? record.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : []; const approvers = []; for (const id of approverIdList) { approvers.push({ id: Number(id), name: await getRecordName('Users', Number(id)) }); } res.json({ ...record, brand_name: await getRecordName('Brands', record.brand_id), creator_name: await getRecordName('Users', record.created_by_user_id), approvers, }); } catch (err) { console.error('Update translation error:', err); res.status(500).json({ error: 'Failed to update translation' }); } }); // Delete translation app.delete('/api/translations/:id', requireAuth, async (req, res) => { try { const existing = await nocodb.get('Translations', req.params.id); if (!existing) return res.status(404).json({ error: 'Translation not found' }); if (req.session.userRole === 'contributor' && existing.created_by_user_id !== req.session.userId) { return res.status(403).json({ error: 'You can only delete your own translations' }); } // Cascade delete translation texts const texts = await nocodb.list('TranslationTexts', { where: `(translation_id,eq,${sanitizeWhereValue(req.params.id)})`, limit: QUERY_LIMITS.large }); if (texts.length > 0) await nocodb.bulkDelete('TranslationTexts', texts.map(t => ({ Id: t.Id }))); await nocodb.delete('Translations', req.params.id); res.json({ success: true }); } catch (err) { console.error('Delete translation error:', err); res.status(500).json({ error: 'Failed to delete translation' }); } }); // List translation texts app.get('/api/translations/:id/texts', requireAuth, async (req, res) => { try { const texts = await nocodb.list('TranslationTexts', { where: `(translation_id,eq,${sanitizeWhereValue(req.params.id)})`, limit: QUERY_LIMITS.large, }); res.json(texts); } catch (err) { console.error('GET translation texts error:', err); res.status(500).json({ error: 'Failed to load translation texts' }); } }); // Add/update translation text app.post('/api/translations/:id/texts', requireAuth, async (req, res) => { const { language_code, language_label, content } = req.body; if (!language_code || !content) return res.status(400).json({ error: 'Language code and content are required' }); try { const translation = await nocodb.get('Translations', req.params.id); if (!translation) return res.status(404).json({ error: 'Translation not found' }); // Check if text for this language already exists (upsert) const existing = await nocodb.list('TranslationTexts', { where: `(translation_id,eq,${sanitizeWhereValue(req.params.id)})~and(language_code,eq,${sanitizeWhereValue(language_code)})`, limit: 1, }); let result; if (existing.length > 0) { await nocodb.update('TranslationTexts', existing[0].Id, { content, language_label: language_label || language_code }); result = await nocodb.get('TranslationTexts', existing[0].Id); } else { result = await nocodb.create('TranslationTexts', { translation_id: Number(req.params.id), language_code, language_label: language_label || language_code, content, }); } res.json(result); } catch (err) { console.error('Add/update translation text error:', err); res.status(500).json({ error: 'Failed to save translation text' }); } }); // Delete translation text app.delete('/api/translations/:id/texts/:textId', requireAuth, async (req, res) => { try { await nocodb.delete('TranslationTexts', req.params.textId); res.json({ success: true }); } catch (err) { console.error('Delete translation text error:', err); res.status(500).json({ error: 'Failed to delete translation text' }); } }); // Submit translation for review app.post('/api/translations/:id/submit-review', requireAuth, async (req, res) => { try { const existing = await nocodb.get('Translations', req.params.id); if (!existing) return res.status(404).json({ error: 'Translation not found' }); if (req.session.userRole === 'contributor' && existing.created_by_user_id !== req.session.userId) { return res.status(403).json({ error: 'You can only submit your own translations' }); } const token = require('crypto').randomUUID(); const expiresAt = new Date(); expiresAt.setDate(expiresAt.getDate() + DEFAULTS.tokenExpiryDays); await nocodb.update('Translations', req.params.id, { status: 'pending_review', approval_token: token, token_expires_at: expiresAt.toISOString(), }); const reviewUrl = `${req.protocol}://${req.get('host')}/review-translation/${token}`; res.json({ success: true, token, reviewUrl, expiresAt: expiresAt.toISOString() }); notify.notifyReviewSubmitted({ type: 'translation', record: existing, reviewUrl }); } catch (err) { console.error('Submit translation review error:', err); res.status(500).json({ error: 'Failed to submit for review' }); } }); // Public: Get translation for review app.get('/api/public/review-translation/:token', async (req, res) => { try { const translations = await nocodb.list('Translations', { where: `(approval_token,eq,${sanitizeWhereValue(req.params.token)})`, limit: 1, }); if (translations.length === 0) { return res.status(404).json({ error: 'Review link not found or expired' }); } const translation = translations[0]; if (translation.token_expires_at) { const expiresAt = new Date(translation.token_expires_at); if (expiresAt < new Date()) { return res.status(410).json({ error: 'Review link has expired' }); } } // Get all translation texts const texts = await nocodb.list('TranslationTexts', { where: `(translation_id,eq,${translation.Id})`, limit: QUERY_LIMITS.large, }); // Resolve approver names const approvers = []; if (translation.approver_ids) { for (const id of translation.approver_ids.split(',').filter(Boolean)) { approvers.push({ id: Number(id), name: await getRecordName('Users', Number(id)) }); } } res.json({ ...translation, brand_name: await getRecordName('Brands', translation.brand_id), creator_name: await getRecordName('Users', translation.created_by_user_id), approvers, texts, }); } catch (err) { console.error('Public translation review fetch error:', err); res.status(500).json({ error: 'Failed to load translation for review' }); } }); // Public: Approve translation app.post('/api/public/review-translation/:token/approve', async (req, res) => { const { approved_by_name } = req.body; try { const translations = await nocodb.list('Translations', { where: `(approval_token,eq,${sanitizeWhereValue(req.params.token)})`, limit: 1, }); if (translations.length === 0) return res.status(404).json({ error: 'Review link not found' }); const translation = translations[0]; if (translation.token_expires_at && new Date(translation.token_expires_at) < new Date()) { return res.status(410).json({ error: 'Review link has expired' }); } await nocodb.update('Translations', translation.Id, { status: 'approved', approved_by_name: approved_by_name || 'Anonymous', approved_at: new Date().toISOString(), }); res.json({ success: true, message: 'Translation approved successfully' }); notify.notifyApproved({ type: 'translation', record: translation, approverName: approved_by_name }); } catch (err) { console.error('Approve translation error:', err); res.status(500).json({ error: 'Failed to approve translation' }); } }); // Public: Reject translation app.post('/api/public/review-translation/:token/reject', async (req, res) => { const { approved_by_name, feedback } = req.body; try { const translations = await nocodb.list('Translations', { where: `(approval_token,eq,${sanitizeWhereValue(req.params.token)})`, limit: 1, }); if (translations.length === 0) return res.status(404).json({ error: 'Review link not found' }); const translation = translations[0]; if (translation.token_expires_at && new Date(translation.token_expires_at) < new Date()) { return res.status(410).json({ error: 'Review link has expired' }); } await nocodb.update('Translations', translation.Id, { status: 'rejected', approved_by_name: approved_by_name || 'Anonymous', feedback: feedback || '', }); res.json({ success: true, message: 'Translation rejected' }); notify.notifyRejected({ type: 'translation', record: translation, approverName: approved_by_name, feedback }); } catch (err) { console.error('Reject translation error:', err); res.status(500).json({ error: 'Failed to reject translation' }); } }); // Public: Request revision on translation app.post('/api/public/review-translation/:token/revision', async (req, res) => { const { feedback, approved_by_name } = req.body; try { const translations = await nocodb.list('Translations', { where: `(approval_token,eq,${sanitizeWhereValue(req.params.token)})`, limit: 1, }); if (translations.length === 0) return res.status(404).json({ error: 'Review link not found' }); const translation = translations[0]; if (translation.token_expires_at && new Date(translation.token_expires_at) < new Date()) { return res.status(410).json({ error: 'Review link has expired' }); } await nocodb.update('Translations', translation.Id, { status: 'revision_requested', approved_by_name: approved_by_name || '', feedback: feedback || '', }); res.json({ success: true, message: 'Revision requested' }); notify.notifyRevisionRequested({ record: translation, approverName: approved_by_name, feedback }); } catch (err) { console.error('Translation revision request error:', err); res.status(500).json({ error: 'Failed to request revision' }); } }); // ─── ISSUE TRACKER API ────────────────────────────────────────── // Internal: List issues with filters app.get('/api/issues', requireAuth, async (req, res) => { try { const { status, category, type, priority, assigned_to_id, brand_id, team_id, sort } = req.query; const conditions = []; if (status) conditions.push({ field: 'status', op: 'eq', value: sanitizeWhereValue(status) }); if (category) conditions.push({ field: 'category', op: 'eq', value: sanitizeWhereValue(category) }); if (type) conditions.push({ field: 'type', op: 'eq', value: sanitizeWhereValue(type) }); if (priority) conditions.push({ field: 'priority', op: 'eq', value: sanitizeWhereValue(priority) }); if (assigned_to_id) conditions.push({ field: 'assigned_to_id', op: 'eq', value: sanitizeWhereValue(assigned_to_id) }); if (brand_id) conditions.push({ field: 'brand_id', op: 'eq', value: sanitizeWhereValue(brand_id) }); if (team_id) conditions.push({ field: 'team_id', op: 'eq', value: sanitizeWhereValue(team_id) }); let issues = await nocodb.list('Issues', { where: conditions, sort: sort || '-created_at', }); // Team-based visibility filtering const userId = req.session.userId; if (req.session.userRole === 'manager') { const myTeamIds = await getUserTeamIds(userId); issues = issues.filter(i => i.assigned_to_id === userId || (i.team_id && myTeamIds.has(i.team_id)) || !i.team_id ); } else if (req.session.userRole === 'contributor') { issues = issues.filter(i => i.assigned_to_id === userId); } // Resolve brand and team names const names = await batchResolveNames({ brand: { table: 'Brands', ids: issues.map(i => i.brand_id) }, team: { table: 'Teams', ids: issues.map(i => i.team_id) }, }); for (const issue of issues) { issue.brand_name = names[`brand:${issue.brand_id}`] || null; issue.team_name = names[`team:${issue.team_id}`] || null; issue.thumbnail_url = issue.thumbnail || null; } // Count by status for dashboard const counts = { new: 0, acknowledged: 0, in_progress: 0, resolved: 0, declined: 0 }; issues.forEach(i => counts[i.status] = (counts[i.status] || 0) + 1); res.json({ issues, counts }); } catch (err) { console.error('List issues error:', err); res.status(500).json({ error: 'Failed to load issues' }); } }); // Internal: List distinct categories app.get('/api/issues/categories', requireAuth, async (req, res) => { try { const issues = await nocodb.list('Issues', { fields: 'category' }); const categories = [...new Set(issues.map(i => i.category).filter(Boolean))]; res.json(categories); } catch (err) { console.error('Get categories error:', err); res.status(500).json({ error: 'Failed to load categories' }); } }); // Bulk delete issues app.post('/api/issues/bulk-delete', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => { try { const { ids } = req.body; if (!Array.isArray(ids) || ids.length === 0) return res.status(400).json({ error: 'ids array required' }); for (const id of ids) { const updates = await nocodb.list('IssueUpdates', { where: `(issue_id,eq,${sanitizeWhereValue(id)})`, limit: QUERY_LIMITS.large }); if (updates.length > 0) await nocodb.bulkDelete('IssueUpdates', updates.map(u => ({ Id: u.Id }))); const atts = await nocodb.list('IssueAttachments', { where: `(issue_id,eq,${sanitizeWhereValue(id)})`, limit: QUERY_LIMITS.large }); if (atts.length > 0) await nocodb.bulkDelete('IssueAttachments', atts.map(a => ({ Id: a.Id }))); } await nocodb.bulkDelete('Issues', ids.map(id => ({ Id: id }))); res.json({ deleted: ids.length }); } catch (err) { console.error('Bulk delete issues error:', err); res.status(500).json({ error: 'Failed to bulk delete issues' }); } }); // Internal: Get single issue with updates and attachments app.get('/api/issues/:id', requireAuth, async (req, res) => { try { const issue = await nocodb.get('Issues', req.params.id); if (!issue) return res.status(404).json({ error: 'Issue not found' }); const [updates, attachments] = await Promise.all([ nocodb.list('IssueUpdates', { where: `(issue_id,eq,${sanitizeWhereValue(req.params.id)})`, sort: '-created_at' }), nocodb.list('IssueAttachments', { where: `(issue_id,eq,${sanitizeWhereValue(req.params.id)})`, sort: '-created_at' }), ]); let brand_name = null; if (issue.brand_id) { try { brand_name = (await nocodb.get('Brands', issue.brand_id)).name; } catch (err) { console.error('Resolve issue brand:', err.message); } } let team_name = null; if (issue.team_id) { try { team_name = (await nocodb.get('Teams', issue.team_id)).name; } catch (err) { console.error('Resolve issue team:', err.message); } } res.json({ ...issue, brand_name, team_name, updates, attachments }); } catch (err) { console.error('Get issue error:', err); res.status(500).json({ error: 'Failed to load issue' }); } }); // Internal: Create issue (on behalf of someone) app.post('/api/issues', requireAuth, async (req, res) => { try { const { title, description, type, category, priority, submitter_name, submitter_email, submitter_phone } = req.body; if (!title || !submitter_name || !submitter_email) { return res.status(400).json({ error: 'Title, submitter name, and email are required' }); } const now = new Date().toISOString(); const issue = await nocodb.create('Issues', { title, description: description || '', type: type || 'request', category: category || 'General', priority: priority || 'medium', status: 'new', submitter_name, submitter_email, submitter_phone: submitter_phone || '', tracking_token: require('crypto').randomUUID(), created_at: now, updated_at: now, }); res.json(issue); } catch (err) { console.error('Create issue error:', err); res.status(500).json({ error: 'Failed to create issue' }); } }); // Internal: Update issue app.patch('/api/issues/:id', requireAuth, async (req, res) => { try { const existing = await nocodb.get('Issues', req.params.id); if (!existing) return res.status(404).json({ error: 'Issue not found' }); const { status, assigned_to_id, internal_notes, resolution_summary, priority, category, brand_id, team_id } = req.body; const updates = { updated_at: new Date().toISOString() }; if (status !== undefined) updates.status = status; if (assigned_to_id !== undefined) updates.assigned_to_id = assigned_to_id; if (internal_notes !== undefined) updates.internal_notes = internal_notes; if (resolution_summary !== undefined) updates.resolution_summary = resolution_summary; if (priority !== undefined) updates.priority = priority; if (category !== undefined) updates.category = category; if (brand_id !== undefined) updates.brand_id = brand_id ? Number(brand_id) : null; if (team_id !== undefined) updates.team_id = team_id ? Number(team_id) : null; if (status === 'resolved' || status === 'declined') { updates.resolved_at = new Date().toISOString(); } await nocodb.update('Issues', req.params.id, updates); res.json({ success: true }); // Notify on assignment change if (assigned_to_id && assigned_to_id !== existing.assigned_to_id) { notify.notifyIssueAssigned({ issue: { ...existing, ...updates }, assignerName: req.session.userName }); } // Notify submitter on status change if (status && status !== existing.status) { notify.notifyIssueStatusUpdate({ issue: { ...existing, ...updates }, oldStatus: existing.status, newStatus: status }); } } catch (err) { console.error('Update issue error:', err); res.status(500).json({ error: 'Failed to update issue' }); } }); // Internal: Delete issue app.delete('/api/issues/:id', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => { try { await nocodb.delete('Issues', req.params.id); res.json({ success: true }); } catch (err) { console.error('Delete issue error:', err); res.status(500).json({ error: 'Failed to delete issue' }); } }); // Internal: Add update to issue app.post('/api/issues/:id/updates', requireAuth, async (req, res) => { try { const { message, is_public } = req.body; if (!message) return res.status(400).json({ error: 'Message is required' }); const update = await nocodb.create('IssueUpdates', { issue_id: Number(req.params.id), message, is_public: is_public || false, author_name: req.session.userName || 'Staff', author_type: 'staff', created_at: new Date().toISOString(), }); await nocodb.update('Issues', req.params.id, { updated_at: new Date().toISOString() }); res.json(update); } catch (err) { console.error('Add update error:', err); res.status(500).json({ error: 'Failed to add update' }); } }); // Internal: Upload attachment app.post('/api/issues/:id/attachments', requireAuth, dynamicUpload('file'), async (req, res) => { try { if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); const attachment = await nocodb.create('IssueAttachments', { issue_id: Number(req.params.id), filename: req.file.filename, original_name: req.file.originalname, mime_type: req.file.mimetype, size: req.file.size, uploaded_by: req.session.userName || 'Staff', created_at: new Date().toISOString(), }); res.json(attachment); } catch (err) { console.error('Upload attachment error:', err); res.status(500).json({ error: 'Failed to upload attachment' }); } }); // Internal: Get attachments app.get('/api/issues/:id/attachments', requireAuth, async (req, res) => { try { const attachments = await nocodb.list('IssueAttachments', { where: `(issue_id,eq,${sanitizeWhereValue(req.params.id)})`, sort: '-created_at', }); res.json(attachments); } catch (err) { console.error('Get attachments error:', err); res.status(500).json({ error: 'Failed to load attachments' }); } }); // Internal: Delete attachment app.delete('/api/issue-attachments/:id', requireAuth, async (req, res) => { try { const attachment = await nocodb.get('IssueAttachments', req.params.id); if (attachment && attachment.filename) { const filePath = path.join(uploadsDir, attachment.filename); if (fs.existsSync(filePath)) fs.unlinkSync(filePath); } await nocodb.delete('IssueAttachments', req.params.id); res.json({ success: true }); } catch (err) { console.error('Delete attachment error:', err); res.status(500).json({ error: 'Failed to delete attachment' }); } }); // Set an issue's thumbnail from one of its image attachments app.patch('/api/issues/:id/thumbnail', requireAuth, async (req, res) => { try { const { attachment_id } = req.body; const issue = await nocodb.get('Issues', req.params.id); if (!issue) return res.status(404).json({ error: 'Issue not found' }); if (attachment_id) { const att = await nocodb.get('IssueAttachments', attachment_id); if (!att) return res.status(404).json({ error: 'Attachment not found' }); await nocodb.update('Issues', req.params.id, { thumbnail: att.url || `/api/uploads/${att.filename}` }); } else { await nocodb.update('Issues', req.params.id, { thumbnail: null }); } const updated = await nocodb.get('Issues', req.params.id); res.json(updated); } catch (err) { res.status(500).json({ error: 'Failed to set thumbnail' }); } }); // ─── PUBLIC ISSUE ROUTES (NO AUTH) ────────────────────────────── // Public: List teams for issue submission app.get('/api/public/teams', async (req, res) => { try { const teams = await nocodb.list('Teams', { sort: 'name', limit: QUERY_LIMITS.medium, fields: 'Id,name' }); res.json(teams.map(t => ({ id: t.Id, name: t.name }))); } catch (err) { console.error('Public teams error:', err); res.status(500).json({ error: 'Failed to load teams' }); } }); // Public: Submit new issue app.post('/api/public/issues', dynamicUpload('file'), async (req, res) => { try { const { name, email, phone, title, description, type, category, priority, team_id } = req.body; if (!name || !email || !title) { return res.status(400).json({ error: 'Name, email, and title are required' }); } const now = new Date().toISOString(); const token = require('crypto').randomUUID(); const issue = await nocodb.create('Issues', { title, description: description || '', type: type || 'request', category: category || 'General', priority: priority || 'medium', status: 'new', submitter_name: name, submitter_email: email, submitter_phone: phone || '', tracking_token: token, team_id: team_id ? Number(team_id) : null, created_at: now, updated_at: now, }); // Upload attachment if provided if (req.file) { await nocodb.create('IssueAttachments', { issue_id: issue.Id, filename: req.file.filename, original_name: req.file.originalname, mime_type: req.file.mimetype, size: req.file.size, uploaded_by: name, created_at: now, }); } res.json({ success: true, token, trackingUrl: `/track/${token}` }); } catch (err) { console.error('Public submit error:', err); res.status(500).json({ error: 'Failed to submit issue' }); } }); // Public: Get issue status by token app.get('/api/public/issues/:token', async (req, res) => { try { const issues = await nocodb.list('Issues', { where: `(tracking_token,eq,${sanitizeWhereValue(req.params.token)})`, limit: 1, }); if (issues.length === 0) return res.status(404).json({ error: 'Issue not found' }); const issue = issues[0]; const [updates, attachments] = await Promise.all([ nocodb.list('IssueUpdates', { where: `(issue_id,eq,${issue.Id})~and(is_public,eq,true)`, sort: '-created_at', }), nocodb.list('IssueAttachments', { where: `(issue_id,eq,${issue.Id})`, sort: '-created_at', }), ]); // Don't expose internal notes const publicIssue = { title: issue.title, description: issue.description, type: issue.type, category: issue.category, priority: issue.priority, status: issue.status, resolution_summary: issue.resolution_summary, created_at: issue.created_at, updated_at: issue.updated_at, resolved_at: issue.resolved_at, }; res.json({ issue: publicIssue, updates, attachments }); } catch (err) { console.error('Public get issue error:', err); res.status(500).json({ error: 'Failed to load issue' }); } }); // Public: Add comment to issue app.post('/api/public/issues/:token/comment', async (req, res) => { try { const { name, message } = req.body; if (!message) return res.status(400).json({ error: 'Message is required' }); const issues = await nocodb.list('Issues', { where: `(tracking_token,eq,${sanitizeWhereValue(req.params.token)})`, limit: 1, }); if (issues.length === 0) return res.status(404).json({ error: 'Issue not found' }); const update = await nocodb.create('IssueUpdates', { issue_id: issues[0].Id, message, is_public: true, author_name: name || 'Submitter', author_type: 'submitter', created_at: new Date().toISOString(), }); await nocodb.update('Issues', issues[0].Id, { updated_at: new Date().toISOString() }); res.json(update); } catch (err) { console.error('Public comment error:', err); res.status(500).json({ error: 'Failed to add comment' }); } }); // Public: Upload attachment to issue app.post('/api/public/issues/:token/attachments', dynamicUpload('file'), async (req, res) => { try { if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); const issues = await nocodb.list('Issues', { where: `(tracking_token,eq,${sanitizeWhereValue(req.params.token)})`, limit: 1, }); if (issues.length === 0) return res.status(404).json({ error: 'Issue not found' }); const attachment = await nocodb.create('IssueAttachments', { issue_id: issues[0].Id, filename: req.file.filename, original_name: req.file.originalname, mime_type: req.file.mimetype, size: req.file.size, uploaded_by: req.body.name || 'Submitter', created_at: new Date().toISOString(), }); res.json(attachment); } catch (err) { console.error('Public upload error:', err); res.status(500).json({ error: 'Failed to upload attachment' }); } }); app.use((err, req, res, next) => { console.error(`[ERROR] ${req.method} ${req.path}:`, err.message); res.status(500).json({ error: 'Internal server error', details: err.message }); }); process.on('uncaughtException', (err) => { console.error('[UNCAUGHT]', err.message); }); process.on('unhandledRejection', (err) => { console.error('[UNHANDLED REJECTION]', err); }); // ─── ROLES ────────────────────────────────────────────────────── app.get('/api/roles', requireAuth, async (req, res) => { try { const roles = await nocodb.list('Roles', { sort: 'name', limit: QUERY_LIMITS.medium }); res.json(roles.map(r => ({ ...r, id: r.Id, _id: r.Id }))); } catch (err) { console.error('Roles list error:', err); res.status(500).json({ error: 'Failed to load roles' }); } }); app.post('/api/roles', requireAuth, requireRole('superadmin'), async (req, res) => { const { name, color } = req.body; if (!name) return res.status(400).json({ error: 'Name is required' }); try { const created = await nocodb.create('Roles', { name, color: color || null }); const role = await nocodb.get('Roles', created.Id); res.status(201).json({ ...role, id: role.Id, _id: role.Id }); } catch (err) { console.error('Create role error:', err); res.status(500).json({ error: 'Failed to create role' }); } }); app.patch('/api/roles/:id', requireAuth, requireRole('superadmin'), async (req, res) => { try { const existing = await nocodb.get('Roles', req.params.id); if (!existing) return res.status(404).json({ error: 'Role not found' }); const data = {}; if (req.body.name !== undefined) data.name = req.body.name; if (req.body.color !== undefined) data.color = req.body.color; if (Object.keys(data).length > 0) await nocodb.update('Roles', req.params.id, data); const role = await nocodb.get('Roles', req.params.id); res.json({ ...role, id: role.Id, _id: role.Id }); } catch (err) { console.error('Update role error:', err); res.status(500).json({ error: 'Failed to update role' }); } }); app.delete('/api/roles/:id', requireAuth, requireRole('superadmin'), async (req, res) => { try { const existing = await nocodb.get('Roles', req.params.id); if (!existing) return res.status(404).json({ error: 'Role not found' }); // Check if any users have this role const usersWithRole = await nocodb.list('Users', { where: `(role_id,eq,${sanitizeWhereValue(req.params.id)})`, limit: 1 }); if (usersWithRole.length > 0) return res.status(409).json({ error: 'Cannot delete role that is assigned to users' }); await nocodb.delete('Roles', req.params.id); res.json({ success: true }); } catch (err) { console.error('Delete role error:', err); res.status(500).json({ error: 'Failed to delete role' }); } }); // ─── APP SETTINGS API ─────────────────────────────────────────── app.get('/api/settings/app', requireAuth, (req, res) => { res.json(appSettings); }); app.patch('/api/settings/app', requireAuth, requireRole('superadmin'), (req, res) => { const { uploadMaxSizeMB } = req.body; if (uploadMaxSizeMB !== undefined) { const val = Number(uploadMaxSizeMB); if (isNaN(val) || val < 1 || val > 500) { return res.status(400).json({ error: 'uploadMaxSizeMB must be between 1 and 500' }); } appSettings.uploadMaxSizeMB = val; } saveSettings(appSettings); res.json(appSettings); }); // ─── AUTH MIGRATION (one-time: auth.db → NocoDB) ──────────────── async function migrateAuthToNocoDB() { const authDbPath = path.join(__dirname, 'auth.db'); if (!fs.existsSync(authDbPath)) { console.log(' No auth.db found — skipping auth migration.'); return; } let Database; try { Database = require('better-sqlite3'); } catch { try { // Fallback: use sqlite3 CLI const { execSync } = require('child_process'); const raw = execSync(`sqlite3 "${authDbPath}" "SELECT email, password_hash, nocodb_user_id FROM auth_credentials;"`, { encoding: 'utf8' }); const rows = raw.trim().split('\n').filter(Boolean).map(line => { const [email, password_hash, nocodb_user_id] = line.split('|'); return { email, password_hash, nocodb_user_id: Number(nocodb_user_id) }; }); if (rows.length === 0) { console.log(' auth.db is empty — nothing to migrate.'); return; } let migrated = 0, skipped = 0; for (const cred of rows) { try { const user = await nocodb.get('Users', cred.nocodb_user_id); if (!user) { console.warn(` User ${cred.nocodb_user_id} (${cred.email}) not found in NocoDB — skipping.`); skipped++; continue; } if (user.password_hash) { skipped++; continue; } await nocodb.update('Users', cred.nocodb_user_id, { password_hash: cred.password_hash }); migrated++; } catch (err) { console.error(` Failed to migrate user ${cred.email}:`, err.message); } } console.log(` Auth migration: ${migrated} migrated, ${skipped} skipped.`); if (migrated > 0) { const bakPath = authDbPath + '.bak'; if (!fs.existsSync(bakPath)) { fs.renameSync(authDbPath, bakPath); console.log(' Renamed auth.db → auth.db.bak'); } } return; } catch (cliErr) { console.warn(' Cannot read auth.db (no better-sqlite3 or sqlite3 CLI):', cliErr.message); return; } } const db = new Database(authDbPath, { readonly: true }); try { const creds = db.prepare('SELECT email, password_hash, nocodb_user_id FROM auth_credentials').all(); if (creds.length === 0) { console.log(' auth.db is empty — nothing to migrate.'); return; } let migrated = 0, skipped = 0; for (const cred of creds) { try { const user = await nocodb.get('Users', cred.nocodb_user_id); if (!user) { console.warn(` User ${cred.nocodb_user_id} (${cred.email}) not found in NocoDB — skipping.`); skipped++; continue; } if (user.password_hash) { skipped++; continue; } await nocodb.update('Users', cred.nocodb_user_id, { password_hash: cred.password_hash }); migrated++; } catch (err) { console.error(` Failed to migrate user ${cred.email}:`, err.message); } } console.log(` Auth migration: ${migrated} migrated, ${skipped} skipped.`); if (migrated > 0) { const bakPath = authDbPath + '.bak'; if (!fs.existsSync(bakPath)) { fs.renameSync(authDbPath, bakPath); console.log(' Renamed auth.db → auth.db.bak'); } } } finally { db.close(); } } // ─── START SERVER ─────────────────────────────────────────────── async function startServer() { // Validate required env vars const REQUIRED_ENV = { NOCODB_URL: 'NocoDB base URL (e.g., http://localhost:8090)', NOCODB_TOKEN: 'NocoDB API token', NOCODB_BASE_ID: 'NocoDB base/project ID', }; const OPTIONAL_ENV = { SESSION_SECRET: 'Session encryption secret (required in production)', APP_URL: 'Public app URL for email links', CLOUDRON_MAIL_SMTP_SERVER: 'SMTP server for password reset emails', CORS_ORIGIN: 'Allowed CORS origin', }; let missingRequired = false; for (const [key, desc] of Object.entries(REQUIRED_ENV)) { if (!process.env[key]) { console.error(`MISSING required env var: ${key} — ${desc}`); missingRequired = true; } } if (missingRequired) { console.error('Cannot start server. Set required environment variables and retry.'); console.error('See .env.example for a template.'); process.exit(1); } for (const [key, desc] of Object.entries(OPTIONAL_ENV)) { if (!process.env[key]) console.warn(` Optional env var not set: ${key} — ${desc}`); } console.log('Ensuring required tables...'); await ensureRequiredTables(); console.log('Running FK column migration...'); await ensureFKColumns(); await ensureTextColumns(); await backfillFKs(); console.log('Checking auth migration...'); await migrateAuthToNocoDB(); console.log('Migration complete.'); // Verify critical columns exist (belt-and-suspenders check) try { const artTableId = await nocodb.resolveTableId('Artefacts'); const artMeta = await fetch(`${nocodb.url}/api/v2/meta/tables/${artTableId}`, { headers: { 'xc-token': nocodb.token }, }).then(r => r.json()); const artCols = (artMeta.columns || []).map(c => c.title); console.log(`Artefacts table columns: ${artCols.join(', ')}`); for (const needed of ['approver_ids', 'project_id', 'campaign_id']) { if (!artCols.includes(needed)) { console.warn(`⚠ MISSING column Artefacts.${needed} — creating now...`); const uidt = needed === 'approver_ids' ? 'SingleLineText' : 'Number'; const cr = await fetch(`${nocodb.url}/api/v2/meta/tables/${artTableId}/columns`, { method: 'POST', headers: { 'xc-token': nocodb.token, 'Content-Type': 'application/json' }, body: JSON.stringify({ title: needed, uidt }), }); console.log(` → ${needed}: ${cr.ok ? 'CREATED' : 'FAILED ' + cr.status}`); } } } catch (err) { console.error('Column verification failed:', err.message); } // In production, serve the built frontend and handle SPA routing if (process.env.NODE_ENV === 'production') { const clientDist = path.join(__dirname, '..', 'client', 'dist'); app.use(express.static(clientDist)); // OG meta tags for public review links (WhatsApp, Slack, etc.) app.get('/review-post/:token', async (req, res) => { try { const indexHtml = require('fs').readFileSync(path.join(clientDist, 'index.html'), 'utf-8'); const posts = await nocodb.list('Posts', { where: `(approval_token,eq,${sanitizeWhereValue(req.params.token)})`, limit: 1, }); if (posts.length === 0) return res.send(indexHtml); const post = posts[0]; // Find first image attachment for thumbnail let ogImage = ''; try { const attachments = await nocodb.list('PostAttachments', { where: `(post_id,eq,${post.Id})`, limit: 10, }); const img = attachments.find(a => (a.mime_type || '').startsWith('image/')); if (img) { const baseUrl = `${req.protocol}://${req.get('host')}`; ogImage = img.url?.startsWith('http') ? img.url : `${baseUrl}${img.url}`; } } catch (e) { /* no attachments */ } const title = post.title || 'Post Review'; const desc = post.description ? post.description.slice(0, 200) : 'Review and approve this post'; const escape = s => s.replace(/&/g, '&').replace(/"/g, '"').replace(/ ${ogImage ? ` ` : ''} `; const html = indexHtml.replace('', ogTags + '\n '); res.send(html); } catch (err) { console.error('OG meta error:', err.message); res.sendFile(path.join(clientDist, 'index.html')); } }); app.get('*', (req, res) => { if (!req.path.startsWith('/api')) { res.sendFile(path.join(clientDist, 'index.html')); } }); } app.listen(PORT, () => { console.log(`Digital Hub API running on http://localhost:${PORT}`); console.log(`Uploads directory: ${uploadsDir}`); }); } startServer();