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:
@@ -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",
|
||||
}),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user