526 lines
16 KiB
JavaScript
526 lines
16 KiB
JavaScript
/**
|
|
* Posts Module - CRUD Operations
|
|
* Uses a single zen_posts table with JSONB for custom fields.
|
|
* Relation fields are stored in zen_posts_relations (many-to-many).
|
|
*/
|
|
|
|
import { query } from '@hykocx/zen/database';
|
|
import { deleteFile } from '@hykocx/zen/storage';
|
|
import { getPostType } from './config.js';
|
|
|
|
function slugify(text) {
|
|
if (!text || typeof text !== 'string') return '';
|
|
return text
|
|
.toLowerCase()
|
|
.trim()
|
|
.normalize('NFD')
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
.replace(/\s+/g, '-')
|
|
.replace(/[^a-z0-9-]/g, '')
|
|
.replace(/-+/g, '-')
|
|
.replace(/^-|-$/g, '');
|
|
}
|
|
|
|
async function ensureUniqueSlug(postType, baseSlug, excludeId = null) {
|
|
let slug = baseSlug || 'post';
|
|
let n = 1;
|
|
for (;;) {
|
|
let result;
|
|
if (excludeId != null) {
|
|
result = await query(
|
|
`SELECT id FROM zen_posts WHERE post_type = $1 AND slug = $2 AND id != $3`,
|
|
[postType, slug, excludeId]
|
|
);
|
|
} else {
|
|
result = await query(
|
|
`SELECT id FROM zen_posts WHERE post_type = $1 AND slug = $2`,
|
|
[postType, slug]
|
|
);
|
|
}
|
|
if (result.rows.length === 0) return slug;
|
|
n++;
|
|
slug = `${baseSlug}-${n}`;
|
|
}
|
|
}
|
|
|
|
function getImageKeys(typeConfig, data) {
|
|
if (!typeConfig || !data) return [];
|
|
return typeConfig.fields
|
|
.filter(f => f.type === 'image')
|
|
.map(f => data[f.name])
|
|
.filter(Boolean);
|
|
}
|
|
|
|
/**
|
|
* Save relation field values for a post.
|
|
* Replaces all existing relations for the given field names.
|
|
* @param {number} postId
|
|
* @param {Object} relationUpdates - { fieldName: [id, id, ...] }
|
|
*/
|
|
async function saveRelations(postId, relationUpdates) {
|
|
for (const [fieldName, ids] of Object.entries(relationUpdates)) {
|
|
// Delete existing relations for this field
|
|
await query(
|
|
`DELETE FROM zen_posts_relations WHERE post_id = $1 AND field_name = $2`,
|
|
[postId, fieldName]
|
|
);
|
|
|
|
if (!ids || ids.length === 0) continue;
|
|
|
|
// Insert new relations
|
|
for (let i = 0; i < ids.length; i++) {
|
|
const relatedId = parseInt(ids[i]);
|
|
if (!relatedId) continue;
|
|
await query(
|
|
`INSERT INTO zen_posts_relations (post_id, field_name, related_post_id, sort_order)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (post_id, field_name, related_post_id) DO UPDATE SET sort_order = $4`,
|
|
[postId, fieldName, relatedId, i]
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load relation fields for a post, grouped by field name.
|
|
* Returns { fieldName: [{ id, slug, title }] }
|
|
* @param {number} postId
|
|
* @param {Object} typeConfig
|
|
* @returns {Promise<Object>}
|
|
*/
|
|
async function loadRelations(postId, typeConfig) {
|
|
const relationFields = typeConfig.fields.filter(f => f.type === 'relation');
|
|
if (relationFields.length === 0) return {};
|
|
|
|
const result = await query(
|
|
`SELECT r.field_name, r.sort_order,
|
|
p.id as related_id, p.slug as related_slug, p.post_type as related_type, p.data as related_data
|
|
FROM zen_posts_relations r
|
|
JOIN zen_posts p ON p.id = r.related_post_id
|
|
WHERE r.post_id = $1
|
|
ORDER BY r.field_name, r.sort_order`,
|
|
[postId]
|
|
);
|
|
|
|
const grouped = {};
|
|
for (const field of relationFields) {
|
|
grouped[field.name] = [];
|
|
}
|
|
|
|
for (const row of result.rows) {
|
|
if (!grouped[row.field_name]) continue;
|
|
const data = typeof row.related_data === 'string' ? JSON.parse(row.related_data) : (row.related_data || {});
|
|
const relatedTypeConfig = getPostType(row.related_type);
|
|
const titleValue = relatedTypeConfig?.titleField ? data[relatedTypeConfig.titleField] : null;
|
|
grouped[row.field_name].push({
|
|
id: row.related_id,
|
|
slug: row.related_slug,
|
|
post_type: row.related_type,
|
|
title: titleValue || row.related_slug,
|
|
// Inclure tous les champs JSONB du post lié (color, text, etc.)
|
|
...data,
|
|
});
|
|
}
|
|
|
|
return grouped;
|
|
}
|
|
|
|
/**
|
|
* Create a new post.
|
|
* Relation field values should be arrays of IDs: { keywords: [1, 5, 12] }
|
|
* @param {string} postType
|
|
* @param {Object} rawData
|
|
* @returns {Promise<Object>}
|
|
*/
|
|
export async function createPost(postType, rawData) {
|
|
const typeConfig = getPostType(postType);
|
|
if (!typeConfig) throw new Error(`Unknown post type: ${postType}`);
|
|
|
|
const slugFieldName = typeConfig.slugField;
|
|
const titleFieldName = typeConfig.titleField;
|
|
const rawSlug = slugFieldName ? rawData[slugFieldName] : null;
|
|
const rawTitle = titleFieldName ? rawData[titleFieldName] : null;
|
|
|
|
if (!rawTitle) throw new Error('Title field is required');
|
|
|
|
const baseSlug = rawSlug?.trim() ? slugify(rawSlug) : slugify(rawTitle);
|
|
const slug = await ensureUniqueSlug(postType, baseSlug || 'post');
|
|
|
|
const categoryField = typeConfig.fields.find(f => f.type === 'category');
|
|
const category_id = categoryField ? (rawData[categoryField.name] || null) : null;
|
|
|
|
// Build data JSONB — exclude slug, category and relation fields (stored separately)
|
|
const data = {};
|
|
for (const field of typeConfig.fields) {
|
|
if (field.type === 'slug') continue;
|
|
if (field.type === 'category') continue;
|
|
if (field.type === 'relation') continue;
|
|
data[field.name] = rawData[field.name] ?? null;
|
|
}
|
|
|
|
const result = await query(
|
|
`INSERT INTO zen_posts (post_type, slug, data, category_id)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING *`,
|
|
[postType, slug, JSON.stringify(data), category_id || null]
|
|
);
|
|
|
|
const postId = result.rows[0].id;
|
|
|
|
// Save relation fields
|
|
const relationUpdates = {};
|
|
for (const field of typeConfig.fields.filter(f => f.type === 'relation')) {
|
|
const ids = rawData[field.name];
|
|
relationUpdates[field.name] = Array.isArray(ids) ? ids : [];
|
|
}
|
|
if (Object.keys(relationUpdates).length > 0) {
|
|
await saveRelations(postId, relationUpdates);
|
|
}
|
|
|
|
return getPostById(postType, postId);
|
|
}
|
|
|
|
/**
|
|
* Get post by ID (includes relation fields).
|
|
* @param {string} postType
|
|
* @param {number} id
|
|
* @returns {Promise<Object|null>}
|
|
*/
|
|
export async function getPostById(postType, id) {
|
|
const result = await query(
|
|
`SELECT p.*, c.title as category_title
|
|
FROM zen_posts p
|
|
LEFT JOIN zen_posts_category c ON p.category_id = c.id
|
|
WHERE p.post_type = $1 AND p.id = $2`,
|
|
[postType, id]
|
|
);
|
|
if (!result.rows[0]) return null;
|
|
|
|
const post = flattenPost(result.rows[0]);
|
|
const typeConfig = getPostType(postType);
|
|
if (typeConfig?.hasRelations) {
|
|
const relations = await loadRelations(id, typeConfig);
|
|
Object.assign(post, relations);
|
|
}
|
|
return post;
|
|
}
|
|
|
|
/**
|
|
* Get post by slug (includes relation fields).
|
|
* @param {string} postType
|
|
* @param {string} slug
|
|
* @returns {Promise<Object|null>}
|
|
*/
|
|
export async function getPostBySlug(postType, slug) {
|
|
const result = await query(
|
|
`SELECT p.*, c.title as category_title
|
|
FROM zen_posts p
|
|
LEFT JOIN zen_posts_category c ON p.category_id = c.id
|
|
WHERE p.post_type = $1 AND p.slug = $2`,
|
|
[postType, slug]
|
|
);
|
|
if (!result.rows[0]) return null;
|
|
|
|
const post = flattenPost(result.rows[0]);
|
|
const typeConfig = getPostType(postType);
|
|
if (typeConfig?.hasRelations) {
|
|
const relations = await loadRelations(result.rows[0].id, typeConfig);
|
|
Object.assign(post, relations);
|
|
}
|
|
return post;
|
|
}
|
|
|
|
/**
|
|
* Get posts for a type with pagination and filters.
|
|
* Pass withRelations: true to include relation fields (adds one query per post — use sparingly on large lists).
|
|
* @param {string} postType
|
|
* @param {Object} options
|
|
* @returns {Promise<Object>}
|
|
*/
|
|
export async function getPosts(postType, options = {}) {
|
|
const {
|
|
page = 1,
|
|
limit = 20,
|
|
search = '',
|
|
category_id = null,
|
|
sortBy = 'created_at',
|
|
sortOrder = 'DESC',
|
|
withRelations = false
|
|
} = options;
|
|
|
|
const typeConfig = getPostType(postType);
|
|
const offset = (page - 1) * limit;
|
|
const conditions = ['p.post_type = $1'];
|
|
const params = [postType];
|
|
let paramIndex = 2;
|
|
|
|
if (search && typeConfig?.titleField) {
|
|
conditions.push(`(p.data->>'${typeConfig.titleField}' ILIKE $${paramIndex})`);
|
|
params.push(`%${search}%`);
|
|
paramIndex++;
|
|
}
|
|
|
|
if (category_id != null) {
|
|
conditions.push(`p.category_id = $${paramIndex}`);
|
|
params.push(category_id);
|
|
paramIndex++;
|
|
}
|
|
|
|
const whereClause = `WHERE ${conditions.join(' AND ')}`;
|
|
|
|
const countResult = await query(
|
|
`SELECT COUNT(*) FROM zen_posts p ${whereClause}`,
|
|
params
|
|
);
|
|
const total = parseInt(countResult.rows[0].count);
|
|
|
|
const validOrder = sortOrder?.toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
|
|
let orderExpr = 'p.created_at';
|
|
|
|
if (sortBy === 'created_at' || sortBy === 'updated_at') {
|
|
orderExpr = `p.${sortBy}`;
|
|
} else if (typeConfig) {
|
|
const sortField = typeConfig.fields.find(f => f.name === sortBy);
|
|
if (sortField) {
|
|
orderExpr = sortField.type === 'date'
|
|
? `(NULLIF(p.data->>'${sortBy}', ''))::date`
|
|
: sortField.type === 'datetime'
|
|
? `(NULLIF(p.data->>'${sortBy}', ''))::timestamptz`
|
|
: `p.data->>'${sortBy}'`;
|
|
}
|
|
}
|
|
|
|
const postsResult = await query(
|
|
`SELECT p.*, c.title as category_title
|
|
FROM zen_posts p
|
|
LEFT JOIN zen_posts_category c ON p.category_id = c.id
|
|
${whereClause}
|
|
ORDER BY ${orderExpr} ${validOrder}
|
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
|
[...params, limit, offset]
|
|
);
|
|
|
|
const posts = postsResult.rows.map(flattenPost);
|
|
|
|
if (withRelations && typeConfig?.hasRelations) {
|
|
for (const post of posts) {
|
|
const relations = await loadRelations(post.id, typeConfig);
|
|
Object.assign(post, relations);
|
|
}
|
|
}
|
|
|
|
return {
|
|
posts,
|
|
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) }
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Search posts of a type by title (for relation picker).
|
|
* @param {string} postType
|
|
* @param {string} search
|
|
* @param {number} limit
|
|
* @returns {Promise<Array>}
|
|
*/
|
|
export async function searchPosts(postType, search = '', limit = 20) {
|
|
const typeConfig = getPostType(postType);
|
|
const titleField = typeConfig?.titleField;
|
|
|
|
let result;
|
|
if (search && titleField) {
|
|
result = await query(
|
|
`SELECT id, slug, data->>'${titleField}' as title
|
|
FROM zen_posts
|
|
WHERE post_type = $1 AND data->>'${titleField}' ILIKE $2
|
|
ORDER BY data->>'${titleField}' ASC
|
|
LIMIT $3`,
|
|
[postType, `%${search}%`, limit]
|
|
);
|
|
} else {
|
|
result = await query(
|
|
`SELECT id, slug, data->>'${titleField || 'title'}' as title
|
|
FROM zen_posts
|
|
WHERE post_type = $1
|
|
ORDER BY created_at DESC
|
|
LIMIT $2`,
|
|
[postType, limit]
|
|
);
|
|
}
|
|
|
|
return result.rows;
|
|
}
|
|
|
|
/**
|
|
* Update a post.
|
|
* Relation field values should be arrays of IDs: { keywords: [1, 5, 12] }
|
|
* @param {string} postType
|
|
* @param {number} id
|
|
* @param {Object} rawData
|
|
* @returns {Promise<Object>}
|
|
*/
|
|
export async function updatePost(postType, id, rawData) {
|
|
const typeConfig = getPostType(postType);
|
|
if (!typeConfig) throw new Error(`Unknown post type: ${postType}`);
|
|
|
|
const existing = await getPostById(postType, id);
|
|
if (!existing) throw new Error('Post not found');
|
|
|
|
const slugFieldName = typeConfig.slugField;
|
|
let slug = existing.slug;
|
|
|
|
if (slugFieldName && rawData[slugFieldName] !== undefined) {
|
|
const newSlug = slugify(rawData[slugFieldName]) || slugify(existing[typeConfig.titleField] || '') || 'post';
|
|
slug = await ensureUniqueSlug(postType, newSlug, id);
|
|
}
|
|
|
|
const categoryField = typeConfig.fields.find(f => f.type === 'category');
|
|
let category_id = existing.category_id;
|
|
if (categoryField && rawData[categoryField.name] !== undefined) {
|
|
category_id = rawData[categoryField.name] || null;
|
|
}
|
|
|
|
const existingData = existing._data || {};
|
|
const newData = { ...existingData };
|
|
|
|
for (const field of typeConfig.fields) {
|
|
if (field.type === 'slug') continue;
|
|
if (field.type === 'category') continue;
|
|
if (field.type === 'relation') continue;
|
|
if (rawData[field.name] !== undefined) {
|
|
newData[field.name] = rawData[field.name];
|
|
}
|
|
}
|
|
|
|
await query(
|
|
`UPDATE zen_posts
|
|
SET slug = $1, data = $2, category_id = $3, updated_at = CURRENT_TIMESTAMP
|
|
WHERE post_type = $4 AND id = $5`,
|
|
[slug, JSON.stringify(newData), category_id || null, postType, id]
|
|
);
|
|
|
|
// Update relation fields if provided
|
|
const relationUpdates = {};
|
|
for (const field of typeConfig.fields.filter(f => f.type === 'relation')) {
|
|
if (rawData[field.name] !== undefined) {
|
|
const ids = rawData[field.name];
|
|
relationUpdates[field.name] = Array.isArray(ids) ? ids : [];
|
|
}
|
|
}
|
|
if (Object.keys(relationUpdates).length > 0) {
|
|
await saveRelations(id, relationUpdates);
|
|
}
|
|
|
|
return getPostById(postType, id);
|
|
}
|
|
|
|
/**
|
|
* Delete a post and clean up its image(s) from storage.
|
|
* Relations are deleted by CASCADE on zen_posts_relations.post_id.
|
|
* @param {string} postType
|
|
* @param {number} id
|
|
* @returns {Promise<boolean>}
|
|
*/
|
|
export async function deletePost(postType, id) {
|
|
const post = await getPostById(postType, id);
|
|
if (!post) return false;
|
|
|
|
const typeConfig = getPostType(postType);
|
|
const imageKeys = getImageKeys(typeConfig, post._data);
|
|
|
|
const result = await query(
|
|
`DELETE FROM zen_posts WHERE post_type = $1 AND id = $2`,
|
|
[postType, id]
|
|
);
|
|
|
|
if (result.rowCount === 0) return false;
|
|
|
|
for (const imageKey of imageKeys) {
|
|
try {
|
|
const deleteResult = await deleteFile(imageKey);
|
|
if (!deleteResult.success) {
|
|
console.warn(`[Posts] Failed to delete image from storage: ${imageKey}`, deleteResult.error);
|
|
}
|
|
} catch (err) {
|
|
console.warn(`[Posts] Error deleting image from storage: ${imageKey}`, err.message);
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Get post by a specific field value stored in JSONB data.
|
|
* Useful for deduplication in importers (e.g. find by cve_id).
|
|
* @param {string} postType
|
|
* @param {string} fieldName
|
|
* @param {string} fieldValue
|
|
* @returns {Promise<Object|null>}
|
|
*/
|
|
export async function getPostByField(postType, fieldName, fieldValue) {
|
|
const result = await query(
|
|
`SELECT p.*, c.title as category_title
|
|
FROM zen_posts p
|
|
LEFT JOIN zen_posts_category c ON p.category_id = c.id
|
|
WHERE p.post_type = $1 AND p.data->>'${fieldName}' = $2
|
|
LIMIT 1`,
|
|
[postType, String(fieldValue)]
|
|
);
|
|
if (!result.rows[0]) return null;
|
|
|
|
const post = flattenPost(result.rows[0]);
|
|
const typeConfig = getPostType(postType);
|
|
if (typeConfig?.hasRelations) {
|
|
const relations = await loadRelations(result.rows[0].id, typeConfig);
|
|
Object.assign(post, relations);
|
|
}
|
|
return post;
|
|
}
|
|
|
|
/**
|
|
* Create or update a post based on a unique field (e.g. cve_id, slug).
|
|
* If a post with the same uniqueField value already exists, it will be updated.
|
|
* Otherwise a new post will be created.
|
|
* Useful for importers / scheduled fetchers.
|
|
*
|
|
* @param {string} postType
|
|
* @param {Object} rawData
|
|
* @param {string} uniqueField - Name of the field to use for deduplication (e.g. 'cve_id', 'slug')
|
|
* @returns {Promise<{ post: Object, created: boolean }>}
|
|
*/
|
|
export async function upsertPost(postType, rawData, uniqueField = 'slug') {
|
|
const typeConfig = getPostType(postType);
|
|
if (!typeConfig) throw new Error(`Unknown post type: ${postType}`);
|
|
|
|
let existing = null;
|
|
|
|
if (uniqueField === 'slug') {
|
|
const slugFieldName = typeConfig.slugField;
|
|
const titleFieldName = typeConfig.titleField;
|
|
const rawSlug = slugFieldName ? rawData[slugFieldName] : null;
|
|
const rawTitle = titleFieldName ? rawData[titleFieldName] : null;
|
|
const baseSlug = rawSlug?.trim() ? slugify(rawSlug) : slugify(rawTitle || '');
|
|
if (baseSlug) {
|
|
existing = await getPostBySlug(postType, baseSlug);
|
|
}
|
|
} else {
|
|
const uniqueValue = rawData[uniqueField];
|
|
if (uniqueValue != null) {
|
|
existing = await getPostByField(postType, uniqueField, uniqueValue);
|
|
}
|
|
}
|
|
|
|
if (existing) {
|
|
const post = await updatePost(postType, existing.id, rawData);
|
|
return { post, created: false };
|
|
}
|
|
|
|
const post = await createPost(postType, rawData);
|
|
return { post, created: true };
|
|
}
|
|
|
|
function flattenPost(row) {
|
|
const { data, ...rest } = row;
|
|
const parsed = typeof data === 'string' ? JSON.parse(data) : (data || {});
|
|
return { ...rest, ...parsed, _data: parsed };
|
|
}
|