// P4 Files (AK-P4a) โ€” shared loader/action guard for Files 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 (mirrors walking-skeleton.tsx). // - 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 { FileServiceError, type FileVisibilityContext } from "./fileService.server"; export const FILES_MODULE_KEY = "files"; export type FilesRouteContext = FileVisibilityContext & { tenantId: string; hasModule: boolean; }; export async function loadFilesContext({ 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: FILES_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: FILES_MODULE_KEY }); } return { tenantId, userId: userInfo.userId, permissions, hasModule }; } /** Require a files.* permission; throws 404 (CatalogError.notFound) if missing. */ export function requireFilesPermission(ctx: FilesRouteContext, permission: string): FilesRouteContext { requirePermission({ tenantId: ctx.tenantId, userId: ctx.userId, permissions: ctx.permissions }, permission); return ctx; } /** * Bare 404 Response for throwing from Files 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 Files action error to a JSON Response for resource routes (the upload * endpoints are called by the browser uploader via fetch, not via the HTML error * boundary). CatalogError keeps its status + i18nKey; FileServiceError is a 400 * with its Klartext message key. Unknown errors are rethrown (โ†’ 500). */ export function fileActionErrorResponse(error: unknown): Response { if (error instanceof CatalogError) { return Response.json({ error: error.i18nKey, code: error.code }, { status: error.status }); } if (error instanceof FileServiceError) { return Response.json({ error: error.messageKey }, { status: 400 }); } throw error; }