update on timeline on portfolio view + some corrections
This commit is contained in:
3
server/.env
Normal file
3
server/.env
Normal file
@@ -0,0 +1,3 @@
|
||||
NOCODB_URL=http://localhost:8090
|
||||
NOCODB_TOKEN=By-wCdkUm6N9JdfmNpGH2jd6LqEejwOXER7FMkgr
|
||||
NOCODB_BASE_ID=p37fzfdy2erdcle
|
||||
18
server/auth-db.js
Normal file
18
server/auth-db.js
Normal file
@@ -0,0 +1,18 @@
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
|
||||
const AUTH_DB_PATH = path.join(__dirname, 'auth.db');
|
||||
|
||||
const authDb = new Database(AUTH_DB_PATH);
|
||||
authDb.pragma('journal_mode = WAL');
|
||||
|
||||
authDb.exec(`
|
||||
CREATE TABLE IF NOT EXISTS auth_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
nocodb_user_id INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
module.exports = { authDb };
|
||||
458
server/migrate-data.js
Normal file
458
server/migrate-data.js
Normal file
@@ -0,0 +1,458 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* migrate-data.js — One-time migration from SQLite to NocoDB.
|
||||
* Run: node migrate-data.js
|
||||
*/
|
||||
|
||||
require('dotenv').config({ path: __dirname + '/.env' });
|
||||
const Database = require('better-sqlite3');
|
||||
const bcrypt = require('bcrypt');
|
||||
const path = require('path');
|
||||
const nocodb = require('./nocodb');
|
||||
const { authDb } = require('./auth-db');
|
||||
|
||||
const sqliteDb = new Database(path.join(__dirname, 'marketing.db'), { readonly: true });
|
||||
|
||||
// ID mapping: { tableName: { oldId: newNocoDbId } }
|
||||
const idMap = {};
|
||||
function mapId(table, oldId, newId) {
|
||||
if (!idMap[table]) idMap[table] = {};
|
||||
idMap[table][oldId] = newId;
|
||||
}
|
||||
function getMappedId(table, oldId) {
|
||||
if (!oldId) return null;
|
||||
return idMap[table]?.[oldId] || null;
|
||||
}
|
||||
|
||||
// Build a lookup: team_member_id → user row
|
||||
function buildTeamMemberToUserMap() {
|
||||
const users = sqliteDb.prepare('SELECT id, team_member_id FROM users').all();
|
||||
const map = {};
|
||||
for (const u of users) {
|
||||
if (u.team_member_id) map[u.team_member_id] = u.id;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async function migrateBrands() {
|
||||
console.log('Migrating Brands...');
|
||||
const rows = sqliteDb.prepare('SELECT * FROM brands ORDER BY id').all();
|
||||
for (const row of rows) {
|
||||
const created = await nocodb.create('Brands', {
|
||||
name: row.name,
|
||||
priority: row.priority,
|
||||
color: row.color,
|
||||
icon: row.icon,
|
||||
});
|
||||
mapId('brands', row.id, created.Id);
|
||||
}
|
||||
console.log(` ✅ ${rows.length} brands migrated`);
|
||||
}
|
||||
|
||||
async function migrateUsers() {
|
||||
console.log('Migrating Users...');
|
||||
const rows = sqliteDb.prepare('SELECT * FROM users ORDER BY id').all();
|
||||
for (const row of rows) {
|
||||
const created = await nocodb.create('Users', {
|
||||
name: row.name,
|
||||
email: row.email,
|
||||
role: row.role,
|
||||
team_role: row.team_role || null,
|
||||
brands: row.brands || '[]',
|
||||
phone: row.phone || null,
|
||||
avatar: row.avatar || null,
|
||||
tutorial_completed: !!row.tutorial_completed,
|
||||
});
|
||||
mapId('users', row.id, created.Id);
|
||||
|
||||
// Create auth credentials entry
|
||||
authDb.prepare(
|
||||
'INSERT OR REPLACE INTO auth_credentials (email, password_hash, nocodb_user_id) VALUES (?, ?, ?)'
|
||||
).run(row.email, row.password_hash, created.Id);
|
||||
}
|
||||
console.log(` ✅ ${rows.length} users migrated (+ auth_credentials)`);
|
||||
}
|
||||
|
||||
async function migrateCampaigns() {
|
||||
console.log('Migrating Campaigns...');
|
||||
const rows = sqliteDb.prepare('SELECT * FROM campaigns ORDER BY id').all();
|
||||
for (const row of rows) {
|
||||
const data = {
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
start_date: row.start_date,
|
||||
end_date: row.end_date,
|
||||
status: row.status,
|
||||
color: row.color,
|
||||
budget: row.budget,
|
||||
goals: row.goals,
|
||||
platforms: row.platforms || '[]',
|
||||
budget_spent: row.budget_spent || 0,
|
||||
revenue: row.revenue || 0,
|
||||
impressions: row.impressions || 0,
|
||||
clicks: row.clicks || 0,
|
||||
conversions: row.conversions || 0,
|
||||
cost_per_click: row.cost_per_click || 0,
|
||||
notes: row.notes || '',
|
||||
};
|
||||
|
||||
const created = await nocodb.create('Campaigns', data);
|
||||
mapId('campaigns', row.id, created.Id);
|
||||
|
||||
// Link Brand
|
||||
if (row.brand_id && getMappedId('brands', row.brand_id)) {
|
||||
await linkRecord('Campaigns', created.Id, 'Brand', getMappedId('brands', row.brand_id));
|
||||
}
|
||||
// Link CreatedByUser
|
||||
if (row.created_by_user_id && getMappedId('users', row.created_by_user_id)) {
|
||||
await linkRecord('Campaigns', created.Id, 'CreatedByUser', getMappedId('users', row.created_by_user_id));
|
||||
}
|
||||
}
|
||||
console.log(` ✅ ${rows.length} campaigns migrated`);
|
||||
}
|
||||
|
||||
async function migrateCampaignTracks() {
|
||||
console.log('Migrating CampaignTracks...');
|
||||
const rows = sqliteDb.prepare('SELECT * FROM campaign_tracks ORDER BY id').all();
|
||||
for (const row of rows) {
|
||||
const created = await nocodb.create('CampaignTracks', {
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
platform: row.platform,
|
||||
budget_allocated: row.budget_allocated || 0,
|
||||
budget_spent: row.budget_spent || 0,
|
||||
revenue: row.revenue || 0,
|
||||
impressions: row.impressions || 0,
|
||||
clicks: row.clicks || 0,
|
||||
conversions: row.conversions || 0,
|
||||
notes: row.notes || '',
|
||||
status: row.status,
|
||||
});
|
||||
mapId('campaign_tracks', row.id, created.Id);
|
||||
|
||||
if (row.campaign_id && getMappedId('campaigns', row.campaign_id)) {
|
||||
await linkRecord('CampaignTracks', created.Id, 'Campaign', getMappedId('campaigns', row.campaign_id));
|
||||
}
|
||||
}
|
||||
console.log(` ✅ ${rows.length} campaign tracks migrated`);
|
||||
}
|
||||
|
||||
async function migrateCampaignAssignments() {
|
||||
console.log('Migrating CampaignAssignments...');
|
||||
const rows = sqliteDb.prepare('SELECT * FROM campaign_assignments ORDER BY id').all();
|
||||
for (const row of rows) {
|
||||
const created = await nocodb.create('CampaignAssignments', {
|
||||
assigned_at: row.assigned_at,
|
||||
});
|
||||
mapId('campaign_assignments', row.id, created.Id);
|
||||
|
||||
if (row.campaign_id && getMappedId('campaigns', row.campaign_id)) {
|
||||
await linkRecord('CampaignAssignments', created.Id, 'Campaign', getMappedId('campaigns', row.campaign_id));
|
||||
}
|
||||
if (row.user_id && getMappedId('users', row.user_id)) {
|
||||
await linkRecord('CampaignAssignments', created.Id, 'Member', getMappedId('users', row.user_id));
|
||||
}
|
||||
if (row.assigned_by && getMappedId('users', row.assigned_by)) {
|
||||
await linkRecord('CampaignAssignments', created.Id, 'Assigner', getMappedId('users', row.assigned_by));
|
||||
}
|
||||
}
|
||||
console.log(` ✅ ${rows.length} campaign assignments migrated`);
|
||||
}
|
||||
|
||||
async function migrateProjects() {
|
||||
console.log('Migrating Projects...');
|
||||
const rows = sqliteDb.prepare('SELECT * FROM projects ORDER BY id').all();
|
||||
const tmToUser = buildTeamMemberToUserMap();
|
||||
for (const row of rows) {
|
||||
const created = await nocodb.create('Projects', {
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
status: row.status,
|
||||
priority: row.priority,
|
||||
due_date: row.due_date,
|
||||
});
|
||||
mapId('projects', row.id, created.Id);
|
||||
|
||||
if (row.brand_id && getMappedId('brands', row.brand_id)) {
|
||||
await linkRecord('Projects', created.Id, 'Brand', getMappedId('brands', row.brand_id));
|
||||
}
|
||||
// owner_id references team_members, map through to users
|
||||
if (row.owner_id) {
|
||||
const userId = tmToUser[row.owner_id];
|
||||
if (userId && getMappedId('users', userId)) {
|
||||
await linkRecord('Projects', created.Id, 'Owner', getMappedId('users', userId));
|
||||
}
|
||||
}
|
||||
if (row.created_by_user_id && getMappedId('users', row.created_by_user_id)) {
|
||||
await linkRecord('Projects', created.Id, 'CreatedByUser', getMappedId('users', row.created_by_user_id));
|
||||
}
|
||||
}
|
||||
console.log(` ✅ ${rows.length} projects migrated`);
|
||||
}
|
||||
|
||||
async function migrateTasks() {
|
||||
console.log('Migrating Tasks...');
|
||||
const rows = sqliteDb.prepare('SELECT * FROM tasks ORDER BY id').all();
|
||||
const tmToUser = buildTeamMemberToUserMap();
|
||||
for (const row of rows) {
|
||||
const created = await nocodb.create('Tasks', {
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
status: row.status,
|
||||
priority: row.priority,
|
||||
due_date: row.due_date,
|
||||
is_personal: !!row.is_personal,
|
||||
completed_at: row.completed_at,
|
||||
});
|
||||
mapId('tasks', row.id, created.Id);
|
||||
|
||||
if (row.project_id && getMappedId('projects', row.project_id)) {
|
||||
await linkRecord('Tasks', created.Id, 'Project', getMappedId('projects', row.project_id));
|
||||
}
|
||||
// assigned_to references team_members
|
||||
if (row.assigned_to) {
|
||||
const userId = tmToUser[row.assigned_to];
|
||||
if (userId && getMappedId('users', userId)) {
|
||||
await linkRecord('Tasks', created.Id, 'AssignedTo', getMappedId('users', userId));
|
||||
}
|
||||
}
|
||||
if (row.created_by_user_id && getMappedId('users', row.created_by_user_id)) {
|
||||
await linkRecord('Tasks', created.Id, 'CreatedByUser', getMappedId('users', row.created_by_user_id));
|
||||
}
|
||||
}
|
||||
console.log(` ✅ ${rows.length} tasks migrated`);
|
||||
}
|
||||
|
||||
async function migratePosts() {
|
||||
console.log('Migrating Posts...');
|
||||
const rows = sqliteDb.prepare('SELECT * FROM posts ORDER BY id').all();
|
||||
const tmToUser = buildTeamMemberToUserMap();
|
||||
for (const row of rows) {
|
||||
const created = await nocodb.create('Posts', {
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
status: row.status,
|
||||
platform: row.platform,
|
||||
platforms: row.platforms || '[]',
|
||||
content_type: row.content_type,
|
||||
scheduled_date: row.scheduled_date,
|
||||
published_date: row.published_date,
|
||||
notes: row.notes,
|
||||
publication_links: row.publication_links || '[]',
|
||||
});
|
||||
mapId('posts', row.id, created.Id);
|
||||
|
||||
if (row.brand_id && getMappedId('brands', row.brand_id)) {
|
||||
await linkRecord('Posts', created.Id, 'Brand', getMappedId('brands', row.brand_id));
|
||||
}
|
||||
if (row.assigned_to) {
|
||||
const userId = tmToUser[row.assigned_to];
|
||||
if (userId && getMappedId('users', userId)) {
|
||||
await linkRecord('Posts', created.Id, 'AssignedTo', getMappedId('users', userId));
|
||||
}
|
||||
}
|
||||
if (row.campaign_id && getMappedId('campaigns', row.campaign_id)) {
|
||||
await linkRecord('Posts', created.Id, 'Campaign', getMappedId('campaigns', row.campaign_id));
|
||||
}
|
||||
if (row.track_id && getMappedId('campaign_tracks', row.track_id)) {
|
||||
await linkRecord('Posts', created.Id, 'Track', getMappedId('campaign_tracks', row.track_id));
|
||||
}
|
||||
if (row.created_by_user_id && getMappedId('users', row.created_by_user_id)) {
|
||||
await linkRecord('Posts', created.Id, 'CreatedByUser', getMappedId('users', row.created_by_user_id));
|
||||
}
|
||||
}
|
||||
console.log(` ✅ ${rows.length} posts migrated`);
|
||||
}
|
||||
|
||||
async function migratePostAttachments() {
|
||||
console.log('Migrating PostAttachments...');
|
||||
const rows = sqliteDb.prepare('SELECT * FROM post_attachments ORDER BY id').all();
|
||||
for (const row of rows) {
|
||||
const created = await nocodb.create('PostAttachments', {
|
||||
filename: row.filename,
|
||||
original_name: row.original_name,
|
||||
mime_type: row.mime_type,
|
||||
size: row.size,
|
||||
url: row.url,
|
||||
});
|
||||
mapId('post_attachments', row.id, created.Id);
|
||||
|
||||
if (row.post_id && getMappedId('posts', row.post_id)) {
|
||||
await linkRecord('PostAttachments', created.Id, 'Post', getMappedId('posts', row.post_id));
|
||||
}
|
||||
}
|
||||
console.log(` ✅ ${rows.length} post attachments migrated`);
|
||||
}
|
||||
|
||||
async function migrateAssets() {
|
||||
console.log('Migrating Assets...');
|
||||
const rows = sqliteDb.prepare('SELECT * FROM assets ORDER BY id').all();
|
||||
const tmToUser = buildTeamMemberToUserMap();
|
||||
for (const row of rows) {
|
||||
const created = await nocodb.create('Assets', {
|
||||
filename: row.filename,
|
||||
original_name: row.original_name,
|
||||
mime_type: row.mime_type,
|
||||
size: row.size,
|
||||
tags: row.tags || '[]',
|
||||
folder: row.folder,
|
||||
});
|
||||
mapId('assets', row.id, created.Id);
|
||||
|
||||
if (row.brand_id && getMappedId('brands', row.brand_id)) {
|
||||
await linkRecord('Assets', created.Id, 'Brand', getMappedId('brands', row.brand_id));
|
||||
}
|
||||
if (row.campaign_id && getMappedId('campaigns', row.campaign_id)) {
|
||||
await linkRecord('Assets', created.Id, 'Campaign', getMappedId('campaigns', row.campaign_id));
|
||||
}
|
||||
if (row.uploaded_by) {
|
||||
const userId = tmToUser[row.uploaded_by];
|
||||
if (userId && getMappedId('users', userId)) {
|
||||
await linkRecord('Assets', created.Id, 'Uploader', getMappedId('users', userId));
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(` ✅ ${rows.length} assets migrated`);
|
||||
}
|
||||
|
||||
async function migrateComments() {
|
||||
console.log('Migrating Comments...');
|
||||
const rows = sqliteDb.prepare('SELECT * FROM comments ORDER BY id').all();
|
||||
for (const row of rows) {
|
||||
const created = await nocodb.create('Comments', {
|
||||
entity_type: row.entity_type,
|
||||
entity_id: row.entity_id,
|
||||
content: row.content,
|
||||
});
|
||||
mapId('comments', row.id, created.Id);
|
||||
|
||||
if (row.user_id && getMappedId('users', row.user_id)) {
|
||||
await linkRecord('Comments', created.Id, 'User', getMappedId('users', row.user_id));
|
||||
}
|
||||
}
|
||||
console.log(` ✅ ${rows.length} comments migrated`);
|
||||
}
|
||||
|
||||
async function migrateBudgetEntries() {
|
||||
console.log('Migrating BudgetEntries...');
|
||||
const rows = sqliteDb.prepare('SELECT * FROM budget_entries ORDER BY id').all();
|
||||
for (const row of rows) {
|
||||
const created = await nocodb.create('BudgetEntries', {
|
||||
label: row.label,
|
||||
amount: row.amount,
|
||||
source: row.source,
|
||||
category: row.category,
|
||||
date_received: row.date_received,
|
||||
notes: row.notes || '',
|
||||
});
|
||||
mapId('budget_entries', row.id, created.Id);
|
||||
|
||||
if (row.campaign_id && getMappedId('campaigns', row.campaign_id)) {
|
||||
await linkRecord('BudgetEntries', created.Id, 'Campaign', getMappedId('campaigns', row.campaign_id));
|
||||
}
|
||||
}
|
||||
console.log(` ✅ ${rows.length} budget entries migrated`);
|
||||
}
|
||||
|
||||
// Helper: link a record to another via NocoDB link API
|
||||
async function linkRecord(table, recordId, linkField, linkedRecordId) {
|
||||
try {
|
||||
const tableId = await nocodb.resolveTableId(table);
|
||||
// Get columns to find the link column ID
|
||||
const res = await fetch(`${nocodb.url}/api/v2/meta/tables/${tableId}`, {
|
||||
headers: { 'xc-token': nocodb.token },
|
||||
});
|
||||
const tableMeta = await res.json();
|
||||
const linkCol = tableMeta.columns.find(c => c.title === linkField && c.uidt === 'Links');
|
||||
if (!linkCol) {
|
||||
console.warn(` ⚠ Link column "${linkField}" not found in ${table}`);
|
||||
return;
|
||||
}
|
||||
await fetch(`${nocodb.url}/api/v2/tables/${tableId}/links/${linkCol.id}/records/${recordId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'xc-token': nocodb.token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify([{ Id: linkedRecordId }]),
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(` ⚠ Failed to link ${table}.${linkField}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Cache for link column metadata (avoid re-fetching per record)
|
||||
const linkColCache = {};
|
||||
async function getLinkColId(table, linkField) {
|
||||
const key = `${table}.${linkField}`;
|
||||
if (linkColCache[key]) return linkColCache[key];
|
||||
const tableId = await nocodb.resolveTableId(table);
|
||||
const res = await fetch(`${nocodb.url}/api/v2/meta/tables/${tableId}`, {
|
||||
headers: { 'xc-token': nocodb.token },
|
||||
});
|
||||
const tableMeta = await res.json();
|
||||
for (const c of tableMeta.columns) {
|
||||
if (c.uidt === 'Links') {
|
||||
linkColCache[`${table}.${c.title}`] = { colId: c.id, tableId };
|
||||
}
|
||||
}
|
||||
return linkColCache[key] || null;
|
||||
}
|
||||
|
||||
// Optimized linkRecord using cache
|
||||
const origLinkRecord = linkRecord;
|
||||
linkRecord = async function(table, recordId, linkField, linkedRecordId) {
|
||||
let cached = linkColCache[`${table}.${linkField}`];
|
||||
if (!cached) {
|
||||
// Populate cache for this table
|
||||
const tableId = await nocodb.resolveTableId(table);
|
||||
const res = await fetch(`${nocodb.url}/api/v2/meta/tables/${tableId}`, {
|
||||
headers: { 'xc-token': nocodb.token },
|
||||
});
|
||||
const tableMeta = await res.json();
|
||||
for (const c of tableMeta.columns) {
|
||||
if (c.uidt === 'Links') {
|
||||
linkColCache[`${table}.${c.title}`] = { colId: c.id, tableId };
|
||||
}
|
||||
}
|
||||
cached = linkColCache[`${table}.${linkField}`];
|
||||
}
|
||||
if (!cached) {
|
||||
console.warn(` ⚠ Link column "${linkField}" not found in ${table}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await fetch(`${nocodb.url}/api/v2/tables/${cached.tableId}/links/${cached.colId}/records/${recordId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'xc-token': nocodb.token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify([{ Id: linkedRecordId }]),
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(` ⚠ Failed to link ${table}.${linkField}: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
async function main() {
|
||||
console.log('Starting migration from SQLite → NocoDB...\n');
|
||||
|
||||
await migrateBrands();
|
||||
await migrateUsers();
|
||||
await migrateCampaigns();
|
||||
await migrateCampaignTracks();
|
||||
await migrateCampaignAssignments();
|
||||
await migrateProjects();
|
||||
await migrateTasks();
|
||||
await migratePosts();
|
||||
await migratePostAttachments();
|
||||
await migrateAssets();
|
||||
await migrateComments();
|
||||
await migrateBudgetEntries();
|
||||
|
||||
console.log('\n✅ Migration complete!');
|
||||
console.log('ID mapping summary:');
|
||||
for (const [table, map] of Object.entries(idMap)) {
|
||||
console.log(` ${table}: ${Object.keys(map).length} records`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Migration failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
216
server/nocodb.js
Normal file
216
server/nocodb.js
Normal file
@@ -0,0 +1,216 @@
|
||||
require('dotenv').config({ path: __dirname + '/.env' });
|
||||
|
||||
const NOCODB_URL = process.env.NOCODB_URL || 'http://localhost:8090';
|
||||
const NOCODB_TOKEN = process.env.NOCODB_TOKEN;
|
||||
const NOCODB_BASE_ID = process.env.NOCODB_BASE_ID;
|
||||
|
||||
class NocoDBError extends Error {
|
||||
constructor(message, status, details) {
|
||||
super(message);
|
||||
this.name = 'NocoDBError';
|
||||
this.status = status;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
// Cache: table name → table ID
|
||||
const tableIdCache = {};
|
||||
|
||||
async function resolveTableId(tableName) {
|
||||
if (tableIdCache[tableName]) return tableIdCache[tableName];
|
||||
|
||||
const res = await fetch(`${NOCODB_URL}/api/v2/meta/bases/${NOCODB_BASE_ID}/tables`, {
|
||||
headers: { 'xc-token': NOCODB_TOKEN },
|
||||
});
|
||||
if (!res.ok) throw new NocoDBError('Failed to fetch tables', res.status);
|
||||
const data = await res.json();
|
||||
for (const t of data.list || []) {
|
||||
tableIdCache[t.title] = t.id;
|
||||
}
|
||||
if (!tableIdCache[tableName]) {
|
||||
throw new NocoDBError(`Table "${tableName}" not found in base ${NOCODB_BASE_ID}`, 404);
|
||||
}
|
||||
return tableIdCache[tableName];
|
||||
}
|
||||
|
||||
function buildWhere(conditions) {
|
||||
if (!conditions || conditions.length === 0) return '';
|
||||
return conditions
|
||||
.map(c => `(${c.field},${c.op},${c.value})`)
|
||||
.join('~and');
|
||||
}
|
||||
|
||||
async function request(method, url, body) {
|
||||
const opts = {
|
||||
method,
|
||||
headers: {
|
||||
'xc-token': NOCODB_TOKEN,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
if (body !== undefined) opts.body = JSON.stringify(body);
|
||||
|
||||
const res = await fetch(url, opts);
|
||||
if (!res.ok) {
|
||||
let details;
|
||||
try { details = await res.json(); } catch {}
|
||||
throw new NocoDBError(
|
||||
`NocoDB ${method} ${url} failed: ${res.status}`,
|
||||
res.status,
|
||||
details
|
||||
);
|
||||
}
|
||||
// DELETE returns empty or {msg}
|
||||
const text = await res.text();
|
||||
return text ? JSON.parse(text) : {};
|
||||
}
|
||||
|
||||
// ─── Link Resolution ─────────────────────────────────────────
|
||||
|
||||
// Cache: "Table.Field" → { colId, tableId }
|
||||
const linkColCache = {};
|
||||
|
||||
async function getLinkColId(table, linkField) {
|
||||
const key = `${table}.${linkField}`;
|
||||
if (linkColCache[key]) return linkColCache[key];
|
||||
const tableId = await resolveTableId(table);
|
||||
const res = await fetch(`${NOCODB_URL}/api/v2/meta/tables/${tableId}`, {
|
||||
headers: { 'xc-token': NOCODB_TOKEN },
|
||||
});
|
||||
if (!res.ok) throw new NocoDBError('Failed to fetch table metadata', res.status);
|
||||
const meta = await res.json();
|
||||
for (const c of meta.columns || []) {
|
||||
if (c.uidt === 'Links' || c.uidt === 'LinkToAnotherRecord') {
|
||||
linkColCache[`${table}.${c.title}`] = { colId: c.id, tableId };
|
||||
}
|
||||
}
|
||||
return linkColCache[key] || null;
|
||||
}
|
||||
|
||||
async function fetchLinkedRecords(table, recordId, linkField) {
|
||||
const info = await getLinkColId(table, linkField);
|
||||
if (!info) return [];
|
||||
try {
|
||||
const data = await request('GET',
|
||||
`${NOCODB_URL}/api/v2/tables/${info.tableId}/links/${info.colId}/records/${recordId}`);
|
||||
return data.list || [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
async function resolveLinks(table, records, linkFields) {
|
||||
if (!records || !linkFields || linkFields.length === 0) return;
|
||||
const arr = Array.isArray(records) ? records : [records];
|
||||
const promises = [];
|
||||
for (const record of arr) {
|
||||
for (const field of linkFields) {
|
||||
const val = record[field];
|
||||
if (typeof val === 'number' && val > 0) {
|
||||
promises.push(
|
||||
fetchLinkedRecords(table, record.Id, field)
|
||||
.then(linked => { record[field] = linked; })
|
||||
);
|
||||
} else if (typeof val === 'number') {
|
||||
record[field] = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
const nocodb = {
|
||||
/**
|
||||
* List records with optional filtering, sorting, pagination.
|
||||
* Pass `links: ['Field1','Field2']` to resolve linked records.
|
||||
*/
|
||||
async list(table, { where, sort, fields, limit, offset, links } = {}) {
|
||||
const tableId = await resolveTableId(table);
|
||||
const params = new URLSearchParams();
|
||||
if (where) params.set('where', typeof where === 'string' ? where : buildWhere(where));
|
||||
if (sort) params.set('sort', sort);
|
||||
if (fields) params.set('fields', Array.isArray(fields) ? fields.join(',') : fields);
|
||||
if (limit) params.set('limit', String(limit));
|
||||
if (offset) params.set('offset', String(offset));
|
||||
const qs = params.toString();
|
||||
const data = await request('GET', `${NOCODB_URL}/api/v2/tables/${tableId}/records${qs ? '?' + qs : ''}`);
|
||||
const records = data.list || [];
|
||||
if (links && links.length > 0) {
|
||||
await resolveLinks(table, records, links);
|
||||
}
|
||||
return records;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a single record by row ID.
|
||||
* Pass `{ links: ['Field1'] }` as third arg to resolve linked records.
|
||||
*/
|
||||
async get(table, rowId, { links } = {}) {
|
||||
const tableId = await resolveTableId(table);
|
||||
const record = await request('GET', `${NOCODB_URL}/api/v2/tables/${tableId}/records/${rowId}`);
|
||||
if (links && links.length > 0) {
|
||||
await resolveLinks(table, [record], links);
|
||||
}
|
||||
return record;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a single record, returns the created record
|
||||
*/
|
||||
async create(table, data) {
|
||||
const tableId = await resolveTableId(table);
|
||||
return request('POST', `${NOCODB_URL}/api/v2/tables/${tableId}/records`, data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a single record by row ID
|
||||
*/
|
||||
async update(table, rowId, data) {
|
||||
const tableId = await resolveTableId(table);
|
||||
return request('PATCH', `${NOCODB_URL}/api/v2/tables/${tableId}/records`, { Id: rowId, ...data });
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a single record by row ID
|
||||
*/
|
||||
async delete(table, rowId) {
|
||||
const tableId = await resolveTableId(table);
|
||||
return request('DELETE', `${NOCODB_URL}/api/v2/tables/${tableId}/records`, { Id: rowId });
|
||||
},
|
||||
|
||||
/**
|
||||
* Bulk create records
|
||||
*/
|
||||
async bulkCreate(table, records) {
|
||||
const tableId = await resolveTableId(table);
|
||||
return request('POST', `${NOCODB_URL}/api/v2/tables/${tableId}/records`, records);
|
||||
},
|
||||
|
||||
/**
|
||||
* Bulk update records (each must include Id)
|
||||
*/
|
||||
async bulkUpdate(table, records) {
|
||||
const tableId = await resolveTableId(table);
|
||||
return request('PATCH', `${NOCODB_URL}/api/v2/tables/${tableId}/records`, records);
|
||||
},
|
||||
|
||||
/**
|
||||
* Bulk delete records (each must include Id)
|
||||
*/
|
||||
async bulkDelete(table, records) {
|
||||
const tableId = await resolveTableId(table);
|
||||
return request('DELETE', `${NOCODB_URL}/api/v2/tables/${tableId}/records`, records);
|
||||
},
|
||||
|
||||
// Expose helpers
|
||||
buildWhere,
|
||||
resolveTableId,
|
||||
getLinkColId,
|
||||
NocoDBError,
|
||||
clearCache() { Object.keys(tableIdCache).forEach(k => delete tableIdCache[k]); },
|
||||
|
||||
// Config getters
|
||||
get url() { return NOCODB_URL; },
|
||||
get token() { return NOCODB_TOKEN; },
|
||||
get baseId() { return NOCODB_BASE_ID; },
|
||||
};
|
||||
|
||||
module.exports = nocodb;
|
||||
12
server/node_modules/.package-lock.json
generated
vendored
12
server/node_modules/.package-lock.json
generated
vendored
@@ -650,6 +650,18 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.2.4",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.4.tgz",
|
||||
"integrity": "sha512-mudtfb4zRB4bVvdj0xRo+e6duH1csJRM8IukBqfTRvHotn9+LBXB8ynAidP9zHqoRC/fsllXgk4kCKlR21fIhw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
|
||||
13
server/package-lock.json
generated
13
server/package-lock.json
generated
@@ -12,6 +12,7 @@
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"connect-sqlite3": "^0.9.16",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.4",
|
||||
"express": "^4.21.0",
|
||||
"express-session": "^1.19.0",
|
||||
"multer": "^1.4.5-lts.1"
|
||||
@@ -663,6 +664,18 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.2.4",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.4.tgz",
|
||||
"integrity": "sha512-mudtfb4zRB4bVvdj0xRo+e6duH1csJRM8IukBqfTRvHotn9+LBXB8ynAidP9zHqoRC/fsllXgk4kCKlR21fIhw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"connect-sqlite3": "^0.9.16",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.4",
|
||||
"express": "^4.21.0",
|
||||
"express-session": "^1.19.0",
|
||||
"multer": "^1.4.5-lts.1"
|
||||
|
||||
2819
server/server.js
2819
server/server.js
File diff suppressed because it is too large
Load Diff
249
server/setup-tables.js
Normal file
249
server/setup-tables.js
Normal file
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* setup-tables.js — Creates a new "Digital Hub" base in NocoDB
|
||||
* with all 12 tables, fields, and links.
|
||||
* Run once: node setup-tables.js
|
||||
*/
|
||||
|
||||
require('dotenv').config({ path: __dirname + '/.env' });
|
||||
|
||||
const NOCODB_URL = process.env.NOCODB_URL || 'http://localhost:8090';
|
||||
const NOCODB_TOKEN = process.env.NOCODB_TOKEN;
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
async function request(method, url, body) {
|
||||
const opts = {
|
||||
method,
|
||||
headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' },
|
||||
};
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
const res = await fetch(url, opts);
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`${method} ${url} → ${res.status}: ${text}`);
|
||||
}
|
||||
const text = await res.text();
|
||||
return text ? JSON.parse(text) : {};
|
||||
}
|
||||
|
||||
async function createBase() {
|
||||
console.log('Creating "Digital Hub" base...');
|
||||
const data = await request('POST', `${NOCODB_URL}/api/v2/meta/bases/`, {
|
||||
title: 'Digital Hub',
|
||||
type: 'database',
|
||||
});
|
||||
console.log(` Base created: ${data.id}`);
|
||||
return data.id;
|
||||
}
|
||||
|
||||
async function createTable(baseId, title, columns) {
|
||||
console.log(` Creating table: ${title}`);
|
||||
const data = await request('POST', `${NOCODB_URL}/api/v2/meta/bases/${baseId}/tables`, {
|
||||
title,
|
||||
columns,
|
||||
});
|
||||
console.log(` → ${data.id}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function createLinkColumn(tableId, title, relatedTableId, type = 'hm') {
|
||||
console.log(` Linking: ${title}`);
|
||||
await request('POST', `${NOCODB_URL}/api/v2/meta/tables/${tableId}/columns`, {
|
||||
title,
|
||||
uidt: 'Links',
|
||||
parentId: tableId,
|
||||
childId: relatedTableId,
|
||||
type,
|
||||
});
|
||||
}
|
||||
|
||||
// Field type helpers
|
||||
const text = (title) => ({ title, uidt: 'SingleLineText' });
|
||||
const longText = (title) => ({ title, uidt: 'LongText' });
|
||||
const email = (title) => ({ title, uidt: 'Email' });
|
||||
const num = (title) => ({ title, uidt: 'Number' });
|
||||
const decimal = (title) => ({ title, uidt: 'Decimal' });
|
||||
const checkbox = (title) => ({ title, uidt: 'Checkbox' });
|
||||
const date = (title) => ({ title, uidt: 'Date' });
|
||||
const dateTime = (title) => ({ title, uidt: 'DateTime' });
|
||||
const singleSelect = (title, options) => ({
|
||||
title,
|
||||
uidt: 'SingleSelect',
|
||||
dtxp: options.map(o => `'${o}'`).join(','),
|
||||
});
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
// 1. Create base
|
||||
const baseId = await createBase();
|
||||
|
||||
// 2. Create tables (without links first)
|
||||
const users = await createTable(baseId, 'Users', [
|
||||
text('name'),
|
||||
email('email'),
|
||||
singleSelect('role', ['superadmin', 'manager', 'contributor']),
|
||||
text('team_role'),
|
||||
longText('brands'),
|
||||
text('phone'),
|
||||
text('avatar'),
|
||||
checkbox('tutorial_completed'),
|
||||
]);
|
||||
|
||||
const brands = await createTable(baseId, 'Brands', [
|
||||
text('name'),
|
||||
text('name_ar'),
|
||||
num('priority'),
|
||||
text('color'),
|
||||
text('icon'),
|
||||
text('category'),
|
||||
]);
|
||||
|
||||
const campaigns = await createTable(baseId, 'Campaigns', [
|
||||
text('name'),
|
||||
longText('description'),
|
||||
date('start_date'),
|
||||
date('end_date'),
|
||||
singleSelect('status', ['planning', 'active', 'paused', 'completed', 'cancelled']),
|
||||
text('color'),
|
||||
decimal('budget'),
|
||||
longText('goals'),
|
||||
longText('platforms'),
|
||||
decimal('budget_spent'),
|
||||
decimal('revenue'),
|
||||
num('impressions'),
|
||||
num('clicks'),
|
||||
num('conversions'),
|
||||
decimal('cost_per_click'),
|
||||
longText('notes'),
|
||||
num('brand_id'),
|
||||
num('created_by_user_id'),
|
||||
]);
|
||||
|
||||
const campaignTracks = await createTable(baseId, 'CampaignTracks', [
|
||||
text('name'),
|
||||
singleSelect('type', ['organic_social', 'paid_social', 'paid_search', 'email', 'seo', 'influencer', 'event', 'other']),
|
||||
text('platform'),
|
||||
decimal('budget_allocated'),
|
||||
decimal('budget_spent'),
|
||||
decimal('revenue'),
|
||||
num('impressions'),
|
||||
num('clicks'),
|
||||
num('conversions'),
|
||||
longText('notes'),
|
||||
singleSelect('status', ['planned', 'active', 'paused', 'completed']),
|
||||
num('campaign_id'),
|
||||
]);
|
||||
|
||||
const campaignAssignments = await createTable(baseId, 'CampaignAssignments', [
|
||||
dateTime('assigned_at'),
|
||||
num('campaign_id'),
|
||||
num('member_id'),
|
||||
num('assigner_id'),
|
||||
]);
|
||||
|
||||
const projects = await createTable(baseId, 'Projects', [
|
||||
text('name'),
|
||||
longText('description'),
|
||||
singleSelect('status', ['active', 'paused', 'completed', 'cancelled']),
|
||||
singleSelect('priority', ['low', 'medium', 'high', 'urgent']),
|
||||
date('start_date'),
|
||||
date('due_date'),
|
||||
num('brand_id'),
|
||||
num('owner_id'),
|
||||
num('created_by_user_id'),
|
||||
]);
|
||||
|
||||
const tasks = await createTable(baseId, 'Tasks', [
|
||||
text('title'),
|
||||
longText('description'),
|
||||
singleSelect('status', ['todo', 'in_progress', 'done']),
|
||||
singleSelect('priority', ['low', 'medium', 'high', 'urgent']),
|
||||
date('start_date'),
|
||||
date('due_date'),
|
||||
checkbox('is_personal'),
|
||||
dateTime('completed_at'),
|
||||
num('project_id'),
|
||||
num('assigned_to_id'),
|
||||
num('created_by_user_id'),
|
||||
]);
|
||||
|
||||
const posts = await createTable(baseId, 'Posts', [
|
||||
text('title'),
|
||||
longText('description'),
|
||||
singleSelect('status', ['draft', 'in_review', 'approved', 'scheduled', 'published', 'rejected']),
|
||||
text('platform'),
|
||||
longText('platforms'),
|
||||
text('content_type'),
|
||||
dateTime('scheduled_date'),
|
||||
dateTime('published_date'),
|
||||
longText('notes'),
|
||||
longText('publication_links'),
|
||||
num('brand_id'),
|
||||
num('assigned_to_id'),
|
||||
num('campaign_id'),
|
||||
num('track_id'),
|
||||
num('created_by_user_id'),
|
||||
]);
|
||||
|
||||
const assets = await createTable(baseId, 'Assets', [
|
||||
text('filename'),
|
||||
text('original_name'),
|
||||
text('mime_type'),
|
||||
num('size'),
|
||||
longText('tags'),
|
||||
text('folder'),
|
||||
num('brand_id'),
|
||||
num('campaign_id'),
|
||||
num('uploader_id'),
|
||||
]);
|
||||
|
||||
const postAttachments = await createTable(baseId, 'PostAttachments', [
|
||||
text('filename'),
|
||||
text('original_name'),
|
||||
text('mime_type'),
|
||||
num('size'),
|
||||
text('url'),
|
||||
num('post_id'),
|
||||
]);
|
||||
|
||||
const comments = await createTable(baseId, 'Comments', [
|
||||
text('entity_type'),
|
||||
num('entity_id'),
|
||||
longText('content'),
|
||||
num('user_id'),
|
||||
]);
|
||||
|
||||
const budgetEntries = await createTable(baseId, 'BudgetEntries', [
|
||||
text('label'),
|
||||
decimal('amount'),
|
||||
text('source'),
|
||||
text('category'),
|
||||
date('date_received'),
|
||||
longText('notes'),
|
||||
num('campaign_id'),
|
||||
]);
|
||||
|
||||
// 3. All relationships are now handled via plain Number FK columns
|
||||
// (brand_id, owner_id, campaign_id, etc.) defined directly in each table above.
|
||||
// No NocoDB link columns are needed.
|
||||
|
||||
// 4. Save base ID to .env
|
||||
const envPath = path.join(__dirname, '.env');
|
||||
let envContent = fs.readFileSync(envPath, 'utf8');
|
||||
envContent = envContent.replace(/^NOCODB_BASE_ID=.*$/m, `NOCODB_BASE_ID=${baseId}`);
|
||||
fs.writeFileSync(envPath, envContent);
|
||||
|
||||
console.log(`\n✅ Done! Base ID ${baseId} saved to .env`);
|
||||
console.log('Tables created:');
|
||||
const tables = [users, brands, campaigns, campaignTracks, campaignAssignments, projects, tasks, posts, assets, postAttachments, comments, budgetEntries];
|
||||
for (const t of tables) {
|
||||
console.log(` ${t.title}: ${t.id}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Setup failed:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user