diff --git a/api/src/index.ts b/api/src/index.ts index f6767e2..ec15ccf 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -103,7 +103,7 @@ import { roles, memberRoles } from './routes/roles'; import { courseRouter, eventRouter } from './routes/course'; import { calendarRouter } from './routes/calendar'; import { docsRouter } from './routes/docs'; -import { units } from './routes/units'; +import { memberUnits, units } from './routes/units'; app.use('/application', applicationRouter); app.use('/ranks', ranks); @@ -118,6 +118,7 @@ app.use('/course', courseRouter) app.use('/courseEvent', eventRouter) app.use('/calendar', calendarRouter) app.use('/units', units) +app.use('/memberUnits', memberUnits); app.use('/docs', docsRouter) app.use('/', authRouter) diff --git a/api/src/routes/units.ts b/api/src/routes/units.ts index e339a8a..081e32b 100644 --- a/api/src/routes/units.ts +++ b/api/src/routes/units.ts @@ -1,10 +1,16 @@ import express = require('express'); const unitsRouter = express.Router(); +const memberUnitsRouter = express.Router(); + +import { Request, Response } from 'express'; import pool from '../db'; -import { requireLogin } from '../middleware/auth'; +import { requireLogin, requireMemberState, requireRole } from '../middleware/auth'; import { logger } from '../services/logging/logger'; import { Unit } from '@app/shared/types/units'; +import { MemberState } from '@app/shared/types/member'; +import { assignNewUnit } from '../services/db/unitService'; +import { audit } from '../services/logging/auditLog'; unitsRouter.use(requireLogin); @@ -26,4 +32,38 @@ unitsRouter.get('/', async (req, res) => { } }); +memberUnitsRouter.post('/admin', [requireMemberState(MemberState.Member), requireRole("17th Administrator")], async (req: Request, res: Response) => { + const memberId = Number(req.query.memberId); + const unitId = Number(req.query.unitId); + const reason = req.query.reason as string; + + try { + + if (!memberId || !unitId) { + return res.status(400).json({ error: 'memberId and unitId query parameters are required' }); + } + + await assignNewUnit(memberId, unitId, req.user.id, req.user.id, reason); + logger.info('app', 'Member force assigned unit', { + member: memberId, + unit: unitId, + caller: req.user.id, + }); + audit.member('update_unit', { actorId: req.user.id, targetId: memberId }, { unit: unitId, reason: reason }); + + res.sendStatus(200); + } catch (error) { + logger.error('app', 'Failed to force assign unit', { + member: memberId, + unit: unitId, + caller: req.user.id, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }) + + res.sendStatus(500); + } +}); + export const units = unitsRouter; +export const memberUnits = memberUnitsRouter; \ No newline at end of file diff --git a/api/src/services/db/unitService.ts b/api/src/services/db/unitService.ts index 9d1b1f0..d75b4a8 100644 --- a/api/src/services/db/unitService.ts +++ b/api/src/services/db/unitService.ts @@ -10,4 +10,13 @@ export async function cancelLatestUnit(userID: number, con: mariadb.Pool | maria } catch (error) { throw error; } +} + +export async function assignNewUnit(memberID: number, unitID: number, authorizedID: number, creatorID: number, reason: string) { + let sql = `CALL sp_update_member_unit(?, ?, ?, ?, ?, NOW())`; + + const result = await pool.query(sql, [memberID, unitID, authorizedID, creatorID, reason]); + if (!result || result.affectedRows === 0) { + throw new Error('Record was not updated'); + } } \ No newline at end of file diff --git a/api/src/services/logging/auditLog.ts b/api/src/services/logging/auditLog.ts index fded7ca..5474b7e 100644 --- a/api/src/services/logging/auditLog.ts +++ b/api/src/services/logging/auditLog.ts @@ -32,8 +32,8 @@ class AuditLogger { logger.error('audit', `AUDIT_FAILURE: Failed to log ${actionType}`, { error: err }); } } - - member(action: 'update_rank' | 'suspension_added' | 'suspension_removed' | 'discharged', context: AuditContext, data: any = {}) { + + member(action: 'update_rank'| 'update_unit' | 'suspension_added' | 'suspension_removed' | 'discharged', context: AuditContext, data: any = {}) { return this.record('member', action, context, data); } diff --git a/ui/src/api/units.ts b/ui/src/api/units.ts index 84b0d8c..251ee4f 100644 --- a/ui/src/api/units.ts +++ b/ui/src/api/units.ts @@ -12,4 +12,15 @@ export async function getUnits(): Promise { throw new Error("Failed to fetch units"); } return response.json(); +} + +export async function adminAssignUnit(member: number, unit: number, reason: string) { + const response = await fetch(`${addr}/memberUnits/admin?memberId=${member}&unitId=${unit}&reason=${encodeURIComponent(reason)}`, { + method: 'POST', + credentials: 'include' + }); + if (!response.ok) { + throw new Error("Failed to assign unit"); + } + return; } \ No newline at end of file diff --git a/ui/src/components/members/TransferMember.vue b/ui/src/components/members/TransferMember.vue new file mode 100644 index 0000000..f67cabe --- /dev/null +++ b/ui/src/components/members/TransferMember.vue @@ -0,0 +1,194 @@ + + + \ No newline at end of file diff --git a/ui/src/pages/memberList.vue b/ui/src/pages/memberList.vue index 560fb00..afc1dfd 100644 --- a/ui/src/pages/memberList.vue +++ b/ui/src/pages/memberList.vue @@ -34,6 +34,7 @@ import MemberCard from "@/components/members/MemberCard.vue"; import { useMemberDirectory } from "@/stores/memberDirectory"; import { Discharge } from "@shared/schemas/dischargeSchema"; + import TransferMember from "@/components/members/TransferMember.vue"; // --- State --- const router = useRouter(); @@ -141,13 +142,19 @@ //discharge form logic const isDischargeOpen = ref(false) - const targetMember = ref(null) + const isTransferOpen = ref(false) + const targetMember = ref(null) function openDischargeModal(member: Member) { targetMember.value = member isDischargeOpen.value = true } + function openTransferModal(member: Member) { + targetMember.value = member + isTransferOpen.value = true + } + async function onSuspend(member: Member) { await suspendMember(member.member_id); await fetchMembers(); @@ -166,12 +173,19 @@ fetchMembers(); memberCache.invalidateMember(value.data.userID); } + + function handleTransferSuccess(value: { memberId: number; unitId: number; reason: string }) { + fetchMembers(); + memberCache.invalidateMember(value.memberId); + }