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
+106 -26
View File
@@ -463,11 +463,6 @@ function toAssistantTimelineMutationError(
error: unknown,
context: "updateInline" | "applyShift" | "quickAssign" | "batchShift",
): AssistantToolErrorResult | null {
const allocationNotFound = toAssistantAllocationNotFoundError(error);
if (allocationNotFound && (context === "updateInline" || context === "batchShift")) {
return allocationNotFound;
}
if (error instanceof TRPCError) {
if (error.code === "NOT_FOUND") {
if (error.message.includes("Resource not found")) {
@@ -489,6 +484,11 @@ function toAssistantTimelineMutationError(
}
}
const allocationNotFound = toAssistantAllocationNotFoundError(error);
if (allocationNotFound && (context === "updateInline" || context === "batchShift")) {
return allocationNotFound;
}
const prismaError = getPrismaRequestErrorMetadata(error);
if (!prismaError) {
return null;
@@ -1115,21 +1115,11 @@ function toAssistantEstimateMutationError(
return null;
}
const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase();
if (errorText.includes("project")) {
return { error: "Project not found with the given criteria." };
}
if (errorText.includes("estimatedemandline") || errorText.includes("estimate_demand_line") || errorText.includes("estimate demand line")) {
return { error: "Estimate demand line not found with the given criteria." };
}
if (errorText.includes("estimateversion") || errorText.includes("estimate_version") || errorText.includes("estimate version")) {
return { error: "Estimate version not found with the given criteria." };
}
if (errorText.includes("estimate")) {
return { error: "Estimate not found with the given criteria." };
}
if (prismaError.code === "P2003") {
const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase();
if (errorText.includes("project")) {
return { error: "Project not found with the given criteria." };
}
if (errorText.includes("role")) {
return { error: "Role not found with the given criteria." };
}
@@ -1139,9 +1129,20 @@ function toAssistantEstimateMutationError(
if (errorText.includes("scopeitem") || errorText.includes("scope_item") || errorText.includes("scope item")) {
return { error: "Estimate scope item not found with the given criteria." };
}
return { error: "One of the referenced project, role, resource, or scope items no longer exists." };
}
if (prismaError.code === "P2025") {
const errorText = `${prismaError.message} ${prismaError.metaText}`.toLowerCase();
if (errorText.includes("estimatedemandline") || errorText.includes("estimate_demand_line") || errorText.includes("estimate demand line")) {
return { error: "Estimate demand line not found with the given criteria." };
}
if (errorText.includes("estimateversion") || errorText.includes("estimate_version") || errorText.includes("estimate version")) {
return { error: "Estimate version not found with the given criteria." };
}
if (errorText.includes("estimate")) {
return { error: "Estimate not found with the given criteria." };
}
switch (action) {
case "generateWeeklyPhasing":
return { error: "Estimate demand line not found with the given criteria." };
@@ -1631,19 +1632,97 @@ function getPrismaRequestErrorMetadata(error: unknown): {
return null;
}
function getTrpcErrorMetadata(error: unknown): {
code: string;
message: string;
} | null {
const queue: unknown[] = [error];
const visited = new Set<unknown>();
while (queue.length > 0) {
const current = queue.shift();
if (current === undefined || current === null || visited.has(current)) {
continue;
}
visited.add(current);
if (current instanceof TRPCError) {
return {
code: current.code,
message: current.message,
};
}
if (typeof current !== "object") {
continue;
}
const candidate = current as {
code?: unknown;
message?: unknown;
cause?: unknown;
data?: { code?: unknown };
shape?: { code?: unknown; message?: unknown };
};
const candidateCode = typeof candidate.code === "string"
? candidate.code
: typeof candidate.data?.code === "string"
? candidate.data.code
: typeof candidate.shape?.code === "string"
? candidate.shape.code
: null;
const candidateMessage = typeof candidate.message === "string"
? candidate.message
: typeof candidate.shape?.message === "string"
? candidate.shape.message
: "";
if (candidateCode && /^[A-Z_]+$/.test(candidateCode)) {
return {
code: candidateCode,
message: candidateMessage,
};
}
if ("cause" in candidate) {
queue.push(candidate.cause);
}
}
return null;
}
function toAssistantNotificationCreationError(
error: unknown,
context: "notification" | "task" | "broadcast",
): AssistantToolErrorResult | null {
const trpcError = getTrpcErrorMetadata(error);
if (
context === "broadcast"
&& error instanceof TRPCError
&& error.code === "BAD_REQUEST"
&& error.message === "No recipients matched the broadcast target."
&& trpcError?.code === "BAD_REQUEST"
&& trpcError.message === "No recipients matched the broadcast target."
) {
return { error: "No recipients matched the broadcast target." };
}
if (trpcError?.code === "NOT_FOUND") {
if (trpcError.message.includes("Sender user not found")) {
return { error: "Sender user not found with the given criteria." };
}
if (trpcError.message.includes("Assignee user not found")) {
return { error: "Assignee user not found with the given criteria." };
}
if (trpcError.message.includes("recipient")) {
return context === "broadcast"
? { error: "Broadcast recipient user not found with the given criteria." }
: context === "task"
? { error: "Task recipient user not found with the given criteria." }
: { error: "Notification recipient user not found with the given criteria." };
}
}
const prismaError = getPrismaRequestErrorMetadata(error);
if (!prismaError) {
return null;
@@ -1681,20 +1760,21 @@ function normalizeAssistantExecutionError(
return { error: error.message };
}
if (error instanceof TRPCError) {
if (error.code === "INTERNAL_SERVER_ERROR") {
const trpcError = getTrpcErrorMetadata(error);
if (trpcError) {
if (trpcError.code === "INTERNAL_SERVER_ERROR") {
return {
error: "The tool could not complete due to an internal error.",
};
}
if (error.code === "UNAUTHORIZED") {
if (trpcError.code === "UNAUTHORIZED") {
return {
error: "Authentication is required to use this tool.",
};
}
if (error.code === "FORBIDDEN") {
if (trpcError.code === "FORBIDDEN") {
return {
error: "You do not have permission to perform this action.",
};
+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";