Compare commits

..

8 Commits

Author SHA1 Message Date
Henry
d1d8a43d3c fix: remove content type restriction on attachments 2026-02-04 22:37:18 +00:00
Henry
8ed719c485 fix: support external avatar URLs in generateAvatarUrl 2026-02-04 22:20:23 +00:00
Henry
f455bec6a7 fix: hide tooltip if content is empty 2026-02-04 21:57:50 +00:00
Henry
aee5391f28 fix: show avatar image in user menu 2026-02-04 21:53:04 +00:00
Henry
c8517ed4c2 feat: generate presigned URLs for avatars 2026-02-04 21:50:37 +00:00
Henry
ceac4c0d81 refactor: use createS3Client in auth hooks 2026-02-04 20:49:20 +00:00
Henry
4c5856ec24 feat: update avatar upload to use new endpoint 2026-02-04 20:47:26 +00:00
Henry
976373a65b refactor: replace presigned URL uploads with backend upload endpoints 2026-02-04 20:46:23 +00:00
30 changed files with 230 additions and 4732 deletions

View File

@@ -47,17 +47,7 @@ See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
## Self Hosting 🐳 ## Self Hosting 🐳
### One-click Deployments The easiest way to self-host Kan is with Docker Compose. This will set up everything for you including your postgres database.
The easiest way to deploy Kan is through Railway. We've partnered with Railway to maintain an official template that supports the development of the project.
<a href="https://railway.com/deploy/kan?referralCode=bZPsr2&utm_medium=integration&utm_source=template&utm_campaign=generic">
<img src="https://railway.app/button.svg" alt="Deploy on Railway" height="40" />
</a>
### Docker Compose
Alternatively, you can self-host Kan with Docker Compose. This will set up everything for you including your postgres database.
1. Create a new file called `docker-compose.yml` and paste the following configuration: 1. Create a new file called `docker-compose.yml` and paste the following configuration:

View File

@@ -38,7 +38,6 @@
"@tiptap/extension-link": "^2.22.2", "@tiptap/extension-link": "^2.22.2",
"@tiptap/extension-mention": "^3.0.9", "@tiptap/extension-mention": "^3.0.9",
"@tiptap/extension-placeholder": "^2.14.0", "@tiptap/extension-placeholder": "^2.14.0",
"@tiptap/extension-typography": "^3.18.0",
"@tiptap/pm": "^2.14.0", "@tiptap/pm": "^2.14.0",
"@tiptap/react": "^2.14.0", "@tiptap/react": "^2.14.0",
"@tiptap/starter-kit": "^2.14.0", "@tiptap/starter-kit": "^2.14.0",

View File

@@ -361,8 +361,8 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
})} })}
</div> </div>
)} )}
{!(isCredentialsEnabled || isMagicLinkAvailable) && <form onSubmit={handleSubmit(onSubmit)}>
socialProviders?.length === 0 && ( {!isCredentialsEnabled && socialProviders?.length === 0 && (
<div className="flex w-full items-center gap-4"> <div className="flex w-full items-center gap-4">
<div className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" /> <div className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" />
<span className="text-center text-sm text-light-900 dark:text-dark-900"> <span className="text-center text-sm text-light-900 dark:text-dark-900">
@@ -371,62 +371,65 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
<div className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" /> <div className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" />
</div> </div>
)} )}
{(isCredentialsEnabled || isMagicLinkAvailable) && ( {!isCredentialsEnabled && socialProviders?.length !== 0 && (
<form onSubmit={handleSubmit(onSubmit)}> <div className="mb-[1.5rem] flex w-full items-center gap-4">
{socialProviders?.length !== 0 && ( <div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
<div className="mb-[1.5rem] flex w-full items-center gap-4"> <span className="text-sm text-light-900 dark:text-dark-900">
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" /> {t`or`}
<span className="text-sm text-light-900 dark:text-dark-900"> </span>
{t`or`} <div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
</span> </div>
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" /> )}
</div> <div className="space-y-2">
)} {isSignUp && isCredentialsEnabled && (
<div className="space-y-2">
{isSignUp && isCredentialsEnabled && (
<div>
<Input
{...register("name", { required: true })}
placeholder={t`Enter your name`}
/>
{errors.name && (
<p className="mt-2 text-xs text-red-400">
{t`Please enter a valid name`}
</p>
)}
</div>
)}
<div> <div>
<Input <Input
{...register("email", { required: true })} {...register("name", { required: true })}
placeholder={t`Enter your email address`} placeholder={t`Enter your name`}
/> />
{errors.email && ( {errors.name && (
<p className="mt-2 text-xs text-red-400"> <p className="mt-2 text-xs text-red-400">
{t`Please enter a valid email address`} {t`Please enter a valid name`}
</p> </p>
)} )}
</div> </div>
)}
{isCredentialsEnabled && ( {(isCredentialsEnabled || isMagicLinkAvailable) && (
<>
<div> <div>
<Input <Input
type="password" {...register("email", { required: true })}
{...register("password", { required: true })} placeholder={t`Enter your email address`}
placeholder={t`Enter your password`}
/> />
{errors.password && ( {errors.email && (
<p className="mt-2 text-xs text-red-400"> <p className="mt-2 text-xs text-red-400">
{errors.password.message ?? {t`Please enter a valid email address`}
t`Please enter a valid password`}
</p> </p>
)} )}
</div> </div>
)}
{loginError && ( {isCredentialsEnabled && (
<p className="mt-2 text-xs text-red-400">{loginError}</p> <div>
)} <Input
</div> type="password"
{...register("password", { required: true })}
placeholder={t`Enter your password`}
/>
{errors.password && (
<p className="mt-2 text-xs text-red-400">
{errors.password.message ??
t`Please enter a valid password`}
</p>
)}
</div>
)}
</>
)}
{loginError && (
<p className="mt-2 text-xs text-red-400">{loginError}</p>
)}
</div>
{(isCredentialsEnabled || isMagicLinkAvailable) && (
<div className="mt-[1.5rem] flex items-center gap-4"> <div className="mt-[1.5rem] flex items-center gap-4">
<Button <Button
isLoading={isLoginWithEmailPending} isLoading={isLoginWithEmailPending}
@@ -438,11 +441,8 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
{isMagicLinkMode ? t`magic link` : t`email`} {isMagicLinkMode ? t`magic link` : t`email`}
</Button> </Button>
</div> </div>
</form> )}
)} </form>
{!(isCredentialsEnabled || isMagicLinkAvailable) && loginError && (
<p className="mt-2 text-xs text-red-400">{loginError}</p>
)}
</div> </div>
); );
} }

View File

@@ -17,7 +17,6 @@ import {
ReactRenderer, ReactRenderer,
useEditor, useEditor,
} from "@tiptap/react"; } from "@tiptap/react";
import Typography from "@tiptap/extension-typography";
import StarterKit from "@tiptap/starter-kit"; import StarterKit from "@tiptap/starter-kit";
import Suggestion from "@tiptap/suggestion"; import Suggestion from "@tiptap/suggestion";
import { import {
@@ -387,54 +386,46 @@ export interface SlashNodeAttrs {
label?: string | null; label?: string | null;
} }
const getCommandItems = (disableHeadings: boolean): SlashCommandItem[] => { const CommandItems: SlashCommandItem[] = [
const headingCommands: SlashCommandItem[] = disableHeadings {
? [] title: "Heading 1",
: [ icon: <HiH1 />,
{ command: ({ editor }) =>
title: "Heading 1", editor.chain().focus().setHeading({ level: 1 }).run(),
icon: <HiH1 />, },
command: ({ editor }) => {
editor.chain().focus().setHeading({ level: 1 }).run(), title: "Heading 2",
}, icon: <HiH2 />,
{ command: ({ editor }) =>
title: "Heading 2", editor.chain().focus().setHeading({ level: 2 }).run(),
icon: <HiH2 />, },
command: ({ editor }) => {
editor.chain().focus().setHeading({ level: 2 }).run(), title: "Heading 3",
}, icon: <HiH3 />,
{ command: ({ editor }) =>
title: "Heading 3", editor.chain().focus().setHeading({ level: 3 }).run(),
icon: <HiH3 />, },
command: ({ editor }) => {
editor.chain().focus().setHeading({ level: 3 }).run(), title: "Bullet List",
}, icon: <HiOutlineListBullet />,
]; command: ({ editor }) => editor.chain().focus().toggleBulletList().run(),
},
return [ {
...headingCommands, title: "Ordered List",
{ icon: <HiOutlineNumberedList />,
title: "Bullet List", command: ({ editor }) => editor.chain().focus().toggleOrderedList().run(),
icon: <HiOutlineListBullet />, },
command: ({ editor }) => editor.chain().focus().toggleBulletList().run(), {
}, title: "Blockquote",
{ icon: <HiOutlineChatBubbleLeftEllipsis />,
title: "Ordered List", command: ({ editor }) => editor.chain().focus().toggleBlockquote().run(),
icon: <HiOutlineNumberedList />, },
command: ({ editor }) => editor.chain().focus().toggleOrderedList().run(), {
}, title: "Code Block",
{ icon: <HiOutlineCodeBracketSquare />,
title: "Blockquote", command: ({ editor }) => editor.chain().focus().toggleCodeBlock().run(),
icon: <HiOutlineChatBubbleLeftEllipsis />, },
command: ({ editor }) => editor.chain().focus().toggleBlockquote().run(), ];
},
{
title: "Code Block",
icon: <HiOutlineCodeBracketSquare />,
command: ({ editor }) => editor.chain().focus().toggleCodeBlock().run(),
},
];
};
export default function Editor({ export default function Editor({
content, content,
@@ -443,8 +434,6 @@ export default function Editor({
readOnly = false, readOnly = false,
workspaceMembers, workspaceMembers,
enableYouTubeEmbed = true, enableYouTubeEmbed = true,
placeholder,
disableHeadings = false,
}: { }: {
content: string | null; content: string | null;
onChange?: (value: string) => void; onChange?: (value: string) => void;
@@ -452,23 +441,18 @@ export default function Editor({
readOnly?: boolean; readOnly?: boolean;
workspaceMembers: WorkspaceMember[]; workspaceMembers: WorkspaceMember[];
enableYouTubeEmbed?: boolean; enableYouTubeEmbed?: boolean;
placeholder?: string;
disableHeadings?: boolean;
}) { }) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const editor = useEditor( const editor = useEditor(
{ {
extensions: [ extensions: [
StarterKit.configure({ StarterKit,
heading: disableHeadings ? false : undefined,
}),
Markdown, Markdown,
Placeholder.configure({ Placeholder.configure({
placeholder: readOnly placeholder: readOnly
? "" ? ""
: placeholder ?? : t`Add description... (type '/' to open commands or '@' to mention)`,
t`Add description... (type '/' to open commands or '@' to mention)`,
}), }),
Link.configure({ Link.configure({
openOnClick: true, openOnClick: true,
@@ -481,10 +465,10 @@ export default function Editor({
autolink: true, autolink: true,
}), }),
SlashCommands.configure({ SlashCommands.configure({
commandItems: getCommandItems(disableHeadings), commandItems: CommandItems,
suggestion: { suggestion: {
items: ({ query }: { query: string }) => items: ({ query }: { query: string }) =>
filterSlashCommandItems(getCommandItems(disableHeadings), query), filterSlashCommandItems(CommandItems, query),
startOfLine: true, startOfLine: true,
char: "/", char: "/",
}, },
@@ -496,28 +480,20 @@ export default function Editor({
suggestion: { suggestion: {
char: "@", char: "@",
items: ({ query }: { query: string }) => { items: ({ query }: { query: string }) => {
const withEmail = workspaceMembers.filter((member) => member.email); const all: MentionItem[] = workspaceMembers.map(
(member: WorkspaceMember) => ({
const mapped = withEmail.map((member: WorkspaceMember) => ({ id: member.publicId,
id: member.publicId, label: member?.user?.name ?? member.email,
label: member?.user?.name?.trim() || member.email || "", image: member?.user?.image ?? null,
image: member?.user?.image ?? null, }),
}));
const all: MentionItem[] = mapped.filter(
(item) => item.label && item.label.length > 0,
); );
const q = query.toLowerCase();
const q = query.toLowerCase().trim(); return all.filter(
(u) =>
if (q === "") { u.label &&
return all; typeof u.label === "string" &&
} u.label.toLowerCase().includes(q),
const filtered = all.filter((u) =>
u.label.toLowerCase().includes(q),
); );
return filtered;
}, },
command: ({ editor, range, props }) => { command: ({ editor, range, props }) => {
const id = props.id ?? ""; const id = props.id ?? "";
@@ -538,17 +514,6 @@ export default function Editor({
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`; return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
}, },
}), }),
Typography.configure({
openDoubleQuote: false,
closeDoubleQuote: false,
openSingleQuote: false,
closeSingleQuote: false,
oneHalf: false,
oneQuarter: false,
threeQuarters: false,
superscriptTwo: false,
superscriptThree: false,
}),
...(enableYouTubeEmbed ? [YouTubeNode] : []), ...(enableYouTubeEmbed ? [YouTubeNode] : []),
], ],
content, content,

View File

@@ -1,40 +1,24 @@
import { Fragment } from "react"; import Link from "next/link";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env"; import { env } from "next-runtime-env";
import { HiLink } from "react-icons/hi"; import { HiLink } from "react-icons/hi";
import { Tooltip } from "~/components/Tooltip"; import { Tooltip } from "~/components/Tooltip";
import { usePopup } from "~/providers/popup";
const displayBaseUrl =
env("NEXT_PUBLIC_KAN_ENV") === "cloud"
? "kan.bn"
: env("NEXT_PUBLIC_BASE_URL");
const linkBaseUrl = env("NEXT_PUBLIC_BASE_URL");
const pathSeparator = (
<div className="mx-1.5 h-4 w-px rotate-[20deg] bg-gray-300 dark:bg-dark-600" />
);
const UpdateBoardSlugButton = ({ const UpdateBoardSlugButton = ({
handleOnClick, handleOnClick,
workspaceSlug, workspaceSlug,
boardSlug, boardSlug,
boardPublicId,
visibility,
isLoading, isLoading,
canEdit, canEdit,
}: { }: {
handleOnClick: () => void; handleOnClick: () => void;
workspaceSlug: string; workspaceSlug: string;
boardSlug: string; boardSlug: string;
boardPublicId: string;
visibility: "public" | "private";
isLoading: boolean; isLoading: boolean;
canEdit: boolean; canEdit: boolean;
}) => { }) => {
const { showPopup } = usePopup(); if (!isLoading && (!workspaceSlug || !boardSlug)) return <></>;
if (isLoading) { if (isLoading) {
return ( return (
@@ -42,55 +26,44 @@ const UpdateBoardSlugButton = ({
); );
} }
if (!workspaceSlug || !boardSlug || !boardPublicId) return <></>;
const isPublic = visibility === "public";
const boardUrl = isPublic
? `${linkBaseUrl}/${workspaceSlug}/${boardSlug}`
: `${linkBaseUrl}/boards/${boardPublicId}`;
const pathSegments = isPublic
? [displayBaseUrl, workspaceSlug, boardSlug]
: [displayBaseUrl, "boards", boardPublicId];
return ( return (
<Tooltip <Tooltip
content={!canEdit ? t`You don't have permission` : undefined} content={!canEdit && !isLoading ? t`You don't have permission` : undefined}
> >
<button <button
onClick={canEdit ? handleOnClick : undefined} onClick={canEdit ? handleOnClick : undefined}
disabled={!canEdit || isLoading} disabled={!canEdit || isLoading}
className="hidden cursor-pointer items-center gap-2 rounded-full border-[1px] bg-light-50 p-1 pl-4 pr-1 text-sm text-light-950 hover:bg-light-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-dark-600 dark:bg-dark-50 dark:text-dark-900 dark:hover:bg-dark-100 xl:flex" className="hidden cursor-pointer items-center gap-2 rounded-full border-[1px] bg-light-50 p-1 pl-4 pr-1 text-sm text-light-950 hover:bg-light-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-dark-600 dark:bg-dark-50 dark:text-dark-900 dark:hover:bg-dark-100 xl:flex"
> >
<div className="flex items-center"> <div className="flex items-center">
{pathSegments.map((segment, i) => ( <span>
<Fragment key={i}> {env("NEXT_PUBLIC_KAN_ENV") === "cloud"
{i > 0 && pathSeparator} ? "kan.bn"
<span>{segment}</span> : env("NEXT_PUBLIC_BASE_URL")}
</Fragment> </span>
))} <div className="mx-1.5 h-4 w-px rotate-[20deg] bg-gray-300 dark:bg-dark-600"></div>
</div> <span>{workspaceSlug}</span>
<button <div className="mx-1.5 h-4 w-px rotate-[20deg] bg-gray-300 dark:bg-dark-600"></div>
type="button" <span>{boardSlug}</span>
onClick={(e) => { </div>
e.stopPropagation(); <Link
navigator.clipboard.writeText(boardUrl).then( href={`${env("NEXT_PUBLIC_BASE_URL")}/${workspaceSlug}/${boardSlug}`}
() => target="_blank"
showPopup({ rel="noopener noreferrer"
header: t`Link copied`, onClick={(e) => {
icon: "success", e.stopPropagation();
message: t`Board URL copied to clipboard`, if (!canEdit) {
}), e.preventDefault();
).catch(() => undefined); }
}} }}
className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-light-200 dark:hover:bg-dark-200" className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-light-200 dark:hover:bg-dark-200"
aria-label={t`Copy board link`} >
> <HiLink className="h-[13px] w-[13px]" />
<HiLink className="h-[13px] w-[13px]" /> </Link>
</button> </button>
</button>
</Tooltip> </Tooltip>
); );
}; };
export default UpdateBoardSlugButton; export default UpdateBoardSlugButton;

View File

@@ -156,7 +156,7 @@ export function UpdateBoardSlugForm({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
variant="secondary" variant="secondary"
href="/settings/workspace" href="/settings?tab=workspace"
onClick={closeModal} onClick={closeModal}
> >
{t`Edit workspace URL`} {t`Edit workspace URL`}

View File

@@ -443,8 +443,6 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
isLoading={isLoading} isLoading={isLoading}
workspaceSlug={workspace.slug ?? ""} workspaceSlug={workspace.slug ?? ""}
boardSlug={boardData?.slug ?? ""} boardSlug={boardData?.slug ?? ""}
boardPublicId={boardId ?? ""}
visibility={boardData?.visibility ?? "private"}
canEdit={canEditBoard} canEdit={canEditBoard}
/> />
<VisibilityButton <VisibilityButton

View File

@@ -1,13 +1,12 @@
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { useState } from "react"; import { useState } from "react";
import ContentEditable from "react-contenteditable";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { HiEllipsisHorizontal, HiPencil, HiTrash } from "react-icons/hi2"; import { HiEllipsisHorizontal, HiPencil, HiTrash } from "react-icons/hi2";
import Avatar from "~/components/Avatar"; import Avatar from "~/components/Avatar";
import Button from "~/components/Button"; import Button from "~/components/Button";
import Editor from "~/components/Editor";
import type { WorkspaceMember } from "~/components/Editor";
import Dropdown from "~/components/Dropdown"; import Dropdown from "~/components/Dropdown";
import { usePermissions } from "~/hooks/usePermissions"; import { usePermissions } from "~/hooks/usePermissions";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
@@ -30,6 +29,7 @@ const Comment = ({
createdAt, createdAt,
comment, comment,
isAuthor, isAuthor,
isAdmin,
isEdited = false, isEdited = false,
isViewOnly = false, isViewOnly = false,
}: { }: {
@@ -42,6 +42,7 @@ const Comment = ({
createdAt: string; createdAt: string;
comment: string | undefined; comment: string | undefined;
isAuthor: boolean; isAuthor: boolean;
isAdmin: boolean;
isEdited: boolean; isEdited: boolean;
isViewOnly: boolean; isViewOnly: boolean;
}) => { }) => {
@@ -56,30 +57,6 @@ const Comment = ({
}, },
}); });
const { data: cardData } = api.card.byId.useQuery(
{
cardPublicId,
},
{
enabled: !!cardPublicId && cardPublicId.length >= 12,
},
);
const workspaceMembers: WorkspaceMember[] =
cardData?.list.board.workspace.members
.filter((member) => member.email)
.map((member) => ({
publicId: member.publicId,
email: member.email,
user: member.user
? {
id: member.user.id,
name: member.user.name ?? null,
image: member.user.image ?? null,
}
: null,
})) ?? [];
if (!publicId) return null; if (!publicId) return null;
const updateCommentMutation = api.card.updateComment.useMutation({ const updateCommentMutation = api.card.updateComment.useMutation({
@@ -165,28 +142,21 @@ const Comment = ({
)} )}
</div> </div>
{!isEditing ? ( {!isEditing ? (
<div className="mt-2"> <ContentEditable
<Editor html={comment ?? ""}
content={comment ?? null} disabled={true}
readOnly={true} className="break-anywhere mt-2 text-sm"
workspaceMembers={workspaceMembers} />
enableYouTubeEmbed={false}
disableHeadings={true}
/>
</div>
) : ( ) : (
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<div className="mt-2"> <ContentEditable
<Editor placeholder={t`Add a comment...`}
content={watch("comment")} html={watch("comment")}
onChange={(value) => setValue("comment", value)} disabled={false}
workspaceMembers={workspaceMembers} onChange={(e) => setValue("comment", e.target.value)}
enableYouTubeEmbed={false} className="block w-full max-w-[800px] border-0 bg-transparent py-1.5 text-sm text-light-900 focus-visible:outline-none dark:text-dark-1000 sm:text-sm sm:leading-6"
placeholder={t`Add comment... (type '/' to open commands or '@' to mention)`} />
disableHeadings={true} <div className="flex justify-end space-x-2">
/>
</div>
<div className="flex justify-end space-x-2 mt-2">
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"

View File

@@ -1,7 +1,6 @@
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { import {
HiEllipsisHorizontal, HiEllipsisHorizontal,
HiLink,
HiOutlineCheckCircle, HiOutlineCheckCircle,
HiOutlineTrash, HiOutlineTrash,
} from "react-icons/hi2"; } from "react-icons/hi2";
@@ -11,54 +10,18 @@ import { authClient } from "@kan/auth/client";
import Dropdown from "~/components/Dropdown"; import Dropdown from "~/components/Dropdown";
import { usePermissions } from "~/hooks/usePermissions"; import { usePermissions } from "~/hooks/usePermissions";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
export default function CardDropdown({ export default function CardDropdown({
cardPublicId,
isTemplate,
boardPublicId,
cardCreatedBy, cardCreatedBy,
}: { }: {
cardPublicId: string;
isTemplate?: boolean;
boardPublicId?: string;
cardCreatedBy?: string | null; cardCreatedBy?: string | null;
}) { }) {
const { openModal } = useModal(); const { openModal } = useModal();
const { showPopup } = usePopup();
const { canEditCard, canDeleteCard } = usePermissions(); const { canEditCard, canDeleteCard } = usePermissions();
const { data: session } = authClient.useSession(); const { data: session } = authClient.useSession();
const isCreator = cardCreatedBy && session?.user.id === cardCreatedBy; const isCreator = cardCreatedBy && session?.user.id === cardCreatedBy;
const handleCopyCardLink = async () => {
const path =
isTemplate && boardPublicId
? `/templates/${boardPublicId}/cards/${cardPublicId}`
: `/cards/${cardPublicId}`;
const url = `${window.location.origin}${path}`;
try {
await navigator.clipboard.writeText(url);
showPopup({
header: t`Link copied`,
icon: "success",
message: t`Card URL copied to clipboard`,
});
} catch (error) {
console.error(error);
showPopup({
header: t`Unable to copy link`,
icon: "error",
message: t`Please try again.`,
});
}
};
const items = [ const items = [
{
label: t`Copy card link`,
action: handleCopyCardLink,
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
},
...(canEditCard ...(canEditCard
? [ ? [
{ {

View File

@@ -1,9 +1,8 @@
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import ContentEditable from "react-contenteditable";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { HiOutlineArrowUp } from "react-icons/hi2"; import { HiOutlineArrowUp } from "react-icons/hi2";
import Editor from "~/components/Editor";
import type { WorkspaceMember } from "~/components/Editor";
import LoadingSpinner from "~/components/LoadingSpinner"; import LoadingSpinner from "~/components/LoadingSpinner";
import { usePermissions } from "~/hooks/usePermissions"; import { usePermissions } from "~/hooks/usePermissions";
import { usePopup } from "~/providers/popup"; import { usePopup } from "~/providers/popup";
@@ -14,13 +13,7 @@ interface FormValues {
comment: string; comment: string;
} }
const NewCommentForm = ({ const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
cardPublicId,
workspaceMembers,
}: {
cardPublicId: string;
workspaceMembers: WorkspaceMember[];
}) => {
const utils = api.useUtils(); const utils = api.useUtils();
const { showPopup } = usePopup(); const { showPopup } = usePopup();
const { canCreateComment } = usePermissions(); const { canCreateComment } = usePermissions();
@@ -30,6 +23,10 @@ const NewCommentForm = ({
}, },
}); });
const queryParams = {
cardPublicId,
};
const addCommentMutation = api.card.addComment.useMutation({ const addCommentMutation = api.card.addComment.useMutation({
onError: (_error, _newList) => { onError: (_error, _newList) => {
showPopup({ showPopup({
@@ -60,13 +57,18 @@ const NewCommentForm = ({
onSubmit={handleSubmit(onSubmit)} onSubmit={handleSubmit(onSubmit)}
className="flex w-full max-w-[800px] flex-col rounded-xl border border-light-600 bg-light-100 p-4 text-light-900 focus-visible:outline-none dark:border-dark-400 dark:bg-dark-100 dark:text-dark-1000 sm:text-sm sm:leading-6" className="flex w-full max-w-[800px] flex-col rounded-xl border border-light-600 bg-light-100 p-4 text-light-900 focus-visible:outline-none dark:border-dark-400 dark:bg-dark-100 dark:text-dark-1000 sm:text-sm sm:leading-6"
> >
<Editor <ContentEditable
content={watch("comment")} placeholder={t`Add a comment...`}
onChange={(value) => setValue("comment", value)} html={watch("comment")}
workspaceMembers={workspaceMembers} disabled={false}
enableYouTubeEmbed={false} onChange={(e) => setValue("comment", e.target.value)}
placeholder={t`Add comment... (type '/' to open commands or '@' to mention)`} className="block w-full border-0 bg-transparent py-1.5 text-light-900 focus-visible:outline-none dark:text-dark-1000 sm:text-sm sm:leading-6"
disableHeadings={true} onKeyDown={async (e) => {
if (e.key === "Enter" && e.shiftKey) {
e.preventDefault();
await handleSubmit(onSubmit)();
}
}}
/> />
<div className="flex justify-end"> <div className="flex justify-end">
<button <button

View File

@@ -5,8 +5,6 @@ import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { IoChevronForwardSharp } from "react-icons/io5"; import { IoChevronForwardSharp } from "react-icons/io5";
import { authClient } from "@kan/auth/client";
import Avatar from "~/components/Avatar"; import Avatar from "~/components/Avatar";
import Editor from "~/components/Editor"; import Editor from "~/components/Editor";
import FeedbackModal from "~/components/FeedbackModal"; import FeedbackModal from "~/components/FeedbackModal";
@@ -16,6 +14,8 @@ import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm"; import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead"; import { PageHead } from "~/components/PageHead";
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal"; import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
import { authClient } from "@kan/auth/client";
import { usePermissions } from "~/hooks/usePermissions"; import { usePermissions } from "~/hooks/usePermissions";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup"; import { usePopup } from "~/providers/popup";
@@ -167,6 +167,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
const { const {
modalContentType, modalContentType,
entityId, entityId,
openModal,
getModalState, getModalState,
clearModalState, clearModalState,
isOpen, isOpen,
@@ -197,24 +198,8 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
}; };
const board = card?.list.board; const board = card?.list.board;
const workspaceMembers = board?.workspace.members;
const boardId = board?.publicId; const boardId = board?.publicId;
const editorWorkspaceMembers =
workspaceMembers
?.filter((member) => member.email)
.map((member) => ({
publicId: member.publicId,
email: member.email,
user: member.user
? {
id: member.user.id,
name: member.user.name ?? null,
image: member.user.image ?? null,
}
: null,
})) ?? [];
const updateCard = api.card.update.useMutation({ const updateCard = api.card.update.useMutation({
onError: () => { onError: () => {
showPopup({ showPopup({
@@ -301,7 +286,6 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
if (!cardId) return <></>; if (!cardId) return <></>;
return ( return (
<> <>
<PageHead <PageHead
@@ -333,12 +317,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
</Link> </Link>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Dropdown <Dropdown cardCreatedBy={card?.createdBy} />
cardPublicId={cardId}
isTemplate={isTemplate}
boardPublicId={boardId}
cardCreatedBy={card?.createdBy}
/>
</div> </div>
</> </>
)} )}
@@ -395,15 +374,9 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
<div className="mt-2"> <div className="mt-2">
<Editor <Editor
content={card.description} content={card.description}
onChange={ onChange={canEdit ? (e) => setValue("description", e) : undefined}
canEdit onBlur={canEdit ? () => handleSubmit(onSubmit)() : undefined}
? (e) => setValue("description", e) workspaceMembers={board?.workspace.members ?? []}
: undefined
}
onBlur={
canEdit ? () => handleSubmit(onSubmit)() : undefined
}
workspaceMembers={workspaceMembers ?? []}
readOnly={!canEdit} readOnly={!canEdit}
/> />
</div> </div>
@@ -447,10 +420,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
</div> </div>
{!isTemplate && ( {!isTemplate && (
<div className="mt-6"> <div className="mt-6">
<NewCommentForm <NewCommentForm cardPublicId={cardId} />
cardPublicId={cardId}
workspaceMembers={editorWorkspaceMembers}
/>
</div> </div>
)} )}
</div> </div>

View File

@@ -1,13 +1,12 @@
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { HiLink, HiXMark } from "react-icons/hi2"; import { HiXMark } from "react-icons/hi2";
import Badge from "~/components/Badge"; import Badge from "~/components/Badge";
import Editor from "~/components/Editor"; import Editor from "~/components/Editor";
import LabelIcon from "~/components/LabelIcon"; import LabelIcon from "~/components/LabelIcon";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import ActivityList from "~/views/card/components/ActivityList"; import ActivityList from "~/views/card/components/ActivityList";
import { AttachmentThumbnails } from "~/views/card/components/AttachmentThumbnails"; import { AttachmentThumbnails } from "~/views/card/components/AttachmentThumbnails";
@@ -24,29 +23,10 @@ export function CardModal({
}) { }) {
const router = useRouter(); const router = useRouter();
const { closeModal, isOpen } = useModal(); const { closeModal, isOpen } = useModal();
const { showPopup } = usePopup();
const [showFade, setShowFade] = useState(false); const [showFade, setShowFade] = useState(false);
const [showTopFade, setShowTopFade] = useState(false); const [showTopFade, setShowTopFade] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const handleCopyCardLink = async () => {
try {
await navigator.clipboard.writeText(window.location.href);
showPopup({
header: t`Link copied`,
icon: "success",
message: t`Card URL copied to clipboard`,
});
} catch (error) {
console.error(error);
showPopup({
header: t`Unable to copy link`,
icon: "error",
message: t`Please try again.`,
});
}
};
const { data, isLoading } = api.card.byId.useQuery( const { data, isLoading } = api.card.byId.useQuery(
{ {
cardPublicId: cardPublicId ?? "", cardPublicId: cardPublicId ?? "",
@@ -85,44 +65,33 @@ export function CardModal({
<div className="h-full p-8"> <div className="h-full p-8">
<div className="mb-6"> <div className="mb-6">
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
<div className="absolute right-[2rem] top-[2rem] flex items-center gap-1"> <button
<button className="absolute right-[2rem] top-[2rem] rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300"
type="button" onClick={(e) => {
onClick={handleCopyCardLink} e.preventDefault();
className="rounded p-1.5 transition-all hover:bg-light-200 focus:outline-none dark:hover:bg-dark-100" closeModal();
aria-label="Copy card link"
>
<HiLink className="h-4 w-4 text-light-900 dark:text-dark-900" />
</button>
<button
type="button"
className="rounded p-1.5 transition-all hover:bg-light-200 focus:outline-none dark:hover:bg-dark-100"
onClick={(e) => {
e.preventDefault();
closeModal();
setTimeout(() => { setTimeout(() => {
void router.replace( void router.replace(
{ {
pathname: router.pathname, pathname: router.pathname,
query: { query: {
...router.query, ...router.query,
workspaceSlug: workspaceSlug ?? "", workspaceSlug,
boardSlug: [boardSlug ?? ""], boardSlug: [boardSlug],
},
}, },
undefined, },
{ shallow: true }, undefined,
); { shallow: true },
}, 400); );
}} }, 400);
> }}
<HiXMark >
size={18} <HiXMark
className="text-light-900 dark:text-dark-900" size={18}
/> className="dark:text-dark-9000 text-light-900"
</button> />
</div> </button>
{isLoading ? ( {isLoading ? (
<div className="flex space-x-2"> <div className="flex space-x-2">
<div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" /> <div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />

View File

@@ -30,7 +30,7 @@ export default function PublicBoardView() {
const { showPopup } = usePopup(); const { showPopup } = usePopup();
const [isRouteLoaded, setIsRouteLoaded] = useState(false); const [isRouteLoaded, setIsRouteLoaded] = useState(false);
const { openModal } = useModal(); const { openModal } = useModal();
const { ref: scrollRef, onMouseDown } = useDragToScroll({ const { ref: scrollRef, onMouseDown } = useDragToScroll({
enabled: true, enabled: true,
direction: "horizontal", direction: "horizontal",
@@ -70,34 +70,29 @@ export default function PublicBoardView() {
}, },
); );
const handleCopyBoardLink = async () => { const CopyBoardLink = () => {
try { return (
await navigator.clipboard.writeText(window.location.href); <button
showPopup({ onClick={async () => {
header: t`Link copied`, try {
icon: "success", await navigator.clipboard.writeText(window.location.href);
message: t`Board URL copied to clipboard`, } catch (error) {
}); console.error(error);
} catch (error) { }
console.error(error);
showPopup({
header: t`Unable to copy link`,
icon: "error",
message: t`Please try again.`,
});
}
};
const CopyBoardLink = () => ( showPopup({
<button header: t`Link copied`,
type="button" icon: "success",
onClick={handleCopyBoardLink} message: t`Board URL copied to clipboard`,
className="rounded p-1.5 transition-all hover:bg-light-200 focus:outline-none dark:hover:bg-dark-100" });
aria-label="Copy board URL" }}
> className="rounded p-1.5 transition-all hover:bg-light-200 dark:hover:bg-dark-100"
<HiLink className="h-4 w-4 text-light-900 dark:text-dark-900" /> aria-label={`Copy board URL`}
</button> >
); <HiLink className={`h-4 w-4 text-light-900 dark:text-dark-900`} />
</button>
);
};
const pathWithoutQuery = router.asPath.split("?")[0]; const pathWithoutQuery = router.asPath.split("?")[0];
const splitPath = pathWithoutQuery?.split("/") ?? []; const splitPath = pathWithoutQuery?.split("/") ?? [];

View File

@@ -143,13 +143,6 @@ export const boardRouter = createTRPCRouter({
}, },
); );
if (!result) {
throw new TRPCError({
message: `Board with public ID ${input.boardPublicId} not found`,
code: "NOT_FOUND",
});
}
// Generate presigned URLs for workspace member avatars // Generate presigned URLs for workspace member avatars
const workspaceWithAvatarUrls = result.workspace const workspaceWithAvatarUrls = result.workspace
? { ? {

View File

@@ -10,7 +10,6 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { mergeActivities } from "../utils/activities"; import { mergeActivities } from "../utils/activities";
import { sendMentionEmails } from "../utils/notifications";
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions"; import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
import { generateAttachmentUrl, generateAvatarUrl } from "@kan/shared/utils"; import { generateAttachmentUrl, generateAvatarUrl } from "@kan/shared/utils";
@@ -154,17 +153,6 @@ export const cardRouter = createTRPCRouter({
await cardActivityRepo.bulkCreate(ctx.db, cardActivitesInsert); await cardActivityRepo.bulkCreate(ctx.db, cardActivitesInsert);
} }
if (input.description) {
sendMentionEmails({
db: ctx.db,
cardPublicId: newCard.publicId,
commentHtml: input.description,
commenterUserId: userId,
}).catch((error) => {
console.error("Failed to send mention emails:", error);
});
}
return newCard; return newCard;
}), }),
addComment: protectedProcedure addComment: protectedProcedure
@@ -227,16 +215,6 @@ export const cardRouter = createTRPCRouter({
createdBy: userId, createdBy: userId,
}); });
sendMentionEmails({
db: ctx.db,
cardPublicId: input.cardPublicId,
commentHtml: input.comment,
commenterUserId: userId,
commentId: newComment.id,
}).catch((error) => {
console.error("Failed to send mention emails:", error);
});
return newComment; return newComment;
}), }),
updateComment: protectedProcedure updateComment: protectedProcedure
@@ -317,16 +295,6 @@ export const cardRouter = createTRPCRouter({
createdBy: userId, createdBy: userId,
}); });
sendMentionEmails({
db: ctx.db,
cardPublicId: input.cardPublicId,
commentHtml: input.comment,
commenterUserId: userId,
commentId: updatedComment.id,
}).catch((error) => {
console.error("Failed to send mention emails:", error);
});
return updatedComment; return updatedComment;
}), }),
deleteComment: protectedProcedure deleteComment: protectedProcedure
@@ -956,15 +924,6 @@ export const cardRouter = createTRPCRouter({
fromDescription: existingCard.description ?? undefined, fromDescription: existingCard.description ?? undefined,
toDescription: input.description, toDescription: input.description,
}); });
sendMentionEmails({
db: ctx.db,
cardPublicId: input.cardPublicId,
commentHtml: input.description,
commenterUserId: userId,
}).catch((error) => {
console.error("Failed to send mention emails:", error);
});
} }
if ( if (

View File

@@ -1,133 +0,0 @@
import { env } from "next-runtime-env";
import type { dbClient } from "@kan/db/client";
import * as cardRepo from "@kan/db/repository/card.repo";
import * as memberRepo from "@kan/db/repository/member.repo";
import * as notificationRepo from "@kan/db/repository/notification.repo";
import * as userRepo from "@kan/db/repository/user.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { sendEmail } from "@kan/email";
import { parseMentionsFromHTML } from "@kan/shared/utils";
/**
* Sends mention notification emails to mentioned members
* Only sends emails for new mentions (checks notification table to avoid duplicates)
*/
export async function sendMentionEmails({
db,
cardPublicId,
commentHtml,
commenterUserId,
commentId,
}: {
db: dbClient;
cardPublicId: string;
commentHtml: string;
commenterUserId: string;
commentId?: number;
}) {
try {
// Parse mentions from HTML
const mentionPublicIds = parseMentionsFromHTML(commentHtml);
if (mentionPublicIds.length === 0) return;
// Get card with board information
const card = await cardRepo.getWithListAndMembersByPublicId(db, cardPublicId);
if (!card?.list.board) return;
const board = card.list.board;
const boardName = board.name;
const cardTitle = card.title;
const cardId = card.id;
// Get workspace ID from workspace publicId
const workspace = await workspaceRepo.getByPublicId(
db,
board.workspace.publicId,
);
if (!workspace?.id) return;
const workspaceId = workspace.id;
// Get commenter information
const commenter = await userRepo.getById(db, commenterUserId);
if (!commenter) return;
const commenterName = commenter.name ?? commenter.email;
// Get mentioned members with full details (filtered by workspace)
const membersWithDetails = await memberRepo.getByPublicIdsWithUsers(
db,
mentionPublicIds,
workspaceId,
);
// Filter out the commenter
const membersToNotify = membersWithDetails.filter(
(member) => member.user?.id !== commenterUserId,
);
if (membersToNotify.length === 0) return;
const baseUrl = env("NEXT_PUBLIC_BASE_URL");
const cardUrl = `${baseUrl}/cards/${cardPublicId}`;
// Send emails to all mentioned members (only if notification doesn't exist)
await Promise.all(
membersToNotify.map(async (member) => {
const userId = member.user?.id;
const email = member.user?.email ?? member.email;
// Skip pending members (no userId) - they can be mentioned but won't receive emails
if (!userId || !email) return;
try {
// Check if notification already exists for this mention
const notificationExists = await notificationRepo.exists(db, {
userId,
cardId,
type: "mention",
});
// If notification already exists, skip sending email
if (notificationExists) {
return;
}
// Create notification record
await notificationRepo.create(db, {
type: "mention",
userId,
cardId,
commentId,
});
// Send email
await sendEmail(
email,
`${commenterName} mentioned you in a comment on ${cardTitle}`,
"MENTION",
{
commenterName,
boardName,
cardTitle,
cardUrl,
},
);
} catch (error) {
console.error("Failed to send mention email:", {
email,
cardPublicId,
error: error instanceof Error ? error.message : String(error),
});
}
}),
);
} catch (error) {
console.error("Error sending mention emails:", {
cardPublicId,
error: error instanceof Error ? error.message : String(error),
});
}
}

View File

@@ -1,46 +0,0 @@
CREATE TYPE "public"."notification_type" AS ENUM('mention', 'workspace.member.added', 'workspace.member.removed', 'workspace.role.changed');--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "notification" (
"id" bigserial PRIMARY KEY NOT NULL,
"publicId" varchar(12) NOT NULL,
"type" "notification_type" NOT NULL,
"userId" uuid NOT NULL,
"cardId" bigint,
"commentId" bigint,
"workspaceId" bigint,
"metadata" text,
"readAt" timestamp,
"createdAt" timestamp DEFAULT now() NOT NULL,
"deletedAt" timestamp,
CONSTRAINT "notification_publicId_unique" UNIQUE("publicId")
);
--> statement-breakpoint
ALTER TABLE "notification" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "notification" ADD CONSTRAINT "notification_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "notification" ADD CONSTRAINT "notification_cardId_card_id_fk" FOREIGN KEY ("cardId") REFERENCES "public"."card"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "notification" ADD CONSTRAINT "notification_commentId_card_comments_id_fk" FOREIGN KEY ("commentId") REFERENCES "public"."card_comments"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "notification" ADD CONSTRAINT "notification_workspaceId_workspace_id_fk" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "notification_user_deleted_idx" ON "notification" USING btree ("userId","deletedAt");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "notification_user_read_deleted_idx" ON "notification" USING btree ("userId","readAt","deletedAt");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "notification_user_type_card_idx" ON "notification" USING btree ("userId","type","cardId");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "notification_user_type_workspace_idx" ON "notification" USING btree ("userId","type","workspaceId");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "notification_user_created_idx" ON "notification" USING btree ("userId","createdAt");

File diff suppressed because it is too large Load Diff

View File

@@ -176,13 +176,6 @@
"when": 1769983198190, "when": 1769983198190,
"tag": "20260201215958_AddUserBoardFavouritesTable", "tag": "20260201215958_AddUserBoardFavouritesTable",
"breakpoints": true "breakpoints": true
},
{
"idx": 25,
"version": "7",
"when": 1770500457005,
"tag": "20260207214056_AddNotificationsTable",
"breakpoints": true
} }
] ]
} }

View File

@@ -212,7 +212,6 @@ export const getByPublicId = async (
columns: { columns: {
publicId: true, publicId: true,
email: true, email: true,
status: true,
}, },
with: { with: {
user: { user: {

View File

@@ -420,7 +420,6 @@ export const getWithListAndMembersByPublicId = async (
) => { ) => {
const card = await db.query.cards.findFirst({ const card = await db.query.cards.findFirst({
columns: { columns: {
id: true,
publicId: true, publicId: true,
title: true, title: true,
description: true, description: true,
@@ -508,7 +507,6 @@ export const getWithListAndMembersByPublicId = async (
columns: { columns: {
publicId: true, publicId: true,
email: true, email: true,
status: true,
}, },
with: { with: {
user: { user: {

View File

@@ -63,36 +63,6 @@ export const getById = async (db: dbClient, memberId: number) => {
}); });
}; };
export const getByPublicIdsWithUsers = async (
db: dbClient,
memberPublicIds: string[],
workspaceId?: number,
) => {
return db.query.workspaceMembers.findMany({
where: (members, { inArray: inArrayFn, eq, and, isNull: isNullFn }) => {
const conditions = [inArrayFn(members.publicId, memberPublicIds)];
if (workspaceId) {
conditions.push(eq(members.workspaceId, workspaceId));
}
conditions.push(eq(members.status, "active"));
conditions.push(isNullFn(members.deletedAt));
return and(...conditions);
},
with: {
user: {
columns: {
id: true,
name: true,
email: true,
},
},
},
});
};
export const getByEmailAndStatus = async ( export const getByEmailAndStatus = async (
db: dbClient, db: dbClient,
email: string, email: string,

View File

@@ -1,98 +0,0 @@
import { and, count, eq, isNull } from "drizzle-orm";
import type { dbClient } from "@kan/db/client";
import type { NotificationType } from "@kan/db/schema";
import { notifications } from "@kan/db/schema";
import { generateUID } from "@kan/shared/utils";
export const create = async (
db: dbClient,
notificationInput: {
type: NotificationType;
userId: string;
cardId?: number;
commentId?: number;
workspaceId?: number;
metadata?: string;
},
) => {
const [result] = await db
.insert(notifications)
.values({
publicId: generateUID(),
type: notificationInput.type,
userId: notificationInput.userId,
cardId: notificationInput.cardId,
commentId: notificationInput.commentId,
workspaceId: notificationInput.workspaceId,
metadata: notificationInput.metadata,
})
.returning();
return result;
};
export const exists = async (
db: dbClient,
args: {
userId: string;
type: NotificationType;
cardId?: number;
workspaceId?: number;
commentId?: number;
},
) => {
const result = await db.query.notifications.findFirst({
where: (notifications, { eq, and, isNull: isNullFn }) => {
const conditions = [
eq(notifications.userId, args.userId),
eq(notifications.type, args.type),
isNullFn(notifications.deletedAt),
];
if (args.cardId) {
conditions.push(eq(notifications.cardId, args.cardId));
}
if (args.workspaceId) {
conditions.push(eq(notifications.workspaceId, args.workspaceId));
}
return and(...conditions);
},
});
return !!result;
};
export const markAsRead = async (
db: dbClient,
notificationId: number,
) => {
const [result] = await db
.update(notifications)
.set({ readAt: new Date() })
.where(eq(notifications.id, notificationId))
.returning();
return result;
};
export const getUnreadCount = async (
db: dbClient,
userId: string,
) => {
const result = await db
.select({ count: count() })
.from(notifications)
.where(
and(
eq(notifications.userId, userId),
isNull(notifications.readAt),
isNull(notifications.deletedAt),
),
);
return result[0]?.count ?? 0;
};

View File

@@ -13,4 +13,3 @@ export * from "./workspaces";
export * from "./subscriptions"; export * from "./subscriptions";
export * from "./workspaceInviteLinks"; export * from "./workspaceInviteLinks";
export * from "./permissions"; export * from "./permissions";
export * from "./notifications";

View File

@@ -1,98 +0,0 @@
import { relations } from "drizzle-orm";
import {
bigint,
bigserial,
index,
pgEnum,
pgTable,
text,
timestamp,
uuid,
varchar,
} from "drizzle-orm/pg-core";
import { cards } from "./cards";
import { comments } from "./cards";
import { users } from "./users";
import { workspaces } from "./workspaces";
export const notificationTypes = [
"mention",
"workspace.member.added",
"workspace.member.removed",
"workspace.role.changed",
] as const;
export type NotificationType = (typeof notificationTypes)[number];
export const notificationTypeEnum = pgEnum("notification_type", notificationTypes);
export const notifications = pgTable(
"notification",
{
id: bigserial("id", { mode: "number" }).primaryKey(),
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
type: notificationTypeEnum("type").notNull(),
userId: uuid("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
cardId: bigint("cardId", { mode: "number" }).references(() => cards.id, {
onDelete: "cascade",
}),
commentId: bigint("commentId", { mode: "number" }).references(
() => comments.id,
{ onDelete: "cascade" },
),
workspaceId: bigint("workspaceId", { mode: "number" }).references(
() => workspaces.id,
{ onDelete: "cascade" },
),
metadata: text("metadata"),
readAt: timestamp("readAt"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
deletedAt: timestamp("deletedAt"),
},
(table) => [
index("notification_user_deleted_idx").on(table.userId, table.deletedAt),
index("notification_user_read_deleted_idx").on(
table.userId,
table.readAt,
table.deletedAt,
),
index("notification_user_type_card_idx").on(
table.userId,
table.type,
table.cardId,
),
index("notification_user_type_workspace_idx").on(
table.userId,
table.type,
table.workspaceId,
),
index("notification_user_created_idx").on(table.userId, table.createdAt),
],
).enableRLS();
export const notificationsRelations = relations(notifications, ({ one }) => ({
user: one(users, {
fields: [notifications.userId],
references: [users.id],
relationName: "notificationsUser",
}),
card: one(cards, {
fields: [notifications.cardId],
references: [cards.id],
relationName: "notificationsCard",
}),
comment: one(comments, {
fields: [notifications.commentId],
references: [comments.id],
relationName: "notificationsComment",
}),
workspace: one(workspaces, {
fields: [notifications.workspaceId],
references: [workspaces.id],
relationName: "notificationsWorkspace",
}),
}));

View File

@@ -3,16 +3,14 @@ import nodemailer from "nodemailer";
import JoinWorkspaceTemplate from "./templates/join-workspace"; import JoinWorkspaceTemplate from "./templates/join-workspace";
import MagicLinkTemplate from "./templates/magic-link"; import MagicLinkTemplate from "./templates/magic-link";
import MentionTemplate from "./templates/mention";
import ResetPasswordTemplate from "./templates/reset-password"; import ResetPasswordTemplate from "./templates/reset-password";
type Templates = "MAGIC_LINK" | "JOIN_WORKSPACE" | "RESET_PASSWORD" | "MENTION"; type Templates = "MAGIC_LINK" | "JOIN_WORKSPACE" | "RESET_PASSWORD";
const emailTemplates: Record<Templates, React.ComponentType<any>> = { const emailTemplates: Record<Templates, React.FC> = {
MAGIC_LINK: MagicLinkTemplate, MAGIC_LINK: MagicLinkTemplate,
JOIN_WORKSPACE: JoinWorkspaceTemplate, JOIN_WORKSPACE: JoinWorkspaceTemplate,
RESET_PASSWORD: ResetPasswordTemplate, RESET_PASSWORD: ResetPasswordTemplate,
MENTION: MentionTemplate,
}; };
const transporter = nodemailer.createTransport({ const transporter = nodemailer.createTransport({

View File

@@ -1,108 +0,0 @@
import { Body } from "@react-email/body";
import { Button } from "@react-email/button";
import { Container } from "@react-email/container";
import { Head } from "@react-email/head";
import { Heading } from "@react-email/heading";
import { Hr } from "@react-email/hr";
import { Html } from "@react-email/html";
import { Link } from "@react-email/link";
import { Preview } from "@react-email/preview";
import { Text } from "@react-email/text";
import { env } from "next-runtime-env";
import * as React from "react";
export const MentionTemplate = ({
commenterName,
boardName,
cardTitle,
cardUrl,
}: {
commenterName: string;
boardName: string;
cardTitle: string;
cardUrl: string;
}) => (
<Html>
<Head />
<Preview>
{commenterName} mentioned you in a comment on {cardTitle}
</Preview>
<Body style={{ backgroundColor: "white" }}>
<Container
style={{
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
margin: "auto",
paddingLeft: "0.75rem",
paddingRight: "0.75rem",
}}
>
<Heading
style={{
marginTop: "2.5rem",
marginBottom: "2.5rem",
fontSize: "24px",
fontWeight: "bold",
color: "#232323",
}}
>
kan.bn
</Heading>
<Heading
style={{ fontSize: "24px", fontWeight: "bold", color: "#232323" }}
>
You were mentioned in a comment
</Heading>
<Text
style={{
fontSize: "0.875rem",
marginBottom: "1rem",
color: "#232323",
}}
>
<strong>{commenterName}</strong> mentioned you in a comment on the
card <strong>{cardTitle}</strong> in the board <strong>{boardName}</strong>.
</Text>
<Button
target="_blank"
href={cardUrl}
style={{
marginBottom: "2rem",
borderRadius: "0.375rem",
backgroundColor: "#282828",
paddingLeft: "1.5rem",
paddingRight: "1.5rem",
paddingTop: "1rem",
paddingBottom: "1rem",
fontSize: "0.875rem",
fontWeight: "500",
lineHeight: "1",
color: "white",
}}
>
View Card
</Button>
<Hr
style={{
marginTop: "2.5rem",
marginBottom: "2rem",
borderWidth: "1px",
}}
/>
<Text style={{ color: "#7e7e7e" }}>
<Link
href={env("NEXT_PUBLIC_BASE_URL")}
target="_blank"
style={{ color: "#7e7e7e", textDecoration: "underline" }}
>
Kan
</Link>
, the open source Trello alternative.
</Text>
</Container>
</Body>
</Html>
);
export default MentionTemplate;

View File

@@ -4,4 +4,3 @@ export * from "./subscriptions";
export * from "./email"; export * from "./email";
export * from "./dueDateFilters"; export * from "./dueDateFilters";
export * from "./s3"; export * from "./s3";
export * from "./mentions";

View File

@@ -1,22 +0,0 @@
/**
* Parses mention data-id attributes from HTML content
* Mentions are stored as: <span data-type="mention" data-id="..." data-label="...">@label</span>
* @param htmlContent - The HTML content to parse
* @returns Array of unique mention public IDs
*/
export function parseMentionsFromHTML(htmlContent: string): string[] {
if (!htmlContent) return [];
// Match all mention spans with data-id attributes
const mentionRegex = /<span[^>]*data-type="mention"[^>]*data-id="([^"]+)"[^>]*>/gi;
const matches = Array.from(htmlContent.matchAll(mentionRegex));
// Extract unique mention IDs
const mentionIds = matches
.map((match) => match[1])
.filter((id): id is string => !!id && id.length >= 12);
// Return unique IDs
return Array.from(new Set(mentionIds));
}

12
pnpm-lock.yaml generated
View File

@@ -136,9 +136,6 @@ importers:
'@tiptap/extension-placeholder': '@tiptap/extension-placeholder':
specifier: ^2.14.0 specifier: ^2.14.0
version: 2.26.1(@tiptap/core@2.26.1(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1) version: 2.26.1(@tiptap/core@2.26.1(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1)
'@tiptap/extension-typography':
specifier: ^3.18.0
version: 3.18.0(@tiptap/core@2.26.1(@tiptap/pm@2.26.1))
'@tiptap/pm': '@tiptap/pm':
specifier: ^2.14.0 specifier: ^2.14.0
version: 2.26.1 version: 2.26.1
@@ -3782,11 +3779,6 @@ packages:
peerDependencies: peerDependencies:
'@tiptap/core': ^2.7.0 '@tiptap/core': ^2.7.0
'@tiptap/extension-typography@3.18.0':
resolution: {integrity: sha512-zTNGJjhJG3lObUhhTbDC1IOyi1DCiCd6i11xsnJDPy5BODYc7t7ZP6VMOWU0LIuMsK9kX02dXoNU7OarJgLpCg==}
peerDependencies:
'@tiptap/core': ^3.18.0
'@tiptap/pm@2.26.1': '@tiptap/pm@2.26.1':
resolution: {integrity: sha512-8aF+mY/vSHbGFqyG663ds84b+vca5Lge3tHdTMTKazxCnhXR9dn2oQJMnZ78YZvdRbkPkMJJHti9h3K7u2UQvw==} resolution: {integrity: sha512-8aF+mY/vSHbGFqyG663ds84b+vca5Lge3tHdTMTKazxCnhXR9dn2oQJMnZ78YZvdRbkPkMJJHti9h3K7u2UQvw==}
@@ -11927,10 +11919,6 @@ snapshots:
dependencies: dependencies:
'@tiptap/core': 2.26.1(@tiptap/pm@2.26.1) '@tiptap/core': 2.26.1(@tiptap/pm@2.26.1)
'@tiptap/extension-typography@3.18.0(@tiptap/core@2.26.1(@tiptap/pm@2.26.1))':
dependencies:
'@tiptap/core': 2.26.1(@tiptap/pm@2.26.1)
'@tiptap/pm@2.26.1': '@tiptap/pm@2.26.1':
dependencies: dependencies:
prosemirror-changeset: 2.3.1 prosemirror-changeset: 2.3.1