All checks were successful
Deploy / deploy (push) Successful in 11s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
4079 lines
159 KiB
JavaScript
4079 lines
159 KiB
JavaScript
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 { authDb } = require('./auth-db');
|
|
|
|
const app = express();
|
|
const PORT = 3001;
|
|
|
|
// ─── HELPERS ────────────────────────────────────────────────────
|
|
|
|
// Extract first linked record ID from NocoDB linked field
|
|
function linkId(record, field) {
|
|
const val = record?.[field];
|
|
if (Array.isArray(val) && val.length > 0) return val[0].Id;
|
|
if (val && typeof val === 'object' && val.Id) return val.Id;
|
|
return null;
|
|
}
|
|
|
|
// Extract first linked record name
|
|
function linkName(record, field) {
|
|
const val = record?.[field];
|
|
if (Array.isArray(val) && val.length > 0) return val[0].name || val[0].title || val[0].Name || null;
|
|
if (val && typeof val === 'object') return val.name || val.title || val.Name || null;
|
|
return null;
|
|
}
|
|
|
|
// Ensure uploads directory exists
|
|
const uploadsDir = path.join(__dirname, 'uploads');
|
|
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: process.env.SESSION_SECRET || 'dev-fallback-secret',
|
|
resave: false,
|
|
saveUninitialized: false,
|
|
cookie: {
|
|
secure: process.env.NODE_ENV === 'production',
|
|
httpOnly: true,
|
|
maxAge: 7 * 24 * 60 * 60 * 1000
|
|
}
|
|
}));
|
|
|
|
// Serve uploaded files
|
|
app.use('/api/uploads', express.static(uploadsDir));
|
|
|
|
// ─── APP SETTINGS (persisted to JSON) ────────────────────────────
|
|
const settingsPath = path.join(__dirname, 'app-settings.json');
|
|
const defaultSettings = { uploadMaxSizeMB: 50 };
|
|
function loadSettings() {
|
|
try { return { ...defaultSettings, ...JSON.parse(fs.readFileSync(settingsPath, 'utf8')) }; }
|
|
catch { return { ...defaultSettings }; }
|
|
}
|
|
function saveSettings(s) { fs.writeFileSync(settingsPath, 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();
|
|
};
|
|
}
|
|
|
|
// NocoDB table name mapping for ownership checks
|
|
const TABLE_NAME_MAP = { posts: 'Posts', tasks: 'Tasks', campaigns: 'Campaigns', projects: 'Projects' };
|
|
|
|
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();
|
|
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' });
|
|
}
|
|
};
|
|
}
|
|
|
|
// ─── LINK HELPER (legacy, used only for migration) ─────────────
|
|
|
|
async function linkRecord(table, recordId, linkField, linkedRecordId) {
|
|
if (!linkedRecordId) return;
|
|
const info = await nocodb.getLinkColId(table, linkField);
|
|
if (!info) return;
|
|
await fetch(`${nocodb.url}/api/v2/tables/${info.tableId}/links/${info.colId}/records/${recordId}`, {
|
|
method: 'POST',
|
|
headers: { 'xc-token': nocodb.token, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify([{ Id: linkedRecordId }]),
|
|
});
|
|
}
|
|
|
|
async function unlinkRecord(table, recordId, linkField, linkedRecordId) {
|
|
if (!linkedRecordId) return;
|
|
const info = await nocodb.getLinkColId(table, linkField);
|
|
if (!info) return;
|
|
await fetch(`${nocodb.url}/api/v2/tables/${info.tableId}/links/${info.colId}/records/${recordId}`, {
|
|
method: 'DELETE',
|
|
headers: { 'xc-token': nocodb.token, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify([{ Id: linkedRecordId }]),
|
|
});
|
|
}
|
|
|
|
// ─── 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'],
|
|
Campaigns: ['brand_id', 'created_by_user_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'],
|
|
};
|
|
|
|
// 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' },
|
|
],
|
|
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' },
|
|
],
|
|
};
|
|
|
|
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' }],
|
|
Tasks: [{ name: 'thumbnail', uidt: 'SingleLineText' }],
|
|
Users: [{ name: 'modules', uidt: 'LongText' }],
|
|
BudgetEntries: [{ name: 'project_id', uidt: 'Number' }, { name: 'destination', uidt: 'SingleLineText' }, { name: 'type', uidt: 'SingleLineText' }],
|
|
Comments: [{ name: 'version_number', uidt: 'Number' }],
|
|
Artefacts: [{ name: 'approver_ids', uidt: 'SingleLineText' }],
|
|
};
|
|
|
|
const ALL_MODULES = ['marketing', 'projects', 'finance'];
|
|
|
|
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: 10000, links: linkFields });
|
|
let updated = 0;
|
|
|
|
for (const record of records) {
|
|
const patch = {};
|
|
let needsUpdate = false;
|
|
|
|
for (const [linkCol, fkField] of Object.entries(linkMap)) {
|
|
const linkedId = linkId(record, linkCol);
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Name lookup helper — fetches a record's display name by table and ID
|
|
const _nameCache = {};
|
|
async function getRecordName(table, id) {
|
|
if (!id) return null;
|
|
const key = `${table}:${id}`;
|
|
if (_nameCache[key] !== undefined) return _nameCache[key];
|
|
try {
|
|
const r = await nocodb.get(table, id);
|
|
const name = r?.name || r?.title || r?.Name || null;
|
|
_nameCache[key] = name;
|
|
return name;
|
|
} catch {
|
|
_nameCache[key] = null;
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Clear name cache periodically (every 60s)
|
|
setInterval(() => { Object.keys(_nameCache).forEach(k => delete _nameCache[k]); }, 60000);
|
|
|
|
// ─── SETUP ROUTES ───────────────────────────────────────────────
|
|
|
|
app.get('/api/setup/status', (req, res) => {
|
|
const count = authDb.prepare('SELECT COUNT(*) as cnt FROM auth_credentials').get().cnt;
|
|
res.json({ needsSetup: count === 0 });
|
|
});
|
|
|
|
app.post('/api/setup', async (req, res) => {
|
|
const count = authDb.prepare('SELECT COUNT(*) as cnt FROM auth_credentials').get().cnt;
|
|
if (count > 0) return res.status(403).json({ error: 'Setup already completed' });
|
|
|
|
const { name, email, password } = req.body;
|
|
if (!name || !email || !password) return res.status(400).json({ error: 'Name, email, and password are required' });
|
|
|
|
try {
|
|
const created = await nocodb.create('Users', { name, email, role: 'superadmin' });
|
|
const passwordHash = await bcrypt.hash(password, 10);
|
|
authDb.prepare('INSERT INTO auth_credentials (email, password_hash, nocodb_user_id) VALUES (?, ?, ?)').run(email, passwordHash, created.Id);
|
|
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 cred = authDb.prepare('SELECT * FROM auth_credentials WHERE email = ?').get(email);
|
|
if (!cred) return res.status(401).json({ error: 'Invalid email or password' });
|
|
|
|
const valid = await bcrypt.compare(password, cred.password_hash);
|
|
if (!valid) return res.status(401).json({ error: 'Invalid email or password' });
|
|
|
|
// Fetch profile from NocoDB
|
|
const user = await nocodb.get('Users', cred.nocodb_user_id);
|
|
if (!user) return res.status(401).json({ error: 'User profile not found' });
|
|
|
|
req.session.userId = user.Id;
|
|
req.session.userEmail = user.email;
|
|
req.session.userRole = user.role;
|
|
req.session.userName = user.name;
|
|
|
|
let modules = ALL_MODULES;
|
|
if (user.role !== 'superadmin' && user.modules) {
|
|
try { modules = JSON.parse(user.modules); } catch { modules = 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,
|
|
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.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' });
|
|
let modules = ALL_MODULES;
|
|
if (user.role !== 'superadmin' && user.modules) {
|
|
try { modules = JSON.parse(user.modules); } catch { modules = 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,
|
|
CreatedAt: user.CreatedAt, created_at: user.CreatedAt,
|
|
profileComplete: !!user.team_role,
|
|
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 cred = authDb.prepare('SELECT * FROM auth_credentials WHERE nocodb_user_id = ?').get(req.session.userId);
|
|
if (!cred) return res.status(404).json({ error: 'Credentials not found' });
|
|
|
|
const valid = await bcrypt.compare(currentPassword, cred.password_hash);
|
|
if (!valid) return res.status(401).json({ error: 'Current password is incorrect' });
|
|
|
|
const hash = await bcrypt.hash(newPassword, 10);
|
|
authDb.prepare('UPDATE auth_credentials SET password_hash = ? WHERE nocodb_user_id = ?').run(hash, req.session.userId);
|
|
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' });
|
|
}
|
|
});
|
|
|
|
// ─── USER MANAGEMENT (Superadmin only) ──────────────────────────
|
|
|
|
app.get('/api/users', requireAuth, requireRole('superadmin'), async (req, res) => {
|
|
try {
|
|
const users = await nocodb.list('Users', { sort: '-CreatedAt' });
|
|
res.json(users);
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Failed to load users' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/users', requireAuth, requireRole('superadmin'), async (req, res) => {
|
|
const { name, email, password, role, avatar, team_role, brands, phone, modules } = req.body;
|
|
if (!name || !email || !role) return res.status(400).json({ error: 'Name, email, and role are required' });
|
|
if (!['superadmin', 'manager', 'contributor'].includes(role)) return res.status(400).json({ error: 'Invalid role' });
|
|
|
|
try {
|
|
const existing = authDb.prepare('SELECT id FROM auth_credentials WHERE email = ?').get(email);
|
|
if (existing) return res.status(409).json({ error: 'Email already exists' });
|
|
|
|
const created = await nocodb.create('Users', {
|
|
name, email, role, avatar: avatar || null,
|
|
team_role: team_role || null,
|
|
brands: JSON.stringify(brands || []),
|
|
phone: phone || null,
|
|
modules: JSON.stringify(modules || ALL_MODULES),
|
|
});
|
|
const defaultPassword = password || 'changeme123';
|
|
const passwordHash = await bcrypt.hash(defaultPassword, 10);
|
|
authDb.prepare('INSERT INTO auth_credentials (email, password_hash, nocodb_user_id) VALUES (?, ?, ?)').run(email, passwordHash, created.Id);
|
|
const user = await nocodb.get('Users', created.Id);
|
|
res.status(201).json({ ...user, id: user.Id, _id: user.Id });
|
|
} catch (err) {
|
|
console.error('Create user error:', err);
|
|
res.status(500).json({ error: 'Failed to create user' });
|
|
}
|
|
});
|
|
|
|
app.patch('/api/users/:id', requireAuth, requireRole('superadmin'), async (req, res) => {
|
|
const { id } = req.params;
|
|
try {
|
|
const existing = await nocodb.get('Users', id);
|
|
if (!existing) return res.status(404).json({ error: 'User not found' });
|
|
if (req.body.role && !['superadmin', 'manager', 'contributor'].includes(req.body.role)) return res.status(400).json({ error: 'Invalid role' });
|
|
|
|
const data = {};
|
|
for (const f of ['name', 'email', 'role', 'avatar', 'team_role', 'phone']) {
|
|
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.password) {
|
|
const hash = await bcrypt.hash(req.body.password, 10);
|
|
authDb.prepare('UPDATE auth_credentials SET password_hash = ? WHERE nocodb_user_id = ?').run(hash, Number(id));
|
|
}
|
|
if (req.body.email && req.body.email !== existing.email) {
|
|
authDb.prepare('UPDATE auth_credentials SET email = ? WHERE nocodb_user_id = ?').run(req.body.email, Number(id));
|
|
}
|
|
|
|
if (Object.keys(data).length > 0) await nocodb.update('Users', id, data);
|
|
const user = await nocodb.get('Users', id);
|
|
res.json(user);
|
|
} catch (err) {
|
|
console.error('Update user error:', err);
|
|
res.status(500).json({ error: 'Failed to update user' });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/users/:id', requireAuth, requireRole('superadmin'), async (req, res) => {
|
|
const { id } = req.params;
|
|
if (Number(id) === req.session.userId) return res.status(400).json({ error: 'Cannot delete your own account' });
|
|
try {
|
|
const user = await nocodb.get('Users', id);
|
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
|
await nocodb.delete('Users', id);
|
|
authDb.prepare('DELETE FROM auth_credentials WHERE nocodb_user_id = ?').run(Number(id));
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Failed to delete user' });
|
|
}
|
|
});
|
|
|
|
// ─── ASSIGNABLE USERS ───────────────────────────────────────────
|
|
|
|
app.get('/api/users/assignable', requireAuth, async (req, res) => {
|
|
try {
|
|
const users = await nocodb.list('Users', {
|
|
sort: 'name',
|
|
});
|
|
res.json(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 {}
|
|
|
|
filtered = users.filter(u => {
|
|
let theirBrands = [];
|
|
try { theirBrands = JSON.parse(u.brands || '[]'); } catch {}
|
|
return u.Id === req.session.userId || theirBrands.some(b => myBrands.includes(b));
|
|
});
|
|
}
|
|
|
|
// Attach teams to each user
|
|
let allTeamMembers = [];
|
|
let allTeams = [];
|
|
try {
|
|
allTeamMembers = await nocodb.list('TeamMembers', { limit: 10000 });
|
|
allTeams = await nocodb.list('Teams', { limit: 500 });
|
|
} catch {}
|
|
const teamMap = {};
|
|
for (const t of allTeams) teamMap[t.Id] = t.name;
|
|
|
|
res.json(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 };
|
|
}));
|
|
} 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 } = 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 role' });
|
|
}
|
|
|
|
try {
|
|
const existing = authDb.prepare('SELECT id FROM auth_credentials WHERE email = ?').get(email);
|
|
if (existing) return res.status(409).json({ error: 'Email already exists' });
|
|
|
|
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),
|
|
});
|
|
|
|
const defaultPassword = password || 'changeme123';
|
|
const passwordHash = await bcrypt.hash(defaultPassword, 10);
|
|
authDb.prepare('INSERT INTO auth_credentials (email, password_hash, nocodb_user_id) VALUES (?, ?, ?)').run(email, passwordHash, created.Id);
|
|
|
|
const user = await nocodb.get('Users', created.Id);
|
|
res.status(201).json({ ...user, id: user.Id, _id: user.Id });
|
|
} 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']) {
|
|
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 (Object.keys(data).length === 0) return res.status(400).json({ error: 'No fields to update' });
|
|
|
|
if (data.email && data.email !== existing.email) {
|
|
authDb.prepare('UPDATE auth_credentials SET email = ? WHERE nocodb_user_id = ?').run(data.email, Number(req.params.id));
|
|
}
|
|
|
|
await nocodb.update('Users', req.params.id, data);
|
|
const user = await nocodb.get('Users', req.params.id);
|
|
res.json({ ...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) => {
|
|
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);
|
|
authDb.prepare('DELETE FROM auth_credentials WHERE nocodb_user_id = ?').run(Number(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(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: 200 });
|
|
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: 10000 });
|
|
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,${status})`);
|
|
if (platform) whereParts.push(`(platform,eq,${platform})`);
|
|
if (brand_id) whereParts.push(`(brand_id,eq,${brand_id})`);
|
|
if (assigned_to) whereParts.push(`(assigned_to_id,eq,${assigned_to})`);
|
|
if (campaign_id) whereParts.push(`(campaign_id,eq,${campaign_id})`);
|
|
|
|
const where = whereParts.length > 0 ? whereParts.join('~and') : undefined;
|
|
const posts = await nocodb.list('Posts', { where, sort: '-UpdatedAt', limit: 500 });
|
|
|
|
// Visibility filtering for contributors
|
|
let filtered = posts;
|
|
if (req.session.userRole === 'contributor') {
|
|
filtered = filtered.filter(p =>
|
|
p.created_by_user_id === req.session.userId || p.assigned_to_id === req.session.userId
|
|
);
|
|
}
|
|
|
|
// Get thumbnails
|
|
const allAttachments = await nocodb.list('PostAttachments', {
|
|
where: "(mime_type,like,image/%)",
|
|
limit: 10000,
|
|
});
|
|
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);
|
|
}
|
|
const names = {};
|
|
for (const id of brandIds) names[`brand:${id}`] = await getRecordName('Brands', id);
|
|
for (const id of userIds) names[`user:${id}`] = await getRecordName('Users', id);
|
|
for (const id of campaignIds) names[`campaign:${id}`] = await getRecordName('Campaigns', id);
|
|
|
|
res.json(filtered.map(p => ({
|
|
...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,
|
|
})));
|
|
} 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 } = 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,
|
|
created_by_user_id: req.session.userId,
|
|
});
|
|
|
|
const post = await nocodb.get('Posts', created.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),
|
|
});
|
|
} catch (err) {
|
|
console.error('Create post error:', err);
|
|
res.status(500).json({ error: 'Failed to create post' });
|
|
}
|
|
});
|
|
|
|
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;
|
|
|
|
// Publish validation
|
|
if (req.body.status === 'published') {
|
|
let currentPlatforms, currentLinks;
|
|
try { currentPlatforms = req.body.platforms || JSON.parse(existing.platforms || '[]'); } catch { currentPlatforms = []; }
|
|
try { currentLinks = req.body.publication_links || JSON.parse(existing.publication_links || '[]'); } catch { currentLinks = []; }
|
|
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);
|
|
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),
|
|
});
|
|
} 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,${req.params.id})`,
|
|
sort: '-CreatedAt', limit: 1000,
|
|
});
|
|
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' });
|
|
}
|
|
});
|
|
|
|
// ─── ASSETS ─────────────────────────────────────────────────────
|
|
|
|
app.get('/api/assets', requireAuth, async (req, res) => {
|
|
try {
|
|
const { folder, tags } = req.query;
|
|
const whereParts = [];
|
|
if (folder) whereParts.push(`(folder,eq,${folder})`);
|
|
if (req.query.brand_id) whereParts.push(`(brand_id,eq,${req.query.brand_id})`);
|
|
if (req.query.campaign_id) whereParts.push(`(campaign_id,eq,${req.query.campaign_id})`);
|
|
const where = whereParts.length > 0 ? whereParts.join('~and') : undefined;
|
|
|
|
let assets = await nocodb.list('Assets', { where, sort: '-CreatedAt', limit: 1000 });
|
|
|
|
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 = {};
|
|
for (const id of brandIds) names[`brand:${id}`] = await getRecordName('Brands', id);
|
|
for (const id of campaignIds) names[`campaign:${id}`] = await getRecordName('Campaigns', id);
|
|
for (const id of userIds) names[`user:${id}`] = await getRecordName('Users', id);
|
|
|
|
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' });
|
|
}
|
|
});
|
|
|
|
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,${status})`);
|
|
const where = whereParts.length > 0 ? whereParts.join('~and') : undefined;
|
|
|
|
let campaigns = await nocodb.list('Campaigns', { where, sort: '-start_date', limit: 500 });
|
|
|
|
// Filter by brand
|
|
if (req.query.brand_id) {
|
|
campaigns = campaigns.filter(c => c.brand_id === Number(req.query.brand_id));
|
|
}
|
|
|
|
// Non-superadmin scoping
|
|
if (req.session.userRole !== 'superadmin') {
|
|
const userId = req.session.userId;
|
|
const myCampaignIds = await getUserCampaignIds(userId);
|
|
campaigns = campaigns.filter(c => {
|
|
return c.created_by_user_id === userId || myCampaignIds.has(c.Id);
|
|
});
|
|
}
|
|
|
|
// Enrich with names
|
|
const brandNames = {}, userNames = {};
|
|
for (const c of campaigns) {
|
|
if (c.brand_id && !brandNames[c.brand_id]) brandNames[c.brand_id] = await getRecordName('Brands', c.brand_id);
|
|
if (c.created_by_user_id && !userNames[c.created_by_user_id]) userNames[c.created_by_user_id] = await getRecordName('Users', c.created_by_user_id);
|
|
}
|
|
|
|
res.json(campaigns.map(c => ({
|
|
...c,
|
|
brand_name: brandNames[c.brand_id] || null,
|
|
creator_user_name: userNames[c.created_by_user_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: 10000 });
|
|
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);
|
|
res.json({ ...campaign, brand_name: brandName });
|
|
} 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 } = 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,
|
|
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),
|
|
});
|
|
} 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 (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) });
|
|
} 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: 10000 });
|
|
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: 10000 });
|
|
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: 10000 });
|
|
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: 10000 });
|
|
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,${req.params.id})`,
|
|
limit: 10000,
|
|
});
|
|
|
|
// 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 {}
|
|
}
|
|
if (assignerId) {
|
|
try {
|
|
const u = await nocodb.get('Users', assignerId);
|
|
assignerName = u.name;
|
|
} catch {}
|
|
}
|
|
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,${req.params.id})`,
|
|
limit: 10000,
|
|
});
|
|
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,${req.params.id})`,
|
|
limit: 10000,
|
|
});
|
|
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 { 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,${req.params.id})~and(member_id,eq,${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: 10000 });
|
|
|
|
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 cNames = {};
|
|
for (const id of campaignIds) cNames[id] = await getRecordName('Campaigns', id);
|
|
const pNames = {};
|
|
for (const id of projectIds) pNames[id] = await getRecordName('Projects', id);
|
|
|
|
res.json(entries.map(e => ({
|
|
...e,
|
|
campaign_name: cNames[e.campaign_id] || null,
|
|
project_name: pNames[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: 10000 });
|
|
let budgetEntries = await nocodb.list('BudgetEntries', { limit: 10000 });
|
|
|
|
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: 10000 });
|
|
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: 10000 });
|
|
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,${req.params.id})`,
|
|
sort: 'CreatedAt', limit: 1000,
|
|
});
|
|
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,${req.params.id})`,
|
|
sort: '-CreatedAt', limit: 10000,
|
|
});
|
|
|
|
const allAttachments = await nocodb.list('PostAttachments', {
|
|
where: "(mime_type,like,image/%)", limit: 10000,
|
|
});
|
|
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 = {};
|
|
for (const id of brandIds) names[`brand:${id}`] = await getRecordName('Brands', id);
|
|
for (const id of userIds) names[`user:${id}`] = await getRecordName('Users', id);
|
|
for (const id of trackIds) names[`track:${id}`] = await getRecordName('CampaignTracks', id);
|
|
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,${status})`);
|
|
if (req.query.brand_id) whereParts.push(`(brand_id,eq,${req.query.brand_id})`);
|
|
if (req.query.owner_id) whereParts.push(`(owner_id,eq,${req.query.owner_id})`);
|
|
const where = whereParts.length > 0 ? whereParts.join('~and') : undefined;
|
|
|
|
const projects = await nocodb.list('Projects', { where, sort: '-CreatedAt', limit: 500 });
|
|
|
|
const brandIds = new Set(), userIds = 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);
|
|
}
|
|
const names = {};
|
|
for (const id of brandIds) names[`brand:${id}`] = await getRecordName('Brands', id);
|
|
for (const id of userIds) names[`user:${id}`] = await getRecordName('Users', id);
|
|
|
|
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,
|
|
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),
|
|
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 } = 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,
|
|
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),
|
|
});
|
|
} 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']) {
|
|
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 (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),
|
|
});
|
|
} 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,${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,${req.query.project_id})`);
|
|
if (req.query.assigned_to) whereParts.push(`(assigned_to_id,eq,${req.query.assigned_to})`);
|
|
if (req.query.priority) whereParts.push(`(priority,eq,${req.query.priority})`);
|
|
if (req.query.created_by) whereParts.push(`(created_by_user_id,eq,${req.query.created_by})`);
|
|
const where = whereParts.length > 0 ? whereParts.join('~and') : undefined;
|
|
|
|
let tasks = await nocodb.list('Tasks', { where, sort: '-CreatedAt', limit: 10000 });
|
|
|
|
// Visibility filtering for contributors
|
|
if (req.session.userRole === 'contributor') {
|
|
tasks = tasks.filter(t =>
|
|
t.created_by_user_id === req.session.userId || t.assigned_to_id === req.session.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 = {};
|
|
for (const id of projectIds) names[`project:${id}`] = await getRecordName('Projects', id);
|
|
for (const id of userIds) names[`user:${id}`] = await getRecordName('Users', id);
|
|
|
|
// 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 {}
|
|
}
|
|
|
|
// 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: 50000,
|
|
});
|
|
for (const c of allComments) {
|
|
commentCounts[c.entity_id] = (commentCounts[c.entity_id] || 0) + 1;
|
|
}
|
|
} catch {}
|
|
|
|
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,
|
|
})));
|
|
} 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,${req.params.memberId})`,
|
|
limit: 10000,
|
|
});
|
|
|
|
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 = {};
|
|
for (const id of projectIds) names[`project:${id}`] = await getRecordName('Projects', id);
|
|
for (const id of userIds) names[`user:${id}`] = await getRecordName('Users', id);
|
|
|
|
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),
|
|
});
|
|
} catch (err) {
|
|
console.error('Create task error:', err);
|
|
res.status(500).json({ error: 'Failed to create task' });
|
|
}
|
|
});
|
|
|
|
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']) {
|
|
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),
|
|
});
|
|
} 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,${req.params.id})`,
|
|
sort: '-CreatedAt', limit: 1000,
|
|
});
|
|
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: 10000 }),
|
|
nocodb.list('Campaigns', { limit: 10000 }),
|
|
nocodb.list('Tasks', { limit: 10000 }),
|
|
nocodb.list('Projects', { limit: 10000 }),
|
|
nocodb.list('Users', { limit: 1000 }),
|
|
nocodb.list('CampaignAssignments', { limit: 10000 }),
|
|
]);
|
|
|
|
// Build user's campaign IDs for scoping
|
|
let myCampaignIds;
|
|
if (!isSuperadmin) {
|
|
myCampaignIds = new Set();
|
|
for (const c of allCampaigns) {
|
|
if (c.created_by_user_id === userId) myCampaignIds.add(c.Id);
|
|
}
|
|
for (const a of allAssignments) {
|
|
if (a.member_id === userId && a.campaign_id) {
|
|
myCampaignIds.add(a.campaign_id);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Posts
|
|
let posts = allPosts;
|
|
if (!isSuperadmin) {
|
|
posts = allPosts.filter(p => !p.campaign_id || myCampaignIds.has(p.campaign_id));
|
|
}
|
|
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));
|
|
}
|
|
const activeCampaigns = campaigns.filter(c => c.status === 'active').length;
|
|
|
|
// Tasks
|
|
let tasks = allTasks;
|
|
if (!isSuperadmin) {
|
|
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 => p.created_by_user_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 ─────────────────────────────────────
|
|
|
|
const COMMENT_ENTITY_TYPES = new Set(['post', 'task', 'project', 'campaign', 'asset']);
|
|
|
|
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,${entityType})~and(entity_id,eq,${entityId})`,
|
|
sort: 'CreatedAt', limit: 1000,
|
|
});
|
|
|
|
// 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 {}
|
|
}
|
|
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: 10000 }),
|
|
nocodb.list('CampaignAssignments', { limit: 10000 }),
|
|
]);
|
|
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: 500 });
|
|
const members = await nocodb.list('TeamMembers', { limit: 10000 });
|
|
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,${req.params.id})`, limit: 10000 });
|
|
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,${req.params.id})`, limit: 10000 });
|
|
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,${req.params.id})`, limit: 10000 });
|
|
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,${req.params.id})~and(user_id,eq,${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,${brand})`);
|
|
if (status) whereParts.push(`(status,eq,${status})`);
|
|
if (post) whereParts.push(`(post_id,eq,${post})`);
|
|
const where = whereParts.length > 0 ? whereParts.join('~and') : undefined;
|
|
|
|
let artefacts = await nocodb.list('Artefacts', { where, sort: '-UpdatedAt', limit: 500 });
|
|
|
|
// Filter by permission: contributors see only their own
|
|
if (req.session.userRole === 'contributor') {
|
|
artefacts = artefacts.filter(a => a.created_by_user_id === req.session.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 = {};
|
|
for (const id of brandIds) names[`brand:${id}`] = await getRecordName('Brands', id);
|
|
for (const id of userIds) names[`user:${id}`] = await getRecordName('Users', id);
|
|
for (const id of postIds) names[`post:${id}`] = await getRecordName('Posts', id);
|
|
for (const id of projectIds) names[`project:${id}`] = await getRecordName('Projects', id);
|
|
for (const id of campaignIds) names[`campaign:${id}`] = await getRecordName('Campaigns', id);
|
|
|
|
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' });
|
|
}
|
|
});
|
|
|
|
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,${req.params.id})`,
|
|
limit: 1000,
|
|
});
|
|
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() + 7); // 7 days from now
|
|
|
|
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() });
|
|
} 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,${req.params.id})`,
|
|
sort: 'version_number',
|
|
limit: 1000,
|
|
});
|
|
|
|
// 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,${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: 1000,
|
|
});
|
|
|
|
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,${req.params.versionId})`,
|
|
limit: 1000,
|
|
}),
|
|
nocodb.list('ArtefactAttachments', {
|
|
where: `(version_id,eq,${req.params.versionId})`,
|
|
limit: 1000,
|
|
}),
|
|
]);
|
|
|
|
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,${req.params.versionId})~and(language_code,eq,${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,${req.params.id})~and(version_number,eq,${version.version_number})`,
|
|
sort: 'CreatedAt',
|
|
limit: 1000,
|
|
});
|
|
|
|
// 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 {}
|
|
}
|
|
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,${req.params.id})`,
|
|
sort: '-CreatedAt',
|
|
limit: 1000,
|
|
});
|
|
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,${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: 1000,
|
|
}),
|
|
nocodb.list('ArtefactAttachments', {
|
|
where: `(version_id,eq,${version.Id})`,
|
|
limit: 1000,
|
|
}),
|
|
]);
|
|
} else {
|
|
// Legacy support: get all attachments if no version found
|
|
attachments = await nocodb.list('ArtefactAttachments', {
|
|
where: `(artefact_id,eq,${artefact.Id})`,
|
|
limit: 1000,
|
|
});
|
|
}
|
|
|
|
// 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: 1000,
|
|
});
|
|
|
|
res.json({
|
|
...artefact,
|
|
brand_name: await getRecordName('Brands', artefact.brand_id),
|
|
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,${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' });
|
|
} 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,${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' });
|
|
} 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 } = req.body;
|
|
try {
|
|
const artefacts = await nocodb.list('Artefacts', {
|
|
where: `(approval_token,eq,${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',
|
|
feedback: feedback || '',
|
|
});
|
|
|
|
res.json({ success: true, message: 'Revision requested' });
|
|
} 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,${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' });
|
|
}
|
|
});
|
|
|
|
// ─── 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, sort } = req.query;
|
|
const conditions = [];
|
|
if (status) conditions.push({ field: 'status', op: 'eq', value: status });
|
|
if (category) conditions.push({ field: 'category', op: 'eq', value: category });
|
|
if (type) conditions.push({ field: 'type', op: 'eq', value: type });
|
|
if (priority) conditions.push({ field: 'priority', op: 'eq', value: priority });
|
|
if (assigned_to_id) conditions.push({ field: 'assigned_to_id', op: 'eq', value: assigned_to_id });
|
|
if (brand_id) conditions.push({ field: 'brand_id', op: 'eq', value: brand_id });
|
|
|
|
const issues = await nocodb.list('Issues', {
|
|
where: conditions,
|
|
sort: sort || '-created_at',
|
|
});
|
|
|
|
// Resolve brand names
|
|
const brands = {};
|
|
for (const issue of issues) {
|
|
if (issue.brand_id && !brands[issue.brand_id]) {
|
|
try { brands[issue.brand_id] = (await nocodb.get('Brands', issue.brand_id)).name; } catch { brands[issue.brand_id] = null; }
|
|
}
|
|
issue.brand_name = brands[issue.brand_id] || 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' });
|
|
}
|
|
});
|
|
|
|
// 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,${req.params.id})`, sort: '-created_at' }),
|
|
nocodb.list('IssueAttachments', { where: `(issue_id,eq,${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 {}
|
|
}
|
|
|
|
res.json({ ...issue, brand_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 { status, assigned_to_id, internal_notes, resolution_summary, priority, category, brand_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 (status === 'resolved' || status === 'declined') {
|
|
updates.resolved_at = new Date().toISOString();
|
|
}
|
|
|
|
await nocodb.update('Issues', req.params.id, updates);
|
|
res.json({ success: true });
|
|
} 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,${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' });
|
|
}
|
|
});
|
|
|
|
// ─── PUBLIC ISSUE ROUTES (NO AUTH) ──────────────────────────────
|
|
|
|
// Public: Submit new issue
|
|
app.post('/api/public/issues', dynamicUpload('file'), async (req, res) => {
|
|
try {
|
|
const { name, email, phone, title, description, type, category, priority } = 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,
|
|
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,${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,${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,${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);
|
|
});
|
|
|
|
// ─── 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);
|
|
});
|
|
|
|
// ─── START SERVER ───────────────────────────────────────────────
|
|
|
|
async function startServer() {
|
|
console.log('Ensuring required tables...');
|
|
await ensureRequiredTables();
|
|
console.log('Running FK column migration...');
|
|
await ensureFKColumns();
|
|
await ensureTextColumns();
|
|
await backfillFKs();
|
|
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));
|
|
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();
|