Skip to content

Commit

Permalink
[recnet-api] Email UI enhancement (#346)
Browse files Browse the repository at this point in the history
## Description

<!--- Describe your changes in detail -->
- Add announcement in email
- Add reaction button for each rec in email
- Display num of invite codes in email

## Related Issue

<!--- This project only accepts pull requests related to open issues -->
<!--- If suggesting a new feature or change, please discuss it in an
issue first -->
<!--- If fixing a bug, there should be an issue describing it with steps
to reproduce -->
<!--- Please link to the issue here: -->
- #341 
- #335 
- #301 

## Notes

<!-- Other thing to say -->

## Test

<!--- Please describe in detail how you tested your changes locally. -->
Run `nx email:dev recnet-api` and go to `localhost:3001`


## Screenshots (if appropriate):

<!--- Add screenshots of your changes here -->
![Screenshot 2024-10-29 at 12 19
06 AM](https://github.com/user-attachments/assets/8740c3c0-7be3-472d-a592-262dca711a22)
![Screenshot 2024-10-29 at 12 19
20 AM](https://github.com/user-attachments/assets/b7308e94-f336-47dd-96be-0b6b8374b6e4)


## TODO

- [ ] Clear `console.log` or `console.error` for debug usage
- [ ] Update the documentation `recnet-docs` if needed
  • Loading branch information
swh00tw authored Oct 29, 2024
2 parents 2cd51c3 + f27c080 commit 45d522d
Show file tree
Hide file tree
Showing 26 changed files with 15,079 additions and 8,966 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/recnet-api-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ name: recnet-api-ci
on:
push:
paths:
- 'apps/recnet-api/package.json'
- "apps/recnet-api/package.json"

jobs:
version_bump:
runs-on: ubuntu-latest
permissions: write-all
if: github.event_name == 'push' &&
github.ref == 'refs/heads/master'
github.ref == 'refs/heads/master'
steps:
- name: Checkout code
uses: actions/checkout@v2
Expand Down
29 changes: 5 additions & 24 deletions apps/recnet-api/src/modules/announcement/announcement.service.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,17 @@
import { HttpStatus, Inject, Injectable } from "@nestjs/common";

import AnnouncementRepository from "@recnet-api/database/repository/announcement.repository";
import {
AnnouncementFilterBy,
Announcement as DbAnnouncement,
} from "@recnet-api/database/repository/announcement.repository.type";
import { AnnouncementFilterBy } from "@recnet-api/database/repository/announcement.repository.type";
import { getOffset } from "@recnet-api/utils";
import { RecnetError } from "@recnet-api/utils/error/recnet.error";
import { ErrorCode } from "@recnet-api/utils/error/recnet.error.const";

import { GetAnnouncementsResponse } from "./announcement.response";
import { transformAnnouncement } from "./announcement.transform";
import { CreateAnnouncementDto } from "./dto/create.announcement.dto";
import { UpdateAnnouncementDto } from "./dto/update.announcement.dto";
import { Announcement } from "./entities/announcement.entity";

import { transformUserPreview } from "../user/user.transformer";

@Injectable()
export class AnnouncementService {
constructor(
Expand All @@ -39,7 +35,7 @@ export class AnnouncementService {
return {
hasNext: dbAnnouncements.length + getOffset(page, pageSize) < totalCount,
totalCount,
announcements: dbAnnouncements.map(this.transformAnnouncement),
announcements: dbAnnouncements.map(transformAnnouncement),
};
}

Expand All @@ -59,7 +55,7 @@ export class AnnouncementService {
const dbAnnouncement = await this.announcementRepository.createAnnouncement(
createAnnouncementInput
);
return this.transformAnnouncement(dbAnnouncement);
return transformAnnouncement(dbAnnouncement);
}

public async updateAnnouncement(
Expand Down Expand Up @@ -99,21 +95,6 @@ export class AnnouncementService {
id,
updateAnnouncementInput
);
return this.transformAnnouncement(updatedDbAnnouncement);
}

private transformAnnouncement(dbAnnouncement: DbAnnouncement): Announcement {
const { createdBy } = dbAnnouncement;
const createdByUserPreview = transformUserPreview(createdBy);
return {
id: dbAnnouncement.id,
title: dbAnnouncement.title,
content: dbAnnouncement.content,
startAt: dbAnnouncement.startAt,
endAt: dbAnnouncement.endAt,
isActivated: dbAnnouncement.isActivated,
allowClose: dbAnnouncement.allowClose,
createdBy: createdByUserPreview,
};
return transformAnnouncement(updatedDbAnnouncement);
}
}
21 changes: 21 additions & 0 deletions apps/recnet-api/src/modules/announcement/announcement.transform.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Announcement as DbAnnouncement } from "@recnet-api/database/repository/announcement.repository.type";
import { transformUserPreview } from "@recnet-api/modules/user/user.transformer";

import { Announcement } from "./entities/announcement.entity";

export const transformAnnouncement = (
dbAnnouncement: DbAnnouncement
): Announcement => {
const { createdBy } = dbAnnouncement;
const createdByUserPreview = transformUserPreview(createdBy);
return {
id: dbAnnouncement.id,
title: dbAnnouncement.title,
content: dbAnnouncement.content,
startAt: dbAnnouncement.startAt,
endAt: dbAnnouncement.endAt,
isActivated: dbAnnouncement.isActivated,
allowClose: dbAnnouncement.allowClose,
createdBy: createdByUserPreview,
};
};
32 changes: 30 additions & 2 deletions apps/recnet-api/src/modules/email/email.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@ 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";
Expand Down Expand Up @@ -40,7 +43,11 @@ export class EmailService {
@Inject(RecRepository)
private readonly recRepository: RecRepository,
@Inject(WeeklyDigestCronLogRepository)
private readonly weeklyDigestCronLogRepository: WeeklyDigestCronLogRepository
private readonly weeklyDigestCronLogRepository: WeeklyDigestCronLogRepository,
@Inject(InviteCodeRepository)
private readonly inviteCodeRepository: InviteCodeRepository,
@Inject(AnnouncementRepository)
private readonly announcementRepository: AnnouncementRepository
) {}

@Cron(WEEKLY_DIGEST_CRON, { utcOffset: 0 })
Expand Down Expand Up @@ -134,13 +141,34 @@ export class EmailService {
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 = {
from: this.nodemailerConfig.user,
to: user.email,
subject: WeeklyDigestSubject(cutoff, this.appConfig.nodeEnv),
html: render(WeeklyDigest({ recsGroupByTitle })),
html: render(
WeeklyDigest({
env: this.appConfig.nodeEnv,
recsGroupByTitle,
numUnusedInviteCodes,
latestAnnouncement,
})
),
};

let result;
Expand Down
145 changes: 103 additions & 42 deletions apps/recnet-api/src/modules/email/templates/WeeklyDigest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,13 @@ import * as React from "react";

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

import { Rec } from "@recnet/recnet-api-model";
import {
Rec,
Announcement,
generateMock,
announcementSchema,
recSchema,
} from "@recnet/recnet-api-model";

interface EmailRecCardProps {
recs: Rec[];
Expand All @@ -32,6 +38,26 @@ function Badge(props: { children: React.ReactNode }) {
);
}

function ReactionButton(props: { href: string }) {
const emojiClass =
"bg-gray-100 aspect-square rounded-[99px] w-auto h-fit text-center translate-x-[-50%] translate-y-[-50%] relative top-1/2 left-1/2 p-1";
return (
<a href={props.href} className="no-underline">
<div className="flex flex-row text-[12px]">
<div className="z-30 flex flex-row justify-center items-center ml-[-4px]">
<div className={emojiClass}>👍</div>
</div>
<div className="z-20 flex flex-row justify-center items-center ml-[-4px]">
<div className={emojiClass}>❤️</div>
</div>
<div className="z-10 flex flex-row justify-center items-center ml-[-4px]">
<div className={emojiClass}>🚀</div>
</div>
</div>
</a>
);
}

function EmailRecCard(props: EmailRecCardProps) {
const { recs } = props;
if (recs.length === 0) {
Expand Down Expand Up @@ -62,52 +88,64 @@ function EmailRecCard(props: EmailRecCardProps) {
<Text>{rec.user.displayName}</Text>
{rec.isSelfRec ? <Badge>{"Self Rec"}</Badge> : null}
</div>
<Text>{rec.description}</Text>
<a
href={`https://recnet.io/rec/${rec.id}`}
className="no-underline text-text"
>
<Text>{rec.description}</Text>
</a>
<ReactionButton
href={`https://recnet.io/rec/${rec.id}?openEmojiPopover=true`}
/>
</Container>
);
})}
</Container>
);
}

function MockEmailRecCard() {
return (
<div className="p-2 border border-2 border-[#646464]">
<div className="p-3 bg-[#F1F1F1] rounded-md mb-2">
<Link href={"https://google.com"} className="text-brand">
<Text className="text-[18px]">{"I am paper's title"}</Text>
</Link>
<Text>{"Author 1, Author 2, Author 3"}</Text>
<div className="flex flex-row items-center text-[14px] gap-x-2">
<CalendarIcon className="w-4 h-4" />
<div>{2024}</div>
</div>
</div>
<div className="px-4 pt-1">
<div className="flex flex-row items-center gap-x-4">
<Img
src={
"https://lh3.googleusercontent.com/a/ACg8ocL6DSnMAUCuiMFjcvW477_gHLTaBDOUP5vgv5mSVO5fJs8=s96-c"
}
alt="avatar"
className="w-[40px] aspect-square rounded-[999px] object-cover"
/>
<Text>{"Mock user"}</Text>
<Badge>{"Self Rec"}</Badge>
</div>
<Text>
{
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non risus."
}
</Text>
</div>
</div>
);
function getMockWeeklyDigestData(): WeeklyDigestProps {
const getMockRec = () =>
generateMock(recSchema, {
stringMap: {
photoUrl: () => "https://avatar.iran.liara.run/public",
},
});
return {
env: "development",
recsGroupByTitle: {
"Paper Title 1": [getMockRec()],
"Paper Title 2": [getMockRec(), getMockRec()],
"Paper Title 3": [getMockRec()],
},
numUnusedInviteCodes: 3,
latestAnnouncement: generateMock(announcementSchema, {
stringMap: {
content: () => "This is a test announcement!",
},
}),
};
}

interface WeeklyDigestProps {
env?: string;
recsGroupByTitle?: Record<string, Rec[]>;
numUnusedInviteCodes?: number;
latestAnnouncement?: Omit<Announcement, "startAt" | "endAt">;
}

const WeeklyDigest = (props: { recsGroupByTitle?: Record<string, Rec[]> }) => {
const { recsGroupByTitle = {} } = props;
const WeeklyDigest = (props: WeeklyDigestProps) => {
/**
Use mock data if testing via email:dev command for testing purposes
*/
const data = !props.env ? getMockWeeklyDigestData() : props;
const {
recsGroupByTitle = {},
latestAnnouncement,
numUnusedInviteCodes,
} = data;
const recsCount = Object.keys(recsGroupByTitle).length;

return (
<Html>
<Tailwind
Expand Down Expand Up @@ -165,20 +203,35 @@ const WeeklyDigest = (props: { recsGroupByTitle?: Record<string, Rec[]> }) => {
)}
</Text>
</Section>
{latestAnnouncement ? (
<Container>
<Hr className="pb-1" />
<div className="m-2 p-4 rounded-lg bg-[#3591FF40] flex flex-col gap-y-1 text-[14px]">
<Text className="my-0">
<span className="mr-1">{"📢"}</span>
<span className="font-bold mr-1">
{latestAnnouncement.title}
</span>
</Text>
<Text className="my-0">{latestAnnouncement.content}</Text>
</div>
</Container>
) : null}
{Object.keys(recsGroupByTitle).map((key, i) => {
const recs = recsGroupByTitle[key];
return (
<React.Fragment key={`${key}-${i}`}>
<Hr />
{i === 0 ? null : <Hr />}
<EmailRecCard recs={recs} />
</React.Fragment>
);
})}
<Hr />
<Section className="px-2">
<Section className="px-4 py-2">
<Text className="text-[16px]">
Any interesting read this week? 👀
</Text>

<div className="w-full flex justify-center">
<Button
href="https://recnet.io"
Expand All @@ -188,9 +241,17 @@ const WeeklyDigest = (props: { recsGroupByTitle?: Record<string, Rec[]> }) => {
</Button>
</div>
</Section>
<Text className="text-text opacity-[40%] p-2 text-[12px]">
Please reply directly if you find any error. Thank you!
</Text>
{numUnusedInviteCodes && numUnusedInviteCodes > 0 ? (
<>
<Hr className="mb-0 py-0" />
<Container className="flex justify-center">
<Text className="text-[12px]">
You have {numUnusedInviteCodes} unused invite code
{numUnusedInviteCodes > 1 ? "s" : ""}! Share the love ❤️
</Text>
</Container>
</>
) : null}
</Container>
</Body>
</Tailwind>
Expand Down
1 change: 1 addition & 0 deletions apps/recnet-docs/src/pages/auth.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ To be continued...
Will talk about the auth flow, tokens, access-control, and more.

Ref:

- [next-firebase-auth-edge](https://next-firebase-auth-edge-docs.vercel.app/)
- [next-firebase-auth-edge Minimal Example](https://github.com/awinogrodzki/next-firebase-auth-edge/tree/main/examples/next-typescript-minimal)
4 changes: 2 additions & 2 deletions apps/recnet-docs/src/pages/contributing.mdx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { Callout } from 'nextra/components'
import { Callout } from "nextra/components";

<Callout type="warning" emoji="🚧">
The document is under construction now. Some information may be missing.
</Callout>

To be continued...

Will talk about the flow and rules of contributing and collaborating.
Will talk about the flow and rules of contributing and collaborating.
Loading

0 comments on commit 45d522d

Please sign in to comment.