From 62573f74e64d9fa2dc313be04639f5bacdf69545 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Sat, 3 Oct 2026 03:28:58 -0400 Subject: [PATCH] feat(promotions): add promotion nominations and leadership transfers - Promotion nominations: members can nominate a peer for promotion from their profile, and a new /personnel/promotions page lets staff review and action the nominations (with an optional note on each). - Leadership transfers: a member's leadership can be handed over to another member, surfaced on the transfers page and in the roster (which also gains a staff remove-member dialog). - Adds the PromotionNominations and LeadershipTransfers collections (registered in the Payload config), their service libs, the migration, and integration coverage. --- .../personnel/promotions/actions.ts | 105 + .../(frontend)/personnel/promotions/page.tsx | 131 + .../(frontend)/profile/[username]/actions.ts | 44 + .../(frontend)/profile/[username]/page.tsx | 31 + src/app/(frontend)/roster/actions.ts | 94 + src/app/(frontend)/roster/page.tsx | 15 +- src/collections/users/LeadershipTransfers.ts | 168 + src/collections/users/PromotionNominations.ts | 200 + src/components/frontend/blocks/sidebarData.ts | 6 + .../profile/NominateForPromotionButton.tsx | 150 + .../frontend/promotions/NominationCard.tsx | 265 + .../promotions/NominationNoteDialog.tsx | 139 + .../frontend/promotions/PromotionsView.tsx | 142 + .../frontend/promotions/nominationTypes.ts | 63 + .../frontend/roster/RemoveMemberDialog.tsx | 147 + src/components/frontend/roster/RosterView.tsx | 197 +- .../transfers/LeadershipTransferCard.tsx | 132 + .../transfers/LeadershipTransfersSection.tsx | 287 + .../frontend/transfers/TransfersView.tsx | 26 +- src/lib/promotions/index.ts | 33 +- src/lib/promotions/nominations.ts | 510 + src/lib/transfers/index.ts | 80 +- src/lib/transfers/leadership.ts | 341 + ..._nominations_and_leadership_transfers.json | 35978 ++++++++++++++++ ...on_nominations_and_leadership_transfers.ts | 116 + src/payload.config.ts | 34 +- tests/int/leadership-transfers.int.spec.ts | 471 + tests/int/promotion-nominations.int.spec.ts | 599 + 28 files changed, 40393 insertions(+), 111 deletions(-) create mode 100644 src/app/(frontend)/personnel/promotions/actions.ts create mode 100644 src/app/(frontend)/personnel/promotions/page.tsx create mode 100644 src/app/(frontend)/roster/actions.ts create mode 100644 src/collections/users/LeadershipTransfers.ts create mode 100644 src/collections/users/PromotionNominations.ts create mode 100644 src/components/frontend/profile/NominateForPromotionButton.tsx create mode 100644 src/components/frontend/promotions/NominationCard.tsx create mode 100644 src/components/frontend/promotions/NominationNoteDialog.tsx create mode 100644 src/components/frontend/promotions/PromotionsView.tsx create mode 100644 src/components/frontend/promotions/nominationTypes.ts create mode 100644 src/components/frontend/roster/RemoveMemberDialog.tsx create mode 100644 src/components/frontend/transfers/LeadershipTransferCard.tsx create mode 100644 src/components/frontend/transfers/LeadershipTransfersSection.tsx create mode 100644 src/lib/promotions/nominations.ts create mode 100644 src/lib/transfers/leadership.ts create mode 100644 src/migrations/20261002_031917_add_promotion_nominations_and_leadership_transfers.json create mode 100644 src/migrations/20261002_031917_add_promotion_nominations_and_leadership_transfers.ts create mode 100644 tests/int/leadership-transfers.int.spec.ts create mode 100644 tests/int/promotion-nominations.int.spec.ts diff --git a/src/app/(frontend)/personnel/promotions/actions.ts b/src/app/(frontend)/personnel/promotions/actions.ts new file mode 100644 index 0000000..f33a23c --- /dev/null +++ b/src/app/(frontend)/personnel/promotions/actions.ts @@ -0,0 +1,105 @@ +"use server"; + +import config from "@payload-config"; +import { getPayload } from "payload"; +import { isPayloadUser } from "@/utils/access-control/isPayloadUser"; +import { + cancelNomination as cancelNominationCore, + recordLeaderDecision, + resolveNomination as resolveNominationCore, +} from "@/lib/promotions/nominations"; + +export interface ActionResult { + readonly success: boolean; + readonly error?: string; + readonly data?: T; +} + +async function authenticate() { + const payloadConfig = await config; + const payload = await getPayload({ config: payloadConfig }); + const { headers } = await import("next/headers"); + const hdrs = await headers(); + const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false }); + + if (!isPayloadUser(user)) { + throw new Error("Unauthorized"); + } + + return { payload, user }; +} + +function actionFailure(caughtError: unknown): ActionResult { + const message = caughtError instanceof Error ? caughtError.message : "Unknown error"; + return { + success: false, + error: message === "Unauthorized" ? "You must be logged in." : message, + }; +} + +/** + * Division leader stage: endorse a nomination that is awaiting endorsement for + * a member of one of the leader's divisions. The current-leader check lives in + * the service lib (re-fetched at call time). + */ +export async function endorseNomination( + nominationId: number, + note?: string, +): Promise { + try { + const { payload, user } = await authenticate(); + await recordLeaderDecision(payload, user, nominationId, { + endorse: true, + ...(note !== undefined ? { note } : {}), + }); + return { success: true }; + } catch (caughtError) { + return actionFailure(caughtError); + } +} + +/** Division leader stage: reject a pending nomination (terminal). */ +export async function rejectNomination(nominationId: number, note?: string): Promise { + try { + const { payload, user } = await authenticate(); + await recordLeaderDecision(payload, user, nominationId, { + endorse: false, + ...(note !== undefined ? { note } : {}), + }); + return { success: true }; + } catch (caughtError) { + return actionFailure(caughtError); + } +} + +/** + * Final superuser stage: approve (bumps the nominee exactly one rung + * transactionally) or reject the nomination. + */ +export async function resolveNomination( + nominationId: number, + approve: boolean, + note?: string, +): Promise { + try { + const { payload, user } = await authenticate(); + await resolveNominationCore(payload, user, nominationId, { + approve, + ...(note !== undefined ? { note } : {}), + }); + return { success: true }; + } catch (caughtError) { + return actionFailure(caughtError); + } +} + +/** Nominator stage: withdraw one of the caller's own pending nominations. */ +export async function cancelNomination(nominationId: number): Promise { + try { + const { payload, user } = await authenticate(); + await cancelNominationCore(payload, user, nominationId); + return { success: true }; + } catch (caughtError) { + return actionFailure(caughtError); + } +} diff --git a/src/app/(frontend)/personnel/promotions/page.tsx b/src/app/(frontend)/personnel/promotions/page.tsx new file mode 100644 index 0000000..509c718 --- /dev/null +++ b/src/app/(frontend)/personnel/promotions/page.tsx @@ -0,0 +1,131 @@ +import config from "@payload-config"; +import { headers as nextHeaders } from "next/headers"; +import { redirect } from "next/navigation"; +import { getPayload } from "payload"; +import { ArrowUpIcon } from "lucide-react"; +import { PromotionsView } from "@/components/frontend/promotions/PromotionsView"; +import type { NominationView } from "@/components/frontend/promotions/nominationTypes"; +import { isPayloadUser } from "@/utils/access-control/isPayloadUser"; +import { isSuperuser } from "@/utils/access-control/hasPermission"; +import type { Assignment, PromotionNomination, User } from "@/payload-types"; + +export const metadata = { + title: "Promotions: Polaris Task Force", +}; + +function userRefId(ref: number | User | null | undefined): number | null { + if (ref == null) return null; + return typeof ref === "number" ? ref : ref.id; +} + +function userRefLabel(ref: number | User | null | undefined): string | null { + if (ref == null || typeof ref === "number") return null; + return ref.displayName || ref.username; +} + +function toNominationView(doc: PromotionNomination): NominationView { + const nomineeId = userRefId(doc.nominee) ?? 0; + const nominatorId = userRefId(doc.nominator) ?? 0; + return { + id: doc.id, + nomineeId, + nomineeName: userRefLabel(doc.nominee) ?? `User #${nomineeId}`, + nominatorId, + nominatorName: userRefLabel(doc.nominator) ?? `User #${nominatorId}`, + accolades: doc.accolades, + status: doc.status, + leaderDecision: doc.leaderDecision ?? "pending", + leaderDecisionByName: userRefLabel(doc.leaderDecisionBy), + leaderNote: doc.leaderNote ?? null, + reviewNote: doc.reviewNote ?? null, + createdAt: doc.createdAt, + leaderDecidedAt: doc.leaderDecidedAt ?? null, + reviewedAt: doc.reviewedAt ?? null, + }; +} + +export default async function PromotionsPage() { + const payload = await getPayload({ config }); + const { user: authUser } = await payload.auth({ + headers: await nextHeaders(), + canSetHeaders: false, + }); + + if (!isPayloadUser(authUser)) { + redirect("/"); + } + const currentUserId = authUser.id; + const superuserViewer = await isSuperuser(payload, authUser); + + // Divisions the viewer leads: their members form the endorsement queue scope. + const divisionsLedRes = await payload.find({ + collection: "assignments", + where: { + and: [{ type: { equals: "division" } }, { leader: { equals: currentUserId } }], + }, + limit: 100, + depth: 0, + overrideAccess: true, + }); + const ledDivisionIds = divisionsLedRes.docs.map((doc: Assignment) => doc.id); + const ledMemberIds = new Set(); + for (const division of divisionsLedRes.docs) { + for (const member of division.members ?? []) { + const memberId = typeof member === "number" ? member : member.id; + if (memberId != null) ledMemberIds.add(memberId); + } + } + + const nominationsRes = await payload.find({ + collection: "promotion-nominations", + sort: "-createdAt", + limit: 100, + depth: 1, + overrideAccess: true, + }); + const nominations = nominationsRes.docs.map(toNominationView); + + const nomineeInLedDivision = (nomination: NominationView) => + ledMemberIds.has(nomination.nomineeId); + + const leaderQueue = nominations.filter( + (nomination) => + nomination.status === "pending" && + nomination.leaderDecision === "pending" && + nomineeInLedDivision(nomination), + ); + const finalQueue = superuserViewer + ? nominations.filter((nomination) => nomination.status === "awaiting_superuser") + : []; + const mySubmissions = nominations.filter( + (nomination) => nomination.nominatorId === currentUserId, + ); + const resolved = nominations.filter((nomination) => + ["approved", "rejected", "cancelled"].includes(nomination.status), + ); + const recentlyResolved = superuserViewer ? resolved : resolved.filter(nomineeInLedDivision); + + return ( +
+
+
+
+

+ Nominate members for promotion from their profile, endorse nominations for your division, + and handle final approvals. +

+
+ + 0} + leaderQueue={leaderQueue} + finalQueue={finalQueue} + mySubmissions={mySubmissions} + recentlyResolved={recentlyResolved} + /> +
+ ); +} diff --git a/src/app/(frontend)/profile/[username]/actions.ts b/src/app/(frontend)/profile/[username]/actions.ts index 786c923..efe4864 100644 --- a/src/app/(frontend)/profile/[username]/actions.ts +++ b/src/app/(frontend)/profile/[username]/actions.ts @@ -21,6 +21,10 @@ import { PERSONAL_EXCERPT_MAX_LENGTH, } from "@/lib/profile/excerpt-limits"; import { promoteMember as promoteMemberService } from "@/lib/promotions"; +import { + cancelNomination as cancelNominationCore, + submitNomination, +} from "@/lib/promotions/nominations"; interface ActionResult { success: boolean; @@ -290,3 +294,43 @@ export async function promoteMember( return { success: false, error: e instanceof Error ? e.message : "Unknown error" }; } } + +/** + * Submit a promotion nomination for the given member (or a self-request when + * the nominee is the authenticated user). The division leader endorsement + * stage and the open-nomination guards are enforced by the service lib. + */ +export async function submitPromotionNomination(input: { + nomineeUsername: string; + accolades: string; +}): Promise> { + try { + const { payload, user } = await authenticate(); + + const nominee = await findUserByUsername(payload, input.nomineeUsername); + if (!nominee) { + return { success: false, error: "User not found." }; + } + + const nomination = await submitNomination(payload, user, { + nomineeId: nominee.id, + accolades: input.accolades, + }); + return { success: true, data: { nominationId: nomination.id } }; + } catch (e) { + if (e instanceof Error && e.message === "Unauthorized") throw e; + return { success: false, error: e instanceof Error ? e.message : "Unknown error" }; + } +} + +/** Withdraw one of the caller's own pending promotion nominations. */ +export async function cancelMyNomination(nominationId: number): Promise { + try { + const { payload, user } = await authenticate(); + await cancelNominationCore(payload, user, nominationId); + return { success: true }; + } catch (e) { + if (e instanceof Error && e.message === "Unauthorized") throw e; + return { success: false, error: e instanceof Error ? e.message : "Unknown error" }; + } +} diff --git a/src/app/(frontend)/profile/[username]/page.tsx b/src/app/(frontend)/profile/[username]/page.tsx index dd38459..214f385 100644 --- a/src/app/(frontend)/profile/[username]/page.tsx +++ b/src/app/(frontend)/profile/[username]/page.tsx @@ -42,8 +42,10 @@ import { RibbonRack } from "@/components/frontend/account/RibbonRack"; import { LeadershipEvaluationSection } from "@/components/frontend/profile/LeadershipEvaluationSection"; import { DossierExcerptEditors } from "@/components/frontend/profile/DossierExcerptEditors"; import { PromoteButton } from "@/components/frontend/profile/PromoteButton"; +import { NominateForPromotionButton } from "@/components/frontend/profile/NominateForPromotionButton"; import { getLeadershipDisplayLabel } from "@/lib/evaluations/levels"; import { allowedPromotionTargets, resolvePromotionAuthority } from "@/lib/promotions"; +import { loadRankLadder, rankIndex } from "@/lib/promotions/authority"; import type { EvaluationMissionOption, EvaluationSummary, @@ -367,6 +369,27 @@ export default async function ProfilePage({ params }: ProfilePageProps) { const allowedTargetRanks = promotionResult ? allowedPromotionTargets(promotionResult, rank) : []; + // Nomination visibility: any logged-in viewer can nominate (or self-request) + // while the member has a next rung to climb and no nomination is open. + const nomineeLadder = promotionResult?.ladder ?? (viewer ? await loadRankLadder(payload) : []); + const currentRankIndex = rankIndex(nomineeLadder, rank.id); + const hasNextRung = currentRankIndex >= 0 && currentRankIndex + 1 < nomineeLadder.length; + const openNominationRes = viewer + ? await payload.find({ + collection: "promotion-nominations", + where: { + and: [ + { nominee: { equals: user.id } }, + { status: { in: ["pending", "awaiting_superuser"] } }, + ], + }, + limit: 1, + depth: 0, + overrideAccess: true, + }) + : null; + const canRequestPromotion = + viewer !== null && hasNextRung && (openNominationRes?.docs.length ?? 0) === 0; const awardEntries = (profile.progression?.awards ?? []).filter( (entry): entry is ProfileAwardEntry => typeof entry === "object" && entry !== null, ); @@ -621,6 +644,14 @@ export default async function ProfilePage({ params }: ProfilePageProps) { /> )} + {canRequestPromotion && ( + + )} + {/* ── Combat Record ──────────────────────────────────── */}
diff --git a/src/app/(frontend)/roster/actions.ts b/src/app/(frontend)/roster/actions.ts new file mode 100644 index 0000000..6819966 --- /dev/null +++ b/src/app/(frontend)/roster/actions.ts @@ -0,0 +1,94 @@ +"use server"; + +import config from "@payload-config"; +import { getPayload } from "payload"; +import type { LeadershipTransfer } from "@/payload-types"; +import { isPayloadUser } from "@/utils/access-control/isPayloadUser"; +import { + cancelLeadershipTransfer as cancelLeadershipTransferCore, + requestLeadershipTransfer as requestLeadershipTransferCore, + resolveLeadershipTransfer as resolveLeadershipTransferCore, +} from "@/lib/transfers/leadership"; + +export interface ActionResult { + readonly success: boolean; + readonly error?: string; + readonly data?: T; +} + +/** + * Web actions for leadership-initiated removals. The roster page's client + * components call requestDivisionRemoval/cancelDivisionRemoval; the transfers + * page's leadership section calls resolveLeadershipTransfer. The lib re-validates + * every guard (leader authority, pending-only transitions, superuser-only + * resolution), so these actions only authenticate and delegate. + */ + +async function authenticate() { + const payloadConfig = await config; + const payload = await getPayload({ config: payloadConfig }); + const { headers } = await import("next/headers"); + const hdrs = await headers(); + const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false }); + + if (!isPayloadUser(user)) { + throw new Error("Unauthorized"); + } + + return { payload, user }; +} + +function actionFailure(caughtError: unknown): ActionResult { + const message = caughtError instanceof Error ? caughtError.message : "Unknown error"; + return { + success: false, + error: message === "Unauthorized" ? "You must be logged in." : message, + }; +} + +export async function requestDivisionRemoval(input: { + readonly subjectId: number; + readonly fromAssignmentId: number; + readonly reason?: string; +}): Promise> { + try { + const { payload, user } = await authenticate(); + const transfer = await requestLeadershipTransferCore(payload, user, { + subjectId: input.subjectId, + fromAssignmentId: input.fromAssignmentId, + ...(input.reason !== undefined ? { reason: input.reason } : {}), + }); + return { success: true, data: transfer }; + } catch (caughtError) { + return actionFailure(caughtError); + } +} + +export async function cancelDivisionRemoval( + transferId: number, +): Promise> { + try { + const { payload, user } = await authenticate(); + const transfer = await cancelLeadershipTransferCore(payload, user, transferId); + return { success: true, data: transfer }; + } catch (caughtError) { + return actionFailure(caughtError); + } +} + +export async function resolveLeadershipTransfer( + transferId: number, + approve: boolean, + note?: string, +): Promise> { + try { + const { payload, user } = await authenticate(); + const transfer = await resolveLeadershipTransferCore(payload, user, transferId, { + approve, + ...(note !== undefined ? { note } : {}), + }); + return { success: true, data: transfer }; + } catch (caughtError) { + return actionFailure(caughtError); + } +} diff --git a/src/app/(frontend)/roster/page.tsx b/src/app/(frontend)/roster/page.tsx index 832ac6a..f5de591 100644 --- a/src/app/(frontend)/roster/page.tsx +++ b/src/app/(frontend)/roster/page.tsx @@ -40,7 +40,7 @@ export default async function RosterPage() { const { user: authUser } = await payload.auth({ headers, canSetHeaders: false }); const currentUser = isPayloadUser(authUser) ? authUser : null; - const [profileRes, assignmentRes] = await Promise.all([ + const [profileRes, assignmentRes, rules] = await Promise.all([ payload.find({ collection: "profiles", limit: 500, @@ -65,6 +65,7 @@ export default async function RosterPage() { subAssignments: true, }, }), + payload.findGlobal({ slug: "game-rules", depth: 0 }), ]); const profiles = profileRes.docs; @@ -132,6 +133,16 @@ export default async function RosterPage() { .filter(Boolean) as number[], })); + // The configured Infantry assignment (leadership-initiated removal + // destination), resolved to a display name when it exists. + const configuredInfantry = rules?.infantryAssignment ?? null; + const infantryId = + configuredInfantry != null && typeof configuredInfantry === "object" + ? configuredInfantry.id + : configuredInfantry; + const infantryName = + infantryId != null ? (assignments.find((a) => a.id === infantryId)?.name ?? null) : null; + return (
@@ -149,6 +160,8 @@ export default async function RosterPage() { members={members} assignments={assignments} leadOrActual={currentUser?.preferences?.display?.leadOrActual ?? "lead"} + currentUserId={currentUser?.id} + infantryName={infantryName} />
); diff --git a/src/collections/users/LeadershipTransfers.ts b/src/collections/users/LeadershipTransfers.ts new file mode 100644 index 0000000..41b0ce3 --- /dev/null +++ b/src/collections/users/LeadershipTransfers.ts @@ -0,0 +1,168 @@ +import { + CollectionConfig, + type CollectionBeforeChangeHook, +} from "payload"; +import type { User } from "@/payload-types"; +import hasRoles from "@/utils/access-control/hasRoles"; +import { isSuperuser } from "@/utils/access-control/hasPermission"; + +export const LEADERSHIP_TRANSFER_STATUSES = [ + "pending", + "approved", + "denied", + "cancelled", +] as const; +export type LeadershipTransferStatus = (typeof LEADERSHIP_TRANSFER_STATUSES)[number]; + +export const LEADERSHIP_TRANSFER_STATUS_TRANSITIONS: Record< + LeadershipTransferStatus, + LeadershipTransferStatus[] +> = { + pending: ["approved", "denied", "cancelled"], + approved: [], + denied: [], + cancelled: [], +}; + +const relationId = (value: unknown): number | null | undefined => + typeof value === "object" && value !== null ? (value as { id: number }).id : (value as number | null | undefined); + +/** + * A removal request always belongs to its initiator. REST/admin creates are forced + * to the authenticated caller; lib writes that run with overrideAccess and no + * req.user set initiator explicitly and skip this guard. + */ +const enforceInitiatorOnCreate: CollectionBeforeChangeHook = async ({ operation, data, req }) => { + if (operation !== "create") return data; + if (req.user) { + const initiatorId = relationId(data?.initiator); + if (initiatorId !== req.user.id) { + throw new Error("You can only submit a removal request as yourself."); + } + } + return data; +}; + +const guardStatusTransition: CollectionBeforeChangeHook = async ({ + operation, + data, + originalDoc, +}) => { + if (operation !== "update") return data; + const nextStatus = data?.status as LeadershipTransferStatus | undefined; + if (!nextStatus || nextStatus === originalDoc?.status) return data; + const current = originalDoc?.status as LeadershipTransferStatus | undefined; + const allowed = current ? LEADERSHIP_TRANSFER_STATUS_TRANSITIONS[current] : []; + if (!allowed.includes(nextStatus)) { + throw new Error(`Illegal leadership transfer status transition: ${current} -> ${nextStatus}.`); + } + return data; +}; + +export const LeadershipTransfers: CollectionConfig = { + slug: "leadership-transfers", + admin: { + group: "Users", + useAsTitle: "subject", + defaultColumns: ["subject", "fromAssignment", "toAssignment", "status", "createdAt"], + }, + access: { + read: async ({ req }) => { + const user = req.user as User | null; + if (!user) return false; + if (await isSuperuser(req.payload, user)) return true; + if (hasRoles(["admin", "developer"], user)) return true; + return { initiator: { equals: user.id } }; + }, + create: ({ req }) => Boolean(req.user), + update: ({ req }) => hasRoles(["admin", "developer"], req.user), + delete: ({ req }) => hasRoles(["developer"], req.user), + }, + fields: [ + { + name: "initiator", + type: "relationship", + relationTo: "users", + required: true, + index: true, + admin: { + description: "Division leader requesting the removal.", + }, + }, + { + name: "subject", + type: "relationship", + relationTo: "users", + required: true, + index: true, + admin: { + description: "Member being transferred.", + }, + }, + { + name: "fromAssignment", + type: "relationship", + relationTo: "assignments", + required: true, + admin: { + description: "Division the member is removed from.", + }, + }, + { + name: "toAssignment", + type: "relationship", + relationTo: "assignments", + required: true, + admin: { + description: "Division the member is transferred to (the Infantry assignment).", + }, + }, + { + name: "reason", + type: "textarea", + maxLength: 2000, + admin: { + description: "Optional reason for the removal, visible to command.", + }, + }, + { + name: "status", + type: "select", + required: true, + defaultValue: "pending", + options: [ + { label: "Pending approval", value: "pending" }, + { label: "Approved", value: "approved" }, + { label: "Denied", value: "denied" }, + { label: "Cancelled", value: "cancelled" }, + ], + }, + { + name: "reviewNote", + type: "textarea", + maxLength: 2000, + admin: { + description: "Reviewer note recorded with the decision.", + }, + }, + { + name: "reviewedBy", + type: "relationship", + relationTo: "users", + admin: { + condition: (data) => data?.status === "approved" || data?.status === "denied", + }, + }, + { + name: "resolvedAt", + type: "date", + admin: { + condition: (data) => data?.status === "approved" || data?.status === "denied", + }, + }, + ], + timestamps: true, + hooks: { + beforeChange: [enforceInitiatorOnCreate, guardStatusTransition], + }, +}; diff --git a/src/collections/users/PromotionNominations.ts b/src/collections/users/PromotionNominations.ts new file mode 100644 index 0000000..10850a8 --- /dev/null +++ b/src/collections/users/PromotionNominations.ts @@ -0,0 +1,200 @@ +import { + CollectionConfig, + type CollectionBeforeChangeHook, +} from "payload"; +import type { User } from "@/payload-types"; +import hasRoles from "@/utils/access-control/hasRoles"; +import { isSuperuser } from "@/utils/access-control/hasPermission"; + +export const NOMINATION_STATUSES = [ + "pending", + "awaiting_superuser", + "approved", + "rejected", + "cancelled", +] as const; +export type NominationStatus = (typeof NOMINATION_STATUSES)[number]; + +export const NOMINATION_STATUS_TRANSITIONS: Record = { + pending: ["awaiting_superuser", "rejected", "cancelled"], + awaiting_superuser: ["approved", "rejected"], + approved: [], + rejected: [], + cancelled: [], +}; + +export const NOMINATION_LEADER_DECISIONS = [ + "pending", + "endorsed", + "rejected", + "not_required", +] as const; +export type NominationLeaderDecision = (typeof NOMINATION_LEADER_DECISIONS)[number]; + +const relationId = (value: unknown): number | null | undefined => + typeof value === "object" && value !== null ? (value as { id: number }).id : (value as number | null | undefined); + +/** + * A submission always belongs to its submitter. REST/admin creates are forced to + * the authenticated caller; lib writes that run with overrideAccess and no req.user + * set nominator explicitly and skip this guard. + */ +const enforceNominatorOnCreate: CollectionBeforeChangeHook = async ({ operation, data, req }) => { + if (operation !== "create") return data; + if (req.user) { + const nominatorId = relationId(data?.nominator); + if (nominatorId !== req.user.id) { + throw new Error("You can only submit a promotion nomination as yourself."); + } + } + return data; +}; + +const guardStatusTransition: CollectionBeforeChangeHook = async ({ + operation, + data, + originalDoc, +}) => { + if (operation !== "update") return data; + const nextStatus = data?.status as NominationStatus | undefined; + if (!nextStatus || nextStatus === originalDoc?.status) return data; + const current = originalDoc?.status as NominationStatus | undefined; + const allowed = current ? NOMINATION_STATUS_TRANSITIONS[current] : []; + if (!allowed.includes(nextStatus)) { + throw new Error(`Illegal promotion nomination status transition: ${current} -> ${nextStatus}.`); + } + return data; +}; + +export const PromotionNominations: CollectionConfig = { + slug: "promotion-nominations", + admin: { + group: "Users", + useAsTitle: "nominee", + defaultColumns: ["nominee", "nominator", "status", "leaderDecision", "createdAt"], + }, + access: { + read: async ({ req }) => { + const user = req.user as User | null; + if (!user) return false; + if (await isSuperuser(req.payload, user)) return true; + if (hasRoles(["admin", "developer"], user)) return true; + // Nominations stay private to the nominator until resolved; the review page + // scopes leader visibility server-side to divisions the leader actually leads. + return { nominator: { equals: user.id } }; + }, + create: ({ req }) => Boolean(req.user), + update: ({ req }) => hasRoles(["admin", "developer"], req.user), + delete: ({ req }) => hasRoles(["admin", "developer"], req.user), + }, + fields: [ + { + name: "nominee", + type: "relationship", + relationTo: "users", + required: true, + index: true, + admin: { + description: "Member being nominated for promotion.", + }, + }, + { + name: "nominator", + type: "relationship", + relationTo: "users", + required: true, + index: true, + admin: { + description: "Member submitting the nomination (self for self-requests).", + }, + }, + { + name: "accolades", + type: "textarea", + required: true, + maxLength: 4000, + admin: { + description: + "Written examples of performance, accolades, and reasons this member deserves promotion.", + }, + }, + { + name: "status", + type: "select", + required: true, + defaultValue: "pending", + options: [ + { label: "Pending division leader endorsement", value: "pending" }, + { label: "Awaiting final approval", value: "awaiting_superuser" }, + { label: "Approved", value: "approved" }, + { label: "Rejected", value: "rejected" }, + { label: "Cancelled", value: "cancelled" }, + ], + }, + { + name: "leaderDecision", + type: "select", + defaultValue: "pending", + options: [ + { label: "Pending", value: "pending" }, + { label: "Endorsed", value: "endorsed" }, + { label: "Rejected", value: "rejected" }, + { label: "Not required", value: "not_required" }, + ], + admin: { + description: "Decision of the nominee's division leader.", + }, + }, + { + name: "leaderDecisionBy", + type: "relationship", + relationTo: "users", + admin: { + condition: (data) => data?.leaderDecision === "endorsed" || data?.leaderDecision === "rejected", + }, + }, + { + name: "leaderDecidedAt", + type: "date", + admin: { + condition: (data) => data?.leaderDecision === "endorsed" || data?.leaderDecision === "rejected", + }, + }, + { + name: "leaderNote", + type: "textarea", + maxLength: 2000, + admin: { + description: "Note from the division leader's endorsement or rejection.", + condition: (data) => data?.leaderDecision === "endorsed" || data?.leaderDecision === "rejected", + }, + }, + { + name: "reviewNote", + type: "textarea", + maxLength: 2000, + admin: { + description: "Final reviewer feedback shown to the nominator.", + }, + }, + { + name: "reviewedBy", + type: "relationship", + relationTo: "users", + admin: { + condition: (data) => data?.status === "approved" || data?.status === "rejected", + }, + }, + { + name: "reviewedAt", + type: "date", + admin: { + condition: (data) => data?.status === "approved" || data?.status === "rejected", + }, + }, + ], + timestamps: true, + hooks: { + beforeChange: [enforceNominatorOnCreate, guardStatusTransition], + }, +}; diff --git a/src/components/frontend/blocks/sidebarData.ts b/src/components/frontend/blocks/sidebarData.ts index 23b91ca..ed04513 100644 --- a/src/components/frontend/blocks/sidebarData.ts +++ b/src/components/frontend/blocks/sidebarData.ts @@ -13,6 +13,7 @@ import { Flag, Kanban, LifeBuoy, + Medal, Map, Package, Radar, @@ -153,6 +154,11 @@ export const sidebarData = { url: "/personnel/statistics", icon: BarChart3, }, + { + title: "Promotions", + url: "/personnel/promotions", + icon: Medal, + }, ], }, ], diff --git a/src/components/frontend/profile/NominateForPromotionButton.tsx b/src/components/frontend/profile/NominateForPromotionButton.tsx new file mode 100644 index 0000000..86c71f3 --- /dev/null +++ b/src/components/frontend/profile/NominateForPromotionButton.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { submitPromotionNomination } from "@/app/(frontend)/profile/[username]/actions"; +import { Loader2Icon, TrophyIcon } from "lucide-react"; + +interface NominateForPromotionButtonProps { + readonly username: string; + readonly displayName: string; + readonly isOwnProfile: boolean; +} + +const MIN_ACCOLADES_LENGTH = 20; + +/** + * Profile-page section that lets any logged-in user nominate the profile owner + * for promotion (or self-request on their own profile). The visibility and the + * next-rung existence check are computed server-side by the page; this + * component only provides the entry point and submits the accolades. + */ +export function NominateForPromotionButton({ + username, + displayName, + isOwnProfile, +}: NominateForPromotionButtonProps) { + const [open, setOpen] = useState(false); + const [accolades, setAccolades] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const router = useRouter(); + + const label = isOwnProfile ? "Request promotion" : "Nominate for promotion"; + const trimmed = accolades.trim(); + const tooShort = trimmed.length > 0 && trimmed.length < MIN_ACCOLADES_LENGTH; + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + if (submitting) return; + setSubmitting(true); + setError(null); + const res = await submitPromotionNomination({ + nomineeUsername: username, + accolades: accolades, + }); + if (res.success) { + toast.success(isOwnProfile ? "Promotion request submitted" : "Nomination submitted", { + description: isOwnProfile + ? "Your promotion request is on its way up the chain." + : `Your nomination for ${displayName} is on its way up the chain.`, + }); + setOpen(false); + setAccolades(""); + router.refresh(); + } else { + setError(res.error ?? "Could not submit the nomination."); + setSubmitting(false); + } + } + + return ( +
+
+
+ +

+ Promotion nomination +

+
+ +
+ { + if (!submitting) setOpen(o); + }} + > + + + {label} + + {isOwnProfile + ? "Make the case for your own promotion. It will be routed to command." + : `Make the case for ${displayName}'s promotion. It will be routed through their division leader.`} + + +
void handleSubmit(event)}> +
+ +