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 {DomainService} from '../services/DomainService.js';
|
||||
import {Keys} from '../services/keys.js';
|
||||
import {MembershipService} from '../services/MembershipService.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
|
||||
@@ -24,16 +25,7 @@ export class Domains {
|
||||
const {projectId} = DomainSchemas.projectId.parse(req.params);
|
||||
|
||||
// Verify user has access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFound('Project not found or you do not have access');
|
||||
}
|
||||
await MembershipService.requireAccess(auth.userId!, projectId);
|
||||
|
||||
const domains = await DomainService.getProjectDomains(projectId);
|
||||
|
||||
@@ -55,19 +47,7 @@ export class Domains {
|
||||
}
|
||||
|
||||
// Verify user has admin access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId,
|
||||
role: {
|
||||
in: ['ADMIN', 'OWNER'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFound('Project not found or you do not have permission');
|
||||
}
|
||||
await MembershipService.requireAdminAccess(auth.userId!, projectId);
|
||||
|
||||
// Check if domain is already linked to another project
|
||||
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
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: domain.projectId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFound('Domain not found or you do not have access');
|
||||
}
|
||||
await MembershipService.requireAccess(auth.userId!, domain.projectId);
|
||||
|
||||
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
|
||||
const membership = await prisma.membership.findFirst({
|
||||
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 MembershipService.requireAdminAccess(auth.userId!, domain.projectId);
|
||||
|
||||
await DomainService.removeDomain(id);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {prisma} from '../database/prisma.js';
|
||||
import {HttpException} from '../exceptions/index.js';
|
||||
import type {AuthResponse} from '../middleware/auth.js';
|
||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {MembershipService} from '../services/MembershipService.js';
|
||||
import {SecurityService} from '../services/SecurityService.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
|
||||
@@ -23,16 +24,7 @@ export class Projects {
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
// Verify user has access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new HttpException(404, 'Project not found or you do not have access');
|
||||
}
|
||||
await MembershipService.requireAccess(auth.userId!, id);
|
||||
|
||||
// Get project with relevant data
|
||||
const project = await prisma.project.findUnique({
|
||||
@@ -96,16 +88,7 @@ export class Projects {
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
// Verify user has access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new HttpException(404, 'Project not found or you do not have access');
|
||||
}
|
||||
await MembershipService.requireAccess(auth.userId!, id);
|
||||
|
||||
// Use existing SecurityService
|
||||
const metrics = await SecurityService.getProjectSecurityMetrics(id);
|
||||
@@ -128,39 +111,14 @@ export class Projects {
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
// Verify user has access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new HttpException(404, 'Project not found or you do not have access');
|
||||
}
|
||||
await MembershipService.requireAccess(auth.userId!, id);
|
||||
|
||||
// Get all members of the project
|
||||
const members = await prisma.membership.findMany({
|
||||
where: {
|
||||
projectId: id,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const members = await MembershipService.getMembers(id);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: members.map(m => ({
|
||||
userId: m.user.id,
|
||||
email: m.user.email,
|
||||
role: m.role,
|
||||
})),
|
||||
data: members,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -190,19 +148,7 @@ export class Projects {
|
||||
const {email, role} = parseResult.data;
|
||||
|
||||
// Verify current user is ADMIN or OWNER
|
||||
const currentMembership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: id,
|
||||
role: {
|
||||
in: ['ADMIN', 'OWNER'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!currentMembership) {
|
||||
throw new HttpException(403, 'Only project admins and owners can add members');
|
||||
}
|
||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||
|
||||
// Find user by email
|
||||
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');
|
||||
}
|
||||
|
||||
// Check if user is already a member
|
||||
const existingMembership = await prisma.membership.findUnique({
|
||||
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,
|
||||
},
|
||||
});
|
||||
// Add member to project
|
||||
const newMembership = await MembershipService.addMember(id, userToAdd.id, role);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
@@ -276,38 +202,7 @@ export class Projects {
|
||||
const {role} = parseResult.data;
|
||||
|
||||
// Verify current user is ADMIN or OWNER
|
||||
const currentMembership = await prisma.membership.findFirst({
|
||||
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');
|
||||
}
|
||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||
|
||||
// Get user info
|
||||
const user = await prisma.user.findUnique({
|
||||
@@ -319,16 +214,8 @@ export class Projects {
|
||||
throw new HttpException(404, 'User not found');
|
||||
}
|
||||
|
||||
// Update role
|
||||
await prisma.membership.update({
|
||||
where: {
|
||||
userId_projectId: {
|
||||
userId,
|
||||
projectId: id,
|
||||
},
|
||||
},
|
||||
data: {role},
|
||||
});
|
||||
// Update role (service handles validation)
|
||||
await MembershipService.updateRole(id, userId, role);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
@@ -360,53 +247,15 @@ export class Projects {
|
||||
}
|
||||
|
||||
// Verify current user is ADMIN or OWNER
|
||||
const currentMembership = await prisma.membership.findFirst({
|
||||
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');
|
||||
}
|
||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||
|
||||
// Cannot remove yourself
|
||||
if (userId === auth.userId) {
|
||||
throw new HttpException(403, 'You cannot remove yourself from the project');
|
||||
}
|
||||
|
||||
// Delete membership
|
||||
await prisma.membership.delete({
|
||||
where: {
|
||||
userId_projectId: {
|
||||
userId,
|
||||
projectId: id,
|
||||
},
|
||||
},
|
||||
});
|
||||
// Remove member (service handles validation)
|
||||
await MembershipService.removeMember(id, userId);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {ErrorCode, HttpException, NotAuthenticated, NotFound} from '../exception
|
||||
import type {AuthResponse} from '../middleware/auth.js';
|
||||
import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {BillingLimitService} from '../services/BillingLimitService.js';
|
||||
import {MembershipService} from '../services/MembershipService.js';
|
||||
import {NtfyService} from '../services/NtfyService.js';
|
||||
import {SecurityService} from '../services/SecurityService.js';
|
||||
import {UserService} from '../services/UserService.js';
|
||||
@@ -108,20 +109,8 @@ export class Users {
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
const data = ProjectSchemas.update.parse(req.body);
|
||||
|
||||
// Verify user has access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
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');
|
||||
}
|
||||
// Verify user has admin/owner access to this project
|
||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||
|
||||
// Update the project
|
||||
const project = await prisma.project.update({
|
||||
@@ -140,19 +129,7 @@ export class Users {
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
// Verify user has admin/owner access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
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');
|
||||
}
|
||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||
|
||||
// Generate new unique API keys
|
||||
const publicKey = `pk_${randomBytes(32).toString('hex')}`;
|
||||
@@ -197,20 +174,8 @@ export class Users {
|
||||
return res.status(404).json({error: 'Billing is not enabled'});
|
||||
}
|
||||
|
||||
// Verify user has access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
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');
|
||||
}
|
||||
// Verify user has admin/owner access to this project
|
||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||
|
||||
// Get the project
|
||||
const project = await prisma.project.findUnique({
|
||||
@@ -288,20 +253,8 @@ export class Users {
|
||||
return res.status(404).json({error: 'Billing is not enabled'});
|
||||
}
|
||||
|
||||
// Verify user has access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
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');
|
||||
}
|
||||
// Verify user has admin/owner access to this project
|
||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||
|
||||
// Get the project
|
||||
const project = await prisma.project.findUnique({
|
||||
@@ -342,16 +295,7 @@ export class Users {
|
||||
}
|
||||
|
||||
// Verify user has access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFound('Project not found or you do not have permission to view billing limits');
|
||||
}
|
||||
await MembershipService.requireAccess(auth.userId!, id);
|
||||
|
||||
// Get billing limits and usage
|
||||
const limitsAndUsage = await BillingLimitService.getLimitsAndUsage(id);
|
||||
@@ -377,19 +321,7 @@ export class Users {
|
||||
const data = BillingLimitSchemas.update.parse(req.body);
|
||||
|
||||
// Verify user has admin/owner access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
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');
|
||||
}
|
||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||
|
||||
// Get the project with current limits
|
||||
const project = await prisma.project.findUnique({
|
||||
@@ -459,16 +391,7 @@ export class Users {
|
||||
}
|
||||
|
||||
// Verify user has access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFound('Project not found or you do not have permission to view billing');
|
||||
}
|
||||
await MembershipService.requireAccess(auth.userId!, id);
|
||||
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {id},
|
||||
@@ -585,16 +508,7 @@ export class Users {
|
||||
}
|
||||
|
||||
// Verify user has access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFound('Project not found or you do not have permission to view billing');
|
||||
}
|
||||
await MembershipService.requireAccess(auth.userId!, id);
|
||||
|
||||
// Get the project
|
||||
const project = await prisma.project.findUnique({
|
||||
@@ -668,16 +582,7 @@ export class Users {
|
||||
}
|
||||
|
||||
// Verify user has access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFound('Project not found or you do not have permission to view security metrics');
|
||||
}
|
||||
await MembershipService.requireAccess(auth.userId!, id);
|
||||
|
||||
// Get security metrics
|
||||
const metrics = await SecurityService.getProjectSecurityMetrics(id);
|
||||
@@ -701,19 +606,7 @@ export class Users {
|
||||
}
|
||||
|
||||
// Verify user has admin/owner access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
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');
|
||||
}
|
||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||
|
||||
// Check if project is disabled - block reset operation
|
||||
const isDisabled = await SecurityService.isProjectDisabled(id);
|
||||
@@ -787,21 +680,7 @@ export class Users {
|
||||
}
|
||||
|
||||
// Verify user has owner or admin access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
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.',
|
||||
);
|
||||
}
|
||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||
|
||||
// Get project to check for active subscription and disabled status
|
||||
const project = await prisma.project.findUnique({
|
||||
|
||||
@@ -8,11 +8,12 @@
|
||||
|
||||
import React from 'react';
|
||||
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 {redis} from '../database/redis.js';
|
||||
import {MembershipService} from '../services/MembershipService.js';
|
||||
import {disableFeedbackForwarding, getIdentities, verifyDomain} from '../services/SESService.js';
|
||||
import {Keys} from '../services/keys.js';
|
||||
|
||||
@@ -75,7 +76,11 @@ export async function checkDomainVerifications() {
|
||||
signale.success(`[DOMAIN-VERIFICATION] Restarted verification for ${sesIdentity.domain}`);
|
||||
} catch (e: unknown) {
|
||||
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(
|
||||
`[DOMAIN-VERIFICATION] Throttling detected, waiting ${delay / 1000} seconds (attempt ${attempt + 1})`,
|
||||
);
|
||||
@@ -83,7 +88,9 @@ export async function checkDomainVerifications() {
|
||||
delay *= 2; // Exponential backoff
|
||||
attempt++;
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
@@ -118,11 +125,8 @@ export async function checkDomainVerifications() {
|
||||
const cacheKey = Keys.Domain.verifiedEmail(dbDomain.id);
|
||||
const alreadySent = await redis.get(cacheKey);
|
||||
if (alreadySent !== '1') {
|
||||
const members = await prisma.membership.findMany({
|
||||
where: {projectId: dbDomain.projectId},
|
||||
include: {user: {select: {email: true}}},
|
||||
});
|
||||
const emails = members.map((m) => m.user.email);
|
||||
const members = await MembershipService.getMembers(dbDomain.projectId);
|
||||
const emails = members.map(m => m.email);
|
||||
if (emails.length > 0) {
|
||||
const template = React.createElement(DomainVerifiedEmail, {
|
||||
projectName: dbDomain.project.name,
|
||||
@@ -132,7 +136,7 @@ export async function checkDomainVerifications() {
|
||||
landingUrl: LANDING_URI,
|
||||
});
|
||||
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
|
||||
}
|
||||
@@ -158,11 +162,8 @@ export async function checkDomainVerifications() {
|
||||
const cacheKey = Keys.Domain.unverifiedEmail(dbDomain.id, year, month);
|
||||
const alreadySent = await redis.get(cacheKey);
|
||||
if (alreadySent !== '1') {
|
||||
const members = await prisma.membership.findMany({
|
||||
where: {projectId: dbDomain.projectId},
|
||||
include: {user: {select: {email: true}}},
|
||||
});
|
||||
const emails = members.map((m) => m.user.email);
|
||||
const members = await MembershipService.getMembers(dbDomain.projectId);
|
||||
const emails = members.map(m => m.email);
|
||||
if (emails.length > 0) {
|
||||
const template = React.createElement(DomainUnverifiedEmail, {
|
||||
projectName: dbDomain.project.name,
|
||||
@@ -172,7 +173,7 @@ export async function checkDomainVerifications() {
|
||||
landingUrl: LANDING_URI,
|
||||
});
|
||||
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 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 {prisma} from '../database/prisma.js';
|
||||
import {ErrorCode, HttpException, NotAuthenticated} from '../exceptions/index.js';
|
||||
import {MembershipService} from '../services/MembershipService.js';
|
||||
|
||||
export interface AuthResponse {
|
||||
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
|
||||
const [membership, project] = await Promise.all([
|
||||
prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_projectId: {
|
||||
userId,
|
||||
projectId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
MembershipService.getMembership(userId, projectId),
|
||||
prisma.project.findUnique({
|
||||
where: {id: projectId},
|
||||
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
|
||||
const [membership, project] = await Promise.all([
|
||||
prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_projectId: {
|
||||
userId,
|
||||
projectId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
MembershipService.getMembership(userId, projectId),
|
||||
prisma.project.findUnique({
|
||||
where: {id: projectId},
|
||||
select: {disabled: true},
|
||||
|
||||
@@ -8,6 +8,7 @@ import {stripe} from '../app/stripe.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {redis} from '../database/redis.js';
|
||||
import {Keys} from './keys.js';
|
||||
import {MembershipService} from './MembershipService.js';
|
||||
import {NtfyService} from './NtfyService.js';
|
||||
|
||||
/**
|
||||
@@ -616,11 +617,8 @@ export class BillingLimitService {
|
||||
return;
|
||||
}
|
||||
|
||||
const members = await prisma.membership.findMany({
|
||||
where: {projectId},
|
||||
include: {user: {select: {email: true}}},
|
||||
});
|
||||
const emails = members.map(m => m.user.email);
|
||||
const members = await MembershipService.getMembers(projectId);
|
||||
const emails = members.map(m => m.email);
|
||||
if (emails.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -669,11 +667,8 @@ export class BillingLimitService {
|
||||
return;
|
||||
}
|
||||
|
||||
const members = await prisma.membership.findMany({
|
||||
where: {projectId},
|
||||
include: {user: {select: {email: true}}},
|
||||
});
|
||||
const emails = members.map(m => m.user.email);
|
||||
const members = await MembershipService.getMembers(projectId);
|
||||
const emails = members.map(m => m.email);
|
||||
if (emails.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React from 'react';
|
||||
import signale from 'signale';
|
||||
import {DomainVerifiedEmail, DomainUnverifiedEmail, sendPlatformEmail} from '@plunk/email';
|
||||
import {DASHBOARD_URI, LANDING_URI} from '../constants.js';
|
||||
import {DomainUnverifiedEmail, DomainVerifiedEmail, sendPlatformEmail} from '@plunk/email';
|
||||
import {DASHBOARD_URI, LANDING_URI} from '../app/constants.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {redis, wrapRedis} from '../database/redis.js';
|
||||
import {HttpException} from '../exceptions/index.js';
|
||||
import {Keys} from './keys.js';
|
||||
import {MembershipService} from './MembershipService.js';
|
||||
import {NtfyService} from './NtfyService.js';
|
||||
import {getDomainVerificationAttributes, verifyDomain} from './SESService.js';
|
||||
|
||||
@@ -92,11 +93,8 @@ export class DomainService {
|
||||
const cacheKey = Keys.Domain.verifiedEmail(domainId);
|
||||
const alreadySent = await redis.get(cacheKey);
|
||||
if (alreadySent !== '1') {
|
||||
const members = await prisma.membership.findMany({
|
||||
where: {projectId: updatedDomain.project.id},
|
||||
include: {user: {select: {email: true}}},
|
||||
});
|
||||
const emails = members.map((m) => m.user.email);
|
||||
const members = await MembershipService.getMembers(updatedDomain.project.id);
|
||||
const emails = members.map(m => m.email);
|
||||
if (emails.length > 0) {
|
||||
const template = React.createElement(DomainVerifiedEmail, {
|
||||
projectName: updatedDomain.project.name,
|
||||
@@ -105,9 +103,7 @@ export class DomainService {
|
||||
dashboardUrl: DASHBOARD_URI,
|
||||
landingUrl: LANDING_URI,
|
||||
});
|
||||
await Promise.all(
|
||||
emails.map((email) => sendPlatformEmail(email, 'Domain Verified Successfully', template)),
|
||||
);
|
||||
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Domain Verified Successfully', template)));
|
||||
// Set cache to prevent duplicate emails (7 days)
|
||||
await redis.setex(cacheKey, 604800, '1');
|
||||
}
|
||||
@@ -127,7 +123,11 @@ export class DomainService {
|
||||
});
|
||||
|
||||
// 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
|
||||
try {
|
||||
@@ -138,11 +138,8 @@ export class DomainService {
|
||||
const cacheKey = Keys.Domain.unverifiedEmail(domainId, year, month);
|
||||
const alreadySent = await redis.get(cacheKey);
|
||||
if (alreadySent !== '1') {
|
||||
const members = await prisma.membership.findMany({
|
||||
where: {projectId: updatedDomain.project.id},
|
||||
include: {user: {select: {email: true}}},
|
||||
});
|
||||
const emails = members.map((m) => m.user.email);
|
||||
const members = await MembershipService.getMembers(updatedDomain.project.id);
|
||||
const emails = members.map(m => m.email);
|
||||
if (emails.length > 0) {
|
||||
const template = React.createElement(DomainUnverifiedEmail, {
|
||||
projectName: updatedDomain.project.name,
|
||||
@@ -151,9 +148,7 @@ export class DomainService {
|
||||
dashboardUrl: DASHBOARD_URI,
|
||||
landingUrl: LANDING_URI,
|
||||
});
|
||||
await Promise.all(
|
||||
emails.map((email) => sendPlatformEmail(email, 'Domain Verification Failed', template)),
|
||||
);
|
||||
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Domain Verification Failed', template)));
|
||||
// Set cache to prevent duplicate emails (until end of month)
|
||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
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 {redis} from '../database/redis.js';
|
||||
import {Keys} from './keys.js';
|
||||
import {MembershipService} from './MembershipService.js';
|
||||
import {NtfyService} from './NtfyService.js';
|
||||
import {QueueService} from './QueueService.js';
|
||||
import {AUTO_PROJECT_DISABLE, DASHBOARD_URI, LANDING_URI} from '../app/constants.js';
|
||||
@@ -177,26 +178,7 @@ export class SecurityService {
|
||||
hasDisabledProject: boolean;
|
||||
disabledProjectNames: string[];
|
||||
}> {
|
||||
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),
|
||||
};
|
||||
return MembershipService.userHasDisabledProject(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -408,11 +390,8 @@ export class SecurityService {
|
||||
|
||||
// Send email notification to project members
|
||||
try {
|
||||
const members = await prisma.membership.findMany({
|
||||
where: {projectId},
|
||||
include: {user: {select: {email: true}}},
|
||||
});
|
||||
const emails = members.map(m => m.user.email);
|
||||
const members = await MembershipService.getMembers(projectId);
|
||||
const emails = members.map(m => m.email);
|
||||
if (emails.length > 0) {
|
||||
const template = React.createElement(ProjectDisabledEmail, {
|
||||
projectName: project.name,
|
||||
|
||||
@@ -70,4 +70,18 @@ export const Keys = {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user