Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: insights social card generation #51

Merged
merged 14 commits into from Jun 8, 2023
19 changes: 19 additions & 0 deletions 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;
}
18 changes: 18 additions & 0 deletions 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;
}
79 changes: 79 additions & 0 deletions 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<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();
}
}
14 changes: 14 additions & 0 deletions 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 {}
199 changes: 199 additions & 0 deletions 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<InsightCardData> {
/*
* const highlightReq = await firstValueFrom(
* this.httpService.get<DbUserHighlight>(`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<DbInsight>(`https://api.opensauced.pizza/v1/insights/${insightId}`),
OgDev-01 marked this conversation as resolved.
Show resolved Hide resolved
);

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<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);

/*
* 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<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);
}
}
}
45 changes: 45 additions & 0 deletions 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}

<div tw="flex-col justify-between w-1200px h-627px bg-white rounded-2xl p-32px pt-48px">
<div tw="px-8 ">
<div tw="w-full flex-col flex-nowrap" style="gap: -10px;">
<h1 tw="font-medium text-72px leading-72px text-zinc-900 tracking-tight" style="width: 100%;">
${pageName}: Insights
</h1>
</div>
</div>
<div>
${
repos.length > 0
? repos.map(({ repoName, avatarUrl }) => `${repoIconWithName(repoName, avatarUrl)}`).join("")
: ""
}
</div>
<div tw="justify-between ">
<div tw="-space-x-3 w-max">
${contributors
.map(
contributor =>
`<div tw="w-8 h-8 overflow-hidden rounded-full border-2 border-solid border-white "><img tw="w-132px h-132px border border-sauced-orange rounded-full" src="${contributor}"/><div>`,
)
.join("")} ${
contributors.length > 3
? `<h2 tw="m-0 font-medium text-32px leading-32px text-zinc-900">+${contributors.length - 3}</h2>`
: ""
}
</div>

<div>
<img tw="w-46px h-46px border border-white rounded" src="https://raw.githubusercontent.com/open-sauced/assets/d9a0d5a317036084aa3f5f4e20cdfbe58dc37377/svgs/slice-Orange-Gradient.svg"/>
</div>
</div>
</div>`;

export default insightCardTemplate;
1 change: 1 addition & 0 deletions src/social-card/templates/tailwind.config.ts
Expand Up @@ -18,6 +18,7 @@ const tailwindConfig = {
"16px": "16px",
"32px": "32px",
"48px": "48px",
"58px": "58px",
"96px": "96px",
"134px": "134px",
"627px": "627px",
Expand Down