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

243 lines
8.4 KiB
TypeScript

// WF-P8 Subframe delta (S00 boards start page) — pipeline access requests.
//
// The pipeline access model is deny-based (PipelineAccess with
// kannSehen=false). The Subframe design keeps locked boards visible on the
// boards start page (greyed out, "Nutzung anfragen"), so this module lists
// the pipelines the viewer is locked OUT of and lets the viewer file an
// access request. projects.admin users get an IN_APP notification (M011) and
// every request is audited (M001). Server-only (.server) — never imported by
// a component.
import { CatalogError, foundationErrors, notify } from "@hivegrid/foundation";
import { createAuditEvent } from "@hivegrid/db/audit-events.server";
import { withTenantRLS, type TenantRlsClient } from "~/utils/db/.server/tenantRls.server";
import { effectivePipelinePrincipals } from "./projectService.server";
import type { ProjectRouteContext } from "./projectAccess.server";
export type PipelineAccessRequestResolution = "approved" | "dismissed";
export type LockedBoardsStartPipeline = {
id: string;
name: string;
typHint: "sales" | "auftrag" | "custom";
isDefault: boolean;
stageCount: number;
projectCount: number;
/** The viewer already has an open access request for this pipeline. */
requestPending: boolean;
};
export type PipelineAccessRequestRow = {
id: string;
tenantId: string;
pipelineId: string;
requestedByUserId: string;
status: "open" | PipelineAccessRequestResolution;
createdAt: Date;
resolvedAt: Date | null;
resolvedByUserId: string | null;
};
/** Deny-rule match: the viewer is locked out when ANY rule with
* kannSehen=false targets one of their effective principals (inverse of
* pipelineVisibilityScopeWhere). */
function lockedPipelineScopeWhere(tenantId: string, ctx: ProjectRouteContext): Record<string, unknown> {
return {
accessRules: {
some: {
tenantId,
kannSehen: false,
OR: effectivePipelinePrincipals(ctx),
},
},
};
}
/**
* Lists the non-archived pipelines the viewer is locked out of, with the same
* stage/project count shape as loadBoardsStartPipelines. Project counts are
* board totals (active, non-demo) WITHOUT the viewer read scope on purpose:
* the viewer cannot read any project on a locked board, so the viewer-scoped
* count would always be 0 — the greyed-out row shows the board's size, which
* the design treats as non-sensitive (like the board name itself).
*/
export async function listLockedPipelinesForViewer({
tenantId,
ctx,
}: {
tenantId: string;
ctx: ProjectRouteContext;
}): Promise<LockedBoardsStartPipeline[]> {
return withTenantRLS(tenantId, async (tx) => {
const pipelines = await tx.pipeline.findMany({
where: {
tenantId,
archivedAt: null,
...lockedPipelineScopeWhere(tenantId, ctx),
},
include: { stages: { where: { archivedAt: null }, select: { id: true } } },
orderBy: { position: "asc" },
});
if (pipelines.length === 0) return [];
const pipelineIds = pipelines.map((pipeline) => pipeline.id);
const [projectTotals, openRequests] = await Promise.all([
tx.projekt.groupBy({
by: ["pipelineId"],
where: { tenantId, pipelineId: { in: pipelineIds }, archivedAt: null, isDemoData: false },
_count: { _all: true },
}),
tx.pipelineAccessRequest.findMany({
where: { tenantId, pipelineId: { in: pipelineIds }, requestedByUserId: ctx.userId, status: "open" },
select: { pipelineId: true },
}),
]);
const countsByPipeline = new Map(projectTotals.map((total) => [total.pipelineId, total._count._all]));
const pendingPipelineIds = new Set(openRequests.map((request) => request.pipelineId));
return pipelines.map((pipeline) => ({
id: pipeline.id,
name: pipeline.name,
typHint: pipeline.typHint,
isDefault: pipeline.isDefault,
stageCount: pipeline.stages.length,
projectCount: countsByPipeline.get(pipeline.id) ?? 0,
requestPending: pendingPipelineIds.has(pipeline.id),
}));
});
}
/** Best-effort M011 fan-out to every projects.admin user (same recipient
* pattern as leadService's notifyLeadSourcePausedAdmins). */
async function notifyProjectsAdmins(
tx: TenantRlsClient,
input: { tenantId: string; requestId: string; pipelineId: string; pipelineName: string; requesterUserId: string }
): Promise<void> {
try {
const admins = await tx.userRole.findMany({
where: {
tenantId: input.tenantId,
role: { permissions: { some: { permission: { name: "projects.admin" } } } },
},
select: { userId: true },
});
const userIds = [...new Set(admins.map((row) => row.userId))];
await Promise.all(
userIds.map((userId) =>
notify(
{
tenantId: input.tenantId,
userId,
channel: "IN_APP",
category: "projects",
messageKey: "projects.notification.pipelineAccessRequested",
sourceKey: `pipeline-access-request:${input.requestId}`,
payloadMetadata: {
pipelineId: input.pipelineId,
pipelineName: input.pipelineName,
requesterUserId: input.requesterUserId,
},
},
tx as unknown as Parameters<typeof notify>[1]
)
)
);
} catch {
// Notification failures must not lose the access request.
}
}
/**
* Files an access request for a pipeline the viewer is locked out of.
* 404-semantics: a pipeline that does not exist, is archived or is NOT locked
* for this viewer answers with the catalog notFound error (a locked-out
* viewer must not be able to probe which pipelines exist). Idempotent: an
* existing open request by the same user is returned instead of duplicated.
*/
export async function requestPipelineAccess({
tenantId,
pipelineId,
userId,
ctx,
}: {
tenantId: string;
pipelineId: string;
userId: string;
ctx: ProjectRouteContext;
}): Promise<{ request: PipelineAccessRequestRow; created: boolean }> {
const result = await withTenantRLS(tenantId, async (tx) => {
const pipeline = await tx.pipeline.findFirst({
where: {
tenantId,
id: pipelineId,
archivedAt: null,
...lockedPipelineScopeWhere(tenantId, ctx),
},
select: { id: true, name: true },
});
if (!pipeline) {
throw new CatalogError(foundationErrors.notFound, { pipelineId });
}
const existing = await tx.pipelineAccessRequest.findFirst({
where: { tenantId, pipelineId, requestedByUserId: userId, status: "open" },
});
if (existing) {
return { request: existing as PipelineAccessRequestRow, created: false, pipelineName: pipeline.name };
}
const request = await tx.pipelineAccessRequest.create({
data: { tenantId, pipelineId, requestedByUserId: userId },
});
await notifyProjectsAdmins(tx, {
tenantId,
requestId: request.id,
pipelineId,
pipelineName: pipeline.name,
requesterUserId: userId,
});
return { request: request as PipelineAccessRequestRow, created: true, pipelineName: pipeline.name };
});
if (result.created) {
// Best-effort audit (M001) — same pattern as auditCrmEvent: the audit
// writer manages its own client/chain, failure never breaks the request.
try {
await createAuditEvent({
tenantId,
actorId: userId,
actorType: "user",
action: "pipeline.access_requested",
targetType: "pipeline",
targetId: pipelineId,
metadata: { requestId: result.request.id, pipelineName: result.pipelineName },
});
} catch {
// Audit must never break the user action; the failure is logged upstream.
}
}
return { request: result.request, created: result.created };
}
/**
* Marks all open access requests of a user for a pipeline as approved or
* dismissed. Called best-effort from the pipeline admin action after a
* user-scoped PipelineAccess rule with kannSehen=true was upserted.
*/
export async function resolveOpenAccessRequests({
tenantId,
pipelineId,
userId,
resolvedByUserId,
status,
}: {
tenantId: string;
pipelineId: string;
userId: string;
resolvedByUserId: string;
status: PipelineAccessRequestResolution;
}): Promise<number> {
return withTenantRLS(tenantId, async (tx) => {
const result = await tx.pipelineAccessRequest.updateMany({
where: { tenantId, pipelineId, requestedByUserId: userId, status: "open" },
data: { status, resolvedAt: new Date(), resolvedByUserId },
});
return result.count;
});
}