Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/services/admin/support-inbox.server.ts
T
2026-07-08 16:15:37 +02:00

141 lines
5.4 KiB
TypeScript

/**
* AK-P19 — Admin Support-Inbox service (DoD #6).
*
* Operator actions on a support ticket: reply, internal note, status change
* and assignment. Replies + notes are rows in the new SupportTicketMessage
* conversation thread (admin.prisma) attached to the existing helpDesk
* SupportTicket (which already carries source/status/priority/tenant and is
* fed by the P21 feedback widget + inbound email via
* `@hivegrid/db/support-tickets.server`). Every write is audited.
*
* DB access is injected so the orchestration is unit-testable without a
* database. Sending the reply back to the customer (email/notify) is left
* to the notification Querschnitt wiring and tracked separately.
*/
import { db } from "~/utils/db.server";
import { recordAdminAction, type AdminActionAuditContext } from "./admin-action-audit.server";
/** Statuses an operator may set on a support ticket. */
export const SUPPORT_TICKET_STATUSES = ["open", "pending", "answered", "closed"] as const;
export type SupportTicketStatus = (typeof SUPPORT_TICKET_STATUSES)[number];
export type SupportInboxErrorCode = "EMPTY_BODY" | "INVALID_STATUS";
export class SupportInboxError extends Error {
override readonly name = "SupportInboxError";
readonly code: SupportInboxErrorCode;
readonly i18nKey: string;
constructor(code: SupportInboxErrorCode) {
super(code);
this.code = code;
this.i18nKey = code === "EMPTY_BODY" ? "admin.support.errors.emptyBody" : "admin.support.errors.invalidStatus";
}
}
export function assertSupportBody(body: string | null | undefined): string {
const trimmed = (body ?? "").trim();
if (trimmed.length === 0) throw new SupportInboxError("EMPTY_BODY");
return trimmed;
}
export function isSupportTicketStatus(value: string): value is SupportTicketStatus {
return (SUPPORT_TICKET_STATUSES as readonly string[]).includes(value);
}
export interface SupportInboxDeps {
readonly db: Pick<typeof db, "supportTicket" | "supportTicketMessage">;
readonly recordAction: (context: AdminActionAuditContext) => Promise<void>;
}
function defaultDeps(): SupportInboxDeps {
return { db, recordAction: recordAdminAction };
}
export interface PostSupportMessageInput {
readonly ticketId: string;
readonly body: string;
readonly authorUserId: string | null;
readonly actorEmail?: string | null;
readonly request?: Request;
}
/** Posts an operator reply (customer-visible) and audits it. */
export async function postSupportReply(input: PostSupportMessageInput, deps: SupportInboxDeps = defaultDeps()): Promise<void> {
const body = assertSupportBody(input.body);
await deps.db.supportTicketMessage.create({
data: { supportTicketId: input.ticketId, authorUserId: input.authorUserId, body, internalNote: false },
});
await deps.db.supportTicket.update({ where: { id: input.ticketId }, data: { status: "answered" } });
await deps.recordAction({
action: "support.replied",
actorUserId: input.authorUserId,
actorEmail: input.actorEmail ?? null,
targetKind: "supportTicket",
targetId: input.ticketId,
reason: "Operator-Antwort an den Tenant",
request: input.request,
});
}
/** Posts an operator-only internal note and audits it. */
export async function postSupportInternalNote(input: PostSupportMessageInput, deps: SupportInboxDeps = defaultDeps()): Promise<void> {
const body = assertSupportBody(input.body);
await deps.db.supportTicketMessage.create({
data: { supportTicketId: input.ticketId, authorUserId: input.authorUserId, body, internalNote: true },
});
await deps.recordAction({
action: "support.noteAdded",
actorUserId: input.authorUserId,
actorEmail: input.actorEmail ?? null,
targetKind: "supportTicket",
targetId: input.ticketId,
reason: "Interne Notiz hinzugefuegt",
request: input.request,
});
}
export interface SetSupportStatusInput {
readonly ticketId: string;
readonly status: string;
readonly actorUserId: string | null;
readonly actorEmail?: string | null;
readonly request?: Request;
}
export async function setSupportTicketStatus(input: SetSupportStatusInput, deps: SupportInboxDeps = defaultDeps()): Promise<void> {
if (!isSupportTicketStatus(input.status)) throw new SupportInboxError("INVALID_STATUS");
await deps.db.supportTicket.update({ where: { id: input.ticketId }, data: { status: input.status } });
await deps.recordAction({
action: "support.statusChanged",
actorUserId: input.actorUserId,
actorEmail: input.actorEmail ?? null,
targetKind: "supportTicket",
targetId: input.ticketId,
reason: "Status geaendert",
after: { status: input.status },
request: input.request,
});
}
export interface AssignSupportTicketInput {
readonly ticketId: string;
readonly assigneeUserId: string | null;
readonly actorUserId: string | null;
readonly actorEmail?: string | null;
readonly request?: Request;
}
export async function assignSupportTicket(input: AssignSupportTicketInput, deps: SupportInboxDeps = defaultDeps()): Promise<void> {
await deps.db.supportTicket.update({ where: { id: input.ticketId }, data: { assignedToUserId: input.assigneeUserId } });
await deps.recordAction({
action: "support.assigned",
actorUserId: input.actorUserId,
actorEmail: input.actorEmail ?? null,
targetKind: "supportTicket",
targetId: input.ticketId,
reason: "Ticket zugewiesen",
after: { assigneeUserId: input.assigneeUserId },
request: input.request,
});
}