feat: send email notifications on user mentions (#372)
* feat: add notifications schema * chore: gen migration * feat: add mention email template * feat: add sendMentionEmail func * feat: add repo funcs * feat: update card router to send emails on mention * fix: update the editor suggestion to show all members * feat: skip pending members in sendMentionEmails * feat: update comments to use tiptap editor
This commit is contained in:
@@ -10,6 +10,7 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { mergeActivities } from "../utils/activities";
|
||||
import { sendMentionEmails } from "../utils/notifications";
|
||||
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
|
||||
import { generateAttachmentUrl, generateAvatarUrl } from "@kan/shared/utils";
|
||||
|
||||
@@ -153,6 +154,17 @@ export const cardRouter = createTRPCRouter({
|
||||
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;
|
||||
}),
|
||||
addComment: protectedProcedure
|
||||
@@ -215,6 +227,16 @@ export const cardRouter = createTRPCRouter({
|
||||
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;
|
||||
}),
|
||||
updateComment: protectedProcedure
|
||||
@@ -295,6 +317,16 @@ export const cardRouter = createTRPCRouter({
|
||||
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;
|
||||
}),
|
||||
deleteComment: protectedProcedure
|
||||
@@ -924,6 +956,15 @@ export const cardRouter = createTRPCRouter({
|
||||
fromDescription: existingCard.description ?? undefined,
|
||||
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 (
|
||||
|
||||
133
packages/api/src/utils/notifications.ts
Normal file
133
packages/api/src/utils/notifications.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
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");
|
||||
3687
packages/db/migrations/meta/20260207214056_snapshot.json
Normal file
3687
packages/db/migrations/meta/20260207214056_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -176,6 +176,13 @@
|
||||
"when": 1769983198190,
|
||||
"tag": "20260201215958_AddUserBoardFavouritesTable",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 25,
|
||||
"version": "7",
|
||||
"when": 1770500457005,
|
||||
"tag": "20260207214056_AddNotificationsTable",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -212,6 +212,7 @@ export const getByPublicId = async (
|
||||
columns: {
|
||||
publicId: true,
|
||||
email: true,
|
||||
status: true,
|
||||
},
|
||||
with: {
|
||||
user: {
|
||||
|
||||
@@ -420,6 +420,7 @@ export const getWithListAndMembersByPublicId = async (
|
||||
) => {
|
||||
const card = await db.query.cards.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
publicId: true,
|
||||
title: true,
|
||||
description: true,
|
||||
@@ -507,6 +508,7 @@ export const getWithListAndMembersByPublicId = async (
|
||||
columns: {
|
||||
publicId: true,
|
||||
email: true,
|
||||
status: true,
|
||||
},
|
||||
with: {
|
||||
user: {
|
||||
|
||||
@@ -63,6 +63,36 @@ 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 (
|
||||
db: dbClient,
|
||||
email: string,
|
||||
|
||||
98
packages/db/src/repository/notification.repo.ts
Normal file
98
packages/db/src/repository/notification.repo.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -13,3 +13,4 @@ export * from "./workspaces";
|
||||
export * from "./subscriptions";
|
||||
export * from "./workspaceInviteLinks";
|
||||
export * from "./permissions";
|
||||
export * from "./notifications";
|
||||
|
||||
98
packages/db/src/schema/notifications.ts
Normal file
98
packages/db/src/schema/notifications.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
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",
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -3,14 +3,16 @@ import nodemailer from "nodemailer";
|
||||
|
||||
import JoinWorkspaceTemplate from "./templates/join-workspace";
|
||||
import MagicLinkTemplate from "./templates/magic-link";
|
||||
import MentionTemplate from "./templates/mention";
|
||||
import ResetPasswordTemplate from "./templates/reset-password";
|
||||
|
||||
type Templates = "MAGIC_LINK" | "JOIN_WORKSPACE" | "RESET_PASSWORD";
|
||||
type Templates = "MAGIC_LINK" | "JOIN_WORKSPACE" | "RESET_PASSWORD" | "MENTION";
|
||||
|
||||
const emailTemplates: Record<Templates, React.FC> = {
|
||||
const emailTemplates: Record<Templates, React.ComponentType<any>> = {
|
||||
MAGIC_LINK: MagicLinkTemplate,
|
||||
JOIN_WORKSPACE: JoinWorkspaceTemplate,
|
||||
RESET_PASSWORD: ResetPasswordTemplate,
|
||||
MENTION: MentionTemplate,
|
||||
};
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
|
||||
108
packages/email/src/templates/mention.tsx
Normal file
108
packages/email/src/templates/mention.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
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;
|
||||
|
||||
@@ -4,3 +4,4 @@ export * from "./subscriptions";
|
||||
export * from "./email";
|
||||
export * from "./dueDateFilters";
|
||||
export * from "./s3";
|
||||
export * from "./mentions";
|
||||
|
||||
22
packages/shared/src/utils/mentions.ts
Normal file
22
packages/shared/src/utils/mentions.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user