diff --git a/packages/api/src/__tests__/notification-procedure-support.test.ts b/packages/api/src/__tests__/notification-procedure-support.test.ts index 51019bf..e73f703 100644 --- a/packages/api/src/__tests__/notification-procedure-support.test.ts +++ b/packages/api/src/__tests__/notification-procedure-support.test.ts @@ -82,6 +82,24 @@ describe("notification procedure support", () => { } }); + it("rewrites assignee foreign-key errors to a not found TRPC error", () => { + const error = { + code: "P2003", + meta: { field_name: "Notification_assigneeId_fkey" }, + }; + + try { + rethrowNotificationReferenceError(error); + throw new Error("expected notification reference error"); + } catch (caught) { + expect(caught).toBeInstanceOf(TRPCError); + expect(caught).toMatchObject>({ + code: "NOT_FOUND", + message: "Assignee user not found", + }); + } + }); + it("rewrites broadcast source foreign-key errors to a not found TRPC error", () => { const error = { code: "P2003", diff --git a/packages/api/src/__tests__/notification-router.test.ts b/packages/api/src/__tests__/notification-router.test.ts index 1d4679f..4fbadea 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 immediate broadcasts when transactional persistence support is unavailable", async () => { + resolveRecipientsMock.mockResolvedValue(["user_a", "user_b"]); + + const create = vi.fn(); + const update = vi.fn(); + const createNotification = vi.fn(); + const db = { + notificationBroadcast: { + create, + update, + }, + notification: { + create: createNotification, + }, + }; + + const caller = createManagerCaller(db); + + await expect(caller.createBroadcast({ + title: "Ops update", + targetType: "all", + })).rejects.toMatchObject({ + code: "INTERNAL_SERVER_ERROR", + message: "Immediate broadcasts require transactional persistence support.", + }); + + expect(resolveRecipientsMock).toHaveBeenCalledWith( + "all", + undefined, + db, + "user_mgr", + ); + expect(create).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + expect(createNotification).not.toHaveBeenCalled(); + expect(emitNotificationCreated).not.toHaveBeenCalled(); + expect(emitTaskAssigned).not.toHaveBeenCalled(); + }); + it("rejects broadcasts with no recipients before opening a broadcast transaction", async () => { resolveRecipientsMock.mockResolvedValue([]); @@ -1294,6 +1333,38 @@ describe("notification.assignTask", () => { }); expect(emitTaskAssigned).toHaveBeenCalledWith("user_4", "task_9"); }); + + it("returns NOT_FOUND when assigning a task to a missing assignee", async () => { + const findUnique = vi.fn().mockResolvedValue({ + id: "task_9", + category: "TASK", + assigneeId: "user_2", + }); + const update = vi.fn().mockRejectedValue({ + code: "P2003", + message: "Foreign key constraint failed", + meta: { field_name: "Notification_assigneeId_fkey" }, + }); + const db = { + notification: { + findUnique, + update, + }, + }; + + const caller = createManagerCaller(db); + await expect(caller.assignTask({ id: "task_9", assigneeId: "user_missing" })).rejects.toMatchObject({ + code: "NOT_FOUND", + message: "Assignee user not found", + }); + + expect(findUnique).toHaveBeenCalledWith({ where: { id: "task_9" } }); + expect(update).toHaveBeenCalledWith({ + where: { id: "task_9" }, + data: { assigneeId: "user_missing" }, + }); + expect(emitTaskAssigned).not.toHaveBeenCalledWith("user_missing", "task_9"); + }); }); // ─── reminders ────────────────────────────────────────────────────────────── diff --git a/packages/api/src/router/notification-broadcast-procedure-support.ts b/packages/api/src/router/notification-broadcast-procedure-support.ts index a5490e5..060d716 100644 --- a/packages/api/src/router/notification-broadcast-procedure-support.ts +++ b/packages/api/src/router/notification-broadcast-procedure-support.ts @@ -31,6 +31,19 @@ function hasTaskLikeBroadcastMetadata(input: z.infer { + if (typeof db.$transaction !== "function") { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Immediate broadcasts require transactional persistence support.", + }); + } + + return db.$transaction.bind(db); +} + function buildBroadcastCreateData( senderId: string, input: z.infer, @@ -178,10 +191,9 @@ export async function createBroadcast( let notificationIds: BroadcastRecipientNotification[] = []; try { - const transactionResult = typeof ctx.db.$transaction === "function" - ? await ctx.db.$transaction((tx) => - persistImmediateBroadcast(tx as typeof ctx.db, senderId, input, recipientIds)) - : await persistImmediateBroadcast(ctx.db, senderId, input, recipientIds); + const transaction = requireImmediateBroadcastTransaction(ctx.db); + const transactionResult = await transaction((tx) => + persistImmediateBroadcast(tx as typeof ctx.db, senderId, input, recipientIds)); persistedBroadcast = transactionResult.broadcast; notificationIds = transactionResult.notificationIds; diff --git a/packages/api/src/router/notification-procedure-base.ts b/packages/api/src/router/notification-procedure-base.ts index 6a584e2..cbf13a2 100644 --- a/packages/api/src/router/notification-procedure-base.ts +++ b/packages/api/src/router/notification-procedure-base.ts @@ -93,6 +93,18 @@ export function rethrowNotificationReferenceError(error: unknown): never { ? candidate.meta.modelName.toLowerCase() : ""; + if ( + typeof candidate.code === "string" + && (candidate.code === "P2003" || candidate.code === "P2025") + && fieldName.includes("assignee") + ) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Assignee user not found", + cause: error, + }); + } + if ( typeof candidate.code === "string" && (candidate.code === "P2003" || candidate.code === "P2025") diff --git a/packages/api/src/router/notification-task-procedure-support.ts b/packages/api/src/router/notification-task-procedure-support.ts index acc3cca..81281e7 100644 --- a/packages/api/src/router/notification-task-procedure-support.ts +++ b/packages/api/src/router/notification-task-procedure-support.ts @@ -15,6 +15,7 @@ import { ListNotificationTasksInputSchema, NotificationIdInputSchema, type NotificationProcedureContext, + rethrowNotificationReferenceError, requireNotificationDbUser, resolveUserId, sendNotificationEmail, @@ -312,10 +313,15 @@ export async function assignTask( }); } - const updated = await ctx.db.notification.update({ - where: { id: input.id }, - data: { assigneeId: input.assigneeId }, - }); + let updated; + try { + updated = await ctx.db.notification.update({ + where: { id: input.id }, + data: { assigneeId: input.assigneeId }, + }); + } catch (error) { + rethrowNotificationReferenceError(error); + } emitTaskAssigned(input.assigneeId, updated.id); return updated;