diff --git a/prisma/migrations/20260616190854_add_is_ghosted/migration.sql b/prisma/migrations/20260616190854_add_is_ghosted/migration.sql new file mode 100644 index 0000000..f919c4a --- /dev/null +++ b/prisma/migrations/20260616190854_add_is_ghosted/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 934f560..0d277c4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 @@ -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[] diff --git a/src/app.ts b/src/app.ts index 8f535ad..c9dab81 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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(); diff --git a/src/controllers/member.controller.ts b/src/controllers/member.controller.ts index 74e8cf6..19ee9e9 100644 --- a/src/controllers/member.controller.ts +++ b/src/controllers/member.controller.ts @@ -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); + + 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}`, + }); +}; + +// 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 }); +}; diff --git a/src/routes/members.ts b/src/routes/members.ts index 559e526..3084d72 100644 --- a/src/routes/members.ts +++ b/src/routes/members.ts @@ -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 @@ -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 diff --git a/src/services/member.service.ts b/src/services/member.service.ts index 4553988..6bd1466 100644 --- a/src/services/member.service.ts +++ b/src/services/member.service.ts @@ -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: { @@ -24,6 +26,7 @@ export const approvedMembers = async () => { return await prisma.member.findMany({ where: { isApproved: true, + isGhosted: false, }, }); }; @@ -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 }, }); }; @@ -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 }, + }, + }); +}; + +/** + * 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 }, + }, + }, + }); }; \ No newline at end of file diff --git a/tests/Member.test.ts b/tests/Member.test.ts index f244af6..9efedbc 100644 --- a/tests/Member.test.ts +++ b/tests/Member.test.ts @@ -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'; @@ -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); @@ -158,6 +161,9 @@ describe('Member Controller - updateAMember', () => { approvedById: null, createdAt: new Date(), updatedAt: new Date(), + isGhosted: false, + ghostedById: null, + ghostedAt: null, }; const updatedMember = { @@ -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 @@ -246,3 +255,140 @@ describe('Member Controller - updateAMember', () => { }); }); }); + +// Dead Zone (Ghost) feature +const makeBaseMember = (overrides: Record = {}) => ({ + 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); + + await getDeadZoneMembers(req, res); + + expect(memberService.deadZoneMembers).toHaveBeenCalledTimes(1); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ success: true, members: ghostedList }); + }); +}); +