diff --git a/src/server/api/error.ts b/src/server/api/error.ts index f1c41392b..f060857f1 100644 --- a/src/server/api/error.ts +++ b/src/server/api/error.ts @@ -8,7 +8,7 @@ import { z } from "@hono/zod-openapi"; const log = logger.child({ module: "api-error" }); -const ErrorCode = z.enum([ +export const ErrorCode = z.enum([ "BAD_REQUEST", "FORBIDDEN", "INTERNAL_SERVER_ERROR", diff --git a/src/server/api/index.ts b/src/server/api/index.ts index d47a20038..cf9d3f7c1 100644 --- a/src/server/api/index.ts +++ b/src/server/api/index.ts @@ -3,6 +3,7 @@ import { initMiddleware } from "./middlewares/init"; import { registerCompanyRoutes } from "./routes/company"; import { registerShareRoutes } from "./routes/company/share"; import { registerStakeholderRoutes } from "./routes/company/stakeholder"; +import { registerTeamMemberRoutes } from "./routes/company/team-member"; const api = PublicAPI(); @@ -12,5 +13,6 @@ api.use("*", initMiddleware()); registerCompanyRoutes(api); registerShareRoutes(api); registerStakeholderRoutes(api); +registerTeamMemberRoutes(api); export default api; diff --git a/src/server/api/routes/company/team-member/create.ts b/src/server/api/routes/company/team-member/create.ts new file mode 100644 index 000000000..93b6639f8 --- /dev/null +++ b/src/server/api/routes/company/team-member/create.ts @@ -0,0 +1,139 @@ +import { SendMemberInviteEmailJob } from "@/jobs/member-inivite-email"; +import { generatePasswordResetToken } from "@/lib/token"; +import type { Roles } from "@/prisma/enums"; +import { withCompanyAuth } from "@/server/api/auth"; +import { ApiError, ErrorResponses } from "@/server/api/error"; +import type { PublicAPI } from "@/server/api/hono"; +import { + CreateMemberSchema, + type TeamMember, + TeamMemberSchema, +} from "@/server/api/schema/team-member"; +import { getIp } from "@/server/api/utils"; +import { createTeamMember } from "@/server/services/team-members/create-team-member"; +import { createRoute, z } from "@hono/zod-openapi"; +import type { Context } from "hono"; + +const ParamsSchema = z.object({ + id: z + .string() + .cuid() + .openapi({ + description: "Company ID", + param: { + name: "id", + in: "path", + }, + + example: "clycjihpy0002c5fzcyf4gjjc", + }), +}); + +const ResponseSchema = z.object({ + message: z.string(), + data: TeamMemberSchema, +}); + +const route = createRoute({ + method: "post", + path: "/v1/companies/{id}/teams", + summary: "Create Team Members", + description: "Create Team Members in a company.", + tags: ["Member"], + request: { + params: ParamsSchema, + body: { + content: { + "application/json": { + schema: CreateMemberSchema, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: ResponseSchema, + }, + }, + description: "Create Team Members", + }, + ...ErrorResponses, + }, +}); + +const create = (app: PublicAPI) => { + app.openapi(route, async (c: Context) => { + const { company, user } = await withCompanyAuth(c); + const body = await c.req.json(); + + const email: string = body.email; + const title: string | null = body.title; + const role: Roles | null = body.role; + const customRoleId: string | null = body.customRoleId; + + const requestIp = getIp(c.req); + const userAgent = c.req.header("User-Agent") || ""; + + const { data, success, message } = await createTeamMember({ + companyId: company.id, + companyName: company.name, + email, + customRoleId, + name: user.name || "", + requestIp, + userAgent, + userId: user.id, + role, + title, + }); + + if (!success && !data) { + throw new ApiError({ + code: "BAD_REQUEST", + message, + }); + } + + const verificationToken = data.verificationToken as string; + const member = data.member; + + const currentTime = new Date().toISOString(); + + const responseData: TeamMember = { + companyId: member?.companyId || "", + customRoleId: member?.customRoleId || null, + isOnboarded: member?.isOnboarded || true, + role: member?.role || null, + status: member?.status || "", + title: member?.title || "", + userId: member?.userId || "", + workEmail: member?.workEmail || "", + id: member?.id || "", + createdAt: member?.createdAt.toString() || currentTime, + lastAccessed: member?.lastAccessed.toString() || currentTime, + updatedAt: member?.updatedAt.toString() || currentTime, + }; + + 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, data: responseData }); + }); +}; + +export default create; diff --git a/src/server/api/routes/company/team-member/delete.ts b/src/server/api/routes/company/team-member/delete.ts new file mode 100644 index 000000000..dc89c4003 --- /dev/null +++ b/src/server/api/routes/company/team-member/delete.ts @@ -0,0 +1,81 @@ +import { withCompanyAuth } from "@/server/api/auth"; +import { ApiError, ErrorResponses } from "@/server/api/error"; +import type { PublicAPI } from "@/server/api/hono"; +import { getHonoUserAgent, getIp } from "@/server/api/utils"; +import { + type DeleteMemberPayload, + deleteMember, +} from "@/server/services/team-members/delete-member"; +import { createRoute, z } from "@hono/zod-openapi"; +import type { Context } from "hono"; +import { RequestParamsSchema } from "./update"; + +const ResponseSchema = z + .object({ + message: z.string(), + }) + .openapi({ + description: "Delete a Team Member by ID", + }); + +const DeleteParamsSchema = RequestParamsSchema.openapi({ + description: "Delete a Team Member by ID", +}); + +const route = createRoute({ + method: "delete", + path: "/v1/companies/{id}/teams/{memberId}", + summary: "Delete a Team Member by ID", + description: "Delete a Team Member by ID", + tags: ["Member"], + request: { + params: DeleteParamsSchema, + }, + responses: { + 200: { + content: { + "application/json": { + schema: ResponseSchema, + }, + }, + description: "Delete a Share by ID", + }, + ...ErrorResponses, + }, +}); + +const deleteOne = (app: PublicAPI) => { + app.openapi(route, async (c: Context) => { + const { company, user } = await withCompanyAuth(c); + const { memberId } = c.req.param(); + + const payload: DeleteMemberPayload = { + companyId: company.id, + memberId: memberId as string, + requestIp: getIp(c.req), + userAgent: getHonoUserAgent(c.req), + user: { + id: user.id, + name: user.name as string, + }, + }; + + const { success, message } = await deleteMember(payload); + + if (!success) { + throw new ApiError({ + code: "BAD_REQUEST", + message, + }); + } + + return c.json( + { + message: message, + }, + 200, + ); + }); +}; + +export default deleteOne; diff --git a/src/server/api/routes/company/team-member/getMany.ts b/src/server/api/routes/company/team-member/getMany.ts new file mode 100644 index 000000000..23e8b4088 --- /dev/null +++ b/src/server/api/routes/company/team-member/getMany.ts @@ -0,0 +1,86 @@ +import { withCompanyAuth } from "@/server/api/auth"; +import { ErrorResponses } from "@/server/api/error"; +import type { PublicAPI } from "@/server/api/hono"; +import { + DEFAULT_PAGINATION_LIMIT, + PaginationQuerySchema, + PaginationResponseSchema, +} from "@/server/api/schema/pagination"; +import { TeamMemberSchema } from "@/server/api/schema/team-member"; +import { getPaginatedMembers } from "@/server/services/team-members/get-members"; +import { createRoute, z } from "@hono/zod-openapi"; +import type { Context } from "hono"; + +const ParamsSchema = z.object({ + id: z + .string() + .cuid() + .openapi({ + description: "Company ID", + param: { + name: "id", + in: "path", + }, + + example: "clxwbok580000i7nge8nm1ry0", + }), +}); + +const ResponseSchema = z + .object({ + data: z.array(TeamMemberSchema), + meta: PaginationResponseSchema, + }) + .openapi({ + description: "Get Team Members by Company ID", + }); + +const route = createRoute({ + method: "get", + path: "/v1/companies/{id}/teams", + summary: "Get list of Team Members", + description: "Get list of Team Members for a company", + tags: ["Member"], + request: { + params: ParamsSchema, + query: PaginationQuerySchema, + }, + responses: { + 200: { + content: { + "application/json": { + schema: ResponseSchema, + }, + }, + description: "Retrieve Team Members for the company", + }, + ...ErrorResponses, + }, +}); + +const getMany = (app: PublicAPI) => { + app.openapi(route, async (c: Context) => { + const { company } = await withCompanyAuth(c); + + const { limit, cursor, total } = c.req.query(); + + const take = limit; + + const { data, meta } = await getPaginatedMembers({ + companyId: company.id, + take: Number(take || DEFAULT_PAGINATION_LIMIT), + cursor, + total: Number(total), + }); + + return c.json( + { + data, + meta, + }, + 200, + ); + }); +}; + +export default getMany; diff --git a/src/server/api/routes/company/team-member/getOne.ts b/src/server/api/routes/company/team-member/getOne.ts new file mode 100644 index 000000000..06b414c8f --- /dev/null +++ b/src/server/api/routes/company/team-member/getOne.ts @@ -0,0 +1,88 @@ +import { withCompanyAuth } from "@/server/api/auth"; +import { ApiError, ErrorResponses } from "@/server/api/error"; +import type { PublicAPI } from "@/server/api/hono"; +import { TeamMemberSchema } from "@/server/api/schema/team-member"; +import { db } from "@/server/db"; +import { createRoute, z } from "@hono/zod-openapi"; +import type { Context } from "hono"; + +const RequestParamsSchema = z.object({ + id: z + .string() + .cuid() + .openapi({ + param: { + name: "id", + in: "path", + }, + description: "Company ID", + type: "string", + example: "clytvs95t0002mhgjmkc8rtve", + }), + memberId: z + .string() + .cuid() + .openapi({ + param: { + name: "memberId", + in: "path", + }, + description: "Team Member ID", + type: "string", + example: "clytvs9gj0008mhgje2129y6g", + }), +}); + +const ResponseSchema = z + .object({ + data: TeamMemberSchema, + }) + .openapi({ + description: "Get a single Team Member by ID", + }); + +const route = createRoute({ + summary: "Get a Team Member", + description: "Get a single Team Member by ID", + tags: ["Member"], + method: "get", + path: "/v1/companies/{id}/teams/{memberId}", + request: { params: RequestParamsSchema }, + responses: { + 200: { + content: { + "application/json": { + schema: ResponseSchema, + }, + }, + description: "Get a single Team Member by ID", + }, + + ...ErrorResponses, + }, +}); + +const getOne = (app: PublicAPI) => { + app.openapi(route, async (c: Context) => { + await withCompanyAuth(c); + + const { memberId } = c.req.param(); + + const teamMember = await db.member.findUnique({ + where: { + id: memberId, + }, + }); + + if (!teamMember) { + throw new ApiError({ + code: "NOT_FOUND", + message: "Team member not found", + }); + } + + return c.json({ data: teamMember }, 200); + }); +}; + +export default getOne; diff --git a/src/server/api/routes/company/team-member/index.ts b/src/server/api/routes/company/team-member/index.ts new file mode 100644 index 000000000..7681c2e34 --- /dev/null +++ b/src/server/api/routes/company/team-member/index.ts @@ -0,0 +1,14 @@ +import type { PublicAPI } from "@/server/api/hono"; +import create from "./create"; +import deleteOne from "./delete"; +import getMany from "./getMany"; +import getOne from "./getOne"; +import update from "./update"; + +export const registerTeamMemberRoutes = (api: PublicAPI) => { + getOne(api); + getMany(api); + create(api); + update(api); + deleteOne(api); +}; diff --git a/src/server/api/routes/company/team-member/update.ts b/src/server/api/routes/company/team-member/update.ts new file mode 100644 index 000000000..287d1cee2 --- /dev/null +++ b/src/server/api/routes/company/team-member/update.ts @@ -0,0 +1,124 @@ +import { withCompanyAuth } from "@/server/api/auth"; +import { ApiError, type ErrorCode, ErrorResponses } from "@/server/api/error"; +import type { PublicAPI } from "@/server/api/hono"; +import { + UpdateMemberSchema, + type UpdateMemberType, +} from "@/server/api/schema/team-member"; +import { getHonoUserAgent, getIp } from "@/server/api/utils"; +import { + type UpdateMemberPayloadType, + updateMember, +} from "@/server/services/team-members/update-member"; +import { createRoute, z } from "@hono/zod-openapi"; +import type { Context } from "hono"; + +export const RequestParamsSchema = z + .object({ + id: z + .string() + .cuid() + .openapi({ + description: "Company ID", + param: { + name: "id", + in: "path", + }, + + example: "clxwbok580000i7nge8nm1ry0", + }), + memberId: z + .string() + .cuid() + .openapi({ + description: "Team Member ID", + param: { + name: "memberId", + in: "path", + }, + + example: "clyd3i9sw000008ij619eabva", + }), + }) + .openapi({ + description: "Update a Team Member by ID", + }); + +const ResponseSchema = z + .object({ + message: z.string(), + data: UpdateMemberSchema, + }) + .openapi({ + description: "Update a Team Member by ID", + }); + +const route = createRoute({ + method: "put", + path: "/v1/companies/{id}/teams/{memberId}", + summary: "Update a Team Member by ID", + description: "Update a Team Member by ID", + tags: ["Member"], + request: { + params: RequestParamsSchema, + body: { + content: { + "application/json": { + schema: UpdateMemberSchema, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: ResponseSchema, + }, + }, + description: "Update a Team Member by ID", + }, + ...ErrorResponses, + }, +}); + +const update = (app: PublicAPI) => { + app.openapi(route, async (c: Context) => { + const { company, user } = await withCompanyAuth(c); + const { memberId } = c.req.param(); + const body = await c.req.json(); + + const payload: UpdateMemberPayloadType = { + memberId: memberId as string, + companyId: company.id, + requestIp: getIp(c.req), + userAgent: getHonoUserAgent(c.req), + data: body as UpdateMemberType, + user: { + id: user.id, + name: user.name as string, + }, + }; + + const result = await updateMember(payload); + const { success, data } = result; + const code = result.code as z.infer; + + if (!success) { + throw new ApiError({ + code, + message: result.message, + }); + } + + return c.json( + { + message: result.message, + data: data as UpdateMemberType, + }, + 200, + ); + }); +}; + +export default update; diff --git a/src/server/api/schema/team-member.ts b/src/server/api/schema/team-member.ts new file mode 100644 index 000000000..471fb73da --- /dev/null +++ b/src/server/api/schema/team-member.ts @@ -0,0 +1,107 @@ +import { MemberStatusEnum } from "@/prisma/enums"; +import { Roles } from "@/prisma/enums"; +import { z } from "@hono/zod-openapi"; + +const MemberStatusArr = Object.values(MemberStatusEnum) as [ + string, + ...string[], +]; +const RolesArr = Object.values(Roles) as [string, ...string[]]; + +export const TeamMemberSchema = z.object({ + id: z.string().cuid().nullish().openapi({ + description: "Team member ID", + example: "cly13ipa40000i7ng42mv4x7b", + }), + + title: z.string().nullish().openapi({ + description: "Team member title", + example: "Co-Founder & CTO", + }), + + status: z.enum(MemberStatusArr).openapi({ + description: "Team member Status", + example: "ACTIVE", + }), + + isOnboarded: z.boolean().openapi({ + description: "Is team member onboarded", + example: false, + }), + + role: z.enum(RolesArr).nullish().openapi({ + description: "Role assigned to the member", + example: "ADMIN", + }), + + workEmail: z.string().nullish().openapi({ + description: "Work Email of the team member", + example: "ceo@westwood.com", + }), + + lastAccessed: z.string().datetime().nullish().openapi({ + description: "Team member last accessed at", + example: "2022-01-01T00:00:00Z", + }), + + createdAt: z.string().datetime().nullish().openapi({ + description: "Team member created at", + example: "2022-01-01T00:00:00Z", + }), + + updatedAt: z.string().datetime().nullish().openapi({ + description: "Team member updated at", + example: "2022-01-01T00:00:00Z", + }), + + userId: z.string().cuid().openapi({ + description: "User ID of the team member", + example: "cly13ipa40000i7ng42mv4x7b", + }), + + companyId: z.string().cuid().openapi({ + description: "Company ID", + example: "cly13ipa40000i7ng42mv4x7b", + }), + + customRoleId: z.string().cuid().nullish().openapi({ + description: "Custom role ID of the team member", + example: "cly13ipa40000i7ng42mv4x7b", + }), +}); + +export type TeamMember = z.infer; + +export const CreateMemberSchema = TeamMemberSchema.pick({ + title: true, + role: true, + customRoleId: true, +}).extend({ + email: z.string().openapi({ + description: "Work Email of the team member", + example: "ceo@westwood.com", + }), +}); + +export type TCreateMember = z.infer; + +export const UpdateMemberSchema = TeamMemberSchema.omit({ + id: true, + createdAt: true, + updatedAt: true, + lastAccessed: true, +}) + .partial() + .refine( + (data) => { + return Object.values(data).some((val) => val !== undefined); + }, + { + message: "At least one field must be provided to update.", + }, + ) + .openapi({ + description: "Update a Team Member by ID", + }); + +export type UpdateMemberType = z.infer; diff --git a/src/server/services/shares/get-shares.ts b/src/server/services/shares/get-shares.ts index 815ed07d9..5e13693b7 100644 --- a/src/server/services/shares/get-shares.ts +++ b/src/server/services/shares/get-shares.ts @@ -1,7 +1,7 @@ import { ProxyPrismaModel } from "@/server/api/pagination/prisma-proxy"; import { db } from "@/server/db"; -type GetPaginatedShares = { +export type GetPaginatedShares = { companyId: string; take: number; cursor?: string; 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-member.ts b/src/server/services/team-members/create-member.ts new file mode 100644 index 000000000..1059c97c4 --- /dev/null +++ b/src/server/services/team-members/create-member.ts @@ -0,0 +1,86 @@ +import type { getRoleById } from "@/lib/rbac/access-control"; +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; + role: Awaited>; +}; + +export async function createMember( + tx: PrismaTransactionalClient, + memberPayload: MemberPayload, +) { + const { userId, companyId, email, title, role } = memberPayload; + // create member + const member = await tx.member.upsert({ + create: { + title, + isOnboarded: false, + lastAccessed: new Date(), + companyId, + userId, + status: "PENDING", + ...role, + }, + update: { + title, + isOnboarded: false, + lastAccessed: new Date(), + status: "PENDING", + ...role, + }, + where: { + companyId_userId: { + companyId, + userId, + }, + }, + select: { + id: true, + userId: true, + user: { + select: { + name: true, + }, + }, + title: true, + status: true, + isOnboarded: true, + role: true, + workEmail: true, + lastAccessed: true, + createdAt: true, + updatedAt: true, + companyId: true, + customRoleId: 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/server/services/team-members/create-team-member.ts b/src/server/services/team-members/create-team-member.ts new file mode 100644 index 000000000..c81ccae0d --- /dev/null +++ b/src/server/services/team-members/create-team-member.ts @@ -0,0 +1,118 @@ +import { getRoleById } from "@/lib/rbac/access-control"; +import { ADMIN_ROLE_ID } from "@/lib/rbac/constants"; +import type { Roles } from "@/prisma/enums"; +import { Audit } from "@/server/audit"; +import { db } from "@/server/db"; +import { checkUserMembershipForInvitation } from "./check-user-membership"; +import { createMember } from "./create-member"; + +interface CreateTeamMember { + name: string; + email: string; + companyId: string; + requestIp: string; + userAgent: string; + userId: string; + role: Roles | null; + title: string | null; + customRoleId: string | null; + companyName: string; +} + +export const createTeamMember = async (payload: CreateTeamMember) => { + const { + title, + companyId, + customRoleId, + email, + name, + requestIp, + role, + userAgent, + userId, + companyName, + } = payload; + + const { verificationToken, member, success, message } = await db.$transaction( + async (tx) => { + const newUserOnTeam = await checkUserMembershipForInvitation(tx, { + name, + email, + companyId, + }); + + if (!newUserOnTeam) { + return { + success: false, + message: "user already a member", + }; + } + + let userRole: Awaited> = { + customRoleId: null, + role: null, + }; + + if (role === "ADMIN") { + userRole = await getRoleById({ id: ADMIN_ROLE_ID, tx }); + } else if (role === "CUSTOM") { + if (!customRoleId) { + return { + success: false, + message: "Enter the CustomRole ID when role set to CUSTOM", + }; + } + + try { + userRole = await getRoleById({ id: customRoleId, tx }); + } catch (error) { + return { + success: false, + message: "Enter Valid CustomRole ID", + }; + } + } + + const { member, verificationToken } = await createMember(tx, { + userId: newUserOnTeam.id, + companyId, + name, + email, + title: title || "", + role: userRole, + }); + + await Audit.create( + { + action: "member.invited", + companyId, + actor: { type: "user", id: userId }, + context: { + requestIp, + userAgent, + }, + target: [{ type: "user", id: member.userId }], + summary: `${name} invited ${member.user?.name} to join ${companyName}`, + }, + tx, + ); + + return { + verificationToken, + member, + success: true, + message: "Team member created Successfully 🎉 !", + }; + }, + ); + + if (!success) { + return { success, message }; + } + + return { + data: { verificationToken, member }, + success, + message, + }; +}; diff --git a/src/server/services/team-members/delete-member.ts b/src/server/services/team-members/delete-member.ts new file mode 100644 index 000000000..4e2ce0bd1 --- /dev/null +++ b/src/server/services/team-members/delete-member.ts @@ -0,0 +1,52 @@ +import { Audit } from "@/server/audit"; +import { db } from "@/server/db"; +import type { UpdateMemberPayloadType } from "./update-member"; + +export interface DeleteMemberPayload + extends Omit {} + +export const deleteMember = async (payload: DeleteMemberPayload) => { + const { companyId, memberId, requestIp, userAgent, user } = payload; + + const existingMember = await db.member.findUnique({ + where: { + id: memberId, + companyId, + }, + }); + + if (!existingMember) { + return { + success: false, + message: `Member with the ID ${memberId} not be found`, + data: null, + }; + } + + const member = await db.member.delete({ + where: { + id: existingMember.id, + }, + include: { + user: true, + company: true, + }, + }); + + await Audit.create( + { + action: "member.removed", + companyId, + actor: { type: "user", id: user.id }, + context: { + requestIp, + userAgent, + }, + target: [{ type: "user", id: member.userId }], + summary: `${user.name} removed ${member.user?.name} from ${member?.company?.name}`, + }, + db, + ); + + return { success: true, message: "Member removed successfully !" }; +}; diff --git a/src/server/services/team-members/get-members.ts b/src/server/services/team-members/get-members.ts new file mode 100644 index 000000000..37cf71136 --- /dev/null +++ b/src/server/services/team-members/get-members.ts @@ -0,0 +1,40 @@ +import { ProxyPrismaModel } from "@/server/api/pagination/prisma-proxy"; +import { db } from "@/server/db"; +import type { GetPaginatedShares } from "../shares/get-shares"; + +type GetPaginatedMembers = GetPaginatedShares; + +export const getPaginatedMembers = async (payload: GetPaginatedMembers) => { + const queryCriteria = { + where: { + companyId: payload.companyId, + }, + orderBy: { + createdAt: "desc", + }, + }; + + console.log("Payload take is : ", payload.take); + + const paginationData = { + take: payload.take, + cursor: payload.cursor, + total: payload.total, + }; + + const prismaModel = ProxyPrismaModel(db.member); + + const { data, count, total, cursor } = await prismaModel.findManyPaginated( + queryCriteria, + paginationData, + ); + + return { + data, + meta: { + count, + total, + cursor, + }, + }; +}; diff --git a/src/server/services/team-members/update-member.ts b/src/server/services/team-members/update-member.ts new file mode 100644 index 000000000..7e2d6d3c3 --- /dev/null +++ b/src/server/services/team-members/update-member.ts @@ -0,0 +1,73 @@ +import type { UpdateMemberType } from "@/server/api/schema/team-member"; +import { Audit } from "@/server/audit"; +import { db } from "@/server/db"; +import type { UpdateSharePayloadType } from "../shares/update-share"; + +export interface UpdateMemberPayloadType + extends Omit { + data: UpdateMemberType; + memberId: string; +} + +export const updateMember = async (payload: UpdateMemberPayloadType) => { + const { companyId, data, memberId, requestIp, user, userAgent } = payload; + + try { + const existingMember = await db.member.findUnique({ + where: { + id: memberId, + }, + }); + + if (!existingMember) { + return { + code: "BAD_REQUEST", + success: false, + message: `Member with the ID ${memberId} not be found`, + data: null, + }; + } + + const memberData = { ...existingMember, ...data }; + + const { member, success, message } = await db.$transaction(async (tx) => { + const member = await tx.member.update({ + where: { + id: memberData.id, + }, + //@ts-ignore + data: memberData, + }); + + await Audit.create( + { + action: "member.updated", + companyId, + actor: { type: "user", id: user.id }, + context: { + requestIp, + userAgent, + }, + target: [{ type: "user", id: member.userId }], + summary: `${user.name} updated ${user.name} details`, + }, + tx, + ); + + return { + message: "Successfully updated Member details !", + success: true, + member, + }; + }); + + return { message, success, data: member, code: "SUCCESS" }; + } catch (error) { + return { + success: false, + code: "INTERNAL_SERVER_ERROR", + message: "Something went wrong, please try again or contact support", + data: null, + }; + } +}; diff --git a/src/trpc/routers/member-router/procedures/invite-member.ts b/src/trpc/routers/member-router/procedures/invite-member.ts index b1983474e..1d26e2437 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 { getRoleById } from "@/lib/rbac/access-control"; import { generatePasswordResetToken } from "@/lib/token"; import { Audit } from "@/server/audit"; -import { generateInviteToken, generateMemberIdentifier } from "@/server/member"; +import { checkUserMembershipForInvitation } from "@/server/services/team-members/check-user-membership"; +import { createMember } from "@/server/services/team-members/create-member"; import { withAccessControl } from "@/trpc/api/trpc"; import { TRPCError } from "@trpc/server"; import { ZodInviteMemberMutationSchema } from "../schema"; @@ -23,8 +24,6 @@ export const inviteMemberProcedure = withAccessControl membership: { companyId }, } = ctx; - const { expires, memberInviteTokenHash } = await generateInviteToken(); - const { token: passwordResetToken } = await generatePasswordResetToken(email); @@ -40,33 +39,13 @@ export const inviteMemberProcedure = withAccessControl }, }); - // 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", @@ -75,51 +54,13 @@ export const inviteMemberProcedure = withAccessControl const role = await getRoleById({ id: roleId, tx }); - // create member - const member = await tx.member.upsert({ - create: { - title, - isOnboarded: false, - lastAccessed: new Date(), - companyId, - userId: invitedUser.id, - status: "PENDING", - ...role, - }, - update: { - title, - isOnboarded: false, - lastAccessed: new Date(), - status: "PENDING", - ...role, - }, - 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 createMember(tx, { + userId: newUserOnTeam.id, + companyId: company.id, + name, + email, + title, + role, }); await Audit.create(