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
1 change: 1 addition & 0 deletions package.json
Expand Up @@ -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"
},
Expand Down
8 changes: 3 additions & 5 deletions src/app.module.ts
Expand Up @@ -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({
Expand Down Expand Up @@ -50,6 +47,7 @@ import { HighlightCardModule } from "./social-card/highlight-card/highlight-card
S3FileStorageModule,
UserCardModule,
HighlightCardModule,
InsightCardModule,
],
controllers: [],
providers: [],
Expand Down
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;
}
49 changes: 33 additions & 16 deletions src/social-card/highlight-card/highlight-card.service.ts
Expand Up @@ -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()
Expand All @@ -40,11 +40,15 @@ export class HighlightCardService {
) {}

private async getHighlightData (highlightId: number): Promise<HighlightCardData> {
const highlightReq = await firstValueFrom(this.httpService.get<DbUserHighlight>(`https://api.opensauced.pizza/v1/user/highlights/${highlightId}`));
const highlightReq = await firstValueFrom(
this.httpService.get<DbUserHighlight>(`https://api.opensauced.pizza/v1/user/highlights/${highlightId}`),
OgDev-01 marked this conversation as resolved.
Show resolved Hide resolved
);
const { login, title, highlight: body, updated_at, url } = highlightReq.data;

const reactionsReq = await firstValueFrom(this.httpService.get<DbReaction[]>(`https://api.opensauced.pizza/v1/highlights/${highlightId}/reactions`));
const reactions = reactionsReq.data.reduce<number>( (acc, curr) => acc + Number(curr.reaction_count), 0);
const reactionsReq = await firstValueFrom(
this.httpService.get<DbReaction[]>(`https://api.opensauced.pizza/v1/highlights/${highlightId}/reactions`),
);
const reactions = reactionsReq.data.reduce<number>((acc, curr) => acc + Number(curr.reaction_count), 0);

const [owner, repoName] = url.replace("https://github.com/", "").split("/");

Expand Down Expand Up @@ -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,
Expand All @@ -94,6 +103,12 @@ export class HighlightCardService {
weight: 400,
style: "normal",
},
{
name: "Inter",
data: interArrayBufferMedium,
weight: 500,
style: "normal",
},
],
tailwindConfig,
});
Expand Down Expand Up @@ -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;
}
}
Expand Down
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 {}