video preview version
This commit is contained in:
172
server/db.js
172
server/db.js
@@ -152,98 +152,17 @@ function initialize() {
|
||||
);
|
||||
`);
|
||||
|
||||
// ─── 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');
|
||||
}
|
||||
// ─── 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(`
|
||||
@@ -259,30 +178,61 @@ function initialize() {
|
||||
);
|
||||
`);
|
||||
|
||||
// ─── 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');
|
||||
// ─── 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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');
|
||||
// Ownership columns (link to users table)
|
||||
for (const table of ['posts', 'tasks', 'campaigns', 'projects']) {
|
||||
addColumnIfMissing(table, 'created_by_user_id', 'INTEGER REFERENCES users(id)');
|
||||
}
|
||||
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');
|
||||
|
||||
// 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);
|
||||
|
||||
BIN
server/node_modules/better-sqlite3/build/Release/better_sqlite3.node
generated
vendored
BIN
server/node_modules/better-sqlite3/build/Release/better_sqlite3.node
generated
vendored
Binary file not shown.
BIN
server/node_modules/better-sqlite3/build/Release/obj.target/better_sqlite3.node
generated
vendored
BIN
server/node_modules/better-sqlite3/build/Release/obj.target/better_sqlite3.node
generated
vendored
Binary file not shown.
548
server/server.js
548
server/server.js
@@ -11,6 +11,60 @@ const { db, initialize } = require('./db');
|
||||
const app = express();
|
||||
const PORT = 3001;
|
||||
|
||||
// ─── SHARED HELPERS ─────────────────────────────────────────────
|
||||
|
||||
// Builds a dynamic UPDATE clause from request body fields.
|
||||
// Returns { clause, values } where clause is "field1 = ?, field2 = ?" and values is the corresponding array.
|
||||
// `jsonFields` are serialized with JSON.stringify before binding.
|
||||
// `extraClauses` are appended as-is (e.g., 'updated_at = CURRENT_TIMESTAMP').
|
||||
function buildUpdate(body, allowedFields, { jsonFields = [], extraClauses = [] } = {}) {
|
||||
const clauses = [...extraClauses];
|
||||
const values = [];
|
||||
for (const field of allowedFields) {
|
||||
if (body[field] !== undefined) {
|
||||
clauses.push(`${field} = ?`);
|
||||
values.push(jsonFields.includes(field) ? JSON.stringify(body[field]) : body[field]);
|
||||
}
|
||||
}
|
||||
return { clauses, values, hasUpdates: clauses.length > 0 };
|
||||
}
|
||||
|
||||
// Reusable SQL fragments for joined queries
|
||||
const POST_SELECT_SQL = `SELECT p.*, b.name as brand_name, t.name as assigned_name, c.name as campaign_name, u.name as creator_user_name
|
||||
FROM posts p
|
||||
LEFT JOIN brands b ON p.brand_id = b.id
|
||||
LEFT JOIN team_members t ON p.assigned_to = t.id
|
||||
LEFT JOIN campaigns c ON p.campaign_id = c.id
|
||||
LEFT JOIN users u ON p.created_by_user_id = u.id`;
|
||||
|
||||
const TASK_SELECT_SQL = `SELECT t.*,
|
||||
p.name as project_name,
|
||||
a.name as assigned_name,
|
||||
c.name as creator_name,
|
||||
u.name as creator_user_name
|
||||
FROM tasks t
|
||||
LEFT JOIN projects p ON t.project_id = p.id
|
||||
LEFT JOIN team_members a ON t.assigned_to = a.id
|
||||
LEFT JOIN team_members c ON t.created_by = c.id
|
||||
LEFT JOIN users u ON t.created_by_user_id = u.id`;
|
||||
|
||||
const PROJECT_SELECT_SQL = `SELECT p.*, b.name as brand_name, t.name as owner_name
|
||||
FROM projects p
|
||||
LEFT JOIN brands b ON p.brand_id = b.id
|
||||
LEFT JOIN team_members t ON p.owner_id = t.id`;
|
||||
|
||||
const CAMPAIGN_SELECT_SQL = `SELECT c.*, b.name as brand_name
|
||||
FROM campaigns c
|
||||
LEFT JOIN brands b ON c.brand_id = b.id`;
|
||||
|
||||
function parsePostJson(post) {
|
||||
return {
|
||||
...post,
|
||||
platforms: JSON.parse(post.platforms || '[]'),
|
||||
publication_links: JSON.parse(post.publication_links || '[]'),
|
||||
};
|
||||
}
|
||||
|
||||
// Ensure uploads directory exists
|
||||
const uploadsDir = path.join(__dirname, 'uploads');
|
||||
if (!fs.existsSync(uploadsDir)) {
|
||||
@@ -52,9 +106,12 @@ app.use(session({
|
||||
app.use('/api/uploads', express.static(uploadsDir));
|
||||
|
||||
// Multer config
|
||||
const decodeOriginalName = (name) => Buffer.from(name, 'latin1').toString('utf8');
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, uploadsDir),
|
||||
filename: (req, file, cb) => {
|
||||
file.originalname = decodeOriginalName(file.originalname);
|
||||
const uniqueName = `${Date.now()}-${Math.round(Math.random() * 1e9)}${path.extname(file.originalname)}`;
|
||||
cb(null, uniqueName);
|
||||
}
|
||||
@@ -86,7 +143,12 @@ function requireRole(...roles) {
|
||||
}
|
||||
|
||||
// Ownership check: contributors can only modify their own resources (or resources assigned to them)
|
||||
const VALID_OWNER_TABLES = new Set(['posts', 'tasks']);
|
||||
|
||||
function requireOwnerOrRole(table, ...allowedRoles) {
|
||||
if (!VALID_OWNER_TABLES.has(table)) {
|
||||
throw new Error(`requireOwnerOrRole: invalid table "${table}"`);
|
||||
}
|
||||
return (req, res, next) => {
|
||||
if (!req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
@@ -186,31 +248,22 @@ app.get('/api/users/me/profile', requireAuth, (req, res) => {
|
||||
});
|
||||
|
||||
app.patch('/api/users/me/profile', requireAuth, async (req, res) => {
|
||||
const { name, team_role, brands, phone } = req.body;
|
||||
const updates = [];
|
||||
const values = [];
|
||||
const { clauses, values, hasUpdates } = buildUpdate(
|
||||
req.body, ['name', 'team_role', 'phone', 'brands'], { jsonFields: ['brands'] }
|
||||
);
|
||||
|
||||
if (name !== undefined) { updates.push('name = ?'); values.push(name); }
|
||||
if (team_role !== undefined) { updates.push('team_role = ?'); values.push(team_role); }
|
||||
if (phone !== undefined) { updates.push('phone = ?'); values.push(phone); }
|
||||
if (brands !== undefined) {
|
||||
updates.push('brands = ?');
|
||||
values.push(JSON.stringify(brands));
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
if (!hasUpdates) {
|
||||
return res.status(400).json({ error: 'No fields to update' });
|
||||
}
|
||||
|
||||
try {
|
||||
values.push(req.session.userId);
|
||||
db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...values);
|
||||
|
||||
// Also update session if name changed
|
||||
if (name !== undefined) {
|
||||
req.session.userName = name;
|
||||
db.prepare(`UPDATE users SET ${clauses.join(', ')} WHERE id = ?`).run(...values);
|
||||
|
||||
if (req.body.name !== undefined) {
|
||||
req.session.userName = req.body.name;
|
||||
}
|
||||
|
||||
|
||||
const user = db.prepare('SELECT id, name, email, role, team_role, brands, phone, avatar, tutorial_completed FROM users WHERE id = ?').get(req.session.userId);
|
||||
res.json({ ...user, brands: JSON.parse(user.brands || '[]') });
|
||||
} catch (err) {
|
||||
@@ -274,35 +327,26 @@ app.patch('/api/users/:id', requireAuth, requireRole('superadmin'), async (req,
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
const { name, email, password, role, avatar } = req.body;
|
||||
const updates = [];
|
||||
const values = [];
|
||||
|
||||
if (name !== undefined) { updates.push('name = ?'); values.push(name); }
|
||||
if (email !== undefined) { updates.push('email = ?'); values.push(email); }
|
||||
if (role !== undefined) {
|
||||
if (!['superadmin', 'manager', 'contributor'].includes(role)) {
|
||||
return res.status(400).json({ error: 'Invalid role' });
|
||||
}
|
||||
updates.push('role = ?');
|
||||
values.push(role);
|
||||
}
|
||||
if (avatar !== undefined) { updates.push('avatar = ?'); values.push(avatar); }
|
||||
if (req.body.team_member_id !== undefined) { updates.push('team_member_id = ?'); values.push(req.body.team_member_id || null); }
|
||||
|
||||
if (password) {
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
updates.push('password_hash = ?');
|
||||
values.push(passwordHash);
|
||||
if (req.body.role !== undefined && !['superadmin', 'manager', 'contributor'].includes(req.body.role)) {
|
||||
return res.status(400).json({ error: 'Invalid role' });
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
const { clauses, values, hasUpdates } = buildUpdate(
|
||||
req.body, ['name', 'email', 'role', 'avatar', 'team_member_id']
|
||||
);
|
||||
|
||||
if (req.body.password) {
|
||||
clauses.push('password_hash = ?');
|
||||
values.push(await bcrypt.hash(req.body.password, 10));
|
||||
}
|
||||
|
||||
if (!hasUpdates && !req.body.password) {
|
||||
return res.status(400).json({ error: 'No fields to update' });
|
||||
}
|
||||
|
||||
try {
|
||||
values.push(id);
|
||||
db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...values);
|
||||
db.prepare(`UPDATE users SET ${clauses.join(', ')} WHERE id = ?`).run(...values);
|
||||
const user = db.prepare('SELECT id, name, email, role, avatar, team_member_id, created_at FROM users WHERE id = ?').get(id);
|
||||
res.json(user);
|
||||
} catch (err) {
|
||||
@@ -352,6 +396,18 @@ app.get('/api/auth/permissions', requireAuth, (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── ASSIGNABLE USERS (no brand filtering) ──────────────────────
|
||||
|
||||
app.get('/api/users/assignable', requireAuth, (req, res) => {
|
||||
const users = db.prepare(`
|
||||
SELECT u.id, u.name, u.team_role, u.avatar, u.team_member_id
|
||||
FROM users u
|
||||
WHERE u.team_member_id IS NOT NULL
|
||||
ORDER BY u.name
|
||||
`).all();
|
||||
res.json(users.map(u => ({ ...u, _id: u.team_member_id })));
|
||||
});
|
||||
|
||||
// ─── TEAM (Users with team info) ────────────────────────────────
|
||||
|
||||
app.get('/api/users/team', requireAuth, (req, res) => {
|
||||
@@ -432,38 +488,26 @@ app.patch('/api/users/team/:id', requireAuth, requireRole('superadmin', 'manager
|
||||
const existing = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
|
||||
if (!existing) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
const updates = [];
|
||||
const values = [];
|
||||
const { clauses, values, hasUpdates } = buildUpdate(
|
||||
req.body, ['name', 'email', 'team_role', 'phone', 'brands'], { jsonFields: ['brands'] }
|
||||
);
|
||||
|
||||
if (req.body.name !== undefined) { updates.push('name = ?'); values.push(req.body.name); }
|
||||
if (req.body.email !== undefined) { updates.push('email = ?'); values.push(req.body.email); }
|
||||
if (req.body.team_role !== undefined) { updates.push('team_role = ?'); values.push(req.body.team_role); }
|
||||
if (req.body.phone !== undefined) { updates.push('phone = ?'); values.push(req.body.phone); }
|
||||
if (req.body.brands !== undefined) {
|
||||
updates.push('brands = ?');
|
||||
values.push(JSON.stringify(req.body.brands));
|
||||
}
|
||||
|
||||
if (updates.length === 0) return res.status(400).json({ error: 'No fields to update' });
|
||||
if (!hasUpdates) return res.status(400).json({ error: 'No fields to update' });
|
||||
|
||||
// Also update team_members table for backward compatibility
|
||||
if (existing.team_member_id) {
|
||||
const tmUpdates = [];
|
||||
const tmValues = [];
|
||||
if (req.body.name !== undefined) { tmUpdates.push('name = ?'); tmValues.push(req.body.name); }
|
||||
if (req.body.email !== undefined) { tmUpdates.push('email = ?'); tmValues.push(req.body.email); }
|
||||
if (req.body.team_role !== undefined) { tmUpdates.push('role = ?'); tmValues.push(req.body.team_role); }
|
||||
if (req.body.phone !== undefined) { tmUpdates.push('phone = ?'); tmValues.push(req.body.phone); }
|
||||
if (req.body.brands !== undefined) { tmUpdates.push('brands = ?'); tmValues.push(JSON.stringify(req.body.brands)); }
|
||||
|
||||
if (tmUpdates.length > 0) {
|
||||
tmValues.push(existing.team_member_id);
|
||||
db.prepare(`UPDATE team_members SET ${tmUpdates.join(', ')} WHERE id = ?`).run(...tmValues);
|
||||
// Map user fields to team_member fields (team_role -> role)
|
||||
const tmBody = { ...req.body };
|
||||
if (tmBody.team_role !== undefined) { tmBody.role = tmBody.team_role; delete tmBody.team_role; }
|
||||
const tm = buildUpdate(tmBody, ['name', 'email', 'role', 'phone', 'brands'], { jsonFields: ['brands'] });
|
||||
if (tm.hasUpdates) {
|
||||
tm.values.push(existing.team_member_id);
|
||||
db.prepare(`UPDATE team_members SET ${tm.clauses.join(', ')} WHERE id = ?`).run(...tm.values);
|
||||
}
|
||||
}
|
||||
|
||||
values.push(id);
|
||||
db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...values);
|
||||
db.prepare(`UPDATE users SET ${clauses.join(', ')} WHERE id = ?`).run(...values);
|
||||
|
||||
const user = db.prepare('SELECT id, name, email, role, team_role, brands, phone, avatar, team_member_id FROM users WHERE id = ?').get(id);
|
||||
res.json({ ...user, _id: user.id, brands: JSON.parse(user.brands || '[]') });
|
||||
@@ -508,25 +552,14 @@ app.patch('/api/team/:id', requireAuth, requireRole('superadmin', 'manager'), (r
|
||||
const existing = db.prepare('SELECT * FROM team_members WHERE id = ?').get(id);
|
||||
if (!existing) return res.status(404).json({ error: 'Member not found' });
|
||||
|
||||
const fields = ['name', 'email', 'role', 'avatar_url'];
|
||||
const updates = [];
|
||||
const values = [];
|
||||
const { clauses, values, hasUpdates } = buildUpdate(
|
||||
req.body, ['name', 'email', 'role', 'avatar_url', 'brands'], { jsonFields: ['brands'] }
|
||||
);
|
||||
|
||||
for (const field of fields) {
|
||||
if (req.body[field] !== undefined) {
|
||||
updates.push(`${field} = ?`);
|
||||
values.push(req.body[field]);
|
||||
}
|
||||
}
|
||||
if (req.body.brands !== undefined) {
|
||||
updates.push('brands = ?');
|
||||
values.push(JSON.stringify(req.body.brands));
|
||||
}
|
||||
|
||||
if (updates.length === 0) return res.status(400).json({ error: 'No fields to update' });
|
||||
if (!hasUpdates) return res.status(400).json({ error: 'No fields to update' });
|
||||
|
||||
values.push(id);
|
||||
db.prepare(`UPDATE team_members SET ${updates.join(', ')} WHERE id = ?`).run(...values);
|
||||
db.prepare(`UPDATE team_members SET ${clauses.join(', ')} WHERE id = ?`).run(...values);
|
||||
|
||||
const member = db.prepare('SELECT * FROM team_members WHERE id = ?').get(id);
|
||||
res.json({ ...member, brands: JSON.parse(member.brands || '[]') });
|
||||
@@ -604,21 +637,11 @@ app.patch('/api/brands/:id', requireAuth, requireRole('superadmin', 'manager'),
|
||||
const existing = db.prepare('SELECT * FROM brands WHERE id = ?').get(id);
|
||||
if (!existing) return res.status(404).json({ error: 'Brand not found' });
|
||||
|
||||
const fields = ['name', 'priority', 'color', 'icon'];
|
||||
const updates = [];
|
||||
const values = [];
|
||||
|
||||
for (const field of fields) {
|
||||
if (req.body[field] !== undefined) {
|
||||
updates.push(`${field} = ?`);
|
||||
values.push(req.body[field]);
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.length === 0) return res.status(400).json({ error: 'No fields to update' });
|
||||
const { clauses, values, hasUpdates } = buildUpdate(req.body, ['name', 'priority', 'color', 'icon']);
|
||||
if (!hasUpdates) return res.status(400).json({ error: 'No fields to update' });
|
||||
|
||||
values.push(id);
|
||||
db.prepare(`UPDATE brands SET ${updates.join(', ')} WHERE id = ?`).run(...values);
|
||||
db.prepare(`UPDATE brands SET ${clauses.join(', ')} WHERE id = ?`).run(...values);
|
||||
|
||||
const brand = db.prepare('SELECT * FROM brands WHERE id = ?').get(id);
|
||||
res.json(brand);
|
||||
@@ -642,7 +665,7 @@ app.get('/api/posts/stats', requireAuth, (req, res) => {
|
||||
|
||||
app.get('/api/posts', requireAuth, (req, res) => {
|
||||
const { status, brand_id, assigned_to, platform } = req.query;
|
||||
let sql = 'SELECT p.*, b.name as brand_name, t.name as assigned_name, c.name as campaign_name, u.name as creator_user_name FROM posts p LEFT JOIN brands b ON p.brand_id = b.id LEFT JOIN team_members t ON p.assigned_to = t.id LEFT JOIN campaigns c ON p.campaign_id = c.id LEFT JOIN users u ON p.created_by_user_id = u.id';
|
||||
let sql = POST_SELECT_SQL;
|
||||
const conditions = [];
|
||||
const values = [];
|
||||
|
||||
@@ -670,17 +693,12 @@ app.get('/api/posts', requireAuth, (req, res) => {
|
||||
|
||||
const posts = db.prepare(sql).all(...values);
|
||||
|
||||
// Add thumbnail for each post
|
||||
const postsWithThumbs = posts.map(p => {
|
||||
const thumb = db.prepare("SELECT url, mime_type FROM post_attachments WHERE post_id = ? AND mime_type LIKE 'image/%' ORDER BY created_at ASC LIMIT 1").get(p.id);
|
||||
return {
|
||||
...p,
|
||||
platforms: JSON.parse(p.platforms || '[]'),
|
||||
publication_links: JSON.parse(p.publication_links || '[]'),
|
||||
thumbnail_url: thumb?.url || null
|
||||
};
|
||||
});
|
||||
|
||||
const thumbnailStmt = db.prepare("SELECT url FROM post_attachments WHERE post_id = ? AND mime_type LIKE 'image/%' ORDER BY created_at ASC LIMIT 1");
|
||||
const postsWithThumbs = posts.map(p => ({
|
||||
...parsePostJson(p),
|
||||
thumbnail_url: thumbnailStmt.get(p.id)?.url || null,
|
||||
}));
|
||||
|
||||
res.json(postsWithThumbs);
|
||||
});
|
||||
|
||||
@@ -696,8 +714,8 @@ app.post('/api/posts', requireAuth, (req, res) => {
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(title, description || null, brand_id || null, assigned_to || null, status || 'draft', platformsArr[0] || null, JSON.stringify(platformsArr), content_type || null, scheduled_date || null, notes || null, campaign_id || null, req.session.userId);
|
||||
|
||||
const post = db.prepare('SELECT p.*, b.name as brand_name, t.name as assigned_name, c.name as campaign_name, u.name as creator_user_name FROM posts p LEFT JOIN brands b ON p.brand_id = b.id LEFT JOIN team_members t ON p.assigned_to = t.id LEFT JOIN campaigns c ON p.campaign_id = c.id LEFT JOIN users u ON p.created_by_user_id = u.id WHERE p.id = ?').get(result.lastInsertRowid);
|
||||
res.status(201).json({ ...post, platforms: JSON.parse(post.platforms || '[]'), publication_links: JSON.parse(post.publication_links || '[]') });
|
||||
const post = db.prepare(`${POST_SELECT_SQL} WHERE p.id = ?`).get(result.lastInsertRowid);
|
||||
res.status(201).json(parsePostJson(post));
|
||||
});
|
||||
|
||||
app.patch('/api/posts/:id', requireAuth, requireOwnerOrRole('posts', 'superadmin', 'manager'), (req, res) => {
|
||||
@@ -705,63 +723,50 @@ app.patch('/api/posts/:id', requireAuth, requireOwnerOrRole('posts', 'superadmin
|
||||
const existing = db.prepare('SELECT * FROM posts WHERE id = ?').get(id);
|
||||
if (!existing) return res.status(404).json({ error: 'Post not found' });
|
||||
|
||||
const fields = ['title', 'description', 'brand_id', 'assigned_to', 'status', 'platform', 'content_type', 'scheduled_date', 'published_date', 'notes', 'campaign_id'];
|
||||
const updates = ['updated_at = CURRENT_TIMESTAMP'];
|
||||
const values = [];
|
||||
const postFields = ['title', 'description', 'brand_id', 'assigned_to', 'status', 'platform', 'content_type', 'scheduled_date', 'published_date', 'notes', 'campaign_id'];
|
||||
const { clauses, values } = buildUpdate(req.body, postFields, {
|
||||
jsonFields: ['platforms', 'publication_links'],
|
||||
extraClauses: ['updated_at = CURRENT_TIMESTAMP'],
|
||||
});
|
||||
|
||||
for (const field of fields) {
|
||||
if (req.body[field] !== undefined) {
|
||||
updates.push(`${field} = ?`);
|
||||
values.push(req.body[field]);
|
||||
}
|
||||
}
|
||||
// Handle JSON array fields
|
||||
if (req.body.platforms !== undefined) {
|
||||
updates.push('platforms = ?');
|
||||
clauses.push('platforms = ?');
|
||||
values.push(JSON.stringify(req.body.platforms));
|
||||
// Also keep platform field in sync (first platform)
|
||||
if (!req.body.platform) {
|
||||
updates.push('platform = ?');
|
||||
clauses.push('platform = ?');
|
||||
values.push(req.body.platforms[0] || null);
|
||||
}
|
||||
}
|
||||
if (req.body.publication_links !== undefined) {
|
||||
updates.push('publication_links = ?');
|
||||
clauses.push('publication_links = ?');
|
||||
values.push(JSON.stringify(req.body.publication_links));
|
||||
}
|
||||
|
||||
// Validate publication links when publishing
|
||||
if (req.body.status === 'published') {
|
||||
const currentPlatforms = req.body.platforms
|
||||
? req.body.platforms
|
||||
: JSON.parse(existing.platforms || '[]');
|
||||
const currentLinks = req.body.publication_links
|
||||
? req.body.publication_links
|
||||
: JSON.parse(existing.publication_links || '[]');
|
||||
|
||||
if (currentPlatforms.length > 0) {
|
||||
const missingPlatforms = currentPlatforms.filter(platform => {
|
||||
const link = currentLinks.find(l => l.platform === platform);
|
||||
return !link || !link.url || !link.url.trim();
|
||||
const currentPlatforms = req.body.platforms || JSON.parse(existing.platforms || '[]');
|
||||
const currentLinks = req.body.publication_links || JSON.parse(existing.publication_links || '[]');
|
||||
const missingPlatforms = currentPlatforms.filter(platform => {
|
||||
const link = currentLinks.find(l => l.platform === platform);
|
||||
return !link || !link.url || !link.url.trim();
|
||||
});
|
||||
if (missingPlatforms.length > 0) {
|
||||
return res.status(400).json({
|
||||
error: `Cannot publish: missing publication links for: ${missingPlatforms.join(', ')}`,
|
||||
missingPlatforms,
|
||||
});
|
||||
if (missingPlatforms.length > 0) {
|
||||
return res.status(400).json({
|
||||
error: `Cannot publish: missing publication links for: ${missingPlatforms.join(', ')}`,
|
||||
missingPlatforms
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!req.body.published_date) {
|
||||
clauses.push('published_date = CURRENT_TIMESTAMP');
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-set published_date when status changes to published
|
||||
if (req.body.status === 'published' && !req.body.published_date) {
|
||||
updates.push('published_date = CURRENT_TIMESTAMP');
|
||||
}
|
||||
|
||||
values.push(id);
|
||||
db.prepare(`UPDATE posts SET ${updates.join(', ')} WHERE id = ?`).run(...values);
|
||||
db.prepare(`UPDATE posts SET ${clauses.join(', ')} WHERE id = ?`).run(...values);
|
||||
|
||||
const post = db.prepare('SELECT p.*, b.name as brand_name, t.name as assigned_name, c.name as campaign_name, u.name as creator_user_name FROM posts p LEFT JOIN brands b ON p.brand_id = b.id LEFT JOIN team_members t ON p.assigned_to = t.id LEFT JOIN campaigns c ON p.campaign_id = c.id LEFT JOIN users u ON p.created_by_user_id = u.id WHERE p.id = ?').get(id);
|
||||
res.json({ ...post, platforms: JSON.parse(post.platforms || '[]'), publication_links: JSON.parse(post.publication_links || '[]') });
|
||||
const post = db.prepare(`${POST_SELECT_SQL} WHERE p.id = ?`).get(id);
|
||||
res.json(parsePostJson(post));
|
||||
});
|
||||
|
||||
app.delete('/api/posts/:id', requireAuth, requireOwnerOrRole('posts', 'superadmin', 'manager'), (req, res) => {
|
||||
@@ -804,6 +809,35 @@ app.post('/api/posts/:id/attachments', requireAuth, upload.single('file'), (req,
|
||||
res.status(201).json(attachment);
|
||||
});
|
||||
|
||||
app.post('/api/posts/:id/attachments/from-asset', requireAuth, (req, res) => {
|
||||
const { asset_id } = req.body;
|
||||
if (!asset_id) return res.status(400).json({ error: 'asset_id is required' });
|
||||
|
||||
const postId = req.params.id;
|
||||
const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
|
||||
if (!post) return res.status(404).json({ error: 'Post not found' });
|
||||
|
||||
// Contributors can only add to their own posts
|
||||
if (req.session.userRole === 'contributor' && post.created_by_user_id !== req.session.userId) {
|
||||
const currentUser = db.prepare('SELECT team_member_id FROM users WHERE id = ?').get(req.session.userId);
|
||||
if (!currentUser?.team_member_id || post.assigned_to !== currentUser.team_member_id) {
|
||||
return res.status(403).json({ error: 'You can only manage attachments on your own posts' });
|
||||
}
|
||||
}
|
||||
|
||||
const asset = db.prepare('SELECT * FROM assets WHERE id = ?').get(asset_id);
|
||||
if (!asset) return res.status(404).json({ error: 'Asset not found' });
|
||||
|
||||
const url = `/api/uploads/${asset.filename}`;
|
||||
const result = db.prepare(`
|
||||
INSERT INTO post_attachments (post_id, filename, original_name, mime_type, size, url)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(postId, asset.filename, asset.original_name, asset.mime_type, asset.size, url);
|
||||
|
||||
const attachment = db.prepare('SELECT * FROM post_attachments WHERE id = ?').get(result.lastInsertRowid);
|
||||
res.status(201).json(attachment);
|
||||
});
|
||||
|
||||
app.delete('/api/attachments/:id', requireAuth, (req, res) => {
|
||||
const attachment = db.prepare('SELECT pa.*, p.created_by_user_id, p.assigned_to FROM post_attachments pa JOIN posts p ON pa.post_id = p.id WHERE pa.id = ?').get(req.params.id);
|
||||
if (!attachment) return res.status(404).json({ error: 'Attachment not found' });
|
||||
@@ -816,10 +850,14 @@ app.delete('/api/attachments/:id', requireAuth, (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Delete file from disk
|
||||
const filePath = path.join(uploadsDir, attachment.filename);
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
// Only delete file from disk if no asset or other attachment references it
|
||||
const otherRefs = db.prepare('SELECT COUNT(*) as cnt FROM post_attachments WHERE filename = ? AND id != ?').get(attachment.filename, req.params.id);
|
||||
const assetRef = db.prepare('SELECT COUNT(*) as cnt FROM assets WHERE filename = ?').get(attachment.filename);
|
||||
if (otherRefs.cnt === 0 && assetRef.cnt === 0) {
|
||||
const filePath = path.join(uploadsDir, attachment.filename);
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare('DELETE FROM post_attachments WHERE id = ?').run(req.params.id);
|
||||
@@ -874,10 +912,13 @@ app.delete('/api/assets/:id', requireAuth, requireRole('superadmin', 'manager'),
|
||||
const asset = db.prepare('SELECT * FROM assets WHERE id = ?').get(req.params.id);
|
||||
if (!asset) return res.status(404).json({ error: 'Asset not found' });
|
||||
|
||||
// Delete file from disk
|
||||
const filePath = path.join(uploadsDir, asset.filename);
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
// Only delete file from disk if no post attachment references it
|
||||
const attachmentRef = db.prepare('SELECT COUNT(*) as cnt FROM post_attachments WHERE filename = ?').get(asset.filename);
|
||||
if (attachmentRef.cnt === 0) {
|
||||
const filePath = path.join(uploadsDir, asset.filename);
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare('DELETE FROM assets WHERE id = ?').run(req.params.id);
|
||||
@@ -888,7 +929,7 @@ app.delete('/api/assets/:id', requireAuth, requireRole('superadmin', 'manager'),
|
||||
|
||||
app.get('/api/campaigns', requireAuth, (req, res) => {
|
||||
const { brand_id, status, start_date, end_date } = req.query;
|
||||
let sql = 'SELECT c.*, b.name as brand_name FROM campaigns c LEFT JOIN brands b ON c.brand_id = b.id';
|
||||
let sql = CAMPAIGN_SELECT_SQL;
|
||||
const conditions = [];
|
||||
const values = [];
|
||||
|
||||
@@ -914,7 +955,7 @@ app.post('/api/campaigns', requireAuth, requireRole('superadmin', 'manager'), (r
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(name, description || null, brand_id || null, start_date, end_date, status || 'planning', color || null, budget || null, goals || null, JSON.stringify(platforms || []), req.session.userId);
|
||||
|
||||
const campaign = db.prepare('SELECT c.*, b.name as brand_name FROM campaigns c LEFT JOIN brands b ON c.brand_id = b.id WHERE c.id = ?').get(result.lastInsertRowid);
|
||||
const campaign = db.prepare(`${CAMPAIGN_SELECT_SQL} WHERE c.id = ?`).get(result.lastInsertRowid);
|
||||
res.status(201).json({ ...campaign, platforms: JSON.parse(campaign.platforms || '[]') });
|
||||
});
|
||||
|
||||
@@ -923,28 +964,18 @@ app.patch('/api/campaigns/:id', requireAuth, requireRole('superadmin', 'manager'
|
||||
const existing = db.prepare('SELECT * FROM campaigns WHERE id = ?').get(id);
|
||||
if (!existing) return res.status(404).json({ error: 'Campaign not found' });
|
||||
|
||||
const fields = ['name', 'description', 'brand_id', 'start_date', 'end_date', 'status', 'color', 'budget', 'goals',
|
||||
'budget_spent', 'revenue', 'impressions', 'clicks', 'conversions', 'cost_per_click', 'notes'];
|
||||
const updates = [];
|
||||
const values = [];
|
||||
const campaignFields = [
|
||||
'name', 'description', 'brand_id', 'start_date', 'end_date', 'status', 'color', 'budget', 'goals',
|
||||
'budget_spent', 'revenue', 'impressions', 'clicks', 'conversions', 'cost_per_click', 'notes', 'platforms',
|
||||
];
|
||||
const { clauses, values, hasUpdates } = buildUpdate(req.body, campaignFields, { jsonFields: ['platforms'] });
|
||||
|
||||
for (const field of fields) {
|
||||
if (req.body[field] !== undefined) {
|
||||
updates.push(`${field} = ?`);
|
||||
values.push(req.body[field]);
|
||||
}
|
||||
}
|
||||
if (req.body.platforms !== undefined) {
|
||||
updates.push('platforms = ?');
|
||||
values.push(JSON.stringify(req.body.platforms));
|
||||
}
|
||||
|
||||
if (updates.length === 0) return res.status(400).json({ error: 'No fields to update' });
|
||||
if (!hasUpdates) return res.status(400).json({ error: 'No fields to update' });
|
||||
|
||||
values.push(id);
|
||||
db.prepare(`UPDATE campaigns SET ${updates.join(', ')} WHERE id = ?`).run(...values);
|
||||
db.prepare(`UPDATE campaigns SET ${clauses.join(', ')} WHERE id = ?`).run(...values);
|
||||
|
||||
const campaign = db.prepare('SELECT c.*, b.name as brand_name FROM campaigns c LEFT JOIN brands b ON c.brand_id = b.id WHERE c.id = ?').get(id);
|
||||
const campaign = db.prepare(`${CAMPAIGN_SELECT_SQL} WHERE c.id = ?`).get(id);
|
||||
res.json({ ...campaign, platforms: JSON.parse(campaign.platforms || '[]') });
|
||||
});
|
||||
|
||||
@@ -1001,20 +1032,13 @@ app.patch('/api/budget/:id', requireAuth, requireRole('superadmin', 'manager'),
|
||||
const existing = db.prepare('SELECT * FROM budget_entries WHERE id = ?').get(req.params.id);
|
||||
if (!existing) return res.status(404).json({ error: 'Budget entry not found' });
|
||||
|
||||
const fields = ['label', 'amount', 'source', 'campaign_id', 'category', 'date_received', 'notes'];
|
||||
const updates = [];
|
||||
const values = [];
|
||||
const { clauses, values, hasUpdates } = buildUpdate(
|
||||
req.body, ['label', 'amount', 'source', 'campaign_id', 'category', 'date_received', 'notes']
|
||||
);
|
||||
if (!hasUpdates) return res.status(400).json({ error: 'No fields to update' });
|
||||
|
||||
for (const field of fields) {
|
||||
if (req.body[field] !== undefined) {
|
||||
updates.push(`${field} = ?`);
|
||||
values.push(req.body[field]);
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.length === 0) return res.status(400).json({ error: 'No fields to update' });
|
||||
values.push(req.params.id);
|
||||
db.prepare(`UPDATE budget_entries SET ${updates.join(', ')} WHERE id = ?`).run(...values);
|
||||
db.prepare(`UPDATE budget_entries SET ${clauses.join(', ')} WHERE id = ?`).run(...values);
|
||||
|
||||
const entry = db.prepare('SELECT be.*, c.name as campaign_name FROM budget_entries be LEFT JOIN campaigns c ON be.campaign_id = c.id WHERE be.id = ?').get(req.params.id);
|
||||
res.json(entry);
|
||||
@@ -1088,22 +1112,14 @@ app.patch('/api/tracks/:id', requireAuth, requireRole('superadmin', 'manager'),
|
||||
const existing = db.prepare('SELECT * FROM campaign_tracks WHERE id = ?').get(req.params.id);
|
||||
if (!existing) return res.status(404).json({ error: 'Track not found' });
|
||||
|
||||
const fields = ['name', 'type', 'platform', 'budget_allocated', 'budget_spent', 'revenue',
|
||||
'impressions', 'clicks', 'conversions', 'notes', 'status'];
|
||||
const updates = [];
|
||||
const values = [];
|
||||
|
||||
for (const field of fields) {
|
||||
if (req.body[field] !== undefined) {
|
||||
updates.push(`${field} = ?`);
|
||||
values.push(req.body[field]);
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.length === 0) return res.status(400).json({ error: 'No fields to update' });
|
||||
const { clauses, values, hasUpdates } = buildUpdate(req.body, [
|
||||
'name', 'type', 'platform', 'budget_allocated', 'budget_spent', 'revenue',
|
||||
'impressions', 'clicks', 'conversions', 'notes', 'status',
|
||||
]);
|
||||
if (!hasUpdates) return res.status(400).json({ error: 'No fields to update' });
|
||||
|
||||
values.push(req.params.id);
|
||||
db.prepare(`UPDATE campaign_tracks SET ${updates.join(', ')} WHERE id = ?`).run(...values);
|
||||
db.prepare(`UPDATE campaign_tracks SET ${clauses.join(', ')} WHERE id = ?`).run(...values);
|
||||
|
||||
const track = db.prepare('SELECT * FROM campaign_tracks WHERE id = ?').get(req.params.id);
|
||||
res.json(track);
|
||||
@@ -1126,14 +1142,14 @@ app.get('/api/campaigns/:id/posts', requireAuth, (req, res) => {
|
||||
WHERE p.campaign_id = ?
|
||||
ORDER BY p.created_at DESC
|
||||
`).all(req.params.id);
|
||||
res.json(posts.map(p => ({ ...p, platforms: JSON.parse(p.platforms || '[]'), publication_links: JSON.parse(p.publication_links || '[]') })));
|
||||
res.json(posts.map(parsePostJson));
|
||||
});
|
||||
|
||||
// ─── PROJECTS ───────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/projects', requireAuth, (req, res) => {
|
||||
const { brand_id, owner_id, status } = req.query;
|
||||
let sql = 'SELECT p.*, b.name as brand_name, t.name as owner_name FROM projects p LEFT JOIN brands b ON p.brand_id = b.id LEFT JOIN team_members t ON p.owner_id = t.id';
|
||||
let sql = PROJECT_SELECT_SQL;
|
||||
const conditions = [];
|
||||
const values = [];
|
||||
|
||||
@@ -1148,7 +1164,7 @@ app.get('/api/projects', requireAuth, (req, res) => {
|
||||
});
|
||||
|
||||
app.get('/api/projects/:id', requireAuth, (req, res) => {
|
||||
const project = db.prepare('SELECT p.*, b.name as brand_name, t.name as owner_name FROM projects p LEFT JOIN brands b ON p.brand_id = b.id LEFT JOIN team_members t ON p.owner_id = t.id WHERE p.id = ?').get(req.params.id);
|
||||
const project = db.prepare(`${PROJECT_SELECT_SQL} WHERE p.id = ?`).get(req.params.id);
|
||||
if (!project) return res.status(404).json({ error: 'Project not found' });
|
||||
res.json(project);
|
||||
});
|
||||
@@ -1162,7 +1178,7 @@ app.post('/api/projects', requireAuth, requireRole('superadmin', 'manager'), (re
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(name, description || null, brand_id || null, owner_id || null, status || 'active', priority || 'medium', due_date || null, req.session.userId);
|
||||
|
||||
const project = db.prepare('SELECT p.*, b.name as brand_name, t.name as owner_name FROM projects p LEFT JOIN brands b ON p.brand_id = b.id LEFT JOIN team_members t ON p.owner_id = t.id WHERE p.id = ?').get(result.lastInsertRowid);
|
||||
const project = db.prepare(`${PROJECT_SELECT_SQL} WHERE p.id = ?`).get(result.lastInsertRowid);
|
||||
res.status(201).json(project);
|
||||
});
|
||||
|
||||
@@ -1171,23 +1187,15 @@ app.patch('/api/projects/:id', requireAuth, requireRole('superadmin', 'manager')
|
||||
const existing = db.prepare('SELECT * FROM projects WHERE id = ?').get(id);
|
||||
if (!existing) return res.status(404).json({ error: 'Project not found' });
|
||||
|
||||
const fields = ['name', 'description', 'brand_id', 'owner_id', 'status', 'priority', 'due_date'];
|
||||
const updates = [];
|
||||
const values = [];
|
||||
|
||||
for (const field of fields) {
|
||||
if (req.body[field] !== undefined) {
|
||||
updates.push(`${field} = ?`);
|
||||
values.push(req.body[field]);
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.length === 0) return res.status(400).json({ error: 'No fields to update' });
|
||||
const { clauses, values, hasUpdates } = buildUpdate(
|
||||
req.body, ['name', 'description', 'brand_id', 'owner_id', 'status', 'priority', 'due_date']
|
||||
);
|
||||
if (!hasUpdates) return res.status(400).json({ error: 'No fields to update' });
|
||||
|
||||
values.push(id);
|
||||
db.prepare(`UPDATE projects SET ${updates.join(', ')} WHERE id = ?`).run(...values);
|
||||
db.prepare(`UPDATE projects SET ${clauses.join(', ')} WHERE id = ?`).run(...values);
|
||||
|
||||
const project = db.prepare('SELECT p.*, b.name as brand_name, t.name as owner_name FROM projects p LEFT JOIN brands b ON p.brand_id = b.id LEFT JOIN team_members t ON p.owner_id = t.id WHERE p.id = ?').get(id);
|
||||
const project = db.prepare(`${PROJECT_SELECT_SQL} WHERE p.id = ?`).get(id);
|
||||
res.json(project);
|
||||
});
|
||||
|
||||
@@ -1201,16 +1209,7 @@ app.delete('/api/projects/:id', requireAuth, requireRole('superadmin', 'manager'
|
||||
|
||||
app.get('/api/tasks', requireAuth, (req, res) => {
|
||||
const { project_id, assigned_to, status, is_personal } = req.query;
|
||||
let sql = `SELECT t.*,
|
||||
p.name as project_name,
|
||||
a.name as assigned_name,
|
||||
c.name as creator_name,
|
||||
u.name as creator_user_name
|
||||
FROM tasks t
|
||||
LEFT JOIN projects p ON t.project_id = p.id
|
||||
LEFT JOIN team_members a ON t.assigned_to = a.id
|
||||
LEFT JOIN team_members c ON t.created_by = c.id
|
||||
LEFT JOIN users u ON t.created_by_user_id = u.id`;
|
||||
let sql = TASK_SELECT_SQL;
|
||||
const conditions = [];
|
||||
const values = [];
|
||||
|
||||
@@ -1219,8 +1218,8 @@ app.get('/api/tasks', requireAuth, (req, res) => {
|
||||
if (status) { conditions.push('t.status = ?'); values.push(status); }
|
||||
if (is_personal !== undefined) { conditions.push('t.is_personal = ?'); values.push(is_personal === 'true' || is_personal === '1' ? 1 : 0); }
|
||||
|
||||
// Visibility filtering: non-superadmins only see tasks they created or are assigned to
|
||||
if (req.session.userRole !== 'superadmin') {
|
||||
// Visibility filtering: contributors only see tasks they created or are assigned to
|
||||
if (req.session.userRole === 'contributor') {
|
||||
const currentUser = db.prepare('SELECT team_member_id FROM users WHERE id = ?').get(req.session.userId);
|
||||
const teamMemberId = currentUser?.team_member_id;
|
||||
if (teamMemberId) {
|
||||
@@ -1259,8 +1258,7 @@ app.post('/api/tasks', requireAuth, (req, res) => {
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(title, description || null, project_id || null, assigned_to || null, created_by || null, status || 'todo', priority || 'medium', due_date || null, is_personal ? 1 : 0, req.session.userId);
|
||||
|
||||
const task = db.prepare(`SELECT t.*, p.name as project_name, a.name as assigned_name, c.name as creator_name, u.name as creator_user_name
|
||||
FROM tasks t LEFT JOIN projects p ON t.project_id = p.id LEFT JOIN team_members a ON t.assigned_to = a.id LEFT JOIN team_members c ON t.created_by = c.id LEFT JOIN users u ON t.created_by_user_id = u.id WHERE t.id = ?`).get(result.lastInsertRowid);
|
||||
const task = db.prepare(`${TASK_SELECT_SQL} WHERE t.id = ?`).get(result.lastInsertRowid);
|
||||
res.status(201).json(task);
|
||||
});
|
||||
|
||||
@@ -1269,31 +1267,30 @@ app.patch('/api/tasks/:id', requireAuth, requireOwnerOrRole('tasks', 'superadmin
|
||||
const existing = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
|
||||
if (!existing) return res.status(404).json({ error: 'Task not found' });
|
||||
|
||||
const fields = ['title', 'description', 'project_id', 'assigned_to', 'created_by', 'status', 'priority', 'due_date', 'is_personal'];
|
||||
const updates = [];
|
||||
const values = [];
|
||||
|
||||
for (const field of fields) {
|
||||
if (req.body[field] !== undefined) {
|
||||
updates.push(`${field} = ?`);
|
||||
values.push(field === 'is_personal' ? (req.body[field] ? 1 : 0) : req.body[field]);
|
||||
}
|
||||
// Pre-process is_personal to a SQLite-compatible integer
|
||||
const body = { ...req.body };
|
||||
if (body.is_personal !== undefined) {
|
||||
body.is_personal = body.is_personal ? 1 : 0;
|
||||
}
|
||||
|
||||
// Auto-set completed_at when status changes to done
|
||||
if (req.body.status === 'done' && existing.status !== 'done') {
|
||||
updates.push('completed_at = CURRENT_TIMESTAMP');
|
||||
} else if (req.body.status && req.body.status !== 'done' && existing.status === 'done') {
|
||||
updates.push('completed_at = NULL');
|
||||
const extraClauses = [];
|
||||
if (body.status === 'done' && existing.status !== 'done') {
|
||||
extraClauses.push('completed_at = CURRENT_TIMESTAMP');
|
||||
} else if (body.status && body.status !== 'done' && existing.status === 'done') {
|
||||
extraClauses.push('completed_at = NULL');
|
||||
}
|
||||
|
||||
if (updates.length === 0) return res.status(400).json({ error: 'No fields to update' });
|
||||
const { clauses, values, hasUpdates } = buildUpdate(
|
||||
body,
|
||||
['title', 'description', 'project_id', 'assigned_to', 'created_by', 'status', 'priority', 'due_date', 'is_personal'],
|
||||
{ extraClauses }
|
||||
);
|
||||
if (!hasUpdates) return res.status(400).json({ error: 'No fields to update' });
|
||||
|
||||
values.push(id);
|
||||
db.prepare(`UPDATE tasks SET ${updates.join(', ')} WHERE id = ?`).run(...values);
|
||||
db.prepare(`UPDATE tasks SET ${clauses.join(', ')} WHERE id = ?`).run(...values);
|
||||
|
||||
const task = db.prepare(`SELECT t.*, p.name as project_name, a.name as assigned_name, c.name as creator_name, u.name as creator_user_name
|
||||
FROM tasks t LEFT JOIN projects p ON t.project_id = p.id LEFT JOIN team_members a ON t.assigned_to = a.id LEFT JOIN team_members c ON t.created_by = c.id LEFT JOIN users u ON t.created_by_user_id = u.id WHERE t.id = ?`).get(id);
|
||||
const task = db.prepare(`${TASK_SELECT_SQL} WHERE t.id = ?`).get(id);
|
||||
res.json(task);
|
||||
});
|
||||
|
||||
@@ -1379,6 +1376,59 @@ app.get('/api/dashboard', requireAuth, (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── COMMENTS / DISCUSSIONS ─────────────────────────────────
|
||||
|
||||
const COMMENT_ENTITY_TYPES = new Set(['post', 'task', 'project', 'campaign', 'asset']);
|
||||
|
||||
app.get('/api/comments/:entityType/:entityId', requireAuth, (req, res) => {
|
||||
const { entityType, entityId } = req.params;
|
||||
if (!COMMENT_ENTITY_TYPES.has(entityType)) {
|
||||
return res.status(400).json({ error: 'Invalid entity type' });
|
||||
}
|
||||
const comments = db.prepare(`
|
||||
SELECT c.*, u.name as user_name, u.avatar as user_avatar
|
||||
FROM comments c
|
||||
LEFT JOIN users u ON c.user_id = u.id
|
||||
WHERE c.entity_type = ? AND c.entity_id = ?
|
||||
ORDER BY c.created_at ASC
|
||||
`).all(entityType, entityId);
|
||||
res.json(comments);
|
||||
});
|
||||
|
||||
app.post('/api/comments/:entityType/:entityId', requireAuth, (req, res) => {
|
||||
const { entityType, entityId } = req.params;
|
||||
const { content } = req.body;
|
||||
if (!COMMENT_ENTITY_TYPES.has(entityType)) {
|
||||
return res.status(400).json({ error: 'Invalid entity type' });
|
||||
}
|
||||
if (!content || !content.trim()) {
|
||||
return res.status(400).json({ error: 'Content is required' });
|
||||
}
|
||||
const result = db.prepare(
|
||||
'INSERT INTO comments (entity_type, entity_id, user_id, content) VALUES (?, ?, ?, ?)'
|
||||
).run(entityType, entityId, req.session.userId, content.trim());
|
||||
|
||||
const comment = db.prepare(`
|
||||
SELECT c.*, u.name as user_name, u.avatar as user_avatar
|
||||
FROM comments c
|
||||
LEFT JOIN users u ON c.user_id = u.id
|
||||
WHERE c.id = ?
|
||||
`).get(result.lastInsertRowid);
|
||||
res.status(201).json(comment);
|
||||
});
|
||||
|
||||
app.delete('/api/comments/:id', requireAuth, (req, res) => {
|
||||
const comment = db.prepare('SELECT * FROM comments WHERE id = ?').get(req.params.id);
|
||||
if (!comment) return res.status(404).json({ error: 'Comment not found' });
|
||||
|
||||
// Only the comment author, managers, or superadmins can delete
|
||||
if (comment.user_id !== req.session.userId && req.session.userRole !== 'superadmin' && req.session.userRole !== 'manager') {
|
||||
return res.status(403).json({ error: 'You can only delete your own comments' });
|
||||
}
|
||||
db.prepare('DELETE FROM comments WHERE id = ?').run(req.params.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ─── ERROR HANDLING ─────────────────────────────────────────────
|
||||
|
||||
// Global Express error handler
|
||||
|
||||
|
Before Width: | Height: | Size: 826 KiB After Width: | Height: | Size: 826 KiB |
BIN
server/uploads/1770579473379-520949231.mp4
Normal file
BIN
server/uploads/1770579473379-520949231.mp4
Normal file
Binary file not shown.
Reference in New Issue
Block a user