add team management (#131)

* add team management

* add more team management

* add join team page
This commit is contained in:
KM Koushik
2025-03-26 22:02:49 +11:00
committed by GitHub
parent f8113e64b5
commit 1ed5c8009f
26 changed files with 1348 additions and 13 deletions

View File

@@ -8,6 +8,7 @@ import { contactsRouter } from "./routers/contacts";
import { campaignRouter } from "./routers/campaign";
import { templateRouter } from "./routers/template";
import { billingRouter } from "./routers/billing";
import { invitationRouter } from "./routers/invitiation";
/**
* This is the primary router for your server.
@@ -24,6 +25,7 @@ export const appRouter = createTRPCRouter({
campaign: campaignRouter,
template: templateRouter,
billing: billingRouter,
invitation: invitationRouter,
});
// export type definition of API

View File

@@ -6,6 +6,7 @@ import { z } from "zod";
import {
apiKeyProcedure,
createTRPCRouter,
teamAdminProcedure,
teamProcedure,
} from "~/server/api/trpc";
import {
@@ -15,11 +16,11 @@ import {
import { db } from "~/server/db";
export const billingRouter = createTRPCRouter({
createCheckoutSession: teamProcedure.mutation(async ({ ctx }) => {
createCheckoutSession: teamAdminProcedure.mutation(async ({ ctx }) => {
return (await createCheckoutSessionForTeam(ctx.team.id)).url;
}),
getManageSessionUrl: teamProcedure.mutation(async ({ ctx }) => {
getManageSessionUrl: teamAdminProcedure.mutation(async ({ ctx }) => {
return await getManageSessionUrl(ctx.team.id);
}),
@@ -65,7 +66,7 @@ export const billingRouter = createTRPCRouter({
return subscription;
}),
updateBillingEmail: teamProcedure
updateBillingEmail: teamAdminProcedure
.input(
z.object({
billingEmail: z.string().email(),

View File

@@ -0,0 +1,110 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { env } from "~/env";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
export const invitationRouter = createTRPCRouter({
createTeam: protectedProcedure
.input(z.object({ name: z.string() }))
.mutation(async ({ ctx, input }) => {
const teams = await ctx.db.team.findMany({
where: {
teamUsers: {
some: {
userId: ctx.session.user.id,
},
},
},
});
if (teams.length > 0) {
console.log("User already has a team");
return;
}
if (!env.NEXT_PUBLIC_IS_CLOUD) {
const _team = await ctx.db.team.findFirst();
if (_team) {
throw new TRPCError({
message: "Can't have multiple teams in self hosted version",
code: "UNAUTHORIZED",
});
}
}
return ctx.db.team.create({
data: {
name: input.name,
teamUsers: {
create: {
userId: ctx.session.user.id,
role: "ADMIN",
},
},
},
});
}),
getUserInvites: protectedProcedure.query(async ({ ctx }) => {
if (!ctx.session.user.email) {
return [];
}
const invites = await ctx.db.teamInvite.findMany({
where: {
email: ctx.session.user.email,
},
include: {
team: true,
},
});
return invites;
}),
getInvite: protectedProcedure
.input(z.object({ inviteId: z.string() }))
.query(async ({ ctx, input }) => {
const invite = await ctx.db.teamInvite.findUnique({
where: {
id: input.inviteId,
},
});
return invite;
}),
acceptTeamInvite: protectedProcedure
.input(z.object({ inviteId: z.string() }))
.mutation(async ({ ctx, input }) => {
const invite = await ctx.db.teamInvite.findUnique({
where: {
id: input.inviteId,
},
});
if (!invite) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Invite not found",
});
}
await ctx.db.teamUser.create({
data: {
teamId: invite.teamId,
userId: ctx.session.user.id,
role: invite.role,
},
});
await ctx.db.teamInvite.delete({
where: {
id: input.inviteId,
},
});
return true;
}),
});

View File

@@ -2,7 +2,13 @@ import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { env } from "~/env";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import {
createTRPCRouter,
protectedProcedure,
teamProcedure,
teamAdminProcedure,
} from "~/server/api/trpc";
import { sendTeamInviteEmail } from "~/server/mailer";
export const teamRouter = createTRPCRouter({
createTeam: protectedProcedure
@@ -55,8 +61,227 @@ export const teamRouter = createTRPCRouter({
},
},
},
include: {
teamUsers: {
where: {
userId: ctx.session.user.id,
},
},
},
});
return teams;
}),
getTeamUsers: teamProcedure.query(async ({ ctx }) => {
const teamUsers = await ctx.db.teamUser.findMany({
where: {
teamId: ctx.team.id,
},
include: {
user: true,
},
});
return teamUsers;
}),
getTeamInvites: teamProcedure.query(async ({ ctx }) => {
const teamInvites = await ctx.db.teamInvite.findMany({
where: {
teamId: ctx.team.id,
},
});
return teamInvites;
}),
createTeamInvite: teamAdminProcedure
.input(z.object({ email: z.string(), role: z.enum(["MEMBER", "ADMIN"]) }))
.mutation(async ({ ctx, input }) => {
const user = await ctx.db.user.findUnique({
where: {
email: input.email,
},
include: {
teamUsers: true,
},
});
if (user && user.teamUsers.length > 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "User already part of a team",
});
}
const teamInvite = await ctx.db.teamInvite.create({
data: {
teamId: ctx.team.id,
email: input.email,
role: input.role,
},
});
const teamUrl = `${env.NEXTAUTH_URL}/join-team?inviteId=${teamInvite.id}`;
await sendTeamInviteEmail(input.email, teamUrl, ctx.team.name);
return teamInvite;
}),
updateTeamUserRole: teamAdminProcedure
.input(
z.object({
userId: z.string(),
role: z.enum(["MEMBER", "ADMIN"]),
})
)
.mutation(async ({ ctx, input }) => {
const teamUser = await ctx.db.teamUser.findFirst({
where: {
teamId: ctx.team.id,
userId: Number(input.userId),
},
});
if (!teamUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Team member not found",
});
}
// Check if this is the last admin
const adminCount = await ctx.db.teamUser.count({
where: {
teamId: ctx.team.id,
role: "ADMIN",
},
});
if (adminCount === 1 && teamUser.role === "ADMIN") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Need at least one admin",
});
}
return ctx.db.teamUser.update({
where: {
teamId_userId: {
teamId: ctx.team.id,
userId: Number(input.userId),
},
},
data: {
role: input.role,
},
});
}),
deleteTeamUser: teamProcedure
.input(z.object({ userId: z.string() }))
.mutation(async ({ ctx, input }) => {
const teamUser = await ctx.db.teamUser.findFirst({
where: {
teamId: ctx.team.id,
userId: Number(input.userId),
},
});
if (!teamUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Team member not found",
});
}
if (
ctx.teamUser.role !== "ADMIN" &&
ctx.session.user.id !== Number(input.userId)
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to delete this team member",
});
}
// Check if this is the last admin
const adminCount = await ctx.db.teamUser.count({
where: {
teamId: ctx.team.id,
role: "ADMIN",
},
});
if (adminCount === 1 && teamUser.role === "ADMIN") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Need at least one admin",
});
}
return ctx.db.teamUser.delete({
where: {
teamId_userId: {
teamId: ctx.team.id,
userId: Number(input.userId),
},
},
});
}),
resendTeamInvite: teamAdminProcedure
.input(z.object({ inviteId: z.string() }))
.mutation(async ({ ctx, input }) => {
const invite = await ctx.db.teamInvite.findUnique({
where: {
id: input.inviteId,
},
});
if (!invite) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Invite not found",
});
}
const teamUrl = `${env.NEXTAUTH_URL}/join-team?inviteId=${invite.id}`;
// TODO: Implement email sending logic
await sendTeamInviteEmail(invite.email, teamUrl, ctx.team.name);
return { success: true };
}),
deleteTeamInvite: teamAdminProcedure
.input(z.object({ inviteId: z.string() }))
.mutation(async ({ ctx, input }) => {
const invite = await ctx.db.teamInvite.findFirst({
where: {
teamId: ctx.team.id,
id: {
equals: input.inviteId,
},
},
});
if (!invite) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Invite not found",
});
}
return ctx.db.teamInvite.delete({
where: {
teamId_email: {
teamId: ctx.team.id,
email: invite.email,
},
},
});
}),
});

View File

@@ -114,17 +114,30 @@ export const teamProcedure = protectedProcedure.use(async ({ ctx, next }) => {
where: { userId: ctx.session.user.id },
include: { team: true },
});
if (!teamUser) {
throw new TRPCError({ code: "NOT_FOUND", message: "Team not found" });
}
return next({
ctx: {
team: teamUser.team,
teamUser,
session: { ...ctx.session, user: ctx.session.user },
},
});
});
export const teamAdminProcedure = teamProcedure.use(async ({ ctx, next }) => {
if (ctx.teamUser.role !== "ADMIN") {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to perform this action",
});
}
return next();
});
export const domainProcedure = teamProcedure
.input(z.object({ id: z.number() }))
.use(async ({ ctx, next, input }) => {
@@ -224,7 +237,6 @@ export const templateProcedure = teamProcedure
return next({ ctx: { ...ctx, template } });
});
/**
* To manage application settings, for hosted version, authenticated users will be considered as admin
*/