adding brand management
This commit is contained in:
372
server/db.js
372
server/db.js
@@ -1,372 +0,0 @@
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
const bcrypt = require('bcrypt');
|
||||
|
||||
const DB_PATH = path.join(__dirname, 'marketing.db');
|
||||
|
||||
const db = new Database(DB_PATH);
|
||||
|
||||
// Enable WAL mode and foreign keys
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
function initialize() {
|
||||
// Create tables
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS team_members (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT,
|
||||
role TEXT,
|
||||
avatar_url TEXT,
|
||||
brands TEXT DEFAULT '[]',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS brands (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
priority INTEGER DEFAULT 2,
|
||||
color TEXT,
|
||||
icon TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
brand_id INTEGER REFERENCES brands(id),
|
||||
assigned_to INTEGER REFERENCES team_members(id),
|
||||
status TEXT DEFAULT 'draft',
|
||||
platform TEXT,
|
||||
content_type TEXT,
|
||||
scheduled_date DATETIME,
|
||||
published_date DATETIME,
|
||||
notes TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS assets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
filename TEXT NOT NULL,
|
||||
original_name TEXT,
|
||||
mime_type TEXT,
|
||||
size INTEGER,
|
||||
tags TEXT DEFAULT '[]',
|
||||
brand_id INTEGER REFERENCES brands(id),
|
||||
campaign_id INTEGER REFERENCES campaigns(id),
|
||||
uploaded_by INTEGER REFERENCES team_members(id),
|
||||
folder TEXT DEFAULT 'general',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS campaigns (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
brand_id INTEGER REFERENCES brands(id),
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
status TEXT DEFAULT 'planning',
|
||||
color TEXT,
|
||||
budget REAL,
|
||||
goals TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
brand_id INTEGER REFERENCES brands(id),
|
||||
owner_id INTEGER REFERENCES team_members(id),
|
||||
status TEXT DEFAULT 'active',
|
||||
priority TEXT DEFAULT 'medium',
|
||||
due_date DATE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
project_id INTEGER REFERENCES projects(id),
|
||||
assigned_to INTEGER REFERENCES team_members(id),
|
||||
created_by INTEGER REFERENCES team_members(id),
|
||||
status TEXT DEFAULT 'todo',
|
||||
priority TEXT DEFAULT 'medium',
|
||||
due_date DATE,
|
||||
is_personal BOOLEAN DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at DATETIME
|
||||
);
|
||||
`);
|
||||
|
||||
// Budget entries table — tracks money received
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS budget_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
label TEXT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
source TEXT,
|
||||
campaign_id INTEGER REFERENCES campaigns(id),
|
||||
category TEXT DEFAULT 'marketing',
|
||||
date_received DATE NOT NULL,
|
||||
notes TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
// Users table for authentication
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'contributor',
|
||||
avatar TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
// Campaign tracks table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS campaign_tracks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
campaign_id INTEGER NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE,
|
||||
name TEXT,
|
||||
type TEXT NOT NULL DEFAULT 'organic_social',
|
||||
platform TEXT,
|
||||
budget_allocated REAL DEFAULT 0,
|
||||
budget_spent REAL DEFAULT 0,
|
||||
revenue REAL DEFAULT 0,
|
||||
impressions INTEGER DEFAULT 0,
|
||||
clicks INTEGER DEFAULT 0,
|
||||
conversions INTEGER DEFAULT 0,
|
||||
notes TEXT DEFAULT '',
|
||||
status TEXT DEFAULT 'planned',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
// ─── Comments / discussion table ───
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS comments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
content TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
// ─── Post attachments table ───
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS post_attachments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||
filename TEXT NOT NULL,
|
||||
original_name TEXT,
|
||||
mime_type TEXT,
|
||||
size INTEGER,
|
||||
url TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
// Campaign assignments (user-to-campaign junction table)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS campaign_assignments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
campaign_id INTEGER NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
assigned_by INTEGER REFERENCES users(id),
|
||||
assigned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(campaign_id, user_id)
|
||||
);
|
||||
`);
|
||||
|
||||
// ─── Column migrations ───
|
||||
// Helper: adds a column to a table if it does not already exist.
|
||||
function addColumnIfMissing(table, column, definition) {
|
||||
const cols = db.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name);
|
||||
if (!cols.includes(column)) {
|
||||
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
||||
console.log(`Added ${column} column to ${table}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Ownership columns (link to users table)
|
||||
for (const table of ['posts', 'tasks', 'campaigns', 'projects']) {
|
||||
addColumnIfMissing(table, 'created_by_user_id', 'INTEGER REFERENCES users(id)');
|
||||
}
|
||||
|
||||
// team_members additions
|
||||
addColumnIfMissing('team_members', 'phone', 'TEXT');
|
||||
|
||||
// campaigns additions
|
||||
addColumnIfMissing('campaigns', 'platforms', "TEXT DEFAULT '[]'");
|
||||
addColumnIfMissing('campaigns', 'budget_spent', 'REAL DEFAULT 0');
|
||||
addColumnIfMissing('campaigns', 'revenue', 'REAL DEFAULT 0');
|
||||
addColumnIfMissing('campaigns', 'impressions', 'INTEGER DEFAULT 0');
|
||||
addColumnIfMissing('campaigns', 'clicks', 'INTEGER DEFAULT 0');
|
||||
addColumnIfMissing('campaigns', 'conversions', 'INTEGER DEFAULT 0');
|
||||
addColumnIfMissing('campaigns', 'cost_per_click', 'REAL DEFAULT 0');
|
||||
addColumnIfMissing('campaigns', 'notes', "TEXT DEFAULT ''");
|
||||
|
||||
// posts additions
|
||||
addColumnIfMissing('posts', 'track_id', 'INTEGER REFERENCES campaign_tracks(id)');
|
||||
addColumnIfMissing('posts', 'campaign_id', 'INTEGER REFERENCES campaigns(id)');
|
||||
addColumnIfMissing('posts', 'publication_links', "TEXT DEFAULT '[]'");
|
||||
|
||||
// posts.platforms with data migration from single platform field
|
||||
const postCols = db.prepare("PRAGMA table_info(posts)").all().map(c => c.name);
|
||||
if (!postCols.includes('platforms')) {
|
||||
db.exec("ALTER TABLE posts ADD COLUMN platforms TEXT DEFAULT '[]'");
|
||||
const rows = db.prepare("SELECT id, platform FROM posts WHERE platform IS NOT NULL AND platform != ''").all();
|
||||
const migrate = db.prepare("UPDATE posts SET platforms = ? WHERE id = ?");
|
||||
for (const row of rows) {
|
||||
migrate.run(JSON.stringify([row.platform]), row.id);
|
||||
}
|
||||
console.log(`Added platforms column to posts, migrated ${rows.length} rows`);
|
||||
}
|
||||
|
||||
// assets additions
|
||||
addColumnIfMissing('assets', 'campaign_id', 'INTEGER REFERENCES campaigns(id)');
|
||||
|
||||
// users additions
|
||||
addColumnIfMissing('users', 'team_member_id', 'INTEGER REFERENCES team_members(id)');
|
||||
addColumnIfMissing('users', 'team_role', 'TEXT');
|
||||
addColumnIfMissing('users', 'brands', "TEXT DEFAULT '[]'");
|
||||
addColumnIfMissing('users', 'phone', 'TEXT');
|
||||
addColumnIfMissing('users', 'tutorial_completed', 'INTEGER DEFAULT 0');
|
||||
|
||||
// Migrate team_members to users (one-time migration)
|
||||
const teamMembers = db.prepare('SELECT * FROM team_members').all();
|
||||
const defaultPasswordHash = bcrypt.hashSync('changeme123', 10);
|
||||
|
||||
for (const tm of teamMembers) {
|
||||
// Skip team_member id=9 (Fahed) - he's already user id=1
|
||||
if (tm.id === 9) {
|
||||
// Just update his team_role and brands
|
||||
db.prepare('UPDATE users SET team_role = ?, brands = ?, team_member_id = ? WHERE id = 1')
|
||||
.run(tm.role, tm.brands, tm.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if user already exists with this team_member_id
|
||||
const existingUser = db.prepare('SELECT id FROM users WHERE team_member_id = ?').get(tm.id);
|
||||
if (existingUser) {
|
||||
// User exists, just update team_role and brands
|
||||
db.prepare('UPDATE users SET team_role = ?, brands = ?, phone = ? WHERE id = ?')
|
||||
.run(tm.role, tm.brands, tm.phone || null, existingUser.id);
|
||||
} else {
|
||||
// Create new user for this team member
|
||||
db.prepare(`
|
||||
INSERT INTO users (name, email, password_hash, role, team_role, brands, phone, team_member_id)
|
||||
VALUES (?, ?, ?, 'contributor', ?, ?, ?, ?)
|
||||
`).run(
|
||||
tm.name,
|
||||
tm.email,
|
||||
defaultPasswordHash,
|
||||
tm.role,
|
||||
tm.brands,
|
||||
tm.phone || null,
|
||||
tm.id
|
||||
);
|
||||
console.log(`✅ Created user account for team member: ${tm.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Seed data only if tables are empty
|
||||
const memberCount = db.prepare('SELECT COUNT(*) as count FROM team_members').get().count;
|
||||
if (memberCount === 0) {
|
||||
seedData();
|
||||
}
|
||||
|
||||
// Seed default superadmin if no users exist
|
||||
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
|
||||
if (userCount === 0) {
|
||||
seedDefaultUser();
|
||||
}
|
||||
}
|
||||
|
||||
function seedData() {
|
||||
const allBrands = JSON.stringify([
|
||||
'Samaya Investment', 'Hira Cultural District', 'Holy Quran Museum',
|
||||
'Al-Safiya Museum', 'Hayhala', 'Jabal Thawr', 'Coffee Chain', 'Taibah Gifts'
|
||||
]);
|
||||
const someBrands = JSON.stringify([
|
||||
'Samaya Investment', 'Hira Cultural District', 'Holy Quran Museum', 'Al-Safiya Museum'
|
||||
]);
|
||||
const mostAccounts = JSON.stringify([
|
||||
'Samaya Investment', 'Hira Cultural District', 'Holy Quran Museum',
|
||||
'Al-Safiya Museum', 'Hayhala', 'Jabal Thawr', 'Coffee Chain'
|
||||
]);
|
||||
const religiousExhibitions = JSON.stringify([
|
||||
'Holy Quran Museum', 'Al-Safiya Museum', 'Jabal Thawr'
|
||||
]);
|
||||
|
||||
const insertMember = db.prepare(`
|
||||
INSERT INTO team_members (name, email, role, brands) VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const members = [
|
||||
['Dr. Muhammad Al-Sayed', 'muhammad.alsayed@samaya.sa', 'approver', allBrands],
|
||||
['Dr. Fahd Al-Thumairi', 'fahd.thumairi@samaya.sa', 'approver', someBrands],
|
||||
['Fahda Abdul Aziz', 'fahda@samaya.sa', 'publisher', mostAccounts],
|
||||
['Sara Al-Zahrani', 'sara@samaya.sa', 'content_creator', JSON.stringify(['Samaya Investment', 'Hira Cultural District', 'Coffee Chain'])],
|
||||
['Noura', 'noura@samaya.sa', 'content_creator', JSON.stringify(['Samaya Investment', 'Hira Cultural District', 'Hayhala', 'Taibah Gifts'])],
|
||||
['Saeed Ghanem', 'saeed@samaya.sa', 'content_creator', religiousExhibitions],
|
||||
['Anas Mater', 'anas@samaya.sa', 'producer', JSON.stringify(['Samaya Investment', 'Hira Cultural District'])],
|
||||
['Muhammad Nu\'man', 'numan@samaya.sa', 'manager', JSON.stringify(['Google Maps'])],
|
||||
['Fahed', 'fahed@samaya.sa', 'manager', allBrands],
|
||||
];
|
||||
|
||||
const insertMembers = db.transaction(() => {
|
||||
for (const m of members) {
|
||||
insertMember.run(...m);
|
||||
}
|
||||
});
|
||||
insertMembers();
|
||||
|
||||
// Seed brands
|
||||
const insertBrand = db.prepare(`
|
||||
INSERT INTO brands (name, priority, color, icon) VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const brands = [
|
||||
['Samaya Investment', 1, '#1E3A5F', '🏢'],
|
||||
['Hira Cultural District', 1, '#8B4513', '🏛️'],
|
||||
['Holy Quran Museum', 1, '#2E7D32', '📖'],
|
||||
['Al-Safiya Museum', 1, '#6A1B9A', '🏺'],
|
||||
['Hayhala', 1, '#C62828', '🎭'],
|
||||
['Jabal Thawr', 1, '#4E342E', '⛰️'],
|
||||
['Coffee Chain', 2, '#795548', '☕'],
|
||||
['Taibah Gifts', 3, '#E65100', '🎁'],
|
||||
];
|
||||
|
||||
const insertBrands = db.transaction(() => {
|
||||
for (const b of brands) {
|
||||
insertBrand.run(...b);
|
||||
}
|
||||
});
|
||||
insertBrands();
|
||||
|
||||
console.log('✅ Database seeded with team members and brands');
|
||||
}
|
||||
|
||||
function seedDefaultUser() {
|
||||
const passwordHash = bcrypt.hashSync('admin123', 10);
|
||||
const insertUser = db.prepare(`
|
||||
INSERT INTO users (name, email, password_hash, role) VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
insertUser.run('Fahed Muhaidi', 'f.mahidi@samayainvest.com', passwordHash, 'superadmin');
|
||||
console.log('✅ Default superadmin created (email: f.mahidi@samayainvest.com, password: admin123)');
|
||||
}
|
||||
|
||||
module.exports = { db, initialize };
|
||||
@@ -1,458 +0,0 @@
|
||||
#!/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);
|
||||
});
|
||||
@@ -591,10 +591,10 @@ app.get('/api/brands', requireAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
app.post('/api/brands', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
|
||||
const { name, priority, color, icon } = req.body;
|
||||
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, priority: priority || 2, color: color || null, icon: icon || null });
|
||||
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) {
|
||||
@@ -607,7 +607,7 @@ app.patch('/api/brands/:id', requireAuth, requireRole('superadmin', 'manager'),
|
||||
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', 'priority', 'color', 'icon']) {
|
||||
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' });
|
||||
@@ -619,6 +619,50 @@ app.patch('/api/brands/:id', requireAuth, requireRole('superadmin', 'manager'),
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/brands/:id/logo', requireAuth, requireRole('superadmin', 'manager'), upload.single('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) => {
|
||||
|
||||
@@ -97,6 +97,7 @@ async function main() {
|
||||
text('color'),
|
||||
text('icon'),
|
||||
text('category'),
|
||||
text('logo'),
|
||||
]);
|
||||
|
||||
const campaigns = await createTable(baseId, 'Campaigns', [
|
||||
|
||||
Reference in New Issue
Block a user