From 95e700bee322a165675778c2d1cc042ed872fb11 Mon Sep 17 00:00:00 2001 From: Harish-Naruto Date: Wed, 17 Jun 2026 00:06:43 +0530 Subject: [PATCH 1/2] change updateRequest to support isApproved == false --- src/controllers/member.controller.ts | 29 ++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/src/controllers/member.controller.ts b/src/controllers/member.controller.ts index 525854f..74e8cf6 100644 --- a/src/controllers/member.controller.ts +++ b/src/controllers/member.controller.ts @@ -3,6 +3,7 @@ import * as memberService from "../services/member.service"; import { ApiError } from "../utils/apiError"; import { uploadImage } from "../utils/imageUtils"; import { SupabaseClient } from "@supabase/supabase-js"; +import { Role } from "../generated/prisma/client"; // List all approved members export const listAllApprovedMembers = async (req: Request, res: Response) => { @@ -114,8 +115,6 @@ export const updateRequest = async (req: Request, res: Response) => { throw new ApiError("No essential creds provided", 400); } - if(!isApproved) throw new ApiError("Someone interrupting the backend flow", 400); - const update = await memberService.approveRequest( isApproved, adminId, @@ -156,3 +155,29 @@ export const getUserInterviews = async (req: Request, res: Response) => { const interviews = await memberService.getInterviews(memberId); res.status(200).json({ success: true, interviews }); }; + +// Update a member's role (SUPER_ADMIN only) +export const updateMemberRole = async (req: Request, res: Response) => { + const { memberId } = req.params; + const { adminId, role } = req.body; + + if (!memberId || !adminId || !role) { + throw new ApiError("memberId, adminId, and role are required", 400); + } + + const validRoles = Object.values(Role); + if (!validRoles.includes(role)) { + throw new ApiError( + `Invalid role. Must be one of: ${validRoles.join(", ")}`, + 400, + ); + } + + const updated = await memberService.updateMemberRole(adminId, memberId, role as Role); + + res.status(200).json({ + success: true, + user: updated, + message: `Role updated to ${role}`, + }); +}; From b617d6386008d137a55c6c746a743ee286788e3d Mon Sep 17 00:00:00 2001 From: Harish-Naruto Date: Wed, 17 Jun 2026 00:07:01 +0530 Subject: [PATCH 2/2] feat: replace isManager boolean with Role enum and implement restricted role management functionality --- .../migration.sql | 24 +++++++++++++++ prisma/schema.prisma | 9 +++++- src/routes/members.ts | 23 +++++++++++++++ src/services/member.service.ts | 29 ++++++++++++++++++- src/services/site-content.service.ts | 24 ++++++++------- tests/Member.test.ts | 12 ++++++-- tests/SiteContent.test.ts | 6 ++-- tsconfig.json | 2 +- 8 files changed, 109 insertions(+), 20 deletions(-) create mode 100644 prisma/migrations/20260616181835_new_role_added/migration.sql diff --git a/prisma/migrations/20260616181835_new_role_added/migration.sql b/prisma/migrations/20260616181835_new_role_added/migration.sql new file mode 100644 index 0000000..1e2772a --- /dev/null +++ b/prisma/migrations/20260616181835_new_role_added/migration.sql @@ -0,0 +1,24 @@ +/* + Warnings: + + - You are about to drop the column `isManager` on the `Member` table. All the data in the column will be lost. + +*/ +-- 1. Create the Role enum +CREATE TYPE "Role" AS ENUM ('SUPER_ADMIN', 'ADMIN', 'FOUNDER', 'MEMBER'); + +-- 2. Add the new role column (nullable initially, no default yet) +ALTER TABLE "Member" ADD COLUMN "role" "Role"; + +-- 3. Backfill: convert isManager → role +UPDATE "Member" SET "role" = CASE + WHEN "isManager" = true THEN 'ADMIN'::"Role" + ELSE 'MEMBER'::"Role" +END; + +-- 4. Now make it NOT NULL with a default +ALTER TABLE "Member" ALTER COLUMN "role" SET NOT NULL; +ALTER TABLE "Member" ALTER COLUMN "role" SET DEFAULT 'MEMBER'::"Role"; + +-- 5. Drop the old column +ALTER TABLE "Member" DROP COLUMN "isManager"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 577e789..934f560 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -27,7 +27,7 @@ model Member { codeforces String? passoutYear DateTime? isApproved Boolean @default(false) - isManager Boolean @default(false) + role Role @default(MEMBER) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -171,6 +171,13 @@ enum Difficulty { Hard } +enum Role { + SUPER_ADMIN + ADMIN + FOUNDER + MEMBER +} + model InterviewExperience { id Int @id @default(autoincrement()) company String diff --git a/src/routes/members.ts b/src/routes/members.ts index 0b4b331..559e526 100644 --- a/src/routes/members.ts +++ b/src/routes/members.ts @@ -195,5 +195,28 @@ export default function membersRouter( */ router.get("/:memberId/interviews", memberCtrl.getUserInterviews); + /** + * @api {patch} /members/:memberId/role Update a member's role + * @apiName UpdateMemberRole + * @apiGroup Member + * + * @apiParam (URL Params) {String} memberId Target member's ID. + * @apiBody {String} adminId ID of the Super Admin performing the change. + * @apiBody {String="SUPER_ADMIN","ADMIN","FOUNDER","MEMBER"} role New role to assign. + * + * @apiSuccess {Boolean} success Request status. + * @apiSuccess {Object} user Updated member object. + * @apiSuccess {String} message Confirmation message. + * + * @apiError (Error 400) BadRequest Missing required fields or invalid role. + * @apiError (Error 403) Forbidden Only Super Admins can assign roles. + * + * @apiExample {curl} Example usage: + * curl -X PATCH http://localhost:3000/members/123/role \ + * -H "Content-Type: application/json" \ + * -d '{"adminId": "superadmin-id", "role": "ADMIN"}' + */ + router.patch("/:memberId/role", memberCtrl.updateMemberRole); + return router; } diff --git a/src/services/member.service.ts b/src/services/member.service.ts index 804a175..4553988 100644 --- a/src/services/member.service.ts +++ b/src/services/member.service.ts @@ -1,5 +1,6 @@ import prisma from "../db/client"; import { ApiError } from "../utils/apiError"; +import { Role } from "../generated/prisma/client"; export const getUserByEmail = async(email: string) => { return await prisma.member.findUnique({ @@ -9,7 +10,7 @@ export const getUserByEmail = async(email: string) => { select: { id: true, isApproved: true, - isManager: true, + role: true, accounts: { select: { password: true @@ -143,4 +144,30 @@ export const getInterviews = async (id: string) => { return await prisma.interviewExperience.findMany({ where: { memberId: id }, }); +}; + +export const updateMemberRole = async ( + superAdminId: string, + memberId: string, + newRole: Role, +) => { + // Verify the requester is a SUPER_ADMIN + const requester = await prisma.member.findUnique({ + where: { id: superAdminId }, + select: { role: true }, + }); + + if (!requester || requester.role !== Role.SUPER_ADMIN) { + throw new ApiError("Forbidden: only Super Admins can assign roles", 403); + } + + // Prevent modifying own role + if (superAdminId === memberId) { + throw new ApiError("Cannot modify your own role", 400); + } + + return await prisma.member.update({ + where: { id: memberId }, + data: { role: newRole }, + }); }; \ No newline at end of file diff --git a/src/services/site-content.service.ts b/src/services/site-content.service.ts index f704525..37c2119 100644 --- a/src/services/site-content.service.ts +++ b/src/services/site-content.service.ts @@ -1,7 +1,7 @@ import { v4 as uuidv4 } from "uuid"; import prisma from "../db/client"; import { ApiError } from "../utils/apiError"; -import { Prisma } from "../generated/prisma/client"; +import { Prisma, Role } from "../generated/prisma/client"; const PAGE_CONTENT_ID = 1; @@ -9,14 +9,16 @@ function toGalleryJson(gallery: GalleryPhoto[]): Prisma.InputJsonValue { return gallery as unknown as Prisma.InputJsonValue; } -async function assertManager(adminId: string) { +const ADMIN_ROLES: Role[] = [Role.SUPER_ADMIN, Role.ADMIN]; + +async function assertAdmin(adminId: string) { const member = await prisma.member.findUnique({ where: { id: adminId }, - select: { isManager: true }, + select: { role: true }, }); - if (!member?.isManager) { - throw new ApiError("Forbidden: manager access required", 403); + if (!member || !ADMIN_ROLES.includes(member.role)) { + throw new ApiError("Forbidden: admin access required", 403); } } @@ -92,7 +94,7 @@ export async function updateSitePageContent( adminId: string, data: Omit, ) { - await assertManager(adminId); + await assertAdmin(adminId); const page = await prisma.sitePageContent.update({ where: { id: PAGE_CONTENT_ID }, @@ -113,7 +115,7 @@ export async function updateSiteAction( key: string, data: Omit, ) { - await assertManager(adminId); + await assertAdmin(adminId); const current = await prisma.siteAction.findUnique({ where: { key } }); if (!current) { @@ -144,7 +146,7 @@ export async function addGalleryPhoto( adminId: string, data: Omit, ) { - await assertManager(adminId); + await assertAdmin(adminId); const current = await getPageContent(); const gallery = parseGalleryPhotos(current.galleryPhotos); @@ -177,7 +179,7 @@ export async function updateGalleryPhoto( photoId: string, data: Omit, ) { - await assertManager(adminId); + await assertAdmin(adminId); const current = await getPageContent(); const gallery = parseGalleryPhotos(current.galleryPhotos); @@ -218,7 +220,7 @@ export async function updateGalleryPhoto( } export async function deleteGalleryPhoto(adminId: string, photoId: string) { - await assertManager(adminId); + await assertAdmin(adminId); const current = await getPageContent(); const gallery = parseGalleryPhotos(current.galleryPhotos); @@ -240,7 +242,7 @@ export async function deleteGalleryPhoto(adminId: string, photoId: string) { } export async function getGalleryPhoto(adminId: string, photoId: string) { - await assertManager(adminId); + await assertAdmin(adminId); const current = await getPageContent(); const gallery = parseGalleryPhotos(current.galleryPhotos); diff --git a/tests/Member.test.ts b/tests/Member.test.ts index d80f545..f244af6 100644 --- a/tests/Member.test.ts +++ b/tests/Member.test.ts @@ -4,8 +4,11 @@ import * as memberService from '../src/services/member.service'; import { ApiError } from '../src/utils/apiError'; import { SupabaseClient } from '@supabase/supabase-js'; import { uploadImage } from '../src/utils/imageUtils'; +import { Role } from '../src/db/client'; + jest.mock('../src/db/client', () => ({ + ...jest.requireActual('../src/db/client'), prisma: { member: { findUnique: jest.fn(), @@ -83,6 +86,7 @@ describe('Member Controller - updateAMember', () => { const res = mockResponse(); + const role = Role.MEMBER const updatedMember = { id: '123', name: 'Test User', @@ -100,7 +104,7 @@ describe('Member Controller - updateAMember', () => { gfg: null, geeksforgeeks: null, passoutYear: new Date('2025-05-31'), - isManager: false, + role: role, isApproved: false, approvedById: null, createdAt: new Date(), @@ -131,6 +135,7 @@ describe('Member Controller - updateAMember', () => { const res = mockResponse(); + const role = Role.MEMBER const oldMember = { id: '123', name: 'Old User', @@ -148,7 +153,7 @@ describe('Member Controller - updateAMember', () => { gfg: null, geeksforgeeks: null, passoutYear: new Date('2025-05-31'), - isManager: false, + role: role, isApproved: false, approvedById: null, createdAt: new Date(), @@ -200,6 +205,7 @@ describe('Member Controller - updateAMember', () => { const res = mockResponse(); + const role:Role = Role.MEMBER const updatedMember = { id: '123', name: 'Updated User', @@ -217,7 +223,7 @@ describe('Member Controller - updateAMember', () => { gfg: null, geeksforgeeks: null, passoutYear: new Date('2025-05-31'), - isManager: false, + role: role, isApproved: false, approvedById: null, createdAt: new Date(), diff --git a/tests/SiteContent.test.ts b/tests/SiteContent.test.ts index fc26ebe..bb48fe0 100644 --- a/tests/SiteContent.test.ts +++ b/tests/SiteContent.test.ts @@ -266,7 +266,7 @@ describe("site-content service guards", () => { const prisma = (await import("../src/db/client")).default; (prisma.member.findUnique as jest.Mock).mockResolvedValue({ - isManager: false, + role: 'MEMBER', }); await expect( @@ -274,14 +274,14 @@ describe("site-content service guards", () => { isVisible: true, url: "https://example.com", }), - ).rejects.toThrow(new ApiError("Forbidden: manager access required", 403)); + ).rejects.toThrow(new ApiError("Forbidden: admin access required", 403)); }); it("should reject visible action without URL", async () => { const prisma = (await import("../src/db/client")).default; (prisma.member.findUnique as jest.Mock).mockResolvedValue({ - isManager: true, + role: 'ADMIN', }); (prisma.siteAction.findUnique as jest.Mock).mockResolvedValue({ key: "recruitment", diff --git a/tsconfig.json b/tsconfig.json index 39fca51..45713f1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -112,7 +112,7 @@ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ "skipLibCheck": true /* Skip type checking all .d.ts files. */ }, - "include": ["src/**/*" ], // Include your source files + "include": ["src/**/*", "tests/**/*"], // Include your source files "exclude": ["node_modules"] // Exclude test files from compilation }