From 2dc69bd4594fd52ffb8817a1fd8b09461c9d4d5e Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Sun, 2 Nov 2025 17:13:33 +1100 Subject: [PATCH 1/4] feat: Notify user roadmap submissions --- apps/page/pages/api/roadmap/submit-triage.ts | 61 +++++- .../email/send-roadmap-triage-notification.ts | 182 ++++++++++++++++++ apps/web/pages/api/inngest.ts | 2 + 3 files changed, 242 insertions(+), 3 deletions(-) create mode 100644 apps/web/inngest/email/send-roadmap-triage-notification.ts diff --git a/apps/page/pages/api/roadmap/submit-triage.ts b/apps/page/pages/api/roadmap/submit-triage.ts index 5065652..cd16c31 100644 --- a/apps/page/pages/api/roadmap/submit-triage.ts +++ b/apps/page/pages/api/roadmap/submit-triage.ts @@ -1,7 +1,26 @@ +import arcjet, { detectBot, tokenBucket } from "@arcjet/next"; import { supabaseAdmin } from "@changes-page/supabase/admin"; import type { NextApiRequest, NextApiResponse } from "next"; import { v4 } from "uuid"; import { getAuthenticatedVisitor } from "../../../lib/visitor-auth"; +import inngestClient from "../../../utils/inngest"; + +const aj = arcjet({ + key: process.env.ARCJET_KEY!, + rules: [ + tokenBucket({ + mode: "LIVE", + characteristics: ["userId"], + refillRate: 5, + interval: "1h", + capacity: 10, + }), + detectBot({ + mode: "LIVE", + block: ["AUTOMATED"], + }), + ], +}); export default async function submitTriageItem( req: NextApiRequest, @@ -54,15 +73,37 @@ export default async function submitTriageItem( .json({ success: false, error: "Authentication required" }); } + if (process.env.ARCJET_KEY) { + const decision = await aj.protect(req, { + userId: visitor.id, + requested: 1, + }); + + if (decision.isDenied()) { + console.log( + "roadmap/submit-triage: [Arcjet Block]", + visitor.id, + decision.reason + ); + + return res.status(403).json({ + success: false, + error: "Request blocked.", + }); + } + } + try { - const { data: boardCheck, error: boardCheckError } = await supabaseAdmin + const { data: board, error: boardCheckError } = await supabaseAdmin .from("roadmap_boards") - .select("id, is_public") + .select( + "id, is_public, title, page_id, pages(url_slug, page_settings(custom_domain))" + ) .eq("id", board_id) .eq("is_public", true) .maybeSingle(); - if (boardCheckError || !boardCheck) { + if (boardCheckError || !board) { return res .status(404) .json({ success: false, error: "Board not found or not public" }); @@ -87,6 +128,20 @@ export default async function submitTriageItem( .json({ success: false, error: "Failed to submit item" }); } + try { + await inngestClient.send({ + name: "email/roadmap.triage-submitted", + data: { + page_id: board.page_id, + board_id: board.id, + board_title: board.title, + item_title: trimmedTitle, + }, + }); + } catch (emailError) { + console.error("submitTriageItem [Email Error]", emailError); + } + res.status(200).json({ success: true, item: triageItem, diff --git a/apps/web/inngest/email/send-roadmap-triage-notification.ts b/apps/web/inngest/email/send-roadmap-triage-notification.ts new file mode 100644 index 0000000..31f7a78 --- /dev/null +++ b/apps/web/inngest/email/send-roadmap-triage-notification.ts @@ -0,0 +1,182 @@ +import { supabaseAdmin } from "@changes-page/supabase/admin"; +import { getAppBaseURL } from "../../utils/helpers"; +import inngestClient from "../../utils/inngest"; +import postmarkClient from "../../utils/postmark"; + +interface EventData { + page_id: string; + board_id: string; + board_title: string; + item_title: string; +} + +export const sendRoadmapTriageNotification = inngestClient.createFunction( + { name: "Email: Roadmap triage submission" }, + { event: "email/roadmap.triage-submitted" }, + async ({ event }) => { + const { page_id, board_id, board_title, item_title }: EventData = + event.data; + + console.log("Sending roadmap triage notification", { + page_id, + board_id, + item_title, + }); + + const { data: page, error: pageError } = await supabaseAdmin + .from("pages") + .select( + ` + title, + user_id, + page_settings(page_logo) + ` + ) + .eq("id", page_id) + .single(); + + if (!page || pageError) { + console.error("Error fetching page:", pageError); + throw new Error("Page not found"); + } + + const { data: authUser, error: authError } = + await supabaseAdmin.auth.admin.getUserById(page.user_id); + + if (!authUser || authError || !authUser.user?.email) { + console.error("Error fetching user:", authError); + throw new Error("User not found or email missing"); + } + + const pageName = page.title; + const pageLogoUrl = page.page_settings?.page_logo || null; + const adminEmail = authUser.user.email; + + const triageUrl = `${getAppBaseURL()}/pages/${page_id}/roadmap/${board_id}`; + + const result = await postmarkClient.sendEmail({ + MessageStream: "outbound", + From: "notification@mail.changes.page", + To: adminEmail, + Subject: `New roadmap idea submitted: ${item_title}`, + HtmlBody: ` + + + + + + New Roadmap Idea Submitted + + + +
+ + +

New roadmap idea submitted

+ +

A user has submitted a new idea to your roadmap: ${board_title}

+ +
+
${item_title}
+
+ +
+ View All Submissions +
+ +

You can review and approve this idea from your roadmap triage section.

+ + +
+ + + `, + TextBody: ` + New roadmap idea submitted + + A user has submitted a new idea to your roadmap: ${board_title} + + Title: ${item_title} + + View all submissions: ${triageUrl} + + You can review and approve this idea from your roadmap triage section. + + --- + This email was sent from ${pageName} via changes.page. + `, + }); + + return { body: "Roadmap triage notification sent", result }; + } +); diff --git a/apps/web/pages/api/inngest.ts b/apps/web/pages/api/inngest.ts index 5023641..dc8c30c 100644 --- a/apps/web/pages/api/inngest.ts +++ b/apps/web/pages/api/inngest.ts @@ -2,6 +2,7 @@ import { serve } from "inngest/next"; import { handleSubscriptionChange } from "../../inngest/billing/handle-subscription"; import { reportUsageForStripeInvoice } from "../../inngest/billing/report-pages-usage-invoice"; import { sendConfirmEmailNotification } from "../../inngest/email/send-confirm-email-notification"; +import { sendRoadmapTriageNotification } from "../../inngest/email/send-roadmap-triage-notification"; import { sendTeamInviteEmail } from "../../inngest/email/send-team-invite"; import { sendWelcomeEmail } from "../../inngest/email/send-welcome-email"; import { sendVisitorMagicLink } from "../../inngest/email/send-visitor-magic-link"; @@ -16,6 +17,7 @@ export default serve("changes-page", [ // Emails sendConfirmEmailNotification, sendPostNotification, + sendRoadmapTriageNotification, sendWelcomeEmail, sendTeamInviteEmail, sendVisitorMagicLink, From 1e0eeb8e31d5aaac8ce37f688493698305b643c6 Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Sun, 2 Nov 2025 17:14:32 +1100 Subject: [PATCH 2/4] Fix board query --- apps/page/pages/api/roadmap/submit-triage.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/page/pages/api/roadmap/submit-triage.ts b/apps/page/pages/api/roadmap/submit-triage.ts index cd16c31..a6892aa 100644 --- a/apps/page/pages/api/roadmap/submit-triage.ts +++ b/apps/page/pages/api/roadmap/submit-triage.ts @@ -96,9 +96,7 @@ export default async function submitTriageItem( try { const { data: board, error: boardCheckError } = await supabaseAdmin .from("roadmap_boards") - .select( - "id, is_public, title, page_id, pages(url_slug, page_settings(custom_domain))" - ) + .select("id, is_public, title, page_id") .eq("id", board_id) .eq("is_public", true) .maybeSingle(); From 3e1d37e2aa4f2493aef071af43e8bee897e5bdc0 Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Sun, 2 Nov 2025 17:25:56 +1100 Subject: [PATCH 3/4] Address CR --- apps/page/pages/api/roadmap/submit-triage.ts | 39 +++++++++++--------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/apps/page/pages/api/roadmap/submit-triage.ts b/apps/page/pages/api/roadmap/submit-triage.ts index a6892aa..d1e6939 100644 --- a/apps/page/pages/api/roadmap/submit-triage.ts +++ b/apps/page/pages/api/roadmap/submit-triage.ts @@ -2,25 +2,28 @@ import arcjet, { detectBot, tokenBucket } from "@arcjet/next"; import { supabaseAdmin } from "@changes-page/supabase/admin"; import type { NextApiRequest, NextApiResponse } from "next"; import { v4 } from "uuid"; +import { escape } from "validator"; import { getAuthenticatedVisitor } from "../../../lib/visitor-auth"; import inngestClient from "../../../utils/inngest"; -const aj = arcjet({ - key: process.env.ARCJET_KEY!, - rules: [ - tokenBucket({ - mode: "LIVE", - characteristics: ["userId"], - refillRate: 5, - interval: "1h", - capacity: 10, - }), - detectBot({ - mode: "LIVE", - block: ["AUTOMATED"], - }), - ], -}); +const aj = process.env.ARCJET_KEY + ? arcjet({ + key: process.env.ARCJET_KEY, + rules: [ + tokenBucket({ + mode: "LIVE", + characteristics: ["userId"], + refillRate: 5, + interval: "1h", + capacity: 10, + }), + detectBot({ + mode: "LIVE", + block: ["AUTOMATED"], + }), + ], + }) + : undefined; export default async function submitTriageItem( req: NextApiRequest, @@ -73,7 +76,7 @@ export default async function submitTriageItem( .json({ success: false, error: "Authentication required" }); } - if (process.env.ARCJET_KEY) { + if (aj) { const decision = await aj.protect(req, { userId: visitor.id, requested: 1, @@ -133,7 +136,7 @@ export default async function submitTriageItem( page_id: board.page_id, board_id: board.id, board_title: board.title, - item_title: trimmedTitle, + item_title: escape(trimmedTitle), }, }); } catch (emailError) { From 9e905395a331bce90c9dc1e11d6cbc70ec173efe Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Sun, 2 Nov 2025 17:37:01 +1100 Subject: [PATCH 4/4] Update empty state --- .../pages/pages/[page_id]/roadmap/index.tsx | 30 +++++-------------- 1 file changed, 7 insertions(+), 23 deletions(-) diff --git a/apps/web/pages/pages/[page_id]/roadmap/index.tsx b/apps/web/pages/pages/[page_id]/roadmap/index.tsx index cb45b77..2e1daa1 100644 --- a/apps/web/pages/pages/[page_id]/roadmap/index.tsx +++ b/apps/web/pages/pages/[page_id]/roadmap/index.tsx @@ -3,6 +3,7 @@ import { InferGetServerSidePropsType } from "next"; import Link from "next/link"; import { useMemo, type JSX } from "react"; import { PrimaryRouterButton } from "../../../../components/core/buttons.component"; +import { EntityEmptyState } from "../../../../components/entity/empty-state"; import AuthLayout from "../../../../components/layout/auth-layout.component"; import Page from "../../../../components/layout/page.component"; import { ROUTES } from "../../../../data/routes.data"; @@ -86,29 +87,12 @@ export default function RoadmapPage({ >
{boards.length === 0 ? ( -
- -

- No roadmap boards -

-

- Get started by creating your first roadmap board. -

-
+ ) : (
{boards.map((board) => (