From b7b3a1c1e56bd6e159bf5fc83b3b22b5f78daf6e Mon Sep 17 00:00:00 2001 From: Aashish-Upadhyay-101 Date: Sat, 11 Nov 2023 12:15:17 +0545 Subject: [PATCH 01/23] feat: database schema for notification --- prisma/schema.prisma | 69 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 53 insertions(+), 16 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 12e48e4d2..b27818bd5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -37,22 +37,24 @@ model Session { } model User { - id String @id @default(cuid()) - name String? - email String? @unique - emailVerified DateTime? - image String? - createdAt DateTime @default(now()) - accounts Account[] - sessions Session[] - documents Document[] - teams UserTeam[] - domains Domain[] - plan String @default("trial") - stripeId String? @unique // Stripe subscription / customer ID - subscriptionId String? @unique // Stripe subscription ID - startsAt DateTime? // Stripe subscription start date - endsAt DateTime? // Stripe subscription end date + id String @id @default(cuid()) + name String? + email String? @unique + emailVerified DateTime? + image String? + createdAt DateTime @default(now()) + accounts Account[] + sessions Session[] + documents Document[] + teams UserTeam[] + domains Domain[] + notificationsSent Notificaiton[] @relation("notificationSent") + notificaitonsReceived Notificaiton[] @relation("notificationReceived") + plan String @default("trial") + stripeId String? @unique // Stripe subscription / customer ID + subscriptionId String? @unique // Stripe subscription ID + startsAt DateTime? // Stripe subscription start date + endsAt DateTime? // Stripe subscription end date } model Team { @@ -200,3 +202,38 @@ model Invitation { @@unique([email]) } + +model Notificaiton { + id String @id @default(cuid()) + senderId String? + receiverId String + sender User? @relation("notificationSent", fields: [senderId], references: [id], onDelete: SetNull) + receiver User @relation("notificationReceived", fields: [receiverId], references: [id], onDelete: Cascade) + event Event + message String + isRead Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +enum Event { + LINKED_VIEWED // When a shared document link is viewed by someone. + LINKED_SHARED // When a user shares a document link with others. + SUBSCRIPTION_RENEWAL // notifications about upcoming subscription renewals or changes. + DOCUMENT_ADDED // when a new document is added + DOCUMENT_UPDATED // when a document's detail changed such as Document Name, etc + DOCUMENT_DELETED // when a document is deleted + DOCUMENT_FEEDBACK // upvote / downvote, reactions, comments etc + TEAM_CREATED // when a team is created + TEAM_UPDATED // when team's detail changed such as Team Name, etc + TEAM_DELETED // when a team is deleted + TEAM_MEMBER_INVITED // when a new team member is invited + TEAM_MEMBER_REMOVED // when a team member is removed from the team + TEAM_MEMBER_PROMOTED // when a team member gets promoted to admin + TEAM_MEMBER_DEMOTED // when a team member gets demoted to member + DOMAIN_ADDED // when a new domain is added + DOMAIN_REMOVED // when a existing domain is removed + ACCOUNT_ACTIVITY // account-related activities, such as login from a new device. + SYSTEM_UPDATE // Notifications about system-wide updates or maintenance. + PROMOTION // announcement about promotions, discounts, or special offers +} From e72bbb5beec7fc1383ec220ae4afeaf8419a9562 Mon Sep 17 00:00:00 2001 From: Aashish-Upadhyay-101 Date: Sat, 11 Nov 2023 13:56:28 +0545 Subject: [PATCH 02/23] feat: webhook schema --- prisma/schema.prisma | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b27818bd5..6c7bb4a8e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -48,6 +48,7 @@ model User { documents Document[] teams UserTeam[] domains Domain[] + webhooks Webhook[] notificationsSent Notificaiton[] @relation("notificationSent") notificaitonsReceived Notificaiton[] @relation("notificationReceived") plan String @default("trial") @@ -203,6 +204,16 @@ model Invitation { @@unique([email]) } +model Webhook { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + targetUrl String + events Event[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + model Notificaiton { id String @id @default(cuid()) senderId String? From 2e47dc4b1c9fbab608d105c05d83fa05c260d975 Mon Sep 17 00:00:00 2001 From: Aashish-Upadhyay-101 Date: Sat, 11 Nov 2023 14:13:08 +0545 Subject: [PATCH 03/23] feat: internal notification webhook --- pages/api/webhooks.ts | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 pages/api/webhooks.ts diff --git a/pages/api/webhooks.ts b/pages/api/webhooks.ts new file mode 100644 index 000000000..ff6c799e4 --- /dev/null +++ b/pages/api/webhooks.ts @@ -0,0 +1,35 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import prisma from "@/lib/prisma"; +import { Event } from "@prisma/client"; +import { getServerSession } from "next-auth"; +import { authOptions } from "./auth/[...nextauth]"; +import { CustomUser } from "@/lib/types"; + +export default async function (req: NextApiRequest, res: NextApiResponse) { + if (req.method === "POST") { + const session = await getServerSession(req, res, authOptions); + if (!session) { + return res.status(401).end("Unauthorized"); + } + + // TODO: signature verification + + const { eventType, eventData } = req.body as { + eventType: Event; + eventData: any; + }; + + const userId = (session.user as CustomUser).id; + + await prisma.notificaiton.create({ + data: { + receiverId: userId, + senderId: eventData.senderId ? eventData.senderId : null, + event: eventType, + message: eventData.message, + }, + }); + + return res.status(201); + } +} From 84a808fb0eeb7f49804c7084b49cd4b5076663dc Mon Sep 17 00:00:00 2001 From: Aashish-Upadhyay-101 Date: Sat, 11 Nov 2023 15:36:04 +0545 Subject: [PATCH 04/23] feat: notification service sent event --- lib/notifications/notification-service.ts | 47 ++++++++++++++++++++++ pages/api/notifications/index.ts | 49 +++++++++++++++++++++++ pages/api/webhooks.ts | 3 ++ 3 files changed, 99 insertions(+) create mode 100644 lib/notifications/notification-service.ts create mode 100644 pages/api/notifications/index.ts diff --git a/lib/notifications/notification-service.ts b/lib/notifications/notification-service.ts new file mode 100644 index 000000000..54a8f6eea --- /dev/null +++ b/lib/notifications/notification-service.ts @@ -0,0 +1,47 @@ +const subscriptions: { + [key: string]: any[]; +} = {}; + +interface ISubscribeToNotification { + userId: string; + sendNotification: CallableFunction; +} + +// subscribe a user to notifications +export function subscribeToNotification({ + userId, + sendNotification, +}: ISubscribeToNotification) { + if (!subscriptions[userId]) { + subscriptions[userId] = []; + } + + subscriptions[userId].push(sendNotification); +} + +// unsubscribe a user to notifications +export function unsubscribeToNotification({ + userId, + sendNotification, +}: ISubscribeToNotification) { + if (subscriptions[userId]) { + subscriptions[userId] = subscriptions[userId].filter( + (cb) => cb !== sendNotification + ); + if (subscriptions[userId].length === 0) { + delete subscriptions[userId]; + } + } +} + +interface INotifySubscriber { + userId: string; + notification: string; +} +export function notifySubscriber({ userId, notification }: INotifySubscriber) { + if (subscriptions[userId]) { + subscriptions[userId].forEach((sendNotification) => + sendNotification(notification) + ); + } +} diff --git a/pages/api/notifications/index.ts b/pages/api/notifications/index.ts new file mode 100644 index 000000000..d80e9a031 --- /dev/null +++ b/pages/api/notifications/index.ts @@ -0,0 +1,49 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import { getServerSession } from "next-auth"; +import { authOptions } from "../auth/[...nextauth]"; +import { CustomUser } from "@/lib/types"; +import { + subscribeToNotification, + unsubscribeToNotification, +} from "@/lib/notifications/notification-service"; +import prisma from "@/lib/prisma"; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method === "GET") { + const session = await getServerSession(req, res, authOptions); + if (!session) { + return res.status(401).end("Unauthorized"); + } + + res.writeHead(200, { + connection: "keep-alive", + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + }); + + const userId = (session.user as CustomUser).id; + + const notifications = await prisma.notificaiton.findMany({ + where: { + receiverId: userId, + }, + }); + + const sendNotification = () => { + res.write(JSON.stringify(notifications)); + }; + + subscribeToNotification({ userId, sendNotification }); + + req.on("close", () => { + unsubscribeToNotification({ userId, sendNotification }); + res.end(); + }); + } else { + res.setHeader("Allow", ["GET"]); + return res.status(405).end(`Method ${req.method} Not Allowed`); + } +} diff --git a/pages/api/webhooks.ts b/pages/api/webhooks.ts index ff6c799e4..a54fd4b20 100644 --- a/pages/api/webhooks.ts +++ b/pages/api/webhooks.ts @@ -31,5 +31,8 @@ export default async function (req: NextApiRequest, res: NextApiResponse) { }); return res.status(201); + } else { + res.setHeader("Allow", ["POST"]); + return res.status(405).end(`Method ${req.method} Not Allowed`); } } From 4eb7281b077a6b3dd7f302d8cc5b2c8a85d5afa4 Mon Sep 17 00:00:00 2001 From: Aashish-Upadhyay-101 Date: Sat, 11 Nov 2023 16:08:10 +0545 Subject: [PATCH 05/23] feat: webhook triggers --- lib/notifications/notification-service.ts | 8 +++-- lib/webhooks.ts | 40 ++++++++++++++++++++++ pages/api/notifications/index.ts | 2 +- pages/api/webhooks.ts | 20 +++++++++-- prisma/schema.prisma | 41 ++++++++++++----------- 5 files changed, 85 insertions(+), 26 deletions(-) create mode 100644 lib/webhooks.ts diff --git a/lib/notifications/notification-service.ts b/lib/notifications/notification-service.ts index 54a8f6eea..9bdc776fb 100644 --- a/lib/notifications/notification-service.ts +++ b/lib/notifications/notification-service.ts @@ -1,3 +1,5 @@ +import { Notification } from "@prisma/client"; + const subscriptions: { [key: string]: any[]; } = {}; @@ -35,10 +37,10 @@ export function unsubscribeToNotification({ } interface INotifySubscriber { - userId: string; - notification: string; + notification: Notification; } -export function notifySubscriber({ userId, notification }: INotifySubscriber) { +export function notifySubscriber({ notification }: INotifySubscriber) { + const userId = notification.receiverId; if (subscriptions[userId]) { subscriptions[userId].forEach((sendNotification) => sendNotification(notification) diff --git a/lib/webhooks.ts b/lib/webhooks.ts new file mode 100644 index 000000000..550a532d0 --- /dev/null +++ b/lib/webhooks.ts @@ -0,0 +1,40 @@ +import prisma from "@/lib/prisma"; +import { Event } from "@prisma/client"; + +interface IWebhookTrigger { + eventType: Event; + eventData: any; +} + +export async function triggerWebhooks({ + eventType, + eventData, +}: IWebhookTrigger) { + try { + const userId = eventData.userId; + const webhooks = await prisma.webhook.findMany({ + where: { + userId, + }, + }); + + for (let webhook of webhooks) { + if (webhook.events.includes(eventType)) { + // send the post request to the webhook's target url + await sendToWebhookEndpoint(webhook.targetUrl, eventData); + } + } + + const internalNotificationWebhook = "/api/webhooks"; + await sendToWebhookEndpoint(internalNotificationWebhook, eventData); + } catch (error) { + console.log(error as Error); + } +} + +async function sendToWebhookEndpoint(url: string, data: any) { + await fetch(url, { + method: "POST", + body: data, + }); +} diff --git a/pages/api/notifications/index.ts b/pages/api/notifications/index.ts index d80e9a031..11b427c31 100644 --- a/pages/api/notifications/index.ts +++ b/pages/api/notifications/index.ts @@ -26,7 +26,7 @@ export default async function handler( const userId = (session.user as CustomUser).id; - const notifications = await prisma.notificaiton.findMany({ + const notifications = await prisma.notification.findMany({ where: { receiverId: userId, }, diff --git a/pages/api/webhooks.ts b/pages/api/webhooks.ts index a54fd4b20..b586e234e 100644 --- a/pages/api/webhooks.ts +++ b/pages/api/webhooks.ts @@ -4,6 +4,7 @@ import { Event } from "@prisma/client"; import { getServerSession } from "next-auth"; import { authOptions } from "./auth/[...nextauth]"; import { CustomUser } from "@/lib/types"; +import { notifySubscriber } from "@/lib/notifications/notification-service"; export default async function (req: NextApiRequest, res: NextApiResponse) { if (req.method === "POST") { @@ -21,15 +22,30 @@ export default async function (req: NextApiRequest, res: NextApiResponse) { const userId = (session.user as CustomUser).id; - await prisma.notificaiton.create({ + const notification = await prisma.notification.create({ data: { - receiverId: userId, + receiverId: eventData.receiverId, senderId: eventData.senderId ? eventData.senderId : null, event: eventType, message: eventData.message, }, }); + // notify the user (in-app notification) + notifySubscriber({ notification }); + + // notify the user (via email) + // TODO: check if user has allow email notification if yes then send the email notification + const user = await prisma.user.findUnique({ + where: { + id: userId, + }, + }); + + if (user?.isEmailNotificationEnabled) { + // notify user via email + } + return res.status(201); } else { res.setHeader("Allow", ["POST"]); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6c7bb4a8e..38cd7b4bd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -37,25 +37,26 @@ model Session { } model User { - id String @id @default(cuid()) - name String? - email String? @unique - emailVerified DateTime? - image String? - createdAt DateTime @default(now()) - accounts Account[] - sessions Session[] - documents Document[] - teams UserTeam[] - domains Domain[] - webhooks Webhook[] - notificationsSent Notificaiton[] @relation("notificationSent") - notificaitonsReceived Notificaiton[] @relation("notificationReceived") - plan String @default("trial") - stripeId String? @unique // Stripe subscription / customer ID - subscriptionId String? @unique // Stripe subscription ID - startsAt DateTime? // Stripe subscription start date - endsAt DateTime? // Stripe subscription end date + id String @id @default(cuid()) + name String? + email String? @unique + emailVerified DateTime? + image String? + isEmailNotificationEnabled Boolean @default(true) // by default the user get email for all the events + createdAt DateTime @default(now()) + accounts Account[] + sessions Session[] + documents Document[] + teams UserTeam[] + domains Domain[] + webhooks Webhook[] + notificationsSent Notification[] @relation("notificationSent") + notificaitonsReceived Notification[] @relation("notificationReceived") + plan String @default("trial") + stripeId String? @unique // Stripe subscription / customer ID + subscriptionId String? @unique // Stripe subscription ID + startsAt DateTime? // Stripe subscription start date + endsAt DateTime? // Stripe subscription end date } model Team { @@ -214,7 +215,7 @@ model Webhook { updatedAt DateTime @updatedAt } -model Notificaiton { +model Notification { id String @id @default(cuid()) senderId String? receiverId String From 1690c333607d3503178655b092b491111d4f23cd Mon Sep 17 00:00:00 2001 From: Aashish-Upadhyay-101 Date: Fri, 17 Nov 2023 21:44:31 +0545 Subject: [PATCH 06/23] feat: document view notification --- components/Sidebar.tsx | 30 ++++++- .../notifications/notification-dropdown.tsx | 78 +++++++++++++++++++ components/ui/badge.tsx | 12 +-- lib/notifications/notification-handlers.ts | 44 +++++++++++ lib/notifications/notification-service.ts | 49 ------------ lib/swr/use-notifications.ts | 21 +++++ lib/types.ts | 10 +++ lib/webhooks.ts | 39 ++++++---- .../[notificationId]/mark-read.ts | 33 ++++++++ pages/api/notifications/index.ts | 23 +----- pages/api/stripe/webhook.ts | 4 +- pages/api/views.ts | 53 ++++++++----- pages/api/webhooks.ts | 72 ++++++++--------- prisma/schema.prisma | 30 ++++--- tailwind.config.js | 16 +++- 15 files changed, 350 insertions(+), 164 deletions(-) create mode 100644 components/notifications/notification-dropdown.tsx create mode 100644 lib/notifications/notification-handlers.ts delete mode 100644 lib/notifications/notification-service.ts create mode 100644 lib/swr/use-notifications.ts create mode 100644 pages/api/notifications/[notificationId]/mark-read.ts diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index 34e87eb05..b7e40d916 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -20,6 +20,10 @@ import { usePlan } from "@/lib/swr/use-billing"; import Image from "next/image"; import SelectTeam from "./teams/select-team"; import { TeamContextType, initialState, useTeam } from "@/context/team-context"; +import NotificationDropdown from "./notifications/notification-dropdown"; +import { Bell } from "lucide-react"; +import useNotifications from "@/lib/swr/use-notifications"; +import { mutate } from "swr"; export default function Sidebar() { const { data: session, status } = useSession(); @@ -31,6 +35,8 @@ export default function Sidebar() { const { currentTeam, teams, isLoading }: TeamContextType = useTeam() || initialState; + const { notifications } = useNotifications(); + const navigation = [ // { // name: "Overview", @@ -75,6 +81,16 @@ export default function Sidebar() { const userPlan = plan && plan.plan; + const getUnReadNotificationCount = () => { + let count = 0; + for (let notification of notifications || []) { + if (!notification.isRead) { + count++; + } + } + return count; + }; + return ( <> @@ -180,7 +196,7 @@ export default function Sidebar() {
{/* Sidebar component, swap this element with another sidebar if you like */}
-
+

Papermark{" "} {userPlan == "pro" ? ( @@ -189,6 +205,18 @@ export default function Sidebar() { ) : null}

+ +
+ + {getUnReadNotificationCount() !== 0 && ( + + {getUnReadNotificationCount() > 9 + ? "9+" + : getUnReadNotificationCount()} + + )} +
+
- -
- -
- - {/* {documents && documents.length === 0 && ( + {webhooks && webhooks.length === 0 && (
- )} */} + )} {/* Documents list */} - {/*
    - {documents - ? documents.map((document) => { - return ; - }) - : Array.from({ length: 3 }).map((_, i) => ( -
  • - - -
  • - ))} -
*/} +
); } export function EmptyWebhooks() { + const [openModal, setOpenModal] = useState(false); + return (
@@ -74,12 +56,12 @@ export function EmptyWebhooks() { Get started by creating a new webhook.

- - - +
); From c322fb9cef2eb211acf1a273c21d0a2b9b4efdf9 Mon Sep 17 00:00:00 2001 From: Aashish-Upadhyay-101 Date: Sat, 18 Nov 2023 22:48:51 +0545 Subject: [PATCH 14/23] feat: webhook delete --- components/webhooks/webhook-table.tsx | 64 +++++++++++++------ .../[notificationId]/mark-read.ts | 2 +- pages/api/webhooks/[id]/index.ts | 49 ++++++++++++++ pages/api/webhooks/index.ts | 2 +- 4 files changed, 94 insertions(+), 23 deletions(-) create mode 100644 pages/api/webhooks/[id]/index.ts diff --git a/components/webhooks/webhook-table.tsx b/components/webhooks/webhook-table.tsx index cfcceb648..3b0bad552 100644 --- a/components/webhooks/webhook-table.tsx +++ b/components/webhooks/webhook-table.tsx @@ -6,13 +6,36 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Webhook } from "@prisma/client"; -import MoreHorizontal from "../shared/icons/more-horizontal"; -import { Badge } from "../ui/badge"; +import MoreHorizontal from "@/components/shared/icons/more-horizontal"; import { timeAgo } from "@/lib/utils"; -import { Skeleton } from "../ui/skeleton"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; +import { mutate } from "swr"; export function WebhookTable({ webhooks }: { webhooks: Webhook[] }) { + const removeWebhook = async (webhookId: string) => { + const response = await fetch(`/api/webhooks/${webhookId}`, { + method: "DELETE", + }); + + if (response.status !== 204) { + const error = await response.json(); + toast.error(error); + return; + } + toast.success("Webhook deleted successfully!"); + await mutate("/api/webhooks"); + }; + return (
@@ -51,7 +74,23 @@ export function WebhookTable({ webhooks }: { webhooks: Webhook[] }) { {timeAgo(webhook.createdAt)} - + + + + + + Actions + removeWebhook(webhook.id)} + > + Remove Webhook + + + ))} @@ -60,20 +99,3 @@ export function WebhookTable({ webhooks }: { webhooks: Webhook[] }) { ); } - -{ - /* - - - - - - - - - - - - - */ -} diff --git a/pages/api/notifications/[notificationId]/mark-read.ts b/pages/api/notifications/[notificationId]/mark-read.ts index a8053a587..07c81593b 100644 --- a/pages/api/notifications/[notificationId]/mark-read.ts +++ b/pages/api/notifications/[notificationId]/mark-read.ts @@ -6,7 +6,7 @@ import prisma from "@/lib/prisma"; export default async function handler( req: NextApiRequest, - res: NextApiResponse + res: NextApiResponse, ) { if (req.method === "POST") { const session = await getServerSession(req, res, authOptions); diff --git a/pages/api/webhooks/[id]/index.ts b/pages/api/webhooks/[id]/index.ts new file mode 100644 index 000000000..7db1c3b99 --- /dev/null +++ b/pages/api/webhooks/[id]/index.ts @@ -0,0 +1,49 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "../../auth/[...nextauth]"; +import { CustomUser } from "@/lib/types"; +import prisma from "@/lib/prisma"; +import { errorhandler } from "@/lib/errorHandler"; + +export default async function handle( + req: NextApiRequest, + res: NextApiResponse, +) { + if (req.method === "DELETE") { + const session = await getServerSession(req, res, authOptions); + if (!session) { + return res.status(401).end("Unauthorized"); + } + + const userId = (session.user as CustomUser).id; + + const { id } = req.query as { id: string }; + + try { + const webhook = await prisma.webhook.findUnique({ + where: { + id, + }, + }); + + if (webhook?.userId !== userId) { + return res + .status(401) + .json("You are not permitted to delete this webhook"); + } + + await prisma.webhook.delete({ + where: { + id, + }, + }); + + return res.status(204).end(); + } catch (error) { + errorhandler(error, res); + } + } else { + res.setHeader("Allow", ["DELETE"]); + return res.status(405).end(`Method ${req.method} Not Allowed`); + } +} diff --git a/pages/api/webhooks/index.ts b/pages/api/webhooks/index.ts index 53102e3d4..32664d3f3 100644 --- a/pages/api/webhooks/index.ts +++ b/pages/api/webhooks/index.ts @@ -5,7 +5,7 @@ import { CustomUser } from "@/lib/types"; import prisma from "@/lib/prisma"; import { errorhandler } from "@/lib/errorHandler"; -export default async function handler( +export default async function handle( req: NextApiRequest, res: NextApiResponse, ) { From fb0364863ba3c3fe5e41bb3afe2587ade2551e90 Mon Sep 17 00:00:00 2001 From: Aashish-Upadhyay-101 Date: Sat, 18 Nov 2023 22:50:22 +0545 Subject: [PATCH 15/23] fix: handler function typo --- pages/api/notifications/[notificationId]/mark-read.ts | 4 ++-- pages/api/notifications/index.ts | 4 ++-- pages/api/notifications/webhooks.ts | 5 ++++- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/pages/api/notifications/[notificationId]/mark-read.ts b/pages/api/notifications/[notificationId]/mark-read.ts index a8053a587..d3ca40b9d 100644 --- a/pages/api/notifications/[notificationId]/mark-read.ts +++ b/pages/api/notifications/[notificationId]/mark-read.ts @@ -4,9 +4,9 @@ import { authOptions } from "../../auth/[...nextauth]"; import { CustomUser } from "@/lib/types"; import prisma from "@/lib/prisma"; -export default async function handler( +export default async function handle( req: NextApiRequest, - res: NextApiResponse + res: NextApiResponse, ) { if (req.method === "POST") { const session = await getServerSession(req, res, authOptions); diff --git a/pages/api/notifications/index.ts b/pages/api/notifications/index.ts index b65666c08..cfd563ef6 100644 --- a/pages/api/notifications/index.ts +++ b/pages/api/notifications/index.ts @@ -4,9 +4,9 @@ import { authOptions } from "../auth/[...nextauth]"; import { CustomUser } from "@/lib/types"; import prisma from "@/lib/prisma"; -export default async function handler( +export default async function handle( req: NextApiRequest, - res: NextApiResponse + res: NextApiResponse, ) { if (req.method === "GET") { const session = await getServerSession(req, res, authOptions); diff --git a/pages/api/notifications/webhooks.ts b/pages/api/notifications/webhooks.ts index ff4bbe9da..13da5e8b1 100644 --- a/pages/api/notifications/webhooks.ts +++ b/pages/api/notifications/webhooks.ts @@ -6,7 +6,10 @@ import { authOptions } from "../auth/[...nextauth]"; import { CustomUser } from "@/lib/types"; import { handleLinkViewed } from "@/lib/notifications/notification-handlers"; -export default async function (req: NextApiRequest, res: NextApiResponse) { +export default async function handle( + req: NextApiRequest, + res: NextApiResponse, +) { if (req.method === "POST") { // const session = await getServerSession(req, res, authOptions); // if (!session) { From ae7647757f8451735a590b68a269747548132f5e Mon Sep 17 00:00:00 2001 From: Aashish-Upadhyay-101 Date: Sat, 18 Nov 2023 23:17:35 +0545 Subject: [PATCH 16/23] fix: notification ordering fix --- pages/api/notifications/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pages/api/notifications/index.ts b/pages/api/notifications/index.ts index cfd563ef6..a207f0b4e 100644 --- a/pages/api/notifications/index.ts +++ b/pages/api/notifications/index.ts @@ -20,6 +20,9 @@ export default async function handle( where: { userId, }, + orderBy: { + createdAt: "desc", + }, }); return res.status(200).json(notifications); From 975251e54072c039c4c642e7656f7b693649d369 Mon Sep 17 00:00:00 2001 From: Aashish-Upadhyay-101 Date: Wed, 22 Nov 2023 21:04:03 +0545 Subject: [PATCH 17/23] fix: minor typos and few changes --- components/webhooks/add-webhook-modal.tsx | 64 ++++++++++++---------- lib/notifications/notification-handlers.ts | 31 ++++++++--- lib/types.ts | 1 + lib/webhooks.ts | 48 +++++++++++++--- pages/api/notifications/webhooks.ts | 41 ++++++-------- pages/api/views.ts | 11 +--- pages/webhooks/index.tsx | 16 +++--- prisma/schema.prisma | 4 +- tailwind.config.js | 2 +- 9 files changed, 131 insertions(+), 87 deletions(-) diff --git a/components/webhooks/add-webhook-modal.tsx b/components/webhooks/add-webhook-modal.tsx index 6f5fe3e1a..9dd44e2d0 100644 --- a/components/webhooks/add-webhook-modal.tsx +++ b/components/webhooks/add-webhook-modal.tsx @@ -15,8 +15,7 @@ import { Checkbox } from "@/components/ui/checkbox"; import { Event } from "@prisma/client"; import { mutate } from "swr"; - -export function AddWebhookModal({children}: {children: React.ReactNode}) { +export function AddWebhookModal({ children }: { children: React.ReactNode }) { const [targetUrl, setTargetUrl] = useState(""); const [creating, setCreating] = useState(false); const [events, setEvents] = useState([]); @@ -30,7 +29,7 @@ export function AddWebhookModal({children}: {children: React.ReactNode}) { if (!targetUrl) { setCreating(false); - setUrlError("please enter the endpoint url") + setUrlError("please enter the endpoint url"); return; } @@ -41,19 +40,16 @@ export function AddWebhookModal({children}: {children: React.ReactNode}) { } // create a document in the database with the blob url - const response = await fetch( - "/api/webhooks", - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - targetUrl, - events, - }), - } - ); + const response = await fetch("/api/webhooks", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + targetUrl, + events, + }), + }); if (!response.ok) { setCreating(false); @@ -69,17 +65,19 @@ export function AddWebhookModal({children}: {children: React.ReactNode}) { const handleEventSelect = (value: Event) => { console.log("selected"); - + if (events.includes(value)) { setEvents(events.filter((event) => event !== value)); - }else { + } else { setEvents([...events, value]); } - } + }; return ( - setModalOpen(true)} asChild>{children} + setModalOpen(true)} asChild> + {children} + Add Webhook @@ -87,11 +85,18 @@ export function AddWebhookModal({children}: {children: React.ReactNode}) {
+ className="flex flex-col gap-3" + >
- setTargetUrl(e.target.value)}/> - {urlError && !targetUrl &&

{urlError}

} + setTargetUrl(e.target.value)} + /> + {urlError && !targetUrl && ( +

{urlError}

+ )}
@@ -99,18 +104,21 @@ export function AddWebhookModal({children}: {children: React.ReactNode}) {
- handleEventSelect("LINKED_VIEWED")}/> + handleEventSelect("LINK_VIEWED")} + />
- {checkboxError &&

{checkboxError}

} + {checkboxError && ( +

{checkboxError}

+ )}
-
diff --git a/lib/notifications/notification-handlers.ts b/lib/notifications/notification-handlers.ts index 449133d3c..a628a4117 100644 --- a/lib/notifications/notification-handlers.ts +++ b/lib/notifications/notification-handlers.ts @@ -32,16 +32,29 @@ export async function handleLinkViewed(eventData: IHandleLinkViewed) { }, }); - // TODO: this can be offloaded to a background job in the future to save some time - // send email to document owner that document has been viewed - if (eventData.data.link.enableNotification) { - await sendViewedDocumentEmail( - eventData.data.documentOwner, - eventData.data.documentId, - eventData.data.documentName, - eventData.data.viewerEmail - ); + // notify the user (via email) + // TODO: check if user has allow email notification if yes then send the email notification + const user = await prisma.user.findUnique({ + where: { + id: eventData.receiverId, + }, + }); + + // check if user has enabled email notification, if yes then check if the link has enable email notification + if (user?.isEmailNotificationEnabled) { + // notify user via email + // TODO: this can be offloaded to a background job in the future to save some time + // send email to document owner that document has been viewed + if (eventData.data.link.enableNotification) { + await sendViewedDocumentEmail( + eventData.data.documentOwner, + eventData.data.documentId, + eventData.data.documentName, + eventData.data.viewerEmail, + ); + } } + return notification; } catch (error) { throw error; diff --git a/lib/types.ts b/lib/types.ts index 1da63a5ef..05fecb936 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -5,6 +5,7 @@ import { View, User as PrismaUser, DocumentVersion, + Event, } from "@prisma/client"; export type CustomUser = NextAuthUser & PrismaUser; diff --git a/lib/webhooks.ts b/lib/webhooks.ts index 2c15f61cf..078f40ec9 100644 --- a/lib/webhooks.ts +++ b/lib/webhooks.ts @@ -1,3 +1,4 @@ +import crypto from "crypto"; import prisma from "@/lib/prisma"; import { Event } from "@prisma/client"; @@ -16,17 +17,21 @@ export async function triggerWebhooks({ const webhooks = await prisma.webhook.findMany({ where: { userId, + events: { + has: eventType, + }, + }, + select: { + targetUrl: true, }, }); for (let webhook of webhooks) { - if (webhook.events.includes(eventType)) { - // send the post request to the webhook's target url - await sendToWebhookEndpoint(webhook.targetUrl, { - eventType, - eventData, - }); - } + // send the post request to the webhook's target url + await sendToWebhookEndpoint(webhook.targetUrl, { + eventType, + eventData, + }); } // send data to internal webhook endpoint for notifications @@ -41,11 +46,38 @@ export async function triggerWebhooks({ } async function sendToWebhookEndpoint(url: string, data: any) { - await fetch(url, { + const signature = generateSignature(JSON.stringify(data)); + const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", + "x-signature": signature, }, body: JSON.stringify(data), }); + + if (!res.ok) { + throw new Error(`HTTP error! status: ${res.status}`); + } + + return res; +} + +const SECRET_KEY = process.env.WEBHOOK_SECRET_KEY as string; + +export function generateSignature(data: string) { + return crypto.createHmac("sha256", SECRET_KEY).update(data).digest("hex"); +} + +export function verifySignature(body: string, signature: string) { + if (!body || !signature) { + return false; + } + + const hash = crypto + .createHmac("sha256", SECRET_KEY) + .update(body) + .digest("hex"); + + return crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(signature)); } diff --git a/pages/api/notifications/webhooks.ts b/pages/api/notifications/webhooks.ts index 13da5e8b1..1ce8b1e2d 100644 --- a/pages/api/notifications/webhooks.ts +++ b/pages/api/notifications/webhooks.ts @@ -1,22 +1,28 @@ import { NextApiRequest, NextApiResponse } from "next"; import prisma from "@/lib/prisma"; -import { Event } from "@prisma/client"; +import { Event, Notification } from "@prisma/client"; import { getServerSession } from "next-auth"; import { authOptions } from "../auth/[...nextauth]"; import { CustomUser } from "@/lib/types"; import { handleLinkViewed } from "@/lib/notifications/notification-handlers"; +import { errorhandler } from "@/lib/errorHandler"; +import { verifySignature } from "@/lib/webhooks"; export default async function handle( req: NextApiRequest, res: NextApiResponse, ) { if (req.method === "POST") { - // const session = await getServerSession(req, res, authOptions); - // if (!session) { - // return res.status(401).end("Unauthorized"); - // } + //signature verification + const signature = req.headers["x-signature"] as string; + if (!signature) { + return res.status(400).end("x-signature header missing from the request"); + } - // TODO: signature verification + const body = JSON.stringify(req.body); + if (!verifySignature(body, signature)) { + return res.status(401).end("Invalid signature"); + } try { const { eventType, eventData } = req.body as { @@ -25,30 +31,19 @@ export default async function handle( }; // const userId = (session.user as CustomUser).id; - + let notification: Notification | null = null; switch (eventType) { - case "LINKED_VIEWED": - handleLinkViewed(eventData); + case "LINK_VIEWED": + notification = await handleLinkViewed(eventData); break; // TODO: other events like Team created, Team member added, etc. } - // notify the user (via email) - // TODO: check if user has allow email notification if yes then send the email notification - const user = await prisma.user.findUnique({ - where: { - id: eventData.receiverId, - }, - }); - - if (user?.isEmailNotificationEnabled) { - // notify user via email - } - - return res.status(201).json("hello"); + // since the internal webhook is for notification purpose we are returning the notification that is being created + return res.status(201).json(notification); } catch (error) { - console.log(error as Error); + errorhandler(error, res); } } else { res.setHeader("Allow", ["POST"]); diff --git a/pages/api/views.ts b/pages/api/views.ts index 2526328ee..dd6aabf7c 100644 --- a/pages/api/views.ts +++ b/pages/api/views.ts @@ -8,7 +8,7 @@ import { triggerWebhooks } from "@/lib/webhooks"; export default async function handle( req: NextApiRequest, - res: NextApiResponse + res: NextApiResponse, ) { // We only allow POST requests if (req.method !== "POST") { @@ -29,11 +29,6 @@ export default async function handle( emailProtected: true, enableNotification: true, password: true, - _count: { - select: { - views: true, - }, - }, }, }); @@ -107,10 +102,10 @@ export default async function handle( // this will trigger the webhook and also notification(both in-app and email) await triggerWebhooks({ - eventType: "LINKED_VIEWED", + eventType: "LINK_VIEWED", eventData: { receiverId: newView.document.owner.id, - event: "LINKED_VIEWED", + event: "LINK_VIEWED", data: { documentId, documentName: newView.document.name, diff --git a/pages/webhooks/index.tsx b/pages/webhooks/index.tsx index dc18f38e9..5b3070a2b 100644 --- a/pages/webhooks/index.tsx +++ b/pages/webhooks/index.tsx @@ -1,15 +1,14 @@ import { useState } from "react"; import { PlusIcon } from "@heroicons/react/24/solid"; -import { AddWebhookModal } from "@/components/webhooks/add-webhook-modal" +import { AddWebhookModal } from "@/components/webhooks/add-webhook-modal"; import { Separator } from "@/components/ui/separator"; -import AppLayout from "@/components/layouts/app" +import AppLayout from "@/components/layouts/app"; import { Button } from "@/components/ui/button"; import { Webhook } from "lucide-react"; import useWebhooks from "@/lib/swr/use-webhooks"; import { Skeleton } from "@/components/ui/skeleton"; import { WebhookTable } from "@/components/webhooks/webhook-table"; - export default function Webhooks() { const { webhooks } = useWebhooks(); @@ -21,7 +20,9 @@ export default function Webhooks() {

Webhooks

-

Manage your Webhooks

+

+ Manage your Webhooks +

    @@ -32,14 +33,13 @@ export default function Webhooks() { - {webhooks && webhooks.length === 0 && ( + {webhooks && webhooks.length === 0 ? (
    + ) : ( + )} - - {/* Documents list */} - ); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 28bba588b..2491cf3fc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -229,8 +229,8 @@ model Notification { } enum Event { - LINKED_VIEWED // When a shared document link is viewed by someone. - LINKED_SHARED // When a user shares a document link with others. + LINK_VIEWED // When a shared document link is viewed by someone. + LINK_SHARED // When a user shares a document link with others. SUBSCRIPTION_RENEWAL // notifications about upcoming subscription renewals or changes. DOCUMENT_ADDED // when a new document is added DOCUMENT_UPDATED // when a document's detail changed such as Document Name, etc diff --git a/tailwind.config.js b/tailwind.config.js index 74d9b9a1f..74e74ec0e 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -209,7 +209,7 @@ module.exports = { display: "none", }, ".no-scrollbar": { - "-ms-overflow-style": "mone", + "-ms-overflow-style": "none", "scrollbar-width": "none", }, }; From a0220af1a48d11d0c93901369c62f10fb2ee7a7f Mon Sep 17 00:00:00 2001 From: Aashish-Upadhyay-101 Date: Thu, 23 Nov 2023 17:58:54 +0545 Subject: [PATCH 18/23] fix: webhook and notification scoped by Team --- .../notifications/notification-dropdown.tsx | 17 +++---- components/webhooks/add-webhook-modal.tsx | 26 +++++++---- lib/notifications/notification-handlers.ts | 4 ++ lib/swr/use-notifications.ts | 7 ++- lib/swr/use-webhooks.ts | 25 +++++----- lib/team/helper.ts | 41 ++++++++++++++++- lib/webhooks.ts | 2 +- .../[notificationId]/mark-read.ts | 33 ------------- .../[notificationId]/mark-read.ts | 46 +++++++++++++++++++ .../[teamId]}/notifications/index.ts | 30 ++++++++---- .../[teamId]}/notifications/webhooks.ts | 2 +- .../[teamId]}/webhooks/[id]/index.ts | 13 ++++-- .../{ => teams/[teamId]}/webhooks/index.ts | 14 +++++- pages/api/views.ts | 3 ++ prisma/schema.prisma | 37 ++++++++------- 15 files changed, 197 insertions(+), 103 deletions(-) delete mode 100644 pages/api/notifications/[notificationId]/mark-read.ts create mode 100644 pages/api/teams/[teamId]/notifications/[notificationId]/mark-read.ts rename pages/api/{ => teams/[teamId]}/notifications/index.ts (52%) rename pages/api/{ => teams/[teamId]}/notifications/webhooks.ts (96%) rename pages/api/{ => teams/[teamId]}/webhooks/[id]/index.ts (75%) rename pages/api/{ => teams/[teamId]}/webhooks/index.ts (80%) diff --git a/components/notifications/notification-dropdown.tsx b/components/notifications/notification-dropdown.tsx index f71c18723..e2c22ff16 100644 --- a/components/notifications/notification-dropdown.tsx +++ b/components/notifications/notification-dropdown.tsx @@ -3,16 +3,15 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, - DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Notifications } from "@/lib/types"; -import Folder from "@/components/shared/icons/folder"; import { Badge } from "@/components/ui/badge"; import { Bell } from "lucide-react"; import { mutate } from "swr"; import { useRouter } from "next/router"; import { timeAgo } from "@/lib/utils"; +import { useTeam } from "@/context/team-context"; export default function NotificationDropdown({ children, @@ -22,16 +21,17 @@ export default function NotificationDropdown({ notifications: Notifications[]; }) { const router = useRouter(); + const teamInfo = useTeam(); const markNotificationRead = async ( notificationId: string, - documentId: string + documentId: string, ) => { const response = await fetch( - `/api/notifications/${notificationId}/mark-read`, + `/api/teams/${teamInfo?.currentTeam?.id}/notifications/${notificationId}/mark-read`, { method: "POST", - } + }, ); if (!response.ok) { @@ -40,7 +40,7 @@ export default function NotificationDropdown({ router.push(`/documents/${documentId}`); - mutate("/api/notifications"); + mutate(`/api/teams/${teamInfo?.currentTeam?.id}/notifications`); }; return ( @@ -61,12 +61,13 @@ export default function NotificationDropdown({ className="relative py-4 pr-4 flex gap-3 hover:cursor-pointer" onClick={() => markNotificationRead(notification.id, notification.documentId) - }> + } + > {notification.message} {!notification.isRead && ( )} - + {timeAgo(notification.createdAt)} diff --git a/components/webhooks/add-webhook-modal.tsx b/components/webhooks/add-webhook-modal.tsx index 9dd44e2d0..8d59f156e 100644 --- a/components/webhooks/add-webhook-modal.tsx +++ b/components/webhooks/add-webhook-modal.tsx @@ -14,6 +14,7 @@ import { Input } from "@/components/ui/input"; import { Checkbox } from "@/components/ui/checkbox"; import { Event } from "@prisma/client"; import { mutate } from "swr"; +import { useTeam } from "@/context/team-context"; export function AddWebhookModal({ children }: { children: React.ReactNode }) { const [targetUrl, setTargetUrl] = useState(""); @@ -23,6 +24,8 @@ export function AddWebhookModal({ children }: { children: React.ReactNode }) { const [checkboxError, setCheckboxError] = useState(""); const [modalOpen, setModalOpen] = useState(false); + const teamInfo = useTeam(); + const handleWebhookCreation = async (event: React.FormEvent) => { event.preventDefault(); setCreating(true); @@ -40,16 +43,19 @@ export function AddWebhookModal({ children }: { children: React.ReactNode }) { } // create a document in the database with the blob url - const response = await fetch("/api/webhooks", { - method: "POST", - headers: { - "Content-Type": "application/json", + const response = await fetch( + `/api/teams/${teamInfo?.currentTeam?.id}/webhooks`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + targetUrl, + events, + }), }, - body: JSON.stringify({ - targetUrl, - events, - }), - }); + ); if (!response.ok) { setCreating(false); @@ -58,7 +64,7 @@ export function AddWebhookModal({ children }: { children: React.ReactNode }) { } setCreating(false); - mutate("/api/webhooks"); + mutate(`/api/teams/${teamInfo?.currentTeam?.id}/webhooks`); toast.success("Webhook created successfully!"); setModalOpen(false); }; diff --git a/lib/notifications/notification-handlers.ts b/lib/notifications/notification-handlers.ts index a628a4117..57c6deebd 100644 --- a/lib/notifications/notification-handlers.ts +++ b/lib/notifications/notification-handlers.ts @@ -4,6 +4,7 @@ import { sendViewedDocumentEmail } from "../emails/send-viewed-document"; interface IHandleLinkViewed { receiverId: string; + teamId: string; event: Event; data: { documentName: string; @@ -11,6 +12,7 @@ interface IHandleLinkViewed { documentId: string; documentOwner: string; link: { + id: string; enableNotification: boolean; }; }; @@ -25,9 +27,11 @@ export async function handleLinkViewed(eventData: IHandleLinkViewed) { : `Someone viewed your ${documentName}`; const notification = await prisma.notification.create({ data: { + teamId: eventData.teamId, userId: eventData.receiverId, event: eventData.event, message, + linkId: eventData.data.link.id, documentId: eventData.data.documentId, }, }); diff --git a/lib/swr/use-notifications.ts b/lib/swr/use-notifications.ts index f7f98ec45..6a6b77606 100644 --- a/lib/swr/use-notifications.ts +++ b/lib/swr/use-notifications.ts @@ -2,15 +2,18 @@ import { Event } from "@prisma/client"; import useSWR from "swr"; import { fetcher } from "../utils"; import { Notifications } from "@/lib/types"; +import { useTeam } from "@/context/team-context"; export default function useNotifications() { + const teamInfo = useTeam(); + const { data: notifications, isValidating } = useSWR( - "/api/notifications", + `/api/teams/${teamInfo?.currentTeam?.id}/notifications`, fetcher, { dedupingInterval: 30000, revalidateOnFocus: true, - } + }, ); return { diff --git a/lib/swr/use-webhooks.ts b/lib/swr/use-webhooks.ts index e7bfdceee..a1792d96f 100644 --- a/lib/swr/use-webhooks.ts +++ b/lib/swr/use-webhooks.ts @@ -1,22 +1,23 @@ import { Webhook } from "@prisma/client"; import useSWR from "swr"; import { fetcher } from "../utils"; +import { useTeam } from "@/context/team-context"; export default function useWebhooks() { - const { data: webhooks, isValidating } = useSWR< - Webhook[] ->( - "/api/webhooks", - fetcher, - { - revalidateOnFocus: false, - dedupingInterval: 60000, - } -); + const teamInfo = useTeam(); + + const { data: webhooks, isValidating } = useSWR( + `/api/teams/${teamInfo?.currentTeam?.id}/webhooks`, + fetcher, + { + revalidateOnFocus: false, + dedupingInterval: 60000, + }, + ); return { webhooks, loading: webhooks ? false : true, isValidating, - } -} \ No newline at end of file + }; +} diff --git a/lib/team/helper.ts b/lib/team/helper.ts index f0251ca93..fc2addf91 100644 --- a/lib/team/helper.ts +++ b/lib/team/helper.ts @@ -23,6 +23,11 @@ interface IDocumentWithLink { options?: {}; } +interface ITeamWithUser { + teamId: string; + userId: string; +} + export async function getTeamWithUsersAndDocument({ teamId, userId, @@ -59,7 +64,11 @@ export async function getTeamWithUsersAndDocument({ // check if the document exists in the team let document: - | (Document & { views?: View[]; versions?: DocumentVersion[]; links?: Link[] }) + | (Document & { + views?: View[]; + versions?: DocumentVersion[]; + links?: Link[]; + }) | undefined; if (docId) { document = team.documents.find((doc) => doc.id === docId); @@ -143,7 +152,7 @@ export async function getDocumentWithTeamAndUser({ } const teamHasUser = document.team?.users.some( - (user) => user.userId === userId + (user) => user.userId === userId, ); if (!teamHasUser) { throw new TeamError("You are not a member of the team"); @@ -151,3 +160,31 @@ export async function getDocumentWithTeamAndUser({ return { document }; } + +export async function getTeamWithUser({ teamId, userId }: ITeamWithUser) { + const team = await prisma.team.findUnique({ + where: { + id: teamId, + }, + include: { + users: { + select: { + userId: true, + }, + }, + }, + }); + + // check if the team exists + if (!team) { + throw new TeamError("Team doesn't exists"); + } + + // check if the user is part the team + const teamHasUser = team?.users.some((user) => user.userId === userId); + if (!teamHasUser) { + throw new TeamError("You are not a member of the team"); + } + + return { team }; +} diff --git a/lib/webhooks.ts b/lib/webhooks.ts index 078f40ec9..ab22d333f 100644 --- a/lib/webhooks.ts +++ b/lib/webhooks.ts @@ -35,7 +35,7 @@ export async function triggerWebhooks({ } // send data to internal webhook endpoint for notifications - const internalNotificationWebhook = `${process.env.NEXT_PUBLIC_BASE_URL}/api/notifications/webhooks`; + const internalNotificationWebhook = `${process.env.NEXT_PUBLIC_BASE_URL}/api/teams/${eventData.teamId}/notifications/webhooks`; await sendToWebhookEndpoint(internalNotificationWebhook, { eventType, eventData, diff --git a/pages/api/notifications/[notificationId]/mark-read.ts b/pages/api/notifications/[notificationId]/mark-read.ts deleted file mode 100644 index d3ca40b9d..000000000 --- a/pages/api/notifications/[notificationId]/mark-read.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { NextApiRequest, NextApiResponse } from "next"; -import { getServerSession } from "next-auth"; -import { authOptions } from "../../auth/[...nextauth]"; -import { CustomUser } from "@/lib/types"; -import prisma from "@/lib/prisma"; - -export default async function handle( - req: NextApiRequest, - res: NextApiResponse, -) { - if (req.method === "POST") { - const session = await getServerSession(req, res, authOptions); - if (!session) { - return res.status(401).end("Unauthorized"); - } - - const { notificationId } = req.query as { notificationId: string }; - - const notifications = await prisma.notification.update({ - where: { - id: notificationId, - }, - data: { - isRead: true, - }, - }); - - return res.status(200).json(notifications); - } else { - res.setHeader("Allow", ["POST"]); - return res.status(405).end(`Method ${req.method} Not Allowed`); - } -} diff --git a/pages/api/teams/[teamId]/notifications/[notificationId]/mark-read.ts b/pages/api/teams/[teamId]/notifications/[notificationId]/mark-read.ts new file mode 100644 index 000000000..62d17af97 --- /dev/null +++ b/pages/api/teams/[teamId]/notifications/[notificationId]/mark-read.ts @@ -0,0 +1,46 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import { getServerSession } from "next-auth"; +import { authOptions } from "../../../../auth/[...nextauth]"; +import { CustomUser } from "@/lib/types"; +import prisma from "@/lib/prisma"; +import { errorhandler } from "@/lib/errorHandler"; +import { getTeamWithUser } from "@/lib/team/helper"; + +export default async function handle( + req: NextApiRequest, + res: NextApiResponse, +) { + if (req.method === "POST") { + const session = await getServerSession(req, res, authOptions); + if (!session) { + return res.status(401).end("Unauthorized"); + } + + const { teamId, notificationId } = req.query as { + notificationId: string; + teamId: string; + }; + + const userId = (session.user as CustomUser).id; + + try { + await getTeamWithUser({ teamId, userId }); + + const notifications = await prisma.notification.update({ + where: { + id: notificationId, + }, + data: { + isRead: true, + }, + }); + + return res.status(200).json(notifications); + } catch (error) { + errorhandler(error, res); + } + } else { + res.setHeader("Allow", ["POST"]); + return res.status(405).end(`Method ${req.method} Not Allowed`); + } +} diff --git a/pages/api/notifications/index.ts b/pages/api/teams/[teamId]/notifications/index.ts similarity index 52% rename from pages/api/notifications/index.ts rename to pages/api/teams/[teamId]/notifications/index.ts index a207f0b4e..365b71ce5 100644 --- a/pages/api/notifications/index.ts +++ b/pages/api/teams/[teamId]/notifications/index.ts @@ -1,8 +1,10 @@ import { NextApiRequest, NextApiResponse } from "next"; import { getServerSession } from "next-auth"; -import { authOptions } from "../auth/[...nextauth]"; +import { authOptions } from "../../../auth/[...nextauth]"; import { CustomUser } from "@/lib/types"; import prisma from "@/lib/prisma"; +import { errorhandler } from "@/lib/errorHandler"; +import { getTeamWithUser } from "@/lib/team/helper"; export default async function handle( req: NextApiRequest, @@ -16,16 +18,24 @@ export default async function handle( const userId = (session.user as CustomUser).id; - const notifications = await prisma.notification.findMany({ - where: { - userId, - }, - orderBy: { - createdAt: "desc", - }, - }); + const { teamId } = req.query as { teamId: string }; - return res.status(200).json(notifications); + try { + await getTeamWithUser({ teamId, userId }); + + const notifications = await prisma.notification.findMany({ + where: { + teamId, + }, + orderBy: { + createdAt: "desc", + }, + }); + + return res.status(200).json(notifications); + } catch (error) { + errorhandler(error, res); + } } else { res.setHeader("Allow", ["GET"]); return res.status(405).end(`Method ${req.method} Not Allowed`); diff --git a/pages/api/notifications/webhooks.ts b/pages/api/teams/[teamId]/notifications/webhooks.ts similarity index 96% rename from pages/api/notifications/webhooks.ts rename to pages/api/teams/[teamId]/notifications/webhooks.ts index 1ce8b1e2d..29e5f7104 100644 --- a/pages/api/notifications/webhooks.ts +++ b/pages/api/teams/[teamId]/notifications/webhooks.ts @@ -2,7 +2,7 @@ import { NextApiRequest, NextApiResponse } from "next"; import prisma from "@/lib/prisma"; import { Event, Notification } from "@prisma/client"; import { getServerSession } from "next-auth"; -import { authOptions } from "../auth/[...nextauth]"; +import { authOptions } from "../../../auth/[...nextauth]"; import { CustomUser } from "@/lib/types"; import { handleLinkViewed } from "@/lib/notifications/notification-handlers"; import { errorhandler } from "@/lib/errorHandler"; diff --git a/pages/api/webhooks/[id]/index.ts b/pages/api/teams/[teamId]/webhooks/[id]/index.ts similarity index 75% rename from pages/api/webhooks/[id]/index.ts rename to pages/api/teams/[teamId]/webhooks/[id]/index.ts index 7db1c3b99..314ff2849 100644 --- a/pages/api/webhooks/[id]/index.ts +++ b/pages/api/teams/[teamId]/webhooks/[id]/index.ts @@ -1,9 +1,10 @@ import { NextApiRequest, NextApiResponse } from "next"; import { getServerSession } from "next-auth/next"; -import { authOptions } from "../../auth/[...nextauth]"; +import { authOptions } from "../../../../auth/[...nextauth]"; import { CustomUser } from "@/lib/types"; import prisma from "@/lib/prisma"; import { errorhandler } from "@/lib/errorHandler"; +import { getTeamWithUser } from "@/lib/team/helper"; export default async function handle( req: NextApiRequest, @@ -19,17 +20,19 @@ export default async function handle( const { id } = req.query as { id: string }; + const { teamId } = req.query as { teamId: string }; + try { + await getTeamWithUser({ teamId, userId }); + const webhook = await prisma.webhook.findUnique({ where: { id, }, }); - if (webhook?.userId !== userId) { - return res - .status(401) - .json("You are not permitted to delete this webhook"); + if (webhook?.teamId !== teamId) { + return res.status(401).json("Webhook doesn't belong to this team"); } await prisma.webhook.delete({ diff --git a/pages/api/webhooks/index.ts b/pages/api/teams/[teamId]/webhooks/index.ts similarity index 80% rename from pages/api/webhooks/index.ts rename to pages/api/teams/[teamId]/webhooks/index.ts index 32664d3f3..32bc63b15 100644 --- a/pages/api/webhooks/index.ts +++ b/pages/api/teams/[teamId]/webhooks/index.ts @@ -1,9 +1,10 @@ import { NextApiRequest, NextApiResponse } from "next"; import { getServerSession } from "next-auth"; -import { authOptions } from "../auth/[...nextauth]"; +import { authOptions } from "../../../auth/[...nextauth]"; import { CustomUser } from "@/lib/types"; import prisma from "@/lib/prisma"; import { errorhandler } from "@/lib/errorHandler"; +import { getTeamWithUser } from "@/lib/team/helper"; export default async function handle( req: NextApiRequest, @@ -17,11 +18,16 @@ export default async function handle( const userId = (session.user as CustomUser).id; + const { teamId } = req.query as { teamId: string }; + const { targetUrl, events } = req.body; try { + await getTeamWithUser({ teamId, userId }); + const webhook = await prisma.webhook.create({ data: { + teamId, userId, targetUrl, events, @@ -40,10 +46,14 @@ export default async function handle( const userId = (session.user as CustomUser).id; + const { teamId } = req.query as { teamId: string }; + try { + await getTeamWithUser({ teamId, userId }); + const webhooks = await prisma.webhook.findMany({ where: { - userId, + teamId, }, orderBy: { createdAt: "desc", diff --git a/pages/api/views.ts b/pages/api/views.ts index dd6aabf7c..b4666cb2d 100644 --- a/pages/api/views.ts +++ b/pages/api/views.ts @@ -26,6 +26,7 @@ export default async function handle( id: linkId, }, select: { + id: true, emailProtected: true, enableNotification: true, password: true, @@ -72,6 +73,7 @@ export default async function handle( document: { select: { name: true, + teamId: true, owner: { select: { email: true, @@ -105,6 +107,7 @@ export default async function handle( eventType: "LINK_VIEWED", eventData: { receiverId: newView.document.owner.id, + teamId: newView.document.teamId, event: "LINK_VIEWED", data: { documentId, diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2491cf3fc..9d06d76c3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -37,42 +37,42 @@ model Session { } model User { - id String @id @default(cuid()) + id String @id @default(cuid()) name String? - email String? @unique + email String? @unique emailVerified DateTime? image String? - isEmailNotificationEnabled Boolean @default(true) // by default the user get email for all the events - createdAt DateTime @default(now()) + isEmailNotificationEnabled Boolean @default(true) // by default the user get email for all the events + createdAt DateTime @default(now()) accounts Account[] sessions Session[] documents Document[] teams UserTeam[] domains Domain[] - webhooks Webhook[] - notifications Notification[] - plan String @default("trial") - stripeId String? @unique // Stripe subscription / customer ID - subscriptionId String? @unique // Stripe subscription ID + plan String @default("trial") + stripeId String? @unique // Stripe subscription / customer ID + subscriptionId String? @unique // Stripe subscription ID startsAt DateTime? // Stripe subscription start date endsAt DateTime? // Stripe subscription end date } model Team { - id String @id @default(cuid()) + id String @id @default(cuid()) name String users UserTeam[] documents Document[] domains Domain[] invitations Invitation[] sentEmails SentEmail[] - plan String @default("free") - stripeId String? @unique // Stripe subscription / customer ID - subscriptionId String? @unique // Stripe subscription ID + webhooks Webhook[] + notifications Notification[] + plan String @default("free") + stripeId String? @unique // Stripe subscription / customer ID + subscriptionId String? @unique // Stripe subscription ID startsAt DateTime? // Stripe subscription start date endsAt DateTime? // Stripe subscription end date - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } enum Role { @@ -209,7 +209,8 @@ model Invitation { model Webhook { id String @id @default(cuid()) userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + teamId String + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) targetUrl String events Event[] createdAt DateTime @default(now()) @@ -219,10 +220,12 @@ model Webhook { model Notification { id String @id @default(cuid()) userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + teamId String + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) event Event message String documentId String? + linkId String? isRead Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt From dea2d361350c65c5c41d8fe3a52aca72744c6925 Mon Sep 17 00:00:00 2001 From: Aashish-Upadhyay-101 Date: Thu, 23 Nov 2023 18:09:44 +0545 Subject: [PATCH 19/23] fix: webhook delete api errors --- components/webhooks/webhook-table.tsx | 14 ++++++++++---- lib/webhooks.ts | 2 +- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/components/webhooks/webhook-table.tsx b/components/webhooks/webhook-table.tsx index 3b0bad552..edc508687 100644 --- a/components/webhooks/webhook-table.tsx +++ b/components/webhooks/webhook-table.tsx @@ -20,12 +20,18 @@ import { Skeleton } from "@/components/ui/skeleton"; import { Button } from "@/components/ui/button"; import { toast } from "sonner"; import { mutate } from "swr"; +import { useTeam } from "@/context/team-context"; export function WebhookTable({ webhooks }: { webhooks: Webhook[] }) { + const teamInfo = useTeam(); + const removeWebhook = async (webhookId: string) => { - const response = await fetch(`/api/webhooks/${webhookId}`, { - method: "DELETE", - }); + const response = await fetch( + `/api/teams/${teamInfo?.currentTeam?.id}/webhooks/${webhookId}`, + { + method: "DELETE", + }, + ); if (response.status !== 204) { const error = await response.json(); @@ -33,7 +39,7 @@ export function WebhookTable({ webhooks }: { webhooks: Webhook[] }) { return; } toast.success("Webhook deleted successfully!"); - await mutate("/api/webhooks"); + await mutate(`/api/teams/${teamInfo?.currentTeam?.id}/webhooks`); }; return ( diff --git a/lib/webhooks.ts b/lib/webhooks.ts index ab22d333f..be826918c 100644 --- a/lib/webhooks.ts +++ b/lib/webhooks.ts @@ -16,7 +16,7 @@ export async function triggerWebhooks({ const webhooks = await prisma.webhook.findMany({ where: { - userId, + teamId: eventData.teamId, events: { has: eventType, }, From 8cbc7ff0e5ee165333833788ea5ad1110acc6776 Mon Sep 17 00:00:00 2001 From: Aashish-Upadhyay-101 Date: Wed, 6 Dec 2023 23:45:08 +0545 Subject: [PATCH 20/23] chore: notification cleanup --- lib/notifications/notification-handlers.ts | 53 +++++++---------- lib/{webhooks.ts => webhooks/index.ts} | 9 +-- lib/webhooks/types.ts | 27 +++++++++ .../teams/[teamId]/notifications/webhooks.ts | 12 +--- pages/api/views.ts | 57 +++++++------------ 5 files changed, 73 insertions(+), 85 deletions(-) rename lib/{webhooks.ts => webhooks/index.ts} (92%) create mode 100644 lib/webhooks/types.ts diff --git a/lib/notifications/notification-handlers.ts b/lib/notifications/notification-handlers.ts index 57c6deebd..6e2382eda 100644 --- a/lib/notifications/notification-handlers.ts +++ b/lib/notifications/notification-handlers.ts @@ -1,27 +1,15 @@ import prisma from "@/lib/prisma"; -import { Event } from "@prisma/client"; -import { sendViewedDocumentEmail } from "../emails/send-viewed-document"; +import { EventData } from "../webhooks/types"; +import { client } from "@/trigger"; -interface IHandleLinkViewed { - receiverId: string; - teamId: string; - event: Event; - data: { - documentName: string; - viewerEmail: string | null; - documentId: string; - documentOwner: string; - link: { - id: string; - enableNotification: boolean; - }; - }; -} - -export async function handleLinkViewed(eventData: IHandleLinkViewed) { +export async function handleLinkViewed(eventData: EventData) { try { - const viewerEmail = eventData.data.viewerEmail; - const documentName = eventData.data.documentName; + // const viewerEmail = eventData.data.viewerEmail; + const viewerEmail = eventData.viewerEmail; + + // const documentName = eventData.data.documentName; + const documentName = eventData.documentName; + const message = viewerEmail ? `${viewerEmail} viewed your ${documentName}` : `Someone viewed your ${documentName}`; @@ -29,10 +17,10 @@ export async function handleLinkViewed(eventData: IHandleLinkViewed) { data: { teamId: eventData.teamId, userId: eventData.receiverId, - event: eventData.event, + event: "LINK_VIEWED", message, - linkId: eventData.data.link.id, - documentId: eventData.data.documentId, + linkId: eventData.link.id, + documentId: eventData.documentId, }, }); @@ -47,15 +35,14 @@ export async function handleLinkViewed(eventData: IHandleLinkViewed) { // check if user has enabled email notification, if yes then check if the link has enable email notification if (user?.isEmailNotificationEnabled) { // notify user via email - // TODO: this can be offloaded to a background job in the future to save some time - // send email to document owner that document has been viewed - if (eventData.data.link.enableNotification) { - await sendViewedDocumentEmail( - eventData.data.documentOwner, - eventData.data.documentId, - eventData.data.documentName, - eventData.data.viewerEmail, - ); + if (eventData.link.enableNotification) { + // trigger link viewed event to trigger send-notification job + console.time("sendemail"); + await client.sendEvent({ + name: "link.viewed", + payload: { viewId: eventData.viewId }, + }); + console.timeEnd("sendemail"); } } diff --git a/lib/webhooks.ts b/lib/webhooks/index.ts similarity index 92% rename from lib/webhooks.ts rename to lib/webhooks/index.ts index be826918c..60cc64a4c 100644 --- a/lib/webhooks.ts +++ b/lib/webhooks/index.ts @@ -1,19 +1,12 @@ import crypto from "crypto"; import prisma from "@/lib/prisma"; -import { Event } from "@prisma/client"; - -interface IWebhookTrigger { - eventType: Event; - eventData: any; -} +import { IWebhookTrigger } from "./types"; export async function triggerWebhooks({ eventType, eventData, }: IWebhookTrigger) { try { - const userId = eventData.userId; - const webhooks = await prisma.webhook.findMany({ where: { teamId: eventData.teamId, diff --git a/lib/webhooks/types.ts b/lib/webhooks/types.ts new file mode 100644 index 000000000..b77d8d2f9 --- /dev/null +++ b/lib/webhooks/types.ts @@ -0,0 +1,27 @@ +import { Event, Link } from "@prisma/client"; + +export interface LinkViewedData { + receiverId: string; + receiverEmail: string; + receiverName: string; + teamId: string; + teamName: string; + documentId: string; + documentName: string; + documentOwner: string; + viewerEmail: string; + link: { + id: string; + enableNotification: boolean; + }; + viewId: string; +} + +type EventType = Event; + +export type EventData = LinkViewedData; + +export interface IWebhookTrigger { + eventType: EventType; + eventData: EventData; +} diff --git a/pages/api/teams/[teamId]/notifications/webhooks.ts b/pages/api/teams/[teamId]/notifications/webhooks.ts index 29e5f7104..be8cd4da2 100644 --- a/pages/api/teams/[teamId]/notifications/webhooks.ts +++ b/pages/api/teams/[teamId]/notifications/webhooks.ts @@ -1,12 +1,9 @@ import { NextApiRequest, NextApiResponse } from "next"; -import prisma from "@/lib/prisma"; -import { Event, Notification } from "@prisma/client"; -import { getServerSession } from "next-auth"; -import { authOptions } from "../../../auth/[...nextauth]"; -import { CustomUser } from "@/lib/types"; +import { Notification } from "@prisma/client"; import { handleLinkViewed } from "@/lib/notifications/notification-handlers"; import { errorhandler } from "@/lib/errorHandler"; import { verifySignature } from "@/lib/webhooks"; +import { IWebhookTrigger } from "@/lib/webhooks/types"; export default async function handle( req: NextApiRequest, @@ -25,10 +22,7 @@ export default async function handle( } try { - const { eventType, eventData } = req.body as { - eventType: Event; - eventData: any; - }; + const { eventType, eventData } = req.body as IWebhookTrigger; // const userId = (session.user as CustomUser).id; let notification: Notification | null = null; diff --git a/pages/api/views.ts b/pages/api/views.ts index 15b6f363e..15ab9043b 100644 --- a/pages/api/views.ts +++ b/pages/api/views.ts @@ -8,7 +8,6 @@ import { triggerWebhooks } from "@/lib/webhooks"; export default async function handle( req: NextApiRequest, res: NextApiResponse, - res: NextApiResponse, ) { // We only allow POST requests if (req.method !== "POST") { @@ -71,7 +70,15 @@ export default async function handle( viewerEmail: email, documentId: documentId, }, - select: { id: true }, + select: { + id: true, + document: { + include: { + owner: true, + team: true, + }, + }, + }, }); console.timeEnd("create-view"); @@ -122,47 +129,27 @@ export default async function handle( }); console.timeEnd("track-analytics"); - // this will trigger the webhook and also notification(both in-app and email) + // // this will trigger the webhook and also notification(both in-app and email) await triggerWebhooks({ eventType: "LINK_VIEWED", eventData: { receiverId: newView.document.owner.id, - teamId: newView.document.teamId, - event: "LINK_VIEWED", - data: { - documentId, - documentName: newView.document.name, - documentOwner: newView.document.owner.email as string, - viewerEmail: email, - link, - documentVersions: newView.document.versions, + receiverEmail: newView.document.owner.email as string, + receiverName: newView.document.owner.name as string, + teamId: newView.document.teamId as string, + teamName: newView.document.team?.name as string, + documentId: documentId, + documentName: newView.document.name, + documentOwner: newView.document.owner.name as string, + viewerEmail: email, + link: { + id: link.id, + enableNotification: link.enableNotification!, }, + viewId: newView.id, }, }); - // check if document version has multiple pages, if so, return the pages - if (newView.document.versions[0].hasPages) { - const pages = await prisma.documentPage.findMany({ - where: { - versionId: newView.document.versions[0].id, - }, - orderBy: { - pageNumber: "asc", - }, - select: { - file: true, - pageNumber: true, - }, - }); - - return res.status(200).json({ - message: "View recorded", - viewId: newView.id, - file: null, - pages: pages, - }); - } - const returnObject = { message: "View recorded", viewId: newView.id, From 4158e7e31797b01fab914094ffabf2a1c9bf3e4a Mon Sep 17 00:00:00 2001 From: Aashish-Upadhyay-101 Date: Thu, 7 Dec 2023 00:52:42 +0545 Subject: [PATCH 21/23] feat: document uploaded event --- .../notifications/notification-dropdown.tsx | 6 ++- components/webhooks/add-webhook-modal.tsx | 14 ++++++- lib/notifications/notification-handlers.ts | 41 +++++++++++++++---- lib/webhooks/types.ts | 26 ++++++++---- pages/api/teams/[teamId]/documents/index.ts | 20 ++++++++- .../teams/[teamId]/notifications/webhooks.ts | 23 ++++++++--- pages/api/views.ts | 10 ++--- prisma/schema.prisma | 2 +- 8 files changed, 110 insertions(+), 32 deletions(-) diff --git a/components/notifications/notification-dropdown.tsx b/components/notifications/notification-dropdown.tsx index e2c22ff16..f290f2a44 100644 --- a/components/notifications/notification-dropdown.tsx +++ b/components/notifications/notification-dropdown.tsx @@ -38,7 +38,9 @@ export default function NotificationDropdown({ throw new Error(`HTTP error! status: ${response.status}`); } - router.push(`/documents/${documentId}`); + if (documentId) { + router.push(`/documents/${documentId}`); + } mutate(`/api/teams/${teamInfo?.currentTeam?.id}/notifications`); }; @@ -60,7 +62,7 @@ export default function NotificationDropdown({ - markNotificationRead(notification.id, notification.documentId) + markNotificationRead(notification.id, notification?.documentId) } > {notification.message} diff --git a/components/webhooks/add-webhook-modal.tsx b/components/webhooks/add-webhook-modal.tsx index 8d59f156e..466812db2 100644 --- a/components/webhooks/add-webhook-modal.tsx +++ b/components/webhooks/add-webhook-modal.tsx @@ -112,11 +112,23 @@ export function AddWebhookModal({ children }: { children: React.ReactNode }) {
    handleEventSelect("LINK_VIEWED")} + onCheckedChange={() => + handleEventSelect("DOCUMENT_VIEWED") + } />
    +
    + + handleEventSelect("DOCUMENT_ADDED") + } + /> + +
    + {checkboxError && (

    {checkboxError}

    )} diff --git a/lib/notifications/notification-handlers.ts b/lib/notifications/notification-handlers.ts index 6e2382eda..89b8e8a97 100644 --- a/lib/notifications/notification-handlers.ts +++ b/lib/notifications/notification-handlers.ts @@ -1,23 +1,24 @@ import prisma from "@/lib/prisma"; -import { EventData } from "../webhooks/types"; +import { + DocumentUploadedData, + DocumentViewdData, + EventData, +} from "../webhooks/types"; import { client } from "@/trigger"; -export async function handleLinkViewed(eventData: EventData) { +export async function handleDocumentViewed(eventData: DocumentViewdData) { try { - // const viewerEmail = eventData.data.viewerEmail; const viewerEmail = eventData.viewerEmail; - - // const documentName = eventData.data.documentName; const documentName = eventData.documentName; - const message = viewerEmail ? `${viewerEmail} viewed your ${documentName}` : `Someone viewed your ${documentName}`; + const notification = await prisma.notification.create({ data: { teamId: eventData.teamId, - userId: eventData.receiverId, - event: "LINK_VIEWED", + userId: eventData.ownerId, + event: "DOCUMENT_VIEWED", message, linkId: eventData.link.id, documentId: eventData.documentId, @@ -28,7 +29,7 @@ export async function handleLinkViewed(eventData: EventData) { // TODO: check if user has allow email notification if yes then send the email notification const user = await prisma.user.findUnique({ where: { - id: eventData.receiverId, + id: eventData.ownerId, }, }); @@ -51,3 +52,25 @@ export async function handleLinkViewed(eventData: EventData) { throw error; } } + +export async function handleDocumentUploaded(eventData: DocumentUploadedData) { + try { + const documentName = eventData.documentName; + const documentOwnerName = eventData.ownerName; + const message = `A new document ${documentName} uploaded by ${documentOwnerName}`; + + const notification = await prisma.notification.create({ + data: { + teamId: eventData.teamId, + userId: eventData.ownerId, + event: "DOCUMENT_ADDED", + message, + documentId: eventData.documentId, + }, + }); + + return notification; + } catch (error) { + throw error; + } +} diff --git a/lib/webhooks/types.ts b/lib/webhooks/types.ts index b77d8d2f9..36b2e86f4 100644 --- a/lib/webhooks/types.ts +++ b/lib/webhooks/types.ts @@ -1,14 +1,13 @@ -import { Event, Link } from "@prisma/client"; +import { Event } from "@prisma/client"; -export interface LinkViewedData { - receiverId: string; - receiverEmail: string; - receiverName: string; +export interface DocumentViewdData { + ownerId: string; + ownerEmail: string; + ownerName: string; teamId: string; teamName: string; documentId: string; documentName: string; - documentOwner: string; viewerEmail: string; link: { id: string; @@ -17,9 +16,22 @@ export interface LinkViewedData { viewId: string; } +export interface DocumentUploadedData { + ownerId: string; + ownerEmail: string; + ownerName: string; + teamId: string; + teamName: string; + documentId: string; + documentName: string; + numPages: number; + type: string; + fileUrl: string; +} + type EventType = Event; -export type EventData = LinkViewedData; +export type EventData = DocumentViewdData | DocumentUploadedData; export interface IWebhookTrigger { eventType: EventType; diff --git a/pages/api/teams/[teamId]/documents/index.ts b/pages/api/teams/[teamId]/documents/index.ts index 5cc4b146a..e027ce8cd 100644 --- a/pages/api/teams/[teamId]/documents/index.ts +++ b/pages/api/teams/[teamId]/documents/index.ts @@ -8,6 +8,7 @@ import { identifyUser, trackAnalytics } from "@/lib/analytics"; import { getTeamWithUsersAndDocument } from "@/lib/team/helper"; import { errorhandler } from "@/lib/errorHandler"; import { client } from "@/trigger"; +import { triggerWebhooks } from "@/lib/webhooks"; export default async function handle( req: NextApiRequest, @@ -76,7 +77,7 @@ export default async function handle( }; try { - await getTeamWithUsersAndDocument({ + const { team } = await getTeamWithUsersAndDocument({ teamId, userId, }); @@ -111,6 +112,7 @@ export default async function handle( include: { links: true, versions: true, + owner: true, }, }); @@ -146,6 +148,22 @@ export default async function handle( }); } + // trigger webhook and notification + await triggerWebhooks({ + eventType: "DOCUMENT_ADDED", + eventData: { + ownerId: document.ownerId, + ownerEmail: document.owner.email as string, + ownerName: document.owner.name as string, + teamId: document.teamId as string, + teamName: team.name, + documentId: document.id, + documentName: document.name, + numPages: document.numPages as number, + type: document.type as string, + fileUrl: document.file, + }, + }); return res.status(201).json(document); } catch (error) { log( diff --git a/pages/api/teams/[teamId]/notifications/webhooks.ts b/pages/api/teams/[teamId]/notifications/webhooks.ts index be8cd4da2..e5258345e 100644 --- a/pages/api/teams/[teamId]/notifications/webhooks.ts +++ b/pages/api/teams/[teamId]/notifications/webhooks.ts @@ -1,9 +1,16 @@ import { NextApiRequest, NextApiResponse } from "next"; import { Notification } from "@prisma/client"; -import { handleLinkViewed } from "@/lib/notifications/notification-handlers"; +import { + handleDocumentViewed, + handleDocumentUploaded, +} from "@/lib/notifications/notification-handlers"; import { errorhandler } from "@/lib/errorHandler"; import { verifySignature } from "@/lib/webhooks"; -import { IWebhookTrigger } from "@/lib/webhooks/types"; +import { + DocumentUploadedData, + DocumentViewdData, + IWebhookTrigger, +} from "@/lib/webhooks/types"; export default async function handle( req: NextApiRequest, @@ -27,11 +34,17 @@ export default async function handle( // const userId = (session.user as CustomUser).id; let notification: Notification | null = null; switch (eventType) { - case "LINK_VIEWED": - notification = await handleLinkViewed(eventData); + case "DOCUMENT_VIEWED": + notification = await handleDocumentViewed( + eventData as DocumentViewdData, + ); break; - // TODO: other events like Team created, Team member added, etc. + case "DOCUMENT_ADDED": + notification = await handleDocumentUploaded( + eventData as DocumentUploadedData, + ); + break; } // since the internal webhook is for notification purpose we are returning the notification that is being created diff --git a/pages/api/views.ts b/pages/api/views.ts index 15ab9043b..911532d87 100644 --- a/pages/api/views.ts +++ b/pages/api/views.ts @@ -2,7 +2,6 @@ import { NextApiRequest, NextApiResponse } from "next"; import prisma from "@/lib/prisma"; import { checkPassword, log } from "@/lib/utils"; import { trackAnalytics } from "@/lib/analytics"; -import { client } from "@/trigger"; import { triggerWebhooks } from "@/lib/webhooks"; export default async function handle( @@ -131,16 +130,15 @@ export default async function handle( // // this will trigger the webhook and also notification(both in-app and email) await triggerWebhooks({ - eventType: "LINK_VIEWED", + eventType: "DOCUMENT_VIEWED", eventData: { - receiverId: newView.document.owner.id, - receiverEmail: newView.document.owner.email as string, - receiverName: newView.document.owner.name as string, + ownerId: newView.document.owner.id, + ownerEmail: newView.document.owner.email as string, + ownerName: newView.document.owner.name as string, teamId: newView.document.teamId as string, teamName: newView.document.team?.name as string, documentId: documentId, documentName: newView.document.name, - documentOwner: newView.document.owner.name as string, viewerEmail: email, link: { id: link.id, diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3648fbd43..7e22d5abc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -248,9 +248,9 @@ model Notification { } enum Event { - LINK_VIEWED // When a shared document link is viewed by someone. LINK_SHARED // When a user shares a document link with others. SUBSCRIPTION_RENEWAL // notifications about upcoming subscription renewals or changes. + DOCUMENT_VIEWED // When a shared document link is viewed by someone. DOCUMENT_ADDED // when a new document is added DOCUMENT_UPDATED // when a document's detail changed such as Document Name, etc DOCUMENT_DELETED // when a document is deleted From 2415beb7005c1eb795a354d575153194ad0ec7f3 Mon Sep 17 00:00:00 2001 From: Aashish-Upadhyay-101 Date: Fri, 8 Dec 2023 01:22:28 +0545 Subject: [PATCH 22/23] feat: document deleted webhook --- .../notifications/notification-dropdown.tsx | 5 ++ components/webhooks/add-webhook-modal.tsx | 51 ++++++++++++------- lib/notifications/notification-handlers.ts | 25 +++++++++ lib/webhooks/types.ts | 2 + .../teams/[teamId]/documents/[id]/index.ts | 27 ++++++++-- .../teams/[teamId]/notifications/webhooks.ts | 7 +++ 6 files changed, 95 insertions(+), 22 deletions(-) diff --git a/components/notifications/notification-dropdown.tsx b/components/notifications/notification-dropdown.tsx index f290f2a44..0270335ac 100644 --- a/components/notifications/notification-dropdown.tsx +++ b/components/notifications/notification-dropdown.tsx @@ -12,6 +12,7 @@ import { mutate } from "swr"; import { useRouter } from "next/router"; import { timeAgo } from "@/lib/utils"; import { useTeam } from "@/context/team-context"; +import { Notification } from "@prisma/client"; export default function NotificationDropdown({ children, @@ -38,6 +39,10 @@ export default function NotificationDropdown({ throw new Error(`HTTP error! status: ${response.status}`); } + const notification = (await response.json()) as Notification; + console.log(notification); + if (notification.event === "DOCUMENT_DELETED") return; + if (documentId) { router.push(`/documents/${documentId}`); } diff --git a/components/webhooks/add-webhook-modal.tsx b/components/webhooks/add-webhook-modal.tsx index 466812db2..a87ee9764 100644 --- a/components/webhooks/add-webhook-modal.tsx +++ b/components/webhooks/add-webhook-modal.tsx @@ -16,6 +16,26 @@ import { Event } from "@prisma/client"; import { mutate } from "swr"; import { useTeam } from "@/context/team-context"; +interface webhookProps { + eventType: Event; + displayText: string; +} + +const webhooks: webhookProps[] = [ + { + eventType: "DOCUMENT_ADDED", + displayText: "document.uploaded", + }, + { + eventType: "DOCUMENT_VIEWED", + displayText: "document.viewed", + }, + { + eventType: "DOCUMENT_DELETED", + displayText: "document.deleted", + }, +]; + export function AddWebhookModal({ children }: { children: React.ReactNode }) { const [targetUrl, setTargetUrl] = useState(""); const [creating, setCreating] = useState(false); @@ -109,25 +129,17 @@ export function AddWebhookModal({ children }: { children: React.ReactNode }) {
    -
    - - handleEventSelect("DOCUMENT_VIEWED") - } - /> - -
    - -
    - - handleEventSelect("DOCUMENT_ADDED") - } - /> - -
    + {webhooks.map((webhook, index) => ( +
    + + handleEventSelect(webhook.eventType) + } + /> + +
    + ))} {checkboxError && (

    {checkboxError}

    @@ -143,6 +155,7 @@ export function AddWebhookModal({ children }: { children: React.ReactNode }) { + ∆
); diff --git a/lib/notifications/notification-handlers.ts b/lib/notifications/notification-handlers.ts index 89b8e8a97..72d6b5809 100644 --- a/lib/notifications/notification-handlers.ts +++ b/lib/notifications/notification-handlers.ts @@ -1,5 +1,6 @@ import prisma from "@/lib/prisma"; import { + DocumentDeletedData, DocumentUploadedData, DocumentViewdData, EventData, @@ -69,6 +70,30 @@ export async function handleDocumentUploaded(eventData: DocumentUploadedData) { }, }); + // TODO: send email notification on document upload + + return notification; + } catch (error) { + throw error; + } +} + +export async function handleDocumentDeleted(eventData: DocumentDeletedData) { + try { + const documentName = eventData.documentName; + const documentOwnerName = eventData.ownerName; + const message = `Document ${documentName} is deleted by ${documentOwnerName}`; + + const notification = await prisma.notification.create({ + data: { + teamId: eventData.teamId, + userId: eventData.ownerId, + event: "DOCUMENT_DELETED", + message, + documentId: eventData.documentId, + }, + }); + return notification; } catch (error) { throw error; diff --git a/lib/webhooks/types.ts b/lib/webhooks/types.ts index 36b2e86f4..e1910dcda 100644 --- a/lib/webhooks/types.ts +++ b/lib/webhooks/types.ts @@ -29,6 +29,8 @@ export interface DocumentUploadedData { fileUrl: string; } +export interface DocumentDeletedData extends DocumentUploadedData {} + type EventType = Event; export type EventData = DocumentViewdData | DocumentUploadedData; diff --git a/pages/api/teams/[teamId]/documents/[id]/index.ts b/pages/api/teams/[teamId]/documents/[id]/index.ts index f07ae1bdc..bdd42e451 100644 --- a/pages/api/teams/[teamId]/documents/[id]/index.ts +++ b/pages/api/teams/[teamId]/documents/[id]/index.ts @@ -6,10 +6,11 @@ import { CustomUser } from "@/lib/types"; import { del } from "@vercel/blob"; import { getTeamWithUsersAndDocument } from "@/lib/team/helper"; import { errorhandler } from "@/lib/errorHandler"; +import { triggerWebhooks } from "@/lib/webhooks"; export default async function handle( req: NextApiRequest, - res: NextApiResponse + res: NextApiResponse, ) { if (req.method === "GET") { // GET /api/teams/:teamId/documents/:id @@ -61,7 +62,7 @@ export default async function handle( const userId = (session.user as CustomUser).id; try { - const { document } = await getTeamWithUsersAndDocument({ + const { document, team } = await getTeamWithUsersAndDocument({ teamId, userId, docId, @@ -78,10 +79,30 @@ export default async function handle( // delete the document from vercel blob await del(document!.file); // delete the document from database - await prisma.document.delete({ + const deletedDocument = await prisma.document.delete({ where: { id: docId, }, + include: { + owner: true, + }, + }); + + // trigger webhook and notification + await triggerWebhooks({ + eventType: "DOCUMENT_DELETED", + eventData: { + ownerId: deletedDocument.ownerId, + ownerEmail: deletedDocument.owner.email as string, + ownerName: deletedDocument.owner.name as string, + teamId: deletedDocument.teamId as string, + teamName: team.name, + documentId: deletedDocument.id, + documentName: deletedDocument.name, + numPages: deletedDocument.numPages as number, + type: deletedDocument.type as string, + fileUrl: deletedDocument.file, + }, }); return res.status(204).end(); // 204 No Content response for successful deletes diff --git a/pages/api/teams/[teamId]/notifications/webhooks.ts b/pages/api/teams/[teamId]/notifications/webhooks.ts index e5258345e..5cba8aed1 100644 --- a/pages/api/teams/[teamId]/notifications/webhooks.ts +++ b/pages/api/teams/[teamId]/notifications/webhooks.ts @@ -3,10 +3,12 @@ import { Notification } from "@prisma/client"; import { handleDocumentViewed, handleDocumentUploaded, + handleDocumentDeleted, } from "@/lib/notifications/notification-handlers"; import { errorhandler } from "@/lib/errorHandler"; import { verifySignature } from "@/lib/webhooks"; import { + DocumentDeletedData, DocumentUploadedData, DocumentViewdData, IWebhookTrigger, @@ -45,6 +47,11 @@ export default async function handle( eventData as DocumentUploadedData, ); break; + + case "DOCUMENT_DELETED": + notification = await handleDocumentDeleted( + eventData as DocumentDeletedData, + ); } // since the internal webhook is for notification purpose we are returning the notification that is being created From 7c3dbcc1a4ae24e9237f5c19c87a3ba974cf3b9e Mon Sep 17 00:00:00 2001 From: Aashish-Upadhyay-101 Date: Fri, 8 Dec 2023 01:29:47 +0545 Subject: [PATCH 23/23] db migration --- components/webhooks/add-webhook-modal.tsx | 2 +- .../migration.sql | 40 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/20231207194348_webhook_and_notification/migration.sql diff --git a/components/webhooks/add-webhook-modal.tsx b/components/webhooks/add-webhook-modal.tsx index a87ee9764..afe4ef003 100644 --- a/components/webhooks/add-webhook-modal.tsx +++ b/components/webhooks/add-webhook-modal.tsx @@ -130,7 +130,7 @@ export function AddWebhookModal({ children }: { children: React.ReactNode }) {
{webhooks.map((webhook, index) => ( -
+
diff --git a/prisma/migrations/20231207194348_webhook_and_notification/migration.sql b/prisma/migrations/20231207194348_webhook_and_notification/migration.sql new file mode 100644 index 000000000..c3bc9fea2 --- /dev/null +++ b/prisma/migrations/20231207194348_webhook_and_notification/migration.sql @@ -0,0 +1,40 @@ +-- CreateEnum +CREATE TYPE "Event" AS ENUM ('LINK_SHARED', 'SUBSCRIPTION_RENEWAL', 'DOCUMENT_VIEWED', 'DOCUMENT_ADDED', 'DOCUMENT_UPDATED', 'DOCUMENT_DELETED', 'DOCUMENT_FEEDBACK', 'TEAM_CREATED', 'TEAM_UPDATED', 'TEAM_DELETED', 'TEAM_MEMBER_INVITED', 'TEAM_MEMBER_REMOVED', 'TEAM_MEMBER_PROMOTED', 'TEAM_MEMBER_DEMOTED', 'DOMAIN_ADDED', 'DOMAIN_REMOVED', 'ACCOUNT_ACTIVITY', 'SYSTEM_UPDATE', 'PROMOTION'); + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "isEmailNotificationEnabled" BOOLEAN NOT NULL DEFAULT true; + +-- CreateTable +CREATE TABLE "Webhook" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "teamId" TEXT NOT NULL, + "targetUrl" TEXT NOT NULL, + "events" "Event"[], + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Webhook_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Notification" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "teamId" TEXT NOT NULL, + "event" "Event" NOT NULL, + "message" TEXT NOT NULL, + "documentId" TEXT, + "linkId" TEXT, + "isRead" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Notification_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Notification" ADD CONSTRAINT "Notification_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;