Skip to content

Commit

Permalink
[recnet-api] Initialize multi-distributing channels and slack integra…
Browse files Browse the repository at this point in the history
…tion (#344)

## Description

This is the very first PR for kicking off the multi-distributing weekly
digest channels and slack integration. I designed the new architecture
with a subscription data model that supports the potential scaling on
two dimensions: sending types and channels. For more information, please
refer to the design doc on
[Notion](https://www.notion.so/Multiple-Distributing-Channels-Slack-Integration-61323d4345c547cb869129e117c5d722).

Here are the changes included in this PR:
1. Add DB migration file to create the subscription table
2. Integrate Slack API and create a testing API to send direct messages

## Related Issue

- #260
- #261

## Notes

<!-- Other thing to say -->

## Test

1. set `SLACK_TOKEN` in your env var (ask me)
2. Run local server
3. Hit `POST /subscriptions/slack/test` with request body
```json
{
    "userId": xxxxx
}
```

## Screenshots (if appropriate):
<img width="736" alt="Screenshot 2024-10-27 at 4 34 16 PM"
src="https://github.com/user-attachments/assets/33a58cf5-596b-43f1-bb9b-d4c20d28dd85">


## TODO

- [x] Clear `console.log` or `console.error` for debug usage
- [ ] Update the documentation `recnet-docs` if needed
  • Loading branch information
joannechen1223 authored Oct 29, 2024
2 parents 7e59be4 + 87174fa commit 2cd51c3
Show file tree
Hide file tree
Showing 21 changed files with 4,188 additions and 3,534 deletions.
3 changes: 3 additions & 0 deletions apps/recnet-api/.env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,6 @@ export PRISMA_DATABASE_URL="postgresql://postgres:admin@localhost:5432/postgres?
# SMTP
export SMTP_USER="lil.recnet@gmail.com"
export SMTP_PASS="ask for password"

# SLACK
export SLACK_TOKEN="ask for token"
5 changes: 5 additions & 0 deletions apps/recnet-api/.eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@
{
"files": ["*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.json"],
"parser": "jsonc-eslint-parser",
"rules": {}
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
-- DropForeignKey
ALTER TABLE "Subscription" DROP CONSTRAINT "Subscription_userId_fkey";

-- AlterTable
ALTER TABLE "User" DROP COLUMN "slackEmail";

-- DropTable
DROP TABLE "Subscription";

-- DropEnum
DROP TYPE "Channel";

-- DropEnum
DROP TYPE "SubscriptionType";

Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
-- CreateEnum
CREATE TYPE "Channel" AS ENUM ('EMAIL', 'SLACK');

-- CreateEnum
CREATE TYPE "SubscriptionType" AS ENUM ('WEEKLY_DIGEST');

-- AlterTable
ALTER TABLE "User" ADD COLUMN "slackEmail" VARCHAR(128);

-- CreateTable
CREATE TABLE "Subscription" (
"id" SERIAL NOT NULL,
"userId" VARCHAR(64) NOT NULL,
"type" "SubscriptionType" NOT NULL,
"channel" "Channel" NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "Subscription_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "Subscription_userId_type_channel_key" ON "Subscription"("userId", "type", "channel");

-- AddForeignKey
ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
25 changes: 25 additions & 0 deletions apps/recnet-api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,17 @@ enum ReactionType {
// Add other reactions here as needed
}

enum Channel {
EMAIL
SLACK
// Add other channels here as needed
}

enum SubscriptionType {
WEEKLY_DIGEST
// Add other subscription types here as needed
}

model User {
id String @id @default(uuid()) @db.VarChar(64) // Primary key, UUID type
provider Provider // Enum type
Expand All @@ -65,6 +76,7 @@ model User {
lastLoginAt DateTime
role Role @default(USER) // Enum type
isActivated Boolean @default(true)
slackEmail String? @db.VarChar(128)
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
Expand All @@ -75,6 +87,7 @@ model User {
inviteCodeUsed InviteCode? @relation("InviteCodeUsedBy")
announcements Announcement[]
recReactions RecReaction[]
subscriptions Subscription[]
@@unique([provider, providerId])
}
Expand Down Expand Up @@ -170,6 +183,18 @@ model RecReaction {
@@unique([userId, recId, reaction])
}

model Subscription {
id Int @id @default(autoincrement())
userId String @db.VarChar(64)
type SubscriptionType
channel Channel
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
@@unique([userId, type, channel])
}

model WeeklyDigestCronLog {
id Int @id @default(autoincrement())
cutoff DateTime
Expand Down
2 changes: 2 additions & 0 deletions apps/recnet-api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { HealthModule } from "./modules/health/health.module";
import { InviteCodeModule } from "./modules/invite-code/invite-code.module";
import { RecModule } from "./modules/rec/rec.module";
import { StatModule } from "./modules/stat/stat.module";
import { SubscriptionModule } from "./modules/subscription/subscription.module";
import { UserModule } from "./modules/user/user.module";
import { LoggerMiddleware } from "./utils/middlewares/logger.middleware";

Expand All @@ -30,6 +31,7 @@ import { LoggerMiddleware } from "./utils/middlewares/logger.middleware";
StatModule,
EmailModule,
AnnouncementModule,
SubscriptionModule,
],
controllers: [],
providers: [],
Expand Down
4 changes: 4 additions & 0 deletions apps/recnet-api/src/config/common.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,7 @@ export const NodemailerConfig = registerAs("nodemailer", () => ({
user: parsedEnv.SMTP_USER,
pass: parsedEnv.SMTP_PASS,
}));

export const SlackConfig = registerAs("slack", () => ({
token: parsedEnv.SLACK_TOKEN,
}));
2 changes: 2 additions & 0 deletions apps/recnet-api/src/config/env.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export const EnvSchema = z.object({
SMTP_SECURE: z.string().optional().default("true"),
SMTP_USER: z.string(),
SMTP_PASS: z.string(),
// slack config
SLACK_TOKEN: z.string().optional(),
});

export const parseEnv = (env: Record<string, string | undefined>) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export const user = Prisma.validator<Prisma.UserDefaultArgs>()({
},
},
email: true,
slackEmail: true,
role: true,
isActivated: true,
following: {
Expand Down
59 changes: 59 additions & 0 deletions apps/recnet-api/src/modules/subscription/slack.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { HttpStatus, Inject, Injectable } from "@nestjs/common";
import { ConfigType } from "@nestjs/config";
import { WebClient } from "@slack/web-api";

import { SlackConfig } from "@recnet-api/config/common.config";
import UserRepository from "@recnet-api/database/repository/user.repository";
import { RecnetError } from "@recnet-api/utils/error/recnet.error";
import { ErrorCode } from "@recnet-api/utils/error/recnet.error.const";

@Injectable()
export class SlackService {
private readonly client: WebClient;

constructor(
@Inject(SlackConfig.KEY)
private readonly slackConfig: ConfigType<typeof SlackConfig>,
private readonly userRepository: UserRepository
) {
this.client = new WebClient(this.slackConfig.token);
}

public async sendDirectMessage(
userId: string,
message: string
): Promise<void> {
const user = await this.userRepository.findUserById(userId);
const email = user.slackEmail || user.email;

// Get the user's Slack ID
const userResp = await this.client.users.lookupByEmail({ email });
const slackId = userResp?.user?.id;
if (!slackId) {
throw new RecnetError(
ErrorCode.SLACK_ERROR,
HttpStatus.INTERNAL_SERVER_ERROR,
`Failed to get Slack ID`
);
}

// Open a direct message conversation
const conversationResp = await this.client.conversations.open({
users: slackId,
});
const conversationId = conversationResp?.channel?.id;
if (!conversationId) {
throw new RecnetError(
ErrorCode.SLACK_ERROR,
HttpStatus.INTERNAL_SERVER_ERROR,
`Failed to open conversation`
);
}

// Send the message
await this.client.chat.postMessage({
channel: conversationId,
text: message,
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Body, Controller, HttpStatus, Inject, Post } from "@nestjs/common";
import { ConfigType } from "@nestjs/config";
import {
ApiBody,
ApiCreatedResponse,
ApiOperation,
ApiTags,
} from "@nestjs/swagger";

import { AppConfig } from "@recnet-api/config/common.config";
import { RecnetError } from "@recnet-api/utils/error/recnet.error";
import { ErrorCode } from "@recnet-api/utils/error/recnet.error.const";

import { SlackService } from "./slack.service";

@ApiTags("subscriptions")
@Controller("subscriptions")
export class SubscriptionController {
constructor(
@Inject(AppConfig.KEY)
private readonly appConfig: ConfigType<typeof AppConfig>,
private readonly slackService: SlackService
) {}

/* Development only */
@ApiOperation({
summary: "Send weekly digest slack to the designated user.",
description: "This endpoint is for development only.",
})
@ApiCreatedResponse()
@ApiBody({
schema: {
properties: {
userId: { type: "string" },
},
required: ["userId"],
},
})
@Post("slack/test")
public async testSendingWeeklyDigest(
@Body("userId") userId: string
): Promise<void> {
if (this.appConfig.nodeEnv === "production") {
throw new RecnetError(
ErrorCode.INTERNAL_SERVER_ERROR,
HttpStatus.INTERNAL_SERVER_ERROR,
"This endpoint is only for development"
);
}
return this.slackService.sendDirectMessage(userId, "Test message");
}
}
13 changes: 13 additions & 0 deletions apps/recnet-api/src/modules/subscription/subscription.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Module } from "@nestjs/common";

import { DbRepositoryModule } from "@recnet-api/database/repository/db.repository.module";

import { SlackService } from "./slack.service";
import { SubscriptionController } from "./subscription.controller";

@Module({
controllers: [SubscriptionController],
providers: [SlackService],
imports: [DbRepositoryModule],
})
export class SubscriptionModule {}
2 changes: 2 additions & 0 deletions apps/recnet-api/src/utils/error/recnet.error.const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const ErrorCode = {
// Third party error codes
EMAIL_SEND_ERROR: 3000,
FETCH_DIGITAL_LIBRARY_ERROR: 3001,
SLACK_ERROR: 3002,
};

export const errorMessages = {
Expand All @@ -42,4 +43,5 @@ export const errorMessages = {
[ErrorCode.DB_REC_NOT_FOUND]: "Rec not found",
[ErrorCode.EMAIL_SEND_ERROR]: "Email send error",
[ErrorCode.FETCH_DIGITAL_LIBRARY_ERROR]: "Fetch digital library error",
[ErrorCode.SLACK_ERROR]: "Slack error",
};
5 changes: 5 additions & 0 deletions apps/recnet-docs/.eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@
"env": {
"jest": true
}
},
{
"files": ["*.json"],
"parser": "jsonc-eslint-parser",
"rules": {}
}
]
}
2 changes: 1 addition & 1 deletion apps/recnet-docs/specs/index.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from "react";
import { render } from "@testing-library/react";
import React from "react";

import Index from "../src/pages/index";

Expand Down
1 change: 1 addition & 0 deletions apps/recnet/src/app/rec/[id]/RecPageContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { LinkPreview } from "@recnet/recnet-web/components/LinkPreview";
import { SelfRecBadge } from "@recnet/recnet-web/components/SelfRecBadge";

import { formatDate } from "@recnet/recnet-date-fns";

import { RecReactionsList } from "./RecReactionsList";

const fallbackImage =
Expand Down
2 changes: 1 addition & 1 deletion apps/recnet/src/components/RecCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ import { Rec } from "@recnet/recnet-api-model";
import { LinkCopyButton } from "./LinkCopyButton";
import { SelfRecBadge } from "./SelfRecBadge";

import { getSharableLink } from "../utils/getSharableRecLink";
import { RecReactionsList } from "../app/rec/[id]/RecReactionsList";
import { getSharableLink } from "../utils/getSharableRecLink";

export function RecCardSkeleton() {
return (
Expand Down
4 changes: 2 additions & 2 deletions apps/recnet/src/utils/setRecnetCustomClaims.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use server";
import "server-only";
import { cookies } from "next/headers";
import { cookies, headers } from "next/headers";
import { getFirebaseAuth } from "next-firebase-auth-edge";
import { refreshServerCookies } from "next-firebase-auth-edge/lib/next/cookies";

Expand All @@ -27,7 +27,7 @@ export async function setRecnetCustomClaims(role: UserRole, userId: string) {
userId,
},
});
await refreshServerCookies(cookies(), {
await refreshServerCookies(cookies(), new Headers(headers()), {
apiKey: authConfig.apiKey,
serviceAccount: authConfig.serviceAccount,
cookieName: authConfig.cookieName,
Expand Down
10 changes: 5 additions & 5 deletions apps/recnet/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,16 @@
"incremental": true,
"plugins": [
{
"name": "next",
},
],
"name": "next"
}
]
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"tailwind.config.js",
"tailwind.config.js"
],
"exclude": ["node_modules", ".next"],
"exclude": ["node_modules", ".next"]
}
Loading

0 comments on commit 2cd51c3

Please sign in to comment.