diff --git a/packages/core/db/migrations/0004_birthday_can_be_null.sql b/packages/core/db/migrations/0004_birthday_can_be_null.sql new file mode 100644 index 0000000..5b328ba --- /dev/null +++ b/packages/core/db/migrations/0004_birthday_can_be_null.sql @@ -0,0 +1 @@ +ALTER TABLE "users" ALTER COLUMN "birthday" DROP NOT NULL; \ No newline at end of file diff --git a/packages/core/db/migrations/meta/0003_snapshot.json b/packages/core/db/migrations/meta/0003_snapshot.json index 19921f8..762ffd9 100644 --- a/packages/core/db/migrations/meta/0003_snapshot.json +++ b/packages/core/db/migrations/meta/0003_snapshot.json @@ -39,14 +39,8 @@ "name": "iceBreakerThreads_user_id_team_id_users_id_team_id_fk", "tableFrom": "iceBreakerThreads", "tableTo": "users", - "columnsFrom": [ - "user_id", - "team_id" - ], - "columnsTo": [ - "id", - "team_id" - ], + "columnsFrom": ["user_id", "team_id"], + "columnsTo": ["id", "team_id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -95,14 +89,8 @@ "name": "presentIdeas_user_id_team_id_users_id_team_id_fk", "tableFrom": "presentIdeas", "tableTo": "users", - "columnsFrom": [ - "user_id", - "team_id" - ], - "columnsTo": [ - "id", - "team_id" - ], + "columnsFrom": ["user_id", "team_id"], + "columnsTo": ["id", "team_id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -110,14 +98,8 @@ "name": "presentIdeas_birthday_person_team_id_users_id_team_id_fk", "tableFrom": "presentIdeas", "tableTo": "users", - "columnsFrom": [ - "birthday_person", - "team_id" - ], - "columnsTo": [ - "id", - "team_id" - ], + "columnsFrom": ["birthday_person", "team_id"], + "columnsTo": ["id", "team_id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -175,10 +157,7 @@ "compositePrimaryKeys": { "users_id_team_id": { "name": "users_id_team_id", - "columns": [ - "id", - "team_id" - ] + "columns": ["id", "team_id"] } }, "uniqueConstraints": {} @@ -191,4 +170,4 @@ "tables": {}, "columns": {} } -} \ No newline at end of file +} diff --git a/packages/core/db/migrations/meta/0004_snapshot.json b/packages/core/db/migrations/meta/0004_snapshot.json new file mode 100644 index 0000000..042b5eb --- /dev/null +++ b/packages/core/db/migrations/meta/0004_snapshot.json @@ -0,0 +1,194 @@ +{ + "version": "5", + "dialect": "pg", + "id": "f4d596d1-e02b-4c39-98c3-5a48f49b8c65", + "prevId": "3ba6727b-d01d-4de2-a788-d1cdaa365084", + "tables": { + "iceBreakerThreads": { + "name": "iceBreakerThreads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "iceBreakerThreads_user_id_team_id_users_id_team_id_fk": { + "name": "iceBreakerThreads_user_id_team_id_users_id_team_id_fk", + "tableFrom": "iceBreakerThreads", + "tableTo": "users", + "columnsFrom": [ + "user_id", + "team_id" + ], + "columnsTo": [ + "id", + "team_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "presentIdeas": { + "name": "presentIdeas", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "birthday_person": { + "name": "birthday_person", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "present_idea": { + "name": "present_idea", + "type": "varchar", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "presentIdeas_user_id_team_id_users_id_team_id_fk": { + "name": "presentIdeas_user_id_team_id_users_id_team_id_fk", + "tableFrom": "presentIdeas", + "tableTo": "users", + "columnsFrom": [ + "user_id", + "team_id" + ], + "columnsTo": [ + "id", + "team_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "presentIdeas_birthday_person_team_id_users_id_team_id_fk": { + "name": "presentIdeas_birthday_person_team_id_users_id_team_id_fk", + "tableFrom": "presentIdeas", + "tableTo": "users", + "columnsFrom": [ + "birthday_person", + "team_id" + ], + "columnsTo": [ + "id", + "team_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "testItems": { + "name": "testItems", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "varchar", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "birthday": { + "name": "birthday", + "type": "date", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id_team_id": { + "name": "users_id_team_id", + "columns": [ + "id", + "team_id" + ] + } + }, + "uniqueConstraints": {} + } + }, + "enums": {}, + "schemas": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + } +} \ No newline at end of file diff --git a/packages/core/db/migrations/meta/_journal.json b/packages/core/db/migrations/meta/_journal.json index e1889bc..16d6c46 100644 --- a/packages/core/db/migrations/meta/_journal.json +++ b/packages/core/db/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1699369077670, "tag": "0003_present_ideas", "breakpoints": true + }, + { + "idx": 4, + "version": "5", + "when": 1699455923273, + "tag": "0004_birthday_can_be_null", + "breakpoints": true } ] } diff --git a/packages/core/db/queries/getBirthdays.ts b/packages/core/db/queries/getBirthdays.ts index c3b81a5..36135cd 100644 --- a/packages/core/db/queries/getBirthdays.ts +++ b/packages/core/db/queries/getBirthdays.ts @@ -1,5 +1,5 @@ import type { Dayjs } from "dayjs"; -import { and, or, sql } from "drizzle-orm"; +import { and, isNull, or, sql } from "drizzle-orm"; import { db } from "@/db/index"; import { users } from "@/db/schema"; @@ -39,3 +39,8 @@ export const getBirthdays = async (date: Dayjs) => sql`EXTRACT('DAY' FROM ${users.birthday}) = ${date.date()}`, ), }); + +export const getUsersWhoseBirthdayIsMissing = async () => + db.query.users.findMany({ + where: isNull(users.birthday), + }); diff --git a/packages/core/db/queries/saveBirthday.ts b/packages/core/db/queries/saveBirthday.ts index 6550ded..449fcb5 100644 --- a/packages/core/db/queries/saveBirthday.ts +++ b/packages/core/db/queries/saveBirthday.ts @@ -9,21 +9,23 @@ import { users } from "@/db/schema"; type Args = { user: string; teamId: string; - birthday: string; + birthday: string | null; }; export const saveBirthday = async ({ birthday, teamId, user }: Args) => { + const birthdayDate = birthday ? dayjs.utc(birthday).toDate() : null; + await db .insert(users) .values({ id: user, teamId, - birthday: dayjs.utc(birthday).toDate(), + birthday: birthdayDate, }) .onConflictDoUpdate({ target: [users.id, users.teamId], set: { - birthday: dayjs.utc(birthday).toDate(), + birthday: birthdayDate, }, }); }; diff --git a/packages/core/db/schema.ts b/packages/core/db/schema.ts index 7b9b99c..1a6e828 100644 --- a/packages/core/db/schema.ts +++ b/packages/core/db/schema.ts @@ -12,7 +12,7 @@ export const users = pgTable( { id: varchar("id").notNull(), teamId: varchar("team_id").notNull(), - birthday: date("birthday", { mode: "date" }).notNull(), + birthday: date("birthday", { mode: "date" }), }, (t) => ({ pk: primaryKey(t.id, t.teamId), diff --git a/packages/functions/cron/daily.ts b/packages/functions/cron/daily.ts index f1e2813..906caf2 100644 --- a/packages/functions/cron/daily.ts +++ b/packages/functions/cron/daily.ts @@ -3,10 +3,26 @@ import utc from "dayjs/plugin/utc"; dayjs.extend(utc); -import { getBirthdays } from "@/db/queries/getBirthdays"; +import { + getBirthdays, + getUsersWhoseBirthdayIsMissing, +} from "@/db/queries/getBirthdays"; import { publishEvent } from "@/utils/eventBridge/publishEvent"; import { cronHandler } from "@/utils/lambda/cronHandler"; +const sendReminderWhoseBirthdayIsMissing = async (eventId?: string) => { + const users = await getUsersWhoseBirthdayIsMissing(); + + await Promise.all( + users.map((user) => + publishEvent("askBirthday", { + user: user.id, + eventId, + }), + ), + ); +}; + export const handler = cronHandler(async (eventId?: string) => { try { const today = dayjs().utc().startOf("day"); @@ -23,6 +39,8 @@ export const handler = cronHandler(async (eventId?: string) => { ), ); + await sendReminderWhoseBirthdayIsMissing(eventId); + return { users, message: users.length ? "Sent present ideas requests" : "No birthdays", diff --git a/packages/functions/events/askBirthday.ts b/packages/functions/events/askBirthday.ts index 1b845aa..cfbb70b 100644 --- a/packages/functions/events/askBirthday.ts +++ b/packages/functions/events/askBirthday.ts @@ -1,3 +1,4 @@ +import { saveBirthday } from "@/db/queries/saveBirthday"; import { constructAskBirthdayMessage, constructAskBirthdayMessageReplacement, @@ -10,9 +11,9 @@ export const handler = handleEvent( "askBirthday", async ({ user, eventId, responseUrl }) => { try { - const userInfo = await getUserInfo(user); + const { user: userInfo } = await getUserInfo(user); - if (!userInfo.user || userInfo.user.is_bot) { + if (!userInfo || userInfo.is_bot || !userInfo.team_id) { return; } @@ -20,7 +21,7 @@ export const handler = handleEvent( const payload = { user, - name: userInfo.user.profile?.first_name || userInfo.user.name || "", + name: userInfo.profile?.first_name || userInfo.name || "", eventId, }; @@ -35,6 +36,12 @@ export const handler = handleEvent( return; } + await saveBirthday({ + birthday: null, + teamId: userInfo.team_id, + user, + }); + await app.client.chat.postMessage(constructAskBirthdayMessage(payload)); } catch (error) { console.error("Error processing askBirthday event: ", error); diff --git a/tests/integration/askBirthday.test.ts b/tests/integration/askBirthday.test.ts index 0cbf7bb..f0d96a2 100644 --- a/tests/integration/askBirthday.test.ts +++ b/tests/integration/askBirthday.test.ts @@ -7,6 +7,7 @@ import { constructBirthdayConfirmedMessage } from "@/services/slack/constructBir import { constructConfirmBirthdayMessage } from "@/services/slack/constructConfirmBirthdayMessage"; import { pollInterval, timeout, waitTimeout } from "@/testUtils/constants"; import { deleteLastDm } from "@/testUtils/integration/deleteLastDm"; +import { sendCronEvent } from "@/testUtils/integration/sendCronEvent"; import { sendSlackInteraction } from "@/testUtils/integration/sendSlackInteraction"; import { app } from "@/testUtils/integration/testSlackApp"; import { waitForDm } from "@/testUtils/integration/waitForDm"; @@ -207,4 +208,29 @@ describe("Slack interactions", () => { }, timeout, ); + + it( + "Should ask for birthday again when birthday is null", + async () => { + const eventId = "AB5_" + Date.now().toString(); + + await testDb.insert(users).values([ + { + id: import.meta.env.VITE_SLACK_USER_ID, + teamId: import.meta.env.VITE_SLACK_TEAM_ID, + birthday: null, + }, + ]); + + await sendCronEvent("daily", eventId); + + const message = await waitForDm(eventId); + + expect( + message.blocks?.[1].accessory?.type, + "Message doesn't have datepicker", + ).toBe("datepicker"); + }, + timeout, + ); }); diff --git a/tests/unit/askBirthday.test.ts b/tests/unit/askBirthday.test.ts index 459d728..a58abf3 100644 --- a/tests/unit/askBirthday.test.ts +++ b/tests/unit/askBirthday.test.ts @@ -1,26 +1,44 @@ import "@/testUtils/unit/mockSlackApp"; +import "@/testUtils/unit/mockDb"; +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 { getUsersWhoseBirthdayIsMissing } from "@/db/queries/getBirthdays"; +import { users } from "@/db/schema"; import type { Events } from "@/events"; import { handler } from "@/functions/events/askBirthday"; import { constructAskBirthdayMessage } from "@/services/slack/constructAskBirthdayMessage"; import { createSlackApp } from "@/services/slack/createSlackApp"; import { getUserInfo } from "@/services/slack/getUserInfo"; +import { testDb } from "@/testUtils/testDb"; import { sendMockSqsMessage } from "@/testUtils/unit/sendMockSqsMessage"; +dayjs.extend(utc); + const constants = vi.hoisted(() => ({ otherBotUserId: "B999", userId: "U001", userName: "Foo", eventId: "E001", + team_id: "T001", })); vi.mock("@/services/slack/getUserInfo", async () => ({ getUserInfo: vi.fn().mockResolvedValue({ user: { is_bot: false, + team_id: constants.team_id, profile: { first_name: constants.userName, }, @@ -37,8 +55,9 @@ describe("Member joined channel", () => { createSlackAppMock = createSlackApp as Mock; }); - afterEach(() => { + afterEach(async () => { vi.clearAllMocks(); + await testDb.delete(users); }); it("Should not do anything if user is a bot", async () => { @@ -81,3 +100,38 @@ describe("Member joined channel", () => { ); }); }); + +describe("getUsersWhoseBirthdayIsMissing", () => { + beforeAll(async () => { + await testDb.delete(users); + }); + afterEach(async () => { + vi.clearAllMocks(); + await testDb.delete(users); + }); + + const mockUser = { + birthday: null, + id: constants.userId, + teamId: constants.team_id, + }; + + it("should return with the mock user", async () => { + await testDb.insert(users).values([mockUser]); + const result = await getUsersWhoseBirthdayIsMissing(); + + expect(result).toEqual([mockUser]); + }); + + it("shouldn't find any users", async () => { + const mockUserWithBirthday = { + ...mockUser, + birthday: dayjs.utc().toDate(), + }; + + await testDb.insert(users).values([mockUserWithBirthday]); + const result = await getUsersWhoseBirthdayIsMissing(); + + expect(result).toEqual([]); + }); +});