The Blog/CMS system is a full-stack content management feature spanning 5 backend modules, 5 entities, a public API surface, and a Next.js 15 frontend with both dashboard CRUD and public-facing SSR pages.
Purpose: Provide a complete blogging platform with categories, tags, SEO, image uploads, draft/publish workflow, soft-delete/trash/restore lifecycle, view tracking, and public SSR rendering with ISR caching.
Key Responsibilities:
- Blog post CRUD with rich text (BlockNote), image upload, SEO metadata
- Blog categories (hierarchical parent/child) and tags
- Generic pages (CMS-style pages: About, Terms, Privacy, etc.)
- Slug generation and uniqueness across entities
- Public blog listing, detail, author pages, category pages
- Public dynamic page rendering via catch-all [slug] route
- ISR caching with tag-based revalidation
Integration Points: Auth system (ownership-based permissions), Permission system (hybrid global.blog-* + blog.*), Storage system (image uploads), Menu Builder (pages as menu items), BlockNote editor (rich text), DOMPurify (XSS prevention)
The Blog/CMS system follows a module-per-entity pattern on the backend (controller/service/routes/validation) and a page-per-feature pattern on the frontend. The Public module is a separate concern providing unauthenticated read-only endpoints.
Ownership-based access control (Blog): Uses hasPermission("global.blog-*") for admin bypass, filters to own posts otherwise. NOTE: All checkPermissionAndThrow calls are commented out — module-level permissions are not enforced.
Soft-delete lifecycle: All entities support soft-delete → trash → restore → permanent-delete via @DeleteDateColumn.
Slug generation: Two implementations coexist — blog.utils.ts (Blog-specific) and shared/generateUniqueSlug.ts (shared, used by Category/Tag/Page/Slug module). Blog's generateUniqueSlug is imported but never used — the slug comes from the frontend.
ManyToMany categories: Blog ↔ BlogCategory via blog_categories_pivot junction table.
ManyToOne tags (unusual): BlogTag → Blog means each tag instance is tied to ONE blog. Tags are NOT shared across blogs — "SEO" tag for Blog A and "SEO" tag for Blog B are separate rows.
ISR caching (frontend): Public pages use next.revalidate + tags on native fetch() for ISR.
React Query (dashboard): All dashboard hooks use @tanstack/react-query for client-side state.
Blog (1) ←→ (*) BlogTag [OneToMany, cascade, onDelete: CASCADE]
Blog (*) ←→ (*) BlogCategory [ManyToMany via blog_categories_pivot, cascade]
Blog (*) ←→ (1) User [ManyToOne via authorId, onDelete: CASCADE]
BlogViewCount (*) ←→ (1) Blog [ManyToOne via blogId, onDelete: CASCADE]
⚠ Blog entity has NO inverse @OneToMany for views — public.service references non-existent "blog.views" relation
BlogCategory (1) ←→ (*) BlogCategory [Self-referential parent/children, onDelete: CASCADE]
⚠ validateParentId restricts each parent to ONE child (artificial limit)
GenericPage — standalone (no FK relationships)
⚠ MenuItem import + OneToMany/ManyToMany imports are dead code (planned but unimplemented)
Browser → GET /blogs/[slug] (Next.js server component)
→ generateMetadata() calls getBlogDetails(slug) → fetch /public/blogs/${slug} with ISR tags
→ Page component calls getBlogDetails(slug) (deduplicated by Next.js)
→ Backend returns up to 5 relatedBlogs (shared categories) in sidebar
→ Renders BlogPageFrontend → SanitizedHTMLPreview (DOMPurify.sanitize → dangerouslySetInnerHTML)
⚠ Uses blog.imageUrl but API returns "image" — hero ALWAYS shows placeholder
⚠ View count NOT incremented (incrementBlogViewCount is dead code — zero callers anywhere)
⚠ Returns HTTP 200 even for not-found (renders <NotFound> component, doesn't call notFound())
⚠ Category links broken — API returns {id, name} without slug — all links go to /category/undefined
Dashboard → GET /blogs/${blogId} (by slug, despite param named "blogId")
→ useBlogById transforms categories/tags to {label, value}
→ BlogsForm loads data into react-hook-form
→ BlockNote editor loads HTML via tryParseHTMLToBlocks()
→ User edits → FormData → PATCH /blogs/${blogId}
→ hasPermission("global.blog-edit") for ownership bypass
→ BlogRepo.findOne({id}) ← NO relations loaded ← categories/tags are undefined
⚠ Category/tag length comparison crashes if categories/tags omitted from payload
(Frontend always sends them, but direct API callers will crash)
→ blog-edit-page.jsx onSuccess calls revalidate('blog-${slug}') + revalidate('blogs')
9 of 10 checkPermissionAndThrow calls COMMENTED OUT — any authenticated user can CRUD any blog. Only bulkBlogUpdateByCategory (L260) has an active check.
blog.service.ts
Privilege escalation
C2
Blog Service
bulkBlogUpdateByCategorysoft-deletes the old category as side-effect (L303: categoryRepo.softDelete(oldCategoryId))
blog.service.ts:303
Data destruction
C3
Blog Service
updateBlog loads blog without relations — blog.categories and blog.tags are undefined — length comparison crashes at runtime
GET /blogs/:id has no auth middleware — defined before router.use(auth()) — leaks draft content to anonymous users
H2
GenericPage Routes
GET /pages/ and GET /pages/:id are fully public — expose draft/archived pages to anonymous users
H3
Blog Routes
POST /blogs has contextMiddleware applied twice (once via router.use, once inline)
H4
Blog Entity
Orphaned categoryId column from old ManyToOne relation + stale IDX_BLOG_CATEGORY_STATUS index
H5
Blog Entity
slug has BOTH column-level unique: true AND a partial index WHERE deletedAt IS NULL — column constraint blocks slug reuse after soft-delete
H6
Blog Service
bulkBlogUpdateByCategory has no userId param — no ownership check on updated blogs
H7
Blog Service
getAllBlogs returns permissions field — leaks user's global permission set to client
H8
BlogCategory Service
updateCategory sets category.name = payload.name — on partial update, payload.name is undefined — DB NOT NULL crash
H9
BlogCategory Validation
deleteCategoryByIds field named blogIds for category IDs — confusing API contract
H10
BlogTag Validation
deleteByIds field named blogIds for tag IDs — same naming mismatch
H11
BlogCategory Service
deleteCategory returns HTTP 200 even when category is NOT deleted (linked to blogs) — client thinks delete succeeded
H12
checkPermissionAndThrow
Returns HTTP 400 (BAD_REQUEST) instead of 403 (FORBIDDEN) for permission denied — affects entire app
H13
BlogCategory Service
getAllCategories has no permission check — any authenticated user enumerates all categories
H14
BlogCategory Service
validateParentId restricts each parent to ONE child — artificial limit contradicts @OneToMany
H15
Frontend API
Blog delete/restore/bulk mutations never revalidate public SSR cache. Create and edit DO revalidate via page-level onSuccess callbacks (blog-create-page.jsx, blog-edit-page.jsx), but the mutation hooks in api/blogs/index.js do not.
H16
Frontend API
Legacy blog-tags.js hook has double base URL bug — api.get(\${NEXT_PUBLIC_API_URL}/blog-tags`)` with axios baseURL already set — requests 404
H17
Frontend API
useBlogById has no permission check — enabled solely by !!blogId — may leak blog data
H18
Frontend API
blog-categories.js is fully dead code (0 importers, 122 LOC)
H19
Frontend Components
getSortIcon called with wrong signature on 3 columns — sort icons always show default, sorting appears broken
H20
Frontend Components
useBulkRestoreBlogs() and useBulkPermanentDeleteBlogs() instantiated inline (not at top level) — creates new mutation instances every render
H21
Frontend Components
useBlogById strips category slugs — blogs-form.jsx tries cat.slug for preview but it's undefined in edit mode
H22
Public Routes
Author pages have zero SEO metadata — no generateMetadata function
H23
Public Routes
Category pages have zero SEO metadata — no generateMetadata function
H24
Public Routes
No error.js files in (main-layout) routes — unhandled errors show default error page
H25
Public Routes
No loading.js files for listing/author/category pages — no loading states
H26
Public Routes
THREE conflicting canonical URL patterns: backend getAllBlogs/public uses /blog/, backend getBlogByIdOrSlug/similar blogs uses /articles/, frontend route is /blogs/ — none match, field ignored entirely
H27
Public Routes
Blog card shows publishedAt for date but detail page shows createdAt — inconsistent dates
H28
Frontend Pages
Preview URL uses /en/${slug} but actual public route has no /en/ prefix — form preview will 404
SQL Injection via orderBy — getSortQuery does NOT validate column names. TypeORM orderBy() interpolates identifiers raw into SQL. ?orderBy=id;DROP TABLE blogs-- is injectable. Affects ALL paginated endpoints (blogs, categories, tags, pages).
NC2
Frontend blogs-form
Rules of Hooks violation — 4 watch() calls (L257-260) appear AFTER if (loading) return <Loading /> early return (L253). When loading toggles, hooks execute in different order — React crash.
NC3
Blog Service
getAllBlogs crashes when blog.author is null — L236 blog.author.avatar without optional chaining. A single deleted author crashes the entire blog list response.
NC4
Blog Service
Blogs with no slug crash on create — slug is not in createBlogSchema validation. generateUniqueSlug is imported but never called. payload.slug can be undefined, inserting NULL into unique non-nullable column.
NC5
Blog Service
Race condition in incrementBlogViewCount — read-then-write not atomic. Concurrent requests cause duplicate records or lost updates. (Moot: function has zero callers.)
NC6
Frontend pages
getDynamicPage hits authenticated /pages/${slug} endpoint — no /public/ prefix unlike blogs. Server-side fetch has no auth cookie — if backend enforces auth, all CMS pages break for anonymous visitors.
NC7
Public Routes
Category links broken on blog detail — public API getBlogByIdOrSlug returns categories as {id, name} without slug. Frontend blog-page-frontend.jsx L32 links to /category/${category?.slug} — all links render as /category/undefined.
NC8
Blog Service
createBlog controller: body authorId can override authenticated user's ID — L16-18: const authorId = req.user?.id || body.authorId then ...body spread can overwrite the correctly-set value.
No pagination limit cap — parsePaginationQuery has no maximum. ?limit=999999999 forces unbounded query — DoS via memory exhaustion on every paginated endpoint.
NH2
Blog Service
deleteFileByPathName called BEFORE blogRepository.save in updateBlog (L443 vs L469) — old image permanently lost if save fails.
NH3
BlogCategory
deleteByCategoryIds and updateBlog use deprecated findByIds — may not respect soft-delete filters in TypeORM 0.3+.
updatePage slug uniqueness check absent — L169 is dead self-assignment data.slug = data.slug. Users can set duplicate slugs.
NH6
Frontend
blog-page-frontend.jsx hero Image uses fill without sizes — Next.js Image with fill requires sizes for responsive optimization. Largest variant always downloaded, degrades LCP on mobile.
NH7
Frontend
handlePreview useCallback has stale dependency array referencing commented-out logic — unnecessary re-creation and misleading.
NH8
Frontend
toast.error() passes error object as second argument (page-form.jsx L156) — react-hot-toast treats 2nd arg as options, not message. Error silently swallowed.
NH9
Frontend
SanitizedHTMLPreview may crash during SSR — DOMPurify.sanitize() requires window/document. During SSR prerendering, this crashes or produces hydration mismatch. Should use isomorphic-dompurify.
NH10
Frontend
Blog edit slug check can corrupt existing slugs — handleSlugCheck() fires onBlur even in edit mode (unlike title which guards with if (!edit)). Can change an existing slug to a deduplicated variant.
ILIKE wildcard injection — searchTerm interpolated into %${searchTerm}% without escaping % and _ wildcards. % matches everything.
NM2
formatRaw defined but never called in getTopBlogs — week/month return raw DB column names vs today entity names.
NM3
tranding typo in public.service — validates query.tranding (misspelled) but filters on query.isTrending.
NM4
Blog cascade: true on tags OneToMany — saving Blog can INSERT/UPDATE any BlogTag objects in array. Combined with updateBlog merge, can create phantom tags.
NM5
contextMiddleware applied twice on most Blog routes (global + inline).
NM6
bulkBlogRestore doesn't filter for actually-deleted blogs — loads with withDeleted: true and restores all IDs.
NM7
GenericPage getPageByIdOrSlug returns drafts/archived to unauthenticated users (no status check).
NM8
getTopBlogs returns inconsistent shapes — today returns entities, week/month return raw objects with different property names.
NM9
Blog ownership checks use httpStatus.BAD_REQUEST (400) for ownership failures — separate from checkPermissionAndThrow 400-not-403 issue.
NM10
getLatestCategoryBlogs returns on FIRST category with blogs — for loop exits immediately. Only one category ever returned.
NM11
categoriesByFeatured destructures authorId from query result but it was never selected.
NM12
Blog slug validation allows arbitrary characters — z.string().min(3) with no format/regex.
NM13
updateCategory has no slug format validation — toLowerCase().trim() but no URL-safe character check.
NM14
getLatestCategoryBlogs loads full blogs relation then ignores it — separate query fetches per-category.
NM15
Category page title from first blog's first category — may show wrong category for multi-category blogs.
NM16
blog-categories-page.jsx parent/reassignment search share state — typing in one dropdown changes the other.
NM17
No file size validation on image upload despite "Max 2MB" label (both blog and page forms).
NM18
robotsIndex/robotsFollow are ghost form fields — in state, serialized to API, but no input in JSX. Always empty strings.
NM19
Blog form date field has zero validation — accepts any string or empty.
NM20
useBlogs default status "published" silently filters for dashboard consumers that forget to pass status.
NM21
Blog form authorName in defaultValues but no input — always uses Redux state. Dead form field.
NM22
Both [slug]/page.js and blogs/[slug]/page.js fetch data twice (metadata + component). Next.js fetch dedup may not apply across render phases.
NM23
permanentlyDeleteGenericPagesByIds — dead code, defined but NOT in export object (L267).
NM24
Blog entity GenericPage imports MenuItem but never uses it — dead import.
NM25
Blog/Page controllers save DB record BEFORE writing file to disk — if file write fails, record references non-existent image.
NM26
Variable shadowing: const page = await getDynamicPage(slug) inside function named page in [slug]/page.js.
9 of 10 blog permission checks are commented out — any authenticated user can CRUD any blog. Only bulkBlogUpdateByCategory has an active check. Ownership-based hasPermission provides a partial barrier.
BlogTag is ManyToOne, not ManyToMany — tags are NOT shared across blogs. Creating "SEO" tag for two blogs creates two separate rows.
Blog entity has dual unique constraint on slug — column-level unique: true prevents reusing slugs from soft-deleted blogs, defeating the partial index.
bulkBlogUpdateByCategory destructively soft-deletes the old category — this is almost certainly unintended.
Public blog/page GETs expose drafts — no status filter on public GET endpoints.
Blog hero image field mismatch — API sends image, frontend reads imageUrl — hero always shows placeholder.
Author pages case-sensitivity — normalizeText() lowercases, but backend exact-matches against mixed-case DB values.
SQL Injection via orderBy — getSortQuery does NOT validate column names. TypeORM interpolates them raw into SQL. Affects ALL paginated endpoints.
Rules of Hooks violation — blogs-form.jsx has 4 watch() calls after conditional early return — React crash when loading toggles.
Category links broken on blog detail — public API returns {id, name} without slug — all category links render as /category/undefined.
No pagination limit cap — sending ?limit=999999999 causes unbounded query and memory exhaustion.
Email Template System: Same pattern of commented-out checkPermissionAndThrow, same permission-seeding gap, same dangerouslySetInnerHTML + DOMPurify pattern
Menu Builder: Uses BlogCategory and Blog entities via menu items — postType: "pages" references GenericPage IDs
Settings system: Same CRUD pattern with soft-delete lifecycle
Add orderBy whitelist to getSortQuery — validate column name against allowed fields before interpolation. Blocks SQL injection on ALL paginated endpoints. (15 min)
Move watch() calls before early return in blogs-form.jsx — all hooks must precede conditional returns to satisfy Rules of Hooks. (10 min)