Skip to content
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

APEX-1562 - send welcome message to birthday squad #26

Merged
merged 8 commits into from
Nov 17, 2023
Merged
Show file tree
Hide file tree
Changes from 4 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
1 change: 1 addition & 0 deletions .github/workflows/pr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ jobs:
VITE_RANDOM_SLACK_CHANNEL_ID: ${{ secrets.RANDOM_SLACK_CHANNEL_ID_TEST }}
VITE_SLACK_DM_ID: ${{ secrets.SLACK_DM_ID_TEST }}
VITE_SLACK_TEAM_ID: ${{ secrets.SLACK_TEAM_ID_TEST }}
VITE_STAGE: ${{ env.STAGE }}
VITE_CI: true
VITE_DB_NAME: ${{ steps.sst-output.outputs.database }}
VITE_DB_SECRET_ARN: ${{ steps.sst-output.outputs.secretArn }}
Expand Down
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,14 +111,17 @@ pnpm test:ci

All urls are displayed in the console output.

- Send out icebreaker question: open `<ApiEndpoint>/icebreaker`
- Ask birthday from everyone: open `<ApiEndpoint>/botJoined`
- Ask birthday from specific user: open `<ApiEndpoint>/userJoined?userId=<slack user id>`
- Send out birthday fill reminder: open `<ApiEndpoint>/daily`
- Send out birthday fill reminder: open `<ApiEndpoint>/birthdayPing`
- Only sends it out to users who have not filled in their birthday yet.
- Send out birthday present idea question: open `<ApiEndpoint>/daily`
- Send out icebreaker question: open `<ApiEndpoint>/icebreaker`
- Only mentions users whose birthday is between 1 and 3 months.
- Send out birthday present idea question: open `<ApiEndpoint>/askPresentIdeas?userId=<birthday person slack user id>`
- Sends it out related to users whose birthday is in exactly 2 months.
- Send out birthday present idea + squadjoin question: open `<ApiEndpoint>/squadJoin?userId=<slack user id>`
- Send out birthday present idea + squadjoin question: open `<ApiEndpoint>/squadJoin?userId=<birthday person slack user id>`
- Create birthday squad: open `<ApiEndpoint>/createBirthdaySquad?userId=<birthday person slack user id>`
- Clean up birthday data: open `<ApiEndpoint>/cleanup?userId=<birthday person slack user id>`

## Generate a new migration

Expand Down
20 changes: 20 additions & 0 deletions packages/core/db/queries/getIceBreakerThreads.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { and, eq } from "drizzle-orm";

import { db } from "@/db/index";
import { iceBreakerThreads } from "@/db/schema";

export const getIceBreakerThreads = async (teamId: string, userId?: string) => {
const threads = await db
.select({
threadId: iceBreakerThreads.threadId,
})
.from(iceBreakerThreads)
.where(
and(
eq(iceBreakerThreads.teamId, teamId),
userId ? eq(iceBreakerThreads.userId, userId) : undefined,
),
);

return threads.map((thread) => thread.threadId);
};
22 changes: 22 additions & 0 deletions packages/core/db/queries/getPresentIdeasByUser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { and, eq } from "drizzle-orm";

import { db } from "@/db/index";
import { presentIdeas } from "@/db/schema";

export const getPresentIdeasByUser = async (teamId: string, userId: string) => {
const ideas = await db
.select()
.from(presentIdeas)
.where(
and(
eq(presentIdeas.teamId, teamId),
eq(presentIdeas.birthdayPerson, userId),
),
);

return ideas.reduce<Map<string, string[]>>((acc, idea) => {
const currentIdeas = acc.get(idea.userId) ?? [];
acc.set(idea.userId, [...currentIdeas, idea.presentIdea]);
return acc;
}, new Map());
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { getIceBreakerThreads } from "@/db/queries/getIceBreakerThreads";
import { getPresentIdeasByUser } from "@/db/queries/getPresentIdeasByUser";
import type { PostMessageArguments } from "@/types/MessageArguments";

import { getIceBreakerThreadLink } from "./getIceBreakerThreadLink";

type Arguments = {
channel: string;
teamId: string;
birthdayPerson: string;
eventId?: string;
};

export const welcomeMessageEventType = "sendSquadWelcomeMessage";

const previousPresentsSheet =
"https://docs.google.com/spreadsheets/d/1pzdNkF-18OUS6qhcVD2HHGkTwTmCXJEvR8LBzPjUnGI/edit?pli=1#gid=0";

const formatIceBreakerLinks = (iceBreakerLinks: string[]) =>
iceBreakerLinks.map((iceBreakerLink) => `• ${iceBreakerLink}`).join("\n");

const formatPresentIdeas = (presentIdeas: string[]) =>
presentIdeas
.map((idea) =>
idea
.split("\n")
.map((line) => `> ${line}`)
.join("\n"),
)
.join("\n\n");

export const constructBirthdaySquadWelcomeMessage = async ({
debrecenid marked this conversation as resolved.
Show resolved Hide resolved
channel,
teamId,
birthdayPerson,
eventId,
}: Arguments): Promise<PostMessageArguments> => {
const iceBreakers = await getIceBreakerThreads(teamId, birthdayPerson);

const iceBreakerLinkResponses = await Promise.all(
iceBreakers.map(getIceBreakerThreadLink),
);

const iceBreakerLinks = formatIceBreakerLinks(
iceBreakerLinkResponses.flatMap(
(iceBreakerLink) => iceBreakerLink.permalink ?? [],
),
);

const presentIdeas = await getPresentIdeasByUser(teamId, birthdayPerson);

const presentIdeasText = Array.from(presentIdeas.entries())
.map(([user, ideas]) => `<@${user}>:\n${formatPresentIdeas(ideas)}\n`)
.join("\n");

return {
channel,
text: `Welcome to the birthday squad of <@${birthdayPerson}>! 🎁`,
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: `*Welcome to the birthday squad of <@${birthdayPerson}>! 🎁*`,
},
},
{
type: "section",
text: {
type: "mrkdwn",
text: `*Icebreaker threads*\n${iceBreakerLinks}`,
},
},
{
type: "section",
debrecenid marked this conversation as resolved.
Show resolved Hide resolved
text: {
type: "mrkdwn",
text: `*Present ideas*\nHere are the present ideas from everyone in the team:\n\n${presentIdeasText}`,
},
},
{
type: "section",
text: {
type: "mrkdwn",
text: `*Previous presents*\nYou can find the previous presents in <${previousPresentsSheet}|this spreadsheet>.`,
},
},
],
unfurl_links: false,
metadata: eventId
? {
event_type: welcomeMessageEventType,
event_payload: {
eventId,
},
}
: undefined,
};
};
12 changes: 12 additions & 0 deletions packages/core/services/slack/getIceBreakerThreadLink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Config } from "sst/node/config";

import { createSlackApp } from "./createSlackApp";

export const getIceBreakerThreadLink = async (threadId: string) => {
const app = createSlackApp();

return app.client.chat.getPermalink({
channel: Config.RANDOM_SLACK_CHANNEL_ID,
message_ts: threadId,
});
};
2 changes: 1 addition & 1 deletion packages/core/types/MessageArguments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { ChatPostMessageArguments } from "@slack/web-api";

export type PostMessageArguments = Pick<
ChatPostMessageArguments,
"channel" | "text" | "blocks" | "metadata"
"channel" | "text" | "blocks" | "metadata" | "unfurl_links"
>;

export type ReplaceMessageArguments = Omit<PostMessageArguments, "channel"> & {
Expand Down
6 changes: 3 additions & 3 deletions packages/core/types/cron/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export const iceBreaker = "iceBreaker";
export const daily = "daily";
export type CronEventType = "iceBreaker" | "daily";

export type CronEventType = typeof iceBreaker | typeof daily;
export const getCronEvent = (type: CronEventType, stage: string) =>
`${type}-${stage}`;
debrecenid marked this conversation as resolved.
Show resolved Hide resolved
4 changes: 1 addition & 3 deletions packages/core/types/schedule/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
export const scheduleEvent = "scheduleEvent";

export type ScheduleEventType = typeof scheduleEvent;
export const getScheduledEvent = (stage: string) => `scheduleEvent-${stage}`;
2 changes: 1 addition & 1 deletion packages/functions/cron/daily.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { cronHandler } from "@/utils/lambda/cronHandler";

import { publishBirthdayEvents } from "./utils/publishBirthdayEvents";

const sendReminderWhoseBirthdayIsMissing = async (eventId?: string) => {
export const sendReminderWhoseBirthdayIsMissing = async (eventId?: string) => {
const users = await getUsersWhoseBirthdayIsMissing();

await Promise.all(
Expand Down
22 changes: 5 additions & 17 deletions packages/functions/events/sendSquadWelcomeMessage.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,20 @@
import { constructBirthdaySquadWelcomeMessage } from "@/services/slack/constructBirthdaySquadWelcomeMessage";
import { createSlackApp } from "@/services/slack/createSlackApp";
import { handleEvent } from "@/utils/eventBridge/handleEvent";

//TODO APEX-1562
export const handler = handleEvent(
"sendSquadWelcomeMessage",
async ({ team, birthdayPerson, conversationId, eventId }) => {
try {
console.info({
team,
const message = await constructBirthdaySquadWelcomeMessage({
channel: conversationId,
teamId: team,
birthdayPerson,
conversationId,
eventId,
});

//TODO APEX-1562
const app = createSlackApp();
await app.client.chat.postMessage({
channel: conversationId,
metadata: eventId
? {
event_type: "sendSquadWelcomeMessage",
event_payload: {
eventId,
},
}
: undefined,
text: "Test message",
});
await app.client.chat.postMessage(message);
} catch (error) {
console.error("Error processing sendSquadWelcomeMessage event: ", error);
}
Expand Down
30 changes: 30 additions & 0 deletions packages/functions/lambdas/manualAskPresentIdeasEvent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { APIGatewayProxyHandlerV2 } from "aws-lambda";

import { getUser } from "@/db/queries/getUser";
import { publishEvent } from "@/utils/eventBridge/publishEvent";
import { errorResult, okResult } from "@/utils/lambda/result";

export const handler: APIGatewayProxyHandlerV2 = async (request) => {
try {
if (!request.queryStringParameters?.userId) {
throw new Error("No userId");
}

const user = await getUser(request.queryStringParameters.userId);

if (!user) {
throw new Error("User not found");
}

await publishEvent("askPresentIdeasFromTeam", {
birthdayPerson: user.id,
team: user.teamId,
});

return okResult("Event sent");
} catch (error) {
console.error(`Error sending manual ask present ideas event: ${error}`);

return errorResult(error);
}
};
30 changes: 30 additions & 0 deletions packages/functions/lambdas/manualCleanup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { APIGatewayProxyHandlerV2 } from "aws-lambda";

import { getUser } from "@/db/queries/getUser";
import { publishEvent } from "@/utils/eventBridge/publishEvent";
import { errorResult, okResult } from "@/utils/lambda/result";

export const handler: APIGatewayProxyHandlerV2 = async (request) => {
try {
if (!request.queryStringParameters?.userId) {
throw new Error("No userId");
}

const user = await getUser(request.queryStringParameters.userId);

if (!user) {
throw new Error("User not found");
}

await publishEvent("birthdayCleanup", {
birthdayPerson: user.id,
team: user.teamId,
});

return okResult("Event sent");
} catch (error) {
console.error(`Error sending manual birthday cleanup event: ${error}`);

return errorResult(error);
}
};
30 changes: 30 additions & 0 deletions packages/functions/lambdas/manualCreateBirthdaySquad.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { APIGatewayProxyHandlerV2 } from "aws-lambda";

import { getUser } from "@/db/queries/getUser";
import { publishEvent } from "@/utils/eventBridge/publishEvent";
import { errorResult, okResult } from "@/utils/lambda/result";

export const handler: APIGatewayProxyHandlerV2 = async (request) => {
try {
if (!request.queryStringParameters?.userId) {
throw new Error("No userId");
}

const user = await getUser(request.queryStringParameters.userId);

if (!user) {
throw new Error("User not found");
}

await publishEvent("createBirthdaySquad", {
birthdayPerson: user.id,
team: user.teamId,
});

return okResult("Event sent");
} catch (error) {
console.error(`Error sending manual createBirthdaySquad event: ${error}`);

return errorResult(error);
}
};
16 changes: 16 additions & 0 deletions packages/functions/lambdas/manualReping.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { APIGatewayProxyHandlerV2 } from "aws-lambda";
import { sendReminderWhoseBirthdayIsMissing } from "cron/daily";

import { errorResult, okResult } from "@/utils/lambda/result";

export const handler: APIGatewayProxyHandlerV2 = async () => {
try {
await sendReminderWhoseBirthdayIsMissing();

return okResult("Ping sent");
} catch (error) {
console.error(`Error sending manual ping event: ${error}`);

return errorResult(error);
}
};
2 changes: 1 addition & 1 deletion packages/functions/utils/lambda/result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export const errorResult = (error: unknown) => ({
statusCode: 500,
body: JSON.stringify(
{
error,
error: error instanceof Error ? error.message : error,
},
null,
2,
Expand Down
Loading
Loading