Files
marketing-app/server/db.js
fahed d15e54044e Campaign assignments, ownership-based editing, and role-scoped data
- Add campaign_assignments table for user-to-campaign mapping
- Superadmin/managers can assign users to campaigns; visibility filtered by assignment/ownership
- Managers can only manage (tracks, assignments) on campaigns they created
- Budget controlled by superadmin only, with proper modal UI for editing
- Ownership-based editing for campaigns, projects, comments (creators can edit their own)
- Role-scoped dashboard and finance data (managers see only their campaigns' data)
- Manager's budget derived from sum of their campaign budgets set by superadmin
- Hide UI features users cannot use (principle of least privilege across all pages)
- Fix profile completion prompt persisting after saving (login response now includes profileComplete)
- Add post detail modal in campaign detail with thumbnails, publication links, and metadata
- Add comment inline editing for comment authors
- Move financial summary cards below filters on Campaigns page

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 13:59:40 +03:00

373 lines
13 KiB
JavaScript

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 };