Skip to content

Commit e8285b9

Browse files
authored
Merge pull request #621 from dzcode-io/feat/contribution-page
Feat: contribution page
2 parents 67a223b + fcc1eb5 commit e8285b9

File tree

17 files changed

+523
-13
lines changed

17 files changed

+523
-13
lines changed

api/src/app/endpoints.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { GetContributionsResponse } from "src/contribution/types";
1+
import {
2+
GetContributionResponse,
3+
GetContributionsForSitemapResponse,
4+
GetContributionsResponse,
5+
GetContributionTitleResponse,
6+
} from "src/contribution/types";
27
import {
38
GetContributorNameResponse,
49
GetContributorResponse,
@@ -34,6 +39,17 @@ export interface Endpoints {
3439
"api:Contributions": {
3540
response: GetContributionsResponse;
3641
};
42+
"api:Contributions/:id": {
43+
response: GetContributionResponse;
44+
params: { id: string };
45+
};
46+
"api:contributions/:id/title": {
47+
response: GetContributionTitleResponse;
48+
params: { id: string };
49+
};
50+
"api:contributions/for-sitemap": {
51+
response: GetContributionsForSitemapResponse;
52+
};
3753
"api:Contributors": {
3854
response: GetContributorsResponse;
3955
};

api/src/contribution/controller.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1-
import { Controller, Get } from "routing-controllers";
1+
import { Controller, Get, NotFoundError, Param } from "routing-controllers";
22
import { Service } from "typedi";
33

44
import { ContributionRepository } from "./repository";
5-
import { GetContributionsResponse } from "./types";
5+
import {
6+
GetContributionTitleResponse,
7+
GetContributionResponse,
8+
GetContributionsResponse,
9+
GetContributionsForSitemapResponse,
10+
} from "./types";
611

712
@Service()
813
@Controller("/Contributions")
@@ -17,4 +22,33 @@ export class ContributionController {
1722
contributions,
1823
};
1924
}
25+
26+
@Get("/for-sitemap")
27+
public async getContributionsForSitemap(): Promise<GetContributionsForSitemapResponse> {
28+
const contributions = await this.contributionRepository.findForSitemap();
29+
30+
return {
31+
contributions,
32+
};
33+
}
34+
35+
@Get("/:id")
36+
public async getContribution(@Param("id") id: string): Promise<GetContributionResponse> {
37+
const contribution = await this.contributionRepository.findByIdWithStats(id);
38+
39+
return {
40+
contribution,
41+
};
42+
}
43+
44+
@Get("/:id/title")
45+
public async getContributionTitle(
46+
@Param("id") id: string,
47+
): Promise<GetContributionTitleResponse> {
48+
const contribution = await this.contributionRepository.findTitle(id);
49+
50+
if (!contribution) throw new NotFoundError("Contribution not found");
51+
52+
return { contribution };
53+
}
2054
}

api/src/contribution/repository.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,28 @@ import { ContributionRow, contributionsTable } from "./table";
1414
export class ContributionRepository {
1515
constructor(private readonly postgresService: PostgresService) {}
1616

17+
public async findTitle(contributionId: string) {
18+
// todo-ZM: guard against SQL injections in all sql`` statements
19+
const statement = sql`
20+
SELECT
21+
${contributionsTable.title}
22+
FROM
23+
${contributionsTable}
24+
WHERE
25+
${contributionsTable.id} = ${contributionId}
26+
`;
27+
28+
const raw = await this.postgresService.db.execute(statement);
29+
const entries = Array.from(raw);
30+
const entry = entries[0];
31+
32+
if (!entry) return null;
33+
34+
const unStringifiedRaw = unStringifyDeep(entry);
35+
const camelCased = camelCaseObject(unStringifiedRaw);
36+
return camelCased;
37+
}
38+
1739
public async findForProject(projectId: string) {
1840
const statement = sql`
1941
SELECT
@@ -58,6 +80,22 @@ export class ContributionRepository {
5880
return camelCased;
5981
}
6082

83+
public async findForSitemap() {
84+
const statement = sql`
85+
SELECT
86+
${contributionsTable.id},
87+
${contributionsTable.title}
88+
FROM
89+
${contributionsTable}
90+
`;
91+
92+
const raw = await this.postgresService.db.execute(statement);
93+
const entries = Array.from(raw);
94+
const unStringifiedRaw = unStringifyDeep(entries);
95+
const camelCased = camelCaseObject(unStringifiedRaw);
96+
return camelCased;
97+
}
98+
6199
public async upsert(contribution: ContributionRow) {
62100
return await this.postgresService.db
63101
.insert(contributionsTable)
@@ -148,4 +186,75 @@ export class ContributionRepository {
148186

149187
return sortedUpdatedAt;
150188
}
189+
190+
public async findByIdWithStats(id: string) {
191+
const statement = sql`
192+
SELECT
193+
p.id as id,
194+
p.name as name,
195+
json_agg(
196+
json_build_object('id', r.id, 'name', r.name, 'owner', r.owner, 'contributions', r.contributions)
197+
) AS repositories
198+
FROM
199+
(SELECT
200+
r.id as id,
201+
r.owner as owner,
202+
r.name as name,
203+
r.project_id as project_id,
204+
json_agg(
205+
json_build_object(
206+
'id',
207+
c.id,
208+
'title',
209+
c.title,
210+
'type',
211+
c.type,
212+
'url',
213+
c.url,
214+
'updated_at',
215+
c.updated_at,
216+
'activity_count',
217+
c.activity_count,
218+
'contributor',
219+
json_build_object(
220+
'id',
221+
cr.id,
222+
'name',
223+
cr.name,
224+
'username',
225+
cr.username,
226+
'avatar_url',
227+
cr.avatar_url
228+
)
229+
)
230+
) AS contributions
231+
FROM
232+
${contributionsTable} c
233+
INNER JOIN
234+
${repositoriesTable} r ON c.repository_id = r.id
235+
INNER JOIN
236+
${contributorsTable} cr ON c.contributor_id = cr.id
237+
WHERE
238+
c.id = ${id}
239+
GROUP BY
240+
r.id) AS r
241+
INNER JOIN
242+
${projectsTable} p ON r.project_id = p.id
243+
GROUP BY
244+
p.id
245+
`;
246+
247+
const raw = await this.postgresService.db.execute(statement);
248+
const entries = Array.from(raw);
249+
const unStringifiedRaw = unStringifyDeep(entries);
250+
251+
const reversed = reverseHierarchy(unStringifiedRaw, [
252+
{ from: "repositories", setParentAs: "project" },
253+
{ from: "contributions", setParentAs: "repository" },
254+
]);
255+
256+
const camelCased = camelCaseObject(reversed);
257+
258+
return camelCased[0];
259+
}
151260
}

api/src/contribution/types.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,23 @@ export interface GetContributionsResponse extends GeneralResponse {
1414
}
1515
>;
1616
}
17+
18+
export interface GetContributionResponse extends GeneralResponse {
19+
contribution: Pick<
20+
ContributionEntity,
21+
"id" | "title" | "type" | "url" | "updatedAt" | "activityCount"
22+
> & {
23+
repository: Pick<RepositoryEntity, "id" | "owner" | "name"> & {
24+
project: Pick<ProjectEntity, "id" | "name">;
25+
};
26+
contributor: Pick<ContributorEntity, "id" | "name" | "username" | "avatarUrl">;
27+
};
28+
}
29+
30+
export interface GetContributionTitleResponse extends GeneralResponse {
31+
contribution: Pick<ContributionEntity, "title">;
32+
}
33+
34+
export interface GetContributionsForSitemapResponse extends GeneralResponse {
35+
contributions: Array<Pick<ContributionEntity, "id" | "title">>;
36+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { Env, handleContributionRequest } from "handler/contribution";
2+
3+
export const onRequest: PagesFunction<Env> = handleContributionRequest;
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { Env, handleContributionRequest } from "handler/contribution";
2+
3+
export const onRequest: PagesFunction<Env> = handleContributionRequest;
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { Env } from "handler/contribution";
2+
import { environments } from "@dzcode.io/utils/dist/config/environment";
3+
import { allLanguages, LanguageEntity } from "@dzcode.io/models/dist/language";
4+
import { getContributionURL } from "@dzcode.io/web/dist/utils/contribution";
5+
import { fsConfig } from "@dzcode.io/utils/dist/config";
6+
import { fetchV2Factory } from "@dzcode.io/utils/dist/fetch/factory";
7+
import { Endpoints } from "@dzcode.io/api/dist/app/endpoints";
8+
9+
function xmlEscape(s: string) {
10+
return s.replace(
11+
/[<>&"']/g,
12+
(c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;", '"': "&quot;", "'": "&#39;" })[c] as string,
13+
);
14+
}
15+
16+
export const onRequest: PagesFunction<Env> = async (context) => {
17+
let stage = context.env.STAGE;
18+
if (!environments.includes(stage)) {
19+
console.log(`⚠️ No STAGE provided, falling back to "development"`);
20+
stage = "development";
21+
}
22+
const fullstackConfig = fsConfig(stage);
23+
const fetchV2 = fetchV2Factory<Endpoints>(fullstackConfig);
24+
25+
const { contributions } = await fetchV2("api:contributions/for-sitemap", {});
26+
27+
const hostname = "https://www.dzCode.io";
28+
const links = contributions.reduce<{ url: string; lang: LanguageEntity["code"] }[]>((pV, cV) => {
29+
return [
30+
...pV,
31+
...allLanguages.map(({ baseUrl, code }) => ({
32+
url: xmlEscape(`${baseUrl}${getContributionURL(cV)}`),
33+
lang: code,
34+
})),
35+
];
36+
}, []);
37+
38+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
39+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
40+
xmlns:news="http://www.google.com/schemas/sitemap-news/0.9"
41+
xmlns:xhtml="http://www.w3.org/1999/xhtml"
42+
xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"
43+
xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
44+
${links
45+
.map(
46+
(link) => `
47+
<url>
48+
<loc>${hostname}${link.url}</loc>
49+
<xhtml:link rel="alternate" hreflang="${link.lang}" href="${hostname}${link.url}" />
50+
</url>`,
51+
)
52+
.join("")}
53+
</urlset>`;
54+
55+
return new Response(xml, { headers: { "content-type": "application/xml; charset=utf-8" } });
56+
};
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
declare const htmlTemplate: string; // @ts-expect-error cloudflare converts this to a string using esbuild
2+
import htmlTemplate from "../public/template.html";
3+
declare const notFoundEn: string; // @ts-expect-error cloudflare converts this to a string using esbuild
4+
import notFoundEn from "../public/404.html";
5+
declare const notFoundAr: string; // @ts-expect-error cloudflare converts this to a string using esbuild
6+
import notFoundAr from "../public/ar/404.html";
7+
8+
import { Environment, environments } from "@dzcode.io/utils/dist/config/environment";
9+
import { fsConfig } from "@dzcode.io/utils/dist/config";
10+
import { plainLocalize } from "@dzcode.io/web/dist/components/locale/utils";
11+
import { dictionary, AllDictionaryKeys } from "@dzcode.io/web/dist/components/locale/dictionary";
12+
import { LanguageEntity } from "@dzcode.io/models/dist/language";
13+
import { fetchV2Factory } from "@dzcode.io/utils/dist/fetch/factory";
14+
import { Endpoints } from "@dzcode.io/api/dist/app/endpoints";
15+
16+
export interface Env {
17+
STAGE: Environment;
18+
}
19+
20+
export const handleContributionRequest: PagesFunction<Env> = async (context) => {
21+
let stage = context.env.STAGE;
22+
if (!environments.includes(stage)) {
23+
console.log(`⚠️ No STAGE provided, falling back to "development"`);
24+
stage = "development";
25+
}
26+
27+
const pathName = new URL(context.request.url).pathname;
28+
29+
const languageRegex = /^\/(ar|en)\//i;
30+
const language = (pathName?.match(languageRegex)?.[1]?.toLowerCase() ||
31+
"en") as LanguageEntity["code"];
32+
const notFound = language === "ar" ? notFoundAr : notFoundEn;
33+
34+
const contributionIdRegex = /contribute\/(.*)-(.*)-(.*)/;
35+
const contributionId =
36+
pathName?.match(contributionIdRegex)?.[2] + "-" + pathName?.match(contributionIdRegex)?.[3];
37+
38+
if (!contributionId)
39+
return new Response(notFound, {
40+
headers: { "content-type": "text/html; charset=utf-8" },
41+
status: 404,
42+
});
43+
44+
const localize = (key: AllDictionaryKeys) =>
45+
plainLocalize(dictionary, language, key, "NO-TRANSLATION");
46+
47+
const fullstackConfig = fsConfig(stage);
48+
const fetchV2 = fetchV2Factory<Endpoints>(fullstackConfig);
49+
50+
try {
51+
const { contribution } = await fetchV2("api:contributions/:id/title", {
52+
params: { id: contributionId },
53+
});
54+
const pageTitle = `${localize("contribution-title-pre")} ${contribution.title} ${localize("contribution-title-post")}`;
55+
56+
const newData = htmlTemplate
57+
.replace(/{{template-title}}/g, pageTitle)
58+
.replace(/{{template-description}}/g, localize("contribute-description"))
59+
.replace(/{{template-lang}}/g, language);
60+
61+
return new Response(newData, { headers: { "content-type": "text/html; charset=utf-8" } });
62+
} catch (error) {
63+
// @TODO-ZM: log error to sentry
64+
console.error(error);
65+
66+
return new Response(notFound, {
67+
headers: { "content-type": "text/html; charset=utf-8" },
68+
status: 404,
69+
});
70+
}
71+
};

web/src/_entry/app.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,11 @@ let routes: Array<
3535
},
3636
{
3737
pageName: "contribute",
38-
// @TODO-ZM: change this back once we have contribution page
39-
path: "/contribute/:slug?",
38+
path: "/contribute",
39+
},
40+
{
41+
pageName: "contribute/contribution",
42+
path: "/contribute/*",
4043
},
4144
{
4245
pageName: "team",

0 commit comments

Comments
 (0)