diff --git a/client/src/App.jsx b/client/src/App.jsx
index 10b109b..2ead1a0 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -59,6 +59,8 @@ function AppContent() {
// Check if profile is incomplete
if (!user.profileComplete && user.role !== 'superadmin') {
setShowProfilePrompt(true)
+ } else {
+ setShowProfilePrompt(false)
}
} else if (!authLoading) {
setLoading(false)
diff --git a/client/src/components/CommentsSection.jsx b/client/src/components/CommentsSection.jsx
index f9ba76a..d4a8a81 100644
--- a/client/src/components/CommentsSection.jsx
+++ b/client/src/components/CommentsSection.jsx
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
-import { Send, Trash2, MessageCircle } from 'lucide-react'
+import { Send, Trash2, MessageCircle, Pencil, Check, X } from 'lucide-react'
import { api, getInitials } from '../utils/api'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext'
@@ -23,6 +23,8 @@ export default function CommentsSection({ entityType, entityId }) {
const [comments, setComments] = useState([])
const [newComment, setNewComment] = useState('')
const [sending, setSending] = useState(false)
+ const [editingId, setEditingId] = useState(null)
+ const [editContent, setEditContent] = useState('')
useEffect(() => {
if (entityType && entityId) loadComments()
@@ -60,6 +62,33 @@ export default function CommentsSection({ entityType, entityId }) {
}
}
+ const startEdit = (comment) => {
+ setEditingId(comment.id)
+ setEditContent(comment.content)
+ }
+
+ const cancelEdit = () => {
+ setEditingId(null)
+ setEditContent('')
+ }
+
+ const saveEdit = async (id) => {
+ if (!editContent.trim()) return
+ try {
+ await api.patch(`/comments/${id}`, { content: editContent.trim() })
+ setEditingId(null)
+ setEditContent('')
+ loadComments()
+ } catch (err) {
+ console.error('Failed to edit comment:', err)
+ }
+ }
+
+ const canEdit = (comment) => {
+ if (!user) return false
+ return comment.user_id === user.id
+ }
+
const canDelete = (comment) => {
if (!user) return false
if (comment.user_id === user.id) return true
@@ -96,16 +125,48 @@ export default function CommentsSection({ entityType, entityId }) {
{c.user_name}
{relativeTime(c.created_at, t)}
- {canDelete(c) && (
-
- )}
+
+ {canEdit(c) && editingId !== c.id && (
+
+ )}
+ {canDelete(c) && (
+
+ )}
+
- {c.content}
+ {editingId === c.id ? (
+
+ setEditContent(e.target.value)}
+ onKeyDown={e => {
+ if (e.key === 'Enter') saveEdit(c.id)
+ if (e.key === 'Escape') cancelEdit()
+ }}
+ autoFocus
+ className="flex-1 px-2 py-1 text-xs border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-brand-primary/30"
+ />
+
+
+
+ ) : (
+ {c.content}
+ )}
))}
diff --git a/client/src/contexts/AuthContext.jsx b/client/src/contexts/AuthContext.jsx
index 0507885..6b562b2 100644
--- a/client/src/contexts/AuthContext.jsx
+++ b/client/src/contexts/AuthContext.jsx
@@ -72,6 +72,8 @@ export function AuthProvider({ children }) {
if (!permissions) return false
if (type === 'post') return permissions.canEditAnyPost || isOwner(resource) || isAssignedTo(resource)
if (type === 'task') return permissions.canEditAnyTask || isOwner(resource) || isAssignedTo(resource)
+ if (type === 'campaign') return permissions.canEditCampaigns || isOwner(resource)
+ if (type === 'project') return permissions.canEditProjects || isOwner(resource)
return false
}
@@ -79,6 +81,8 @@ export function AuthProvider({ children }) {
if (!permissions) return false
if (type === 'post') return permissions.canDeleteAnyPost || isOwner(resource) || isAssignedTo(resource)
if (type === 'task') return permissions.canDeleteAnyTask || isOwner(resource) || isAssignedTo(resource)
+ if (type === 'campaign') return permissions.canDeleteCampaigns || isOwner(resource)
+ if (type === 'project') return permissions.canDeleteProjects || isOwner(resource)
return false
}
diff --git a/client/src/pages/CampaignDetail.jsx b/client/src/pages/CampaignDetail.jsx
index 8ef3e88..76d93d0 100644
--- a/client/src/pages/CampaignDetail.jsx
+++ b/client/src/pages/CampaignDetail.jsx
@@ -1,10 +1,10 @@
import { useState, useEffect, useContext } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
-import { ArrowLeft, Plus, Edit2, Trash2, DollarSign, Eye, MousePointer, Target, TrendingUp, FileText, Megaphone, Search, Globe, Pencil } from 'lucide-react'
+import { ArrowLeft, Plus, Edit2, Trash2, DollarSign, Eye, MousePointer, Target, TrendingUp, FileText, Megaphone, Search, Globe, Pencil, Users, X, UserPlus } from 'lucide-react'
import { format } from 'date-fns'
import { AppContext } from '../App'
import { useAuth } from '../contexts/AuthContext'
-import { api, PLATFORMS } from '../utils/api'
+import { api, PLATFORMS, getInitials } from '../utils/api'
import PlatformIcon, { PlatformIcons } from '../components/PlatformIcon'
import StatusBadge from '../components/StatusBadge'
import BrandBadge from '../components/BrandBadge'
@@ -44,12 +44,19 @@ export default function CampaignDetail() {
const { id } = useParams()
const navigate = useNavigate()
const { brands } = useContext(AppContext)
- const { permissions } = useAuth()
- const canManage = permissions?.canEditCampaigns
+ const { permissions, user } = useAuth()
+ const isSuperadmin = user?.role === 'superadmin'
const [campaign, setCampaign] = useState(null)
const [tracks, setTracks] = useState([])
const [posts, setPosts] = useState([])
+ const [assignments, setAssignments] = useState([])
+ const [allUsers, setAllUsers] = useState([])
const [loading, setLoading] = useState(true)
+ const [showAssignModal, setShowAssignModal] = useState(false)
+ const [selectedUserIds, setSelectedUserIds] = useState([])
+ const canSetBudget = permissions?.canSetBudget
+ const [editingBudget, setEditingBudget] = useState(false)
+ const [budgetValue, setBudgetValue] = useState('')
const [showTrackModal, setShowTrackModal] = useState(false)
const [editingTrack, setEditingTrack] = useState(null)
const [trackForm, setTrackForm] = useState(EMPTY_TRACK)
@@ -58,21 +65,26 @@ export default function CampaignDetail() {
const [metricsForm, setMetricsForm] = useState(EMPTY_METRICS)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [trackToDelete, setTrackToDelete] = useState(null)
+ const [selectedPost, setSelectedPost] = useState(null)
+
+ const isCreator = campaign?.createdByUserId === user?.id || campaign?.created_by_user_id === user?.id
+ const canManage = isSuperadmin || (permissions?.canEditCampaigns && isCreator)
+ const canAssign = isSuperadmin || (permissions?.canAssignCampaigns && isCreator)
useEffect(() => { loadAll() }, [id])
const loadAll = async () => {
try {
- const [campRes, tracksRes, postsRes] = await Promise.all([
- api.get(`/campaigns`),
+ const [campRes, tracksRes, postsRes, assignRes] = await Promise.all([
+ api.get(`/campaigns/${id}`),
api.get(`/campaigns/${id}/tracks`),
api.get(`/campaigns/${id}/posts`),
+ api.get(`/campaigns/${id}/assignments`),
])
- const allCampaigns = campRes.data || campRes || []
- const found = allCampaigns.find(c => String(c.id) === String(id) || String(c._id) === String(id))
- setCampaign(found || null)
+ setCampaign(campRes.data || campRes || null)
setTracks(tracksRes.data || tracksRes || [])
setPosts(postsRes.data || postsRes || [])
+ setAssignments(Array.isArray(assignRes) ? assignRes : (assignRes.data || []))
} catch (err) {
console.error('Failed to load campaign:', err)
} finally {
@@ -80,6 +92,49 @@ export default function CampaignDetail() {
}
}
+ const loadUsersForAssign = async () => {
+ try {
+ const users = await api.get('/users/team?all=true')
+ setAllUsers(Array.isArray(users) ? users : (users.data || []))
+ } catch (err) {
+ console.error('Failed to load users:', err)
+ }
+ }
+
+ const openAssignModal = () => {
+ loadUsersForAssign()
+ setSelectedUserIds(assignments.map(a => a.user_id))
+ setShowAssignModal(true)
+ }
+
+ const saveAssignments = async () => {
+ try {
+ const currentIds = assignments.map(a => a.user_id)
+ const toAdd = selectedUserIds.filter(id => !currentIds.includes(id))
+ const toRemove = currentIds.filter(id => !selectedUserIds.includes(id))
+
+ if (toAdd.length > 0) {
+ await api.post(`/campaigns/${id}/assignments`, { user_ids: toAdd })
+ }
+ for (const uid of toRemove) {
+ await api.delete(`/campaigns/${id}/assignments/${uid}`)
+ }
+ setShowAssignModal(false)
+ loadAll()
+ } catch (err) {
+ console.error('Failed to save assignments:', err)
+ }
+ }
+
+ const removeAssignment = async (userId) => {
+ try {
+ await api.delete(`/campaigns/${id}/assignments/${userId}`)
+ loadAll()
+ } catch (err) {
+ console.error('Failed to remove assignment:', err)
+ }
+ }
+
const saveTrack = async () => {
try {
const data = {
@@ -198,11 +253,65 @@ export default function CampaignDetail() {
{campaign.start_date && campaign.end_date && (
{format(new Date(campaign.start_date), 'MMM d')} – {format(new Date(campaign.end_date), 'MMM d, yyyy')}
)}
- {campaign.budget > 0 && Budget: {campaign.budget.toLocaleString()} SAR}
+
+ Budget: {campaign.budget > 0 ? `${campaign.budget.toLocaleString()} SAR` : 'Not set'}
+ {canSetBudget && (
+
+ )}
+
+ {/* Assigned Team */}
+
+
+
+ Assigned Team
+
+ {canAssign && (
+
+ )}
+
+ {assignments.length === 0 ? (
+
No team members assigned yet.
+ ) : (
+
+ {assignments.map(a => (
+
+
+ {a.user_avatar ? (
+

+ ) : (
+ getInitials(a.user_name)
+ )}
+
+
{a.user_name}
+ {canAssign && (
+
+ )}
+
+ ))}
+
+ )}
+
+
{/* Aggregate Metrics */}
{tracks.length > 0 && (
@@ -343,7 +452,14 @@ export default function CampaignDetail() {
{posts.map(post => (
-
+
setSelectedPost(post)}
+ className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary cursor-pointer transition-colors"
+ >
+ {post.thumbnail_url && (
+

+ )}
{post.title}
@@ -351,6 +467,7 @@ export default function CampaignDetail() {
{post.track_name &&
{post.track_name}}
+ {post.brand_name &&
}
{post.assigned_name &&
→ {post.assigned_name}}
{post.platforms && post.platforms.length > 0 && (
@@ -550,6 +667,211 @@ export default function CampaignDetail() {
>
Are you sure you want to delete this campaign track? This action cannot be undone.
+
+ {/* Assign Members Modal */}
+
setShowAssignModal(false)}
+ title="Assign Team Members"
+ >
+
+ {allUsers.map(u => {
+ const checked = selectedUserIds.includes(u.id || u._id)
+ return (
+
+ )
+ })}
+
+
+
+
+
+
+
+ {/* Budget Modal */}
+
setEditingBudget(false)} title="Set Campaign Budget" size="sm">
+
+
+
+ setBudgetValue(e.target.value)}
+ autoFocus
+ min="0"
+ className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
+ placeholder="Enter budget amount"
+ />
+
+
+
+
+
+
+
+
+ {/* Post Detail Modal */}
+
setSelectedPost(null)}
+ title={selectedPost?.title || 'Post Details'}
+ size="lg"
+ >
+ {selectedPost && (
+
+ {/* Thumbnail / Media */}
+ {selectedPost.thumbnail_url && (
+
+

+
+ )}
+
+ {/* Status & Platforms */}
+
+
+ {selectedPost.brand_name &&
}
+ {selectedPost.platforms && selectedPost.platforms.length > 0 && (
+
+ )}
+
+
+ {/* Description */}
+ {selectedPost.description && (
+
+
Description
+
{selectedPost.description}
+
+ )}
+
+ {/* Meta info grid */}
+
+ {selectedPost.track_name && (
+
+
Track
+
{selectedPost.track_name}
+
+ )}
+ {selectedPost.assigned_name && (
+
+
Assigned to
+
{selectedPost.assigned_name}
+
+ )}
+ {selectedPost.creator_user_name && (
+
+
Created by
+
{selectedPost.creator_user_name}
+
+ )}
+ {selectedPost.scheduled_date && (
+
+
Scheduled
+
{format(new Date(selectedPost.scheduled_date), 'MMM d, yyyy')}
+
+ )}
+ {selectedPost.published_date && (
+
+
Published
+
{format(new Date(selectedPost.published_date), 'MMM d, yyyy')}
+
+ )}
+ {selectedPost.created_at && (
+
+
Created
+
{format(new Date(selectedPost.created_at), 'MMM d, yyyy')}
+
+ )}
+
+
+ {/* Publication Links */}
+ {selectedPost.publication_links && selectedPost.publication_links.length > 0 && (
+
+ )}
+
+ {/* Notes */}
+ {selectedPost.notes && (
+
+
Notes
+
{selectedPost.notes}
+
+ )}
+
+ )}
+
)
}
diff --git a/client/src/pages/Campaigns.jsx b/client/src/pages/Campaigns.jsx
index 8163fae..e504823 100644
--- a/client/src/pages/Campaigns.jsx
+++ b/client/src/pages/Campaigns.jsx
@@ -155,6 +155,41 @@ export default function Campaigns() {
return (
+ {/* Toolbar */}
+
+
+
+
+
+ {permissions?.canCreateCampaigns && (
+
+ )}
+
+
{/* Summary Cards */}
{(totalBudget > 0 || totalSpent > 0) && (
@@ -206,41 +241,6 @@ export default function Campaigns() {
)}
- {/* Toolbar */}
-
-
-
-
-
- {permissions?.canCreateCampaigns && (
-
- )}
-
-
{/* Calendar */}
@@ -449,12 +449,16 @@ export default function Campaigns() {
-
+
setFormData(f => ({ ...f, budget: e.target.value }))}
- className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
+ disabled={!permissions?.canSetBudget}
+ className={`w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary ${!permissions?.canSetBudget ? 'bg-surface-tertiary text-text-tertiary cursor-not-allowed' : ''}`}
placeholder="e.g., 50000"
/>
diff --git a/client/src/pages/Finance.jsx b/client/src/pages/Finance.jsx
index 32991e4..aaf0354 100644
--- a/client/src/pages/Finance.jsx
+++ b/client/src/pages/Finance.jsx
@@ -3,6 +3,7 @@ import { Plus, DollarSign, TrendingUp, TrendingDown, Wallet, PiggyBank, Eye, Mou
import { format } from 'date-fns'
import { AppContext } from '../App'
import { api } from '../utils/api'
+import { useAuth } from '../contexts/AuthContext'
import Modal from '../components/Modal'
import StatusBadge from '../components/StatusBadge'
@@ -52,6 +53,8 @@ function ProgressRing({ pct, size = 80, stroke = 8, color = '#10b981' }) {
export default function Finance() {
const { brands } = useContext(AppContext)
+ const { permissions } = useAuth()
+ const canManageFinance = permissions?.canManageFinance
const [entries, setEntries] = useState([])
const [summary, setSummary] = useState(null)
const [campaigns, setCampaigns] = useState([])
@@ -268,12 +271,14 @@ export default function Finance() {
Budget Received
-
+ {canManageFinance && (
+
+ )}
{entries.length === 0 ? (
@@ -304,14 +309,16 @@ export default function Finance() {
{Number(entry.amount).toLocaleString()} SAR
-
-
-
-
+ {canManageFinance && (
+
+
+
+
+ )}
))}
diff --git a/client/src/pages/ProjectDetail.jsx b/client/src/pages/ProjectDetail.jsx
index 039fb69..15715de 100644
--- a/client/src/pages/ProjectDetail.jsx
+++ b/client/src/pages/ProjectDetail.jsx
@@ -7,6 +7,7 @@ import {
import { format, differenceInDays, startOfDay, addDays, isAfter, isBefore } from 'date-fns'
import { AppContext } from '../App'
import { api, PRIORITY_CONFIG } from '../utils/api'
+import { useAuth } from '../contexts/AuthContext'
import StatusBadge from '../components/StatusBadge'
import BrandBadge from '../components/BrandBadge'
import Modal from '../components/Modal'
@@ -22,6 +23,9 @@ export default function ProjectDetail() {
const { id } = useParams()
const navigate = useNavigate()
const { teamMembers, brands } = useContext(AppContext)
+ const { permissions, canEditResource, canDeleteResource } = useAuth()
+ const canEditProject = canEditResource('project', project)
+ const canManageProject = permissions?.canEditProjects
const [project, setProject] = useState(null)
const [tasks, setTasks] = useState([])
const [loading, setLoading] = useState(true)
@@ -247,13 +251,15 @@ export default function ProjectDetail() {
)}
-
+ {canEditProject && (
+
+ )}
{project.description && (
@@ -344,6 +350,8 @@ export default function ProjectDetail() {
openEditTask(task)}
onDelete={() => handleDeleteTask(task._id)}
onStatusChange={handleTaskStatusChange}
@@ -401,12 +409,16 @@ export default function ProjectDetail() {
-
-
+ {canEditResource('task', task) && (
+
+ )}
+ {canDeleteResource('task', task) && (
+
+ )}
|
@@ -486,7 +498,7 @@ export default function ProjectDetail() {
- {editingTask && (
+ {editingTask && canDeleteResource('task', editingTask) && (
{/* Actions on hover */}
-
- {task.status !== 'done' && (
-
- )}
-
-
-
+ {(canEdit || canDelete) && (
+
+ {canEdit && task.status !== 'done' && (
+
+ )}
+ {canEdit && (
+
+ )}
+ {canDelete && (
+
+ )}
+
+ )}
)
}
diff --git a/client/src/pages/Projects.jsx b/client/src/pages/Projects.jsx
index a119053..b431f0c 100644
--- a/client/src/pages/Projects.jsx
+++ b/client/src/pages/Projects.jsx
@@ -2,6 +2,7 @@ import { useState, useEffect, useContext } from 'react'
import { Plus, Search, FolderKanban } from 'lucide-react'
import { AppContext } from '../App'
import { api } from '../utils/api'
+import { useAuth } from '../contexts/AuthContext'
import ProjectCard from '../components/ProjectCard'
import Modal from '../components/Modal'
@@ -12,6 +13,7 @@ const EMPTY_PROJECT = {
export default function Projects() {
const { teamMembers, brands } = useContext(AppContext)
+ const { permissions } = useAuth()
const [projects, setProjects] = useState([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
@@ -81,13 +83,15 @@ export default function Projects() {
/>
-
+ {permissions?.canCreateProjects && (
+
+ )}
{/* Project grid */}
diff --git a/server/db.js b/server/db.js
index b9fd43e..d3e5203 100644
--- a/server/db.js
+++ b/server/db.js
@@ -178,6 +178,18 @@ function initialize() {
);
`);
+ // 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) {
diff --git a/server/server.js b/server/server.js
index 67ae7dd..dc54a58 100644
--- a/server/server.js
+++ b/server/server.js
@@ -143,7 +143,7 @@ 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']);
+const VALID_OWNER_TABLES = new Set(['posts', 'tasks', 'campaigns', 'projects']);
function requireOwnerOrRole(table, ...allowedRoles) {
if (!VALID_OWNER_TABLES.has(table)) {
@@ -158,7 +158,7 @@ function requireOwnerOrRole(table, ...allowedRoles) {
return next();
}
// Contributors must own the resource or be assigned to it
- const row = db.prepare(`SELECT created_by_user_id, assigned_to FROM ${table} WHERE id = ?`).get(req.params.id);
+ const row = db.prepare(`SELECT * FROM ${table} WHERE id = ?`).get(req.params.id);
if (!row) {
return res.status(404).json({ error: 'Not found' });
}
@@ -166,10 +166,12 @@ function requireOwnerOrRole(table, ...allowedRoles) {
if (row.created_by_user_id === req.session.userId) {
return next();
}
- // Check if resource is assigned to user's team member
- const currentUser = db.prepare('SELECT team_member_id FROM users WHERE id = ?').get(req.session.userId);
- if (currentUser?.team_member_id && row.assigned_to === currentUser.team_member_id) {
- return next();
+ // Check if resource is assigned to user's team member (for tables with assigned_to)
+ if (row.assigned_to !== undefined) {
+ const currentUser = db.prepare('SELECT team_member_id FROM users WHERE id = ?').get(req.session.userId);
+ if (currentUser?.team_member_id && row.assigned_to === currentUser.team_member_id) {
+ return next();
+ }
}
return res.status(403).json({ error: 'You can only modify your own items' });
};
@@ -201,6 +203,7 @@ app.post('/api/auth/login', async (req, res) => {
req.session.userName = user.name;
req.session.teamMemberId = user.team_member_id;
+ const profileComplete = !!user.team_role;
res.json({
user: {
id: user.id,
@@ -209,6 +212,9 @@ app.post('/api/auth/login', async (req, res) => {
role: user.role,
avatar: user.avatar,
team_member_id: user.team_member_id,
+ team_role: user.team_role,
+ tutorial_completed: user.tutorial_completed,
+ profileComplete,
},
});
} catch (err) {
@@ -233,7 +239,7 @@ app.get('/api/auth/me', requireAuth, (req, res) => {
return res.status(404).json({ error: 'User not found' });
}
// Check if profile is complete
- const profileComplete = !!(user.team_role && user.brands);
+ const profileComplete = !!user.team_role;
res.json({ ...user, profileComplete, brands: JSON.parse(user.brands || '[]') });
});
@@ -386,6 +392,8 @@ app.get('/api/auth/permissions', requireAuth, (req, res) => {
canManageFinance: canManage,
canManageTeam: canManage,
canManageUsers: role === 'superadmin',
+ canAssignCampaigns: canManage,
+ canSetBudget: role === 'superadmin',
// Posts & tasks: everyone can create, but only own (for contributors)
canCreatePosts: true,
canCreateTasks: true,
@@ -418,12 +426,15 @@ app.get('/api/users/team', requireAuth, (req, res) => {
ORDER BY name
`).all();
- // Filter based on brand overlap for non-superadmins
+ // Skip brand filter when loading users for campaign assignment (managers need to see all users)
+ const skipBrandFilter = req.query.all === 'true' && (req.session.userRole === 'superadmin' || req.session.userRole === 'manager');
+
+ // Filter based on brand overlap for non-superadmins (unless skipped)
let filteredUsers = users;
- if (req.session.userRole !== 'superadmin') {
+ if (req.session.userRole !== 'superadmin' && !skipBrandFilter) {
const currentUser = db.prepare('SELECT brands FROM users WHERE id = ?').get(req.session.userId);
const myBrands = JSON.parse(currentUser?.brands || '[]');
-
+
filteredUsers = users.filter(u => {
const theirBrands = JSON.parse(u.brands || '[]');
// Always include self, or if there's brand overlap
@@ -929,10 +940,27 @@ 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 = CAMPAIGN_SELECT_SQL;
+ const isSuperadmin = req.session.userRole === 'superadmin';
+
+ let sql;
+ if (isSuperadmin) {
+ sql = CAMPAIGN_SELECT_SQL;
+ } else {
+ // Non-superadmins only see campaigns assigned to them or created by them
+ sql = `SELECT DISTINCT c.*, b.name as brand_name
+ FROM campaigns c
+ LEFT JOIN brands b ON c.brand_id = b.id
+ LEFT JOIN campaign_assignments ca ON ca.campaign_id = c.id`;
+ }
+
const conditions = [];
const values = [];
+ if (!isSuperadmin) {
+ conditions.push('(c.created_by_user_id = ? OR ca.user_id = ?)');
+ values.push(req.session.userId, req.session.userId);
+ }
+
if (brand_id) { conditions.push('c.brand_id = ?'); values.push(brand_id); }
if (status) { conditions.push('c.status = ?'); values.push(status); }
if (start_date) { conditions.push('c.end_date >= ?'); values.push(start_date); }
@@ -945,30 +973,55 @@ app.get('/api/campaigns', requireAuth, (req, res) => {
res.json(campaigns.map(c => ({ ...c, platforms: JSON.parse(c.platforms || '[]') })));
});
+app.get('/api/campaigns/:id', requireAuth, (req, res) => {
+ const campaign = db.prepare(`${CAMPAIGN_SELECT_SQL} WHERE c.id = ?`).get(req.params.id);
+ if (!campaign) return res.status(404).json({ error: 'Campaign not found' });
+
+ if (!userHasCampaignAccess(req.session.userId, req.session.userRole, req.params.id)) {
+ return res.status(403).json({ error: 'You do not have access to this campaign' });
+ }
+
+ res.json({ ...campaign, platforms: JSON.parse(campaign.platforms || '[]') });
+});
+
app.post('/api/campaigns', requireAuth, requireRole('superadmin', 'manager'), (req, res) => {
const { name, description, brand_id, start_date, end_date, status, color, budget, goals, platforms } = req.body;
if (!name) return res.status(400).json({ error: 'Name is required' });
if (!start_date || !end_date) return res.status(400).json({ error: 'Start and end dates are required' });
+ // Managers cannot set budget — only superadmin can
+ const effectiveBudget = req.session.userRole === 'superadmin' ? (budget || null) : null;
+
const result = db.prepare(`
INSERT INTO campaigns (name, description, brand_id, start_date, end_date, status, color, budget, goals, platforms, created_by_user_id)
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);
+ `).run(name, description || null, brand_id || null, start_date, end_date, status || 'planning', color || null, effectiveBudget, goals || null, JSON.stringify(platforms || []), req.session.userId);
- const campaign = db.prepare(`${CAMPAIGN_SELECT_SQL} WHERE c.id = ?`).get(result.lastInsertRowid);
+ const campaignId = result.lastInsertRowid;
+
+ // Auto-assign creator to campaign
+ db.prepare('INSERT OR IGNORE INTO campaign_assignments (campaign_id, user_id, assigned_by) VALUES (?, ?, ?)').run(campaignId, req.session.userId, req.session.userId);
+
+ const campaign = db.prepare(`${CAMPAIGN_SELECT_SQL} WHERE c.id = ?`).get(campaignId);
res.status(201).json({ ...campaign, platforms: JSON.parse(campaign.platforms || '[]') });
});
-app.patch('/api/campaigns/:id', requireAuth, requireRole('superadmin', 'manager'), (req, res) => {
+app.patch('/api/campaigns/:id', requireAuth, requireOwnerOrRole('campaigns', 'superadmin', 'manager'), (req, res) => {
const { id } = req.params;
const existing = db.prepare('SELECT * FROM campaigns WHERE id = ?').get(id);
if (!existing) return res.status(404).json({ error: 'Campaign not found' });
+ // Strip budget field if user is not superadmin
+ const body = { ...req.body };
+ if (req.session.userRole !== 'superadmin') {
+ delete body.budget;
+ }
+
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'] });
+ const { clauses, values, hasUpdates } = buildUpdate(body, campaignFields, { jsonFields: ['platforms'] });
if (!hasUpdates) return res.status(400).json({ error: 'No fields to update' });
@@ -1003,13 +1056,93 @@ app.delete('/api/campaigns/:id', requireAuth, requireRole('superadmin', 'manager
}
});
+// ─── CAMPAIGN ASSIGNMENTS ───────────────────────────────────────
+
+// Helper: check if user has access to a campaign
+function userHasCampaignAccess(userId, userRole, campaignId) {
+ if (userRole === 'superadmin') return true;
+ const row = db.prepare(`
+ SELECT 1 FROM campaigns c
+ LEFT JOIN campaign_assignments ca ON ca.campaign_id = c.id AND ca.user_id = ?
+ WHERE c.id = ? AND (c.created_by_user_id = ? OR ca.user_id = ?)
+ `).get(userId, campaignId, userId, userId);
+ return !!row;
+}
+
+app.get('/api/campaigns/:id/assignments', requireAuth, (req, res) => {
+ const assignments = db.prepare(`
+ SELECT ca.*, u.name as user_name, u.email as user_email, u.avatar as user_avatar, u.role as user_role,
+ ab.name as assigned_by_name
+ FROM campaign_assignments ca
+ JOIN users u ON ca.user_id = u.id
+ LEFT JOIN users ab ON ca.assigned_by = ab.id
+ WHERE ca.campaign_id = ?
+ ORDER BY ca.assigned_at ASC
+ `).all(req.params.id);
+ res.json(assignments);
+});
+
+app.post('/api/campaigns/:id/assignments', requireAuth, requireRole('superadmin', 'manager'), (req, res) => {
+ const { user_ids } = req.body;
+ if (!Array.isArray(user_ids) || user_ids.length === 0) {
+ return res.status(400).json({ error: 'user_ids array is required' });
+ }
+
+ const campaign = db.prepare('SELECT id, created_by_user_id FROM campaigns WHERE id = ?').get(req.params.id);
+ if (!campaign) return res.status(404).json({ error: 'Campaign not found' });
+
+ // Only superadmin or campaign creator can assign
+ if (req.session.userRole !== 'superadmin' && campaign.created_by_user_id !== req.session.userId) {
+ return res.status(403).json({ error: 'Only the campaign creator or superadmin can assign members' });
+ }
+
+ const insert = db.prepare('INSERT OR IGNORE INTO campaign_assignments (campaign_id, user_id, assigned_by) VALUES (?, ?, ?)');
+ const tx = db.transaction(() => {
+ for (const userId of user_ids) {
+ insert.run(req.params.id, userId, req.session.userId);
+ }
+ });
+ tx();
+
+ // Return updated assignments
+ const assignments = db.prepare(`
+ SELECT ca.*, u.name as user_name, u.email as user_email, u.avatar as user_avatar, u.role as user_role
+ FROM campaign_assignments ca
+ JOIN users u ON ca.user_id = u.id
+ WHERE ca.campaign_id = ?
+ ORDER BY ca.assigned_at ASC
+ `).all(req.params.id);
+ res.json(assignments);
+});
+
+app.delete('/api/campaigns/:id/assignments/:userId', requireAuth, requireRole('superadmin', 'manager'), (req, res) => {
+ const campaign = db.prepare('SELECT id, created_by_user_id FROM campaigns WHERE id = ?').get(req.params.id);
+ if (!campaign) return res.status(404).json({ error: 'Campaign not found' });
+
+ // Only superadmin or campaign creator can unassign
+ if (req.session.userRole !== 'superadmin' && campaign.created_by_user_id !== req.session.userId) {
+ return res.status(403).json({ error: 'Only the campaign creator or superadmin can remove members' });
+ }
+
+ const result = db.prepare('DELETE FROM campaign_assignments WHERE campaign_id = ? AND user_id = ?').run(req.params.id, req.params.userId);
+ if (result.changes === 0) return res.status(404).json({ error: 'Assignment not found' });
+ res.json({ success: true });
+});
+
// ─── BUDGET ENTRIES ─────────────────────────────────────────────
app.get('/api/budget', requireAuth, requireRole('superadmin', 'manager'), (req, res) => {
+ const isSuperadmin = req.session.userRole === 'superadmin';
+ const userId = req.session.userId;
+ const campaignFilter = isSuperadmin
+ ? ''
+ : `AND be.campaign_id IN (SELECT id FROM campaigns WHERE created_by_user_id = ${userId} UNION SELECT campaign_id FROM campaign_assignments WHERE user_id = ${userId})`;
+
const entries = db.prepare(`
SELECT be.*, c.name as campaign_name
FROM budget_entries be
LEFT JOIN campaigns c ON be.campaign_id = c.id
+ WHERE 1=1 ${campaignFilter}
ORDER BY be.date_received DESC
`).all();
res.json(entries);
@@ -1050,10 +1183,23 @@ app.delete('/api/budget/:id', requireAuth, requireRole('superadmin', 'manager'),
res.json({ success: true });
});
-// Finance summary — aggregates across all campaigns & tracks
+// Finance summary — aggregates across campaigns & tracks (scoped by user's campaigns)
app.get('/api/finance/summary', requireAuth, requireRole('superadmin', 'manager'), (req, res) => {
- const totalReceived = db.prepare('SELECT COALESCE(SUM(amount), 0) as total FROM budget_entries').get().total;
+ const isSuperadmin = req.session.userRole === 'superadmin';
+ const userId = req.session.userId;
+ const myCampaignIds = isSuperadmin
+ ? null
+ : `SELECT id FROM campaigns WHERE created_by_user_id = ${userId} UNION SELECT campaign_id FROM campaign_assignments WHERE user_id = ${userId}`;
+ // For superadmin: totalReceived from budget_entries (org-wide funding)
+ // For managers: totalReceived = sum of their campaigns' budgets (allocated by superadmin)
+ const totalReceived = isSuperadmin
+ ? db.prepare('SELECT COALESCE(SUM(amount), 0) as total FROM budget_entries').get().total
+ : db.prepare(`SELECT COALESCE(SUM(budget), 0) as total FROM campaigns WHERE id IN (${myCampaignIds})`).get().total;
+
+ const campaignFilter = isSuperadmin
+ ? ''
+ : `WHERE c.id IN (${myCampaignIds})`;
const campaignStats = db.prepare(`
SELECT
c.id, c.name, c.budget, c.status,
@@ -1065,6 +1211,7 @@ app.get('/api/finance/summary', requireAuth, requireRole('superadmin', 'manager'
COALESCE(SUM(ct.conversions), 0) as tracks_conversions
FROM campaigns c
LEFT JOIN campaign_tracks ct ON ct.campaign_id = c.id
+ ${campaignFilter}
GROUP BY c.id
ORDER BY c.start_date DESC
`).all();
@@ -1090,6 +1237,9 @@ app.get('/api/finance/summary', requireAuth, requireRole('superadmin', 'manager'
// ─── CAMPAIGN TRACKS ────────────────────────────────────────────
app.get('/api/campaigns/:id/tracks', requireAuth, (req, res) => {
+ if (!userHasCampaignAccess(req.session.userId, req.session.userRole, req.params.id)) {
+ return res.status(403).json({ error: 'You do not have access to this campaign' });
+ }
const tracks = db.prepare('SELECT * FROM campaign_tracks WHERE campaign_id = ? ORDER BY created_at').all(req.params.id);
res.json(tracks);
});
@@ -1133,16 +1283,25 @@ app.delete('/api/tracks/:id', requireAuth, requireRole('superadmin', 'manager'),
// Get posts linked to a campaign (across all tracks)
app.get('/api/campaigns/:id/posts', requireAuth, (req, res) => {
+ if (!userHasCampaignAccess(req.session.userId, req.session.userRole, req.params.id)) {
+ return res.status(403).json({ error: 'You do not have access to this campaign' });
+ }
const posts = db.prepare(`
- SELECT p.*, t.name as assigned_name, b.name as brand_name, ct.name as track_name, ct.type as track_type
+ SELECT p.*, t.name as assigned_name, b.name as brand_name, ct.name as track_name, ct.type as track_type,
+ u.name as creator_user_name
FROM posts p
LEFT JOIN team_members t ON p.assigned_to = t.id
LEFT JOIN brands b ON p.brand_id = b.id
LEFT JOIN campaign_tracks ct ON p.track_id = ct.id
+ LEFT JOIN users u ON p.created_by_user_id = u.id
WHERE p.campaign_id = ?
ORDER BY p.created_at DESC
`).all(req.params.id);
- res.json(posts.map(parsePostJson));
+ const thumbnailStmt = db.prepare("SELECT url FROM post_attachments WHERE post_id = ? AND mime_type LIKE 'image/%' ORDER BY created_at ASC LIMIT 1");
+ res.json(posts.map(p => ({
+ ...parsePostJson(p),
+ thumbnail_url: thumbnailStmt.get(p.id)?.url || null,
+ })));
});
// ─── PROJECTS ───────────────────────────────────────────────────
@@ -1182,7 +1341,7 @@ app.post('/api/projects', requireAuth, requireRole('superadmin', 'manager'), (re
res.status(201).json(project);
});
-app.patch('/api/projects/:id', requireAuth, requireRole('superadmin', 'manager'), (req, res) => {
+app.patch('/api/projects/:id', requireAuth, requireOwnerOrRole('projects', 'superadmin', 'manager'), (req, res) => {
const { id } = req.params;
const existing = db.prepare('SELECT * FROM projects WHERE id = ?').get(id);
if (!existing) return res.status(404).json({ error: 'Project not found' });
@@ -1303,25 +1462,36 @@ app.delete('/api/tasks/:id', requireAuth, requireOwnerOrRole('tasks', 'superadmi
// ─── DASHBOARD ──────────────────────────────────────────────────
app.get('/api/dashboard', requireAuth, (req, res) => {
- // Post counts by status
- const postsByStatus = db.prepare('SELECT status, COUNT(*) as count FROM posts GROUP BY status').all();
- const totalPosts = db.prepare('SELECT COUNT(*) as count FROM posts').get().count;
+ const isSuperadmin = req.session.userRole === 'superadmin';
+ const userId = req.session.userId;
- // Active campaigns
- const activeCampaigns = db.prepare("SELECT COUNT(*) as count FROM campaigns WHERE status = 'active'").get().count;
- const totalCampaigns = db.prepare('SELECT COUNT(*) as count FROM campaigns').get().count;
+ // Subquery for user's campaign IDs (non-superadmins)
+ const myCampaignIds = `SELECT id FROM campaigns WHERE created_by_user_id = ${userId} UNION SELECT campaign_id FROM campaign_assignments WHERE user_id = ${userId}`;
+ const campaignFilter = isSuperadmin
+ ? ''
+ : `AND (c.created_by_user_id = ${userId} OR c.id IN (SELECT campaign_id FROM campaign_assignments WHERE user_id = ${userId}))`;
- // Overdue tasks
+ // Post counts by status (scoped to user's campaigns for non-superadmins)
+ const postCampaignFilter = isSuperadmin ? '' : `WHERE p.campaign_id IN (${myCampaignIds})`;
+ const postsByStatus = db.prepare(`SELECT status, COUNT(*) as count FROM posts p ${postCampaignFilter} GROUP BY status`).all();
+ const totalPosts = db.prepare(`SELECT COUNT(*) as count FROM posts p ${postCampaignFilter}`).get().count;
+
+ // Active campaigns (filtered by assignment for non-superadmins)
+ const activeCampaigns = db.prepare(`SELECT COUNT(*) as count FROM campaigns c WHERE status = 'active' ${campaignFilter}`).get().count;
+ const totalCampaigns = db.prepare(`SELECT COUNT(*) as count FROM campaigns c WHERE 1=1 ${campaignFilter}`).get().count;
+
+ // Overdue tasks (scoped to user's campaigns via projects for non-superadmins)
+ const taskCampaignFilter = isSuperadmin ? '' : `AND (tasks.created_by_user_id = ${userId} OR tasks.project_id IN (SELECT id FROM projects WHERE created_by_user_id = ${userId}))`;
const overdueTasks = db.prepare(`
- SELECT COUNT(*) as count FROM tasks
- WHERE due_date < date('now') AND status != 'done'
+ SELECT COUNT(*) as count FROM tasks
+ WHERE due_date < date('now') AND status != 'done' ${taskCampaignFilter}
`).get().count;
- // Total tasks by status
- const tasksByStatus = db.prepare('SELECT status, COUNT(*) as count FROM tasks GROUP BY status').all();
+ // Total tasks by status (scoped)
+ const tasksByStatus = db.prepare(`SELECT status, COUNT(*) as count FROM tasks WHERE 1=1 ${taskCampaignFilter} GROUP BY status`).all();
- // Team workload (tasks assigned per member)
- const teamWorkload = db.prepare(`
+ // Team workload — only for superadmins
+ const teamWorkload = isSuperadmin ? db.prepare(`
SELECT t.id, t.name, t.role,
COUNT(CASE WHEN tk.status != 'done' THEN 1 END) as active_tasks,
COUNT(CASE WHEN tk.status = 'done' THEN 1 END) as completed_tasks,
@@ -1331,26 +1501,29 @@ app.get('/api/dashboard', requireAuth, (req, res) => {
LEFT JOIN posts p ON p.assigned_to = t.id
GROUP BY t.id
ORDER BY active_tasks DESC
- `).all();
+ `).all() : [];
- // Active projects
- const activeProjects = db.prepare("SELECT COUNT(*) as count FROM projects WHERE status = 'active'").get().count;
+ // Active projects (scoped for non-superadmins)
+ const projectFilter = isSuperadmin ? '' : `AND (created_by_user_id = ${userId})`;
+ const activeProjects = db.prepare(`SELECT COUNT(*) as count FROM projects WHERE status = 'active' ${projectFilter}`).get().count;
- // Recent posts
+ // Recent posts (scoped to user's campaigns for non-superadmins)
+ const recentPostFilter = isSuperadmin ? '' : `WHERE p.campaign_id IN (${myCampaignIds})`;
const recentPosts = db.prepare(`
SELECT p.*, b.name as brand_name, t.name as assigned_name
- FROM posts p
- LEFT JOIN brands b ON p.brand_id = b.id
+ FROM posts p
+ LEFT JOIN brands b ON p.brand_id = b.id
LEFT JOIN team_members t ON p.assigned_to = t.id
+ ${recentPostFilter}
ORDER BY p.updated_at DESC LIMIT 5
`).all();
- // Upcoming campaigns
+ // Upcoming campaigns (filtered by assignment for non-superadmins)
const upcomingCampaigns = db.prepare(`
- SELECT c.*, b.name as brand_name
- FROM campaigns c
+ SELECT c.*, b.name as brand_name
+ FROM campaigns c
LEFT JOIN brands b ON c.brand_id = b.id
- WHERE c.end_date >= date('now')
+ WHERE c.end_date >= date('now') ${campaignFilter}
ORDER BY c.start_date ASC LIMIT 5
`).all();
@@ -1417,6 +1590,30 @@ app.post('/api/comments/:entityType/:entityId', requireAuth, (req, res) => {
res.status(201).json(comment);
});
+app.patch('/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 can edit
+ if (comment.user_id !== req.session.userId) {
+ return res.status(403).json({ error: 'You can only edit your own comments' });
+ }
+
+ const { content } = req.body;
+ if (!content || !content.trim()) {
+ return res.status(400).json({ error: 'Content is required' });
+ }
+
+ db.prepare('UPDATE comments SET content = ? WHERE id = ?').run(content.trim(), req.params.id);
+ const updated = 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(req.params.id);
+ res.json(updated);
+});
+
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' });
diff --git a/start.sh b/start.sh
new file mode 100755
index 0000000..5ccb3c1
--- /dev/null
+++ b/start.sh
@@ -0,0 +1,12 @@
+#!/bin/bash
+
+# Start both frontend and backend concurrently
+trap 'kill 0' EXIT
+
+echo "Starting backend server..."
+cd server && npm run dev &
+
+echo "Starting frontend client..."
+cd client && npm run dev &
+
+wait