diff --git a/packages/api/src/__tests__/notification-router.test.ts b/packages/api/src/__tests__/notification-router.test.ts index 49427f5..1d4679f 100644 --- a/packages/api/src/__tests__/notification-router.test.ts +++ b/packages/api/src/__tests__/notification-router.test.ts @@ -336,6 +336,45 @@ describe("notification.createBroadcast", () => { ); }); + it("rejects broadcasts with no recipients before opening a broadcast transaction", async () => { + resolveRecipientsMock.mockResolvedValue([]); + + const txCreateBroadcast = vi.fn(); + const txUpdateBroadcast = vi.fn(); + const tx = { + notificationBroadcast: { + create: txCreateBroadcast, + update: txUpdateBroadcast, + }, + }; + const transaction = vi.fn(async (callback: (db: typeof tx) => Promise) => callback(tx)); + const create = vi.fn(); + const update = vi.fn(); + const db = { + $transaction: transaction, + notificationBroadcast: { + create, + update, + }, + }; + + const caller = createManagerCaller(db); + + await expect(caller.createBroadcast({ + title: "Ops update", + targetType: "all", + })).rejects.toMatchObject({ + code: "BAD_REQUEST", + message: "No recipients matched the broadcast target.", + }); + + expect(transaction).not.toHaveBeenCalled(); + expect(create).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + expect(txCreateBroadcast).not.toHaveBeenCalled(); + expect(txUpdateBroadcast).not.toHaveBeenCalled(); + }); + it("rejects scheduled broadcasts when no recipients match the target", async () => { resolveRecipientsMock.mockResolvedValue([]); @@ -1103,6 +1142,7 @@ describe("notification.updateTaskStatus", () => { where: { id: "task_1", OR: [{ userId: "user_1" }, { assigneeId: "user_1" }], + category: { in: ["TASK", "APPROVAL"] }, }, }); expect(update).toHaveBeenCalledWith({ @@ -1138,11 +1178,87 @@ describe("notification.updateTaskStatus", () => { expect(db.notification.update).toHaveBeenCalledWith({ where: { id: "task_2" }, - data: { taskStatus: "IN_PROGRESS" }, + data: { + taskStatus: "IN_PROGRESS", + completedAt: null, + completedBy: null, + }, }); expect(emitTaskStatusChanged).toHaveBeenNthCalledWith(1, "user_1", "task_2"); expect(emitTaskStatusChanged).toHaveBeenNthCalledWith(2, "user_3", "task_2"); }); + + it("clears completion metadata when reopening a completed task", async () => { + const db = withUserLookup({ + notification: { + findFirst: vi.fn().mockResolvedValue({ + id: "task_3", + userId: "user_1", + assigneeId: "user_3", + taskStatus: "DONE", + completedAt: new Date("2026-04-01T10:00:00Z"), + completedBy: "user_2", + }), + update: vi.fn().mockResolvedValue({ + id: "task_3", + taskStatus: "OPEN", + completedAt: null, + completedBy: null, + }), + }, + }); + + const caller = createProtectedCaller(db); + const result = await caller.updateTaskStatus({ id: "task_3", status: "OPEN" }); + + expect(result).toMatchObject({ + id: "task_3", + taskStatus: "OPEN", + completedAt: null, + completedBy: null, + }); + expect(db.notification.update).toHaveBeenCalledWith({ + where: { id: "task_3" }, + data: { + taskStatus: "OPEN", + completedAt: null, + completedBy: null, + }, + }); + expect(emitTaskStatusChanged).toHaveBeenNthCalledWith(1, "user_1", "task_3"); + expect(emitTaskStatusChanged).toHaveBeenNthCalledWith(2, "user_3", "task_3"); + expect(emitTaskCompleted).not.toHaveBeenCalled(); + }); + + it("rejects task status updates for non-task notifications", async () => { + const findFirst = vi.fn().mockResolvedValue(null); + const update = vi.fn(); + const db = withUserLookup({ + notification: { + findFirst, + update, + }, + }); + + const caller = createProtectedCaller(db); + await expect(caller.updateTaskStatus({ id: "notif_info_1", status: "DONE" })).rejects.toMatchObject( + { + code: "NOT_FOUND", + message: "Task not found or you do not have permission", + }, + ); + + expect(findFirst).toHaveBeenCalledWith({ + where: { + id: "notif_info_1", + OR: [{ userId: "user_1" }, { assigneeId: "user_1" }], + category: { in: ["TASK", "APPROVAL"] }, + }, + }); + expect(update).not.toHaveBeenCalled(); + expect(emitTaskCompleted).not.toHaveBeenCalled(); + expect(emitTaskStatusChanged).not.toHaveBeenCalled(); + }); }); describe("notification.assignTask", () => { diff --git a/packages/api/src/router/notification-task-procedure-support.ts b/packages/api/src/router/notification-task-procedure-support.ts index ab664f4..acc3cca 100644 --- a/packages/api/src/router/notification-task-procedure-support.ts +++ b/packages/api/src/router/notification-task-procedure-support.ts @@ -131,6 +131,7 @@ export async function updateNotificationTaskStatus( where: { id: input.id, OR: [{ userId }, { assigneeId: userId }], + category: { in: ["TASK", "APPROVAL"] }, }, }); @@ -146,7 +147,9 @@ export async function updateNotificationTaskStatus( where: { id: input.id }, data: { taskStatus: input.status, - ...(isCompleting ? { completedAt: new Date(), completedBy: userId } : {}), + ...(isCompleting + ? { completedAt: new Date(), completedBy: userId } + : { completedAt: null, completedBy: null }), }, });