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.`} + + + + + Earliest first + + + + {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 + + + setSelectedReel(reel)} + > + Cancel + + + + ))} + + + + )} + + + + { + 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} + + + + + + Reason for cancellation (optional) + + setReason(event.target.value)} + placeholder="Add context for the creator" + disabled={cancelling} + maxLength={500} + /> + + + ) : null} + + + + Keep scheduled + + + {cancelling ? ( + + ) : null} + {cancelling ? "Cancelling..." : "Cancel reel"} + + + + + + ); +} diff --git a/lib/actions/scheduled-reels.js b/lib/actions/scheduled-reels.js new file mode 100644 index 00000000..9794ff99 --- /dev/null +++ b/lib/actions/scheduled-reels.js @@ -0,0 +1,86 @@ +"use server"; + +import { cookies } from "next/headers"; +import { + cancelScheduledReel, + listUpcomingScheduledReels, +} from "@/lib/services/scheduled-reels"; + +const ADMIN_ROLES = new Set([ + "admin", + "super-admin", + "super_admin", + "superadmin", +]); + +function getApiBaseUrl() { + return process.env.API_URL || process.env.NEXT_PUBLIC_API_URL || ""; +} + +async function requireAdminSession() { + const cookieStore = await cookies(); + const cookieHeader = cookieStore.toString(); + const apiBaseUrl = getApiBaseUrl().replace(/\/$/, ""); + + if (!cookieHeader) { + throw new Error("Authentication is required to manage scheduled reels."); + } + + if (!apiBaseUrl) { + throw new Error("Administrator session verification is unavailable."); + } + + let response; + try { + response = await fetch(`${apiBaseUrl}/api/auth/me`, { + method: "GET", + headers: { + cookie: cookieHeader, + }, + cache: "no-store", + }); + } catch { + throw new Error("Administrator session verification is unavailable."); + } + + if (!response.ok) { + throw new Error("Authentication is required to manage scheduled reels."); + } + + const payload = await response.json(); + const user = payload?.user ?? payload?.data?.user ?? payload?.data ?? payload; + const role = + typeof user?.role === "string" ? user.role : user?.role?.name; + const normalizedRole = role?.toLowerCase(); + + if (!normalizedRole || !ADMIN_ROLES.has(normalizedRole)) { + throw new Error("Administrator access is required to manage scheduled reels."); + } + + const administratorId = user?.id ?? user?._id ?? user?.email; + if (!administratorId) { + throw new Error("The authenticated administrator could not be identified."); + } + + return { + administratorId: String(administratorId), + }; +} + +export async function listUpcomingScheduledReelsAction() { + await requireAdminSession(); + return listUpcomingScheduledReels(); +} + +export async function cancelScheduledReelAction(reelId, reason) { + const { administratorId } = await requireAdminSession(); + + if (typeof reelId !== "string" || !reelId.trim()) { + throw new Error("A scheduled reel identifier is required."); + } + + return cancelScheduledReel(reelId, { + cancelledBy: administratorId, + reason: typeof reason === "string" ? reason : "", + }); +} diff --git a/lib/services/creator-notifications.js b/lib/services/creator-notifications.js new file mode 100644 index 00000000..0069392e --- /dev/null +++ b/lib/services/creator-notifications.js @@ -0,0 +1,29 @@ +/** + * Stub notification adapter for creator-facing publishing notifications. + * Replace this implementation with the platform notification API when it is + * available without changing callers of this service. + */ + +/** + * Notify a creator that an administrator cancelled one of their scheduled reels. + * + * @param {Object} notification + * @param {string} notification.creatorId + * @param {string} notification.reelId + * @param {string} notification.reelCaption + * @param {string} notification.scheduledFor + * @param {string} notification.cancelledBy + * @param {string} [notification.reason] + * @returns {Promise<{success: boolean, notificationId: string, deliveredBy: string}>} + */ +export async function notifyCreatorOfScheduledReelCancellation(notification) { + if (!notification?.creatorId || !notification?.reelId) { + throw new Error("Creator and reel identifiers are required for notification."); + } + + return { + success: true, + notificationId: `stub_reel_cancelled_${notification.reelId}`, + deliveredBy: "stub", + }; +} diff --git a/lib/services/scheduled-reels.js b/lib/services/scheduled-reels.js new file mode 100644 index 00000000..15b9a5af --- /dev/null +++ b/lib/services/scheduled-reels.js @@ -0,0 +1,176 @@ +import { notifyCreatorOfScheduledReelCancellation } from "@/lib/services/creator-notifications"; + +/** + * Expected scheduled reel contract for future platform integration. + * + * @typedef {Object} ScheduledReel + * @property {string} id Stable reel identifier. + * @property {string} caption Creator-provided reel caption. + * @property {string|null} thumbnailUrl Optional preview image URL. + * @property {{id: string, name: string, email: string}} creator Reel owner. + * @property {string} scheduledFor ISO-8601 go-live timestamp. + * @property {string} timezone IANA timezone selected by the creator. + * @property {"scheduled"|"publishing"|"published"|"cancelled"} status Publishing state. + * @property {string} createdAt ISO-8601 creation timestamp. + * @property {string} updatedAt ISO-8601 last-update timestamp. + * @property {string|null} cancelledAt ISO-8601 cancellation timestamp when cancelled. + * @property {string|null} cancelledBy Administrator identifier when cancelled. + * @property {string|null} cancellationReason Optional administrator-provided reason. + */ + +export const SCHEDULED_REEL_FIELDS = Object.freeze({ + id: "Stable reel identifier", + caption: "Creator-provided reel caption", + thumbnailUrl: "Optional preview image URL", + creator: "Creator object containing id, name, and email", + scheduledFor: "ISO-8601 go-live timestamp", + timezone: "IANA timezone selected by the creator", + status: "scheduled, publishing, published, or cancelled", + createdAt: "ISO-8601 creation timestamp", + updatedAt: "ISO-8601 last-update timestamp", + cancelledAt: "ISO-8601 cancellation timestamp or null", + cancelledBy: "Cancelling administrator identifier or null", + cancellationReason: "Optional cancellation reason or null", +}); + +const hoursFromNow = (hours) => + new Date(Date.now() + hours * 60 * 60 * 1000).toISOString(); + +/** @type {ScheduledReel[]} */ +let scheduledReels = [ + { + id: "reel_scheduled_103", + caption: "A reminder about maintaining good character in daily life.", + thumbnailUrl: null, + creator: { + id: "creator_103", + name: "Ustadha Maryam J.", + email: "maryam@example.com", + }, + scheduledFor: hoursFromNow(72), + timezone: "Europe/London", + status: "scheduled", + createdAt: hoursFromNow(-12), + updatedAt: hoursFromNow(-12), + cancelledAt: null, + cancelledBy: null, + cancellationReason: null, + }, + { + id: "reel_scheduled_101", + caption: "Three practical ways to prepare for the Friday prayer.", + thumbnailUrl: null, + creator: { + id: "creator_101", + name: "Sheikh Ibrahim", + email: "ibrahim@example.com", + }, + scheduledFor: hoursFromNow(8), + timezone: "Africa/Lagos", + status: "scheduled", + createdAt: hoursFromNow(-30), + updatedAt: hoursFromNow(-4), + cancelledAt: null, + cancelledBy: null, + cancellationReason: null, + }, + { + id: "reel_scheduled_102", + caption: "A short reflection on gratitude and using our time well.", + thumbnailUrl: null, + creator: { + id: "creator_102", + name: "Dr. Amina Yusuf", + email: "amina@example.com", + }, + scheduledFor: hoursFromNow(30), + timezone: "America/New_York", + status: "scheduled", + createdAt: hoursFromNow(-20), + updatedAt: hoursFromNow(-6), + cancelledAt: null, + cancelledBy: null, + cancellationReason: null, + }, +]; + +const cloneReel = (reel) => ({ + ...reel, + creator: { ...reel.creator }, +}); + +/** + * Return only future reels that remain scheduled, sorted by earliest go-live time. + * This in-memory adapter can be replaced by a platform request while preserving + * the return shape. + * + * @returns {Promise} + */ +export async function listUpcomingScheduledReels() { + const now = Date.now(); + + return scheduledReels + .filter( + (reel) => + reel.status === "scheduled" && + Number.isFinite(Date.parse(reel.scheduledFor)) && + Date.parse(reel.scheduledFor) > now + ) + .sort( + (first, second) => + Date.parse(first.scheduledFor) - Date.parse(second.scheduledFor) + ) + .map(cloneReel); +} + +/** + * Cancel a reel before publishing and notify its creator through the stub adapter. + * + * @param {string} reelId + * @param {{cancelledBy: string, reason?: string}} cancellation + * @returns {Promise<{reel: ScheduledReel, notification: {success: boolean, notificationId: string, deliveredBy: string}}>} + */ +export async function cancelScheduledReel(reelId, cancellation) { + const reelIndex = scheduledReels.findIndex((reel) => reel.id === reelId); + + if (reelIndex === -1) { + throw new Error("Scheduled reel was not found."); + } + + const reel = scheduledReels[reelIndex]; + if (reel.status !== "scheduled" || Date.parse(reel.scheduledFor) <= Date.now()) { + throw new Error("This reel can no longer be cancelled before publishing."); + } + + if (!cancellation?.cancelledBy) { + throw new Error("The cancelling administrator is required."); + } + + const cancelledAt = new Date().toISOString(); + const cancelledReel = { + ...reel, + status: "cancelled", + updatedAt: cancelledAt, + cancelledAt, + cancelledBy: cancellation.cancelledBy, + cancellationReason: cancellation.reason?.trim() || null, + }; + + scheduledReels = scheduledReels.map((item) => + item.id === reelId ? cancelledReel : item + ); + + const notification = await notifyCreatorOfScheduledReelCancellation({ + creatorId: reel.creator.id, + reelId: reel.id, + reelCaption: reel.caption, + scheduledFor: reel.scheduledFor, + cancelledBy: cancellation.cancelledBy, + reason: cancellation.reason?.trim() || "", + }); + + return { + reel: cloneReel(cancelledReel), + notification, + }; +}
+ {reel.caption} +
+ {reel.id} +
+ {reel.creator.name} +
+ {reel.creator.email} +
+ {formatGoLive(reel.scheduledFor, reel.timezone)} +
+ {reel.timezone} +
+ {selectedReel.caption} +
+ {selectedReel.creator.name} ยท {formatGoLive( + selectedReel.scheduledFor, + selectedReel.timezone + )} {selectedReel.timezone} +