106 lines
3.9 KiB
TypeScript
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,
|
|
});
|
|
}
|