diff --git a/changelog.html b/changelog.html index 663a9c7..14fc31c 100644 --- a/changelog.html +++ b/changelog.html @@ -37,9 +37,14 @@
-All notable changes to this project will be documented in this file
+
+ src/social-card/insight-card/insight-card.controller.ts
+
+
+ insights
+
+ Methods+ |
+
+
|
+
+ + + Async + checkInsightSocialCard + + + | +|||||||||
+
+ checkInsightSocialCard(id: number, res: FastifyReply)
+ |
+ |||||||||
+ Decorators :
+ + @Get('/:id/metadata')
+ |
+ |||||||||
+ + | +|||||||||
+
+
+ Parameters :
+
+
+
+
+
+ Returns :
+ Promise<void>
+
+
+
+
+ |
+
+ + + Async + generateInsightSocialCard + + + | +|||||||||
+
+ generateInsightSocialCard(id: number, res: FastifyReply)
+ |
+ |||||||||
+ Decorators :
+ + @Get('/:id')
+ |
+ |||||||||
+ + | +|||||||||
+
+
+ Parameters :
+
+
+
+
+
+ Returns :
+ Promise<void>
+
+
+
+
+ |
+
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<void> {
+ 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<void> {
+ 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();
+ }
+}
+
+ +
+ src/github/entities/db-insight.entity.ts
+
+ Properties+ |
+
+
|
+
+ + created_at + + + + + | +
+ created_at:
+ |
+
+ Type : string
+
+ |
+
+ + id + + + + + | +
+ id:
+ |
+
+ Type : number
+
+ |
+
+ + is_favorite + + + + + | +
+ is_favorite:
+ |
+
+ Type : boolean
+
+ |
+
+ + is_public + + + + + | +
+ is_public:
+ |
+
+ Type : boolean
+
+ |
+
+ + name + + + + + | +
+ name:
+ |
+
+ Type : string
+
+ |
+
+ + repos + + + + + | +
+ repos:
+ |
+
+ Type : DbUserInsightRepo[]
+
+ |
+
+ + short_code + + + + + | +
+ short_code:
+ |
+
+ Type : string
+
+ |
+
+ + updated_at + + + + + | +
+ updated_at:
+ |
+
+ Type : string
+
+ |
+
+ + user_id + + + + + | +
+ user_id:
+ |
+
+ Type : number
+
+ |
+
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;
+}
+
+ +
+ src/github/entities/db-repos.entity.ts
+
+ Properties+ |
+
+
|
+
+ + churnTotalCount + + + + + | +
+ churnTotalCount:
+ |
+
+ Type : number
+
+ |
+
+ Optional + | +
+ + closed_prs_count + + + + + | +
+ closed_prs_count:
+ |
+
+ Type : number
+
+ |
+
+ Optional + | +
+ + description + + + + + | +
+ description:
+ |
+
+ Type : string
+
+ |
+
+ + draft_prs_count + + + + + | +
+ draft_prs_count:
+ |
+
+ Type : number
+
+ |
+
+ Optional + | +
+ + full_name + + + + + | +
+ full_name:
+ |
+
+ Type : string
+
+ |
+
+ + host_id + + + + + | +
+ host_id:
+ |
+
+ Type : string
+
+ |
+
+ + id + + + + + | +
+ id:
+ |
+
+ Type : string
+
+ |
+
+ + issues + + + + + | +
+ issues:
+ |
+
+ Type : number
+
+ |
+
+ + language + + + + + | +
+ language:
+ |
+
+ Type : string
+
+ |
+
+ + merged_prs_count + + + + + | +
+ merged_prs_count:
+ |
+
+ Type : number
+
+ |
+
+ Optional + | +
+ + open_prs_count + + + + + | +
+ open_prs_count:
+ |
+
+ Type : number
+
+ |
+
+ Optional + | +
+ + pr_active_count + + + + + | +
+ pr_active_count:
+ |
+
+ Type : number
+
+ |
+
+ Optional + | +
+ + pr_velocity_count + + + + + | +
+ pr_velocity_count:
+ |
+
+ Type : number
+
+ |
+
+ Optional + | +
+ + size + + + + + | +
+ size:
+ |
+
+ Type : number
+
+ |
+
+ + spam_prs_count + + + + + | +
+ spam_prs_count:
+ |
+
+ Type : number
+
+ |
+
+ Optional + | +
+ + stars + + + + + | +
+ stars:
+ |
+
+ Type : number
+
+ |
+
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;
+}
+
+ +
+ src/github/entities/db-insight.entity.ts
+
+ Properties+ |
+
+
|
+
+ + created_at + + + + + | +
+ created_at:
+ |
+
+ Type : string
+
+ |
+
+ Optional + | +
+ + full_name + + + + + | +
+ full_name:
+ |
+
+ Type : string
+
+ |
+
+ + id + + + + + | +
+ id:
+ |
+
+ Type : number
+
+ |
+
+ + insight_id + + + + + | +
+ insight_id:
+ |
+
+ Type : number
+
+ |
+
+ + repo_id + + + + + | +
+ repo_id:
+ |
+
+ Type : number
+
+ |
+
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;
+}
+
+ +
+ src/social-card/insight-card/insight-card.service.ts
+
+ Properties+ |
+
+
|
+
+ + contributors + + + + + | +
+ contributors:
+ |
+
+ Type : string[]
+
+ |
+
+ + pageName + + + + + | +
+ pageName:
+ |
+
+ Type : string
+
+ |
+
+ + repos + + + + + | +
+ repos:
+ |
+
+ Type : literal type[]
+
+ |
+
+ + updated_at + + + + + | +
+ updated_at:
+ |
+
+ Type : Date
+
+ |
+
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<InsightCardData> {
+ const maxRepoQueryIdsLenght = 10;
+
+ const insightPageReq = await firstValueFrom(
+ this.httpService.get<DbInsight>(`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<RequiresUpdateMeta> {
+ 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<string> {
+ 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);
+ }
+ }
+}
+
+
- Default value : (avatarUrl: string, title: string, body: string, langs: string, repos: string, reactions: number): string => `
+ Default value : |