141 lines
5.4 KiB
TypeScript
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,
|
|
});
|
|
}
|