feat(explore): add public repositories explore view
+ 304
- 58
@@ -1,5 +1,5 @@
 {
-  "_generatedAtUnix": 1663372097423,
+  "_generatedAtUnix": 1663449267544,
   "_hashAlgorithm": "sha1",
   "_version": 2,
   "islands": {

...
@@ -36,6 +36,10 @@
     "RegisterView": {
       "hash": "3e2c7053b529624ed6cfa9ea8631b4480bd9775b",
       "pathSource": "./app/views/auth/RegisterView.tsx"
+    },
+    "RepositoryExploreView": {
+      "hash": "d776d1d9f5a8559427f64fe13e14886ac3afe526",
+      "pathSource": "./app/views/repository/RepositoryExploreView.tsx"
     }
   }
 }

app/components/PageHeader.tsx
@@ -18,57 +18,23 @@ export const PageHeader: VFC<PageHeaderProps & WithThemeSchemeProp> = ({
 }) => {
   const invertThemeScheme = themeScheme === "light" ? "dark" : "light";
 
-  const pageHeader = useMemo(() => {
+  const pageHeaderActions = useMemo(() => {
     if (commonProps.authenticated) {
       return (
-        <>
-          <StyledPageHeaderNav>
-            <a aria-label={"Explore Projects"} href={"/explore/projects"}>
-              Explore Projects
-            </a>
-          </StyledPageHeaderNav>
-          <StyledActionsArea>
-            <a
-              href={`/theme/${invertThemeScheme}`}
-              style={{ color: NamedColors.TEXT_MUTED[themeScheme] }}
-              title={`Click to enable ${
-                themeScheme === "light" ? "dark" : "light"
-              } mode`}
-            >
-              {`${themeScheme === "light" ? "Dark" : "Light"} mode`}
-            </a>
-            <a aria-label={"Log off your account"} href={"/auth/logout"}>
-              Logout
-            </a>
-          </StyledActionsArea>
-        </>
+        <a aria-label={"Log off your account"} href={"/auth/logout"}>
+          Logout
+        </a>
       );
     }
 
     return (
       <>
-        <StyledPageHeaderNav>
-          <a aria-label={"Explore Projects"} href={"/explore/projects"}>
-            Explore Projects
-          </a>
-        </StyledPageHeaderNav>
-        <StyledActionsArea>
-          <a
-            href={`/theme/${invertThemeScheme}`}
-            style={{ color: NamedColors.TEXT_MUTED[themeScheme] }}
-            title={`Click to enable ${
-              themeScheme === "light" ? "dark" : "light"
-            } mode`}
-          >
-            {`${themeScheme === "light" ? "Dark" : "Light"} mode`}
-          </a>
-          <a aria-label={"Register a new account"} href={"/auth/register"}>
-            Register
-          </a>
-          <a aria-label={"Login to your account"} href={"/auth/login"}>
-            Login
-          </a>
-        </StyledActionsArea>
+        <a aria-label={"Register a new account"} href={"/auth/register"}>
+          Register
+        </a>
+        <a aria-label={"Login to your account"} href={"/auth/login"}>
+          Login
+        </a>
       </>
     );
   }, [commonProps.authenticated]);

...
@@ -81,7 +47,23 @@ export const PageHeader: VFC<PageHeaderProps & WithThemeSchemeProp> = ({
           <h1 style={{ margin: 0, marginLeft: 20 }}>{Const.APP_NAME}</h1>
         </a>
       </StyledLogoArea>
-      {pageHeader}
+      <StyledPageHeaderNav>
+        <a aria-label={"Explore Repositories"} href={"/repositories/explore"}>
+          Explore Repositories
+        </a>
+      </StyledPageHeaderNav>
+      <StyledActionsArea>
+        <a
+          href={`/theme/${invertThemeScheme}`}
+          style={{ color: NamedColors.TEXT_MUTED[themeScheme] }}
+          title={`Click to enable ${
+            themeScheme === "light" ? "dark" : "light"
+          } mode`}
+        >
+          {`${themeScheme === "light" ? "Dark" : "Light"} mode`}
+        </a>
+        {pageHeaderActions}
+      </StyledActionsArea>
     </StyledPageHeader>
   );
 };

...
@@ -140,11 +122,6 @@ const StyledPageHeaderNav = styled.nav`
 
   gap: 24px;
   margin-left: 4px;
-
-  a {
-    font-size: 20px;
-    line-height: 24px;
-  }
 `;
 
 const StyledActionsArea = styled.div`

...
@@ -154,4 +131,8 @@ const StyledActionsArea = styled.div`
   align-items: center;
 
   padding-right: 12px;
+
+  & > a {
+    margin-left: 12px;
+  }
 `;

app/controllers/auth/getDashboardView.ts
@@ -2,10 +2,11 @@
 import type { ReqHandler } from "@ethicdevs/react-monolith";
 // app
 import { AppRoute } from "../../routes";
+import { makeUsersService } from "../../services/user";
+// app views
 import DashboardView, {
   DashboardViewProps,
 } from "../../views/auth/DashboardView";
-import { makeUsersService } from "../../services/users";
 
 const getDashboardView: ReqHandler = async (request, reply) => {
   const { authenticated, curr_user_uid } = request.session.data;

new file
app/controllers/repository/getRepositoryExploreView.ts
@@ -0,0 +1,20 @@
+// 1st-party
+import { ReqHandler } from "@ethicdevs/react-monolith";
+// app
+import { makeRepositoryService } from "../../services/repository";
+// app views
+import RepositoryExploreView, {
+  RepositoryExploreViewProps,
+} from "../../views/repository/RepositoryExploreView";
+
+const getRepositoryExploreView: ReqHandler = async (request, reply) => {
+  const repoService = makeRepositoryService({ request });
+  const repositories = await repoService.getRepositoryExploreCollection();
+
+  const reqHandler = reply.makeRequestHandler(request, reply);
+  return reqHandler<RepositoryExploreViewProps>(RepositoryExploreView.name, {
+    repositories,
+  });
+};
+
+export default getRepositoryExploreView;

new file
app/controllers/repository/index.ts
@@ -0,0 +1,5 @@
+import { default as getRepositoryExploreView } from "./getRepositoryExploreView";
+
+export const RepositoryController = {
+  getRepositoryExploreView,
+};

@@ -60,6 +60,20 @@ const getNodeEnv = (
   return defaultEnv;
 };
 
+const getHostname = (hostname?: string | null): string => {
+  if (hostname == null || hostname === "fake") {
+    return "localhost";
+  }
+  return String(hostname);
+};
+
+const getPort = (port?: string | null): number => {
+  if (port == null || port === "fake") {
+    return 4100;
+  }
+  return Number(port);
+};
+
 const getCookieName = (cookieName?: string | null): string => {
   if (cookieName == null || cookieName === "fake") {
     throw new Error("[env] COOKIE_NAME is missing.");

...
@@ -116,6 +130,8 @@ const getGitRepositoriesRoot = (
 
 interface IEnv {
   NODE_ENV: NodeEnv;
+  PORT: number;
+  HOST: string;
   COOKIE_NAME: string;
   COOKIE_SECRET: string;
   DATABASE_URL: string;

...
@@ -126,11 +142,17 @@ interface IEnv {
 
 export const Env: IEnv = {
   NODE_ENV: getNodeEnv(process.env.NODE_ENV),
+  PORT: getPort(process.env.PORT),
+  HOST: getHostname(process.env.HOST),
+  // ---
   COOKIE_NAME: getCookieName(process.env.COOKIE_NAME),
   COOKIE_SECRET: getCookieSecret(process.env.COOKIE_SECRET),
+  // --
   DATABASE_URL: getDatabaseUrl(process.env.DATABASE_URL),
+  // ---
   DEPLOYMENT_DOMAIN: getDeploymentDomain(process.env.DEPLOYMENT_DOMAIN),
   DEPLOYMENT_SCHEME: getDeploymentScheme(process.env.DEPLOYMENT_SCHEME),
+  // ---
   GIT_REPOSITORIES_ROOT: getGitRepositoriesRoot(
     process.env.GIT_REPOSITORIES_ROOT
   ),

@@ -8,6 +8,7 @@ import React from "react";
 import { authenticatedOrLogin, guestOrRedirect } from "./utils/server";
 // app controllers
 import { AuthController } from "./controllers/auth";
+import { RepositoryController } from "./controllers/repository";
 import * as HomeController from "./controllers/HomeController";
 import * as ThemeController from "./controllers/ThemeController";
 

...
@@ -19,6 +20,7 @@ export enum AppRoute {
   AUTH_LOGIN = "auth.login",
   AUTH_LOGIN_ACTION = "auth.login.action",
   USER_DASHBOARD = "user.dashboard",
+  REPOSITORY_EXPLORE = "repository.explore",
 }
 
 export interface AppRoutesParams extends IRouteParams {

...
@@ -42,6 +44,7 @@ export interface AppRoutesParams extends IRouteParams {
     };
   };
   [AppRoute.USER_DASHBOARD]: undefined;
+  [AppRoute.REPOSITORY_EXPLORE]: undefined;
 }
 
 export const AppRoutesSchemas: Record<AppRoute, undefined | FastifySchema> = {

...
@@ -85,6 +88,7 @@ export const AppRoutesSchemas: Record<AppRoute, undefined | FastifySchema> = {
     },
   },
   [AppRoute.USER_DASHBOARD]: undefined,
+  [AppRoute.REPOSITORY_EXPLORE]: undefined,
 };
 
 const RootAppRouter: AppRouter = () => {

...
@@ -148,6 +152,14 @@ const RootAppRouter: AppRouter = () => {
           preHandler={authenticatedOrLogin()}
           handler={AuthController.getDashboardView}
         />
+        {/* --- */}
+        <Router.Route
+          name={AppRoute.REPOSITORY_EXPLORE}
+          method={"GET"}
+          path={"/repositories/explore"}
+          preHandler={authenticatedOrLogin()}
+          handler={RepositoryController.getRepositoryExploreView}
+        />
       </Router.Group>
     </Router.Root>
   );

@@ -15,7 +15,7 @@ import fastifyFormBody from "@fastify/formbody";
 import fastifyGitServer from "@ethicdevs/fastify-git-server";
 import fastifyServeStatic from "fastify-static";
 // generated via script[generate:prisma]
-import { PrismaClient } from "@prisma/client";
+import { GlobalRole, PrismaClient } from "@prisma/client";
 // app root
 import * as Paths from "../paths";
 import { version as appVersion } from "../package.json";

...
@@ -30,9 +30,6 @@ import {
   makeRequestHandler,
 } from "./utils/server";
 
-const HOST = process.env.HOST || "localhost";
-const PORT = process.env.PORT || 4100;
-
 let server: null | AppServer = null;
 
 async function main(): Promise<AppServer> {

...
@@ -52,7 +49,7 @@ async function main(): Promise<AppServer> {
     signed: true,
   };
 
-  server = await makeAppServer(HOST, PORT, {
+  server = await makeAppServer(Env.HOST, Env.PORT, {
     appName: Const.APP_NAME,
     appVersion,
     env,

...
@@ -141,7 +138,7 @@ async function main(): Promise<AppServer> {
           curr_user_avatar_uri: null,
           curr_user_uid: null,
           curr_user_username: null,
-          curr_user_role: "GUEST",
+          curr_user_role: GlobalRole.GUEST,
           flash_message: null,
           flash_message_shown_once: false,
           two_factor_lock: false,

app/services/gitServer/temp_mock.ts
@@ -8,6 +8,15 @@ export const ORGS_REPOS: Record<
     { username: string; password: string; gitRepositoryDir: string }
   >
 > = {
+  ethicdevs: {
+    "test-repository": {
+      username: "ethicdevs",
+      password: "secret",
+      gitRepositoryDir: resolve(
+        join(Env.GIT_REPOSITORIES_ROOT, "ethicdevs", "test-repository")
+      ),
+    },
+  },
   wnemencha: {
     "react-monolith-samples": {
       username: "wnemencha",

new file
app/services/repository/getHttpCloneUrl.ts
@@ -0,0 +1,40 @@
+// 1st-party
+import type { ServiceMethodFactory } from "@ethicdevs/react-monolith";
+// generated via script[generate:prisma]
+import type { Repository } from "@prisma/client";
+// app
+import { Env } from "../../env";
+import { getEnv } from "../../utils/server";
+// service
+import type { RepositoryServiceDeps } from "./types";
+
+const makeGetHttpCloneUrl: ServiceMethodFactory<
+  RepositoryServiceDeps,
+  [Repository],
+  Promise<string>
+> = ({ request }) => {
+  const env = getEnv();
+  return async (repo) => {
+    const parentOrg = await request.prisma.organization.findUnique({
+      where: {
+        id: repo.organizationId,
+      },
+    });
+
+    if (parentOrg == null) {
+      throw new Error(
+        `Could not find the parent organization for project "${repo.slug}".`
+      );
+    }
+
+    const authCredentials = `${parentOrg.slug}:secret`;
+    const baseUrl =
+      env === "development"
+        ? `${Env.DEPLOYMENT_SCHEME}://${authCredentials}@${Env.DEPLOYMENT_DOMAIN}:${Env.PORT}`
+        : `${Env.DEPLOYMENT_SCHEME}://${authCredentials}@${Env.DEPLOYMENT_DOMAIN}`;
+
+    return `${baseUrl}/${parentOrg.slug}/${repo.slug}.git`;
+  };
+};
+
+export default makeGetHttpCloneUrl;

new file
app/services/repository/getRepositoryExploreCollection.ts
@@ -0,0 +1,37 @@
+// 1st-party
+import type { ServiceMethodFactory } from "@ethicdevs/react-monolith";
+// generated via script[generate:prisma]
+import { Repository, ResourceVisibility } from "@prisma/client";
+// service
+import type { RepositoryServiceDeps } from "./types";
+import { default as makeGetHttpCloneUrl } from "./getHttpCloneUrl";
+import { default as makeGetSshCloneUrl } from "./getSshCloneUrl";
+
+const makeGetRepositoryExploreCollection: ServiceMethodFactory<
+  RepositoryServiceDeps,
+  void[],
+  Promise<Repository[]>
+> = (deps) => {
+  const { request } = deps;
+  const getHttpCloneUrl = makeGetHttpCloneUrl(deps);
+  const getSshCloneUrl = makeGetSshCloneUrl(deps);
+  return async () => {
+    const repositories = await request.prisma.repository.findMany({
+      where: {
+        visibility: ResourceVisibility.PUBLIC,
+      },
+    });
+
+    const repositoriesWithMetas = await Promise.all(
+      repositories.map(async (repo) => ({
+        ...repo,
+        httpCloneUrl: await getHttpCloneUrl(repo),
+        sshCloneUrl: await getSshCloneUrl(repo),
+      }))
+    );
+
+    return repositoriesWithMetas;
+  };
+};
+
+export default makeGetRepositoryExploreCollection;

new file
app/services/repository/getSshCloneUrl.ts
@@ -0,0 +1,33 @@
+// 1st-party
+import type { ServiceMethodFactory } from "@ethicdevs/react-monolith";
+// generated via script[generate:prisma]
+import type { Repository } from "@prisma/client";
+// app
+import { Env } from "../../env";
+// service
+import type { RepositoryServiceDeps } from "./types";
+
+const makeGetSshCloneUrl: ServiceMethodFactory<
+  RepositoryServiceDeps,
+  [Repository],
+  Promise<string>
+> = ({ request }) => {
+  return async (repo) => {
+    const parentOrg = await request.prisma.organization.findUnique({
+      where: {
+        id: repo.organizationId,
+      },
+    });
+
+    if (parentOrg == null) {
+      throw new Error(
+        `Could not find the parent organization for project "${repo.slug}".`
+      );
+    }
+
+    const baseUrl = `ssh://git@${Env.DEPLOYMENT_DOMAIN}`;
+    return `${baseUrl}/${parentOrg.slug}/${repo.slug}.git`;
+  };
+};
+
+export default makeGetSshCloneUrl;

new file
app/services/repository/index.ts
@@ -0,0 +1,17 @@
+// 1st-party
+import { makeService } from "@ethicdevs/react-monolith";
+// app
+import type { RepositoryServiceAPI, RepositoryServiceDeps } from "./types";
+// service methods
+import { default as makeGetHttpCloneUrl } from "./getHttpCloneUrl";
+import { default as makeGetRepositoryExploreCollection } from "./getRepositoryExploreCollection";
+import { default as makeGetSshCloneUrl } from "./getSshCloneUrl";
+
+export const makeRepositoryService = makeService<
+  RepositoryServiceAPI,
+  RepositoryServiceDeps
+>({
+  getHttpCloneUrl: makeGetHttpCloneUrl,
+  getRepositoryExploreCollection: makeGetRepositoryExploreCollection,
+  getSshCloneUrl: makeGetSshCloneUrl,
+});

new file
app/services/repository/types.ts
@@ -0,0 +1,16 @@
+// 1st-party
+import type { ServiceApiContract } from "@ethicdevs/react-monolith";
+// 3rd-party
+import type { FastifyRequest } from "fastify";
+// generated via script[generate:prisma]
+import type { Repository } from "@prisma/client";
+
+export interface RepositoryServiceAPI extends ServiceApiContract {
+  getRepositoryExploreCollection(): Promise<Repository[]>;
+  getHttpCloneUrl(): Promise<string>;
+  getSshCloneUrl(): Promise<string>;
+}
+
+export interface RepositoryServiceDeps {
+  request: FastifyRequest;
+}

app/services/users/getUserByEmailAddress.ts -> app/services/user/getUserByEmailAddress.ts
app/services/users/getUserById.ts -> app/services/user/getUserById.ts
app/services/users/getUserByUsername.ts -> app/services/user/getUserByUsername.ts
app/services/users/index.ts -> app/services/user/index.ts
app/services/users/types.ts -> app/services/user/types.ts
new file
app/views/repository/RepositoryExploreView.tsx
@@ -0,0 +1,36 @@
+// 1st-party
+import type { ReactView } from "@ethicdevs/react-monolith";
+// 3rd-party
+import React from "react";
+// generated via script[generate:prisma]
+import type { Repository } from "@prisma/client";
+// app
+import type { CommonProps } from "../../types";
+import { Layout, PageWrapper } from "../../components";
+
+export interface RepositoryExploreViewProps extends CommonProps {
+  repositories: Repository[];
+}
+
+const RepositoryExploreView: ReactView<RepositoryExploreViewProps> = ({
+  commonProps,
+  repositories,
+}) => {
+  return (
+    <Layout {...commonProps} showSideMenu={false}>
+      <PageWrapper>
+        {repositories.map((repo) => (
+          <div key={repo.id}>
+            <h2>{repo.displayName}</h2>
+            <code>
+              <pre>{JSON.stringify(repo, null, 2)}</pre>
+            </code>
+          </div>
+        ))}
+      </PageWrapper>
+    </Layout>
+  );
+};
+
+RepositoryExploreView.displayName = "RepositoryExploreView";
+export default RepositoryExploreView;

new file
db/migrations/20220917213639_add_org_and_repo_visibility_field/migration.sql
@@ -0,0 +1,8 @@
+-- CreateEnum
+CREATE TYPE "ResourceVisibility" AS ENUM ('PUBLIC', 'UNLISTED', 'PRIVATE');
+
+-- AlterTable
+ALTER TABLE "Organization" ADD COLUMN     "visibility" "ResourceVisibility" NOT NULL DEFAULT E'PRIVATE';
+
+-- AlterTable
+ALTER TABLE "Repository" ADD COLUMN     "visibility" "ResourceVisibility" NOT NULL DEFAULT E'PRIVATE';

@@ -17,6 +17,12 @@ enum GlobalRole {
   SUPER_ADMIN
 }
 
+enum ResourceVisibility {
+  PUBLIC
+  UNLISTED
+  PRIVATE
+}
+
 // models
 
 model Organization {

...
@@ -28,6 +34,7 @@ model Organization {
 
   avatarUri   String?
   displayName String?
+  visibility ResourceVisibility @default(PRIVATE)
   websiteUrl String?
 
   repositories Repository[] @relation("ManyRepositoriesToOneOrganization")

...
@@ -47,6 +54,7 @@ model Repository {
   displayName String?
   keywords String[]
   shortDescription String?
+  visibility ResourceVisibility @default(PRIVATE)
   websiteUrl String?
 
   organization Organization @relation("ManyRepositoriesToOneOrganization", fields: [organizationId], references: [id])