Skip to content

Commit 33d3246

Browse files
committed
Fix support feedback error handling and improve API validation
1 parent 60fe778 commit 33d3246

File tree

2 files changed

+148
-145
lines changed

2 files changed

+148
-145
lines changed

apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/SupportCaseDetails.tsx

Lines changed: 114 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,46 @@ function SupportCaseDetails({ ticket, team }: SupportCaseDetailsProps) {
4747
// rating/feedback
4848
const [rating, setRating] = useState(0);
4949
const [feedback, setFeedback] = useState("");
50+
// non-blocking warning when status check fails
51+
const [statusCheckFailed, setStatusCheckFailed] = useState(false);
52+
53+
// Helper function to handle status check errors consistently
54+
const handleStatusCheckError = (
55+
error: unknown,
56+
logContext: Record<string, string | number | boolean>,
57+
) => {
58+
const errorMessage = error instanceof Error ? error.message : String(error);
59+
const errorType = /network|fetch/i.test(errorMessage)
60+
? "network"
61+
: /API Server error/i.test(errorMessage)
62+
? "server"
63+
: /Internal server error/i.test(errorMessage)
64+
? "server"
65+
: "unknown";
66+
67+
// Report analytics for status check failure
68+
reportSupportFeedbackStatusCheckFailed({
69+
ticketId: ticket.id,
70+
errorMessage,
71+
errorType,
72+
});
73+
74+
// Set degraded state for warning banner
75+
setStatusCheckFailed(true);
76+
77+
// Log the error
78+
console.warn(
79+
"[SUPPORT_FEEDBACK_CLIENT] Status check failed, but allowing feedback submission",
80+
{
81+
...logContext,
82+
error: errorMessage,
83+
},
84+
);
85+
86+
// Throw a custom error that React Query will catch, but the UI will handle gracefully
87+
// This prevents treating the error as "no feedback exists" which could cause duplicates
88+
throw new Error(`Status check failed: ${errorMessage}`);
89+
};
5090

5191
// Check if feedback has already been submitted for this ticket
5292
const feedbackStatusQuery = useQuery({
@@ -75,32 +115,8 @@ function SupportCaseDetails({ ticket, team }: SupportCaseDetailsProps) {
75115
},
76116
);
77117

78-
// Report analytics for status check failure
79-
const errorType = /network|fetch/i.test(result.error)
80-
? "network"
81-
: /API Server error/i.test(result.error)
82-
? "server"
83-
: /Internal server error/i.test(result.error)
84-
? "server"
85-
: "unknown";
86-
87-
reportSupportFeedbackStatusCheckFailed({
88-
ticketId: ticket.id,
89-
errorMessage: result.error,
90-
errorType,
91-
});
92-
93-
// Don't throw error for status check failures - allow user to still submit feedback
94-
console.warn(
95-
"[SUPPORT_FEEDBACK_CLIENT] Status check failed, but allowing feedback submission",
96-
{
97-
...logContext,
98-
error: result.error,
99-
},
100-
);
101-
102-
// Return false to indicate no feedback found (allow submission)
103-
return false;
118+
// Use shared error handler to maintain consistent behavior
119+
return handleStatusCheckError(result.error, logContext);
104120
}
105121

106122
console.log(
@@ -112,6 +128,9 @@ function SupportCaseDetails({ ticket, team }: SupportCaseDetailsProps) {
112128
},
113129
);
114130

131+
// Clear degraded state on success
132+
if (statusCheckFailed) setStatusCheckFailed(false);
133+
115134
// Additional debugging for production
116135
if (result.hasFeedback) {
117136
console.log(
@@ -139,34 +158,8 @@ function SupportCaseDetails({ ticket, team }: SupportCaseDetailsProps) {
139158
errorName: error instanceof Error ? error.name : "Unknown",
140159
});
141160

142-
// Report analytics for status check failure
143-
const errorType = /network|fetch/i.test(
144-
error instanceof Error ? error.message : String(error),
145-
)
146-
? "network"
147-
: /API Server error/i.test(
148-
error instanceof Error ? error.message : String(error),
149-
)
150-
? "server"
151-
: "unknown";
152-
153-
reportSupportFeedbackStatusCheckFailed({
154-
ticketId: ticket.id,
155-
errorMessage: error instanceof Error ? error.message : String(error),
156-
errorType,
157-
});
158-
159-
// Don't throw error - allow user to still submit feedback
160-
console.warn(
161-
"[SUPPORT_FEEDBACK_CLIENT] Status check failed with exception, but allowing feedback submission",
162-
{
163-
...logContext,
164-
error: error instanceof Error ? error.message : String(error),
165-
},
166-
);
167-
168-
// Return false to indicate no feedback found (allow submission)
169-
return false;
161+
// Use shared error handler to maintain consistent behavior
162+
return handleStatusCheckError(error, logContext);
170163
}
171164
},
172165
enabled: ticket.status === "closed",
@@ -176,7 +169,8 @@ function SupportCaseDetails({ ticket, team }: SupportCaseDetailsProps) {
176169

177170
const feedbackSubmitted = feedbackStatusQuery.data ?? false;
178171
const isLoading = feedbackStatusQuery.isLoading;
179-
const hasError = feedbackStatusQuery.isError;
172+
// query never throws; use local degraded flag for the inline warning
173+
const hasError = statusCheckFailed;
180174

181175
const handleStarClick = (starIndex: number) => {
182176
setRating(starIndex + 1);
@@ -508,75 +502,77 @@ function SupportCaseDetails({ ticket, team }: SupportCaseDetailsProps) {
508502
</div>
509503
)}
510504

511-
{ticket.status === "closed" && !isLoading && !feedbackSubmitted && (
512-
<div className="border-t p-6">
513-
<p className="text-muted-foreground text-sm">
514-
This ticket is closed. Give us a quick rating to let us know how
515-
we did!
516-
</p>
517-
{hasError && (
518-
<div className="mt-2">
519-
<p className="text-destructive text-xs">
520-
Couldn't verify prior feedback right now — you can still
521-
submit a rating.
522-
</p>
505+
{ticket.status === "closed" &&
506+
!isLoading &&
507+
(!feedbackSubmitted || hasError) && (
508+
<div className="border-t p-6">
509+
<p className="text-muted-foreground text-sm">
510+
This ticket is closed. Give us a quick rating to let us know how
511+
we did!
512+
</p>
513+
{hasError && (
514+
<div className="mt-2">
515+
<p className="text-destructive text-xs">
516+
Couldn't verify prior feedback right now — you can still
517+
submit a rating.
518+
</p>
519+
</div>
520+
)}
521+
522+
<div className="flex gap-2 mb-6 mt-4">
523+
{[1, 2, 3, 4, 5].map((starValue) => (
524+
<button
525+
key={`star-${starValue}`}
526+
type="button"
527+
onClick={() => handleStarClick(starValue - 1)}
528+
className="transition-colors"
529+
aria-label={`Rate ${starValue} out of 5 stars`}
530+
>
531+
<StarIcon
532+
size={32}
533+
className={cn(
534+
"transition-colors",
535+
starValue <= rating
536+
? "text-pink-500 fill-current stroke-current"
537+
: "text-muted-foreground fill-current stroke-current",
538+
"hover:text-pink-500",
539+
)}
540+
strokeWidth={starValue <= rating ? 2 : 1}
541+
/>
542+
</button>
543+
))}
523544
</div>
524-
)}
525545

526-
<div className="flex gap-2 mb-6 mt-4">
527-
{[1, 2, 3, 4, 5].map((starValue) => (
528-
<button
529-
key={`star-${starValue}`}
546+
<div className="relative">
547+
<AutoResizeTextarea
548+
value={feedback}
549+
onChange={(e) => setFeedback(e.target.value)}
550+
placeholder="Optional: Tell us how we can improve."
551+
maxLength={1000}
552+
className="text-sm w-full bg-card text-foreground rounded-lg p-4 pr-28 min-h-[100px] resize-none border border-border focus:outline-none placeholder:text-muted-foreground"
553+
/>
554+
<Button
530555
type="button"
531-
onClick={() => handleStarClick(starValue - 1)}
532-
className="transition-colors"
533-
aria-label={`Rate ${starValue} out of 5 stars`}
556+
onClick={handleSendFeedback}
557+
disabled={submitFeedbackMutation.isPending || rating === 0}
558+
className="absolute bottom-3 right-3 rounded-full h-auto py-2 px-4"
559+
variant="secondary"
560+
size="sm"
534561
>
535-
<StarIcon
536-
size={32}
537-
className={cn(
538-
"transition-colors",
539-
starValue <= rating
540-
? "text-pink-500 fill-current stroke-current"
541-
: "text-muted-foreground fill-current stroke-current",
542-
"hover:text-pink-500",
543-
)}
544-
strokeWidth={starValue <= rating ? 2 : 1}
545-
/>
546-
</button>
547-
))}
548-
</div>
549-
550-
<div className="relative">
551-
<AutoResizeTextarea
552-
value={feedback}
553-
onChange={(e) => setFeedback(e.target.value)}
554-
placeholder="Optional: Tell us how we can improve."
555-
maxLength={1000}
556-
className="text-sm w-full bg-card text-foreground rounded-lg p-4 pr-28 min-h-[100px] resize-none border border-border focus:outline-none placeholder:text-muted-foreground"
557-
/>
558-
<Button
559-
type="button"
560-
onClick={handleSendFeedback}
561-
disabled={submitFeedbackMutation.isPending || rating === 0}
562-
className="absolute bottom-3 right-3 rounded-full h-auto py-2 px-4"
563-
variant="secondary"
564-
size="sm"
565-
>
566-
{submitFeedbackMutation.isPending ? (
567-
<>
568-
<Spinner className="size-4 mr-2" />
569-
Sending...
570-
</>
571-
) : (
572-
"Send Feedback"
573-
)}
574-
</Button>
562+
{submitFeedbackMutation.isPending ? (
563+
<>
564+
<Spinner className="size-4 mr-2" />
565+
Sending...
566+
</>
567+
) : (
568+
"Send Feedback"
569+
)}
570+
</Button>
571+
</div>
575572
</div>
576-
</div>
577-
)}
573+
)}
578574

579-
{ticket.status === "closed" && feedbackSubmitted && (
575+
{ticket.status === "closed" && feedbackSubmitted && !hasError && (
580576
<div className="border-t p-6">
581577
<p className="text-muted-foreground text-sm">
582578
Thank you for your feedback! We appreciate your input and will use

apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/apis/feedback.ts

Lines changed: 34 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,16 @@ export async function submitSupportFeedback({
1313
ticketId: string;
1414
}): Promise<FeedbackSubmitResult> {
1515
try {
16-
// Fail fast on missing configuration
16+
// Basic input validation first - fail fast on invalid input
17+
if (!ticketId?.trim()) {
18+
return { error: "ticketId is required." };
19+
}
20+
21+
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
22+
return { error: "Rating must be an integer between 1 and 5." };
23+
}
24+
25+
// Configuration validation after input validation
1726
const siwaUrl =
1827
process.env.SIWA_URL ?? process.env.NEXT_PUBLIC_SIWA_URL ?? "";
1928

@@ -27,15 +36,6 @@ export async function submitSupportFeedback({
2736
throw new Error("SERVICE_AUTH_KEY_SIWA not configured");
2837
}
2938

30-
// Basic input validation/normalization
31-
if (!ticketId?.trim()) {
32-
return { error: "ticketId is required." };
33-
}
34-
35-
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
36-
return { error: "Rating must be an integer between 1 and 5." };
37-
}
38-
3939
const normalizedFeedback = (feedback ?? "")
4040
.toString()
4141
.trim()
@@ -76,12 +76,8 @@ export async function submitSupportFeedback({
7676
return { success: true };
7777
} catch (error) {
7878
if (error instanceof Error) {
79-
if (error.name === "AbortError") {
80-
return { error: "Request timeout" };
81-
}
82-
if (error.message.includes("fetch")) {
83-
return { error: "Network error" };
84-
}
79+
if (error.name === "AbortError") return { error: "Request timeout" };
80+
if (error instanceof TypeError) return { error: "Network error" };
8581
return { error: error.message };
8682
}
8783
return { error: "Unknown error occurred" };
@@ -92,6 +88,12 @@ export async function checkFeedbackStatus(
9288
ticketId: string,
9389
): Promise<FeedbackStatusResult> {
9490
try {
91+
// Basic input validation first - fail fast on invalid input
92+
if (!ticketId?.trim()) {
93+
return { error: "ticketId is required." };
94+
}
95+
96+
// Configuration validation after input validation
9597
const siwaUrl =
9698
process.env.SIWA_URL ?? process.env.NEXT_PUBLIC_SIWA_URL ?? "";
9799

@@ -105,10 +107,6 @@ export async function checkFeedbackStatus(
105107
throw new Error("SERVICE_AUTH_KEY_SIWA not configured");
106108
}
107109

108-
if (!ticketId?.trim()) {
109-
return { error: "ticketId is required." };
110-
}
111-
112110
const ac = new AbortController();
113111
const t = setTimeout(() => ac.abort(), 10_000);
114112

@@ -150,19 +148,28 @@ export async function checkFeedbackStatus(
150148
return { error: "Invalid JSON response from API" };
151149
}
152150

153-
if (typeof data.has_feedback !== "boolean") {
151+
// Comprehensive validation of the API response structure
152+
if (
153+
typeof data.has_feedback !== "boolean" ||
154+
(data.feedback_data !== null &&
155+
(typeof data.feedback_data !== "object" ||
156+
typeof data.feedback_data.id !== "string" ||
157+
(data.feedback_data.rating !== null &&
158+
typeof data.feedback_data.rating !== "number") ||
159+
(data.feedback_data.feedback !== null &&
160+
typeof data.feedback_data.feedback !== "string") ||
161+
(data.feedback_data.ticket_id !== null &&
162+
typeof data.feedback_data.ticket_id !== "string") ||
163+
typeof data.feedback_data.created_at !== "string"))
164+
) {
154165
return { error: "Invalid response format from API" };
155166
}
156167

157168
return { hasFeedback: data.has_feedback };
158169
} catch (error) {
159170
if (error instanceof Error) {
160-
if (error.name === "AbortError") {
161-
return { error: "Request timeout" };
162-
}
163-
if (error.message.includes("fetch")) {
164-
return { error: "Network error" };
165-
}
171+
if (error.name === "AbortError") return { error: "Request timeout" };
172+
if (error instanceof TypeError) return { error: "Network error" };
166173
return { error: error.message };
167174
}
168175
return { error: "Unknown error occurred" };

0 commit comments

Comments
 (0)