Skip to content

(SP: 3) [Frontend] Drizzle query layer + swap blog routes from Sanity to PostgreSQL#391

Merged
ViktorSvertoka merged 8 commits intodevelopfrom
sl/feat/blog-admin
Mar 5, 2026
Merged

(SP: 3) [Frontend] Drizzle query layer + swap blog routes from Sanity to PostgreSQL#391
ViktorSvertoka merged 8 commits intodevelopfrom
sl/feat/blog-admin

Conversation

@LesiaUKR
Copy link
Collaborator

@LesiaUKR LesiaUKR commented Mar 4, 2026

Closes #386

Goal

Replace all Sanity GROQ queries in public blog routes with Drizzle ORM queries. After this PR, the public blog reads entirely from PostgreSQL — zero requests to api.sanity.io or cdn.sanity.io.

Summary

New: Query layer — db/queries/blog/ (3 files)

Pattern follows db/queries/quizzes/admin-quiz.ts — multi-step fetch, assemble in JS, exported interfaces.

  • blog-posts.tsgetBlogPosts(locale), getBlogPostBySlug(slug, locale), getBlogPostsByCategory(categorySlug, locale), getBlogPostSlugs(). Filters by isPublished = true AND (scheduledPublishAt IS NULL OR scheduledPublishAt <= NOW()). Joins post translations, author translations, categories. Converts DB nullundefined at query boundary (adapter pattern).
  • blog-categories.tsgetBlogCategories(locale) — all categories ordered by displayOrder with locale-aware title.
  • blog-authors.tsgetBlogAuthorByName(name, locale) — author profile lookup (name, bio, jobTitle, company, city, imageUrl, socialMedia).

New: BlogPostRenderer.tsx — server component

Replaces the 200+ line Portable Text renderer from PostDetails.tsx. Renders Tiptap JSON to semantic HTML with Tailwind. Recursive renderNode/renderContent pattern. Supports: paragraph, heading (h1-h6), blockquote, bulletList, orderedList, codeBlock, image (next/image), hardBreak, text marks (bold, italic, code, link, strike, underline).

New: lib/blog/text.ts — shared extractPlainText()

Lightweight recursive walker for Tiptap JSON → plain text. Used in BlogFilters (excerpt generation, search filtering), BlogCard (excerpt), BlogHeaderSearch (search snippet). Replaces the old plainTextFromPortableText() that walked Sanity Portable Text blocks.

Modified: Blog pages (Sanity → Drizzle swap)

  • blog/page.tsx — removed GROQ query + client import. Uses getBlogPosts(locale) + getBlogCategories(locale). Added revalidate = 3600.
  • blog/[slug]/page.tsxgenerateMetadata uses getBlogPostBySlug. Removed dynamic = 'force-dynamic'. Added revalidate = 3600.
  • blog/[slug]/PostDetails.tsx — replaced GROQ fetch with getBlogPostBySlug. Replaced entire renderPortableText* block (~200 lines) with <BlogPostRenderer content={body} />. Kept JSON-LD structured data, breadcrumbs, recommended posts with seeded shuffle.
  • blog/category/[category]/page.tsx — uses getBlogPostsByCategory(categorySlug, locale) + getBlogCategories(locale). Removed local slugify() — DB has proper slugs. Removed GROQ + client import.

Modified: API routes

  • api/blog-search/route.ts — replaced GROQ with getBlogPosts(locale), returns {id, title, body, slug}.
  • api/blog-author/route.ts — replaced GROQ with getBlogAuthorByName(name, locale). Bio is now plain text string (was Portable Text array — root cause of "Objects are not valid as React child" runtime error).

Modified: Blog components (type migration)

  • BlogFilters.tsx — new Post type (_idid, slug.currentslug, body → Tiptap JSON). Removed all Portable Text types (PortableTextSpan, PortableTextBlock, PortableTextImage, PortableText). Author.bio is now string | null (was Portable Text). Uses extractPlainText() for excerpts and search.
  • BlogCard.tsx — fixed Link import (next/link@/i18n/routing). Uses extractPlainText for excerpt. Updated field refs (post.slug.currentpost.slug).
  • BlogGrid.tsx — key prop post._idpost.id.
  • BlogHeaderSearch.tsx — updated PostSearchItem type (_idid, slugstring). Uses extractPlainText for search snippets.

Modified: Header components (category type update)

Category type changed from Sanity shape { _id: string; title: string } to DB shape { id: string; slug: string; title: string } across all components:

  • layout.tsx — replaced Sanity cached query (getCachedBlogCategories) with getBlogCategories(locale). Removed groq, client, unstable_cache imports.
  • AppChrome.tsx, MainSwitcher.tsx, UnifiedHeader.tsx, DesktopNav.tsx, MobileActions.tsx — updated category prop types.
  • AppMobileMenu.tsx — updated category type, removed local slugify(), uses category.slug directly.
  • BlogCategoryLinks.tsx — updated category type, removed local slugify(), uses category.slug directly. category._idcategory.id.

Modified: lib/blog/image.ts

shouldBypassImageOptimization() now always returns false — Cloudinary URLs work natively with Next.js Image optimizer (no bypass needed unlike Sanity CDN).

Deleted: BlogNavLinks.tsx

Dead code — not imported anywhere. Was the old Sanity-based category navigation.

Test plan

  • npm run dev → navigate /en/blog, /uk/blog, /pl/blog — posts load
  • /en/blog/[any-slug] — post renders with correct body, images, author
  • /en/blog/category/[cat] — category filter works
  • Blog search returns results, snippets display correctly
  • Click author name → author profile loads (bio as text, social links)
  • Browser network tab: zero requests to api.sanity.io or cdn.sanity.io
  • npm run build — no errors

Summary by CodeRabbit

Release Notes

  • New Features

    • Added enhanced blog post rendering with improved content display
    • Implemented new blog data queries for optimized performance
  • Improvements

    • Simplified blog search results format
    • Enhanced category navigation with improved slug-based routing
    • Updated blog author profile data retrieval
    • Improved image optimization handling across blog components

LesiaUKR added 7 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.
@vercel
Copy link
Contributor

vercel bot commented Mar 4, 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 5, 2026 0:36am

Request Review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 4, 2026

📝 Walkthrough

Walkthrough

This PR replaces Sanity/GROQ blog data fetching with Drizzle/Postgres queries, adds locale-aware query functions for posts/categories/authors, introduces a TipTap JSON server-side BlogPostRenderer, updates blog types and components to the new shapes, and removes client-side GROQ fetches across blog routes.

Changes

Cohort / File(s) Summary
Query Layer
frontend/db/queries/blog/blog-posts.ts, frontend/db/queries/blog/blog-categories.ts, frontend/db/queries/blog/blog-authors.ts
New Drizzle-based modules exposing getBlogPosts, getBlogPostBySlug, getBlogPostsByCategory, getBlogPostSlugs, getBlogCategories, getCachedBlogCategories, and getBlogAuthorByName (locale-aware, published/scheduled filtering, category attachments).
Pages & Routing
frontend/app/[locale]/blog/page.tsx, frontend/app/[locale]/blog/[slug]/page.tsx, frontend/app/[locale]/blog/[slug]/PostDetails.tsx, frontend/app/[locale]/blog/category/[category]/page.tsx, frontend/app/[locale]/layout.tsx
Replace inline GROQ/client fetches with the new query functions; update metadata/generation points and category handling to use slug/title; add locale args where needed.
API Routes
frontend/app/api/blog-author/route.ts, frontend/app/api/blog-search/route.ts
Swap GROQ fetches for getBlogAuthorByName and getBlogPosts; simplify response shapes and use plain-text extraction for search.
Renderer
frontend/components/blog/BlogPostRenderer.tsx
New server component to render TipTap JSON into semantic HTML (headings, lists, blockquote, code, images, marks, links).
Blog Components / Types
frontend/components/blog/BlogCard.tsx, .../BlogFilters.tsx, .../BlogGrid.tsx, .../BlogHeaderSearch.tsx, .../BlogCategoryLinks.tsx
Update data shapes: _idid, slug.currentslug, categories from string{slug,title}; remove PortableText types; switch to extractPlainText for excerpts and search snippets; update keys and link construction.
Removed Component
frontend/components/blog/BlogNavLinks.tsx
Deleted client-side category-fetching nav component (categories now provided server-side).
Header / Nav Props
frontend/components/header/... (AppChrome.tsx, AppMobileMenu.tsx, DesktopNav.tsx, MainSwitcher.tsx, MobileActions.tsx, UnifiedHeader.tsx)
Prop type changes: blog category shape updated to { id, slug, title }; removed local slugify logic and adjusted keys/links accordingly.
DB Schema / Drizzle Meta
frontend/db/schema/blog.ts, frontend/drizzle/meta/*
Refactor pgTable declarations to table-callback form and add index declarations; update drizzle snapshot JSON formatting.
Lib Utilities
frontend/lib/blog/image.ts, frontend/lib/blog/text.ts, frontend/lib/services/orders/checkout.ts
Remove isSanityAssetUrl; simplify shouldBypassImageOptimization to a noop-stub; add extractPlainText for TipTap JSON extraction; minor formatting changes.
Misc / Seeds / Actions
frontend/db/seed-blog-migration.ts, frontend/actions/quiz.ts
Minor logging formatting and an import reorder in quiz actions (no behavior changes).

Sequence Diagram(s)

sequenceDiagram
  participant Browser
  participant NextJS_Server
  participant QueryLayer
  participant Postgres
  Browser->>NextJS_Server: GET /{locale}/blog/{slug}
  NextJS_Server->>QueryLayer: getBlogPostBySlug(slug, locale)
  QueryLayer->>Postgres: execute Drizzle query (posts + joins)
  Postgres-->>QueryLayer: row(s) with translations, author, categories
  QueryLayer-->>NextJS_Server: BlogPost object (with categories)
  NextJS_Server->>NextJS_Server: render PostDetails -> BlogPostRenderer(content)
  NextJS_Server-->>Browser: HTML response (rendered post)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~70 minutes

Possibly related issues

Possibly related PRs

  • PR #390 — Modifies frontend/db/schema/blog.ts with similar Drizzle table/index changes.
  • PR #201 — Updates header/navigation components and Category prop shapes overlapping the header/type adjustments here.
  • PR #363 — Touches frontend/lib/blog/image.ts (sanity/cloudinary image helpers) which this PR also modifies.

Suggested labels

refactor, enhancement

Suggested reviewers

  • AM1007
  • ViktorSvertoka

Poem

🐇 I hopped from GROQ to rows that sing,
Postgres seeds and Drizzle bring.
TipTap JSON blooms into HTML art,
I chew the code and play my part. 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 9.52% 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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and accurately describes the main objective: migrating the frontend blog from Sanity GROQ queries to Drizzle ORM with PostgreSQL as the data source.
Linked Issues check ✅ Passed All code requirements from issue #386 are satisfied: Drizzle query layer created (blog-posts.ts, blog-categories.ts, blog-authors.ts with all specified functions), BlogPostRenderer component added, blog routes updated to use new queries with ISR, types updated across components, and Sanity GROQ queries completely removed from public blog routes.
Out of Scope Changes check ✅ Passed All changes are directly aligned with issue #386 scope: query layer implementation, BlogPostRenderer creation, blog route updates, component type migrations, image handling adjustment, and deletion of dead code (BlogNavLinks.tsx)—no unrelated modifications present.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • 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.

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: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/app/[locale]/blog/category/[category]/page.tsx (1)

72-73: ⚠️ Potential issue | 🟠 Major

Posts can disappear when the first item has no image.

Line 72 gates featured rendering on featuredPost?.mainImage, but Line 127 always drops the first post via posts.slice(1). If the first post has no image, it won’t render anywhere.

Suggested fix
-  const featuredPost = posts[0];
+  const featuredPost = posts[0];
+  const showFeaturedHero = Boolean(featuredPost?.mainImage);

-        {featuredPost?.mainImage && (
+        {showFeaturedHero && (
           <section className="mt-10">
             ...
           </section>
         )}
         <div className="mt-12">
-          <BlogCategoryGrid posts={posts.slice(1)} />
+          <BlogCategoryGrid posts={showFeaturedHero ? posts.slice(1) : posts} />
         </div>

Also applies to: 127-127

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

In `@frontend/app/`[locale]/blog/category/[category]/page.tsx around lines 72 -
73, The featured-post rendering is gated on featuredPost?.mainImage while the
list rendering unconditionally uses posts.slice(1), which causes the first post
to be dropped entirely if it lacks an image; update the logic so the featured
slot and the posts list stay consistent — either choose the featured post as the
first post that has a mainImage (e.g., compute featuredPost = posts.find(p =>
p.mainImage) and then use posts.filter(p => p !== featuredPost) for the list),
or stop gating the featured section on featuredPost?.mainImage and instead
render a fallback for posts without images; adjust references to featuredPost
and posts.slice(1) accordingly so no post is silently omitted.
🧹 Nitpick comments (6)
frontend/db/queries/blog/blog-authors.ts (1)

6-14: Consider using a stronger type for socialMedia.

The socialMedia field is typed as unknown[], but based on the schema and consumer expectations (SocialLink[]), you could use a more specific type to improve type safety across the codebase.

♻️ Proposed type improvement
+export interface SocialMediaLink {
+  platform?: string;
+  url?: string;
+}
+
 export interface BlogAuthorProfile {
   name: string;
   image: string | null;
   company: string | null;
   jobTitle: string | null;
   city: string | null;
   bio: string | null;
-  socialMedia: unknown[];
+  socialMedia: SocialMediaLink[];
 }

Then update line 57:

-    socialMedia: (row.socialMedia as unknown[]) ?? [],
+    socialMedia: (row.socialMedia as SocialMediaLink[]) ?? [],
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/db/queries/blog/blog-authors.ts` around lines 6 - 14, The
BlogAuthorProfile type uses socialMedia: unknown[] which is too loose; change it
to the correct, stronger type (e.g., SocialLink[] or an appropriate interface)
so consumers get proper type safety. Update the BlogAuthorProfile declaration
(the socialMedia field) to use the SocialLink[] type (or the exact
SocialLink-like type used elsewhere) and ensure any imports or exports for
SocialLink are added/adjusted so the file compiles and callers receive the
stronger typing.
frontend/components/blog/BlogPostRenderer.tsx (2)

145-156: Consider handling missing image src gracefully.

If node.attrs?.src is undefined or empty, next/image may error or produce unexpected behavior. Consider returning null or a placeholder when src is missing.

♻️ Proposed improvement
     case 'image':
+      if (!node.attrs?.src) return null;
       return (
         <Image
           key={key}
-          src={node.attrs?.src ?? ''}
+          src={node.attrs.src}
           alt={node.attrs?.alt ?? 'Post image'}
           width={1200}
           height={800}
           sizes="100vw"
           className="my-6 h-auto w-full rounded-xl border border-gray-200"
         />
       );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/blog/BlogPostRenderer.tsx` around lines 145 - 156, The
image renderer in BlogPostRenderer's case 'image' uses node.attrs?.src directly
which can be undefined/empty and cause next/image to error; update the case
'image' branch (where Image is returned) to check node.attrs?.src (and
optionally node.attrs?.alt) and if the src is falsy return null or a lightweight
placeholder element instead of rendering <Image>, otherwise render <Image> with
the validated src and alt values; ensure you reference the same node.attrs?.src
check and the Image component when making the change.

38-48: Consider sanitizing external link URLs.

Links rendered from user content could potentially contain javascript: URLs or other harmful schemes. Consider validating the href before rendering.

🛡️ Proposed validation
       case 'link':
+        const href = mark.attrs?.href;
+        const isSafeUrl = href && (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('/') || href.startsWith('#') || href.startsWith('mailto:'));
+        if (!isSafeUrl) {
+          // Render as plain text if URL is potentially unsafe
+          break;
+        }
         node = (
           <a
-            href={mark.attrs?.href}
+            href={href}
             target="_blank"
             rel="noopener noreferrer"
             className="text-[var(--accent-primary)] underline underline-offset-4"
           >
             {node}
           </a>
         );
         break;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/blog/BlogPostRenderer.tsx` around lines 38 - 48, The link
case in BlogPostRenderer is rendering user-provided hrefs (mark.attrs?.href)
without sanitization, which allows unsafe schemes like javascript:; update the
case 'link' branch to validate/sanitize the URL before rendering: parse
mark.attrs?.href (e.g., new URL or a safe parser), allow only safe protocols
(http:, https:, mailto: or a configurable whitelist), and if the href is invalid
or uses a disallowed scheme set a safe fallback (e.g., omit the href or use '#'
and do not set target="_blank") or render the content as plain text; keep the
existing rel="noopener noreferrer" but ensure target="_blank" is only added for
validated http(s) links.
frontend/db/queries/blog/blog-posts.ts (2)

85-97: Remove unused buildPostJoins helper.

This function is defined but never called. The join logic is duplicated inline in getBlogPosts, getBlogPostBySlug, and getBlogPostsByCategory.

♻️ Either remove or use the helper

Option 1: Remove the unused function

-function buildPostJoins(locale: string) {
-  return (qb: any) =>
-    qb
-      .leftJoin(
-        blogPostTranslations,
-        sql`${blogPostTranslations.postId} = ${blogPosts.id} AND ${blogPostTranslations.locale} = ${locale}`
-      )
-      .leftJoin(blogAuthors, eq(blogAuthors.id, blogPosts.authorId))
-      .leftJoin(
-        blogAuthorTranslations,
-        sql`${blogAuthorTranslations.authorId} = ${blogAuthors.id} AND ${blogAuthorTranslations.locale} = ${locale}`
-      );
-}

Option 2: Refactor queries to use the helper (requires adjusting the query builder pattern)

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

In `@frontend/db/queries/blog/blog-posts.ts` around lines 85 - 97, The helper
function buildPostJoins is defined but unused and duplicates join logic present
in getBlogPosts, getBlogPostBySlug, and getBlogPostsByCategory; either delete
buildPostJoins entirely, or update those three functions to call
buildPostJoins(locale) as the join callback (adjusting the query-builder usage
to accept a returned function) so the join logic is centralized and the
duplicated leftJoin/sql blocks are removed from getBlogPosts, getBlogPostBySlug,
and getBlogPostsByCategory.

209-214: Redundant author bio override.

The assemblePost function already sets bio: row.authorBio ?? null at line 152. Re-assigning the same value here appears unnecessary.

♻️ Simplify by returning base directly
   const catMap = await attachCategories([row.id], locale);
-  const base = assemblePost(row as RawPostRow, catMap.get(row.id) ?? []);
-
-  return {
-    ...base,
-    author: base.author
-      ? { ...base.author, bio: (row as RawPostRow).authorBio }
-      : undefined,
-  };
+  return assemblePost(row as RawPostRow, catMap.get(row.id) ?? []);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/db/queries/blog/blog-posts.ts` around lines 209 - 214, The code
redundantly overrides author.bio when assembling a post; in the assemblePost
function avoid reassigning author.bio from (row as RawPostRow).authorBio since
base already sets bio (authorBio ?? null). Replace the returned object that
spreads base and reassigns author with simply returning base (i.e., remove the
author: base.author ? {...} override) so the existing bio value is preserved and
duplication removed.
frontend/app/api/blog-search/route.ts (1)

10-17: Consider returning a lighter search payload.

Line 13 and Line 17 currently return full body JSON for all posts. This can get heavy as content grows. A better long-term shape is id/title/slug + plainText (or excerpt) precomputed server-side to reduce transfer and client filtering cost.

Refactor sketch
+import { extractPlainText } from '@/lib/blog/text';

   const items = posts.map(p => ({
     id: p.id,
     title: p.title,
-    body: p.body,
+    bodyText: extractPlainText(p.body),
     slug: p.slug,
   }));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/api/blog-search/route.ts` around lines 10 - 17, The search route
currently includes the full post body in the response; update the posts mapping
in route.ts (the posts.map(...) that builds items and the
NextResponse.json(items) return) to return a lighter payload: keep id, title,
slug and replace body with a precomputed excerpt or plainText field (e.g.,
excerpt or plainText) trimmed to a safe length or generated from
post.summary/plainText if available server-side; ensure the map uses the
server-side precomputed field (or derives a short excerpt) before returning
items so clients receive id/title/slug/excerpt instead of the full body.
🤖 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]/blog/[slug]/PostDetails.tsx:
- Line 224: PostDetails currently passes the unoptimized prop unconditionally on
the hero, recommended post, and author images which disables Next.js image
optimization; remove the unconditional unoptimized attribute in the PostDetails
component and instead mirror the conditional pattern used in BlogCard (use the
same check you have there — e.g., a boolean like disableImageOptimization or a
function that detects local/static URLs) so you only set unoptimized when that
condition is true; update the hero image, the recommended-post image rendering
in PostDetails (or RecommendedPost helper used there), and the author avatar
rendering to use that conditional flag rather than always passing unoptimized.

In `@frontend/app/`[locale]/layout.tsx:
- Around line 28-31: The layout currently calls getBlogCategories(locale) on
every render which can hammer the DB; wrap or replace that call with a cached
variant keyed by locale (e.g., implement cachedGetBlogCategories(locale) or add
caching inside getBlogCategories) that returns previously fetched categories for
a locale and only refreshes on TTL/invalidations (or use Next.js caching helpers
if available). Locate the call site in layout.tsx (the Promise.all that includes
getBlogCategories) and swap it to use the cached function, or modify the
existing getBlogCategories function to consult an in-memory Map/cache keyed by
locale (and implement a sensible TTL or explicit invalidation) so repeated
global layout requests do not trigger DB queries.

In `@frontend/components/blog/BlogCategoryLinks.tsx`:
- Around line 13-15: Update the test fixtures used by the BlogCategoryLinks
component so they match the new category schema: replace objects that pass {
_id, title } with { id, slug, title } (add a slug and rename _id to id) in
frontend/components/tests/blog/blog-category-links.test.tsx (the fixture around
lines where the test creates category props) and any other tests that construct
category props for BlogCategoryLinks; ensure the updated objects provide valid
id and slug strings so category.id and category.slug are defined in the
component.

In `@frontend/components/blog/BlogFilters.tsx`:
- Around line 387-390: The category title access is assumed non-null and can
throw when c.title is null; update the filtering and featured-label paths (look
for the postCategories mapping that uses normalizeTag(c.title) and the usages
around resolvedCategory.norm and getCategoryLabel) to defensively handle nullish
titles by skipping or defaulting them before calling normalizeTag and before
passing into getCategoryLabel—e.g., filter out or map to an empty string when
c.title is null, and ensure resolvedCategory input is validated (null/undefined)
before includes/getCategoryLabel is called so the filter and featured-label
rendering cannot fail on a missing title.

In `@frontend/db/schema/blog.ts`:
- Around line 4-5: Remove the duplicate import of the identifier "index" in the
import list in frontend/db/schema/blog.ts; locate the import statement that
currently contains "index, index," (referencing the imported symbol "index") and
delete the redundant entry so "index" is only imported once to resolve the
parse/compile error.

---

Outside diff comments:
In `@frontend/app/`[locale]/blog/category/[category]/page.tsx:
- Around line 72-73: The featured-post rendering is gated on
featuredPost?.mainImage while the list rendering unconditionally uses
posts.slice(1), which causes the first post to be dropped entirely if it lacks
an image; update the logic so the featured slot and the posts list stay
consistent — either choose the featured post as the first post that has a
mainImage (e.g., compute featuredPost = posts.find(p => p.mainImage) and then
use posts.filter(p => p !== featuredPost) for the list), or stop gating the
featured section on featuredPost?.mainImage and instead render a fallback for
posts without images; adjust references to featuredPost and posts.slice(1)
accordingly so no post is silently omitted.

---

Nitpick comments:
In `@frontend/app/api/blog-search/route.ts`:
- Around line 10-17: The search route currently includes the full post body in
the response; update the posts mapping in route.ts (the posts.map(...) that
builds items and the NextResponse.json(items) return) to return a lighter
payload: keep id, title, slug and replace body with a precomputed excerpt or
plainText field (e.g., excerpt or plainText) trimmed to a safe length or
generated from post.summary/plainText if available server-side; ensure the map
uses the server-side precomputed field (or derives a short excerpt) before
returning items so clients receive id/title/slug/excerpt instead of the full
body.

In `@frontend/components/blog/BlogPostRenderer.tsx`:
- Around line 145-156: The image renderer in BlogPostRenderer's case 'image'
uses node.attrs?.src directly which can be undefined/empty and cause next/image
to error; update the case 'image' branch (where Image is returned) to check
node.attrs?.src (and optionally node.attrs?.alt) and if the src is falsy return
null or a lightweight placeholder element instead of rendering <Image>,
otherwise render <Image> with the validated src and alt values; ensure you
reference the same node.attrs?.src check and the Image component when making the
change.
- Around line 38-48: The link case in BlogPostRenderer is rendering
user-provided hrefs (mark.attrs?.href) without sanitization, which allows unsafe
schemes like javascript:; update the case 'link' branch to validate/sanitize the
URL before rendering: parse mark.attrs?.href (e.g., new URL or a safe parser),
allow only safe protocols (http:, https:, mailto: or a configurable whitelist),
and if the href is invalid or uses a disallowed scheme set a safe fallback
(e.g., omit the href or use '#' and do not set target="_blank") or render the
content as plain text; keep the existing rel="noopener noreferrer" but ensure
target="_blank" is only added for validated http(s) links.

In `@frontend/db/queries/blog/blog-authors.ts`:
- Around line 6-14: The BlogAuthorProfile type uses socialMedia: unknown[] which
is too loose; change it to the correct, stronger type (e.g., SocialLink[] or an
appropriate interface) so consumers get proper type safety. Update the
BlogAuthorProfile declaration (the socialMedia field) to use the SocialLink[]
type (or the exact SocialLink-like type used elsewhere) and ensure any imports
or exports for SocialLink are added/adjusted so the file compiles and callers
receive the stronger typing.

In `@frontend/db/queries/blog/blog-posts.ts`:
- Around line 85-97: The helper function buildPostJoins is defined but unused
and duplicates join logic present in getBlogPosts, getBlogPostBySlug, and
getBlogPostsByCategory; either delete buildPostJoins entirely, or update those
three functions to call buildPostJoins(locale) as the join callback (adjusting
the query-builder usage to accept a returned function) so the join logic is
centralized and the duplicated leftJoin/sql blocks are removed from
getBlogPosts, getBlogPostBySlug, and getBlogPostsByCategory.
- Around line 209-214: The code redundantly overrides author.bio when assembling
a post; in the assemblePost function avoid reassigning author.bio from (row as
RawPostRow).authorBio since base already sets bio (authorBio ?? null). Replace
the returned object that spreads base and reassigns author with simply returning
base (i.e., remove the author: base.author ? {...} override) so the existing bio
value is preserved and duplication removed.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 0910f02c-d27e-48cb-a403-5cb424e69c5c

📥 Commits

Reviewing files that changed from the base of the PR and between efa85ef and 5f77c79.

📒 Files selected for processing (33)
  • frontend/actions/quiz.ts
  • 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/[locale]/layout.tsx
  • frontend/app/api/blog-author/route.ts
  • frontend/app/api/blog-search/route.ts
  • frontend/components/blog/BlogCard.tsx
  • frontend/components/blog/BlogCategoryLinks.tsx
  • frontend/components/blog/BlogFilters.tsx
  • frontend/components/blog/BlogGrid.tsx
  • frontend/components/blog/BlogHeaderSearch.tsx
  • frontend/components/blog/BlogNavLinks.tsx
  • frontend/components/blog/BlogPostRenderer.tsx
  • frontend/components/header/AppChrome.tsx
  • frontend/components/header/AppMobileMenu.tsx
  • frontend/components/header/DesktopNav.tsx
  • frontend/components/header/MainSwitcher.tsx
  • frontend/components/header/MobileActions.tsx
  • frontend/components/header/UnifiedHeader.tsx
  • frontend/components/quiz/QuizzesSection.tsx
  • frontend/db/queries/blog/blog-authors.ts
  • frontend/db/queries/blog/blog-categories.ts
  • frontend/db/queries/blog/blog-posts.ts
  • frontend/db/schema/blog.ts
  • frontend/db/seed-blog-migration.ts
  • frontend/drizzle/meta/0027_snapshot.json
  • frontend/drizzle/meta/0028_snapshot.json
  • frontend/drizzle/meta/_journal.json
  • frontend/lib/blog/image.ts
  • frontend/lib/blog/text.ts
  • frontend/lib/services/orders/checkout.ts
💤 Files with no reviewable changes (1)
  • frontend/components/blog/BlogNavLinks.tsx

Comment on lines +13 to 15
id: string;
slug: string;
title: string;
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

Update dependent test fixtures to the new category schema.

BlogCategoryLinks now requires { id, slug, title }, but frontend/components/tests/blog/blog-category-links.test.tsx (Lines 46-53) still passes { _id, title }. That creates invalid props for this component (category.slug/category.id undefined) and can break test behavior.

Proposed fixture update (test file)
 render(
   <BlogCategoryLinks
     categories={[
-      { _id: '1', title: 'Tech' },
-      { _id: '2', title: 'Growth' },
+      { id: '1', slug: 'tech', title: 'Tech' },
+      { id: '2', slug: 'growth', title: 'Growth' },
     ]}
   />
 );

Also applies to: 83-83

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

In `@frontend/components/blog/BlogCategoryLinks.tsx` around lines 13 - 15, Update
the test fixtures used by the BlogCategoryLinks component so they match the new
category schema: replace objects that pass { _id, title } with { id, slug, title
} (add a slug and rename _id to id) in
frontend/components/tests/blog/blog-category-links.test.tsx (the fixture around
lines where the test creates category props) and any other tests that construct
category props for BlogCategoryLinks; ensure the updated objects provide valid
id and slug strings so category.id and category.slug are defined in the
component.

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.

🧹 Nitpick comments (3)
frontend/db/schema/blog.ts (1)

102-104: Add an index for publish-window query paths.

Nice refactor to callback-style indexes. Since public blog reads now filter by publish flags/timestamps, add a composite index to keep those queries scalable as rows grow.

💡 Suggested change
   table => ({
     authorIdx: index('blog_posts_author_id_idx').on(table.authorId),
+    publishWindowIdx: index('blog_posts_publish_window_idx').on(
+      table.isPublished,
+      table.scheduledPublishAt,
+      table.publishedAt
+    ),
   })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/db/schema/blog.ts` around lines 102 - 104, Add a composite index for
publish-window queries alongside the existing authorIdx: update the table => ({
... }) index block (where authorIdx is defined using
index('blog_posts_author_id_idx').on(table.authorId)) to include a new index
(e.g., blog_posts_publish_idx) that covers the publish flag/timestamp columns
used by read queries (for example published/status, publishAt/start, and
unpublishAt/end) so reads filtering by publish state and timestamps are
supported efficiently as the table grows.
frontend/app/[locale]/blog/[slug]/PostDetails.tsx (1)

55-58: Avoid loading the full post list before the 404 short-circuit.

getBlogPosts(locale) executes even when the slug does not exist. That adds avoidable DB load for invalid-slug traffic.

Proposed change
-  const [post, allPosts] = await Promise.all([
-    getBlogPostBySlug(slugParam, locale),
-    getBlogPosts(locale),
-  ]);
-
-  if (!post) return notFound();
+  const post = await getBlogPostBySlug(slugParam, locale);
+  if (!post) return notFound();
+  const allPosts = await getBlogPosts(locale);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/`[locale]/blog/[slug]/PostDetails.tsx around lines 55 - 58,
Currently Promise.all triggers getBlogPosts(locale) even for missing slugs;
change the logic in PostDetails.tsx to first await getBlogPostBySlug(slugParam,
locale), check the returned post and short-circuit (throw/render 404) if
missing, and only then call getBlogPosts(locale) to load allPosts; remove the
Promise.all usage and sequence the calls so getBlogPosts is not executed for
invalid slugs (reference functions getBlogPostBySlug, getBlogPosts and variables
slugParam, locale, post).
frontend/components/blog/BlogFilters.tsx (1)

379-408: Precompute searchable body text to keep filtering responsive.

extractPlainText(post.body) runs for every post on every filter pass. For larger datasets, this can cause noticeable UI lag while searching.

Proposed change
+  const searchableBodyById = useMemo(() => {
+    const map = new Map<string, string>();
+    for (const post of posts) {
+      map.set(post.id, normalizeSearchText(extractPlainText(post.body)));
+    }
+    return map;
+  }, [posts]);
+
   const filteredPosts = useMemo(() => {
     return posts.filter(post => {
@@
       if (searchQueryNormalized) {
         const titleText = normalizeSearchText(post.title);
-        const bodyText = normalizeSearchText(extractPlainText(post.body));
+        const bodyText = searchableBodyById.get(post.id) || '';
         const words = searchQueryNormalized.split(/\s+/).filter(Boolean);
@@
-  }, [posts, resolvedAuthor, resolvedCategory, searchQueryNormalized]);
+  }, [
+    posts,
+    resolvedAuthor,
+    resolvedCategory,
+    searchQueryNormalized,
+    searchableBodyById,
+  ]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/blog/BlogFilters.tsx` around lines 379 - 408, The filter
recalculates extractPlainText(post.body) for every post on each render;
precompute normalized searchable title/body once when posts change and use those
in the filteredPosts useMemo. Add a new useMemo (dependent on posts) that maps
each post to a lightweight derived object containing searchTitle =
normalizeSearchText(post.title) and searchBody =
normalizeSearchText(extractPlainText(post.body)), then update the existing
filteredPosts useMemo to iterate over that derived array and reference
searchTitle/searchBody instead of calling extractPlainText/normalizeSearchText
inside the per-post filter.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@frontend/app/`[locale]/blog/[slug]/PostDetails.tsx:
- Around line 55-58: Currently Promise.all triggers getBlogPosts(locale) even
for missing slugs; change the logic in PostDetails.tsx to first await
getBlogPostBySlug(slugParam, locale), check the returned post and short-circuit
(throw/render 404) if missing, and only then call getBlogPosts(locale) to load
allPosts; remove the Promise.all usage and sequence the calls so getBlogPosts is
not executed for invalid slugs (reference functions getBlogPostBySlug,
getBlogPosts and variables slugParam, locale, post).

In `@frontend/components/blog/BlogFilters.tsx`:
- Around line 379-408: The filter recalculates extractPlainText(post.body) for
every post on each render; precompute normalized searchable title/body once when
posts change and use those in the filteredPosts useMemo. Add a new useMemo
(dependent on posts) that maps each post to a lightweight derived object
containing searchTitle = normalizeSearchText(post.title) and searchBody =
normalizeSearchText(extractPlainText(post.body)), then update the existing
filteredPosts useMemo to iterate over that derived array and reference
searchTitle/searchBody instead of calling extractPlainText/normalizeSearchText
inside the per-post filter.

In `@frontend/db/schema/blog.ts`:
- Around line 102-104: Add a composite index for publish-window queries
alongside the existing authorIdx: update the table => ({ ... }) index block
(where authorIdx is defined using
index('blog_posts_author_id_idx').on(table.authorId)) to include a new index
(e.g., blog_posts_publish_idx) that covers the publish flag/timestamp columns
used by read queries (for example published/status, publishAt/start, and
unpublishAt/end) so reads filtering by publish state and timestamps are
supported efficiently as the table grows.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 282b90ee-ea9c-4524-ac82-52a14de04a39

📥 Commits

Reviewing files that changed from the base of the PR and between 5f77c79 and 6884af3.

📒 Files selected for processing (5)
  • frontend/app/[locale]/blog/[slug]/PostDetails.tsx
  • frontend/app/[locale]/layout.tsx
  • frontend/components/blog/BlogFilters.tsx
  • frontend/db/queries/blog/blog-categories.ts
  • frontend/db/schema/blog.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • frontend/db/queries/blog/blog-categories.ts

@ViktorSvertoka ViktorSvertoka merged commit f909b9a into develop Mar 5, 2026
7 checks passed
@ViktorSvertoka ViktorSvertoka deleted the sl/feat/blog-admin branch March 5, 2026 13:23
@LesiaUKR LesiaUKR restored the sl/feat/blog-admin branch March 5, 2026 18:31
@coderabbitai coderabbitai bot mentioned this pull request Mar 8, 2026
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