192 lines
7.0 KiB
TypeScript
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 }
|
|
);
|
|
});
|
|
}
|