From 67f3264e4b0350ff56349dd2e906abfb269ad7a1 Mon Sep 17 00:00:00 2001 From: Balint Dolla Date: Fri, 3 Nov 2023 15:52:18 +0100 Subject: [PATCH 1/7] feat: use daily cron to publish askPresentIdeas event --- packages/core/db/queries/getBirthdays.ts | 8 ++ packages/core/db/schema.ts | 2 + packages/core/events/index.ts | 4 + packages/functions/cron/daily.ts | 24 ++++ .../functions/cron/iceBreakerQuestions.ts | 18 +-- packages/functions/events/askPresentIdeas.ts | 8 ++ .../functions/utils/lambda/cronHandler.ts | 22 ++++ stacks/CronStack.ts | 12 +- stacks/MyStack.ts | 1 + tests/unit/presentIdeas.test.ts | 112 ++++++++++++++++++ tests/utils/callWithMockCronEvent.ts | 11 ++ 11 files changed, 205 insertions(+), 17 deletions(-) create mode 100644 packages/functions/cron/daily.ts create mode 100644 packages/functions/events/askPresentIdeas.ts create mode 100644 packages/functions/utils/lambda/cronHandler.ts create mode 100644 tests/unit/presentIdeas.test.ts create mode 100644 tests/utils/callWithMockCronEvent.ts diff --git a/packages/core/db/queries/getBirthdays.ts b/packages/core/db/queries/getBirthdays.ts index f5d9415..c3b81a5 100644 --- a/packages/core/db/queries/getBirthdays.ts +++ b/packages/core/db/queries/getBirthdays.ts @@ -31,3 +31,11 @@ export const getBirthdaysBetween = async (startDate: Dayjs, endDate: Dayjs) => { where: and(filterStart, filterEnd), }); }; + +export const getBirthdays = async (date: Dayjs) => + db.query.users.findMany({ + where: and( + sql`EXTRACT('MONTH' FROM ${users.birthday}) = ${date.month() + 1}`, + sql`EXTRACT('DAY' FROM ${users.birthday}) = ${date.date()}`, + ), + }); diff --git a/packages/core/db/schema.ts b/packages/core/db/schema.ts index e97adc2..e21f510 100644 --- a/packages/core/db/schema.ts +++ b/packages/core/db/schema.ts @@ -19,6 +19,8 @@ export const users = pgTable( }), ); +export type SelectUser = typeof users.$inferSelect; + export const iceBreakerThreads = pgTable( "iceBreakerThreads", { diff --git a/packages/core/events/index.ts b/packages/core/events/index.ts index f041e39..a571a00 100644 --- a/packages/core/events/index.ts +++ b/packages/core/events/index.ts @@ -30,6 +30,10 @@ const Events = z.object({ birthday: z.string(), responseUrl: z.string(), }), + askPresentIdeas: BaseEvent.extend({ + user: z.string(), + team: z.string(), + }), }); export type Events = z.infer; diff --git a/packages/functions/cron/daily.ts b/packages/functions/cron/daily.ts new file mode 100644 index 0000000..7d7a6d7 --- /dev/null +++ b/packages/functions/cron/daily.ts @@ -0,0 +1,24 @@ +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; + +dayjs.extend(utc); + +import { getBirthdays } from "@/db/queries/getBirthdays"; +import { publishEvent } from "@/utils/eventBridge/publishEvent"; +import { cronHandler } from "@/utils/lambda/cronHandler"; + +export const handler = cronHandler(async (eventId?: string) => { + const today = dayjs().utc().startOf("day"); + + const users = await getBirthdays(today.add(2, "month")); + + await Promise.all( + users.map((user) => + publishEvent("askPresentIdeas", { + team: user.teamId, + user: user.id, + eventId, + }), + ), + ); +}); diff --git a/packages/functions/cron/iceBreakerQuestions.ts b/packages/functions/cron/iceBreakerQuestions.ts index c90a91c..b166047 100644 --- a/packages/functions/cron/iceBreakerQuestions.ts +++ b/packages/functions/cron/iceBreakerQuestions.ts @@ -1,4 +1,3 @@ -import type { APIGatewayProxyEventV2, EventBridgeEvent } from "aws-lambda"; import { Config } from "sst/node/config"; import { db } from "@/db/index"; @@ -7,25 +6,14 @@ import { iceBreakerThreads } from "@/db/schema"; import { getIceBreakerWindow } from "@/services/birthday/getIcebreakerWindow"; import { constructIceBreakerQuestion } from "@/services/slack/constructIceBreakerQuestion"; import { createSlackApp } from "@/services/slack/createSlackApp"; +import { cronHandler } from "@/utils/lambda/cronHandler"; -type Event = - | APIGatewayProxyEventV2 - | EventBridgeEvent<"Scheduled Event", unknown>; - -const isApiGatewayProxyEventV2 = ( - event: Event, -): event is APIGatewayProxyEventV2 => "queryStringParameters" in event; - -export const handler = async (request: Event) => { +export const handler = cronHandler(async (eventId?: string) => { const { start, end } = getIceBreakerWindow(); const users = await getBirthdaysBetween(start, end); const app = createSlackApp(); - const eventId = isApiGatewayProxyEventV2(request) - ? request.queryStringParameters?.eventId - : undefined; - const message = await app.client.chat.postMessage( constructIceBreakerQuestion({ channel: Config.RANDOM_SLACK_CHANNEL_ID, @@ -47,4 +35,4 @@ export const handler = async (request: Event) => { }), ), ); -}; +}); diff --git a/packages/functions/events/askPresentIdeas.ts b/packages/functions/events/askPresentIdeas.ts new file mode 100644 index 0000000..d5e7f6e --- /dev/null +++ b/packages/functions/events/askPresentIdeas.ts @@ -0,0 +1,8 @@ +import { handleEvent } from "@/utils/eventBridge/handleEvent"; + +export const handler = handleEvent( + "askPresentIdeas", + async ({ team, user, eventId }) => { + console.log(`askPresentIdeas: ${team} ${user} ${eventId}`); + }, +); diff --git a/packages/functions/utils/lambda/cronHandler.ts b/packages/functions/utils/lambda/cronHandler.ts new file mode 100644 index 0000000..008c271 --- /dev/null +++ b/packages/functions/utils/lambda/cronHandler.ts @@ -0,0 +1,22 @@ +import type { APIGatewayProxyEventV2, EventBridgeEvent } from "aws-lambda"; + +type Event = + | APIGatewayProxyEventV2 + | EventBridgeEvent<"Scheduled Event", unknown>; + +const isApiGatewayProxyEventV2 = ( + event: Event, +): event is APIGatewayProxyEventV2 => "queryStringParameters" in event; + +export const cronHandler = + (handler: (eventId?: string) => Promise) => async (request: Event) => { + try { + const eventId = isApiGatewayProxyEventV2(request) + ? request.queryStringParameters?.eventId + : undefined; + + await handler(eventId); + } catch (error) { + console.error(error); + } + }; diff --git a/stacks/CronStack.ts b/stacks/CronStack.ts index 3547ee6..c3c34ec 100644 --- a/stacks/CronStack.ts +++ b/stacks/CronStack.ts @@ -6,11 +6,19 @@ import { ConfigStack } from "./ConfigStack"; export function CronStack({ stack }: StackContext) { const secrets = use(ConfigStack); - const cron = new Cron(stack, "Cron", { + const icebreakerCron = new Cron(stack, "Cron", { job: "packages/functions/cron/iceBreakerQuestions.handler", // Every first Tuesday of the month at 11:00 UTC schedule: "cron(0 11 ? * 3#1 *)", }); - cron.bind(secrets); + icebreakerCron.bind(secrets); + + const dailyCron = new Cron(stack, "DailyCron", { + job: "packages/functions/cron/daily.handler", + // Every day at 11:00 UTC + schedule: "cron(0 11 ? * * *)", + }); + + dailyCron.bind(secrets); } diff --git a/stacks/MyStack.ts b/stacks/MyStack.ts index cad81a4..5e50b51 100644 --- a/stacks/MyStack.ts +++ b/stacks/MyStack.ts @@ -59,6 +59,7 @@ export function MyStack({ stack }: StackContext) { "POST /slack/interaction": "packages/functions/lambdas/slack-interaction.handler", "GET /icebreaker": "packages/functions/cron/iceBreakerQuestions.handler", + "GET /daily": "packages/functions/cron/daily.handler", }, }); diff --git a/tests/unit/presentIdeas.test.ts b/tests/unit/presentIdeas.test.ts new file mode 100644 index 0000000..e823ef0 --- /dev/null +++ b/tests/unit/presentIdeas.test.ts @@ -0,0 +1,112 @@ +import { + EventBridgeClient, + PutEventsCommand, +} from "@aws-sdk/client-eventbridge"; +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; +import type { Mock } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { getBirthdays } from "@/db/queries/getBirthdays"; +import type { SelectUser } from "@/db/schema"; +import { callWithMockCronEvent } from "@/testUtils/callWithMockCronEvent"; +import { mockEventBridgePayload } from "@/testUtils/mocks/mockEventBridgePayload"; + +dayjs.extend(utc); + +const constants = vi.hoisted(() => ({ + userId: "U001", + teamId: "T001", + secondUserId: "U002", + eventId: "E001", +})); + +vi.mock("@aws-sdk/client-eventbridge", async () => { + const EventBridgeClient = vi.fn(); + EventBridgeClient.prototype.send = vi.fn(); + + const PutEventsCommand = vi.fn(); + + return { + EventBridgeClient, + PutEventsCommand, + }; +}); + +vi.mock("@/db/queries/getBirthdays", () => ({ + getBirthdays: vi.fn().mockResolvedValue([]), +})); + +describe("Present ideas", () => { + let eventBridge: EventBridgeClient; + let getBirthdaysMock: Mock; + + beforeEach(() => { + eventBridge = new EventBridgeClient(); + getBirthdaysMock = getBirthdays as Mock; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("Should publish askPresentIdeas event if user has birthday exactly 2 months from now", async () => { + getBirthdaysMock.mockResolvedValueOnce([ + { + id: constants.userId, + teamId: constants.teamId, + birthday: dayjs.utc().add(2, "month").toDate(), + }, + ] satisfies SelectUser[]); + + await callWithMockCronEvent(constants.eventId); + + expect(eventBridge.send).toHaveBeenCalledOnce(); + expect(PutEventsCommand).toHaveBeenCalledWith( + mockEventBridgePayload("askPresentIdeas", { + team: constants.teamId, + user: constants.userId, + eventId: constants.eventId, + }), + ); + }); + + it("Should publish askPresentIdeas event twice if 2 users have birthday exactly 2 months from now", async () => { + getBirthdaysMock.mockResolvedValueOnce([ + { + id: constants.userId, + teamId: constants.teamId, + birthday: dayjs.utc().add(2, "month").toDate(), + }, + { + id: constants.secondUserId, + teamId: constants.teamId, + birthday: dayjs.utc().add(2, "month").toDate(), + }, + ] satisfies SelectUser[]); + + await callWithMockCronEvent(constants.eventId); + + expect(eventBridge.send).toHaveBeenCalledTimes(2); + expect(PutEventsCommand).toHaveBeenCalledWith( + mockEventBridgePayload("askPresentIdeas", { + team: constants.teamId, + user: constants.userId, + eventId: constants.eventId, + }), + ); + expect(PutEventsCommand).toHaveBeenCalledWith( + mockEventBridgePayload("askPresentIdeas", { + team: constants.teamId, + user: constants.secondUserId, + eventId: constants.eventId, + }), + ); + }); + + it("Should not publish askPresentIdeas event if no user has birthday exactly 2 months from now", async () => { + await callWithMockCronEvent(constants.eventId); + + expect(eventBridge.send).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/utils/callWithMockCronEvent.ts b/tests/utils/callWithMockCronEvent.ts new file mode 100644 index 0000000..aa27532 --- /dev/null +++ b/tests/utils/callWithMockCronEvent.ts @@ -0,0 +1,11 @@ +import { handler } from "@/functions/cron/daily"; + +import { mockLambdaEvent } from "./mocks/mockLambdaPayload"; + +export const callWithMockCronEvent = async (eventId: string) => + handler({ + ...mockLambdaEvent, + queryStringParameters: { + eventId, + }, + }); From 26a51314a3c30b208a093f1dde8cd890b0c420b2 Mon Sep 17 00:00:00 2001 From: Balint Dolla Date: Fri, 3 Nov 2023 15:52:18 +0100 Subject: [PATCH 2/7] feat: send back response on ice breaker lambda call --- packages/functions/cron/iceBreakerQuestions.ts | 4 ++++ packages/functions/utils/lambda/cronHandler.ts | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/functions/cron/iceBreakerQuestions.ts b/packages/functions/cron/iceBreakerQuestions.ts index b166047..a271c2f 100644 --- a/packages/functions/cron/iceBreakerQuestions.ts +++ b/packages/functions/cron/iceBreakerQuestions.ts @@ -35,4 +35,8 @@ export const handler = cronHandler(async (eventId?: string) => { }), ), ); + + return { + message: "Ice breaker question sent", + }; }); diff --git a/packages/functions/utils/lambda/cronHandler.ts b/packages/functions/utils/lambda/cronHandler.ts index 008c271..5b1a1e9 100644 --- a/packages/functions/utils/lambda/cronHandler.ts +++ b/packages/functions/utils/lambda/cronHandler.ts @@ -9,7 +9,8 @@ const isApiGatewayProxyEventV2 = ( ): event is APIGatewayProxyEventV2 => "queryStringParameters" in event; export const cronHandler = - (handler: (eventId?: string) => Promise) => async (request: Event) => { + (handler: (eventId?: string) => Promise) => + async (request: Event) => { try { const eventId = isApiGatewayProxyEventV2(request) ? request.queryStringParameters?.eventId From 1effd06c6371a2212f40123ae9f1e3487e365787 Mon Sep 17 00:00:00 2001 From: Balint Dolla Date: Fri, 3 Nov 2023 15:52:18 +0100 Subject: [PATCH 3/7] feat: send message to teammates when user has birthday --- packages/core/db/queries/getTeammates.ts | 15 +++ .../slack/constructAskBirthdayMessage.ts | 2 +- .../slack/constructAskPresentIdeasMessage.ts | 42 ++++++ .../slack/constructIceBreakerQuestion.ts | 18 +-- packages/functions/events/askPresentIdeas.ts | 28 +++- tests/integration/iceBreakerQuestions.test.ts | 10 +- tests/integration/presentIdeas.test.ts | 56 ++++++++ tests/unit/getBirthdaysBetween.test.ts | 10 +- tests/unit/presentIdeas.test.ts | 124 +++++++++++++++--- tests/utils/waitForDm.ts | 2 +- 10 files changed, 272 insertions(+), 35 deletions(-) create mode 100644 packages/core/db/queries/getTeammates.ts create mode 100644 packages/core/services/slack/constructAskPresentIdeasMessage.ts create mode 100644 tests/integration/presentIdeas.test.ts diff --git a/packages/core/db/queries/getTeammates.ts b/packages/core/db/queries/getTeammates.ts new file mode 100644 index 0000000..d44cb9c --- /dev/null +++ b/packages/core/db/queries/getTeammates.ts @@ -0,0 +1,15 @@ +import { and, eq, not } from "drizzle-orm"; + +import { db } from "@/db/index"; +import { users } from "@/db/schema"; + +export const getTeammates = async (teamId: string, userId: string) => { + const teammates = await db + .select({ + id: users.id, + }) + .from(users) + .where(and(eq(users.teamId, teamId), not(eq(users.id, userId)))); + + return teammates.map((teammate) => teammate.id); +}; diff --git a/packages/core/services/slack/constructAskBirthdayMessage.ts b/packages/core/services/slack/constructAskBirthdayMessage.ts index a57c0fc..c16d853 100644 --- a/packages/core/services/slack/constructAskBirthdayMessage.ts +++ b/packages/core/services/slack/constructAskBirthdayMessage.ts @@ -43,7 +43,7 @@ const constructBaseAskBirthdayMessage = ({ metadata: { event_type: "askBirthday", event_payload: { - originalEventId: eventId, + eventId, }, }, }); diff --git a/packages/core/services/slack/constructAskPresentIdeasMessage.ts b/packages/core/services/slack/constructAskPresentIdeasMessage.ts new file mode 100644 index 0000000..d6dbd1d --- /dev/null +++ b/packages/core/services/slack/constructAskPresentIdeasMessage.ts @@ -0,0 +1,42 @@ +import type { ChatPostMessageArguments } from "@slack/web-api"; + +type Arguments = { + birthdayPerson: string; + user: string; + name: string; + eventId?: string; +}; + +export const constructAskPresentIdeasMessage = ({ + birthdayPerson, + user, + name, + eventId, +}: Arguments): ChatPostMessageArguments => + ({ + channel: user, + metadata: { + event_type: "askPresentIdeas", + event_payload: { + eventId: eventId || "", + birthdayPerson, + }, + }, + text: `Hey, <@${user}>! <@${birthdayPerson}>'s birthday is in 2 months. Do you have any present ideas?`, + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: `Hey ${name}! 👋`, + }, + }, + { + type: "section", + text: { + type: "mrkdwn", + text: `It's <@${birthdayPerson}>'s birthday is in 2 months. Do you have any present ideas?`, + }, + }, + ], + }) satisfies ChatPostMessageArguments; diff --git a/packages/core/services/slack/constructIceBreakerQuestion.ts b/packages/core/services/slack/constructIceBreakerQuestion.ts index 1f692e0..6154d69 100644 --- a/packages/core/services/slack/constructIceBreakerQuestion.ts +++ b/packages/core/services/slack/constructIceBreakerQuestion.ts @@ -10,9 +10,11 @@ const iceBreakerQuestions = [ "Hey Guys! What was the last item that you were window shopping for?", ]; -export const constructIceBreakerQuestion = ( - args: Arguments, -): ChatPostMessageArguments => { +export const constructIceBreakerQuestion = ({ + users, + channel, + eventId, +}: Arguments): ChatPostMessageArguments => { const randomIceBreakerQuestion = iceBreakerQuestions[Math.floor(Math.random() * iceBreakerQuestions.length)]; @@ -26,12 +28,12 @@ export const constructIceBreakerQuestion = ( }, ]; - if (args.users.length) { + if (users.length) { blocks.push({ type: "section", text: { type: "mrkdwn", - text: `Let's see your ones ${args.users + text: `Let's see your ones ${users .map((user) => `<@${user}>`) .join(", ")}!`, }, @@ -39,12 +41,12 @@ export const constructIceBreakerQuestion = ( } return { - channel: args.channel, - metadata: args.eventId + channel: channel, + metadata: eventId ? { event_type: "iceBreakerQuestion", event_payload: { - eventId: args.eventId, + eventId: eventId, }, } : undefined, diff --git a/packages/functions/events/askPresentIdeas.ts b/packages/functions/events/askPresentIdeas.ts index d5e7f6e..443cf38 100644 --- a/packages/functions/events/askPresentIdeas.ts +++ b/packages/functions/events/askPresentIdeas.ts @@ -1,8 +1,34 @@ +import { getTeammates } from "@/db/queries/getTeammates"; +import { constructAskPresentIdeasMessage } from "@/services/slack/constructAskPresentIdeasMessage"; +import { createSlackApp } from "@/services/slack/createSlackApp"; +import { getUserInfo } from "@/services/slack/getUserInfo"; import { handleEvent } from "@/utils/eventBridge/handleEvent"; export const handler = handleEvent( "askPresentIdeas", async ({ team, user, eventId }) => { - console.log(`askPresentIdeas: ${team} ${user} ${eventId}`); + const teammates = await getTeammates(team, user); + + const app = createSlackApp(); + + const userNames = await Promise.all( + teammates.map((teammate) => getUserInfo(teammate)), + ); + + await Promise.all( + teammates.map((teammate, i) => + app.client.chat.postMessage( + constructAskPresentIdeasMessage({ + birthdayPerson: user, + user: teammate, + name: + userNames[i].user?.profile?.first_name || + userNames[i].user?.name || + "", + eventId, + }), + ), + ), + ); }, ); diff --git a/tests/integration/iceBreakerQuestions.test.ts b/tests/integration/iceBreakerQuestions.test.ts index 32775cd..e540ea0 100644 --- a/tests/integration/iceBreakerQuestions.test.ts +++ b/tests/integration/iceBreakerQuestions.test.ts @@ -12,6 +12,10 @@ import { import { testDb } from "@/testUtils/testDb"; import { waitForPostInRandom } from "@/testUtils/waitForPostInRandomChannel"; +const constants = vi.hoisted(() => ({ + teamId: "T1", +})); + describe("Icebreaker questions", () => { beforeAll(async () => { await testDb.delete(users); @@ -84,7 +88,7 @@ describe("Icebreaker questions", () => { const items = await testDb .select() .from(iceBreakerThreads) - .where(eq(iceBreakerThreads.teamId, "T1")); + .where(eq(iceBreakerThreads.teamId, constants.teamId)); if (items.length < usersInWindow.length) { return Promise.reject(); @@ -100,7 +104,7 @@ describe("Icebreaker questions", () => { for (const user of usersInWindow) { expect(threads).toContainEqual({ id: expect.any(Number), - teamId: "T1", + teamId: constants.teamId, userId: user, threadId: expect.any(String), }); @@ -109,7 +113,7 @@ describe("Icebreaker questions", () => { for (const user of usersOutsideWindow) { expect(threads).not.toContainEqual({ id: expect.any(Number), - teamId: "T1", + teamId: constants.teamId, userId: user, threadId: expect.any(String), }); diff --git a/tests/integration/presentIdeas.test.ts b/tests/integration/presentIdeas.test.ts new file mode 100644 index 0000000..d1e794a --- /dev/null +++ b/tests/integration/presentIdeas.test.ts @@ -0,0 +1,56 @@ +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; + +import { users } from "@/db/schema"; +import { timeout } from "@/testUtils/constants"; +import { deleteLastDmMessage } from "@/testUtils/deleteLastDmMessage"; +import { testDb } from "@/testUtils/testDb"; +import { waitForDm } from "@/testUtils/waitForDm"; + +dayjs.extend(utc); + +const constants = vi.hoisted(() => ({ + birthdayPerson: "U1", +})); + +describe("Present ideas", () => { + beforeAll(async () => { + await testDb.delete(users); + }); + + afterEach(async () => { + await deleteLastDmMessage(); + + await testDb.delete(users); + }); + + it( + "Should ask for present ideas in DM", + async () => { + await testDb.insert(users).values({ + id: import.meta.env.VITE_SLACK_USER_ID, + teamId: import.meta.env.VITE_SLACK_TEAM_ID, + birthday: new Date(), + }); + + await testDb.insert(users).values({ + id: constants.birthdayPerson, + teamId: import.meta.env.VITE_SLACK_TEAM_ID, + birthday: dayjs.utc().add(2, "month").toDate(), + }); + + const eventId = "PI1_" + Date.now().toString(); + + await fetch(`${import.meta.env.VITE_API_URL}/daily?eventId=${eventId}`); + + const message = await waitForDm(eventId); + + expect(message.blocks?.length).toBe(2); + expect(message.blocks?.[1].text?.text).toContain( + `<@${constants.birthdayPerson}>`, + ); + }, + timeout, + ); +}); diff --git a/tests/unit/getBirthdaysBetween.test.ts b/tests/unit/getBirthdaysBetween.test.ts index cd5db19..7d5ddb9 100644 --- a/tests/unit/getBirthdaysBetween.test.ts +++ b/tests/unit/getBirthdaysBetween.test.ts @@ -1,7 +1,7 @@ import "@/testUtils/mocks/mockDb"; import dayjs from "dayjs"; -import { afterEach, beforeAll, describe, expect, it } from "vitest"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { getBirthdaysBetween } from "@/db/queries/getBirthdays"; import { users } from "@/db/schema"; @@ -13,6 +13,10 @@ import { import { testCases } from "@/testUtils/iceBreakerTestCases"; import { testDb } from "@/testUtils/testDb"; +const constants = vi.hoisted(() => ({ + teamId: "T1", +})); + describe("Get birthdays between", () => { beforeAll(async () => { await testDb.delete(users); @@ -34,7 +38,7 @@ describe("Get birthdays between", () => { for (const user of usersInWindow) { expect(filteredUsers).toContainEqual({ id: user, - teamId: "T1", + teamId: constants.teamId, birthday: expect.any(Date), }); } @@ -42,7 +46,7 @@ describe("Get birthdays between", () => { for (const user of usersOutsideWindow) { expect(filteredUsers).not.toContainEqual({ id: user, - teamId: "T1", + teamId: constants.teamId, birthday: expect.any(Date), }); } diff --git a/tests/unit/presentIdeas.test.ts b/tests/unit/presentIdeas.test.ts index e823ef0..2b2c69e 100644 --- a/tests/unit/presentIdeas.test.ts +++ b/tests/unit/presentIdeas.test.ts @@ -1,3 +1,7 @@ +import "@/testUtils/mocks/mockDb"; +import "@/testUtils/mocks/mockEventBridge"; +import "@/testUtils/mocks/mockSlackApp"; + import { EventBridgeClient, PutEventsCommand, @@ -5,49 +9,72 @@ import { import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; import type { Mock } from "vitest"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; import { getBirthdays } from "@/db/queries/getBirthdays"; -import type { SelectUser } from "@/db/schema"; +import { type SelectUser, users } from "@/db/schema"; +import type { Events } from "@/events"; +import { handler } from "@/functions/events/askPresentIdeas"; +import { constructAskPresentIdeasMessage } from "@/services/slack/constructAskPresentIdeasMessage"; +import { createSlackApp } from "@/services/slack/createSlackApp"; import { callWithMockCronEvent } from "@/testUtils/callWithMockCronEvent"; import { mockEventBridgePayload } from "@/testUtils/mocks/mockEventBridgePayload"; +import { sendMockSqsMessage } from "@/testUtils/sendMockSqsMessage"; +import { testDb } from "@/testUtils/testDb"; dayjs.extend(utc); const constants = vi.hoisted(() => ({ userId: "U001", teamId: "T001", - secondUserId: "U002", + otherUserIds: ["U002", "U003"], eventId: "E001", + namePostfix: "Name", })); -vi.mock("@aws-sdk/client-eventbridge", async () => { - const EventBridgeClient = vi.fn(); - EventBridgeClient.prototype.send = vi.fn(); - - const PutEventsCommand = vi.fn(); - - return { - EventBridgeClient, - PutEventsCommand, - }; -}); - vi.mock("@/db/queries/getBirthdays", () => ({ getBirthdays: vi.fn().mockResolvedValue([]), })); +vi.mock("@/services/slack/getUserInfo", async () => ({ + getUserInfo: vi.fn().mockImplementation((userId: string) => + Promise.resolve({ + user: { + is_bot: false, + profile: { + first_name: userId + constants.namePostfix, + }, + }, + }), + ), +})); + describe("Present ideas", () => { let eventBridge: EventBridgeClient; let getBirthdaysMock: Mock; + let createSlackAppMock: Mock; + + beforeAll(async () => { + await testDb.delete(users); + }); beforeEach(() => { eventBridge = new EventBridgeClient(); getBirthdaysMock = getBirthdays as Mock; + createSlackAppMock = createSlackApp as Mock; }); - afterEach(() => { + afterEach(async () => { vi.clearAllMocks(); + await testDb.delete(users); }); it("Should publish askPresentIdeas event if user has birthday exactly 2 months from now", async () => { @@ -79,7 +106,7 @@ describe("Present ideas", () => { birthday: dayjs.utc().add(2, "month").toDate(), }, { - id: constants.secondUserId, + id: constants.otherUserIds[0], teamId: constants.teamId, birthday: dayjs.utc().add(2, "month").toDate(), }, @@ -98,7 +125,7 @@ describe("Present ideas", () => { expect(PutEventsCommand).toHaveBeenCalledWith( mockEventBridgePayload("askPresentIdeas", { team: constants.teamId, - user: constants.secondUserId, + user: constants.otherUserIds[0], eventId: constants.eventId, }), ); @@ -109,4 +136,65 @@ describe("Present ideas", () => { expect(eventBridge.send).not.toHaveBeenCalled(); }); + + it("Should send dm to team mates", async () => { + const event = { + user: constants.userId, + team: constants.teamId, + eventId: constants.eventId, + } satisfies Events["askPresentIdeas"]; + + const sendDMMock = vi.spyOn( + createSlackAppMock().client.chat, + "postMessage", + ); + + await Promise.all( + constants.otherUserIds.map((userId) => + testDb.insert(users).values({ + id: userId, + teamId: constants.teamId, + birthday: dayjs.utc().toDate(), + }), + ), + ); + + await sendMockSqsMessage("askPresentIdeas", event, handler); + + expect(sendDMMock).toHaveBeenCalledTimes(constants.otherUserIds.length); + + constants.otherUserIds.forEach((userId) => { + expect(sendDMMock).toHaveBeenCalledWith( + constructAskPresentIdeasMessage({ + birthdayPerson: constants.userId, + user: userId, + name: userId + constants.namePostfix, + eventId: constants.eventId, + }), + ); + }); + }); + + it("Should not send dm to the one whose birthday is coming up", async () => { + const event = { + user: constants.userId, + team: constants.teamId, + eventId: constants.eventId, + } satisfies Events["askPresentIdeas"]; + + const sendDMMock = vi.spyOn( + createSlackAppMock().client.chat, + "postMessage", + ); + + await testDb.insert(users).values({ + id: constants.userId, + teamId: constants.teamId, + birthday: dayjs.utc().toDate(), + }); + + await sendMockSqsMessage("askPresentIdeas", event, handler); + + expect(sendDMMock).not.toHaveBeenCalled(); + }); }); diff --git a/tests/utils/waitForDm.ts b/tests/utils/waitForDm.ts index 3ae405f..cc09ead 100644 --- a/tests/utils/waitForDm.ts +++ b/tests/utils/waitForDm.ts @@ -21,7 +21,7 @@ export const waitForDm = async (eventId: string) => (message) => // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - (message.metadata?.event_payload?.["originalEventId"] as + (message.metadata?.event_payload?.["eventId"] as | string | undefined) === eventId, ) From c1a6464e8712aad724d5156eb0ade6cbf1f75bcb Mon Sep 17 00:00:00 2001 From: Balint Dolla Date: Fri, 3 Nov 2023 16:14:39 +0100 Subject: [PATCH 4/7] chore: use one insertmany statement --- packages/functions/cron/iceBreakerQuestions.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/functions/cron/iceBreakerQuestions.ts b/packages/functions/cron/iceBreakerQuestions.ts index a271c2f..2dc26ff 100644 --- a/packages/functions/cron/iceBreakerQuestions.ts +++ b/packages/functions/cron/iceBreakerQuestions.ts @@ -26,14 +26,12 @@ export const handler = cronHandler(async (eventId?: string) => { throw new Error("Failed to send ice breaker question"); } - await Promise.all( - users.map((user) => - db.insert(iceBreakerThreads).values({ - userId: user.id, - teamId: user.teamId, - threadId: message.ts!, - }), - ), + await db.insert(iceBreakerThreads).values( + users.map((user) => ({ + userId: user.id, + teamId: user.teamId, + threadId: message.ts!, + })), ); return { From a5d83bcedfbc37ab54084b6e2d50671710659d01 Mon Sep 17 00:00:00 2001 From: Balint Dolla Date: Fri, 3 Nov 2023 17:02:26 +0100 Subject: [PATCH 5/7] chore: move sending slack message to separate lambda --- packages/core/db/schema.ts | 2 - packages/core/events/index.ts | 8 +- packages/functions/cron/daily.ts | 9 +- packages/functions/events/askPresentIdeas.ts | 34 ----- .../events/askPresentIdeasFromTeam.ts | 20 +++ .../events/askPresentIdeasFromUser.ts | 26 ++++ ...tIdeas.test.ts => askPresentIdeas.test.ts} | 0 ...tIdeas.test.ts => askPresentIdeas.test.ts} | 140 ++++++++++-------- 8 files changed, 134 insertions(+), 105 deletions(-) delete mode 100644 packages/functions/events/askPresentIdeas.ts create mode 100644 packages/functions/events/askPresentIdeasFromTeam.ts create mode 100644 packages/functions/events/askPresentIdeasFromUser.ts rename tests/integration/{presentIdeas.test.ts => askPresentIdeas.test.ts} (100%) rename tests/unit/{presentIdeas.test.ts => askPresentIdeas.test.ts} (54%) diff --git a/packages/core/db/schema.ts b/packages/core/db/schema.ts index e21f510..e97adc2 100644 --- a/packages/core/db/schema.ts +++ b/packages/core/db/schema.ts @@ -19,8 +19,6 @@ export const users = pgTable( }), ); -export type SelectUser = typeof users.$inferSelect; - export const iceBreakerThreads = pgTable( "iceBreakerThreads", { diff --git a/packages/core/events/index.ts b/packages/core/events/index.ts index a571a00..02ceb60 100644 --- a/packages/core/events/index.ts +++ b/packages/core/events/index.ts @@ -30,10 +30,14 @@ const Events = z.object({ birthday: z.string(), responseUrl: z.string(), }), - askPresentIdeas: BaseEvent.extend({ - user: z.string(), + askPresentIdeasFromTeam: BaseEvent.extend({ + birthdayPerson: z.string(), team: z.string(), }), + askPresentIdeasFromUser: BaseEvent.extend({ + birthdayPerson: z.string(), + user: z.string(), + }), }); export type Events = z.infer; diff --git a/packages/functions/cron/daily.ts b/packages/functions/cron/daily.ts index 7d7a6d7..f281112 100644 --- a/packages/functions/cron/daily.ts +++ b/packages/functions/cron/daily.ts @@ -14,11 +14,16 @@ export const handler = cronHandler(async (eventId?: string) => { await Promise.all( users.map((user) => - publishEvent("askPresentIdeas", { + publishEvent("askPresentIdeasFromTeam", { team: user.teamId, - user: user.id, + birthdayPerson: user.id, eventId, }), ), ); + + return { + users, + message: users.length ? "Sent present ideas requests" : "No birthdays", + }; }); diff --git a/packages/functions/events/askPresentIdeas.ts b/packages/functions/events/askPresentIdeas.ts deleted file mode 100644 index 443cf38..0000000 --- a/packages/functions/events/askPresentIdeas.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { getTeammates } from "@/db/queries/getTeammates"; -import { constructAskPresentIdeasMessage } from "@/services/slack/constructAskPresentIdeasMessage"; -import { createSlackApp } from "@/services/slack/createSlackApp"; -import { getUserInfo } from "@/services/slack/getUserInfo"; -import { handleEvent } from "@/utils/eventBridge/handleEvent"; - -export const handler = handleEvent( - "askPresentIdeas", - async ({ team, user, eventId }) => { - const teammates = await getTeammates(team, user); - - const app = createSlackApp(); - - const userNames = await Promise.all( - teammates.map((teammate) => getUserInfo(teammate)), - ); - - await Promise.all( - teammates.map((teammate, i) => - app.client.chat.postMessage( - constructAskPresentIdeasMessage({ - birthdayPerson: user, - user: teammate, - name: - userNames[i].user?.profile?.first_name || - userNames[i].user?.name || - "", - eventId, - }), - ), - ), - ); - }, -); diff --git a/packages/functions/events/askPresentIdeasFromTeam.ts b/packages/functions/events/askPresentIdeasFromTeam.ts new file mode 100644 index 0000000..f2c1bbe --- /dev/null +++ b/packages/functions/events/askPresentIdeasFromTeam.ts @@ -0,0 +1,20 @@ +import { getTeammates } from "@/db/queries/getTeammates"; +import { handleEvent } from "@/utils/eventBridge/handleEvent"; +import { publishEvent } from "@/utils/eventBridge/publishEvent"; + +export const handler = handleEvent( + "askPresentIdeasFromTeam", + async ({ team, birthdayPerson, eventId }) => { + const teammates = await getTeammates(team, birthdayPerson); + + await Promise.all( + teammates.map((teammate) => + publishEvent("askPresentIdeasFromUser", { + user: teammate, + birthdayPerson, + eventId, + }), + ), + ); + }, +); diff --git a/packages/functions/events/askPresentIdeasFromUser.ts b/packages/functions/events/askPresentIdeasFromUser.ts new file mode 100644 index 0000000..71659ea --- /dev/null +++ b/packages/functions/events/askPresentIdeasFromUser.ts @@ -0,0 +1,26 @@ +import { constructAskPresentIdeasMessage } from "@/services/slack/constructAskPresentIdeasMessage"; +import { createSlackApp } from "@/services/slack/createSlackApp"; +import { getUserInfo } from "@/services/slack/getUserInfo"; +import { handleEvent } from "@/utils/eventBridge/handleEvent"; + +export const handler = handleEvent( + "askPresentIdeasFromUser", + async ({ birthdayPerson, user, eventId }) => { + const userInfo = await getUserInfo(user); + + if (!userInfo.user) { + return; + } + + const app = createSlackApp(); + + await app.client.chat.postMessage( + constructAskPresentIdeasMessage({ + birthdayPerson, + user, + name: userInfo.user.profile?.first_name || userInfo.user.name || "", + eventId, + }), + ); + }, +); diff --git a/tests/integration/presentIdeas.test.ts b/tests/integration/askPresentIdeas.test.ts similarity index 100% rename from tests/integration/presentIdeas.test.ts rename to tests/integration/askPresentIdeas.test.ts diff --git a/tests/unit/presentIdeas.test.ts b/tests/unit/askPresentIdeas.test.ts similarity index 54% rename from tests/unit/presentIdeas.test.ts rename to tests/unit/askPresentIdeas.test.ts index 2b2c69e..53b7266 100644 --- a/tests/unit/presentIdeas.test.ts +++ b/tests/unit/askPresentIdeas.test.ts @@ -8,7 +8,6 @@ import { } from "@aws-sdk/client-eventbridge"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; -import type { Mock } from "vitest"; import { afterEach, beforeAll, @@ -19,12 +18,9 @@ import { vi, } from "vitest"; -import { getBirthdays } from "@/db/queries/getBirthdays"; -import { type SelectUser, users } from "@/db/schema"; +import { users } from "@/db/schema"; import type { Events } from "@/events"; -import { handler } from "@/functions/events/askPresentIdeas"; -import { constructAskPresentIdeasMessage } from "@/services/slack/constructAskPresentIdeasMessage"; -import { createSlackApp } from "@/services/slack/createSlackApp"; +import { handler as askPresentIdeasFromTeam } from "@/functions/events/askPresentIdeasFromTeam"; import { callWithMockCronEvent } from "@/testUtils/callWithMockCronEvent"; import { mockEventBridgePayload } from "@/testUtils/mocks/mockEventBridgePayload"; import { sendMockSqsMessage } from "@/testUtils/sendMockSqsMessage"; @@ -40,10 +36,6 @@ const constants = vi.hoisted(() => ({ namePostfix: "Name", })); -vi.mock("@/db/queries/getBirthdays", () => ({ - getBirthdays: vi.fn().mockResolvedValue([]), -})); - vi.mock("@/services/slack/getUserInfo", async () => ({ getUserInfo: vi.fn().mockImplementation((userId: string) => Promise.resolve({ @@ -57,10 +49,8 @@ vi.mock("@/services/slack/getUserInfo", async () => ({ ), })); -describe("Present ideas", () => { +describe("Daily cron", () => { let eventBridge: EventBridgeClient; - let getBirthdaysMock: Mock; - let createSlackAppMock: Mock; beforeAll(async () => { await testDb.delete(users); @@ -68,8 +58,6 @@ describe("Present ideas", () => { beforeEach(() => { eventBridge = new EventBridgeClient(); - getBirthdaysMock = getBirthdays as Mock; - createSlackAppMock = createSlackApp as Mock; }); afterEach(async () => { @@ -77,29 +65,27 @@ describe("Present ideas", () => { await testDb.delete(users); }); - it("Should publish askPresentIdeas event if user has birthday exactly 2 months from now", async () => { - getBirthdaysMock.mockResolvedValueOnce([ - { - id: constants.userId, - teamId: constants.teamId, - birthday: dayjs.utc().add(2, "month").toDate(), - }, - ] satisfies SelectUser[]); + it("Should publish askPresentIdeasFromTeam event if user has birthday exactly 2 months from now", async () => { + await testDb.insert(users).values({ + id: constants.userId, + teamId: constants.teamId, + birthday: dayjs.utc().add(2, "month").toDate(), + }); await callWithMockCronEvent(constants.eventId); expect(eventBridge.send).toHaveBeenCalledOnce(); expect(PutEventsCommand).toHaveBeenCalledWith( - mockEventBridgePayload("askPresentIdeas", { + mockEventBridgePayload("askPresentIdeasFromTeam", { team: constants.teamId, - user: constants.userId, + birthdayPerson: constants.userId, eventId: constants.eventId, }), ); }); - it("Should publish askPresentIdeas event twice if 2 users have birthday exactly 2 months from now", async () => { - getBirthdaysMock.mockResolvedValueOnce([ + it("Should publish askPresentIdeasFromTeam event twice if 2 users have birthday exactly 2 months from now", async () => { + await testDb.insert(users).values([ { id: constants.userId, teamId: constants.teamId, @@ -110,82 +96,102 @@ describe("Present ideas", () => { teamId: constants.teamId, birthday: dayjs.utc().add(2, "month").toDate(), }, - ] satisfies SelectUser[]); + ]); await callWithMockCronEvent(constants.eventId); expect(eventBridge.send).toHaveBeenCalledTimes(2); expect(PutEventsCommand).toHaveBeenCalledWith( - mockEventBridgePayload("askPresentIdeas", { + mockEventBridgePayload("askPresentIdeasFromTeam", { team: constants.teamId, - user: constants.userId, + birthdayPerson: constants.userId, eventId: constants.eventId, }), ); expect(PutEventsCommand).toHaveBeenCalledWith( - mockEventBridgePayload("askPresentIdeas", { + mockEventBridgePayload("askPresentIdeasFromTeam", { team: constants.teamId, - user: constants.otherUserIds[0], + birthdayPerson: constants.otherUserIds[0], eventId: constants.eventId, }), ); }); - it("Should not publish askPresentIdeas event if no user has birthday exactly 2 months from now", async () => { + it("Should not publish askPresentIdeasFromTeam event if no user has birthday exactly 2 months from now", async () => { + await testDb.insert(users).values([ + { + id: constants.userId, + teamId: constants.teamId, + birthday: dayjs.utc().add(3, "month").toDate(), + }, + { + id: constants.otherUserIds[0], + teamId: constants.teamId, + birthday: dayjs.utc().add(4, "month").toDate(), + }, + ]); + await callWithMockCronEvent(constants.eventId); expect(eventBridge.send).not.toHaveBeenCalled(); }); +}); - it("Should send dm to team mates", async () => { +describe("askPresentIdeasFromTeam", () => { + let eventBridge: EventBridgeClient; + + beforeAll(async () => { + await testDb.delete(users); + }); + + beforeEach(() => { + eventBridge = new EventBridgeClient(); + }); + + afterEach(async () => { + vi.clearAllMocks(); + await testDb.delete(users); + }); + + it("Should publish askPresentIdeasFromUser event", async () => { const event = { - user: constants.userId, + birthdayPerson: constants.userId, team: constants.teamId, eventId: constants.eventId, - } satisfies Events["askPresentIdeas"]; + } satisfies Events["askPresentIdeasFromTeam"]; - const sendDMMock = vi.spyOn( - createSlackAppMock().client.chat, - "postMessage", + await testDb.insert(users).values( + constants.otherUserIds.map((userId) => ({ + id: userId, + teamId: constants.teamId, + birthday: dayjs.utc().toDate(), + })), ); - await Promise.all( - constants.otherUserIds.map((userId) => - testDb.insert(users).values({ - id: userId, - teamId: constants.teamId, - birthday: dayjs.utc().toDate(), - }), - ), + await sendMockSqsMessage( + "askPresentIdeasFromTeam", + event, + askPresentIdeasFromTeam, ); - await sendMockSqsMessage("askPresentIdeas", event, handler); - - expect(sendDMMock).toHaveBeenCalledTimes(constants.otherUserIds.length); - + expect(eventBridge.send).toHaveBeenCalledTimes(2); constants.otherUserIds.forEach((userId) => { - expect(sendDMMock).toHaveBeenCalledWith( - constructAskPresentIdeasMessage({ - birthdayPerson: constants.userId, + expect(PutEventsCommand).toHaveBeenCalledWith( + mockEventBridgePayload("askPresentIdeasFromUser", { user: userId, - name: userId + constants.namePostfix, + birthdayPerson: constants.userId, eventId: constants.eventId, }), ); }); }); - it("Should not send dm to the one whose birthday is coming up", async () => { + it("Should not publish askPresentIdeasFromUser event for the one whose birthday is coming up", async () => { const event = { - user: constants.userId, + birthdayPerson: constants.userId, team: constants.teamId, eventId: constants.eventId, - } satisfies Events["askPresentIdeas"]; - - const sendDMMock = vi.spyOn( - createSlackAppMock().client.chat, - "postMessage", - ); + } satisfies Events["askPresentIdeasFromTeam"]; await testDb.insert(users).values({ id: constants.userId, @@ -193,8 +199,12 @@ describe("Present ideas", () => { birthday: dayjs.utc().toDate(), }); - await sendMockSqsMessage("askPresentIdeas", event, handler); + await sendMockSqsMessage( + "askPresentIdeasFromTeam", + event, + askPresentIdeasFromTeam, + ); - expect(sendDMMock).not.toHaveBeenCalled(); + expect(eventBridge.send).not.toHaveBeenCalled(); }); }); From 46969db55dba05a121c6b7a464a962679e08413e Mon Sep 17 00:00:00 2001 From: Balint Dolla Date: Fri, 3 Nov 2023 17:25:37 +0100 Subject: [PATCH 6/7] chore: update diagram --- architecture/backend.drawio | 225 +++++++++++++++++++++++++++--------- 1 file changed, 173 insertions(+), 52 deletions(-) diff --git a/architecture/backend.drawio b/architecture/backend.drawio index bf23a37..1755cd4 100644 --- a/architecture/backend.drawio +++ b/architecture/backend.drawio @@ -1,11 +1,11 @@ - + - + @@ -39,15 +39,15 @@ - + - + - + @@ -56,12 +56,12 @@ - + - + - + @@ -86,15 +86,18 @@ - + - + + + + @@ -136,7 +139,7 @@ - + @@ -144,6 +147,20 @@ + + + + + + + + + + + + + + @@ -175,10 +192,10 @@ - + - + @@ -186,10 +203,17 @@ + + + + + + + - - + + @@ -205,35 +229,28 @@ - - - - - - - - + - + - + - + - + - + @@ -268,19 +285,19 @@ - - + + - + - + - + @@ -290,12 +307,12 @@ - + - + - + @@ -307,22 +324,22 @@ - + - - + + - - + + - + - + - + @@ -331,12 +348,12 @@ - + - + - + @@ -344,20 +361,124 @@ - + - + - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From bf1605997eabc4e7889d1f6c301af1944721a182 Mon Sep 17 00:00:00 2001 From: Balint Dolla Date: Fri, 3 Nov 2023 17:43:03 +0100 Subject: [PATCH 7/7] chore: refactor slack messages --- .../slack/constructAskBirthdayMessage.ts | 31 +++------- .../slack/constructAskPresentIdeasMessage.ts | 20 ++----- .../constructBirthdayConfirmedMessage.ts | 12 +--- .../slack/constructConfirmBirthdayMessage.ts | 49 +++------------ .../slack/constructIceBreakerQuestion.ts | 26 ++++---- packages/core/services/slack/messageItems.ts | 59 +++++++++++++++++++ tests/utils/generateIceBreakerTestUsers.ts | 14 ++--- 7 files changed, 100 insertions(+), 111 deletions(-) create mode 100644 packages/core/services/slack/messageItems.ts diff --git a/packages/core/services/slack/constructAskBirthdayMessage.ts b/packages/core/services/slack/constructAskBirthdayMessage.ts index c16d853..c80dcb6 100644 --- a/packages/core/services/slack/constructAskBirthdayMessage.ts +++ b/packages/core/services/slack/constructAskBirthdayMessage.ts @@ -3,6 +3,8 @@ import type { ChatPostMessageArguments } from "@slack/web-api"; import type { ChatReplaceMessageArguments } from "@/types/ChatReplaceMessageArguments"; import { pickBirthdayActionId } from "@/types/SlackInteractionRequest"; +import { makeTextBlock, makeTextBlockWithDatepicker } from "./messageItems"; + type Arguments = { user: string; name: string; @@ -15,30 +17,11 @@ const constructBaseAskBirthdayMessage = ({ }: Arguments): Omit => ({ text: "Please share your birthday with us! 🥳", blocks: [ - { - type: "section", - text: { - type: "mrkdwn", - text: `Hey ${name}! 👋`, - }, - }, - { - type: "section", - text: { - type: "mrkdwn", - text: "Please share your birthday with us! 🥳", - }, - accessory: { - type: "datepicker", - initial_date: "1995-01-01", - placeholder: { - type: "plain_text", - text: "Select a date", - emoji: true, - }, - action_id: pickBirthdayActionId, - }, - }, + makeTextBlock(`Hey ${name}! 👋`), + makeTextBlockWithDatepicker( + "Please share your birthday with us! 🥳", + pickBirthdayActionId, + ), ], metadata: { event_type: "askBirthday", diff --git a/packages/core/services/slack/constructAskPresentIdeasMessage.ts b/packages/core/services/slack/constructAskPresentIdeasMessage.ts index d6dbd1d..2923bf0 100644 --- a/packages/core/services/slack/constructAskPresentIdeasMessage.ts +++ b/packages/core/services/slack/constructAskPresentIdeasMessage.ts @@ -1,5 +1,7 @@ import type { ChatPostMessageArguments } from "@slack/web-api"; +import { makeTextBlock } from "./messageItems"; + type Arguments = { birthdayPerson: string; user: string; @@ -24,19 +26,9 @@ export const constructAskPresentIdeasMessage = ({ }, text: `Hey, <@${user}>! <@${birthdayPerson}>'s birthday is in 2 months. Do you have any present ideas?`, blocks: [ - { - type: "section", - text: { - type: "mrkdwn", - text: `Hey ${name}! 👋`, - }, - }, - { - type: "section", - text: { - type: "mrkdwn", - text: `It's <@${birthdayPerson}>'s birthday is in 2 months. Do you have any present ideas?`, - }, - }, + makeTextBlock(`Hey ${name}! 👋`), + makeTextBlock( + `It's <@${birthdayPerson}>'s birthday is in 2 months. Do you have any present ideas?`, + ), ], }) satisfies ChatPostMessageArguments; diff --git a/packages/core/services/slack/constructBirthdayConfirmedMessage.ts b/packages/core/services/slack/constructBirthdayConfirmedMessage.ts index 5dcc894..d5aff5c 100644 --- a/packages/core/services/slack/constructBirthdayConfirmedMessage.ts +++ b/packages/core/services/slack/constructBirthdayConfirmedMessage.ts @@ -1,16 +1,10 @@ import type { ChatReplaceMessageArguments } from "@/types/ChatReplaceMessageArguments"; +import { makeTextBlock } from "./messageItems"; + export const constructBirthdayConfirmedMessage = (): ChatReplaceMessageArguments => ({ replace_original: true, text: "Thanks for submitting your birthday! 🎉", - blocks: [ - { - type: "section", - text: { - type: "mrkdwn", - text: `Thanks for submitting your birthday! 🎉`, - }, - }, - ], + blocks: [makeTextBlock(`Thanks for submitting your birthday! 🎉`)], }); diff --git a/packages/core/services/slack/constructConfirmBirthdayMessage.ts b/packages/core/services/slack/constructConfirmBirthdayMessage.ts index 9333f9a..54651d5 100644 --- a/packages/core/services/slack/constructConfirmBirthdayMessage.ts +++ b/packages/core/services/slack/constructConfirmBirthdayMessage.ts @@ -4,6 +4,8 @@ import { birthdayIncorrectActionId, } from "@/types/SlackInteractionRequest"; +import { makeActionsBlock, makeTextBlock } from "./messageItems"; + export const constructConfirmBirthdayMessage = ( birthday: string, ): ChatReplaceMessageArguments => @@ -11,45 +13,12 @@ export const constructConfirmBirthdayMessage = ( replace_original: true, text: "Confirm your birthday", blocks: [ - { - type: "section", - text: { - type: "mrkdwn", - text: `Are you sure your birthday is ${birthday}?`, - }, - }, - { - type: "actions", - elements: [ - { - type: "button", - text: { - type: "plain_text", - emoji: true, - text: "Yes", - }, - style: "primary", - action_id: birthdayConfirmActionId, - value: birthday, - }, - { - type: "button", - text: { - type: "plain_text", - emoji: true, - text: "No", - }, - style: "danger", - action_id: birthdayIncorrectActionId, - value: birthday, - }, - ], - }, - ], - metadata: { - event_type: "confirmBirthday", - event_payload: { + makeTextBlock(`Are you sure your birthday is ${birthday}?`), + makeActionsBlock( + birthdayConfirmActionId, + birthdayIncorrectActionId, birthday, - }, - }, + "", + ), + ], }) satisfies ChatReplaceMessageArguments; diff --git a/packages/core/services/slack/constructIceBreakerQuestion.ts b/packages/core/services/slack/constructIceBreakerQuestion.ts index 6154d69..7e7d4f7 100644 --- a/packages/core/services/slack/constructIceBreakerQuestion.ts +++ b/packages/core/services/slack/constructIceBreakerQuestion.ts @@ -1,5 +1,7 @@ import type { ChatPostMessageArguments, KnownBlock } from "@slack/web-api"; +import { makeTextBlock } from "./messageItems"; + type Arguments = { users: string[]; channel: string; @@ -19,25 +21,17 @@ export const constructIceBreakerQuestion = ({ iceBreakerQuestions[Math.floor(Math.random() * iceBreakerQuestions.length)]; const blocks: KnownBlock[] = [ - { - type: "section", - text: { - type: "mrkdwn", - text: `${randomIceBreakerQuestion} Post your picks in the thread! 👇`, - }, - }, + makeTextBlock( + `${randomIceBreakerQuestion} Post your picks in the thread! 👇`, + ), ]; if (users.length) { - blocks.push({ - type: "section", - text: { - type: "mrkdwn", - text: `Let's see your ones ${users - .map((user) => `<@${user}>`) - .join(", ")}!`, - }, - }); + blocks.push( + makeTextBlock( + `Let's see your ones ${users.map((user) => `<@${user}>`).join(", ")}!`, + ), + ); } return { diff --git a/packages/core/services/slack/messageItems.ts b/packages/core/services/slack/messageItems.ts new file mode 100644 index 0000000..a4c760c --- /dev/null +++ b/packages/core/services/slack/messageItems.ts @@ -0,0 +1,59 @@ +import type { ActionsBlock, SectionBlock } from "@slack/web-api"; + +export const makeTextBlock = (text: string): SectionBlock => ({ + type: "section", + text: { + type: "mrkdwn", + text, + }, +}); + +export const makeTextBlockWithDatepicker = ( + text: string, + actionId: string, +): SectionBlock => ({ + ...makeTextBlock(text), + accessory: { + type: "datepicker", + initial_date: "1995-01-01", + placeholder: { + type: "plain_text", + text: "Select a date", + emoji: true, + }, + action_id: actionId, + }, +}); + +export const makeActionsBlock = ( + yesActionId: string, + noActionId: string, + yesValue: string, + noValue: string, +): ActionsBlock => ({ + type: "actions", + elements: [ + { + type: "button", + text: { + type: "plain_text", + emoji: true, + text: "Yes", + }, + style: "primary", + action_id: yesActionId, + value: yesValue, + }, + { + type: "button", + text: { + type: "plain_text", + emoji: true, + text: "No", + }, + style: "danger", + action_id: noActionId, + value: noValue, + }, + ], +}); diff --git a/tests/utils/generateIceBreakerTestUsers.ts b/tests/utils/generateIceBreakerTestUsers.ts index e213254..3d38988 100644 --- a/tests/utils/generateIceBreakerTestUsers.ts +++ b/tests/utils/generateIceBreakerTestUsers.ts @@ -30,14 +30,12 @@ export const generateIceBreakerTestUsers = async (today?: string) => { start.add(6, "months"), // random day outside the window ]; - await Promise.all( - birthdays.map((birthday, i) => - testDb.insert(users).values({ - id: `U${i + 1}`, - teamId: "T1", - birthday: birthday.toDate(), - }), - ), + await testDb.insert(users).values( + birthdays.map((birthday, i) => ({ + id: `U${i + 1}`, + teamId: "T1", + birthday: birthday.toDate(), + })), ); };