// 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 = 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 { 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 { 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 { 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 { 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 { 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 = { 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 { 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[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 { return (await createTaskWithPermissionResult(input, "tasks.write")).task; } export async function createIdempotentTaskFromSource( input: CreateTaskInput & { readonly source: Exclude; 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; readonly sourceRef: string } ): Promise<{ readonly task: TaskRow; readonly replayed: boolean }> { return createTaskWithPermissionResultWithTx(tx, input, "tasks.write", { idempotentSourceRef: true }); } export type CreateAcceptanceFollowUpTaskInput = Omit & { linkedEntityType: "project"; sourceRef: string; }; /** Create the follow-up task that is part of submitting an acceptance protocol. */ export async function createAcceptanceFollowUpTask(input: CreateAcceptanceFollowUpTaskInput): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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, ctx: TaskVisibilityContext ): Promise { 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, ctx: TaskVisibilityContext ): Promise { // 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 { 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 { 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 = 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> { 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> { 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 { 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 { 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 })); }