"use server"; import config from "@payload-config"; import { getPayload } from "payload"; import type { AttendanceResponse } from "@/lib/attendance"; import { setMissionAttendance as setAttendance } from "@/lib/attendance"; import { notifyAttendanceChanged } from "@/lib/attendance/realtime"; import { isAdminOrDeveloper } from "@/lib/promotions/authority"; import { removeRoleSignup, signupForRole, withdrawRoleSignup, } from "@/lib/intelligence/roleSignups"; import { hasPermission } from "@/utils/access-control/hasPermission"; import { isPayloadUser } from "@/utils/access-control/isPayloadUser"; import { emitGameEvent } from "@/utils/event-log/emit"; import { EventTypes } from "@/utils/event-log/eventTypes"; import { nextDayKey, parseDateKey, wallTimeToInstant, zonedDateKey, zonedParts, zonedWeekday, } from "@/lib/intelligence/fridayTime"; import type { Mission, User } from "@/payload-types"; export interface ActionResult { success: boolean; error?: string; 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 (!user || !isPayloadUser(user)) { throw new Error("Unauthorized"); } return { payload, user }; } export async function setMissionAttendance(input: { missionId: number; response: AttendanceResponse; }): Promise { try { const { payload, user } = await authenticate(); const userId = user.id as number; await setAttendance(payload, { missionId: input.missionId, userId, response: input.response, }); // The bot also polls as a recovery path, but this makes web RSVP changes // visible in Discord immediately when the bot is running. void notifyAttendanceChanged(payload, input.missionId).catch((error) => { payload.logger.warn( `Attendance changed, but Discord realtime notification failed: ${ error instanceof Error ? error.message : "Unknown error" }`, ); }); return { success: true }; } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; return message === "Unauthorized" ? { success: false, error: "You must be logged in." } : { success: false, error: message }; } } const MISSION_TYPES = ["Zeus", "PvE", "PvP", "PvPvE", "Training", "Special"] as const; // Not exported: "use server" files may only export async functions. const MAX_SCHEDULED_AHEAD_MISSIONS = 2; function isFridayDate(value: string): boolean { const date = new Date(value); if (Number.isNaN(date.getTime())) { return false; } return zonedWeekday(date) === 5; } // Side ops always start 20:00 America/New_York on the validated Friday, // regardless of the server's own timezone (prod runs UTC). function fridayStartDateTime(value: string): string { const date = new Date(value); if (Number.isNaN(date.getTime())) { return date.toISOString(); } const p = zonedParts(date); return wallTimeToInstant(p.year, p.month, p.day, 20, 0).toISOString(); } type SideOpMissionUser = { id: number; username: string; displayName?: string | null; }; async function resolveSideOpCampaign( payload: Awaited>, user: SideOpMissionUser, campaignId?: number | null, newCampaignName?: string | null, ): Promise<{ campaignId: number; created: boolean }> { if (campaignId) { const campaign = await payload.findByID({ collection: "campaigns", id: campaignId, depth: 0, }); const ownerId = typeof campaign.owner === "object" ? campaign.owner?.id : campaign.owner; if (ownerId !== user.id) { throw new Error("You can only schedule missions into campaigns you own."); } return { campaignId: campaign.id, created: false }; } const name = newCampaignName?.trim() || `${user.displayName || user.username}'s Side Ops`; const existing = await payload.find({ collection: "campaigns", where: { and: [{ name: { equals: name } }, { owner: { equals: user.id } }] }, limit: 1, depth: 0, }); if (existing.docs.length > 0) { return { campaignId: existing.docs[0].id, created: false }; } const campaign = await payload.create({ collection: "campaigns", overrideAccess: true, data: { name, summary: "Player-organized Friday side operations.", status: "concept", campaignMode: "custom", owner: user.id, startDate: new Date().toISOString().slice(0, 10), }, }); return { campaignId: campaign.id, created: true }; } async function countUpcomingPlayerMissions( payload: Awaited>, userId: number, excludeMissionId?: number, ): Promise { const result = await payload.count({ collection: "missions", where: { and: [ { createdBy: { equals: userId } }, { "classification.startDateTime": { greater_than: new Date().toISOString() } }, { "ownershipAndStatus.status": { not_in: ["Completed", "Cancelled"] } }, ...(excludeMissionId ? [{ id: { not_equals: excludeMissionId } }] : []), ], }, }); return result.totalDocs; } function isSideOpOwner( mission: { createdBy?: number | { id: number } | null }, userId: number, ): boolean { const ownerId = typeof mission.createdBy === "object" ? mission.createdBy?.id : mission.createdBy; return ownerId === userId; } type InfilExfilMethod = "helicopter" | "aircraft" | "paradrop" | "boat" | "car" | "onFoot"; const INFIL_EXFIL_METHODS: readonly InfilExfilMethod[] = [ "helicopter", "aircraft", "paradrop", "boat", "car", "onFoot", ]; export async function uploadSideOpModlist( formData: FormData, ): Promise> { try { const { payload, user } = await authenticate(); const userId = user.id as number; const file = formData.get("file"); const name = formData.get("name"); if (!(file instanceof File) || file.size === 0) { return { success: false, error: "Please choose a mod list file." }; } if (typeof name !== "string" || !name.trim()) { return { success: false, error: "Please give the mod list a name." }; } if (file.size > 10 * 1024 * 1024) { return { success: false, error: "Mod list file must be 10 MB or smaller." }; } // Only HTML mod lists exported from the Arma 3 launcher are supported. Some // browsers report an empty file.type, so also accept by extension. const isHtml = file.type === "text/html" || /\.html?$/i.test(file.name); if (!isHtml) { return { success: false, error: "Mod list must be an HTML file exported from the Arma 3 launcher.", }; } const buffer = Buffer.from(await file.arrayBuffer()); const created = await payload.create({ collection: "mod-lists", overrideAccess: true, data: { modlistName: name.trim(), author: userId, }, file: { data: buffer, mimetype: file.type || "text/html", name: file.name, size: file.size, }, }); return { success: true, data: { modlistId: created.id, modlistName: created.modlistName } }; } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; if (/invalid: file/i.test(message)) { return { success: false, error: "That file type is not accepted. Please upload the .html file exported from the Arma 3 launcher, or a .txt mod list.", }; } return message === "Unauthorized" ? { success: false, error: "You must be logged in." } : { success: false, error: message }; } } function missionRef(mission: { name: string; codeName?: string | null }): string { return mission.codeName ? `${mission.name} (${mission.codeName})` : mission.name; } export interface FridaySideOpInput { name: string; codeName?: string; summary: string; coverImageId?: number | null; mapId?: number; missionType: (typeof MISSION_TYPES)[number]; startDateTime: string; estimatedDuration?: number; maxPlayers?: number; campaignId?: number | null; newCampaignName?: string | null; noCampaign?: boolean; customMapName?: string | null; requiredModsId?: number | null; briefing?: { sectionName: string; details?: Record | null }[] | null; missionObjectives?: { title: string; description?: string | null; missionCritical?: boolean | null; successOrFailConditions?: string | null; redacted?: boolean | null; }[] | null; civilianPresence?: "None" | "Low" | "Medium" | "High" | null; factionsInvolved?: { faction: number; threatLevel: "ally" | "friendly" | "neutral" | "hostile"; }[] | null; radioPlan?: Record | null; insertion?: InfilExfilMethod[] | null; extraction?: InfilExfilMethod[] | null; assetsAvailable?: { assetType: string; assetCount?: string | null; doesRespawn?: boolean | null; assetCallsign?: string | null; }[] | null; } type SideOpNarrativeData = { briefing?: { sectionName: string; details: MissionLexicalState }[]; missionObjectives?: FridaySideOpInput["missionObjectives"]; civilianPresence?: FridaySideOpInput["civilianPresence"]; factionsInvolved?: FridaySideOpInput["factionsInvolved"]; commsAndDocs?: { radioPlan?: MissionLexicalState | null }; logisticsAndSupport?: { insertion?: InfilExfilMethod[] | null; extraction?: InfilExfilMethod[] | null; assetsAvailable?: FridaySideOpInput["assetsAvailable"]; }; }; type MissionLexicalState = NonNullable[number]["details"]>; type NarrativeDataResult = { success: true; data: SideOpNarrativeData } | { success: false; error: string }; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function isLexicalValue(value: unknown): value is MissionLexicalState | null | undefined { return value === undefined || value === null || (isRecord(value) && isRecord(value.root)); } const EMPTY_LEXICAL_STATE: MissionLexicalState = { root: { type: "root", children: [], direction: null, format: "", indent: 0, version: 1, }, }; function normalizeLexicalValue( value: Record | null | undefined, ): MissionLexicalState { if (value === null || value === undefined || !isLexicalValue(value)) { return EMPTY_LEXICAL_STATE; } return value; } function narrativeData(input: { briefing?: FridaySideOpInput["briefing"]; missionObjectives?: FridaySideOpInput["missionObjectives"]; civilianPresence?: FridaySideOpInput["civilianPresence"]; factionsInvolved?: FridaySideOpInput["factionsInvolved"]; radioPlan?: FridaySideOpInput["radioPlan"]; insertion?: FridaySideOpInput["insertion"]; extraction?: FridaySideOpInput["extraction"]; assetsAvailable?: FridaySideOpInput["assetsAvailable"]; }): NarrativeDataResult { const radioPlan = input.radioPlan; if (!isLexicalValue(radioPlan)) { return { success: false, error: "Invalid radio plan format." }; } if (input.briefing?.some((entry) => !isLexicalValue(entry.details))) { return { success: false, error: "Invalid briefing details format." }; } const commsAndDocs: SideOpNarrativeData["commsAndDocs"] = {}; if (radioPlan !== undefined) { commsAndDocs.radioPlan = radioPlan; } const logisticsAndSupport: SideOpNarrativeData["logisticsAndSupport"] = {}; if (input.insertion !== undefined) { logisticsAndSupport.insertion = input.insertion; } if (input.extraction !== undefined) { logisticsAndSupport.extraction = input.extraction; } if (input.assetsAvailable !== undefined) { logisticsAndSupport.assetsAvailable = input.assetsAvailable; } return { success: true, data: { ...(input.briefing !== undefined ? { briefing: (input.briefing ?? []).map((entry) => ({ sectionName: entry.sectionName, details: normalizeLexicalValue(entry.details), })), } : {}), ...(input.missionObjectives !== undefined ? { missionObjectives: input.missionObjectives } : {}), ...(input.civilianPresence !== undefined ? { civilianPresence: input.civilianPresence } : {}), ...(input.factionsInvolved !== undefined ? { factionsInvolved: input.factionsInvolved } : {}), ...(Object.keys(commsAndDocs).length > 0 ? { commsAndDocs } : {}), ...(Object.keys(logisticsAndSupport).length > 0 ? { logisticsAndSupport } : {}), }, }; } async function findActiveMissionOnFriday( payload: Awaited>, startDateTime: string, excludeMissionId?: number, ): Promise<{ id: number; name: string; codeName?: string | null } | null> { const start = new Date(fridayStartDateTime(startDateTime)); const key = zonedDateKey(start); const { year, month, day } = parseDateKey(key); const next = parseDateKey(nextDayKey(key)); const dayStart = wallTimeToInstant(year, month, day); const dayEnd = wallTimeToInstant(next.year, next.month, next.day); const res = await payload.find({ collection: "missions", where: { and: [ { "classification.startDateTime": { greater_than_equal: dayStart.toISOString() } }, { "classification.startDateTime": { less_than: dayEnd.toISOString() } }, { "ownershipAndStatus.status": { not_in: ["Completed", "Cancelled"] } }, ...(excludeMissionId ? [{ id: { not_equals: excludeMissionId } }] : []), ], }, depth: 0, limit: 1, overrideAccess: true, select: { name: true, codeName: true }, }); return res.docs[0] ?? null; } async function resolveSideOpMap( payload: Awaited>, input: { mapId?: number | null; customMapName?: string | null }, ): Promise { const customName = input.customMapName?.trim(); if (customName) { const exact = await payload.find({ collection: "maps", where: { name: { equals: customName } }, depth: 0, limit: 1, overrideAccess: true, }); if (exact.docs.length > 0) return exact.docs[0].id; const all = await payload.find({ collection: "maps", depth: 0, limit: 500, overrideAccess: true, sort: "name", }); const existing = all.docs.find( (m) => m.name.toLocaleLowerCase() === customName.toLocaleLowerCase(), ); if (existing) return existing.id; const created = await payload.create({ collection: "maps", data: { name: customName, worldSizeWidth: 8192, worldSizeHeight: 8192, basemapMode: "image", }, overrideAccess: true, }); return created.id; } return input.mapId ?? null; } export async function createFridaySideOp( input: FridaySideOpInput, ): Promise> { try { const { payload, user } = await authenticate(); const userId = user.id as number; if (!input.name?.trim() || !input.summary?.trim()) { return { success: false, error: "Name and summary are required." }; } if (!input.mapId && !input.customMapName?.trim()) { return { success: false, error: "Please choose a map." }; } if (!MISSION_TYPES.includes(input.missionType)) { return { success: false, error: "Please choose a valid mission type." }; } if (!isFridayDate(input.startDateTime)) { return { success: false, error: "Side operations can only be scheduled on a Friday.", }; } const startDate = new Date(input.startDateTime); if (Number.isNaN(startDate.getTime())) { return { success: false, error: "Invalid start date/time." }; } if (startDate.getTime() <= Date.now()) { return { success: false, error: "Side operations must be scheduled in the future." }; } const scheduledAhead = await countUpcomingPlayerMissions(payload, userId); if (scheduledAhead >= MAX_SCHEDULED_AHEAD_MISSIONS) { return { success: false, error: `You already have ${MAX_SCHEDULED_AHEAD_MISSIONS} upcoming missions scheduled. Cancel or complete one before scheduling another.`, }; } const fridayConflict = await findActiveMissionOnFriday(payload, input.startDateTime); if (fridayConflict) { return { success: false, error: `That Friday is already taken by "${missionRef(fridayConflict)}". Only one operation per Friday, first come first served.`, }; } const campaignId = input.noCampaign ? null : ( await resolveSideOpCampaign( payload, { id: userId, username: user.username, displayName: user.displayName }, input.campaignId ?? null, input.newCampaignName ?? null, ) ).campaignId; const resolvedMapId = await resolveSideOpMap(payload, { mapId: input.mapId, customMapName: input.customMapName, }); if (resolvedMapId == null) { return { success: false, error: "Please choose a map." }; } const narrative = narrativeData(input); if (!narrative.success) return narrative; const mission = await payload.create({ collection: "missions", overrideAccess: true, data: { name: input.name.trim(), ...(input.codeName?.trim() ? { codeName: input.codeName.trim() } : {}), summary: input.summary.trim(), operationType: "side", ...(input.coverImageId ? { coverImage: input.coverImageId } : {}), createdBy: userId, ownershipAndStatus: { // Side ops are created as a quiet "Ready" draft. The creator commits // them to "Scheduled" (markSideOpScheduled) to lock the Friday slot // and trigger the roll-call; only then does the mission lifecycle // (auto-start -> auto-complete) drive them. status: "Ready", visibility: "unit", authors: [userId], }, classification: { map: resolvedMapId, missionType: input.missionType, campaign: campaignId, startDateTime: fridayStartDateTime(input.startDateTime), estimatedDuration: input.estimatedDuration ?? 240, }, missionRoles: { maxPlayers: input.maxPlayers ?? 32, }, gameDetails: { serverDetails: { serverIp: "arma.onyxsimple.com", serverPort: 2302, }, ...(input.requiredModsId ? { gameSetup: { requiredMods: input.requiredModsId } } : {}), }, ...narrative.data, }, }); const startIso = fridayStartDateTime(input.startDateTime); await emitGameEvent(payload, { type: EventTypes.MissionSchedule, message: `${user.displayName || user.username} scheduled side operation "${missionRef(mission)}" for ${new Date(startIso).toLocaleString()}.`, actor: userId, targetCollection: "missions", targetId: mission.id, data: { startDateTime: startIso, campaignId }, }); return { success: true, data: { missionId: mission.id } }; } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; return message === "Unauthorized" ? { success: false, error: "You must be logged in." } : { success: false, error: message }; } } export interface UpdateFridaySideOpInput { missionId: number; name?: string; codeName?: string; summary?: string; coverImageId?: number | null; mapId?: number; missionType?: (typeof MISSION_TYPES)[number]; startDateTime?: string; estimatedDuration?: number; maxPlayers?: number; campaignId?: number | null; newCampaignName?: string | null; noCampaign?: boolean; customMapName?: string | null; requiredModsId?: number | null; briefing?: { sectionName: string; details?: Record | null }[] | null; missionObjectives?: FridaySideOpInput["missionObjectives"]; civilianPresence?: FridaySideOpInput["civilianPresence"]; factionsInvolved?: FridaySideOpInput["factionsInvolved"]; radioPlan?: Record | null; insertion?: InfilExfilMethod[] | null; extraction?: InfilExfilMethod[] | null; assetsAvailable?: FridaySideOpInput["assetsAvailable"]; } export async function updateFridaySideOp( input: UpdateFridaySideOpInput, ): Promise> { try { const { payload, user } = await authenticate(); const userId = user.id as number; const mission = await payload.findByID({ collection: "missions", id: input.missionId, depth: 0, }); const canManage = await hasPermission(payload, user, "missions:update"); if (!canManage && !isSideOpOwner(mission, userId)) { return { success: false, error: "Only the player who scheduled this side operation can edit it.", }; } if (["Completed", "Cancelled"].includes(mission.ownershipAndStatus.status)) { return { success: false, error: `This mission is ${mission.ownershipAndStatus.status.toLowerCase()} and can no longer be edited.`, }; } if (input.startDateTime !== undefined) { if (!isFridayDate(input.startDateTime)) { return { success: false, error: "Side operations can only be scheduled on a Friday.", }; } const startDate = new Date(input.startDateTime); if (Number.isNaN(startDate.getTime())) { return { success: false, error: "Invalid start date/time." }; } if (startDate.getTime() <= Date.now()) { return { success: false, error: "Side operations must be scheduled in the future." }; } const fridayConflict = await findActiveMissionOnFriday( payload, input.startDateTime, input.missionId, ); if (fridayConflict) { return { success: false, error: `That Friday is already taken by "${fridayConflict.name}" (${fridayConflict.codeName}). Only one operation per Friday, first come first served.`, }; } } let resolvedCampaignId: number | null = null; let campaignChangeRequested = false; if (input.noCampaign) { campaignChangeRequested = true; } else if (input.campaignId != null || (input.newCampaignName && input.newCampaignName.trim())) { const resolved = await resolveSideOpCampaign( payload, user, input.campaignId ?? null, input.newCampaignName ?? null, ); resolvedCampaignId = resolved.campaignId; campaignChangeRequested = true; } type SideOpClassificationUpdate = { map?: number; missionType?: Mission["classification"]["missionType"]; campaign?: number | null; startDateTime?: string; estimatedDuration?: number; }; const classification: SideOpClassificationUpdate = {}; const data: { name?: string; codeName?: string | null; summary?: string; coverImage?: number | null; classification?: SideOpClassificationUpdate; missionRoles?: { maxPlayers?: number }; gameDetails?: { gameSetup?: { requiredMods?: number | null } }; } = {}; if (input.name !== undefined) data.name = input.name.trim(); if (input.codeName !== undefined) data.codeName = input.codeName.trim() || null; if (input.summary !== undefined) data.summary = input.summary.trim(); if (input.coverImageId !== undefined) data.coverImage = input.coverImageId; if (input.estimatedDuration !== undefined) { classification.estimatedDuration = input.estimatedDuration; } if (input.mapId !== undefined || input.customMapName !== undefined) { const resolvedMapId = await resolveSideOpMap(payload, { mapId: input.mapId, customMapName: input.customMapName, }); if (resolvedMapId != null) { classification.map = resolvedMapId; } } if (input.missionType !== undefined) { classification.missionType = input.missionType; } if (input.startDateTime !== undefined) { classification.startDateTime = fridayStartDateTime(input.startDateTime); } if (campaignChangeRequested) { classification.campaign = resolvedCampaignId; } if (Object.keys(classification).length > 0) { data.classification = classification; } if (input.maxPlayers !== undefined) { data.missionRoles = { maxPlayers: input.maxPlayers }; } if (input.requiredModsId !== undefined) { data.gameDetails = { gameSetup: { requiredMods: input.requiredModsId } }; } const narrative = narrativeData(input); if (!narrative.success) return narrative; Object.assign(data, narrative.data); const updated = await payload.update({ collection: "missions", id: input.missionId, depth: 0, overrideAccess: true, data, }); await emitGameEvent(payload, { type: EventTypes.MissionUpdate, message: `${user.displayName || user.username} updated side operation "${missionRef(updated)}".`, actor: userId, targetCollection: "missions", targetId: updated.id, data: { fields: Object.keys(data) }, }); return { success: true, data: { missionId: updated.id } }; } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; return message === "Unauthorized" ? { success: false, error: "You must be logged in." } : { success: false, error: message }; } } export async function cancelFridaySideOp( missionId: number, ): Promise> { try { const { payload, user } = await authenticate(); const userId = user.id as number; const mission = await payload.findByID({ collection: "missions", id: missionId, depth: 0, }); const canManage = (await hasPermission(payload, user, "missions:update")) || (await hasPermission(payload, user, "missions:delete")); if (!canManage && !isSideOpOwner(mission, userId)) { return { success: false, error: "Only the player who scheduled this side operation can cancel it.", }; } if (["Completed", "Cancelled"].includes(mission.ownershipAndStatus.status)) { return { success: false, error: `This mission is already ${mission.ownershipAndStatus.status.toLowerCase()}.`, }; } const updated = await payload.update({ collection: "missions", id: missionId, depth: 0, overrideAccess: true, data: { ownershipAndStatus: { status: "Cancelled", }, }, }); await emitGameEvent(payload, { type: EventTypes.MissionCancel, message: `${user.displayName || user.username} cancelled side operation "${missionRef(updated)}".`, actor: userId, targetCollection: "missions", targetId: updated.id, }); return { success: true, data: { missionId: updated.id } }; } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; return message === "Unauthorized" ? { success: false, error: "You must be logged in." } : { success: false, error: message }; } } export async function markSideOpScheduled( missionId: number, ): Promise> { try { const { payload, user } = await authenticate(); const userId = user.id as number; const mission = await payload.findByID({ collection: "missions", id: missionId, depth: 0, }); const canManage = await hasPermission(payload, user, "missions:update"); if (!canManage && !isSideOpOwner(mission, userId)) { return { success: false, error: "Only the player who scheduled this side operation can commit it to the schedule.", }; } const status = mission.ownershipAndStatus.status; if (status === "Scheduled") { return { success: true, data: { missionId: mission.id } }; } if (!["Concept", "Planning", "Ready"].includes(status)) { return { success: false, error: status === "Active" ? "This mission is already underway and cannot be scheduled." : `This mission is ${status.toLowerCase()} and can no longer be scheduled.`, }; } const updated = await payload.update({ collection: "missions", id: missionId, depth: 0, overrideAccess: true, data: { ownershipAndStatus: { status: "Scheduled", }, }, }); await emitGameEvent(payload, { type: EventTypes.MissionCommit, message: `${user.displayName || user.username} committed side operation "${missionRef(updated)}" to the schedule. The roll-call will be posted and the mission hooks delivered.`, actor: userId, targetCollection: "missions", targetId: updated.id, }); return { success: true, data: { missionId: updated.id } }; } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; return message === "Unauthorized" ? { success: false, error: "You must be logged in." } : { success: false, error: message }; } } export async function moveFridaySideOpToCampaign( missionId: number, campaignId: number, ): Promise> { try { const { payload, user } = await authenticate(); const userId = user.id as number; const mission = await payload.findByID({ collection: "missions", id: missionId, depth: 0, }); const canManage = await hasPermission(payload, user, "missions:update"); if (!canManage && !isSideOpOwner(mission, userId)) { return { success: false, error: "Only the player who scheduled this side operation can move it.", }; } const campaign = await payload.findByID({ collection: "campaigns", id: campaignId, depth: 0, }); const ownerId = typeof campaign.owner === "object" ? campaign.owner?.id : campaign.owner; if (ownerId !== userId) { return { success: false, error: "You can only move missions into campaigns you own." }; } await payload.update({ collection: "missions", id: missionId, depth: 0, overrideAccess: true, data: { classification: { campaign: campaignId, }, }, }); await emitGameEvent(payload, { type: EventTypes.MissionMoveCampaign, message: `${user.displayName || user.username} moved side operation "${missionRef(mission)}" to campaign "${campaign.name}".`, actor: userId, targetCollection: "missions", targetId: missionId, data: { campaignId }, }); return { success: true, data: { missionId } }; } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; return message === "Unauthorized" ? { success: false, error: "You must be logged in." } : { success: false, error: message }; } } // --------------------------------------------------------------------------- // Role sign-ups // --------------------------------------------------------------------------- export async function signupForMissionRole(input: { missionId: number; roleIndex: number; }): Promise { try { const { payload, user } = await authenticate(); const userId = user.id as number; const mission = await payload .findByID({ collection: "missions", id: input.missionId, depth: 0, user, overrideAccess: false, }) .catch(() => null); if (!mission) { return { success: false, error: "You do not have access to this mission." }; } await signupForRole(payload, { missionId: input.missionId, userId, roleIndex: input.roleIndex, bypassEligibility: isAdminOrDeveloper(user as User), }); return { success: true }; } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; return message === "Unauthorized" ? { success: false, error: "You must be logged in." } : { success: false, error: message }; } } export async function withdrawMissionRoleSignup(input: { missionId: number; }): Promise { try { const { payload, user } = await authenticate(); const userId = user.id as number; const mission = await payload .findByID({ collection: "missions", id: input.missionId, depth: 0, user, overrideAccess: false, }) .catch(() => null); if (!mission) { return { success: false, error: "You do not have access to this mission." }; } await withdrawRoleSignup(payload, { missionId: input.missionId, userId }); return { success: true }; } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; return message === "Unauthorized" ? { success: false, error: "You must be logged in." } : { success: false, error: message }; } } export async function removeMissionRoleSignup(input: { missionId: number; signupId: number; }): Promise { try { const { payload, user } = await authenticate(); const userId = user.id as number; const canManage = await hasPermission(payload, user, "missions:update"); if (!canManage) { return { success: false, error: "You do not have permission to manage role sign-ups." }; } await removeRoleSignup(payload, { missionId: input.missionId, signupId: input.signupId, actorId: userId, }); return { success: true }; } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; return message === "Unauthorized" ? { success: false, error: "You must be logged in." } : { success: false, error: message }; } }