feat(blog): add SEO fields, scheduling, and representative ownership to blog posts
- Introduced new SEO fields (meta_title, meta_description, primary_keyword, secondary_keywords, faq, internal_links, external_links, reading_time, canonical_url, og_image) to the Blog entity. - Added scheduling capability with a scheduled_at field to manage automatic publishing of blog posts. - Implemented representative ownership through a foreign key representation_id in the Blog entity, allowing representatives to manage their own posts. - Updated BlogController and RepresentationBlogController to handle new fields and ensure proper data handling for SEO and scheduling. - Created BlogWriter service to encapsulate the logic for applying SEO and scheduling fields to blog entities. - Added PublishScheduledBlogsMessage and its handler to manage the publishing of scheduled blogs. - Implemented ScheduledBlogPublisher service to publish drafts whose scheduled_at has arrived, respecting review status. - Created migration to update the database schema with new fields and constraints. - Added tests to ensure the correct functionality of new features, including SEO fields, representative scope, and scheduled publishing.
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
import { z } from 'zod';
|
||||
import type { Blog } from '../types';
|
||||
|
||||
/**
|
||||
* Shared blog form schema, defaults and payload builder — reused by the admin
|
||||
* BlogFormPage and the representative RepresentationBlogFormPage so both accept
|
||||
* the same core + SEO + scheduling fields without duplication.
|
||||
*/
|
||||
export const blogFormSchema = z.object({
|
||||
title: z.string().min(3, 'عنوان الزامی است'),
|
||||
summary: z.string().optional(),
|
||||
body: z.string().min(10, 'محتوا الزامی است'),
|
||||
tags: z.string().optional(),
|
||||
status: z.enum(['draft', 'published']),
|
||||
image_url: z.string().optional(),
|
||||
city_id: z.number().nullable().optional(),
|
||||
// SEO
|
||||
meta_title: z.string().optional(),
|
||||
meta_description: z.string().optional(),
|
||||
primary_keyword: z.string().optional(),
|
||||
secondary_keywords: z.string().optional(), // comma-separated in the form
|
||||
internal_links: z.string().optional(), // comma-separated URLs
|
||||
external_links: z.string().optional(),
|
||||
reading_time: z.number().nullable().optional(),
|
||||
faq: z.array(z.object({ q: z.string(), a: z.string() })).optional(),
|
||||
// scheduling (unix seconds, null = manual publish)
|
||||
scheduled_at: z.number().nullable().optional(),
|
||||
});
|
||||
|
||||
export type BlogFormData = z.infer<typeof blogFormSchema>;
|
||||
|
||||
const splitCsv = (s?: string): string[] =>
|
||||
s ? s.split(',').map((x) => x.trim()).filter(Boolean) : [];
|
||||
|
||||
export function blogDefaults(blog?: Blog): Partial<BlogFormData> {
|
||||
if (!blog) return { status: 'draft', faq: [] };
|
||||
return {
|
||||
title: blog.title,
|
||||
summary: blog.summary ?? '',
|
||||
body: blog.body,
|
||||
tags: blog.tags?.join(', ') ?? '',
|
||||
status: blog.status,
|
||||
image_url: blog.image_url ?? '',
|
||||
city_id: blog.city ? Number(blog.city.id) : null,
|
||||
meta_title: blog.meta_title ?? '',
|
||||
meta_description: blog.meta_description ?? '',
|
||||
primary_keyword: blog.primary_keyword ?? '',
|
||||
secondary_keywords: (blog.secondary_keywords ?? []).join(', '),
|
||||
internal_links: (blog.internal_links ?? []).map((l) => (typeof l === 'string' ? l : l.url)).join(', '),
|
||||
external_links: (blog.external_links ?? []).map((l) => (typeof l === 'string' ? l : l.url)).join(', '),
|
||||
reading_time: blog.reading_time ?? null,
|
||||
faq: blog.faq ?? [],
|
||||
scheduled_at: blog.scheduled_at ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build the API payload (shared shape for admin + representative endpoints). */
|
||||
export function buildBlogPayload(d: BlogFormData): Record<string, unknown> {
|
||||
return {
|
||||
title: d.title,
|
||||
body: d.body,
|
||||
summary: d.summary,
|
||||
status: d.status,
|
||||
image_url: d.image_url ?? '',
|
||||
tags: splitCsv(d.tags),
|
||||
city_id: d.city_id ?? null,
|
||||
meta_title: d.meta_title || null,
|
||||
meta_description: d.meta_description || null,
|
||||
primary_keyword: d.primary_keyword || null,
|
||||
secondary_keywords: splitCsv(d.secondary_keywords),
|
||||
internal_links: splitCsv(d.internal_links),
|
||||
external_links: splitCsv(d.external_links),
|
||||
reading_time: d.reading_time ?? null,
|
||||
faq: (d.faq ?? []).filter((f) => f.q.trim() && f.a.trim()),
|
||||
scheduled_at: d.scheduled_at ?? null,
|
||||
};
|
||||
}
|
||||
@@ -446,6 +446,21 @@ export interface Blog {
|
||||
review_note?: string | null;
|
||||
/** کلید یکتای موضوع پایپلاین (specialty, angle) */
|
||||
topic_slug?: string | null;
|
||||
// SEO
|
||||
meta_title?: string | null;
|
||||
meta_description?: string | null;
|
||||
primary_keyword?: string | null;
|
||||
secondary_keywords?: string[];
|
||||
faq?: { q: string; a: string }[];
|
||||
internal_links?: (string | { url: string; anchor?: string })[];
|
||||
external_links?: (string | { url: string; anchor?: string })[];
|
||||
reading_time?: number | null;
|
||||
canonical_url?: string | null;
|
||||
og_image?: string | null;
|
||||
/** زمان انتشار خودکار (unix)، null = دستی */
|
||||
scheduled_at?: number | null;
|
||||
/** نمایندهٔ مالک، null = پست ادمین/سراسری */
|
||||
representation?: { uuid: string; name: string; domain?: string | null } | null;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user