Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions app/api/boards/[id]/notes/[noteId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
Expand All @@ -219,7 +223,9 @@ export async function PUT(
item.content,
boardName,
userName,
"added"
"added",
boardId,
baseUrl
);
}
}
Expand All @@ -240,7 +246,9 @@ export async function PUT(
u.content,
boardName,
userName,
"completed"
"completed",
boardId,
baseUrl
);
}
}
Expand Down
10 changes: 9 additions & 1 deletion app/api/boards/[id]/notes/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }> }) {
Expand Down Expand Up @@ -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",
Expand Down
186 changes: 185 additions & 1 deletion lib/__tests__/slack.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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 <Special>";

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();
});
});
32 changes: 21 additions & 11 deletions lib/slack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,15 @@ export async function updateSlackMessage(
originalText: string,
completed: boolean,
boardName: string,
userName: string
userName: string,
boardId: string,
baseUrl: string
): Promise<void> {
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",
Expand All @@ -112,36 +115,43 @@ 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(
webhookUrl: string,
todoContent: string,
boardName: string,
userName: string,
action: "added" | "completed"
action: "added" | "completed",
boardId: string,
baseUrl: string
): Promise<string | null> {
const message = formatTodoForSlack(todoContent, boardName, userName, action);
const message = formatTodoForSlack(todoContent, boardName, userName, action, boardId, baseUrl);
return await sendSlackMessage(webhookUrl, {
text: message,
username: "Gumboard",
Expand Down
Loading