Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
1 parent b3d5ea6764
commit e341c5938a
5784 files changed
+1104929 -3

No files matched your search

@@ -0,0 +1,109 @@
// P4 EntityLink (AK-P4b) §5c — Gate W1 generic cross-entity link service.
//
// Tenant-scoped, idempotent CRUD over the polymorphic `EntityLink` table. Both
// sides (source + target) are polymorphic, so any module docks onto this one
// service instead of building its own. No CRM coupling here — wiring concrete
// entities (Task<->Contact, etc.) happens in P6. NO React/UI imports.
//
// RLS-ready (AK-P4b "Teil A", mirrors fileService): every EntityLink access runs
// inside withTenantRLS() so the tenant GUC is set before the query. Functional
// no-op until the EntityLink RLS policy is activated (prisma/rls/task-isolation.sql,
// P5 cutover). EntityLink is the only table this service touches, so each function
// is a single withTenantRLS() block (no nested transactions to worry about).
import { withTenantRLS } from "~/utils/db/.server/tenantRls.server";
export type LinkableEntityType = "contact" | "company" | "lead" | "deal" | "project" | "task" | "file" | "user" | "order" | "appointment" | "form_submission";
export type EntityLinkRow = {
id: string;
sourceType: LinkableEntityType;
sourceId: string;
targetType: LinkableEntityType;
targetId: string;
linkRole: string | null;
createdAt: Date;
};
export type EntityRef = { entityType: LinkableEntityType; entityId: string };
/**
* Create (or update the role of) a directed link between two entities. Idempotent
* on the unique tuple (tenantId, source*, target*) — a repeated call only
* refreshes `linkRole`.
*/
export async function linkEntities(input: {
tenantId: string;
userId: string;
sourceType: LinkableEntityType;
sourceId: string;
targetType: LinkableEntityType;
targetId: string;
linkRole?: string | null;
}): Promise<void> {
await withTenantRLS(input.tenantId, (tx) =>
tx.entityLink.upsert({
where: {
tenantId_sourceType_sourceId_targetType_targetId: {
tenantId: input.tenantId,
sourceType: input.sourceType,
sourceId: input.sourceId,
targetType: input.targetType,
targetId: input.targetId,
},
},
update: { linkRole: input.linkRole ?? null },
create: {
tenantId: input.tenantId,
createdByUserId: input.userId,
sourceType: input.sourceType,
sourceId: input.sourceId,
targetType: input.targetType,
targetId: input.targetId,
linkRole: input.linkRole ?? null,
},
}),
);
}
/** Remove a directed link between two entities (no-op if absent). */
export async function unlinkEntities(input: {
tenantId: string;
sourceType: LinkableEntityType;
sourceId: string;
targetType: LinkableEntityType;
targetId: string;
}): Promise<void> {
await withTenantRLS(input.tenantId, (tx) =>
tx.entityLink.deleteMany({
where: {
tenantId: input.tenantId,
sourceType: input.sourceType,
sourceId: input.sourceId,
targetType: input.targetType,
targetId: input.targetId,
},
}),
);
}
/**
* List all links touching an entity (as either source or target), tenant-scoped.
* Returns rows oriented as stored; callers normalize direction as needed.
*/
export async function listLinksFor(input: { tenantId: string; entityType: LinkableEntityType; entityId: string }): Promise<EntityLinkRow[]> {
const rows = await withTenantRLS(input.tenantId, (tx) =>
tx.entityLink.findMany({
where: {
tenantId: input.tenantId,
OR: [
{ sourceType: input.entityType, sourceId: input.entityId },
{ targetType: input.entityType, targetId: input.entityId },
],
},
select: { id: true, sourceType: true, sourceId: true, targetType: true, targetId: true, linkRole: true, createdAt: true },
orderBy: { createdAt: "asc" },
}),
);
return rows as EntityLinkRow[];
}
@@ -0,0 +1,86 @@
// 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<string, string | undefined>;
requireModule?: boolean;
}): Promise<FilesRouteContext> {
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;
}
@@ -0,0 +1,84 @@
// P4 Files (AK-P4a) §12 — audit + activity wiring for the Files module.
//
// Audit (DoD 13): every write action records a tamper-evident AuditEvent (M001)
// via foundation.auditEvent with the shared db client.
//
// Activity (DoD 14): user-facing timeline entries are logged AGAINST the linked
// entity (the file itself is not an Activity subject in v1 — see decision 4 in
// the build plan). Only FileEntityType values that map to an ActivitySubjectType
// (contact/company/lead/deal/project/task) produce an activity; others are
// skipped (no shared @hivegrid/activity edit).
import type { JsonValue } from "@toolctrl/foundation";
import { createAuditEvent } from "@toolctrl/db/audit-events.server";
import { logActivity, type ActivitySubjectType, type ActivityType } from "@hivegrid/activity";
import type { FilesRouteContext } from "./fileAccess.server";
import type { FileEntityType } from "./fileService.server";
export type FileAuditAction = "file.uploaded" | "file.linked" | "file.unlinked" | "file.archived" | "file.reactivated" | "file.deleted" | "file.updated";
export async function auditFileEvent(input: {
ctx: Pick<FilesRouteContext, "tenantId" | "userId">;
action: FileAuditAction;
fileId: string;
metadata?: Record<string, JsonValue>;
}): Promise<void> {
try {
// createAuditEvent (M001) writes the tamper-evident, hash-chained AuditEvent
// and defaults its client to the shared db singleton.
await createAuditEvent({
tenantId: input.ctx.tenantId,
actorId: input.ctx.userId,
actorType: "user",
action: input.action,
targetType: "file",
targetId: input.fileId,
metadata: input.metadata ?? null,
});
} catch {
// Audit must never break the user action; the failure is logged upstream.
}
}
const ENTITY_TO_ACTIVITY_SUBJECT: Partial<Record<FileEntityType, ActivitySubjectType>> = {
contact: "contact",
company: "company",
lead: "lead",
deal: "deal",
project: "project",
task: "task",
};
/**
* Log a file-related activity against the linked entity's timeline. No-op when
* the entity type has no Activity subject mapping (e.g. user/form_submission).
*/
export async function logFileActivity(input: {
ctx: Pick<FilesRouteContext, "tenantId" | "userId">;
entityType: FileEntityType;
entityId: string;
activityType: Extract<ActivityType, "file_uploaded">;
summaryText: string;
fileId: string;
}): Promise<void> {
const subjectType = ENTITY_TO_ACTIVITY_SUBJECT[input.entityType];
if (!subjectType) {
return;
}
try {
// logActivity defaults its write client to the shared db singleton.
await logActivity({
tenantId: input.ctx.tenantId,
actorUserId: input.ctx.userId,
actorKind: "user",
activityType: input.activityType,
subjectType,
subjectId: input.entityId,
summaryText: input.summaryText,
// The file is not an Activity subject in v1; carry its id in metadata.
metadata: { fileId: input.fileId },
});
} catch {
// Activity logging is best-effort; never break the user action.
}
}
@@ -0,0 +1,74 @@
// Producer-side helper for the toolctrl.file-preview BullMQ queue. Web routes
// call enqueueFilePreview(...) after a successful upload (and on the "regenerate
// preview" action) to trigger asynchronous preview generation in the worker.
// Mirrors fileScanQueue.server.ts so the queue lifecycle is the same shared
// pattern. Unlike the scan queue there is no enable-flag: preview generation is
// always desired; if Redis is unreachable the enqueue is a no-op and the file
// simply keeps previewStatus=pending until re-triggered.
import {
buildFilePreviewJob,
enqueueJob,
queueNames,
resolveRedisConnectionOptions,
resolveRedisConnectionOptionsFromProcessEnv,
type RedisConnectionEnv,
type TypedQueue,
} from "@toolctrl/queue";
export type EnqueueFilePreviewInput = {
fileId: string;
tenantId: string;
bucket: string;
storageKey: string;
mimeType: string;
metadata?: Record<string, unknown>;
};
export type EnqueueFilePreviewResult = { enqueued: true; jobId?: string } | { enqueued: false; reason: "queue-unavailable" };
type Dependencies = {
env?: RedisConnectionEnv;
queue?: TypedQueue<typeof queueNames.filePreview>;
};
export async function enqueueFilePreview(input: EnqueueFilePreviewInput, dependencies: Dependencies = {}): Promise<EnqueueFilePreviewResult> {
let queue = dependencies.queue;
let shouldCloseQueue = false;
const connection = queue
? undefined
: dependencies.env
? resolveRedisConnectionOptions({ env: dependencies.env })
: resolveRedisConnectionOptionsFromProcessEnv();
if (!queue && !connection) {
return { enqueued: false, reason: "queue-unavailable" };
}
if (!queue && connection) {
const queueRuntimeModule = "@toolctrl/queue/runtime";
const { createQueue } = (await import(/* @vite-ignore */ queueRuntimeModule)) as typeof import("@toolctrl/queue/runtime");
queue = createQueue(queueNames.filePreview, { connection });
shouldCloseQueue = true;
}
if (!queue) {
return { enqueued: false, reason: "queue-unavailable" };
}
try {
const descriptor = buildFilePreviewJob({
fileId: input.fileId,
tenantId: input.tenantId,
bucket: input.bucket,
storageKey: input.storageKey,
mimeType: input.mimeType,
metadata: input.metadata,
});
const job = await enqueueJob(queue, descriptor, { tenantId: input.tenantId });
return { enqueued: true, jobId: typeof job.id === "string" ? job.id : undefined };
} finally {
if (shouldCloseQueue && queue) {
await queue.close().catch(() => {});
}
}
}
@@ -0,0 +1,13 @@
// P4 Files (AK-P4a) — pure route-path builders for Files URLs. The loader builds
// these and passes them into the View-Models so the VMs stay routing-agnostic.
// Media URLs ALWAYS point at the auth-proxy route (AD-23), never at raw S3.
export type MediaVariant = "original" | "preview" | "thumb";
export function filesDetailPath(tenantSlug: string, fileId: string): string {
return `/${tenantSlug}/files/${fileId}`;
}
export function mediaVariantPath(tenantSlug: string, fileId: string, variant: MediaVariant): string {
return `/${tenantSlug}/media/${fileId}/${variant}`;
}
@@ -0,0 +1,85 @@
// Producer-side helper for the toolctrl.file-scan BullMQ queue. Web routes
// call enqueueFileScan(...) after a successful upload to trigger the
// asynchronous post-upload ClamAV scan in the worker. Mirrors the
// TransactionalEmailQueue.server.ts shape so the queue lifecycle is the
// same shared pattern.
import {
buildFileScanJob,
enqueueJob,
queueNames,
resolveRedisConnectionOptions,
resolveRedisConnectionOptionsFromProcessEnv,
type RedisConnectionEnv,
type TypedQueue,
} from "@toolctrl/queue";
export type EnqueueFileScanInput = {
bucket: string;
key: string;
tenantId?: string;
uploadId?: string;
contentType?: string;
// AK-P4a §5 — File row id so the worker can write the verdict back onto
// File.scanStatus (clean|infected). See enqueueFileScan callers.
fileId?: string;
metadata?: Record<string, unknown>;
};
export type EnqueueFileScanResult =
| { enqueued: true; jobId?: string }
| { enqueued: false; reason: "scan-disabled" | "queue-unavailable" };
type Dependencies = {
env?: RedisConnectionEnv & { SCAN_QUEUE_ENABLED?: string };
queue?: TypedQueue<typeof queueNames.fileScan>;
};
export async function enqueueFileScan(input: EnqueueFileScanInput, dependencies: Dependencies = {}): Promise<EnqueueFileScanResult> {
const env = dependencies.env ?? (process.env as RedisConnectionEnv & { SCAN_QUEUE_ENABLED?: string });
if (env.SCAN_QUEUE_ENABLED !== "true") {
return { enqueued: false, reason: "scan-disabled" };
}
let queue = dependencies.queue;
let shouldCloseQueue = false;
const connection = queue
? undefined
: dependencies.env
? resolveRedisConnectionOptions({ env: dependencies.env })
: resolveRedisConnectionOptionsFromProcessEnv();
if (!queue && !connection) {
return { enqueued: false, reason: "queue-unavailable" };
}
if (!queue && connection) {
const queueRuntimeModule = "@toolctrl/queue/runtime";
const { createQueue } = (await import(/* @vite-ignore */ queueRuntimeModule)) as typeof import("@toolctrl/queue/runtime");
queue = createQueue(queueNames.fileScan, { connection });
shouldCloseQueue = true;
}
if (!queue) {
return { enqueued: false, reason: "queue-unavailable" };
}
try {
const descriptor = buildFileScanJob({
bucket: input.bucket,
key: input.key,
tenantId: input.tenantId,
uploadId: input.uploadId,
contentType: input.contentType,
fileId: input.fileId,
metadata: input.metadata,
});
const job = await enqueueJob(queue, descriptor, {
...(input.tenantId !== undefined ? { tenantId: input.tenantId } : {}),
});
return { enqueued: true, jobId: typeof job.id === "string" ? job.id : undefined };
} finally {
if (shouldCloseQueue && queue) {
await queue.close().catch(() => {});
}
}
}
@@ -0,0 +1,578 @@
// P4 Files (AK-P4a) §3/§7 — domain service for the Files module.
//
// Pure-ish server logic: builds/queries File rows (always tenant-scoped),
// orchestrates chunked upload + assembly, enforces visibility (404, not 403),
// and fans out to the scan/preview queues. NO React/UI imports. Routes call
// these functions from their loaders/actions and build the View-Model from the
// returned rows.
//
// Tenant isolation is enforced THREEFOLD: every query carries `where: { tenantId }`
// (app-level scoping), the storage keys carry the `tenants/{tenantId}/` prefix
// (assertFileKeyBelongsToTenant), and every DB access runs inside withTenantRLS()
// (R-P2b) so `app.tenant_id` is set for the policies shipped in
// local/AK-P4a-merge-migration.sql. Until that RLS block is activated (P5 cutover)
// the wrapper is a harmless no-op (a transaction that sets a GUC no policy reads);
// it makes Files RLS-ready without changing runtime behaviour.
//
// Wrapping rules (so activation is safe): slow I/O (S3, queue) stays OUTSIDE the
// transaction, and functions never nest withTenantRLS() — internal composition
// goes through *WithTx(tx, ...) helpers that take the active transaction client.
import { CatalogError, foundationErrors } from "@toolctrl/foundation";
import { buildFileStorageKeys, buildUploadChunkKey, uploadChunkPrefix } from "@toolctrl/storage";
import {
FILE_UPLOAD_CHUNK_SIZE_BYTES,
FILE_UPLOAD_MAX_FILE_SIZE_BYTES,
} from "@toolctrl/view-models";
import { withTenantRLS, type TenantRlsClient } from "~/utils/db/.server/tenantRls.server";
import { assembleChunkedUpload, deleteFile, putBufferAsFile, resolveStorageBucket } from "./storageService";
import { enqueueFileScan } from "./fileScanQueue.server";
import { enqueueFilePreview } from "./filePreviewQueue.server";
/**
* Domain error carrying an i18n message key (M002 Klartext). Routes map
* `messageKey` to a catalog error / VM error branch — never a bare HTTP code.
*/
export class FileServiceError extends Error {
readonly messageKey: string;
constructor(messageKey: string) {
super(messageKey);
this.name = "FileServiceError";
this.messageKey = messageKey;
}
}
export type FileEntityType =
| "contact"
| "company"
| "lead"
| "deal"
| "project"
| "user"
| "form_submission"
| "order"
| "task"
| "appointment";
export type FileSource = "manual_upload" | "customer_portal" | "form_submission" | "email_attachment" | "automation" | "api";
/**
* Curated upload allow-list for the Files module. Deliberately broader than the
* shared scan `DEFAULT_ALLOW_LIST` (which is per-context and lacks HEIC) — Files
* is a general document store and HEIC is mandatory (iPhone photos, AK-P4a).
* The shared allow-list stays untouched (mergeable boilerplate). Layer-2 ClamAV
* still scans the stored bytes regardless of declared MIME.
*/
export const FILES_ALLOWED_MIME_TYPES: ReadonlySet<string> = new Set([
"image/png",
"image/jpeg",
"image/webp",
"image/gif",
"image/heic",
"image/heif",
"application/pdf",
"text/plain",
"text/markdown",
"text/csv",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
]);
export type FileVisibilityContext = {
userId: string;
/** The user's permission names for this tenant (from getPermissionsByUser). */
permissions: readonly string[];
};
function hasPermission(ctx: FileVisibilityContext, permission: string): boolean {
return ctx.permissions.includes(permission);
}
// --- Upload session -------------------------------------------------------
export type CreateUploadSessionInput = {
tenantId: string;
userId: string;
originalFilename: string;
mimeType: string;
totalSizeBytes: number;
};
export type CreateUploadSessionResult = {
uploadId: string;
chunkSize: number;
totalChunks: number;
};
export async function createUploadSession(input: CreateUploadSessionInput): Promise<CreateUploadSessionResult> {
if (input.totalSizeBytes <= 0) {
throw new FileServiceError("files.error.emptyFile");
}
if (input.totalSizeBytes > FILE_UPLOAD_MAX_FILE_SIZE_BYTES) {
throw new FileServiceError("files.error.tooLarge");
}
if (!FILES_ALLOWED_MIME_TYPES.has(input.mimeType)) {
throw new FileServiceError("files.error.mimeBlocked");
}
const totalChunks = Math.max(1, Math.ceil(input.totalSizeBytes / FILE_UPLOAD_CHUNK_SIZE_BYTES));
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
const upload = await withTenantRLS(input.tenantId, (tx) =>
tx.fileUpload.create({
data: {
tenantId: input.tenantId,
createdByUserId: input.userId,
originalFilename: input.originalFilename,
totalSizeBytes: input.totalSizeBytes,
mimeType: input.mimeType,
totalChunks,
status: "in_progress",
expiresAt,
},
select: { id: true },
}),
);
return { uploadId: upload.id, chunkSize: FILE_UPLOAD_CHUNK_SIZE_BYTES, totalChunks };
}
export type StoreUploadChunkInput = {
tenantId: string;
userId: string;
uploadId: string;
chunkIndex: number;
bytes: Uint8Array;
};
export async function storeUploadChunk(input: StoreUploadChunkInput): Promise<{ receivedChunks: number; totalChunks: number }> {
const upload = await withTenantRLS(input.tenantId, (tx) =>
tx.fileUpload.findFirst({
where: { id: input.uploadId, tenantId: input.tenantId, createdByUserId: input.userId },
select: { id: true, status: true, totalChunks: true, expiresAt: true },
}),
);
if (!upload) {
throw new CatalogError(foundationErrors.notFound, { uploadId: input.uploadId });
}
if (upload.status !== "in_progress") {
throw new FileServiceError("files.error.uploadNotActive");
}
if (upload.expiresAt.getTime() < Date.now()) {
throw new FileServiceError("files.error.uploadExpired");
}
if (input.chunkIndex < 0 || input.chunkIndex >= upload.totalChunks) {
throw new FileServiceError("files.error.chunkIndexOutOfRange");
}
const bucket = resolveStorageBucket();
const chunkKey = buildUploadChunkKey(input.tenantId, input.uploadId, input.chunkIndex);
// S3 PUT stays OUTSIDE the RLS transaction (no slow I/O inside a tx).
await putBufferAsFile({ bucket, key: chunkKey, body: input.bytes, contentType: "application/octet-stream" });
// Persist the chunk + recount in one tenant-scoped transaction.
const received = await withTenantRLS(input.tenantId, async (tx) => {
// Upsert so a retried chunk does not double-count receivedChunks.
const existing = await tx.fileUploadChunk.findUnique({
where: { uploadId_chunkIndex: { uploadId: input.uploadId, chunkIndex: input.chunkIndex } },
select: { id: true },
});
if (existing) {
await tx.fileUploadChunk.update({
where: { uploadId_chunkIndex: { uploadId: input.uploadId, chunkIndex: input.chunkIndex } },
data: { storageKey: chunkKey, sizeBytes: input.bytes.byteLength },
});
} else {
await tx.fileUploadChunk.create({
data: {
tenantId: input.tenantId,
uploadId: input.uploadId,
chunkIndex: input.chunkIndex,
storageKey: chunkKey,
sizeBytes: input.bytes.byteLength,
},
});
}
const count = await tx.fileUploadChunk.count({ where: { uploadId: input.uploadId, tenantId: input.tenantId } });
await tx.fileUpload.update({ where: { id: input.uploadId }, data: { receivedChunks: count } });
return count;
});
return { receivedChunks: received, totalChunks: upload.totalChunks };
}
export type CompleteUploadInput = {
tenantId: string;
userId: string;
uploadId: string;
displayName?: string;
source?: FileSource;
createdByPortalUserId?: string | null;
isDemoData?: boolean;
};
export type CompletedFile = {
id: string;
displayName: string;
mimeType: string;
sizeBytes: number;
storageKey: string;
};
export async function completeUpload(input: CompleteUploadInput): Promise<CompletedFile> {
// Segment 1 (DB): validate the session, read the chunk manifest and create the
// pending File row. The File id is needed for the storage key, so the row is
// created here (before S3 assembly). All contiguous DB work, no slow I/O.
const { upload, chunks, file } = await withTenantRLS(input.tenantId, async (tx) => {
const session = await tx.fileUpload.findFirst({
where: { id: input.uploadId, tenantId: input.tenantId, createdByUserId: input.userId },
select: { id: true, status: true, totalChunks: true, originalFilename: true, mimeType: true, totalSizeBytes: true },
});
if (!session) {
throw new CatalogError(foundationErrors.notFound, { uploadId: input.uploadId });
}
if (session.status !== "in_progress") {
throw new FileServiceError("files.error.uploadNotActive");
}
const manifest = await tx.fileUploadChunk.findMany({
where: { uploadId: input.uploadId, tenantId: input.tenantId },
orderBy: { chunkIndex: "asc" },
select: { chunkIndex: true, storageKey: true },
});
if (manifest.length !== session.totalChunks) {
throw new FileServiceError("files.error.uploadIncomplete");
}
// Verify contiguous 0..totalChunks-1 ordering.
for (let index = 0; index < manifest.length; index += 1) {
if (manifest[index].chunkIndex !== index) {
throw new FileServiceError("files.error.uploadIncomplete");
}
}
const created = await tx.file.create({
data: {
tenantId: input.tenantId,
createdByUserId: input.userId,
displayName: (input.displayName ?? session.originalFilename).slice(0, 255),
mimeType: session.mimeType,
sizeBytes: session.totalSizeBytes,
storageKey: "", // set after assembly below
source: input.source ?? "manual_upload",
createdByPortalUserId: input.createdByPortalUserId ?? null,
isDemoData: input.isDemoData ?? false,
previewStatus: "pending",
scanStatus: "pending",
},
select: { id: true, displayName: true, mimeType: true, sizeBytes: true },
});
return { upload: session, chunks: manifest, file: created };
});
const bucket = resolveStorageBucket();
const { original } = buildFileStorageKeys(input.tenantId, file.id);
try {
// S3 assembly stays OUTSIDE the RLS transaction.
await assembleChunkedUpload({
bucket,
targetKey: original,
chunkSourceKeys: chunks.map((chunk) => chunk.storageKey),
contentType: upload.mimeType,
});
} catch (error) {
// Roll the half-created File back so a failed assembly leaves no orphan row.
await withTenantRLS(input.tenantId, async (tx) => {
await tx.file.delete({ where: { id: file.id } }).catch(() => {});
await tx.fileUpload.update({ where: { id: input.uploadId }, data: { status: "failed", failureReason: "assembly-failed" } }).catch(() => {});
}).catch(() => {});
throw error instanceof FileServiceError ? error : new FileServiceError("files.error.assemblyFailed");
}
await withTenantRLS(input.tenantId, (tx) => tx.file.update({ where: { id: file.id }, data: { storageKey: original } }));
// Clean up temp chunk objects (best-effort, S3 outside the tx) + mark the
// session done in one tenant-scoped transaction.
await Promise.all(chunks.map((chunk) => deleteFile({ bucket, key: chunk.storageKey }).catch(() => {})));
await withTenantRLS(input.tenantId, async (tx) => {
await tx.fileUploadChunk.deleteMany({ where: { uploadId: input.uploadId, tenantId: input.tenantId } }).catch(() => {});
await tx.fileUpload.update({ where: { id: input.uploadId }, data: { status: "completed", targetFileId: file.id } });
});
// Fan out async scan (ClamAV, layer 2) + preview generation. When scanning is
// disabled (dev / SCAN_QUEUE_ENABLED!=true) the enqueue is a no-op; in that
// case mark the file scanStatus=skipped so the fail-closed media proxy (§6)
// still serves it. In prod the file stays scanStatus=pending until the worker
// writes a clean/infected verdict.
const scanResult = await enqueueFileScan({ bucket, key: original, tenantId: input.tenantId, contentType: upload.mimeType, fileId: file.id }).catch(
() => ({ enqueued: false, reason: "queue-unavailable" }) as const,
);
if (!scanResult.enqueued && scanResult.reason === "scan-disabled") {
await withTenantRLS(input.tenantId, (tx) => tx.file.update({ where: { id: file.id }, data: { scanStatus: "skipped" } })).catch(() => {});
}
await enqueueFilePreview({ fileId: file.id, tenantId: input.tenantId, bucket, storageKey: original, mimeType: upload.mimeType }).catch(() => {});
return { id: file.id, displayName: file.displayName, mimeType: file.mimeType, sizeBytes: file.sizeBytes, storageKey: original };
}
// --- Visibility / read ----------------------------------------------------
export type FileRow = {
id: string;
tenantId: string;
createdByUserId: string;
assignedToUserId: string | null;
displayName: string;
mimeType: string;
sizeBytes: number;
storageKey: string;
previewStorageKey: string | null;
thumbnailStorageKey: string | null;
previewStatus: string;
scanStatus: string;
tags: string[];
notes: string | null;
source: string;
createdAt: Date;
archivedAt: Date | null;
};
/**
* Owner/link visibility for files.read_own (§7). Visible when the user owns or
* is assigned the file, OR holds files.read_all, OR a readable FileEntityLink
* exists. v1 link-readability is minimal (the link's mere existence in-tenant);
* full transitive entity-permission checks are v2.
*/
// Internal: visibility check on an already-loaded file, using the active RLS
// transaction client (no own transaction → safe to call inside withTenantRLS).
async function isFileVisibleWithTx(
tx: TenantRlsClient,
file: Pick<FileRow, "id" | "tenantId" | "createdByUserId" | "assignedToUserId">,
ctx: FileVisibilityContext,
): Promise<boolean> {
if (hasPermission(ctx, "files.read_all")) {
return true;
}
if (file.createdByUserId === ctx.userId || file.assignedToUserId === ctx.userId) {
return true;
}
const link = await tx.fileEntityLink.findFirst({
where: { fileId: file.id, tenantId: file.tenantId },
select: { id: true },
});
return Boolean(link);
}
export async function isFileVisible(file: Pick<FileRow, "id" | "tenantId" | "createdByUserId" | "assignedToUserId">, ctx: FileVisibilityContext): Promise<boolean> {
// Fast paths need no DB; only the link lookup opens a tenant-scoped transaction.
if (hasPermission(ctx, "files.read_all") || file.createdByUserId === ctx.userId || file.assignedToUserId === ctx.userId) {
return true;
}
return withTenantRLS(file.tenantId, (tx) => isFileVisibleWithTx(tx, file, ctx));
}
// Internal: load + visibility on the active transaction client (no own tx).
async function getVisibleFileOrThrowWithTx(tx: TenantRlsClient, tenantId: string, fileId: string, ctx: FileVisibilityContext): Promise<FileRow> {
const file = await tx.file.findFirst({ where: { id: fileId, tenantId } });
if (!file) {
throw new CatalogError(foundationErrors.notFound, { fileId });
}
if (!(await isFileVisibleWithTx(tx, file as FileRow, ctx))) {
throw new CatalogError(foundationErrors.notFound, { fileId });
}
return file as FileRow;
}
/** Load a single file (tenant-scoped) and enforce visibility, else 404. */
export async function getVisibleFileOrThrow(tenantId: string, fileId: string, ctx: FileVisibilityContext): Promise<FileRow> {
return withTenantRLS(tenantId, (tx) => getVisibleFileOrThrowWithTx(tx, tenantId, fileId, ctx));
}
export type FileLink = { entityType: string; entityId: string; linkRole: string | null };
/** Load a file with its entity links (tenant-scoped + visibility), else 404. */
export async function getVisibleFileWithLinks(
tenantId: string,
fileId: string,
ctx: FileVisibilityContext,
): Promise<{ file: FileRow; links: FileLink[] }> {
return withTenantRLS(tenantId, async (tx) => {
const file = await getVisibleFileOrThrowWithTx(tx, tenantId, fileId, ctx);
const links = await tx.fileEntityLink.findMany({
where: { fileId, tenantId },
select: { entityType: true, entityId: true, linkRole: true },
orderBy: { createdAt: "asc" },
});
return { file, links: links as FileLink[] };
});
}
export type ListFilesFilter = {
mimeGroup?: string | null;
tags?: readonly string[];
assignedToUserId?: string | null;
includeArchived?: boolean;
};
const MIME_GROUP_PREFIX: Record<string, string> = { image: "image/", pdf: "application/pdf", text: "text/" };
/**
* List files for the browser (tenant-scoped + read_own visibility), newest
* first, cursor-paginated. Visibility for non-read_all users: owner/assignee or
* any linked file (v1 minimal, §7).
*/
export async function listFilesForBrowser(input: {
tenantId: string;
ctx: FileVisibilityContext;
filter?: ListFilesFilter;
cursor?: string | null;
limit?: number;
}): Promise<{ rows: FileRow[]; nextCursor: string | null }> {
const limit = Math.min(Math.max(input.limit ?? 50, 1), 100);
const filter = input.filter ?? {};
const visibility = hasPermission(input.ctx, "files.read_all")
? {}
: {
OR: [
{ createdByUserId: input.ctx.userId },
{ assignedToUserId: input.ctx.userId },
{ links: { some: { tenantId: input.tenantId } } },
],
};
const mimePrefix = filter.mimeGroup ? MIME_GROUP_PREFIX[filter.mimeGroup] : undefined;
const where = {
tenantId: input.tenantId,
...(filter.includeArchived ? {} : { archivedAt: null }),
...(filter.assignedToUserId ? { assignedToUserId: filter.assignedToUserId } : {}),
...(filter.tags && filter.tags.length > 0 ? { tags: { hasEvery: [...filter.tags] } } : {}),
...(mimePrefix ? { mimeType: { startsWith: mimePrefix } } : {}),
...visibility,
};
const rows = await withTenantRLS(input.tenantId, (tx) =>
tx.file.findMany({
where,
orderBy: { createdAt: "desc" },
take: limit + 1,
...(input.cursor ? { cursor: { id: input.cursor }, skip: 1 } : {}),
}),
);
const nextCursor = rows.length > limit ? rows[limit - 1].id : null;
return { rows: rows.slice(0, limit) as FileRow[], nextCursor };
}
// --- Mutations ------------------------------------------------------------
export async function linkFile(input: {
tenantId: string;
userId: string;
fileId: string;
entityType: FileEntityType;
entityId: string;
linkRole?: string | null;
}): Promise<void> {
await withTenantRLS(input.tenantId, async (tx) => {
// Ensure the file belongs to the tenant before linking.
const file = await tx.file.findFirst({ where: { id: input.fileId, tenantId: input.tenantId }, select: { id: true } });
if (!file) {
throw new CatalogError(foundationErrors.notFound, { fileId: input.fileId });
}
await tx.fileEntityLink.upsert({
where: { fileId_entityType_entityId: { fileId: input.fileId, entityType: input.entityType, entityId: input.entityId } },
update: { linkRole: input.linkRole ?? null },
create: {
tenantId: input.tenantId,
createdByUserId: input.userId,
fileId: input.fileId,
entityType: input.entityType,
entityId: input.entityId,
linkRole: input.linkRole ?? null,
},
});
});
}
export async function unlinkFile(input: { tenantId: string; fileId: string; entityType: FileEntityType; entityId: string }): Promise<void> {
await withTenantRLS(input.tenantId, (tx) =>
tx.fileEntityLink.deleteMany({
where: { tenantId: input.tenantId, fileId: input.fileId, entityType: input.entityType, entityId: input.entityId },
}),
);
}
export async function archiveFile(input: { tenantId: string; fileId: string }): Promise<void> {
await withTenantRLS(input.tenantId, (tx) =>
tx.file.updateMany({ where: { id: input.fileId, tenantId: input.tenantId }, data: { archivedAt: new Date() } }),
);
}
export async function reactivateFile(input: { tenantId: string; fileId: string }): Promise<void> {
await withTenantRLS(input.tenantId, (tx) =>
tx.file.updateMany({ where: { id: input.fileId, tenantId: input.tenantId }, data: { archivedAt: null } }),
);
}
export async function updateFileMeta(input: {
tenantId: string;
fileId: string;
tags?: string[];
notes?: string | null;
assignedToUserId?: string | null;
displayName?: string;
}): Promise<void> {
await withTenantRLS(input.tenantId, (tx) =>
tx.file.updateMany({
where: { id: input.fileId, tenantId: input.tenantId },
data: {
...(input.tags !== undefined ? { tags: input.tags } : {}),
...(input.notes !== undefined ? { notes: input.notes } : {}),
...(input.assignedToUserId !== undefined ? { assignedToUserId: input.assignedToUserId } : {}),
...(input.displayName !== undefined ? { displayName: input.displayName.slice(0, 255) } : {}),
},
}),
);
}
export async function regenerateFilePreview(input: { tenantId: string; fileId: string }): Promise<void> {
const file = await withTenantRLS(input.tenantId, async (tx) => {
const row = await tx.file.findFirst({
where: { id: input.fileId, tenantId: input.tenantId },
select: { id: true, storageKey: true, mimeType: true, previewStatus: true },
});
if (!row) {
throw new CatalogError(foundationErrors.notFound, { fileId: input.fileId });
}
if (row.previewStatus !== "failed") {
throw new FileServiceError("files.error.previewNotRegenerable");
}
await tx.file.update({ where: { id: row.id }, data: { previewStatus: "pending" } });
return row;
});
const bucket = resolveStorageBucket();
// Enqueue stays OUTSIDE the RLS transaction.
await enqueueFilePreview({ fileId: file.id, tenantId: input.tenantId, bucket, storageKey: file.storageKey, mimeType: file.mimeType }).catch(() => {});
}
export async function hardDeleteFile(input: { tenantId: string; fileId: string }): Promise<void> {
const file = await withTenantRLS(input.tenantId, (tx) =>
tx.file.findFirst({
where: { id: input.fileId, tenantId: input.tenantId },
select: { id: true, storageKey: true, previewStorageKey: true, thumbnailStorageKey: true },
}),
);
if (!file) {
throw new CatalogError(foundationErrors.notFound, { fileId: input.fileId });
}
const bucket = resolveStorageBucket();
// S3 deletes stay OUTSIDE the RLS transaction.
for (const key of [file.storageKey, file.previewStorageKey, file.thumbnailStorageKey]) {
if (key) {
await deleteFile({ bucket, key }).catch(() => {});
}
}
// FileEntityLink rows cascade on File delete (schema onDelete: Cascade).
await withTenantRLS(input.tenantId, (tx) => tx.file.delete({ where: { id: file.id } }));
}
/** Best-effort cleanup of all temp chunk objects of an upload session. */
export async function purgeUploadChunks(tenantId: string, uploadId: string, chunkKeys: readonly string[]): Promise<void> {
const bucket = resolveStorageBucket();
await Promise.all(chunkKeys.map((key) => deleteFile({ bucket, key }).catch(() => {})));
void uploadChunkPrefix(tenantId, uploadId);
}
@@ -0,0 +1,57 @@
// P4 Files (AK-P4a) §6 — web-side presigned download URL builder for the
// auth-proxy media route. Bucket stays private (AD-23); the proxy redirects an
// authorized request to a short-lived signed URL. Reuses the shared SigV4
// signer from @toolctrl/storage (no @aws-sdk/s3-request-presigner dependency).
import { loadRequiredWebConfig } from "@toolctrl/config";
import { createS3SignedUrlAdapter, createSigV4DownloadSigner, StorageAdapterError } from "@toolctrl/storage";
import { resolveStorageBucket } from "./storageService";
export const MEDIA_SIGNED_URL_TTL_SECONDS = 300;
function sanitizeFilename(filename: string): string {
// Strip quotes/control chars so the value is safe inside a Content-Disposition header.
return filename.replace(/["\\\r\n]/g, "").slice(0, 200);
}
/**
* Create a short-lived (default 300s) presigned GET URL for an object in the
* private files bucket. `filename` (optional) sets an inline Content-Disposition
* so the browser shows a sensible name on download.
*/
export async function createMediaDownloadUrl({
key,
filename,
ttlSeconds = MEDIA_SIGNED_URL_TTL_SECONDS,
}: {
key: string;
filename?: string;
ttlSeconds?: number;
}): Promise<string> {
const { storage } = loadRequiredWebConfig();
const bucket = resolveStorageBucket();
const adapter = createS3SignedUrlAdapter({
bucket,
downloadSigner: createSigV4DownloadSigner({
endpoint: storage.endpoint ?? "",
region: storage.region ?? "",
bucket,
accessKeyId: storage.accessKeyId ?? "",
secretAccessKey: storage.secretAccessKey ?? "",
sessionToken: storage.sessionToken,
forcePathStyle: storage.forcePathStyle === undefined ? undefined : storage.forcePathStyle.toLowerCase() === "true",
}),
uploadSigner: () => {
throw new StorageAdapterError("signed_upload_failed", "Signed upload URLs are not supported by the media proxy.", {
operation: "createSignedUploadUrl",
bucket,
});
},
});
const result = await adapter.createSignedDownloadUrl({
key,
expiresInSeconds: ttlSeconds,
...(filename ? { responseContentDisposition: `inline; filename="${sanitizeFilename(filename)}"` } : {}),
});
return result.url;
}
@@ -0,0 +1,25 @@
import type { PreUploadScanResult } from "@toolctrl/storage";
import { createAdminLog } from "~/utils/db/logs.db.server";
/**
* Build an onReject callback for storeFile's scan option that writes an
* "upload.rejected.scan" audit event with the verdict's bounded reason +
* detail + detected MIME. Callers pass the route's incoming Request so the
* audit row gets the right userId / url; never include the raw upload bytes
* in the persisted detail.
*/
export function auditRejectedUpload(request: Request, context: string) {
return async (verdict: PreUploadScanResult) => {
const payload = {
context,
reason: verdict.reason ?? "unknown",
detail: verdict.detail,
detectedMimeType: verdict.detectedMimeType,
};
try {
await createAdminLog(request, "upload.rejected.scan", JSON.stringify(payload));
} catch {
// audit write failures must never block the security signal
}
};
}
@@ -0,0 +1,375 @@
import { loadRequiredWebConfig } from "@toolctrl/config";
import {
createLocalStorageAdapter,
createS3StorageAdapter,
createMagicBytesScanAdapter,
resolveAllowList,
StorageAdapterError,
type PreUploadScanAdapter,
type PreUploadScanResult,
type ScanContext,
type StorageAdapter,
} from "@toolctrl/storage";
import { fetchOutboundHttpWithRedirects } from "~/utils/outboundHttp.server";
type StorageConfig = ReturnType<typeof loadRequiredWebConfig>["storage"];
type RuntimeProvider = "s3" | "local";
// Upload "content" can be caller-supplied. Only inline data: URIs are fetched
// directly; any remote URL is routed through the SSRF-safe helper, which
// rejects private/loopback/link-local/metadata addresses and validates the
// scheme and every redirect hop. A bare fetch() here would let an authenticated
// user reach cloud metadata or internal services (e.g. via "HTTP://169.254..."
// which the prior case-sensitive "http" check missed).
async function fetchUploadContent(content: string): Promise<Blob> {
const trimmed = (content ?? "").trim();
if (trimmed.toLowerCase().startsWith("data:")) {
return await (await fetch(trimmed)).blob();
}
const response = await fetchOutboundHttpWithRedirects(trimmed, {});
return await response.blob();
}
export class StorageScanRejectedError extends Error {
readonly bucket: string;
readonly id: string;
readonly filename: string;
readonly scanResult: PreUploadScanResult;
constructor(bucket: string, id: string, filename: string, scanResult: PreUploadScanResult) {
super(`Upload rejected by pre-upload scanner: ${scanResult.reason ?? "unknown"} (${scanResult.detail ?? ""})`);
this.name = "StorageScanRejectedError";
this.bucket = bucket;
this.id = id;
this.filename = filename;
this.scanResult = scanResult;
}
}
export type StoreFileInput = {
bucket: string;
content: string;
id: string;
replace?: boolean;
throwOnFailure?: boolean;
/**
* When set, the upload buffer is run through MagicBytesScanAdapter before
* the storage PUT. A rejected scan throws StorageScanRejectedError (always,
* regardless of throwOnFailure) so callers cannot accidentally swallow a
* security verdict. When unset the scan is skipped — that's only safe for
* server-internal uploads where the bytes never came from a user.
*/
scan?: {
context: ScanContext;
declaredMimeType?: string;
filename?: string;
/**
* Side-effect callback fired exactly once when the scanner returns a
* rejected verdict, BEFORE the StorageScanRejectedError is thrown. The
* callback may write an audit event or telemetry; storeFile awaits it so
* the audit record is persisted before the route gets a chance to
* swallow the error.
*/
onReject?: (verdict: PreUploadScanResult) => void | Promise<void>;
/**
* When true AND the layer-1 scan passes AND the storage PUT succeeds,
* storeFile enqueues a toolctrl.file-scan job so the worker runs ClamAV
* (layer 2) over the stored object. Requires SCAN_QUEUE_ENABLED=true
* on the runtime; otherwise the enqueue is a no-op. Failures to enqueue
* are logged but never throw — the synchronous user-visible upload
* should not block on a queue-write hiccup.
*/
enqueuePostScan?: boolean;
/**
* Optional tenant attribution for the post-upload scan job (carried
* through to the worker's audit row).
*/
tenantId?: string;
};
};
const defaultScanAdapter: PreUploadScanAdapter = createMagicBytesScanAdapter({
allowList: resolveAllowList(typeof process !== "undefined" ? (process.env as Record<string, string | undefined>) : {}),
enabled: typeof process !== "undefined" ? process.env.STORAGE_SCAN_LAYER1_ENABLED !== "false" : true,
});
export async function storeFile({ bucket, content, id, replace, throwOnFailure = false, scan }: StoreFileInput): Promise<string> {
if (content.trim().toLowerCase().startsWith("http") && !replace) {
if (throwOnFailure) {
throw new Error("Strict uploads require upload content, not an existing URL.");
}
return content;
}
const config = loadRequiredWebConfig();
const primaryProvider = resolvePrimaryStorageProvider(config.storage);
if (!primaryProvider) {
if (throwOnFailure) {
throw new StorageAdapterError("config_missing", "No storage provider configured.", {
operation: "storeFile",
bucket,
});
}
return content;
}
if (scan) {
const blob = await fetchUploadContent(content);
const body = new Uint8Array(await blob.arrayBuffer());
const declared = scan.declaredMimeType ?? blob.type ?? "application/octet-stream";
const filename = scan.filename ?? id;
const verdict = await defaultScanAdapter.scan({
buffer: body,
declaredMimeType: declared,
filename,
context: scan.context,
});
if (verdict.status === "rejected") {
if (scan.onReject) {
try {
await scan.onReject(verdict);
} catch {
// never let an audit-write failure mask the security verdict
}
}
throw new StorageScanRejectedError(bucket, id, filename, verdict);
}
let stored: string;
try {
stored = await putBufferViaProvider(primaryProvider, bucket, id, body, blob.type || undefined, config.storage);
} catch (error) {
const fallbackProvider = resolveFallbackProvider(config.storage, primaryProvider);
if (fallbackProvider) {
try {
stored = await putBufferViaProvider(fallbackProvider, bucket, id, body, blob.type || undefined, config.storage);
} catch (fallbackError) {
if (throwOnFailure) throw fallbackError;
return content;
}
} else {
if (throwOnFailure) throw error;
return content;
}
}
if (scan.enqueuePostScan) {
try {
const { enqueueFileScan } = await import("./fileScanQueue.server.js");
await enqueueFileScan({
bucket,
key: id,
tenantId: scan.tenantId,
contentType: blob.type || scan.declaredMimeType,
});
} catch {
// never block a successful upload on a queue write
}
}
return stored;
}
try {
return await putFileViaProvider(primaryProvider, bucket, id, content, config.storage);
} catch (error) {
const fallbackProvider = resolveFallbackProvider(config.storage, primaryProvider);
if (fallbackProvider) {
try {
return await putFileViaProvider(fallbackProvider, bucket, id, content, config.storage);
} catch (fallbackError) {
if (throwOnFailure) {
throw fallbackError;
}
return content;
}
}
if (throwOnFailure) {
throw error;
}
return content;
}
}
export async function putBufferAsFile({
bucket,
key,
body,
contentType,
}: {
bucket: string;
key: string;
body: Uint8Array;
contentType?: string;
}): Promise<{ publicUrl: string | null }> {
const config = loadRequiredWebConfig();
const primaryProvider = resolvePrimaryStorageProvider(config.storage);
if (!primaryProvider) {
throw new StorageAdapterError("config_missing", "No storage provider configured.", {
operation: "putBufferAsFile",
bucket,
});
}
const adapter = createRuntimeStorageAdapter(primaryProvider, bucket, config.storage);
const result = await adapter.putObject({ key, body, contentType });
return { publicUrl: result.publicUrl ?? null };
}
export async function deleteFile({ bucket, key }: { bucket: string; key: string }): Promise<void> {
const config = loadRequiredWebConfig();
const primaryProvider = resolvePrimaryStorageProvider(config.storage);
if (!primaryProvider) {
return;
}
const adapter = createRuntimeStorageAdapter(primaryProvider, bucket, config.storage);
await adapter.deleteObject({ key });
}
export function hasStorageCredentials(): boolean {
const config = loadRequiredWebConfig();
return resolvePrimaryStorageProvider(config.storage) !== undefined;
}
/**
* Default files bucket (STORAGE_BUCKET). The Files module (AK-P4a) stores all
* objects in this single private bucket; tenant isolation is carried by the
* `tenants/{tenantId}/...` key prefix (§2) plus app-level scoping/RLS.
*/
export function resolveStorageBucket(): string {
const config = loadRequiredWebConfig();
const bucket = config.storage.bucket;
if (!bucket || bucket.trim().length === 0) {
throw new StorageAdapterError("config_missing", "STORAGE_BUCKET is not configured.", {
operation: "resolveStorageBucket",
missingFields: ["bucket"],
});
}
return bucket;
}
/**
* Assemble previously-stored temp chunk objects into a single object via
* multipart UploadPartCopy (§3). `chunkSourceKeys` MUST be ordered by chunk
* index. Aborts the multipart upload on any failure so no dangling parts remain.
*/
export async function assembleChunkedUpload({
bucket,
targetKey,
chunkSourceKeys,
contentType,
}: {
bucket: string;
targetKey: string;
chunkSourceKeys: readonly string[];
contentType?: string;
}): Promise<{ publicUrl: string | null }> {
const config = loadRequiredWebConfig();
const provider = resolvePrimaryStorageProvider(config.storage);
if (!provider) {
throw new StorageAdapterError("config_missing", "No storage provider configured.", {
operation: "assembleChunkedUpload",
bucket,
});
}
const adapter = createRuntimeStorageAdapter(provider, bucket, config.storage);
const handle = await adapter.createMultipartUpload({ key: targetKey, contentType });
try {
const parts = [];
for (let index = 0; index < chunkSourceKeys.length; index += 1) {
parts.push(
await adapter.uploadPartCopy({
key: targetKey,
uploadId: handle.uploadId,
partNumber: index + 1,
sourceKey: chunkSourceKeys[index],
}),
);
}
const result = await adapter.completeMultipartUpload({ key: targetKey, uploadId: handle.uploadId, parts });
return { publicUrl: result.publicUrl ?? null };
} catch (error) {
await adapter.abortMultipartUpload({ key: targetKey, uploadId: handle.uploadId }).catch(() => {});
throw error;
}
}
async function putFileViaProvider(provider: RuntimeProvider, bucket: string, id: string, content: string, storage: StorageConfig): Promise<string> {
const blob = await fetchUploadContent(content);
const body = new Uint8Array(await blob.arrayBuffer());
const contentType = blob.type || undefined;
return putBufferViaProvider(provider, bucket, id, body, contentType, storage);
}
async function putBufferViaProvider(
provider: RuntimeProvider,
bucket: string,
id: string,
body: Uint8Array,
contentType: string | undefined,
storage: StorageConfig
): Promise<string> {
const adapter = createRuntimeStorageAdapter(provider, bucket, storage);
const result = await adapter.putObject({ key: id, body, contentType });
return result.publicUrl ?? "";
}
function resolvePrimaryStorageProvider(storage: StorageConfig): RuntimeProvider | undefined {
if (storage.provider === "s3" || storage.provider === "local") {
return storage.provider;
}
if (hasCompleteS3StorageConfig(storage)) {
return "s3";
}
if (storage.localRoot) {
return "local";
}
return undefined;
}
function resolveFallbackProvider(storage: StorageConfig, primary: RuntimeProvider): RuntimeProvider | undefined {
const fallback = storage.fallbackProvider;
if ((fallback === "s3" || fallback === "local") && fallback !== primary) {
return fallback;
}
return undefined;
}
function createRuntimeStorageAdapter(provider: RuntimeProvider, bucket: string, storage: StorageConfig): StorageAdapter {
if (provider === "s3") {
if (!hasCompleteS3StorageConfig(storage)) {
throw new StorageAdapterError("config_missing", "S3 storage configuration is incomplete.", {
operation: "createRuntimeStorageAdapter",
bucket,
missingFields: ["endpoint", "region", "accessKeyId", "secretAccessKey"].filter((field) => !storage[field as keyof typeof storage]),
});
}
return createS3StorageAdapter({
endpoint: storage.endpoint,
region: storage.region,
bucket,
accessKeyId: storage.accessKeyId,
secretAccessKey: storage.secretAccessKey,
sessionToken: storage.sessionToken,
publicBaseUrl: storage.publicBaseUrl,
forcePathStyle: storage.forcePathStyle === undefined ? undefined : storage.forcePathStyle.toLowerCase() === "true",
});
}
if (!storage.localRoot) {
throw new StorageAdapterError("config_missing", "Local storage root is not configured.", {
operation: "createRuntimeStorageAdapter",
bucket,
missingFields: ["localRoot"],
});
}
return createLocalStorageAdapter({
rootDir: storage.localRoot,
bucket,
publicBaseUrl: storage.localPublicBaseUrl,
});
}
function hasCompleteS3StorageConfig(storage: StorageConfig): storage is StorageConfig & {
endpoint: string;
region: string;
accessKeyId: string;
secretAccessKey: string;
} {
return Boolean(storage.endpoint && storage.region && storage.accessKeyId && storage.secretAccessKey);
}
@@ -0,0 +1,85 @@
// 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<string, string | undefined>;
requireModule?: boolean;
}): Promise<TasksRouteContext> {
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;
}
@@ -0,0 +1,87 @@
// P4 Tasks (AK-P4b) §12 — audit + activity wiring for the Tasks module.
//
// Audit (DoD 13): every write action records a tamper-evident AuditEvent (M001)
// via createAuditEvent with the shared db client.
//
// Activity (DoD 14): user-facing timeline entries are logged against the most
// relevant entity. When the task is linked to a CRM entity that maps to an
// Activity subject (contact/company/lead/deal/project) the entry hangs off that
// entity's timeline with the task referenced; otherwise it hangs off the task's
// own timeline (`task` is itself a valid Activity subject).
import type { JsonValue } from "@toolctrl/foundation";
import { createAuditEvent } from "@toolctrl/db/audit-events.server";
import { logActivity, type ActivitySubjectType, type ActivityType } from "@hivegrid/activity";
import type { TasksRouteContext } from "./taskAccess.server";
import type { TaskEntityType } from "./taskService.server";
export type TaskAuditAction =
| "task.created"
| "task.updated"
| "task.reassigned"
| "task.completed"
| "task.archived"
| "task.reactivated"
| "task.deleted"
| "task.watcher_added"
| "task.watcher_removed";
export async function auditTaskEvent(input: {
ctx: Pick<TasksRouteContext, "tenantId" | "userId">;
action: TaskAuditAction;
taskId: string;
metadata?: Record<string, JsonValue>;
}): Promise<void> {
try {
await createAuditEvent({
tenantId: input.ctx.tenantId,
actorId: input.ctx.userId,
actorType: "user",
action: input.action,
targetType: "task",
targetId: input.taskId,
metadata: input.metadata ?? null,
});
} catch {
// Audit must never break the user action; the failure is logged upstream.
}
}
const ENTITY_TO_ACTIVITY_SUBJECT: Partial<Record<TaskEntityType, ActivitySubjectType>> = {
contact: "contact",
company: "company",
lead: "lead",
deal: "deal",
project: "project",
};
/**
* Log a task activity. When the task links to a CRM entity with an Activity
* subject, the entry hangs off that entity (with the task as a related ref);
* otherwise it hangs off the task's own timeline. Best-effort.
*/
export async function logTaskActivity(input: {
ctx: Pick<TasksRouteContext, "tenantId" | "userId">;
taskId: string;
activityType: Extract<ActivityType, "task_created" | "task_completed">;
summaryText: string;
linkedEntityType?: TaskEntityType | null;
linkedEntityId?: string | null;
}): Promise<void> {
const linkedSubject = input.linkedEntityType ? ENTITY_TO_ACTIVITY_SUBJECT[input.linkedEntityType] : undefined;
const useLinked = Boolean(linkedSubject && input.linkedEntityId);
try {
await logActivity({
tenantId: input.ctx.tenantId,
actorUserId: input.ctx.userId,
actorKind: "user",
activityType: input.activityType,
subjectType: useLinked ? (linkedSubject as ActivitySubjectType) : "task",
subjectId: useLinked ? (input.linkedEntityId as string) : input.taskId,
summaryText: input.summaryText,
relatedEntityRefs: useLinked ? [{ type: "task", id: input.taskId }] : undefined,
});
} catch {
// Activity logging is best-effort; never break the user action.
}
}
@@ -0,0 +1,19 @@
// P4 Tasks (AK-P4b) — pure route-path builders for Tasks URLs. The loader builds
// these and passes them into the View-Models so the VMs stay routing-agnostic.
export function tasksListPath(tenantSlug: string): string {
return `/${tenantSlug}/tasks`;
}
export function tasksDetailPath(tenantSlug: string, taskId: string): string {
return `/${tenantSlug}/tasks/${taskId}`;
}
export function tasksNewPath(tenantSlug: string, prefill?: { entityType: string; entityId: string }): string {
const base = `/${tenantSlug}/tasks/new`;
if (!prefill) {
return base;
}
const query = new URLSearchParams({ entityType: prefill.entityType, entityId: prefill.entityId });
return `${base}?${query.toString()}`;
}
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { resolveAssignment, TaskServiceError } from "./taskService.server";
/**
* Single-assignee rule (AK-P4b DoD 2): a task is assigned to exactly one of a
* user or a group; default = creator. Pure logic, no DB.
*/
describe("resolveAssignment", () => {
it("defaults to the creator when neither user nor group is given", () => {
expect(resolveAssignment({ creatorUserId: "u-creator" })).toEqual({
assignedToUserId: "u-creator",
assignedToGroupId: null,
});
});
it("keeps a single user assignment", () => {
expect(resolveAssignment({ creatorUserId: "u-creator", assignedToUserId: "u-1" })).toEqual({
assignedToUserId: "u-1",
assignedToGroupId: null,
});
});
it("keeps a single group assignment", () => {
expect(resolveAssignment({ creatorUserId: "u-creator", assignedToGroupId: "g-1" })).toEqual({
assignedToUserId: null,
assignedToGroupId: "g-1",
});
});
it("rejects assigning to both a user and a group", () => {
expect(() => resolveAssignment({ creatorUserId: "u-creator", assignedToUserId: "u-1", assignedToGroupId: "g-1" })).toThrow(TaskServiceError);
});
});
@@ -0,0 +1,505 @@
// P4 Tasks (AK-P4b) §5 — domain service for the Tasks module.
//
// Pure-ish server logic: builds/queries Task rows (always tenant-scoped),
// enforces the single-assignee rule (DoD 2), enforces visibility (404, not 403),
// and manages watchers. NO React/UI imports. Routes call these from their
// loaders/actions and build the View-Model from the returned rows.
//
// Tenant isolation is enforced twofold: every query carries `where: { tenantId }`
// (app-level scoping) plus the RLS policies from the merge migration (once
// R-P2b's `SET LOCAL app.tenant_id` is live). Task<->CRM entity linking is wired
// in P6; here a task carries its inline primary association
// (`linkedEntityType`/`linkedEntityId`) only.
//
// RLS-ready (AK-P4b "Teil A", mirrors fileService): every tenant-scoped access
// runs inside withTenantRLS() so the tenant GUC is set before the query. Internal
// composition goes through *WithTx(tx, ...) helpers that reuse the active
// transaction client — functions never nest withTenantRLS(). This is a functional
// no-op until the Task/TaskWatcher RLS policies are activated
// (prisma/rls/task-isolation.sql, P5 cutover). The only access NOT wrapped is the
// global User lookup (resolveUserLabels): User is not tenant-scoped (a user can
// belong to several tenants), so it stays on the db singleton.
import { CatalogError, foundationErrors } from "@toolctrl/foundation";
import { db } from "~/utils/db.server";
import { withTenantRLS, type TenantRlsClient } from "~/utils/db/.server/tenantRls.server";
/**
* Domain error carrying an i18n message key (M002 Klartext). Routes map
* `messageKey` to a catalog error / VM error branch — never a bare HTTP code.
*/
export class TaskServiceError extends Error {
readonly messageKey: string;
constructor(messageKey: string) {
super(messageKey);
this.name = "TaskServiceError";
this.messageKey = messageKey;
}
}
export type TaskStatus = "open" | "in_progress" | "blocked" | "done" | "cancelled";
export type TaskPriority = "low" | "normal" | "high" | "urgent";
export type TaskSource = "manual" | "chat" | "form_submission" | "automation" | "api";
export type TaskEntityType = "contact" | "company" | "lead" | "deal" | "project" | "user" | "order" | "appointment";
const TERMINAL_STATUSES: ReadonlySet<TaskStatus> = new Set(["done", "cancelled"]);
const MAX_TITLE_LENGTH = 255;
export type TaskVisibilityContext = {
userId: string;
/** The user's permission names for this tenant (from getPermissionsByUser). */
permissions: readonly string[];
};
function hasPermission(ctx: TaskVisibilityContext, permission: string): boolean {
return ctx.permissions.includes(permission);
}
/** The group ids the user belongs to in this tenant (for group-assignment
* visibility), on the active RLS transaction client. GroupUser has no tenantId of
* its own; scope via the group's tenant. */
async function userGroupIdsWithTx(tx: TenantRlsClient, tenantId: string, userId: string): Promise<string[]> {
const memberships = await tx.groupUser.findMany({
where: { userId, group: { tenantId } },
select: { groupId: true },
});
return memberships.map((membership) => membership.groupId);
}
export type TaskRow = {
id: string;
tenantId: string;
createdByUserId: string;
title: string;
description: string | null;
status: TaskStatus;
priority: TaskPriority;
deadline: Date | null;
assignedToUserId: string | null;
assignedToGroupId: string | null;
linkedEntityType: TaskEntityType | null;
linkedEntityId: string | null;
source: TaskSource;
sourceRef: string | null;
completedAt: Date | null;
isDemoData: boolean;
createdAt: Date;
updatedAt: Date;
archivedAt: Date | null;
};
// --- Assignment rule (DoD 2) ----------------------------------------------
/**
* Resolve the single-assignee rule (DoD 2): exactly one of user/group. If both
* are given → error; if neither → default to the creator (user). Returns the
* pair to persist. Exported for unit testing (pure, no DB).
*/
export function resolveAssignment(input: { creatorUserId: string; assignedToUserId?: string | null; assignedToGroupId?: string | null }): {
assignedToUserId: string | null;
assignedToGroupId: string | null;
} {
const hasUser = Boolean(input.assignedToUserId);
const hasGroup = Boolean(input.assignedToGroupId);
if (hasUser && hasGroup) {
throw new TaskServiceError("tasks.error.assigneeConflict");
}
if (!hasUser && !hasGroup) {
return { assignedToUserId: input.creatorUserId, assignedToGroupId: null };
}
return {
assignedToUserId: hasUser ? (input.assignedToUserId ?? null) : null,
assignedToGroupId: hasGroup ? (input.assignedToGroupId ?? null) : null,
};
}
// --- Mutations ------------------------------------------------------------
export type CreateTaskInput = {
tenantId: string;
userId: string;
title: string;
description?: string | null;
priority?: TaskPriority;
deadline?: Date | null;
assignedToUserId?: string | null;
assignedToGroupId?: string | null;
linkedEntityType?: TaskEntityType | null;
linkedEntityId?: string | null;
source?: TaskSource;
sourceRef?: string | null;
isDemoData?: boolean;
};
export async function createTask(input: CreateTaskInput): Promise<TaskRow> {
const title = input.title.trim();
if (!title) {
throw new TaskServiceError("tasks.error.titleRequired");
}
const assignment = resolveAssignment({
creatorUserId: input.userId,
assignedToUserId: input.assignedToUserId,
assignedToGroupId: input.assignedToGroupId,
});
const linkedEntityType = input.linkedEntityType ?? null;
const linkedEntityId = input.linkedEntityId ?? null;
if ((linkedEntityType && !linkedEntityId) || (!linkedEntityType && linkedEntityId)) {
throw new TaskServiceError("tasks.error.invalidEntityLink");
}
const task = await withTenantRLS(input.tenantId, (tx) =>
tx.task.create({
data: {
tenantId: input.tenantId,
createdByUserId: input.userId,
title: title.slice(0, MAX_TITLE_LENGTH),
description: input.description ?? null,
priority: input.priority ?? "normal",
deadline: input.deadline ?? null,
assignedToUserId: assignment.assignedToUserId,
assignedToGroupId: assignment.assignedToGroupId,
linkedEntityType,
linkedEntityId,
source: input.source ?? "manual",
sourceRef: input.sourceRef ?? null,
isDemoData: input.isDemoData ?? false,
},
}),
);
return task as TaskRow;
}
export type UpdateTaskInput = {
tenantId: string;
taskId: string;
title?: string;
description?: string | null;
status?: TaskStatus;
priority?: TaskPriority;
deadline?: Date | null;
linkedEntityType?: TaskEntityType | null;
linkedEntityId?: string | null;
};
export async function updateTask(input: UpdateTaskInput): Promise<void> {
if (input.title !== undefined && !input.title.trim()) {
throw new TaskServiceError("tasks.error.titleRequired");
}
// Keep completedAt in sync with status transitions to/from a terminal state.
let completedAt: Date | null | undefined;
if (input.status !== undefined) {
completedAt = TERMINAL_STATUSES.has(input.status) ? new Date() : null;
}
await withTenantRLS(input.tenantId, async (tx) => {
const existing = await tx.task.findFirst({
where: { id: input.taskId, tenantId: input.tenantId },
select: { id: true, status: true },
});
if (!existing) {
throw new CatalogError(foundationErrors.notFound, { taskId: input.taskId });
}
await tx.task.updateMany({
where: { id: input.taskId, tenantId: input.tenantId },
data: {
...(input.title !== undefined ? { title: input.title.trim().slice(0, MAX_TITLE_LENGTH) } : {}),
...(input.description !== undefined ? { description: input.description } : {}),
...(input.status !== undefined ? { status: input.status } : {}),
...(input.priority !== undefined ? { priority: input.priority } : {}),
...(input.deadline !== undefined ? { deadline: input.deadline } : {}),
...(input.linkedEntityType !== undefined ? { linkedEntityType: input.linkedEntityType } : {}),
...(input.linkedEntityId !== undefined ? { linkedEntityId: input.linkedEntityId } : {}),
...(completedAt !== undefined ? { completedAt } : {}),
},
});
});
}
/** Reassign a task to a single user OR group (exactly one), else error. */
export async function reassignTask(input: {
tenantId: string;
taskId: string;
assignedToUserId?: string | null;
assignedToGroupId?: string | null;
}): Promise<void> {
await withTenantRLS(input.tenantId, async (tx) => {
const existing = await tx.task.findFirst({
where: { id: input.taskId, tenantId: input.tenantId },
select: { createdByUserId: true },
});
if (!existing) {
throw new CatalogError(foundationErrors.notFound, { taskId: input.taskId });
}
const assignment = resolveAssignment({
creatorUserId: existing.createdByUserId,
assignedToUserId: input.assignedToUserId,
assignedToGroupId: input.assignedToGroupId,
});
await tx.task.updateMany({
where: { id: input.taskId, tenantId: input.tenantId },
data: { assignedToUserId: assignment.assignedToUserId, assignedToGroupId: assignment.assignedToGroupId },
});
});
}
export async function completeTask(input: { tenantId: string; taskId: string }): Promise<void> {
await withTenantRLS(input.tenantId, (tx) =>
tx.task.updateMany({
where: { id: input.taskId, tenantId: input.tenantId },
data: { status: "done", completedAt: new Date() },
}),
);
}
export async function archiveTask(input: { tenantId: string; taskId: string }): Promise<void> {
await withTenantRLS(input.tenantId, (tx) => tx.task.updateMany({ where: { id: input.taskId, tenantId: input.tenantId }, data: { archivedAt: new Date() } }));
}
export async function reactivateTask(input: { tenantId: string; taskId: string }): Promise<void> {
await withTenantRLS(input.tenantId, (tx) => tx.task.updateMany({ where: { id: input.taskId, tenantId: input.tenantId }, data: { archivedAt: null } }));
}
export async function hardDeleteTask(input: { tenantId: string; taskId: string }): Promise<void> {
await withTenantRLS(input.tenantId, async (tx) => {
const task = await tx.task.findFirst({ where: { id: input.taskId, tenantId: input.tenantId }, select: { id: true } });
if (!task) {
throw new CatalogError(foundationErrors.notFound, { taskId: input.taskId });
}
// TaskWatcher rows cascade on Task delete (schema onDelete: Cascade).
await tx.task.delete({ where: { id: task.id } });
});
}
// --- Watchers -------------------------------------------------------------
export async function addWatcher(input: { tenantId: string; taskId: string; userId: string }): Promise<void> {
await withTenantRLS(input.tenantId, async (tx) => {
const task = await tx.task.findFirst({ where: { id: input.taskId, tenantId: input.tenantId }, select: { id: true } });
if (!task) {
throw new CatalogError(foundationErrors.notFound, { taskId: input.taskId });
}
await tx.taskWatcher.upsert({
where: { taskId_userId: { taskId: input.taskId, userId: input.userId } },
update: {},
create: { tenantId: input.tenantId, taskId: input.taskId, userId: input.userId },
});
});
}
export async function removeWatcher(input: { tenantId: string; taskId: string; userId: string }): Promise<void> {
await withTenantRLS(input.tenantId, (tx) => tx.taskWatcher.deleteMany({ where: { tenantId: input.tenantId, taskId: input.taskId, userId: input.userId } }));
}
// --- Visibility / read ----------------------------------------------------
/**
* Owner/assignee/watcher/group visibility for tasks.read_own (§7). Visible when
* the user created or is assigned the task, is a watcher, is a member of the
* assigned group, OR holds tasks.read_all.
*/
// Internal: visibility check on an already-loaded task, using the active RLS
// transaction client (no own transaction → safe to call inside withTenantRLS).
async function isTaskVisibleWithTx(
tx: TenantRlsClient,
task: Pick<TaskRow, "id" | "tenantId" | "createdByUserId" | "assignedToUserId" | "assignedToGroupId">,
ctx: TaskVisibilityContext
): Promise<boolean> {
if (hasPermission(ctx, "tasks.read_all")) {
return true;
}
if (task.createdByUserId === ctx.userId || task.assignedToUserId === ctx.userId) {
return true;
}
const watcher = await tx.taskWatcher.findFirst({
where: { taskId: task.id, tenantId: task.tenantId, userId: ctx.userId },
select: { id: true },
});
if (watcher) {
return true;
}
if (task.assignedToGroupId) {
const membership = await tx.groupUser.findFirst({
where: { groupId: task.assignedToGroupId, userId: ctx.userId },
select: { id: true },
});
if (membership) {
return true;
}
}
return false;
}
export async function isTaskVisible(
task: Pick<TaskRow, "id" | "tenantId" | "createdByUserId" | "assignedToUserId" | "assignedToGroupId">,
ctx: TaskVisibilityContext
): Promise<boolean> {
// Fast paths need no DB; only the watcher/group lookups open a tenant-scoped
// transaction.
if (hasPermission(ctx, "tasks.read_all") || task.createdByUserId === ctx.userId || task.assignedToUserId === ctx.userId) {
return true;
}
return withTenantRLS(task.tenantId, (tx) => isTaskVisibleWithTx(tx, task, ctx));
}
export type TaskWatcherRow = { userId: string };
// Internal: load + visibility on the active transaction client (no own tx).
async function getVisibleTaskOrThrowWithTx(tx: TenantRlsClient, tenantId: string, taskId: string, ctx: TaskVisibilityContext): Promise<TaskRow> {
const task = await tx.task.findFirst({ where: { id: taskId, tenantId } });
if (!task) {
throw new CatalogError(foundationErrors.notFound, { taskId });
}
if (!(await isTaskVisibleWithTx(tx, task as TaskRow, ctx))) {
throw new CatalogError(foundationErrors.notFound, { taskId });
}
return task as TaskRow;
}
/** Load a single task (tenant-scoped) and enforce visibility, else 404. */
export async function getVisibleTaskOrThrow(tenantId: string, taskId: string, ctx: TaskVisibilityContext): Promise<TaskRow> {
return withTenantRLS(tenantId, (tx) => getVisibleTaskOrThrowWithTx(tx, tenantId, taskId, ctx));
}
/** Load a task with its watchers (tenant-scoped + visibility), else 404. */
export async function getVisibleTaskWithWatchers(
tenantId: string,
taskId: string,
ctx: TaskVisibilityContext
): Promise<{ task: TaskRow; watchers: TaskWatcherRow[] }> {
return withTenantRLS(tenantId, async (tx) => {
const task = await getVisibleTaskOrThrowWithTx(tx, tenantId, taskId, ctx);
const watchers = await tx.taskWatcher.findMany({
where: { taskId, tenantId },
select: { userId: true },
orderBy: { createdAt: "asc" },
});
return { task, watchers };
});
}
export type TaskListPreset = "mine" | "created" | "all";
export type ListTasksFilter = {
preset?: TaskListPreset;
status?: TaskStatus | null;
priority?: TaskPriority | null;
assignedToUserId?: string | null;
linkedEntityType?: TaskEntityType | null;
linkedEntityId?: string | null;
source?: TaskSource | null;
includeArchived?: boolean;
};
/**
* List tasks for the browser (tenant-scoped + read_own visibility), newest
* first, cursor-paginated. Presets: `mine` (assigned to me / my groups),
* `created` (created by me), `all` (everything visible). Non-read_all users only
* ever see tasks they own, are assigned, watch, or whose assigned group they are
* a member of.
*/
export async function listTasksForBrowser(input: {
tenantId: string;
ctx: TaskVisibilityContext;
filter?: ListTasksFilter;
cursor?: string | null;
limit?: number;
}): Promise<{ rows: TaskRow[]; nextCursor: string | null }> {
const limit = Math.min(Math.max(input.limit ?? 50, 1), 100);
const filter = input.filter ?? {};
const preset = filter.preset ?? "all";
return withTenantRLS(input.tenantId, async (tx) => {
const groupIds = await userGroupIdsWithTx(tx, input.tenantId, input.ctx.userId);
const visibleToUser = [
{ createdByUserId: input.ctx.userId },
{ assignedToUserId: input.ctx.userId },
{ watchers: { some: { tenantId: input.tenantId, userId: input.ctx.userId } } },
...(groupIds.length > 0 ? [{ assignedToGroupId: { in: groupIds } }] : []),
];
// Base visibility scope: read_all sees all in-tenant; otherwise restricted.
const visibilityScope = hasPermission(input.ctx, "tasks.read_all") ? {} : { OR: visibleToUser };
// Preset narrows further within the visible scope.
let presetScope: Record<string, unknown> = {};
if (preset === "created") {
presetScope = { createdByUserId: input.ctx.userId };
} else if (preset === "mine") {
presetScope = {
OR: [{ assignedToUserId: input.ctx.userId }, ...(groupIds.length > 0 ? [{ assignedToGroupId: { in: groupIds } }] : [])],
};
}
const where = {
tenantId: input.tenantId,
...(filter.includeArchived ? {} : { archivedAt: null }),
...(filter.status ? { status: filter.status } : {}),
...(filter.priority ? { priority: filter.priority } : {}),
...(filter.assignedToUserId ? { assignedToUserId: filter.assignedToUserId } : {}),
...(filter.linkedEntityType && filter.linkedEntityId ? { linkedEntityType: filter.linkedEntityType, linkedEntityId: filter.linkedEntityId } : {}),
...(filter.source ? { source: filter.source } : {}),
AND: [visibilityScope, presetScope],
};
const rows = await tx.task.findMany({
where,
orderBy: { createdAt: "desc" },
take: limit + 1,
...(input.cursor ? { cursor: { id: input.cursor }, skip: 1 } : {}),
});
const nextCursor = rows.length > limit ? rows[limit - 1].id : null;
return { rows: rows.slice(0, limit) as TaskRow[], nextCursor };
});
}
// --- Directory helpers (assignee/watcher display names + picker options) ---
export type LabeledOption = { id: string; label: string };
function userLabel(user: { firstName: string; lastName: string; email: string }): string {
const name = `${user.firstName} ${user.lastName}`.trim();
return name || user.email;
}
/** Resolve display labels for a set of user ids (global user table). */
export async function resolveUserLabels(userIds: readonly string[]): Promise<Map<string, string>> {
const ids = [...new Set(userIds)].filter(Boolean);
if (ids.length === 0) {
return new Map();
}
const users = await db.user.findMany({
where: { id: { in: ids } },
select: { id: true, firstName: true, lastName: true, email: true },
});
return new Map(users.map((user) => [user.id, userLabel(user)]));
}
/** Resolve display labels for a set of group ids (tenant-scoped). */
export async function resolveGroupLabels(tenantId: string, groupIds: readonly string[]): Promise<Map<string, string>> {
const ids = [...new Set(groupIds)].filter(Boolean);
if (ids.length === 0) {
return new Map();
}
const groups = await withTenantRLS(tenantId, (tx) =>
tx.group.findMany({
where: { id: { in: ids }, tenantId },
select: { id: true, name: true },
}),
);
return new Map(groups.map((group) => [group.id, group.name]));
}
/** Tenant members as assignee picker options (id + display label). */
export async function listAssignableUsers(tenantId: string): Promise<LabeledOption[]> {
const members = await withTenantRLS(tenantId, (tx) =>
tx.tenantUser.findMany({
where: { tenantId },
select: { user: { select: { id: true, firstName: true, lastName: true, email: true } } },
}),
);
return members.filter((member) => member.user).map((member) => ({ id: member.user.id, label: userLabel(member.user) }));
}
/** Tenant groups as assignee picker options (id + display label). */
export async function listAssignableGroups(tenantId: string): Promise<LabeledOption[]> {
const groups = await withTenantRLS(tenantId, (tx) => tx.group.findMany({ where: { tenantId }, select: { id: true, name: true } }));
return groups.map((group) => ({ id: group.id, label: group.name }));
}