Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions prisma/migrations/20260616181835_new_role_added/migration.sql
Original file line number Diff line number Diff line change
@@ -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";
9 changes: 8 additions & 1 deletion prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -171,6 +171,13 @@ enum Difficulty {
Hard
}

enum Role {
SUPER_ADMIN
ADMIN
FOUNDER
MEMBER
}

model InterviewExperience {
id Int @id @default(autoincrement())
company String
Expand Down
29 changes: 27 additions & 2 deletions src/controllers/member.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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}`,
});
};
23 changes: 23 additions & 0 deletions src/routes/members.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
29 changes: 28 additions & 1 deletion src/services/member.service.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -9,7 +10,7 @@ export const getUserByEmail = async(email: string) => {
select: {
id: true,
isApproved: true,
isManager: true,
role: true,
accounts: {
select: {
password: true
Expand Down Expand Up @@ -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 },
});
};
24 changes: 13 additions & 11 deletions src/services/site-content.service.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
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;

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);
}
}

Expand Down Expand Up @@ -92,7 +94,7 @@ export async function updateSitePageContent(
adminId: string,
data: Omit<UpdateSitePageContentInput, "updatedById">,
) {
await assertManager(adminId);
await assertAdmin(adminId);

const page = await prisma.sitePageContent.update({
where: { id: PAGE_CONTENT_ID },
Expand All @@ -113,7 +115,7 @@ export async function updateSiteAction(
key: string,
data: Omit<UpdateSiteActionInput, "updatedById">,
) {
await assertManager(adminId);
await assertAdmin(adminId);

const current = await prisma.siteAction.findUnique({ where: { key } });
if (!current) {
Expand Down Expand Up @@ -144,7 +146,7 @@ export async function addGalleryPhoto(
adminId: string,
data: Omit<CreateGalleryPhotoInput, "createdById">,
) {
await assertManager(adminId);
await assertAdmin(adminId);

const current = await getPageContent();
const gallery = parseGalleryPhotos(current.galleryPhotos);
Expand Down Expand Up @@ -177,7 +179,7 @@ export async function updateGalleryPhoto(
photoId: string,
data: Omit<UpdateGalleryPhotoInput, "updatedById">,
) {
await assertManager(adminId);
await assertAdmin(adminId);

const current = await getPageContent();
const gallery = parseGalleryPhotos(current.galleryPhotos);
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
12 changes: 9 additions & 3 deletions tests/Member.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -83,6 +86,7 @@ describe('Member Controller - updateAMember', () => {

const res = mockResponse();

const role = Role.MEMBER
const updatedMember = {
id: '123',
name: 'Test User',
Expand All @@ -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(),
Expand Down Expand Up @@ -131,6 +135,7 @@ describe('Member Controller - updateAMember', () => {

const res = mockResponse();

const role = Role.MEMBER
const oldMember = {
id: '123',
name: 'Old User',
Expand All @@ -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(),
Expand Down Expand Up @@ -200,6 +205,7 @@ describe('Member Controller - updateAMember', () => {

const res = mockResponse();

const role:Role = Role.MEMBER
const updatedMember = {
id: '123',
name: 'Updated User',
Expand All @@ -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(),
Expand Down
6 changes: 3 additions & 3 deletions tests/SiteContent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,22 +266,22 @@ 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(
siteContentService.updateSiteAction("user-1", "recruitment", {
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",
Expand Down
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Loading