chore: import codes

This commit is contained in:
2026-04-12 12:50:14 -04:00
parent 4bcb4898e8
commit 65ae3c6788
241 changed files with 48834 additions and 1 deletions
+525
View File
@@ -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 };
}