Track HiveGrid Cloud planning reference
This commit is contained in:
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 }));
|
||||
}
|
||||
Reference in new issue
Block a user