chore: Consolidate membership checks in single service
This commit is contained in:
@@ -8,6 +8,7 @@ import type {AuthResponse} from '../middleware/auth.js';
|
|||||||
import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js';
|
import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js';
|
||||||
import {DomainService} from '../services/DomainService.js';
|
import {DomainService} from '../services/DomainService.js';
|
||||||
import {Keys} from '../services/keys.js';
|
import {Keys} from '../services/keys.js';
|
||||||
|
import {MembershipService} from '../services/MembershipService.js';
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||||
|
|
||||||
@@ -24,16 +25,7 @@ export class Domains {
|
|||||||
const {projectId} = DomainSchemas.projectId.parse(req.params);
|
const {projectId} = DomainSchemas.projectId.parse(req.params);
|
||||||
|
|
||||||
// Verify user has access to this project
|
// Verify user has access to this project
|
||||||
const membership = await prisma.membership.findFirst({
|
await MembershipService.requireAccess(auth.userId!, projectId);
|
||||||
where: {
|
|
||||||
userId: auth.userId,
|
|
||||||
projectId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
throw new NotFound('Project not found or you do not have access');
|
|
||||||
}
|
|
||||||
|
|
||||||
const domains = await DomainService.getProjectDomains(projectId);
|
const domains = await DomainService.getProjectDomains(projectId);
|
||||||
|
|
||||||
@@ -55,19 +47,7 @@ export class Domains {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify user has admin access to this project
|
// Verify user has admin access to this project
|
||||||
const membership = await prisma.membership.findFirst({
|
await MembershipService.requireAdminAccess(auth.userId!, projectId);
|
||||||
where: {
|
|
||||||
userId: auth.userId,
|
|
||||||
projectId,
|
|
||||||
role: {
|
|
||||||
in: ['ADMIN', 'OWNER'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
throw new NotFound('Project not found or you do not have permission');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if domain is already linked to another project
|
// Check if domain is already linked to another project
|
||||||
const ownershipCheck = await DomainService.checkDomainOwnership(domain, auth.userId);
|
const ownershipCheck = await DomainService.checkDomainOwnership(domain, auth.userId);
|
||||||
@@ -117,16 +97,7 @@ export class Domains {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify user has access to the project this domain belongs to
|
// Verify user has access to the project this domain belongs to
|
||||||
const membership = await prisma.membership.findFirst({
|
await MembershipService.requireAccess(auth.userId!, domain.projectId);
|
||||||
where: {
|
|
||||||
userId: auth.userId,
|
|
||||||
projectId: domain.projectId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
throw new NotFound('Domain not found or you do not have access');
|
|
||||||
}
|
|
||||||
|
|
||||||
const verificationStatus = await DomainService.checkVerification(id);
|
const verificationStatus = await DomainService.checkVerification(id);
|
||||||
|
|
||||||
@@ -154,19 +125,7 @@ export class Domains {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify user has admin access to the project this domain belongs to
|
// Verify user has admin access to the project this domain belongs to
|
||||||
const membership = await prisma.membership.findFirst({
|
await MembershipService.requireAdminAccess(auth.userId!, domain.projectId);
|
||||||
where: {
|
|
||||||
userId: auth.userId,
|
|
||||||
projectId: domain.projectId,
|
|
||||||
role: {
|
|
||||||
in: ['ADMIN', 'OWNER'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
throw new NotFound('Domain not found or you do not have permission');
|
|
||||||
}
|
|
||||||
|
|
||||||
await DomainService.removeDomain(id);
|
await DomainService.removeDomain(id);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {prisma} from '../database/prisma.js';
|
|||||||
import {HttpException} from '../exceptions/index.js';
|
import {HttpException} from '../exceptions/index.js';
|
||||||
import type {AuthResponse} from '../middleware/auth.js';
|
import type {AuthResponse} from '../middleware/auth.js';
|
||||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||||
|
import {MembershipService} from '../services/MembershipService.js';
|
||||||
import {SecurityService} from '../services/SecurityService.js';
|
import {SecurityService} from '../services/SecurityService.js';
|
||||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||||
|
|
||||||
@@ -23,16 +24,7 @@ export class Projects {
|
|||||||
const {id} = UtilitySchemas.id.parse(req.params);
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
// Verify user has access to this project
|
// Verify user has access to this project
|
||||||
const membership = await prisma.membership.findFirst({
|
await MembershipService.requireAccess(auth.userId!, id);
|
||||||
where: {
|
|
||||||
userId: auth.userId,
|
|
||||||
projectId: id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
throw new HttpException(404, 'Project not found or you do not have access');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get project with relevant data
|
// Get project with relevant data
|
||||||
const project = await prisma.project.findUnique({
|
const project = await prisma.project.findUnique({
|
||||||
@@ -96,16 +88,7 @@ export class Projects {
|
|||||||
const {id} = UtilitySchemas.id.parse(req.params);
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
// Verify user has access to this project
|
// Verify user has access to this project
|
||||||
const membership = await prisma.membership.findFirst({
|
await MembershipService.requireAccess(auth.userId!, id);
|
||||||
where: {
|
|
||||||
userId: auth.userId,
|
|
||||||
projectId: id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
throw new HttpException(404, 'Project not found or you do not have access');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use existing SecurityService
|
// Use existing SecurityService
|
||||||
const metrics = await SecurityService.getProjectSecurityMetrics(id);
|
const metrics = await SecurityService.getProjectSecurityMetrics(id);
|
||||||
@@ -128,39 +111,14 @@ export class Projects {
|
|||||||
const {id} = UtilitySchemas.id.parse(req.params);
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
// Verify user has access to this project
|
// Verify user has access to this project
|
||||||
const membership = await prisma.membership.findFirst({
|
await MembershipService.requireAccess(auth.userId!, id);
|
||||||
where: {
|
|
||||||
userId: auth.userId,
|
|
||||||
projectId: id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
throw new HttpException(404, 'Project not found or you do not have access');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all members of the project
|
// Get all members of the project
|
||||||
const members = await prisma.membership.findMany({
|
const members = await MembershipService.getMembers(id);
|
||||||
where: {
|
|
||||||
projectId: id,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
user: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
email: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: members.map(m => ({
|
data: members,
|
||||||
userId: m.user.id,
|
|
||||||
email: m.user.email,
|
|
||||||
role: m.role,
|
|
||||||
})),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,19 +148,7 @@ export class Projects {
|
|||||||
const {email, role} = parseResult.data;
|
const {email, role} = parseResult.data;
|
||||||
|
|
||||||
// Verify current user is ADMIN or OWNER
|
// Verify current user is ADMIN or OWNER
|
||||||
const currentMembership = await prisma.membership.findFirst({
|
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||||
where: {
|
|
||||||
userId: auth.userId,
|
|
||||||
projectId: id,
|
|
||||||
role: {
|
|
||||||
in: ['ADMIN', 'OWNER'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!currentMembership) {
|
|
||||||
throw new HttpException(403, 'Only project admins and owners can add members');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find user by email
|
// Find user by email
|
||||||
const userToAdd = await prisma.user.findUnique({
|
const userToAdd = await prisma.user.findUnique({
|
||||||
@@ -214,28 +160,8 @@ export class Projects {
|
|||||||
throw new HttpException(404, 'User with this email does not have an account');
|
throw new HttpException(404, 'User with this email does not have an account');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user is already a member
|
// Add member to project
|
||||||
const existingMembership = await prisma.membership.findUnique({
|
const newMembership = await MembershipService.addMember(id, userToAdd.id, role);
|
||||||
where: {
|
|
||||||
userId_projectId: {
|
|
||||||
userId: userToAdd.id,
|
|
||||||
projectId: id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingMembership) {
|
|
||||||
throw new HttpException(409, 'User is already a member of this project');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create membership
|
|
||||||
const newMembership = await prisma.membership.create({
|
|
||||||
data: {
|
|
||||||
userId: userToAdd.id,
|
|
||||||
projectId: id,
|
|
||||||
role,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -276,38 +202,7 @@ export class Projects {
|
|||||||
const {role} = parseResult.data;
|
const {role} = parseResult.data;
|
||||||
|
|
||||||
// Verify current user is ADMIN or OWNER
|
// Verify current user is ADMIN or OWNER
|
||||||
const currentMembership = await prisma.membership.findFirst({
|
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||||
where: {
|
|
||||||
userId: auth.userId,
|
|
||||||
projectId: id,
|
|
||||||
role: {
|
|
||||||
in: ['ADMIN', 'OWNER'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!currentMembership) {
|
|
||||||
throw new HttpException(403, 'Only project admins and owners can update member roles');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get target membership
|
|
||||||
const targetMembership = await prisma.membership.findUnique({
|
|
||||||
where: {
|
|
||||||
userId_projectId: {
|
|
||||||
userId,
|
|
||||||
projectId: id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!targetMembership) {
|
|
||||||
throw new HttpException(404, 'Member not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cannot change OWNER role
|
|
||||||
if (targetMembership.role === 'OWNER') {
|
|
||||||
throw new HttpException(403, 'Cannot change the role of the project owner');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get user info
|
// Get user info
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
@@ -319,16 +214,8 @@ export class Projects {
|
|||||||
throw new HttpException(404, 'User not found');
|
throw new HttpException(404, 'User not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update role
|
// Update role (service handles validation)
|
||||||
await prisma.membership.update({
|
await MembershipService.updateRole(id, userId, role);
|
||||||
where: {
|
|
||||||
userId_projectId: {
|
|
||||||
userId,
|
|
||||||
projectId: id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
data: {role},
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -360,53 +247,15 @@ export class Projects {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify current user is ADMIN or OWNER
|
// Verify current user is ADMIN or OWNER
|
||||||
const currentMembership = await prisma.membership.findFirst({
|
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||||
where: {
|
|
||||||
userId: auth.userId,
|
|
||||||
projectId: id,
|
|
||||||
role: {
|
|
||||||
in: ['ADMIN', 'OWNER'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!currentMembership) {
|
|
||||||
throw new HttpException(403, 'Only project admins and owners can remove members');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get target membership
|
|
||||||
const targetMembership = await prisma.membership.findUnique({
|
|
||||||
where: {
|
|
||||||
userId_projectId: {
|
|
||||||
userId,
|
|
||||||
projectId: id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!targetMembership) {
|
|
||||||
throw new HttpException(404, 'Member not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cannot remove OWNER
|
|
||||||
if (targetMembership.role === 'OWNER') {
|
|
||||||
throw new HttpException(403, 'Cannot remove the project owner');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cannot remove yourself
|
// Cannot remove yourself
|
||||||
if (userId === auth.userId) {
|
if (userId === auth.userId) {
|
||||||
throw new HttpException(403, 'You cannot remove yourself from the project');
|
throw new HttpException(403, 'You cannot remove yourself from the project');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete membership
|
// Remove member (service handles validation)
|
||||||
await prisma.membership.delete({
|
await MembershipService.removeMember(id, userId);
|
||||||
where: {
|
|
||||||
userId_projectId: {
|
|
||||||
userId,
|
|
||||||
projectId: id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {ErrorCode, HttpException, NotAuthenticated, NotFound} from '../exception
|
|||||||
import type {AuthResponse} from '../middleware/auth.js';
|
import type {AuthResponse} from '../middleware/auth.js';
|
||||||
import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js';
|
import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js';
|
||||||
import {BillingLimitService} from '../services/BillingLimitService.js';
|
import {BillingLimitService} from '../services/BillingLimitService.js';
|
||||||
|
import {MembershipService} from '../services/MembershipService.js';
|
||||||
import {NtfyService} from '../services/NtfyService.js';
|
import {NtfyService} from '../services/NtfyService.js';
|
||||||
import {SecurityService} from '../services/SecurityService.js';
|
import {SecurityService} from '../services/SecurityService.js';
|
||||||
import {UserService} from '../services/UserService.js';
|
import {UserService} from '../services/UserService.js';
|
||||||
@@ -108,20 +109,8 @@ export class Users {
|
|||||||
const {id} = UtilitySchemas.id.parse(req.params);
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
const data = ProjectSchemas.update.parse(req.body);
|
const data = ProjectSchemas.update.parse(req.body);
|
||||||
|
|
||||||
// Verify user has access to this project
|
// Verify user has admin/owner access to this project
|
||||||
const membership = await prisma.membership.findFirst({
|
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||||
where: {
|
|
||||||
userId: auth.userId,
|
|
||||||
projectId: id,
|
|
||||||
role: {
|
|
||||||
in: ['ADMIN', 'OWNER'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
throw new NotFound('Project not found or you do not have permission to update it');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the project
|
// Update the project
|
||||||
const project = await prisma.project.update({
|
const project = await prisma.project.update({
|
||||||
@@ -140,19 +129,7 @@ export class Users {
|
|||||||
const {id} = UtilitySchemas.id.parse(req.params);
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
// Verify user has admin/owner access to this project
|
// Verify user has admin/owner access to this project
|
||||||
const membership = await prisma.membership.findFirst({
|
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||||
where: {
|
|
||||||
userId: auth.userId,
|
|
||||||
projectId: id,
|
|
||||||
role: {
|
|
||||||
in: ['ADMIN', 'OWNER'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
throw new NotFound('Project not found or you do not have permission to regenerate keys');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate new unique API keys
|
// Generate new unique API keys
|
||||||
const publicKey = `pk_${randomBytes(32).toString('hex')}`;
|
const publicKey = `pk_${randomBytes(32).toString('hex')}`;
|
||||||
@@ -197,20 +174,8 @@ export class Users {
|
|||||||
return res.status(404).json({error: 'Billing is not enabled'});
|
return res.status(404).json({error: 'Billing is not enabled'});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify user has access to this project
|
// Verify user has admin/owner access to this project
|
||||||
const membership = await prisma.membership.findFirst({
|
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||||
where: {
|
|
||||||
userId: auth.userId,
|
|
||||||
projectId: id,
|
|
||||||
role: {
|
|
||||||
in: ['ADMIN', 'OWNER'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
throw new NotFound('Project not found or you do not have permission to manage billing');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the project
|
// Get the project
|
||||||
const project = await prisma.project.findUnique({
|
const project = await prisma.project.findUnique({
|
||||||
@@ -288,20 +253,8 @@ export class Users {
|
|||||||
return res.status(404).json({error: 'Billing is not enabled'});
|
return res.status(404).json({error: 'Billing is not enabled'});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify user has access to this project
|
// Verify user has admin/owner access to this project
|
||||||
const membership = await prisma.membership.findFirst({
|
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||||
where: {
|
|
||||||
userId: auth.userId,
|
|
||||||
projectId: id,
|
|
||||||
role: {
|
|
||||||
in: ['ADMIN', 'OWNER'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
throw new NotFound('Project not found or you do not have permission to manage billing');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the project
|
// Get the project
|
||||||
const project = await prisma.project.findUnique({
|
const project = await prisma.project.findUnique({
|
||||||
@@ -342,16 +295,7 @@ export class Users {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify user has access to this project
|
// Verify user has access to this project
|
||||||
const membership = await prisma.membership.findFirst({
|
await MembershipService.requireAccess(auth.userId!, id);
|
||||||
where: {
|
|
||||||
userId: auth.userId,
|
|
||||||
projectId: id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
throw new NotFound('Project not found or you do not have permission to view billing limits');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get billing limits and usage
|
// Get billing limits and usage
|
||||||
const limitsAndUsage = await BillingLimitService.getLimitsAndUsage(id);
|
const limitsAndUsage = await BillingLimitService.getLimitsAndUsage(id);
|
||||||
@@ -377,19 +321,7 @@ export class Users {
|
|||||||
const data = BillingLimitSchemas.update.parse(req.body);
|
const data = BillingLimitSchemas.update.parse(req.body);
|
||||||
|
|
||||||
// Verify user has admin/owner access to this project
|
// Verify user has admin/owner access to this project
|
||||||
const membership = await prisma.membership.findFirst({
|
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||||
where: {
|
|
||||||
userId: auth.userId,
|
|
||||||
projectId: id,
|
|
||||||
role: {
|
|
||||||
in: ['ADMIN', 'OWNER'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
throw new NotFound('Project not found or you do not have permission to update billing limits');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the project with current limits
|
// Get the project with current limits
|
||||||
const project = await prisma.project.findUnique({
|
const project = await prisma.project.findUnique({
|
||||||
@@ -459,16 +391,7 @@ export class Users {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify user has access to this project
|
// Verify user has access to this project
|
||||||
const membership = await prisma.membership.findFirst({
|
await MembershipService.requireAccess(auth.userId!, id);
|
||||||
where: {
|
|
||||||
userId: auth.userId,
|
|
||||||
projectId: id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
throw new NotFound('Project not found or you do not have permission to view billing');
|
|
||||||
}
|
|
||||||
|
|
||||||
const project = await prisma.project.findUnique({
|
const project = await prisma.project.findUnique({
|
||||||
where: {id},
|
where: {id},
|
||||||
@@ -585,16 +508,7 @@ export class Users {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify user has access to this project
|
// Verify user has access to this project
|
||||||
const membership = await prisma.membership.findFirst({
|
await MembershipService.requireAccess(auth.userId!, id);
|
||||||
where: {
|
|
||||||
userId: auth.userId,
|
|
||||||
projectId: id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
throw new NotFound('Project not found or you do not have permission to view billing');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the project
|
// Get the project
|
||||||
const project = await prisma.project.findUnique({
|
const project = await prisma.project.findUnique({
|
||||||
@@ -668,16 +582,7 @@ export class Users {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify user has access to this project
|
// Verify user has access to this project
|
||||||
const membership = await prisma.membership.findFirst({
|
await MembershipService.requireAccess(auth.userId!, id);
|
||||||
where: {
|
|
||||||
userId: auth.userId,
|
|
||||||
projectId: id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
throw new NotFound('Project not found or you do not have permission to view security metrics');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get security metrics
|
// Get security metrics
|
||||||
const metrics = await SecurityService.getProjectSecurityMetrics(id);
|
const metrics = await SecurityService.getProjectSecurityMetrics(id);
|
||||||
@@ -701,19 +606,7 @@ export class Users {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify user has admin/owner access to this project
|
// Verify user has admin/owner access to this project
|
||||||
const membership = await prisma.membership.findFirst({
|
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||||
where: {
|
|
||||||
userId: auth.userId,
|
|
||||||
projectId: id,
|
|
||||||
role: {
|
|
||||||
in: ['ADMIN', 'OWNER'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
throw new NotFound('Project not found or you do not have permission to reset it');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if project is disabled - block reset operation
|
// Check if project is disabled - block reset operation
|
||||||
const isDisabled = await SecurityService.isProjectDisabled(id);
|
const isDisabled = await SecurityService.isProjectDisabled(id);
|
||||||
@@ -787,21 +680,7 @@ export class Users {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify user has owner or admin access to this project
|
// Verify user has owner or admin access to this project
|
||||||
const membership = await prisma.membership.findFirst({
|
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||||
where: {
|
|
||||||
userId: auth.userId,
|
|
||||||
projectId: id,
|
|
||||||
role: {
|
|
||||||
in: ['OWNER', 'ADMIN'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
throw new NotFound(
|
|
||||||
'Project not found or you do not have permission to delete it. Only project owners and admins can delete projects.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get project to check for active subscription and disabled status
|
// Get project to check for active subscription and disabled status
|
||||||
const project = await prisma.project.findUnique({
|
const project = await prisma.project.findUnique({
|
||||||
|
|||||||
@@ -8,11 +8,12 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
import {DomainVerifiedEmail, DomainUnverifiedEmail, sendPlatformEmail} from '@plunk/email';
|
import {DomainUnverifiedEmail, DomainVerifiedEmail, sendPlatformEmail} from '@plunk/email';
|
||||||
|
|
||||||
import {DASHBOARD_URI, LANDING_URI} from '../constants.js';
|
import {DASHBOARD_URI, LANDING_URI} from '../app/constants.js';
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {redis} from '../database/redis.js';
|
import {redis} from '../database/redis.js';
|
||||||
|
import {MembershipService} from '../services/MembershipService.js';
|
||||||
import {disableFeedbackForwarding, getIdentities, verifyDomain} from '../services/SESService.js';
|
import {disableFeedbackForwarding, getIdentities, verifyDomain} from '../services/SESService.js';
|
||||||
import {Keys} from '../services/keys.js';
|
import {Keys} from '../services/keys.js';
|
||||||
|
|
||||||
@@ -75,7 +76,11 @@ export async function checkDomainVerifications() {
|
|||||||
signale.success(`[DOMAIN-VERIFICATION] Restarted verification for ${sesIdentity.domain}`);
|
signale.success(`[DOMAIN-VERIFICATION] Restarted verification for ${sesIdentity.domain}`);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const error = e as {Code?: string; name?: string; message?: string};
|
const error = e as {Code?: string; name?: string; message?: string};
|
||||||
if (error?.Code === 'Throttling' || error?.name === 'Throttling' || error?.message?.includes('Throttling')) {
|
if (
|
||||||
|
error?.Code === 'Throttling' ||
|
||||||
|
error?.name === 'Throttling' ||
|
||||||
|
error?.message?.includes('Throttling')
|
||||||
|
) {
|
||||||
signale.warn(
|
signale.warn(
|
||||||
`[DOMAIN-VERIFICATION] Throttling detected, waiting ${delay / 1000} seconds (attempt ${attempt + 1})`,
|
`[DOMAIN-VERIFICATION] Throttling detected, waiting ${delay / 1000} seconds (attempt ${attempt + 1})`,
|
||||||
);
|
);
|
||||||
@@ -83,7 +88,9 @@ export async function checkDomainVerifications() {
|
|||||||
delay *= 2; // Exponential backoff
|
delay *= 2; // Exponential backoff
|
||||||
attempt++;
|
attempt++;
|
||||||
} else {
|
} else {
|
||||||
signale.error(`[DOMAIN-VERIFICATION] Error restarting verification: ${error?.message || 'Unknown error'}`);
|
signale.error(
|
||||||
|
`[DOMAIN-VERIFICATION] Error restarting verification: ${error?.message || 'Unknown error'}`,
|
||||||
|
);
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,11 +125,8 @@ export async function checkDomainVerifications() {
|
|||||||
const cacheKey = Keys.Domain.verifiedEmail(dbDomain.id);
|
const cacheKey = Keys.Domain.verifiedEmail(dbDomain.id);
|
||||||
const alreadySent = await redis.get(cacheKey);
|
const alreadySent = await redis.get(cacheKey);
|
||||||
if (alreadySent !== '1') {
|
if (alreadySent !== '1') {
|
||||||
const members = await prisma.membership.findMany({
|
const members = await MembershipService.getMembers(dbDomain.projectId);
|
||||||
where: {projectId: dbDomain.projectId},
|
const emails = members.map(m => m.email);
|
||||||
include: {user: {select: {email: true}}},
|
|
||||||
});
|
|
||||||
const emails = members.map((m) => m.user.email);
|
|
||||||
if (emails.length > 0) {
|
if (emails.length > 0) {
|
||||||
const template = React.createElement(DomainVerifiedEmail, {
|
const template = React.createElement(DomainVerifiedEmail, {
|
||||||
projectName: dbDomain.project.name,
|
projectName: dbDomain.project.name,
|
||||||
@@ -132,7 +136,7 @@ export async function checkDomainVerifications() {
|
|||||||
landingUrl: LANDING_URI,
|
landingUrl: LANDING_URI,
|
||||||
});
|
});
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
emails.map((email) => sendPlatformEmail(email, 'Domain Verified Successfully', template)),
|
emails.map(email => sendPlatformEmail(email, 'Domain Verified Successfully', template)),
|
||||||
);
|
);
|
||||||
await redis.setex(cacheKey, 604800, '1'); // 7 days
|
await redis.setex(cacheKey, 604800, '1'); // 7 days
|
||||||
}
|
}
|
||||||
@@ -158,11 +162,8 @@ export async function checkDomainVerifications() {
|
|||||||
const cacheKey = Keys.Domain.unverifiedEmail(dbDomain.id, year, month);
|
const cacheKey = Keys.Domain.unverifiedEmail(dbDomain.id, year, month);
|
||||||
const alreadySent = await redis.get(cacheKey);
|
const alreadySent = await redis.get(cacheKey);
|
||||||
if (alreadySent !== '1') {
|
if (alreadySent !== '1') {
|
||||||
const members = await prisma.membership.findMany({
|
const members = await MembershipService.getMembers(dbDomain.projectId);
|
||||||
where: {projectId: dbDomain.projectId},
|
const emails = members.map(m => m.email);
|
||||||
include: {user: {select: {email: true}}},
|
|
||||||
});
|
|
||||||
const emails = members.map((m) => m.user.email);
|
|
||||||
if (emails.length > 0) {
|
if (emails.length > 0) {
|
||||||
const template = React.createElement(DomainUnverifiedEmail, {
|
const template = React.createElement(DomainUnverifiedEmail, {
|
||||||
projectName: dbDomain.project.name,
|
projectName: dbDomain.project.name,
|
||||||
@@ -172,7 +173,7 @@ export async function checkDomainVerifications() {
|
|||||||
landingUrl: LANDING_URI,
|
landingUrl: LANDING_URI,
|
||||||
});
|
});
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
emails.map((email) => sendPlatformEmail(email, 'Domain Verification Failed', template)),
|
emails.map(email => sendPlatformEmail(email, 'Domain Verification Failed', template)),
|
||||||
);
|
);
|
||||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||||
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
|
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import jsonwebtoken from 'jsonwebtoken';
|
|||||||
import {JWT_SECRET, PLUNK_ENABLED} from '../app/constants.js';
|
import {JWT_SECRET, PLUNK_ENABLED} from '../app/constants.js';
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {ErrorCode, HttpException, NotAuthenticated} from '../exceptions/index.js';
|
import {ErrorCode, HttpException, NotAuthenticated} from '../exceptions/index.js';
|
||||||
|
import {MembershipService} from '../services/MembershipService.js';
|
||||||
|
|
||||||
export interface AuthResponse {
|
export interface AuthResponse {
|
||||||
type: 'jwt' | 'apiKey';
|
type: 'jwt' | 'apiKey';
|
||||||
@@ -101,14 +102,7 @@ export const requireProjectAccess = async (req: Request, res: Response, next: Ne
|
|||||||
|
|
||||||
// Verify user has access to this project and get project status
|
// Verify user has access to this project and get project status
|
||||||
const [membership, project] = await Promise.all([
|
const [membership, project] = await Promise.all([
|
||||||
prisma.membership.findUnique({
|
MembershipService.getMembership(userId, projectId),
|
||||||
where: {
|
|
||||||
userId_projectId: {
|
|
||||||
userId,
|
|
||||||
projectId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
prisma.project.findUnique({
|
prisma.project.findUnique({
|
||||||
where: {id: projectId},
|
where: {id: projectId},
|
||||||
select: {disabled: true},
|
select: {disabled: true},
|
||||||
@@ -360,14 +354,7 @@ export const requireAuth = async (req: Request, res: Response, next: NextFunctio
|
|||||||
|
|
||||||
// Verify user has access to this project and get project status
|
// Verify user has access to this project and get project status
|
||||||
const [membership, project] = await Promise.all([
|
const [membership, project] = await Promise.all([
|
||||||
prisma.membership.findUnique({
|
MembershipService.getMembership(userId, projectId),
|
||||||
where: {
|
|
||||||
userId_projectId: {
|
|
||||||
userId,
|
|
||||||
projectId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
prisma.project.findUnique({
|
prisma.project.findUnique({
|
||||||
where: {id: projectId},
|
where: {id: projectId},
|
||||||
select: {disabled: true},
|
select: {disabled: true},
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {stripe} from '../app/stripe.js';
|
|||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {redis} from '../database/redis.js';
|
import {redis} from '../database/redis.js';
|
||||||
import {Keys} from './keys.js';
|
import {Keys} from './keys.js';
|
||||||
|
import {MembershipService} from './MembershipService.js';
|
||||||
import {NtfyService} from './NtfyService.js';
|
import {NtfyService} from './NtfyService.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -616,11 +617,8 @@ export class BillingLimitService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const members = await prisma.membership.findMany({
|
const members = await MembershipService.getMembers(projectId);
|
||||||
where: {projectId},
|
const emails = members.map(m => m.email);
|
||||||
include: {user: {select: {email: true}}},
|
|
||||||
});
|
|
||||||
const emails = members.map(m => m.user.email);
|
|
||||||
if (emails.length === 0) {
|
if (emails.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -669,11 +667,8 @@ export class BillingLimitService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const members = await prisma.membership.findMany({
|
const members = await MembershipService.getMembers(projectId);
|
||||||
where: {projectId},
|
const emails = members.map(m => m.email);
|
||||||
include: {user: {select: {email: true}}},
|
|
||||||
});
|
|
||||||
const emails = members.map(m => m.user.email);
|
|
||||||
if (emails.length === 0) {
|
if (emails.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
import {DomainVerifiedEmail, DomainUnverifiedEmail, sendPlatformEmail} from '@plunk/email';
|
import {DomainUnverifiedEmail, DomainVerifiedEmail, sendPlatformEmail} from '@plunk/email';
|
||||||
import {DASHBOARD_URI, LANDING_URI} from '../constants.js';
|
import {DASHBOARD_URI, LANDING_URI} from '../app/constants.js';
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {redis, wrapRedis} from '../database/redis.js';
|
import {redis, wrapRedis} from '../database/redis.js';
|
||||||
import {HttpException} from '../exceptions/index.js';
|
import {HttpException} from '../exceptions/index.js';
|
||||||
import {Keys} from './keys.js';
|
import {Keys} from './keys.js';
|
||||||
|
import {MembershipService} from './MembershipService.js';
|
||||||
import {NtfyService} from './NtfyService.js';
|
import {NtfyService} from './NtfyService.js';
|
||||||
import {getDomainVerificationAttributes, verifyDomain} from './SESService.js';
|
import {getDomainVerificationAttributes, verifyDomain} from './SESService.js';
|
||||||
|
|
||||||
@@ -92,11 +93,8 @@ export class DomainService {
|
|||||||
const cacheKey = Keys.Domain.verifiedEmail(domainId);
|
const cacheKey = Keys.Domain.verifiedEmail(domainId);
|
||||||
const alreadySent = await redis.get(cacheKey);
|
const alreadySent = await redis.get(cacheKey);
|
||||||
if (alreadySent !== '1') {
|
if (alreadySent !== '1') {
|
||||||
const members = await prisma.membership.findMany({
|
const members = await MembershipService.getMembers(updatedDomain.project.id);
|
||||||
where: {projectId: updatedDomain.project.id},
|
const emails = members.map(m => m.email);
|
||||||
include: {user: {select: {email: true}}},
|
|
||||||
});
|
|
||||||
const emails = members.map((m) => m.user.email);
|
|
||||||
if (emails.length > 0) {
|
if (emails.length > 0) {
|
||||||
const template = React.createElement(DomainVerifiedEmail, {
|
const template = React.createElement(DomainVerifiedEmail, {
|
||||||
projectName: updatedDomain.project.name,
|
projectName: updatedDomain.project.name,
|
||||||
@@ -105,9 +103,7 @@ export class DomainService {
|
|||||||
dashboardUrl: DASHBOARD_URI,
|
dashboardUrl: DASHBOARD_URI,
|
||||||
landingUrl: LANDING_URI,
|
landingUrl: LANDING_URI,
|
||||||
});
|
});
|
||||||
await Promise.all(
|
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Domain Verified Successfully', template)));
|
||||||
emails.map((email) => sendPlatformEmail(email, 'Domain Verified Successfully', template)),
|
|
||||||
);
|
|
||||||
// Set cache to prevent duplicate emails (7 days)
|
// Set cache to prevent duplicate emails (7 days)
|
||||||
await redis.setex(cacheKey, 604800, '1');
|
await redis.setex(cacheKey, 604800, '1');
|
||||||
}
|
}
|
||||||
@@ -127,7 +123,11 @@ export class DomainService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Send notification about domain verification failed
|
// Send notification about domain verification failed
|
||||||
await NtfyService.notifyDomainVerificationFailed(domain.domain, updatedDomain.project.name, updatedDomain.project.id);
|
await NtfyService.notifyDomainVerificationFailed(
|
||||||
|
domain.domain,
|
||||||
|
updatedDomain.project.name,
|
||||||
|
updatedDomain.project.id,
|
||||||
|
);
|
||||||
|
|
||||||
// Send email notification about domain verification failed
|
// Send email notification about domain verification failed
|
||||||
try {
|
try {
|
||||||
@@ -138,11 +138,8 @@ export class DomainService {
|
|||||||
const cacheKey = Keys.Domain.unverifiedEmail(domainId, year, month);
|
const cacheKey = Keys.Domain.unverifiedEmail(domainId, year, month);
|
||||||
const alreadySent = await redis.get(cacheKey);
|
const alreadySent = await redis.get(cacheKey);
|
||||||
if (alreadySent !== '1') {
|
if (alreadySent !== '1') {
|
||||||
const members = await prisma.membership.findMany({
|
const members = await MembershipService.getMembers(updatedDomain.project.id);
|
||||||
where: {projectId: updatedDomain.project.id},
|
const emails = members.map(m => m.email);
|
||||||
include: {user: {select: {email: true}}},
|
|
||||||
});
|
|
||||||
const emails = members.map((m) => m.user.email);
|
|
||||||
if (emails.length > 0) {
|
if (emails.length > 0) {
|
||||||
const template = React.createElement(DomainUnverifiedEmail, {
|
const template = React.createElement(DomainUnverifiedEmail, {
|
||||||
projectName: updatedDomain.project.name,
|
projectName: updatedDomain.project.name,
|
||||||
@@ -151,9 +148,7 @@ export class DomainService {
|
|||||||
dashboardUrl: DASHBOARD_URI,
|
dashboardUrl: DASHBOARD_URI,
|
||||||
landingUrl: LANDING_URI,
|
landingUrl: LANDING_URI,
|
||||||
});
|
});
|
||||||
await Promise.all(
|
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Domain Verification Failed', template)));
|
||||||
emails.map((email) => sendPlatformEmail(email, 'Domain Verification Failed', template)),
|
|
||||||
);
|
|
||||||
// Set cache to prevent duplicate emails (until end of month)
|
// Set cache to prevent duplicate emails (until end of month)
|
||||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||||
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
|
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
|
||||||
|
|||||||
@@ -0,0 +1,392 @@
|
|||||||
|
import type {Membership, Role} from '@plunk/db';
|
||||||
|
|
||||||
|
import {prisma} from '../database/prisma.js';
|
||||||
|
import {redis, REDIS_ONE_MINUTE, wrapRedis} from '../database/redis.js';
|
||||||
|
import {HttpException} from '../exceptions/index.js';
|
||||||
|
import {Keys} from './keys.js';
|
||||||
|
|
||||||
|
const FIVE_MINUTES_IN_SECONDS = 5 * 60;
|
||||||
|
|
||||||
|
export interface MemberWithEmail {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
role: Role;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OwnerInfo {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DisabledProjectInfo {
|
||||||
|
hasDisabledProject: boolean;
|
||||||
|
disabledProjectNames: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for managing project memberships
|
||||||
|
* Centralizes all membership-related database queries with caching
|
||||||
|
*/
|
||||||
|
export class MembershipService {
|
||||||
|
// ============================================
|
||||||
|
// AUTHORIZATION METHODS (Cached)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user has any access to a project (any role)
|
||||||
|
* CACHED (1 min TTL) - called on every authenticated request
|
||||||
|
*/
|
||||||
|
public static async hasAccess(userId: string, projectId: string): Promise<boolean> {
|
||||||
|
return wrapRedis(
|
||||||
|
Keys.Membership.access(userId, projectId),
|
||||||
|
async () => {
|
||||||
|
const membership = await prisma.membership.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_projectId: {
|
||||||
|
userId,
|
||||||
|
projectId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return membership !== null;
|
||||||
|
},
|
||||||
|
REDIS_ONE_MINUTE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user has admin or owner access to a project
|
||||||
|
* CACHED (1 min TTL) - called before write operations
|
||||||
|
*/
|
||||||
|
public static async hasAdminAccess(userId: string, projectId: string): Promise<boolean> {
|
||||||
|
return wrapRedis(
|
||||||
|
Keys.Membership.admin(userId, projectId),
|
||||||
|
async () => {
|
||||||
|
const membership = await prisma.membership.findFirst({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
projectId,
|
||||||
|
role: {
|
||||||
|
in: ['ADMIN', 'OWNER'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return membership !== null;
|
||||||
|
},
|
||||||
|
REDIS_ONE_MINUTE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user's membership with role info
|
||||||
|
* CACHED (1 min TTL) - returns full membership or null
|
||||||
|
*/
|
||||||
|
public static async getMembership(userId: string, projectId: string): Promise<Membership | null> {
|
||||||
|
return wrapRedis(
|
||||||
|
Keys.Membership.full(userId, projectId),
|
||||||
|
async () => {
|
||||||
|
return prisma.membership.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_projectId: {
|
||||||
|
userId,
|
||||||
|
projectId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
REDIS_ONE_MINUTE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require membership or throw 404
|
||||||
|
* Uses cached getMembership internally
|
||||||
|
*/
|
||||||
|
public static async requireAccess(userId: string, projectId: string): Promise<Membership> {
|
||||||
|
const membership = await this.getMembership(userId, projectId);
|
||||||
|
|
||||||
|
if (!membership) {
|
||||||
|
throw new HttpException(404, 'Project not found or you do not have access');
|
||||||
|
}
|
||||||
|
|
||||||
|
return membership;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require admin/owner access or throw 403
|
||||||
|
* Uses cached hasAdminAccess internally
|
||||||
|
*/
|
||||||
|
public static async requireAdminAccess(userId: string, projectId: string): Promise<Membership> {
|
||||||
|
const membership = await this.getMembership(userId, projectId);
|
||||||
|
|
||||||
|
if (!membership) {
|
||||||
|
throw new HttpException(404, 'Project not found or you do not have access');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (membership.role !== 'ADMIN' && membership.role !== 'OWNER') {
|
||||||
|
throw new HttpException(403, 'Insufficient permissions. Admin or owner access required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return membership;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// MEMBER LISTING (Not Cached - Dynamic Data)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all members of a project with user info
|
||||||
|
* NOT CACHED - returns fresh data for member management UI
|
||||||
|
*/
|
||||||
|
public static async getMembers(projectId: string): Promise<MemberWithEmail[]> {
|
||||||
|
const memberships = await prisma.membership.findMany({
|
||||||
|
where: {
|
||||||
|
projectId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'asc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return memberships.map((m) => ({
|
||||||
|
userId: m.userId,
|
||||||
|
email: m.user.email,
|
||||||
|
role: m.role,
|
||||||
|
createdAt: m.createdAt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get project owner
|
||||||
|
* CACHED (5 min TTL) - owner rarely changes
|
||||||
|
*/
|
||||||
|
public static async getOwner(projectId: string): Promise<OwnerInfo> {
|
||||||
|
return wrapRedis(
|
||||||
|
Keys.Membership.owner(projectId),
|
||||||
|
async () => {
|
||||||
|
const ownerMembership = await prisma.membership.findFirst({
|
||||||
|
where: {
|
||||||
|
projectId,
|
||||||
|
role: 'OWNER',
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ownerMembership) {
|
||||||
|
throw new HttpException(404, 'Project owner not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: ownerMembership.userId,
|
||||||
|
email: ownerMembership.user.email,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
FIVE_MINUTES_IN_SECONDS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// CRUD OPERATIONS (Invalidate Cache)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a member to a project
|
||||||
|
* Invalidates cache for the project
|
||||||
|
*/
|
||||||
|
public static async addMember(
|
||||||
|
projectId: string,
|
||||||
|
userId: string,
|
||||||
|
role: 'ADMIN' | 'MEMBER',
|
||||||
|
): Promise<Membership> {
|
||||||
|
// Check if membership already exists
|
||||||
|
const existingMembership = await prisma.membership.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_projectId: {
|
||||||
|
userId,
|
||||||
|
projectId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingMembership) {
|
||||||
|
throw new HttpException(409, 'User is already a member of this project');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new membership
|
||||||
|
const newMembership = await prisma.membership.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
projectId,
|
||||||
|
role,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Invalidate cache
|
||||||
|
await this.invalidateCache(projectId, userId);
|
||||||
|
|
||||||
|
return newMembership;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a member's role
|
||||||
|
* Throws if trying to change OWNER role
|
||||||
|
* Invalidates cache
|
||||||
|
*/
|
||||||
|
public static async updateRole(
|
||||||
|
projectId: string,
|
||||||
|
userId: string,
|
||||||
|
newRole: 'ADMIN' | 'MEMBER',
|
||||||
|
): Promise<Membership> {
|
||||||
|
// Get existing membership
|
||||||
|
const existingMembership = await prisma.membership.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_projectId: {
|
||||||
|
userId,
|
||||||
|
projectId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingMembership) {
|
||||||
|
throw new HttpException(404, 'Membership not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent changing OWNER role
|
||||||
|
if (existingMembership.role === 'OWNER') {
|
||||||
|
throw new HttpException(403, 'Cannot change the role of the project owner');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update role
|
||||||
|
const updatedMembership = await prisma.membership.update({
|
||||||
|
where: {
|
||||||
|
userId_projectId: {
|
||||||
|
userId,
|
||||||
|
projectId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
role: newRole,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Invalidate cache
|
||||||
|
await this.invalidateCache(projectId, userId);
|
||||||
|
|
||||||
|
return updatedMembership;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a member from a project
|
||||||
|
* Throws if trying to remove OWNER
|
||||||
|
* Invalidates cache
|
||||||
|
*/
|
||||||
|
public static async removeMember(projectId: string, userId: string): Promise<void> {
|
||||||
|
// Get existing membership
|
||||||
|
const existingMembership = await prisma.membership.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_projectId: {
|
||||||
|
userId,
|
||||||
|
projectId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingMembership) {
|
||||||
|
throw new HttpException(404, 'Membership not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent removing OWNER
|
||||||
|
if (existingMembership.role === 'OWNER') {
|
||||||
|
throw new HttpException(403, 'Cannot remove the project owner');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete membership
|
||||||
|
await prisma.membership.delete({
|
||||||
|
where: {
|
||||||
|
userId_projectId: {
|
||||||
|
userId,
|
||||||
|
projectId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Invalidate cache
|
||||||
|
await this.invalidateCache(projectId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// UTILITY METHODS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user is member of any disabled project
|
||||||
|
* NOT CACHED - security-critical check
|
||||||
|
*/
|
||||||
|
public static async userHasDisabledProject(userId: string): Promise<DisabledProjectInfo> {
|
||||||
|
const disabledMemberships = await prisma.membership.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
project: {
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
project: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasDisabledProject: disabledMemberships.length > 0,
|
||||||
|
disabledProjectNames: disabledMemberships.map((m) => m.project.name),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// PRIVATE CACHE MANAGEMENT
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate all caches for a project and user
|
||||||
|
* Called after membership changes
|
||||||
|
*/
|
||||||
|
private static async invalidateCache(projectId: string, userId?: string): Promise<void> {
|
||||||
|
const keysToDelete: string[] = [];
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
// Invalidate user-specific caches
|
||||||
|
keysToDelete.push(
|
||||||
|
Keys.Membership.access(userId, projectId),
|
||||||
|
Keys.Membership.admin(userId, projectId),
|
||||||
|
Keys.Membership.full(userId, projectId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate project-wide caches
|
||||||
|
keysToDelete.push(Keys.Membership.owner(projectId));
|
||||||
|
|
||||||
|
// Delete all keys
|
||||||
|
if (keysToDelete.length > 0) {
|
||||||
|
await redis.del(...keysToDelete);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import signale from 'signale';
|
|||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {redis} from '../database/redis.js';
|
import {redis} from '../database/redis.js';
|
||||||
import {Keys} from './keys.js';
|
import {Keys} from './keys.js';
|
||||||
|
import {MembershipService} from './MembershipService.js';
|
||||||
import {NtfyService} from './NtfyService.js';
|
import {NtfyService} from './NtfyService.js';
|
||||||
import {QueueService} from './QueueService.js';
|
import {QueueService} from './QueueService.js';
|
||||||
import {AUTO_PROJECT_DISABLE, DASHBOARD_URI, LANDING_URI} from '../app/constants.js';
|
import {AUTO_PROJECT_DISABLE, DASHBOARD_URI, LANDING_URI} from '../app/constants.js';
|
||||||
@@ -177,26 +178,7 @@ export class SecurityService {
|
|||||||
hasDisabledProject: boolean;
|
hasDisabledProject: boolean;
|
||||||
disabledProjectNames: string[];
|
disabledProjectNames: string[];
|
||||||
}> {
|
}> {
|
||||||
const disabledMemberships = await prisma.membership.findMany({
|
return MembershipService.userHasDisabledProject(userId);
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
project: {
|
|
||||||
disabled: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
project: {
|
|
||||||
select: {
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
hasDisabledProject: disabledMemberships.length > 0,
|
|
||||||
disabledProjectNames: disabledMemberships.map(m => m.project.name),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -408,11 +390,8 @@ export class SecurityService {
|
|||||||
|
|
||||||
// Send email notification to project members
|
// Send email notification to project members
|
||||||
try {
|
try {
|
||||||
const members = await prisma.membership.findMany({
|
const members = await MembershipService.getMembers(projectId);
|
||||||
where: {projectId},
|
const emails = members.map(m => m.email);
|
||||||
include: {user: {select: {email: true}}},
|
|
||||||
});
|
|
||||||
const emails = members.map(m => m.user.email);
|
|
||||||
if (emails.length > 0) {
|
if (emails.length > 0) {
|
||||||
const template = React.createElement(ProjectDisabledEmail, {
|
const template = React.createElement(ProjectDisabledEmail, {
|
||||||
projectName: project.name,
|
projectName: project.name,
|
||||||
|
|||||||
@@ -70,4 +70,18 @@ export const Keys = {
|
|||||||
return `workflows:enabled:${projectId}`;
|
return `workflows:enabled:${projectId}`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
Membership: {
|
||||||
|
access(userId: string, projectId: string): string {
|
||||||
|
return `membership:access:${userId}:${projectId}`;
|
||||||
|
},
|
||||||
|
admin(userId: string, projectId: string): string {
|
||||||
|
return `membership:admin:${userId}:${projectId}`;
|
||||||
|
},
|
||||||
|
full(userId: string, projectId: string): string {
|
||||||
|
return `membership:full:${userId}:${projectId}`;
|
||||||
|
},
|
||||||
|
owner(projectId: string): string {
|
||||||
|
return `membership:owner:${projectId}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
Reference in New Issue
Block a user