feat: add repo funcs and share link toggle
This commit is contained in:
@@ -3,7 +3,12 @@ import { t } from "@lingui/core/macro";
|
|||||||
import { env } from "next-runtime-env";
|
import { env } from "next-runtime-env";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { HiXMark } from "react-icons/hi2";
|
import {
|
||||||
|
HiInformationCircle,
|
||||||
|
HiMiniCheck,
|
||||||
|
HiOutlineDocumentDuplicate,
|
||||||
|
HiXMark,
|
||||||
|
} from "react-icons/hi2";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import type { InviteMemberInput } from "@kan/api/types";
|
import type { InviteMemberInput } from "@kan/api/types";
|
||||||
@@ -31,7 +36,11 @@ export function InviteMemberForm({
|
|||||||
userId: string | undefined;
|
userId: string | undefined;
|
||||||
}) {
|
}) {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const [isCreateAnotherEnabled, setIsCreateAnotherEnabled] = useState(false);
|
const [isShareInviteLinkEnabled, setIsShareInviteLinkEnabled] =
|
||||||
|
useState(false);
|
||||||
|
const [inviteLink, setInviteLink] = useState<string>("");
|
||||||
|
const [isLoadingInviteLink, setIsLoadingInviteLink] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
const { closeModal } = useModal();
|
const { closeModal } = useModal();
|
||||||
const { workspace } = useWorkspace();
|
const { workspace } = useWorkspace();
|
||||||
const { showPopup } = usePopup();
|
const { showPopup } = usePopup();
|
||||||
@@ -56,6 +65,21 @@ export function InviteMemberForm({
|
|||||||
|
|
||||||
const refetchBoards = () => utils.board.all.refetch();
|
const refetchBoards = () => utils.board.all.refetch();
|
||||||
|
|
||||||
|
// Fetch active invite link on component mount
|
||||||
|
const { data: activeInviteLink, refetch: refetchInviteLink } =
|
||||||
|
api.member.getActiveInviteLink.useQuery(
|
||||||
|
{ workspacePublicId: workspace.publicId || "" },
|
||||||
|
{ enabled: !!workspace.publicId },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set initial state based on active invite link
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeInviteLink) {
|
||||||
|
setIsShareInviteLinkEnabled(activeInviteLink.isActive);
|
||||||
|
setInviteLink(activeInviteLink.inviteLink || "");
|
||||||
|
}
|
||||||
|
}, [activeInviteLink]);
|
||||||
|
|
||||||
const inviteMember = api.member.invite.useMutation({
|
const inviteMember = api.member.invite.useMutation({
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
closeModal();
|
closeModal();
|
||||||
@@ -64,7 +88,7 @@ export function InviteMemberForm({
|
|||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
reset();
|
reset();
|
||||||
if (!isCreateAnotherEnabled) closeModal();
|
if (!isShareInviteLinkEnabled) closeModal();
|
||||||
|
|
||||||
if (error.data?.code === "CONFLICT") {
|
if (error.data?.code === "CONFLICT") {
|
||||||
showPopup({
|
showPopup({
|
||||||
@@ -82,6 +106,36 @@ export function InviteMemberForm({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const createInviteLink = api.member.createInviteLink.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setInviteLink(data.inviteLink);
|
||||||
|
setIsLoadingInviteLink(false);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
setIsLoadingInviteLink(false);
|
||||||
|
showPopup({
|
||||||
|
header: t`Error creating invite link`,
|
||||||
|
message: t`Please try again later.`,
|
||||||
|
icon: "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deactivateInviteLink = api.member.deactivateInviteLink.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
setInviteLink("");
|
||||||
|
setIsLoadingInviteLink(false);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
setIsLoadingInviteLink(false);
|
||||||
|
showPopup({
|
||||||
|
header: t`Error deactivating invite link`,
|
||||||
|
message: t`Please try again later.`,
|
||||||
|
icon: "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const teamSubscription = getSubscriptionByPlan(subscriptions, "team");
|
const teamSubscription = getSubscriptionByPlan(subscriptions, "team");
|
||||||
const proSubscription = getSubscriptionByPlan(subscriptions, "pro");
|
const proSubscription = getSubscriptionByPlan(subscriptions, "pro");
|
||||||
|
|
||||||
@@ -109,6 +163,43 @@ export function InviteMemberForm({
|
|||||||
inviteMember.mutate(member);
|
inviteMember.mutate(member);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleInviteLinkToggle = async () => {
|
||||||
|
setIsLoadingInviteLink(true);
|
||||||
|
|
||||||
|
if (isShareInviteLinkEnabled && workspace.publicId) {
|
||||||
|
// Deactivate invite link
|
||||||
|
await deactivateInviteLink.mutateAsync({
|
||||||
|
workspacePublicId: workspace.publicId,
|
||||||
|
});
|
||||||
|
setIsShareInviteLinkEnabled(false);
|
||||||
|
} else {
|
||||||
|
// Create new invite link
|
||||||
|
await createInviteLink.mutateAsync({
|
||||||
|
workspacePublicId: workspace.publicId,
|
||||||
|
});
|
||||||
|
setIsShareInviteLinkEnabled(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(inviteLink);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
showPopup({
|
||||||
|
header: t`Invite link copied`,
|
||||||
|
message: t`Invite link copied to clipboard`,
|
||||||
|
icon: "success",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
showPopup({
|
||||||
|
header: t`Error`,
|
||||||
|
message: t`Failed to copy invite link`,
|
||||||
|
icon: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleUpgrade = async () => {
|
const handleUpgrade = async () => {
|
||||||
const { data, error } = await authClient.subscription.upgrade({
|
const { data, error } = await authClient.subscription.upgrade({
|
||||||
plan: "team",
|
plan: "team",
|
||||||
@@ -173,6 +264,34 @@ export function InviteMemberForm({
|
|||||||
}}
|
}}
|
||||||
errorMessage={errors.email?.message}
|
errorMessage={errors.email?.message}
|
||||||
/>
|
/>
|
||||||
|
{isShareInviteLinkEnabled && inviteLink && (
|
||||||
|
<div className="my-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
value={inviteLink}
|
||||||
|
className="pr-10 text-sm text-light-900 dark:text-dark-900"
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute inset-y-0 right-0 flex items-center pr-3 text-light-900 hover:text-light-950 dark:text-dark-900 dark:hover:text-dark-950"
|
||||||
|
onClick={copyToClipboard}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<HiMiniCheck className="h-5 w-5 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<HiOutlineDocumentDuplicate className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-start gap-1">
|
||||||
|
<HiInformationCircle className="mt-0.5 h-4 w-4 text-dark-900" />
|
||||||
|
<p className="text-xs text-gray-500 dark:text-dark-900">
|
||||||
|
{t`Anyone with this link can join your workspace`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
|
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
|
||||||
<div className="mt-3 rounded-md bg-light-100 p-3 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900">
|
<div className="mt-3 rounded-md bg-light-100 p-3 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900">
|
||||||
@@ -205,11 +324,9 @@ export function InviteMemberForm({
|
|||||||
{(hasTeamSubscription || hasProSubscription) &&
|
{(hasTeamSubscription || hasProSubscription) &&
|
||||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
|
env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
|
||||||
<Toggle
|
<Toggle
|
||||||
label={t`Invite another`}
|
label={t`Share invite link`}
|
||||||
isChecked={isCreateAnotherEnabled}
|
isChecked={isShareInviteLinkEnabled}
|
||||||
onChange={() =>
|
onChange={handleInviteLinkToggle}
|
||||||
setIsCreateAnotherEnabled(!isCreateAnotherEnabled)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
@@ -226,7 +343,7 @@ export function InviteMemberForm({
|
|||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={inviteMember.isPending}
|
disabled={inviteMember.isPending || isShareInviteLinkEnabled}
|
||||||
isLoading={inviteMember.isPending}
|
isLoading={inviteMember.isPending}
|
||||||
className="inline-flex w-full justify-center rounded-md bg-light-1000 px-3 py-2 text-sm font-semibold text-light-50 shadow-sm focus-visible:outline-none dark:bg-dark-1000 dark:text-dark-50"
|
className="inline-flex w-full justify-center rounded-md bg-light-1000 px-3 py-2 text-sm font-semibold text-light-50 shadow-sm focus-visible:outline-none dark:bg-dark-1000 dark:text-dark-50"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import * as inviteLinkRepo from "@kan/db/repository/inviteLink.repo";
|
||||||
import * as memberRepo from "@kan/db/repository/member.repo";
|
import * as memberRepo from "@kan/db/repository/member.repo";
|
||||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||||
import * as userRepo from "@kan/db/repository/user.repo";
|
import * as userRepo from "@kan/db/repository/user.repo";
|
||||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||||
import { getSubscriptionByPlan, hasUnlimitedSeats } from "@kan/shared/utils";
|
import {
|
||||||
|
generateUID,
|
||||||
|
getSubscriptionByPlan,
|
||||||
|
hasUnlimitedSeats,
|
||||||
|
} from "@kan/shared/utils";
|
||||||
import { updateSubscriptionSeats } from "@kan/stripe";
|
import { updateSubscriptionSeats } from "@kan/stripe";
|
||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||||
import { assertUserInWorkspace } from "../utils/auth";
|
import { assertUserInWorkspace } from "../utils/auth";
|
||||||
|
|
||||||
export const memberRouter = createTRPCRouter({
|
export const memberRouter = createTRPCRouter({
|
||||||
@@ -241,4 +246,338 @@ export const memberRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return { success: true };
|
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);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// 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",
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
71
packages/db/src/repository/inviteLink.repo.ts
Normal file
71
packages/db/src/repository/inviteLink.repo.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { and, eq, gt } from "drizzle-orm";
|
||||||
|
|
||||||
|
import type { dbClient } from "@kan/db/client";
|
||||||
|
import { workspaceInviteLinks } from "@kan/db/schema";
|
||||||
|
import { generateUID } from "@kan/shared/utils";
|
||||||
|
|
||||||
|
export const createInviteLink = async (
|
||||||
|
db: dbClient,
|
||||||
|
args: {
|
||||||
|
workspaceId: number;
|
||||||
|
code: string;
|
||||||
|
expiresAt: Date | null;
|
||||||
|
createdBy: string;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const [result] = await db
|
||||||
|
.insert(workspaceInviteLinks)
|
||||||
|
.values({
|
||||||
|
publicId: generateUID(),
|
||||||
|
workspaceId: args.workspaceId,
|
||||||
|
code: args.code,
|
||||||
|
expiresAt: args.expiresAt ?? null,
|
||||||
|
status: "active",
|
||||||
|
createdBy: args.createdBy,
|
||||||
|
})
|
||||||
|
.returning({
|
||||||
|
publicId: workspaceInviteLinks.publicId,
|
||||||
|
code: workspaceInviteLinks.code,
|
||||||
|
status: workspaceInviteLinks.status,
|
||||||
|
expiresAt: workspaceInviteLinks.expiresAt,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deactivateAllActiveForWorkspace = async (
|
||||||
|
db: dbClient,
|
||||||
|
args: { workspaceId: number; updatedBy: string },
|
||||||
|
) => {
|
||||||
|
await db
|
||||||
|
.update(workspaceInviteLinks)
|
||||||
|
.set({
|
||||||
|
status: "inactive",
|
||||||
|
updatedBy: args.updatedBy,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(workspaceInviteLinks.workspaceId, args.workspaceId),
|
||||||
|
eq(workspaceInviteLinks.status, "active"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getActiveForWorkspace = async (
|
||||||
|
db: dbClient,
|
||||||
|
workspaceId: number,
|
||||||
|
) => {
|
||||||
|
return db.query.workspaceInviteLinks.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(workspaceInviteLinks.workspaceId, workspaceId),
|
||||||
|
eq(workspaceInviteLinks.status, "active"),
|
||||||
|
),
|
||||||
|
orderBy: (links, { desc }) => [desc(links.createdAt)],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getByCode = async (db: dbClient, code: string) => {
|
||||||
|
return db.query.workspaceInviteLinks.findFirst({
|
||||||
|
where: eq(workspaceInviteLinks.code, code),
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -87,11 +87,25 @@ export const getByPublicId = (db: dbClient, workspacePublicId: string) => {
|
|||||||
publicId: true,
|
publicId: true,
|
||||||
name: true,
|
name: true,
|
||||||
plan: true,
|
plan: true,
|
||||||
|
slug: true,
|
||||||
},
|
},
|
||||||
where: eq(workspaces.publicId, workspacePublicId),
|
where: eq(workspaces.publicId, workspacePublicId),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getById = (db: dbClient, workspaceId: number) => {
|
||||||
|
return db.query.workspaces.findFirst({
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
publicId: true,
|
||||||
|
name: true,
|
||||||
|
plan: true,
|
||||||
|
slug: true,
|
||||||
|
},
|
||||||
|
where: eq(workspaces.id, workspaceId),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const getByPublicIdWithMembers = (
|
export const getByPublicIdWithMembers = (
|
||||||
db: dbClient,
|
db: dbClient,
|
||||||
workspacePublicId: string,
|
workspacePublicId: string,
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ export * from "./users";
|
|||||||
export * from "./integrations";
|
export * from "./integrations";
|
||||||
export * from "./workspaces";
|
export * from "./workspaces";
|
||||||
export * from "./subscriptions";
|
export * from "./subscriptions";
|
||||||
|
export * from "./workspaceInviteLinks";
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import {
|
|||||||
pgEnum,
|
pgEnum,
|
||||||
pgTable,
|
pgTable,
|
||||||
timestamp,
|
timestamp,
|
||||||
|
uuid,
|
||||||
varchar,
|
varchar,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
import { users } from "./users";
|
||||||
import { workspaces } from "./workspaces";
|
import { workspaces } from "./workspaces";
|
||||||
|
|
||||||
export const inviteLinkStatuses = ["active", "inactive"] as const;
|
export const inviteLinkStatuses = ["active", "inactive"] as const;
|
||||||
@@ -18,6 +20,7 @@ export const inviteLinkStatusEnum = pgEnum(
|
|||||||
|
|
||||||
export const workspaceInviteLinks = pgTable("workspace_invite_links", {
|
export const workspaceInviteLinks = pgTable("workspace_invite_links", {
|
||||||
id: bigserial("id", { mode: "number" }).primaryKey(),
|
id: bigserial("id", { mode: "number" }).primaryKey(),
|
||||||
|
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
|
||||||
workspaceId: bigint("workspaceId", { mode: "number" })
|
workspaceId: bigint("workspaceId", { mode: "number" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => workspaces.id, { onDelete: "cascade" }),
|
.references(() => workspaces.id, { onDelete: "cascade" }),
|
||||||
@@ -25,4 +28,11 @@ export const workspaceInviteLinks = pgTable("workspace_invite_links", {
|
|||||||
status: inviteLinkStatusEnum("status").notNull().default("active"),
|
status: inviteLinkStatusEnum("status").notNull().default("active"),
|
||||||
expiresAt: timestamp("expiresAt"),
|
expiresAt: timestamp("expiresAt"),
|
||||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
|
createdBy: uuid("createdBy").references(() => users.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
updatedAt: timestamp("updatedAt"),
|
||||||
|
updatedBy: uuid("updatedBy").references(() => users.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
}).enableRLS();
|
}).enableRLS();
|
||||||
|
|||||||
Reference in New Issue
Block a user