Skip to content
92 changes: 92 additions & 0 deletions __tests__/admin/scheduled-reels.service.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

const { notifyCreator } = vi.hoisted(() => ({
notifyCreator: vi.fn().mockResolvedValue({
success: true,
notificationId: "stub_notification",
deliveredBy: "stub",
}),
}));

vi.mock("@/lib/services/creator-notifications", () => ({
notifyCreatorOfScheduledReelCancellation: notifyCreator,
}));

beforeEach(() => {
vi.resetModules();
notifyCreator.mockClear();
});

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("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),
})
);
});
});
271 changes: 271 additions & 0 deletions app/[locale]/admin/page.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
"use client";
Comment thread
coderabbitai[bot] marked this conversation as resolved.

import { useCallback, useEffect, useState } from "react";
import { CalendarClock, Clock3, Film, Loader2, RefreshCw, XCircle } from "lucide-react";
import { format } from "date-fns";
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 {
cancelScheduledReel,
listUpcomingScheduledReels,
} from "@/lib/services/scheduled-reels";

function creatorInitials(name) {
return name
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0])
.join("")
.toUpperCase();
}

function formatGoLive(timestamp) {
const date = new Date(timestamp);
return Number.isNaN(date.getTime())
? "Invalid schedule"
: format(date, "MMM d, yyyy 'at' h:mm a");
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

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 listUpcomingScheduledReels();
setReels(upcomingReels);
} catch {
setReels([]);
setLoadError("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 cancelScheduledReel(selectedReel.id, {
cancelledBy: "current-admin",
reason,
});
setReels((current) =>
current.filter((reel) => reel.id !== selectedReel.id)
);
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 (
<PageShell>
<PageHeader
title="Scheduled reels"
description="Review and manage reels waiting to be published. Upcoming items are ordered by go-live time."
icon={CalendarClock}
actions={
<Button variant="outline" onClick={loadQueue} disabled={loading}>
<RefreshCw className={loading ? "animate-spin" : ""} />
Refresh
</Button>
}
/>

<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle>Upcoming publishing queue</CardTitle>
<CardDescription>
{reels.length === 1
? "1 reel is scheduled to publish."
: `${reels.length} reels are scheduled to publish.`}
</CardDescription>
</div>
<Badge variant="secondary" className="shrink-0">
<Clock3 className="mr-1 h-3.5 w-3.5" />
Earliest first
</Badge>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex min-h-64 items-center justify-center" role="status">
<Loader2 className="h-7 w-7 animate-spin text-accent" />
<span className="sr-only">Loading scheduled reels</span>
</div>
) : loadError ? (
<EmptyState
icon={XCircle}
title="Unable to load the queue"
description={loadError}
action={
<Button variant="outline" onClick={loadQueue}>
Try again
</Button>
}
/>
) : reels.length === 0 ? (
<EmptyState
icon={Film}
title="No scheduled reels"
description="There are no upcoming reels waiting to be published. New scheduled items will appear here automatically."
/>
) : (
<div className="overflow-x-auto rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Reel</TableHead>
<TableHead>Creator</TableHead>
<TableHead>Go-live time</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{reels.map((reel) => (
<TableRow key={reel.id}>
<TableCell className="min-w-72">
<div className="flex items-center gap-3">
<div className="flex h-12 w-16 shrink-0 items-center justify-center rounded-lg bg-secondary/10">
<Film className="h-5 w-5 text-accent" aria-hidden="true" />
</div>
<div>
<p className="line-clamp-2 max-w-sm font-medium text-ink">
{reel.caption}
</p>
<p className="mt-1 text-xs text-ink-muted">{reel.id}</p>
</div>
</div>
</TableCell>
<TableCell>
<div className="flex min-w-52 items-center gap-3">
<Avatar className="h-9 w-9">
<AvatarFallback>{creatorInitials(reel.creator.name)}</AvatarFallback>
</Avatar>
<div>
<p className="font-medium text-ink">{reel.creator.name}</p>
<p className="text-xs text-ink-muted">{reel.creator.email}</p>
</div>
</div>
</TableCell>
<TableCell className="min-w-56">
<p className="font-medium text-ink">{formatGoLive(reel.scheduledFor)}</p>
<p className="mt-1 text-xs text-ink-muted">{reel.timezone}</p>
</TableCell>
<TableCell>
<Badge variant="outline">Scheduled</Badge>
</TableCell>
<TableCell className="text-right">
<Button
variant="destructive"
size="sm"
onClick={() => setSelectedReel(reel)}
aria-label={`Cancel scheduled reel by ${reel.creator.name}`}
>
<XCircle />
Cancel
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>

<Dialog open={Boolean(selectedReel)} onOpenChange={(open) => !open && closeCancelDialog()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Cancel scheduled reel?</DialogTitle>
<DialogDescription>
This removes the reel from the publishing queue. The creator will be notified about the cancellation.
</DialogDescription>
</DialogHeader>

{selectedReel && (
<div className="space-y-4">
<div className="rounded-lg border bg-muted/30 p-4">
<p className="font-medium text-ink">{selectedReel.caption}</p>
<p className="mt-2 text-sm text-ink-muted">
By {selectedReel.creator.name} · {formatGoLive(selectedReel.scheduledFor)}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="cancellation-reason">Reason for cancellation (optional)</Label>
<Textarea
id="cancellation-reason"
value={reason}
onChange={(event) => setReason(event.target.value)}
placeholder="Add context for the creator"
maxLength={500}
disabled={cancelling}
/>
</div>
</div>
)}

<DialogFooter>
<Button variant="outline" onClick={closeCancelDialog} disabled={cancelling}>
Keep scheduled
</Button>
<Button variant="destructive" onClick={confirmCancellation} disabled={cancelling}>
{cancelling ? <Loader2 className="animate-spin" /> : <XCircle />}
{cancelling ? "Cancelling…" : "Cancel reel"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</PageShell>
);
}
29 changes: 29 additions & 0 deletions lib/services/creator-notifications.js
Original file line number Diff line number Diff line change
@@ -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",
};
}
Loading
Loading