155 lines
4.9 KiB
TypeScript
155 lines
4.9 KiB
TypeScript
import { TFunction } from "i18next";
|
|
import { db } from "~/utils/db.server";
|
|
import NotificationService from "~/modules/notifications/services/.server/NotificationService";
|
|
import { redactFeedbackText } from "./FeedbackRedaction.server";
|
|
import { createWidgetSupportTicket, WIDGET_SUPPORT_TICKET_LEGACY_SOURCES } from "@hivegrid/db/support-tickets.server";
|
|
import { createFileFromBytes } from "~/utils/integrations/fileService.server";
|
|
import { withTenantRLS } from "~/utils/db/.server/tenantRls.server";
|
|
|
|
const MAX_FEEDBACK_PER_DAY = 3;
|
|
const FEEDBACK_RATE_LIMIT_WINDOW_HOURS = 24;
|
|
|
|
/**
|
|
* AK-P21 — the feedback widget lets the reporter attach ONE image instead of
|
|
* capturing a screenshot itself (decision 2026-07-21, see the ticket doc).
|
|
* The cap is deliberately small: the whole multipart body is buffered in
|
|
* memory by the route action.
|
|
*/
|
|
export const MAX_FEEDBACK_ATTACHMENT_BYTES = 5 * 1024 * 1024;
|
|
export const ALLOWED_FEEDBACK_ATTACHMENT_MIME_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"] as const;
|
|
|
|
export type FeedbackAttachmentInput = {
|
|
filename: string;
|
|
mimeType: string;
|
|
bytes: Uint8Array;
|
|
};
|
|
|
|
export class FeedbackUserError extends Error {
|
|
override readonly name = "FeedbackUserError";
|
|
|
|
constructor(
|
|
readonly messageKey:
|
|
| "feedback.messageRequired"
|
|
| "feedback.limitReached"
|
|
| "feedback.attachment.empty"
|
|
| "feedback.attachment.tooLarge"
|
|
| "feedback.attachment.unsupportedType",
|
|
readonly status: 400 | 429
|
|
) {
|
|
super(messageKey);
|
|
}
|
|
}
|
|
|
|
export function isAllowedFeedbackAttachmentMimeType(mimeType: string): boolean {
|
|
return (ALLOWED_FEEDBACK_ATTACHMENT_MIME_TYPES as readonly string[]).includes(mimeType.toLowerCase());
|
|
}
|
|
|
|
async function send({
|
|
t,
|
|
tenantId,
|
|
userId,
|
|
message,
|
|
fromUrl,
|
|
attachment,
|
|
}: {
|
|
t: TFunction;
|
|
tenantId: string | null;
|
|
userId: string | null;
|
|
message: string;
|
|
fromUrl: string;
|
|
attachment?: FeedbackAttachmentInput | null;
|
|
}) {
|
|
void t;
|
|
const redactedMessage = redactFeedbackText(message);
|
|
const redactedFromUrl = redactFeedbackText(fromUrl);
|
|
if (!redactedMessage) {
|
|
throw new FeedbackUserError("feedback.messageRequired", 400);
|
|
}
|
|
|
|
const createdSince = new Date(Date.now() - FEEDBACK_RATE_LIMIT_WINDOW_HOURS * 60 * 60 * 1_000);
|
|
if (tenantId) {
|
|
const feedbackToday = await db.supportTicket.count({
|
|
where: { tenantId, source: { in: [...WIDGET_SUPPORT_TICKET_LEGACY_SOURCES] }, createdAt: { gte: createdSince } },
|
|
});
|
|
if (feedbackToday >= MAX_FEEDBACK_PER_DAY) {
|
|
throw new FeedbackUserError("feedback.limitReached", 429);
|
|
}
|
|
} else if (userId) {
|
|
const feedbackToday = await db.supportTicket.count({
|
|
where: { userId, source: { in: [...WIDGET_SUPPORT_TICKET_LEGACY_SOURCES] }, createdAt: { gte: createdSince } },
|
|
});
|
|
if (feedbackToday >= MAX_FEEDBACK_PER_DAY) {
|
|
throw new FeedbackUserError("feedback.limitReached", 429);
|
|
}
|
|
}
|
|
const ticket = await createWidgetSupportTicket({
|
|
tenantId,
|
|
userId,
|
|
subject: undefined,
|
|
message: redactedMessage,
|
|
fromUrl: redactedFromUrl,
|
|
});
|
|
// The attachment is stored AFTER the ticket so a rejected upload never costs
|
|
// the reporter their written feedback. createFileFromBytes is fail-closed:
|
|
// it runs the magic-bytes allow-list before the storage PUT and enqueues the
|
|
// ClamAV scan, so the file stays scanStatus=pending until the worker clears
|
|
// it. A File is always tenant-scoped, so tenant-less feedback cannot carry
|
|
// one.
|
|
if (attachment && tenantId && userId) {
|
|
assertFeedbackAttachment(attachment);
|
|
const file = await createFileFromBytes({
|
|
tenantId,
|
|
userId,
|
|
filename: attachment.filename,
|
|
mimeType: attachment.mimeType,
|
|
bytes: attachment.bytes,
|
|
scanContext: "mediaLibrary",
|
|
});
|
|
await withTenantRLS(tenantId, (tx) =>
|
|
tx.fileEntityLink.create({
|
|
data: {
|
|
tenantId,
|
|
createdByUserId: userId,
|
|
fileId: file.id,
|
|
entityType: "support_ticket",
|
|
entityId: ticket.ticketId,
|
|
linkRole: "feedback_attachment",
|
|
},
|
|
})
|
|
);
|
|
}
|
|
await NotificationService.sendToRoles({
|
|
channel: "admin-users",
|
|
tenantId: null,
|
|
notification: {
|
|
message: "[New Support Ticket] " + ticket.subject,
|
|
},
|
|
});
|
|
return ticket;
|
|
}
|
|
|
|
function assertFeedbackAttachment(attachment: FeedbackAttachmentInput): void {
|
|
if (attachment.bytes.byteLength === 0) {
|
|
throw new FeedbackUserError("feedback.attachment.empty", 400);
|
|
}
|
|
if (attachment.bytes.byteLength > MAX_FEEDBACK_ATTACHMENT_BYTES) {
|
|
throw new FeedbackUserError("feedback.attachment.tooLarge", 400);
|
|
}
|
|
if (!isAllowedFeedbackAttachmentMimeType(attachment.mimeType)) {
|
|
throw new FeedbackUserError("feedback.attachment.unsupportedType", 400);
|
|
}
|
|
}
|
|
|
|
async function deleteMany({ ids }: { ids: string[] }) {
|
|
return await db.feedback.deleteMany({
|
|
where: {
|
|
id: { in: ids },
|
|
},
|
|
});
|
|
}
|
|
|
|
export default {
|
|
send,
|
|
deleteMany,
|
|
};
|