Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/components/modals/team-member/team-member-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
103 changes: 99 additions & 4 deletions src/server/api/routes/company/team/create.ts
Original file line number Diff line number Diff line change
@@ -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: "[email protected]",
}),
})
.openapi("TeamMember");

const route = createRoute({
method: "post",
path: "/v1/companies/:id/teams",
request: {
body: {
content: {
"application/json": {
schema: TeamMemberSchema,
},
},
},
},
responses: {
200: {
content: {
Expand All @@ -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." });
});
};

Expand Down
49 changes: 49 additions & 0 deletions src/server/services/team-members/check-user-membership.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { PrismaClient } from "@prisma/client";

export type PrismaTransactionalClient = Parameters<
Parameters<PrismaClient["$transaction"]>[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;
}
72 changes: 72 additions & 0 deletions src/server/services/team-members/create-team-member.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { generateInviteToken, generateMemberIdentifier } from "@/server/member";
import type { PrismaClient } from "@prisma/client";

export type PrismaTransactionalClient = Parameters<
Parameters<PrismaClient["$transaction"]>[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,
};
}
84 changes: 13 additions & 71 deletions src/trpc/routers/member-router/procedures/invite-member.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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);

Expand All @@ -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(
Expand Down