feat: team-based visibility, roles management, unified users, UI fixes
All checks were successful
Deploy / deploy (push) Successful in 12s
All checks were successful
Deploy / deploy (push) Successful in 12s
- Add Roles table with CRUD routes and Settings page management - Unify user management: remove Users page, enhance Team page with permission level + role dropdowns - Add team-based visibility scoping to projects, campaigns, posts, tasks, issues, artefacts, and dashboard - Add team_id to projects and campaigns (create + edit forms) - Add getUserTeamIds/getUserVisibilityContext helpers - Fix Budgets modal horizontal scroll (separate linked-to row) - Add collapsible filter bar to PostProduction page Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -102,6 +102,31 @@ function stripSensitiveFields(data) {
|
||||
return data;
|
||||
}
|
||||
|
||||
// Get all team IDs for a user
|
||||
async function getUserTeamIds(userId) {
|
||||
const entries = await nocodb.list('TeamMembers', { where: `(user_id,eq,${userId})`, limit: 200 });
|
||||
return new Set(entries.map(e => e.team_id));
|
||||
}
|
||||
|
||||
// Get full visibility context for a user (team IDs + team project/campaign IDs)
|
||||
async function getUserVisibilityContext(userId) {
|
||||
const myTeamIds = await getUserTeamIds(userId);
|
||||
if (myTeamIds.size === 0) return { myTeamIds, teamProjectIds: new Set(), teamCampaignIds: new Set() };
|
||||
|
||||
// Fetch projects and campaigns that belong to the user's teams
|
||||
const allProjects = await nocodb.list('Projects', { limit: 2000 });
|
||||
const allCampaigns = await nocodb.list('Campaigns', { limit: 2000 });
|
||||
|
||||
const teamProjectIds = new Set(
|
||||
allProjects.filter(p => p.team_id && myTeamIds.has(p.team_id)).map(p => p.Id)
|
||||
);
|
||||
const teamCampaignIds = new Set(
|
||||
allCampaigns.filter(c => c.team_id && myTeamIds.has(c.team_id)).map(c => c.Id)
|
||||
);
|
||||
|
||||
return { myTeamIds, teamProjectIds, teamCampaignIds };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getRecordName,
|
||||
batchResolveNames,
|
||||
@@ -111,5 +136,7 @@ module.exports = {
|
||||
sanitizeWhereValue,
|
||||
getUserModules,
|
||||
stripSensitiveFields,
|
||||
getUserTeamIds,
|
||||
getUserVisibilityContext,
|
||||
_nameCache,
|
||||
};
|
||||
|
||||
355
server/server.js
355
server/server.js
@@ -11,7 +11,7 @@ const SqliteStore = require('connect-sqlite3')(session);
|
||||
const nocodb = require('./nocodb');
|
||||
const crypto = require('crypto');
|
||||
const { PORT, UPLOADS_DIR, SETTINGS_PATH, DEFAULTS, QUERY_LIMITS, ALL_MODULES, TABLE_NAME_MAP, COMMENT_ENTITY_TYPES } = require('./config');
|
||||
const { getRecordName, batchResolveNames, parseApproverIds, safeJsonParse, pickBodyFields, sanitizeWhereValue, getUserModules, stripSensitiveFields } = require('./helpers');
|
||||
const { getRecordName, batchResolveNames, parseApproverIds, safeJsonParse, pickBodyFields, sanitizeWhereValue, getUserModules, stripSensitiveFields, getUserTeamIds, getUserVisibilityContext } = require('./helpers');
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -125,6 +125,11 @@ function requireOwnerOrRole(table, ...allowedRoles) {
|
||||
if (row.created_by_user_id === req.session.userId) return next();
|
||||
if (row.assigned_to_id && row.assigned_to_id === req.session.userId) return next();
|
||||
if (row.owner_id && row.owner_id === req.session.userId) return next();
|
||||
// Manager team-based access: if resource has team_id and manager is in that team
|
||||
if (req.session.userRole === 'manager' && row.team_id) {
|
||||
const myTeamIds = await getUserTeamIds(req.session.userId);
|
||||
if (myTeamIds.has(row.team_id)) return next();
|
||||
}
|
||||
return res.status(403).json({ error: 'You can only modify your own items' });
|
||||
} catch (err) {
|
||||
console.error('Owner check error:', err);
|
||||
@@ -139,8 +144,8 @@ const FK_COLUMNS = {
|
||||
Tasks: ['project_id', 'assigned_to_id', 'created_by_user_id'],
|
||||
CampaignTracks: ['campaign_id'],
|
||||
CampaignAssignments: ['campaign_id', 'member_id', 'assigner_id'],
|
||||
Projects: ['brand_id', 'owner_id', 'created_by_user_id'],
|
||||
Campaigns: ['brand_id', 'created_by_user_id'],
|
||||
Projects: ['brand_id', 'owner_id', 'created_by_user_id', 'team_id'],
|
||||
Campaigns: ['brand_id', 'created_by_user_id', 'team_id'],
|
||||
Posts: ['brand_id', 'assigned_to_id', 'campaign_id', 'track_id', 'created_by_user_id'],
|
||||
Assets: ['brand_id', 'campaign_id', 'uploader_id'],
|
||||
PostAttachments: ['post_id'],
|
||||
@@ -149,6 +154,7 @@ const FK_COLUMNS = {
|
||||
BudgetEntries: ['campaign_id', 'project_id'],
|
||||
Artefacts: ['project_id', 'campaign_id'],
|
||||
Issues: ['brand_id', 'assigned_to_id', 'team_id'],
|
||||
Users: ['role_id'],
|
||||
};
|
||||
|
||||
// Maps link column names to FK field names for migration
|
||||
@@ -395,6 +401,10 @@ const REQUIRED_TABLES = {
|
||||
{ title: 'uploaded_by', uidt: 'SingleLineText' },
|
||||
{ title: 'created_at', uidt: 'DateTime' },
|
||||
],
|
||||
Roles: [
|
||||
{ title: 'name', uidt: 'SingleLineText' },
|
||||
{ title: 'color', uidt: 'SingleLineText' },
|
||||
],
|
||||
};
|
||||
|
||||
async function ensureRequiredTables() {
|
||||
@@ -831,84 +841,25 @@ app.patch('/api/users/me/tutorial', requireAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ─── USER MANAGEMENT (Superadmin only) ──────────────────────────
|
||||
// ─── USER MANAGEMENT ────────────────────────────────────────────
|
||||
|
||||
app.get('/api/users', requireAuth, requireRole('superadmin'), async (req, res) => {
|
||||
app.get('/api/users', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const users = await nocodb.list('Users', { sort: '-CreatedAt' });
|
||||
res.json(stripSensitiveFields(users));
|
||||
const users = await nocodb.list('Users', { sort: 'name' });
|
||||
// Enrich with role_name
|
||||
let roles = [];
|
||||
try { roles = await nocodb.list('Roles', { limit: QUERY_LIMITS.medium }); } catch {}
|
||||
const roleMap = {};
|
||||
for (const r of roles) roleMap[r.Id] = r.name;
|
||||
res.json(stripSensitiveFields(users.map(u => ({
|
||||
...u, id: u.Id, _id: u.Id,
|
||||
role_name: u.role_id ? (roleMap[u.role_id] || null) : null,
|
||||
}))));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to load users' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/users', requireAuth, requireRole('superadmin'), async (req, res) => {
|
||||
const { name, email, password, role, avatar, team_role, brands, phone, modules } = req.body;
|
||||
if (!name || !email || !role) return res.status(400).json({ error: 'Name, email, and role are required' });
|
||||
if (!['superadmin', 'manager', 'contributor'].includes(role)) return res.status(400).json({ error: 'Invalid role' });
|
||||
|
||||
try {
|
||||
const existing = await nocodb.list('Users', { where: `(email,eq,${sanitizeWhereValue(email)})`, limit: 1 });
|
||||
if (existing.length > 0) return res.status(409).json({ error: 'Email already exists' });
|
||||
|
||||
const defaultPassword = password || 'changeme123';
|
||||
const passwordHash = await bcrypt.hash(defaultPassword, 10);
|
||||
const created = await nocodb.create('Users', {
|
||||
name, email, role, avatar: avatar || null,
|
||||
team_role: team_role || null,
|
||||
brands: JSON.stringify(brands || []),
|
||||
phone: phone || null,
|
||||
modules: JSON.stringify(modules || ALL_MODULES),
|
||||
password_hash: passwordHash,
|
||||
});
|
||||
const user = await nocodb.get('Users', created.Id);
|
||||
res.status(201).json(stripSensitiveFields({ ...user, id: user.Id, _id: user.Id }));
|
||||
} catch (err) {
|
||||
console.error('Create user error:', err);
|
||||
res.status(500).json({ error: 'Failed to create user' });
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/api/users/:id', requireAuth, requireRole('superadmin'), async (req, res) => {
|
||||
const { id } = req.params;
|
||||
try {
|
||||
const existing = await nocodb.get('Users', id);
|
||||
if (!existing) return res.status(404).json({ error: 'User not found' });
|
||||
if (req.body.role && !['superadmin', 'manager', 'contributor'].includes(req.body.role)) return res.status(400).json({ error: 'Invalid role' });
|
||||
|
||||
const data = {};
|
||||
for (const f of ['name', 'email', 'role', 'avatar', 'team_role', 'phone']) {
|
||||
if (req.body[f] !== undefined) data[f] = req.body[f];
|
||||
}
|
||||
if (req.body.brands !== undefined) data.brands = JSON.stringify(req.body.brands);
|
||||
if (req.body.modules !== undefined) data.modules = JSON.stringify(req.body.modules);
|
||||
|
||||
if (req.body.password) {
|
||||
data.password_hash = await bcrypt.hash(req.body.password, 10);
|
||||
}
|
||||
|
||||
if (Object.keys(data).length > 0) await nocodb.update('Users', id, data);
|
||||
const user = await nocodb.get('Users', id);
|
||||
res.json(stripSensitiveFields(user));
|
||||
} catch (err) {
|
||||
console.error('Update user error:', err);
|
||||
res.status(500).json({ error: 'Failed to update user' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/users/:id', requireAuth, requireRole('superadmin'), async (req, res) => {
|
||||
const { id } = req.params;
|
||||
if (Number(id) === req.session.userId) return res.status(400).json({ error: 'Cannot delete your own account' });
|
||||
try {
|
||||
const user = await nocodb.get('Users', id);
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
await nocodb.delete('Users', id);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to delete user' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── ASSIGNABLE USERS ───────────────────────────────────────────
|
||||
|
||||
app.get('/api/users/assignable', requireAuth, async (req, res) => {
|
||||
@@ -945,20 +896,24 @@ app.get('/api/users/team', requireAuth, async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Attach teams to each user
|
||||
// Attach teams + role_name to each user
|
||||
let allTeamMembers = [];
|
||||
let allTeams = [];
|
||||
let roles = [];
|
||||
try {
|
||||
allTeamMembers = await nocodb.list('TeamMembers', { limit: QUERY_LIMITS.max });
|
||||
allTeams = await nocodb.list('Teams', { limit: QUERY_LIMITS.medium });
|
||||
} catch (err) { console.error('Load teams for user list:', err.message); }
|
||||
roles = await nocodb.list('Roles', { limit: QUERY_LIMITS.medium });
|
||||
} catch (err) { console.error('Load teams/roles for user list:', err.message); }
|
||||
const teamMap = {};
|
||||
for (const t of allTeams) teamMap[t.Id] = t.name;
|
||||
const roleMap = {};
|
||||
for (const r of roles) roleMap[r.Id] = r.name;
|
||||
|
||||
res.json(stripSensitiveFields(filtered.map(u => {
|
||||
const userTeamEntries = allTeamMembers.filter(tm => tm.user_id === u.Id);
|
||||
const teams = userTeamEntries.map(tm => ({ id: tm.team_id, name: teamMap[tm.team_id] || 'Unknown' }));
|
||||
return { ...u, id: u.Id, _id: u.Id, teams };
|
||||
return { ...u, id: u.Id, _id: u.Id, teams, role_name: u.role_id ? (roleMap[u.role_id] || null) : null };
|
||||
})));
|
||||
} catch (err) {
|
||||
console.error('Team list error:', err);
|
||||
@@ -967,13 +922,16 @@ app.get('/api/users/team', requireAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
app.post('/api/users/team', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
|
||||
const { name, email, password, team_role, brands, phone, role } = req.body;
|
||||
const { name, email, password, team_role, brands, phone, role, role_id, avatar } = req.body;
|
||||
if (!name) return res.status(400).json({ error: 'Name is required' });
|
||||
if (!email) return res.status(400).json({ error: 'Email is required' });
|
||||
|
||||
let userRole = role || 'contributor';
|
||||
if (req.session.userRole === 'manager' && userRole !== 'contributor') {
|
||||
return res.status(403).json({ error: 'Managers can only create users with contributor role' });
|
||||
return res.status(403).json({ error: 'Managers can only create users with contributor permission level' });
|
||||
}
|
||||
if (userRole && !['superadmin', 'manager', 'contributor'].includes(userRole)) {
|
||||
return res.status(400).json({ error: 'Invalid permission level' });
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -987,6 +945,8 @@ app.post('/api/users/team', requireAuth, requireRole('superadmin', 'manager'), a
|
||||
brands: JSON.stringify(brands || []), phone: phone || null,
|
||||
modules: JSON.stringify(req.body.modules || ALL_MODULES),
|
||||
password_hash: passwordHash,
|
||||
role_id: role_id || null,
|
||||
avatar: avatar || null,
|
||||
});
|
||||
|
||||
const user = await nocodb.get('Users', created.Id);
|
||||
@@ -1003,11 +963,25 @@ app.patch('/api/users/team/:id', requireAuth, requireRole('superadmin', 'manager
|
||||
if (!existing) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
const data = {};
|
||||
for (const f of ['name', 'email', 'team_role', 'phone']) {
|
||||
for (const f of ['name', 'email', 'team_role', 'phone', 'avatar']) {
|
||||
if (req.body[f] !== undefined) data[f] = req.body[f];
|
||||
}
|
||||
if (req.body.brands !== undefined) data.brands = JSON.stringify(req.body.brands);
|
||||
if (req.body.modules !== undefined) data.modules = JSON.stringify(req.body.modules);
|
||||
if (req.body.role_id !== undefined) data.role_id = req.body.role_id;
|
||||
|
||||
// Only superadmin can change permission level (role field)
|
||||
if (req.body.role !== undefined && req.session.userRole === 'superadmin') {
|
||||
if (!['superadmin', 'manager', 'contributor'].includes(req.body.role)) {
|
||||
return res.status(400).json({ error: 'Invalid permission level' });
|
||||
}
|
||||
data.role = req.body.role;
|
||||
}
|
||||
|
||||
// Password change
|
||||
if (req.body.password) {
|
||||
data.password_hash = await bcrypt.hash(req.body.password, 10);
|
||||
}
|
||||
|
||||
if (Object.keys(data).length === 0) return res.status(400).json({ error: 'No fields to update' });
|
||||
|
||||
@@ -1021,6 +995,7 @@ app.patch('/api/users/team/:id', requireAuth, requireRole('superadmin', 'manager
|
||||
});
|
||||
|
||||
app.delete('/api/users/team/:id', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
|
||||
if (Number(req.params.id) === req.session.userId) return res.status(400).json({ error: 'Cannot delete your own account' });
|
||||
try {
|
||||
const user = await nocodb.get('Users', req.params.id);
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
@@ -1154,11 +1129,18 @@ app.get('/api/posts', requireAuth, async (req, res) => {
|
||||
const where = whereParts.length > 0 ? whereParts.join('~and') : undefined;
|
||||
const posts = await nocodb.list('Posts', { where, sort: '-UpdatedAt', limit: QUERY_LIMITS.medium });
|
||||
|
||||
// Visibility filtering for contributors
|
||||
// Team-based visibility filtering
|
||||
let filtered = posts;
|
||||
if (req.session.userRole === 'contributor') {
|
||||
const userId = req.session.userId;
|
||||
if (req.session.userRole === 'manager') {
|
||||
const { teamCampaignIds } = await getUserVisibilityContext(userId);
|
||||
filtered = filtered.filter(p =>
|
||||
p.created_by_user_id === req.session.userId || p.assigned_to_id === req.session.userId
|
||||
p.created_by_user_id === userId || p.assigned_to_id === userId ||
|
||||
(p.campaign_id && teamCampaignIds.has(p.campaign_id)) || !p.campaign_id
|
||||
);
|
||||
} else if (req.session.userRole === 'contributor') {
|
||||
filtered = filtered.filter(p =>
|
||||
p.created_by_user_id === userId || p.assigned_to_id === userId
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1568,30 +1550,40 @@ app.get('/api/campaigns', requireAuth, async (req, res) => {
|
||||
campaigns = campaigns.filter(c => c.brand_id === Number(req.query.brand_id));
|
||||
}
|
||||
|
||||
// Non-superadmin scoping
|
||||
if (req.session.userRole !== 'superadmin') {
|
||||
const userId = req.session.userId;
|
||||
// Team-based visibility scoping
|
||||
const userId = req.session.userId;
|
||||
if (req.session.userRole === 'manager') {
|
||||
const myTeamIds = await getUserTeamIds(userId);
|
||||
const myCampaignIds = await getUserCampaignIds(userId);
|
||||
campaigns = campaigns.filter(c => {
|
||||
return c.created_by_user_id === userId || myCampaignIds.has(c.Id);
|
||||
});
|
||||
campaigns = campaigns.filter(c =>
|
||||
c.created_by_user_id === userId || myCampaignIds.has(c.Id) ||
|
||||
(c.team_id && myTeamIds.has(c.team_id)) || !c.team_id
|
||||
);
|
||||
} else if (req.session.userRole === 'contributor') {
|
||||
const myCampaignIds = await getUserCampaignIds(userId);
|
||||
campaigns = campaigns.filter(c =>
|
||||
c.created_by_user_id === userId || myCampaignIds.has(c.Id)
|
||||
);
|
||||
}
|
||||
|
||||
// Enrich with names
|
||||
const brandIds = new Set(), userIds = new Set();
|
||||
const brandIds = new Set(), userIds = new Set(), teamIds = new Set();
|
||||
for (const c of campaigns) {
|
||||
if (c.brand_id) brandIds.add(c.brand_id);
|
||||
if (c.created_by_user_id) userIds.add(c.created_by_user_id);
|
||||
if (c.team_id) teamIds.add(c.team_id);
|
||||
}
|
||||
const names = await batchResolveNames({
|
||||
brand: { table: 'Brands', ids: [...brandIds] },
|
||||
user: { table: 'Users', ids: [...userIds] },
|
||||
team: { table: 'Teams', ids: [...teamIds] },
|
||||
});
|
||||
|
||||
res.json(campaigns.map(c => ({
|
||||
...c,
|
||||
brand_name: names[`brand:${c.brand_id}`] || null,
|
||||
creator_user_name: names[`user:${c.created_by_user_id}`] || null,
|
||||
team_name: names[`team:${c.team_id}`] || null,
|
||||
})));
|
||||
} catch (err) {
|
||||
console.error('GET /campaigns error:', err);
|
||||
@@ -1615,14 +1607,15 @@ app.get('/api/campaigns/:id', requireAuth, async (req, res) => {
|
||||
}
|
||||
|
||||
const brandName = await getRecordName('Brands', campaign.brand_id);
|
||||
res.json({ ...campaign, brand_name: brandName });
|
||||
const teamName = await getRecordName('Teams', campaign.team_id);
|
||||
res.json({ ...campaign, brand_name: brandName, team_name: teamName });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to load campaign' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/campaigns', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
|
||||
const { name, description, brand_id, start_date, end_date, status, color, budget, goals, platforms } = req.body;
|
||||
const { name, description, brand_id, start_date, end_date, status, color, budget, goals, platforms, team_id } = 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' });
|
||||
|
||||
@@ -1640,6 +1633,7 @@ app.post('/api/campaigns', requireAuth, requireRole('superadmin', 'manager'), as
|
||||
budget_spent: 0, revenue: 0, impressions: 0, clicks: 0, conversions: 0, cost_per_click: 0,
|
||||
notes: '',
|
||||
brand_id: brand_id ? Number(brand_id) : null,
|
||||
team_id: team_id ? Number(team_id) : null,
|
||||
created_by_user_id: req.session.userId,
|
||||
});
|
||||
|
||||
@@ -1655,6 +1649,7 @@ app.post('/api/campaigns', requireAuth, requireRole('superadmin', 'manager'), as
|
||||
res.status(201).json({
|
||||
...campaign,
|
||||
brand_name: await getRecordName('Brands', campaign.brand_id),
|
||||
team_name: await getRecordName('Teams', campaign.team_id),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Create campaign error:', err);
|
||||
@@ -1677,13 +1672,18 @@ app.patch('/api/campaigns/:id', requireAuth, requireOwnerOrRole('campaigns', 'su
|
||||
}
|
||||
if (body.platforms !== undefined) data.platforms = JSON.stringify(body.platforms);
|
||||
if (body.brand_id !== undefined) data.brand_id = body.brand_id ? Number(body.brand_id) : null;
|
||||
if (body.team_id !== undefined) data.team_id = body.team_id ? Number(body.team_id) : null;
|
||||
|
||||
if (Object.keys(data).length === 0) return res.status(400).json({ error: 'No fields to update' });
|
||||
|
||||
await nocodb.update('Campaigns', req.params.id, data);
|
||||
|
||||
const campaign = await nocodb.get('Campaigns', req.params.id);
|
||||
res.json({ ...campaign, brand_name: await getRecordName('Brands', campaign.brand_id) });
|
||||
res.json({
|
||||
...campaign,
|
||||
brand_name: await getRecordName('Brands', campaign.brand_id),
|
||||
team_name: await getRecordName('Teams', campaign.team_id),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Update campaign error:', err);
|
||||
res.status(500).json({ error: 'Failed to update campaign' });
|
||||
@@ -2128,17 +2128,33 @@ app.get('/api/projects', requireAuth, async (req, res) => {
|
||||
if (req.query.owner_id) whereParts.push(`(owner_id,eq,${sanitizeWhereValue(req.query.owner_id)})`);
|
||||
const where = whereParts.length > 0 ? whereParts.join('~and') : undefined;
|
||||
|
||||
const projects = await nocodb.list('Projects', { where, sort: '-CreatedAt', limit: QUERY_LIMITS.medium });
|
||||
let projects = await nocodb.list('Projects', { where, sort: '-CreatedAt', limit: QUERY_LIMITS.medium });
|
||||
|
||||
const brandIds = new Set(), userIds = new Set();
|
||||
// Team-based visibility filtering
|
||||
const userId = req.session.userId;
|
||||
if (req.session.userRole === 'manager') {
|
||||
const myTeamIds = await getUserTeamIds(userId);
|
||||
projects = projects.filter(p =>
|
||||
p.created_by_user_id === userId || p.owner_id === userId ||
|
||||
(p.team_id && myTeamIds.has(p.team_id)) || !p.team_id
|
||||
);
|
||||
} else if (req.session.userRole === 'contributor') {
|
||||
projects = projects.filter(p =>
|
||||
p.created_by_user_id === userId || p.owner_id === userId
|
||||
);
|
||||
}
|
||||
|
||||
const brandIds = new Set(), userIds = new Set(), teamIds = new Set();
|
||||
for (const p of projects) {
|
||||
if (p.brand_id) brandIds.add(p.brand_id);
|
||||
if (p.owner_id) userIds.add(p.owner_id);
|
||||
if (p.created_by_user_id) userIds.add(p.created_by_user_id);
|
||||
if (p.team_id) teamIds.add(p.team_id);
|
||||
}
|
||||
const names = await batchResolveNames({
|
||||
brand: { table: 'Brands', ids: [...brandIds] },
|
||||
user: { table: 'Users', ids: [...userIds] },
|
||||
team: { table: 'Teams', ids: [...teamIds] },
|
||||
});
|
||||
|
||||
res.json(projects.map(p => ({
|
||||
@@ -2146,6 +2162,7 @@ app.get('/api/projects', requireAuth, async (req, res) => {
|
||||
brand_name: names[`brand:${p.brand_id}`] || null,
|
||||
owner_name: names[`user:${p.owner_id}`] || null,
|
||||
creator_user_name: names[`user:${p.created_by_user_id}`] || null,
|
||||
team_name: names[`team:${p.team_id}`] || null,
|
||||
thumbnail_url: p.thumbnail ? `/api/uploads/${p.thumbnail}` : null,
|
||||
})));
|
||||
} catch (err) {
|
||||
@@ -2162,6 +2179,7 @@ app.get('/api/projects/:id', requireAuth, async (req, res) => {
|
||||
brand_name: await getRecordName('Brands', project.brand_id),
|
||||
owner_name: await getRecordName('Users', project.owner_id),
|
||||
creator_user_name: await getRecordName('Users', project.created_by_user_id),
|
||||
team_name: await getRecordName('Teams', project.team_id),
|
||||
thumbnail_url: project.thumbnail ? `/api/uploads/${project.thumbnail}` : null,
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -2170,7 +2188,7 @@ app.get('/api/projects/:id', requireAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
app.post('/api/projects', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
|
||||
const { name, description, brand_id, owner_id, status, priority, start_date, due_date } = req.body;
|
||||
const { name, description, brand_id, owner_id, status, priority, start_date, due_date, team_id } = req.body;
|
||||
if (!name) return res.status(400).json({ error: 'Name is required' });
|
||||
|
||||
try {
|
||||
@@ -2180,6 +2198,7 @@ app.post('/api/projects', requireAuth, requireRole('superadmin', 'manager'), asy
|
||||
start_date: start_date || null, due_date: due_date || null,
|
||||
brand_id: brand_id ? Number(brand_id) : null,
|
||||
owner_id: owner_id ? Number(owner_id) : null,
|
||||
team_id: team_id ? Number(team_id) : null,
|
||||
created_by_user_id: req.session.userId,
|
||||
});
|
||||
|
||||
@@ -2188,6 +2207,7 @@ app.post('/api/projects', requireAuth, requireRole('superadmin', 'manager'), asy
|
||||
...project,
|
||||
brand_name: await getRecordName('Brands', project.brand_id),
|
||||
owner_name: await getRecordName('Users', project.owner_id),
|
||||
team_name: await getRecordName('Teams', project.team_id),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Create project error:', err);
|
||||
@@ -2207,6 +2227,7 @@ app.patch('/api/projects/:id', requireAuth, requireOwnerOrRole('projects', 'supe
|
||||
}
|
||||
if (req.body.brand_id !== undefined) data.brand_id = req.body.brand_id ? Number(req.body.brand_id) : null;
|
||||
if (req.body.owner_id !== undefined) data.owner_id = req.body.owner_id ? Number(req.body.owner_id) : null;
|
||||
if (req.body.team_id !== undefined) data.team_id = req.body.team_id ? Number(req.body.team_id) : null;
|
||||
|
||||
if (Object.keys(data).length === 0) {
|
||||
return res.status(400).json({ error: 'No fields to update' });
|
||||
@@ -2219,6 +2240,7 @@ app.patch('/api/projects/:id', requireAuth, requireOwnerOrRole('projects', 'supe
|
||||
...project,
|
||||
brand_name: await getRecordName('Brands', project.brand_id),
|
||||
owner_name: await getRecordName('Users', project.owner_id),
|
||||
team_name: await getRecordName('Teams', project.team_id),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Update project error:', err);
|
||||
@@ -2279,10 +2301,17 @@ app.get('/api/tasks', requireAuth, async (req, res) => {
|
||||
|
||||
let tasks = await nocodb.list('Tasks', { where, sort: '-CreatedAt', limit: QUERY_LIMITS.max });
|
||||
|
||||
// Visibility filtering for contributors
|
||||
if (req.session.userRole === 'contributor') {
|
||||
// Team-based visibility filtering
|
||||
const userId = req.session.userId;
|
||||
if (req.session.userRole === 'manager') {
|
||||
const { teamProjectIds } = await getUserVisibilityContext(userId);
|
||||
tasks = tasks.filter(t =>
|
||||
t.created_by_user_id === req.session.userId || t.assigned_to_id === req.session.userId
|
||||
t.created_by_user_id === userId || t.assigned_to_id === userId ||
|
||||
(t.project_id && teamProjectIds.has(t.project_id)) || !t.project_id
|
||||
);
|
||||
} else if (req.session.userRole === 'contributor') {
|
||||
tasks = tasks.filter(t =>
|
||||
t.created_by_user_id === userId || t.assigned_to_id === userId
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2586,12 +2615,14 @@ app.get('/api/dashboard', requireAuth, async (req, res) => {
|
||||
nocodb.list('CampaignAssignments', { limit: QUERY_LIMITS.max }),
|
||||
]);
|
||||
|
||||
// Build user's campaign IDs for scoping
|
||||
let myCampaignIds;
|
||||
// Build team-based scoping context
|
||||
let myTeamIds = new Set();
|
||||
let myCampaignIds = new Set();
|
||||
if (!isSuperadmin) {
|
||||
myCampaignIds = new Set();
|
||||
myTeamIds = await getUserTeamIds(userId);
|
||||
for (const c of allCampaigns) {
|
||||
if (c.created_by_user_id === userId) myCampaignIds.add(c.Id);
|
||||
if (req.session.userRole === 'manager' && c.team_id && myTeamIds.has(c.team_id)) myCampaignIds.add(c.Id);
|
||||
}
|
||||
for (const a of allAssignments) {
|
||||
if (a.member_id === userId && a.campaign_id) {
|
||||
@@ -2600,10 +2631,26 @@ app.get('/api/dashboard', requireAuth, async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Build team project IDs for managers
|
||||
let myProjectIds = new Set();
|
||||
if (!isSuperadmin) {
|
||||
for (const p of allProjects) {
|
||||
if (p.created_by_user_id === userId || p.owner_id === userId) myProjectIds.add(p.Id);
|
||||
if (req.session.userRole === 'manager' && p.team_id && myTeamIds.has(p.team_id)) myProjectIds.add(p.Id);
|
||||
}
|
||||
}
|
||||
|
||||
// Posts
|
||||
let posts = allPosts;
|
||||
if (!isSuperadmin) {
|
||||
posts = allPosts.filter(p => !p.campaign_id || myCampaignIds.has(p.campaign_id));
|
||||
if (req.session.userRole === 'manager') {
|
||||
posts = allPosts.filter(p =>
|
||||
p.created_by_user_id === userId || p.assigned_to_id === userId ||
|
||||
(p.campaign_id && myCampaignIds.has(p.campaign_id)) || !p.campaign_id
|
||||
);
|
||||
} else {
|
||||
posts = allPosts.filter(p => p.created_by_user_id === userId || p.assigned_to_id === userId);
|
||||
}
|
||||
}
|
||||
const postsByStatus = {};
|
||||
for (const p of posts) {
|
||||
@@ -2613,16 +2660,23 @@ app.get('/api/dashboard', requireAuth, async (req, res) => {
|
||||
// Campaigns
|
||||
let campaigns = allCampaigns;
|
||||
if (!isSuperadmin) {
|
||||
campaigns = allCampaigns.filter(c => myCampaignIds.has(c.Id));
|
||||
campaigns = allCampaigns.filter(c => myCampaignIds.has(c.Id) || c.created_by_user_id === userId);
|
||||
}
|
||||
const activeCampaigns = campaigns.filter(c => c.status === 'active').length;
|
||||
|
||||
// Tasks
|
||||
let tasks = allTasks;
|
||||
if (!isSuperadmin) {
|
||||
tasks = allTasks.filter(t =>
|
||||
t.created_by_user_id === userId || t.assigned_to_id === userId
|
||||
);
|
||||
if (req.session.userRole === 'manager') {
|
||||
tasks = allTasks.filter(t =>
|
||||
t.created_by_user_id === userId || t.assigned_to_id === userId ||
|
||||
(t.project_id && myProjectIds.has(t.project_id)) || !t.project_id
|
||||
);
|
||||
} else {
|
||||
tasks = allTasks.filter(t =>
|
||||
t.created_by_user_id === userId || t.assigned_to_id === userId
|
||||
);
|
||||
}
|
||||
}
|
||||
const overdueTasks = tasks.filter(t => t.due_date && new Date(t.due_date) < new Date() && t.status !== 'done').length;
|
||||
const tasksByStatus = {};
|
||||
@@ -2633,7 +2687,7 @@ app.get('/api/dashboard', requireAuth, async (req, res) => {
|
||||
// Projects
|
||||
let projects = allProjects;
|
||||
if (!isSuperadmin) {
|
||||
projects = allProjects.filter(p => p.created_by_user_id === userId);
|
||||
projects = allProjects.filter(p => myProjectIds.has(p.Id) || p.created_by_user_id === userId || p.owner_id === userId);
|
||||
}
|
||||
const activeProjects = projects.filter(p => p.status === 'active').length;
|
||||
|
||||
@@ -2920,9 +2974,18 @@ app.get('/api/artefacts', requireAuth, async (req, res) => {
|
||||
|
||||
let artefacts = await nocodb.list('Artefacts', { where, sort: '-UpdatedAt', limit: QUERY_LIMITS.medium });
|
||||
|
||||
// Filter by permission: contributors see only their own
|
||||
if (req.session.userRole === 'contributor') {
|
||||
artefacts = artefacts.filter(a => a.created_by_user_id === req.session.userId);
|
||||
// Team-based visibility filtering
|
||||
const userId = req.session.userId;
|
||||
if (req.session.userRole === 'manager') {
|
||||
const { teamProjectIds, teamCampaignIds } = await getUserVisibilityContext(userId);
|
||||
artefacts = artefacts.filter(a =>
|
||||
a.created_by_user_id === userId ||
|
||||
(a.project_id && teamProjectIds.has(a.project_id)) ||
|
||||
(a.campaign_id && teamCampaignIds.has(a.campaign_id)) ||
|
||||
(!a.project_id && !a.campaign_id)
|
||||
);
|
||||
} else if (req.session.userRole === 'contributor') {
|
||||
artefacts = artefacts.filter(a => a.created_by_user_id === userId);
|
||||
}
|
||||
|
||||
// Enrich with names
|
||||
@@ -3785,11 +3848,23 @@ app.get('/api/issues', requireAuth, async (req, res) => {
|
||||
if (brand_id) conditions.push({ field: 'brand_id', op: 'eq', value: sanitizeWhereValue(brand_id) });
|
||||
if (team_id) conditions.push({ field: 'team_id', op: 'eq', value: sanitizeWhereValue(team_id) });
|
||||
|
||||
const issues = await nocodb.list('Issues', {
|
||||
let issues = await nocodb.list('Issues', {
|
||||
where: conditions,
|
||||
sort: sort || '-created_at',
|
||||
});
|
||||
|
||||
// Team-based visibility filtering
|
||||
const userId = req.session.userId;
|
||||
if (req.session.userRole === 'manager') {
|
||||
const myTeamIds = await getUserTeamIds(userId);
|
||||
issues = issues.filter(i =>
|
||||
i.assigned_to_id === userId ||
|
||||
(i.team_id && myTeamIds.has(i.team_id)) || !i.team_id
|
||||
);
|
||||
} else if (req.session.userRole === 'contributor') {
|
||||
issues = issues.filter(i => i.assigned_to_id === userId);
|
||||
}
|
||||
|
||||
// Resolve brand and team names
|
||||
const names = await batchResolveNames({
|
||||
brand: { table: 'Brands', ids: issues.map(i => i.brand_id) },
|
||||
@@ -4207,6 +4282,62 @@ process.on('unhandledRejection', (err) => {
|
||||
console.error('[UNHANDLED REJECTION]', err);
|
||||
});
|
||||
|
||||
// ─── ROLES ──────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/roles', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const roles = await nocodb.list('Roles', { sort: 'name', limit: QUERY_LIMITS.medium });
|
||||
res.json(roles.map(r => ({ ...r, id: r.Id, _id: r.Id })));
|
||||
} catch (err) {
|
||||
console.error('Roles list error:', err);
|
||||
res.status(500).json({ error: 'Failed to load roles' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/roles', requireAuth, requireRole('superadmin'), async (req, res) => {
|
||||
const { name, color } = req.body;
|
||||
if (!name) return res.status(400).json({ error: 'Name is required' });
|
||||
try {
|
||||
const created = await nocodb.create('Roles', { name, color: color || null });
|
||||
const role = await nocodb.get('Roles', created.Id);
|
||||
res.status(201).json({ ...role, id: role.Id, _id: role.Id });
|
||||
} catch (err) {
|
||||
console.error('Create role error:', err);
|
||||
res.status(500).json({ error: 'Failed to create role' });
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/api/roles/:id', requireAuth, requireRole('superadmin'), async (req, res) => {
|
||||
try {
|
||||
const existing = await nocodb.get('Roles', req.params.id);
|
||||
if (!existing) return res.status(404).json({ error: 'Role not found' });
|
||||
const data = {};
|
||||
if (req.body.name !== undefined) data.name = req.body.name;
|
||||
if (req.body.color !== undefined) data.color = req.body.color;
|
||||
if (Object.keys(data).length > 0) await nocodb.update('Roles', req.params.id, data);
|
||||
const role = await nocodb.get('Roles', req.params.id);
|
||||
res.json({ ...role, id: role.Id, _id: role.Id });
|
||||
} catch (err) {
|
||||
console.error('Update role error:', err);
|
||||
res.status(500).json({ error: 'Failed to update role' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/roles/:id', requireAuth, requireRole('superadmin'), async (req, res) => {
|
||||
try {
|
||||
const existing = await nocodb.get('Roles', req.params.id);
|
||||
if (!existing) return res.status(404).json({ error: 'Role not found' });
|
||||
// Check if any users have this role
|
||||
const usersWithRole = await nocodb.list('Users', { where: `(role_id,eq,${sanitizeWhereValue(req.params.id)})`, limit: 1 });
|
||||
if (usersWithRole.length > 0) return res.status(409).json({ error: 'Cannot delete role that is assigned to users' });
|
||||
await nocodb.delete('Roles', req.params.id);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('Delete role error:', err);
|
||||
res.status(500).json({ error: 'Failed to delete role' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── APP SETTINGS API ───────────────────────────────────────────
|
||||
|
||||
app.get('/api/settings/app', requireAuth, (req, res) => {
|
||||
|
||||
Reference in New Issue
Block a user