// P4 Tasks (AK-P4b) §5 — shared loader/action guard for Tasks routes. // // Resolves the authenticated tenant context and the user's permission set once, // so routes can gate consistently. Module-not-active handling differs by intent // (§ "404 vs locked" decision): // - GET loaders pass `requireModule: false` and render a locked/empty VM when // `hasModule` is false. // - Write actions pass `requireModule: true` → 404 (CatalogError.notFound) when // the module is off, and additionally require an explicit permission. import { CatalogError, foundationErrors, requirePermission } from "@toolctrl/foundation"; 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 { getPermissionsByUser } from "~/utils/db/permissions/userRoles.db.server"; import { TaskServiceError, type TaskVisibilityContext } from "./taskService.server"; export const TASKS_MODULE_KEY = "tasks"; export type TasksRouteContext = TaskVisibilityContext & { tenantId: string; hasModule: boolean; }; export async function loadTasksContext({ request, params, requireModule = false, }: { request: Request; params: Record; requireModule?: boolean; }): Promise { 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] = await Promise.all([ tenantHasModule({ tenantId, userId: userInfo.userId, moduleKey: TASKS_MODULE_KEY }), getPermissionsByUser(userInfo.userId, tenantId), ]); if (requireModule && !hasModule) { // Mutations on a disabled module look "not found" (Bauprinzip A.5.3). throw new CatalogError(foundationErrors.notFound, { module: TASKS_MODULE_KEY }); } return { tenantId, userId: userInfo.userId, permissions, hasModule }; } /** Require a tasks.* permission; throws 404 (CatalogError.notFound) if missing. */ export function requireTasksPermission(ctx: TasksRouteContext, permission: string): TasksRouteContext { requirePermission({ tenantId: ctx.tenantId, userId: ctx.userId, permissions: ctx.permissions }, permission); return ctx; } /** * Bare 404 Response for throwing from Tasks loaders/actions. The body text is * never shown to users (the route ErrorBoundary renders ServerError); we keep a * `return new Response` helper so call sites can `throw notFoundResponse()` * without inlining a hardcoded user-facing literal. */ export function notFoundResponse(): Response { return new Response("Not Found", { status: 404 }); } /** Bare 400 Response for invalid action input (same rationale as notFoundResponse). */ export function badRequestResponse(): Response { return new Response("Bad Request", { status: 400 }); } /** * Map a Tasks action error to a JSON Response for resource routes. CatalogError * keeps its status + i18nKey; TaskServiceError is a 400 with its Klartext * message key. Unknown errors are rethrown (→ 500). */ export function taskActionErrorResponse(error: unknown): Response { if (error instanceof CatalogError) { return Response.json({ error: error.i18nKey, code: error.code }, { status: error.status }); } if (error instanceof TaskServiceError) { return Response.json({ error: error.messageKey }, { status: 400 }); } throw error; }