649 lines
18 KiB
TypeScript
649 lines
18 KiB
TypeScript
import { TRPCError } from "@trpc/server";
|
|
import { z } from "zod";
|
|
|
|
import * as inviteLinkRepo from "@kan/db/repository/inviteLink.repo";
|
|
import * as memberRepo from "@kan/db/repository/member.repo";
|
|
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
|
import * as userRepo from "@kan/db/repository/user.repo";
|
|
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
|
import {
|
|
generateUID,
|
|
getSubscriptionByPlan,
|
|
hasUnlimitedSeats,
|
|
} from "@kan/shared/utils";
|
|
import { updateSubscriptionSeats } from "@kan/stripe";
|
|
|
|
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
|
import { assertUserInWorkspace } from "../utils/auth";
|
|
|
|
export const memberRouter = createTRPCRouter({
|
|
invite: protectedProcedure
|
|
.meta({
|
|
openapi: {
|
|
summary: "Invite a member to a workspace",
|
|
method: "POST",
|
|
path: "/workspaces/{workspacePublicId}/members/invite",
|
|
description: "Invites a member to a workspace",
|
|
tags: ["Workspaces"],
|
|
protect: true,
|
|
},
|
|
})
|
|
.input(
|
|
z.object({
|
|
email: z.string().email(),
|
|
workspacePublicId: z.string().min(12),
|
|
}),
|
|
)
|
|
.output(z.custom<Awaited<ReturnType<typeof memberRepo.create>>>())
|
|
.mutation(async ({ ctx, input }) => {
|
|
const userId = ctx.user?.id;
|
|
|
|
if (!userId)
|
|
throw new TRPCError({
|
|
message: `User not authenticated`,
|
|
code: "UNAUTHORIZED",
|
|
});
|
|
|
|
const workspace = await workspaceRepo.getByPublicIdWithMembers(
|
|
ctx.db,
|
|
input.workspacePublicId,
|
|
);
|
|
|
|
if (!workspace)
|
|
throw new TRPCError({
|
|
message: `Workspace with public ID ${input.workspacePublicId} not found`,
|
|
code: "NOT_FOUND",
|
|
});
|
|
|
|
await assertUserInWorkspace(ctx.db, userId, workspace.id, "admin");
|
|
|
|
const isInvitedEmailAlreadyMember = workspace.members.some(
|
|
(member) => member.email === input.email,
|
|
);
|
|
|
|
if (isInvitedEmailAlreadyMember) {
|
|
throw new TRPCError({
|
|
message: `User with email ${input.email} is already a member of this workspace`,
|
|
code: "CONFLICT",
|
|
});
|
|
}
|
|
|
|
if (process.env.NEXT_PUBLIC_KAN_ENV === "cloud") {
|
|
const subscriptions = await subscriptionRepo.getByReferenceId(
|
|
ctx.db,
|
|
workspace.publicId,
|
|
);
|
|
|
|
// get the active subscriptions
|
|
const activeTeamSubscription = getSubscriptionByPlan(
|
|
subscriptions,
|
|
"team",
|
|
);
|
|
const activeProSubscription = getSubscriptionByPlan(
|
|
subscriptions,
|
|
"pro",
|
|
);
|
|
const unlimitedSeats = hasUnlimitedSeats(subscriptions);
|
|
|
|
if (!activeTeamSubscription && !activeProSubscription) {
|
|
throw new TRPCError({
|
|
message: `Workspace with public ID ${workspace.publicId} does not have an active subscription`,
|
|
code: "NOT_FOUND",
|
|
});
|
|
}
|
|
|
|
// Update the Stripe subscription
|
|
if (activeTeamSubscription?.stripeSubscriptionId && !unlimitedSeats) {
|
|
try {
|
|
await updateSubscriptionSeats(
|
|
activeTeamSubscription.stripeSubscriptionId,
|
|
1,
|
|
);
|
|
} catch (error) {
|
|
console.error("Failed to update Stripe subscription seats:", error);
|
|
throw new TRPCError({
|
|
message: `Failed to update subscription for the new member.`,
|
|
code: "INTERNAL_SERVER_ERROR",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const existingUser = await userRepo.getByEmail(ctx.db, input.email);
|
|
|
|
const invite = await memberRepo.create(ctx.db, {
|
|
workspaceId: workspace.id,
|
|
email: input.email,
|
|
userId: existingUser?.id ?? null,
|
|
createdBy: userId,
|
|
role: "member",
|
|
status: "invited",
|
|
});
|
|
|
|
if (!invite)
|
|
throw new TRPCError({
|
|
message: `Unable to invite user with email ${input.email}`,
|
|
code: "INTERNAL_SERVER_ERROR",
|
|
});
|
|
|
|
const { status } = await ctx.auth.api.signInMagicLink({
|
|
email: input.email,
|
|
callbackURL: `/boards?type=invite&memberPublicId=${invite.publicId}`,
|
|
});
|
|
|
|
if (!status) {
|
|
console.error("Failed to send magic link invitation:", {
|
|
email: input.email,
|
|
callbackURL: `/boards?type=invite&memberPublicId=${invite.publicId}`,
|
|
});
|
|
|
|
await memberRepo.softDelete(ctx.db, {
|
|
memberId: invite.id,
|
|
deletedAt: new Date(),
|
|
deletedBy: userId,
|
|
});
|
|
|
|
throw new TRPCError({
|
|
message: `Failed to send magic link invitation to user with email ${input.email}.`,
|
|
code: "INTERNAL_SERVER_ERROR",
|
|
});
|
|
}
|
|
|
|
return invite;
|
|
}),
|
|
delete: protectedProcedure
|
|
.meta({
|
|
openapi: {
|
|
summary: "Delete a member from a workspace",
|
|
method: "DELETE",
|
|
path: "/workspaces/{workspacePublicId}/members/{memberPublicId}",
|
|
description: "Deletes a member from a workspace",
|
|
tags: ["Workspaces"],
|
|
protect: true,
|
|
},
|
|
})
|
|
.input(
|
|
z.object({
|
|
workspacePublicId: z.string().min(12),
|
|
memberPublicId: z.string().min(12),
|
|
}),
|
|
)
|
|
.output(z.object({ success: z.boolean() }))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const userId = ctx.user?.id;
|
|
|
|
if (!userId)
|
|
throw new TRPCError({
|
|
message: `User not authenticated`,
|
|
code: "UNAUTHORIZED",
|
|
});
|
|
|
|
const workspace = await workspaceRepo.getByPublicId(
|
|
ctx.db,
|
|
input.workspacePublicId,
|
|
);
|
|
|
|
if (!workspace)
|
|
throw new TRPCError({
|
|
message: `Workspace with public ID ${input.workspacePublicId} not found`,
|
|
code: "NOT_FOUND",
|
|
});
|
|
|
|
await assertUserInWorkspace(ctx.db, userId, workspace.id, "admin");
|
|
|
|
const member = await memberRepo.getByPublicId(
|
|
ctx.db,
|
|
input.memberPublicId,
|
|
);
|
|
|
|
if (!member)
|
|
throw new TRPCError({
|
|
message: `Member with public ID ${input.memberPublicId} not found`,
|
|
code: "NOT_FOUND",
|
|
});
|
|
|
|
const deletedMember = await memberRepo.softDelete(ctx.db, {
|
|
memberId: member.id,
|
|
deletedAt: new Date(),
|
|
deletedBy: userId,
|
|
});
|
|
|
|
if (!deletedMember)
|
|
throw new TRPCError({
|
|
message: `Failed to delete member with public ID ${input.memberPublicId}`,
|
|
code: "INTERNAL_SERVER_ERROR",
|
|
});
|
|
|
|
// Handle subscription seat decrement for cloud environment
|
|
if (process.env.NEXT_PUBLIC_KAN_ENV === "cloud") {
|
|
const subscriptions = await subscriptionRepo.getByReferenceId(
|
|
ctx.db,
|
|
workspace.publicId,
|
|
);
|
|
|
|
// get the active subscriptions
|
|
const activeTeamSubscription = getSubscriptionByPlan(
|
|
subscriptions,
|
|
"team",
|
|
);
|
|
const unlimitedSeats = hasUnlimitedSeats(subscriptions);
|
|
|
|
// Only decrease seats if there's an active subscription and stripeSubscriptionId
|
|
if (activeTeamSubscription?.stripeSubscriptionId && !unlimitedSeats) {
|
|
try {
|
|
await updateSubscriptionSeats(
|
|
activeTeamSubscription.stripeSubscriptionId,
|
|
-1,
|
|
);
|
|
} catch (error) {
|
|
console.error(
|
|
"Failed to decrease Stripe subscription seats:",
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { success: true };
|
|
}),
|
|
getActiveInviteLink: protectedProcedure
|
|
.meta({
|
|
openapi: {
|
|
summary: "Get active invite link for workspace",
|
|
method: "GET",
|
|
path: "/workspaces/{workspacePublicId}/invite",
|
|
description: "Gets the active invite link for a workspace",
|
|
tags: ["Invites"],
|
|
protect: true,
|
|
},
|
|
})
|
|
.input(
|
|
z.object({
|
|
workspacePublicId: z.string().min(12),
|
|
}),
|
|
)
|
|
.output(
|
|
z.object({
|
|
id: z.number().optional(),
|
|
inviteCode: z.string().optional(),
|
|
inviteLink: z.string().optional(),
|
|
isActive: z.boolean(),
|
|
expiresAt: z.date().optional(),
|
|
}),
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
const userId = ctx.user?.id;
|
|
|
|
if (!userId)
|
|
throw new TRPCError({
|
|
message: `User not authenticated`,
|
|
code: "UNAUTHORIZED",
|
|
});
|
|
|
|
const workspace = await workspaceRepo.getByPublicId(
|
|
ctx.db,
|
|
input.workspacePublicId,
|
|
);
|
|
|
|
if (!workspace)
|
|
throw new TRPCError({
|
|
message: `Workspace not found`,
|
|
code: "NOT_FOUND",
|
|
});
|
|
|
|
// Check if user is in workspace
|
|
await assertUserInWorkspace(ctx.db, userId, workspace.id);
|
|
|
|
// Get active invite link for this workspace
|
|
const activeInviteLink = await inviteLinkRepo.getActiveForWorkspace(
|
|
ctx.db,
|
|
workspace.id,
|
|
);
|
|
|
|
if (
|
|
activeInviteLink &&
|
|
(!activeInviteLink.expiresAt || new Date() < activeInviteLink.expiresAt)
|
|
) {
|
|
return {
|
|
id: activeInviteLink.id,
|
|
inviteCode: activeInviteLink.code,
|
|
inviteLink: `${process.env.NEXT_PUBLIC_BASE_URL}/invite/${activeInviteLink.code}`,
|
|
isActive: true,
|
|
expiresAt: activeInviteLink.expiresAt ?? undefined,
|
|
};
|
|
}
|
|
|
|
return { isActive: false };
|
|
}),
|
|
createInviteLink: protectedProcedure
|
|
.meta({
|
|
openapi: {
|
|
summary: "Create invite link for workspace",
|
|
method: "POST",
|
|
path: "/workspaces/{workspacePublicId}/invites",
|
|
description: "Create invite link for a workspace",
|
|
tags: ["Invites"],
|
|
protect: true,
|
|
},
|
|
})
|
|
.input(
|
|
z.object({
|
|
workspacePublicId: z.string().min(12),
|
|
}),
|
|
)
|
|
.output(
|
|
z.object({
|
|
publicId: z.string().min(12),
|
|
inviteCode: z.string(),
|
|
inviteLink: z.string(),
|
|
expiresAt: z.date().nullable(),
|
|
}),
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const userId = ctx.user?.id;
|
|
|
|
if (!userId)
|
|
throw new TRPCError({
|
|
message: `User not authenticated`,
|
|
code: "UNAUTHORIZED",
|
|
});
|
|
|
|
const workspace = await workspaceRepo.getByPublicId(
|
|
ctx.db,
|
|
input.workspacePublicId,
|
|
);
|
|
|
|
if (!workspace)
|
|
throw new TRPCError({
|
|
message: `Workspace not found`,
|
|
code: "NOT_FOUND",
|
|
});
|
|
|
|
// Check if user is in workspace
|
|
await assertUserInWorkspace(ctx.db, userId, workspace.id, "admin");
|
|
|
|
// Check subscription for cloud environment
|
|
if (process.env.NEXT_PUBLIC_KAN_ENV === "cloud") {
|
|
const subscriptions = await subscriptionRepo.getByReferenceId(
|
|
ctx.db,
|
|
workspace.publicId,
|
|
);
|
|
|
|
const activeTeamSubscription = getSubscriptionByPlan(
|
|
subscriptions,
|
|
"team",
|
|
);
|
|
const activeProSubscription = getSubscriptionByPlan(
|
|
subscriptions,
|
|
"pro",
|
|
);
|
|
|
|
if (!activeTeamSubscription && !activeProSubscription) {
|
|
throw new TRPCError({
|
|
message: `Invite links require a Team or Pro subscription`,
|
|
code: "FORBIDDEN",
|
|
});
|
|
}
|
|
}
|
|
|
|
// Deactivate any existing active invite links
|
|
await inviteLinkRepo.deactivateAllActiveForWorkspace(ctx.db, {
|
|
workspaceId: workspace.id,
|
|
updatedBy: userId,
|
|
});
|
|
|
|
// Generate new invite code
|
|
const inviteCode = generateUID();
|
|
const expiresAt = new Date();
|
|
expiresAt.setDate(expiresAt.getDate() + 7);
|
|
|
|
// Create new invite link
|
|
const inviteLink = await inviteLinkRepo.createInviteLink(ctx.db, {
|
|
workspaceId: workspace.id,
|
|
code: inviteCode,
|
|
expiresAt,
|
|
createdBy: userId,
|
|
});
|
|
|
|
if (!inviteLink) {
|
|
throw new TRPCError({
|
|
message: `Failed to create invite link`,
|
|
code: "INTERNAL_SERVER_ERROR",
|
|
});
|
|
}
|
|
|
|
return {
|
|
publicId: inviteLink.publicId,
|
|
inviteCode: inviteLink.code,
|
|
inviteLink: `${process.env.NEXT_PUBLIC_BASE_URL}/invite/${inviteLink.code}`,
|
|
expiresAt: inviteLink.expiresAt,
|
|
};
|
|
}),
|
|
deactivateInviteLink: protectedProcedure
|
|
.meta({
|
|
openapi: {
|
|
summary: "Deactivate invite link for workspace",
|
|
method: "DELETE",
|
|
path: "/workspaces/{workspacePublicId}/invites",
|
|
description: "Deactivates the invite link for a workspace",
|
|
tags: ["Invites"],
|
|
protect: true,
|
|
},
|
|
})
|
|
.input(
|
|
z.object({
|
|
workspacePublicId: z.string().min(12),
|
|
}),
|
|
)
|
|
.output(
|
|
z.object({
|
|
success: z.boolean(),
|
|
}),
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const userId = ctx.user?.id;
|
|
|
|
if (!userId)
|
|
throw new TRPCError({
|
|
message: `User not authenticated`,
|
|
code: "UNAUTHORIZED",
|
|
});
|
|
|
|
const workspace = await workspaceRepo.getByPublicId(
|
|
ctx.db,
|
|
input.workspacePublicId,
|
|
);
|
|
|
|
if (!workspace)
|
|
throw new TRPCError({
|
|
message: `Workspace not found`,
|
|
code: "NOT_FOUND",
|
|
});
|
|
|
|
// Check if user is in workspace
|
|
await assertUserInWorkspace(ctx.db, userId, workspace.id, "admin");
|
|
|
|
// Deactivate all active invite links
|
|
await inviteLinkRepo.deactivateAllActiveForWorkspace(ctx.db, {
|
|
workspaceId: workspace.id,
|
|
updatedBy: userId,
|
|
});
|
|
|
|
return { success: true };
|
|
}),
|
|
getInviteByCode: publicProcedure
|
|
.meta({
|
|
openapi: {
|
|
summary: "Get invite information by code",
|
|
method: "GET",
|
|
path: "/workspaces/{workspacePublicId}/invites/{inviteCode}",
|
|
description: "Get invite information by invite code",
|
|
tags: ["Invites"],
|
|
protect: false,
|
|
},
|
|
})
|
|
.input(
|
|
z.object({
|
|
inviteCode: z.string().min(12),
|
|
}),
|
|
)
|
|
.output(
|
|
z
|
|
.object({
|
|
publicId: z.string().min(12),
|
|
status: z.string(),
|
|
expiresAt: z.date().nullable(),
|
|
})
|
|
.optional(),
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
const invite = await inviteLinkRepo.getByCode(ctx.db, input.inviteCode);
|
|
|
|
if (
|
|
!invite ||
|
|
invite.status !== "active" ||
|
|
(invite.expiresAt && new Date() > invite.expiresAt)
|
|
) {
|
|
throw new TRPCError({
|
|
message: `Invalid or expired invite link`,
|
|
code: "BAD_REQUEST",
|
|
});
|
|
}
|
|
|
|
return {
|
|
publicId: invite.publicId,
|
|
status: invite.status,
|
|
expiresAt: invite.expiresAt ?? null,
|
|
};
|
|
}),
|
|
acceptInviteLink: publicProcedure
|
|
.meta({
|
|
openapi: {
|
|
summary: "Accept an invite link",
|
|
method: "POST",
|
|
path: "/workspaces/{workspacePublicId}/invites/accept",
|
|
description: "Accepts an invitation via invite link",
|
|
tags: ["Invites"],
|
|
protect: false,
|
|
},
|
|
})
|
|
.input(
|
|
z.object({
|
|
inviteCode: z.string().min(12),
|
|
}),
|
|
)
|
|
.output(
|
|
z.object({
|
|
success: z.boolean(),
|
|
workspacePublicId: z.string().optional(),
|
|
workspaceSlug: z.string().optional(),
|
|
}),
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const userId = ctx.user?.id;
|
|
|
|
if (!userId)
|
|
throw new TRPCError({
|
|
message: `User not authenticated`,
|
|
code: "UNAUTHORIZED",
|
|
});
|
|
|
|
const invite = await inviteLinkRepo.getByCode(ctx.db, input.inviteCode);
|
|
|
|
if (
|
|
!invite ||
|
|
invite.status !== "active" ||
|
|
(invite.expiresAt && new Date() > invite.expiresAt)
|
|
)
|
|
throw new TRPCError({
|
|
message: `Invalid or expired invite link`,
|
|
code: "BAD_REQUEST",
|
|
});
|
|
|
|
const workspace = await workspaceRepo.getById(ctx.db, invite.workspaceId);
|
|
|
|
if (!workspace)
|
|
throw new TRPCError({
|
|
message: `Workspace not found`,
|
|
code: "NOT_FOUND",
|
|
});
|
|
|
|
const isMember = await workspaceRepo.isUserInWorkspace(
|
|
ctx.db,
|
|
userId,
|
|
invite.workspaceId,
|
|
);
|
|
|
|
if (isMember) {
|
|
throw new TRPCError({
|
|
message: `User is already a member of this workspace`,
|
|
code: "CONFLICT",
|
|
});
|
|
}
|
|
|
|
const user = await userRepo.getById(ctx.db, userId);
|
|
|
|
if (!user)
|
|
throw new TRPCError({
|
|
message: `User not found`,
|
|
code: "NOT_FOUND",
|
|
});
|
|
|
|
if (process.env.NEXT_PUBLIC_KAN_ENV === "cloud") {
|
|
const subscriptions = await subscriptionRepo.getByReferenceId(
|
|
ctx.db,
|
|
workspace.publicId,
|
|
);
|
|
|
|
// get the active subscriptions
|
|
const activeTeamSubscription = getSubscriptionByPlan(
|
|
subscriptions,
|
|
"team",
|
|
);
|
|
const activeProSubscription = getSubscriptionByPlan(
|
|
subscriptions,
|
|
"pro",
|
|
);
|
|
const unlimitedSeats = hasUnlimitedSeats(subscriptions);
|
|
|
|
if (!activeTeamSubscription && !activeProSubscription) {
|
|
throw new TRPCError({
|
|
message: `Workspace with public ID ${workspace.publicId} does not have an active subscription`,
|
|
code: "NOT_FOUND",
|
|
});
|
|
}
|
|
|
|
// Update the Stripe subscription
|
|
if (activeTeamSubscription?.stripeSubscriptionId && !unlimitedSeats) {
|
|
try {
|
|
await updateSubscriptionSeats(
|
|
activeTeamSubscription.stripeSubscriptionId,
|
|
1,
|
|
);
|
|
} catch (error) {
|
|
console.error("Failed to update Stripe subscription seats:", error);
|
|
throw new TRPCError({
|
|
message: `Failed to update subscription for the new member.`,
|
|
code: "INTERNAL_SERVER_ERROR",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
await memberRepo.create(ctx.db, {
|
|
workspaceId: invite.workspaceId,
|
|
email: user.email,
|
|
userId: user.id,
|
|
createdBy: user.id,
|
|
role: "member",
|
|
status: "active",
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
workspacePublicId: workspace.publicId,
|
|
workspaceSlug: workspace.slug,
|
|
};
|
|
}),
|
|
});
|