734 lines
30 KiB
TypeScript
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 }));
|
|
}
|