From 8be7d8a95ef2a7723ee52497bd460950e2b3aeca Mon Sep 17 00:00:00 2001
From: shadrach <shadrachtemitayo@gmail.com>
Date: Wed, 15 Jan 2025 08:38:42 +0100
Subject: [PATCH 1/2] add analyticsAdmin permission and implement users search
 api

---
 desci-server/src/middleware/ensureAdmin.ts    | 20 +++++++++--
 desci-server/src/routes/v1/admin/index.ts     | 11 +++---
 .../src/routes/v1/admin/users/index.ts        | 36 ++++++++++++++++---
 3 files changed, 57 insertions(+), 10 deletions(-)

diff --git a/desci-server/src/middleware/ensureAdmin.ts b/desci-server/src/middleware/ensureAdmin.ts
index 66fa665fe..c76eecbd2 100644
--- a/desci-server/src/middleware/ensureAdmin.ts
+++ b/desci-server/src/middleware/ensureAdmin.ts
@@ -1,5 +1,7 @@
 import { Request, Response, NextFunction } from 'express';
 
+import { RequestWithUser } from './authorisation.js';
+
 // export const ensureUser = async (req: Request, res: Response, next: NextFunction) => {
 //   const userId = req.session.userId;
 //   console.log('REQ SESS', req.session, req.cookies);
@@ -20,12 +22,26 @@ import { Request, Response, NextFunction } from 'express';
 // };
 const disableList = ['noreply+test@desci.com'];
 
-export const ensureAdmin = async (req: Request, res: Response, next: NextFunction) => {
-  const user = (req as any).user;
+export const ensureAdmin = async (req: RequestWithUser, res: Response, next: NextFunction) => {
+  const user = req.user;
+
+  if (user.email.indexOf('@desci.com') > -1 && disableList.indexOf(user.email) < 0) {
+    next();
+    return;
+  }
+
+  res.sendStatus(401);
+};
+
+export const ensureUserIsAdmin = async (req: RequestWithUser, res: Response, next: NextFunction) => {
+  const user = req.user;
 
   if (user.email.indexOf('@desci.com') > -1 && disableList.indexOf(user.email) < 0) {
     next();
     return;
+  } else if (user.isAdmin) {
+    next();
+    return;
   }
 
   res.sendStatus(401);
diff --git a/desci-server/src/routes/v1/admin/index.ts b/desci-server/src/routes/v1/admin/index.ts
index a3bb6733d..78d36dbd2 100644
--- a/desci-server/src/routes/v1/admin/index.ts
+++ b/desci-server/src/routes/v1/admin/index.ts
@@ -5,17 +5,18 @@ import { listAttestations } from '../../../controllers/admin/communities/index.j
 import { debugAllNodesHandler, debugNodeHandler } from '../../../controllers/admin/debug.js';
 import { listDoiRecords, mintDoi } from '../../../controllers/admin/doi/index.js';
 import { resumePublish } from '../../../controllers/admin/publish/resumePublish.js';
-import { ensureAdmin } from '../../../middleware/ensureAdmin.js';
+import { ensureAdmin, ensureUserIsAdmin } from '../../../middleware/ensureAdmin.js';
 import { ensureUser } from '../../../middleware/permissions.js';
 import { asyncHandler } from '../../../utils/asyncHandler.js';
 
 import communities from './communities/index.js';
 import doiRouter from './doi.js';
+import usersRouter from './users/index.js';
 
 const router = Router();
 
-router.get('/analytics', [ensureUser, ensureAdmin], getAnalytics);
-router.get('/analytics/csv', [ensureUser, ensureAdmin], createCsv);
+router.get('/analytics', [ensureUser, ensureUserIsAdmin], getAnalytics);
+router.get('/analytics/csv', [ensureUser, ensureUserIsAdmin], createCsv);
 
 router.get('/doi/list', [ensureUser, ensureAdmin], listDoiRecords);
 router.post('/mint/:uuid', [ensureUser, ensureAdmin], asyncHandler(mintDoi));
@@ -27,8 +28,10 @@ router.post('/resumePublish', [ensureUser, ensureAdmin], asyncHandler(resumePubl
 
 router.use('/communities', [ensureUser, ensureAdmin], communities);
 router.get('/attestations', [ensureUser, ensureAdmin], asyncHandler(listAttestations));
-// router.use('/users', [ensureUser, ensureAdmin], usersRouter);
+router.use('/users', [ensureUser, ensureAdmin], usersRouter);
+// router.use('/nodes', [ensureUser, ensureAdmin], usersRouter);
 
 router.use('/doi', doiRouter);
+// router.use('/users', usersRouter);
 
 export default router;
diff --git a/desci-server/src/routes/v1/admin/users/index.ts b/desci-server/src/routes/v1/admin/users/index.ts
index 9c26843fa..16554ff18 100644
--- a/desci-server/src/routes/v1/admin/users/index.ts
+++ b/desci-server/src/routes/v1/admin/users/index.ts
@@ -1,6 +1,9 @@
 import { NextFunction, Response, Router } from 'express';
+import { Request } from 'express';
+import z from 'zod';
 
-import { SuccessResponse } from '../../../../core/ApiResponse.js';
+import { prisma } from '../../../../client.js';
+import { SuccessMessageResponse, SuccessResponse } from '../../../../core/ApiResponse.js';
 import { ensureAdmin } from '../../../../middleware/ensureAdmin.js';
 import { ensureUser } from '../../../../middleware/permissions.js';
 import { asyncHandler } from '../../../../utils/asyncHandler.js';
@@ -8,13 +11,38 @@ import { asyncHandler } from '../../../../utils/asyncHandler.js';
 // const logger = parentLogger.child({ module: 'Admin/communities' });
 const router = Router();
 
+const userSearchSchema = z.object({
+  query: z.object({
+    page: z.coerce.number().optional().default(0),
+    cursor: z.coerce.number().optional().default(1),
+    limit: z.coerce.number().optional().default(20),
+  }),
+});
+
 router.get(
   '/search',
   [ensureUser, ensureAdmin],
-  asyncHandler(async (_req: Request, res: Response, _next: NextFunction) => {
-    //
-    new SuccessResponse([]).send(res);
+  asyncHandler(async (req: Request, res: Response, _next: NextFunction) => {
+    const {
+      query: { page, limit, cursor },
+    } = await userSearchSchema.parseAsync(req);
+    const count = await prisma.user.count({});
+    const users = await prisma.user.findMany({ cursor: { id: cursor }, skip: page * limit, take: limit });
+
+    new SuccessResponse({ cursor: users[users.length - 1].id, page, count, users }).send(res);
   }),
 );
 
+router.get(
+  '/toggleAdmin',
+  [ensureUser, ensureAdmin],
+  asyncHandler(
+    async (req: Request<any, any, { userId: number; isAdmin: boolean }>, res: Response, _next: NextFunction) => {
+      const userId = req.body.userId;
+      await prisma.user.update({ where: { id: userId }, data: { isAdmin: req.body.isAdmin } });
+      new SuccessMessageResponse().send(res);
+    },
+  ),
+);
+
 export default router;

From aa8d26917ae498bafb155d1a6589ba1acbcca6e6 Mon Sep 17 00:00:00 2001
From: shadrach <shadrachtemitayo@gmail.com>
Date: Wed, 15 Jan 2025 12:44:37 +0100
Subject: [PATCH 2/2] feat: users admin functionlity

---
 desci-server/src/controllers/admin/users.ts   | 116 ++++++++++++++++++
 desci-server/src/middleware/permissions.ts    |   2 +-
 desci-server/src/routes/v1/admin/index.ts     |   2 +-
 .../src/routes/v1/admin/users/index.ts        |  39 ++----
 docker-compose.dev.yml                        |   4 +-
 5 files changed, 130 insertions(+), 33 deletions(-)
 create mode 100644 desci-server/src/controllers/admin/users.ts

diff --git a/desci-server/src/controllers/admin/users.ts b/desci-server/src/controllers/admin/users.ts
new file mode 100644
index 000000000..0dd9e26a2
--- /dev/null
+++ b/desci-server/src/controllers/admin/users.ts
@@ -0,0 +1,116 @@
+import { User } from '@prisma/client';
+import { Request, Response } from 'express';
+import z from 'zod';
+
+import { prisma } from '../../client.js';
+import { NotFoundError } from '../../core/ApiError.js';
+import { SuccessResponse } from '../../core/ApiResponse.js';
+import { emailRegex } from '../../core/helper.js';
+import { logger as parentLogger } from '../../logger.js';
+import { formatOrcidString, orcidRegex } from '../../utils.js';
+
+export type SearchProfilesRequest = Request<never, never, never, { name?: string; orcid?: string }> & {
+  user: User; // added by auth middleware
+};
+
+export type SearchProfilesResBody =
+  | {
+      profiles: UserProfile[];
+    }
+  | {
+      error: string;
+    };
+
+export type UserProfile = { name: string; id: number; orcid?: string; organisations?: string[] };
+
+const userSearchSchema = z.object({
+  query: z.object({
+    page: z.coerce.number().optional().default(0),
+    cursor: z.coerce.number().optional().default(1),
+    limit: z.coerce.number().optional().default(20),
+    search: z.string().optional().default(''),
+  }),
+});
+
+export const searchUserProfiles = async (req: SearchProfilesRequest, res: Response<SearchProfilesResBody>) => {
+  // debugger;
+  const user = req.user;
+  const { name } = req.query;
+  let { orcid } = req.query;
+  const logger = parentLogger.child({
+    module: 'Users::searchProfiles',
+    body: req.body,
+    userId: user.id,
+    name,
+    orcid,
+    queryType: orcid ? 'orcid' : 'name',
+  });
+
+  const {
+    query: { page, limit, cursor, search },
+  } = await userSearchSchema.parseAsync(req);
+
+  logger.trace({ page, cursor, limit, search });
+
+  const count = await prisma.user.count({});
+  const users = await prisma.user.findMany({ cursor: { id: cursor }, skip: page * limit, take: limit });
+
+  new SuccessResponse({ cursor: users[users.length - 1].id, page, count, data: users }).send(res);
+  return;
+  if (orcid && orcidRegex.test(orcid) === false)
+    throw new NotFoundError('Invalid orcid id, orcid must follow either 123456780000 or 1234-4567-8000-0000 format.');
+  // return res
+  //   .status(400)
+  //   .json({ error: 'Invalid orcid id, orcid must follow either 123456780000 or 1234-4567-8000-0000 format.' });
+
+  if (orcid) orcid = formatOrcidString(orcid); // Ensure hyphenated
+
+  if (name?.toString().length < 2 && !orcid) throw new NotFoundError('Name query must be at least 2 characters');
+  // return res.status(400).json({ error: 'Name query must be at least 2 characters' });
+
+  //   try {
+  const isEmail = emailRegex.test(name);
+  let emailMatches = [];
+  if (isEmail) {
+    emailMatches = await prisma.user.findMany({
+      where: {
+        email: {
+          mode: 'insensitive',
+          equals: name as string,
+        },
+      },
+      include: { userOrganizations: { include: { organization: { select: { name: true } } } } },
+    });
+  }
+
+  const profiles = orcid
+    ? await prisma.user.findMany({
+        where: { orcid: orcid },
+        include: { userOrganizations: { include: { organization: { select: { name: true } } } } },
+      })
+    : await prisma.user.findMany({
+        where: { name: { contains: name as string, mode: 'insensitive', not: null } },
+        include: { userOrganizations: { include: { organization: { select: { name: true } } } } },
+      });
+
+  // logger.info({ profiles }, 'PROFILES');
+  if (profiles || emailMatches) {
+    const profilesReturn: UserProfile[] = [...emailMatches, ...profiles].map((profile) => ({
+      name: profile.name,
+      id: profile.id,
+      organisations: profile.userOrganizations.map((org) => org.organization.name),
+      ...(profile.orcid && { orcid: profile.orcid }),
+    }));
+    //   return res.status(200).json({ profiles: profilesReturn });
+    new SuccessResponse({ profiles: profilesReturn });
+  } else {
+    new SuccessResponse({ profiles: [] });
+  }
+  //   }
+  //   catch (e) {
+  //     logger.error({ e }, 'Failed to search for profiles');
+  //     return res.status(500).json({ error: 'Search failed' });
+  //   }
+
+  //   return res.status(500).json({ error: 'Something went wrong' });
+};
diff --git a/desci-server/src/middleware/permissions.ts b/desci-server/src/middleware/permissions.ts
index c4480708e..6c6f691f4 100644
--- a/desci-server/src/middleware/permissions.ts
+++ b/desci-server/src/middleware/permissions.ts
@@ -22,7 +22,7 @@ export const ensureUser = async (req: ExpressRequest, res: Response, next: NextF
   const retrievedUser = authTokenRetrieval || apiKeyRetrieval;
 
   if (!retrievedUser) {
-    // logger.trace({ token, apiKey }, 'ENSURE USER');
+    logger.trace({ token, apiKey }, 'ENSURE USER');
     res.status(401).send({ ok: false, message: 'Unauthorized' });
   } else {
     (req as any).user = retrievedUser;
diff --git a/desci-server/src/routes/v1/admin/index.ts b/desci-server/src/routes/v1/admin/index.ts
index 78d36dbd2..9c0e30481 100644
--- a/desci-server/src/routes/v1/admin/index.ts
+++ b/desci-server/src/routes/v1/admin/index.ts
@@ -28,7 +28,7 @@ router.post('/resumePublish', [ensureUser, ensureAdmin], asyncHandler(resumePubl
 
 router.use('/communities', [ensureUser, ensureAdmin], communities);
 router.get('/attestations', [ensureUser, ensureAdmin], asyncHandler(listAttestations));
-router.use('/users', [ensureUser, ensureAdmin], usersRouter);
+router.use('/users', usersRouter);
 // router.use('/nodes', [ensureUser, ensureAdmin], usersRouter);
 
 router.use('/doi', doiRouter);
diff --git a/desci-server/src/routes/v1/admin/users/index.ts b/desci-server/src/routes/v1/admin/users/index.ts
index 16554ff18..f16ef8451 100644
--- a/desci-server/src/routes/v1/admin/users/index.ts
+++ b/desci-server/src/routes/v1/admin/users/index.ts
@@ -1,8 +1,8 @@
 import { NextFunction, Response, Router } from 'express';
 import { Request } from 'express';
-import z from 'zod';
 
 import { prisma } from '../../../../client.js';
+import { searchUserProfiles } from '../../../../controllers/admin/users.js';
 import { SuccessMessageResponse, SuccessResponse } from '../../../../core/ApiResponse.js';
 import { ensureAdmin } from '../../../../middleware/ensureAdmin.js';
 import { ensureUser } from '../../../../middleware/permissions.js';
@@ -11,38 +11,17 @@ import { asyncHandler } from '../../../../utils/asyncHandler.js';
 // const logger = parentLogger.child({ module: 'Admin/communities' });
 const router = Router();
 
-const userSearchSchema = z.object({
-  query: z.object({
-    page: z.coerce.number().optional().default(0),
-    cursor: z.coerce.number().optional().default(1),
-    limit: z.coerce.number().optional().default(20),
-  }),
-});
+router.get('/search', [ensureUser, ensureAdmin], asyncHandler(searchUserProfiles));
 
-router.get(
-  '/search',
+router.patch(
+  '/:userId/toggleRole',
   [ensureUser, ensureAdmin],
-  asyncHandler(async (req: Request, res: Response, _next: NextFunction) => {
-    const {
-      query: { page, limit, cursor },
-    } = await userSearchSchema.parseAsync(req);
-    const count = await prisma.user.count({});
-    const users = await prisma.user.findMany({ cursor: { id: cursor }, skip: page * limit, take: limit });
-
-    new SuccessResponse({ cursor: users[users.length - 1].id, page, count, users }).send(res);
+  asyncHandler(async (req: Request<{ userId: number }, any>, res: Response, _next: NextFunction) => {
+    const userId = req.params.userId;
+    const user = await prisma.user.findFirst({ where: { id: parseInt(userId.toString()) }, select: { isAdmin: true } });
+    await prisma.user.update({ where: { id: parseInt(userId.toString()) }, data: { isAdmin: !user.isAdmin } });
+    new SuccessMessageResponse().send(res);
   }),
 );
 
-router.get(
-  '/toggleAdmin',
-  [ensureUser, ensureAdmin],
-  asyncHandler(
-    async (req: Request<any, any, { userId: number; isAdmin: boolean }>, res: Response, _next: NextFunction) => {
-      const userId = req.body.userId;
-      await prisma.user.update({ where: { id: userId }, data: { isAdmin: req.body.isAdmin } });
-      new SuccessMessageResponse().send(res);
-    },
-  ),
-);
-
 export default router;
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index 2b07ab93c..fa8993cc3 100755
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -99,10 +99,12 @@ services:
         condition: service_healthy
       db_postgres:
         condition: service_healthy
+    env_file:
+      - .env
     environment:
       # https://github.com/graphprotocol/graph-node/blob/master/docs/environment-variables.md
       postgres_host: db_postgres
-      # postgres_port: 5433
+      postgres_port: ${PG_PORT:-5432}
       postgres_user: walter
       postgres_pass: white
       postgres_db: postgres