fix(api): harden broadcast and assistant fallback errors

This commit is contained in:
2026-03-30 12:03:27 +02:00
parent 22cff9648e
commit 6a6e98b5f7
5 changed files with 305 additions and 57 deletions
+62 -19
View File
@@ -63,6 +63,31 @@ async function sendNotificationEmail(
}
}
function rethrowNotificationReferenceError(error: unknown): never {
const candidate = error as {
code?: unknown;
message?: unknown;
meta?: { field_name?: unknown };
};
const fieldName = typeof candidate.meta?.field_name === "string"
? candidate.meta.field_name.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,
});
}
throw error;
}
// ─── Zod Enums ────────────────────────────────────────────────────────────────
const categoryEnum = z.enum(["NOTIFICATION", "REMINDER", "TASK", "APPROVAL"]);
@@ -590,28 +615,26 @@ export const notificationRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
const senderId = ctx.dbUser.id;
// 1. Create broadcast record
const broadcast = await 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 } : {}),
},
});
// 2. If scheduled in the future, just return the broadcast
// Scheduled broadcasts can be stored immediately because fan-out is deferred.
if (input.scheduledAt && input.scheduledAt > new Date()) {
return broadcast;
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 } : {}),
},
});
}
// 3. Resolve recipients
// Resolve recipients before persisting immediate broadcasts so empty targets
// do not leave orphaned broadcast rows behind.
const recipientIds = await resolveRecipients(
input.targetType,
input.targetValue,
@@ -626,6 +649,26 @@ export const notificationRouter = createTRPCRouter({
});
}
let broadcast;
try {
broadcast = await 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 } : {}),
},
});
} catch (error) {
rethrowNotificationReferenceError(error);
}
// 4. Create individual notifications for each recipient
const isTask = input.category === "TASK" || input.category === "APPROVAL";