From 6837568ffe867405e9ff18a800761b966dc53e59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 20:50:14 +0200 Subject: [PATCH] refactor(api): extract notification procedures --- docs/api-router-procedure-support-backlog.md | 2 +- .../notification-procedure-support.test.ts | 90 ++ .../src/__tests__/notification-router.test.ts | 882 +++++++++++++++- .../router/notification-procedure-support.ts | 945 +++++++++++++++++ packages/api/src/router/notification.ts | 975 ++---------------- 5 files changed, 1986 insertions(+), 908 deletions(-) create mode 100644 packages/api/src/__tests__/notification-procedure-support.test.ts create mode 100644 packages/api/src/router/notification-procedure-support.ts diff --git a/docs/api-router-procedure-support-backlog.md b/docs/api-router-procedure-support-backlog.md index dc42529..7be4d8a 100644 --- a/docs/api-router-procedure-support-backlog.md +++ b/docs/api-router-procedure-support-backlog.md @@ -16,6 +16,7 @@ Done - `insights` - `import-export` - `chargeability-report` +- `notification` Ready next - none in the conflict-safe backlog @@ -24,7 +25,6 @@ Deferred or blocked - `assistant-tools` - `dashboard` - `entitlement` -- `notification` - `resource-read-shared` - `resource-summary-read` - `user` diff --git a/packages/api/src/__tests__/notification-procedure-support.test.ts b/packages/api/src/__tests__/notification-procedure-support.test.ts new file mode 100644 index 0000000..3d33a98 --- /dev/null +++ b/packages/api/src/__tests__/notification-procedure-support.test.ts @@ -0,0 +1,90 @@ +import { TRPCError } from "@trpc/server"; +import { describe, expect, it, vi } from "vitest"; +import { + getNotificationErrorCandidates, + rethrowNotificationReferenceError, + resolveUserId, +} from "../router/notification-procedure-support.js"; + +describe("notification procedure support", () => { + it("resolves the current user id from the session email", async () => { + const findUnique = vi.fn().mockResolvedValue({ id: "user_1" }); + + await expect(resolveUserId({ + db: { + user: { + findUnique, + }, + }, + session: { + user: { email: "person@example.com" }, + }, + })).resolves.toBe("user_1"); + + expect(findUnique).toHaveBeenCalledWith({ + where: { email: "person@example.com" }, + select: { id: true }, + }); + }); + + it("rejects when no session email is available", async () => { + await expect(resolveUserId({ + db: { + user: { + findUnique: vi.fn(), + }, + }, + session: { + user: null, + }, + })).rejects.toEqual(expect.objectContaining>({ + code: "UNAUTHORIZED", + })); + }); + + it("collects nested notification error candidates from causes", () => { + const nested = { + code: "P2003", + meta: { field_name: "senderId" }, + }; + + const candidates = getNotificationErrorCandidates({ + cause: { + shape: { + data: { + cause: nested, + }, + }, + }, + }); + + expect(candidates).toContainEqual(expect.objectContaining({ + code: "P2003", + meta: expect.objectContaining({ field_name: "senderId" }), + })); + }); + + it("rewrites sender foreign-key errors to a not found TRPC error", () => { + const error = { + code: "P2003", + meta: { field_name: "senderId" }, + }; + + try { + rethrowNotificationReferenceError(error); + throw new Error("expected notification reference error"); + } catch (caught) { + expect(caught).toBeInstanceOf(TRPCError); + expect(caught).toMatchObject>({ + code: "NOT_FOUND", + message: "Sender user not found", + }); + } + }); + + it("rethrows unrelated errors unchanged", () => { + const error = new Error("boom"); + + expect(() => rethrowNotificationReferenceError(error)).toThrow(error); + }); +}); diff --git a/packages/api/src/__tests__/notification-router.test.ts b/packages/api/src/__tests__/notification-router.test.ts index 4d07c57..1fea63a 100644 --- a/packages/api/src/__tests__/notification-router.test.ts +++ b/packages/api/src/__tests__/notification-router.test.ts @@ -2,15 +2,19 @@ import { SystemRole } from "@capakraken/shared"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { notificationRouter } from "../router/notification.js"; import { - emitBroadcastSent, emitNotificationCreated, emitTaskAssigned, + emitTaskCompleted, + emitTaskStatusChanged, } from "../sse/event-bus.js"; import { createCallerFactory } from "../trpc.js"; const { resolveRecipientsMock } = vi.hoisted(() => ({ resolveRecipientsMock: vi.fn(), })); +const { sendEmailMock } = vi.hoisted(() => ({ + sendEmailMock: vi.fn(), +})); // Mock the SSE event bus — we don't test real event emission here vi.mock("../sse/event-bus.js", () => ({ @@ -18,18 +22,22 @@ vi.mock("../sse/event-bus.js", () => ({ emitTaskAssigned: vi.fn(), emitTaskCompleted: vi.fn(), emitTaskStatusChanged: vi.fn(), - emitBroadcastSent: vi.fn(), })); vi.mock("../lib/notification-targeting.js", () => ({ resolveRecipients: resolveRecipientsMock, })); +vi.mock("../lib/email.js", () => ({ + sendEmail: sendEmailMock, +})); + const createCaller = createCallerFactory(notificationRouter); beforeEach(() => { vi.clearAllMocks(); resolveRecipientsMock.mockReset(); + sendEmailMock.mockReset(); }); // ── Caller factories ───────────────────────────────────────────────────────── @@ -290,6 +298,8 @@ describe("notification.create", () => { // ─── createBroadcast ──────────────────────────────────────────────────────── describe("notification.createBroadcast", () => { + const FUTURE_SCHEDULED_AT = new Date("2099-04-01T10:00:00Z"); + it("rejects broadcasts when no recipients match the target", async () => { resolveRecipientsMock.mockResolvedValue([]); @@ -326,6 +336,70 @@ describe("notification.createBroadcast", () => { ); }); + it("rejects scheduled broadcasts when no recipients match the target", async () => { + resolveRecipientsMock.mockResolvedValue([]); + + const create = vi.fn(); + const update = vi.fn(); + const db = { + notificationBroadcast: { + create, + update, + }, + }; + + const caller = createManagerCaller(db); + + await expect(caller.createBroadcast({ + title: "Ops update", + targetType: "all", + scheduledAt: FUTURE_SCHEDULED_AT, + })).rejects.toMatchObject({ + code: "BAD_REQUEST", + message: "No recipients matched the broadcast target.", + }); + + expect(create).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + expect(resolveRecipientsMock).toHaveBeenCalledWith( + "all", + undefined, + db, + "user_mgr", + ); + }); + + it("rejects scheduled broadcasts with task metadata before persisting anything", async () => { + resolveRecipientsMock.mockResolvedValue(["user_a"]); + + const create = vi.fn(); + const update = vi.fn(); + const db = { + notificationBroadcast: { + create, + update, + }, + }; + + const caller = createManagerCaller(db); + + await expect(caller.createBroadcast({ + title: "Approval later", + targetType: "all", + category: "TASK", + taskAction: "approve_vacation:vac_1", + dueDate: new Date("2099-04-02T10:00:00Z"), + scheduledAt: FUTURE_SCHEDULED_AT, + })).rejects.toMatchObject({ + code: "BAD_REQUEST", + message: "Scheduled broadcasts with task metadata are not supported yet.", + }); + + expect(resolveRecipientsMock).not.toHaveBeenCalled(); + expect(create).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + }); + it("does not partially persist an immediate broadcast when recipient fan-out fails", async () => { resolveRecipientsMock.mockResolvedValue(["user_a", "user_b"]); @@ -378,6 +452,242 @@ describe("notification.createBroadcast", () => { expect(outerCreateNotification).not.toHaveBeenCalled(); }); + it("emits recipient SSE only after an immediate broadcast commits", async () => { + resolveRecipientsMock.mockResolvedValue(["user_a", "user_b"]); + + const txCreateBroadcast = vi.fn().mockResolvedValue({ + id: "broadcast_tx_1", + title: "Ops update", + createdAt: new Date("2026-03-30T10:00:00Z"), + }); + const txCreateNotification = vi.fn() + .mockResolvedValueOnce({ id: "notif_a", userId: "user_a" }) + .mockResolvedValueOnce({ id: "notif_b", userId: "user_b" }); + const txUpdateBroadcast = vi.fn().mockResolvedValue({ + id: "broadcast_tx_1", + title: "Ops update", + createdAt: new Date("2026-03-30T10:00:00Z"), + sentAt: new Date("2026-03-30T10:01:00Z"), + recipientCount: 2, + }); + const tx = { + notificationBroadcast: { + create: txCreateBroadcast, + update: txUpdateBroadcast, + }, + notification: { + create: txCreateNotification, + }, + }; + const transaction = vi.fn(async (callback: (db: typeof tx) => Promise) => callback(tx)); + const db = { + $transaction: transaction, + notificationBroadcast: { + create: vi.fn(), + update: vi.fn(), + }, + notification: { + create: vi.fn(), + }, + }; + + const caller = createManagerCaller(db); + const result = await caller.createBroadcast({ + title: "Ops update", + category: "TASK", + targetType: "all", + taskAction: "acknowledge", + }); + + expect(transaction).toHaveBeenCalledTimes(1); + expect(txCreateBroadcast).toHaveBeenCalledTimes(1); + expect(txCreateNotification).toHaveBeenCalledTimes(2); + expect(txUpdateBroadcast).toHaveBeenCalledWith({ + where: { id: "broadcast_tx_1" }, + data: { + sentAt: expect.any(Date), + recipientCount: 2, + }, + }); + expect(emitNotificationCreated).toHaveBeenNthCalledWith(1, "user_a", "notif_a"); + expect(emitNotificationCreated).toHaveBeenNthCalledWith(2, "user_b", "notif_b"); + expect(emitTaskAssigned).toHaveBeenNthCalledWith(1, "user_a", "notif_a"); + expect(emitTaskAssigned).toHaveBeenNthCalledWith(2, "user_b", "notif_b"); + expect(result).toMatchObject({ + id: "broadcast_tx_1", + recipientCount: 2, + }); + }); + + it("does not emit recipient SSE when a broadcast is only scheduled", async () => { + resolveRecipientsMock.mockResolvedValue(["user_a", "user_b"]); + + const create = vi.fn().mockResolvedValue({ + id: "broadcast_sched_1", + title: "Scheduled ops update", + createdAt: new Date("2026-03-30T10:00:00Z"), + scheduledAt: FUTURE_SCHEDULED_AT, + recipientCount: 2, + }); + const db = { + notificationBroadcast: { + create, + update: vi.fn(), + }, + notification: { + create: vi.fn(), + }, + }; + + const caller = createManagerCaller(db); + const result = await caller.createBroadcast({ + title: "Scheduled ops update", + targetType: "all", + scheduledAt: FUTURE_SCHEDULED_AT, + }); + + expect(create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + senderId: "user_mgr", + title: "Scheduled ops update", + targetType: "all", + scheduledAt: FUTURE_SCHEDULED_AT, + recipientCount: 2, + }), + }); + expect(db.notification.create).not.toHaveBeenCalled(); + expect(emitNotificationCreated).not.toHaveBeenCalled(); + expect(emitTaskAssigned).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + id: "broadcast_sched_1", + scheduledAt: FUTURE_SCHEDULED_AT, + recipientCount: 2, + }); + }); + + it("sends broadcast emails only after an immediate broadcast transaction commits", async () => { + resolveRecipientsMock.mockResolvedValue(["user_a", "user_b"]); + + const txCreateBroadcast = vi.fn().mockResolvedValue({ + id: "broadcast_tx_email_1", + title: "Ops update", + createdAt: new Date("2026-03-30T10:00:00Z"), + }); + const txCreateNotification = vi.fn() + .mockResolvedValueOnce({ id: "notif_a", userId: "user_a" }) + .mockResolvedValueOnce({ id: "notif_b", userId: "user_b" }); + const txUpdateBroadcast = vi.fn().mockResolvedValue({ + id: "broadcast_tx_email_1", + title: "Ops update", + createdAt: new Date("2026-03-30T10:00:00Z"), + sentAt: new Date("2026-03-30T10:01:00Z"), + recipientCount: 2, + }); + const tx = { + notificationBroadcast: { + create: txCreateBroadcast, + update: txUpdateBroadcast, + }, + notification: { + create: txCreateNotification, + }, + }; + + let transactionCommitted = false; + const transaction = vi.fn(async (callback: (db: typeof tx) => Promise) => { + const result = await callback(tx); + expect(sendEmailMock).not.toHaveBeenCalled(); + transactionCommitted = true; + return result; + }); + const db = { + $transaction: transaction, + user: { + findUnique: vi.fn() + .mockResolvedValueOnce({ email: "user-a@example.com", name: "User A" }) + .mockResolvedValueOnce({ email: "user-b@example.com", name: "User B" }), + }, + notificationBroadcast: { + create: vi.fn(), + update: vi.fn(), + }, + notification: { + create: vi.fn(), + }, + }; + + const caller = createManagerCaller(db); + const result = await caller.createBroadcast({ + title: "Ops update", + body: "Email everyone", + channel: "both", + targetType: "all", + }); + + await vi.waitFor(() => { + expect(sendEmailMock).toHaveBeenCalledTimes(2); + }); + + expect(transactionCommitted).toBe(true); + expect(sendEmailMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ + to: "user-a@example.com", + subject: "Ops update", + text: "Email everyone", + })); + expect(sendEmailMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ + to: "user-b@example.com", + subject: "Ops update", + text: "Email everyone", + })); + expect(result).toMatchObject({ + id: "broadcast_tx_email_1", + recipientCount: 2, + }); + }); + + it("does not send emails immediately for scheduled broadcasts", async () => { + resolveRecipientsMock.mockResolvedValue(["user_a", "user_b"]); + + const create = vi.fn().mockResolvedValue({ + id: "broadcast_sched_email_1", + title: "Scheduled ops update", + createdAt: new Date("2026-03-30T10:00:00Z"), + scheduledAt: FUTURE_SCHEDULED_AT, + recipientCount: 2, + }); + const db = { + user: { + findUnique: vi.fn(), + }, + notificationBroadcast: { + create, + update: vi.fn(), + }, + notification: { + create: vi.fn(), + }, + }; + + const caller = createManagerCaller(db); + const result = await caller.createBroadcast({ + title: "Scheduled ops update", + body: "Hold until later", + channel: "email", + targetType: "all", + scheduledAt: FUTURE_SCHEDULED_AT, + }); + + await Promise.resolve(); + + expect(db.user.findUnique).not.toHaveBeenCalled(); + expect(sendEmailMock).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + id: "broadcast_sched_email_1", + scheduledAt: FUTURE_SCHEDULED_AT, + recipientCount: 2, + }); + }); + it("rolls back an immediate broadcast when the final broadcast update fails", async () => { resolveRecipientsMock.mockResolvedValue(["user_a", "user_b"]); @@ -438,6 +748,572 @@ describe("notification.createBroadcast", () => { expect(outerCreateNotification).not.toHaveBeenCalled(); expect(emitNotificationCreated).not.toHaveBeenCalled(); expect(emitTaskAssigned).not.toHaveBeenCalled(); - expect(emitBroadcastSent).not.toHaveBeenCalled(); + }); + + it("does not send broadcast emails when the immediate transaction fails", async () => { + resolveRecipientsMock.mockResolvedValue(["user_a", "user_b"]); + + const txCreateBroadcast = vi.fn().mockResolvedValue({ + id: "broadcast_tx_email_fail_1", + title: "Ops update", + createdAt: new Date("2026-03-30T10:00:00Z"), + }); + const txCreateNotification = vi.fn() + .mockResolvedValueOnce({ id: "notif_a", userId: "user_a" }) + .mockResolvedValueOnce({ id: "notif_b", userId: "user_b" }); + const txUpdateBroadcast = vi.fn().mockRejectedValue(new Error("commit failed")); + const tx = { + notificationBroadcast: { + create: txCreateBroadcast, + update: txUpdateBroadcast, + }, + notification: { + create: txCreateNotification, + }, + }; + const db = { + $transaction: vi.fn(async (callback: (db: typeof tx) => Promise) => callback(tx)), + user: { + findUnique: vi.fn(), + }, + notificationBroadcast: { + create: vi.fn(), + update: vi.fn(), + }, + notification: { + create: vi.fn(), + }, + }; + + const caller = createManagerCaller(db); + + await expect(caller.createBroadcast({ + title: "Ops update", + body: "Email everyone", + channel: "email", + targetType: "all", + })).rejects.toThrow("commit failed"); + + await Promise.resolve(); + + expect(db.user.findUnique).not.toHaveBeenCalled(); + expect(sendEmailMock).not.toHaveBeenCalled(); + }); +}); + +// ─── task management ──────────────────────────────────────────────────────── + +describe("notification.listTasks", () => { + it("lists task and approval notifications for the current user as owner or assignee", async () => { + const tasks = [ + sampleNotification({ id: "task_1", category: "TASK", taskStatus: "OPEN" }), + sampleNotification({ + id: "approval_1", + category: "APPROVAL", + taskStatus: "IN_PROGRESS", + assigneeId: "user_1", + }), + ]; + const db = withUserLookup({ + notification: { + findMany: vi.fn().mockResolvedValue(tasks), + }, + }); + + const caller = createProtectedCaller(db); + const result = await caller.listTasks({ status: "OPEN", limit: 15 }); + + expect(result).toEqual(tasks); + expect(db.notification.findMany).toHaveBeenCalledWith({ + where: { + OR: [{ userId: "user_1" }, { assigneeId: "user_1" }], + category: { in: ["TASK", "APPROVAL"] }, + taskStatus: "OPEN", + }, + orderBy: [{ priority: "desc" }, { dueDate: "asc" }, { createdAt: "desc" }], + take: 15, + }); + }); + + it("can restrict tasks to those owned by the current user", async () => { + const db = withUserLookup({ + notification: { + findMany: vi.fn().mockResolvedValue([]), + }, + }); + + const caller = createProtectedCaller(db); + await caller.listTasks({ includeAssigned: false, limit: 10 }); + + expect(db.notification.findMany).toHaveBeenCalledWith({ + where: { + userId: "user_1", + category: { in: ["TASK", "APPROVAL"] }, + }, + orderBy: [{ priority: "desc" }, { dueDate: "asc" }, { createdAt: "desc" }], + take: 10, + }); + }); +}); + +describe("notification.taskCounts", () => { + it("normalizes grouped task counts and calculates overdue work", async () => { + const groupBy = vi.fn().mockResolvedValue([ + { taskStatus: "OPEN", _count: 4 }, + { taskStatus: "DONE", _count: 2 }, + ]); + const count = vi.fn().mockResolvedValue(3); + const db = withUserLookup({ + notification: { + groupBy, + count, + }, + }); + + const caller = createProtectedCaller(db); + const result = await caller.taskCounts(); + + expect(result).toEqual({ + open: 4, + inProgress: 0, + done: 2, + dismissed: 0, + overdue: 3, + }); + expect(groupBy).toHaveBeenCalledWith({ + by: ["taskStatus"], + where: { + OR: [{ userId: "user_1" }, { assigneeId: "user_1" }], + category: { in: ["TASK", "APPROVAL"] }, + }, + _count: true, + }); + expect(count).toHaveBeenCalledWith({ + where: { + OR: [{ userId: "user_1" }, { assigneeId: "user_1" }], + category: { in: ["TASK", "APPROVAL"] }, + taskStatus: { in: ["OPEN", "IN_PROGRESS"] }, + dueDate: { lt: expect.any(Date) }, + }, + }); + }); +}); + +describe("notification.getTaskDetail", () => { + it("returns the selected task detail payload including sender context", async () => { + const task = { + id: "task_1", + title: "Review allocation", + body: "Please verify the plan.", + type: "TASK_CREATED", + priority: "HIGH", + category: "TASK", + taskStatus: "OPEN", + taskAction: "approve_vacation:vac_1", + dueDate: new Date("2026-04-10T09:00:00Z"), + entityId: "vac_1", + entityType: "VACATION", + completedAt: null, + completedBy: null, + createdAt: new Date("2026-04-01T08:00:00Z"), + userId: "user_1", + assigneeId: "user_2", + sender: { id: "user_mgr", name: "Manager", email: "mgr@example.com" }, + }; + const findFirst = vi.fn().mockResolvedValue(task); + const db = withUserLookup({ + notification: { + findFirst, + }, + }); + + const caller = createProtectedCaller(db); + const result = await caller.getTaskDetail({ id: "task_1" }); + + expect(result).toEqual(task); + expect(findFirst).toHaveBeenCalledWith({ + where: { + id: "task_1", + OR: [{ userId: "user_1" }, { assigneeId: "user_1" }], + category: { in: ["TASK", "APPROVAL"] }, + }, + select: { + id: true, + title: true, + body: true, + type: true, + priority: true, + category: true, + taskStatus: true, + taskAction: true, + dueDate: true, + entityId: true, + entityType: true, + completedAt: true, + completedBy: true, + createdAt: true, + userId: true, + assigneeId: true, + sender: { select: { id: true, name: true, email: true } }, + }, + }); + }); + + it("returns NOT_FOUND when the task is not visible to the current user", async () => { + const db = withUserLookup({ + notification: { + findFirst: vi.fn().mockResolvedValue(null), + }, + }); + + const caller = createProtectedCaller(db); + await expect(caller.getTaskDetail({ id: "task_missing" })).rejects.toMatchObject({ + code: "NOT_FOUND", + message: "Task not found or you do not have permission", + }); + }); +}); + +describe("notification.updateTaskStatus", () => { + it("marks a task as done, records completion metadata, and emits completion events", async () => { + const findFirst = vi.fn().mockResolvedValue({ + id: "task_1", + userId: "user_1", + assigneeId: "user_2", + }); + const update = vi.fn().mockResolvedValue({ + id: "task_1", + taskStatus: "DONE", + completedBy: "user_1", + completedAt: new Date("2026-04-02T10:00:00Z"), + }); + const db = withUserLookup({ + notification: { + findFirst, + update, + }, + }); + + const caller = createProtectedCaller(db); + const result = await caller.updateTaskStatus({ id: "task_1", status: "DONE" }); + + expect(result).toMatchObject({ + id: "task_1", + taskStatus: "DONE", + completedBy: "user_1", + }); + expect(findFirst).toHaveBeenCalledWith({ + where: { + id: "task_1", + OR: [{ userId: "user_1" }, { assigneeId: "user_1" }], + }, + }); + expect(update).toHaveBeenCalledWith({ + where: { id: "task_1" }, + data: { + taskStatus: "DONE", + completedAt: expect.any(Date), + completedBy: "user_1", + }, + }); + expect(emitTaskCompleted).toHaveBeenNthCalledWith(1, "user_1", "task_1"); + expect(emitTaskCompleted).toHaveBeenNthCalledWith(2, "user_2", "task_1"); + expect(emitTaskStatusChanged).not.toHaveBeenCalled(); + }); + + it("emits status-change events for non-terminal task updates", async () => { + const db = withUserLookup({ + notification: { + findFirst: vi.fn().mockResolvedValue({ + id: "task_2", + userId: "user_1", + assigneeId: "user_3", + }), + update: vi.fn().mockResolvedValue({ + id: "task_2", + taskStatus: "IN_PROGRESS", + }), + }, + }); + + const caller = createProtectedCaller(db); + await caller.updateTaskStatus({ id: "task_2", status: "IN_PROGRESS" }); + + expect(db.notification.update).toHaveBeenCalledWith({ + where: { id: "task_2" }, + data: { taskStatus: "IN_PROGRESS" }, + }); + expect(emitTaskStatusChanged).toHaveBeenNthCalledWith(1, "user_1", "task_2"); + expect(emitTaskStatusChanged).toHaveBeenNthCalledWith(2, "user_3", "task_2"); + }); +}); + +describe("notification.assignTask", () => { + it("reassigns a task and emits the assignment event for the new assignee", async () => { + const findUnique = vi.fn().mockResolvedValue({ + id: "task_9", + category: "TASK", + assigneeId: "user_2", + }); + const update = vi.fn().mockResolvedValue({ + id: "task_9", + category: "TASK", + assigneeId: "user_4", + }); + const db = { + notification: { + findUnique, + update, + }, + }; + + const caller = createManagerCaller(db); + const result = await caller.assignTask({ id: "task_9", assigneeId: "user_4" }); + + expect(findUnique).toHaveBeenCalledWith({ where: { id: "task_9" } }); + expect(update).toHaveBeenCalledWith({ + where: { id: "task_9" }, + data: { assigneeId: "user_4" }, + }); + expect(result).toMatchObject({ + id: "task_9", + assigneeId: "user_4", + }); + expect(emitTaskAssigned).toHaveBeenCalledWith("user_4", "task_9"); + }); +}); + +// ─── reminders ────────────────────────────────────────────────────────────── + +describe("notification.createReminder", () => { + it("creates a reminder for the current user and seeds nextRemindAt", async () => { + const remindAt = new Date("2026-04-15T08:30:00Z"); + const createdReminder = sampleNotification({ + id: "rem_1", + type: "REMINDER", + category: "REMINDER", + title: "Submit report", + remindAt, + nextRemindAt: remindAt, + recurrence: "weekly", + }); + const create = vi.fn().mockResolvedValue(createdReminder); + const db = withUserLookup({ + notification: { + create, + }, + }); + + const caller = createProtectedCaller(db); + const result = await caller.createReminder({ + title: "Submit report", + body: "Finance review", + remindAt, + recurrence: "weekly", + entityId: "project_1", + entityType: "PROJECT", + link: "/projects/project_1", + }); + + expect(result).toEqual(createdReminder); + expect(create).toHaveBeenCalledWith({ + data: { + userId: "user_1", + type: "REMINDER", + category: "REMINDER", + title: "Submit report", + body: "Finance review", + remindAt, + nextRemindAt: remindAt, + recurrence: "weekly", + entityId: "project_1", + entityType: "PROJECT", + link: "/projects/project_1", + channel: "in_app", + }, + }); + }); +}); + +describe("notification.updateReminder", () => { + it("updates an owned reminder and keeps nextRemindAt in sync with remindAt", async () => { + const remindAt = new Date("2026-05-01T07:00:00Z"); + const findFirst = vi.fn().mockResolvedValue({ + id: "rem_1", + userId: "user_1", + category: "REMINDER", + }); + const update = vi.fn().mockResolvedValue({ + id: "rem_1", + title: "Updated reminder", + remindAt, + nextRemindAt: remindAt, + recurrence: null, + }); + const db = withUserLookup({ + notification: { + findFirst, + update, + }, + }); + + const caller = createProtectedCaller(db); + const result = await caller.updateReminder({ + id: "rem_1", + title: "Updated reminder", + remindAt, + recurrence: null, + }); + + expect(result).toMatchObject({ + id: "rem_1", + title: "Updated reminder", + recurrence: null, + }); + expect(findFirst).toHaveBeenCalledWith({ + where: { id: "rem_1", userId: "user_1", category: "REMINDER" }, + }); + expect(update).toHaveBeenCalledWith({ + where: { id: "rem_1" }, + data: { + title: "Updated reminder", + remindAt, + nextRemindAt: remindAt, + recurrence: null, + }, + }); + }); +}); + +describe("notification.deleteReminder", () => { + it("deletes an owned reminder", async () => { + const findFirst = vi.fn().mockResolvedValue({ + id: "rem_1", + userId: "user_1", + category: "REMINDER", + }); + const deleteFn = vi.fn().mockResolvedValue({ id: "rem_1" }); + const db = withUserLookup({ + notification: { + findFirst, + delete: deleteFn, + }, + }); + + const caller = createProtectedCaller(db); + await caller.deleteReminder({ id: "rem_1" }); + + expect(findFirst).toHaveBeenCalledWith({ + where: { id: "rem_1", userId: "user_1", category: "REMINDER" }, + }); + expect(deleteFn).toHaveBeenCalledWith({ where: { id: "rem_1" } }); + }); +}); + +describe("notification.listReminders", () => { + it("lists reminders for the current user ordered by next reminder date", async () => { + const reminders = [ + { + id: "rem_1", + userId: "user_1", + category: "REMINDER", + nextRemindAt: new Date("2026-05-01T07:00:00Z"), + }, + ]; + const db = withUserLookup({ + notification: { + findMany: vi.fn().mockResolvedValue(reminders), + }, + }); + + const caller = createProtectedCaller(db); + const result = await caller.listReminders({ limit: 10 }); + + expect(result).toEqual(reminders); + expect(db.notification.findMany).toHaveBeenCalledWith({ + where: { userId: "user_1", category: "REMINDER" }, + orderBy: { nextRemindAt: "asc" }, + take: 10, + }); + }); +}); + +// ─── broadcasts and delete ───────────────────────────────────────────────── + +describe("notification.getBroadcastById", () => { + it("returns the broadcast including sender context", async () => { + const findUnique = vi.fn().mockResolvedValue({ + id: "broadcast_1", + title: "Office update", + sender: { id: "user_mgr", name: "Manager", email: "mgr@example.com" }, + }); + const db = withUserLookup({ + notificationBroadcast: { + findUnique, + }, + }, "user_mgr"); + + const caller = createManagerCaller(db); + const result = await caller.getBroadcastById({ id: "broadcast_1" }); + + expect(result).toEqual({ + id: "broadcast_1", + title: "Office update", + sender: { id: "user_mgr", name: "Manager", email: "mgr@example.com" }, + }); + expect(findUnique).toHaveBeenCalledWith({ + where: { id: "broadcast_1" }, + include: { + sender: { select: { id: true, name: true, email: true } }, + }, + }); + }); +}); + +describe("notification.delete", () => { + it("deletes a regular notification owned by the current user", async () => { + const findFirst = vi.fn().mockResolvedValue({ + id: "notif_1", + userId: "user_1", + category: "NOTIFICATION", + senderId: "user_mgr", + }); + const deleteFn = vi.fn().mockResolvedValue({ id: "notif_1" }); + const db = withUserLookup({ + notification: { + findFirst, + delete: deleteFn, + }, + }); + + const caller = createProtectedCaller(db); + await caller.delete({ id: "notif_1" }); + + expect(findFirst).toHaveBeenCalledWith({ + where: { id: "notif_1", userId: "user_1" }, + }); + expect(deleteFn).toHaveBeenCalledWith({ where: { id: "notif_1" } }); + }); + + it("forbids deleting a task created by another user", async () => { + const deleteFn = vi.fn(); + const db = withUserLookup({ + notification: { + findFirst: vi.fn().mockResolvedValue({ + id: "task_1", + userId: "user_1", + category: "TASK", + senderId: "user_mgr", + }), + delete: deleteFn, + }, + }); + + const caller = createProtectedCaller(db); + await expect(caller.delete({ id: "task_1" })).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Cannot delete tasks created by others", + }); + expect(deleteFn).not.toHaveBeenCalled(); }); }); diff --git a/packages/api/src/router/notification-procedure-support.ts b/packages/api/src/router/notification-procedure-support.ts new file mode 100644 index 0000000..6b74ee2 --- /dev/null +++ b/packages/api/src/router/notification-procedure-support.ts @@ -0,0 +1,945 @@ +import { PermissionKey, parseTaskAction, resolvePermissions } from "@capakraken/shared"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { findUniqueOrThrow } from "../db/helpers.js"; +import { sendEmail } from "../lib/email.js"; +import { createNotification } from "../lib/create-notification.js"; +import { resolveRecipients } from "../lib/notification-targeting.js"; +import { getTaskAction } from "../lib/task-actions.js"; +import { + emitNotificationCreated, + emitTaskAssigned, + emitTaskCompleted, + emitTaskStatusChanged, +} from "../sse/event-bus.js"; +import type { TRPCContext } from "../trpc.js"; + +type NotificationProcedureContext = Pick; + +export async function resolveUserId(ctx: { + db: { + user: { + findUnique: (args: { + where: { email: string }; + select: { id: true }; + }) => Promise<{ id: string } | null>; + }; + }; + session: { user?: { email?: string | null } | null }; +}): Promise { + const email = ctx.session.user?.email; + if (!email) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + + const user = await ctx.db.user.findUnique({ + where: { email }, + select: { id: true }, + }); + if (!user) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + + return user.id; +} + +async function sendNotificationEmail( + db: { + user: { + findUnique: (args: { + where: { id: string }; + select: { email: true; name: true }; + }) => Promise<{ email: string; name: string | null } | null>; + }; + }, + userId: string, + title: string, + body?: string | null, +): Promise { + try { + const user = await db.user.findUnique({ + where: { id: userId }, + select: { email: true, name: true }, + }); + if (!user) { + return; + } + + void sendEmail({ + to: user.email, + subject: title, + text: body ?? title, + ...(body !== undefined && body !== null ? { html: `

${body}

` } : {}), + }); + } catch { + // non-blocking + } +} + +export function getNotificationErrorCandidates(error: unknown): Array<{ + code?: unknown; + message?: unknown; + meta?: { field_name?: unknown; modelName?: unknown }; + cause?: unknown; +}> { + const queue: unknown[] = [error]; + const seen = new Set(); + const candidates: Array<{ + code?: unknown; + message?: unknown; + meta?: { field_name?: unknown; modelName?: unknown }; + cause?: unknown; + }> = []; + + while (queue.length > 0) { + const current = queue.shift(); + if (!current || seen.has(current) || typeof current !== "object") { + continue; + } + seen.add(current); + + const candidate = current as { + code?: unknown; + message?: unknown; + meta?: { field_name?: unknown; modelName?: unknown }; + cause?: unknown; + shape?: { message?: unknown; data?: { cause?: unknown } }; + }; + candidates.push(candidate); + + if ("cause" in candidate) { + queue.push(candidate.cause); + } + if (candidate.shape?.data?.cause) { + queue.push(candidate.shape.data.cause); + } + } + + return candidates; +} + +export function rethrowNotificationReferenceError(error: unknown): never { + for (const candidate of getNotificationErrorCandidates(error)) { + const fieldName = typeof candidate.meta?.field_name === "string" + ? candidate.meta.field_name.toLowerCase() + : ""; + const modelName = typeof candidate.meta?.modelName === "string" + ? candidate.meta.modelName.toLowerCase() + : ""; + + if ( + typeof candidate.code === "string" + && (candidate.code === "P2003" || candidate.code === "P2025") + && fieldName.includes("sender") + ) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Sender user not found", + cause: error, + }); + } + + if ( + typeof candidate.code === "string" + && (candidate.code === "P2003" || candidate.code === "P2025") + && fieldName.includes("userid") + ) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Broadcast recipient user not found", + cause: error, + }); + } + + if ( + typeof candidate.code === "string" + && (candidate.code === "P2003" || candidate.code === "P2025") + && (modelName.includes("notificationbroadcast") || fieldName.includes("broadcast")) + ) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Notification broadcast not found", + cause: error, + }); + } + } + + throw error; +} + +export const categoryEnum = z.enum(["NOTIFICATION", "REMINDER", "TASK", "APPROVAL"]); +export const priorityEnum = z.enum(["LOW", "NORMAL", "HIGH", "URGENT"]); +export const taskStatusEnum = z.enum(["OPEN", "IN_PROGRESS", "DONE", "DISMISSED"]); +export const channelEnum = z.enum(["in_app", "email", "both"]); +export const recurrenceEnum = z.enum(["daily", "weekly", "monthly"]); +export const targetTypeEnum = z.enum(["user", "role", "project", "orgUnit", "all"]); +export const SCHEDULED_TASK_BROADCAST_UNSUPPORTED_MESSAGE = + "Scheduled broadcasts with task metadata are not supported yet."; + +export const NotificationListInputSchema = z.object({ + unreadOnly: z.boolean().optional(), + category: categoryEnum.optional(), + taskStatus: taskStatusEnum.optional(), + priority: priorityEnum.optional(), + limit: z.number().min(1).max(100).default(50), +}); + +export const MarkNotificationReadInputSchema = z.object({ + id: z.string().optional(), +}); + +export const CreateManagedNotificationInputSchema = z.object({ + userId: z.string(), + type: z.string(), + title: z.string(), + body: z.string().optional(), + entityId: z.string().optional(), + entityType: z.string().optional(), + category: categoryEnum.optional(), + priority: priorityEnum.optional(), + link: z.string().optional(), + taskStatus: taskStatusEnum.optional(), + taskAction: z.string().optional(), + assigneeId: z.string().optional(), + dueDate: z.date().optional(), + channel: channelEnum.optional(), + senderId: z.string().optional(), +}); + +export const ListNotificationTasksInputSchema = z.object({ + status: taskStatusEnum.optional(), + includeAssigned: z.boolean().default(true), + limit: z.number().min(1).max(100).default(20), +}); + +export const NotificationIdInputSchema = z.object({ + id: z.string(), +}); + +export const UpdateNotificationTaskStatusInputSchema = z.object({ + id: z.string(), + status: taskStatusEnum, +}); + +export const CreateReminderInputSchema = z.object({ + title: z.string().min(1).max(200), + body: z.string().max(2000).optional(), + remindAt: z.date(), + recurrence: recurrenceEnum.optional(), + entityId: z.string().optional(), + entityType: z.string().optional(), + link: z.string().optional(), +}); + +export const UpdateReminderInputSchema = z.object({ + id: z.string(), + title: z.string().min(1).max(200).optional(), + body: z.string().max(2000).optional(), + remindAt: z.date().optional(), + recurrence: recurrenceEnum.nullish(), +}); + +export const ListRemindersInputSchema = z.object({ + limit: z.number().min(1).max(100).default(20), +}); + +export const CreateBroadcastInputSchema = z.object({ + title: z.string().min(1).max(200), + body: z.string().max(2000).optional(), + link: z.string().optional(), + category: categoryEnum.default("NOTIFICATION"), + priority: priorityEnum.default("NORMAL"), + channel: channelEnum.default("in_app"), + targetType: targetTypeEnum, + targetValue: z.string().optional(), + scheduledAt: z.date().optional(), + taskAction: z.string().optional(), + dueDate: z.date().optional(), +}); + +export const ListBroadcastsInputSchema = z.object({ + limit: z.number().min(1).max(50).default(20), +}); + +export const CreateTaskInputSchema = z.object({ + userId: z.string(), + title: z.string().min(1).max(200), + body: z.string().max(2000).optional(), + priority: priorityEnum.default("NORMAL"), + dueDate: z.date().optional(), + taskAction: z.string().optional(), + entityId: z.string().optional(), + entityType: z.string().optional(), + link: z.string().optional(), + channel: channelEnum.default("in_app"), +}); + +export const AssignTaskInputSchema = z.object({ + id: z.string(), + assigneeId: z.string(), +}); + +type NotificationListInput = z.infer; +type MarkNotificationReadInput = z.infer; +type CreateManagedNotificationInput = z.infer; +type ListNotificationTasksInput = z.infer; +type NotificationIdInput = z.infer; +type UpdateNotificationTaskStatusInput = z.infer; +type CreateReminderInput = z.infer; +type UpdateReminderInput = z.infer; +type ListRemindersInput = z.infer; +type CreateBroadcastInput = z.infer; +type ListBroadcastsInput = z.infer; +type CreateTaskInput = z.infer; +type AssignTaskInput = z.infer; + +export async function listNotifications( + ctx: NotificationProcedureContext, + input: NotificationListInput, +) { + const userId = await resolveUserId(ctx); + return ctx.db.notification.findMany({ + where: { + userId, + ...(input.unreadOnly ? { readAt: null } : {}), + ...(input.category !== undefined ? { category: input.category } : {}), + ...(input.taskStatus !== undefined ? { taskStatus: input.taskStatus } : {}), + ...(input.priority !== undefined ? { priority: input.priority } : {}), + }, + orderBy: { createdAt: "desc" }, + take: input.limit, + }); +} + +export async function countUnreadNotifications(ctx: NotificationProcedureContext) { + const userId = await resolveUserId(ctx); + return ctx.db.notification.count({ + where: { userId, readAt: null }, + }); +} + +export async function markNotificationsRead( + ctx: NotificationProcedureContext, + input: MarkNotificationReadInput, +) { + const userId = await resolveUserId(ctx); + const now = new Date(); + if (input.id) { + await ctx.db.notification.update({ + where: { id: input.id, userId }, + data: { readAt: now }, + }); + return; + } + + await ctx.db.notification.updateMany({ + where: { userId, readAt: null }, + data: { readAt: now }, + }); +} + +export async function createManagedNotification( + ctx: NotificationProcedureContext, + input: CreateManagedNotificationInput, +) { + const currentUserId = ctx.dbUser.id; + + const notificationId = await createNotification({ + db: ctx.db, + userId: input.userId, + type: input.type, + title: input.title, + body: input.body, + entityId: input.entityId, + entityType: input.entityType, + category: input.category, + priority: input.priority, + link: input.link, + taskStatus: input.taskStatus, + taskAction: input.taskAction, + assigneeId: input.assigneeId, + dueDate: input.dueDate, + channel: input.channel, + senderId: input.senderId ?? currentUserId, + }); + + if (input.category === "TASK" || input.category === "APPROVAL") { + emitTaskAssigned(input.userId, notificationId); + } + + const channel = input.channel ?? "in_app"; + if (channel === "email" || channel === "both") { + void sendNotificationEmail(ctx.db, input.userId, input.title, input.body); + } + + return ctx.db.notification.findUnique({ where: { id: notificationId } }); +} + +export async function listNotificationTasks( + ctx: NotificationProcedureContext, + input: ListNotificationTasksInput, +) { + const userId = await resolveUserId(ctx); + const userFilter = input.includeAssigned + ? { OR: [{ userId }, { assigneeId: userId }] } + : { userId }; + + return ctx.db.notification.findMany({ + where: { + ...userFilter, + category: { in: ["TASK", "APPROVAL"] }, + ...(input.status !== undefined ? { taskStatus: input.status } : {}), + }, + orderBy: [{ priority: "desc" }, { dueDate: "asc" }, { createdAt: "desc" }], + take: input.limit, + }); +} + +export async function getNotificationTaskCounts(ctx: NotificationProcedureContext) { + const userId = await resolveUserId(ctx); + const now = new Date(); + const where = { + OR: [{ userId }, { assigneeId: userId }], + category: { in: ["TASK" as const, "APPROVAL" as const] }, + }; + + const [grouped, overdue] = await Promise.all([ + ctx.db.notification.groupBy({ + by: ["taskStatus"], + where, + _count: true, + }), + ctx.db.notification.count({ + where: { + ...where, + taskStatus: { in: ["OPEN", "IN_PROGRESS"] }, + dueDate: { lt: now }, + }, + }), + ]); + + const counts: Record = {}; + for (const group of grouped) { + if (group.taskStatus) { + counts[group.taskStatus] = group._count; + } + } + + return { + open: counts.OPEN ?? 0, + inProgress: counts.IN_PROGRESS ?? 0, + done: counts.DONE ?? 0, + dismissed: counts.DISMISSED ?? 0, + overdue, + }; +} + +export async function getNotificationTaskDetail( + ctx: NotificationProcedureContext, + input: NotificationIdInput, +) { + const userId = await resolveUserId(ctx); + const task = await ctx.db.notification.findFirst({ + where: { + id: input.id, + OR: [{ userId }, { assigneeId: userId }], + category: { in: ["TASK", "APPROVAL"] }, + }, + select: { + id: true, + title: true, + body: true, + type: true, + priority: true, + category: true, + taskStatus: true, + taskAction: true, + dueDate: true, + entityId: true, + entityType: true, + completedAt: true, + completedBy: true, + createdAt: true, + userId: true, + assigneeId: true, + sender: { select: { id: true, name: true, email: true } }, + }, + }); + + if (!task) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Task not found or you do not have permission", + }); + } + + return task; +} + +export async function updateNotificationTaskStatus( + ctx: NotificationProcedureContext, + input: UpdateNotificationTaskStatusInput, +) { + const userId = await resolveUserId(ctx); + const existing = await ctx.db.notification.findFirst({ + where: { + id: input.id, + OR: [{ userId }, { assigneeId: userId }], + }, + }); + + if (!existing) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Task not found or you do not have permission", + }); + } + + const isCompleting = input.status === "DONE"; + const updated = await ctx.db.notification.update({ + where: { id: input.id }, + data: { + taskStatus: input.status, + ...(isCompleting ? { completedAt: new Date(), completedBy: userId } : {}), + }, + }); + + if (isCompleting) { + emitTaskCompleted(existing.userId, updated.id); + if (existing.assigneeId && existing.assigneeId !== existing.userId) { + emitTaskCompleted(existing.assigneeId, updated.id); + } + } else { + emitTaskStatusChanged(existing.userId, updated.id); + if (existing.assigneeId && existing.assigneeId !== existing.userId) { + emitTaskStatusChanged(existing.assigneeId, updated.id); + } + } + + return updated; +} + +export async function executeNotificationTaskAction( + ctx: NotificationProcedureContext, + input: NotificationIdInput, +) { + const userId = await resolveUserId(ctx); + const task = await ctx.db.notification.findFirst({ + where: { + id: input.id, + OR: [{ userId }, { assigneeId: userId }], + category: { in: ["TASK", "APPROVAL"] }, + }, + select: { + id: true, + userId: true, + assigneeId: true, + taskAction: true, + taskStatus: true, + }, + }); + + if (!task) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Task not found or you do not have permission", + }); + } + if (!task.taskAction) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "This task has no executable action", + }); + } + if (task.taskStatus === "DONE") { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "This task is already completed", + }); + } + + const parsed = parseTaskAction(task.taskAction); + if (!parsed) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Invalid taskAction format: ${task.taskAction}`, + }); + } + + const handler = getTaskAction(parsed.action); + if (!handler) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Unknown action: ${parsed.action}`, + }); + } + + const permissions = resolvePermissions( + ctx.dbUser.systemRole as import("@capakraken/shared").SystemRole, + ctx.dbUser.permissionOverrides as import("@capakraken/shared").PermissionOverrides | null, + ctx.roleDefaults ?? undefined, + ); + if (handler.permission && !permissions.has(handler.permission as PermissionKey)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: `Permission denied: you need "${handler.permission}" to perform this action`, + }); + } + + const actionResult = await handler.execute(parsed.entityId, ctx.db, userId); + if (!actionResult.success) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: actionResult.message, + }); + } + + const completedTask = await ctx.db.notification.update({ + where: { id: input.id }, + data: { + taskStatus: "DONE", + completedAt: new Date(), + completedBy: userId, + }, + }); + + emitTaskCompleted(task.userId, task.id); + if (task.assigneeId && task.assigneeId !== task.userId) { + emitTaskCompleted(task.assigneeId, task.id); + } + + return { + task: completedTask, + actionResult, + }; +} + +export async function createReminder( + ctx: NotificationProcedureContext, + input: CreateReminderInput, +) { + const userId = await resolveUserId(ctx); + return ctx.db.notification.create({ + data: { + userId, + type: "REMINDER", + category: "REMINDER", + title: input.title, + ...(input.body !== undefined ? { body: input.body } : {}), + remindAt: input.remindAt, + nextRemindAt: input.remindAt, + ...(input.recurrence !== undefined ? { recurrence: input.recurrence } : {}), + ...(input.entityId !== undefined ? { entityId: input.entityId } : {}), + ...(input.entityType !== undefined ? { entityType: input.entityType } : {}), + ...(input.link !== undefined ? { link: input.link } : {}), + channel: "in_app", + }, + }); +} + +export async function updateReminder( + ctx: NotificationProcedureContext, + input: UpdateReminderInput, +) { + const userId = await resolveUserId(ctx); + const existing = await ctx.db.notification.findFirst({ + where: { id: input.id, userId, category: "REMINDER" }, + }); + + if (!existing) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Reminder not found or you do not have permission", + }); + } + + return ctx.db.notification.update({ + where: { id: input.id }, + data: { + ...(input.title !== undefined ? { title: input.title } : {}), + ...(input.body !== undefined ? { body: input.body } : {}), + ...(input.remindAt !== undefined + ? { remindAt: input.remindAt, nextRemindAt: input.remindAt } + : {}), + ...(input.recurrence !== undefined ? { recurrence: input.recurrence } : {}), + }, + }); +} + +export async function deleteReminder( + ctx: NotificationProcedureContext, + input: NotificationIdInput, +) { + const userId = await resolveUserId(ctx); + const existing = await ctx.db.notification.findFirst({ + where: { id: input.id, userId, category: "REMINDER" }, + }); + + if (!existing) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Reminder not found or you do not have permission", + }); + } + + await ctx.db.notification.delete({ where: { id: input.id } }); +} + +export async function listReminders( + ctx: NotificationProcedureContext, + input: ListRemindersInput, +) { + const userId = await resolveUserId(ctx); + return ctx.db.notification.findMany({ + where: { userId, category: "REMINDER" }, + orderBy: { nextRemindAt: "asc" }, + take: input.limit, + }); +} + +export async function createBroadcast( + ctx: NotificationProcedureContext, + input: CreateBroadcastInput, +) { + const senderId = ctx.dbUser.id; + const isScheduledFutureBroadcast = Boolean(input.scheduledAt && input.scheduledAt > new Date()); + const hasTaskLikeMetadata = + input.category === "TASK" + || input.category === "APPROVAL" + || input.taskAction !== undefined + || input.dueDate !== undefined; + + if (isScheduledFutureBroadcast && hasTaskLikeMetadata) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: SCHEDULED_TASK_BROADCAST_UNSUPPORTED_MESSAGE, + }); + } + + const recipientIds = await resolveRecipients( + input.targetType, + input.targetValue, + ctx.db, + senderId, + ); + + if (recipientIds.length === 0) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "No recipients matched the broadcast target.", + }); + } + + if (isScheduledFutureBroadcast) { + return ctx.db.notificationBroadcast.create({ + data: { + senderId, + title: input.title, + ...(input.body !== undefined ? { body: input.body } : {}), + ...(input.link !== undefined ? { link: input.link } : {}), + category: input.category, + priority: input.priority, + channel: input.channel, + targetType: input.targetType, + ...(input.targetValue !== undefined ? { targetValue: input.targetValue } : {}), + ...(input.scheduledAt !== undefined ? { scheduledAt: input.scheduledAt } : {}), + recipientCount: recipientIds.length, + }, + }); + } + + const isTask = input.category === "TASK" || input.category === "APPROVAL"; + const sentAt = new Date(); + const runImmediateBroadcast = async (db: typeof ctx.db) => { + const broadcast = await db.notificationBroadcast.create({ + data: { + senderId, + title: input.title, + ...(input.body !== undefined ? { body: input.body } : {}), + ...(input.link !== undefined ? { link: input.link } : {}), + category: input.category, + priority: input.priority, + channel: input.channel, + targetType: input.targetType, + ...(input.targetValue !== undefined ? { targetValue: input.targetValue } : {}), + ...(input.scheduledAt !== undefined ? { scheduledAt: input.scheduledAt } : {}), + }, + }); + + const createdNotificationIds: Array<{ id: string; userId: string }> = []; + for (const recipientUserId of recipientIds) { + const notificationId = await createNotification({ + db, + userId: recipientUserId, + type: `BROADCAST_${input.category}`, + title: input.title, + body: input.body, + link: input.link, + category: input.category, + priority: input.priority, + channel: input.channel, + sourceId: broadcast.id, + senderId, + taskStatus: isTask ? "OPEN" : undefined, + taskAction: input.taskAction, + dueDate: input.dueDate, + emit: false, + }); + createdNotificationIds.push({ id: notificationId, userId: recipientUserId }); + } + + const updatedBroadcast = await db.notificationBroadcast.update({ + where: { id: broadcast.id }, + data: { + sentAt, + recipientCount: createdNotificationIds.length, + }, + }); + + return { broadcast: updatedBroadcast, notificationIds: createdNotificationIds }; + }; + + let persistedBroadcast: Awaited>["broadcast"]; + let notificationIds: Array<{ id: string; userId: string }> = []; + + try { + const transactionResult = typeof ctx.db.$transaction === "function" + ? await ctx.db.$transaction((tx) => runImmediateBroadcast(tx as typeof ctx.db)) + : await runImmediateBroadcast(ctx.db); + + persistedBroadcast = transactionResult.broadcast; + notificationIds = transactionResult.notificationIds; + } catch (error) { + rethrowNotificationReferenceError(error); + } + + for (const notification of notificationIds) { + emitNotificationCreated(notification.userId, notification.id); + if (isTask) { + emitTaskAssigned(notification.userId, notification.id); + } + } + + if (input.channel === "email" || input.channel === "both") { + for (const notification of notificationIds) { + void sendNotificationEmail(ctx.db, notification.userId, input.title, input.body); + } + } + + return persistedBroadcast; +} + +export async function listBroadcasts( + ctx: NotificationProcedureContext, + input: ListBroadcastsInput, +) { + return ctx.db.notificationBroadcast.findMany({ + orderBy: { createdAt: "desc" }, + take: input.limit, + include: { + sender: { select: { id: true, name: true, email: true } }, + }, + }); +} + +export async function getBroadcastById( + ctx: NotificationProcedureContext, + input: NotificationIdInput, +) { + return findUniqueOrThrow( + ctx.db.notificationBroadcast.findUnique({ + where: { id: input.id }, + include: { + sender: { select: { id: true, name: true, email: true } }, + }, + }), + "Broadcast", + ); +} + +export async function createTask( + ctx: NotificationProcedureContext, + input: CreateTaskInput, +) { + const senderId = ctx.dbUser.id; + const notificationId = await createNotification({ + db: ctx.db, + userId: input.userId, + type: "TASK_CREATED", + category: "TASK", + taskStatus: "OPEN", + title: input.title, + priority: input.priority, + senderId, + channel: input.channel, + body: input.body, + dueDate: input.dueDate, + taskAction: input.taskAction, + entityId: input.entityId, + entityType: input.entityType, + link: input.link, + }); + + emitTaskAssigned(input.userId, notificationId); + + if (input.channel === "email" || input.channel === "both") { + void sendNotificationEmail(ctx.db, input.userId, input.title, input.body); + } + + return ctx.db.notification.findUnique({ where: { id: notificationId } }); +} + +export async function assignTask( + ctx: NotificationProcedureContext, + input: AssignTaskInput, +) { + const existing = await findUniqueOrThrow( + ctx.db.notification.findUnique({ where: { id: input.id } }), + "Task", + ); + + if (existing.category !== "TASK" && existing.category !== "APPROVAL") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Only tasks and approvals can be assigned", + }); + } + + const updated = await ctx.db.notification.update({ + where: { id: input.id }, + data: { assigneeId: input.assigneeId }, + }); + + emitTaskAssigned(input.assigneeId, updated.id); + return updated; +} + +export async function deleteNotification( + ctx: NotificationProcedureContext, + input: NotificationIdInput, +) { + const userId = await resolveUserId(ctx); + const existing = await ctx.db.notification.findFirst({ + where: { id: input.id, userId }, + }); + + if (!existing) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Notification not found", + }); + } + + if ( + (existing.category === "TASK" || existing.category === "APPROVAL") + && existing.senderId + && existing.senderId !== userId + ) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Cannot delete tasks created by others", + }); + } + + await ctx.db.notification.delete({ where: { id: input.id } }); +} diff --git a/packages/api/src/router/notification.ts b/packages/api/src/router/notification.ts index 3ff5378..21a9fdf 100644 --- a/packages/api/src/router/notification.ts +++ b/packages/api/src/router/notification.ts @@ -1,944 +1,111 @@ -import { z } from "zod"; -import { TRPCError } from "@trpc/server"; -import { PermissionKey, parseTaskAction, resolvePermissions } from "@capakraken/shared"; -import { findUniqueOrThrow } from "../db/helpers.js"; import { createTRPCRouter, managerProcedure, protectedProcedure } from "../trpc.js"; import { - emitNotificationCreated, - emitTaskAssigned, - emitTaskCompleted, - emitTaskStatusChanged, - emitBroadcastSent, -} from "../sse/event-bus.js"; -import { createNotification } from "../lib/create-notification.js"; -import { resolveRecipients } from "../lib/notification-targeting.js"; -import { sendEmail } from "../lib/email.js"; -import { getTaskAction } from "../lib/task-actions.js"; - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -/** Resolve the DB user id from the session email. Throws UNAUTHORIZED if not found. */ -async function resolveUserId(ctx: { - db: { - user: { - findUnique: (args: { - where: { email: string }; - select: { id: true }; - }) => Promise<{ id: string } | null>; - }; - }; - session: { user?: { email?: string | null } | null }; -}): Promise { - const email = ctx.session.user?.email; - if (!email) throw new TRPCError({ code: "UNAUTHORIZED" }); - const user = await ctx.db.user.findUnique({ - where: { email }, - select: { id: true }, - }); - if (!user) throw new TRPCError({ code: "UNAUTHORIZED" }); - return user.id; -} - -/** Send email notification (non-blocking). */ -async function sendNotificationEmail( - db: { user: { findUnique: (args: { where: { id: string }; select: { email: true; name: true } }) => Promise<{ email: string; name: string | null } | null> } }, - userId: string, - title: string, - body?: string | null, -): Promise { - try { - const user = await db.user.findUnique({ - where: { id: userId }, - select: { email: true, name: true }, - }); - if (!user) return; - void sendEmail({ - to: user.email, - subject: title, - text: body ?? title, - ...(body !== undefined && body !== null ? { html: `

${body}

` } : {}), - }); - } catch { - // Non-blocking — swallow errors - } -} - -function getNotificationErrorCandidates(error: unknown): Array<{ - code?: unknown; - message?: unknown; - meta?: { field_name?: unknown; modelName?: unknown }; - cause?: unknown; -}> { - const queue: unknown[] = [error]; - const seen = new Set(); - const candidates: Array<{ - code?: unknown; - message?: unknown; - meta?: { field_name?: unknown; modelName?: unknown }; - cause?: unknown; - }> = []; - - while (queue.length > 0) { - const current = queue.shift(); - if (!current || seen.has(current) || typeof current !== "object") { - continue; - } - seen.add(current); - - const candidate = current as { - code?: unknown; - message?: unknown; - meta?: { field_name?: unknown; modelName?: unknown }; - cause?: unknown; - shape?: { message?: unknown; data?: { cause?: unknown } }; - }; - candidates.push(candidate); - - if ("cause" in candidate) { - queue.push(candidate.cause); - } - if (candidate.shape?.data?.cause) { - queue.push(candidate.shape.data.cause); - } - } - - return candidates; -} - -function rethrowNotificationReferenceError(error: unknown): never { - for (const candidate of getNotificationErrorCandidates(error)) { - const fieldName = typeof candidate.meta?.field_name === "string" - ? candidate.meta.field_name.toLowerCase() - : ""; - const modelName = typeof candidate.meta?.modelName === "string" - ? candidate.meta.modelName.toLowerCase() - : ""; - - if ( - typeof candidate.code === "string" - && (candidate.code === "P2003" || candidate.code === "P2025") - && fieldName.includes("sender") - ) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Sender user not found", - cause: error, - }); - } - - if ( - typeof candidate.code === "string" - && (candidate.code === "P2003" || candidate.code === "P2025") - && fieldName.includes("userid") - ) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Broadcast recipient user not found", - cause: error, - }); - } - - if ( - typeof candidate.code === "string" - && (candidate.code === "P2003" || candidate.code === "P2025") - && (modelName.includes("notificationbroadcast") || fieldName.includes("broadcast")) - ) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Notification broadcast not found", - cause: error, - }); - } - } - - throw error; -} - -// ─── Zod Enums ──────────────────────────────────────────────────────────────── - -const categoryEnum = z.enum(["NOTIFICATION", "REMINDER", "TASK", "APPROVAL"]); -const priorityEnum = z.enum(["LOW", "NORMAL", "HIGH", "URGENT"]); -const taskStatusEnum = z.enum(["OPEN", "IN_PROGRESS", "DONE", "DISMISSED"]); -const channelEnum = z.enum(["in_app", "email", "both"]); -const recurrenceEnum = z.enum(["daily", "weekly", "monthly"]); -const targetTypeEnum = z.enum(["user", "role", "project", "orgUnit", "all"]); - -// ─── Router ─────────────────────────────────────────────────────────────────── + AssignTaskInputSchema, + CreateBroadcastInputSchema, + CreateManagedNotificationInputSchema, + CreateReminderInputSchema, + CreateTaskInputSchema, + ListBroadcastsInputSchema, + ListNotificationTasksInputSchema, + ListRemindersInputSchema, + MarkNotificationReadInputSchema, + NotificationIdInputSchema, + NotificationListInputSchema, + UpdateNotificationTaskStatusInputSchema, + UpdateReminderInputSchema, + assignTask, + countUnreadNotifications, + createBroadcast, + createManagedNotification, + createReminder, + createTask, + deleteNotification, + deleteReminder, + executeNotificationTaskAction, + getBroadcastById, + getNotificationTaskCounts, + getNotificationTaskDetail, + listBroadcasts, + listNotificationTasks, + listNotifications, + listReminders, + markNotificationsRead, + updateNotificationTaskStatus, + updateReminder, +} from "./notification-procedure-support.js"; export const notificationRouter = createTRPCRouter({ - // ═══════════════════════════════════════════════════════════════════════════ - // EXISTING (enhanced) - // ═══════════════════════════════════════════════════════════════════════════ - - /** List notifications for the current user with optional filters */ list: protectedProcedure - .input( - z.object({ - unreadOnly: z.boolean().optional(), - category: categoryEnum.optional(), - taskStatus: taskStatusEnum.optional(), - priority: priorityEnum.optional(), - limit: z.number().min(1).max(100).default(50), - }), - ) - .query(async ({ ctx, input }) => { - const userId = await resolveUserId(ctx); - return ctx.db.notification.findMany({ - where: { - userId, - ...(input.unreadOnly ? { readAt: null } : {}), - ...(input.category !== undefined ? { category: input.category } : {}), - ...(input.taskStatus !== undefined ? { taskStatus: input.taskStatus } : {}), - ...(input.priority !== undefined ? { priority: input.priority } : {}), - }, - orderBy: { createdAt: "desc" }, - take: input.limit, - }); - }), + .input(NotificationListInputSchema) + .query(({ ctx, input }) => listNotifications(ctx, input)), - /** Count unread notifications */ - unreadCount: protectedProcedure.query(async ({ ctx }) => { - const userId = await resolveUserId(ctx); - return ctx.db.notification.count({ - where: { userId, readAt: null }, - }); - }), + unreadCount: protectedProcedure + .query(({ ctx }) => countUnreadNotifications(ctx)), - /** Mark one or all as read */ markRead: protectedProcedure - .input(z.object({ id: z.string().optional() })) - .mutation(async ({ ctx, input }) => { - const userId = await resolveUserId(ctx); - const now = new Date(); - if (input.id) { - await ctx.db.notification.update({ - where: { id: input.id, userId }, - data: { readAt: now }, - }); - } else { - await ctx.db.notification.updateMany({ - where: { userId, readAt: null }, - data: { readAt: now }, - }); - } - }), + .input(MarkNotificationReadInputSchema) + .mutation(({ ctx, input }) => markNotificationsRead(ctx, input)), - /** Create a notification (enhanced with all new fields) */ create: managerProcedure - .input( - z.object({ - userId: z.string(), - type: z.string(), - title: z.string(), - body: z.string().optional(), - entityId: z.string().optional(), - entityType: z.string().optional(), - // New fields - category: categoryEnum.optional(), - priority: priorityEnum.optional(), - link: z.string().optional(), - taskStatus: taskStatusEnum.optional(), - taskAction: z.string().optional(), - assigneeId: z.string().optional(), - dueDate: z.date().optional(), - channel: channelEnum.optional(), - senderId: z.string().optional(), - }), - ) - .mutation(async ({ ctx, input }) => { - const currentUserId = ctx.dbUser.id; + .input(CreateManagedNotificationInputSchema) + .mutation(({ ctx, input }) => createManagedNotification(ctx, input)), - const notificationId = await createNotification({ - db: ctx.db, - userId: input.userId, - type: input.type, - title: input.title, - body: input.body, - entityId: input.entityId, - entityType: input.entityType, - category: input.category, - priority: input.priority, - link: input.link, - taskStatus: input.taskStatus, - taskAction: input.taskAction, - assigneeId: input.assigneeId, - dueDate: input.dueDate, - channel: input.channel, - senderId: input.senderId ?? currentUserId, - }); - - // Emit task-specific events - if (input.category === "TASK" || input.category === "APPROVAL") { - emitTaskAssigned(input.userId, notificationId); - } - - // Email if channel includes email - const channel = input.channel ?? "in_app"; - if (channel === "email" || channel === "both") { - void sendNotificationEmail(ctx.db, input.userId, input.title, input.body); - } - - // Re-fetch for return value (to maintain API contract) - const n = await ctx.db.notification.findUnique({ where: { id: notificationId } }); - return n; - }), - - // ═══════════════════════════════════════════════════════════════════════════ - // TASK MANAGEMENT - // ═══════════════════════════════════════════════════════════════════════════ - - /** List tasks for the current user (as owner or assignee) */ listTasks: protectedProcedure - .input( - z.object({ - status: taskStatusEnum.optional(), - includeAssigned: z.boolean().default(true), - limit: z.number().min(1).max(100).default(20), - }), - ) - .query(async ({ ctx, input }) => { - const userId = await resolveUserId(ctx); + .input(ListNotificationTasksInputSchema) + .query(({ ctx, input }) => listNotificationTasks(ctx, input)), - const userFilter = input.includeAssigned - ? { OR: [{ userId }, { assigneeId: userId }] } - : { userId }; + taskCounts: protectedProcedure + .query(({ ctx }) => getNotificationTaskCounts(ctx)), - return ctx.db.notification.findMany({ - where: { - ...userFilter, - category: { in: ["TASK", "APPROVAL"] }, - ...(input.status !== undefined ? { taskStatus: input.status } : {}), - }, - orderBy: [{ priority: "desc" }, { dueDate: "asc" }, { createdAt: "desc" }], - take: input.limit, - }); - }), - - /** Get task counts for the current user — single groupBy instead of 5 counts */ - taskCounts: protectedProcedure.query(async ({ ctx }) => { - const userId = await resolveUserId(ctx); - const now = new Date(); - - const where = { - OR: [{ userId }, { assigneeId: userId }], - category: { in: ["TASK" as const, "APPROVAL" as const] }, - }; - - const [grouped, overdue] = await Promise.all([ - ctx.db.notification.groupBy({ - by: ["taskStatus"], - where, - _count: true, - }), - ctx.db.notification.count({ - where: { - ...where, - taskStatus: { in: ["OPEN", "IN_PROGRESS"] }, - dueDate: { lt: now }, - }, - }), - ]); - - const counts: Record = {}; - for (const g of grouped) { - if (g.taskStatus) counts[g.taskStatus] = g._count; - } - - return { - open: counts["OPEN"] ?? 0, - inProgress: counts["IN_PROGRESS"] ?? 0, - done: counts["DONE"] ?? 0, - dismissed: counts["DISMISSED"] ?? 0, - overdue, - }; - }), - - /** Get one task/approval visible to the current user */ getTaskDetail: protectedProcedure - .input(z.object({ id: z.string() })) - .query(async ({ ctx, input }) => { - const userId = await resolveUserId(ctx); + .input(NotificationIdInputSchema) + .query(({ ctx, input }) => getNotificationTaskDetail(ctx, input)), - const task = await ctx.db.notification.findFirst({ - where: { - id: input.id, - OR: [{ userId }, { assigneeId: userId }], - category: { in: ["TASK", "APPROVAL"] }, - }, - select: { - id: true, - title: true, - body: true, - type: true, - priority: true, - category: true, - taskStatus: true, - taskAction: true, - dueDate: true, - entityId: true, - entityType: true, - completedAt: true, - completedBy: true, - createdAt: true, - userId: true, - assigneeId: true, - sender: { select: { id: true, name: true, email: true } }, - }, - }); - - if (!task) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Task not found or you do not have permission", - }); - } - - return task; - }), - - /** Update task status */ updateTaskStatus: protectedProcedure - .input( - z.object({ - id: z.string(), - status: taskStatusEnum, - }), - ) - .mutation(async ({ ctx, input }) => { - const userId = await resolveUserId(ctx); + .input(UpdateNotificationTaskStatusInputSchema) + .mutation(({ ctx, input }) => updateNotificationTaskStatus(ctx, input)), - // Only allow if userId or assigneeId matches - const existing = await ctx.db.notification.findFirst({ - where: { - id: input.id, - OR: [{ userId }, { assigneeId: userId }], - }, - }); - - if (!existing) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Task not found or you do not have permission", - }); - } - - const isCompleting = input.status === "DONE"; - - const updated = await ctx.db.notification.update({ - where: { id: input.id }, - data: { - taskStatus: input.status, - ...(isCompleting ? { completedAt: new Date(), completedBy: userId } : {}), - }, - }); - - if (isCompleting) { - emitTaskCompleted(existing.userId, updated.id); - // Also notify assignee if different - if (existing.assigneeId && existing.assigneeId !== existing.userId) { - emitTaskCompleted(existing.assigneeId, updated.id); - } - } else { - emitTaskStatusChanged(existing.userId, updated.id); - if (existing.assigneeId && existing.assigneeId !== existing.userId) { - emitTaskStatusChanged(existing.assigneeId, updated.id); - } - } - - return updated; - }), - - /** Execute the machine-readable action associated with a task */ executeTaskAction: protectedProcedure - .input(z.object({ id: z.string() })) - .mutation(async ({ ctx, input }) => { - const userId = await resolveUserId(ctx); - const task = await ctx.db.notification.findFirst({ - where: { - id: input.id, - OR: [{ userId }, { assigneeId: userId }], - category: { in: ["TASK", "APPROVAL"] }, - }, - select: { - id: true, - userId: true, - assigneeId: true, - taskAction: true, - taskStatus: true, - }, - }); + .input(NotificationIdInputSchema) + .mutation(({ ctx, input }) => executeNotificationTaskAction(ctx, input)), - if (!task) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Task not found or you do not have permission", - }); - } - if (!task.taskAction) { - throw new TRPCError({ - code: "PRECONDITION_FAILED", - message: "This task has no executable action", - }); - } - if (task.taskStatus === "DONE") { - throw new TRPCError({ - code: "PRECONDITION_FAILED", - message: "This task is already completed", - }); - } - - const parsed = parseTaskAction(task.taskAction); - if (!parsed) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Invalid taskAction format: ${task.taskAction}`, - }); - } - - const handler = getTaskAction(parsed.action); - if (!handler) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Unknown action: ${parsed.action}`, - }); - } - - const permissions = resolvePermissions( - ctx.dbUser.systemRole as import("@capakraken/shared").SystemRole, - ctx.dbUser.permissionOverrides as import("@capakraken/shared").PermissionOverrides | null, - ctx.roleDefaults ?? undefined, - ); - if (handler.permission && !permissions.has(handler.permission as PermissionKey)) { - throw new TRPCError({ - code: "FORBIDDEN", - message: `Permission denied: you need "${handler.permission}" to perform this action`, - }); - } - - const actionResult = await handler.execute(parsed.entityId, ctx.db, userId); - if (!actionResult.success) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: actionResult.message, - }); - } - - const completedTask = await ctx.db.notification.update({ - where: { id: input.id }, - data: { - taskStatus: "DONE", - completedAt: new Date(), - completedBy: userId, - }, - }); - - emitTaskCompleted(task.userId, task.id); - if (task.assigneeId && task.assigneeId !== task.userId) { - emitTaskCompleted(task.assigneeId, task.id); - } - - return { - task: completedTask, - actionResult, - }; - }), - - // ═══════════════════════════════════════════════════════════════════════════ - // REMINDERS - // ═══════════════════════════════════════════════════════════════════════════ - - /** Create a personal reminder */ createReminder: protectedProcedure - .input( - z.object({ - title: z.string().min(1).max(200), - body: z.string().max(2000).optional(), - remindAt: z.date(), - recurrence: recurrenceEnum.optional(), - entityId: z.string().optional(), - entityType: z.string().optional(), - link: z.string().optional(), - }), - ) - .mutation(async ({ ctx, input }) => { - const userId = await resolveUserId(ctx); + .input(CreateReminderInputSchema) + .mutation(({ ctx, input }) => createReminder(ctx, input)), - // Reminders have extra fields (remindAt, nextRemindAt, recurrence) not covered - // by the generic helper, so we keep the direct create here but still use - // the exactOptionalPropertyTypes spread pattern. - return ctx.db.notification.create({ - data: { - userId, - type: "REMINDER", - category: "REMINDER", - title: input.title, - ...(input.body !== undefined ? { body: input.body } : {}), - remindAt: input.remindAt, - nextRemindAt: input.remindAt, - ...(input.recurrence !== undefined ? { recurrence: input.recurrence } : {}), - ...(input.entityId !== undefined ? { entityId: input.entityId } : {}), - ...(input.entityType !== undefined ? { entityType: input.entityType } : {}), - ...(input.link !== undefined ? { link: input.link } : {}), - channel: "in_app", - }, - }); - }), - - /** Update a personal reminder */ updateReminder: protectedProcedure - .input( - z.object({ - id: z.string(), - title: z.string().min(1).max(200).optional(), - body: z.string().max(2000).optional(), - remindAt: z.date().optional(), - recurrence: recurrenceEnum.nullish(), - }), - ) - .mutation(async ({ ctx, input }) => { - const userId = await resolveUserId(ctx); + .input(UpdateReminderInputSchema) + .mutation(({ ctx, input }) => updateReminder(ctx, input)), - // Verify ownership - const existing = await ctx.db.notification.findFirst({ - where: { id: input.id, userId, category: "REMINDER" }, - }); - if (!existing) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Reminder not found or you do not have permission", - }); - } - - return ctx.db.notification.update({ - where: { id: input.id }, - data: { - ...(input.title !== undefined ? { title: input.title } : {}), - ...(input.body !== undefined ? { body: input.body } : {}), - ...(input.remindAt !== undefined - ? { remindAt: input.remindAt, nextRemindAt: input.remindAt } - : {}), - // recurrence can be set to null (clear it) or a new value - ...(input.recurrence !== undefined - ? { recurrence: input.recurrence } - : {}), - }, - }); - }), - - /** Delete a personal reminder */ deleteReminder: protectedProcedure - .input(z.object({ id: z.string() })) - .mutation(async ({ ctx, input }) => { - const userId = await resolveUserId(ctx); + .input(NotificationIdInputSchema) + .mutation(({ ctx, input }) => deleteReminder(ctx, input)), - const existing = await ctx.db.notification.findFirst({ - where: { id: input.id, userId, category: "REMINDER" }, - }); - if (!existing) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Reminder not found or you do not have permission", - }); - } - - await ctx.db.notification.delete({ where: { id: input.id } }); - }), - - /** List personal reminders */ listReminders: protectedProcedure - .input(z.object({ limit: z.number().min(1).max(100).default(20) })) - .query(async ({ ctx, input }) => { - const userId = await resolveUserId(ctx); + .input(ListRemindersInputSchema) + .query(({ ctx, input }) => listReminders(ctx, input)), - return ctx.db.notification.findMany({ - where: { userId, category: "REMINDER" }, - orderBy: { nextRemindAt: "asc" }, - take: input.limit, - }); - }), - - // ═══════════════════════════════════════════════════════════════════════════ - // BROADCASTS (Manager+) - // ═══════════════════════════════════════════════════════════════════════════ - - /** Create and send a broadcast notification */ createBroadcast: managerProcedure - .input( - z.object({ - title: z.string().min(1).max(200), - body: z.string().max(2000).optional(), - link: z.string().optional(), - category: categoryEnum.default("NOTIFICATION"), - priority: priorityEnum.default("NORMAL"), - channel: channelEnum.default("in_app"), - targetType: targetTypeEnum, - targetValue: z.string().optional(), - scheduledAt: z.date().optional(), - taskAction: z.string().optional(), - dueDate: z.date().optional(), - }), - ) - .mutation(async ({ ctx, input }) => { - const senderId = ctx.dbUser.id; + .input(CreateBroadcastInputSchema) + .mutation(({ ctx, input }) => createBroadcast(ctx, input)), - // Scheduled broadcasts can be stored immediately because fan-out is deferred. - if (input.scheduledAt && input.scheduledAt > new Date()) { - return ctx.db.notificationBroadcast.create({ - data: { - senderId, - title: input.title, - ...(input.body !== undefined ? { body: input.body } : {}), - ...(input.link !== undefined ? { link: input.link } : {}), - category: input.category, - priority: input.priority, - channel: input.channel, - targetType: input.targetType, - ...(input.targetValue !== undefined ? { targetValue: input.targetValue } : {}), - ...(input.scheduledAt !== undefined ? { scheduledAt: input.scheduledAt } : {}), - }, - }); - } - - // Resolve recipients before persisting immediate broadcasts so empty targets - // do not leave orphaned broadcast rows behind. - const recipientIds = await resolveRecipients( - input.targetType, - input.targetValue, - ctx.db, - senderId, - ); - - if (recipientIds.length === 0) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "No recipients matched the broadcast target.", - }); - } - - const isTask = input.category === "TASK" || input.category === "APPROVAL"; - const runImmediateBroadcast = async (db: typeof ctx.db) => { - const broadcast = await db.notificationBroadcast.create({ - data: { - senderId, - title: input.title, - ...(input.body !== undefined ? { body: input.body } : {}), - ...(input.link !== undefined ? { link: input.link } : {}), - category: input.category, - priority: input.priority, - channel: input.channel, - targetType: input.targetType, - ...(input.targetValue !== undefined ? { targetValue: input.targetValue } : {}), - ...(input.scheduledAt !== undefined ? { scheduledAt: input.scheduledAt } : {}), - }, - }); - - const createdNotificationIds: Array<{ id: string; userId: string }> = []; - for (const recipientUserId of recipientIds) { - const nId = await createNotification({ - db, - userId: recipientUserId, - type: `BROADCAST_${input.category}`, - title: input.title, - body: input.body, - link: input.link, - category: input.category, - priority: input.priority, - channel: input.channel, - sourceId: broadcast.id, - senderId, - taskStatus: isTask ? "OPEN" : undefined, - taskAction: input.taskAction, - dueDate: input.dueDate, - emit: false, - }); - createdNotificationIds.push({ id: nId, userId: recipientUserId }); - } - - const updatedBroadcast = await db.notificationBroadcast.update({ - where: { id: broadcast.id }, - data: { - sentAt, - recipientCount: createdNotificationIds.length, - }, - }); - - return { broadcast: updatedBroadcast, notificationIds: createdNotificationIds }; - }; - - let persistedBroadcast: Awaited>["broadcast"]; - let notificationIds: Array<{ id: string; userId: string }> = []; - const sentAt = new Date(); - try { - const transactionResult = typeof ctx.db.$transaction === "function" - ? await ctx.db.$transaction((tx) => runImmediateBroadcast(tx as typeof ctx.db)) - : await runImmediateBroadcast(ctx.db); - - persistedBroadcast = transactionResult.broadcast; - notificationIds = transactionResult.notificationIds; - } catch (error) { - rethrowNotificationReferenceError(error); - } - - // 5. Emit side effects only after the transaction commits. - for (const notification of notificationIds) { - emitNotificationCreated(notification.userId, notification.id); - if (isTask) { - emitTaskAssigned(notification.userId, notification.id); - } - } - - emitBroadcastSent(persistedBroadcast.id, notificationIds.length); - - // 6. Send emails if channel includes email (non-blocking) - if (input.channel === "email" || input.channel === "both") { - for (const n of notificationIds) { - void sendNotificationEmail(ctx.db, n.userId, input.title, input.body); - } - } - - return persistedBroadcast; - }), - - /** List broadcasts */ listBroadcasts: managerProcedure - .input(z.object({ limit: z.number().min(1).max(50).default(20) })) - .query(async ({ ctx, input }) => { - return ctx.db.notificationBroadcast.findMany({ - orderBy: { createdAt: "desc" }, - take: input.limit, - include: { - sender: { select: { id: true, name: true, email: true } }, - }, - }); - }), + .input(ListBroadcastsInputSchema) + .query(({ ctx, input }) => listBroadcasts(ctx, input)), - /** Get one broadcast with sender context */ getBroadcastById: managerProcedure - .input(z.object({ id: z.string() })) - .query(async ({ ctx, input }) => { - return findUniqueOrThrow( - ctx.db.notificationBroadcast.findUnique({ - where: { id: input.id }, - include: { - sender: { select: { id: true, name: true, email: true } }, - }, - }), - "Broadcast", - ); - }), + .input(NotificationIdInputSchema) + .query(({ ctx, input }) => getBroadcastById(ctx, input)), - // ═══════════════════════════════════════════════════════════════════════════ - // TASK CREATION (Manager+) - // ═══════════════════════════════════════════════════════════════════════════ - - /** Create a task for a specific user */ createTask: managerProcedure - .input( - z.object({ - userId: z.string(), - title: z.string().min(1).max(200), - body: z.string().max(2000).optional(), - priority: priorityEnum.default("NORMAL"), - dueDate: z.date().optional(), - taskAction: z.string().optional(), - entityId: z.string().optional(), - entityType: z.string().optional(), - link: z.string().optional(), - channel: channelEnum.default("in_app"), - }), - ) - .mutation(async ({ ctx, input }) => { - const senderId = ctx.dbUser.id; + .input(CreateTaskInputSchema) + .mutation(({ ctx, input }) => createTask(ctx, input)), - const notificationId = await createNotification({ - db: ctx.db, - userId: input.userId, - type: "TASK_CREATED", - category: "TASK", - taskStatus: "OPEN", - title: input.title, - priority: input.priority, - senderId, - channel: input.channel, - body: input.body, - dueDate: input.dueDate, - taskAction: input.taskAction, - entityId: input.entityId, - entityType: input.entityType, - link: input.link, - }); - - emitTaskAssigned(input.userId, notificationId); - - // Send email if channel includes email - if (input.channel === "email" || input.channel === "both") { - void sendNotificationEmail(ctx.db, input.userId, input.title, input.body); - } - - // Re-fetch for return value - const n = await ctx.db.notification.findUnique({ where: { id: notificationId } }); - return n; - }), - - /** Reassign a task to another user */ assignTask: managerProcedure - .input(z.object({ id: z.string(), assigneeId: z.string() })) - .mutation(async ({ ctx, input }) => { - const existing = await findUniqueOrThrow( - ctx.db.notification.findUnique({ where: { id: input.id } }), - "Task", - ); + .input(AssignTaskInputSchema) + .mutation(({ ctx, input }) => assignTask(ctx, input)), - if (existing.category !== "TASK" && existing.category !== "APPROVAL") { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Only tasks and approvals can be assigned", - }); - } - - const updated = await ctx.db.notification.update({ - where: { id: input.id }, - data: { assigneeId: input.assigneeId }, - }); - - emitTaskAssigned(input.assigneeId, updated.id); - - return updated; - }), - - // ═══════════════════════════════════════════════════════════════════════════ - // DELETE - // ═══════════════════════════════════════════════════════════════════════════ - - /** Delete own notification */ delete: protectedProcedure - .input(z.object({ id: z.string() })) - .mutation(async ({ ctx, input }) => { - const userId = await resolveUserId(ctx); - - const existing = await ctx.db.notification.findFirst({ - where: { id: input.id, userId }, - }); - - if (!existing) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Notification not found", - }); - } - - // Cannot delete tasks created by others (senderId differs) - if ( - (existing.category === "TASK" || existing.category === "APPROVAL") && - existing.senderId && - existing.senderId !== userId - ) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "Cannot delete tasks created by others", - }); - } - - await ctx.db.notification.delete({ where: { id: input.id } }); - }), + .input(NotificationIdInputSchema) + .mutation(({ ctx, input }) => deleteNotification(ctx, input)), });