/** * T-159…T-161 — Task service. * * A Task is a 1:1 sidecar of a `pages.kind='task'` page (A-006): the PAGE * carries the blocks/comments/permissions tree (so a task is shareable, * commentable, versioned, searchable — for free), the TASK row carries the * queryable fields (status/dueAt/assigneeIds). The page's `title` is the * task's title (mirrored on updates). * * Authorization (denied-by-default, server-side): * - create/list: workspace EDIT / VIEW (the task inherits the space grant) * - get/update/remove: page-level grant on the task page (the same * `assertCan` the page routes use — non-member → 403, unknown → 404) * - inbox (`scope=inbox`): strictly the CALLER's assigned tasks, then the * page-level grant is re-checked per row (a task whose page grant was * revoked after assignment never leaks — negative test) */ import { Inject, Injectable } from "@nestjs/common"; import type { Request } from "express"; import { Prisma, type Task } from "@prisma/client"; import { PrismaService } from "../../common/prisma.service.js"; import { AuditService } from "../../common/audit.service.js"; import { EventBus } from "../../common/event-bus.js"; import { NotificationsService } from "../notifications/notifications.service.js"; import { ApiError } from "../../common/errors.js"; import { assertCan, actorLevel } from "../../lib/authorize.js"; import { between } from "../../lib/positions.js"; import { EVENTS } from "../../constants.js"; import { encodeCursor, parsePagination } from "../../lib/pagination.js"; import { TASK_TRANSITIONS, type TaskStatus } from "./dto.js"; const TASK_SELECT = { id: true, workspaceId: true, pageId: true, title: true, assigneeIds: true, createdBy: true, status: true, dueAt: true, createdAt: true, updatedAt: true } as const; type TaskRow = Prisma.TaskGetPayload<{ select: typeof TASK_SELECT }>; type PageSummary = { id: string; title: string; trashedAt: Date | null; reviewState: string; parentId: string | null }; @Injectable() export class TasksService { constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(AuditService) private readonly audit: AuditService, @Inject(EventBus) private readonly events: EventBus, @Inject(NotificationsService) private readonly notifications: NotificationsService ) {} // ---------- T-159: CRUD ---------- /** * `POST /workspaces/:workspaceId/tasks` — create the task PAGE (kind=task, * seeded title block — the same shape a normal page gets) + the Task sidecar * in ONE transaction. EDIT-gated at the space (the page inherits the space * grant; page-level grants can then narrow it). */ async create( workspaceId: string, userId: string, input: { title: string; parentId?: string; status?: TaskStatus; dueAt?: string | null; assigneeIds?: string[] }, req: Request ) { await assertCan(this.prisma, { workspaceId, userId }, "EDIT"); // Assignees must be (or be invited as) workspace members — a notification // to an outsider would be a leak; fail closed instead. const assigneeIds = input.assigneeIds ?? []; if (assigneeIds.length) { const members = await this.prisma.workspaceMember.findMany({ where: { workspaceId, userId: { in: assigneeIds } }, select: { userId: true } }); const known = new Set(members.map((m) => m.userId)); const bad = assigneeIds.filter((id) => !known.has(id)); if (bad.length) { throw new ApiError("VALIDATION_ERROR", 400, "assigneeIds must be workspace members.", [ { field: "assigneeIds", issue: "not_workspace_member", received: bad.join(",") } ]); } } if (input.parentId) { const parent = await this.prisma.page.findUnique({ where: { id: input.parentId } }); if (!parent || parent.workspaceId !== workspaceId) throw ApiError.notFound("Parent page not found."); if (parent.trashedAt) throw ApiError.validation("Cannot create under a trashed page.", [{ field: "parentId", issue: "parent_trashed" }]); } const dueAt = input.dueAt ? new Date(input.dueAt) : null; const pageId = `tpg_${Math.random().toString(36).slice(2, 14)}`; const row = await this.prisma.$transaction(async (tx) => { // Append to the sibling list (same hot-path `between` the page create uses). const last = await tx.page.findFirst({ where: { workspaceId, parentId: input.parentId ?? null, deletedAt: null, trashedAt: null }, orderBy: { position: "desc" }, select: { position: true } }); const position = between(last?.position); const page = await tx.page.create({ data: { id: pageId, workspaceId, parentId: input.parentId ?? null, position, title: input.title, kind: "task", createdBy: userId, updatedBy: userId } }); await tx.block.create({ data: { id: `blk_${Math.random().toString(36).slice(2, 14)}`, pageId, workspaceId, parentId: null, type: "PARAGRAPH", position: "A", content: { text: input.title }, properties: { isTitle: true }, createdBy: userId } }); return tx.task.create({ data: { workspaceId, pageId, title: input.title, status: input.status ?? "todo", dueAt, assigneeIds, createdBy: userId }, select: TASK_SELECT }); }); await this.audit.audit(req, req?.auth, { action: "task.created", resourceType: "task", resourceId: row.id, workspaceId, pageId, metadata: { title: row.title, status: row.status, dueAt: dueAt?.toISOString() ?? null, assigneeIds } }); await this.events.publish("page.created", { workspaceId, pageId, title: row.title, parentId: input.parentId ?? null }); // T-159: notify the assignees (the creator is NOT self-notified). for (const uid of assigneeIds) { if (uid === userId) continue; try { await this.notifications.notify({ workspaceId, userId: uid, type: "TASK_ASSIGNED", title: `You were assigned: ${row.title}`, link: { type: "page", id: pageId, pageId }, actorId: userId }); } catch { /* best-effort — the assignment itself must never fail on a notify */ } } return { ...row, pageId, parentId: input.parentId ?? null }; } /** * `GET /workspaces/:workspaceId/tasks` — the inbox (scope=inbox: the * caller's assigned tasks) or all workspace tasks (scope=all). Filters: * status, dueBefore/After, month (calendar), overdue. Keyset-paginated on * (createdAt desc, id desc) — the same cursor shape as the activity feeds. */ async list( workspaceId: string, userId: string, query: { scope?: "inbox" | "all"; status?: TaskStatus; dueBefore?: string; dueAfter?: string; month?: string; overdue?: boolean; cursor?: string; limit?: string } ) { await assertCan(this.prisma, { workspaceId, userId }, "VIEW"); const { cursor, limit } = parsePagination(query); const now = new Date(); const where: Prisma.TaskWhereInput = { workspaceId }; if (query.scope !== "all") where.assigneeIds = { has: userId }; if (query.status) where.status = query.status; const dueAt: { lt?: Date; gte?: Date } = {}; if (query.dueBefore) dueAt.lt = new Date(query.dueBefore); if (query.dueAfter) dueAt.gte = new Date(query.dueAfter); if (query.overdue) { dueAt.lt = now; where.status = { notIn: ["done", "cancelled"] }; } if (query.month) { const [y, m] = query.month.split("-").map(Number); dueAt.gte = new Date(Date.UTC(y, (m ?? 1) - 1, 1)); dueAt.lt = new Date(Date.UTC(y, m ?? 1, 1)); } if (Object.keys(dueAt).length) where.dueAt = dueAt; if (cursor) { where.AND = { OR: [ { createdAt: { lt: new Date(cursor.position) } }, { createdAt: new Date(cursor.position), id: { lt: cursor.id } } ] }; } const rows = await this.prisma.task.findMany({ where, orderBy: [{ createdAt: "desc" }, { id: "desc" }], take: limit + 1, select: TASK_SELECT }); const page = rows.slice(0, limit); const next = rows[limit]; // Attach the page title/state; DROP rows whose page grant is gone // (assignee's page access revoked after the assignment — no-leak). const withPage: Array = []; for (const t of page) { const p = await this.prisma.page.findUnique({ where: { id: t.pageId }, select: { id: true, title: true, trashedAt: true, reviewState: true, parentId: true } }); if (!p) continue; if (p.trashedAt) continue; // trashed tasks leave the list (restore brings them back) if (query.scope !== "all") { const level = await actorLevel(this.prisma, { workspaceId, userId, resource: { pageId: t.pageId } }); if (level === "NONE") continue; // revoked page grant → never leak } withPage.push({ ...t, page: p, pageTitle: p.title }); } return { data: withPage, nextCursor: next ? encodeCursor(next.id, next.createdAt.toISOString()) : null }; } /** `GET /workspaces/:workspaceId/tasks/:id` — the task + its page. */ async get(workspaceId: string, taskId: string, userId: string) { const t = await this.prisma.task.findUnique({ where: { id: taskId }, select: TASK_SELECT }); if (!t || t.workspaceId !== workspaceId) throw ApiError.notFound("Task not found."); const page = await this.prisma.page.findUnique({ where: { id: t.pageId } }); if (!page) throw ApiError.notFound("Task page not found."); // Page-level grant (non-member of the space → 403, the repo no-leak rule). const level = await actorLevel(this.prisma, { workspaceId, userId, resource: { pageId: t.pageId } }); if (level === "NONE") throw new ApiError("FORBIDDEN", 403, "Requires VIEW access (you have NONE)."); return { ...t, page: { id: page.id, title: page.title, trashedAt: page.trashedAt, reviewState: page.reviewState } }; } /** * `PATCH /workspaces/:workspaceId/tasks/:id` — update title/status/due/ * assignees. Status changes go through the transition matrix (illegal → * 400). A title change mirrors onto the page (the page title IS the task * title). New assignees get TASK_ASSIGNED (best-effort). */ async update( workspaceId: string, taskId: string, userId: string, input: { title?: string; status?: TaskStatus; dueAt?: string | null; assigneeIds?: string[] | null }, req: Request ) { const t = await this.prisma.task.findUnique({ where: { id: taskId }, select: TASK_SELECT }); if (!t || t.workspaceId !== workspaceId) throw ApiError.notFound("Task not found."); const page = await this.prisma.page.findUnique({ where: { id: t.pageId }, select: { id: true, workspaceId: true } }); if (!page) throw ApiError.notFound("Task page not found."); await assertCan(this.prisma, { workspaceId, userId, resource: { pageId: t.pageId } }, "EDIT"); const data: Prisma.TaskUpdateInput = {}; const meta: Record = {}; if (input.title !== undefined) { data.title = input.title; meta.title = { from: t.title, to: input.title }; } if (input.status !== undefined && input.status !== t.status) { const allowed = TASK_TRANSITIONS[t.status as TaskStatus] ?? []; if (!allowed.includes(input.status)) { throw new ApiError("VALIDATION_ERROR", 400, `Illegal task transition ${t.status} → ${input.status}.`, [ { field: "status", issue: "illegal_transition", from: t.status, to: input.status, allowed } ]); } data.status = input.status; meta.status = { from: t.status, to: input.status }; } if (input.dueAt !== undefined) { data.dueAt = input.dueAt ? new Date(input.dueAt) : null; meta.dueAt = { from: t.dueAt?.toISOString() ?? null, to: input.dueAt ?? null }; } if (input.assigneeIds !== undefined) { const assigneeIds = input.assigneeIds ?? []; if (assigneeIds.length) { const members = await this.prisma.workspaceMember.findMany({ where: { workspaceId, userId: { in: assigneeIds } }, select: { userId: true } }); const known = new Set(members.map((m) => m.userId)); const bad = assigneeIds.filter((id) => !known.has(id)); if (bad.length) { throw new ApiError("VALIDATION_ERROR", 400, "assigneeIds must be workspace members.", [ { field: "assigneeIds", issue: "not_workspace_member", received: bad.join(",") } ]); } } data.assigneeIds = assigneeIds; meta.assigneeIds = { from: t.assigneeIds, to: assigneeIds }; } const [updated] = await this.prisma.$transaction(async (tx) => { const task = await tx.task.update({ where: { id: t.id }, data, select: TASK_SELECT }); if (input.title !== undefined) { await tx.page.update({ where: { id: t.pageId }, data: { title: input.title, updatedBy: userId } }); // keep the seeded title block in sync (the editor's title block). const titleBlock = await tx.block.findFirst({ where: { pageId: t.pageId, properties: { path: ["isTitle"], equals: true } }, orderBy: { position: "asc" }, select: { id: true } }); if (titleBlock) { await tx.block.update({ where: { id: titleBlock.id }, data: { content: { text: input.title } as object } }); } } return [task]; }); await this.audit.audit(req, req?.auth, { action: "task.updated", resourceType: "task", resourceId: t.id, workspaceId, pageId: t.pageId, metadata: meta }); await this.events.publish(EVENTS.PAGE_UPDATED, { workspaceId, pageId: t.pageId, fields: Object.keys(meta) }); // Notify NEWLY assigned members only (diff old→new; never the actor). const fresh = ((input.assigneeIds ?? []) as string[]).filter((id) => !t.assigneeIds.includes(id) && id !== userId); for (const uid of fresh) { try { await this.notifications.notify({ workspaceId, userId: uid, type: "TASK_ASSIGNED", title: `You were assigned: ${updated.title}`, link: { type: "page", id: t.pageId, pageId: t.pageId }, actorId: userId }); } catch { /* best-effort */ } } return { ...updated, pageId: t.pageId }; } /** * `DELETE /workspaces/:workspaceId/tasks/:id` — trash the task page (the * task row is cascade-deleted with it). Same audit/event shape as the page * trash (`page.deleted` + `task.deleted`). */ async remove(workspaceId: string, taskId: string, userId: string, req: Request) { const t = await this.prisma.task.findUnique({ where: { id: taskId }, select: TASK_SELECT }); if (!t || t.workspaceId !== workspaceId) throw ApiError.notFound("Task not found."); const page = await this.prisma.page.findUnique({ where: { id: t.pageId }, select: { id: true, trashedAt: true } }); if (!page) throw ApiError.notFound("Task page not found."); await assertCan(this.prisma, { workspaceId, userId, resource: { pageId: t.pageId } }, "EDIT"); const now = new Date(); await this.prisma.page.update({ where: { id: t.pageId }, data: { trashedAt: now, updatedBy: userId } }); await this.audit.audit(req, req?.auth, { action: "task.deleted", resourceType: "task", resourceId: t.id, workspaceId, pageId: t.pageId, metadata: { title: t.title, permanent: false } }); await this.audit.audit(req, req?.auth, { action: "page.deleted", resourceType: "page", resourceId: t.pageId, workspaceId, pageId: t.pageId, metadata: { pageIds: [t.pageId], permanent: false } }); await this.events.publish(EVENTS.PAGE_DELETED, { workspaceId, pageId: t.pageId, permanent: false, pageIds: [t.pageId] }); return { trashed: true, taskId: t.id, pageId: t.pageId }; } }