chore: import codes
This commit is contained in:
@@ -0,0 +1,525 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user