From be2eb573692af9e9664d606c94f1fc88e4f3b7df Mon Sep 17 00:00:00 2001 From: Dries Augustyns Date: Wed, 31 Dec 2025 16:20:17 +0100 Subject: [PATCH] chore: Consolidate membership checks in single service --- apps/api/src/controllers/Domains.ts | 51 +-- apps/api/src/controllers/Projects.ts | 181 +-------- apps/api/src/controllers/Users.ts | 151 +------ apps/api/src/jobs/domain-verification.ts | 33 +- apps/api/src/middleware/auth.ts | 19 +- apps/api/src/services/BillingLimitService.ts | 15 +- apps/api/src/services/DomainService.ts | 33 +- apps/api/src/services/MembershipService.ts | 392 +++++++++++++++++++ apps/api/src/services/SecurityService.ts | 29 +- apps/api/src/services/keys.ts | 14 + 10 files changed, 484 insertions(+), 434 deletions(-) create mode 100644 apps/api/src/services/MembershipService.ts diff --git a/apps/api/src/controllers/Domains.ts b/apps/api/src/controllers/Domains.ts index 629d34e..c868992 100644 --- a/apps/api/src/controllers/Domains.ts +++ b/apps/api/src/controllers/Domains.ts @@ -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); diff --git a/apps/api/src/controllers/Projects.ts b/apps/api/src/controllers/Projects.ts index 06a8d6a..023e39e 100644 --- a/apps/api/src/controllers/Projects.ts +++ b/apps/api/src/controllers/Projects.ts @@ -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, diff --git a/apps/api/src/controllers/Users.ts b/apps/api/src/controllers/Users.ts index 5c00d7f..06b8e71 100644 --- a/apps/api/src/controllers/Users.ts +++ b/apps/api/src/controllers/Users.ts @@ -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({ diff --git a/apps/api/src/jobs/domain-verification.ts b/apps/api/src/jobs/domain-verification.ts index d718d7d..b3a2e13 100644 --- a/apps/api/src/jobs/domain-verification.ts +++ b/apps/api/src/jobs/domain-verification.ts @@ -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); diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts index 89fad80..8655910 100644 --- a/apps/api/src/middleware/auth.ts +++ b/apps/api/src/middleware/auth.ts @@ -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}, diff --git a/apps/api/src/services/BillingLimitService.ts b/apps/api/src/services/BillingLimitService.ts index 49f48aa..6155d5f 100644 --- a/apps/api/src/services/BillingLimitService.ts +++ b/apps/api/src/services/BillingLimitService.ts @@ -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; } diff --git a/apps/api/src/services/DomainService.ts b/apps/api/src/services/DomainService.ts index 7d1503c..2e54f2b 100644 --- a/apps/api/src/services/DomainService.ts +++ b/apps/api/src/services/DomainService.ts @@ -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); diff --git a/apps/api/src/services/MembershipService.ts b/apps/api/src/services/MembershipService.ts new file mode 100644 index 0000000..13b7f03 --- /dev/null +++ b/apps/api/src/services/MembershipService.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + // 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 { + // 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 { + // 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 { + 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 { + 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); + } + } +} diff --git a/apps/api/src/services/SecurityService.ts b/apps/api/src/services/SecurityService.ts index c852e84..027f416 100644 --- a/apps/api/src/services/SecurityService.ts +++ b/apps/api/src/services/SecurityService.ts @@ -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, diff --git a/apps/api/src/services/keys.ts b/apps/api/src/services/keys.ts index b4ac80a..39e0691 100644 --- a/apps/api/src/services/keys.ts +++ b/apps/api/src/services/keys.ts @@ -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;