243 lines
8.4 KiB
TypeScript
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;
|
|
});
|
|
}
|