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

506 lines
20 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 } 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 }));
}