Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/integrations/projects/projektNotizService.server.ts
T

192 lines
7.0 KiB
TypeScript

// WF-P8 S02-N (Modal Notiz erstellen) — project notes with a reader gate.
//
// The visibility choice is the point of this module: "alle" follows normal
// project access, "innendienst" additionally requires the
// `projects.read_internal_notes` permission, and "nur_ich" is author-only.
// The gate is applied in SQL (see `noteReadScopeWhere`) so a caller cannot
// forget it, and RLS still scopes every row to the bound tenant on top.
import { CatalogError, foundationErrors } from "@hivegrid/foundation";
import { logActivity } from "@hivegrid/activity";
import { withTenantRLS, type TenantRlsClient } from "~/utils/db/.server/tenantRls.server";
import { projectEditScopeWhere, projectReadScopeWhere, type ProjectVisibilityContext } from "./projectService.server";
export class ProjektNotizServiceError extends Error {
readonly messageKey: string;
constructor(messageKey: string) {
super(messageKey);
this.name = "ProjektNotizServiceError";
this.messageKey = messageKey;
}
}
export type ProjektNotizSichtbarkeit = "alle" | "innendienst" | "nur_ich";
export const PROJEKT_NOTIZ_SICHTBARKEITEN: readonly ProjektNotizSichtbarkeit[] = ["alle", "innendienst", "nur_ich"];
export function isProjektNotizSichtbarkeit(value: string): value is ProjektNotizSichtbarkeit {
return (PROJEKT_NOTIZ_SICHTBARKEITEN as readonly string[]).includes(value);
}
export type ProjektNotizRow = {
id: string;
tenantId: string;
projektId: string;
autorUserId: string | null;
inhalt: string;
sichtbarkeit: ProjektNotizSichtbarkeit;
createdAt: Date;
updatedAt: Date;
archivedAt: Date | null;
};
/** Note-reader context: the project visibility context plus the internal-notes permission. */
export type ProjektNotizContext = ProjectVisibilityContext & {
/** True when the actor holds `projects.read_internal_notes`. */
canReadInternal: boolean;
};
/** Derive the note-reader context from a project context (single source for the gate). */
export function toProjektNotizContext(ctx: ProjectVisibilityContext): ProjektNotizContext {
return { ...ctx, canReadInternal: ctx.permissions.includes("projects.read_internal_notes") };
}
function normalizeText(value?: string | null): string | null {
const normalized = value?.trim();
return normalized || null;
}
/**
* SQL-level reader gate. Returns a `where` fragment that only matches notes the
* actor is allowed to see. Kept as a fragment (not a post-filter) so a caller
* cannot accidentally page over rows it may not read.
*/
export function noteReadScopeWhere(ctx: ProjektNotizContext) {
const allowed: Record<string, unknown>[] = [{ sichtbarkeit: "alle" }];
if (ctx.canReadInternal) allowed.push({ sichtbarkeit: "innendienst" });
// `nur_ich` is author-only. An anonymous/system context matches nothing here,
// which is deliberate: a null author must not open up every private note.
if (ctx.userId) allowed.push({ sichtbarkeit: "nur_ich", autorUserId: ctx.userId });
return { OR: allowed };
}
async function requireProjectWithTx(
tx: TenantRlsClient,
input: { tenantId: string; projectId: string; ctx: ProjectVisibilityContext; editable: boolean }
): Promise<void> {
const project = await tx.projekt.findFirst({
where: {
id: input.projectId,
tenantId: input.tenantId,
archivedAt: null,
...(input.editable ? projectEditScopeWhere(input.tenantId, input.ctx) : projectReadScopeWhere(input.tenantId, input.ctx)),
},
select: { id: true },
});
// Permission-denied shows 404, never 403 (Bauprinzip A.5.3).
if (!project) throw new CatalogError(foundationErrors.notFound, { projectId: input.projectId });
}
export async function listProjectNotes(input: {
tenantId: string;
projectId: string;
ctx: ProjektNotizContext;
}): Promise<ProjektNotizRow[]> {
return withTenantRLS(input.tenantId, async (tx) => {
await requireProjectWithTx(tx, { ...input, editable: false });
const rows = await tx.projektNotiz.findMany({
where: {
tenantId: input.tenantId,
projektId: input.projectId,
archivedAt: null,
...noteReadScopeWhere(input.ctx),
},
orderBy: { createdAt: "desc" },
});
return rows as ProjektNotizRow[];
});
}
export async function createProjectNote(input: {
tenantId: string;
projectId: string;
ctx: ProjektNotizContext;
autorUserId: string | null;
inhalt: string;
sichtbarkeit: ProjektNotizSichtbarkeit;
}): Promise<ProjektNotizRow> {
return withTenantRLS(input.tenantId, async (tx) => {
const inhalt = normalizeText(input.inhalt);
if (!inhalt) throw new ProjektNotizServiceError("projects.error.noteContentRequired");
// "Nur ich" without an author would be unreadable for everyone including
// its creator, so reject it instead of writing an orphan note.
if (input.sichtbarkeit === "nur_ich" && !input.autorUserId) {
throw new ProjektNotizServiceError("projects.error.notePrivateNeedsAuthor");
}
await requireProjectWithTx(tx, { ...input, editable: true });
const row = await tx.projektNotiz.create({
data: {
tenantId: input.tenantId,
projektId: input.projectId,
autorUserId: input.autorUserId,
inhalt,
sichtbarkeit: input.sichtbarkeit,
},
});
await logActivity(
{
tenantId: input.tenantId,
actorUserId: input.autorUserId,
actorKind: input.autorUserId ? "user" : "system",
activityType: "project_note_created",
subjectType: "project",
subjectId: input.projectId,
summaryText: "activity.runtime.projects.noteCreated",
// The note body itself is deliberately NOT mirrored into the activity
// stream: the stream has no reader gate, so a "nur ich" note would leak.
metadata: { noteId: row.id, sichtbarkeit: row.sichtbarkeit },
},
{ client: tx }
);
return row as ProjektNotizRow;
});
}
export async function archiveProjectNote(input: {
tenantId: string;
projectId: string;
noteId: string;
ctx: ProjektNotizContext;
actorUserId: string | null;
}): Promise<void> {
await withTenantRLS(input.tenantId, async (tx) => {
await requireProjectWithTx(tx, { ...input, editable: true });
// Reader gate applies to archiving too: you cannot archive what you may not see.
const note = await tx.projektNotiz.findFirst({
where: {
id: input.noteId,
tenantId: input.tenantId,
projektId: input.projectId,
archivedAt: null,
...noteReadScopeWhere(input.ctx),
},
select: { id: true },
});
if (!note) throw new CatalogError(foundationErrors.notFound, { noteId: input.noteId });
await tx.projektNotiz.update({ where: { id: note.id }, data: { archivedAt: new Date() } });
await logActivity(
{
tenantId: input.tenantId,
actorUserId: input.actorUserId,
actorKind: input.actorUserId ? "user" : "system",
activityType: "project_note_archived",
subjectType: "project",
subjectId: input.projectId,
summaryText: "activity.runtime.projects.noteArchived",
metadata: { noteId: note.id },
},
{ client: tx }
);
});
}