- 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.
78 lines
3.2 KiB
TypeScript
78 lines
3.2 KiB
TypeScript
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,
|
|
};
|
|
}
|