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:
parent
7a4db71c25
commit
be459cde0f
5 changed files with 40817 additions and 0 deletions
316
src/collections/users/AssignmentTransfers.ts
Normal file
316
src/collections/users/AssignmentTransfers.ts
Normal 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.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
20204
src/migrations/20260831_180233_assignment_transfers.json
Normal file
20204
src/migrations/20260831_180233_assignment_transfers.json
Normal file
File diff suppressed because it is too large
Load diff
80
src/migrations/20260831_180233_assignment_transfers.ts
Normal file
80
src/migrations/20260831_180233_assignment_transfers.ts
Normal 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";`)
|
||||
}
|
||||
20205
src/migrations/20260901_014006_add_assignment_transfer_event_target.json
Normal file
20205
src/migrations/20260901_014006_add_assignment_transfer_event_target.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in a new issue