369 lines
9.3 KiB
TypeScript
369 lines
9.3 KiB
TypeScript
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<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);
|
|
}
|
|
}
|
|
}
|