From 416f1c808ab8b682de0879d545c7f148ce87d6dd Mon Sep 17 00:00:00 2001 From: Sunday Ogbonna Date: Wed, 7 Jun 2023 00:02:54 +0100 Subject: [PATCH 01/14] style: update tailwind config --- src/social-card/templates/tailwind.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/social-card/templates/tailwind.config.ts b/src/social-card/templates/tailwind.config.ts index cf8bc90..4054cc3 100644 --- a/src/social-card/templates/tailwind.config.ts +++ b/src/social-card/templates/tailwind.config.ts @@ -18,6 +18,7 @@ const tailwindConfig = { "16px": "16px", "32px": "32px", "48px": "48px", + "58px": "58px", "96px": "96px", "134px": "134px", "627px": "627px", From 7d9cb9bcbc6d0d101270dd508ddba822786a6a05 Mon Sep 17 00:00:00 2001 From: Sunday Ogbonna Date: Wed, 7 Jun 2023 00:03:17 +0100 Subject: [PATCH 02/14] chore: add insight entity interfaces --- src/github/entities/db-insight.entity.ts | 19 +++++++++++++++++++ src/github/entities/db-repos.entity.ts | 18 ++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 src/github/entities/db-insight.entity.ts create mode 100644 src/github/entities/db-repos.entity.ts diff --git a/src/github/entities/db-insight.entity.ts b/src/github/entities/db-insight.entity.ts new file mode 100644 index 0000000..98a3080 --- /dev/null +++ b/src/github/entities/db-insight.entity.ts @@ -0,0 +1,19 @@ +export interface DbInsight { + id: number; + user_id: number; + name: string; + is_public: boolean; + is_favorite: boolean; + short_code: string; + created_at: string; + updated_at: string; + repos: DbUserInsightRepo[]; +} + +interface DbUserInsightRepo { + readonly id: number; + readonly insight_id: number; + readonly repo_id: number; + readonly full_name: string; + readonly created_at?: string; +} diff --git a/src/github/entities/db-repos.entity.ts b/src/github/entities/db-repos.entity.ts new file mode 100644 index 0000000..4173ded --- /dev/null +++ b/src/github/entities/db-repos.entity.ts @@ -0,0 +1,18 @@ +export interface DbRepo { + id: string; + host_id: string; + size: number; + stars: number; + issues: number; + full_name: string; + pr_active_count?: number; + open_prs_count?: number; + merged_prs_count?: number; + closed_prs_count?: number; + draft_prs_count?: number; + spam_prs_count?: number; + pr_velocity_count?: number; + churnTotalCount?: number; + language: string; + description: string; +} From 092ca854c853ee60f291bcd5babe3970b6d0c575 Mon Sep 17 00:00:00 2001 From: Sunday Ogbonna Date: Wed, 7 Jun 2023 00:03:46 +0100 Subject: [PATCH 03/14] feat: create insight template --- .../insight-card/insight-card.controller.ts | 79 +++++++++++++++++++ .../templates/insight-card.template.ts | 45 +++++++++++ 2 files changed, 124 insertions(+) create mode 100644 src/social-card/insight-card/insight-card.controller.ts create mode 100644 src/social-card/templates/insight-card.template.ts diff --git a/src/social-card/insight-card/insight-card.controller.ts b/src/social-card/insight-card/insight-card.controller.ts new file mode 100644 index 0000000..3078675 --- /dev/null +++ b/src/social-card/insight-card/insight-card.controller.ts @@ -0,0 +1,79 @@ +import { + Controller, + Get, + Header, + HttpStatus, + Param, + ParseIntPipe, + Redirect, + Res, + StreamableFile, +} from "@nestjs/common"; +import { + ApiBadRequestResponse, + ApiForbiddenResponse, + ApiNoContentResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiResponse, + ApiTags, +} from "@nestjs/swagger"; +import { FastifyReply } from "fastify"; +import { InsightCardService } from "./insight-card.service"; + +@Controller("insights") +@ApiTags("Insight social cards") +export class InsightCardController { + constructor(private readonly insightCardService: InsightCardService) {} + + @Get("/:id") + @ApiOperation({ + operationId: "generateInsightSocialCard", + summary: "Gets latest cache aware social card link for :id or generates a new one", + }) + @Header("Content-Type", "image/png") + @ApiOkResponse({ type: StreamableFile, description: "Social card image" }) + @ApiNotFoundResponse({ description: "Insight not found" }) + @ApiForbiddenResponse({ description: "Rate limit exceeded" }) + @ApiBadRequestResponse({ description: "Invalid insight id" }) + @Redirect() + async generateInsightSocialCard( + @Param("id", ParseIntPipe) id: number, + @Res({ passthrough: true }) res: FastifyReply + ): Promise { + const { fileUrl, hasFile, needsUpdate } = await this.insightCardService.checkRequiresUpdate(id); + + if (hasFile && !needsUpdate) { + return res.status(HttpStatus.FOUND).redirect(fileUrl); + } + + const url = await this.insightCardService.getgetInsightCard(id); + + return res.status(HttpStatus.FOUND).redirect(url); + } + + @Get("/:id/metadata") + @ApiOperation({ + operationId: "getInsightSocialCardMetadata", + summary: "Gets latest cache aware social card metadata for :id", + }) + @ApiNoContentResponse({ description: "Insight social card image is up to date", status: HttpStatus.NO_CONTENT }) + @ApiResponse({ description: "Insight social card image needs regeneration", status: HttpStatus.NOT_MODIFIED }) + @ApiNotFoundResponse({ description: "Insight social card image not found", status: HttpStatus.NOT_FOUND }) + @ApiBadRequestResponse({ description: "Invalid insight id", status: HttpStatus.BAD_REQUEST }) + async checkInsightSocialCard( + @Param("id", ParseIntPipe) id: number, + @Res({ passthrough: true }) res: FastifyReply + ): Promise { + const { fileUrl, hasFile, needsUpdate, lastModified } = await this.insightCardService.checkRequiresUpdate(id); + + return res + .headers({ + "x-amz-meta-last-modified": lastModified?.toISOString() ?? "", + "x-amz-meta-location": fileUrl, + }) + .status(hasFile ? (needsUpdate ? HttpStatus.NOT_MODIFIED : HttpStatus.NO_CONTENT) : HttpStatus.NOT_FOUND) + .send(); + } +} diff --git a/src/social-card/templates/insight-card.template.ts b/src/social-card/templates/insight-card.template.ts new file mode 100644 index 0000000..eb9ee2b --- /dev/null +++ b/src/social-card/templates/insight-card.template.ts @@ -0,0 +1,45 @@ +import cardStyleSetup from "./shared/card-style-setup"; +import repoIconWithName from "./shared/repo-icon-with-name"; +const insightCardTemplate = ( + pageName: string, + contributors: string[], + repos: { repoName: string; avatarUrl: string }[], +): string => ` + ${cardStyleSetup} + +
+
+
+

+ ${pageName}: Insights +

+
+
+
+ ${ + repos.length > 0 + ? repos.map(({ repoName, avatarUrl }) => `${repoIconWithName(repoName, avatarUrl)}`).join("") + : "" +} +
+
+
+ ${contributors + .map( + contributor => + `
`, + ) + .join("")} ${ + contributors.length > 3 + ? `

+${contributors.length - 3}

` + : "" +} +
+ +
+ +
+
+
`; + +export default insightCardTemplate; From bb4068f7c5074c6918ff2cbdccabc540467c95d9 Mon Sep 17 00:00:00 2001 From: Sunday Ogbonna Date: Wed, 7 Jun 2023 00:04:06 +0100 Subject: [PATCH 04/14] feat: add insight services --- .../insight-card/insight-card.service.ts | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 src/social-card/insight-card/insight-card.service.ts diff --git a/src/social-card/insight-card/insight-card.service.ts b/src/social-card/insight-card/insight-card.service.ts new file mode 100644 index 0000000..19c26b0 --- /dev/null +++ b/src/social-card/insight-card/insight-card.service.ts @@ -0,0 +1,199 @@ +import { ForbiddenException, Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { HttpService } from "@nestjs/axios"; +import { Resvg } from "@resvg/resvg-js"; +import fs from "node:fs/promises"; +import { GithubService } from "../../github/github.service"; +import { S3FileStorageService } from "../../s3-file-storage/s3-file-storage.service"; + +import tailwindConfig from "../templates/tailwind.config"; +import { firstValueFrom } from "rxjs"; + +import { RequiresUpdateMeta } from "../user-card/user-card.service"; +import { DbInsight } from "../../github/entities/db-insight.entity"; +import insightCardTemplate from "../templates/insight-card.template"; + +/* + * interface HighlightCardData { + * title: string; + * body: string; + * reactions: number; + * avatarUrl: string; + * repo: Repository; + * langTotal: number; + * langs: (Language & { + * size: number; + * })[]; + * updated_at: Date; + * url: string; + * } + */ + +interface InsightCardData { + pageName: string; + repos: { repoName: string; avatarUrl: string }[]; + contributors: string[]; + updated_at: Date; +} + +@Injectable() +export class InsightCardService { + private readonly logger = new Logger(this.constructor.name); + + constructor ( + private readonly httpService: HttpService, + private readonly githubService: GithubService, + private readonly s3FileStorageService: S3FileStorageService, + ) {} + + private async getInsightData (insightId: number): Promise { + /* + * const highlightReq = await firstValueFrom( + * this.httpService.get(`https://api.opensauced.pizza/v1/user/highlights/${highlightId}`) + * ); + * const { login, title, highlight: body, updated_at, url } = highlightReq.data; + */ + + const insightPageReq = await firstValueFrom( + this.httpService.get(`https://api.opensauced.pizza/v1/insights/${insightId}`), + ); + + const { repos, name, updated_at } = insightPageReq.data; + + const repoIdsQuery = repos.map(repo => repo.repo_id).join(","); + + const contributorsReq = await firstValueFrom( + this.httpService.get<{ author_login: string }[]>( + `https://api.opensauced.pizza/v1/contributors/search?repoIds=${repoIdsQuery}`, + ), + ); + const contributors = contributorsReq.data.map(contributor => contributor.author_login); + + const repositories = repos.map(repo => { + const [owner, repoName] = repo.full_name.split("/"); + + return { + repoName, + avatarUrl: `https://github.com/${owner}.png&size=50`, + }; + }); + + // const [owner, repoName] = url.replace("https://github.com/", "").split("/"); + + /* + * const user = await this.githubService.getUser(login); + * const repo = await this.githubService.getRepo(owner, repoName); + */ + + /* + * const langList = repo.languages?.edges?.flatMap(edge => { + * if (edge) { + * return { + * ...edge.node, + * size: edge.size, + * }; + * } + * }) as (Language & { size: number })[]; + */ + + return { + pageName: name, + repos: repositories, + contributors, + updated_at: new Date(updated_at), + }; + } + + // public only to be used in local scripts. Not for controller direct use. + async generateCardBuffer (insightId: number, insightData?: InsightCardData) { + const { html } = await import("satori-html"); + const satori = (await import("satori")).default; + + const { pageName, repos, contributors } = insightData ? insightData : await this.getInsightData(insightId); + + const template = html(insightCardTemplate(pageName, contributors, repos)); + + const interArrayBuffer = await fs.readFile("node_modules/@fontsource/inter/files/inter-all-400-normal.woff"); + + const svg = await satori(template, { + width: 1200, + height: 627, + fonts: [ + { + name: "Inter", + data: interArrayBuffer, + weight: 400, + style: "normal", + }, + ], + tailwindConfig, + }); + + const resvg = new Resvg(svg, { background: "rgba(238, 235, 230, .9)" }); + + const pngData = resvg.render(); + + return { png: pngData.asPng(), svg }; + } + + async checkRequiresUpdate (id: number): Promise { + const hash = `insights/${String(id)}.png`; + const fileUrl = `${this.s3FileStorageService.getCdnEndpoint()}${hash}`; + const hasFile = await this.s3FileStorageService.fileExists(hash); + + const returnVal: RequiresUpdateMeta = { + fileUrl, + hasFile, + needsUpdate: true, + lastModified: null, + }; + + if (hasFile) { + const lastModified = await this.s3FileStorageService.getFileLastModified(hash); + + returnVal.lastModified = lastModified; + + const { updated_at } = await this.getInsightData(id); + + /* + * const metadata = await this.s3FileStorageService.getFileMeta(hash); + * const savedReactions = metadata?.["reactions-count"] ?? "0"; + */ + + if (lastModified && lastModified > updated_at) { + this.logger.debug( + `Highlight ${id} exists in S3 with lastModified: ${lastModified.toISOString()} newer than updated_at: ${updated_at.toISOString()}, and reaction count is the same, redirecting to ${fileUrl}`, + ); + returnVal.needsUpdate = false; + } + } + + return returnVal; + } + + async getgetInsightCard (id: number): Promise { + const { remaining } = await this.githubService.rateLimit(); + + if (remaining < 1000) { + throw new ForbiddenException("Rate limit exceeded"); + } + + const insightData = await this.getInsightData(id); + + try { + const hash = `insights/${String(id)}.png`; + const fileUrl = `${this.s3FileStorageService.getCdnEndpoint()}${hash}`; + + const { png } = await this.generateCardBuffer(id, insightData); + + await this.s3FileStorageService.uploadFile(png, hash, "image/png"); + + this.logger.debug(`Insight ${id} did not exist in S3, generated image and uploaded to S3, redirecting`); + + return fileUrl; + } catch (e) { + this.logger.error(`Error generating insight card for ${id}`, e); + + throw (new NotFoundException); + } + } +} From 0702c3aa16c5ae700a33a57acc2b0c3683dbcddd Mon Sep 17 00:00:00 2001 From: Sunday Ogbonna Date: Wed, 7 Jun 2023 00:04:18 +0100 Subject: [PATCH 05/14] feat: add insight modules --- .../insight-card/insight-card.module.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/social-card/insight-card/insight-card.module.ts diff --git a/src/social-card/insight-card/insight-card.module.ts b/src/social-card/insight-card/insight-card.module.ts new file mode 100644 index 0000000..d078b54 --- /dev/null +++ b/src/social-card/insight-card/insight-card.module.ts @@ -0,0 +1,14 @@ +import { Module } from "@nestjs/common"; +import { HttpModule } from "@nestjs/axios"; +import { GithubModule } from "../../github/github.module"; +import { S3FileStorageModule } from "../../s3-file-storage/s3-file-storage.module"; + +import { InsightCardService } from "./insight-card.service"; +import { InsightCardController } from "./insight-card.controller"; + +@Module({ + imports: [HttpModule, GithubModule, S3FileStorageModule], + providers: [InsightCardService], + controllers: [InsightCardController], +}) +export class InsightCardModule {} From 0b6300c8c56ce6fa3873f32497ce301c9390fc31 Mon Sep 17 00:00:00 2001 From: Sunday Ogbonna Date: Wed, 7 Jun 2023 00:04:54 +0100 Subject: [PATCH 06/14] linting --- .../insight-card/insight-card.controller.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/social-card/insight-card/insight-card.controller.ts b/src/social-card/insight-card/insight-card.controller.ts index 3078675..f5a97fe 100644 --- a/src/social-card/insight-card/insight-card.controller.ts +++ b/src/social-card/insight-card/insight-card.controller.ts @@ -25,7 +25,7 @@ import { InsightCardService } from "./insight-card.service"; @Controller("insights") @ApiTags("Insight social cards") export class InsightCardController { - constructor(private readonly insightCardService: InsightCardService) {} + constructor (private readonly insightCardService: InsightCardService) {} @Get("/:id") @ApiOperation({ @@ -38,9 +38,9 @@ export class InsightCardController { @ApiForbiddenResponse({ description: "Rate limit exceeded" }) @ApiBadRequestResponse({ description: "Invalid insight id" }) @Redirect() - async generateInsightSocialCard( + async generateInsightSocialCard ( @Param("id", ParseIntPipe) id: number, - @Res({ passthrough: true }) res: FastifyReply + @Res({ passthrough: true }) res: FastifyReply, ): Promise { const { fileUrl, hasFile, needsUpdate } = await this.insightCardService.checkRequiresUpdate(id); @@ -62,9 +62,9 @@ export class InsightCardController { @ApiResponse({ description: "Insight social card image needs regeneration", status: HttpStatus.NOT_MODIFIED }) @ApiNotFoundResponse({ description: "Insight social card image not found", status: HttpStatus.NOT_FOUND }) @ApiBadRequestResponse({ description: "Invalid insight id", status: HttpStatus.BAD_REQUEST }) - async checkInsightSocialCard( + async checkInsightSocialCard ( @Param("id", ParseIntPipe) id: number, - @Res({ passthrough: true }) res: FastifyReply + @Res({ passthrough: true }) res: FastifyReply, ): Promise { const { fileUrl, hasFile, needsUpdate, lastModified } = await this.insightCardService.checkRequiresUpdate(id); From 26ae190a0b8e08b674931593e9c1a4fbdce400d1 Mon Sep 17 00:00:00 2001 From: Sunday Ogbonna Date: Wed, 7 Jun 2023 10:41:36 +0100 Subject: [PATCH 07/14] linting --- src/app.module.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/app.module.ts b/src/app.module.ts index e363c03..2f37626 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -11,15 +11,12 @@ import DigitalOceanConfig from "./config/digital-ocean.config"; import { UserCardModule } from "./social-card/user-card/user-card.module"; import { S3FileStorageModule } from "./s3-file-storage/s3-file-storage.module"; import { HighlightCardModule } from "./social-card/highlight-card/highlight-card.module"; +import { InsightCardModule } from "./social-card/insight-card/insight-card.module"; @Module({ imports: [ ConfigModule.forRoot({ - load: [ - ApiConfig, - GitHubConfig, - DigitalOceanConfig, - ], + load: [ApiConfig, GitHubConfig, DigitalOceanConfig], isGlobal: true, }), LoggerModule.forRootAsync({ @@ -50,6 +47,7 @@ import { HighlightCardModule } from "./social-card/highlight-card/highlight-card S3FileStorageModule, UserCardModule, HighlightCardModule, + InsightCardModule, ], controllers: [], providers: [], From a81ab0bd7a0f0cda5730d7572a6c5e0c54e65af1 Mon Sep 17 00:00:00 2001 From: Sunday Ogbonna Date: Wed, 7 Jun 2023 10:41:59 +0100 Subject: [PATCH 08/14] chore: add test script for insights --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 1fd454a..94af84e 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "test:e2e": "npm run test --config test/jest-e2e.json", "test:local:user": "npx ts-node test/local-dev/UserCards", "test:local:highlight": "npx ts-node test/local-dev/HighlightCards", + "test:local:insight": "npx ts-node test/local-dev/InsightCards", "docs": "npx compodoc -p tsconfig.json --hideGenerator --disableDependencies -d ./dist/documentation ./src", "docs:serve": "npm run docs -- --serve" }, From 3ed1f2da24b2b00eb5ede82c6eb8a8e00cd08184 Mon Sep 17 00:00:00 2001 From: Sunday Ogbonna Date: Wed, 7 Jun 2023 10:42:42 +0100 Subject: [PATCH 09/14] style: update highlight title font weight --- .../highlight-card/highlight-card.service.ts | 49 +++++++++++++------ .../templates/highlight-card.template.ts | 13 +++-- 2 files changed, 43 insertions(+), 19 deletions(-) diff --git a/src/social-card/highlight-card/highlight-card.service.ts b/src/social-card/highlight-card/highlight-card.service.ts index 5c0490e..478fdcc 100644 --- a/src/social-card/highlight-card/highlight-card.service.ts +++ b/src/social-card/highlight-card/highlight-card.service.ts @@ -16,17 +16,17 @@ import { DbReaction } from "../../github/entities/db-reaction.entity"; import { RequiresUpdateMeta } from "../user-card/user-card.service"; interface HighlightCardData { - title: string, - body: string, - reactions: number, - avatarUrl: string, - repo: Repository, - langTotal: number, + title: string; + body: string; + reactions: number; + avatarUrl: string; + repo: Repository; + langTotal: number; langs: (Language & { - size: number, - })[], - updated_at: Date, - url: string, + size: number; + })[]; + updated_at: Date; + url: string; } @Injectable() @@ -40,11 +40,15 @@ export class HighlightCardService { ) {} private async getHighlightData (highlightId: number): Promise { - const highlightReq = await firstValueFrom(this.httpService.get(`https://api.opensauced.pizza/v1/user/highlights/${highlightId}`)); + const highlightReq = await firstValueFrom( + this.httpService.get(`https://api.opensauced.pizza/v1/user/highlights/${highlightId}`), + ); const { login, title, highlight: body, updated_at, url } = highlightReq.data; - const reactionsReq = await firstValueFrom(this.httpService.get(`https://api.opensauced.pizza/v1/highlights/${highlightId}/reactions`)); - const reactions = reactionsReq.data.reduce( (acc, curr) => acc + Number(curr.reaction_count), 0); + const reactionsReq = await firstValueFrom( + this.httpService.get(`https://api.opensauced.pizza/v1/highlights/${highlightId}/reactions`), + ); + const reactions = reactionsReq.data.reduce((acc, curr) => acc + Number(curr.reaction_count), 0); const [owner, repoName] = url.replace("https://github.com/", "").split("/"); @@ -78,11 +82,16 @@ export class HighlightCardService { const { html } = await import("satori-html"); const satori = (await import("satori")).default; - const { title, body, reactions, avatarUrl, repo, langs, langTotal } = highlightData ? highlightData : await this.getHighlightData(highlightId); + const { title, body, reactions, avatarUrl, repo, langs, langTotal } = highlightData + ? highlightData + : await this.getHighlightData(highlightId); - const template = html(highlightCardTemplate(avatarUrl, title, body, userLangs(langs, langTotal), userProfileRepos([repo], 2), reactions)); + const template = html( + highlightCardTemplate(avatarUrl, title, body, userLangs(langs, langTotal), userProfileRepos([repo], 2), reactions), + ); const interArrayBuffer = await fs.readFile("node_modules/@fontsource/inter/files/inter-all-400-normal.woff"); + const interArrayBufferMedium = await fs.readFile("node_modules/@fontsource/inter/files/inter-all-500-normal.woff"); const svg = await satori(template, { width: 1200, @@ -94,6 +103,12 @@ export class HighlightCardService { weight: 400, style: "normal", }, + { + name: "Inter", + data: interArrayBufferMedium, + weight: 500, + style: "normal", + }, ], tailwindConfig, }); @@ -127,7 +142,9 @@ export class HighlightCardService { const savedReactions = metadata?.["reactions-count"] ?? "0"; if (lastModified && lastModified > updated_at && savedReactions === String(reactions)) { - this.logger.debug(`Highlight ${id} exists in S3 with lastModified: ${lastModified.toISOString()} newer than updated_at: ${updated_at.toISOString()}, and reaction count is the same, redirecting to ${fileUrl}`); + this.logger.debug( + `Highlight ${id} exists in S3 with lastModified: ${lastModified.toISOString()} newer than updated_at: ${updated_at.toISOString()}, and reaction count is the same, redirecting to ${fileUrl}`, + ); returnVal.needsUpdate = false; } } diff --git a/src/social-card/templates/highlight-card.template.ts b/src/social-card/templates/highlight-card.template.ts index 15a2eda..6ac49ee 100644 --- a/src/social-card/templates/highlight-card.template.ts +++ b/src/social-card/templates/highlight-card.template.ts @@ -1,7 +1,14 @@ import cardFooter from "./shared/card-footer"; import cardStyleSetup from "./shared/card-style-setup"; -const highlightCardTemplate = (avatarUrl: string, title: string, body: string, langs: string, repos: string, reactions: number): string => ` +const highlightCardTemplate = ( + avatarUrl: string, + title: string, + body: string, + langs: string, + repos: string, + reactions: number, +): string => ` ${cardStyleSetup}
@@ -11,8 +18,8 @@ const highlightCardTemplate = (avatarUrl: string, title: string, body: string, l
-

- ${title} +

+ ${title.length > 50 ? `${title.slice(0, 50)}...` : title}

${body.length > 108 ? `${body.slice(0, 108)}...` : body} From 1765777e0a7dc36b2fc2b77784d8b0ba3311c260 Mon Sep 17 00:00:00 2001 From: Sunday Ogbonna Date: Wed, 7 Jun 2023 10:43:26 +0100 Subject: [PATCH 10/14] feat: refactor insights card generation logic --- .../insight-card/insight-card.service.ts | 47 ++++++++--------- .../templates/insight-card.template.ts | 36 ++----------- .../templates/shared/card-footer.ts | 11 ++-- .../templates/shared/insight-contributors.ts | 16 ++++++ .../templates/shared/insight-footer.ts | 20 ++++++++ .../templates/shared/insight-repos.ts | 18 +++++++ .../user-card/user-card.service.ts | 50 ++++++++++++------- test/local-dev/InsightCards.ts | 33 ++++++++++++ 8 files changed, 151 insertions(+), 80 deletions(-) create mode 100644 src/social-card/templates/shared/insight-contributors.ts create mode 100644 src/social-card/templates/shared/insight-footer.ts create mode 100644 src/social-card/templates/shared/insight-repos.ts create mode 100644 test/local-dev/InsightCards.ts diff --git a/src/social-card/insight-card/insight-card.service.ts b/src/social-card/insight-card/insight-card.service.ts index 19c26b0..c854f06 100644 --- a/src/social-card/insight-card/insight-card.service.ts +++ b/src/social-card/insight-card/insight-card.service.ts @@ -11,6 +11,8 @@ import { firstValueFrom } from "rxjs"; import { RequiresUpdateMeta } from "../user-card/user-card.service"; import { DbInsight } from "../../github/entities/db-insight.entity"; import insightCardTemplate from "../templates/insight-card.template"; +import insightRepos from "../templates/shared/insight-repos"; +import insightContributors from "../templates/shared/insight-contributors"; /* * interface HighlightCardData { @@ -48,7 +50,7 @@ export class InsightCardService { private async getInsightData (insightId: number): Promise { /* * const highlightReq = await firstValueFrom( - * this.httpService.get(`https://api.opensauced.pizza/v1/user/highlights/${highlightId}`) + * this.httpService.get(`https://opensauced.pizza/v1/user/highlights/${highlightId}`) * ); * const { login, title, highlight: body, updated_at, url } = highlightReq.data; */ @@ -59,42 +61,30 @@ export class InsightCardService { const { repos, name, updated_at } = insightPageReq.data; - const repoIdsQuery = repos.map(repo => repo.repo_id).join(","); + const query = (new URLSearchParams); + + query.set("repoIds", repos.map(repo => repo.repo_id).join(",")); const contributorsReq = await firstValueFrom( - this.httpService.get<{ author_login: string }[]>( - `https://api.opensauced.pizza/v1/contributors/search?repoIds=${repoIdsQuery}`, + this.httpService.get<{ data: { author_login: string }[] }>( + `https://api.opensauced.pizza/v1/contributors/search?${String(query)}`, ), ); - const contributors = contributorsReq.data.map(contributor => contributor.author_login); + + const contributorsRes = contributorsReq.data.data; + const contributors = contributorsRes.map( + ({ author_login }) => `https://www.github.com/${author_login}.png?size=50`, + ); const repositories = repos.map(repo => { const [owner, repoName] = repo.full_name.split("/"); return { repoName, - avatarUrl: `https://github.com/${owner}.png&size=50`, + avatarUrl: `https://www.github.com/${owner}.png?size=50`, }; }); - // const [owner, repoName] = url.replace("https://github.com/", "").split("/"); - - /* - * const user = await this.githubService.getUser(login); - * const repo = await this.githubService.getRepo(owner, repoName); - */ - - /* - * const langList = repo.languages?.edges?.flatMap(edge => { - * if (edge) { - * return { - * ...edge.node, - * size: edge.size, - * }; - * } - * }) as (Language & { size: number })[]; - */ - return { pageName: name, repos: repositories, @@ -110,9 +100,10 @@ export class InsightCardService { const { pageName, repos, contributors } = insightData ? insightData : await this.getInsightData(insightId); - const template = html(insightCardTemplate(pageName, contributors, repos)); + const template = html(insightCardTemplate(pageName, insightContributors(contributors), insightRepos(repos, 2))); const interArrayBuffer = await fs.readFile("node_modules/@fontsource/inter/files/inter-all-400-normal.woff"); + const interArrayBufferMedium = await fs.readFile("node_modules/@fontsource/inter/files/inter-all-500-normal.woff"); const svg = await satori(template, { width: 1200, @@ -124,6 +115,12 @@ export class InsightCardService { weight: 400, style: "normal", }, + { + name: "Inter", + data: interArrayBufferMedium, + weight: 500, + style: "normal", + }, ], tailwindConfig, }); diff --git a/src/social-card/templates/insight-card.template.ts b/src/social-card/templates/insight-card.template.ts index eb9ee2b..863b37d 100644 --- a/src/social-card/templates/insight-card.template.ts +++ b/src/social-card/templates/insight-card.template.ts @@ -1,45 +1,19 @@ import cardStyleSetup from "./shared/card-style-setup"; -import repoIconWithName from "./shared/repo-icon-with-name"; -const insightCardTemplate = ( - pageName: string, - contributors: string[], - repos: { repoName: string; avatarUrl: string }[], -): string => ` +import insightFooter from "./shared/insight-footer"; + +const insightCardTemplate = (pageName: string, contributors: string, repos: string): string => ` ${cardStyleSetup}

-
+

${pageName}: Insights

-
- ${ - repos.length > 0 - ? repos.map(({ repoName, avatarUrl }) => `${repoIconWithName(repoName, avatarUrl)}`).join("") - : "" -} -
-
-
- ${contributors - .map( - contributor => - `
`, - ) - .join("")} ${ - contributors.length > 3 - ? `

+${contributors.length - 3}

` - : "" -} -
-
- -
-
+ ${insightFooter(contributors, repos)}
`; export default insightCardTemplate; diff --git a/src/social-card/templates/shared/card-footer.ts b/src/social-card/templates/shared/card-footer.ts index caa6fcd..87f068c 100644 --- a/src/social-card/templates/shared/card-footer.ts +++ b/src/social-card/templates/shared/card-footer.ts @@ -1,4 +1,3 @@ - const heartIconData = `data:image/svg+xml,%3csvg width='24' height='24' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill-rule='evenodd' clip-rule='evenodd' d='M6.73649 2.5C3.82903 2.5 1 5.052 1 8.51351C1 12.3318 3.80141 15.5735 6.38882 17.7763C7.70549 18.8973 9.01844 19.7929 10.0004 20.4077C10.4922 20.7157 10.9029 20.9544 11.1922 21.1169C11.4093 21.2388 11.5582 21.318 11.6223 21.3516C11.7407 21.4132 11.8652 21.4527 12 21.4527C12.1193 21.4527 12.2378 21.4238 12.3438 21.3693C12.5003 21.2886 12.6543 21.2031 12.8078 21.1169C13.0971 20.9544 13.5078 20.7157 13.9996 20.4077C14.9816 19.7929 16.2945 18.8973 17.6112 17.7763C20.1986 15.5735 23 12.3318 23 8.51351C23 5.052 20.171 2.5 17.2635 2.5C14.9702 2.5 13.1192 3.72621 12 5.60482C10.8808 3.72621 9.02981 2.5 6.73649 2.5ZM6.73649 4C4.65746 4 2.5 5.88043 2.5 8.51351C2.5 11.6209 4.8236 14.4738 7.36118 16.6342C8.60701 17.6948 9.85656 18.5479 10.7965 19.1364C11.2656 19.4301 11.6557 19.6567 11.9269 19.8091L12 19.85L12.0731 19.8091C12.3443 19.6567 12.7344 19.4301 13.2035 19.1364C14.1434 18.5479 15.393 17.6948 16.6388 16.6342C19.1764 14.4738 21.5 11.6209 21.5 8.51351C21.5 5.88043 19.3425 4 17.2635 4C15.1581 4 13.4627 5.38899 12.7115 7.64258C12.6094 7.94883 12.3228 8.15541 12 8.15541C11.6772 8.15541 11.3906 7.94883 11.2885 7.64258C10.5373 5.38899 8.84185 4 6.73649 4Z' fill='%2324292F'/%3e%3c/svg%3e`; const cardFooter = (langs: string, repos: string, reactions?: number) => ` @@ -8,7 +7,8 @@ const cardFooter = (langs: string, repos: string, reactions?: number) => ` ${repos}
- ${reactions + ${ + reactions ? `
@@ -17,11 +17,12 @@ const cardFooter = (langs: string, repos: string, reactions?: number) => `
` - : ""} + : "" +}
-
-
+
+
${langs}
diff --git a/src/social-card/templates/shared/insight-contributors.ts b/src/social-card/templates/shared/insight-contributors.ts new file mode 100644 index 0000000..d4c3612 --- /dev/null +++ b/src/social-card/templates/shared/insight-contributors.ts @@ -0,0 +1,16 @@ +const insightContributors = (contributors: string[]): string => { + const repoList = contributors.map( + contributor => + ``, + ); + + return `${repoList.slice(0, 5).join("")}${ + repoList.length > 5 + ? `

+${ + repoList.length - 5 + }

` + : `` + }`; +}; + +export default insightContributors; diff --git a/src/social-card/templates/shared/insight-footer.ts b/src/social-card/templates/shared/insight-footer.ts new file mode 100644 index 0000000..6cdaf90 --- /dev/null +++ b/src/social-card/templates/shared/insight-footer.ts @@ -0,0 +1,20 @@ +const insightFooter = (contributors: string, repos: string) => ` +
+
+
+ ${repos} +
+
+ +
+
+ +
+
+ ${contributors} +
+
+
+ `; + +export default insightFooter; diff --git a/src/social-card/templates/shared/insight-repos.ts b/src/social-card/templates/shared/insight-repos.ts new file mode 100644 index 0000000..2e6885c --- /dev/null +++ b/src/social-card/templates/shared/insight-repos.ts @@ -0,0 +1,18 @@ +import repoIconWithName from "./repo-icon-with-name"; + +const insightRepos = (repos: { repoName: string; avatarUrl: string }[], limit: number): string => { + const charLimit = limit === 1 ? 60 : repos.length === 1 ? 60 : 15; + const repoList = repos.map(({ repoName, avatarUrl }) => + repoIconWithName( + `${repoName.substring(0, charLimit).replace(/\.+$/, "")}${repoName.length > charLimit ? "..." : ""}`, + `${String(avatarUrl)}&size=40`, + )); + + return `${repoList.slice(0, limit).join("")}${ + repoList.length > limit + ? `

+${repoList.length - limit}

` + : `` + }`; +}; + +export default insightRepos; diff --git a/src/social-card/user-card/user-card.service.ts b/src/social-card/user-card/user-card.service.ts index 883af31..7a2aa2c 100644 --- a/src/social-card/user-card/user-card.service.ts +++ b/src/social-card/user-card/user-card.service.ts @@ -4,7 +4,6 @@ import { Resvg } from "@resvg/resvg-js"; import { Repository, Language, User } from "@octokit/graphql-schema"; import fs from "node:fs/promises"; - import { GithubService } from "../../github/github.service"; import { S3FileStorageService } from "../../s3-file-storage/s3-file-storage.service"; import userLangs from "../templates/shared/user-langs"; @@ -13,22 +12,22 @@ import userProfileCardTemplate from "../templates/user-profile-card.template"; import tailwindConfig from "../templates/tailwind.config"; export interface UserCardData { - id: User["databaseId"], - name: User["name"], + id: User["databaseId"]; + name: User["name"]; langs: (Language & { - size: number, - })[], - langTotal: number, - repos: Repository[], - avatarUrl: string, - formattedName: string, + size: number; + })[]; + langTotal: number; + repos: Repository[]; + avatarUrl: string; + formattedName: string; } export interface RequiresUpdateMeta { - fileUrl: string, + fileUrl: string; hasFile: boolean; needsUpdate: boolean; - lastModified: Date | null, + lastModified: Date | null; } @Injectable() @@ -42,13 +41,18 @@ export class UserCardService { ) {} private async getUserData (username: string): Promise { - const langs: Record = {}; + const langs: Record< + string, + Language & { + size: number; + } + > = {}; const today = (new Date); const today30daysAgo = new Date((new Date).setDate(today.getDate() - 30)); const user = await this.githubService.getUser(username); - const langRepos = user.repositories.nodes?.filter(repo => new Date(String(repo?.pushedAt)) > today30daysAgo) as Repository[]; + const langRepos = user.repositories.nodes?.filter( + repo => new Date(String(repo?.pushedAt)) > today30daysAgo, + ) as Repository[]; let langTotal = 0; langRepos.map(repo => { @@ -73,7 +77,9 @@ export class UserCardService { name: user.name, langs: Array.from(Object.values(langs)).sort((a, b) => b.size - a.size), langTotal, - repos: user.topRepositories.nodes?.filter(repo => !repo?.isPrivate && repo?.owner.login !== username) as Repository[], + repos: user.topRepositories.nodes?.filter( + repo => !repo?.isPrivate && repo?.owner.login !== username, + ) as Repository[], avatarUrl: `${String(user.avatarUrl)}&size=150`, formattedName: user.login, }; @@ -84,9 +90,13 @@ export class UserCardService { const { html } = await import("satori-html"); const satori = (await import("satori")).default; - const { avatarUrl, repos, langs, langTotal, formattedName } = userData ? userData : await this.getUserData(username); + const { avatarUrl, repos, langs, langTotal, formattedName } = userData + ? userData + : await this.getUserData(username); - const template = html(userProfileCardTemplate(avatarUrl, formattedName, userLangs(langs, langTotal), userProfileRepos(repos, 3))); + const template = html( + userProfileCardTemplate(avatarUrl, formattedName, userLangs(langs, langTotal), userProfileRepos(repos, 3)), + ); const interArrayBuffer = await fs.readFile("node_modules/@fontsource/inter/files/inter-all-400-normal.woff"); @@ -129,7 +139,9 @@ export class UserCardService { returnVal.lastModified = lastModified; if (lastModified && lastModified > today3daysAgo) { - this.logger.debug(`User ${username} exists in S3 with lastModified: ${lastModified.toISOString()} less than 3 days ago, redirecting to ${fileUrl}`); + this.logger.debug( + `User ${username} exists in S3 with lastModified: ${lastModified.toISOString()} less than 3 days ago, redirecting to ${fileUrl}`, + ); returnVal.needsUpdate = false; } } diff --git a/test/local-dev/InsightCards.ts b/test/local-dev/InsightCards.ts new file mode 100644 index 0000000..645c798 --- /dev/null +++ b/test/local-dev/InsightCards.ts @@ -0,0 +1,33 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { AppModule } from "../../src/app.module"; +import { existsSync } from "node:fs"; +import { mkdir, writeFile } from "fs/promises"; +import { InsightCardService } from "../../src/social-card/insight-card/insight-card.service"; + +const testInsightIds = [350, 351]; + +const folderPath = "dist"; + +async function testHighlightCards() { + const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule] }).compile(); + + const app = moduleFixture.createNestApplication(); + + await app.init(); + + const instance = app.get(InsightCardService); + + const promises = testInsightIds.map(async (id) => { + const { svg } = await instance.generateCardBuffer(id); + + if (!existsSync(folderPath)) { + await mkdir(folderPath); + } + await writeFile(`${folderPath}/${id}.svg`, svg); + }); + + // generating sequential: 10.5 seconds, parallel: 4.5 seconds + await Promise.all(promises); +} + +testHighlightCards(); From cebaa2b3fabd568825c6d99e2507a953fc217558 Mon Sep 17 00:00:00 2001 From: Sunday Ogbonna Date: Wed, 7 Jun 2023 10:46:39 +0100 Subject: [PATCH 11/14] misc: remove comments --- .../insight-card/insight-card.service.ts | 28 ------------------- 1 file changed, 28 deletions(-) diff --git a/src/social-card/insight-card/insight-card.service.ts b/src/social-card/insight-card/insight-card.service.ts index c854f06..a5dd61c 100644 --- a/src/social-card/insight-card/insight-card.service.ts +++ b/src/social-card/insight-card/insight-card.service.ts @@ -14,22 +14,6 @@ import insightCardTemplate from "../templates/insight-card.template"; import insightRepos from "../templates/shared/insight-repos"; import insightContributors from "../templates/shared/insight-contributors"; -/* - * interface HighlightCardData { - * title: string; - * body: string; - * reactions: number; - * avatarUrl: string; - * repo: Repository; - * langTotal: number; - * langs: (Language & { - * size: number; - * })[]; - * updated_at: Date; - * url: string; - * } - */ - interface InsightCardData { pageName: string; repos: { repoName: string; avatarUrl: string }[]; @@ -48,13 +32,6 @@ export class InsightCardService { ) {} private async getInsightData (insightId: number): Promise { - /* - * const highlightReq = await firstValueFrom( - * this.httpService.get(`https://opensauced.pizza/v1/user/highlights/${highlightId}`) - * ); - * const { login, title, highlight: body, updated_at, url } = highlightReq.data; - */ - const insightPageReq = await firstValueFrom( this.httpService.get(`https://api.opensauced.pizza/v1/insights/${insightId}`), ); @@ -151,11 +128,6 @@ export class InsightCardService { const { updated_at } = await this.getInsightData(id); - /* - * const metadata = await this.s3FileStorageService.getFileMeta(hash); - * const savedReactions = metadata?.["reactions-count"] ?? "0"; - */ - if (lastModified && lastModified > updated_at) { this.logger.debug( `Highlight ${id} exists in S3 with lastModified: ${lastModified.toISOString()} newer than updated_at: ${updated_at.toISOString()}, and reaction count is the same, redirecting to ${fileUrl}`, From e8e436aca2f25537fed2abbeba2233d06e4cac00 Mon Sep 17 00:00:00 2001 From: Sunday Ogbonna Date: Wed, 7 Jun 2023 10:50:37 +0100 Subject: [PATCH 12/14] chore: set test output to png --- src/social-card/templates/shared/insight-contributors.ts | 3 +-- test/local-dev/InsightCards.ts | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/social-card/templates/shared/insight-contributors.ts b/src/social-card/templates/shared/insight-contributors.ts index d4c3612..f80bd14 100644 --- a/src/social-card/templates/shared/insight-contributors.ts +++ b/src/social-card/templates/shared/insight-contributors.ts @@ -1,7 +1,6 @@ const insightContributors = (contributors: string[]): string => { const repoList = contributors.map( - contributor => - ``, + contributor => ``, ); return `${repoList.slice(0, 5).join("")}${ diff --git a/test/local-dev/InsightCards.ts b/test/local-dev/InsightCards.ts index 645c798..b8a24e1 100644 --- a/test/local-dev/InsightCards.ts +++ b/test/local-dev/InsightCards.ts @@ -18,12 +18,12 @@ async function testHighlightCards() { const instance = app.get(InsightCardService); const promises = testInsightIds.map(async (id) => { - const { svg } = await instance.generateCardBuffer(id); + const { png } = await instance.generateCardBuffer(id); if (!existsSync(folderPath)) { await mkdir(folderPath); } - await writeFile(`${folderPath}/${id}.svg`, svg); + await writeFile(`${folderPath}/${id}.png`, png); }); // generating sequential: 10.5 seconds, parallel: 4.5 seconds From bf6c9ddc88343aa0b1ddfdf1dfa934b722324018 Mon Sep 17 00:00:00 2001 From: Sunday Ogbonna Date: Wed, 7 Jun 2023 11:15:51 +0100 Subject: [PATCH 13/14] style: update user social card font weight --- src/social-card/user-card/user-card.service.ts | 7 +++++++ test/local-dev/HighlightCards.ts | 8 ++++---- test/local-dev/UserCards.ts | 13 +++++-------- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/social-card/user-card/user-card.service.ts b/src/social-card/user-card/user-card.service.ts index 7a2aa2c..4f186d8 100644 --- a/src/social-card/user-card/user-card.service.ts +++ b/src/social-card/user-card/user-card.service.ts @@ -99,6 +99,7 @@ export class UserCardService { ); const interArrayBuffer = await fs.readFile("node_modules/@fontsource/inter/files/inter-all-400-normal.woff"); + const interArrayBufferMedium = await fs.readFile("node_modules/@fontsource/inter/files/inter-all-500-normal.woff"); const svg = await satori(template, { width: 1200, @@ -110,6 +111,12 @@ export class UserCardService { weight: 400, style: "normal", }, + { + name: "Inter", + data: interArrayBufferMedium, + weight: 500, + style: "normal", + }, ], tailwindConfig, }); diff --git a/test/local-dev/HighlightCards.ts b/test/local-dev/HighlightCards.ts index 64d82a7..d71c746 100644 --- a/test/local-dev/HighlightCards.ts +++ b/test/local-dev/HighlightCards.ts @@ -8,7 +8,7 @@ const testHighlights = [102, 101, 103]; const folderPath = "dist"; -async function testHighlightCards () { +async function testHighlightCards() { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule] }).compile(); const app = moduleFixture.createNestApplication(); @@ -17,13 +17,13 @@ async function testHighlightCards () { const instance = app.get(HighlightCardService); - const promises = testHighlights.map(async id => { - const { svg } = await instance.generateCardBuffer(id); + const promises = testHighlights.map(async (id) => { + const { png } = await instance.generateCardBuffer(id); if (!existsSync(folderPath)) { await mkdir(folderPath); } - await writeFile(`${folderPath}/${id}.svg`, svg); + await writeFile(`${folderPath}/${id}.png`, png); }); // generating sequential: 10.5 seconds, parallel: 4.5 seconds diff --git a/test/local-dev/UserCards.ts b/test/local-dev/UserCards.ts index fe8aae8..e7d22ad 100644 --- a/test/local-dev/UserCards.ts +++ b/test/local-dev/UserCards.ts @@ -4,14 +4,11 @@ import { UserCardService } from "../../src/social-card/user-card/user-card.servi import { existsSync } from "node:fs"; import { mkdir, writeFile } from "fs/promises"; - -const testUsernames = [ - "bdougie", "deadreyo", "defunkt", "0-vortex", "Anush008", "diivi" -]; +const testUsernames = ["bdougie", "deadreyo", "defunkt", "0-vortex", "Anush008", "diivi"]; const folderPath = "dist"; -async function testUserCards () { +async function testUserCards() { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule] }).compile(); const app = moduleFixture.createNestApplication(); @@ -20,13 +17,13 @@ async function testUserCards () { const instance = app.get(UserCardService); - const promises = testUsernames.map(async username => { - const { svg } = await instance.generateCardBuffer(username); + const promises = testUsernames.map(async (username) => { + const { png } = await instance.generateCardBuffer(username); if (!existsSync(folderPath)) { await mkdir(folderPath); } - await writeFile(`${folderPath}/${username}.svg`, svg); + await writeFile(`${folderPath}/${username}.png`, png); }); // generating sequential: 10.5 seconds, parallel: 4.5 seconds From 433079b238be98af9e91e7514ba29b185d2fe8e5 Mon Sep 17 00:00:00 2001 From: Sunday Ogbonna Date: Wed, 7 Jun 2023 20:46:47 +0100 Subject: [PATCH 14/14] refactor: limit repos to 10 --- src/social-card/insight-card/insight-card.service.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/social-card/insight-card/insight-card.service.ts b/src/social-card/insight-card/insight-card.service.ts index a5dd61c..374325b 100644 --- a/src/social-card/insight-card/insight-card.service.ts +++ b/src/social-card/insight-card/insight-card.service.ts @@ -32,6 +32,8 @@ export class InsightCardService { ) {} private async getInsightData (insightId: number): Promise { + const maxRepoQueryIdsLenght = 10; + const insightPageReq = await firstValueFrom( this.httpService.get(`https://api.opensauced.pizza/v1/insights/${insightId}`), ); @@ -40,7 +42,13 @@ export class InsightCardService { const query = (new URLSearchParams); - query.set("repoIds", repos.map(repo => repo.repo_id).join(",")); + query.set( + "repoIds", + repos + .slice(0, maxRepoQueryIdsLenght) + .map(repo => repo.repo_id) + .join(","), + ); const contributorsReq = await firstValueFrom( this.httpService.get<{ data: { author_login: string }[] }>(