diff --git a/__tests__/admin/scheduled-reels.service.test.js b/__tests__/admin/scheduled-reels.service.test.js new file mode 100644 index 00000000..0e7b12c3 --- /dev/null +++ b/__tests__/admin/scheduled-reels.service.test.js @@ -0,0 +1,163 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { notifyCreator, cookiesMock } = vi.hoisted(() => ({ + notifyCreator: vi.fn().mockResolvedValue({ + success: true, + notificationId: "stub_notification", + deliveredBy: "stub", + }), + cookiesMock: vi.fn(), +})); + +vi.mock("@/lib/services/creator-notifications", () => ({ + notifyCreatorOfScheduledReelCancellation: notifyCreator, +})); + +vi.mock("next/headers", () => ({ + cookies: cookiesMock, +})); + +beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + process.env.NEXT_PUBLIC_API_URL = "https://api.example.com"; + cookiesMock.mockResolvedValue({ + toString: () => "session=authenticated-session", + }); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + user: { + id: "admin_from_session", + role: "admin", + }, + }), + }) + ); +}); + +describe("scheduled reels service", () => { + it("returns upcoming scheduled reels sorted by go-live time", async () => { + const { listUpcomingScheduledReels } = await import( + "@/lib/services/scheduled-reels" + ); + + const reels = await listUpcomingScheduledReels(); + const timestamps = reels.map((reel) => Date.parse(reel.scheduledFor)); + + expect(reels.length).toBeGreaterThan(0); + expect(timestamps).toEqual([...timestamps].sort((a, b) => a - b)); + expect(reels.every((reel) => reel.status === "scheduled")).toBe(true); + expect( + reels.every((reel) => Date.parse(reel.scheduledFor) > Date.now()) + ).toBe(true); + }); + + it("cancels an upcoming reel and notifies its creator", async () => { + const { cancelScheduledReel, listUpcomingScheduledReels } = await import( + "@/lib/services/scheduled-reels" + ); + const [reel] = await listUpcomingScheduledReels(); + + const result = await cancelScheduledReel(reel.id, { + cancelledBy: "admin_test", + reason: "Editorial review", + }); + + expect(result.reel.status).toBe("cancelled"); + expect(result.reel.cancelledBy).toBe("admin_test"); + expect(result.reel.cancellationReason).toBe("Editorial review"); + expect(notifyCreator).toHaveBeenCalledOnce(); + expect(notifyCreator).toHaveBeenCalledWith( + expect.objectContaining({ + creatorId: reel.creator.id, + reelId: reel.id, + cancelledBy: "admin_test", + reason: "Editorial review", + }) + ); + + const remaining = await listUpcomingScheduledReels(); + expect(remaining.some((item) => item.id === reel.id)).toBe(false); + }); + + it("derives the cancelling administrator from the authenticated server session", async () => { + const { listUpcomingScheduledReels } = await import( + "@/lib/services/scheduled-reels" + ); + const [reel] = await listUpcomingScheduledReels(); + const { cancelScheduledReelAction } = await import( + "@/lib/actions/scheduled-reels" + ); + + const result = await cancelScheduledReelAction( + reel.id, + "Session-authorized cancellation" + ); + + expect(fetch).toHaveBeenCalledWith( + "https://api.example.com/api/auth/me", + expect.objectContaining({ + method: "GET", + headers: { + cookie: "session=authenticated-session", + }, + cache: "no-store", + }) + ); + expect(result.reel.cancelledBy).toBe("admin_from_session"); + expect(notifyCreator).toHaveBeenCalledWith( + expect.objectContaining({ + reelId: reel.id, + cancelledBy: "admin_from_session", + reason: "Session-authorized cancellation", + }) + ); + }); + + it("rejects cancellation when no authenticated session is present", async () => { + cookiesMock.mockResolvedValueOnce({ + toString: () => "", + }); + const { cancelScheduledReelAction } = await import( + "@/lib/actions/scheduled-reels" + ); + + await expect( + cancelScheduledReelAction("reel_scheduled_101", "Unauthorized") + ).rejects.toThrow("Authentication is required to manage scheduled reels."); + expect(notifyCreator).not.toHaveBeenCalled(); + }); + + it("rejects cancellation when the scheduled reel does not exist", async () => { + const { cancelScheduledReel } = await import( + "@/lib/services/scheduled-reels" + ); + + await expect( + cancelScheduledReel("missing_reel", { cancelledBy: "admin_test" }) + ).rejects.toThrow("Scheduled reel was not found."); + expect(notifyCreator).not.toHaveBeenCalled(); + }); + + it("exports the expected scheduling fields for platform integration", async () => { + const { SCHEDULED_REEL_FIELDS } = await import( + "@/lib/services/scheduled-reels" + ); + + expect(SCHEDULED_REEL_FIELDS).toEqual( + expect.objectContaining({ + id: expect.any(String), + creator: expect.any(String), + scheduledFor: expect.any(String), + timezone: expect.any(String), + status: expect.any(String), + cancelledAt: expect.any(String), + cancelledBy: expect.any(String), + cancellationReason: expect.any(String), + }) + ); + }); +}); diff --git a/app/[locale]/admin/page.jsx b/app/[locale]/admin/page.jsx new file mode 100644 index 00000000..37c04ff2 --- /dev/null +++ b/app/[locale]/admin/page.jsx @@ -0,0 +1,350 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { + CalendarClock, + Clock3, + Film, + Loader2, + RefreshCw, + XCircle, +} from "lucide-react"; +import { toast } from "sonner"; +import { PageShell } from "@/components/ui/page-shell"; +import { PageHeader } from "@/components/ui/page-header"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { EmptyState } from "@/components/ui/empty-state"; +import { + cancelScheduledReelAction, + listUpcomingScheduledReelsAction, +} from "@/lib/actions/scheduled-reels"; + +function creatorInitials(name) { + return name + .split(" ") + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]) + .join("") + .toUpperCase(); +} + +export function formatGoLive(timestamp, timezone) { + const date = new Date(timestamp); + + if (Number.isNaN(date.getTime())) { + return "Invalid schedule"; + } + + try { + return new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + hour12: true, + }).format(date); + } catch { + return "Invalid schedule"; + } +} + +export default function ScheduledReelsQueuePage() { + const [reels, setReels] = useState([]); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(""); + const [selectedReel, setSelectedReel] = useState(null); + const [reason, setReason] = useState(""); + const [cancelling, setCancelling] = useState(false); + + const loadQueue = useCallback(async () => { + setLoading(true); + setLoadError(""); + + try { + const upcomingReels = await listUpcomingScheduledReelsAction(); + setReels(upcomingReels); + } catch (error) { + setReels([]); + setLoadError( + error instanceof Error + ? error.message + : "The scheduled reels queue could not be loaded." + ); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadQueue(); + }, [loadQueue]); + + const closeCancelDialog = () => { + if (cancelling) return; + setSelectedReel(null); + setReason(""); + }; + + const confirmCancellation = async () => { + if (!selectedReel) return; + + setCancelling(true); + try { + await cancelScheduledReelAction(selectedReel.id, reason); + await loadQueue(); + setSelectedReel(null); + setReason(""); + toast.success("Scheduled reel cancelled and creator notified."); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "The reel could not be cancelled." + ); + } finally { + setCancelling(false); + } + }; + + return ( + + + + Refresh + + } + /> + + + +
+ Upcoming publishing queue + + {reels.length === 1 + ? "1 reel is scheduled to publish." + : `${reels.length} reels are scheduled to publish.`} + +
+ + +
+ + {loading ? ( +
+ + Loading scheduled reels +
+ ) : loadError ? ( + + Try again + + } + /> + ) : reels.length === 0 ? ( + + ) : ( +
+ + + + Reel + Creator + Go-live time + Status + Actions + + + + {reels.map((reel) => ( + + +
+
+
+
+

+ {reel.caption} +

+

+ {reel.id} +

+
+
+
+ +
+ + + {creatorInitials(reel.creator.name)} + + +
+

+ {reel.creator.name} +

+

+ {reel.creator.email} +

+
+
+
+ +

+ {formatGoLive(reel.scheduledFor, reel.timezone)} +

+

+ {reel.timezone} +

+
+ + Scheduled + + + + +
+ ))} +
+
+
+ )} +
+
+ + { + if (!open) closeCancelDialog(); + }} + > + + + Cancel scheduled reel? + + This removes the reel from the upcoming publishing queue and + notifies its creator. The reel will not be published at its + scheduled time. + + + + {selectedReel ? ( +
+
+

+ {selectedReel.caption} +

+

+ {selectedReel.creator.name} ยท {formatGoLive( + selectedReel.scheduledFor, + selectedReel.timezone + )} {selectedReel.timezone} +

+
+ +
+ +