add team management (#131)
* add team management * add more team management * add join team page
This commit is contained in:
@@ -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
|
||||
|
@@ -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(),
|
||||
|
110
apps/web/src/server/api/routers/invitiation.ts
Normal file
110
apps/web/src/server/api/routers/invitiation.ts
Normal 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;
|
||||
}),
|
||||
});
|
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
@@ -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
|
||||
*/
|
||||
|
Reference in New Issue
Block a user