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" }, 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: [], 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; +} 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/insight-card/insight-card.controller.ts b/src/social-card/insight-card/insight-card.controller.ts new file mode 100644 index 0000000..f5a97fe --- /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/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 {} 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..374325b --- /dev/null +++ b/src/social-card/insight-card/insight-card.service.ts @@ -0,0 +1,176 @@ +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"; +import insightRepos from "../templates/shared/insight-repos"; +import insightContributors from "../templates/shared/insight-contributors"; + +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 maxRepoQueryIdsLenght = 10; + + const insightPageReq = await firstValueFrom( + this.httpService.get(`https://api.opensauced.pizza/v1/insights/${insightId}`), + ); + + const { repos, name, updated_at } = insightPageReq.data; + + const query = (new URLSearchParams); + + query.set( + "repoIds", + repos + .slice(0, maxRepoQueryIdsLenght) + .map(repo => repo.repo_id) + .join(","), + ); + + const contributorsReq = await firstValueFrom( + this.httpService.get<{ data: { author_login: string }[] }>( + `https://api.opensauced.pizza/v1/contributors/search?${String(query)}`, + ), + ); + + 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://www.github.com/${owner}.png?size=50`, + }; + }); + + 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, 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, + height: 627, + fonts: [ + { + name: "Inter", + data: interArrayBuffer, + weight: 400, + style: "normal", + }, + { + name: "Inter", + data: interArrayBufferMedium, + weight: 500, + 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); + + 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); + } + } +} 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} 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..863b37d --- /dev/null +++ b/src/social-card/templates/insight-card.template.ts @@ -0,0 +1,19 @@ +import cardStyleSetup from "./shared/card-style-setup"; +import insightFooter from "./shared/insight-footer"; + +const insightCardTemplate = (pageName: string, contributors: string, repos: string): string => ` + ${cardStyleSetup} + +

+
+
+

+ ${pageName}: Insights +

+
+
+ + ${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..f80bd14 --- /dev/null +++ b/src/social-card/templates/shared/insight-contributors.ts @@ -0,0 +1,15 @@ +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/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", diff --git a/src/social-card/user-card/user-card.service.ts b/src/social-card/user-card/user-card.service.ts index 883af31..4f186d8 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,11 +90,16 @@ 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"); + const interArrayBufferMedium = await fs.readFile("node_modules/@fontsource/inter/files/inter-all-500-normal.woff"); const svg = await satori(template, { width: 1200, @@ -100,6 +111,12 @@ export class UserCardService { weight: 400, style: "normal", }, + { + name: "Inter", + data: interArrayBufferMedium, + weight: 500, + style: "normal", + }, ], tailwindConfig, }); @@ -129,7 +146,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/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/InsightCards.ts b/test/local-dev/InsightCards.ts new file mode 100644 index 0000000..b8a24e1 --- /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 { png } = await instance.generateCardBuffer(id); + + if (!existsSync(folderPath)) { + await mkdir(folderPath); + } + await writeFile(`${folderPath}/${id}.png`, png); + }); + + // generating sequential: 10.5 seconds, parallel: 4.5 seconds + await Promise.all(promises); +} + +testHighlightCards(); 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