710 lines
22 KiB
TypeScript
710 lines
22 KiB
TypeScript
import {
|
|
HttpStatus,
|
|
Injectable,
|
|
} from '@nestjs/common';
|
|
import { AppException, ErrorCode } from '../../common/errors';
|
|
import * as bcrypt from 'bcrypt';
|
|
import { createHash, randomBytes } from 'crypto';
|
|
import { Prisma, UserNotificationType } from '@prisma/client';
|
|
import { PrismaService } from '../../../prisma/prisma.service';
|
|
import { AcceptStaffInviteDto } from './dto/accept-staff-invite.dto';
|
|
import { isUnlimitedSeats, normalizeTabPermissions } from '../../common/permissions';
|
|
import {
|
|
filterPermissionsForOrgType,
|
|
getOrganizationTypeName,
|
|
} from '../../common/organization-type';
|
|
import { InviteStaffDto } from './dto/invite-staff.dto';
|
|
import { UpdateStaffMemberDto } from './dto/update-staff-member.dto';
|
|
import { UserNotificationService } from '../notifications/user-notification.service';
|
|
|
|
@Injectable()
|
|
export class StaffService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly userNotifications: UserNotificationService,
|
|
) {}
|
|
|
|
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
|
if (!user?.organizationId) {
|
|
throw new AppException(ErrorCode.AUTH_ORG_NOT_SELECTED, HttpStatus.BAD_REQUEST);
|
|
}
|
|
return user.organizationId;
|
|
}
|
|
|
|
async list(userId: string, organizationId: string) {
|
|
const actor = await this.getActorMembership(userId, organizationId);
|
|
if (!actor || !this.canViewStaff(actor)) {
|
|
throw new AppException(ErrorCode.PERMISSION_ACCESS_STAFF, HttpStatus.FORBIDDEN);
|
|
}
|
|
|
|
const org = await this.prisma.organization.findUnique({
|
|
where: { id: organizationId },
|
|
include: { plan: true },
|
|
});
|
|
if (!org) {
|
|
throw new AppException(ErrorCode.PERMISSION_ORG_NOT_FOUND, HttpStatus.NOT_FOUND);
|
|
}
|
|
|
|
const [members, seatsUsed] = await Promise.all([
|
|
this.prisma.membership.findMany({
|
|
where: { organizationId },
|
|
include: {
|
|
user: { select: { id: true, email: true, name: true, passwordHash: true } },
|
|
permissions: { include: { permission: true } },
|
|
invitations: {
|
|
orderBy: { createdAt: 'desc' },
|
|
take: 1,
|
|
},
|
|
},
|
|
orderBy: [{ isOwner: 'desc' }, { createdAt: 'asc' }],
|
|
}),
|
|
this.prisma.membership.count({
|
|
where: {
|
|
organizationId,
|
|
OR: [{ isOwner: true }, { isActive: true }],
|
|
},
|
|
}),
|
|
]);
|
|
|
|
const maxUsers = org.plan?.maxUsers ?? 0;
|
|
const unlimited = isUnlimitedSeats(maxUsers);
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
members: members.map((m) => ({
|
|
id: m.id,
|
|
userId: m.user.id,
|
|
email: m.user.email,
|
|
name: m.user.name,
|
|
isOwner: m.isOwner,
|
|
isActive: m.isOwner ? true : m.isActive,
|
|
invitationStatus: this.getInvitationStatus(m),
|
|
hasPassword: Boolean(m.user.passwordHash),
|
|
invitedAt: m.invitations[0]?.createdAt?.toISOString() || null,
|
|
acceptedAt: m.invitations[0]?.acceptedAt?.toISOString() || null,
|
|
permissions: m.isOwner
|
|
? null
|
|
: m.permissions.map((p) => p.permission.name),
|
|
})),
|
|
seats: {
|
|
used: seatsUsed,
|
|
limit: unlimited ? null : maxUsers,
|
|
unlimited,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
async invite(userId: string, organizationId: string, dto: InviteStaffDto) {
|
|
const actor = await this.getActorMembership(userId, organizationId);
|
|
if (!actor || !this.canEditStaff(actor)) {
|
|
throw new AppException(ErrorCode.PERMISSION_EDIT_STAFF, HttpStatus.FORBIDDEN);
|
|
}
|
|
|
|
const email = dto.email.trim().toLowerCase();
|
|
const orgType = await getOrganizationTypeName(this.prisma, organizationId);
|
|
const normalizedPerms = filterPermissionsForOrgType(dto.permissionNames, orgType);
|
|
|
|
const permissionRows = await this.prisma.permission.findMany({
|
|
where: { name: { in: normalizedPerms } },
|
|
select: { id: true, name: true },
|
|
});
|
|
if (permissionRows.length !== normalizedPerms.length) {
|
|
const ok = new Set(permissionRows.map((p) => p.name));
|
|
const missing = normalizedPerms.filter((n) => !ok.has(n));
|
|
throw new AppException(ErrorCode.STAFF_UNKNOWN_PERMISSIONS, HttpStatus.BAD_REQUEST);
|
|
}
|
|
|
|
const plainToken = this.generateInviteToken();
|
|
const tokenHash = this.hashInviteToken(plainToken);
|
|
|
|
const result = await this.prisma.$transaction(async (tx) => {
|
|
const org = await tx.organization.findUnique({
|
|
where: { id: organizationId },
|
|
include: { plan: true },
|
|
});
|
|
if (!org) {
|
|
throw new AppException(ErrorCode.PERMISSION_ORG_NOT_FOUND, HttpStatus.NOT_FOUND);
|
|
}
|
|
|
|
if (!org.plan) {
|
|
throw new AppException(ErrorCode.STAFF_NO_SUBSCRIPTION, HttpStatus.BAD_REQUEST);
|
|
}
|
|
|
|
const maxUsers = org.plan.maxUsers;
|
|
const seatsUsed = await tx.membership.count({
|
|
where: {
|
|
organizationId,
|
|
OR: [{ isOwner: true }, { isActive: true }],
|
|
},
|
|
});
|
|
if (!isUnlimitedSeats(maxUsers) && seatsUsed >= maxUsers) {
|
|
throw new AppException(ErrorCode.STAFF_SEAT_LIMIT, HttpStatus.BAD_REQUEST);
|
|
}
|
|
|
|
const existingUser = await tx.user.findUnique({ where: { email } });
|
|
let targetUserId: string;
|
|
|
|
if (existingUser) {
|
|
if (existingUser.id === org.ownerId) {
|
|
throw new AppException(ErrorCode.STAFF_INVITE_OWNER_EMAIL, HttpStatus.BAD_REQUEST);
|
|
}
|
|
const dup = await tx.membership.findUnique({
|
|
where: {
|
|
userId_organizationId: {
|
|
userId: existingUser.id,
|
|
organizationId,
|
|
},
|
|
},
|
|
});
|
|
if (dup) {
|
|
throw new AppException(ErrorCode.STAFF_ALREADY_MEMBER, HttpStatus.CONFLICT);
|
|
}
|
|
targetUserId = existingUser.id;
|
|
} else {
|
|
const created = await tx.user.create({
|
|
data: {
|
|
email,
|
|
name: dto.name.trim(),
|
|
passwordHash: null,
|
|
},
|
|
});
|
|
targetUserId = created.id;
|
|
}
|
|
|
|
const membership = await tx.membership.create({
|
|
data: {
|
|
userId: targetUserId,
|
|
organizationId,
|
|
isOwner: false,
|
|
isActive: existingUser ? true : false,
|
|
},
|
|
});
|
|
|
|
if (permissionRows.length > 0) {
|
|
await tx.membershipPermission.createMany({
|
|
data: permissionRows.map((p) => ({
|
|
membershipId: membership.id,
|
|
permissionId: p.id,
|
|
})),
|
|
});
|
|
}
|
|
|
|
let inviteUrl: string | null = null;
|
|
let invitationId: string | null = null;
|
|
|
|
if (!existingUser) {
|
|
const invitation = await tx.staffInvitation.create({
|
|
data: {
|
|
membershipId: membership.id,
|
|
invitedById: userId,
|
|
tokenHash,
|
|
expiresAt: this.getInviteExpiryDate(),
|
|
},
|
|
});
|
|
invitationId = invitation.id;
|
|
inviteUrl = this.buildInviteUrl(plainToken);
|
|
}
|
|
|
|
return {
|
|
membershipId: membership.id,
|
|
userId: targetUserId,
|
|
invitationId,
|
|
inviteUrl,
|
|
isPending: !existingUser,
|
|
};
|
|
});
|
|
|
|
void this.userNotifications.notify({
|
|
organizationId,
|
|
type: UserNotificationType.STAFF_INVITE,
|
|
href: '/staff',
|
|
actorUserId: userId,
|
|
payload: {
|
|
membershipId: result.membershipId,
|
|
staffInvitationId: result.invitationId,
|
|
email,
|
|
},
|
|
requiredPermission: 'TAB_STAFF_READ',
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
membershipId: result.membershipId,
|
|
userId: result.userId,
|
|
email,
|
|
invitationId: result.invitationId,
|
|
invitationUrl: result.inviteUrl,
|
|
invitationStatus: result.isPending ? 'PENDING' : 'ACCEPTED',
|
|
},
|
|
};
|
|
}
|
|
|
|
async getInvitationLink(userId: string, organizationId: string, membershipId: string) {
|
|
const actor = await this.getActorMembership(userId, organizationId);
|
|
if (!actor || !this.canEditStaff(actor)) {
|
|
throw new AppException(ErrorCode.PERMISSION_EDIT_STAFF, HttpStatus.FORBIDDEN);
|
|
}
|
|
|
|
const membership = await this.prisma.membership.findFirst({
|
|
where: { id: membershipId, organizationId },
|
|
include: {
|
|
user: { select: { email: true } },
|
|
invitations: { orderBy: { createdAt: 'desc' }, take: 1 },
|
|
},
|
|
});
|
|
|
|
if (!membership) {
|
|
throw new AppException(ErrorCode.STAFF_MEMBER_NOT_FOUND, HttpStatus.NOT_FOUND);
|
|
}
|
|
if (membership.isOwner) {
|
|
throw new AppException(ErrorCode.STAFF_OWNER_NO_INVITE_LINK, HttpStatus.BAD_REQUEST);
|
|
}
|
|
if (membership.isActive) {
|
|
throw new AppException(ErrorCode.STAFF_ALREADY_ACCEPTED, HttpStatus.BAD_REQUEST);
|
|
}
|
|
|
|
const invitation = membership.invitations[0];
|
|
if (!invitation) {
|
|
throw new AppException(ErrorCode.STAFF_INVITE_MISSING, HttpStatus.BAD_REQUEST);
|
|
}
|
|
if (invitation.acceptedAt) {
|
|
throw new AppException(ErrorCode.STAFF_INVITE_ALREADY_ACCEPTED, HttpStatus.BAD_REQUEST);
|
|
}
|
|
if (invitation.revokedAt) {
|
|
throw new AppException(ErrorCode.STAFF_INVITE_INVALID, HttpStatus.BAD_REQUEST);
|
|
}
|
|
|
|
const plainToken = this.generateInviteToken();
|
|
const tokenHash = this.hashInviteToken(plainToken);
|
|
await this.prisma.staffInvitation.update({
|
|
where: { id: invitation.id },
|
|
data: {
|
|
tokenHash,
|
|
expiresAt: this.getInviteExpiryDate(),
|
|
},
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
membershipId: membership.id,
|
|
invitationId: invitation.id,
|
|
email: membership.user.email,
|
|
invitationUrl: this.buildInviteUrl(plainToken),
|
|
},
|
|
};
|
|
}
|
|
|
|
async previewInvite(token: string) {
|
|
const invitation = await this.findValidInvitation(token);
|
|
const org = invitation.membership.organization;
|
|
const user = invitation.membership.user;
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
email: user.email,
|
|
name: user.name,
|
|
organizationName: org.name,
|
|
expiresAt: invitation.expiresAt.toISOString(),
|
|
status: invitation.acceptedAt ? 'ACCEPTED' : 'PENDING',
|
|
mode: invitation.membership.isActive ? 'password_setup' : 'join',
|
|
},
|
|
};
|
|
}
|
|
|
|
async clearPassword(
|
|
actorUserId: string,
|
|
organizationId: string,
|
|
membershipId: string,
|
|
) {
|
|
const actor = await this.getActorMembership(actorUserId, organizationId);
|
|
if (!actor || !this.canEditStaff(actor)) {
|
|
throw new AppException(ErrorCode.PERMISSION_EDIT_STAFF, HttpStatus.FORBIDDEN);
|
|
}
|
|
|
|
const membership = await this.prisma.membership.findFirst({
|
|
where: { id: membershipId, organizationId },
|
|
include: {
|
|
user: { select: { id: true, email: true } },
|
|
},
|
|
});
|
|
|
|
if (!membership) {
|
|
throw new AppException(ErrorCode.STAFF_MEMBER_NOT_FOUND, HttpStatus.NOT_FOUND);
|
|
}
|
|
if (membership.isOwner) {
|
|
throw new AppException(ErrorCode.STAFF_CANNOT_EDIT_OWNER, HttpStatus.FORBIDDEN);
|
|
}
|
|
if (membership.userId === actorUserId) {
|
|
throw new AppException(ErrorCode.STAFF_CANNOT_CLEAR_OWN_PASSWORD, HttpStatus.BAD_REQUEST);
|
|
}
|
|
if (!membership.isActive) {
|
|
throw new AppException(ErrorCode.STAFF_PASSWORD_CLEAR_ACTIVE_ONLY, HttpStatus.BAD_REQUEST);
|
|
}
|
|
|
|
const plainToken = this.generateInviteToken();
|
|
const tokenHash = this.hashInviteToken(plainToken);
|
|
|
|
const invitation = await this.prisma.$transaction(async (tx) => {
|
|
await tx.user.update({
|
|
where: { id: membership.userId },
|
|
data: { passwordHash: null },
|
|
});
|
|
await tx.session.deleteMany({
|
|
where: { userId: membership.userId },
|
|
});
|
|
await tx.staffInvitation.updateMany({
|
|
where: {
|
|
membershipId: membership.id,
|
|
acceptedAt: null,
|
|
revokedAt: null,
|
|
},
|
|
data: { revokedAt: new Date() },
|
|
});
|
|
return tx.staffInvitation.create({
|
|
data: {
|
|
membershipId: membership.id,
|
|
invitedById: actorUserId,
|
|
tokenHash,
|
|
expiresAt: this.getInviteExpiryDate(),
|
|
},
|
|
});
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
membershipId: membership.id,
|
|
invitationId: invitation.id,
|
|
email: membership.user.email,
|
|
invitationUrl: this.buildInviteUrl(plainToken),
|
|
},
|
|
};
|
|
}
|
|
|
|
async acceptInvite(dto: AcceptStaffInviteDto) {
|
|
const invitation = await this.findValidInvitation(dto.token);
|
|
|
|
if (invitation.acceptedAt) {
|
|
throw new AppException(ErrorCode.STAFF_INVITE_ALREADY_ACCEPTED, HttpStatus.BAD_REQUEST);
|
|
}
|
|
|
|
const passwordHash = await bcrypt.hash(dto.password, 10);
|
|
const now = new Date();
|
|
|
|
await this.prisma.$transaction(async (tx) => {
|
|
await tx.user.update({
|
|
where: { id: invitation.membership.userId },
|
|
data: {
|
|
passwordHash,
|
|
name: dto.name.trim(),
|
|
},
|
|
});
|
|
|
|
await tx.membership.update({
|
|
where: { id: invitation.membershipId },
|
|
data: { isActive: true },
|
|
});
|
|
|
|
await tx.staffInvitation.update({
|
|
where: { id: invitation.id },
|
|
data: { acceptedAt: now },
|
|
});
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
email: invitation.membership.user.email,
|
|
},
|
|
message: 'Invitation accepted. You can now log in.',
|
|
};
|
|
}
|
|
|
|
async updateMember(
|
|
actorUserId: string,
|
|
organizationId: string,
|
|
membershipId: string,
|
|
dto: UpdateStaffMemberDto,
|
|
) {
|
|
const actor = await this.getActorMembership(actorUserId, organizationId);
|
|
if (!actor || !this.canEditStaff(actor)) {
|
|
throw new AppException(ErrorCode.PERMISSION_EDIT_STAFF, HttpStatus.FORBIDDEN);
|
|
}
|
|
|
|
const target = await this.prisma.membership.findFirst({
|
|
where: { id: membershipId, organizationId },
|
|
include: {
|
|
user: true,
|
|
permissions: { include: { permission: true } },
|
|
},
|
|
});
|
|
|
|
if (!target) {
|
|
throw new AppException(ErrorCode.STAFF_MEMBER_NOT_FOUND, HttpStatus.NOT_FOUND);
|
|
}
|
|
if (target.isOwner) {
|
|
throw new AppException(ErrorCode.STAFF_CANNOT_EDIT_OWNER, HttpStatus.FORBIDDEN);
|
|
}
|
|
|
|
if (dto.name !== undefined) {
|
|
await this.prisma.user.update({
|
|
where: { id: target.userId },
|
|
data: { name: dto.name.trim() },
|
|
});
|
|
}
|
|
|
|
if (dto.permissionNames !== undefined) {
|
|
const orgType = await getOrganizationTypeName(this.prisma, organizationId);
|
|
const normalizedPerms = filterPermissionsForOrgType(dto.permissionNames, orgType);
|
|
const permissionRows = await this.prisma.permission.findMany({
|
|
where: { name: { in: normalizedPerms } },
|
|
select: { id: true, name: true },
|
|
});
|
|
if (permissionRows.length !== normalizedPerms.length) {
|
|
const ok = new Set(permissionRows.map((p) => p.name));
|
|
const missing = normalizedPerms.filter((n) => !ok.has(n));
|
|
throw new AppException(ErrorCode.STAFF_UNKNOWN_PERMISSIONS, HttpStatus.BAD_REQUEST);
|
|
}
|
|
|
|
await this.prisma.$transaction([
|
|
this.prisma.membershipPermission.deleteMany({ where: { membershipId: target.id } }),
|
|
...(permissionRows.length
|
|
? [
|
|
this.prisma.membershipPermission.createMany({
|
|
data: permissionRows.map((p) => ({
|
|
membershipId: target.id,
|
|
permissionId: p.id,
|
|
})),
|
|
}),
|
|
]
|
|
: []),
|
|
]);
|
|
}
|
|
|
|
return { success: true, message: 'Member updated' };
|
|
}
|
|
|
|
async enableMember(actorUserId: string, organizationId: string, membershipId: string) {
|
|
const actor = await this.getActorMembership(actorUserId, organizationId);
|
|
if (!actor || !this.canEditStaff(actor)) {
|
|
throw new AppException(ErrorCode.PERMISSION_EDIT_STAFF, HttpStatus.FORBIDDEN);
|
|
}
|
|
|
|
const target = await this.prisma.membership.findFirst({
|
|
where: { id: membershipId, organizationId },
|
|
include: {
|
|
invitations: { orderBy: { createdAt: 'desc' }, take: 1 },
|
|
},
|
|
});
|
|
|
|
if (!target) {
|
|
throw new AppException(ErrorCode.STAFF_MEMBER_NOT_FOUND, HttpStatus.NOT_FOUND);
|
|
}
|
|
if (target.isOwner) {
|
|
throw new AppException(ErrorCode.STAFF_CANNOT_ENABLE_OWNER, HttpStatus.FORBIDDEN);
|
|
}
|
|
if (target.isActive) {
|
|
throw new AppException(ErrorCode.STAFF_ALREADY_ACTIVE, HttpStatus.BAD_REQUEST);
|
|
}
|
|
|
|
const invitation = target.invitations[0];
|
|
if (invitation && !invitation.acceptedAt) {
|
|
throw new AppException(ErrorCode.STAFF_INVITE_MISSING, HttpStatus.BAD_REQUEST);
|
|
}
|
|
|
|
await this.prisma.$transaction(async (tx) => {
|
|
await this.assertOrganizationHasAvailableSeat(organizationId, tx);
|
|
await tx.membership.update({
|
|
where: { id: membershipId },
|
|
data: { isActive: true },
|
|
});
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
message: 'Member enabled. They can sign in to this organization again.',
|
|
};
|
|
}
|
|
|
|
async disableMember(actorUserId: string, organizationId: string, membershipId: string) {
|
|
const actor = await this.getActorMembership(actorUserId, organizationId);
|
|
if (!actor || !this.canEditStaff(actor)) {
|
|
throw new AppException(ErrorCode.PERMISSION_EDIT_STAFF, HttpStatus.FORBIDDEN);
|
|
}
|
|
|
|
const target = await this.prisma.membership.findFirst({
|
|
where: { id: membershipId, organizationId },
|
|
});
|
|
|
|
if (!target) {
|
|
throw new AppException(ErrorCode.STAFF_MEMBER_NOT_FOUND, HttpStatus.NOT_FOUND);
|
|
}
|
|
if (target.isOwner) {
|
|
throw new AppException(ErrorCode.STAFF_CANNOT_DISABLE_OWNER, HttpStatus.FORBIDDEN);
|
|
}
|
|
if (actorUserId === target.userId) {
|
|
throw new AppException(ErrorCode.STAFF_CANNOT_DISABLE_SELF, HttpStatus.BAD_REQUEST);
|
|
}
|
|
if (!target.isActive) {
|
|
throw new AppException(ErrorCode.STAFF_ALREADY_DISABLED, HttpStatus.BAD_REQUEST);
|
|
}
|
|
|
|
await this.prisma.membership.update({
|
|
where: { id: membershipId },
|
|
data: { isActive: false },
|
|
});
|
|
|
|
await this.prisma.session.deleteMany({
|
|
where: { userId: target.userId },
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
message: 'Member disabled. Their seat is now available for another invite.',
|
|
};
|
|
}
|
|
|
|
async removeMember(actorUserId: string, organizationId: string, membershipId: string) {
|
|
const actor = await this.getActorMembership(actorUserId, organizationId);
|
|
if (!actor || !this.canEditStaff(actor)) {
|
|
throw new AppException(ErrorCode.PERMISSION_EDIT_STAFF, HttpStatus.FORBIDDEN);
|
|
}
|
|
|
|
const target = await this.prisma.membership.findFirst({
|
|
where: { id: membershipId, organizationId },
|
|
});
|
|
|
|
if (!target) {
|
|
throw new AppException(ErrorCode.STAFF_MEMBER_NOT_FOUND, HttpStatus.NOT_FOUND);
|
|
}
|
|
if (target.isOwner) {
|
|
throw new AppException(ErrorCode.STAFF_CANNOT_REMOVE_OWNER, HttpStatus.FORBIDDEN);
|
|
}
|
|
|
|
await this.prisma.membership.delete({ where: { id: membershipId } });
|
|
|
|
return { success: true, message: 'Member removed' };
|
|
}
|
|
|
|
private async assertOrganizationHasAvailableSeat(
|
|
organizationId: string,
|
|
db: Prisma.TransactionClient | PrismaService = this.prisma,
|
|
) {
|
|
const org = await db.organization.findUnique({
|
|
where: { id: organizationId },
|
|
include: { plan: true },
|
|
});
|
|
if (!org) {
|
|
throw new AppException(ErrorCode.PERMISSION_ORG_NOT_FOUND, HttpStatus.NOT_FOUND);
|
|
}
|
|
if (!org.plan) {
|
|
throw new AppException(ErrorCode.STAFF_NO_SUBSCRIPTION, HttpStatus.BAD_REQUEST);
|
|
}
|
|
|
|
const maxUsers = org.plan.maxUsers;
|
|
const seatsUsed = await db.membership.count({
|
|
where: {
|
|
organizationId,
|
|
OR: [{ isOwner: true }, { isActive: true }],
|
|
},
|
|
});
|
|
if (!isUnlimitedSeats(maxUsers) && seatsUsed >= maxUsers) {
|
|
throw new AppException(ErrorCode.STAFF_SEAT_LIMIT, HttpStatus.BAD_REQUEST);
|
|
}
|
|
}
|
|
|
|
private async getActorMembership(userId: string, organizationId: string) {
|
|
return this.prisma.membership.findFirst({
|
|
where: { userId, organizationId },
|
|
include: {
|
|
permissions: { include: { permission: true } },
|
|
organization: { select: { planId: true } },
|
|
},
|
|
});
|
|
}
|
|
|
|
private getInvitationStatus(m: {
|
|
isOwner: boolean;
|
|
isActive: boolean;
|
|
invitations: { acceptedAt: Date | null; revokedAt: Date | null; expiresAt: Date }[];
|
|
}): 'ACTIVE' | 'PENDING' | 'EXPIRED' | 'DISABLED' {
|
|
if (m.isOwner) return 'ACTIVE';
|
|
if (m.isActive) return 'ACTIVE';
|
|
const invitation = m.invitations[0];
|
|
if (invitation?.acceptedAt) return 'DISABLED';
|
|
if (!invitation) return 'DISABLED';
|
|
if (invitation.revokedAt) return 'EXPIRED';
|
|
return invitation.expiresAt.getTime() > Date.now() ? 'PENDING' : 'EXPIRED';
|
|
}
|
|
|
|
private generateInviteToken(): string {
|
|
return randomBytes(32).toString('hex');
|
|
}
|
|
|
|
private hashInviteToken(token: string): string {
|
|
return createHash('sha256').update(token).digest('hex');
|
|
}
|
|
|
|
private getInviteExpiryDate(): Date {
|
|
const d = new Date();
|
|
d.setDate(d.getDate() + 7);
|
|
return d;
|
|
}
|
|
|
|
private buildInviteUrl(token: string): string {
|
|
const appUrl = process.env.FRONTEND_URL || 'http://localhost:3001';
|
|
return `${appUrl}/accept-invite?token=${encodeURIComponent(token)}`;
|
|
}
|
|
|
|
private async findValidInvitation(token: string) {
|
|
const invitation = await this.prisma.staffInvitation.findUnique({
|
|
where: { tokenHash: this.hashInviteToken(token) },
|
|
include: {
|
|
membership: {
|
|
include: {
|
|
user: { select: { id: true, email: true, name: true } },
|
|
organization: { select: { id: true, name: true } },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!invitation) {
|
|
throw new AppException(ErrorCode.STAFF_INVITE_MISSING, HttpStatus.NOT_FOUND);
|
|
}
|
|
if (invitation.revokedAt) {
|
|
throw new AppException(ErrorCode.STAFF_INVITE_REVOKED, HttpStatus.BAD_REQUEST);
|
|
}
|
|
if (invitation.expiresAt.getTime() <= Date.now()) {
|
|
throw new AppException(ErrorCode.STAFF_INVITE_EXPIRED, HttpStatus.BAD_REQUEST);
|
|
}
|
|
return invitation;
|
|
}
|
|
|
|
private canViewStaff(m: {
|
|
isOwner: boolean;
|
|
organization?: { planId: string | null };
|
|
permissions: { permission: { name: string } }[];
|
|
}): boolean {
|
|
if (m.isOwner) return true;
|
|
return m.permissions.some(
|
|
(p) =>
|
|
p.permission.name === 'TAB_STAFF_READ' || p.permission.name === 'TAB_STAFF_EDIT',
|
|
);
|
|
}
|
|
|
|
private canEditStaff(m: {
|
|
isOwner: boolean;
|
|
organization?: { planId: string | null };
|
|
permissions: { permission: { name: string } }[];
|
|
}): boolean {
|
|
if (m.isOwner) return Boolean(m.organization?.planId);
|
|
return m.permissions.some((p) => p.permission.name === 'TAB_STAFF_EDIT');
|
|
}
|
|
}
|