diff --git a/src/app/(protected)/admin/blog/AdminBlog.module.scss b/src/app/(protected)/admin/blog/AdminBlog.module.scss index 73cc3898..7845e79a 100644 --- a/src/app/(protected)/admin/blog/AdminBlog.module.scss +++ b/src/app/(protected)/admin/blog/AdminBlog.module.scss @@ -76,6 +76,14 @@ @include badge-warning; } +.revisionPending { + @include badge-warning; +} + +.revisionDraft { + @include badge-info; +} + .rowAuthor { color: var(--muted); } diff --git a/src/app/(protected)/admin/blog/[slug]/edit/EditPost.module.scss b/src/app/(protected)/admin/blog/[slug]/edit/EditPost.module.scss deleted file mode 100644 index a2ba002c..00000000 --- a/src/app/(protected)/admin/blog/[slug]/edit/EditPost.module.scss +++ /dev/null @@ -1,24 +0,0 @@ -@use "../../../../../../styles/mixins" as *; - -.topBar { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 1.5rem; -} - -.viewLink { - font-size: 0.8125rem; - color: var(--muted); - text-decoration: none; - - &:hover { - color: var(--primary); - } -} - -.error { - text-align: center; - padding: 2rem 1rem; - color: var(--error-text); -} diff --git a/src/app/(protected)/admin/blog/[slug]/edit/page.tsx b/src/app/(protected)/admin/blog/[slug]/edit/page.tsx index cf79597e..e4574eb6 100644 --- a/src/app/(protected)/admin/blog/[slug]/edit/page.tsx +++ b/src/app/(protected)/admin/blog/[slug]/edit/page.tsx @@ -1,76 +1,153 @@ "use client"; -import Link from "next/link"; +import { Check, X as IconX } from "lucide-react"; import { useRouter } from "next/navigation"; import { use, useEffect, useState } from "react"; -import { ExternalLink as IconExternalLink } from "lucide-react"; +import BlogEditor, { BlogEditorData } from "@/components/blog/BlogEditor"; +import BlogEditorHeading from "@/components/blog/BlogEditorHeading"; +import BlogEditorToolbar from "@/components/blog/BlogEditorToolbar"; +import RevisionDiffViewer from "@/components/blog/RevisionDiffViewer"; +import RevisionPanel from "@/components/blog/RevisionPanel"; +import Button from "@/components/shared/Button"; +import ConfirmDialog from "@/components/shared/ConfirmDialog"; +import InlineNotice from "@/components/shared/InlineNotice"; +import { FormSkeletonContent } from "@/components/shared/skeletons/FormSkeleton"; import { expectAppData } from "@/lib/api/result"; import type { BlogStatus } from "@/lib/constants"; -import type { ImageFocalPoint } from "@/lib/imageFocalPoint"; - -import BlogEditor from "@/components/blog/BlogEditor"; -import BackLink from "@/components/shared/BackLink"; - -import styles from "./EditPost.module.scss"; -import { FormSkeletonContent } from "@/components/shared/skeletons/FormSkeleton"; +import { + DEFAULT_IMAGE_FOCAL_POINT, + type ImageFocalPoint, +} from "@/lib/imageFocalPoint"; +import { formatDateTime } from "@/lib/utils"; interface Props { params: Promise<{ slug: string }>; } +interface RevisionData { + title: string; + content: string; + excerpt: string; + coverImage: string; + coverFocalPoint?: ImageFocalPoint; + tags: string[]; + updatedAt: string; + submittedAt: string | null; +} + +interface EditablePost { + title: string; + content: string; + excerpt: string; + coverImage: string; + coverFocalPoint?: ImageFocalPoint; + tags: string[]; + status: BlogStatus; + authors: { userId: string; name: string }[]; + slug: string; + updatedAt: string; + pendingRevision?: RevisionData | null; +} + +type ReviewAction = "approve" | "reject"; + +interface Notice { + message: string; + tone: "error" | "success"; +} + export default function EditBlogPostPage({ params }: Props) { const { slug } = use(params); const router = useRouter(); - const [post, setPost] = useState(null); + const [post, setPost] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); + const [reviewAction, setReviewAction] = useState(null); + const [actionLoading, setActionLoading] = useState(false); + const [notice, setNotice] = useState(null); useEffect(() => { + let cancelled = false; + async function fetchPost() { try { - const res = await fetch(`/api/admin/blog/${slug}`); - const data = await expectAppData(res); - setPost(data.post); + const response = await fetch(`/api/admin/blog/${slug}`); + const data = await expectAppData(response); + if (!cancelled) setPost(data.post); } catch { - setError("Failed to load post."); + if (!cancelled) setError("Failed to load post."); } finally { - setLoading(false); + if (!cancelled) setLoading(false); } } void fetchPost(); + return () => { + cancelled = true; + }; }, [slug]); - const handleSave = async (data: { - title: string; - content: string; - excerpt: string; - coverImage: string; - coverFocalPoint: ImageFocalPoint; - tags: string[]; - status: BlogStatus; - authors: { userId: string; name: string }[]; - }) => { - const res = await fetch(`/api/admin/blog/${slug}`, { + const handleSave = async (data: BlogEditorData) => { + const response = await fetch(`/api/admin/blog/${slug}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), }); - const updated = await expectAppData(res); - // If slug changed (shouldn't normally), redirect + const updated = await expectAppData(response); + setPost(updated.post); + setNotice({ message: "Live post saved.", tone: "success" }); if (updated.post?.slug && updated.post.slug !== slug) { router.push(`/admin/blog/${updated.post.slug}/edit`); } }; + const submitReviewAction = async (action: ReviewAction) => { + setActionLoading(true); + setNotice(null); + try { + const response = await fetch(`/api/admin/blog/${slug}/revision`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action }), + }); + const data = await expectAppData(response); + setPost(data.post); + setReviewAction(null); + setNotice({ + message: + action === "approve" + ? "Changes approved and published." + : "Proposed changes rejected and discarded.", + tone: "success", + }); + } catch { + setReviewAction(null); + setNotice({ + message: + action === "approve" + ? "Failed to approve the revision." + : "Failed to reject the revision.", + tone: "error", + }); + } finally { + setActionLoading(false); + } + }; + + const toolbar = ( + + ); + if (loading) { return (
-
- -
+ {toolbar}
); @@ -79,41 +156,108 @@ export default function EditBlogPostPage({ params }: Props) { if (error || !post) { return (
-

{error || "Post not found."}

- + {toolbar} + {error || "Post not found."}
); } + const revision = post.pendingRevision; + const isSubmitted = Boolean(revision?.submittedAt); + const liveEditorData = { + title: post.title, + content: post.content, + excerpt: post.excerpt, + coverImage: post.coverImage, + coverFocalPoint: post.coverFocalPoint || DEFAULT_IMAGE_FOCAL_POINT, + tags: post.tags, + status: post.status, + authors: post.authors || [], + }; + return (
-
- - {post.status === "published" && ( - - View Published Post - - )} -
+ {toolbar} + + + + {notice && ( + {notice.message} + )} + + {revision && ( + + + + + ) : undefined + } + > + + + )} + + {reviewAction && ( + setReviewAction(null)} + onConfirm={() => void submitReviewAction(reviewAction)} + /> + )}
); } diff --git a/src/app/(protected)/admin/blog/page.tsx b/src/app/(protected)/admin/blog/page.tsx index e2cc763e..a1465e96 100644 --- a/src/app/(protected)/admin/blog/page.tsx +++ b/src/app/(protected)/admin/blog/page.tsx @@ -7,6 +7,7 @@ import { useEffect, useState } from "react"; import { expectAppData } from "@/lib/api/result"; import type { BlogStatus } from "@/lib/constants"; +import { formatShortDate } from "@/lib/utils"; import BackLink from "@/components/shared/BackLink"; import Pagination from "@/components/shared/Pagination"; @@ -25,6 +26,10 @@ interface Post { publishedAt: string | null; createdAt: string; updatedAt: string; + pendingRevision?: { + submittedAt?: string | null; + updatedAt?: string; + } | null; } export default function AdminBlogPage() { @@ -126,17 +131,24 @@ export default function AdminBlogPage() { > {post.status} + {post.pendingRevision?.submittedAt ? ( + + Review Requested + + ) : post.pendingRevision ? ( + + Draft Revision + + ) : null} {post.authors?.map((a) => a.name).join(", ") || "Unknown"} - {new Date( - post.publishedAt || post.createdAt, - ).toLocaleDateString("en-IN", { - year: "numeric", - month: "short", - day: "numeric", - })} + {formatShortDate(post.publishedAt || post.createdAt)} @@ -152,7 +164,7 @@ export default function AdminBlogPage() { href={`/blog/${post.slug}`} className={styles.btnSecondary} target="_blank" - rel="noreferrer" + rel="noopener noreferrer" > View diff --git a/src/app/(protected)/internal/blog/[slug]/edit/EditBlog.module.scss b/src/app/(protected)/internal/blog/[slug]/edit/EditBlog.module.scss deleted file mode 100644 index 81e28d04..00000000 --- a/src/app/(protected)/internal/blog/[slug]/edit/EditBlog.module.scss +++ /dev/null @@ -1,11 +0,0 @@ -@use "../../../../../../styles/mixins" as *; - -.topBar { - margin-bottom: 1.5rem; -} - -.error { - text-align: center; - padding: 2rem 1rem; - color: var(--error-text); -} diff --git a/src/app/(protected)/internal/blog/[slug]/edit/page.tsx b/src/app/(protected)/internal/blog/[slug]/edit/page.tsx index 1a63dce0..31ede5d5 100644 --- a/src/app/(protected)/internal/blog/[slug]/edit/page.tsx +++ b/src/app/(protected)/internal/blog/[slug]/edit/page.tsx @@ -2,15 +2,21 @@ import { use, useEffect, useState } from "react"; +import BlogEditor, { BlogEditorData } from "@/components/blog/BlogEditor"; +import BlogEditorHeading from "@/components/blog/BlogEditorHeading"; +import BlogEditorToolbar from "@/components/blog/BlogEditorToolbar"; +import RevisionPanel from "@/components/blog/RevisionPanel"; +import Button from "@/components/shared/Button"; +import ConfirmDialog from "@/components/shared/ConfirmDialog"; +import InlineNotice from "@/components/shared/InlineNotice"; +import { FormSkeletonContent } from "@/components/shared/skeletons/FormSkeleton"; import { expectAppData } from "@/lib/api/result"; import type { BlogStatus } from "@/lib/constants"; -import type { ImageFocalPoint } from "@/lib/imageFocalPoint"; - -import BlogEditor from "@/components/blog/BlogEditor"; -import BackLink from "@/components/shared/BackLink"; - -import styles from "./EditBlog.module.scss"; -import { FormSkeletonContent } from "@/components/shared/skeletons/FormSkeleton"; +import { + DEFAULT_IMAGE_FOCAL_POINT, + type ImageFocalPoint, +} from "@/lib/imageFocalPoint"; +import { formatDateTime } from "@/lib/utils"; interface Props { params: Promise<{ slug: string }>; @@ -25,6 +31,22 @@ interface EditablePost { tags: string[]; status: BlogStatus; authors: { userId: string; name: string }[]; + pendingRevision?: { + title: string; + content: string; + excerpt: string; + coverImage: string; + coverFocalPoint?: ImageFocalPoint; + tags: string[]; + updatedAt: string; + submittedAt: string | null; + submittedBy: string; + } | null; +} + +interface Notice { + message: string; + tone: "error" | "success"; } export default function EditMyBlogPage({ params }: Props) { @@ -32,6 +54,15 @@ export default function EditMyBlogPage({ params }: Props) { const [post, setPost] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); + const [notice, setNotice] = useState(null); + const [discardDialogOpen, setDiscardDialogOpen] = useState(false); + const [pendingAction, setPendingAction] = useState< + "withdraw" | "discard" | null + >(null); + + const isPublished = post?.status === "published"; + const hasRevision = Boolean(post?.pendingRevision); + const isSubmitted = Boolean(post?.pendingRevision?.submittedAt); useEffect(() => { let cancelled = false; @@ -41,7 +72,7 @@ export default function EditMyBlogPage({ params }: Props) { const response = await fetch(`/api/internal/blog/${slug}`); const data = await expectAppData(response); if (!cancelled) setPost(data.post); - } catch (err) { + } catch (err: unknown) { if (!cancelled) { setError(err instanceof Error ? err.message : "Failed to load blog."); } @@ -56,16 +87,13 @@ export default function EditMyBlogPage({ params }: Props) { }; }, [slug]); - const handleSave = async (data: { - title: string; - content: string; - excerpt: string; - coverImage: string; - coverFocalPoint: ImageFocalPoint; - tags: string[]; - status: BlogStatus; - authors: { userId: string; name: string }[]; - }) => { + useEffect(() => { + if (!notice || notice.tone === "error") return; + const timeout = window.setTimeout(() => setNotice(null), 5000); + return () => window.clearTimeout(timeout); + }, [notice]); + + const handleSave = async (data: BlogEditorData, requestApproval = false) => { const response = await fetch(`/api/internal/blog/${slug}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, @@ -76,18 +104,83 @@ export default function EditMyBlogPage({ params }: Props) { coverImage: data.coverImage, coverFocalPoint: data.coverFocalPoint, tags: data.tags, + requestApproval, }), }); const result = await expectAppData(response); setPost(result.post); + setNotice({ + message: requestApproval + ? "Changes submitted for administrator review." + : isPublished + ? "Draft revision saved." + : "Draft saved.", + tone: "success", + }); }; + const handleWithdrawReview = async () => { + setPendingAction("withdraw"); + setNotice(null); + try { + const response = await fetch(`/api/internal/blog/${slug}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ cancelApproval: true }), + }); + const result = await expectAppData(response); + setPost(result.post); + setNotice({ + message: "Review request withdrawn. The draft is editable again.", + tone: "success", + }); + } catch { + setNotice({ + message: "Failed to withdraw the review request.", + tone: "error", + }); + } finally { + setPendingAction(null); + } + }; + + const handleDiscardRevision = async () => { + setPendingAction("discard"); + setNotice(null); + try { + const response = await fetch(`/api/internal/blog/${slug}`, { + method: "DELETE", + }); + const result = await expectAppData(response); + setPost(result.post); + setDiscardDialogOpen(false); + setNotice({ + message: "Draft changes discarded. The editor now shows live content.", + tone: "success", + }); + } catch { + setDiscardDialogOpen(false); + setNotice({ + message: "Failed to discard the draft changes.", + tone: "error", + }); + } finally { + setPendingAction(null); + } + }; + + const toolbar = ( + + ); + if (loading) { return (
-
- -
+ {toolbar}
); @@ -96,24 +189,143 @@ export default function EditMyBlogPage({ params }: Props) { if (error || !post) { return (
-

{error || "Blog not found."}

- + {toolbar} + {error || "Blog not found."}
); } + const revision = post.pendingRevision; + const initialEditorData = + isPublished && revision + ? { + title: revision.title, + content: revision.content, + excerpt: revision.excerpt, + coverImage: revision.coverImage, + coverFocalPoint: + revision.coverFocalPoint || DEFAULT_IMAGE_FOCAL_POINT, + tags: revision.tags, + status: post.status, + authors: post.authors, + } + : { + title: post.title, + content: post.content, + excerpt: post.excerpt, + coverImage: post.coverImage, + coverFocalPoint: post.coverFocalPoint || DEFAULT_IMAGE_FOCAL_POINT, + tags: post.tags, + status: post.status, + authors: post.authors, + }; + return (
-
- -
- + + {notice && ( + {notice.message} + )} + + {isPublished && ( + + {isSubmitted && ( + + )} + + + ) : undefined + } + /> + )} + + {!isSubmitted && ( + handleSave(data, false)} + saveButtonLabel={isPublished ? "Save Draft Revision" : "Save Changes"} + secondaryButton={ + isPublished + ? { + label: "Request Approval to Publish", + onClick: (data) => handleSave(data, true), + variant: "primary", + } + : undefined + } + canManageAuthors={false} + canManageStatus={false} + uploadEndpoint={`/api/internal/blog/upload-image?slug=${encodeURIComponent(slug)}`} + /> + )} + + {discardDialogOpen && ( + setDiscardDialogOpen(false)} + onConfirm={() => void handleDiscardRevision()} + /> + )}
); } diff --git a/src/app/(protected)/internal/dashboard/Dashboard.module.scss b/src/app/(protected)/internal/dashboard/Dashboard.module.scss index b74f9577..55c1c38b 100644 --- a/src/app/(protected)/internal/dashboard/Dashboard.module.scss +++ b/src/app/(protected)/internal/dashboard/Dashboard.module.scss @@ -41,12 +41,6 @@ .blogTitleLink { text-decoration: none; - &::after { - content: ""; - position: absolute; - inset: 0; - } - &:hover { color: var(--primary); } @@ -54,15 +48,8 @@ .editBlogLink { position: relative; - z-index: 1; - display: inline-flex; - flex: 0 0 auto; - color: var(--muted); - padding: 0.125rem; - - &:hover { - color: var(--primary); - } + z-index: 2; + @include icon-button(32px); } .blogDescription { @@ -72,6 +59,27 @@ color: var(--muted); } -.blogStatus { +.blogStatuses { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.375rem; +} + +.blogStatus, +.blogRevisionStatus { @include badge-chip; } + +.blogPublished { + @include badge-success; +} + +.blogRevisionStatus { + @include badge-info; +} + +.blogDraft, +.blogReviewStatus { + @include badge-warning; +} diff --git a/src/app/(protected)/internal/dashboard/page.tsx b/src/app/(protected)/internal/dashboard/page.tsx index 1e782856..2aa87f1b 100644 --- a/src/app/(protected)/internal/dashboard/page.tsx +++ b/src/app/(protected)/internal/dashboard/page.tsx @@ -1,3 +1,4 @@ +import mongoose from "mongoose"; import { auth } from "@/lib/auth"; import { headers } from "next/headers"; import Link from "next/link"; @@ -29,10 +30,17 @@ export default async function DashboardPage() { const userIsAdmin = isHead(user.access); await dbConnect(); - const myBlogs = await BlogPost.find({ - "authors.userId": user.id, - }) - .select("title slug excerpt status updatedAt") + const authorQuery = mongoose.Types.ObjectId.isValid(user.id) + ? [ + { "authors.userId": new mongoose.Types.ObjectId(String(user.id)) }, + { "authors.userId": String(user.id) }, + ] + : [{ "authors.userId": String(user.id) }]; + + const myBlogs = await BlogPost.find({ $or: authorQuery }) + .select( + "title slug excerpt status updatedAt pendingRevision.submittedAt pendingRevision.updatedAt", + ) .sort({ updatedAt: -1 }) .lean(); @@ -103,11 +111,15 @@ export default async function DashboardPage() {
{myBlogs.map((post) => { const isDraft = post.status === "draft"; - const canEdit = userIsAdmin || isDraft; const editHref = userIsAdmin ? `/admin/blog/${post.slug}/edit` : `/internal/blog/${post.slug}/edit`; + const isSubmitted = Boolean(post.pendingRevision?.submittedAt); + const hasDraftRevision = Boolean( + post.pendingRevision && !post.pendingRevision.submittedAt, + ); + return (
@@ -121,16 +133,14 @@ export default async function DashboardPage() { {post.title} )} - {canEdit && ( - - - - )} + + +

{post.excerpt || @@ -138,7 +148,24 @@ export default async function DashboardPage() { ? "Blog draft" : "Read this published blog post.")}

- {post.status} +
+ + {isDraft ? "draft" : "published"} + + {isSubmitted ? ( + + Review requested + + ) : hasDraftRevision ? ( + + Draft changes saved + + ) : null} +
); })} diff --git a/src/app/(public)/blog/[slug]/BlogPost.module.scss b/src/app/(public)/blog/[slug]/BlogPost.module.scss index 043a132e..dede77ac 100644 --- a/src/app/(public)/blog/[slug]/BlogPost.module.scss +++ b/src/app/(public)/blog/[slug]/BlogPost.module.scss @@ -59,6 +59,12 @@ @include outline-chip; } +.editLink { + @include btn-secondary($btn-xs); + margin-left: auto; + text-decoration: none; +} + .coverWrapper { max-width: 1360px; margin: 40px auto 0; diff --git a/src/app/(public)/blog/[slug]/page.tsx b/src/app/(public)/blog/[slug]/page.tsx index 343d2db5..18b88f37 100644 --- a/src/app/(public)/blog/[slug]/page.tsx +++ b/src/app/(public)/blog/[slug]/page.tsx @@ -1,8 +1,16 @@ -import { notFound } from "next/navigation"; import type { Metadata } from "next"; +import { headers } from "next/headers"; import Link from "next/link"; -import { rankRelatedPosts } from "@/lib/blog/relatedPosts"; +import { notFound } from "next/navigation"; +import { PencilLine as IconEdit } from "lucide-react"; + +import { isBlogAuthor } from "@/lib/access/blog"; +import { isHead } from "@/lib/access/roles"; +import { auth } from "@/lib/auth"; import { extractMarkdownHeadings } from "@/lib/blog/markdownHeadings"; +import { readingTimeLabel } from "@/lib/blog/readingTime"; +import { rankRelatedPosts } from "@/lib/blog/relatedPosts"; +import { tagAccent } from "@/lib/constants"; import dbConnect from "@/lib/mongodb"; import { ogImage, @@ -11,13 +19,12 @@ import { SITE_NAME, SITE_URL, } from "@/lib/seo"; -import { readingTimeLabel } from "@/lib/blog/readingTime"; -import { tagAccent } from "@/lib/constants"; import BlogPost from "@/models/BlogPost"; import ArticleReader from "@/components/blog/ArticleReader"; import BackLink from "@/components/shared/BackLink"; import CompatibleImage from "@/components/shared/CompatibleImage"; import JsonLd from "@/components/shared/JsonLd"; + import styles from "./BlogPost.module.scss"; interface Props { @@ -53,6 +60,17 @@ export default async function BlogPostPage({ params }: Props) { notFound(); } + const session = await auth.api.getSession({ + headers: await headers(), + }); + const user = session?.user; + const userIsAdmin = user ? isHead(user.access) : false; + const userIsAuthor = user ? isBlogAuthor(user, post) : false; + const canEdit = userIsAdmin || userIsAuthor; + const editHref = userIsAdmin + ? `/admin/blog/${post.slug}/edit` + : `/internal/blog/${post.slug}/edit`; + const relatedDocuments = post.tags.length > 0 ? await BlogPost.find({ @@ -196,6 +214,12 @@ export default async function BlogPostPage({ params }: Props) { {wasEdited && ( Updated {updatedDate} )} + {canEdit && ( + + + Edit Article + + )}
diff --git a/src/app/api/admin/blog/[slug]/revision/route.ts b/src/app/api/admin/blog/[slug]/revision/route.ts new file mode 100644 index 00000000..41d956fd --- /dev/null +++ b/src/app/api/admin/blog/[slug]/revision/route.ts @@ -0,0 +1,186 @@ +/** + * POST /api/admin/blog/[slug]/revision - Approve or reject a staged blog revision (admin only) + */ + +import mongoose from "mongoose"; +import { revalidatePath } from "next/cache"; +import { NextRequest } from "next/server"; +import { z } from "zod"; + +import { requireHead } from "@/lib/api/auth"; +import { + parseJson, + parseRouteParams, + type AppErrorCode, +} from "@/lib/api/result"; +import { jsonError, jsonOk, jsonResult } from "@/lib/api/result.server"; +import { slugParamsSchema } from "@/lib/api/schemas/boundary"; +import { auditActor, auditedTransaction } from "@/lib/audit"; +import { + summarizeBlogRevision, + summarizePublicContent, +} from "@/lib/audit/summary"; +import { invalidateCache } from "@/lib/cache"; +import dbConnect from "@/lib/mongodb"; +import { errorToLogMetadata, logger } from "@/lib/utils"; +import BlogPost from "@/models/BlogPost"; + +const revisionActionSchema = z + .object({ + action: z.enum(["approve", "reject"]), + }) + .strict(); + +class RevisionRouteError extends Error { + constructor( + readonly code: AppErrorCode, + message: string, + ) { + super(message); + } +} + +type RouteContext = { params: Promise<{ slug: string }> }; + +export async function POST(request: NextRequest, context: RouteContext) { + try { + const authorization = await requireHead(request); + if (!authorization.ok) return jsonResult(authorization); + const user = authorization.data.user; + + const validatedParams = parseRouteParams( + await context.params, + slugParamsSchema, + ); + if (!validatedParams.ok) return jsonResult(validatedParams); + const { slug } = validatedParams.data; + + const parsedBody = await parseJson(request, revisionActionSchema); + if (!parsedBody.ok) return jsonResult(parsedBody); + const { action } = parsedBody.data; + + await dbConnect(); + const dbSession = await mongoose.startSession(); + let saved; + try { + saved = await auditedTransaction(dbSession, async (transaction) => { + const current = await BlogPost.findOne({ slug }).session(transaction); + if (!current) { + throw new RevisionRouteError("NOT_FOUND", "Blog post not found."); + } + if (current.status !== "published") { + throw new RevisionRouteError( + "CONFLICT", + "Only published posts can have revisions reviewed.", + ); + } + const before = current.toObject(); + const rev = current.pendingRevision; + if (!rev) { + throw new RevisionRouteError( + "VALIDATION_ERROR", + "No pending revision found for this post.", + ); + } + if (!rev.submittedAt) { + throw new RevisionRouteError( + "CONFLICT", + "This revision has not been submitted for review.", + ); + } + + if (action === "approve") { + if ( + !rev.baseUpdatedAt || + rev.baseUpdatedAt.getTime() !== current.updatedAt.getTime() + ) { + throw new RevisionRouteError( + "CONFLICT", + "The live post changed after this revision was started. Discard it and create a new revision before approval.", + ); + } + + current.set({ + title: rev.title ?? current.title, + content: rev.content ?? current.content, + excerpt: rev.excerpt ?? current.excerpt, + coverImage: rev.coverImage ?? current.coverImage, + coverFocalPoint: rev.coverFocalPoint ?? current.coverFocalPoint, + tags: rev.tags ?? current.tags, + pendingRevision: null, + }); + await current.save({ session: transaction }); + + return { + result: current, + audit: { + actor: auditActor(user), + category: "blog" as const, + action: "update" as const, + operation: "blog.revision.approve", + target: { + type: "blog-post", + id: String(current._id), + label: current.title, + }, + before: summarizePublicContent( + before as unknown as Record, + ), + after: summarizePublicContent( + current.toObject() as unknown as Record, + ), + }, + }; + } else { + const revisionBefore = rev.toObject?.() || rev; + current.set({ pendingRevision: null }); + await current.save({ session: transaction, timestamps: false }); + + return { + result: current, + audit: { + actor: auditActor(user), + category: "blog" as const, + action: "delete" as const, + operation: "blog.revision.reject", + target: { + type: "blog-revision", + id: String(current._id), + label: rev.title, + }, + before: summarizeBlogRevision( + revisionBefore as unknown as Record, + ), + after: {}, + }, + }; + } + }); + } finally { + await dbSession.endSession(); + } + + if (action === "approve") { + await invalidateCache("blog"); + await invalidateCache("admin:blog"); + await invalidateCache("home"); + revalidatePath("/"); + revalidatePath(`/blog/${slug}`); + revalidatePath("/sitemap.xml"); + } else { + await invalidateCache("admin:blog"); + } + + return jsonOk({ post: saved.toObject() }); + } catch (err) { + if (err instanceof RevisionRouteError) { + return jsonError(err.code, err.message); + } + logger.error("Admin blog revision action failed", { + route: "POST /api/admin/blog/[slug]/revision", + operation: "process_revision", + ...errorToLogMetadata(err), + }); + return jsonError("INTERNAL_ERROR", "Internal server error."); + } +} diff --git a/src/app/api/admin/blog/[slug]/route.ts b/src/app/api/admin/blog/[slug]/route.ts index 3859bb3b..eea2a3d0 100644 --- a/src/app/api/admin/blog/[slug]/route.ts +++ b/src/app/api/admin/blog/[slug]/route.ts @@ -85,6 +85,7 @@ export async function PATCH(request: NextRequest, context: RouteContext) { const parsedBody = await parseJson(request, jsonObjectSchema); if (!parsedBody.ok) return jsonResult(parsedBody); const body = parsedBody.data; + const wasPublished = post.status === "published"; // Updatable fields if (body.title !== undefined) { @@ -93,9 +94,10 @@ export async function PATCH(request: NextRequest, context: RouteContext) { return jsonError("VALIDATION_ERROR", "Title must be 1-200 characters."); } post.title = title; - // Regenerate slug from new title - const newSlugBase = titleToSlug(title); - if (newSlugBase) post.slug = await uniqueSlug(newSlugBase, slug); + if (!wasPublished) { + const newSlugBase = titleToSlug(title); + if (newSlugBase) post.slug = await uniqueSlug(newSlugBase, slug); + } } if (body.content !== undefined) { @@ -206,6 +208,8 @@ export async function PATCH(request: NextRequest, context: RouteContext) { await invalidateCache("admin:blog"); await invalidateCache("home"); revalidatePath("/"); + revalidatePath(`/blog/${slug}`); + if (saved.slug !== slug) revalidatePath(`/blog/${saved.slug}`); revalidatePath("/sitemap.xml"); return jsonOk({ post: saved.toObject() }); diff --git a/src/app/api/admin/blog/route.ts b/src/app/api/admin/blog/route.ts index 1fad6e6d..df3cc624 100644 --- a/src/app/api/admin/blog/route.ts +++ b/src/app/api/admin/blog/route.ts @@ -74,7 +74,7 @@ export async function GET(request: NextRequest) { const [posts, total] = await Promise.all([ BlogPost.find(filter) .select( - "title slug excerpt tags status publishedAt createdAt updatedAt authors", + "title slug excerpt tags status publishedAt createdAt updatedAt authors pendingRevision.submittedAt pendingRevision.updatedAt", ) .sort({ updatedAt: -1 }) .skip(skip) diff --git a/src/app/api/internal/blog/[slug]/route.ts b/src/app/api/internal/blog/[slug]/route.ts index f5d9ffc0..44521264 100644 --- a/src/app/api/internal/blog/[slug]/route.ts +++ b/src/app/api/internal/blog/[slug]/route.ts @@ -1,20 +1,24 @@ import mongoose from "mongoose"; import { NextRequest } from "next/server"; +import { z } from "zod"; import { canEditBlogDraft } from "@/lib/access/blog"; import { auditActor, auditedTransaction } from "@/lib/audit"; -import { summarizePublicContent } from "@/lib/audit/summary"; +import { + summarizeBlogRevision, + summarizePublicContent, +} from "@/lib/audit/summary"; import { err as appError, ok, parseJson, parseRouteParams, + type AppErrorCode, } from "@/lib/api/result"; import { jsonError, jsonOk, jsonResult } from "@/lib/api/result.server"; -import { jsonObjectSchema, slugParamsSchema } from "@/lib/api/schemas/boundary"; +import { slugParamsSchema } from "@/lib/api/schemas/boundary"; import { auth } from "@/lib/auth"; import { invalidateCache } from "@/lib/cache"; -import { parseImageFocalPoint } from "@/lib/imageFocalPoint"; import dbConnect from "@/lib/mongodb"; import { DEFAULT_TAG_MAX_LENGTH, normalizeTags } from "@/lib/tagUtils"; import { errorToLogMetadata, logger } from "@/lib/utils"; @@ -22,6 +26,75 @@ import BlogPost from "@/models/BlogPost"; type RouteContext = { params: Promise<{ slug: string }> }; +const editableRevisionFields = [ + "title", + "content", + "excerpt", + "coverImage", + "coverFocalPoint", + "tags", +] as const; + +const memberBlogPatchSchema = z + .object({ + title: z.string().trim().min(1).max(200).optional(), + content: z.string().optional(), + excerpt: z.string().trim().max(500).optional(), + coverImage: z.string().optional(), + coverFocalPoint: z + .object({ + x: z.number().min(0).max(1), + y: z.number().min(0).max(1), + }) + .strict() + .optional(), + tags: z.array(z.string()).optional(), + requestApproval: z.boolean().optional().default(false), + cancelApproval: z.boolean().optional().default(false), + }) + .strict() + .superRefine((input, context) => { + const hasEditableFields = editableRevisionFields.some( + (field) => input[field] !== undefined, + ); + if (!hasEditableFields && !input.requestApproval && !input.cancelApproval) { + context.addIssue({ + code: "custom", + message: "At least one editable field or revision action is required.", + }); + } + if (input.requestApproval && input.cancelApproval) { + context.addIssue({ + code: "custom", + message: "Approval cannot be requested and withdrawn together.", + }); + } + if (input.cancelApproval && hasEditableFields) { + context.addIssue({ + code: "custom", + message: "Withdraw the review request before editing the revision.", + }); + } + const normalizedTags = + input.tags === undefined ? [] : normalizeTags(input.tags); + if (normalizedTags.some((tag) => tag.length > DEFAULT_TAG_MAX_LENGTH)) { + context.addIssue({ + code: "custom", + path: ["tags"], + message: `Each tag must be ${DEFAULT_TAG_MAX_LENGTH} characters or fewer.`, + }); + } + }); + +class BlogRouteError extends Error { + constructor( + readonly code: AppErrorCode, + message: string, + ) { + super(message); + } +} + async function getAuthorizedDraft(request: NextRequest, slug: string) { const session = await auth.api.getSession({ headers: request.headers }); if (!session) return appError("UNAUTHENTICATED", "Unauthorized"); @@ -71,97 +144,223 @@ export async function PATCH(request: NextRequest, context: RouteContext) { const result = await getAuthorizedDraft(request, slug); if (!result.ok) return jsonResult(result); - const parsedBody = await parseJson(request, jsonObjectSchema); + const parsedBody = await parseJson(request, memberBlogPatchSchema); if (!parsedBody.ok) return jsonResult(parsedBody); const body = parsedBody.data; - const post = result.data.post; + const user = result.data.user; + const newTags = + body.tags === undefined ? undefined : normalizeTags(body.tags); + const { requestApproval, cancelApproval } = body; - if (body.title !== undefined) { - const title = String(body.title).trim(); - if (!title || title.length > 200) { - return jsonError("VALIDATION_ERROR", "Title must be 1-200 characters."); - } - post.title = title; - } + const dbSession = await mongoose.startSession(); + let saved; + try { + saved = await auditedTransaction(dbSession, async (transaction) => { + const current = await BlogPost.findOne({ slug }).session(transaction); + if (!current) throw new BlogRouteError("NOT_FOUND", "Post not found."); + if (!canEditBlogDraft(user, current)) { + throw new BlogRouteError("FORBIDDEN", "Forbidden"); + } + const before = current.toObject(); - if (body.content !== undefined) { - post.content = String(body.content); - } + if (current.status === "published") { + const existingRev = + current.pendingRevision?.toObject?.() || current.pendingRevision; + const currentBase = existingRev || current; - if (body.excerpt !== undefined) { - const excerpt = String(body.excerpt).trim(); - if (excerpt.length > 500) { - return jsonError( - "VALIDATION_ERROR", - "Excerpt must be 500 characters or fewer.", - ); - } - post.excerpt = excerpt; - } + if (cancelApproval && !existingRev?.submittedAt) { + throw new BlogRouteError( + "CONFLICT", + "There is no submitted revision to withdraw.", + ); + } + if (existingRev?.submittedAt && !cancelApproval) { + throw new BlogRouteError( + "CONFLICT", + "Withdraw the review request before editing the revision.", + ); + } - if (body.coverImage !== undefined) { - post.coverImage = String(body.coverImage); + const now = new Date(); + const updatedRevision = { + title: body.title ?? currentBase.title, + content: body.content ?? currentBase.content, + excerpt: body.excerpt ?? currentBase.excerpt, + coverImage: + body.coverImage !== undefined + ? body.coverImage + : currentBase.coverImage, + coverFocalPoint: + body.coverFocalPoint !== undefined + ? body.coverFocalPoint + : currentBase.coverFocalPoint, + tags: newTags !== undefined ? newTags : currentBase.tags, + baseUpdatedAt: existingRev?.baseUpdatedAt ?? current.updatedAt, + updatedAt: now, + submittedAt: cancelApproval ? null : requestApproval ? now : null, + submittedBy: requestApproval + ? new mongoose.Types.ObjectId(String(user.id)) + : (existingRev?.submittedBy ?? + new mongoose.Types.ObjectId(String(user.id))), + }; + + current.set({ pendingRevision: updatedRevision }); + await current.save({ session: transaction, timestamps: false }); + + return { + result: current, + audit: { + actor: auditActor(user), + category: "blog" as const, + action: existingRev ? ("update" as const) : ("create" as const), + operation: requestApproval + ? "blog.revision.submit" + : cancelApproval + ? "blog.revision.withdraw" + : "blog.revision.update", + target: { + type: "blog-revision", + id: String(current._id), + label: updatedRevision.title, + }, + before: summarizeBlogRevision( + existingRev as unknown as Record | null, + ), + after: summarizeBlogRevision(updatedRevision), + }, + }; + } else { + if (requestApproval || cancelApproval) { + throw new BlogRouteError( + "CONFLICT", + "Approval actions are only available for published posts.", + ); + } + current.set({ + title: body.title ?? current.title, + content: body.content ?? current.content, + excerpt: body.excerpt ?? current.excerpt, + coverImage: + body.coverImage !== undefined + ? body.coverImage + : current.coverImage, + coverFocalPoint: + body.coverFocalPoint !== undefined + ? body.coverFocalPoint + : current.coverFocalPoint, + tags: newTags !== undefined ? newTags : current.tags, + }); + await current.save({ session: transaction }); + + return { + result: current, + audit: { + actor: auditActor(user), + category: "blog" as const, + action: "update" as const, + operation: "blog.draft.update", + target: { + type: "blog-post", + id: String(current._id), + label: current.title, + }, + before: summarizePublicContent( + before as unknown as Record, + ), + after: summarizePublicContent( + current.toObject() as unknown as Record, + ), + }, + }; + } + }); + } finally { + await dbSession.endSession(); } - if (body.coverFocalPoint !== undefined) { - post.coverFocalPoint = parseImageFocalPoint(body.coverFocalPoint); + if (saved.status !== "published") { + await invalidateCache("blog"); } + await invalidateCache("admin:blog"); - if (body.tags !== undefined && Array.isArray(body.tags)) { - post.tags = normalizeTags(body.tags).filter( - (tag) => tag.length <= DEFAULT_TAG_MAX_LENGTH, - ); + return jsonOk({ post: saved.toObject() }); + } catch (err) { + if (err instanceof BlogRouteError) { + return jsonError(err.code, err.message); } + logger.error("Internal blog update failed", { + route: "PATCH /api/internal/blog/[slug]", + operation: "update_draft", + ...errorToLogMetadata(err), + }); + return jsonError("INTERNAL_ERROR", "Internal server error."); + } +} + +export async function DELETE(request: NextRequest, context: RouteContext) { + try { + const validatedParams = parseRouteParams( + await context.params, + slugParamsSchema, + ); + if (!validatedParams.ok) return jsonResult(validatedParams); + const { slug } = validatedParams.data; + const result = await getAuthorizedDraft(request, slug); + if (!result.ok) return jsonResult(result); const dbSession = await mongoose.startSession(); let saved; try { saved = await auditedTransaction(dbSession, async (transaction) => { const current = await BlogPost.findOne({ slug }).session(transaction); - if (!current) throw new Error("Blog draft disappeared during update."); - const before = current.toObject(); - current.set({ - title: post.title, - content: post.content, - excerpt: post.excerpt, - coverImage: post.coverImage, - coverFocalPoint: post.coverFocalPoint, - tags: post.tags, - }); - await current.save({ session: transaction }); + if (!current) throw new BlogRouteError("NOT_FOUND", "Post not found."); + if (!canEditBlogDraft(result.data.user, current)) { + throw new BlogRouteError("FORBIDDEN", "Forbidden"); + } + if (current.status !== "published" || !current.pendingRevision) { + throw new BlogRouteError( + "CONFLICT", + "There is no pending revision to discard.", + ); + } + const revision = + current.pendingRevision.toObject?.() || current.pendingRevision; + current.set({ pendingRevision: null }); + await current.save({ session: transaction, timestamps: false }); + return { result: current, audit: { actor: auditActor(result.data.user), category: "blog" as const, - action: "update" as const, - operation: "blog.draft.update", + action: "delete" as const, + operation: "blog.revision.discard", target: { - type: "blog-post", + type: "blog-revision", id: String(current._id), - label: current.title, + label: revision.title, }, - before: summarizePublicContent( - before as unknown as Record, - ), - after: summarizePublicContent( - current.toObject() as unknown as Record, + before: summarizeBlogRevision( + revision as unknown as Record, ), + after: {}, }, }; }); } finally { await dbSession.endSession(); } - await invalidateCache("blog"); - await invalidateCache("admin:blog"); + await invalidateCache("admin:blog"); return jsonOk({ post: saved.toObject() }); } catch (err) { - logger.error("Internal blog update failed", { - route: "PATCH /api/internal/blog/[slug]", - operation: "update_draft", + if (err instanceof BlogRouteError) { + return jsonError(err.code, err.message); + } + logger.error("Internal blog revision discard failed", { + route: "DELETE /api/internal/blog/[slug]", + operation: "discard_revision", ...errorToLogMetadata(err), }); return jsonError("INTERNAL_ERROR", "Internal server error."); diff --git a/src/components/blog/BlogEditor.module.scss b/src/components/blog/BlogEditor.module.scss index 6239a2bc..21af6701 100644 --- a/src/components/blog/BlogEditor.module.scss +++ b/src/components/blog/BlogEditor.module.scss @@ -94,26 +94,8 @@ .actions { display: flex; + flex-wrap: wrap; gap: 0.75rem; padding-top: 0.5rem; border-top: 1px solid var(--border); } - -.btnPrimary { - @include btn-primary($btn-lg); -} - -.btnSecondary { - @include btn-secondary; - font-size: 0.8125rem; - - &.active { - background: var(--primary-alpha); - border-color: var(--primary); - color: var(--primary); - } -} - -.btnDanger { - @include btn-danger; -} diff --git a/src/components/blog/BlogEditor.tsx b/src/components/blog/BlogEditor.tsx index d95c231b..633d27d3 100644 --- a/src/components/blog/BlogEditor.tsx +++ b/src/components/blog/BlogEditor.tsx @@ -1,10 +1,11 @@ "use client"; import { useState } from "react"; -import MarkdownEditor from "@/components/shared/MarkdownEditor"; import ImageUpload from "@/components/shared/ImageUpload"; -import UserSearch, { UserSearchItem } from "@/components/shared/UserSearch"; +import MarkdownEditor from "@/components/shared/MarkdownEditor"; +import Button from "@/components/shared/Button"; import TagEditor from "@/components/shared/TagEditor"; +import UserSearch, { UserSearchItem } from "@/components/shared/UserSearch"; import { X as IconX } from "lucide-react"; import { BLOG_TAGS, BLOG_STATUSES, type BlogStatus } from "@/lib/constants"; import { @@ -18,6 +19,17 @@ interface BlogAuthor { name: string; } +export interface BlogEditorData { + title: string; + content: string; + excerpt: string; + coverImage: string; + coverFocalPoint: ImageFocalPoint; + tags: string[]; + status: BlogStatus; + authors: BlogAuthor[]; +} + interface BlogEditorProps { initialData?: { title: string; @@ -29,20 +41,18 @@ interface BlogEditorProps { status: BlogStatus; authors: BlogAuthor[]; }; - onSave: (data: { - title: string; - content: string; - excerpt: string; - coverImage: string; - coverFocalPoint: ImageFocalPoint; - tags: string[]; - status: BlogStatus; - authors: BlogAuthor[]; - }) => Promise; + onSave: (data: BlogEditorData) => Promise; isNew?: boolean; canManageAuthors?: boolean; canManageStatus?: boolean; uploadEndpoint?: string; + saveButtonLabel?: string; + secondaryButton?: { + label: string; + onClick: (data: BlogEditorData) => Promise; + disabled?: boolean; + variant?: "primary" | "secondary" | "danger"; + }; } export default function BlogEditor({ @@ -52,6 +62,8 @@ export default function BlogEditor({ canManageAuthors = true, canManageStatus = true, uploadEndpoint = "/api/admin/blog/upload-image", + saveButtonLabel, + secondaryButton, }: BlogEditorProps) { const [title, setTitle] = useState(initialData?.title || ""); const [content, setContent] = useState(initialData?.content || ""); @@ -70,6 +82,17 @@ export default function BlogEditor({ const [saving, setSaving] = useState(false); const [error, setError] = useState(""); + const editorData = (): BlogEditorData => ({ + title, + content, + excerpt, + coverImage, + coverFocalPoint, + tags, + status, + authors, + }); + const addAuthor = (user: UserSearchItem) => { if (authors.some((author) => author.userId === user.id)) return; setAuthors((previous) => [ @@ -90,18 +113,9 @@ export default function BlogEditor({ setSaving(true); setError(""); try { - await onSave({ - title, - content, - excerpt, - coverImage, - coverFocalPoint, - tags, - status, - authors, - }); - } catch (err: any) { - setError(err.message || "Failed to save."); + await onSave(editorData()); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Failed to save."); } finally { setSaving(false); } @@ -113,8 +127,11 @@ export default function BlogEditor({ {/* Title */}
- + setTitle(e.target.value)} @@ -126,8 +143,11 @@ export default function BlogEditor({ {/* Excerpt */}
- +