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