diff --git a/apps/app/src/actions/organization/create-organization-action.ts b/apps/app/src/actions/organization/create-organization-action.ts index 78bee2c03b..8bf579a056 100644 --- a/apps/app/src/actions/organization/create-organization-action.ts +++ b/apps/app/src/actions/organization/create-organization-action.ts @@ -32,7 +32,7 @@ export const createOrganizationAction = authActionClient const hasVercelConfig = Boolean( process.env.NEXT_PUBLIC_VERCEL_URL && - process.env.VERCEL_AUTH_TOKEN && + process.env.VERCEL_ACCESS_TOKEN && process.env.VERCEL_TEAM_ID && process.env.VERCEL_PROJECT_ID ); diff --git a/apps/app/src/components/header.tsx b/apps/app/src/components/header.tsx index 66d085c55a..8de627a235 100644 --- a/apps/app/src/components/header.tsx +++ b/apps/app/src/components/header.tsx @@ -3,8 +3,6 @@ import { getI18n } from "@/locales/server"; import { Button } from "@bubba/ui/button"; import { Icons } from "@bubba/ui/icons"; import { Skeleton } from "@bubba/ui/skeleton"; -import { Inbox } from "@novu/react"; -import { useSession } from "next-auth/react"; import Link from "next/link"; import { Suspense } from "react"; import { AssistantButton } from "./assistant/button"; diff --git a/apps/app/src/components/notification-center.tsx b/apps/app/src/components/notification-center.tsx index 006553e766..075cad31db 100644 --- a/apps/app/src/components/notification-center.tsx +++ b/apps/app/src/components/notification-center.tsx @@ -28,70 +28,73 @@ function NotificationItem({ description, createdAt, recordId, + from, + to, markMessageAsRead, + type, }: { id: string; setOpen: (open: boolean) => void; - description: string; + description: string | undefined; createdAt: string; - recordId?: string; - from?: string; - to?: string; + recordId: string | undefined; + from: string | undefined; + to: string | undefined; markMessageAsRead: (id: string) => void; - type?: string; + type: string | undefined; }) { - return ( -
- setOpen(false)} - href={recordId ? `/tasks/${recordId}` : "#"} - > -
-
- -
-
-
-

{description}

- - {formatDistanceToNow(new Date(createdAt))} ago - -
- - {markMessageAsRead && ( -
- +
+
+ +
+
+
+

{description}

+ + {formatDistanceToNow(new Date(createdAt))} ago + +
+ + {markMessageAsRead && ( +
+ +
+ )}
- )} -
- ); + ); + default: + return null; + } } export function NotificationCenter() { const t = useI18n(); const [isOpen, setOpen] = useState(false); - const { hasUnseenNotifications, notifications, markMessageAsRead, markAllMessagesAsSeen, markAllMessagesAsRead, - subscriberId, } = useNotifications(); - console.log(subscriberId); - console.log(notifications); - const unreadNotifications = notifications.filter( (notification) => !notification.read, ); @@ -164,9 +167,12 @@ export function NotificationCenter() { id={notification.id} markMessageAsRead={markMessageAsRead} setOpen={setOpen} - description={notification.payload.description || ""} + description={notification.payload.description} createdAt={notification.createdAt} recordId={notification.payload.recordId} + type={notification.payload.type} + from={notification.payload?.from} + to={notification.payload?.to} /> ); })} @@ -203,9 +209,12 @@ export function NotificationCenter() { key={notification.id} id={notification.id} setOpen={setOpen} - description={notification.payload.description || ""} + description={notification.payload.description} createdAt={notification.createdAt} recordId={notification.payload.recordId} + type={notification.payload.type} + from={notification.payload?.from} + to={notification.payload?.to} markMessageAsRead={markMessageAsRead} /> ); diff --git a/apps/app/src/env.mjs b/apps/app/src/env.mjs index 7273fdb6fb..f433ea6be6 100644 --- a/apps/app/src/env.mjs +++ b/apps/app/src/env.mjs @@ -17,7 +17,7 @@ export const env = createEnv({ UPLOADTHING_SECRET: z.string(), DISCORD_WEBHOOK_URL: z.string(), TRIGGER_SECRET_KEY: z.string(), - VERCEL_AUTH_TOKEN: z.string().optional(), + VERCEL_ACCESS_TOKEN: z.string().optional(), VERCEL_TEAM_ID: z.string().optional(), VERCEL_PROJECT_ID: z.string().optional(), }, @@ -46,7 +46,7 @@ export const env = createEnv({ TRIGGER_SECRET_KEY: process.env.TRIGGER_SECRET_KEY, NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY, NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST, - VERCEL_AUTH_TOKEN: process.env.VERCEL_AUTH_TOKEN, + VERCEL_ACCESS_TOKEN: process.env.VERCEL_ACCESS_TOKEN, VERCEL_TEAM_ID: process.env.VERCEL_TEAM_ID, VERCEL_PROJECT_ID: process.env.VERCEL_PROJECT_ID, NEXT_PUBLIC_VERCEL_URL: process.env.NEXT_PUBLIC_VERCEL_URL, diff --git a/apps/app/src/jobs/tasks/notifications/risk-task-notifications.ts b/apps/app/src/jobs/tasks/notifications/risk-task-notifications.ts new file mode 100644 index 0000000000..47b3dd4d26 --- /dev/null +++ b/apps/app/src/jobs/tasks/notifications/risk-task-notifications.ts @@ -0,0 +1,95 @@ +import { db } from "@bubba/db"; +import { + NotificationTypes, + TriggerEvents, + trigger, +} from "@bubba/notifications"; +import { logger, schedules } from "@trigger.dev/sdk/v3"; +import { formatDistance } from "date-fns"; + +export const sendRiskTaskNotifications = schedules.task({ + id: "send-risk-task-notifications", + run: async () => { + const now = new Date(); + const upcomingThreshold = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); + + logger.info( + `Sending risk task notifications from now: ${now} to ${upcomingThreshold}`, + ); + + const tasks = await db.riskMitigationTask.findMany({ + where: { + dueDate: { gte: now, lte: upcomingThreshold }, + status: { in: ["open", "pending"] }, + notifiedAt: null, + }, + select: { + id: true, + dueDate: true, + notifiedAt: true, + riskId: true, + title: true, + owner: { + select: { + id: true, + email: true, + name: true, + image: true, + organizationId: true, + }, + }, + }, + }); + + const notifiedTasks = []; + + for (const task of tasks) { + const owner = task.owner; + + const timeUntilDue = task.dueDate + ? formatDistance(task.dueDate, new Date(), { + addSuffix: true, + }) + : "soon"; + + try { + if (!owner || !owner.email || !owner.organizationId) { + logger.warn(`Skipping task ${task.id} - owner ${owner?.id} missing email or organizationId`); + continue; + } + + await db.riskMitigationTask.update({ + where: { id: task.id }, + data: { notifiedAt: new Date() }, + }); + + await trigger({ + name: TriggerEvents.TaskReminderInApp, + user: { + subscriberId: `${owner.organizationId}_${owner.id}`, + email: owner.email, + fullName: owner.name, + image: owner.image, + organizationId: owner.organizationId, + }, + payload: { + description: `${task.title} is due ${timeUntilDue}`, + recordId: `/risk/${task.riskId}/tasks/${task.id}`, + type: NotificationTypes.Task, + }, + }); + + + notifiedTasks.push(task.id); + } catch (error) { + logger.error( + `Error processing task ${task.id} for ${owner?.email}: ${error}`, + ); + } + } + + if (notifiedTasks.length) { + logger.info(`Sent notifications for tasks: ${notifiedTasks.join(", ")}`); + } + }, +}); \ No newline at end of file diff --git a/apps/app/src/jobs/tasks/notifications/utils/task-email-notification.tsx b/apps/app/src/jobs/tasks/notifications/utils/task-email-notification.tsx new file mode 100644 index 0000000000..767bf8912d --- /dev/null +++ b/apps/app/src/jobs/tasks/notifications/utils/task-email-notification.tsx @@ -0,0 +1,49 @@ +import TaskReminderEmail from "@bubba/email/emails/reminders/task-reminder"; +import { TriggerEvents, trigger } from "@bubba/notifications"; +import { render } from "@react-email/render"; + +interface Props { + owner: { + id: string; + fullName?: string; + email: string; + organizationId: string; + }; + task: { + recordId: string; + dueDate: string; + }; +} + +export async function sendTaskEmailNotification({ owner, task }: Props) { + try { + const html = await render( + , + ); + + const triggerData = { + name: TriggerEvents.TaskReminderEmail, + payload: { + subject: "Task Reminder", + html, + }, + replyTo: owner.email, + user: { + subscriberId: `${owner.organizationId}_${owner.id}`, + organizationId: owner.organizationId, + email: owner.email, + fullName: owner.fullName, + }, + }; + + await trigger(triggerData); + } catch (error) { + console.error("Failed to send task email notification: ", error); + throw error; + } +} diff --git a/apps/app/src/lib/domains.ts b/apps/app/src/lib/domains.ts index db62848dae..9a25b8f37f 100644 --- a/apps/app/src/lib/domains.ts +++ b/apps/app/src/lib/domains.ts @@ -12,7 +12,7 @@ export const addDomainToVercel = async (domain: string) => { { method: "POST", headers: { - Authorization: `Bearer ${process.env.VERCEL_AUTH_TOKEN}`, + Authorization: `Bearer ${process.env.VERCEL_ACCESS_TOKEN}`, "Content-Type": "application/json", }, body: JSON.stringify({ @@ -29,7 +29,7 @@ export const removeDomainFromVercelProject = async (domain: string) => { }`, { headers: { - Authorization: `Bearer ${process.env.VERCEL_AUTH_TOKEN}`, + Authorization: `Bearer ${process.env.VERCEL_ACCESS_TOKEN}`, }, method: "DELETE", }, @@ -42,7 +42,7 @@ export const removeDomainFromVercelTeam = async (domain: string) => { }`, { headers: { - Authorization: `Bearer ${process.env.VERCEL_AUTH_TOKEN}`, + Authorization: `Bearer ${process.env.VERCEL_ACCESS_TOKEN}`, }, method: "DELETE", }, @@ -59,7 +59,7 @@ export const getDomainResponse = async ( { method: "GET", headers: { - Authorization: `Bearer ${process.env.VERCEL_AUTH_TOKEN}`, + Authorization: `Bearer ${process.env.VERCEL_ACCESS_TOKEN}`, "Content-Type": "application/json", }, }, @@ -77,7 +77,7 @@ export const getConfigResponse = async ( { method: "GET", headers: { - Authorization: `Bearer ${process.env.VERCEL_AUTH_TOKEN}`, + Authorization: `Bearer ${process.env.VERCEL_ACCESS_TOKEN}`, "Content-Type": "application/json", }, }, @@ -94,7 +94,7 @@ export const verifyDomain = async ( { method: "POST", headers: { - Authorization: `Bearer ${process.env.VERCEL_AUTH_TOKEN}`, + Authorization: `Bearer ${process.env.VERCEL_ACCESS_TOKEN}`, "Content-Type": "application/json", }, }, diff --git a/apps/app/trigger.config.ts b/apps/app/trigger.config.ts index f633ce6dd6..dc475f0515 100644 --- a/apps/app/trigger.config.ts +++ b/apps/app/trigger.config.ts @@ -1,4 +1,5 @@ import { PrismaInstrumentation } from "@prisma/instrumentation"; +import { syncVercelEnvVars } from "@trigger.dev/build/extensions/core"; import { prismaExtension } from "@trigger.dev/build/extensions/prisma"; import { puppeteer } from "@trigger.dev/build/extensions/puppeteer"; import { defineConfig } from "@trigger.dev/sdk/v3"; @@ -15,6 +16,7 @@ export default defineConfig({ schema: "../../packages/db/prisma/schema.prisma", }), puppeteer(), + syncVercelEnvVars(), ], }, retries: { diff --git a/bun.lockb b/bun.lockb index 338ee6ab69..abdc0556d9 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index f00f715880..763d29863c 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ }, "devDependencies": { "@biomejs/biome": "1.9.4", - "turbo": "^2.3.4", + "turbo": "^2.4.1", "typescript": "5.7.2" }, "engines": { diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index fb88ae0eae..8c57bdf6a2 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -418,6 +418,7 @@ model RiskMitigationTask { description String status RiskTaskStatus @default(open) dueDate DateTime? + notifiedAt DateTime? completedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/packages/email/emails/reminders/task-reminder.tsx b/packages/email/emails/reminders/task-reminder.tsx new file mode 100644 index 0000000000..ad86abd63c --- /dev/null +++ b/packages/email/emails/reminders/task-reminder.tsx @@ -0,0 +1,109 @@ +import { + Body, + Button, + Container, + Font, + Heading, + Html, + Link, + Preview, + Section, + Tailwind, + Text, +} from "@react-email/components"; +import { Footer } from "../../components/footer"; +import { Logo } from "../../components/logo"; + +interface Props { + email: string; + name: string; + dueDate: string; + recordId: string; +} + +export const TaskReminderEmail = ({ + email, + name, + dueDate, + recordId, +}: Props) => { + const link = `${process.env.NEXT_PUBLIC_APP_URL ?? "https://app.trycomp.ai"}${recordId}`; + + return ( + + + + + + + + + Comp AI - Task Reminder + + + + + + Task Reminder + + + + Hey {name}, you're assigned to a task that is due soon ({dueDate} + ). + +
+ +
+ + + or copy and paste this URL into your browser{" "} + + {link} + + + +
+
+ + this notification was intended for{" "} + {email}.{" "} + +
+ +
+ +