Files
clinicpro/assets/admin/lib/blogForm.ts
T
hamed 62b2f28c4f 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.
2026-07-23 22:05:23 +03:30

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,
};
}