Skip to content

Commit

Permalink
Release dev to master (#352)
Browse files Browse the repository at this point in the history
## RecNet auto-release action
This is an auto-generated PR by recnet-release-action 🤖
Please make sure to test your changes in staging before merging. 
## Related Issues
- #260
- #261
## Related PRs
- #350
- #351
- #349
## Staging links
recnet-web:
[https://vercel.live/link/recnet-git-dev-recnet-542617e7.vercel.app](https://vercel.live/link/recnet-git-dev-recnet-542617e7.vercel.app)
recnet-api:
[https://dev-api.recnet.io/api](https://dev-api.recnet.io/api)
  • Loading branch information
swh00tw authored Nov 8, 2024
2 parents 7f23e00 + 0a37632 commit b95c886
Show file tree
Hide file tree
Showing 45 changed files with 1,480 additions and 742 deletions.
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

0 comments on commit b95c886

Please sign in to comment.