import type {Membership} from '@plunk/db'; import type {DisabledProjectInfo, MemberWithEmail, OwnerInfo} from '@plunk/types'; 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; /** * 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); } } }