Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 59 additions & 3 deletions apps/page/pages/api/roadmap/submit-triage.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,29 @@
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 = 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,
Expand Down Expand Up @@ -54,15 +76,35 @@ export default async function submitTriageItem(
.json({ success: false, error: "Authentication required" });
}

if (aj) {
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")
.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" });
Expand All @@ -87,6 +129,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: escape(trimmedTitle),
},
});
} catch (emailError) {
console.error("submitTriageItem [Email Error]", emailError);
}

res.status(200).json({
success: true,
item: triageItem,
Expand Down
182 changes: 182 additions & 0 deletions apps/web/inngest/email/send-roadmap-triage-notification.ts
Original file line number Diff line number Diff line change
@@ -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: `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>New Roadmap Idea Submitted</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #f8f9fa;
}
.container {
background-color: white;
padding: 40px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.logo {
text-align: center;
margin-bottom: 30px;
}
.button {
display: inline-block;
background-color: #4f46e5;
color: white;
padding: 12px 24px;
text-decoration: none;
border-radius: 6px;
font-weight: 500;
margin: 20px 0;
}
.button:hover {
background-color: #4338ca;
}
.idea-box {
background-color: #f9fafb;
border-left: 4px solid #4f46e5;
padding: 16px;
margin: 20px 0;
border-radius: 4px;
}
.idea-title {
font-weight: 600;
font-size: 16px;
margin-bottom: 8px;
color: #111827;
}
.idea-description {
color: #4b5563;
margin: 0;
}
.footer {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e5e7eb;
font-size: 14px;
color: #6b7280;
}
.meta-info {
font-size: 14px;
color: #6b7280;
margin-top: 12px;
}
</style>
</head>
<body>
<div class="container">
<div class="logo">
${
pageLogoUrl
? `<img src="${pageLogoUrl}" alt="${pageName}" style="max-width: 150px; height: auto; margin: 0 auto;"/>`
: `<h1 style="color: #4f46e5; margin: 0;">${pageName}</h1>`
}
</div>

<h2>New roadmap idea submitted</h2>

<p>A user has submitted a new idea to your roadmap: <strong>${board_title}</strong></p>

<div class="idea-box">
<div class="idea-title">${item_title}</div>
</div>

<div style="text-align: center;">
<a href="${triageUrl}" class="button">View All Submissions</a>
</div>

<p style="color: #6b7280; font-size: 14px;">You can review and approve this idea from your roadmap triage section.</p>

<div class="footer">
<p>This email was sent from ${pageName} via changes.page.</p>
</div>
</div>
</body>
</html>
`,
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 };
}
);
2 changes: 2 additions & 0 deletions apps/web/pages/api/inngest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -16,6 +17,7 @@ export default serve("changes-page", [
// Emails
sendConfirmEmailNotification,
sendPostNotification,
sendRoadmapTriageNotification,
sendWelcomeEmail,
sendTeamInviteEmail,
sendVisitorMagicLink,
Expand Down
30 changes: 7 additions & 23 deletions apps/web/pages/pages/[page_id]/roadmap/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -86,29 +87,12 @@ export default function RoadmapPage({
>
<div className="space-y-6">
{boards.length === 0 ? (
<div className="text-center py-12">
<svg
className="mx-auto h-12 w-12 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
vectorEffect="non-scaling-stroke"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2"
/>
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">
No roadmap boards
</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Get started by creating your first roadmap board.
</p>
</div>
<EntityEmptyState
title="No roadmap boards"
message="Roadmaps help you share what you're working on and what's coming next. Create a board to organize features, track progress, and gather feedback from your users."
buttonLink={`/pages/${page_id}/roadmap/new`}
buttonLabel="Create Your First Board"
/>
) : (
<div className="bg-white dark:bg-gray-800 shadow rounded-lg divide-y divide-gray-200 dark:divide-gray-700">
{boards.map((board) => (
Expand Down