1
0
Fork 0

feat(missions): let side op creators mark their op as Ready

Adds a 'Mark as Ready' control on the mission detail page (owner or missions:update gated) plus the markSideOpReady server action. Status-gated to Concept/Planning/Scheduled; emits a mission:ready event. Stops the daily ready-reminders and lets missionEmbeds post the roll-call.
This commit is contained in:
Jason Fraley 2026-09-07 05:07:48 -04:00
parent d757c58c5d
commit 227885daf7
3 changed files with 92 additions and 4 deletions

View file

@ -738,6 +738,70 @@ export async function cancelFridaySideOp(
}
}
export async function markSideOpReady(
missionId: number,
): Promise<ActionResult<{ missionId: number }>> {
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 mark it Ready.",
};
}
const status = mission.ownershipAndStatus.status;
if (status === "Ready") {
return { success: true, data: { missionId: mission.id } };
}
if (!["Concept", "Planning", "Scheduled"].includes(status)) {
return {
success: false,
error:
status === "Active"
? "This mission is already underway and cannot be marked Ready."
: `This mission is ${status.toLowerCase()} and can no longer be marked Ready.`,
};
}
const updated = await payload.update({
collection: "missions",
id: missionId,
depth: 0,
overrideAccess: true,
data: {
ownershipAndStatus: {
status: "Ready",
},
},
});
await emitGameEvent(payload, {
type: EventTypes.MissionReady,
message: `${user.displayName || user.username} marked side operation "${updated.name}" (${updated.codeName}) as Ready — details are locked in and the roll-call can be posted.`,
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,

View file

@ -3,10 +3,13 @@
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { PencilIcon, XCircleIcon } from "lucide-react";
import { CheckCircleIcon, PencilIcon, XCircleIcon } from "lucide-react";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { Button } from "@/components/ui/button";
import { cancelFridaySideOp } from "@/app/(frontend)/intelligence/missions/actions";
import {
cancelFridaySideOp,
markSideOpReady,
} from "@/app/(frontend)/intelligence/missions/actions";
import {
CreateSideOpDialog,
type SideOpDialogInitial,
@ -34,6 +37,19 @@ export function SideOpControls({
const [isPending, startTransition] = useTransition();
const isTerminal = status === "Completed" || status === "Cancelled";
const canMarkReady = ["Concept", "Planning", "Scheduled"].includes(status);
function handleMarkReady() {
startTransition(async () => {
const result = await markSideOpReady(initial.missionId);
if (!result.success) {
toast.error(result.error ?? "Failed to mark the side operation as Ready.");
return;
}
toast.success("Side operation marked as Ready. Roll-call can now be posted.");
router.refresh();
});
}
function handleCancel() {
if (!confirmingCancel) {
@ -64,6 +80,12 @@ export function SideOpControls({
</ItemHeader>
<ItemContent>
<div className="flex flex-wrap gap-2">
{canMarkReady && (
<Button size="sm" variant="default" onClick={handleMarkReady} disabled={isPending}>
<CheckCircleIcon className="size-3.5" />
Mark as Ready
</Button>
)}
<Button size="sm" variant="outline" onClick={() => setEditOpen(true)} disabled={isPending}>
<PencilIcon className="size-3.5" />
Edit
@ -79,8 +101,9 @@ export function SideOpControls({
</Button>
</div>
<p className="text-xs text-muted-foreground">
Editing covers details, campaign, and narrative. Cancelling frees your scheduling slot
and updates the Discord embed.
Marking the operation Ready locks in the details so the Discord roll-call can be posted
and the mission hooks delivered. Editing covers details, campaign, and narrative.
Cancelling frees your scheduling slot and updates the Discord embed.
</p>
</ItemContent>

View file

@ -126,6 +126,7 @@ export const EventTypes = {
MissionAutoComplete: "mission:auto-complete",
MissionObjectiveRedactedToggle: "mission:objective-redacted-toggle",
MissionSchedule: "mission:schedule",
MissionReady: "mission:ready",
MissionUpdate: "mission:update",
MissionCancel: "mission:cancel",
MissionMoveCampaign: "mission:move-campaign",