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:
parent
d757c58c5d
commit
227885daf7
3 changed files with 92 additions and 4 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue