Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/integrations/taskService.server.ts
T

734 lines
30 KiB
TypeScript

// 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, notify } from "@hivegrid/foundation";
import { tasksIndex } from "@hivegrid/search";
import type { DbTransactionClient } from "@hivegrid/db/transactions.server";
import { TenantUserStatus } from "~/application/enums/tenants/TenantUserStatus";
import { publishSearchProjectionDelete, publishTaskSearchUpsert } from "~/services/search/searchIndexProjection.server";
import { db } from "~/utils/db.server";
import { withTenantRLS, type TenantRlsClient } from "~/utils/db/.server/tenantRls.server";
import { assertFileLinkTargetVisibleWithTx } from "./fileVisibility.server";
import { appPath } from "~/utils/app/CanonicalUrlUtils";
import type { ProjectVisibilityContext } from "./projects/projectService.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 = ProjectVisibilityContext;
function hasPermission(ctx: TaskVisibilityContext, permission: string): boolean {
return ctx.permissions.includes(permission);
}
function requireTaskPermission(ctx: TaskVisibilityContext, permission: "tasks.write" | "tasks.delete"): void {
if (!hasPermission(ctx, permission)) {
throw new CatalogError(foundationErrors.notFound);
}
}
async function requireTenantUserWithTx(tx: DbTransactionClient, tenantId: string, userId: string): Promise<void> {
const member = await tx.tenantUser.findFirst({
where: { tenantId, userId, status: TenantUserStatus.ACTIVE, user: { active: true } },
select: { id: true },
});
if (!member) {
throw new CatalogError(foundationErrors.notFound, { userId });
}
}
async function requireTenantGroupWithTx(tx: DbTransactionClient, tenantId: string, groupId: string): Promise<void> {
const group = await tx.group.findFirst({
where: { id: groupId, tenantId },
select: { id: true },
});
if (!group) {
throw new CatalogError(foundationErrors.notFound, { groupId });
}
}
async function requireTenantAssignmentWithTx(
tx: DbTransactionClient,
tenantId: string,
assignment: { assignedToUserId: string | null; assignedToGroupId: string | null },
): Promise<void> {
if (assignment.assignedToUserId) {
await requireTenantUserWithTx(tx, tenantId, assignment.assignedToUserId);
}
if (assignment.assignedToGroupId) {
await requireTenantGroupWithTx(tx, tenantId, assignment.assignedToGroupId);
}
}
async function requireVisibleEntityLinkWithTx(
tx: DbTransactionClient,
tenantId: string,
linkedEntityType: TaskEntityType | null,
linkedEntityId: string | null,
ctx: TaskVisibilityContext,
): Promise<void> {
if (!linkedEntityType || !linkedEntityId) return;
await assertFileLinkTargetVisibleWithTx(tx as unknown as TenantRlsClient, tenantId, linkedEntityType, linkedEntityId, ctx);
const expectedPipelineType = linkedEntityType === "deal" ? "sales" : linkedEntityType === "order" ? "auftrag" : null;
if (!expectedPipelineType) return;
const project = await tx.projekt.findFirst({
where: {
id: linkedEntityId,
tenantId,
archivedAt: null,
pipeline: { tenantId, archivedAt: null, typHint: expectedPipelineType },
},
select: { id: true },
});
if (!project) throw new CatalogError(foundationErrors.notFound, { entityType: linkedEntityType, entityId: linkedEntityId });
}
/** 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: DbTransactionClient, 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;
ctx: TaskVisibilityContext;
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;
/** Tenant slug for the in-app assignment notification deeplink; without it the notification carries no link. */
tenantSlug?: string;
};
/**
* In-app notification for the assignee (M011). Self-assignments are silent;
* a notification failure never breaks the task write.
*/
const TASK_PRIORITY_NOTIFICATION_TONES: Record<TaskPriority, string> = {
urgent: "subtle-negative",
high: "subtle-warning",
normal: "subtle-information",
low: "subtle-neutral",
};
async function notifyTaskAssignedWithTx(
tx: DbTransactionClient,
input: {
tenantId: string;
actorUserId: string;
assignedToUserId: string | null;
taskId: string;
taskTitle: string;
taskPriority: TaskPriority;
tenantSlug?: string;
}
): Promise<void> {
if (!input.assignedToUserId || input.assignedToUserId === input.actorUserId) return;
try {
await notify(
{
tenantId: input.tenantId,
userId: input.assignedToUserId,
channel: "IN_APP",
category: "tasks",
messageKey: "tasks.notification.assigned",
sourceKey: `task-assigned:${input.taskId}:${input.assignedToUserId}`,
payloadMetadata: {
taskId: input.taskId,
taskTitle: input.taskTitle,
subjectLabel: input.taskTitle,
tagKey: `tasks.priority.${input.taskPriority}`,
tagTone: TASK_PRIORITY_NOTIFICATION_TONES[input.taskPriority] ?? "subtle-neutral",
...(input.tenantSlug ? { deeplink: appPath({ tenantSlug: input.tenantSlug, path: `tasks/${input.taskId}`, mode: "host-aware" }) } : {}),
},
},
tx as unknown as Parameters<typeof notify>[1]
);
} catch {
// Notification delivery is best-effort; the task mutation must not fail on it.
}
}
async function createTaskWithPermissionResult(
input: CreateTaskInput,
permission: "tasks.write" | "acceptance.write",
options: { readonly idempotentSourceRef?: boolean } = {}
): Promise<{ task: TaskRow; replayed: boolean }> {
return withTenantRLS(input.tenantId, (tx) => createTaskWithPermissionResultWithTx(tx, input, permission, options));
}
async function createTaskWithPermissionResultWithTx(
tx: DbTransactionClient,
input: CreateTaskInput,
permission: "tasks.write" | "acceptance.write",
options: { readonly idempotentSourceRef?: boolean } = {}
): Promise<{ task: TaskRow; replayed: boolean }> {
if (!hasPermission(input.ctx, permission)) {
throw new CatalogError(foundationErrors.notFound);
}
const title = input.title.trim();
if (!title) {
throw new TaskServiceError("tasks.error.titleRequired");
}
const assignment = resolveAssignment({
creatorUserId: input.ctx.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 source = input.source ?? "manual";
const sourceRef = input.sourceRef?.trim() || null;
if (options.idempotentSourceRef && !sourceRef) {
throw new TaskServiceError("tasks.error.sourceRefRequired");
}
await requireTenantUserWithTx(tx, input.tenantId, input.ctx.userId);
if (options.idempotentSourceRef && sourceRef) {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`task-source:${input.tenantId}:${source}:${sourceRef}`}, 0))`;
const existing = await tx.task.findFirst({
where: { tenantId: input.tenantId, source, sourceRef, archivedAt: null },
});
if (existing) return { task: existing as TaskRow, replayed: true };
}
await requireTenantAssignmentWithTx(tx, input.tenantId, assignment);
await requireVisibleEntityLinkWithTx(tx, input.tenantId, linkedEntityType, linkedEntityId, input.ctx);
const created = await tx.task.create({
data: {
tenantId: input.tenantId,
createdByUserId: input.ctx.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,
sourceRef,
isDemoData: input.isDemoData ?? false,
},
});
await publishTaskSearchUpsert(tx as unknown as TenantRlsClient, created);
await notifyTaskAssignedWithTx(tx, {
tenantId: input.tenantId,
actorUserId: input.ctx.userId,
assignedToUserId: assignment.assignedToUserId,
taskId: created.id,
taskTitle: created.title,
taskPriority: (created.priority as TaskPriority) ?? "normal",
tenantSlug: input.tenantSlug,
});
return { task: created as TaskRow, replayed: false };
}
export async function createTask(input: CreateTaskInput): Promise<TaskRow> {
return (await createTaskWithPermissionResult(input, "tasks.write")).task;
}
export async function createIdempotentTaskFromSource(
input: CreateTaskInput & { readonly source: Exclude<TaskSource, "manual">; readonly sourceRef: string }
): Promise<{ readonly task: TaskRow; readonly replayed: boolean }> {
return createTaskWithPermissionResult(input, "tasks.write", { idempotentSourceRef: true });
}
/** Compose a source-backed task into an existing RLS transaction. This keeps
* cross-module source links and effects atomic with the task row. */
export function createIdempotentTaskFromSourceWithTx(
tx: DbTransactionClient,
input: CreateTaskInput & { readonly source: Exclude<TaskSource, "manual">; readonly sourceRef: string }
): Promise<{ readonly task: TaskRow; readonly replayed: boolean }> {
return createTaskWithPermissionResultWithTx(tx, input, "tasks.write", { idempotentSourceRef: true });
}
export type CreateAcceptanceFollowUpTaskInput = Omit<CreateTaskInput, "source" | "linkedEntityType"> & {
linkedEntityType: "project";
sourceRef: string;
};
/** Create the follow-up task that is part of submitting an acceptance protocol. */
export async function createAcceptanceFollowUpTask(input: CreateAcceptanceFollowUpTaskInput): Promise<TaskRow> {
return (await createTaskWithPermissionResult({ ...input, source: "automation" }, "acceptance.write")).task;
}
export type UpdateTaskInput = {
tenantId: string;
taskId: string;
ctx: TaskVisibilityContext;
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> {
requireTaskPermission(input.ctx, "tasks.write");
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 getVisibleTaskOrThrowWithTx(tx, input.tenantId, input.taskId, input.ctx);
const linkedEntityType = input.linkedEntityType !== undefined ? input.linkedEntityType : existing.linkedEntityType;
const linkedEntityId = input.linkedEntityId !== undefined ? input.linkedEntityId : existing.linkedEntityId;
if ((linkedEntityType && !linkedEntityId) || (!linkedEntityType && linkedEntityId)) {
throw new TaskServiceError("tasks.error.invalidEntityLink");
}
await requireVisibleEntityLinkWithTx(tx, input.tenantId, linkedEntityType, linkedEntityId, input.ctx);
const updated = await tx.task.update({
where: { id: existing.id, 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 } : {}),
},
});
await publishTaskSearchUpsert(tx, updated);
});
}
/** Reassign a task to a single user OR group (exactly one), else error. */
export async function reassignTask(input: {
tenantId: string;
taskId: string;
ctx: TaskVisibilityContext;
assignedToUserId?: string | null;
assignedToGroupId?: string | null;
/** Tenant slug for the in-app assignment notification deeplink. */
tenantSlug?: string;
}): Promise<void> {
requireTaskPermission(input.ctx, "tasks.write");
await withTenantRLS(input.tenantId, async (tx) => {
const existing = await getVisibleTaskOrThrowWithTx(tx, input.tenantId, input.taskId, input.ctx);
const assignment = resolveAssignment({
creatorUserId: existing.createdByUserId,
assignedToUserId: input.assignedToUserId,
assignedToGroupId: input.assignedToGroupId,
});
await requireTenantAssignmentWithTx(tx, input.tenantId, assignment);
const updated = await tx.task.update({
where: { id: existing.id, tenantId: input.tenantId },
data: { assignedToUserId: assignment.assignedToUserId, assignedToGroupId: assignment.assignedToGroupId },
});
await publishTaskSearchUpsert(tx, updated);
if (assignment.assignedToUserId && assignment.assignedToUserId !== existing.assignedToUserId) {
await notifyTaskAssignedWithTx(tx, {
tenantId: input.tenantId,
actorUserId: input.ctx.userId,
assignedToUserId: assignment.assignedToUserId,
taskId: existing.id,
taskTitle: existing.title,
taskPriority: (existing.priority as TaskPriority) ?? "normal",
tenantSlug: input.tenantSlug,
});
}
});
}
export async function completeTask(input: { tenantId: string; taskId: string; ctx: TaskVisibilityContext }): Promise<void> {
requireTaskPermission(input.ctx, "tasks.write");
await withTenantRLS(input.tenantId, async (tx) => {
const existing = await getVisibleTaskOrThrowWithTx(tx, input.tenantId, input.taskId, input.ctx);
const updated = await tx.task.update({
where: { id: existing.id, tenantId: input.tenantId },
data: { status: "done", completedAt: new Date() },
});
await publishTaskSearchUpsert(tx, updated);
});
}
export async function archiveTask(input: { tenantId: string; taskId: string; ctx: TaskVisibilityContext }): Promise<void> {
requireTaskPermission(input.ctx, "tasks.write");
await withTenantRLS(input.tenantId, async (tx) => {
const existing = await getVisibleTaskOrThrowWithTx(tx, input.tenantId, input.taskId, input.ctx);
await tx.task.update({ where: { id: existing.id, tenantId: input.tenantId }, data: { archivedAt: new Date() } });
await publishSearchProjectionDelete(tx, { schema: tasksIndex, tenantId: input.tenantId, entityType: "task", entityId: existing.id });
});
}
export async function reactivateTask(input: { tenantId: string; taskId: string; ctx: TaskVisibilityContext }): Promise<void> {
requireTaskPermission(input.ctx, "tasks.write");
await withTenantRLS(input.tenantId, async (tx) => {
const existing = await getVisibleTaskOrThrowWithTx(tx, input.tenantId, input.taskId, input.ctx);
const updated = await tx.task.update({ where: { id: existing.id, tenantId: input.tenantId }, data: { archivedAt: null } });
await publishTaskSearchUpsert(tx, updated);
});
}
export async function hardDeleteTask(input: { tenantId: string; taskId: string; ctx: TaskVisibilityContext }): Promise<void> {
requireTaskPermission(input.ctx, "tasks.delete");
await withTenantRLS(input.tenantId, async (tx) => {
const task = await getVisibleTaskOrThrowWithTx(tx, input.tenantId, input.taskId, input.ctx);
// TaskWatcher rows cascade on Task delete (schema onDelete: Cascade).
await tx.task.delete({ where: { id: task.id, tenantId: input.tenantId } });
await publishSearchProjectionDelete(tx, { schema: tasksIndex, tenantId: input.tenantId, entityType: "task", entityId: task.id });
});
}
// --- Watchers -------------------------------------------------------------
export async function addWatcher(input: { tenantId: string; taskId: string; userId: string; ctx: TaskVisibilityContext }): Promise<void> {
requireTaskPermission(input.ctx, "tasks.write");
await withTenantRLS(input.tenantId, async (tx) => {
const task = await getVisibleTaskOrThrowWithTx(tx, input.tenantId, input.taskId, input.ctx);
await requireTenantUserWithTx(tx, input.tenantId, input.userId);
await tx.taskWatcher.upsert({
where: { taskId_userId: { taskId: task.id, userId: input.userId } },
update: {},
create: { tenantId: input.tenantId, taskId: task.id, userId: input.userId },
});
});
}
export async function removeWatcher(input: { tenantId: string; taskId: string; userId: string; ctx: TaskVisibilityContext }): Promise<void> {
requireTaskPermission(input.ctx, "tasks.write");
await withTenantRLS(input.tenantId, async (tx) => {
const task = await getVisibleTaskOrThrowWithTx(tx, input.tenantId, input.taskId, input.ctx);
await requireTenantUserWithTx(tx, input.tenantId, input.userId);
await tx.taskWatcher.deleteMany({ where: { tenantId: input.tenantId, taskId: task.id, 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: DbTransactionClient,
task: Pick<TaskRow, "id" | "tenantId" | "createdByUserId" | "assignedToUserId" | "assignedToGroupId">,
ctx: TaskVisibilityContext
): Promise<boolean> {
if (hasPermission(ctx, "tasks.read_all")) {
return true;
}
if (!hasPermission(ctx, "tasks.read_own")) {
return false;
}
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, group: { tenantId: task.tenantId } },
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")) {
return true;
}
if (!hasPermission(ctx, "tasks.read_own")) {
return false;
}
if (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: DbTransactionClient, 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 ListTasksFilter = {
/** Free-text needle matched case-insensitively against title and description. */
search?: string | null;
status?: TaskStatus | null;
priority?: TaskPriority | null;
assignedToUserId?: string | null;
linkedEntityType?: TaskEntityType | null;
linkedEntityId?: string | null;
source?: TaskSource | null;
includeArchived?: boolean;
includeDemoData?: boolean;
};
/**
* List tasks for the browser (tenant-scoped + read_own visibility), newest
* first, cursor-paginated. 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 }> {
if (!hasPermission(input.ctx, "tasks.read_all") && !hasPermission(input.ctx, "tasks.read_own")) {
return { rows: [], nextCursor: null };
}
const limit = Math.min(Math.max(input.limit ?? 50, 1), 100);
const filter = input.filter ?? {};
const search = filter.search?.trim() ?? "";
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 };
// Free-text search narrows further within the visible scope. Kept as a
// separate AND branch so it can never widen what the user may see.
const searchScope: Record<string, unknown> = search
? {
OR: [
{ title: { contains: search, mode: "insensitive" as const } },
{ description: { contains: search, mode: "insensitive" as const } },
],
}
: {};
const where = {
tenantId: input.tenantId,
...(filter.includeArchived ? {} : { archivedAt: null }),
...(filter.includeDemoData ? {} : { isDemoData: false }),
...(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, searchScope],
};
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, status: TenantUserStatus.ACTIVE, user: { active: true } },
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 }));
}