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