-
Notifications
You must be signed in to change notification settings - Fork 560
[dashboard] - support in dashboard #7608
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Yash094
wants to merge
9
commits into
main
Choose a base branch
from
yash/support
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
36e6b09
support-in-dashboard v1
Yash094 b7c72f4
lint fixes
Yash094 6b5da01
updates based on feedback
Yash094 4504a56
coderabbit review fix
Yash094 2b6700e
small fix
Yash094 23d2939
fixes
Yash094 fc4a4ac
fixes
Yash094 190f358
merge main
Yash094 13737d7
fix lint
Yash094 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,328 @@ | ||
"use server"; | ||
import "server-only"; | ||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs"; | ||
import { getAuthToken, getAuthTokenWalletAddress } from "./auth-token"; | ||
|
||
export interface SupportTicket { | ||
id: string; | ||
status: "needs_response" | "in_progress" | "on_hold" | "closed" | "resolved"; | ||
createdAt: string; | ||
updatedAt: string; | ||
messages?: SupportMessage[]; | ||
} | ||
|
||
interface SupportMessage { | ||
id: string; | ||
content: string; | ||
createdAt: string; | ||
timestamp: string; | ||
author?: { | ||
name: string; | ||
email: string; | ||
type: "user" | "customer"; | ||
}; | ||
} | ||
|
||
interface CreateSupportTicketRequest { | ||
message: string; | ||
teamSlug: string; | ||
title: string; | ||
} | ||
|
||
interface SendMessageRequest { | ||
ticketId: string; | ||
teamSlug: string; | ||
message: string; | ||
} | ||
|
||
export async function getSupportTicketsByTeam( | ||
teamSlug: string, | ||
authToken?: string, | ||
): Promise<SupportTicket[]> { | ||
if (!teamSlug) { | ||
throw new Error("Team slug is required to fetch support tickets"); | ||
} | ||
|
||
const token = authToken || (await getAuthToken()); | ||
if (!token) { | ||
throw new Error("No auth token available"); | ||
} | ||
|
||
// URL encode the team slug to handle special characters like # | ||
const encodedTeamSlug = encodeURIComponent(teamSlug); | ||
const apiUrl = `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${encodedTeamSlug}/support-conversations/list`; | ||
|
||
// Build the POST payload according to API spec | ||
const payload = { | ||
limit: 50, | ||
descending: true, | ||
}; | ||
|
||
const response = await fetch(apiUrl, { | ||
body: JSON.stringify(payload), | ||
cache: "no-store", | ||
headers: { | ||
Accept: "application/json", | ||
"Accept-Encoding": "identity", | ||
Authorization: `Bearer ${token}`, | ||
"Content-Type": "application/json", | ||
}, | ||
method: "POST", | ||
}); | ||
|
||
if (!response.ok) { | ||
const errorText = await response.text(); | ||
throw new Error(`API Server error: ${response.status} - ${errorText}`); | ||
} | ||
const data: { data?: SupportTicket[] } = await response.json(); | ||
const conversations = data.data || []; | ||
return conversations; | ||
} | ||
|
||
interface RawSupportMessage { | ||
id: string; | ||
text?: string; | ||
timestamp?: string; | ||
createdAt?: string; | ||
isPrivateNote?: boolean; | ||
sentByUser?: { | ||
name: string; | ||
email: string; | ||
isExternal: boolean; | ||
}; | ||
// Add any other fields you use from the API | ||
} | ||
|
||
export async function getSupportTicket( | ||
ticketId: string, | ||
teamSlug: string, | ||
authToken?: string, | ||
): Promise<SupportTicket | null> { | ||
if (!ticketId || !teamSlug) { | ||
throw new Error("Ticket ID and team slug are required"); | ||
} | ||
|
||
const token = authToken || (await getAuthToken()); | ||
if (!token) { | ||
throw new Error("No auth token available"); | ||
} | ||
|
||
// URL encode the team slug to handle special characters like # | ||
const encodedTeamSlug = encodeURIComponent(teamSlug); | ||
const encodedTicketId = encodeURIComponent(ticketId); | ||
|
||
const messagesPayload = { | ||
limit: 100, | ||
descending: false, | ||
}; | ||
|
||
// Fetch conversation details and messages in parallel | ||
const [conversationResponse, messagesResponse] = await Promise.all([ | ||
fetch( | ||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${encodedTeamSlug}/support-conversations/${encodedTicketId}`, | ||
{ | ||
cache: "no-store", | ||
headers: { | ||
Accept: "application/json", | ||
"Accept-Encoding": "identity", | ||
Authorization: `Bearer ${token}`, | ||
"Content-Type": "application/json", | ||
}, | ||
method: "GET", | ||
}, | ||
), | ||
fetch( | ||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${encodedTeamSlug}/support-conversations/${encodedTicketId}/messages/list`, | ||
{ | ||
body: JSON.stringify(messagesPayload), | ||
cache: "no-store", | ||
headers: { | ||
Accept: "application/json", | ||
"Accept-Encoding": "identity", | ||
Authorization: `Bearer ${token}`, | ||
"Content-Type": "application/json", | ||
}, | ||
method: "POST", | ||
}, | ||
), | ||
]); | ||
|
||
if (!conversationResponse.ok) { | ||
if (conversationResponse.status === 404) { | ||
return null; // Ticket not found | ||
} | ||
const errorText = await conversationResponse.text(); | ||
throw new Error( | ||
`API Server error: ${conversationResponse.status} - ${errorText}`, | ||
); | ||
} | ||
|
||
const conversation: SupportTicket = await conversationResponse.json(); | ||
|
||
// Fetch and map messages if the messages request was successful | ||
if (messagesResponse.ok) { | ||
const messagesData: { data?: unknown[] } = await messagesResponse.json(); | ||
const rawMessages = messagesData.data || []; | ||
// Transform the raw messages to match our interface | ||
const messages: SupportMessage[] = (rawMessages as RawSupportMessage[]) | ||
.filter((msg) => { | ||
// Filter out messages without content - check both text and text fields | ||
const hasContent = msg.text && msg.text.length > 0; | ||
const hasText = msg.text && msg.text.trim().length > 0; | ||
// Filter out private notes - they should not be shown to customers | ||
const isNotPrivateNote = !msg.isPrivateNote; | ||
return (hasContent || hasText) && isNotPrivateNote; | ||
}) | ||
.map((msg) => { | ||
// Use text if available and is a non-empty array, otherwise fall back to text | ||
let content = ""; | ||
if (typeof msg.text === "string" && msg.text.trim().length > 0) { | ||
content = msg.text; | ||
} | ||
|
||
// Clean up 'Email:' line to show only the plain email if it contains a mailto link | ||
if (content) { | ||
content = content | ||
.split("\n") | ||
.map((line) => { | ||
if (line.trim().toLowerCase().startsWith("email:")) { | ||
// Extract email from <mailto:...|...> | ||
const match = line.match(/<mailto:([^|>]+)\|[^>]+>/); | ||
if (match) { | ||
return `Email: ${match[1]}`; | ||
} | ||
} | ||
return line; | ||
}) | ||
.join("\n"); | ||
} | ||
|
||
// Map the author information from sentByUser if available | ||
const author = msg.sentByUser | ||
? { | ||
name: msg.sentByUser.name, | ||
email: msg.sentByUser.email, | ||
type: (msg.sentByUser.isExternal ? "customer" : "user") as | ||
| "user" | ||
| "customer", | ||
} | ||
: undefined; | ||
|
||
return { | ||
id: msg.id, | ||
content: content, | ||
createdAt: msg.timestamp || msg.createdAt || "", | ||
timestamp: msg.timestamp || msg.createdAt || "", | ||
author: author, | ||
}; | ||
}); | ||
|
||
conversation.messages = messages; | ||
} else { | ||
// Don't throw error, just leave messages empty | ||
const errorText = await messagesResponse.text(); | ||
console.error("Failed to fetch messages:", errorText); | ||
conversation.messages = []; | ||
} | ||
|
||
return conversation; | ||
} | ||
|
||
export async function createSupportTicket( | ||
request: CreateSupportTicketRequest, | ||
): Promise<SupportTicket> { | ||
if (!request.teamSlug) { | ||
throw new Error("Team slug is required to create support ticket"); | ||
} | ||
|
||
const token = await getAuthToken(); | ||
if (!token) { | ||
throw new Error("No auth token available"); | ||
} | ||
|
||
// Fetch wallet address (server-side) | ||
const walletAddress = await getAuthTokenWalletAddress(); | ||
|
||
// URL encode the team slug to handle special characters like # | ||
const encodedTeamSlug = encodeURIComponent(request.teamSlug); | ||
const apiUrl = `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${encodedTeamSlug}/support-conversations`; | ||
|
||
// Build the payload for creating a conversation | ||
// If the message does not already include wallet address, prepend it | ||
let message = request.message; | ||
if (!message.includes("Wallet address:")) { | ||
message = `Wallet address: ${String(walletAddress || "-")}\n${message}`; | ||
} | ||
|
||
const payload = { | ||
markdown: message.trim(), | ||
title: request.title, | ||
}; | ||
|
||
const body = JSON.stringify(payload); | ||
const headers: Record<string, string> = { | ||
Accept: "application/json", | ||
Authorization: `Bearer ${token}`, | ||
"Content-Type": "application/json", | ||
"Accept-Encoding": "identity", | ||
}; | ||
|
||
const response = await fetch(apiUrl, { | ||
body, | ||
headers, | ||
method: "POST", | ||
}); | ||
|
||
if (!response.ok) { | ||
const errorText = await response.text(); | ||
throw new Error(`API Server error: ${response.status} - ${errorText}`); | ||
} | ||
|
||
const createdConversation: SupportTicket = await response.json(); | ||
return createdConversation; | ||
} | ||
|
||
export async function sendMessageToTicket( | ||
request: SendMessageRequest, | ||
): Promise<void> { | ||
if (!request.ticketId || !request.teamSlug) { | ||
throw new Error("Ticket ID and team slug are required"); | ||
} | ||
|
||
const token = await getAuthToken(); | ||
if (!token) { | ||
throw new Error("No auth token available"); | ||
} | ||
|
||
// URL encode the team slug and ticket ID to handle special characters like # | ||
const encodedTeamSlug = encodeURIComponent(request.teamSlug); | ||
const encodedTicketId = encodeURIComponent(request.ticketId); | ||
const apiUrl = `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${encodedTeamSlug}/support-conversations/${encodedTicketId}/messages`; | ||
|
||
// Build the payload for sending a message | ||
// Append /unthread send for customer messages to ensure proper routing | ||
const messageWithUnthread = `${request.message.trim()}\n/unthread send`; | ||
const payload = { | ||
markdown: messageWithUnthread, | ||
}; | ||
|
||
const body = JSON.stringify(payload); | ||
const headers: Record<string, string> = { | ||
Accept: "application/json", | ||
Authorization: `Bearer ${token}`, | ||
"Content-Type": "application/json", | ||
"Accept-Encoding": "identity", | ||
}; | ||
|
||
const response = await fetch(apiUrl, { | ||
body, | ||
headers, | ||
method: "POST", | ||
}); | ||
|
||
if (!response.ok) { | ||
const errorText = await response.text(); | ||
throw new Error(`API Server error: ${response.status} - ${errorText}`); | ||
} | ||
// Message sent successfully, no need to return anything | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💡 Verification agent
🧩 Analysis chain
Semantic mismatch: Variable name suggests ID but function expects slug.
The variable
lastVisitedTeamId
and the cookie constantLAST_USED_TEAM_ID
suggest that a team ID is stored, butgetTeamBySlug()
expects a slug parameter. This creates confusion about what the cookie actually contains.If the cookie now stores slugs (as part of the refactoring), consider renaming the variable and constant to reflect this:
Run the following script to verify what values are actually stored in the
LAST_USED_TEAM_ID
cookie across the codebase:🏁 Script executed:
Length of output: 15218
Rename
LAST_USED_TEAM_ID
and related variables to reflect that they store slugs, not IDsWe’ve confirmed that the cookie
LAST_USED_TEAM_ID
always holds the team slug (see its use inSaveLastUsedProject.tsx
,custom-contract.tsx
, etc.) and is passed intogetTeamBySlug()
. The current naming (ID
) is misleading. Please update:• constants/cookies.ts
– export const LAST_USED_TEAM_ID →
LAST_USED_TEAM_SLUG
• apps/dashboard/src/@/api/team.ts
–
const lastVisitedTeamId
→const lastVisitedTeamSlug
– use
cookiesStore.get(LAST_USED_TEAM_SLUG)
and pass togetTeamBySlug(lastVisitedTeamSlug)
• apps/dashboard/src/@/api/auth-token.ts
–
const lastUsedTeamId
→const lastUsedTeamSlug
–
cookiesManager.get(LAST_USED_TEAM_SLUG)
• components that read/write this cookie (e.g. SaveLastUsedProject.tsx, SaveLastVisitedPage.tsx, custom-contract.tsx)
– prop/variable names from
teamId
→teamSlug
– calls to
setCookie(LAST_USED_TEAM_ID, …)
/getCookie(LAST_USED_TEAM_ID)
→ useLAST_USED_TEAM_SLUG
Example diff in
apps/dashboard/src/@/api/team.ts
:These renames will eliminate confusion between “ID” and “slug” throughout the codebase.
📝 Committable suggestion
🤖 Prompt for AI Agents
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
getTeamById was calling the getTeamBySlug just as a wrapper!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@Yash094, thank you for that clarification! You're absolutely right - if
getTeamById
was just a wrapper aroundgetTeamBySlug
, then the functionality is unchanged by this refactoring. The change simply removes the unnecessary wrapper and callsgetTeamBySlug
directly.This means the variable
lastVisitedTeamId
and cookieLAST_USED_TEAM_ID
were actually storing slugs all along, and the misleading naming predated this change. The refactoring is a good cleanup that removes the wrapper layer.While the functionality is correct, my suggestion about renaming
LAST_USED_TEAM_ID
toLAST_USED_TEAM_SLUG
andlastVisitedTeamId
tolastVisitedTeamSlug
would still improve code clarity by making it explicit that these store slugs rather than IDs. But that's a separate improvement for better semantic naming, not a fix for this specific change.Thanks for the context - it helps understand that this is purely a wrapper removal, not a functional change!
✏️ Learnings added
🧠 Learnings used
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@Yash094 Prefer using
getTeamById
still so its not confusingThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.