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:
Jose Selesan
2026-04-22 15:30:04 -03:00
parent 5f2a7fd494
commit 6ad4fc9ee9
37 changed files with 1935 additions and 78 deletions

View File

@@ -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);
}

View File

@@ -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);