diff --git a/backend/app.js b/backend/app.js index 949f3bc..8267f6c 100644 --- a/backend/app.js +++ b/backend/app.js @@ -105,6 +105,8 @@ app.get('/api/health', (req, res) => { app.use('/api', require('./routes/auth')); app.use('/api', requireAuth, require('./routes/tasks')); app.use('/api', requireAuth, require('./routes/projects')); +app.use('/api', requireAuth, require('./routes/admin')); +app.use('/api', requireAuth, require('./routes/shares')); app.use('/api', requireAuth, require('./routes/areas')); app.use('/api', requireAuth, require('./routes/notes')); app.use('/api', requireAuth, require('./routes/tags')); diff --git a/backend/middleware/authorize.js b/backend/middleware/authorize.js new file mode 100644 index 0000000..dbf30c2 --- /dev/null +++ b/backend/middleware/authorize.js @@ -0,0 +1,31 @@ +const permissionsService = require('../services/permissionsService'); + +// requiredAccess: 'ro' | 'rw' | 'admin' +// resourceType: 'project' | 'task' | 'note' +// getResourceUid: function(req) => string | Promise +function hasAccess(requiredAccess, resourceType, getResourceUid, options = {}) { + const notFoundMessage = options.notFoundMessage || 'Not found'; + const forbiddenStatus = options.forbiddenStatus || 403; // 403 by default; can be 404 for legacy routes + const LEVELS = { none: 0, ro: 1, rw: 2, admin: 3 }; + return async function (req, res, next) { + try { + const uid = await (typeof getResourceUid === 'function' ? getResourceUid(req) : getResourceUid); + if (!uid) return res.status(404).json({ error: notFoundMessage }); + + const access = await permissionsService.getAccess( + req.currentUser?.id || req.session?.userId, + resourceType, + uid + ); + if (LEVELS[access] >= LEVELS[requiredAccess]) return next(); + if (forbiddenStatus === 404) { + return res.status(404).json({ error: notFoundMessage }); + } + return res.status(403).json({ error: 'Forbidden' }); + } catch (err) { + next(err); + } + }; +} + +module.exports = { hasAccess }; \ No newline at end of file diff --git a/backend/migrations/20250810090000-create-roles.js b/backend/migrations/20250810090000-create-roles.js new file mode 100644 index 0000000..35cff69 --- /dev/null +++ b/backend/migrations/20250810090000-create-roles.js @@ -0,0 +1,25 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('roles', { + id: { type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true }, + user_id: { + type: Sequelize.INTEGER, + allowNull: false, + unique: true, + references: { model: 'users', key: 'id' }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }, + is_admin: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false }, + created_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.literal('CURRENT_TIMESTAMP') }, + updated_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.literal('CURRENT_TIMESTAMP') }, + }); + await queryInterface.addIndex('roles', ['is_admin']); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.dropTable('roles'); + }, +}; diff --git a/backend/migrations/20250810090100-create-actions.js b/backend/migrations/20250810090100-create-actions.js new file mode 100644 index 0000000..479beee --- /dev/null +++ b/backend/migrations/20250810090100-create-actions.js @@ -0,0 +1,37 @@ +"use strict"; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable("actions", { + id: { type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true }, + actor_user_id: { + type: Sequelize.INTEGER, + allowNull: false, + references: { model: "users", key: "id" }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + verb: { type: Sequelize.STRING, allowNull: false }, + resource_type: { type: Sequelize.STRING, allowNull: false }, + resource_uid: { type: Sequelize.STRING, allowNull: false }, + target_user_id: { + type: Sequelize.INTEGER, + allowNull: false, + references: { model: "users", key: "id" }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + access_level: { type: Sequelize.STRING, allowNull: true }, + metadata: { type: Sequelize.JSON, allowNull: true }, + created_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.literal('CURRENT_TIMESTAMP') }, + updated_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.literal('CURRENT_TIMESTAMP') }, + }); + + await queryInterface.addIndex("actions", ["resource_type", "resource_uid"]); + await queryInterface.addIndex("actions", ["target_user_id"]); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.dropTable("actions"); + }, +}; diff --git a/backend/migrations/20250810090200-create-permissions.js b/backend/migrations/20250810090200-create-permissions.js new file mode 100644 index 0000000..9b135c0 --- /dev/null +++ b/backend/migrations/20250810090200-create-permissions.js @@ -0,0 +1,49 @@ +"use strict"; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable("permissions", { + id: { type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true }, + user_id: { + type: Sequelize.INTEGER, + allowNull: false, + references: { model: "users", key: "id" }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + resource_type: { type: Sequelize.STRING, allowNull: false }, + resource_uid: { type: Sequelize.STRING, allowNull: false }, + access_level: { type: Sequelize.STRING, allowNull: false }, + propagation: { type: Sequelize.STRING, allowNull: false, defaultValue: 'direct' }, + granted_by_user_id: { + type: Sequelize.INTEGER, + allowNull: false, + references: { model: "users", key: "id" }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + source_action_id: { + type: Sequelize.INTEGER, + allowNull: true, + references: { model: "actions", key: "id" }, + onUpdate: "CASCADE", + onDelete: "SET NULL", + }, + created_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.literal('CURRENT_TIMESTAMP') }, + updated_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.literal('CURRENT_TIMESTAMP') }, + }); + + await queryInterface.addConstraint('permissions', { + fields: ['user_id', 'resource_type', 'resource_uid'], + type: 'unique', + name: 'uniq_permissions_user_resource', + }); + await queryInterface.addIndex("permissions", ["resource_type", "resource_uid"]); + await queryInterface.addIndex("permissions", ["user_id"]); + await queryInterface.addIndex("permissions", ["access_level"]); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.dropTable("permissions"); + }, +}; diff --git a/backend/models/action.js b/backend/models/action.js new file mode 100644 index 0000000..df12343 --- /dev/null +++ b/backend/models/action.js @@ -0,0 +1,47 @@ +const { DataTypes } = require('sequelize'); + +module.exports = (sequelize) => { + const Action = sequelize.define( + 'Action', + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + actor_user_id: { + type: DataTypes.INTEGER, + allowNull: false, + }, + verb: { + type: DataTypes.STRING, + allowNull: false, + }, + resource_type: { + type: DataTypes.STRING, + allowNull: false, + }, + resource_uid: { + type: DataTypes.STRING, + allowNull: false, + }, + target_user_id: { + type: DataTypes.INTEGER, + allowNull: false, + }, + access_level: { + type: DataTypes.STRING, + allowNull: true, + }, + metadata: { + type: DataTypes.JSON, + allowNull: true, + }, + }, + { + tableName: 'actions', + } + ); + + return Action; +}; diff --git a/backend/models/index.js b/backend/models/index.js index a110811..b9011b6 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -29,6 +29,9 @@ const Tag = require('./tag')(sequelize); const Note = require('./note')(sequelize); const InboxItem = require('./inbox_item')(sequelize); const TaskEvent = require('./task_event')(sequelize); +const Role = require('./role')(sequelize); +const Action = require('./action')(sequelize); +const Permission = require('./permission')(sequelize); // Define associations User.hasMany(Area, { foreignKey: 'user_id' }); @@ -115,6 +118,19 @@ Tag.belongsToMany(Project, { otherKey: 'project_id', }); +// Roles and permissions associations +User.hasOne(Role, { foreignKey: 'user_id' }); +Role.belongsTo(User, { foreignKey: 'user_id' }); + +Permission.belongsTo(User, { foreignKey: 'user_id', as: 'User' }); +Permission.belongsTo(User, { foreignKey: 'granted_by_user_id', as: 'GrantedBy' }); +// Optional backrefs if needed later: +// User.hasMany(Permission, { foreignKey: 'user_id', as: 'Permissions' }); + +// Actions relations (optional aliases) +Action.belongsTo(User, { foreignKey: 'actor_user_id', as: 'Actor' }); +Action.belongsTo(User, { foreignKey: 'target_user_id', as: 'Target' }); + module.exports = { sequelize, User, @@ -125,4 +141,7 @@ module.exports = { Note, InboxItem, TaskEvent, + Role, + Action, + Permission, }; diff --git a/backend/models/permission.js b/backend/models/permission.js new file mode 100644 index 0000000..3d34e89 --- /dev/null +++ b/backend/models/permission.js @@ -0,0 +1,53 @@ +const { DataTypes } = require('sequelize'); + +module.exports = (sequelize) => { + const Permission = sequelize.define( + 'Permission', + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + user_id: { + type: DataTypes.INTEGER, + allowNull: false, + }, + resource_type: { + type: DataTypes.STRING, + allowNull: false, + }, + resource_uid: { + type: DataTypes.STRING, + allowNull: false, + }, + access_level: { + type: DataTypes.STRING, + allowNull: false, + }, + propagation: { + type: DataTypes.STRING, + allowNull: false, + defaultValue: 'direct', + }, + granted_by_user_id: { + type: DataTypes.INTEGER, + allowNull: false, + }, + source_action_id: { + type: DataTypes.INTEGER, + allowNull: true, + }, + }, + { + tableName: 'permissions', + indexes: [ + { fields: ['user_id'] }, + { fields: ['resource_type', 'resource_uid'] }, + { fields: ['access_level'] }, + ], + } + ); + + return Permission; +}; diff --git a/backend/models/role.js b/backend/models/role.js new file mode 100644 index 0000000..b9edaf9 --- /dev/null +++ b/backend/models/role.js @@ -0,0 +1,29 @@ +const { DataTypes } = require('sequelize'); + +module.exports = (sequelize) => { + const Role = sequelize.define( + 'Role', + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + user_id: { + type: DataTypes.INTEGER, + allowNull: false, + unique: true, + }, + is_admin: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + }, + { + tableName: 'roles', + } + ); + + return Role; +}; diff --git a/backend/routes/admin.js b/backend/routes/admin.js new file mode 100644 index 0000000..d67346b --- /dev/null +++ b/backend/routes/admin.js @@ -0,0 +1,40 @@ +const express = require('express'); +const router = express.Router(); +const { Role, User } = require('../models'); +const { isAdmin } = require('../services/rolesService'); + +// POST /api/admin/set-admin-role +// Body: { user_id: number, is_admin: boolean } +router.post('/admin/set-admin-role', async (req, res) => { + try { + const requesterId = req.currentUser?.id || req.session?.userId; + if (!requesterId) return res.status(401).json({ error: 'Authentication required' }); + + // Allow if requester is already admin OR if there are no roles yet (bootstrap) + const requesterIsAdmin = await isAdmin(requesterId); + const existingRolesCount = await Role.count(); + if (!requesterIsAdmin && existingRolesCount > 0) { + return res.status(403).json({ error: 'Forbidden' }); + } + + const { user_id, is_admin } = req.body; + if (!user_id || typeof is_admin !== 'boolean') { + return res.status(400).json({ error: 'user_id and is_admin are required' }); + } + + const user = await User.findByPk(user_id); + if (!user) return res.status(400).json({ error: 'Invalid user_id' }); + + const [role] = await Role.findOrCreate({ where: { user_id }, defaults: { user_id, is_admin } }); + if (role.is_admin !== is_admin) { + role.is_admin = is_admin; + await role.save(); + } + res.json({ user_id, is_admin: role.is_admin }); + } catch (err) { + console.error('Error setting admin role:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +module.exports = router; diff --git a/backend/routes/shares.js b/backend/routes/shares.js new file mode 100644 index 0000000..0dd7735 --- /dev/null +++ b/backend/routes/shares.js @@ -0,0 +1,82 @@ +const express = require('express'); +const { User, Permission } = require('../models'); +const { execAction } = require('../services/execAction'); +const router = express.Router(); + +// POST /api/shares +router.post('/shares', async (req, res) => { + try { + if (!req.session || !req.session.userId) { + return res.status(401).json({ error: 'Authentication required' }); + } + const { resource_type, resource_uid, target_user_email, access_level } = req.body; + if (!resource_type || !resource_uid || !target_user_email || !access_level) { + return res.status(400).json({ error: 'Missing parameters' }); + } + const target = await User.findOne({ where: { email: target_user_email } }); + if (!target) return res.status(404).json({ error: 'Target user not found' }); + + await execAction({ + verb: 'share_grant', + actorUserId: req.session.userId, + targetUserId: target.id, + resourceType: resource_type, + resourceUid: resource_uid, + accessLevel: access_level, + }); + res.status(204).end(); + } catch (err) { + console.error('Error sharing resource:', err); + res.status(400).json({ error: 'Unable to share resource' }); + } +}); + +// DELETE /api/shares +router.delete('/shares', async (req, res) => { + try { + if (!req.session || !req.session.userId) { + return res.status(401).json({ error: 'Authentication required' }); + } + const { resource_type, resource_uid, target_user_id } = req.body; + if (!resource_type || !resource_uid || !target_user_id) { + return res.status(400).json({ error: 'Missing parameters' }); + } + + await execAction({ + verb: 'share_revoke', + actorUserId: req.session.userId, + targetUserId: Number(target_user_id), + resourceType: resource_type, + resourceUid: resource_uid, + }); + res.status(204).end(); + } catch (err) { + console.error('Error revoking share:', err); + res.status(400).json({ error: 'Unable to revoke share' }); + } +}); + +// GET /api/shares?resource_type=...&resource_uid=... +router.get('/shares', async (req, res) => { + try { + if (!req.session || !req.session.userId) { + return res.status(401).json({ error: 'Authentication required' }); + } + const { resource_type, resource_uid } = req.query; + if (!resource_type || !resource_uid) { + return res.status(400).json({ error: 'Missing parameters' }); + } + + const rows = await Permission.findAll({ + where: { resource_type, resource_uid, propagation: 'direct' }, + attributes: ['user_id', 'access_level', 'created_at'], + raw: true, + }); + res.json({ shares: rows }); + } catch (err) { + console.error('Error listing shares:', err); + res.status(400).json({ error: 'Unable to list shares' }); + } +}); + +module.exports = router; diff --git a/backend/services/applyPerms.js b/backend/services/applyPerms.js new file mode 100644 index 0000000..a7cc4ce --- /dev/null +++ b/backend/services/applyPerms.js @@ -0,0 +1,45 @@ +const { sequelize } = require('../models'); + +function maxAccess(a, b) { + if (a === 'rw' || b === 'rw') return 'rw'; + if (a === 'ro' || b === 'ro') return 'ro'; + return 'none'; +} + +async function applyPerms(tx, changes) { + const { Permission } = require('../models'); + const upserts = changes.upserts || []; + const deletes = changes.deletes || []; + + // Upserts: for SQLite we can emulate ON CONFLICT with find+create/update in tx + for (const u of upserts) { + const where = { user_id: u.userId, resource_type: u.resourceType, resource_uid: u.resourceUid }; + const existing = await Permission.findOne({ where, transaction: tx, lock: tx.LOCK.UPDATE }); + if (existing) { + const nextLevel = maxAccess(existing.access_level, u.accessLevel); + await existing.update({ + access_level: nextLevel, + propagation: u.propagation || existing.propagation, + granted_by_user_id: u.grantedByUserId, + source_action_id: u.sourceActionId || existing.source_action_id || null, + }, { transaction: tx }); + } else { + await Permission.create({ + user_id: u.userId, + resource_type: u.resourceType, + resource_uid: u.resourceUid, + access_level: u.accessLevel, + propagation: u.propagation || 'direct', + granted_by_user_id: u.grantedByUserId, + source_action_id: u.sourceActionId || null, + }, { transaction: tx }); + } + } + + // Deletes + for (const d of deletes) { + await Permission.destroy({ where: { user_id: d.userId, resource_type: d.resourceType, resource_uid: d.resourceUid }, transaction: tx }); + } +} + +module.exports = { applyPerms }; diff --git a/backend/services/execAction.js b/backend/services/execAction.js new file mode 100644 index 0000000..f03cfba --- /dev/null +++ b/backend/services/execAction.js @@ -0,0 +1,67 @@ +const { sequelize, Action } = require('../models'); +const { isAdmin } = require('./rolesService'); +const { applyPerms } = require('./applyPerms'); +const { calculateProjectPerms, calculateTaskPerms, calculateNotePerms, calculateAreaPerms, calculateTagPerms } = require('./permissionsCalculators'); + +async function assertActorCanShare(actorUserId, resourceType, resourceOwnerId) { + if (await isAdmin(actorUserId)) return; + if (resourceOwnerId !== actorUserId) { + const err = new Error('Forbidden'); + err.status = 403; + throw err; + } +} + +async function execAction(action) { + // action: { verb, actorUserId, targetUserId, resourceType, resourceUid, accessLevel? } + return await sequelize.transaction(async (tx) => { + // Resolve owner id for authorization (basic impl for projects; extend later) + let ownerUserId = null; + if (action.resourceType === 'project') { + const { Project } = require('../models'); + const proj = await Project.findOne({ where: { uid: action.resourceUid }, attributes: ['user_id'], transaction: tx, lock: tx.LOCK.UPDATE }); + if (!proj) { + const err = new Error('Resource not found'); + err.status = 404; + throw err; + } + ownerUserId = proj.user_id; + } + + await assertActorCanShare(action.actorUserId, action.resourceType, ownerUserId); + + const actionRow = await Action.create({ + actor_user_id: action.actorUserId, + verb: action.verb, + resource_type: action.resourceType, + resource_uid: action.resourceUid, + target_user_id: action.targetUserId, + access_level: action.accessLevel || null, + metadata: null, + }, { transaction: tx }); + + let changes = { upserts: [], deletes: [] }; + const ctx = { tx }; + + if (action.resourceType === 'project') { + changes = await calculateProjectPerms(ctx, action); + } else if (action.resourceType === 'task') { + changes = await calculateTaskPerms(ctx, action); + } else if (action.resourceType === 'note') { + changes = await calculateNotePerms(ctx, action); + } else if (action.resourceType === 'area') { + changes = await calculateAreaPerms(ctx, action); + } else if (action.resourceType === 'tag') { + changes = await calculateTagPerms(ctx, action); + } + + // Attach source_action_id + changes.upserts = changes.upserts.map((u) => ({ ...u, source_action_id: actionRow.id })); + + await applyPerms(tx, changes); + + return actionRow.id; + }); +} + +module.exports = { execAction }; diff --git a/backend/services/permissionsCalculators.js b/backend/services/permissionsCalculators.js new file mode 100644 index 0000000..6f4189e --- /dev/null +++ b/backend/services/permissionsCalculators.js @@ -0,0 +1,101 @@ +const { Project, Task, Note } = require('../models'); + +function emptyChanges() { return { upserts: [], deletes: [] }; } + +function pushUpsert(changes, u) { changes.upserts.push(u); } +function pushDelete(changes, d) { changes.deletes.push(d); } + +async function collectProjectDescendants(projectId) { + // tasks (all levels) and notes + const rootTasks = await Task.findAll({ where: { project_id: projectId }, attributes: ['id', 'uid', 'parent_task_id'], raw: true }); + const notes = await Note.findAll({ where: { project_id: projectId }, attributes: ['uid'], raw: true }); + + const taskUids = new Set(); + const queue = [...rootTasks]; + for (const t of rootTasks) taskUids.add(t.uid); + while (queue.length) { + const node = queue.shift(); + const children = await Task.findAll({ where: { parent_task_id: node.id }, attributes: ['id', 'uid'], raw: true }); + for (const c of children) { + if (!taskUids.has(c.uid)) { + taskUids.add(c.uid); + queue.push({ id: c.id }); + } + } + } + return { taskUids: Array.from(taskUids), noteUids: notes.map(n => n.uid) }; +} + +async function calculateProjectPerms(ctx, action) { + const changes = emptyChanges(); + // find project id + const project = await Project.findOne({ where: { uid: action.resourceUid }, attributes: ['id', 'user_id'], transaction: ctx.tx }); + if (!project) return changes; + + const { taskUids, noteUids } = await collectProjectDescendants(project.id); + + if (action.verb === 'share_grant') { + const direct = { userId: action.targetUserId, resourceType: 'project', resourceUid: action.resourceUid, accessLevel: action.accessLevel, propagation: 'direct', grantedByUserId: action.actorUserId }; + pushUpsert(changes, direct); + for (const tuid of taskUids) pushUpsert(changes, { userId: action.targetUserId, resourceType: 'task', resourceUid: tuid, accessLevel: action.accessLevel, propagation: 'inherited', grantedByUserId: action.actorUserId }); + for (const nuid of noteUids) pushUpsert(changes, { userId: action.targetUserId, resourceType: 'note', resourceUid: nuid, accessLevel: action.accessLevel, propagation: 'inherited', grantedByUserId: action.actorUserId }); + } else if (action.verb === 'share_revoke') { + pushDelete(changes, { userId: action.targetUserId, resourceType: 'project', resourceUid: action.resourceUid }); + for (const tuid of taskUids) pushDelete(changes, { userId: action.targetUserId, resourceType: 'task', resourceUid: tuid }); + for (const nuid of noteUids) pushDelete(changes, { userId: action.targetUserId, resourceType: 'note', resourceUid: nuid }); + } + + return changes; +} + +async function calculateTaskPerms(ctx, action) { + // Handle single task subtree (task + subtasks) + const changes = emptyChanges(); + const task = await Task.findOne({ where: { uid: action.resourceUid }, attributes: ['id'], transaction: ctx.tx }); + if (!task) return changes; + + const taskUids = new Set([action.resourceUid]); + const queue = [{ id: task.id }]; + while (queue.length) { + const node = queue.shift(); + const children = await Task.findAll({ where: { parent_task_id: node.id }, attributes: ['id', 'uid'], transaction: ctx.tx, raw: true }); + for (const c of children) { + if (!taskUids.has(c.uid)) { + taskUids.add(c.uid); + queue.push({ id: c.id }); + } + } + } + + if (action.verb === 'share_grant') { + for (const tuid of taskUids) pushUpsert(changes, { userId: action.targetUserId, resourceType: 'task', resourceUid: tuid, accessLevel: action.accessLevel, propagation: tuid === action.resourceUid ? 'direct' : 'inherited', grantedByUserId: action.actorUserId }); + } else if (action.verb === 'share_revoke') { + for (const tuid of taskUids) pushDelete(changes, { userId: action.targetUserId, resourceType: 'task', resourceUid: tuid }); + } + + return changes; +} + +async function calculateNotePerms(ctx, action) { + const changes = emptyChanges(); + if (action.verb === 'share_grant') { + pushUpsert(changes, { userId: action.targetUserId, resourceType: 'note', resourceUid: action.resourceUid, accessLevel: action.accessLevel, propagation: 'direct', grantedByUserId: action.actorUserId }); + } else if (action.verb === 'share_revoke') { + pushDelete(changes, { userId: action.targetUserId, resourceType: 'note', resourceUid: action.resourceUid }); + } + return changes; +} + +async function calculateAreaPerms(ctx, action) { + const changes = emptyChanges(); + // TODO: implement area→projects→tasks/notes cascade later + return changes; +} + +async function calculateTagPerms(ctx, action) { + const changes = emptyChanges(); + // No-op for now (tags excluded from project cascade) + return changes; +} + +module.exports = { calculateProjectPerms, calculateTaskPerms, calculateNotePerms, calculateAreaPerms, calculateTagPerms }; diff --git a/backend/services/permissionsService.js b/backend/services/permissionsService.js new file mode 100644 index 0000000..3f1c4ef --- /dev/null +++ b/backend/services/permissionsService.js @@ -0,0 +1,54 @@ +const { Op } = require('sequelize'); +const { Project, Task, Note, Permission } = require('../models'); +const { isAdmin } = require('./rolesService'); + +const ACCESS = { NONE: 'none', RO: 'ro', RW: 'rw', ADMIN: 'admin' }; + +async function getSharedUidsForUser(resourceType, userId) { + const rows = await Permission.findAll({ + where: { user_id: userId, resource_type: resourceType }, + attributes: ['resource_uid'], + raw: true, + }); + const set = new Set(rows.map((r) => r.resource_uid)); + return Array.from(set); +} + +async function getAccess(userId, resourceType, resourceUid) { + if (await isAdmin(userId)) return ACCESS.ADMIN; + + // ownership via model + if (resourceType === 'project') { + const proj = await Project.findOne({ where: { uid: resourceUid }, attributes: ['user_id'], raw: true }); + if (!proj) return ACCESS.NONE; + if (proj.user_id === userId) return ACCESS.RW; + } else if (resourceType === 'task') { + const t = await Task.findOne({ where: { uid: resourceUid }, attributes: ['user_id'], raw: true }); + if (!t) return ACCESS.NONE; + if (t.user_id === userId) return ACCESS.RW; + } else if (resourceType === 'note') { + const n = await Note.findOne({ where: { uid: resourceUid }, attributes: ['user_id'], raw: true }); + if (!n) return ACCESS.NONE; + if (n.user_id === userId) return ACCESS.RW; + } + + // shared + const perm = await Permission.findOne({ + where: { user_id: userId, resource_type: resourceType, resource_uid: resourceUid }, + attributes: ['access_level'], + raw: true, + }); + return perm ? perm.access_level : ACCESS.NONE; +} + +async function ownershipOrPermissionWhere(resourceType, userId) { + const sharedUids = await getSharedUidsForUser(resourceType, userId); + return { + [Op.or]: [ + { user_id: userId }, + sharedUids.length ? { uid: { [Op.in]: sharedUids } } : { uid: null }, + ], + }; +} + +module.exports = { ACCESS, getAccess, ownershipOrPermissionWhere, getSharedUidsForUser }; diff --git a/backend/services/rolesService.js b/backend/services/rolesService.js new file mode 100644 index 0000000..4b49498 --- /dev/null +++ b/backend/services/rolesService.js @@ -0,0 +1,9 @@ +const { Role } = require('../models'); + +async function isAdmin(userId) { + if (!userId) return false; + const role = await Role.findOne({ where: { user_id: userId } }); + return !!(role && role.is_admin); +} + +module.exports = { isAdmin };