// 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 = 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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, ctx: TaskVisibilityContext ): Promise { 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, ctx: TaskVisibilityContext ): Promise { // 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 { 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 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 = {}; 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> { 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 }, 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 })); }