Add feedback dialog for cloud dashboard (#293)

This commit is contained in:
KM Koushik
2025-11-29 10:22:12 +11:00
committed by GitHub
parent 357d561a8e
commit e1b64d0d7b
9 changed files with 247 additions and 25 deletions
+2
View File
@@ -13,6 +13,7 @@ import { dashboardRouter } from "./routers/dashboard";
import { suppressionRouter } from "./routers/suppression";
import { limitsRouter } from "./routers/limits";
import { waitlistRouter } from "./routers/waitlist";
import { feedbackRouter } from "./routers/feedback";
/**
* This is the primary router for your server.
@@ -34,6 +35,7 @@ export const appRouter = createTRPCRouter({
suppression: suppressionRouter,
limits: limitsRouter,
waitlist: waitlistRouter,
feedback: feedbackRouter,
});
// export type definition of API
+1 -11
View File
@@ -10,6 +10,7 @@ import { sendMail } from "~/server/mailer";
import { logger } from "~/server/logger/log";
import { UseSend } from "usesend-js";
import { isCloud } from "~/utils/common";
import { toPlainHtml } from "~/server/utils/email-content";
const waitlistUserSelection = {
id: true,
@@ -19,17 +20,6 @@ const waitlistUserSelection = {
createdAt: true,
} as const;
function toPlainHtml(text: string) {
const escaped = text
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
return `<pre style="font-family: inherit; white-space: pre-wrap; margin: 0;">${escaped}</pre>`;
}
function formatDisplayNameFromEmail(email: string) {
const localPart = email.split("@")[0] ?? email;
const pieces = localPart.split(/[._-]+/).filter(Boolean);
@@ -0,0 +1,49 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { createTRPCRouter, teamProcedure } from "~/server/api/trpc";
import { env } from "~/env";
import { isCloud } from "~/utils/common";
import { sendMail } from "~/server/mailer";
import { toPlainHtml } from "~/server/utils/email-content";
export const feedbackRouter = createTRPCRouter({
send: teamProcedure
.input(
z.object({
message: z.string().trim().min(1, "Feedback cannot be empty").max(2000),
}),
)
.mutation(async ({ ctx, input }) => {
if (!isCloud()) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Feedback is only available on the cloud version.",
});
}
if (!env.FOUNDER_EMAIL) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Feedback email is not configured.",
});
}
const senderEmail = ctx.session.user.email ?? "Unknown";
const senderName = ctx.session.user.name ?? "Unknown";
const text = `New feedback received\n\nFrom: ${senderName} (${senderEmail})\nUser ID: ${ctx.session.user.id}\nTeam: ${ctx.team.name} (ID: ${ctx.team.id})\n\nMessage:\n${
input.message
}`;
await sendMail(
env.FOUNDER_EMAIL,
`Product feedback from ${ctx.team.name}`,
text,
toPlainHtml(text),
ctx.session.user.email ?? undefined,
);
return { success: true };
}),
});
+1 -9
View File
@@ -9,6 +9,7 @@ import {
WAITLIST_EMAIL_TYPES,
waitlistSubmissionSchema,
} from "~/app/wait-list/schema";
import { escapeHtml } from "~/server/utils/email-content";
const RATE_LIMIT_WINDOW_SECONDS = 60 * 60 * 6; // 6 hours
const RATE_LIMIT_MAX_ATTEMPTS = 3;
@@ -18,15 +19,6 @@ const EMAIL_TYPE_LABEL: Record<(typeof WAITLIST_EMAIL_TYPES)[number], string> =
marketing: "Marketing",
};
function escapeHtml(input: string) {
return input
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
export const waitlistRouter = createTRPCRouter({
submitRequest: authedProcedure
.input(waitlistSubmissionSchema)
@@ -0,0 +1,12 @@
export function escapeHtml(input: string) {
return input
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
export function toPlainHtml(text: string) {
return `<pre style="font-family: inherit; white-space: pre-wrap; margin: 0;">${escapeHtml(text)}</pre>`;
}