From d15e54044e787d5a104a1fc5e0cfc8710047314b Mon Sep 17 00:00:00 2001 From: fahed Date: Mon, 9 Feb 2026 13:59:40 +0300 Subject: [PATCH] Campaign assignments, ownership-based editing, and role-scoped data - Add campaign_assignments table for user-to-campaign mapping - Superadmin/managers can assign users to campaigns; visibility filtered by assignment/ownership - Managers can only manage (tracks, assignments) on campaigns they created - Budget controlled by superadmin only, with proper modal UI for editing - Ownership-based editing for campaigns, projects, comments (creators can edit their own) - Role-scoped dashboard and finance data (managers see only their campaigns' data) - Manager's budget derived from sum of their campaign budgets set by superadmin - Hide UI features users cannot use (principle of least privilege across all pages) - Fix profile completion prompt persisting after saving (login response now includes profileComplete) - Add post detail modal in campaign detail with thumbnails, publication links, and metadata - Add comment inline editing for comment authors - Move financial summary cards below filters on Campaigns page Co-Authored-By: Claude Opus 4.6 --- client/src/App.jsx | 2 + client/src/components/CommentsSection.jsx | 81 ++++- client/src/contexts/AuthContext.jsx | 4 + client/src/pages/CampaignDetail.jsx | 344 +++++++++++++++++++++- client/src/pages/Campaigns.jsx | 78 ++--- client/src/pages/Finance.jsx | 35 ++- client/src/pages/ProjectDetail.jsx | 82 ++++-- client/src/pages/Projects.jsx | 18 +- server/db.js | 12 + server/server.js | 283 +++++++++++++++--- start.sh | 12 + 11 files changed, 797 insertions(+), 154 deletions(-) create mode 100755 start.sh 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 && ( +
+ {selectedPost.title} +
+ )} + + {/* 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 && ( +
+

Publication Links

+
+ {selectedPost.publication_links.map((link, i) => { + const url = typeof link === 'string' ? link : link.url + const platform = typeof link === 'string' ? null : link.platform + const platformInfo = platform ? PLATFORMS[platform] : null + return ( + + {platformInfo && } + + {platformInfo ? platformInfo.label : url} + + {url} + + ) + })} +
+
+ )} + + {/* 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