feat: add complex user invitation and management features
- Implemented complex member and invitation schemas using Zod for validation. - Created new API endpoints for inviting, accepting, revoking, and canceling complex user invitations. - Developed handlers for managing complex users, including listing, inviting, revoking, and canceling invitations. - Added frontend components for displaying and managing complex users and invitations. - Introduced session storage management for pending invitations. - Integrated Gravatar for user avatars based on email. - Created database migration for complex invitations table.
This commit is contained in:
@@ -0,0 +1,525 @@
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { resolveAvatarUrl } from '@/lib/avatar';
|
||||
import { sendMail } from '@/lib/mailer';
|
||||
import { db } from '@/lib/prisma';
|
||||
import type {
|
||||
AcceptComplexInvitationsInput,
|
||||
AcceptComplexInvitationsResponse,
|
||||
CancelComplexInvitationResponse,
|
||||
ComplexInvitation,
|
||||
ComplexInvitationStatus,
|
||||
ComplexUsersOverview,
|
||||
InviteComplexUserInput,
|
||||
InviteComplexUserResponse,
|
||||
RevokeComplexUserInput,
|
||||
} from '@repo/api-contract';
|
||||
import { v7 as uuidv7 } from 'uuid';
|
||||
|
||||
const INVITATION_TTL_DAYS = Number(Bun.env.COMPLEX_INVITATION_TTL_DAYS ?? 7);
|
||||
const APP_BASE_URL = Bun.env.APP_BASE_URL ?? 'http://localhost:5173';
|
||||
|
||||
export class ComplexMembersError extends Error {
|
||||
status: 400 | 403 | 404 | 409;
|
||||
|
||||
constructor(message: string, status: 400 | 403 | 404 | 409 = 400) {
|
||||
super(message);
|
||||
this.name = 'ComplexMembersError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeEmail(email: string): string {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
function createInvitationToken(): string {
|
||||
return randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
function nowPlusDays(days: number): Date {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + days);
|
||||
return date;
|
||||
}
|
||||
|
||||
function getInvitationStatus(invitation: {
|
||||
expiresAt: Date;
|
||||
acceptedAt: Date | null;
|
||||
revokedAt: Date | null;
|
||||
}): ComplexInvitationStatus {
|
||||
if (invitation.acceptedAt) return 'ACCEPTED';
|
||||
if (invitation.revokedAt) return 'REVOKED';
|
||||
if (invitation.expiresAt < new Date()) return 'EXPIRED';
|
||||
return 'PENDING';
|
||||
}
|
||||
|
||||
function formatInvitation(invitation: {
|
||||
id: string;
|
||||
complexId: string;
|
||||
email: string;
|
||||
expiresAt: Date;
|
||||
acceptedAt: Date | null;
|
||||
revokedAt: Date | null;
|
||||
createdAt: Date;
|
||||
}): ComplexInvitation {
|
||||
return {
|
||||
id: invitation.id,
|
||||
complexId: invitation.complexId,
|
||||
email: invitation.email,
|
||||
status: getInvitationStatus(invitation),
|
||||
createdAt: invitation.createdAt.toISOString(),
|
||||
expiresAt: invitation.expiresAt.toISOString(),
|
||||
acceptedAt: invitation.acceptedAt?.toISOString() ?? null,
|
||||
revokedAt: invitation.revokedAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureComplexAdmin(userId: string, complexId: string) {
|
||||
const membership = await db.complexUser.findUnique({
|
||||
where: {
|
||||
complexId_userId: {
|
||||
complexId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new ComplexMembersError('No tenés acceso a este complejo.', 403);
|
||||
}
|
||||
|
||||
if (membership.role !== 'ADMIN') {
|
||||
throw new ComplexMembersError('Solo un ADMIN puede administrar usuarios.', 403);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureComplexMember(userId: string, complexId: string) {
|
||||
const membership = await db.complexUser.findUnique({
|
||||
where: {
|
||||
complexId_userId: {
|
||||
complexId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new ComplexMembersError('No tenés acceso a este complejo.', 403);
|
||||
}
|
||||
|
||||
return membership;
|
||||
}
|
||||
|
||||
async function sendInvitationEmail(input: {
|
||||
complexName: string;
|
||||
inviteeEmail: string;
|
||||
token: string;
|
||||
}) {
|
||||
const inviteUrl = new URL('/invite', APP_BASE_URL);
|
||||
inviteUrl.searchParams.set('inviteToken', input.token);
|
||||
inviteUrl.searchParams.set('email', input.inviteeEmail);
|
||||
|
||||
await sendMail({
|
||||
to: input.inviteeEmail,
|
||||
subject: `Te invitaron a ${input.complexName}`,
|
||||
text: `Te invitaron a sumarte a ${input.complexName}. Abrí este enlace para crear tu cuenta o iniciar sesión: ${inviteUrl.toString()}`,
|
||||
html: `
|
||||
<h2>Te invitaron a <strong>${input.complexName}</strong></h2>
|
||||
<p>Hacé click en el siguiente enlace para crear tu cuenta o iniciar sesión y aceptar la invitación:</p>
|
||||
<p><a href="${inviteUrl.toString()}">${inviteUrl.toString()}</a></p>
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshPendingInvitation(invitationId: string) {
|
||||
const token = createInvitationToken();
|
||||
const tokenHash = hashToken(token);
|
||||
const expiresAt = nowPlusDays(INVITATION_TTL_DAYS);
|
||||
|
||||
const invitation = await db.complexInvitation.update({
|
||||
where: {
|
||||
id: invitationId,
|
||||
},
|
||||
data: {
|
||||
tokenHash,
|
||||
expiresAt,
|
||||
acceptedAt: null,
|
||||
revokedAt: null,
|
||||
},
|
||||
include: {
|
||||
complex: {
|
||||
select: {
|
||||
complexName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
invitation,
|
||||
token,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listComplexUsers(complexId: string) {
|
||||
const [members, invitations] = await Promise.all([
|
||||
db.complexUser.findMany({
|
||||
where: { complexId },
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
}),
|
||||
db.complexInvitation.findMany({
|
||||
where: {
|
||||
complexId,
|
||||
acceptedAt: null,
|
||||
revokedAt: null,
|
||||
expiresAt: {
|
||||
gte: new Date(),
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const membersWithAvatars = members.map((member) => ({
|
||||
userId: member.userId,
|
||||
fullName: member.user.name,
|
||||
email: member.user.email,
|
||||
avatarUrl: resolveAvatarUrl({
|
||||
email: member.user.email,
|
||||
image: member.user.image,
|
||||
}),
|
||||
role: member.role,
|
||||
createdAt: member.createdAt.toISOString(),
|
||||
}));
|
||||
|
||||
return {
|
||||
members: membersWithAvatars,
|
||||
invitations: invitations.map(formatInvitation),
|
||||
} satisfies ComplexUsersOverview;
|
||||
}
|
||||
|
||||
export async function inviteComplexUser(
|
||||
userId: string,
|
||||
complexId: string,
|
||||
input: InviteComplexUserInput
|
||||
): Promise<InviteComplexUserResponse> {
|
||||
await ensureComplexAdmin(userId, complexId);
|
||||
|
||||
const email = normalizeEmail(input.email);
|
||||
const complex = await db.complex.findUnique({
|
||||
where: { id: complexId },
|
||||
select: {
|
||||
complexName: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!complex) {
|
||||
throw new ComplexMembersError('El complejo no existe.', 404);
|
||||
}
|
||||
|
||||
const existingUser = await db.user.findUnique({
|
||||
where: { email },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
const existingMembership = await db.complexUser.findUnique({
|
||||
where: {
|
||||
complexId_userId: {
|
||||
complexId,
|
||||
userId: existingUser.id,
|
||||
},
|
||||
},
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
if (existingMembership) {
|
||||
throw new ComplexMembersError('Ese usuario ya pertenece al complejo.', 409);
|
||||
}
|
||||
}
|
||||
|
||||
const token = createInvitationToken();
|
||||
|
||||
const invitation = await db.$transaction(async (tx) => {
|
||||
const pendingInvitation = await tx.complexInvitation.findFirst({
|
||||
where: {
|
||||
complexId,
|
||||
email,
|
||||
acceptedAt: null,
|
||||
revokedAt: null,
|
||||
expiresAt: {
|
||||
gte: new Date(),
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
if (pendingInvitation) {
|
||||
const refreshed = await tx.complexInvitation.update({
|
||||
where: {
|
||||
id: pendingInvitation.id,
|
||||
},
|
||||
data: {
|
||||
tokenHash: hashToken(token),
|
||||
expiresAt: nowPlusDays(INVITATION_TTL_DAYS),
|
||||
acceptedAt: null,
|
||||
revokedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
return refreshed;
|
||||
}
|
||||
|
||||
return tx.complexInvitation.create({
|
||||
data: {
|
||||
id: uuidv7(),
|
||||
complexId,
|
||||
email,
|
||||
tokenHash: hashToken(token),
|
||||
expiresAt: nowPlusDays(INVITATION_TTL_DAYS),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await sendInvitationEmail({
|
||||
complexName: complex.complexName,
|
||||
inviteeEmail: email,
|
||||
token,
|
||||
});
|
||||
|
||||
return {
|
||||
invitation: formatInvitation(invitation),
|
||||
};
|
||||
}
|
||||
|
||||
export async function resendComplexInvitation(
|
||||
userId: string,
|
||||
complexId: string,
|
||||
invitationId: string
|
||||
): Promise<InviteComplexUserResponse> {
|
||||
await ensureComplexAdmin(userId, complexId);
|
||||
|
||||
const pendingInvitation = await db.complexInvitation.findUnique({
|
||||
where: {
|
||||
id: invitationId,
|
||||
},
|
||||
include: {
|
||||
complex: {
|
||||
select: {
|
||||
complexName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!pendingInvitation || pendingInvitation.complexId !== complexId) {
|
||||
throw new ComplexMembersError('La invitación no existe.', 404);
|
||||
}
|
||||
|
||||
if (pendingInvitation.acceptedAt) {
|
||||
throw new ComplexMembersError('La invitación ya fue aceptada.', 409);
|
||||
}
|
||||
|
||||
if (pendingInvitation.revokedAt) {
|
||||
throw new ComplexMembersError('La invitación fue cancelada.', 409);
|
||||
}
|
||||
|
||||
const refreshed = await refreshPendingInvitation(invitationId);
|
||||
|
||||
await sendInvitationEmail({
|
||||
complexName: refreshed.invitation.complex.complexName,
|
||||
inviteeEmail: pendingInvitation.email,
|
||||
token: refreshed.token,
|
||||
});
|
||||
|
||||
return {
|
||||
invitation: formatInvitation({
|
||||
id: refreshed.invitation.id,
|
||||
complexId: refreshed.invitation.complexId,
|
||||
email: refreshed.invitation.email,
|
||||
expiresAt: refreshed.invitation.expiresAt,
|
||||
acceptedAt: refreshed.invitation.acceptedAt,
|
||||
revokedAt: refreshed.invitation.revokedAt,
|
||||
createdAt: refreshed.invitation.createdAt,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function acceptComplexInvitations(
|
||||
userId: string,
|
||||
email: string,
|
||||
input: AcceptComplexInvitationsInput = {}
|
||||
): Promise<AcceptComplexInvitationsResponse> {
|
||||
const normalizedEmail = normalizeEmail(email);
|
||||
const tokenHash = input.inviteToken ? hashToken(input.inviteToken) : null;
|
||||
|
||||
const invitations = await db.complexInvitation.findMany({
|
||||
where: {
|
||||
email: normalizedEmail,
|
||||
acceptedAt: null,
|
||||
revokedAt: null,
|
||||
expiresAt: {
|
||||
gte: new Date(),
|
||||
},
|
||||
...(tokenHash
|
||||
? {
|
||||
tokenHash,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
if (tokenHash && invitations.length === 0) {
|
||||
throw new ComplexMembersError('La invitación es inválida o ya venció.', 404);
|
||||
}
|
||||
|
||||
let acceptedCount = 0;
|
||||
|
||||
for (const invitation of invitations) {
|
||||
await db.$transaction(async (tx) => {
|
||||
const existingMembership = await tx.complexUser.findUnique({
|
||||
where: {
|
||||
complexId_userId: {
|
||||
complexId: invitation.complexId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingMembership) {
|
||||
await tx.complexUser.create({
|
||||
data: {
|
||||
complexId: invitation.complexId,
|
||||
userId,
|
||||
role: 'EMPLOYEE',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await tx.complexInvitation.update({
|
||||
where: {
|
||||
id: invitation.id,
|
||||
},
|
||||
data: {
|
||||
acceptedAt: new Date(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
acceptedCount += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
acceptedCount,
|
||||
};
|
||||
}
|
||||
|
||||
export async function revokeComplexUser(
|
||||
userId: string,
|
||||
complexId: string,
|
||||
input: RevokeComplexUserInput
|
||||
) {
|
||||
await ensureComplexAdmin(userId, complexId);
|
||||
|
||||
const membership = await db.complexUser.findUnique({
|
||||
where: {
|
||||
complexId_userId: {
|
||||
complexId,
|
||||
userId: input.userId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new ComplexMembersError('Ese usuario no pertenece al complejo.', 404);
|
||||
}
|
||||
|
||||
if (membership.role !== 'EMPLOYEE') {
|
||||
throw new ComplexMembersError('Solo podés revocar usuarios con rol EMPLOYEE.', 409);
|
||||
}
|
||||
|
||||
await db.complexUser.delete({
|
||||
where: {
|
||||
complexId_userId: {
|
||||
complexId,
|
||||
userId: input.userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
};
|
||||
}
|
||||
|
||||
export async function cancelComplexInvitation(
|
||||
userId: string,
|
||||
complexId: string,
|
||||
invitationId: string
|
||||
): Promise<CancelComplexInvitationResponse> {
|
||||
await ensureComplexAdmin(userId, complexId);
|
||||
|
||||
const invitation = await db.complexInvitation.findUnique({
|
||||
where: {
|
||||
id: invitationId,
|
||||
},
|
||||
select: {
|
||||
complexId: true,
|
||||
acceptedAt: true,
|
||||
revokedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!invitation || invitation.complexId !== complexId) {
|
||||
throw new ComplexMembersError('La invitación no existe.', 404);
|
||||
}
|
||||
|
||||
if (invitation.acceptedAt) {
|
||||
throw new ComplexMembersError('La invitación ya fue aceptada.', 409);
|
||||
}
|
||||
|
||||
if (invitation.revokedAt) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
await db.complexInvitation.update({
|
||||
where: {
|
||||
id: invitationId,
|
||||
},
|
||||
data: {
|
||||
revokedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function getComplexUsersOverview(userId: string, complexId: string) {
|
||||
await ensureComplexMember(userId, complexId);
|
||||
return listComplexUsers(complexId);
|
||||
}
|
||||
@@ -65,7 +65,7 @@ async function buildUniqueSlug(source: string, excludeComplexId?: string): Promi
|
||||
}
|
||||
|
||||
export async function createComplex(input: CreateComplexInput) {
|
||||
const { userId, ...rest } = input;
|
||||
const { userId } = input;
|
||||
|
||||
return db.$transaction(async (tx) => {
|
||||
const complexSlug = await buildUniqueSlug(input.complexName);
|
||||
|
||||
Reference in New Issue
Block a user