Marketing Hub: RBAC, i18n (AR/EN), tasks overhaul, team/user merge, tutorial
Features: - Full RBAC with 3 roles (superadmin/manager/contributor) - Ownership tracking on posts, tasks, campaigns, projects - Task system: assign to anyone, filter combobox, visibility scoping - Team members merged into users table (single source of truth) - Post thumbnails on kanban cards from attachments - Publication link validation before publishing - Interactive onboarding tutorial with Settings restart - Full Arabic/English i18n with RTL layout support - Language toggle in sidebar, IBM Plex Sans Arabic font - Brand-based visibility filtering for non-superadmins - Manager can only create contributors - Profile completion flow for new users - Cookie-based sessions (express-session + SQLite)
This commit is contained in:
410
server/db.js
Normal file
410
server/db.js
Normal file
@@ -0,0 +1,410 @@
|
||||
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
|
||||
);
|
||||
`);
|
||||
|
||||
// ─── Ownership columns (link to users table) ───
|
||||
const addOwnership = (table, column) => {
|
||||
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} INTEGER REFERENCES users(id)`);
|
||||
console.log(`✅ Added ${column} column to ${table}`);
|
||||
}
|
||||
};
|
||||
addOwnership('posts', 'created_by_user_id');
|
||||
addOwnership('tasks', 'created_by_user_id');
|
||||
addOwnership('campaigns', 'created_by_user_id');
|
||||
addOwnership('projects', 'created_by_user_id');
|
||||
|
||||
// Add phone column to team_members if missing
|
||||
const teamMemberCols = db.prepare("PRAGMA table_info(team_members)").all().map(c => c.name);
|
||||
if (!teamMemberCols.includes('phone')) {
|
||||
db.exec("ALTER TABLE team_members ADD COLUMN phone TEXT");
|
||||
console.log('✅ Added phone column to team_members');
|
||||
}
|
||||
|
||||
// Migrations — add columns if they don't exist
|
||||
const campaignCols = db.prepare("PRAGMA table_info(campaigns)").all().map(c => c.name);
|
||||
if (!campaignCols.includes('platforms')) {
|
||||
db.exec("ALTER TABLE campaigns ADD COLUMN platforms TEXT DEFAULT '[]'");
|
||||
console.log('✅ Added platforms column to campaigns');
|
||||
}
|
||||
|
||||
// Campaign performance tracking columns
|
||||
if (!campaignCols.includes('budget_spent')) {
|
||||
db.exec("ALTER TABLE campaigns ADD COLUMN budget_spent REAL DEFAULT 0");
|
||||
console.log('✅ Added budget_spent column to campaigns');
|
||||
}
|
||||
if (!campaignCols.includes('revenue')) {
|
||||
db.exec("ALTER TABLE campaigns ADD COLUMN revenue REAL DEFAULT 0");
|
||||
console.log('✅ Added revenue column to campaigns');
|
||||
}
|
||||
if (!campaignCols.includes('impressions')) {
|
||||
db.exec("ALTER TABLE campaigns ADD COLUMN impressions INTEGER DEFAULT 0");
|
||||
console.log('✅ Added impressions column to campaigns');
|
||||
}
|
||||
if (!campaignCols.includes('clicks')) {
|
||||
db.exec("ALTER TABLE campaigns ADD COLUMN clicks INTEGER DEFAULT 0");
|
||||
console.log('✅ Added clicks column to campaigns');
|
||||
}
|
||||
if (!campaignCols.includes('conversions')) {
|
||||
db.exec("ALTER TABLE campaigns ADD COLUMN conversions INTEGER DEFAULT 0");
|
||||
console.log('✅ Added conversions column to campaigns');
|
||||
}
|
||||
if (!campaignCols.includes('cost_per_click')) {
|
||||
db.exec("ALTER TABLE campaigns ADD COLUMN cost_per_click REAL DEFAULT 0");
|
||||
console.log('✅ Added cost_per_click column to campaigns');
|
||||
}
|
||||
if (!campaignCols.includes('notes')) {
|
||||
db.exec("ALTER TABLE campaigns ADD COLUMN notes TEXT DEFAULT ''");
|
||||
console.log('✅ Added notes column to campaigns');
|
||||
}
|
||||
|
||||
// Add track_id to posts
|
||||
const postCols = db.prepare("PRAGMA table_info(posts)").all().map(c => c.name);
|
||||
if (!postCols.includes('track_id')) {
|
||||
db.exec("ALTER TABLE posts ADD COLUMN track_id INTEGER REFERENCES campaign_tracks(id)");
|
||||
console.log('✅ Added track_id column to posts');
|
||||
}
|
||||
if (!postCols.includes('campaign_id')) {
|
||||
db.exec("ALTER TABLE posts ADD COLUMN campaign_id INTEGER REFERENCES campaigns(id)");
|
||||
console.log('✅ Added campaign_id column to posts');
|
||||
}
|
||||
if (!postCols.includes('platforms')) {
|
||||
// Add platforms column, migrate existing platform values
|
||||
db.exec("ALTER TABLE posts ADD COLUMN platforms TEXT DEFAULT '[]'");
|
||||
// Migrate: copy single platform value into platforms JSON array
|
||||
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`);
|
||||
}
|
||||
|
||||
// Add campaign_id to assets
|
||||
const assetCols = db.prepare("PRAGMA table_info(assets)").all().map(c => c.name);
|
||||
if (!assetCols.includes('campaign_id')) {
|
||||
db.exec("ALTER TABLE assets ADD COLUMN campaign_id INTEGER REFERENCES campaigns(id)");
|
||||
console.log('✅ Added campaign_id column to assets');
|
||||
}
|
||||
|
||||
// ─── Link users to team_members ───
|
||||
const userCols = db.prepare("PRAGMA table_info(users)").all().map(c => c.name);
|
||||
if (!userCols.includes('team_member_id')) {
|
||||
db.exec("ALTER TABLE users ADD COLUMN team_member_id INTEGER REFERENCES team_members(id)");
|
||||
console.log('✅ Added team_member_id column to users');
|
||||
}
|
||||
|
||||
// ─── 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
|
||||
);
|
||||
`);
|
||||
|
||||
// ─── Publication links column on posts ───
|
||||
if (!postCols.includes('publication_links')) {
|
||||
db.exec("ALTER TABLE posts ADD COLUMN publication_links TEXT DEFAULT '[]'");
|
||||
console.log('✅ Added publication_links column to posts');
|
||||
}
|
||||
|
||||
// ─── Merge team_members into users ───
|
||||
if (!userCols.includes('team_role')) {
|
||||
db.exec("ALTER TABLE users ADD COLUMN team_role TEXT");
|
||||
console.log('✅ Added team_role column to users');
|
||||
}
|
||||
if (!userCols.includes('brands')) {
|
||||
db.exec("ALTER TABLE users ADD COLUMN brands TEXT DEFAULT '[]'");
|
||||
console.log('✅ Added brands column to users');
|
||||
}
|
||||
if (!userCols.includes('phone')) {
|
||||
db.exec("ALTER TABLE users ADD COLUMN phone TEXT");
|
||||
console.log('✅ Added phone column to users');
|
||||
}
|
||||
if (!userCols.includes('tutorial_completed')) {
|
||||
db.exec("ALTER TABLE users ADD COLUMN tutorial_completed INTEGER DEFAULT 0");
|
||||
console.log('✅ Added tutorial_completed column to users');
|
||||
}
|
||||
|
||||
// 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 };
|
||||
Reference in New Issue
Block a user