Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/modules/helpDesk/services/Feedback.server.ts
T

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,
};