Players meeting a role's minimum rank and MOS can queue for mission roles before op time. Slot holders are recomputed statelessly from signup order plus current RSVP (first N committed signups hold the role); attendance writes diff holders around the write so RSVP changes auto-promote, with notifications and events. Adds mission-role-signups collection, roleSignups/missionTime libs, sign-up panel, role slots + rich-text descriptions (with RoleDescription renderer), orphan-pruning hook, migrations, tests, and AGENTS.md docs.
616 lines
20 KiB
TypeScript
616 lines
20 KiB
TypeScript
import type { Mission, MissionRoleSignup, Qualification, Rank, User } from "@/payload-types";
|
|
import { emitGameEvent } from "@/utils/event-log/emit";
|
|
import { EventTypes } from "@/utils/event-log/eventTypes";
|
|
import { notifyUser } from "@/lib/notifications";
|
|
import { loadRankLadder, resolveRankFromLadder, rankIndex } from "@/lib/promotions/authority";
|
|
import { isMissionPast } from "@/lib/intelligence/missionTime";
|
|
|
|
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
|
|
|
|
export const roleSignupsClosedMessage = "Role sign-ups are closed for this mission.";
|
|
|
|
/** Notification type used when a waitlisted member is promoted into a slot. */
|
|
export const ROLE_PROMOTED_NOTIFICATION_TYPE = "mission:role-promoted";
|
|
|
|
export interface RoleRequirementRefs {
|
|
requiredMos?: (Qualification | number)[] | null;
|
|
minimumRank?: Rank | number | null;
|
|
}
|
|
|
|
export interface RoleEligibility {
|
|
eligible: boolean;
|
|
reasons: string[];
|
|
}
|
|
|
|
export interface RoleSignupGate {
|
|
open: boolean;
|
|
reason?: string;
|
|
}
|
|
|
|
export interface RoleQueueEntryView {
|
|
signupId: number;
|
|
userId: number;
|
|
name: string;
|
|
holding: boolean;
|
|
/** 1-based position within this role's queue (slot holders come first). */
|
|
position: number;
|
|
}
|
|
|
|
export interface RoleSignupRoleState {
|
|
roleIndex: number;
|
|
roleName: string;
|
|
slots: number;
|
|
filled: number;
|
|
entries: RoleQueueEntryView[];
|
|
}
|
|
|
|
export interface MissionRoleSignupState {
|
|
roles: RoleSignupRoleState[];
|
|
mySignup: { roleIndex: number; holding: boolean; position: number } | null;
|
|
}
|
|
|
|
/** userId -> roleIndex for everyone currently holding a slot on the mission. */
|
|
export type SlotHolderMap = Map<number, number>;
|
|
|
|
/**
|
|
* Sign-up gate: sign-ups stay open until the op start passes (mirroring
|
|
* attendance) and close permanently on Cancelled/Completed.
|
|
*/
|
|
export function roleSignupGate(mission: {
|
|
ownershipAndStatus?: { status?: string | null } | null;
|
|
classification?: { startDateTime?: string | null } | null;
|
|
}): RoleSignupGate {
|
|
const status = mission.ownershipAndStatus?.status;
|
|
if (status === "Cancelled") return { open: false, reason: "This operation was cancelled." };
|
|
if (status === "Completed") return { open: false, reason: "This operation has concluded." };
|
|
const startDateTime = mission.classification?.startDateTime;
|
|
if (startDateTime && isMissionPast(startDateTime)) {
|
|
return { open: false, reason: roleSignupsClosedMessage };
|
|
}
|
|
return { open: true };
|
|
}
|
|
|
|
interface EligibilityProfileData {
|
|
rankId: number | null;
|
|
qualificationIds: number[];
|
|
}
|
|
|
|
async function loadEligibilityProfileData(
|
|
payload: PayloadType,
|
|
userId: number,
|
|
): Promise<EligibilityProfileData | null> {
|
|
const res = await payload.find({
|
|
collection: "profiles",
|
|
where: { user: { equals: userId } },
|
|
limit: 1,
|
|
depth: 1,
|
|
select: { rank: true, progression: { qualifications: true } },
|
|
overrideAccess: true,
|
|
});
|
|
const doc = res.docs[0] as
|
|
| {
|
|
rank?: number | { id: number } | null;
|
|
progression?: { qualifications?: (Qualification | number)[] | null };
|
|
}
|
|
| undefined;
|
|
if (!doc) return null;
|
|
|
|
const rankId =
|
|
typeof doc.rank === "object" && doc.rank !== null ? doc.rank.id : (doc.rank ?? null);
|
|
const qualificationIds = (doc.progression?.qualifications ?? [])
|
|
.map((q) => (typeof q === "object" && q !== null ? q.id : q))
|
|
.filter((id): id is number => typeof id === "number");
|
|
|
|
return { rankId: rankId ?? null, qualificationIds };
|
|
}
|
|
|
|
async function resolveQualificationNames(
|
|
payload: PayloadType,
|
|
refs: (Qualification | number)[],
|
|
): Promise<Map<number, string>> {
|
|
const names = new Map<number, string>();
|
|
const ids: number[] = [];
|
|
for (const ref of refs) {
|
|
if (typeof ref === "object" && ref !== null) names.set(ref.id, ref.name);
|
|
else if (typeof ref === "number") ids.push(ref);
|
|
}
|
|
if (ids.length > 0) {
|
|
const res = await payload.find({
|
|
collection: "qualifications",
|
|
where: { id: { in: ids } },
|
|
limit: ids.length,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
});
|
|
for (const q of res.docs as Qualification[]) names.set(q.id, q.name);
|
|
}
|
|
return names;
|
|
}
|
|
|
|
/**
|
|
* Check the current user against each role's requirements in one pass
|
|
* (profile + rank ladder are loaded once). Roles without requirements are
|
|
* always eligible. A required rank missing from the ladder fails open so a
|
|
* misconfigured rank never bricks sign-ups.
|
|
*/
|
|
export async function checkRoleEligibilities(
|
|
payload: PayloadType,
|
|
userId: number,
|
|
roles: RoleRequirementRefs[],
|
|
bypass = false,
|
|
): Promise<RoleEligibility[]> {
|
|
if (bypass) return roles.map(() => ({ eligible: true, reasons: [] }));
|
|
|
|
const hasRequirements = roles.some(
|
|
(r) => r.minimumRank != null || (r.requiredMos?.length ?? 0) > 0,
|
|
);
|
|
if (!hasRequirements) return roles.map(() => ({ eligible: true, reasons: [] }));
|
|
|
|
const [ladder, profileData] = await Promise.all([
|
|
loadRankLadder(payload),
|
|
loadEligibilityProfileData(payload, userId),
|
|
]);
|
|
|
|
const results: RoleEligibility[] = [];
|
|
for (const role of roles) {
|
|
const reasons: string[] = [];
|
|
|
|
if (role.minimumRank != null) {
|
|
const minId = typeof role.minimumRank === "object" ? role.minimumRank.id : role.minimumRank;
|
|
const minRank = resolveRankFromLadder(ladder, minId);
|
|
if (minRank) {
|
|
const profileRank =
|
|
profileData?.rankId != null ? resolveRankFromLadder(ladder, profileData.rankId) : null;
|
|
if (!profileRank) {
|
|
reasons.push("You need a rank on your profile before you can hold this role.");
|
|
} else if (rankIndex(ladder, profileRank.id) < rankIndex(ladder, minRank.id)) {
|
|
reasons.push(`Requires the rank of ${minRank.name} or higher.`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const mos = role.requiredMos ?? [];
|
|
if (mos.length > 0) {
|
|
if (!profileData) {
|
|
reasons.push("You need a profile before you can hold this role.");
|
|
} else {
|
|
const names = await resolveQualificationNames(payload, mos);
|
|
const have = new Set(profileData.qualificationIds);
|
|
const missing = mos
|
|
.map((ref) => (typeof ref === "object" && ref !== null ? ref.id : ref))
|
|
.filter((id): id is number => typeof id === "number" && !have.has(id))
|
|
.map((id) => names.get(id) ?? `#${id}`);
|
|
if (missing.length > 0) {
|
|
reasons.push(
|
|
missing.length === 1
|
|
? `Requires qualification: ${missing[0]}.`
|
|
: `Requires qualifications: ${missing.join(", ")}.`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
results.push({ eligible: reasons.length === 0, reasons });
|
|
}
|
|
return results;
|
|
}
|
|
|
|
export async function checkRoleEligibility(
|
|
payload: PayloadType,
|
|
userId: number,
|
|
role: RoleRequirementRefs,
|
|
): Promise<RoleEligibility> {
|
|
return (await checkRoleEligibilities(payload, userId, [role]))[0];
|
|
}
|
|
|
|
function missionName(mission: { name?: string | null } | null | undefined, id: number): string {
|
|
return mission?.name ?? `Mission #${id}`;
|
|
}
|
|
|
|
function signupUserId(signup: MissionRoleSignup): number {
|
|
return typeof signup.user === "object" ? signup.user.id : signup.user;
|
|
}
|
|
|
|
/** Stable FCFS order: createdAt ascending, id as the tiebreaker. */
|
|
function byFirstCome(a: MissionRoleSignup, b: MissionRoleSignup): number {
|
|
if (a.createdAt < b.createdAt) return -1;
|
|
if (a.createdAt > b.createdAt) return 1;
|
|
return a.id - b.id;
|
|
}
|
|
|
|
async function fetchSignupsForMission(
|
|
payload: PayloadType,
|
|
missionId: number,
|
|
): Promise<MissionRoleSignup[]> {
|
|
const res = await payload.find({
|
|
collection: "mission-role-signups",
|
|
where: { mission: { equals: missionId } },
|
|
limit: 1000,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
});
|
|
return (res.docs as MissionRoleSignup[]).slice().sort(byFirstCome);
|
|
}
|
|
|
|
async function fetchAttendingUserIds(
|
|
payload: PayloadType,
|
|
missionId: number,
|
|
): Promise<Set<number>> {
|
|
const res = await payload.find({
|
|
collection: "mission-attendances",
|
|
where: {
|
|
and: [{ mission: { equals: missionId } }, { response: { equals: "yes" } }],
|
|
},
|
|
limit: 1000,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
});
|
|
return new Set(
|
|
(res.docs as { user: number | { id: number } }[]).map((d) =>
|
|
typeof d.user === "object" ? d.user.id : d.user,
|
|
),
|
|
);
|
|
}
|
|
|
|
async function resolveUserName(payload: PayloadType, userId: number): Promise<string> {
|
|
const user = (await payload
|
|
.findByID({ collection: "users", id: userId, depth: 0, overrideAccess: true })
|
|
.catch(() => null)) as User | null;
|
|
return user ? user.displayName || user.username : `user #${userId}`;
|
|
}
|
|
|
|
/**
|
|
* Compute who currently holds each role's slots. Slot holders are the first
|
|
* `slots` sign-ups (FCFS) whose owner's attendance RSVP is "yes"; everyone
|
|
* else is waitlisted in signup order. Stateless: derived entirely from the
|
|
* current signups + attendance rows, so there is no promotion state to drift.
|
|
*/
|
|
export async function getSlotHolders(
|
|
payload: PayloadType,
|
|
missionId: number,
|
|
): Promise<SlotHolderMap> {
|
|
const signups = await fetchSignupsForMission(payload, missionId);
|
|
if (signups.length === 0) return new Map();
|
|
|
|
const mission = (await payload.findByID({
|
|
collection: "missions",
|
|
id: missionId,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})) as Mission;
|
|
const roles = mission.missionRoles?.providedRoles ?? [];
|
|
const yesUserIds = await fetchAttendingUserIds(payload, missionId);
|
|
|
|
const holders: SlotHolderMap = new Map();
|
|
for (let index = 0; index < roles.length; index++) {
|
|
const slots = Math.max(1, roles[index].slots ?? 1);
|
|
let held = 0;
|
|
for (const signup of signups) {
|
|
if (held >= slots) break;
|
|
if (signup.roleIndex !== index) continue;
|
|
const userId = signupUserId(signup);
|
|
if (!yesUserIds.has(userId)) continue;
|
|
holders.set(userId, index);
|
|
held += 1;
|
|
}
|
|
}
|
|
return holders;
|
|
}
|
|
|
|
export function diffPromotedUsers(
|
|
before: SlotHolderMap,
|
|
after: SlotHolderMap,
|
|
): { userId: number; roleIndex: number }[] {
|
|
const promoted: { userId: number; roleIndex: number }[] = [];
|
|
for (const [userId, roleIndex] of after) {
|
|
if (!before.has(userId)) promoted.push({ userId, roleIndex });
|
|
}
|
|
return promoted;
|
|
}
|
|
|
|
/**
|
|
* Notify members who newly hold a slot (and log a promoted event). Called
|
|
* after any mutation that can shift slot ownership: RSVP changes, sign-up
|
|
* switches, and withdrawals. `excludeUserIds` silences notifications for the
|
|
* acting user (their own action already confirms it in the UI).
|
|
*/
|
|
export async function notifySlotHoldersDiff(
|
|
payload: PayloadType,
|
|
missionId: number,
|
|
before: SlotHolderMap,
|
|
after: SlotHolderMap,
|
|
excludeUserIds: number[] = [],
|
|
): Promise<void> {
|
|
const promoted = diffPromotedUsers(before, after).filter(
|
|
(p) => !excludeUserIds.includes(p.userId),
|
|
);
|
|
if (promoted.length === 0) return;
|
|
|
|
const mission = (await payload
|
|
.findByID({
|
|
collection: "missions",
|
|
id: missionId,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
.catch(() => null)) as Mission | null;
|
|
const roles = mission?.missionRoles?.providedRoles ?? [];
|
|
|
|
for (const { userId, roleIndex } of promoted) {
|
|
const roleName = roles[roleIndex]?.roleName ?? `role #${roleIndex}`;
|
|
const userName = await resolveUserName(payload, userId);
|
|
await notifyUser(payload, {
|
|
userId,
|
|
type: ROLE_PROMOTED_NOTIFICATION_TYPE,
|
|
title: `Slot granted: ${roleName}`,
|
|
message: `You now hold a ${roleName} slot for ${missionName(mission, missionId)}.`,
|
|
link: `/intelligence/missions/${missionId}`,
|
|
});
|
|
await emitGameEvent(payload, {
|
|
type: EventTypes.MissionRolePromoted,
|
|
message: `${missionName(mission, missionId)}: ${userName} was promoted into the ${roleName} slot.`,
|
|
targetCollection: "missions",
|
|
targetId: missionId,
|
|
data: { userId, roleIndex },
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Full sign-up state for the mission detail page: per-role FCFS queue with
|
|
* slot-holding flags, plus the viewer's own position (if any). Sign-ups whose
|
|
* roleIndex points past the current role list (the mission's roles were later
|
|
* edited) are pruned by the Missions hook, so they are ignored here.
|
|
*/
|
|
export async function getMissionRoleSignupState(
|
|
payload: PayloadType,
|
|
missionId: number,
|
|
viewerId?: number | null,
|
|
): Promise<MissionRoleSignupState> {
|
|
const mission = (await payload.findByID({
|
|
collection: "missions",
|
|
id: missionId,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})) as Mission;
|
|
const roles = mission.missionRoles?.providedRoles ?? [];
|
|
|
|
const signups = await fetchSignupsForMission(payload, missionId);
|
|
const yesUserIds = await fetchAttendingUserIds(payload, missionId);
|
|
|
|
const userIds = [...new Set(signups.map(signupUserId))];
|
|
const usersRes =
|
|
userIds.length > 0
|
|
? await payload.find({
|
|
collection: "users",
|
|
where: { id: { in: userIds } },
|
|
limit: userIds.length,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
: { docs: [] as User[] };
|
|
const nameById = new Map<number, string>();
|
|
for (const u of usersRes.docs as User[]) {
|
|
nameById.set(u.id, u.displayName || u.username);
|
|
}
|
|
|
|
const stateRoles: RoleSignupRoleState[] = roles.map((role, index) => {
|
|
const slots = Math.max(1, role.slots ?? 1);
|
|
const entries = signups.filter((s) => s.roleIndex === index);
|
|
let held = 0;
|
|
const views: RoleQueueEntryView[] = entries.map((signup, position) => {
|
|
const userId = signupUserId(signup);
|
|
const holding = yesUserIds.has(userId) && held < slots;
|
|
if (holding) held += 1;
|
|
return {
|
|
signupId: signup.id,
|
|
userId,
|
|
name: nameById.get(userId) ?? "Unknown",
|
|
holding,
|
|
position: position + 1,
|
|
};
|
|
});
|
|
return {
|
|
roleIndex: index,
|
|
roleName: role.roleName,
|
|
slots,
|
|
filled: views.filter((v) => v.holding).length,
|
|
entries: views,
|
|
};
|
|
});
|
|
|
|
let mySignup: MissionRoleSignupState["mySignup"] = null;
|
|
if (viewerId != null) {
|
|
for (const roleState of stateRoles) {
|
|
const mine = roleState.entries.find((e) => e.userId === viewerId);
|
|
if (mine) {
|
|
mySignup = {
|
|
roleIndex: roleState.roleIndex,
|
|
holding: mine.holding,
|
|
position: mine.position,
|
|
};
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return { roles: stateRoles, mySignup };
|
|
}
|
|
|
|
/**
|
|
* Queue the user for a role (FCFS). One live sign-up per user per mission:
|
|
* signing up for another role moves the existing sign-up. No-op when already
|
|
* signed up for the same role. Throws descriptive errors for the action layer.
|
|
*/
|
|
export async function signupForRole(
|
|
payload: PayloadType,
|
|
input: {
|
|
missionId: number;
|
|
userId: number;
|
|
roleIndex: number;
|
|
bypassEligibility?: boolean;
|
|
},
|
|
): Promise<void> {
|
|
const { missionId, userId, roleIndex, bypassEligibility = false } = input;
|
|
|
|
const mission = (await payload.findByID({
|
|
collection: "missions",
|
|
id: missionId,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})) as Mission;
|
|
const gate = roleSignupGate(mission);
|
|
if (!gate.open) throw new Error(gate.reason ?? roleSignupsClosedMessage);
|
|
|
|
const roles = mission.missionRoles?.providedRoles ?? [];
|
|
const role = roles[roleIndex];
|
|
if (!role) throw new Error("This role no longer exists on the mission.");
|
|
|
|
if (!bypassEligibility) {
|
|
const eligibility = await checkRoleEligibility(payload, userId, role);
|
|
if (!eligibility.eligible) throw new Error(eligibility.reasons.join(" "));
|
|
}
|
|
|
|
const existingRes = await payload.find({
|
|
collection: "mission-role-signups",
|
|
where: {
|
|
and: [{ mission: { equals: missionId } }, { user: { equals: userId } }],
|
|
},
|
|
limit: 1,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
});
|
|
const existing = existingRes.docs[0] as MissionRoleSignup | undefined;
|
|
if (existing && existing.roleIndex === roleIndex) return;
|
|
|
|
const beforeHolders = await getSlotHolders(payload, missionId);
|
|
|
|
if (existing) {
|
|
await payload.update({
|
|
collection: "mission-role-signups",
|
|
id: existing.id,
|
|
data: { roleIndex, roleNameSnapshot: role.roleName },
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
});
|
|
} else {
|
|
await payload.create({
|
|
collection: "mission-role-signups",
|
|
data: { mission: missionId, user: userId, roleIndex, roleNameSnapshot: role.roleName },
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
});
|
|
}
|
|
|
|
const userName = await resolveUserName(payload, userId);
|
|
await emitGameEvent(payload, {
|
|
type: EventTypes.MissionRoleSignup,
|
|
message: `${missionName(mission, missionId)}: ${userName} signed up for ${role.roleName}.`,
|
|
actor: userId,
|
|
targetCollection: "missions",
|
|
targetId: missionId,
|
|
data: { roleIndex },
|
|
});
|
|
|
|
const afterHolders = await getSlotHolders(payload, missionId);
|
|
await notifySlotHoldersDiff(payload, missionId, beforeHolders, afterHolders, [userId]);
|
|
}
|
|
|
|
async function deleteSignupAndReconcile(
|
|
payload: PayloadType,
|
|
mission: Mission,
|
|
signup: MissionRoleSignup,
|
|
options: { actorId?: number; removedByStaff?: boolean } = {},
|
|
): Promise<void> {
|
|
const missionId = mission.id;
|
|
const roleIndex = signup.roleIndex;
|
|
const roleName =
|
|
signup.roleNameSnapshot ??
|
|
(mission.missionRoles?.providedRoles ?? [])[roleIndex]?.roleName ??
|
|
`role #${roleIndex}`;
|
|
const userId = signupUserId(signup);
|
|
|
|
const beforeHolders = await getSlotHolders(payload, missionId);
|
|
await payload.delete({
|
|
collection: "mission-role-signups",
|
|
id: signup.id,
|
|
overrideAccess: true,
|
|
});
|
|
|
|
const userName = await resolveUserName(payload, userId);
|
|
await emitGameEvent(payload, {
|
|
type: EventTypes.MissionRoleWithdraw,
|
|
message: options.removedByStaff
|
|
? `${missionName(mission, missionId)}: ${userName} was removed from ${roleName} by staff.`
|
|
: `${missionName(mission, missionId)}: ${userName} withdrew from ${roleName}.`,
|
|
actor: options.removedByStaff ? options.actorId : userId,
|
|
targetCollection: "missions",
|
|
targetId: missionId,
|
|
data: { roleIndex, removedByStaff: options.removedByStaff === true },
|
|
});
|
|
|
|
const afterHolders = await getSlotHolders(payload, missionId);
|
|
await notifySlotHoldersDiff(
|
|
payload,
|
|
missionId,
|
|
beforeHolders,
|
|
afterHolders,
|
|
[options.removedByStaff ? options.actorId : userId].filter(
|
|
(id): id is number => typeof id === "number",
|
|
),
|
|
);
|
|
}
|
|
|
|
/** Remove the user's own sign-up from the mission. */
|
|
export async function withdrawRoleSignup(
|
|
payload: PayloadType,
|
|
input: { missionId: number; userId: number },
|
|
): Promise<void> {
|
|
const { missionId, userId } = input;
|
|
|
|
const signups = await fetchSignupsForMission(payload, missionId);
|
|
const existing = signups.find((s) => signupUserId(s) === userId);
|
|
if (!existing) throw new Error("You are not signed up for a role on this mission.");
|
|
|
|
const mission = (await payload.findByID({
|
|
collection: "missions",
|
|
id: missionId,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})) as Mission;
|
|
|
|
await deleteSignupAndReconcile(payload, mission, existing, { actorId: userId });
|
|
}
|
|
|
|
/** Staff removal: delete any sign-up on the mission and reconcile slots. */
|
|
export async function removeRoleSignup(
|
|
payload: PayloadType,
|
|
input: { missionId: number; signupId: number; actorId?: number },
|
|
): Promise<void> {
|
|
const signup = (await payload
|
|
.findByID({
|
|
collection: "mission-role-signups",
|
|
id: input.signupId,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
.catch(() => null)) as MissionRoleSignup | null;
|
|
if (!signup) throw new Error("Sign-up not found.");
|
|
|
|
const signupMissionId = typeof signup.mission === "object" ? signup.mission.id : signup.mission;
|
|
if (signupMissionId !== input.missionId) {
|
|
throw new Error("That sign-up belongs to a different mission.");
|
|
}
|
|
|
|
const mission = (await payload.findByID({
|
|
collection: "missions",
|
|
id: input.missionId,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})) as Mission;
|
|
|
|
await deleteSignupAndReconcile(payload, mission, signup, {
|
|
actorId: input.actorId,
|
|
removedByStaff: true,
|
|
});
|
|
}
|