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( export async function moveFridaySideOpToCampaign(
missionId: number, missionId: number,
campaignId: number, campaignId: number,

View file

@ -3,10 +3,13 @@
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { toast } from "sonner"; 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 { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cancelFridaySideOp } from "@/app/(frontend)/intelligence/missions/actions"; import {
cancelFridaySideOp,
markSideOpReady,
} from "@/app/(frontend)/intelligence/missions/actions";
import { import {
CreateSideOpDialog, CreateSideOpDialog,
type SideOpDialogInitial, type SideOpDialogInitial,
@ -34,6 +37,19 @@ export function SideOpControls({
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const isTerminal = status === "Completed" || status === "Cancelled"; 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() { function handleCancel() {
if (!confirmingCancel) { if (!confirmingCancel) {
@ -64,6 +80,12 @@ export function SideOpControls({
</ItemHeader> </ItemHeader>
<ItemContent> <ItemContent>
<div className="flex flex-wrap gap-2"> <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}> <Button size="sm" variant="outline" onClick={() => setEditOpen(true)} disabled={isPending}>
<PencilIcon className="size-3.5" /> <PencilIcon className="size-3.5" />
Edit Edit
@ -79,8 +101,9 @@ export function SideOpControls({
</Button> </Button>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Editing covers details, campaign, and narrative. Cancelling frees your scheduling slot Marking the operation Ready locks in the details so the Discord roll-call can be posted
and updates the Discord embed. and the mission hooks delivered. Editing covers details, campaign, and narrative.
Cancelling frees your scheduling slot and updates the Discord embed.
</p> </p>
</ItemContent> </ItemContent>

View file

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