Skip to content

(SP: 5) [Frontend] Full blog post Admin with Tiptap editor and publish workflow + DB optimization#408

Merged
ViktorSvertoka merged 17 commits intodevelopfrom
sl/feat/blog-admin
Mar 22, 2026
Merged

(SP: 5) [Frontend] Full blog post Admin with Tiptap editor and publish workflow + DB optimization#408
ViktorSvertoka merged 17 commits intodevelopfrom
sl/feat/blog-admin

Conversation

@LesiaUKR
Copy link
Collaborator

@LesiaUKR LesiaUKR commented Mar 20, 2026

Goal

Admin can fully manage blog content from the admin panel:

  • Posts: create, edit, publish, schedule, delete with Tiptap WYSIWYG editor
  • Authors: create, edit, delete with multilingual profiles, Cloudinary photos, social media links
  • Categories: inline CRUD with display order management

Scope

Posts (Issue #387)

  • Query layer (admin-blog.ts): list, CRUD, publish toggle, inline creation
  • Validation (admin-blog.ts): Zod schemas for post, category, author
  • BlogPostListTable: status badges, publish/unpublish toggle, preview link, delete guard
  • BlogPostForm: create/edit with locale tabs, dirty tracking, inline category/author creation
  • BlogTiptapEditor: headings, lists, checklists (TaskList), code blocks (github-dark), images, links, sticky toolbar
  • BlogPublishControls: draft/publish/schedule workflow
  • BlogImageUpload: Cloudinary upload with preview
  • InlineBlogCategoryForm + InlineBlogAuthorForm: create from post form
  • Preview page: locale-aware rendering with BlogPostRenderer
  • API routes: POST create, PUT update, DELETE draft, PATCH publish toggle, image upload
  • BlogPostRenderer: added taskList/taskItem rendering
  • AdminSidebar: Blog section (Posts, New Post, Authors, Categories)
  • Dependencies: @tiptap/extension-image, extension-link, extension-task-list, extension-task-item

Authors management (Issue #388)

  • Query layer: getAdminBlogAuthorsFull, getAdminBlogAuthorById, updateBlogAuthor, deleteBlogAuthor (with post guard)
  • Validation: updateBlogAuthorSchema, extended createBlogAuthorSchema with optional image/social/bio fields
  • API routes: PUT/DELETE /api/admin/blog/authors/[id]
  • BlogAuthorListTable: photo thumbnail, name, job title, post count, delete guard
  • BlogAuthorForm: locale tabs (name, jobTitle, company, city, bio), profile photo upload, social media editor (platform dropdown + URL)
  • Pages: list, create (/authors/new), edit (/authors/[id])

Categories management (Issue #388)

  • Query layer: getAdminBlogCategoriesFull, getAdminBlogCategoryById, updateBlogCategory, deleteBlogCategory, swapBlogCategoryOrder
  • Validation: updateBlogCategorySchema, swapCategoryOrderSchema, extended createBlogCategorySchema with description
  • API routes: PUT/DELETE /api/admin/blog/categories/[id], POST /api/admin/blog/categories/reorder
  • BlogCategoryManager: single-page inline CRUD with move up/down reordering, inline create/edit forms, delete guard (post count)

Blog DB optimization + public revalidation (Issue #409)

  • Add STATIC_PAGE_REVALIDATE constant (604800s / 7 days) in lib/constants/cache.ts
  • Replace force-dynamic with ISR (revalidate = STATIC_PAGE_REVALIDATE) on /blog/[slug]
  • Unify ISR TTL across all blog pages (list, post, category)
  • Wrap getBlogPostBySlug in React.cache (deduplicate generateMetadata + PostDetails within one render)
  • Add unstable_cache for: getBlogPosts, getBlogPostBySlug, getBlogPostsByCategory, getBlogAuthorByName
  • Switch all public blog routes and APIs (/blog, /blog/[slug], /blog/category/[cat], /api/blog/search, /api/blog/author) to cached query variants
  • Add revalidatePath for public blog pages in author and category admin routes
  • Add revalidateTag('blog-posts') in all admin mutation routes (posts, authors, categories) to invalidate unstable_cache
  • Fix inconsistent revalidatePath patterns (missing [locale] prefix, missing 'page' type)
  • Switch blog list and category pages from getBlogCategories to getCachedBlogCategories

Expected impact

Full blog content management from admin panel. Draft → schedule → publish workflow. Preview before publishing. Author profiles with social links. Category ordering. No Sanity required for any blog operation.

Out of scope

Closes #387
Closes #388
Closes #409

Summary by CodeRabbit

  • New Features

    • Full admin blog management: list, create, edit, preview, publish/schedule, multi-language translations, authors and categories management, and inline creation flows.
    • Rich-text editor with images, links, task lists and inline image upload; image upload UX and dedicated upload control.
    • Admin API endpoints for posts, authors, categories, images with validation, CSRF protection and cache revalidation.
  • Tests

    • Updated tests to match new API paths.
  • Chores

    • Updated editor-related dependencies.

LesiaUKR added 12 commits March 3, 2026 22:40
…ript

- Add FK indexes on blog_posts.author_id and blog_post_categories.category_id
- Add one-time migration script: fetches Sanity data via REST API,
  re-uploads images to Cloudinary, converts Portable Text → Tiptap JSON,
  inserts into 7 blog tables (4 categories, 3 authors, 21 posts)
- Drizzle migration 0028 for index changes

Closes #384, #385
…routes

Swap every blog page, API route, and header component from Sanity GROQ
queries to Drizzle ORM against PostgreSQL. Adds Tiptap JSON renderer,
shared text extraction, and typed query layer for posts/authors/categories.
Fixes runtime crash where /api/blog-author returned Portable Text bio
objects that React tried to render as children.
… workflow (#387)

- Query layer (admin-blog.ts): list, CRUD, publish toggle, inline creation
- Validation (admin-blog.ts): Zod schemas for post, category, author
- BlogPostListTable: status badges, publish/unpublish toggle, preview link, delete guard
- BlogPostForm: create/edit with locale tabs, dirty tracking, inline category/author creation
- BlogTiptapEditor: headings, lists, checklists (TaskList), code blocks (github-dark),
  images, links, sticky toolbar
- BlogPublishControls: draft/publish/schedule workflow
- BlogImageUpload: Cloudinary upload with preview
- InlineBlogCategoryForm + InlineBlogAuthorForm: create from post form
- Preview page: locale-aware rendering with BlogPostRenderer
- API routes: POST create, PUT update, DELETE draft, PATCH publish toggle, image upload
- BlogPostRenderer: added taskList/taskItem rendering for checklists
- AdminSidebar: Blog section added (Posts, New Post, Authors, Categories)
- Dependencies: @tiptap/extension-image, extension-link, extension-task-list, extension-task-item
@vercel
Copy link
Contributor

vercel bot commented Mar 20, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
devlovers-net Ignored Ignored Preview Mar 22, 2026 0:59am

Request Review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 20, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a complete admin blog subsystem: server pages, client admin components (editor, forms, lists), node-runtime admin API routes (CRUD, images, reorder) with origin/CSRF/admin guards, Drizzle admin queries, Zod validation, Tiptap editor/extensions, and ISR/caching for public blog queries.

Changes

Cohort / File(s) Summary
Admin Pages
frontend/app/[locale]/admin/blog/page.tsx, frontend/app/[locale]/admin/blog/new/page.tsx, frontend/app/[locale]/admin/blog/[id]/page.tsx, frontend/app/[locale]/admin/blog/[id]/preview/page.tsx, frontend/app/[locale]/admin/blog/authors/page.tsx, frontend/app/[locale]/admin/blog/categories/page.tsx, frontend/app/[locale]/admin/blog/authors/new/page.tsx, frontend/app/[locale]/admin/blog/authors/[id]/page.tsx
New App Router server pages for listing, creating, editing, previewing posts and managing authors/categories; fetch admin data and issue CSRF tokens; set metadata and ISR where applicable.
Admin API Routes
frontend/app/api/admin/blog/route.ts, frontend/app/api/admin/blog/[id]/route.ts, frontend/app/api/admin/blog/images/route.ts, frontend/app/api/admin/blog/authors/route.ts, frontend/app/api/admin/blog/authors/[id]/route.ts, frontend/app/api/admin/blog/categories/route.ts, frontend/app/api/admin/blog/categories/[id]/route.ts, frontend/app/api/admin/blog/categories/reorder/route.ts
New node-runtime API handlers implementing create/update/delete/patch/reorder and image upload; include origin guard, admin auth, CSRF checks, payload validation, file-size checks, slug conflict handling, revalidation, and structured error responses.
Admin UI Components
frontend/components/admin/blog/BlogPostForm.tsx, .../BlogPostListTable.tsx, .../BlogImageUpload.tsx, .../BlogTiptapEditor.tsx, .../BlogPublishControls.tsx, .../InlineBlogAuthorForm.tsx, .../InlineBlogCategoryForm.tsx, .../BlogAuthorForm.tsx, .../BlogCategoryManager.tsx, .../BlogAuthorListTable.tsx
New client components for post create/edit, Tiptap WYSIWYG with image upload, main image uploader, publish controls (draft/publish/schedule), inline author/category creation, author/category manager, list/table views with publish/delete actions and dirty-tracking.
Admin Sidebar
frontend/components/admin/AdminSidebar.tsx
Added Blog section and child links (Posts, New Post, Authors, Categories) with icons and active-route integration.
DB Query Layer
frontend/db/queries/blog/admin-blog.ts
Large Drizzle module exposing admin list/detail queries, create/update/delete posts with translation upserts and category sync, publish toggle/scheduling, author/category helpers and inline/full CRUD, plus reorder swap.
Validation
frontend/lib/validation/admin-blog.ts
Zod schemas for admin payloads: createBlogPostSchema (with publishMode schedule refine), category/author create & update schemas, and swapCategoryOrderSchema with inferred types.
Public Blog Caching & Queries
frontend/db/queries/blog/*, frontend/lib/constants/cache.ts, frontend/app/[locale]/blog/**
Added cached wrappers (unstable_cache) and STATIC_PAGE_REVALIDATE constant (7 days); switched public blog pages and APIs to cached variants and increased ISR to 7 days.
Renderer & Client Fixes
frontend/components/blog/BlogPostRenderer.tsx, frontend/components/blog/BlogFilters.tsx, frontend/components/blog/BlogHeaderSearch.tsx
Added Tiptap taskList/taskItem rendering; updated fetch endpoints for blog author/search to new paths.
Tests & Package
frontend/lib/tests/*, frontend/package.json, frontend/drizzle/meta/_journal.json
Updated tests to new API paths; bumped/added Tiptap dependencies and extensions; minor newline fix in drizzle metadata.

Sequence Diagram(s)

sequenceDiagram
    participant Browser
    participant Form as BlogPostForm
    participant API as /api/admin/blog
    participant DB as Drizzle/Database
    participant CDN as Cloudinary

    Browser->>Form: Submit form (translations, slug, image, publishMode)
    Form->>API: POST/PUT JSON + x-csrf-token
    API->>API: guard origin, require admin, validate CSRF, parse & validate body
    API->>DB: check slug uniqueness / create or update post, upsert translations, sync categories
    DB-->>API: postId / success
    alt image upload during flow
        Form->>API: POST /api/admin/blog/images (FormData + csrf)
        API->>CDN: uploadImage(file)
        CDN-->>API: { url, publicId }
        API-->>Form: { url, publicId }
    end
    alt publish requested
        API->>DB: toggleBlogPostPublish(postId, isPublished, scheduledPublishAt)
        DB-->>API: success
    end
    API->>Browser: { success: true, postId }
Loading
sequenceDiagram
    participant Browser
    participant Editor as BlogTiptapEditor
    participant API as /api/admin/blog/images
    participant Cloud as Cloudinary

    Browser->>Editor: Click image button -> pick file
    Editor->>API: POST FormData + x-csrf-token
    API->>API: origin guard, admin auth, parse FormData, validate file size
    API->>Cloud: uploadImage(file, { folder: "blog/posts" })
    Cloud-->>API: { url, publicId }
    API-->>Editor: { url, publicId }
    Editor->>Editor: insert image node
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related issues

Possibly related PRs

Suggested labels

enhancement, UI, setup

Suggested reviewers

  • AM1007
  • ViktorSvertoka

"🐰
I hopped through code and planted seeds of posts,
Tiptap trimmed the hedges while the upload toasts,
Locales tucked in snug, authors and categories host,
Drafts and schedules bloom — the admin garden boasts!"

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 8.70% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR comprehensively implements all coding requirements from #387 (Posts CRUD with Tiptap editor, draft/schedule/publish workflows, admin routes, BlogPostForm, BlogTiptapEditor, API routes, query layer) and #388 (Authors and Categories management with full CRUD, delete guards, social links, reordering).
Out of Scope Changes check ✅ Passed All changes are directly aligned with the PR objectives. The only tangential changes are caching improvements (STATIC_PAGE_REVALIDATE, switching to unstable_cache, ISR revalidation changes) which are closely tied to ensuring admin changes properly invalidate and update public blog pages.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the PR's main objectives: implementing full blog post admin functionality with Tiptap editor and publish workflow, plus database optimization via caching.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch sl/feat/blog-admin

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Tip

You can get early access to new features in CodeRabbit.

Enable the early_access setting to enable early access features such as new models, tools, and more.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 15

🧹 Nitpick comments (3)
frontend/app/[locale]/admin/blog/page.tsx (1)

35-35: Minor: extra space in props.

There's a double space between csrfTokenDelete={csrfTokenDelete} and csrfTokenPublish={csrfTokenPublish}.

-        <BlogPostListTable posts={posts} csrfTokenDelete={csrfTokenDelete}  csrfTokenPublish={csrfTokenPublish}/>
+        <BlogPostListTable posts={posts} csrfTokenDelete={csrfTokenDelete} csrfTokenPublish={csrfTokenPublish} />
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/`[locale]/admin/blog/page.tsx at line 35, The JSX line rendering
BlogPostListTable has an extra space between the props csrfTokenDelete and
csrfTokenPublish; update the BlogPostListTable invocation to remove the
duplicate space so props read consecutively (e.g., ensure BlogPostListTable
posts={posts} csrfTokenDelete={csrfTokenDelete}
csrfTokenPublish={csrfTokenPublish}), leaving other props and spacing intact.
frontend/components/admin/blog/BlogImageUpload.tsx (1)

33-38: Redundant CSRF token transmission.

The CSRF token is sent both in the FormData body (line 34) and as a header (line 38). If the server only validates one, consider removing the redundant transmission.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/admin/blog/BlogImageUpload.tsx` around lines 33 - 38, The
CSRF token is being sent twice in BlogImageUpload (csrfToken appended to
FormData via formData.append('csrf_token', csrfToken) and again in fetch headers
as 'x-csrf-token'); remove the redundant transmission by choosing one method
your server expects (prefer headers for AJAX requests) and delete the
formData.append('csrf_token', csrfToken) call (or alternatively remove the
header if your server requires form field validation), leaving only the single
source of truth for csrfToken in the upload flow.
frontend/app/api/admin/blog/[id]/route.ts (1)

113-113: Consider including the post ID in error logging context.

The logError call passes an empty object. Including the post id would improve traceability when debugging failures.

Suggested improvement
-    logError('admin_blog_post_update_failed', error, {});
+    logError('admin_blog_post_update_failed', error, { postId: id });

Apply similarly to lines 171 and 230.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/api/admin/blog/`[id]/route.ts at line 113, The logError call
using the key "admin_blog_post_update_failed" omits the post identifier; update
the call to include the post id in the context object (e.g. pass { postId: id }
or { id } instead of {}) so the error log contains traceable post information;
apply the same change to the other logError calls in this file that handle blog
post operations (the other logError invocations near the post handlers) so they
also include the postId in their context.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@frontend/app/`[locale]/admin/blog/[id]/preview/page.tsx:
- Around line 63-75: The code collapses multiple categoryRows into a single
categoryName using categoryRows[0], losing extra categories; update the preview
to preserve and render all categories instead of picking the first. Replace the
single-value usage of categoryName with a collection derived from categoryRows
(e.g., map over categoryRows titles) and render that list wherever categoryName
was used (in the preview component for the post with id and lang), or
alternatively implement and enforce a primary category field on
blogPostCategories and query that explicitly if a single category must be shown.
Ensure the query and downstream rendering use the list of titles from
blogCategoryTranslations (via categoryRows) rather than an arbitrary first
element.

In `@frontend/app/api/admin/blog/`[id]/route.ts:
- Around line 160-162: After calling deleteBlogPost(id) in the DELETE handler,
call and await revalidatePath for the blog listing and the deleted post routes
to invalidate cache; specifically add await revalidatePath('/blog') and await
revalidatePath(`/blog/${id}`) before returning noStoreJson({ success: true }).
This mirrors the PUT/PATCH handlers' behavior and ensures stale pages are
cleared.

In `@frontend/app/api/admin/blog/route.ts`:
- Around line 83-103: The current flow calls createBlogPost(...) then
toggleBlogPostPublish(...), which is not atomic and can leave a committed draft
if the publish step fails; update the implementation to persist publish state in
the initial insert or wrap both operations in a single transaction so both the
post and its publish metadata (isPublished and scheduledPublishAt derived from
data.publishMode and data.scheduledPublishAt) are saved atomically; locate
usages of createBlogPost and toggleBlogPostPublish in this route and either add
parameters to createBlogPost to accept isPublished/scheduledPublishAt or replace
both calls with a transactional helper that performs the insert and
publish-update together.

In `@frontend/components/admin/AdminSidebar.tsx`:
- Around line 58-69: In AdminSidebar.tsx locate the 'Blog' nav object (the item
with label 'Blog' inside the sidebar items) and fix two things: (1) correct
indentation so the opening brace for the 'Blog' object aligns with other
top-level sections (e.g., the Quiz section) to match project formatting, and (2)
remove the two child entries with hrefs '/admin/blog/authors' and
'/admin/blog/categories' (the items with label 'Authors' and 'Categories') so
users won't hit 404s until those pages exist (alternatively replace their hrefs
with a single placeholder route like '/admin/blog/coming-soon' if you prefer a
visible placeholder).

In `@frontend/components/admin/blog/BlogImageUpload.tsx`:
- Around line 42-52: The JSON parse can throw for non-JSON error responses in
BlogImageUpload; wrap the response parsing so you first check res.ok and then
attempt to parse JSON with a try/catch (or parse as text fallback) before using
data, and if parsing fails include the HTTP status and raw body in setError
(e.g., use res.text() when res.json() throws) so setError and the catch handler
report a descriptive server error rather than a misleading "Network error";
update the block around res.json(), the !res.ok handling, and the catch to
surface status and body via setError and still handle genuine network failures.

In `@frontend/components/admin/blog/BlogPostForm.tsx`:
- Around line 99-102: The scheduledDate state currently converts stored
timestamps to UTC for the datetime-local input and then submits the raw local
string, causing timezone shifts; add two helpers named
toLocalDateTimeInputValue(value: string) and fromLocalDateTimeInputValue(value:
string) to convert stored ISO timestamps into local datetime-local input strings
and back to ISO UTC on submit, use toLocalDateTimeInputValue when initializing
scheduledDate (replace the current new Date(...).toISOString().slice(0,16)
logic) and anywhere you populate the form snapshot (lines around 131-133), and
call fromLocalDateTimeInputValue(scheduledDate) when building the submit payload
(and at the submission points around 292-293) so stored values remain in a
single UTC contract.
- Around line 327-337: The Title label in BlogPostForm is not associated with
its input and several other controls (including the Tiptap body editor and the
category group), so add explicit associations: give the title input an id (e.g.,
`title-${activeLocale}`) and set the label's htmlFor to that id, update
handleTitleChange usage as needed; for the Tiptap body editor add an
aria-labelledby or aria-label tied to a visible label id (e.g.,
`body-${activeLocale}`) so screen readers can announce it; replace the lone
category label with a semantic <fieldset> and <legend> wrapping the category
radio/select controls to group them; and apply the same id/htmlFor or
aria-labelledby fixes to the other affected blocks (the blocks referenced around
lines 341-354, 361-375, 395-418, 441-455, 461-485) so every visible label (and
LocaleTabs where relevant) is programmatically associated with its control.

In `@frontend/components/admin/blog/BlogPostListTable.tsx`:
- Around line 61-62: Replace the single-id boolean states with collections so
multiple in-flight row actions are tracked: change deletingId/setDeletingId and
togglingId/setTogglingId to Set<string> (or Record<string,boolean>) and update
all usages (including the handlers onDeletePost, handleDeleteConfirm,
onTogglePublish or similar functions referenced between lines 66-99) to add the
post id to the set when a request starts and remove it in both success and
error/finally paths; also change UI checks from equality (deletingId === id /
togglingId === id) to membership checks (pendingDeletes.has(id) /
pendingToggles.has(id)) or to disable all row actions if you opt to block
globally. Ensure you initialize state as empty Set, use a functional updater
when mutating sets (copying before add/delete) and preserve proper cleanup so
rows remain disabled only while their request is in flight.

In `@frontend/components/admin/blog/BlogPublishControls.tsx`:
- Around line 7-12: The scheduledDate from the datetime-local input in
BlogPublishControls is timezone-less and will be interpreted by the server in
its own timezone; update the code that submits or handles scheduledDate (e.g.,
the onScheduledDateChange handler or the form submission inside
BlogPublishControls) to convert the local datetime to an ISOUTC timestamp (for
example via new Date(scheduledDate).toISOString()) or append the client's
timezone offset before sending so the server receives an unambiguous UTC time.

In `@frontend/components/admin/blog/BlogTiptapEditor.tsx`:
- Around line 109-123: The image upload handler in BlogTiptapEditor.tsx silently
returns on failed responses and calls res.json() unguarded, which can throw and
hides server error messages; fix by checking res.ok before parsing JSON and
handling non-OK responses: after fetch, if (!res.ok) read the response text or
try parsing JSON to extract the error payload and surface it to the user (e.g.,
show a toast/notification or call editor-specific error UI) instead of returning
early; also wrap the fetch/res.json sequence in try/catch to catch network/parse
errors and surface those via the same user-visible mechanism; look for the
upload block that uses formData, csrfToken, res, data, and
editor.chain().setImage to implement these changes.

In `@frontend/db/queries/blog/admin-blog.ts`:
- Around line 234-240: The admin update path only sets blogPosts.updatedAt when
scalar fields are present in baseUpdate, so translation-only or category-only
edits don't touch updatedAt; modify the update logic around baseUpdate and the
db.update(blogPosts).set(...) call so that whenever translations or categories
are changed (e.g., the code paths that modify translations, categories, or call
the logic in the blocks currently after 242-272), you also set
baseUpdate.updatedAt = new Date() and execute the
db.update(blogPosts).set(baseUpdate).where(eq(blogPosts.id, postId)) (or perform
a minimal update that sets only updatedAt) even when baseUpdate otherwise has no
scalar fields, ensuring updatedAt is refreshed for translation/category-only
edits.
- Around line 219-272: The updateBlogPost function currently performs
baseUpdate, translation upserts (blogPostTranslations), and category
deletes/inserts (blogPostCategories) as separate statements which can leave a
half-applied edit if a later step fails; wrap the entire sequence in a single
database transaction so all statements succeed or the whole change is rolled
back. Specifically, open a transaction at the start of updateBlogPost (use your
DB client's transaction API), run the .update(blogPosts).set(baseUpdate), the
loop of .insert(...).onConflictDoUpdate for blogPostTranslations, and the
delete/insert sequence for blogPostCategories inside that transaction using the
transaction-scoped db/tx object, and commit/rollback via the client’s
transaction mechanism. Ensure you use the same transaction handle for every db
call in updateBlogPost so failures undo prior writes.
- Around line 288-301: toggleBlogPostPublish currently marks posts published
immediately even when opts.scheduledPublishAt is provided; change the update
logic in toggleBlogPostPublish so that if opts.scheduledPublishAt is set the row
is left unpublished (isPublished = false and publishedAt = null) and only
scheduledPublishAt is written, and only when there is no scheduledPublishAt and
opts.isPublished === true should you set isPublished = true and publishedAt =
now; also ensure clearing behavior (e.g., when unpublishing or removing a
schedule) still clears scheduledPublishAt and updates updatedAt accordingly.

In `@frontend/lib/validation/admin-blog.ts`:
- Around line 3-6: The blogTranslationSchema currently allows any value for body
via z.unknown().default(null); change this to validate the editor JSON by
replacing body with a required schema matching Tiptap's JSONContent structure
(e.g., an object with required keys like type:string and content:array, plus
nested node/schema shapes) instead of defaulting to null, and remove the
.default(null) so missing bodies fail validation; update any related validation
helpers such as getMissingBodies() to align with the new required body shape and
ensure functions that consume body expect the validated JSONContent type.

In `@frontend/package.json`:
- Around line 38-45: The package.json entry for `@tiptap/core` is pinned at
^3.19.0 while other Tiptap packages (`@tiptap/react`, `@tiptap/starter-kit`, and the
various `@tiptap/extension-`* packages) use ^3.20.x; update the "@tiptap/core"
dependency to ^3.20.0 (or ^3.20.x) so all Tiptap packages share the same
major/minor version, then refresh the lockfile by running npm install / yarn
install to ensure peer deps resolve correctly.

---

Nitpick comments:
In `@frontend/app/`[locale]/admin/blog/page.tsx:
- Line 35: The JSX line rendering BlogPostListTable has an extra space between
the props csrfTokenDelete and csrfTokenPublish; update the BlogPostListTable
invocation to remove the duplicate space so props read consecutively (e.g.,
ensure BlogPostListTable posts={posts} csrfTokenDelete={csrfTokenDelete}
csrfTokenPublish={csrfTokenPublish}), leaving other props and spacing intact.

In `@frontend/app/api/admin/blog/`[id]/route.ts:
- Line 113: The logError call using the key "admin_blog_post_update_failed"
omits the post identifier; update the call to include the post id in the context
object (e.g. pass { postId: id } or { id } instead of {}) so the error log
contains traceable post information; apply the same change to the other logError
calls in this file that handle blog post operations (the other logError
invocations near the post handlers) so they also include the postId in their
context.

In `@frontend/components/admin/blog/BlogImageUpload.tsx`:
- Around line 33-38: The CSRF token is being sent twice in BlogImageUpload
(csrfToken appended to FormData via formData.append('csrf_token', csrfToken) and
again in fetch headers as 'x-csrf-token'); remove the redundant transmission by
choosing one method your server expects (prefer headers for AJAX requests) and
delete the formData.append('csrf_token', csrfToken) call (or alternatively
remove the header if your server requires form field validation), leaving only
the single source of truth for csrfToken in the upload flow.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 227e29fe-19ef-424e-a21d-bc3d2ce51ab4

📥 Commits

Reviewing files that changed from the base of the PR and between e5a8ff6 and 3919bdf.

⛔ Files ignored due to path filters (1)
  • frontend/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (22)
  • frontend/app/[locale]/admin/blog/[id]/page.tsx
  • frontend/app/[locale]/admin/blog/[id]/preview/page.tsx
  • frontend/app/[locale]/admin/blog/new/page.tsx
  • frontend/app/[locale]/admin/blog/page.tsx
  • frontend/app/api/admin/blog/[id]/route.ts
  • frontend/app/api/admin/blog/authors/route.ts
  • frontend/app/api/admin/blog/categories/route.ts
  • frontend/app/api/admin/blog/images/route.ts
  • frontend/app/api/admin/blog/route.ts
  • frontend/components/admin/AdminSidebar.tsx
  • frontend/components/admin/blog/BlogImageUpload.tsx
  • frontend/components/admin/blog/BlogPostForm.tsx
  • frontend/components/admin/blog/BlogPostListTable.tsx
  • frontend/components/admin/blog/BlogPublishControls.tsx
  • frontend/components/admin/blog/BlogTiptapEditor.tsx
  • frontend/components/admin/blog/InlineBlogAuthorForm.tsx
  • frontend/components/admin/blog/InlineBlogCategoryForm.tsx
  • frontend/components/blog/BlogPostRenderer.tsx
  • frontend/db/queries/blog/admin-blog.ts
  • frontend/drizzle/meta/_journal.json
  • frontend/lib/validation/admin-blog.ts
  • frontend/package.json

Comment on lines +63 to +75
const categoryRows = await db
.select({ title: blogCategoryTranslations.title })
.from(blogPostCategories)
.innerJoin(
blogCategoryTranslations,
and(
eq(blogCategoryTranslations.categoryId, blogPostCategories.categoryId),
eq(blogCategoryTranslations.locale, lang)
)
)
.where(eq(blogPostCategories.postId, id));

const categoryName = categoryRows[0]?.title ?? null;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't collapse a multi-category post to an arbitrary first row.

BlogPostForm allows selecting multiple categories, but this page keeps only categoryRows[0] and the query has no explicit ordering. With more than one category attached, the preview label is effectively arbitrary and the other assignments disappear. Render all categories here, or introduce a real primary-category rule.

Also applies to: 115-119

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/`[locale]/admin/blog/[id]/preview/page.tsx around lines 63 - 75,
The code collapses multiple categoryRows into a single categoryName using
categoryRows[0], losing extra categories; update the preview to preserve and
render all categories instead of picking the first. Replace the single-value
usage of categoryName with a collection derived from categoryRows (e.g., map
over categoryRows titles) and render that list wherever categoryName was used
(in the preview component for the post with id and lang), or alternatively
implement and enforce a primary category field on blogPostCategories and query
that explicitly if a single category must be shown. Ensure the query and
downstream rendering use the list of titles from blogCategoryTranslations (via
categoryRows) rather than an arbitrary first element.

Comment on lines +83 to +103
const postId = await createBlogPost({
slug: data.slug,
authorId: data.authorId,
mainImageUrl: data.mainImageUrl,
mainImagePublicId: data.mainImagePublicId,
tags: data.tags,
resourceLink: data.resourceLink,
translations: data.translations as Record<string, { title: string; body: unknown }>,
categoryIds: data.categoryIds,
});

// Apply publish state if not draft
if (data.publishMode !== 'draft') {
await toggleBlogPostPublish(postId, {
isPublished: true,
scheduledPublishAt:
data.publishMode === 'schedule' && data.scheduledPublishAt
? new Date(data.scheduledPublishAt)
: null,
});
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Create + publish isn't atomic.

createBlogPost() commits first and toggleBlogPostPublish() runs afterward. If the second write fails, this endpoint returns 500 but the draft already exists, and retries will likely trip the slug-conflict path. Persist both steps in one transaction or include the publish state in the initial insert.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/api/admin/blog/route.ts` around lines 83 - 103, The current flow
calls createBlogPost(...) then toggleBlogPostPublish(...), which is not atomic
and can leave a committed draft if the publish step fails; update the
implementation to persist publish state in the initial insert or wrap both
operations in a single transaction so both the post and its publish metadata
(isPublished and scheduledPublishAt derived from data.publishMode and
data.scheduledPublishAt) are saved atomically; locate usages of createBlogPost
and toggleBlogPostPublish in this route and either add parameters to
createBlogPost to accept isPublished/scheduledPublishAt or replace both calls
with a transactional helper that performs the insert and publish-update
together.

Comment on lines +58 to 69
},
{
label: 'Blog',
icon: BookOpen,
basePath: '/admin/blog',
items: [
{ label: 'Posts', href: '/admin/blog', icon: PenLine },
{ label: 'New Post', href: '/admin/blog/new', icon: Plus },
{ label: 'Authors', href: '/admin/blog/authors', icon: Users },
{ label: 'Categories', href: '/admin/blog/categories', icon: FolderOpen },
],
},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Formatting inconsistency and potential 404 links.

  1. Formatting: Lines 58-59 have inconsistent indentation - the opening brace for Blog section should align with the Quiz section above.

  2. Missing routes: The sidebar includes links to /admin/blog/authors and /admin/blog/categories, but the PR objectives note these pages are out of scope. Users clicking these links will see a 404.

Suggested formatting fix
     ],
-  },
-    {
+  },
+  {
     label: 'Blog',

Consider either removing the Author/Categories nav items until those pages are implemented, or adding placeholder pages that indicate "coming soon."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
},
{
label: 'Blog',
icon: BookOpen,
basePath: '/admin/blog',
items: [
{ label: 'Posts', href: '/admin/blog', icon: PenLine },
{ label: 'New Post', href: '/admin/blog/new', icon: Plus },
{ label: 'Authors', href: '/admin/blog/authors', icon: Users },
{ label: 'Categories', href: '/admin/blog/categories', icon: FolderOpen },
],
},
},
{
label: 'Blog',
icon: BookOpen,
basePath: '/admin/blog',
items: [
{ label: 'Posts', href: '/admin/blog', icon: PenLine },
{ label: 'New Post', href: '/admin/blog/new', icon: Plus },
{ label: 'Authors', href: '/admin/blog/authors', icon: Users },
{ label: 'Categories', href: '/admin/blog/categories', icon: FolderOpen },
],
},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/admin/AdminSidebar.tsx` around lines 58 - 69, In
AdminSidebar.tsx locate the 'Blog' nav object (the item with label 'Blog' inside
the sidebar items) and fix two things: (1) correct indentation so the opening
brace for the 'Blog' object aligns with other top-level sections (e.g., the Quiz
section) to match project formatting, and (2) remove the two child entries with
hrefs '/admin/blog/authors' and '/admin/blog/categories' (the items with label
'Authors' and 'Categories') so users won't hit 404s until those pages exist
(alternatively replace their hrefs with a single placeholder route like
'/admin/blog/coming-soon' if you prefer a visible placeholder).

Comment on lines +219 to +272
export async function updateBlogPost(
postId: string,
input: UpdateBlogPostInput
): Promise<void> {
const baseUpdate: Record<string, unknown> = {};
if (input.slug !== undefined) baseUpdate.slug = input.slug;
if (input.authorId !== undefined) baseUpdate.authorId = input.authorId;
if (input.mainImageUrl !== undefined)
baseUpdate.mainImageUrl = input.mainImageUrl;
if (input.mainImagePublicId !== undefined)
baseUpdate.mainImagePublicId = input.mainImagePublicId;
if (input.tags !== undefined) baseUpdate.tags = input.tags;
if (input.resourceLink !== undefined)
baseUpdate.resourceLink = input.resourceLink;

if (Object.keys(baseUpdate).length > 0) {
baseUpdate.updatedAt = new Date();
await db
.update(blogPosts)
.set(baseUpdate)
.where(eq(blogPosts.id, postId));
}

if (input.translations) {
for (const [locale, trans] of Object.entries(input.translations)) {
await db
.insert(blogPostTranslations)
.values({
postId,
locale,
title: trans.title,
body: trans.body,
})
.onConflictDoUpdate({
target: [blogPostTranslations.postId, blogPostTranslations.locale],
set: { title: trans.title, body: trans.body },
});
}
}

if (input.categoryIds !== undefined) {
await db
.delete(blogPostCategories)
.where(eq(blogPostCategories.postId, postId));

if (input.categoryIds.length > 0) {
await db.insert(blogPostCategories).values(
input.categoryIds.map(categoryId => ({
postId,
categoryId,
}))
);
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

This update path can commit half an edit.

The base row update, translation upserts, and category replacement are separate statements. If any later step fails, earlier writes stay committed and the post is left in a mixed state. Run the whole edit inside one DB transaction.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/db/queries/blog/admin-blog.ts` around lines 219 - 272, The
updateBlogPost function currently performs baseUpdate, translation upserts
(blogPostTranslations), and category deletes/inserts (blogPostCategories) as
separate statements which can leave a half-applied edit if a later step fails;
wrap the entire sequence in a single database transaction so all statements
succeed or the whole change is rolled back. Specifically, open a transaction at
the start of updateBlogPost (use your DB client's transaction API), run the
.update(blogPosts).set(baseUpdate), the loop of .insert(...).onConflictDoUpdate
for blogPostTranslations, and the delete/insert sequence for blogPostCategories
inside that transaction using the transaction-scoped db/tx object, and
commit/rollback via the client’s transaction mechanism. Ensure you use the same
transaction handle for every db call in updateBlogPost so failures undo prior
writes.

Comment on lines +3 to +6
const blogTranslationSchema = z.object({
title: z.string().min(1, 'Title is required'),
body: z.unknown().default(null),
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n frontend/lib/validation/admin-blog.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 2372


🏁 Script executed:

rg "CreateBlogPostPayload|blogTranslationSchema" -t ts -t tsx -A 3

Repository: DevLoversTeam/devlovers.net

Length of output: 98


🏁 Script executed:

rg "tiptap|editor|body" --type-list | head -20

Repository: DevLoversTeam/devlovers.net

Length of output: 460


🏁 Script executed:

fd "." frontend/lib/validation -type f | xargs ls -la

Repository: DevLoversTeam/devlovers.net

Length of output: 1301


🏁 Script executed:

rg "CreateBlogPostPayload" -A 3 -B 1

Repository: DevLoversTeam/devlovers.net

Length of output: 406


🏁 Script executed:

rg "body:" --type ts -A 2 -B 1

Repository: DevLoversTeam/devlovers.net

Length of output: 50383


🏁 Script executed:

find frontend -name "*.ts" -o -name "*.tsx" | xargs grep -l "tiptap\|editor" 2>/dev/null | head -10

Repository: DevLoversTeam/devlovers.net

Length of output: 355


🏁 Script executed:

head -100 frontend/lib/admin/tiptap-transforms.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 2862


🏁 Script executed:

head -100 frontend/components/admin/blog/BlogTiptapEditor.tsx

Repository: DevLoversTeam/devlovers.net

Length of output: 2558


🏁 Script executed:

rg "body" frontend/components/admin/blog/BlogPostForm.tsx -A 2 -B 2

Repository: DevLoversTeam/devlovers.net

Length of output: 2045


Validate editor JSON structure instead of accepting unknown.

The body field currently accepts z.unknown().default(null), but the form expects JSONContent (Tiptap's structured format). This allows malformed or arbitrary payloads to pass validation and reach storage. The form also includes getMissingBodies() validation suggesting bodies should be required for all locales; consider removing .default(null) and defining the proper JSONContent shape with required fields.

Also applies to: 16-20

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/validation/admin-blog.ts` around lines 3 - 6, The
blogTranslationSchema currently allows any value for body via
z.unknown().default(null); change this to validate the editor JSON by replacing
body with a required schema matching Tiptap's JSONContent structure (e.g., an
object with required keys like type:string and content:array, plus nested
node/schema shapes) instead of defaulting to null, and remove the .default(null)
so missing bodies fail validation; update any related validation helpers such as
getMissingBodies() to align with the new required body shape and ensure
functions that consume body expect the validated JSONContent type.

…API routes

- Add placeholder pages for Authors/Categories (Issue #388)
- Move blog API routes: blog-author → blog/author, blog-search → blog/search
- Fix scheduled posts being published immediately (isPublished logic)
- Add isScheduling defense-in-depth guard in toggleBlogPostPublish
- Always update updatedAt on save, not only when base fields change
- Add toast.error for failed uploads/requests instead of silent failures
- Add dirty tracking in edit mode (disable Update until changes detected)
- Fix task list rendering in BlogPostRenderer (checkmarks, no bullets)
- Fix TiptapEditor bullet points clipping editor border (pl-2)
- Center Actions column in BlogPostListTable
- Move preview page queries to admin-blog.ts helpers
- Align @tiptap/core to ^3.20.0 to match extension versions
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (5)
frontend/lib/tests/blog/blog-author-route.test.ts (1)

19-19: Update describe block to match new route path.

The describe block still references the old endpoint /api/blog-author, but the import and request URLs have been updated to /api/blog/author.

📝 Suggested fix
-describe('GET /api/blog-author', () => {
+describe('GET /api/blog/author', () => {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/tests/blog/blog-author-route.test.ts` at line 19, Update the
test suite description string to match the new route path: change the
describe(...) title from '/api/blog-author' to '/api/blog/author' so the
describe block accurately reflects the endpoint used by the imports and requests
in this test file (look for the describe('GET /api/blog-author', ...)
declaration in blog-author-route.test.ts).
frontend/lib/tests/blog/blog-search-route.test.ts (1)

19-19: Update describe block to match new route path.

The describe block still references the old endpoint /api/blog-search, but the import and request URL have been updated to /api/blog/search.

📝 Suggested fix
-describe('GET /api/blog-search', () => {
+describe('GET /api/blog/search', () => {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/tests/blog/blog-search-route.test.ts` at line 19, Update the
describe block string to match the new route path: change the test suite label
describe('GET /api/blog-search', ...) to describe('GET /api/blog/search', ...)
so it aligns with the imported route and the request URL used in the tests
(ensure any other occurrences of '/api/blog-search' in this test file are also
updated).
frontend/components/admin/blog/BlogPostListTable.tsx (1)

40-47: Redundant new Date() wrapper.

The date parameter is already typed as Date | null. Wrapping it in new Date(date) at line 42 is unnecessary when the input is already a Date object. This works but adds minor overhead.

Proposed fix
 function formatDate(date: Date | null): string {
   if (!date) return '-';
-  return new Date(date).toLocaleDateString('en-GB', {
+  return date.toLocaleDateString('en-GB', {
     day: '2-digit',
     month: 'short',
     year: 'numeric',
   });
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/admin/blog/BlogPostListTable.tsx` around lines 40 - 47,
The formatDate function unnecessarily wraps an already Date-typed parameter in
new Date(date); update formatDate to call date.toLocaleDateString('en-GB', {
day: '2-digit', month: 'short', year: 'numeric' }) directly when date is
non-null (preserve the existing null-check and return '-' for null) and remove
the new Date(...) wrapper to avoid redundant construction and minor overhead;
refer to the formatDate function to make this change.
frontend/app/api/admin/blog/route.ts (1)

70-81: Slug uniqueness check has a TOCTOU window, but DB constraint provides safety net.

Two concurrent requests could both pass this application-level check before either inserts. The database's unique constraint (per frontend/db/schema/blog.ts line 82) will reject the second insert, but the error will surface as a generic 500 rather than a helpful 409. This is acceptable given low concurrency expectations for admin operations, but consider catching the unique constraint violation and returning DUPLICATE_SLUG for a better UX.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/api/admin/blog/route.ts` around lines 70 - 81, The current slug
uniqueness check (using db.select on blogPosts with eq) has a TOCTOU gap; wrap
the insert that creates a blog post in a try/catch and catch the database
unique-constraint violation for the blogPosts.slug column and convert it into
the existing noStoreJson({ error: 'Slug already exists', code: 'DUPLICATE_SLUG'
}, { status: 409 }) response. Locate the insert logic that writes to blogPosts
(the code that runs after the select check) and in the catch examine the DB
error (e.g., Postgres error code '23505' or the driver/sqlite unique constraint
message) to detect a slug uniqueness failure, then return the 409 DUPLICATE_SLUG
response instead of letting it bubble up as a 500. Ensure other errors are
rethrown or handled normally.
frontend/components/admin/blog/BlogTiptapEditor.tsx (1)

104-133: Consider adding a catch block for network errors.

The error handling for !res.ok responses was improved and now properly surfaces failures via toast. However, network errors (fetch throws) or unexpected exceptions will still propagate as unhandled rejections without user feedback.

🛡️ Proposed fix to catch all failure modes
   async function handleImageUpload(e: React.ChangeEvent<HTMLInputElement>) {
     const file = e.target.files?.[0];
     if (!file || !editor) return;
 
     setUploading(true);
     try {
       const formData = new FormData();
       formData.append('file', file);
       formData.append('csrf_token', csrfToken);
 
       const res = await fetch('/api/admin/blog/images', {
         method: 'POST',
         headers: { 'x-csrf-token': csrfToken },
         body: formData,
       });
 
       if (!res.ok) {
         const data = await res.json().catch(() => ({}));
         toast.error(data.error ?? 'Image upload failed');
         return;
       }
 
       const data = await res.json();
       editor.chain().focus().setImage({ src: data.url }).run();
+    } catch {
+      toast.error('Image upload failed. Please check your connection.');
     } finally {
       setUploading(false);
       if (fileInputRef.current) fileInputRef.current.value = '';
     }
-
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/admin/blog/BlogTiptapEditor.tsx` around lines 104 - 133,
The handleImageUpload function currently only handles HTTP error responses but
not network exceptions; wrap the await fetch and subsequent JSON parsing in a
try/catch (or add a catch after the existing try) so any thrown errors are
caught, call toast.error with a user-friendly message (and optionally
console.error) inside the catch, and keep the existing finally block that calls
setUploading(false) and resets fileInputRef; reference handleImageUpload,
editor, setUploading, toast, and fileInputRef when locating where to add the
catch.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@frontend/app/`[locale]/admin/blog/categories/page.tsx:
- Line 3: Replace the hard-coded English strings: the exported metadata constant
(export const metadata) and the placeholder/copy inside the page component in
page.tsx, with locale-aware values by loading the locale-specific dictionary or
translation function for the [locale] route (e.g., use the route params.locale
or an async getDictionary/getTranslations for the passed locale) and use that to
set the metadata title and the UI placeholder text via the translator (e.g.,
t('categories.title') and t('categories.placeholder')). Ensure metadata is
produced from a localized string (make metadata a localized value or
generateMetadata that reads the locale) and swap all hard-coded English literals
in the page component for translated keys so uk/pl and other locales render
correct text.

In `@frontend/app/api/admin/blog/`[id]/route.ts:
- Around line 82-99: The update and publish operations are not atomic:
updateBlogPost(...) is committed separately from toggleBlogPostPublish(...),
which can leave the database in a partially applied state if the second call
fails; modify the code so both changes occur in a single transaction or a single
repository method (e.g., create a new function updateAndTogglePublish or extend
updateBlogPost to accept publish options) that calls updateBlogPost and
toggleBlogPostPublish within a DB transaction (or performs both updates in one
SQL/ORM call) and only commits if both succeed, rolling back on error and
propagating the failure.

In `@frontend/components/admin/blog/BlogPostListTable.tsx`:
- Around line 16-21: The getStatus function currently returns 'draft' whenever
post.isPublished is false, which prevents detecting scheduled posts; update
getStatus (in BlogPostListTable.tsx) to first check if post.scheduledPublishAt
exists and is in the future (new Date(post.scheduledPublishAt) > new Date()) and
return 'scheduled' in that case, then fall back to testing post.isPublished to
return 'published' or 'draft' (ensure you treat undefined/null
scheduledPublishAt safely and use AdminBlogListItem and BlogStatus types).

---

Nitpick comments:
In `@frontend/app/api/admin/blog/route.ts`:
- Around line 70-81: The current slug uniqueness check (using db.select on
blogPosts with eq) has a TOCTOU gap; wrap the insert that creates a blog post in
a try/catch and catch the database unique-constraint violation for the
blogPosts.slug column and convert it into the existing noStoreJson({ error:
'Slug already exists', code: 'DUPLICATE_SLUG' }, { status: 409 }) response.
Locate the insert logic that writes to blogPosts (the code that runs after the
select check) and in the catch examine the DB error (e.g., Postgres error code
'23505' or the driver/sqlite unique constraint message) to detect a slug
uniqueness failure, then return the 409 DUPLICATE_SLUG response instead of
letting it bubble up as a 500. Ensure other errors are rethrown or handled
normally.

In `@frontend/components/admin/blog/BlogPostListTable.tsx`:
- Around line 40-47: The formatDate function unnecessarily wraps an already
Date-typed parameter in new Date(date); update formatDate to call
date.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year:
'numeric' }) directly when date is non-null (preserve the existing null-check
and return '-' for null) and remove the new Date(...) wrapper to avoid redundant
construction and minor overhead; refer to the formatDate function to make this
change.

In `@frontend/components/admin/blog/BlogTiptapEditor.tsx`:
- Around line 104-133: The handleImageUpload function currently only handles
HTTP error responses but not network exceptions; wrap the await fetch and
subsequent JSON parsing in a try/catch (or add a catch after the existing try)
so any thrown errors are caught, call toast.error with a user-friendly message
(and optionally console.error) inside the catch, and keep the existing finally
block that calls setUploading(false) and resets fileInputRef; reference
handleImageUpload, editor, setUploading, toast, and fileInputRef when locating
where to add the catch.

In `@frontend/lib/tests/blog/blog-author-route.test.ts`:
- Line 19: Update the test suite description string to match the new route path:
change the describe(...) title from '/api/blog-author' to '/api/blog/author' so
the describe block accurately reflects the endpoint used by the imports and
requests in this test file (look for the describe('GET /api/blog-author', ...)
declaration in blog-author-route.test.ts).

In `@frontend/lib/tests/blog/blog-search-route.test.ts`:
- Line 19: Update the describe block string to match the new route path: change
the test suite label describe('GET /api/blog-search', ...) to describe('GET
/api/blog/search', ...) so it aligns with the imported route and the request URL
used in the tests (ensure any other occurrences of '/api/blog-search' in this
test file are also updated).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 20c3c253-1c6b-4b77-805c-8ea3de8b87ee

📥 Commits

Reviewing files that changed from the base of the PR and between 3919bdf and 411f69a.

⛔ Files ignored due to path filters (1)
  • frontend/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (19)
  • frontend/app/[locale]/admin/blog/[id]/preview/page.tsx
  • frontend/app/[locale]/admin/blog/authors/page.tsx
  • frontend/app/[locale]/admin/blog/categories/page.tsx
  • frontend/app/api/admin/blog/[id]/route.ts
  • frontend/app/api/admin/blog/route.ts
  • frontend/app/api/blog/author/route.ts
  • frontend/app/api/blog/search/route.ts
  • frontend/components/admin/blog/BlogImageUpload.tsx
  • frontend/components/admin/blog/BlogPostForm.tsx
  • frontend/components/admin/blog/BlogPostListTable.tsx
  • frontend/components/admin/blog/BlogTiptapEditor.tsx
  • frontend/components/admin/blog/InlineBlogAuthorForm.tsx
  • frontend/components/admin/blog/InlineBlogCategoryForm.tsx
  • frontend/components/blog/BlogFilters.tsx
  • frontend/components/blog/BlogHeaderSearch.tsx
  • frontend/db/queries/blog/admin-blog.ts
  • frontend/lib/tests/blog/blog-author-route.test.ts
  • frontend/lib/tests/blog/blog-search-route.test.ts
  • frontend/package.json
✅ Files skipped from review due to trivial changes (2)
  • frontend/app/[locale]/admin/blog/authors/page.tsx
  • frontend/app/[locale]/admin/blog/[id]/preview/page.tsx
🚧 Files skipped from review as they are similar to previous changes (6)
  • frontend/package.json
  • frontend/components/admin/blog/InlineBlogAuthorForm.tsx
  • frontend/components/admin/blog/InlineBlogCategoryForm.tsx
  • frontend/components/admin/blog/BlogImageUpload.tsx
  • frontend/components/admin/blog/BlogPostForm.tsx
  • frontend/db/queries/blog/admin-blog.ts

@@ -0,0 +1,14 @@
import { Metadata } from 'next';

export const metadata: Metadata = { title: 'Categories | DevLovers' };
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Localize metadata and placeholder copy for the locale route.

Line 3 and Lines 8–10 are hard-coded in English under a [locale] route. This will show English text for uk/pl admin paths too.

🌐 Suggested fix
 import { Metadata } from 'next';
 
-export const metadata: Metadata = { title: 'Categories | DevLovers' };
+const copy = {
+  en: { title: 'Categories', placeholder: 'Category management coming...' },
+  uk: { title: 'Категорії', placeholder: 'Керування категоріями незабаром...' },
+  pl: { title: 'Kategorie', placeholder: 'Zarządzanie kategoriami wkrótce...' },
+} as const;
+
+export async function generateMetadata({
+  params,
+}: {
+  params: Promise<{ locale: keyof typeof copy }>;
+}): Promise<Metadata> {
+  const { locale } = await params;
+  const t = copy[locale] ?? copy.en;
+  return { title: `${t.title} | DevLovers` };
+}
 
-export default function AdminBlogCategoriesPage() {
+export default async function AdminBlogCategoriesPage({
+  params,
+}: {
+  params: Promise<{ locale: keyof typeof copy }>;
+}) {
+  const { locale } = await params;
+  const t = copy[locale] ?? copy.en;
   return (
     <div className="mx-auto max-w-5xl px-6 py-8">
-      <h1 className="text-foreground text-2xl font-bold">Categories</h1>
+      <h1 className="text-foreground text-2xl font-bold">{t.title}</h1>
       <p className="text-muted-foreground mt-2 text-sm">
-        Category management coming...
+        {t.placeholder}
       </p>
     </div>
   );
 }

Also applies to: 8-11

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/`[locale]/admin/blog/categories/page.tsx at line 3, Replace the
hard-coded English strings: the exported metadata constant (export const
metadata) and the placeholder/copy inside the page component in page.tsx, with
locale-aware values by loading the locale-specific dictionary or translation
function for the [locale] route (e.g., use the route params.locale or an async
getDictionary/getTranslations for the passed locale) and use that to set the
metadata title and the UI placeholder text via the translator (e.g.,
t('categories.title') and t('categories.placeholder')). Ensure metadata is
produced from a localized string (make metadata a localized value or
generateMetadata that reads the locale) and swap all hard-coded English literals
in the page component for translated keys so uk/pl and other locales render
correct text.

Comment on lines +82 to +99
await updateBlogPost(id, {
slug: data.slug,
authorId: data.authorId,
mainImageUrl: data.mainImageUrl,
mainImagePublicId: data.mainImagePublicId,
tags: data.tags,
resourceLink: data.resourceLink,
translations: data.translations as Record<string, { title: string; body: unknown }>,
categoryIds: data.categoryIds,
});

await toggleBlogPostPublish(id, {
isPublished: data.publishMode === 'publish',
scheduledPublishAt:
data.publishMode === 'schedule' && data.scheduledPublishAt
? new Date(data.scheduledPublishAt)
: null,
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Update + publish toggle are not atomic.

Similar to the POST handler, updateBlogPost() commits first, then toggleBlogPostPublish() runs separately. If the publish step fails mid-request, the content update persists but the publish state may be inconsistent with what the user intended. Consider wrapping both operations in a transaction or combining them.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/api/admin/blog/`[id]/route.ts around lines 82 - 99, The update
and publish operations are not atomic: updateBlogPost(...) is committed
separately from toggleBlogPostPublish(...), which can leave the database in a
partially applied state if the second call fails; modify the code so both
changes occur in a single transaction or a single repository method (e.g.,
create a new function updateAndTogglePublish or extend updateBlogPost to accept
publish options) that calls updateBlogPost and toggleBlogPostPublish within a DB
transaction (or performs both updates in one SQL/ORM call) and only commits if
both succeed, rolling back on error and propagating the failure.

Comment on lines +16 to +21
function getStatus(post: AdminBlogListItem): BlogStatus {
if (!post.isPublished) return 'draft';
if (post.scheduledPublishAt && new Date(post.scheduledPublishAt) > new Date())
return 'scheduled';
return 'published';
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Status logic incorrectly marks scheduled posts as "draft".

The getStatus function returns 'draft' when isPublished is false (line 17), but according to the backend logic in toggleBlogPostPublish (admin-blog.ts lines 286-304), scheduled posts have isPublished: false with a future scheduledPublishAt. This means scheduled posts will incorrectly display as "Draft" instead of "Scheduled".

The check for scheduledPublishAt at lines 18-19 is unreachable when isPublished is false.

Proposed fix
 function getStatus(post: AdminBlogListItem): BlogStatus {
-  if (!post.isPublished) return 'draft';
-  if (post.scheduledPublishAt && new Date(post.scheduledPublishAt) > new Date())
+  if (post.scheduledPublishAt && new Date(post.scheduledPublishAt) > new Date())
     return 'scheduled';
+  if (!post.isPublished) return 'draft';
   return 'published';
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function getStatus(post: AdminBlogListItem): BlogStatus {
if (!post.isPublished) return 'draft';
if (post.scheduledPublishAt && new Date(post.scheduledPublishAt) > new Date())
return 'scheduled';
return 'published';
}
function getStatus(post: AdminBlogListItem): BlogStatus {
if (post.scheduledPublishAt && new Date(post.scheduledPublishAt) > new Date())
return 'scheduled';
if (!post.isPublished) return 'draft';
return 'published';
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/admin/blog/BlogPostListTable.tsx` around lines 16 - 21,
The getStatus function currently returns 'draft' whenever post.isPublished is
false, which prevents detecting scheduled posts; update getStatus (in
BlogPostListTable.tsx) to first check if post.scheduledPublishAt exists and is
in the future (new Date(post.scheduledPublishAt) > new Date()) and return
'scheduled' in that case, then fall back to testing post.isPublished to return
'published' or 'draft' (ensure you treat undefined/null scheduledPublishAt
safely and use AdminBlogListItem and BlogStatus types).

Authors management:
- Authors list page with photo, name, job title, post count
- Create/edit forms with locale tabs (name, bio, jobTitle, company, city)
- Profile photo upload via Cloudinary (reuses BlogImageUpload)
- Social media editor (platform dropdown + URL, dynamic rows)
- Delete guard: blocked if author has posts assigned
- API routes: PUT/DELETE /api/admin/blog/authors/[id]

Categories management:
- Single-page inline CRUD (BlogCategoryManager)
- Inline create/edit with 3-locale titles and descriptions
- Move up/down reordering (swap displayOrder values)
- Delete guard: blocked if category has posts assigned
- API routes: PUT/DELETE /api/admin/blog/categories/[id], POST reorder

Query layer (admin-blog.ts):
- 9 new functions: getAdminBlogAuthorsFull, getAdminBlogAuthorById,
  updateBlogAuthor, deleteBlogAuthor, getAdminBlogCategoriesFull,
  getAdminBlogCategoryById, updateBlogCategory, deleteBlogCategory,
  swapBlogCategoryOrder
- Extended CreateBlogAuthorInput/CreateBlogCategoryInput with optional
  fields (backward compatible with inline forms)

Validation (admin-blog.ts):
- updateBlogAuthorSchema, updateBlogCategorySchema, swapCategoryOrderSchema
- Extended create schemas with optional image, social, bio, description

Closes #388
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

♻️ Duplicate comments (2)
frontend/lib/validation/admin-blog.ts (1)

3-6: ⚠️ Potential issue | 🟠 Major

Validate the editor document instead of accepting unknown.

body is stored and rendered as Tiptap JSON, but this schema allows any payload through and even defaults missing bodies to null. That pushes malformed content into storage and defers the failure to preview/render time. Make body a required editor-document schema.

Also applies to: 16-20

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/lib/validation/admin-blog.ts` around lines 3 - 6, The
blogTranslationSchema currently accepts any value for body (body:
z.unknown().default(null)) which lets invalid Tiptap JSON through; change body
to be a required editor-document schema (remove the default null) by replacing
z.unknown().default(null) with the shared/appropriate editor document Zod schema
(e.g., editorDocumentSchema or a z.object matching Tiptap's { type, content, ...
} shape) so it validates the document structure, and apply the same fix to the
other occurrences referenced (lines 16-20) to ensure all blog translation/body
validations enforce the editor-document structure.
frontend/db/queries/blog/admin-blog.ts (1)

219-270: ⚠️ Potential issue | 🟠 Major

updateBlogPost can commit a half-edit.

The base post update, translation upserts, and category replacement run as separate statements. If any later write fails, earlier changes stay committed and the post is left in a mixed state. Run the whole mutation in one transaction.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/db/queries/blog/admin-blog.ts` around lines 219 - 270, The
updateBlogPost function performs multiple separate writes (the initial db.update
on blogPosts, the blogPostTranslations upserts, and the blogPostCategories
delete/insert) which can leave the row partially updated if a later step fails;
wrap the entire sequence in a single database transaction (use db.transaction or
your DB client's transactional API) and execute the update, all translation
upserts, and category deletes/inserts against the transaction connection (e.g.,
replace db.update/... with tx.update/... and db.insert/... with
tx.insert/tx.delete/tx.insert inside the transaction callback), awaiting the
transaction so any thrown error rolls back all changes.
🧹 Nitpick comments (4)
frontend/app/api/admin/blog/authors/[id]/route.ts (2)

18-22: Consider extracting the shared noStoreJson helper.

This helper is duplicated across authors/[id]/route.ts and categories/[id]/route.ts. Extracting it to a shared utility (e.g., @/lib/api/responses) would reduce duplication.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/api/admin/blog/authors/`[id]/route.ts around lines 18 - 22, The
helper function noStoreJson duplicated in authors/[id]/route.ts and
categories/[id]/route.ts should be extracted to a shared utility (e.g., create a
module at `@/lib/api/responses` exporting noStoreJson); replace the local
implementations in both route files with an import of the shared noStoreJson,
ensuring the exported function signature (body: unknown, init?: { status?:
number }) and behavior (NextResponse.json + set 'Cache-Control' to 'no-store')
remain identical and update any imports accordingly.

111-117: Same fragile string matching as the categories route.

error.message === 'AUTHOR_HAS_POSTS' has the same brittleness concern. A shared approach (custom error classes) would benefit both routes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/api/admin/blog/authors/`[id]/route.ts around lines 111 - 117,
Replace the fragile string-match in the catch block by throwing and checking a
dedicated custom error class instead of comparing error.message; create an
AuthorHasPostsError (or reuse a shared DomainError) and update the code in
authors/[id]/route.ts to catch errors and test with error instanceof
AuthorHasPostsError (instead of error.message === 'AUTHOR_HAS_POSTS'), then
return the same noStoreJson({...}, { status: 409 }) when that class is detected;
ensure any service or deleteAuthor function that previously threw the string now
throws the new AuthorHasPostsError so the instanceof check works across the
codebase.
frontend/components/admin/blog/BlogCategoryManager.tsx (1)

112-126: Consider warning when switching edit targets with unsaved changes.

Clicking "Edit" on a different category silently replaces the current edit state. For a "Chill" review this is acceptable, but you might want to add a dirty check or prompt later.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/admin/blog/BlogCategoryManager.tsx` around lines 112 -
126, The startEdit function replaces the current edit state unconditionally; add
a dirty-check before switching so you warn or confirm when there are unsaved
changes. Implement a compare between current edit state (editTitles, editDescs,
editSlug, and editingId) and the original category data for the currently
editing id (via the AdminBlogCategoryListItem currently in your list), and if
they differ show a confirmation dialog (or return) before calling setEditingId,
setEditSlug, setEditTitles, setEditDescs, and setEditError; ensure this logic
lives inside startEdit so clicking a new category triggers the prompt and only
proceeds on user confirmation.
frontend/app/api/admin/blog/categories/[id]/route.ts (1)

111-117: Consider using a custom error class instead of string matching.

Matching error.message === 'CATEGORY_HAS_POSTS' is fragile — any typo or refactor in the query layer could silently break this branch. A typed error class (e.g., CategoryHasPostsError) would be safer.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/api/admin/blog/categories/`[id]/route.ts around lines 111 - 117,
Replace fragile string matching in the catch block of the DELETE handler in
route.ts by creating and using a typed error class (e.g., CategoryHasPostsError)
instead of throwing an Error with message 'CATEGORY_HAS_POSTS'; update the code
that currently throws that string (the deletion/query logic that raises the
condition) to throw new CategoryHasPostsError(), then change the catch to check
error instanceof CategoryHasPostsError and return the 409 noStoreJson response;
ensure the new error class is exported/imported where needed so both the throw
site and the catch site reference the same symbol.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@frontend/app/api/admin/blog/categories/reorder/route.ts`:
- Around line 62-64: revalidatePath is called without the dynamic [locale]
segment after swapBlogCategoryOrder, so localized pages (e.g.
/en/admin/blog/categories) are not revalidated; change the revalidation to
either use a locale-agnostic tag (use revalidateTag with a shared tag like
'admin-blog-categories' and ensure the page uses fetch/cache with that tag) or
explicitly revalidate each locale by calling revalidatePath for each concrete
locale path (e.g. `/en/admin/blog/categories`, `/uk/...`) after
swapBlogCategoryOrder; apply the same fix in the corresponding [id]/route.ts
locations where revalidatePath is used.

In `@frontend/components/admin/blog/BlogAuthorForm.tsx`:
- Around line 99-103: The state initializer for image is converting a missing
initialData.imagePublicId into an empty string which loses the original null
semantics; update the initializer and the other occurrence (around uses at the
block referenced by lines 186–190) to preserve null by using
initialData.imagePublicId ?? null (or a conditional that sets publicId to null
when undefined) instead of ''. Locate the image state and any places that
construct { url: initialData.imageUrl, publicId: ... } and change them to set
publicId to null when the initial value is missing so submits do not send an
empty-string sentinel.

In `@frontend/components/admin/blog/BlogAuthorListTable.tsx`:
- Around line 26-47: The handleDelete function currently only handles non-2xx
responses but not fetch rejections; update handleDelete to wrap the await
fetch(...) call and subsequent response parsing in a try/catch so network or
other exceptions are caught, call toast.error with a generic network/error
message (or use error.message) on catch, and still let the existing non-OK
response handling run in the try block; keep setDeletingId(authorId) before the
try and setDeletingId(null) in the finally so UI state is always cleared
(references: handleDelete, setDeletingId, csrfTokenDelete, router.refresh,
toast).

In `@frontend/components/admin/blog/BlogCategoryManager.tsx`:
- Around line 206-225: handleSwap currently only handles non-OK responses but
not network exceptions; wrap the await fetch in a try/catch (inside the existing
try/finally) or change the outer try to catch errors from fetch, and in the
catch call toast.error with a user-friendly message (e.g., "Network error
reordering categories") and console.error the caught error; preserve the
existing router.refresh() on success and ensure setReorderingId(null) remains in
the finally block so the spinner/state is cleared.
- Around line 180-202: The handleDelete function lacks a catch for network/fetch
errors so failures leave the user without feedback; wrap the await fetch call in
a try/catch/finally (or add a catch after the try) around fetch and response
parsing in handleDelete, call toast.error with a generic network error message
(and optionally log the error) when an exception is caught, ensure
setDeletingId(categoryId) is still cleared in the existing finally block, and
keep existing logic that inspects res.ok/data.code and calls router.refresh() on
success; reference functions/variables: handleDelete, setDeletingId,
csrfTokenDelete, fetch, router.refresh, and toast.error.

In `@frontend/db/queries/blog/admin-blog.ts`:
- Around line 371-380: The current read-then-write for computing displayOrder
can cause races; wrap the max-read and insert in a single transaction and
acquire a lock when reading the max (e.g., SELECT COALESCE(MAX(displayOrder),
-1) FROM blogCategories FOR UPDATE) so the computed (max + 1) is stable before
calling db.insert on blogCategories, or alternatively use a dedicated DB
sequence (nextval) for displayOrder; apply the same transactional/locked fix to
the other create block around lines 425-437 as well.
- Around line 634-671: The update flow in updateBlogAuthor currently performs
the base blogAuthors update and then multiple translation upserts in separate
statements, leaving partial changes if an upsert fails; wrap the entire
operation in a single database transaction (use the project's
db.transaction/transactional API) so the update to blogAuthors and all
inserts/onConflictDoUpdate calls to blogAuthorTranslations execute atomically;
move the .update(...) and the for-loop of .insert(...).onConflictDoUpdate(...)
inside one transaction callback, and apply the same transactional change to the
analogous author/category edit functions referenced (lines ~801-826) to ensure
all related updates are rolled back on error.
- Around line 846-864: The two separate updates that swap displayOrder on
blogCategories are not atomic; wrap the select and both update operations in a
single transaction so the read and both writes use the same transactional
connection and will commit or rollback together (use db.transaction or your
project's transactional API), and perform the select, compute order1/order2,
then run the two updates via the transaction object (using the same
blogCategories, id1, id2, displayOrder identifiers) to prevent partial writes
and interleaving.
- Around line 14-15: Currently ADMIN_LOCALE is hard-coded to 'en', causing admin
queries (getAdminBlogList, getAdminBlogAuthors, getAdminBlogCategories,
getAdminBlogAuthorsFull, getAdminBlogCategoriesFull) to always join English
translations; change these helpers to accept a locale parameter (e.g., locale or
activeLocale) instead of using ADMIN_LOCALE, thread the active route locale into
each call site, and modify the query logic to prefer the requested locale but
fall back to 'en' only when the requested translation is missing. Ensure the
constant ADMIN_LOCALE is removed or only used as the fallback value and update
all callers to pass the active locale.

---

Duplicate comments:
In `@frontend/db/queries/blog/admin-blog.ts`:
- Around line 219-270: The updateBlogPost function performs multiple separate
writes (the initial db.update on blogPosts, the blogPostTranslations upserts,
and the blogPostCategories delete/insert) which can leave the row partially
updated if a later step fails; wrap the entire sequence in a single database
transaction (use db.transaction or your DB client's transactional API) and
execute the update, all translation upserts, and category deletes/inserts
against the transaction connection (e.g., replace db.update/... with
tx.update/... and db.insert/... with tx.insert/tx.delete/tx.insert inside the
transaction callback), awaiting the transaction so any thrown error rolls back
all changes.

In `@frontend/lib/validation/admin-blog.ts`:
- Around line 3-6: The blogTranslationSchema currently accepts any value for
body (body: z.unknown().default(null)) which lets invalid Tiptap JSON through;
change body to be a required editor-document schema (remove the default null) by
replacing z.unknown().default(null) with the shared/appropriate editor document
Zod schema (e.g., editorDocumentSchema or a z.object matching Tiptap's { type,
content, ... } shape) so it validates the document structure, and apply the same
fix to the other occurrences referenced (lines 16-20) to ensure all blog
translation/body validations enforce the editor-document structure.

---

Nitpick comments:
In `@frontend/app/api/admin/blog/authors/`[id]/route.ts:
- Around line 18-22: The helper function noStoreJson duplicated in
authors/[id]/route.ts and categories/[id]/route.ts should be extracted to a
shared utility (e.g., create a module at `@/lib/api/responses` exporting
noStoreJson); replace the local implementations in both route files with an
import of the shared noStoreJson, ensuring the exported function signature
(body: unknown, init?: { status?: number }) and behavior (NextResponse.json +
set 'Cache-Control' to 'no-store') remain identical and update any imports
accordingly.
- Around line 111-117: Replace the fragile string-match in the catch block by
throwing and checking a dedicated custom error class instead of comparing
error.message; create an AuthorHasPostsError (or reuse a shared DomainError) and
update the code in authors/[id]/route.ts to catch errors and test with error
instanceof AuthorHasPostsError (instead of error.message ===
'AUTHOR_HAS_POSTS'), then return the same noStoreJson({...}, { status: 409 })
when that class is detected; ensure any service or deleteAuthor function that
previously threw the string now throws the new AuthorHasPostsError so the
instanceof check works across the codebase.

In `@frontend/app/api/admin/blog/categories/`[id]/route.ts:
- Around line 111-117: Replace fragile string matching in the catch block of the
DELETE handler in route.ts by creating and using a typed error class (e.g.,
CategoryHasPostsError) instead of throwing an Error with message
'CATEGORY_HAS_POSTS'; update the code that currently throws that string (the
deletion/query logic that raises the condition) to throw new
CategoryHasPostsError(), then change the catch to check error instanceof
CategoryHasPostsError and return the 409 noStoreJson response; ensure the new
error class is exported/imported where needed so both the throw site and the
catch site reference the same symbol.

In `@frontend/components/admin/blog/BlogCategoryManager.tsx`:
- Around line 112-126: The startEdit function replaces the current edit state
unconditionally; add a dirty-check before switching so you warn or confirm when
there are unsaved changes. Implement a compare between current edit state
(editTitles, editDescs, editSlug, and editingId) and the original category data
for the currently editing id (via the AdminBlogCategoryListItem currently in
your list), and if they differ show a confirmation dialog (or return) before
calling setEditingId, setEditSlug, setEditTitles, setEditDescs, and
setEditError; ensure this logic lives inside startEdit so clicking a new
category triggers the prompt and only proceeds on user confirmation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: bfad8915-b6f2-4b43-a2ef-4b686365e145

📥 Commits

Reviewing files that changed from the base of the PR and between 411f69a and f8d7601.

📒 Files selected for processing (14)
  • frontend/app/[locale]/admin/blog/authors/[id]/page.tsx
  • frontend/app/[locale]/admin/blog/authors/new/page.tsx
  • frontend/app/[locale]/admin/blog/authors/page.tsx
  • frontend/app/[locale]/admin/blog/categories/[id]/page.tsx
  • frontend/app/[locale]/admin/blog/categories/new/page.tsx
  • frontend/app/[locale]/admin/blog/categories/page.tsx
  • frontend/app/api/admin/blog/authors/[id]/route.ts
  • frontend/app/api/admin/blog/categories/[id]/route.ts
  • frontend/app/api/admin/blog/categories/reorder/route.ts
  • frontend/components/admin/blog/BlogAuthorForm.tsx
  • frontend/components/admin/blog/BlogAuthorListTable.tsx
  • frontend/components/admin/blog/BlogCategoryManager.tsx
  • frontend/db/queries/blog/admin-blog.ts
  • frontend/lib/validation/admin-blog.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • frontend/app/[locale]/admin/blog/authors/page.tsx

Comment on lines +99 to +103
const [image, setImage] = useState<{ url: string; publicId: string } | null>(
initialData?.imageUrl
? { url: initialData.imageUrl, publicId: initialData.imagePublicId ?? '' }
: null
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Don't write '' into a nullable imagePublicId.

Edit mode converts a missing imagePublicId to an empty string, and submit sends that sentinel back to the API. That silently changes a nullable field into a non-null value and can confuse code that distinguishes null from "has a Cloudinary id".

Suggested fix
       const body = {
         slug: slug.trim(),
         imageUrl: image?.url ?? null,
-        imagePublicId: image?.publicId ?? null,
+        imagePublicId: image?.publicId?.trim() || null,
         socialMedia: socialMedia.filter(s => s.url.trim()),
         translations: Object.fromEntries(

Also applies to: 186-190

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/admin/blog/BlogAuthorForm.tsx` around lines 99 - 103, The
state initializer for image is converting a missing initialData.imagePublicId
into an empty string which loses the original null semantics; update the
initializer and the other occurrence (around uses at the block referenced by
lines 186–190) to preserve null by using initialData.imagePublicId ?? null (or a
conditional that sets publicId to null when undefined) instead of ''. Locate the
image state and any places that construct { url: initialData.imageUrl, publicId:
... } and change them to set publicId to null when the initial value is missing
so submits do not send an empty-string sentinel.

Comment on lines +14 to +15
const ADMIN_LOCALE = 'en';

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Hard-coding ADMIN_LOCALE to 'en' makes these admin queries English-only.

getAdminBlogList(), getAdminBlogAuthors(), getAdminBlogCategories(), getAdminBlogAuthorsFull(), and getAdminBlogCategoriesFull() all join translations through this constant, so /uk and /pl admin routes still surface English titles. Thread the active route locale into these helpers and fall back to English only when that translation is missing.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/db/queries/blog/admin-blog.ts` around lines 14 - 15, Currently
ADMIN_LOCALE is hard-coded to 'en', causing admin queries (getAdminBlogList,
getAdminBlogAuthors, getAdminBlogCategories, getAdminBlogAuthorsFull,
getAdminBlogCategoriesFull) to always join English translations; change these
helpers to accept a locale parameter (e.g., locale or activeLocale) instead of
using ADMIN_LOCALE, thread the active route locale into each call site, and
modify the query logic to prefer the requested locale but fall back to 'en' only
when the requested translation is missing. Ensure the constant ADMIN_LOCALE is
removed or only used as the fallback value and update all callers to pass the
active locale.

Comment on lines +371 to +380
const [maxRow] = await db
.select({ max: sql<number>`COALESCE(MAX(${blogCategories.displayOrder}), -1)` })
.from(blogCategories);

const [created] = await db
.insert(blogCategories)
.values({
slug: input.slug,
displayOrder: (maxRow?.max ?? -1) + 1,
})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

MAX(displayOrder) + 1 is race-prone here.

Two concurrent creates can read the same max value and assign the same displayOrder value, or trip a uniqueness constraint if one exists. Allocate the next order inside a transaction/lock, or derive it from a monotonic database primitive instead of a read-then-write sequence.

Also applies to: 425-437

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/db/queries/blog/admin-blog.ts` around lines 371 - 380, The current
read-then-write for computing displayOrder can cause races; wrap the max-read
and insert in a single transaction and acquire a lock when reading the max
(e.g., SELECT COALESCE(MAX(displayOrder), -1) FROM blogCategories FOR UPDATE) so
the computed (max + 1) is stable before calling db.insert on blogCategories, or
alternatively use a dedicated DB sequence (nextval) for displayOrder; apply the
same transactional/locked fix to the other create block around lines 425-437 as
well.

Comment on lines +634 to +671
export async function updateBlogAuthor(
authorId: string,
input: UpdateBlogAuthorInput
): Promise<void> {
await db
.update(blogAuthors)
.set({
slug: input.slug,
imageUrl: input.imageUrl,
imagePublicId: input.imagePublicId,
socialMedia: input.socialMedia,
updatedAt: new Date(),
})
.where(eq(blogAuthors.id, authorId));

for (const [locale, trans] of Object.entries(input.translations)) {
await db
.insert(blogAuthorTranslations)
.values({
authorId,
locale,
name: trans.name,
bio: trans.bio ?? null,
jobTitle: trans.jobTitle ?? null,
company: trans.company ?? null,
city: trans.city ?? null,
})
.onConflictDoUpdate({
target: [blogAuthorTranslations.authorId, blogAuthorTranslations.locale],
set: {
name: trans.name,
bio: trans.bio ?? null,
jobTitle: trans.jobTitle ?? null,
company: trans.company ?? null,
city: trans.city ?? null,
},
});
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Author/category edits are also non-atomic.

Each function updates the base row first and then upserts translations in separate statements. A later failure leaves slug/image fields saved without the matching translation changes. Wrap each edit flow in a single transaction.

Also applies to: 801-826

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/db/queries/blog/admin-blog.ts` around lines 634 - 671, The update
flow in updateBlogAuthor currently performs the base blogAuthors update and then
multiple translation upserts in separate statements, leaving partial changes if
an upsert fails; wrap the entire operation in a single database transaction (use
the project's db.transaction/transactional API) so the update to blogAuthors and
all inserts/onConflictDoUpdate calls to blogAuthorTranslations execute
atomically; move the .update(...) and the for-loop of
.insert(...).onConflictDoUpdate(...) inside one transaction callback, and apply
the same transactional change to the analogous author/category edit functions
referenced (lines ~801-826) to ensure all related updates are rolled back on
error.

Comment on lines +846 to +864
const rows = await db
.select({ id: blogCategories.id, displayOrder: blogCategories.displayOrder })
.from(blogCategories)
.where(sql`${blogCategories.id} IN (${id1}, ${id2})`);

if (rows.length !== 2) throw new Error('CATEGORIES_NOT_FOUND');

const order1 = rows.find(r => r.id === id1)!.displayOrder;
const order2 = rows.find(r => r.id === id2)!.displayOrder;

await db
.update(blogCategories)
.set({ displayOrder: order2 })
.where(eq(blogCategories.id, id1));

await db
.update(blogCategories)
.set({ displayOrder: order1 })
.where(eq(blogCategories.id, id2));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Make the category swap atomic.

These two updates run independently. If the second write fails, both rows can end up sharing the same displayOrder, and concurrent reorder requests can interleave badly. The read and both writes should happen in one transaction.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/db/queries/blog/admin-blog.ts` around lines 846 - 864, The two
separate updates that swap displayOrder on blogCategories are not atomic; wrap
the select and both update operations in a single transaction so the read and
both writes use the same transactional connection and will commit or rollback
together (use db.transaction or your project's transactional API), and perform
the select, compute order1/order2, then run the two updates via the transaction
object (using the same blogCategories, id1, id2, displayOrder identifiers) to
prevent partial writes and interleaving.

- revalidatePath: use '/[locale]/...' pattern with 'page' arg to match
  all locale variants (authors/[id], categories/[id], categories/reorder)
- Add catch blocks for network errors in handleDelete (BlogAuthorListTable,
  BlogCategoryManager) and handleSwap (BlogCategoryManager)
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
frontend/app/api/admin/blog/categories/[id]/route.ts (1)

112-117: Avoid string-matching DB errors across layers.

Line 112 couples route behavior to a magic message string. Prefer a shared typed error (or exported constant) from the query layer to make this contract refactor-safe.

Refactor direction
- if (error instanceof Error && error.message === 'CATEGORY_HAS_POSTS') {
+ if (error instanceof CategoryHasPostsError) {
    return noStoreJson(
      { error: 'Category has posts assigned', code: 'HAS_POSTS' },
      { status: 409 }
    );
  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/api/admin/blog/categories/`[id]/route.ts around lines 112 - 117,
The route is matching on a magic string 'CATEGORY_HAS_POSTS'—replace this
brittle check with a shared, typed error or exported constant from the
query/repo layer: export either a custom class (e.g., CategoryHasPostsError) or
an ERROR_CODE constant (e.g., CATEGORY_HAS_POSTS_CODE) from the module that
performs the deletion (the deleteCategory / categories repository function),
update that module to throw the class or include the code on the thrown Error,
then import and check that class/constant in
frontend/app/api/admin/blog/categories/[id]/route.ts instead of matching the
message string so the contract is explicit and refactor-safe.
frontend/components/admin/blog/BlogAuthorListTable.tsx (1)

95-116: Consider extracting shared author actions into a small subcomponent.

The Edit/Delete button logic is duplicated in mobile and desktop layouts. A shared AuthorRowActions component would reduce drift risk and simplify future behavior changes.

Also applies to: 159-180

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/admin/blog/BlogAuthorListTable.tsx` around lines 95 -
116, Extract the duplicated Edit/Delete UI into a new AuthorRowActions component
that accepts props { author, handleDelete, deletingId } and renders the Link to
`/admin/blog/authors/${author.id}` and the Delete button using the same
disabled, title, className (cn) and deletingId conditional text logic found in
the current blocks; replace both duplicated blocks (the one around
handleDelete(author.id) and the one at lines 159-180) with <AuthorRowActions
author={author} handleDelete={handleDelete} deletingId={deletingId} /> so all
behavior (disabled when author.postCount > 0, title showing postCount, button
text of '...' when deleting) is preserved in a single component.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@frontend/components/admin/blog/BlogAuthorListTable.tsx`:
- Around line 24-25: The delete flow allows starting multiple concurrent deletes
because the UI only disables the row being deleted; update the logic so there is
a single global in-flight marker and early-return on new delete attempts: use
the existing deletingId/setDeletingId state as the single in-flight flag (treat
any non-null deletingId as "busy"), change the delete handler (e.g.,
handleDeleteAuthor or similar) to check if deletingId !== null and return
immediately if so, setDeletingId(id) at start and clear on finally, and update
row/button disabled props (lines that compare deletingId === row.id) to instead
disable when deletingId !== null (or derive isBusy = deletingId !== null) so all
delete buttons are disabled while a delete is in progress to prevent duplicate
requests.

---

Nitpick comments:
In `@frontend/app/api/admin/blog/categories/`[id]/route.ts:
- Around line 112-117: The route is matching on a magic string
'CATEGORY_HAS_POSTS'—replace this brittle check with a shared, typed error or
exported constant from the query/repo layer: export either a custom class (e.g.,
CategoryHasPostsError) or an ERROR_CODE constant (e.g., CATEGORY_HAS_POSTS_CODE)
from the module that performs the deletion (the deleteCategory / categories
repository function), update that module to throw the class or include the code
on the thrown Error, then import and check that class/constant in
frontend/app/api/admin/blog/categories/[id]/route.ts instead of matching the
message string so the contract is explicit and refactor-safe.

In `@frontend/components/admin/blog/BlogAuthorListTable.tsx`:
- Around line 95-116: Extract the duplicated Edit/Delete UI into a new
AuthorRowActions component that accepts props { author, handleDelete, deletingId
} and renders the Link to `/admin/blog/authors/${author.id}` and the Delete
button using the same disabled, title, className (cn) and deletingId conditional
text logic found in the current blocks; replace both duplicated blocks (the one
around handleDelete(author.id) and the one at lines 159-180) with
<AuthorRowActions author={author} handleDelete={handleDelete}
deletingId={deletingId} /> so all behavior (disabled when author.postCount > 0,
title showing postCount, button text of '...' when deleting) is preserved in a
single component.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 13f6e22c-d78d-4567-b123-76f3e39c194c

📥 Commits

Reviewing files that changed from the base of the PR and between f8d7601 and bdcf255.

📒 Files selected for processing (3)
  • frontend/app/api/admin/blog/categories/[id]/route.ts
  • frontend/components/admin/blog/BlogAuthorListTable.tsx
  • frontend/components/admin/blog/BlogCategoryManager.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • frontend/components/admin/blog/BlogCategoryManager.tsx

Comment on lines +24 to +25
const [deletingId, setDeletingId] = useState<string | null>(null);

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Prevent overlapping delete requests.

Line 29 tracks a single in-flight delete, but Line 105 and Line 169 only disable the current row. Users can start a second delete before the first finishes, leading to inconsistent pending UI and duplicate requests.

Suggested fix
 async function handleDelete(authorId: string) {
+  if (deletingId !== null) return;
   if (!confirm('Delete this author?')) return;

   setDeletingId(authorId);
   try {
@@
-                disabled={author.postCount > 0 || deletingId === author.id}
+                disabled={author.postCount > 0 || deletingId !== null}
@@
-                      disabled={author.postCount > 0 || deletingId === author.id}
+                      disabled={author.postCount > 0 || deletingId !== null}

Also applies to: 29-30, 105-106, 169-170

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/admin/blog/BlogAuthorListTable.tsx` around lines 24 - 25,
The delete flow allows starting multiple concurrent deletes because the UI only
disables the row being deleted; update the logic so there is a single global
in-flight marker and early-return on new delete attempts: use the existing
deletingId/setDeletingId state as the single in-flight flag (treat any non-null
deletingId as "busy"), change the delete handler (e.g., handleDeleteAuthor or
similar) to check if deletingId !== null and return immediately if so,
setDeletingId(id) at start and clear on finally, and update row/button disabled
props (lines that compare deletingId === row.id) to instead disable when
deletingId !== null (or derive isBusy = deletingId !== null) so all delete
buttons are disabled while a delete is in progress to prevent duplicate
requests.

…ages

- Add STATIC_PAGE_REVALIDATE constant (7 days) in lib/constants/cache.ts
- Replace force-dynamic with ISR on /blog/[slug]
- Wrap getBlogPostBySlug in React.cache (deduplicate within render)
- Add unstable_cache for getBlogPosts, getBlogPostBySlug, getBlogPostsByCategory, getBlogAuthorByName
- Switch all public blog routes to cached query variants
- Add revalidatePath + revalidateTag for public blog pages in author/category/post admin routes
- Fix inconsistent revalidatePath patterns in admin routes
- Use getCachedBlogCategories on blog list and category pages
@LesiaUKR LesiaUKR changed the title (SP: 3) [Frontend] Full blog post Admin with Tiptap editor and publish workflow (SP: 5) [Frontend] Full blog post Admin with Tiptap editor and publish workflow + DB optimization Mar 21, 2026
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

🧹 Nitpick comments (2)
frontend/app/[locale]/blog/page.tsx (1)

8-8: Prefer the shared cache constant instead of a repeated literal.

Line 8 should reuse STATIC_PAGE_REVALIDATE to prevent TTL drift across pages/routes.

♻️ Suggested refactor
 import { DynamicGridBackground } from '@/components/shared/DynamicGridBackground';
 import { getCachedBlogCategories } from '@/db/queries/blog/blog-categories';
 import { getCachedBlogPosts } from '@/db/queries/blog/blog-posts';
-export const revalidate = 604800; // 7 days
+import { STATIC_PAGE_REVALIDATE } from '@/lib/constants/cache';
+
+export const revalidate = STATIC_PAGE_REVALIDATE;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/`[locale]/blog/page.tsx at line 8, The page exports a hard-coded
TTL (export const revalidate = 604800) which should reuse the shared
STATIC_PAGE_REVALIDATE constant to avoid drift; update the file to import
STATIC_PAGE_REVALIDATE and replace the literal 604800 with that constant (keep
the export name export const revalidate = STATIC_PAGE_REVALIDATE) and ensure the
import references the module where STATIC_PAGE_REVALIDATE is defined.
frontend/app/api/admin/blog/[id]/route.ts (1)

60-60: Using create schema for updates.

The PUT handler uses createBlogPostSchema which requires all fields. This works if the client always sends the complete payload, but consider defining a separate updateBlogPostSchema with optional fields for clearer semantics and to support partial updates in the future.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/api/admin/blog/`[id]/route.ts at line 60, The PUT handler
currently uses createBlogPostSchema.safeParse(rawBody) which enforces all
required fields; define a new updateBlogPostSchema that makes fields optional
(or uses partial types) and replace createBlogPostSchema with
updateBlogPostSchema in the PUT handler (and any related validation logic) so
updates can accept partial payloads while preserving createBlogPostSchema for
POSTs; ensure the validation call and any TypeScript types that depend on
createBlogPostSchema (e.g., inferred types) are updated to use the new
updateBlogPostSchema or its inferred UpdateBlogPost type.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@frontend/app/api/admin/blog/`[id]/route.ts:
- Around line 165-166: The DELETE handler calls revalidateTag('blog-authors',
'default') and revalidateTag('blog-posts', 'default') incorrectly — ensure both
tag invalidations are awaited (or run via Promise.all) and terminate statements
properly; update the DELETE handler to either await each call (await
revalidateTag('blog-authors','default'); await
revalidateTag('blog-posts','default');) or use await
Promise.all([revalidateTag('blog-authors','default'),
revalidateTag('blog-posts','default')]) so revalidation completes before
returning, referencing the revalidateTag calls in the DELETE handler.
- Around line 221-222: The PATCH handler that toggles publish status currently
calls revalidatePath('/[locale]/blog', 'page') and
revalidatePath('/[locale]/blog/[slug]', 'page') but does not clear the cached
query results tagged by getCachedBlogPosts; add a call to
revalidateTag('blog-posts') in the same PATCH flow (e.g., after updating the
post and before returning) so any cache entries created via getCachedBlogPosts
(tagged 'blog-posts') are invalidated along with the path revalidations.
- Around line 103-104: The calls to revalidateTag use an incorrect two-argument
signature and the second call is missing a semicolon; update to call
revalidateTag with a single tag per call by replacing
revalidateTag('blog-authors', 'default') and revalidateTag('blog-posts',
'default') with revalidateTag('blog-authors'); and revalidateTag('blog-posts');
respectively, ensuring each statement ends with a semicolon.

In `@frontend/app/api/admin/blog/authors/`[id]/route.ts:
- Around line 71-72: The calls to revalidateTag in route.ts use an incorrect
two-argument signature (revalidateTag('blog-authors', 'default') and
revalidateTag('blog-posts', 'default')); update each call to use the
single-argument API by removing the second 'default' parameter (i.e., call
revalidateTag with only 'blog-authors' and 'blog-posts' respectively) so the
function signature matches the library's expected usage.
- Around line 115-116: In the DELETE handler, fix the incorrect revalidation by
calling revalidateTag with the specific author id and awaiting the calls; locate
the DELETE handler function and replace the current
revalidateTag('blog-authors', 'default') and revalidateTag('blog-posts',
'default') invocations with awaited calls that include the deleted author's
identifier (e.g., await revalidateTag('blog-authors', id) and await
revalidateTag('blog-posts', idOrRelevantPostTag)) so caches for that specific
author and related posts are correctly invalidated.

In `@frontend/app/api/admin/blog/categories/`[id]/route.ts:
- Around line 71-72: The calls to revalidateTag use an incorrect two-argument
form; update both usages (revalidateTag('blog-categories','default') and
revalidateTag('blog-posts','default')) to the correct signature by passing the
single tag string only (e.g., revalidateTag('blog-categories') and
revalidateTag('blog-posts')), ensuring they match the runtime's revalidateTag
API.
- Around line 115-116: In the DELETE handler (exported DELETE function) the two
revalidateTag calls are used the same incorrect way as elsewhere; update the
lines that call revalidateTag('blog-categories', 'default') and
revalidateTag('blog-posts', 'default') to match the correct usage elsewhere —
call the same correct signature (and await the promise) and handle any errors
(e.g., await revalidateTag(...); or wrap in try/catch) so the tags are actually
revalidated after deletion.

In `@frontend/app/api/admin/blog/categories/reorder/route.ts`:
- Around line 62-65: swapBlogCategoryOrder currently runs two separate UPDATEs
and can race; modify swapBlogCategoryOrder in
frontend/db/queries/blog/admin-blog.ts to run the read-and-swap inside a single
db.transaction (use tx for queries/updates) so both updates commit atomically:
within the transaction select the two rows from blogCategories (select id and
displayOrder), validate rows.length === 2, compute the two displayOrder values,
then perform the two tx.update(...) calls swapping displayOrder using
eq(blogCategories.id, ...); this guarantees atomic swap and prevents
inconsistent displayOrder on concurrent reorders.

In `@frontend/app/api/admin/blog/route.ts`:
- Line 105: Remove the invalid second argument and add the missing semicolon on
the revalidate call: replace revalidateTag('blog-posts', 'default') with
revalidateTag('blog-posts'); in the route handler where revalidateTag is invoked
so it uses the single accepted parameter and the statement is properly
terminated.
- Around line 70-93: The slug check is vulnerable to TOCTOU races: wrap the
createBlogPost call in a try/catch and detect the DB unique-constraint error
(e.g., Postgres code "23505" or your DB client's unique-violation indicator) and
return the same noStoreJson({ error: 'Slug already exists', code:
'DUPLICATE_SLUG' }, { status: 409 }) response instead of letting the exception
bubble to a 500; keep the existing pre-check (db.select / existing) but add this
defensive handling around createBlogPost so concurrent inserts produce a 409
with the same payload.

In `@frontend/app/api/blog/author/route.ts`:
- Line 5: The route exports only revalidate currently which is ineffective
without enabling full route caching; add an export named dynamic with the value
'force-static' (i.e., export const dynamic = 'force-static') alongside the
existing export const revalidate = 604800 so the GET route handler in route.ts
will use Next.js full route cache and honor the 7-day revalidation window.

---

Nitpick comments:
In `@frontend/app/`[locale]/blog/page.tsx:
- Line 8: The page exports a hard-coded TTL (export const revalidate = 604800)
which should reuse the shared STATIC_PAGE_REVALIDATE constant to avoid drift;
update the file to import STATIC_PAGE_REVALIDATE and replace the literal 604800
with that constant (keep the export name export const revalidate =
STATIC_PAGE_REVALIDATE) and ensure the import references the module where
STATIC_PAGE_REVALIDATE is defined.

In `@frontend/app/api/admin/blog/`[id]/route.ts:
- Line 60: The PUT handler currently uses
createBlogPostSchema.safeParse(rawBody) which enforces all required fields;
define a new updateBlogPostSchema that makes fields optional (or uses partial
types) and replace createBlogPostSchema with updateBlogPostSchema in the PUT
handler (and any related validation logic) so updates can accept partial
payloads while preserving createBlogPostSchema for POSTs; ensure the validation
call and any TypeScript types that depend on createBlogPostSchema (e.g.,
inferred types) are updated to use the new updateBlogPostSchema or its inferred
UpdateBlogPost type.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c8249c33-7e74-479e-999c-7078503cf70b

📥 Commits

Reviewing files that changed from the base of the PR and between bdcf255 and 2f25eaa.

📒 Files selected for processing (15)
  • frontend/app/[locale]/blog/[slug]/PostDetails.tsx
  • frontend/app/[locale]/blog/[slug]/page.tsx
  • frontend/app/[locale]/blog/category/[category]/page.tsx
  • frontend/app/[locale]/blog/page.tsx
  • frontend/app/api/admin/blog/[id]/route.ts
  • frontend/app/api/admin/blog/authors/[id]/route.ts
  • frontend/app/api/admin/blog/categories/[id]/route.ts
  • frontend/app/api/admin/blog/categories/reorder/route.ts
  • frontend/app/api/admin/blog/route.ts
  • frontend/app/api/blog/author/route.ts
  • frontend/app/api/blog/search/route.ts
  • frontend/db/queries/blog/blog-authors.ts
  • frontend/db/queries/blog/blog-categories.ts
  • frontend/db/queries/blog/blog-posts.ts
  • frontend/lib/constants/cache.ts

Comment on lines +103 to +104
revalidateTag('blog-authors', 'default');
revalidateTag('blog-posts', 'default')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Incorrect revalidateTag signature and missing semicolon.

Same issue as other routes: revalidateTag accepts only one argument, and line 104 is missing a semicolon.

Proposed fix
-    revalidateTag('blog-authors', 'default');
-    revalidateTag('blog-posts', 'default')
+    revalidateTag('blog-authors');
+    revalidateTag('blog-posts');
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
revalidateTag('blog-authors', 'default');
revalidateTag('blog-posts', 'default')
revalidateTag('blog-authors');
revalidateTag('blog-posts');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/api/admin/blog/`[id]/route.ts around lines 103 - 104, The calls
to revalidateTag use an incorrect two-argument signature and the second call is
missing a semicolon; update to call revalidateTag with a single tag per call by
replacing revalidateTag('blog-authors', 'default') and
revalidateTag('blog-posts', 'default') with revalidateTag('blog-authors'); and
revalidateTag('blog-posts'); respectively, ensuring each statement ends with a
semicolon.

Comment on lines +165 to +166
revalidateTag('blog-authors', 'default');
revalidateTag('blog-posts', 'default')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Same revalidateTag issues in DELETE handler.

Proposed fix
-    revalidateTag('blog-authors', 'default');
-    revalidateTag('blog-posts', 'default')
+    revalidateTag('blog-authors');
+    revalidateTag('blog-posts');
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
revalidateTag('blog-authors', 'default');
revalidateTag('blog-posts', 'default')
revalidateTag('blog-authors');
revalidateTag('blog-posts');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/api/admin/blog/`[id]/route.ts around lines 165 - 166, The DELETE
handler calls revalidateTag('blog-authors', 'default') and
revalidateTag('blog-posts', 'default') incorrectly — ensure both tag
invalidations are awaited (or run via Promise.all) and terminate statements
properly; update the DELETE handler to either await each call (await
revalidateTag('blog-authors','default'); await
revalidateTag('blog-posts','default');) or use await
Promise.all([revalidateTag('blog-authors','default'),
revalidateTag('blog-posts','default')]) so revalidation completes before
returning, referencing the revalidateTag calls in the DELETE handler.

Comment on lines +71 to +72
revalidateTag('blog-authors', 'default');
revalidateTag('blog-posts', 'default');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Incorrect revalidateTag signature.

Same issue: revalidateTag only accepts a single tag argument.

Proposed fix
-    revalidateTag('blog-authors', 'default');
-    revalidateTag('blog-posts', 'default');
+    revalidateTag('blog-authors');
+    revalidateTag('blog-posts');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/api/admin/blog/authors/`[id]/route.ts around lines 71 - 72, The
calls to revalidateTag in route.ts use an incorrect two-argument signature
(revalidateTag('blog-authors', 'default') and revalidateTag('blog-posts',
'default')); update each call to use the single-argument API by removing the
second 'default' parameter (i.e., call revalidateTag with only 'blog-authors'
and 'blog-posts' respectively) so the function signature matches the library's
expected usage.

Comment on lines +115 to +116
revalidateTag('blog-authors', 'default');
revalidateTag('blog-posts', 'default');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Same revalidateTag issue in DELETE handler.

Proposed fix
-    revalidateTag('blog-authors', 'default');
-    revalidateTag('blog-posts', 'default');
+    revalidateTag('blog-authors');
+    revalidateTag('blog-posts');
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
revalidateTag('blog-authors', 'default');
revalidateTag('blog-posts', 'default');
revalidateTag('blog-authors');
revalidateTag('blog-posts');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/api/admin/blog/authors/`[id]/route.ts around lines 115 - 116, In
the DELETE handler, fix the incorrect revalidation by calling revalidateTag with
the specific author id and awaiting the calls; locate the DELETE handler
function and replace the current revalidateTag('blog-authors', 'default') and
revalidateTag('blog-posts', 'default') invocations with awaited calls that
include the deleted author's identifier (e.g., await
revalidateTag('blog-authors', id) and await revalidateTag('blog-posts',
idOrRelevantPostTag)) so caches for that specific author and related posts are
correctly invalidated.

Comment on lines +115 to +116
revalidateTag('blog-categories', 'default');
revalidateTag('blog-posts', 'default');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Same revalidateTag issue in DELETE handler.

Proposed fix
-    revalidateTag('blog-categories', 'default');
-    revalidateTag('blog-posts', 'default');
+    revalidateTag('blog-categories');
+    revalidateTag('blog-posts');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/api/admin/blog/categories/`[id]/route.ts around lines 115 - 116,
In the DELETE handler (exported DELETE function) the two revalidateTag calls are
used the same incorrect way as elsewhere; update the lines that call
revalidateTag('blog-categories', 'default') and revalidateTag('blog-posts',
'default') to match the correct usage elsewhere — call the same correct
signature (and await the promise) and handle any errors (e.g., await
revalidateTag(...); or wrap in try/catch) so the tags are actually revalidated
after deletion.

Comment on lines +62 to +65
await swapBlogCategoryOrder(parsed.data.id1, parsed.data.id2);
revalidatePath('/[locale]/admin/blog/categories', 'page');
revalidateTag('blog-categories', 'default');
revalidateTag('blog-posts', 'default');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Race condition in category reorder operation.

The swapBlogCategoryOrder function (in frontend/db/queries/blog/admin-blog.ts lines 842-865) executes two separate UPDATE statements without a database transaction. If two concurrent reorder requests target overlapping categories, the displayOrder values can become inconsistent.

Consider wrapping the two updates in a transaction:

// In admin-blog.ts
export async function swapBlogCategoryOrder(id1: string, id2: string): Promise<void> {
  await db.transaction(async (tx) => {
    const rows = await tx
      .select({ id: blogCategories.id, displayOrder: blogCategories.displayOrder })
      .from(blogCategories)
      .where(sql`${blogCategories.id} IN (${id1}, ${id2})`);

    if (rows.length !== 2) throw new Error('CATEGORIES_NOT_FOUND');

    const order1 = rows.find(r => r.id === id1)!.displayOrder;
    const order2 = rows.find(r => r.id === id2)!.displayOrder;

    await tx.update(blogCategories).set({ displayOrder: order2 }).where(eq(blogCategories.id, id1));
    await tx.update(blogCategories).set({ displayOrder: order1 }).where(eq(blogCategories.id, id2));
  });
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/api/admin/blog/categories/reorder/route.ts` around lines 62 -
65, swapBlogCategoryOrder currently runs two separate UPDATEs and can race;
modify swapBlogCategoryOrder in frontend/db/queries/blog/admin-blog.ts to run
the read-and-swap inside a single db.transaction (use tx for queries/updates) so
both updates commit atomically: within the transaction select the two rows from
blogCategories (select id and displayOrder), validate rows.length === 2, compute
the two displayOrder values, then perform the two tx.update(...) calls swapping
displayOrder using eq(blogCategories.id, ...); this guarantees atomic swap and
prevents inconsistent displayOrder on concurrent reorders.

: null,
});
}
revalidateTag('blog-posts', 'default')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing semicolon.

Line 105 is missing a semicolon after revalidateTag('blog-posts', 'default').

Additionally, similar to the reorder route, revalidateTag only accepts one argument — the 'default' parameter is invalid.

Proposed fix
-    revalidateTag('blog-posts', 'default')
+    revalidateTag('blog-posts');
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
revalidateTag('blog-posts', 'default')
revalidateTag('blog-posts');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/api/admin/blog/route.ts` at line 105, Remove the invalid second
argument and add the missing semicolon on the revalidate call: replace
revalidateTag('blog-posts', 'default') with revalidateTag('blog-posts'); in the
route handler where revalidateTag is invoked so it uses the single accepted
parameter and the statement is properly terminated.

import { getCachedBlogAuthorByName } from '@/db/queries/blog/blog-authors';

export const revalidate = 0;
export const revalidate = 604800; // 7 days
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

In Next.js 15 App Router Route Handlers, does export const revalidate = 604800cache GET responses by itself, or isexport const dynamic = 'force-static' (or explicit Cache-Control headers) required?

💡 Result:

In Next.js 15 App Router Route Handlers, export const revalidate = 604800 does not cache GET responses by itself. GET Route Handlers are not cached by default in Next.js 15. To enable caching of the Route Handler response (Full Route Cache), export const dynamic = 'force-static' is required. The revalidate export sets the revalidation interval for fetch requests within the handler or enables ISR-style revalidation once caching is opted into via force-static. Explicit Cache-Control headers in the Response can also control caching independently.

Citations:


Add export const dynamic = 'force-static' to enable caching of this route handler.

The revalidate = 604800 export does not cache GET responses by itself in Next.js 15. GET Route Handlers are not cached by default. To enable Full Route Cache and make the 7-day revalidation interval effective, add export const dynamic = 'force-static' alongside the revalidate setting. Without it, this configuration is misleading and has no caching effect.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/api/blog/author/route.ts` at line 5, The route exports only
revalidate currently which is ineffective without enabling full route caching;
add an export named dynamic with the value 'force-static' (i.e., export const
dynamic = 'force-static') alongside the existing export const revalidate =
604800 so the GET route handler in route.ts will use Next.js full route cache
and honor the 7-day revalidation window.

- Prevent overlapping delete requests: disable all delete buttons while any delete is in flight (BlogAuthorListTable, BlogCategoryManager, BlogPostListTable)
- Add missing revalidateTag('blog-posts') in PATCH publish toggle handler
- Add TOCTOU guard: catch duplicate key constraint on post creation for proper 409 response
- Revert API route /api/blog/author to revalidate=0 with Cache-Control: no-store (searchParams makes route always dynamic, data caching handled by unstable_cache)
- Fix missing semicolons on revalidateTag calls
@ViktorSvertoka ViktorSvertoka merged commit 156094e into develop Mar 22, 2026
7 checks passed
@ViktorSvertoka ViktorSvertoka deleted the sl/feat/blog-admin branch March 22, 2026 13:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants