diff --git a/src/components/modals/team-member/team-member-modal.tsx b/src/components/modals/team-member/team-member-modal.tsx index 005f43c01..9d98cef62 100644 --- a/src/components/modals/team-member/team-member-modal.tsx +++ b/src/components/modals/team-member/team-member-modal.tsx @@ -22,7 +22,7 @@ import { useRouter } from "next/navigation"; import { useForm } from "react-hook-form"; import { z } from "zod"; -const ZodTeamMemberSchema = z.object({ +export const ZodTeamMemberSchema = z.object({ name: z.string(), loginEmail: z.string(), workEmail: z.string(), diff --git a/src/server/api/routes/company/team/create.ts b/src/server/api/routes/company/team/create.ts index 1370d1563..8bf34c6ca 100644 --- a/src/server/api/routes/company/team/create.ts +++ b/src/server/api/routes/company/team/create.ts @@ -1,11 +1,41 @@ -import { ErrorResponses } from "@/server/api/error"; +import { SendMemberInviteEmailJob } from "@/jobs/member-inivite-email"; +import { generatePasswordResetToken } from "@/lib/token"; +import { withCompanyAuth } from "@/server/api/auth"; +import { ApiError, ErrorResponses } from "@/server/api/error"; import type { PublicAPI } from "@/server/api/hono"; +import { Audit } from "@/server/audit"; +import { db } from "@/server/db"; +import { checkUserMembershipForInvitation } from "@/server/services/team-members/check-user-membership"; +import { createTeamMember } from "@/server/services/team-members/create-team-member"; import { createRoute, z } from "@hono/zod-openapi"; import type { Context, HonoRequest } from "hono"; +const TeamMemberSchema = z + .object({ + title: z.string().openapi({ + example: "Software Engineer", + }), + name: z.string().openapi({ + example: "Xyz Corp", + }), + email: z.string().openapi({ + example: "john@xyz.inc", + }), + }) + .openapi("TeamMember"); + const route = createRoute({ method: "post", path: "/v1/companies/:id/teams", + request: { + body: { + content: { + "application/json": { + schema: TeamMemberSchema, + }, + }, + }, + }, responses: { 200: { content: { @@ -22,11 +52,76 @@ const route = createRoute({ }, }); +const getIp = (req: HonoRequest) => { + return ( + req.header("x-forwarded-for") || req.header("remoteAddr") || "Unknown IP" + ); +}; + const create = (app: PublicAPI) => { app.openapi(route, async (c: Context) => { - const req: HonoRequest = await c.req; - console.log({ req }); - return c.json({ message: "TODO: implement this endpoint" }); + const companyId = c.req.param("id"); + const { company, user } = await withCompanyAuth(companyId, c.req.header); + + const { name, title, email } = c.req.valid("json"); + + const { verificationToken } = await db.$transaction(async (tx) => { + const newUserOnTeam = await checkUserMembershipForInvitation(tx, { + name, + email, + companyId: company.id, + }); + + if (!newUserOnTeam) { + throw new ApiError({ + code: "BAD_REQUEST", + message: "user already a member", + }); + } + + const { member, verificationToken } = await createTeamMember(tx, { + userId: newUserOnTeam.id, + companyId: company.id, + name, + email, + title, + }); + + await Audit.create( + { + action: "member.invited", + companyId: company.id, + actor: { type: "user", id: user.id }, + context: { + requestIp: getIp(c.req), + userAgent: c.req.header("User-Agent") || "", + }, + target: [{ type: "user", id: member.userId }], + summary: `${user.name} invited ${member.user?.name} to join ${company.name}`, + }, + tx, + ); + + return { verificationToken }; + }); + + const { token: passwordResetToken } = + await generatePasswordResetToken(email); + + const payload = { + verificationToken, + passwordResetToken, + email, + company, + user: { + email: user.email, + name: user.name, + }, + }; + + await new SendMemberInviteEmailJob().emit(payload); + + return c.json({ message: "Team member created." }); }); }; diff --git a/src/server/services/team-members/check-user-membership.ts b/src/server/services/team-members/check-user-membership.ts new file mode 100644 index 000000000..e869ec33f --- /dev/null +++ b/src/server/services/team-members/check-user-membership.ts @@ -0,0 +1,49 @@ +import type { PrismaClient } from "@prisma/client"; + +export type PrismaTransactionalClient = Parameters< + Parameters[0] +>[0]; + +type UserPayload = { + name: string; + email: string; + companyId: string; +}; + +export async function checkUserMembershipForInvitation( + tx: PrismaTransactionalClient, + user: UserPayload, +) { + const { name, email, companyId } = user; + + // create or find user + const invitedUser = await tx.user.upsert({ + where: { + email, + }, + update: {}, + create: { + name, + email, + }, + select: { + id: true, + }, + }); + + // check if user is already a member + const prevMember = await tx.member.findUnique({ + where: { + companyId_userId: { + companyId, + userId: invitedUser.id, + }, + }, + }); + + if (prevMember && prevMember.status === "ACTIVE") { + return false; + } + + return invitedUser; +} diff --git a/src/server/services/team-members/create-team-member.ts b/src/server/services/team-members/create-team-member.ts new file mode 100644 index 000000000..38a93ae7c --- /dev/null +++ b/src/server/services/team-members/create-team-member.ts @@ -0,0 +1,72 @@ +import { generateInviteToken, generateMemberIdentifier } from "@/server/member"; +import type { PrismaClient } from "@prisma/client"; + +export type PrismaTransactionalClient = Parameters< + Parameters[0] +>[0]; + +type MemberPayload = { + userId: string; + name: string; + title: string; + email: string; + companyId: string; +}; + +export async function createTeamMember( + tx: PrismaTransactionalClient, + memberPayload: MemberPayload, +) { + const { userId, companyId, email, title } = memberPayload; + // create member + const member = await tx.member.upsert({ + create: { + title, + isOnboarded: false, + lastAccessed: new Date(), + companyId, + userId, + status: "PENDING", + }, + update: { + title, + isOnboarded: false, + lastAccessed: new Date(), + status: "PENDING", + }, + where: { + companyId_userId: { + companyId, + userId, + }, + }, + select: { + id: true, + userId: true, + user: { + select: { + name: true, + }, + }, + }, + }); + + const { expires, memberInviteTokenHash } = await generateInviteToken(); + + // custom verification token for member invitation + const { token: verificationToken } = await tx.verificationToken.create({ + data: { + identifier: generateMemberIdentifier({ + email, + memberId: member.id, + }), + token: memberInviteTokenHash, + expires, + }, + }); + + return { + verificationToken, + member, + }; +} diff --git a/src/trpc/routers/member-router/procedures/invite-member.ts b/src/trpc/routers/member-router/procedures/invite-member.ts index 9ba1bca3f..b1c143832 100644 --- a/src/trpc/routers/member-router/procedures/invite-member.ts +++ b/src/trpc/routers/member-router/procedures/invite-member.ts @@ -2,7 +2,8 @@ import { SendMemberInviteEmailJob } from "@/jobs/member-inivite-email"; import { generatePasswordResetToken } from "@/lib/token"; import { Audit } from "@/server/audit"; import { checkMembership } from "@/server/auth"; -import { generateInviteToken, generateMemberIdentifier } from "@/server/member"; +import { checkUserMembershipForInvitation } from "@/server/services/team-members/check-user-membership"; +import { createTeamMember } from "@/server/services/team-members/create-team-member"; import { withAuth } from "@/trpc/api/trpc"; import { TRPCError } from "@trpc/server"; import { ZodInviteMemberMutationSchema } from "../schema"; @@ -14,8 +15,6 @@ export const inviteMemberProcedure = withAuth const { name, email, title } = input; const { userAgent, requestIp, session } = ctx; - const { expires, memberInviteTokenHash } = await generateInviteToken(); - const { token: passwordResetToken } = await generatePasswordResetToken(email); @@ -33,82 +32,25 @@ export const inviteMemberProcedure = withAuth }, }); - // create or find user - const invitedUser = await tx.user.upsert({ - where: { - email, - }, - update: {}, - create: { - name, - email, - }, - select: { - id: true, - }, - }); - - // check if user is already a member - const prevMember = await tx.member.findUnique({ - where: { - companyId_userId: { - companyId, - userId: invitedUser.id, - }, - }, + const newUserOnTeam = await checkUserMembershipForInvitation(tx, { + name, + email, + companyId: company.id, }); - // if already a member, throw error - if (prevMember && prevMember.status === "ACTIVE") { + if (!newUserOnTeam) { throw new TRPCError({ code: "FORBIDDEN", message: "user already a member", }); } - // create member - const member = await tx.member.upsert({ - create: { - title, - isOnboarded: false, - lastAccessed: new Date(), - companyId, - userId: invitedUser.id, - status: "PENDING", - }, - update: { - title, - isOnboarded: false, - lastAccessed: new Date(), - status: "PENDING", - }, - where: { - companyId_userId: { - companyId, - userId: invitedUser.id, - }, - }, - select: { - id: true, - userId: true, - user: { - select: { - name: true, - }, - }, - }, - }); - - // custom verification token for member invitation - const { token: verificationToken } = await tx.verificationToken.create({ - data: { - identifier: generateMemberIdentifier({ - email, - memberId: member.id, - }), - token: memberInviteTokenHash, - expires, - }, + const { member, verificationToken } = await createTeamMember(tx, { + userId: newUserOnTeam.id, + companyId: company.id, + name, + email, + title, }); await Audit.create(