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

106 lines
3.9 KiB
TypeScript

/**
* AK-P19 — Admin tenant-notes service (DoD #3).
*
* CRUD for the operator notes shown on the tenant-detail screen
* (AdminTenantNote, admin.prisma). Create/delete are audited; the list
* feeds the `buildAdminTenantNoteListVM` builder. DB access is injected so
* the orchestration unit-tests without a database.
*/
import { ADMIN_TENANT_NOTE_CATEGORIES, type AdminTenantNoteInputRow } from "@hivegrid/view-models";
import { db } from "~/utils/db.server";
import { recordAdminAction, type AdminActionAuditContext } from "./admin-action-audit.server";
export class TenantNoteError extends Error {
override readonly name = "TenantNoteError";
readonly code = "EMPTY_BODY" as const;
readonly i18nKey = "admin.tenantNotes.errors.emptyBody";
}
export function assertNoteBody(noteText: string | null | undefined): string {
const trimmed = (noteText ?? "").trim();
if (trimmed.length === 0) throw new TenantNoteError();
return trimmed;
}
/** Normalizes a raw category onto a known one, defaulting to general. */
export function normalizeNoteCategory(category: string | null | undefined): string {
const candidate = (category ?? "").trim().toLowerCase();
return (ADMIN_TENANT_NOTE_CATEGORIES as readonly string[]).includes(candidate) ? candidate : "general";
}
export interface TenantNotesDeps {
readonly db: Pick<typeof db, "adminTenantNote">;
readonly recordAction: (context: AdminActionAuditContext) => Promise<void>;
}
function defaultDeps(): TenantNotesDeps {
return { db, recordAction: recordAdminAction };
}
/** Reads the notes for a tenant in the VM input shape (pinned/sort done in VM). */
export async function getTenantNoteRows(tenantId: string, deps: TenantNotesDeps = defaultDeps()): Promise<AdminTenantNoteInputRow[]> {
const notes = await deps.db.adminTenantNote.findMany({
where: { tenantId },
select: { id: true, createdAt: true, category: true, pinned: true, noteText: true, author: { select: { id: true, email: true } } },
});
return notes.map((note) => ({
id: note.id,
createdAtIso: note.createdAt.toISOString(),
category: note.category,
pinned: note.pinned,
noteText: note.noteText,
authorEmail: note.author?.email ?? null,
authorId: note.author?.id ?? null,
}));
}
export interface CreateTenantNoteInput {
readonly tenantId: string;
readonly noteText: string;
readonly category?: string | null;
readonly pinned?: boolean;
readonly authorUserId: string | null;
readonly actorEmail?: string | null;
readonly request?: Request;
}
export async function createTenantNote(input: CreateTenantNoteInput, deps: TenantNotesDeps = defaultDeps()): Promise<void> {
const noteText = assertNoteBody(input.noteText);
const category = normalizeNoteCategory(input.category);
const created = await deps.db.adminTenantNote.create({
data: { tenantId: input.tenantId, authorUserId: input.authorUserId, noteText, category, pinned: input.pinned ?? false },
});
await deps.recordAction({
action: "tenant.note.created",
actorUserId: input.authorUserId,
actorEmail: input.actorEmail ?? null,
targetKind: "tenant",
targetId: input.tenantId,
reason: "Notiz angelegt",
after: { noteId: created.id, category, pinned: input.pinned ?? false },
request: input.request,
});
}
export interface DeleteTenantNoteInput {
readonly noteId: string;
readonly tenantId: string;
readonly actorUserId: string | null;
readonly actorEmail?: string | null;
readonly request?: Request;
}
export async function deleteTenantNote(input: DeleteTenantNoteInput, deps: TenantNotesDeps = defaultDeps()): Promise<void> {
await deps.db.adminTenantNote.deleteMany({ where: { id: input.noteId, tenantId: input.tenantId } });
await deps.recordAction({
action: "tenant.note.deleted",
actorUserId: input.actorUserId,
actorEmail: input.actorEmail ?? null,
targetKind: "tenant",
targetId: input.tenantId,
reason: "Notiz geloescht",
after: { noteId: input.noteId },
request: input.request,
});
}