diff --git a/app/api/boards/[id]/notes/[noteId]/route.ts b/app/api/boards/[id]/notes/[noteId]/route.ts index 0f9ac0f5e..f4844cd76 100644 --- a/app/api/boards/[id]/notes/[noteId]/route.ts +++ b/app/api/boards/[id]/notes/[noteId]/route.ts @@ -9,6 +9,7 @@ import { shouldSendNotification, } from "@/lib/slack"; import { noteSchema } from "@/lib/types"; +import { getBaseUrl } from "@/lib/utils"; export async function PUT( request: NextRequest, @@ -188,21 +189,24 @@ export async function PUT( const userName = note.user?.name || note.user?.email || "Unknown User"; const boardName = note.board.name; const isArchived = archivedAt !== null; - // Get content from first checklist item for Slack message const noteContent = note.checklistItems && note.checklistItems.length > 0 ? note.checklistItems[0].content : ""; + const baseUrl = getBaseUrl(request); await updateSlackMessage( user.organization.slackWebhookUrl, noteContent, isArchived, boardName, - userName + userName, + boardId, + baseUrl ); } if (user.organization?.slackWebhookUrl && checklistChanges) { const boardName = updatedNote.board.name; const userName = user.name || user.email || "Unknown User"; + const baseUrl = getBaseUrl(request); for (const item of checklistChanges.created) { if ( @@ -219,7 +223,9 @@ export async function PUT( item.content, boardName, userName, - "added" + "added", + boardId, + baseUrl ); } } @@ -240,7 +246,9 @@ export async function PUT( u.content, boardName, userName, - "completed" + "completed", + boardId, + baseUrl ); } } diff --git a/app/api/boards/[id]/notes/route.ts b/app/api/boards/[id]/notes/route.ts index 22c161a0f..465f4412e 100644 --- a/app/api/boards/[id]/notes/route.ts +++ b/app/api/boards/[id]/notes/route.ts @@ -10,6 +10,7 @@ import { } from "@/lib/slack"; import { NOTE_COLORS } from "@/lib/constants"; import { noteSchema } from "@/lib/types"; +import { getBaseUrl } from "@/lib/utils"; // Get all notes for a board export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { @@ -207,7 +208,14 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ hasContent && shouldSendNotification(session.user.id, boardId, board.name, board.sendSlackUpdates) ) { - const slackMessage = formatNoteForSlack(noteWithItems, board.name, user.name || user.email); + const baseUrl = getBaseUrl(request); + const slackMessage = formatNoteForSlack( + noteWithItems, + board.name, + user.name || user.email, + boardId, + baseUrl + ); const messageId = await sendSlackMessage(user.organization.slackWebhookUrl, { text: slackMessage, username: "Gumboard", diff --git a/lib/__tests__/slack.test.ts b/lib/__tests__/slack.test.ts index 371b4117a..6bcd6402f 100644 --- a/lib/__tests__/slack.test.ts +++ b/lib/__tests__/slack.test.ts @@ -1,4 +1,9 @@ -import { hasValidContent } from "../slack"; +import { + hasValidContent, + formatNoteForSlack, + formatTodoForSlack, + updateSlackMessage, +} from "../slack"; describe("hasValidContent", () => { it("should return false for null and undefined", () => { @@ -50,3 +55,182 @@ describe("hasValidContent", () => { expect(hasValidContent("... loading ...")).toBe(true); }); }); + +describe("formatNoteForSlack", () => { + const baseUrl = "https://example.com"; + const boardId = "board123"; + const boardName = "Test Board"; + const userName = "John Doe"; + + it("should format note with checklist item and clickable board link", () => { + const note = { + checklistItems: [{ content: "Test task" }], + }; + + const result = formatNoteForSlack(note, boardName, userName, boardId, baseUrl); + + expect(result).toBe( + `:heavy_plus_sign: Test task by ${userName} in <${baseUrl}/boards/${boardId}|${boardName}>` + ); + }); + + it("should use 'New note' when no checklist items", () => { + const note = { checklistItems: [] }; + + const result = formatNoteForSlack(note, boardName, userName, boardId, baseUrl); + + expect(result).toBe( + `:heavy_plus_sign: New note by ${userName} in <${baseUrl}/boards/${boardId}|${boardName}>` + ); + }); + + it("should use 'New note' when checklistItems is undefined", () => { + const note = {}; + + const result = formatNoteForSlack(note, boardName, userName, boardId, baseUrl); + + expect(result).toBe( + `:heavy_plus_sign: New note by ${userName} in <${baseUrl}/boards/${boardId}|${boardName}>` + ); + }); + + it("should handle board names with special characters", () => { + const note = { + checklistItems: [{ content: "Test task" }], + }; + const specialBoardName = "Test & Board "; + + const result = formatNoteForSlack(note, specialBoardName, userName, boardId, baseUrl); + + expect(result).toContain(`<${baseUrl}/boards/${boardId}|${specialBoardName}>`); + }); +}); + +describe("formatTodoForSlack", () => { + const baseUrl = "https://example.com"; + const boardId = "board123"; + const boardName = "Test Board"; + const userName = "John Doe"; + const todoContent = "Complete the task"; + + it("should format added todo with clickable board link", () => { + const result = formatTodoForSlack(todoContent, boardName, userName, "added", boardId, baseUrl); + + expect(result).toBe( + `:heavy_plus_sign: ${todoContent} by ${userName} in <${baseUrl}/boards/${boardId}|${boardName}>` + ); + }); + + it("should format completed todo with clickable board link", () => { + const result = formatTodoForSlack( + todoContent, + boardName, + userName, + "completed", + boardId, + baseUrl + ); + + expect(result).toBe( + `:white_check_mark: ${todoContent} by ${userName} in <${baseUrl}/boards/${boardId}|${boardName}>` + ); + }); + + it("should use correct emoji for each action type", () => { + const addedResult = formatTodoForSlack( + todoContent, + boardName, + userName, + "added", + boardId, + baseUrl + ); + const completedResult = formatTodoForSlack( + todoContent, + boardName, + userName, + "completed", + boardId, + baseUrl + ); + + expect(addedResult).toContain(":heavy_plus_sign:"); + expect(completedResult).toContain(":white_check_mark:"); + }); +}); + +describe("updateSlackMessage", () => { + const baseUrl = "https://example.com"; + const boardId = "board123"; + const boardName = "Test Board"; + const userName = "John Doe"; + const originalText = "Original task"; + const webhookUrl = "https://hooks.slack.com/services/TEST"; + + beforeEach(() => { + global.fetch = jest.fn(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("should format completed message with clickable board link", async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ ok: true }); + + await updateSlackMessage(webhookUrl, originalText, true, boardName, userName, boardId, baseUrl); + + expect(global.fetch).toHaveBeenCalledWith( + webhookUrl, + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + text: `:white_check_mark: ${originalText} by ${userName} in <${baseUrl}/boards/${boardId}|${boardName}>`, + username: "Gumboard", + icon_emoji: ":clipboard:", + }), + }) + ); + }); + + it("should format uncompleted message with clickable board link", async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ ok: true }); + + await updateSlackMessage( + webhookUrl, + originalText, + false, + boardName, + userName, + boardId, + baseUrl + ); + + expect(global.fetch).toHaveBeenCalledWith( + webhookUrl, + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + text: `:heavy_plus_sign: ${originalText} by ${userName} in <${baseUrl}/boards/${boardId}|${boardName}>`, + username: "Gumboard", + icon_emoji: ":clipboard:", + }), + }) + ); + }); + + it("should handle fetch errors gracefully", async () => { + const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(); + (global.fetch as jest.Mock).mockRejectedValueOnce(new Error("Network error")); + + await updateSlackMessage(webhookUrl, originalText, true, boardName, userName, boardId, baseUrl); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("Error updating Slack message:") + ); + + consoleErrorSpy.mockRestore(); + }); +}); diff --git a/lib/slack.ts b/lib/slack.ts index 5d91a4aaa..242146452 100644 --- a/lib/slack.ts +++ b/lib/slack.ts @@ -81,12 +81,15 @@ export async function updateSlackMessage( originalText: string, completed: boolean, boardName: string, - userName: string + userName: string, + boardId: string, + baseUrl: string ): Promise { try { + const boardLink = `<${baseUrl}/boards/${boardId}|${boardName}>`; const updatedText = completed - ? `:white_check_mark: ${originalText} by ${userName} in ${boardName}` - : `:heavy_plus_sign: ${originalText} by ${userName} in ${boardName}`; + ? `:white_check_mark: ${originalText} by ${userName} in ${boardLink}` + : `:heavy_plus_sign: ${originalText} by ${userName} in ${boardLink}`; const response = await fetch(webhookUrl, { method: "POST", @@ -112,26 +115,31 @@ export async function updateSlackMessage( export function formatNoteForSlack( note: { checklistItems?: Array<{ content: string }> }, boardName: string, - userName: string + userName: string, + boardId: string, + baseUrl: string ): string { - // Get content from first checklist item const content = note.checklistItems && note.checklistItems.length > 0 ? note.checklistItems[0].content : "New note"; - return `:heavy_plus_sign: ${content} by ${userName} in ${boardName}`; + const boardLink = `<${baseUrl}/boards/${boardId}|${boardName}>`; + return `:heavy_plus_sign: ${content} by ${userName} in ${boardLink}`; } export function formatTodoForSlack( todoContent: string, boardName: string, userName: string, - action: "added" | "completed" + action: "added" | "completed", + boardId: string, + baseUrl: string ): string { + const boardLink = `<${baseUrl}/boards/${boardId}|${boardName}>`; if (action === "completed") { - return `:white_check_mark: ${todoContent} by ${userName} in ${boardName}`; + return `:white_check_mark: ${todoContent} by ${userName} in ${boardLink}`; } - return `:heavy_plus_sign: ${todoContent} by ${userName} in ${boardName}`; + return `:heavy_plus_sign: ${todoContent} by ${userName} in ${boardLink}`; } export async function sendTodoNotification( @@ -139,9 +147,11 @@ export async function sendTodoNotification( todoContent: string, boardName: string, userName: string, - action: "added" | "completed" + action: "added" | "completed", + boardId: string, + baseUrl: string ): Promise { - const message = formatTodoForSlack(todoContent, boardName, userName, action); + const message = formatTodoForSlack(todoContent, boardName, userName, action, boardId, baseUrl); return await sendSlackMessage(webhookUrl, { text: message, username: "Gumboard",