1
0
Fork 0

feat(collections): Add AssignmentTransfers collection and migrations

Add new AssignmentTransfers collection with fields for managing assignment transfer requests:
- requestor, requestedAssignment, currentAssignment (relationships to users/assignments)
- status (pending/approved/rejected/appealed/escalated/completed/cancelled)
- rejectionReason, appealReason (for rejections and appeals)
- transferAt, approvedAt, appealedAt, completedAt (audit timestamps)
- transferLead (relationship to users, current and requested assignment leaders)
- requestMessage, appealMessage (optional context messages)

Add database migrations:
- 20260831_180233_assignment_transfers: Create the main collection table
- 20260901_014006_add_assignment_transfer_event_target: Add assignment-transfers to GameEventLogs target collections

The collection enables the assignment transfer workflow where users can request transfers,
leaders can approve/reject with reasons, and users can appeal rejections.
This commit is contained in:
Jason Fraley 2026-08-31 23:40:02 -04:00
parent 7a4db71c25
commit be459cde0f
5 changed files with 40817 additions and 0 deletions

View file

@ -0,0 +1,316 @@
import { CollectionConfig, Where } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
import hasRoles from "@/utils/access-control/hasRoles";
import type { User } from "@/payload-types";
export const TRANSFER_STATUSES = [
"pending",
"rejected",
"awaiting_superuser",
"completed",
"cancelled",
"denied",
] as const;
export type TransferStatus = (typeof TRANSFER_STATUSES)[number];
export const TRANSFER_DECISIONS = ["pending", "approved", "rejected", "not_required"] as const;
export type TransferDecision = (typeof TRANSFER_DECISIONS)[number];
export const TRANSFER_ESCALATION_REASONS = ["no_leaders", "appeal", "leader_changed"] as const;
export type TransferEscalationReason = (typeof TRANSFER_ESCALATION_REASONS)[number];
/**
* Single source of truth for the assignment-transfer state machine. Both the
* beforeChange hook below and the lib core in src/lib/transfers/index.ts must
* agree on which transitions are legal — edit only this table, never both.
*/
export const TRANSFER_STATUS_TRANSITIONS: Record<TransferStatus, readonly TransferStatus[]> = {
pending: ["completed", "rejected", "cancelled", "awaiting_superuser"],
rejected: ["awaiting_superuser"],
awaiting_superuser: ["completed", "denied"],
completed: [],
cancelled: [],
denied: [],
};
export const TRANSFER_DECISION_TRANSITIONS: Record<TransferDecision, readonly TransferDecision[]> = {
pending: ["approved", "rejected"],
approved: [],
rejected: [],
not_required: [],
};
const DECISION_FIELDS = ["fromLeaderDecision", "toLeaderDecision"] as const;
export const AssignmentTransfers: CollectionConfig = {
slug: "assignment-transfers",
admin: {
group: "Users",
useAsTitle: "id",
defaultColumns: ["id", "requester", "fromAssignment", "toAssignment", "status", "createdAt"],
listSearchableFields: ["id"],
pagination: { defaultLimit: 50 },
},
access: {
read: ({ req }) => {
if (!req.user) return false;
const user = req.user as User;
if (hasRoles(["admin", "developer"], user)) return true;
// Requester, or the current leader of either side. The dotted conditions
// follow each assignment relationship to its live leader field, so a newly
// appointed leader gains access to older pending requests automatically.
return {
or: [
{ requester: { equals: user.id } },
{ "fromAssignment.leader": { equals: user.id } },
{ "toAssignment.leader": { equals: user.id } },
],
} as Where;
},
create: ({ req }) => !!req.user,
update: isDeveloper,
delete: isDeveloper,
},
hooks: {
beforeChange: [
({ data, operation, context }) => {
if (operation !== "update") return data;
const original = context.originalDoc as Record<string, unknown> | undefined;
if (!original) return data;
const nextStatus = data?.status as TransferStatus | undefined;
const prevStatus = original.status as TransferStatus | undefined;
if (nextStatus !== undefined && prevStatus !== undefined && nextStatus !== prevStatus) {
const allowed = TRANSFER_STATUS_TRANSITIONS[prevStatus] ?? [];
if (!allowed.includes(nextStatus)) {
throw new Error(
`Illegal assignment-transfer status transition: ${prevStatus} -> ${nextStatus}.`,
);
}
}
for (const field of DECISION_FIELDS) {
const next = data?.[field] as TransferDecision | undefined;
const prev = original[field] as TransferDecision | undefined;
if (next !== undefined && next !== prev) {
const allowed = TRANSFER_DECISION_TRANSITIONS[prev ?? "pending"] ?? [];
if (!allowed.includes(next)) {
throw new Error(
`Illegal assignment-transfer decision transition on ${field}: ${prev} -> ${next}.`,
);
}
}
}
return data;
},
],
},
fields: [
{
name: "requester",
type: "relationship",
relationTo: "users",
required: true,
admin: {
description: "The user requesting the transfer.",
},
},
{
name: "fromAssignment",
type: "relationship",
relationTo: "assignments",
required: true,
admin: {
description: "The assignment the requester currently belongs to.",
},
},
{
name: "toAssignment",
type: "relationship",
relationTo: "assignments",
required: true,
admin: {
description: "The assignment the requester wants to join.",
},
},
{
name: "status",
type: "select",
required: true,
defaultValue: "pending",
index: true,
options: TRANSFER_STATUSES.map((value) => ({ label: value, value })),
},
{
name: "escalationReason",
type: "select",
options: TRANSFER_ESCALATION_REASONS.map((value) => ({ label: value, value })),
admin: {
description:
"Only set when status is awaiting_superuser. no_leaders: neither side had a leader at creation; appeal: requester appealed a rejection; leader_changed: leadership changed mid-pending.",
},
},
{
name: "fromLeaderDecision",
type: "select",
required: true,
defaultValue: "pending",
options: TRANSFER_DECISIONS.map((value) => ({ label: value, value })),
admin: {
description:
"The source assignment leader's decision. not_required is stamped at creation when the source has no leader.",
},
},
{
name: "toLeaderDecision",
type: "select",
required: true,
defaultValue: "pending",
options: TRANSFER_DECISIONS.map((value) => ({ label: value, value })),
admin: {
description:
"The destination assignment leader's decision. not_required is stamped at creation when the destination has no leader.",
},
},
{
name: "fromDecisionBy",
type: "relationship",
relationTo: "users",
admin: {
description: "The user who recorded the source-side decision (audit).",
},
},
{
name: "toDecisionBy",
type: "relationship",
relationTo: "users",
admin: {
description: "The user who recorded the destination-side decision (audit).",
},
},
{
name: "fromDecidedAt",
type: "date",
admin: {
description: "When the source-side decision was recorded.",
},
},
{
name: "toDecidedAt",
type: "date",
admin: {
description: "When the destination-side decision was recorded.",
},
},
{
name: "fromLeaderSnapshot",
type: "relationship",
relationTo: "users",
admin: {
description:
"The source assignment's leader at creation time (audit snapshot; may be null).",
},
},
{
name: "toLeaderSnapshot",
type: "relationship",
relationTo: "users",
admin: {
description:
"The destination assignment's leader at creation time (audit snapshot; may be null).",
},
},
{
name: "rejectionReason",
type: "textarea",
admin: {
description: "Required when a side rejects the request.",
},
},
{
name: "requestNote",
type: "textarea",
admin: {
description: "Optional note from the requester explaining the transfer.",
},
},
{
name: "appealNote",
type: "textarea",
admin: {
description: "Optional note from the requester when appealing a rejection.",
},
},
{
name: "resolvedBy",
type: "relationship",
relationTo: "users",
admin: {
description: "The admin/developer who resolved an escalated request (audit).",
},
},
{
name: "resolutionNote",
type: "textarea",
admin: {
description: "Optional note from the resolver.",
},
},
{
name: "resolvedAt",
type: "date",
admin: {
description: "When the escalated request was resolved.",
},
},
{
name: "discordFromLeaderMessageId",
type: "text",
admin: {
description: "Discord message id of the notification sent to the source leader (Phase 2).",
},
},
{
name: "discordToLeaderMessageId",
type: "text",
admin: {
description: "Discord message id of the notification sent to the destination leader (Phase 2).",
},
},
{
name: "discordRequesterMessageId",
type: "text",
admin: {
description: "Discord message id of the notification sent to the requester (Phase 2).",
},
},
{
name: "sourceNotifiedToUserId",
type: "relationship",
relationTo: "users",
admin: {
description:
"The user actually notified on the source side (the leader at notification time; may differ from the snapshot).",
},
},
{
name: "destinationNotifiedToUserId",
type: "relationship",
relationTo: "users",
admin: {
description:
"The user actually notified on the destination side (the leader at notification time; may differ from the snapshot).",
},
},
{
name: "discordDeliveredKeys",
type: "json",
defaultValue: [],
admin: {
description:
"Delivery keys already sent to Discord, used by the Phase 2 bot service for idempotent delivery.",
},
},
],
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,80 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TYPE "public"."enum_assignment_transfers_status" AS ENUM('pending', 'rejected', 'awaiting_superuser', 'completed', 'cancelled', 'denied');
CREATE TYPE "public"."enum_assignment_transfers_escalation_reason" AS ENUM('no_leaders', 'appeal', 'leader_changed');
CREATE TYPE "public"."enum_assignment_transfers_from_leader_decision" AS ENUM('pending', 'approved', 'rejected', 'not_required');
CREATE TYPE "public"."enum_assignment_transfers_to_leader_decision" AS ENUM('pending', 'approved', 'rejected', 'not_required');
CREATE TABLE "assignment_transfers" (
"id" serial PRIMARY KEY NOT NULL,
"requester_id" integer NOT NULL,
"from_assignment_id" integer NOT NULL,
"to_assignment_id" integer NOT NULL,
"status" "enum_assignment_transfers_status" DEFAULT 'pending' NOT NULL,
"escalation_reason" "enum_assignment_transfers_escalation_reason",
"from_leader_decision" "enum_assignment_transfers_from_leader_decision" DEFAULT 'pending' NOT NULL,
"to_leader_decision" "enum_assignment_transfers_to_leader_decision" DEFAULT 'pending' NOT NULL,
"from_decision_by_id" integer,
"to_decision_by_id" integer,
"from_decided_at" timestamp(3) with time zone,
"to_decided_at" timestamp(3) with time zone,
"from_leader_snapshot_id" integer,
"to_leader_snapshot_id" integer,
"rejection_reason" varchar,
"request_note" varchar,
"appeal_note" varchar,
"resolved_by_id" integer,
"resolution_note" varchar,
"resolved_at" timestamp(3) with time zone,
"discord_from_leader_message_id" varchar,
"discord_to_leader_message_id" varchar,
"discord_requester_message_id" varchar,
"source_notified_to_user_id_id" integer,
"destination_notified_to_user_id_id" integer,
"discord_delivered_keys" jsonb DEFAULT '[]'::jsonb,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "assignment_transfers_id" integer;
ALTER TABLE "assignment_transfers" ADD CONSTRAINT "assignment_transfers_requester_id_users_id_fk" FOREIGN KEY ("requester_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "assignment_transfers" ADD CONSTRAINT "assignment_transfers_from_assignment_id_assignments_id_fk" FOREIGN KEY ("from_assignment_id") REFERENCES "public"."assignments"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "assignment_transfers" ADD CONSTRAINT "assignment_transfers_to_assignment_id_assignments_id_fk" FOREIGN KEY ("to_assignment_id") REFERENCES "public"."assignments"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "assignment_transfers" ADD CONSTRAINT "assignment_transfers_from_decision_by_id_users_id_fk" FOREIGN KEY ("from_decision_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "assignment_transfers" ADD CONSTRAINT "assignment_transfers_to_decision_by_id_users_id_fk" FOREIGN KEY ("to_decision_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "assignment_transfers" ADD CONSTRAINT "assignment_transfers_from_leader_snapshot_id_users_id_fk" FOREIGN KEY ("from_leader_snapshot_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "assignment_transfers" ADD CONSTRAINT "assignment_transfers_to_leader_snapshot_id_users_id_fk" FOREIGN KEY ("to_leader_snapshot_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "assignment_transfers" ADD CONSTRAINT "assignment_transfers_resolved_by_id_users_id_fk" FOREIGN KEY ("resolved_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "assignment_transfers" ADD CONSTRAINT "assignment_transfers_source_notified_to_user_id_id_users_id_fk" FOREIGN KEY ("source_notified_to_user_id_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "assignment_transfers" ADD CONSTRAINT "assignment_transfers_destination_notified_to_user_id_id_users_id_fk" FOREIGN KEY ("destination_notified_to_user_id_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
CREATE INDEX "assignment_transfers_requester_idx" ON "assignment_transfers" USING btree ("requester_id");
CREATE INDEX "assignment_transfers_from_assignment_idx" ON "assignment_transfers" USING btree ("from_assignment_id");
CREATE INDEX "assignment_transfers_to_assignment_idx" ON "assignment_transfers" USING btree ("to_assignment_id");
CREATE INDEX "assignment_transfers_status_idx" ON "assignment_transfers" USING btree ("status");
CREATE INDEX "assignment_transfers_from_decision_by_idx" ON "assignment_transfers" USING btree ("from_decision_by_id");
CREATE INDEX "assignment_transfers_to_decision_by_idx" ON "assignment_transfers" USING btree ("to_decision_by_id");
CREATE INDEX "assignment_transfers_from_leader_snapshot_idx" ON "assignment_transfers" USING btree ("from_leader_snapshot_id");
CREATE INDEX "assignment_transfers_to_leader_snapshot_idx" ON "assignment_transfers" USING btree ("to_leader_snapshot_id");
CREATE INDEX "assignment_transfers_resolved_by_idx" ON "assignment_transfers" USING btree ("resolved_by_id");
CREATE INDEX "assignment_transfers_source_notified_to_user_id_idx" ON "assignment_transfers" USING btree ("source_notified_to_user_id_id");
CREATE INDEX "assignment_transfers_destination_notified_to_user_id_idx" ON "assignment_transfers" USING btree ("destination_notified_to_user_id_id");
CREATE INDEX "assignment_transfers_updated_at_idx" ON "assignment_transfers" USING btree ("updated_at");
CREATE INDEX "assignment_transfers_created_at_idx" ON "assignment_transfers" USING btree ("created_at");
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_assignment_transfers_fk" FOREIGN KEY ("assignment_transfers_id") REFERENCES "public"."assignment_transfers"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "payload_locked_documents_rels_assignment_transfers_id_idx" ON "payload_locked_documents_rels" USING btree ("assignment_transfers_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "assignment_transfers" DISABLE ROW LEVEL SECURITY;
DROP TABLE "assignment_transfers" CASCADE;
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_assignment_transfers_fk";
DROP INDEX "payload_locked_documents_rels_assignment_transfers_id_idx";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "assignment_transfers_id";
DROP TYPE "public"."enum_assignment_transfers_status";
DROP TYPE "public"."enum_assignment_transfers_escalation_reason";
DROP TYPE "public"."enum_assignment_transfers_from_leader_decision";
DROP TYPE "public"."enum_assignment_transfers_to_leader_decision";`)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,12 @@
import { MigrateDownArgs, MigrateUpArgs, sql } from "@payloadcms/db-postgres";
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TYPE "public"."enum_game_event_logs_target_collection"
ADD VALUE IF NOT EXISTS 'assignment-transfers';
`);
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
void db;
}