diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/SupportCaseDetails.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/SupportCaseDetails.tsx index 645d8244aa4..97661aff9af 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/SupportCaseDetails.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/_components/SupportCaseDetails.tsx @@ -34,7 +34,7 @@ interface SupportCaseDetailsProps { team: Team; } -export function SupportCaseDetails({ ticket, team }: SupportCaseDetailsProps) { +function SupportCaseDetails({ ticket, team }: SupportCaseDetailsProps) { const [replyMessage, setReplyMessage] = useState(""); const [isSubmittingReply, setIsSubmittingReply] = useState(false); const [localMessages, setLocalMessages] = useState(ticket.messages || []); @@ -42,6 +42,15 @@ export function SupportCaseDetails({ ticket, team }: SupportCaseDetailsProps) { // rating/feedback const [rating, setRating] = useState(0); const [feedback, setFeedback] = useState(""); + // non-blocking warning when status check fails + const [statusCheckFailed, setStatusCheckFailed] = useState(false); + + // Helper function to handle status check errors consistently + const handleStatusCheckError = (_error: unknown) => { + // Set degraded state for warning banner + setStatusCheckFailed(true); + return; + }; // Check if feedback has already been submitted for this ticket const feedbackStatusQuery = useQuery({ @@ -49,18 +58,25 @@ export function SupportCaseDetails({ ticket, team }: SupportCaseDetailsProps) { queryFn: async () => { const result = await checkFeedbackStatus(ticket.id); if ("error" in result) { - throw new Error(result.error); + handleStatusCheckError(result.error); + return false; // Non-blocking: allow feedback submission despite status check failure } + + // Clear degraded state on success + if (statusCheckFailed) setStatusCheckFailed(false); + return result.hasFeedback; }, enabled: ticket.status === "closed", staleTime: 60_000, gcTime: 5 * 60_000, + retry: 1, // Reduce retries since we want non-blocking behavior }); const feedbackSubmitted = feedbackStatusQuery.data ?? false; const isLoading = feedbackStatusQuery.isLoading; - const hasError = feedbackStatusQuery.isError; + // query never throws; use local degraded flag for the inline warning + const hasError = statusCheckFailed; const handleStarClick = (starIndex: number) => { setRating(starIndex + 1); @@ -70,29 +86,36 @@ export function SupportCaseDetails({ ticket, team }: SupportCaseDetailsProps) { const submitFeedbackMutation = useMutation({ mutationFn: async () => { if (rating === 0) { - throw new Error("Please select a rating"); + const error = "Please select a rating"; + throw new Error(error); } const result = await submitSupportFeedback({ rating, feedback, ticketId: ticket.id, }); + if ("error" in result) { + // Add more specific error information + throw new Error(result.error); } + return result; }, onSuccess: () => { toast.success("Thank you for your feedback!"); + setRating(0); setFeedback(""); + // mark as submitted immediately queryClient.setQueryData(["feedbackStatus", ticket.id], true); }, onError: (err) => { - console.error("Failed to submit feedback:", err); const msg = err instanceof Error ? err.message : String(err ?? ""); let message = "Failed to submit feedback. Please try again."; + if (/network|fetch/i.test(msg)) { message = "Network error. Please check your connection and try again."; } else if ( @@ -102,6 +125,7 @@ export function SupportCaseDetails({ ticket, team }: SupportCaseDetailsProps) { } else if (/API Server error/i.test(msg)) { message = "Server error. Please try again later."; } + toast.error(message); }, }); @@ -157,8 +181,7 @@ export function SupportCaseDetails({ ticket, team }: SupportCaseDetailsProps) { } setReplyMessage(""); - } catch (error) { - console.error("Failed to send reply:", error); + } catch { toast.error("Failed to send Message. Please try again."); // Remove the optimistic message on error @@ -233,73 +256,77 @@ export function SupportCaseDetails({ ticket, team }: SupportCaseDetailsProps) { )} - {ticket.status === "closed" && !isLoading && !feedbackSubmitted && ( -
-

- This ticket is closed. Give us a quick rating to let us know how - we did! -

- {hasError && ( -

- Couldn't verify prior feedback right now — you can still submit - a rating. + {ticket.status === "closed" && + !isLoading && + (!feedbackSubmitted || hasError) && ( +

+

+ This ticket is closed. Give us a quick rating to let us know how + we did!

- )} + {hasError && ( +
+

+ Couldn't verify prior feedback right now — you can still + submit a rating. +

+
+ )} -
- {[1, 2, 3, 4, 5].map((starValue) => ( - + ))} +
+ +
+ setFeedback(e.target.value)} + placeholder="Optional: Tell us how we can improve." + maxLength={1000} + 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" + /> + - ))} -
- -
- setFeedback(e.target.value)} - placeholder="Optional: Tell us how we can improve." - maxLength={1000} - 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" - /> - + {submitFeedbackMutation.isPending ? ( + <> + + Sending... + + ) : ( + "Send Feedback" + )} + +
-
- )} + )} - {ticket.status === "closed" && feedbackSubmitted && ( + {ticket.status === "closed" && feedbackSubmitted && !hasError && (

Thank you for your feedback! We appreciate your input and will use @@ -455,3 +482,5 @@ function TicketMessage(props: { message: SupportMessage }) {

); } + +export { SupportCaseDetails }; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/apis/feedback.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/apis/feedback.ts index ed8dd57c58f..1e7f25ecca1 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/apis/feedback.ts +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/(team)/~/support/apis/feedback.ts @@ -13,7 +13,16 @@ export async function submitSupportFeedback({ ticketId: string; }): Promise { try { - // Fail fast on missing configuration + // Basic input validation first - fail fast on invalid input + if (!ticketId?.trim()) { + return { error: "ticketId is required." }; + } + + if (!Number.isInteger(rating) || rating < 1 || rating > 5) { + return { error: "Rating must be an integer between 1 and 5." }; + } + + // Configuration validation after input validation const siwaUrl = process.env.SIWA_URL ?? process.env.NEXT_PUBLIC_SIWA_URL ?? ""; @@ -27,15 +36,6 @@ export async function submitSupportFeedback({ throw new Error("SERVICE_AUTH_KEY_SIWA not configured"); } - // Basic input validation/normalization - if (!ticketId?.trim()) { - return { error: "ticketId is required." }; - } - - if (!Number.isInteger(rating) || rating < 1 || rating > 5) { - return { error: "Rating must be an integer between 1 and 5." }; - } - const normalizedFeedback = (feedback ?? "") .toString() .trim() @@ -49,6 +49,7 @@ export async function submitSupportFeedback({ const ac = new AbortController(); const t = setTimeout(() => ac.abort(), 10_000); + const response = await fetch(`${siwaUrl}/v1/csat/saveCSATFeedback`, { method: "POST", cache: "no-store", @@ -62,13 +63,33 @@ export async function submitSupportFeedback({ if (!response.ok) { const errorText = await response.text(); - return { error: `API Server error: ${response.status} - ${errorText}` }; + const error = `API Server error: ${response.status} - ${errorText}`; + return { error }; } - return { success: true }; + try { + const data = await response.json(); + + // Validate response structure + if (typeof data !== "object" || data === null) { + return { error: "Invalid response format from API" }; + } + + if (!data.success) { + return { error: "API returned unsuccessful response" }; + } + + return { success: true }; + } catch (_jsonError) { + return { error: "Invalid JSON response from API" }; + } } catch (error) { - console.error("Feedback submission error:", error); - return { error: "Internal server error" }; + if (error instanceof Error) { + if (error.name === "AbortError") return { error: "Request timeout" }; + if (error instanceof TypeError) return { error: "Network error" }; + return { error: error.message }; + } + return { error: "Unknown error occurred" }; } } @@ -76,12 +97,12 @@ export async function checkFeedbackStatus( ticketId: string, ): Promise { try { - // Basic input validation + // Basic input validation first - fail fast on invalid input if (!ticketId?.trim()) { return { error: "ticketId is required." }; } - // Fail fast on missing configuration + // Configuration validation after input validation const siwaUrl = process.env.SIWA_URL ?? process.env.NEXT_PUBLIC_SIWA_URL ?? ""; @@ -95,29 +116,71 @@ export async function checkFeedbackStatus( throw new Error("SERVICE_AUTH_KEY_SIWA not configured"); } - const fullUrl = `${siwaUrl}/v1/csat/getCSATFeedback?ticket_id=${encodeURIComponent(ticketId)}`; - const ac = new AbortController(); const t = setTimeout(() => ac.abort(), 10_000); - const response = await fetch(fullUrl, { - method: "GET", - cache: "no-store", - headers: { - "Content-Type": "application/json", - "x-service-api-key": apiKey, + + const response = await fetch( + `${siwaUrl}/v1/csat/getCSATFeedback?ticket_id=${encodeURIComponent( + ticketId.trim(), + )}`, + { + method: "GET", + cache: "no-store", + headers: { + "Content-Type": "application/json", + "x-service-api-key": apiKey, + }, + signal: ac.signal, }, - signal: ac.signal, - }).finally(() => clearTimeout(t)); + ).finally(() => clearTimeout(t)); if (!response.ok) { const errorText = await response.text(); - return { error: `API Server error: ${response.status} - ${errorText}` }; + const error = `API Server error: ${response.status} - ${errorText}`; + return { error }; + } + + let data: { + has_feedback: boolean; + feedback_data: { + id: string; + rating: number | null; + feedback: string | null; + ticket_id: string | null; + created_at: string; + } | null; + }; + + try { + data = await response.json(); + } catch (_jsonError) { + return { error: "Invalid JSON response from API" }; + } + + // Comprehensive validation of the API response structure + if ( + typeof data.has_feedback !== "boolean" || + (data.feedback_data != null && + (typeof data.feedback_data !== "object" || + typeof data.feedback_data.id !== "string" || + (data.feedback_data.rating != null && + typeof data.feedback_data.rating !== "number") || + (data.feedback_data.feedback != null && + typeof data.feedback_data.feedback !== "string") || + (data.feedback_data.ticket_id != null && + typeof data.feedback_data.ticket_id !== "string") || + typeof data.feedback_data.created_at !== "string")) + ) { + return { error: "Invalid response format from API" }; } - const data = await response.json(); return { hasFeedback: data.has_feedback }; } catch (error) { - console.error("Feedback status check error:", error); - return { error: "Internal server error" }; + if (error instanceof Error) { + if (error.name === "AbortError") return { error: "Request timeout" }; + if (error instanceof TypeError) return { error: "Network error" }; + return { error: error.message }; + } + return { error: "Unknown error occurred" }; } }