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
6 changes: 6 additions & 0 deletions prisma/migrations/20260616190854_add_is_ghosted/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "Member" ADD COLUMN "ghostedById" TEXT,
ADD COLUMN "isGhosted" BOOLEAN NOT NULL DEFAULT false;

-- AddForeignKey
ALTER TABLE "Member" ADD CONSTRAINT "Member_ghostedById_fkey" FOREIGN KEY ("ghostedById") REFERENCES "Member"("id") ON DELETE SET NULL ON UPDATE CASCADE;
6 changes: 6 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ model Member {
codeforces String?
passoutYear DateTime?
isApproved Boolean @default(false)
isGhosted Boolean @default(false)
role Role @default(MEMBER)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
Expand All @@ -36,6 +37,11 @@ model Member {
approvedById String?
approvedMembers Member[] @relation("MemberApprovals")

// Ghost / Dead Zone audit (self-reference)
ghostedBy Member? @relation("MemberGhosts", fields: [ghostedById], references: [id])
ghostedById String?
ghostedMembers Member[] @relation("MemberGhosts")

// Authentication & Relations
accounts Account[]
achievements MemberAchievement[]
Expand Down
1 change: 0 additions & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { errorHandler } from "./utils/apiError";
import path from "path";
import { logger } from "./utils/logger";
import morgan from "morgan";
import { supabase } from "./utils/supabaseClient";
import config from "./config";

const app = express();
Expand Down
30 changes: 30 additions & 0 deletions src/controllers/member.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,3 +181,33 @@ export const updateMemberRole = async (req: Request, res: Response) => {
message: `Role updated to ${role}`,
});
};

// Ghost or unghost a member (Dead Zone) — ADMIN & SUPER_ADMIN only
export const ghostMember = async (req: Request, res: Response) => {
const { memberId } = req.params;
const { adminId, ghost = true } = req.body;

if (!memberId || !adminId) {
throw new ApiError("memberId and adminId are required", 400);
}

if (typeof ghost !== "boolean") {
throw new ApiError('"ghost" must be a boolean', 400);
}

const updated = await memberService.ghostMember(adminId, memberId, ghost);

Comment thread
Harish-Naruto marked this conversation as resolved.
const action = ghost ? "ghosted" : "unghosted";
const movement = ghost ? "moved to Dead Zone" : "restored from Dead Zone";
res.status(200).json({
success: true,
user: updated,
message: `Member ${action} and ${movement}`,
});
Comment thread
Harish-Naruto marked this conversation as resolved.
};

// Get all ghosted members (Dead Zone audit list) — ADMIN & SUPER_ADMIN only
export const getDeadZoneMembers = async (req: Request, res: Response) => {
const members = await memberService.deadZoneMembers();
res.status(200).json({ success: true, members });
};
49 changes: 49 additions & 0 deletions src/routes/members.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,22 @@ export default function membersRouter(
* curl -X GET http://localhost:3000/members/unapproved
*/
router.get("/unapproved", memberCtrl.getUnapprovedMembers);

/**
* @api {get} /members/dead-zone List all ghosted members (Dead Zone)
* @apiName GetDeadZoneMembers
* @apiGroup Member
*
* @apiDescription Returns all member records that have been ghosted by an admin.
* These members are hidden from the public approved list and the pending
* approval queue. Data is preserved for audit purposes.
*
* @apiSuccess {Object[]} members List of ghosted member objects (includes ghostedBy admin info).
*
* @apiExample {curl} Example usage:
* curl -X GET http://localhost:3000/members/dead-zone
*/
router.get("/dead-zone", memberCtrl.getDeadZoneMembers);

/**
* @api {get} /members/:memberId Get a member's details
Expand Down Expand Up @@ -152,6 +168,39 @@ export default function membersRouter(
*/
router.patch("/approve/:memberId", memberCtrl.updateRequest);

/**
* @api {patch} /members/ghost/:memberId Ghost or unghost a member (Dead Zone)
* @apiName GhostMember
* @apiGroup Member
*
* @apiDescription Moves a member into the Dead Zone by setting isGhosted=true.
* Ghosted members are silently removed from the pending approval queue and
* the public member listing without hard-deleting their data.
* Setting ghost=false restores the member back to the active queue.
*
* @apiParam (URL Params) {String} memberId Target member's ID.
* @apiBody {String} adminId ID of the Admin or Super Admin performing the action.
* @apiBody {Boolean} [ghost=true] true to ghost, false to unghost.
*
* @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 ghost value.
* @apiError (Error 403) Forbidden Only Admins and Super Admins can ghost members.
*
* @apiExample {curl} Ghost a member:
* curl -X PATCH http://localhost:3000/members/ghost/123 \
* -H "Content-Type: application/json" \
* -d '{"adminId": "admin-id", "ghost": true}'
*
* @apiExample {curl} Unghost a member:
* curl -X PATCH http://localhost:3000/members/ghost/123 \
* -H "Content-Type: application/json" \
* -d '{"adminId": "admin-id", "ghost": false}'
*/
router.patch("/ghost/:memberId", memberCtrl.ghostMember);


/**
* @api {get} /members/:memberId/achievements Get member's achievements
Expand Down
60 changes: 59 additions & 1 deletion src/services/member.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import prisma from "../db/client";
import { ApiError } from "../utils/apiError";
import { Role } from "../generated/prisma/client";

const GHOST_ALLOWED_ROLES: Role[] = [Role.ADMIN, Role.SUPER_ADMIN];

export const getUserByEmail = async(email: string) => {
return await prisma.member.findUnique({
where: {
Expand All @@ -24,6 +26,7 @@ export const approvedMembers = async () => {
return await prisma.member.findMany({
where: {
isApproved: true,
isGhosted: false,
},
});
};
Expand Down Expand Up @@ -98,7 +101,7 @@ export const updatePassword = async(id: string, password: string) => {

export const unapprovedMembers = async () => {
return await prisma.member.findMany({
where: { isApproved: false },
where: { isApproved: false, isGhosted: false },
});
};

Expand Down Expand Up @@ -170,4 +173,59 @@ export const updateMemberRole = async (
where: { id: memberId },
data: { role: newRole },
});
};

/**
* Ghost or unghost a member request (Dead Zone).
* Only ADMIN and SUPER_ADMIN are allowed to perform this action.
*/
export const ghostMember = async (
adminId: string,
memberId: string,
ghost: boolean,
) => {
// Verify requester exists and has the right role
const requester = await prisma.member.findUnique({
where: { id: adminId },
select: { role: true },
});

if (!requester || !GHOST_ALLOWED_ROLES.includes(requester.role)) {
throw new ApiError("Forbidden: only Admins and Super Admins can ghost members", 403);
}

// Prevent self-ghosting
if (adminId === memberId) {
throw new ApiError("Cannot ghost yourself", 400);
}

const target = await prisma.member.findUnique({
where: { id: memberId },
select: { id: true },
});
if (!target) {
throw new ApiError("Member not found", 404);
}

return await prisma.member.update({
where: { id: memberId },
data: {
isGhosted: ghost,
ghostedBy: ghost ? { connect: { id: adminId } } : { disconnect: true },
},
});
Comment thread
Harish-Naruto marked this conversation as resolved.
};

/**
* Retrieve all members currently in the Dead Zone (ghosted).
*/
export const deadZoneMembers = async () => {
return await prisma.member.findMany({
where: { isGhosted: true },
include: {
ghostedBy: {
select: { id: true, name: true, email: true, role: true },
},
},
});
};
Comment thread
Harish-Naruto marked this conversation as resolved.
148 changes: 147 additions & 1 deletion tests/Member.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Request, Response } from 'express';
import { createAMember, updateAMember } from '../src/controllers/member.controller';
import { createAMember, updateAMember, ghostMember, getDeadZoneMembers } from '../src/controllers/member.controller';
import * as memberService from '../src/services/member.service';
import { ApiError } from '../src/utils/apiError';
import { SupabaseClient } from '@supabase/supabase-js';
Expand Down Expand Up @@ -109,6 +109,9 @@ describe('Member Controller - updateAMember', () => {
approvedById: null,
createdAt: new Date(),
updatedAt: new Date(),
isGhosted: false,
ghostedById: null,
ghostedAt: null,
};

jest.spyOn(memberService, 'updateMember').mockResolvedValue(updatedMember);
Expand Down Expand Up @@ -158,6 +161,9 @@ describe('Member Controller - updateAMember', () => {
approvedById: null,
createdAt: new Date(),
updatedAt: new Date(),
isGhosted: false,
ghostedById: null,
ghostedAt: null,
};

const updatedMember = {
Expand Down Expand Up @@ -228,6 +234,9 @@ describe('Member Controller - updateAMember', () => {
approvedById: null,
createdAt: new Date(),
updatedAt: new Date(),
isGhosted: false,
ghostedById: null,
ghostedAt: null,
};

// Mock updatePassword and getDetails
Expand All @@ -246,3 +255,140 @@ describe('Member Controller - updateAMember', () => {
});
});
});

// Dead Zone (Ghost) feature
const makeBaseMember = (overrides: Record<string, unknown> = {}) => ({
id: 'member-123',
name: 'Test User',
email: 'test@example.com',
birth_date: null,
phone: null,
bio: null,
profilePhoto: null,
github: null,
linkedin: null,
twitter: null,
leetcode: null,
codeforces: null,
codechef: null,
gfg: null,
geeksforgeeks: null,
passoutYear: new Date('2025-05-31'),
role: Role.MEMBER,
isApproved: false,
isGhosted: false,
approvedById: null,
ghostedById: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
});

describe('Member Controller - ghostMember', () => {
beforeEach(() => jest.clearAllMocks());

it('should ghost a member and return 200', async () => {
const req = {
params: { memberId: 'member-123' },
body: { adminId: 'admin-456', ghost: true },
} as unknown as Request;
const res = mockResponse();

const ghosted = makeBaseMember({ isGhosted: true, ghostedById: 'admin-456' });
jest.spyOn(memberService, 'ghostMember').mockResolvedValue(ghosted);

await ghostMember(req, res);

expect(memberService.ghostMember).toHaveBeenCalledWith('admin-456', 'member-123', true);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({
success: true,
user: ghosted,
message: 'Member ghosted and moved to Dead Zone',
});
});

it('should unghost a member when ghost=false', async () => {
const req = {
params: { memberId: 'member-123' },
body: { adminId: 'admin-456', ghost: false },
} as unknown as Request;
const res = mockResponse();

const restored = makeBaseMember({ isGhosted: false, ghostedById: null });
jest.spyOn(memberService, 'ghostMember').mockResolvedValue(restored);

await ghostMember(req, res);

expect(memberService.ghostMember).toHaveBeenCalledWith('admin-456', 'member-123', false);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({
success: true,
user: restored,
message: 'Member unghosted and restored from Dead Zone',
});
});

it('should throw 400 if memberId or adminId is missing', async () => {
const req = {
params: { memberId: 'member-123' },
body: {},
} as unknown as Request;
const res = mockResponse();

await expect(ghostMember(req, res)).rejects.toThrow(
new ApiError('memberId and adminId are required', 400),
);
});

it('should throw 400 if ghost is not a boolean', async () => {
const req = {
params: { memberId: 'member-123' },
body: { adminId: 'admin-456', ghost: 'yes' },
} as unknown as Request;
const res = mockResponse();

await expect(ghostMember(req, res)).rejects.toThrow(
new ApiError('"ghost" must be a boolean', 400),
);
});

it('should propagate 403 when service rejects non-admin requester', async () => {
const req = {
params: { memberId: 'member-123' },
body: { adminId: 'non-admin-id', ghost: true },
} as unknown as Request;
const res = mockResponse();

jest.spyOn(memberService, 'ghostMember').mockRejectedValue(
new ApiError('Forbidden: only Admins and Super Admins can ghost members', 403),
);

await expect(ghostMember(req, res)).rejects.toThrow(
new ApiError('Forbidden: only Admins and Super Admins can ghost members', 403),
);
});
});

describe('Member Controller - getDeadZoneMembers', () => {
beforeEach(() => jest.clearAllMocks());

it('should return all ghosted members with 200', async () => {
const req = {} as Request;
const res = mockResponse();

const ghostedList = [
makeBaseMember({ isGhosted: true, ghostedById: 'admin-456' }),
makeBaseMember({ id: 'member-789', isGhosted: true, ghostedById: 'admin-456' }),
];

jest.spyOn(memberService, 'deadZoneMembers').mockResolvedValue(ghostedList as any);
Comment thread
Harish-Naruto marked this conversation as resolved.

await getDeadZoneMembers(req, res);

expect(memberService.deadZoneMembers).toHaveBeenCalledTimes(1);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({ success: true, members: ghostedList });
});
});

Loading