/** * 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} */ 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} */ 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} */ 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} */ 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} */ 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} */ 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} */ 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} */ 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} */ 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 }; }