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

@@ -1,14 +1,25 @@
import { requireAuth } from '@/middlewares/require-auth.middleware';
import { acceptComplexInvitationsHandler } from '@/modules/complex/handlers/accept-complex-invitations.handler';
import { cancelComplexInvitationHandler } from '@/modules/complex/handlers/cancel-complex-invitation.handler';
import { createComplexHandler } from '@/modules/complex/handlers/create-complex.handler';
import { getComplexByIdHandler } from '@/modules/complex/handlers/get-complex-by-id.handler';
import { getComplexBySlugHandler } from '@/modules/complex/handlers/get-complex-by-slug.handler';
import { getCurrentComplexHandler } from '@/modules/complex/handlers/get-current-complex.handler';
import { inviteComplexUserHandler } from '@/modules/complex/handlers/invite-complex-user.handler';
import { listComplexUsersHandler } from '@/modules/complex/handlers/list-complex-users.handler';
import { listMyComplexesHandler } from '@/modules/complex/handlers/list-my-complexes.handler';
import { resendComplexInvitationHandler } from '@/modules/complex/handlers/resend-complex-invitation.handler';
import { revokeComplexUserHandler } from '@/modules/complex/handlers/revoke-complex-user.handler';
import { selectComplexHandler } from '@/modules/complex/handlers/select-complex.handler';
import { updateComplexHandler } from '@/modules/complex/handlers/update-complex.handler';
import type { AppEnv } from '@/types/hono';
import { zValidator } from '@hono/zod-validator';
import { createComplexSchema, updateComplexSchema } from '@repo/api-contract';
import {
acceptComplexInvitationsSchema,
createComplexSchema,
inviteComplexUserSchema,
updateComplexSchema,
} from '@repo/api-contract';
import { Hono } from 'hono';
import { z } from 'zod';
@@ -21,6 +32,11 @@ complexRoutes.use('*', requireAuth);
complexRoutes.post('/', zValidator('json', createComplexSchema), createComplexHandler);
complexRoutes.get('/me', getCurrentComplexHandler);
complexRoutes.post('/select', selectComplexHandler);
complexRoutes.post(
'/accept-pending',
zValidator('json', acceptComplexInvitationsSchema),
acceptComplexInvitationsHandler
);
complexRoutes.get('/mine', listMyComplexesHandler);
complexRoutes.get(
'/slug/:slug',
@@ -28,6 +44,33 @@ complexRoutes.get(
getComplexBySlugHandler
);
complexRoutes.get(
'/:id/users',
zValidator('param', complexIdParamsSchema),
listComplexUsersHandler
);
complexRoutes.post(
'/:id/users',
zValidator('param', complexIdParamsSchema),
zValidator('json', inviteComplexUserSchema),
inviteComplexUserHandler
);
complexRoutes.post(
'/:id/invitations/:invitationId/resend',
zValidator('param', z.object({ id: z.uuid(), invitationId: z.uuid() })),
resendComplexInvitationHandler
);
complexRoutes.delete(
'/:id/invitations/:invitationId',
zValidator('param', z.object({ id: z.uuid(), invitationId: z.uuid() })),
cancelComplexInvitationHandler
);
complexRoutes.delete(
'/:id/users/:userId',
zValidator('param', z.object({ id: z.uuid(), userId: z.string().min(1) })),
revokeComplexUserHandler
);
complexRoutes.get('/:id', zValidator('param', complexIdParamsSchema), getComplexByIdHandler);
complexRoutes.patch(
'/:id',

View File

@@ -0,0 +1,27 @@
import {
ComplexMembersError,
acceptComplexInvitations,
} from '@/modules/complex/services/complex-members.service';
import type { AppContext } from '@/types/hono';
import type { AcceptComplexInvitationsInput } from '@repo/api-contract';
export async function acceptComplexInvitationsHandler(c: AppContext) {
const user = c.get('user');
const payload = c.req.valid('json' as never) as AcceptComplexInvitationsInput;
try {
const result = await acceptComplexInvitations(user.id, user.email, payload);
return c.json(result);
} catch (error) {
if (error instanceof ComplexMembersError) {
return c.json(
{ message: error.message },
{
status: error.status,
}
);
}
throw error;
}
}

View File

@@ -0,0 +1,27 @@
import {
ComplexMembersError,
cancelComplexInvitation,
} from '@/modules/complex/services/complex-members.service';
import type { AppContext } from '@/types/hono';
import { z } from 'zod';
const cancelInvitationParamsSchema = z.object({
id: z.uuid(),
invitationId: z.uuid(),
});
export async function cancelComplexInvitationHandler(c: AppContext) {
const user = c.get('user');
const params = cancelInvitationParamsSchema.parse(c.req.param());
try {
const result = await cancelComplexInvitation(user.id, params.id, params.invitationId);
return c.json(result);
} catch (error) {
if (error instanceof ComplexMembersError) {
return c.json({ message: error.message }, { status: error.status });
}
throw error;
}
}

View File

@@ -0,0 +1,33 @@
import {
ComplexMembersError,
inviteComplexUser,
} from '@/modules/complex/services/complex-members.service';
import type { AppContext } from '@/types/hono';
import type { InviteComplexUserInput } from '@repo/api-contract';
import { z } from 'zod';
const complexParamsSchema = z.object({
id: z.uuid(),
});
export async function inviteComplexUserHandler(c: AppContext) {
const user = c.get('user');
const params = complexParamsSchema.parse(c.req.param());
const payload = c.req.valid('json' as never) as InviteComplexUserInput;
try {
const result = await inviteComplexUser(user.id, params.id, payload);
return c.json(result, 201);
} catch (error) {
if (error instanceof ComplexMembersError) {
return c.json(
{ message: error.message },
{
status: error.status,
}
);
}
throw error;
}
}

View File

@@ -0,0 +1,14 @@
import { getComplexUsersOverview } from '@/modules/complex/services/complex-members.service';
import type { AppContext } from '@/types/hono';
import { z } from 'zod';
const complexParamsSchema = z.object({
id: z.uuid(),
});
export async function listComplexUsersHandler(c: AppContext) {
const user = c.get('user');
const params = complexParamsSchema.parse(c.req.param());
const result = await getComplexUsersOverview(user.id, params.id);
return c.json(result);
}

View File

@@ -0,0 +1,27 @@
import {
ComplexMembersError,
resendComplexInvitation,
} from '@/modules/complex/services/complex-members.service';
import type { AppContext } from '@/types/hono';
import { z } from 'zod';
const resendInvitationParamsSchema = z.object({
id: z.uuid(),
invitationId: z.uuid(),
});
export async function resendComplexInvitationHandler(c: AppContext) {
const user = c.get('user');
const params = resendInvitationParamsSchema.parse(c.req.param());
try {
const result = await resendComplexInvitation(user.id, params.id, params.invitationId);
return c.json(result);
} catch (error) {
if (error instanceof ComplexMembersError) {
return c.json({ message: error.message }, { status: error.status });
}
throw error;
}
}

View File

@@ -0,0 +1,32 @@
import {
ComplexMembersError,
revokeComplexUser,
} from '@/modules/complex/services/complex-members.service';
import type { AppContext } from '@/types/hono';
import { z } from 'zod';
const revokeParamsSchema = z.object({
id: z.uuid(),
userId: z.string().min(1),
});
export async function revokeComplexUserHandler(c: AppContext) {
const user = c.get('user');
const params = revokeParamsSchema.parse(c.req.param());
try {
const result = await revokeComplexUser(user.id, params.id, { userId: params.userId });
return c.json(result);
} catch (error) {
if (error instanceof ComplexMembersError) {
return c.json(
{ message: error.message },
{
status: error.status,
}
);
}
throw error;
}
}

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