1
18import { Inject, Injectable } from "@nestjs/common";
19import type { Request } from "express";
20import { Prisma, type Task } from "@prisma/client";
21import { PrismaService } from "../../common/prisma.service.js";
22import { AuditService } from "../../common/audit.service.js";
23import { EventBus } from "../../common/event-bus.js";
24import { NotificationsService } from "../notifications/notifications.service.js";
25import { ApiError } from "../../common/errors.js";
26import { assertCan, actorLevel } from "../../lib/authorize.js";
27import { between } from "../../lib/positions.js";
28import { EVENTS } from "../../constants.js";
29import { encodeCursor, parsePagination } from "../../lib/pagination.js";
30import { TASK_TRANSITIONS, type TaskStatus } from "./dto.js";
31
32const TASK_SELECT = {
33 id: true,
34 workspaceId: true,
35 pageId: true,
36 title: true,
37 assigneeIds: true,
38 createdBy: true,
39 status: true,
40 dueAt: true,
41 createdAt: true,
42 updatedAt: true
43} as const;
44
45type TaskRow = Prisma.TaskGetPayload<{ select: typeof TASK_SELECT }>;
46type PageSummary = { id: string; title: string; trashedAt: Date | null; reviewState: string; parentId: string | null };
47
48@Injectable()
49export class TasksService {
50 constructor(
51 @Inject(PrismaService) private readonly prisma: PrismaService,
52 @Inject(AuditService) private readonly audit: AuditService,
53 @Inject(EventBus) private readonly events: EventBus,
54 @Inject(NotificationsService) private readonly notifications: NotificationsService
55 ) {}
56
57
58
59
65 async create(
66 workspaceId: string,
67 userId: string,
68 input: { title: string; parentId?: string; status?: TaskStatus; dueAt?: string | null; assigneeIds?: string[] },
69 req: Request
70 ) {
71 await assertCan(this.prisma, { workspaceId, userId }, "EDIT");
72
73
74 const assigneeIds = input.assigneeIds ?? [];
75 if (assigneeIds.length) {
76 const members = await this.prisma.workspaceMember.findMany({
77 where: { workspaceId, userId: { in: assigneeIds } },
78 select: { userId: true }
79 });
80 const known = new Set(members.map((m) => m.userId));
81 const bad = assigneeIds.filter((id) => !known.has(id));
82 if (bad.length) {
83 throw new ApiError("VALIDATION_ERROR", 400, "assigneeIds must be workspace members.", [
84 { field: "assigneeIds", issue: "not_workspace_member", received: bad.join(",") }
85 ]);
86 }
87 }
88 if (input.parentId) {
89 const parent = await this.prisma.page.findUnique({ where: { id: input.parentId } });
90 if (!parent || parent.workspaceId !== workspaceId) throw ApiError.notFound("Parent page not found.");
91 if (parent.trashedAt) throw ApiError.validation("Cannot create under a trashed page.", [{ field: "parentId", issue: "parent_trashed" }]);
92 }
93 const dueAt = input.dueAt ? new Date(input.dueAt) : null;
94 const pageId = `tpg_${Math.random().toString(36).slice(2, 14)}`;
95
96 const row = await this.prisma.$transaction(async (tx) => {
97
98 const last = await tx.page.findFirst({
99 where: { workspaceId, parentId: input.parentId ?? null, deletedAt: null, trashedAt: null },
100 orderBy: { position: "desc" },
101 select: { position: true }
102 });
103 const position = between(last?.position);
104 const page = await tx.page.create({
105 data: {
106 id: pageId,
107 workspaceId,
108 parentId: input.parentId ?? null,
109 position,
110 title: input.title,
111 kind: "task",
112 createdBy: userId,
113 updatedBy: userId
114 }
115 });
116 await tx.block.create({
117 data: {
118 id: `blk_${Math.random().toString(36).slice(2, 14)}`,
119 pageId,
120 workspaceId,
121 parentId: null,
122 type: "PARAGRAPH",
123 position: "A",
124 content: { text: input.title },
125 properties: { isTitle: true },
126 createdBy: userId
127 }
128 });
129 return tx.task.create({
130 data: {
131 workspaceId,
132 pageId,
133 title: input.title,
134 status: input.status ?? "todo",
135 dueAt,
136 assigneeIds,
137 createdBy: userId
138 },
139 select: TASK_SELECT
140 });
141 });
142
143 await this.audit.audit(req, req?.auth, {
144 action: "task.created",
145 resourceType: "task",
146 resourceId: row.id,
147 workspaceId,
148 pageId,
149 metadata: { title: row.title, status: row.status, dueAt: dueAt?.toISOString() ?? null, assigneeIds }
150 });
151 await this.events.publish("page.created", { workspaceId, pageId, title: row.title, parentId: input.parentId ?? null });
152
153
154 for (const uid of assigneeIds) {
155 if (uid === userId) continue;
156 try {
157 await this.notifications.notify({
158 workspaceId,
159 userId: uid,
160 type: "TASK_ASSIGNED",
161 title: `You were assigned: ${row.title}`,
162 link: { type: "page", id: pageId, pageId },
163 actorId: userId
164 });
165 } catch {
166
167 }
168 }
169 return { ...row, pageId, parentId: input.parentId ?? null };
170 }
171
172
178 async list(
179 workspaceId: string,
180 userId: string,
181 query: { scope?: "inbox" | "all"; status?: TaskStatus; dueBefore?: string; dueAfter?: string; month?: string; overdue?: boolean; cursor?: string; limit?: string }
182 ) {
183 await assertCan(this.prisma, { workspaceId, userId }, "VIEW");
184 const { cursor, limit } = parsePagination(query);
185 const now = new Date();
186 const where: Prisma.TaskWhereInput = { workspaceId };
187 if (query.scope !== "all") where.assigneeIds = { has: userId };
188 if (query.status) where.status = query.status;
189 const dueAt: { lt?: Date; gte?: Date } = {};
190 if (query.dueBefore) dueAt.lt = new Date(query.dueBefore);
191 if (query.dueAfter) dueAt.gte = new Date(query.dueAfter);
192 if (query.overdue) {
193 dueAt.lt = now;
194 where.status = { notIn: ["done", "cancelled"] };
195 }
196 if (query.month) {
197 const [y, m] = query.month.split("-").map(Number);
198 dueAt.gte = new Date(Date.UTC(y, (m ?? 1) - 1, 1));
199 dueAt.lt = new Date(Date.UTC(y, m ?? 1, 1));
200 }
201 if (Object.keys(dueAt).length) where.dueAt = dueAt;
202 if (cursor) {
203 where.AND = {
204 OR: [
205 { createdAt: { lt: new Date(cursor.position) } },
206 { createdAt: new Date(cursor.position), id: { lt: cursor.id } }
207 ]
208 };
209 }
210 const rows = await this.prisma.task.findMany({
211 where,
212 orderBy: [{ createdAt: "desc" }, { id: "desc" }],
213 take: limit + 1,
214 select: TASK_SELECT
215 });
216 const page = rows.slice(0, limit);
217 const next = rows[limit];
218
219
220 const withPage: Array<TaskRow & { page: PageSummary | null; pageTitle: string }> = [];
221 for (const t of page) {
222 const p = await this.prisma.page.findUnique({
223 where: { id: t.pageId },
224 select: { id: true, title: true, trashedAt: true, reviewState: true, parentId: true }
225 });
226 if (!p) continue;
227 if (p.trashedAt) continue;
228 if (query.scope !== "all") {
229 const level = await actorLevel(this.prisma, { workspaceId, userId, resource: { pageId: t.pageId } });
230 if (level === "NONE") continue;
231 }
232 withPage.push({ ...t, page: p, pageTitle: p.title });
233 }
234 return {
235 data: withPage,
236 nextCursor: next ? encodeCursor(next.id, next.createdAt.toISOString()) : null
237 };
238 }
239
240
241 async get(workspaceId: string, taskId: string, userId: string) {
242 const t = await this.prisma.task.findUnique({ where: { id: taskId }, select: TASK_SELECT });
243 if (!t || t.workspaceId !== workspaceId) throw ApiError.notFound("Task not found.");
244 const page = await this.prisma.page.findUnique({ where: { id: t.pageId } });
245 if (!page) throw ApiError.notFound("Task page not found.");
246
247 const level = await actorLevel(this.prisma, { workspaceId, userId, resource: { pageId: t.pageId } });
248 if (level === "NONE") throw new ApiError("FORBIDDEN", 403, "Requires VIEW access (you have NONE).");
249 return { ...t, page: { id: page.id, title: page.title, trashedAt: page.trashedAt, reviewState: page.reviewState } };
250 }
251
252
258 async update(
259 workspaceId: string,
260 taskId: string,
261 userId: string,
262 input: { title?: string; status?: TaskStatus; dueAt?: string | null; assigneeIds?: string[] | null },
263 req: Request
264 ) {
265 const t = await this.prisma.task.findUnique({ where: { id: taskId }, select: TASK_SELECT });
266 if (!t || t.workspaceId !== workspaceId) throw ApiError.notFound("Task not found.");
267 const page = await this.prisma.page.findUnique({ where: { id: t.pageId }, select: { id: true, workspaceId: true } });
268 if (!page) throw ApiError.notFound("Task page not found.");
269 await assertCan(this.prisma, { workspaceId, userId, resource: { pageId: t.pageId } }, "EDIT");
270
271 const data: Prisma.TaskUpdateInput = {};
272 const meta: Record<string, unknown> = {};
273 if (input.title !== undefined) {
274 data.title = input.title;
275 meta.title = { from: t.title, to: input.title };
276 }
277 if (input.status !== undefined && input.status !== t.status) {
278 const allowed = TASK_TRANSITIONS[t.status as TaskStatus] ?? [];
279 if (!allowed.includes(input.status)) {
280 throw new ApiError("VALIDATION_ERROR", 400, `Illegal task transition ${t.status} → ${input.status}.`, [
281 { field: "status", issue: "illegal_transition", from: t.status, to: input.status, allowed }
282 ]);
283 }
284 data.status = input.status;
285 meta.status = { from: t.status, to: input.status };
286 }
287 if (input.dueAt !== undefined) {
288 data.dueAt = input.dueAt ? new Date(input.dueAt) : null;
289 meta.dueAt = { from: t.dueAt?.toISOString() ?? null, to: input.dueAt ?? null };
290 }
291 if (input.assigneeIds !== undefined) {
292 const assigneeIds = input.assigneeIds ?? [];
293 if (assigneeIds.length) {
294 const members = await this.prisma.workspaceMember.findMany({
295 where: { workspaceId, userId: { in: assigneeIds } },
296 select: { userId: true }
297 });
298 const known = new Set(members.map((m) => m.userId));
299 const bad = assigneeIds.filter((id) => !known.has(id));
300 if (bad.length) {
301 throw new ApiError("VALIDATION_ERROR", 400, "assigneeIds must be workspace members.", [
302 { field: "assigneeIds", issue: "not_workspace_member", received: bad.join(",") }
303 ]);
304 }
305 }
306 data.assigneeIds = assigneeIds;
307 meta.assigneeIds = { from: t.assigneeIds, to: assigneeIds };
308 }
309
310 const [updated] = await this.prisma.$transaction(async (tx) => {
311 const task = await tx.task.update({ where: { id: t.id }, data, select: TASK_SELECT });
312 if (input.title !== undefined) {
313 await tx.page.update({ where: { id: t.pageId }, data: { title: input.title, updatedBy: userId } });
314
315 const titleBlock = await tx.block.findFirst({
316 where: { pageId: t.pageId, properties: { path: ["isTitle"], equals: true } },
317 orderBy: { position: "asc" },
318 select: { id: true }
319 });
320 if (titleBlock) {
321 await tx.block.update({
322 where: { id: titleBlock.id },
323 data: { content: { text: input.title } as object }
324 });
325 }
326 }
327 return [task];
328 });
329
330 await this.audit.audit(req, req?.auth, {
331 action: "task.updated",
332 resourceType: "task",
333 resourceId: t.id,
334 workspaceId,
335 pageId: t.pageId,
336 metadata: meta
337 });
338 await this.events.publish(EVENTS.PAGE_UPDATED, { workspaceId, pageId: t.pageId, fields: Object.keys(meta) });
339
340
341 const fresh = ((input.assigneeIds ?? []) as string[]).filter((id) => !t.assigneeIds.includes(id) && id !== userId);
342 for (const uid of fresh) {
343 try {
344 await this.notifications.notify({
345 workspaceId,
346 userId: uid,
347 type: "TASK_ASSIGNED",
348 title: `You were assigned: ${updated.title}`,
349 link: { type: "page", id: t.pageId, pageId: t.pageId },
350 actorId: userId
351 });
352 } catch {
353
354 }
355 }
356 return { ...updated, pageId: t.pageId };
357 }
358
359
364 async remove(workspaceId: string, taskId: string, userId: string, req: Request) {
365 const t = await this.prisma.task.findUnique({ where: { id: taskId }, select: TASK_SELECT });
366 if (!t || t.workspaceId !== workspaceId) throw ApiError.notFound("Task not found.");
367 const page = await this.prisma.page.findUnique({ where: { id: t.pageId }, select: { id: true, trashedAt: true } });
368 if (!page) throw ApiError.notFound("Task page not found.");
369 await assertCan(this.prisma, { workspaceId, userId, resource: { pageId: t.pageId } }, "EDIT");
370 const now = new Date();
371 await this.prisma.page.update({ where: { id: t.pageId }, data: { trashedAt: now, updatedBy: userId } });
372 await this.audit.audit(req, req?.auth, {
373 action: "task.deleted",
374 resourceType: "task",
375 resourceId: t.id,
376 workspaceId,
377 pageId: t.pageId,
378 metadata: { title: t.title, permanent: false }
379 });
380 await this.audit.audit(req, req?.auth, {
381 action: "page.deleted",
382 resourceType: "page",
383 resourceId: t.pageId,
384 workspaceId,
385 pageId: t.pageId,
386 metadata: { pageIds: [t.pageId], permanent: false }
387 });
388 await this.events.publish(EVENTS.PAGE_DELETED, { workspaceId, pageId: t.pageId, permanent: false, pageIds: [t.pageId] });
389 return { trashed: true, taskId: t.id, pageId: t.pageId };
390 }
391}
392