117 lines
5.0 KiB
TypeScript
117 lines
5.0 KiB
TypeScript
import { CatalogError, foundationErrors, requirePermission } from "@hivegrid/foundation";
|
|
import { KwpProjectDomainError } from "@hivegrid/integrations/kwp-project-domain.server";
|
|
import { requireAuth } from "~/utils/loaders.middleware";
|
|
import { getTenantIdFromUrl } from "~/utils/services/.server/urlService";
|
|
import { getUserInfo } from "~/utils/session.server";
|
|
import { tenantHasModule } from "~/utils/connector/tenantConnector.server";
|
|
import { db } from "~/utils/db.server";
|
|
import { getPermissionsByUser } from "~/utils/db/permissions/userRoles.db.server";
|
|
import { PROJECTS_MODULE_KEY } from "./projectRoutePaths";
|
|
import { AuftragServiceError } from "./auftragService.server";
|
|
import { ProjectServiceError, type ProjectVisibilityContext } from "./projectService.server";
|
|
|
|
export type ProjectRouteContext = ProjectVisibilityContext & {
|
|
tenantId: string;
|
|
hasModule: boolean;
|
|
};
|
|
|
|
export const PROJECT_READ_PERMISSIONS = [
|
|
"projects.read_all",
|
|
"projects.read_own",
|
|
"projects.read_own_pl",
|
|
"projects.read_own_member",
|
|
] as const;
|
|
|
|
export async function loadProjectContext({
|
|
request,
|
|
params,
|
|
requireModule = false,
|
|
}: {
|
|
request: Request;
|
|
params: Record<string, string | undefined>;
|
|
requireModule?: boolean;
|
|
}): Promise<ProjectRouteContext> {
|
|
await requireAuth({ request, params });
|
|
const [tenantId, userInfo] = await Promise.all([getTenantIdFromUrl(params), getUserInfo(request)]);
|
|
if (!tenantId || !userInfo.userId) {
|
|
throw new CatalogError(foundationErrors.notFound);
|
|
}
|
|
const [hasModule, permissions, roleAssignments, groupMemberships] = await Promise.all([
|
|
tenantHasModule({ tenantId, userId: userInfo.userId, moduleKey: PROJECTS_MODULE_KEY }),
|
|
getPermissionsByUser(userInfo.userId, tenantId),
|
|
db.userRole.findMany({ where: { tenantId, userId: userInfo.userId }, select: { roleId: true } }),
|
|
db.groupUser.findMany({ where: { userId: userInfo.userId, group: { tenantId } }, select: { groupId: true } }),
|
|
]);
|
|
if (requireModule && !hasModule) {
|
|
throw new CatalogError(foundationErrors.notFound, { module: PROJECTS_MODULE_KEY });
|
|
}
|
|
return {
|
|
tenantId,
|
|
userId: userInfo.userId,
|
|
permissions,
|
|
roleIds: roleAssignments.map(({ roleId }) => roleId),
|
|
groupIds: groupMemberships.map(({ groupId }) => groupId),
|
|
hasModule,
|
|
};
|
|
}
|
|
|
|
export function requireProjectPermission(ctx: ProjectRouteContext, permission: string): ProjectRouteContext {
|
|
requirePermission({ tenantId: ctx.tenantId, userId: ctx.userId, permissions: ctx.permissions }, permission);
|
|
return ctx;
|
|
}
|
|
|
|
/** 404 for a route the caller may not see (Bauprinzip A.5.3: Permission-Denied
|
|
* renders 404, not 403 — and never a 500). */
|
|
export function projectNotFoundResponse(): Response {
|
|
return new Response("Not Found", { status: 404 });
|
|
}
|
|
|
|
/**
|
|
* Loader-side access guard: resolves the project context and renders a graceful
|
|
* 404 when the projects module is off OR the viewer lacks the required
|
|
* permission(s) — instead of letting the underlying CatalogError surface as a
|
|
* 500. Auth redirects from loadProjectContext propagate untouched (module is not
|
|
* required here, so requireAuth's redirect Response is not swallowed). Use in
|
|
* LOADERS only; actions keep throwing so projectActionErrorResponse can map them.
|
|
*/
|
|
export async function requireProjectView(
|
|
args: { request: Request; params: Record<string, string | undefined> },
|
|
permission: string | readonly string[],
|
|
): Promise<ProjectRouteContext> {
|
|
const ctx = await loadProjectContext({ request: args.request, params: args.params, requireModule: false });
|
|
if (!ctx.hasModule) {
|
|
throw projectNotFoundResponse();
|
|
}
|
|
try {
|
|
return Array.isArray(permission) ? requireAnyProjectPermission(ctx, permission) : requireProjectPermission(ctx, permission as string);
|
|
} catch (error) {
|
|
if (error instanceof Response) throw error;
|
|
throw projectNotFoundResponse();
|
|
}
|
|
}
|
|
|
|
export function requireAnyProjectPermission(ctx: ProjectRouteContext, permissions: readonly string[]): ProjectRouteContext {
|
|
if (permissions.some((permission) => ctx.permissions.includes(permission))) return ctx;
|
|
requirePermission({ tenantId: ctx.tenantId, userId: ctx.userId, permissions: ctx.permissions }, permissions[0] ?? "projects.read_own");
|
|
return ctx;
|
|
}
|
|
|
|
export function projectActionErrorResponse(error: unknown): Response {
|
|
if (error instanceof CatalogError) {
|
|
return Response.json({ error: error.i18nKey, code: error.code }, { status: error.status });
|
|
}
|
|
if (error instanceof ProjectServiceError) {
|
|
return Response.json({ error: error.messageKey }, { status: 400 });
|
|
}
|
|
if (error instanceof AuftragServiceError) {
|
|
return Response.json({ error: error.messageKey }, { status: 400 });
|
|
}
|
|
// A KWP-bound project refuses writes HiveGrid does not own. That is a normal
|
|
// "no" for the actor, not a crash: rethrowing turned a drag onto another board
|
|
// column into a full-page 500 instead of a message plus a card that snaps back.
|
|
if (error instanceof KwpProjectDomainError) {
|
|
return Response.json({ error: `projects.error.kwp.${error.code}` }, { status: 400 });
|
|
}
|
|
throw error;
|
|
}
|