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>>()) .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, }; }), });