Skip to content

Feat: contribution page #621

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

Merged
merged 9 commits into from
Dec 30, 2024
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
39 changes: 37 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,34 @@ export class ContributionController {
contributions,
};
}

@Get("/for-sitemap")
public async getContributionsForSitemap(): Promise<GetContributionsForSitemapResponse> {
// @TODO-ZM: title is a markdown, we should render it to plain text
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 };
}
}
108 changes: 108 additions & 0 deletions api/src/contribution/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,27 @@ import { ContributionRow, contributionsTable } from "./table";
export class ContributionRepository {
constructor(private readonly postgresService: PostgresService) {}

public async findTitle(contributionId: string) {
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 +79,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 +185,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">;
Comment on lines +19 to +26
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should improve these types so it can be easy to be shared

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any particular action item for this PR? or this is a general point that we should address at some point?

Copy link
Member

@omdxp omdxp Dec 30, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd say just a general note for later, like allowing type aliases for readability

};
}

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
Loading