Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: create team member api #406

Closed
wants to merge 1 commit into from
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