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

Release dev to master #352

Merged
merged 28 commits into from
Nov 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
9aac34f
feat: migrate email digest
joannechen1223 Nov 6, 2024
b74f4ad
refactor: move component to dedicated folder and refactor interface
swh00tw Nov 7, 2024
1a4f520
chore: remove redundant button
swh00tw Nov 7, 2024
2eb5686
refactor: refactor code
swh00tw Nov 7, 2024
9e6d207
refactor: move ProfileEditForm to dedicated file
swh00tw Nov 7, 2024
35d19c8
refactor: move AccountSetting to dedicated file
swh00tw Nov 7, 2024
6a18384
refactor: finish refactoring UserSettingDialog and move it to global …
swh00tw Nov 7, 2024
67de25f
feat: add subscription API model and schema
swh00tw Nov 7, 2024
b947311
feat: send slack digest
joannechen1223 Nov 7, 2024
0977b90
fix: fix typo
swh00tw Nov 7, 2024
6750714
feat: add Subscription setting page
swh00tw Nov 7, 2024
bb40960
chore: export api types
swh00tw Nov 7, 2024
d2e44ad
feat: finish subscription trpc procedures
swh00tw Nov 7, 2024
775276b
refactor: weekly-digest worker refactoring
joannechen1223 Nov 7, 2024
2d4eacc
chore: patch comments
joannechen1223 Nov 7, 2024
8d7e218
script: add userSubscriptionMigrate.sh
joannechen1223 Nov 7, 2024
d77a169
feat: add subscription card
swh00tw Nov 7, 2024
2421bd6
feat: finish subscription setting panel
swh00tw Nov 7, 2024
aacff5e
feat: add GET /users/subscriptions
joannechen1223 Nov 8, 2024
41dfeb9
feat: add POST /users/subscriptions
joannechen1223 Nov 8, 2024
9ad2408
[recnet-api] Migrate email digest to subscription system and add slac…
swh00tw Nov 8, 2024
399bbe2
[recnet-api] Add user subscriptions api (#351)
joannechen1223 Nov 8, 2024
21552c2
chore: merge from dev
swh00tw Nov 8, 2024
b14821d
chore: remove comment
swh00tw Nov 8, 2024
07e3e11
[recnet-web] Subscription setting UI (#350)
swh00tw Nov 8, 2024
bfc95c0
chore: update recnet-api version to v1.8.2
joannechen1223 Nov 8, 2024
8144661
chore: version bump
swh00tw Nov 8, 2024
0a37632
fix: fix ci
swh00tw Nov 8, 2024
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
2 changes: 1 addition & 1 deletion apps/recnet-api/package.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"name": "recnet-api",
"version": "1.8.1"
"version": "1.8.2"
}
19 changes: 19 additions & 0 deletions apps/recnet-api/scripts/userSubscriptionMigrate.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#!/bin/bash

# Database connection details
DB_NAME=""
DB_USER=""
DB_HOST=""
DB_PORT=""
DB_PASSWORD=""

export PGPASSWORD=$DB_PASSWORD

# Query to select all user IDs and insert into Subscription table
psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -p "$DB_PORT" -t -c "SELECT id FROM recnet.\"User\";" | while read -r userId; do
if [[ -n "$userId" ]]; then
# Insert a new subscription for each user ID
psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -p "$DB_PORT" -c \
"INSERT INTO recnet.\"Subscription\" (\"userId\", \"type\", \"channel\") VALUES ('$userId', 'WEEKLY_DIGEST', 'EMAIL') ON CONFLICT DO NOTHING;"
fi
done
46 changes: 45 additions & 1 deletion apps/recnet-api/src/database/repository/user.repository.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { Injectable } from "@nestjs/common";
import { Prisma, Provider } from "@prisma/client";
import {
Channel,
Prisma,
Provider,
Subscription,
SubscriptionType,
} from "@prisma/client";

import PrismaConnectionProvider from "@recnet-api/database/prisma/prisma.connection.provider";
import { getOffset } from "@recnet-api/utils";
Expand Down Expand Up @@ -130,6 +136,44 @@ export default class UserRepository {
return user.isActivated;
}

public async createOrUpdateSubscription(
userId: string,
type: SubscriptionType,
channels: Channel[]
): Promise<Subscription[]> {
// delete if not in list
return this.prisma.$transaction(async (prisma) => {
await prisma.subscription.deleteMany({
where: {
userId,
type,
channel: { notIn: channels },
},
});

// create new channels
const subscriptions = await this.prisma.subscription.findMany({
where: { userId, type },
});
const currentChannels = subscriptions.map((s) => s.channel);
const newChannels = channels.filter((c) => !currentChannels.includes(c));
await prisma.subscription.createMany({
data: newChannels.map((channel) => ({
userId,
type,
channel,
})),
});

return prisma.subscription.findMany({
where: {
userId,
type,
},
});
});
}

private transformUserFilterByToPrismaWhere(
filter: UserFilterBy
): Prisma.UserWhereInput {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,12 @@ export const user = Prisma.validator<Prisma.UserDefaultArgs>()({
},
},
recommendations: true,
subscriptions: true,
},
});

export type User = Prisma.UserGetPayload<typeof user>;
export type Subscriptions = Prisma.UserGetPayload<typeof user>["subscriptions"];

export type UserFilterBy = {
handle?: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
export type WeeklyDigestCronResult = {
successCount: number;
errorUserIds: string[];
email: {
successCount: number;
errorUserIds: string[];
};
slack: {
successCount: number;
errorUserIds: string[];
};
};
11 changes: 3 additions & 8 deletions apps/recnet-api/src/modules/email/email.const.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,5 @@
export const MAX_REC_PER_MAIL = 5;
export const EMAIL_RETRY_DURATION_MS = 1000;

export const WEEKLY_DIGEST_CRON = "0 0 0 * * 3";
export const EMAIL_RETRY_LIMIT = 3;

export const RETRY_LIMIT = 3;

export const SLEEP_DURATION_MS = 1000;

// providers
export const MAIL_TRANSPORTER = "MAIL_TRANSPORTER";
export const EMAIL_DEV_HANDLE_WHITELIST = ["joannechen1223", "swh00tw"];
54 changes: 0 additions & 54 deletions apps/recnet-api/src/modules/email/email.controller.ts

This file was deleted.

27 changes: 3 additions & 24 deletions apps/recnet-api/src/modules/email/email.module.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,13 @@
import { Module } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";

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

import { MAIL_TRANSPORTER } from "./email.const";
import { EmailController } from "./email.controller";
import { EmailService } from "./email.service";
import { Transporter } from "./email.type";
import EmailDevTransporter from "./transporters/email.dev.transporters";
import EmailTransporter from "./transporters/email.transporters";

const transporterFactory = (configService: ConfigService): Transporter => {
const nodeEnv = configService.get("app").nodeEnv;
const nodemailerConfig = configService.get("nodemailer");

return nodeEnv === "production"
? new EmailTransporter(nodemailerConfig)
: new EmailDevTransporter(nodemailerConfig);
};
import EmailTransporter from "./transporters/email.transporter";

@Module({
controllers: [EmailController],
providers: [
EmailService,
{
provide: MAIL_TRANSPORTER,
useFactory: transporterFactory,
inject: [ConfigService],
},
],
providers: [EmailService, EmailTransporter],
imports: [DbRepositoryModule],
exports: [EmailService],
})
export class EmailModule {}
148 changes: 13 additions & 135 deletions apps/recnet-api/src/modules/email/email.service.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,17 @@
import { Inject, Injectable, Logger } from "@nestjs/common";
import { Inject, Injectable } from "@nestjs/common";
import { ConfigType } from "@nestjs/config";
import { Cron } from "@nestjs/schedule";
import { CronStatus } from "@prisma/client";
import { render } from "@react-email/render";
import groupBy from "lodash.groupby";

import { AppConfig, NodemailerConfig } from "@recnet-api/config/common.config";
import AnnouncementRepository from "@recnet-api/database/repository/announcement.repository";
import InviteCodeRepository from "@recnet-api/database/repository/invite-code.repository";
import RecRepository from "@recnet-api/database/repository/rec.repository";
import { RecFilterBy } from "@recnet-api/database/repository/rec.repository.type";
import UserRepository from "@recnet-api/database/repository/user.repository";
import { User as DbUser } from "@recnet-api/database/repository/user.repository.type";
import WeeklyDigestCronLogRepository from "@recnet-api/database/repository/weekly-digest-cron-log.repository";
import { transformAnnouncement } from "@recnet-api/modules/announcement/announcement.transform";
import { Rec } from "@recnet-api/modules/rec/entities/rec.entity";
import { transformRec } from "@recnet-api/modules/rec/rec.transformer";
import { sleep } from "@recnet-api/utils";

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

import {
MAIL_TRANSPORTER,
MAX_REC_PER_MAIL,
SLEEP_DURATION_MS,
WEEKLY_DIGEST_CRON,
} from "./email.const";
import { SendMailResult, Transporter } from "./email.type";
SendResult,
WeeklyDigestContent,
} from "@recnet-api/modules/subscription/subscription.type";

import WeeklyDigest, { WeeklyDigestSubject } from "./templates/WeeklyDigest";
import EmailTransporter from "./transporters/email.transporter";

@Injectable()
export class EmailService {
Expand All @@ -36,125 +20,19 @@ export class EmailService {
private readonly appConfig: ConfigType<typeof AppConfig>,
@Inject(NodemailerConfig.KEY)
private readonly nodemailerConfig: ConfigType<typeof NodemailerConfig>,
@Inject(MAIL_TRANSPORTER)
private transporter: Transporter,
@Inject(UserRepository)
private readonly userRepository: UserRepository,
@Inject(RecRepository)
private readonly recRepository: RecRepository,
@Inject(WeeklyDigestCronLogRepository)
private readonly weeklyDigestCronLogRepository: WeeklyDigestCronLogRepository,
@Inject(InviteCodeRepository)
private readonly inviteCodeRepository: InviteCodeRepository,
@Inject(AnnouncementRepository)
private readonly announcementRepository: AnnouncementRepository
private transporter: EmailTransporter
) {}

@Cron(WEEKLY_DIGEST_CRON, { utcOffset: 0 })
public async weeklyDigestCron(): Promise<void> {
const logger = new Logger("WeeklyDigestCron");

logger.log("Start weekly digest cron");
const cutoff = getLatestCutOff();
const cronLog =
await this.weeklyDigestCronLogRepository.createWeeklyDigestCronLog(
cutoff
);

try {
const users = await this.userRepository.findAllUsers();
const results = [];

for (const user of users) {
const recs = await this.getRecsForUser(user, cutoff);
if (recs.length === 0) {
results.push({ success: true, skip: true });
continue;
}

const result = await this.sendWeeklyDigest(user, recs, cutoff);
results.push(result);

// avoid rate limit
await sleep(SLEEP_DURATION_MS);
}

const successCount = results.filter(
(result) => result.success && !result.skip
).length;
const errorUserIds = results
.filter((result) => !result.success)
.map((result) => result.userId)
.filter((userId) => userId !== undefined) as string[];

// log the successful result to DB
await this.weeklyDigestCronLogRepository.endWeeklyDigestCron(cronLog.id, {
status: CronStatus.SUCCESS,
result: { successCount, errorUserIds },
});
logger.log(
`Finish weekly digest cron: ${successCount} emails sent, ${errorUserIds.length} errors`
);
} catch (error) {
logger.error(`Error in weekly digest cron: ${error}`);

// log the failed result to DB
const errorMsg = error instanceof Error ? error.message : "Unknown error";
await this.weeklyDigestCronLogRepository.endWeeklyDigestCron(cronLog.id, {
status: CronStatus.FAILURE,
errorMsg,
});
}
}

public async sendTestEmail(userId: string): Promise<{ success: boolean }> {
const user = await this.userRepository.findUserById(userId);
const cutoff = getLatestCutOff();
const recs = await this.getRecsForUser(user, cutoff);
return this.sendWeeklyDigest(user, recs, cutoff);
}

private async getRecsForUser(user: DbUser, cutoff: Date): Promise<Rec[]> {
const followings = user.following.map(
(following: { followingId: string }) => following.followingId
);
const filter: RecFilterBy = {
userIds: followings,
cutoff,
};

const dbRecs = await this.recRepository.findRecs(
1,
MAX_REC_PER_MAIL,
filter
);
return dbRecs.map((dbRec) => transformRec(dbRec));
}

private async sendWeeklyDigest(
public async sendWeeklyDigest(
user: DbUser,
recs: Rec[],
content: WeeklyDigestContent,
cutoff: Date
): Promise<SendMailResult> {
const recsGroupByTitle = groupBy(recs, (rec) => {
): Promise<SendResult> {
const recsGroupByTitle = groupBy(content.recs, (rec) => {
const titleLowercase = rec.article.title.toLowerCase();
const words = titleLowercase.split(" ").filter((w) => w.length > 0);
return words.join("");
});
const numUnusedInviteCodes =
await this.inviteCodeRepository.countInviteCodes({
used: false,
ownerId: user.id,
});
const currentActivatedAnnouncements =
await this.announcementRepository.findAnnouncements(1, 1, {
activatedOnly: true,
currentOnly: true,
});
const latestAnnouncement =
currentActivatedAnnouncements.length > 0
? transformAnnouncement(currentActivatedAnnouncements[0])
: undefined;

// send email
const mailOptions = {
Expand All @@ -165,8 +43,8 @@ export class EmailService {
WeeklyDigest({
env: this.appConfig.nodeEnv,
recsGroupByTitle,
numUnusedInviteCodes,
latestAnnouncement,
numUnusedInviteCodes: content.numUnusedInviteCodes,
latestAnnouncement: content.latestAnnouncement,
})
),
};
Expand Down
Loading
Loading