Skip to content

Commit

Permalink
Merge pull request #621 from dzcode-io/feat/contribution-page
Browse files Browse the repository at this point in the history
Feat: contribution page
  • Loading branch information
ZibanPirate authored Dec 30, 2024
2 parents 67a223b + fcc1eb5 commit e8285b9
Show file tree
Hide file tree
Showing 17 changed files with 523 additions and 13 deletions.
18 changes: 17 additions & 1 deletion api/src/app/endpoints.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { GetContributionsResponse } from "src/contribution/types";
import {
GetContributionResponse,
GetContributionsForSitemapResponse,
GetContributionsResponse,
GetContributionTitleResponse,
} from "src/contribution/types";
import {
GetContributorNameResponse,
GetContributorResponse,
Expand Down Expand Up @@ -34,6 +39,17 @@ export interface Endpoints {
"api:Contributions": {
response: GetContributionsResponse;
};
"api:Contributions/:id": {
response: GetContributionResponse;
params: { id: string };
};
"api:contributions/:id/title": {
response: GetContributionTitleResponse;
params: { id: string };
};
"api:contributions/for-sitemap": {
response: GetContributionsForSitemapResponse;
};
"api:Contributors": {
response: GetContributorsResponse;
};
Expand Down
38 changes: 36 additions & 2 deletions api/src/contribution/controller.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { Controller, Get } from "routing-controllers";
import { Controller, Get, NotFoundError, Param } from "routing-controllers";
import { Service } from "typedi";

import { ContributionRepository } from "./repository";
import { GetContributionsResponse } from "./types";
import {
GetContributionTitleResponse,
GetContributionResponse,
GetContributionsResponse,
GetContributionsForSitemapResponse,
} from "./types";

@Service()
@Controller("/Contributions")
Expand All @@ -17,4 +22,33 @@ export class ContributionController {
contributions,
};
}

@Get("/for-sitemap")
public async getContributionsForSitemap(): Promise<GetContributionsForSitemapResponse> {
const contributions = await this.contributionRepository.findForSitemap();

return {
contributions,
};
}

@Get("/:id")
public async getContribution(@Param("id") id: string): Promise<GetContributionResponse> {
const contribution = await this.contributionRepository.findByIdWithStats(id);

return {
contribution,
};
}

@Get("/:id/title")
public async getContributionTitle(
@Param("id") id: string,
): Promise<GetContributionTitleResponse> {
const contribution = await this.contributionRepository.findTitle(id);

if (!contribution) throw new NotFoundError("Contribution not found");

return { contribution };
}
}
109 changes: 109 additions & 0 deletions api/src/contribution/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,28 @@ import { ContributionRow, contributionsTable } from "./table";
export class ContributionRepository {
constructor(private readonly postgresService: PostgresService) {}

public async findTitle(contributionId: string) {
// todo-ZM: guard against SQL injections in all sql`` statements
const statement = sql`
SELECT
${contributionsTable.title}
FROM
${contributionsTable}
WHERE
${contributionsTable.id} = ${contributionId}
`;

const raw = await this.postgresService.db.execute(statement);
const entries = Array.from(raw);
const entry = entries[0];

if (!entry) return null;

const unStringifiedRaw = unStringifyDeep(entry);
const camelCased = camelCaseObject(unStringifiedRaw);
return camelCased;
}

public async findForProject(projectId: string) {
const statement = sql`
SELECT
Expand Down Expand Up @@ -58,6 +80,22 @@ export class ContributionRepository {
return camelCased;
}

public async findForSitemap() {
const statement = sql`
SELECT
${contributionsTable.id},
${contributionsTable.title}
FROM
${contributionsTable}
`;

const raw = await this.postgresService.db.execute(statement);
const entries = Array.from(raw);
const unStringifiedRaw = unStringifyDeep(entries);
const camelCased = camelCaseObject(unStringifiedRaw);
return camelCased;
}

public async upsert(contribution: ContributionRow) {
return await this.postgresService.db
.insert(contributionsTable)
Expand Down Expand Up @@ -148,4 +186,75 @@ export class ContributionRepository {

return sortedUpdatedAt;
}

public async findByIdWithStats(id: string) {
const statement = sql`
SELECT
p.id as id,
p.name as name,
json_agg(
json_build_object('id', r.id, 'name', r.name, 'owner', r.owner, 'contributions', r.contributions)
) AS repositories
FROM
(SELECT
r.id as id,
r.owner as owner,
r.name as name,
r.project_id as project_id,
json_agg(
json_build_object(
'id',
c.id,
'title',
c.title,
'type',
c.type,
'url',
c.url,
'updated_at',
c.updated_at,
'activity_count',
c.activity_count,
'contributor',
json_build_object(
'id',
cr.id,
'name',
cr.name,
'username',
cr.username,
'avatar_url',
cr.avatar_url
)
)
) AS contributions
FROM
${contributionsTable} c
INNER JOIN
${repositoriesTable} r ON c.repository_id = r.id
INNER JOIN
${contributorsTable} cr ON c.contributor_id = cr.id
WHERE
c.id = ${id}
GROUP BY
r.id) AS r
INNER JOIN
${projectsTable} p ON r.project_id = p.id
GROUP BY
p.id
`;

const raw = await this.postgresService.db.execute(statement);
const entries = Array.from(raw);
const unStringifiedRaw = unStringifyDeep(entries);

const reversed = reverseHierarchy(unStringifiedRaw, [
{ from: "repositories", setParentAs: "project" },
{ from: "contributions", setParentAs: "repository" },
]);

const camelCased = camelCaseObject(reversed);

return camelCased[0];
}
}
20 changes: 20 additions & 0 deletions api/src/contribution/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,23 @@ export interface GetContributionsResponse extends GeneralResponse {
}
>;
}

export interface GetContributionResponse extends GeneralResponse {
contribution: Pick<
ContributionEntity,
"id" | "title" | "type" | "url" | "updatedAt" | "activityCount"
> & {
repository: Pick<RepositoryEntity, "id" | "owner" | "name"> & {
project: Pick<ProjectEntity, "id" | "name">;
};
contributor: Pick<ContributorEntity, "id" | "name" | "username" | "avatarUrl">;
};
}

export interface GetContributionTitleResponse extends GeneralResponse {
contribution: Pick<ContributionEntity, "title">;
}

export interface GetContributionsForSitemapResponse extends GeneralResponse {
contributions: Array<Pick<ContributionEntity, "id" | "title">>;
}
3 changes: 3 additions & 0 deletions web/cloudflare/functions/ar/contribute/[slug].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { Env, handleContributionRequest } from "handler/contribution";

export const onRequest: PagesFunction<Env> = handleContributionRequest;
3 changes: 3 additions & 0 deletions web/cloudflare/functions/contribute/[slug].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { Env, handleContributionRequest } from "handler/contribution";

export const onRequest: PagesFunction<Env> = handleContributionRequest;
56 changes: 56 additions & 0 deletions web/cloudflare/functions/w/contributions-sitemap.xml.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Env } from "handler/contribution";
import { environments } from "@dzcode.io/utils/dist/config/environment";
import { allLanguages, LanguageEntity } from "@dzcode.io/models/dist/language";
import { getContributionURL } from "@dzcode.io/web/dist/utils/contribution";
import { fsConfig } from "@dzcode.io/utils/dist/config";
import { fetchV2Factory } from "@dzcode.io/utils/dist/fetch/factory";
import { Endpoints } from "@dzcode.io/api/dist/app/endpoints";

function xmlEscape(s: string) {
return s.replace(
/[<>&"']/g,
(c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;", '"': "&quot;", "'": "&#39;" })[c] as string,
);
}

export const onRequest: PagesFunction<Env> = async (context) => {
let stage = context.env.STAGE;
if (!environments.includes(stage)) {
console.log(`⚠️ No STAGE provided, falling back to "development"`);
stage = "development";
}
const fullstackConfig = fsConfig(stage);
const fetchV2 = fetchV2Factory<Endpoints>(fullstackConfig);

const { contributions } = await fetchV2("api:contributions/for-sitemap", {});

const hostname = "https://www.dzCode.io";
const links = contributions.reduce<{ url: string; lang: LanguageEntity["code"] }[]>((pV, cV) => {
return [
...pV,
...allLanguages.map(({ baseUrl, code }) => ({
url: xmlEscape(`${baseUrl}${getContributionURL(cV)}`),
lang: code,
})),
];
}, []);

const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:news="http://www.google.com/schemas/sitemap-news/0.9"
xmlns:xhtml="http://www.w3.org/1999/xhtml"
xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"
xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
${links
.map(
(link) => `
<url>
<loc>${hostname}${link.url}</loc>
<xhtml:link rel="alternate" hreflang="${link.lang}" href="${hostname}${link.url}" />
</url>`,
)
.join("")}
</urlset>`;

return new Response(xml, { headers: { "content-type": "application/xml; charset=utf-8" } });
};
71 changes: 71 additions & 0 deletions web/cloudflare/handler/contribution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
declare const htmlTemplate: string; // @ts-expect-error cloudflare converts this to a string using esbuild
import htmlTemplate from "../public/template.html";
declare const notFoundEn: string; // @ts-expect-error cloudflare converts this to a string using esbuild
import notFoundEn from "../public/404.html";
declare const notFoundAr: string; // @ts-expect-error cloudflare converts this to a string using esbuild
import notFoundAr from "../public/ar/404.html";

import { Environment, environments } from "@dzcode.io/utils/dist/config/environment";
import { fsConfig } from "@dzcode.io/utils/dist/config";
import { plainLocalize } from "@dzcode.io/web/dist/components/locale/utils";
import { dictionary, AllDictionaryKeys } from "@dzcode.io/web/dist/components/locale/dictionary";
import { LanguageEntity } from "@dzcode.io/models/dist/language";
import { fetchV2Factory } from "@dzcode.io/utils/dist/fetch/factory";
import { Endpoints } from "@dzcode.io/api/dist/app/endpoints";

export interface Env {
STAGE: Environment;
}

export const handleContributionRequest: PagesFunction<Env> = async (context) => {
let stage = context.env.STAGE;
if (!environments.includes(stage)) {
console.log(`⚠️ No STAGE provided, falling back to "development"`);
stage = "development";
}

const pathName = new URL(context.request.url).pathname;

const languageRegex = /^\/(ar|en)\//i;
const language = (pathName?.match(languageRegex)?.[1]?.toLowerCase() ||
"en") as LanguageEntity["code"];
const notFound = language === "ar" ? notFoundAr : notFoundEn;

const contributionIdRegex = /contribute\/(.*)-(.*)-(.*)/;
const contributionId =
pathName?.match(contributionIdRegex)?.[2] + "-" + pathName?.match(contributionIdRegex)?.[3];

if (!contributionId)
return new Response(notFound, {
headers: { "content-type": "text/html; charset=utf-8" },
status: 404,
});

const localize = (key: AllDictionaryKeys) =>
plainLocalize(dictionary, language, key, "NO-TRANSLATION");

const fullstackConfig = fsConfig(stage);
const fetchV2 = fetchV2Factory<Endpoints>(fullstackConfig);

try {
const { contribution } = await fetchV2("api:contributions/:id/title", {
params: { id: contributionId },
});
const pageTitle = `${localize("contribution-title-pre")} ${contribution.title} ${localize("contribution-title-post")}`;

const newData = htmlTemplate
.replace(/{{template-title}}/g, pageTitle)
.replace(/{{template-description}}/g, localize("contribute-description"))
.replace(/{{template-lang}}/g, language);

return new Response(newData, { headers: { "content-type": "text/html; charset=utf-8" } });
} catch (error) {
// @TODO-ZM: log error to sentry
console.error(error);

return new Response(notFound, {
headers: { "content-type": "text/html; charset=utf-8" },
status: 404,
});
}
};
7 changes: 5 additions & 2 deletions web/src/_entry/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,11 @@ let routes: Array<
},
{
pageName: "contribute",
// @TODO-ZM: change this back once we have contribution page
path: "/contribute/:slug?",
path: "/contribute",
},
{
pageName: "contribute/contribution",
path: "/contribute/*",
},
{
pageName: "team",
Expand Down
Loading

0 comments on commit e8285b9

Please sign in to comment.