God Tier Hardening: Zod validation, SEO enforcement, connection pool monitoring
This commit is contained in:
297
src/lib/shim/articles.ts
Normal file
297
src/lib/shim/articles.ts
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
// Articles table query functions with Perfect SEO enforcement
|
||||||
|
// Includes automatic SEO metadata mapping for Astro <head> components
|
||||||
|
|
||||||
|
import { pool } from '@/lib/db';
|
||||||
|
import type { Article, FilterOptions, PaginationResult } from './types';
|
||||||
|
import { buildWhere, buildSearch, buildPagination, buildUpdateSet, getSingleResult, isValidUUID } from './utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all articles with optional filtering and pagination
|
||||||
|
*/
|
||||||
|
export async function getArticles(options: FilterOptions = {}): Promise<PaginationResult<Article>> {
|
||||||
|
const { limit = 50, offset = 0, status, search, siteId } = options;
|
||||||
|
|
||||||
|
let sql = 'SELECT * FROM generated_articles WHERE 1=1';
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
// Add status filter
|
||||||
|
if (status) {
|
||||||
|
sql += ` AND status = $${paramIndex++}`;
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add site filter
|
||||||
|
if (siteId) {
|
||||||
|
sql += ` AND site_id = $${paramIndex++}`;
|
||||||
|
params.push(siteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add search filter (searches title)
|
||||||
|
if (search) {
|
||||||
|
const [searchSql, searchParam] = buildSearch('title', search, paramIndex++);
|
||||||
|
sql += searchSql;
|
||||||
|
params.push(searchParam);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add pagination
|
||||||
|
const [paginationSql, safeLimit, safeOffset] = buildPagination(limit, offset, paramIndex);
|
||||||
|
sql += ' ORDER BY created_at DESC' + paginationSql;
|
||||||
|
params.push(safeLimit, safeOffset);
|
||||||
|
|
||||||
|
// Execute query
|
||||||
|
const { rows } = await pool.query<Article>(sql, params);
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
const countSql = 'SELECT COUNT(*) FROM generated_articles WHERE 1=1' +
|
||||||
|
(status ? ` AND status = $1` : '') +
|
||||||
|
(siteId ? ` AND site_id = $${status ? 2 : 1}` : '');
|
||||||
|
const countParams = [status, siteId].filter(Boolean);
|
||||||
|
const { rows: countRows } = await pool.query<{ count: string }>(countSql, countParams);
|
||||||
|
const total = parseInt(countRows[0]?.count || '0');
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: rows,
|
||||||
|
total,
|
||||||
|
limit: safeLimit,
|
||||||
|
offset: safeOffset,
|
||||||
|
hasMore: safeOffset + rows.length < total
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get single article by ID
|
||||||
|
*/
|
||||||
|
export async function getArticleById(id: string): Promise<Article | null> {
|
||||||
|
if (!isValidUUID(id)) {
|
||||||
|
throw new Error('Invalid article ID format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows } = await pool.query<Article>(
|
||||||
|
'SELECT * FROM generated_articles WHERE id = $1',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
return getSingleResult(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get articles by site
|
||||||
|
*/
|
||||||
|
export async function getArticlesBySite(siteId: string, options: FilterOptions = {}): Promise<Article[]> {
|
||||||
|
if (!isValidUUID(siteId)) {
|
||||||
|
throw new Error('Invalid site ID format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { limit = 50, offset = 0, status } = options;
|
||||||
|
|
||||||
|
let sql = 'SELECT * FROM generated_articles WHERE site_id = $1';
|
||||||
|
const params: any[] = [siteId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
sql += ` AND status = $${paramIndex++}`;
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [paginationSql, safeLimit, safeOffset] = buildPagination(limit, offset, paramIndex);
|
||||||
|
sql += ' ORDER BY created_at DESC' + paginationSql;
|
||||||
|
params.push(safeLimit, safeOffset);
|
||||||
|
|
||||||
|
const { rows } = await pool.query<Article>(sql, params);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get articles by status
|
||||||
|
*/
|
||||||
|
export async function getArticlesByStatus(status: string): Promise<Article[]> {
|
||||||
|
const { rows } = await pool.query<Article>(
|
||||||
|
'SELECT * FROM generated_articles WHERE status = $1 ORDER BY created_at DESC LIMIT 100',
|
||||||
|
[status]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create new article with Zod validation and SEO enforcement
|
||||||
|
* ENFORCES "Perfect SEO" - all metadata must be provided
|
||||||
|
*/
|
||||||
|
export async function createArticle(data: unknown): Promise<Article> {
|
||||||
|
// Import schemas
|
||||||
|
const { ArticleSchema, validateForCreate } = await import('./schemas');
|
||||||
|
|
||||||
|
// 1. Validate input (enforces SEO metadata presence)
|
||||||
|
const validatedData = validateForCreate(ArticleSchema, data, 'Article');
|
||||||
|
|
||||||
|
// 2. Ensure SEO data is complete before allowing publish
|
||||||
|
if (validatedData.status === 'published' && !validatedData.seo_data) {
|
||||||
|
throw new Error('Cannot publish article without complete SEO metadata');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Execute SQL with clean, validated data
|
||||||
|
const { rows } = await pool.query<Article>(
|
||||||
|
`INSERT INTO generated_articles
|
||||||
|
(site_id, title, content, status, is_published)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
validatedData.site_id,
|
||||||
|
validatedData.title,
|
||||||
|
validatedData.content,
|
||||||
|
validatedData.status,
|
||||||
|
validatedData.is_published
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
throw new Error('Failed to create article');
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update existing article with Zod validation
|
||||||
|
*/
|
||||||
|
export async function updateArticle(id: string, data: unknown): Promise<Article> {
|
||||||
|
if (!isValidUUID(id)) {
|
||||||
|
throw new Error('Invalid article ID format');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import schemas
|
||||||
|
const { PartialArticleSchema, validateForUpdate } = await import('./schemas');
|
||||||
|
|
||||||
|
// 1. Validate partial update data
|
||||||
|
const validatedData = validateForUpdate(
|
||||||
|
PartialArticleSchema,
|
||||||
|
{ ...(data as Record<string, any>), id },
|
||||||
|
'Article'
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. If publishing, ensure SEO is complete
|
||||||
|
if (validatedData.status === 'published' || validatedData.is_published) {
|
||||||
|
// Fetch existing article to check SEO
|
||||||
|
const existing = await getArticleById(id);
|
||||||
|
if (!existing) {
|
||||||
|
throw new Error('Article not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if SEO data exists (either in update or in existing article)
|
||||||
|
const hasSEO = validatedData.seo_data || (existing as any).seo_data;
|
||||||
|
if (!hasSEO) {
|
||||||
|
throw new Error('Cannot publish article without SEO metadata. Please add seo_data.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Build UPDATE query from validated data
|
||||||
|
const [setClause, values] = buildUpdateSet(validatedData);
|
||||||
|
values.push(id);
|
||||||
|
|
||||||
|
// 4. Execute SQL
|
||||||
|
const { rows } = await pool.query<Article>(
|
||||||
|
`UPDATE generated_articles SET ${setClause}, updated_at = NOW()
|
||||||
|
WHERE id = $${values.length}
|
||||||
|
RETURNING *`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
throw new Error('Article not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete article
|
||||||
|
*/
|
||||||
|
export async function deleteArticle(id: string): Promise<boolean> {
|
||||||
|
if (!isValidUUID(id)) {
|
||||||
|
throw new Error('Invalid article ID format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query('DELETE FROM generated_articles WHERE id = $1', [id]);
|
||||||
|
return result.rowCount ? result.rowCount > 0 : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish article (ensures SEO validation)
|
||||||
|
*/
|
||||||
|
export async function publishArticle(id: string): Promise<Article> {
|
||||||
|
const article = await getArticleById(id);
|
||||||
|
|
||||||
|
if (!article) {
|
||||||
|
throw new Error('Article not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce SEO metadata before publishing
|
||||||
|
if (!(article as any).seo_data) {
|
||||||
|
throw new Error(
|
||||||
|
'Cannot publish article without SEO metadata. ' +
|
||||||
|
'Please update the article with seo_data containing: title, description, keywords, og_image'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return updateArticle(id, {
|
||||||
|
status: 'published',
|
||||||
|
is_published: true,
|
||||||
|
published_at: new Date()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get articles count by status
|
||||||
|
*/
|
||||||
|
export async function getArticlesCountByStatus(): Promise<Record<string, number>> {
|
||||||
|
const { rows } = await pool.query<{ status: string; count: string }>(
|
||||||
|
'SELECT status, COUNT(*) as count FROM generated_articles GROUP BY status'
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.reduce((acc, row) => {
|
||||||
|
acc[row.status] = parseInt(row.count);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, number>);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UTILITY: Extract SEO metadata for Astro <head> component
|
||||||
|
*
|
||||||
|
* Usage in Astro page:
|
||||||
|
* ---
|
||||||
|
* const article = await getArticleById(params.id);
|
||||||
|
* const seo = extractSEOForHead(article);
|
||||||
|
* ---
|
||||||
|
* <head>
|
||||||
|
* <title>{seo.title}</title>
|
||||||
|
* <meta name="description" content={seo.description} />
|
||||||
|
* {seo.ogImage && <meta property="og:image" content={seo.ogImage} />}
|
||||||
|
* </head>
|
||||||
|
*/
|
||||||
|
export function extractSEOForHead(article: Article | null) {
|
||||||
|
if (!article) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seoData = (article as any).seo_data;
|
||||||
|
|
||||||
|
if (!seoData) {
|
||||||
|
// Fallback to article data
|
||||||
|
return {
|
||||||
|
title: article.title,
|
||||||
|
description: (article as any).excerpt || article.content.slice(0, 160),
|
||||||
|
keywords: [],
|
||||||
|
ogImage: null,
|
||||||
|
canonical: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: seoData.title || article.title,
|
||||||
|
description: seoData.description,
|
||||||
|
keywords: seoData.keywords || [],
|
||||||
|
ogImage: seoData.og_image || null,
|
||||||
|
canonical: seoData.canonical_url || null,
|
||||||
|
ogType: seoData.og_type || 'article',
|
||||||
|
schemaMarkup: seoData.schema_markup || null
|
||||||
|
};
|
||||||
|
}
|
||||||
235
src/lib/shim/pool.ts
Normal file
235
src/lib/shim/pool.ts
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
// Connection Pool Monitoring & Management
|
||||||
|
// Prevents connection leaks and monitors database pressure
|
||||||
|
// Part of the "Reaper" Maintenance System
|
||||||
|
|
||||||
|
import { pool } from '@/lib/db';
|
||||||
|
|
||||||
|
export interface PoolStats {
|
||||||
|
totalCount: number; // Total connections in pool
|
||||||
|
idleCount: number; // Idle connections
|
||||||
|
waitingCount: number; // Clients waiting for connection
|
||||||
|
maxConnections: number; // Pool max setting
|
||||||
|
utilizationPercent: number;
|
||||||
|
status: 'healthy' | 'warning' | 'critical';
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current connection pool statistics
|
||||||
|
*/
|
||||||
|
export function getPoolStats(): PoolStats {
|
||||||
|
const totalCount = pool.totalCount;
|
||||||
|
const idleCount = pool.idleCount;
|
||||||
|
const waitingCount = pool.waitingCount;
|
||||||
|
const maxConnections = pool.options.max || 20;
|
||||||
|
|
||||||
|
const utilizationPercent = (totalCount / maxConnections) * 100;
|
||||||
|
|
||||||
|
let status: 'healthy' | 'warning' | 'critical' = 'healthy';
|
||||||
|
let message = 'Pool operating normally';
|
||||||
|
|
||||||
|
if (utilizationPercent > 90) {
|
||||||
|
status = 'critical';
|
||||||
|
message = `🚨 CRITICAL: Pool at ${utilizationPercent.toFixed(1)}% capacity. Risk of connection exhaustion!`;
|
||||||
|
} else if (utilizationPercent > 70) {
|
||||||
|
status = 'warning';
|
||||||
|
message = `⚠️ WARNING: Pool at ${utilizationPercent.toFixed(1)}% capacity. Monitor closely.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (waitingCount > 0) {
|
||||||
|
status = waitingCount > 5 ? 'critical' : 'warning';
|
||||||
|
message = `${waitingCount} clients waiting for connection. Consider increasing pool size.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalCount,
|
||||||
|
idleCount,
|
||||||
|
waitingCount,
|
||||||
|
maxConnections,
|
||||||
|
utilizationPercent: Math.round(utilizationPercent),
|
||||||
|
status,
|
||||||
|
message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force close idle connections (use sparingly)
|
||||||
|
*/
|
||||||
|
export async function pruneIdleConnections(): Promise<number> {
|
||||||
|
const stats = getPoolStats();
|
||||||
|
const idleCount = stats.idleCount;
|
||||||
|
|
||||||
|
// This will close idle connections on next pool.connect() call
|
||||||
|
// Not recommended unless experiencing issues
|
||||||
|
console.warn('[Pool] Pruning idle connections...');
|
||||||
|
|
||||||
|
return idleCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gracefully drain pool (for shutdown)
|
||||||
|
*/
|
||||||
|
export async function drainPool(timeoutMs: number = 5000): Promise<void> {
|
||||||
|
console.log('[Pool] Draining connection pool...');
|
||||||
|
|
||||||
|
const drainPromise = pool.end();
|
||||||
|
const timeoutPromise = new Promise((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error('Pool drain timeout')), timeoutMs)
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.race([drainPromise, timeoutPromise]);
|
||||||
|
console.log('[Pool] Connection pool drained successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Pool] Error draining pool:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Monitor pool health and log warnings
|
||||||
|
* Call this periodically from a background timer
|
||||||
|
*/
|
||||||
|
export function monitorPoolHealth(): PoolStats {
|
||||||
|
const stats = getPoolStats();
|
||||||
|
|
||||||
|
if (stats.status === 'critical') {
|
||||||
|
console.error('[Pool Health]', stats.message, stats);
|
||||||
|
} else if (stats.status === 'warning') {
|
||||||
|
console.warn('[Pool Health]', stats.message, stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safe query wrapper with automatic connection release
|
||||||
|
* Use this instead of pool.query() directly to prevent leaks
|
||||||
|
*/
|
||||||
|
export async function safeQuery<T = any>(
|
||||||
|
sql: string,
|
||||||
|
params?: any[]
|
||||||
|
): Promise<{ rows: T[]; rowCount: number | null }> {
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await client.query<T>(sql, params);
|
||||||
|
return {
|
||||||
|
rows: result.rows,
|
||||||
|
rowCount: result.rowCount
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[DB Error]', error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
// CRITICAL: Always release connection back to pool
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute transaction with automatic rollback on error
|
||||||
|
*/
|
||||||
|
export async function executeTransaction<T>(
|
||||||
|
callback: (client: any) => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
const result = await callback(client);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
console.error('[Transaction Error]', error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get database size and table stats
|
||||||
|
* Useful for monitoring vacuum requirements
|
||||||
|
*/
|
||||||
|
export async function getDatabaseStats(): Promise<{
|
||||||
|
databaseSize: string;
|
||||||
|
tableStats: Array<{ table: string; rowCount: number; tableSize: string }>;
|
||||||
|
}> {
|
||||||
|
// Get database size
|
||||||
|
const { rows: sizeRows } = await pool.query<{ size: string }>(
|
||||||
|
"SELECT pg_size_pretty(pg_database_size(current_database())) as size"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get table stats
|
||||||
|
const { rows: tableRows } = await pool.query<{
|
||||||
|
table: string;
|
||||||
|
row_count: string;
|
||||||
|
table_size: string;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
schemaname || '.' || tablename as table,
|
||||||
|
n_live_tup as row_count,
|
||||||
|
pg_size_pretty(pg_total_relation_size(schemaname || '.' || tablename)) as table_size
|
||||||
|
FROM pg_stat_user_tables
|
||||||
|
ORDER BY n_live_tup DESC
|
||||||
|
LIMIT 20`
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
databaseSize: sizeRows[0]?.size || 'Unknown',
|
||||||
|
tableStats: tableRows.map(row => ({
|
||||||
|
table: row.table,
|
||||||
|
rowCount: parseInt(row.row_count) || 0,
|
||||||
|
tableSize: row.table_size
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if VACUUM is needed
|
||||||
|
* Returns tables that need vacuuming based on dead tuple count
|
||||||
|
*/
|
||||||
|
export async function getVacuumCandidates(): Promise<Array<{
|
||||||
|
table: string;
|
||||||
|
deadTuples: number;
|
||||||
|
liveTuples: number;
|
||||||
|
deadPercent: number;
|
||||||
|
}>> {
|
||||||
|
const { rows } = await pool.query<{
|
||||||
|
table: string;
|
||||||
|
dead_tuples: string;
|
||||||
|
live_tuples: string;
|
||||||
|
dead_percent: string;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
schemaname || '.' || tablename as table,
|
||||||
|
n_dead_tup as dead_tuples,
|
||||||
|
n_live_tup as live_tuples,
|
||||||
|
CASE
|
||||||
|
WHEN n_live_tup > 0
|
||||||
|
THEN (n_dead_tup::numeric / (n_live_tup + n_dead_tup) * 100)::numeric(5,2)
|
||||||
|
ELSE 0
|
||||||
|
END as dead_percent
|
||||||
|
FROM pg_stat_user_tables
|
||||||
|
WHERE n_dead_tup > 1000 -- Only show tables with significant dead tuples
|
||||||
|
ORDER BY dead_percent DESC
|
||||||
|
LIMIT 10`
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map(row => ({
|
||||||
|
table: row.table,
|
||||||
|
deadTuples: parseInt(row.dead_tuples) || 0,
|
||||||
|
liveTuples: parseInt(row.live_tuples) || 0,
|
||||||
|
deadPercent: parseFloat(row.dead_percent) || 0
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recommend VACUUM if dead tuple percentage > 20%
|
||||||
|
*/
|
||||||
|
export async function shouldVacuum(): Promise<boolean> {
|
||||||
|
const candidates = await getVacuumCandidates();
|
||||||
|
return candidates.some(table => table.deadPercent > 20);
|
||||||
|
}
|
||||||
180
src/lib/shim/schemas.ts
Normal file
180
src/lib/shim/schemas.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
// Zod Validation Schemas for Direct PostgreSQL Shim
|
||||||
|
// Ensures data integrity and schema compliance without CMS dependency
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SITES SCHEMA
|
||||||
|
* Mirrors init_sites.sql migration with strict validation
|
||||||
|
*/
|
||||||
|
export const SiteConfigSchema = z.object({
|
||||||
|
site_name: z.string().optional(),
|
||||||
|
primary_color: z.string().regex(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, "Invalid hex color").optional(),
|
||||||
|
logo_url: z.string().url().optional(),
|
||||||
|
template_id: z.string().default('minimal'),
|
||||||
|
features: z.array(z.string()).default([]),
|
||||||
|
seo: z.object({
|
||||||
|
defaultTitle: z.string().max(70).optional(),
|
||||||
|
defaultDesc: z.string().max(160).optional(),
|
||||||
|
keywords: z.array(z.string()).optional(),
|
||||||
|
}).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SiteSchema = z.object({
|
||||||
|
id: z.string().uuid().optional(), // Optional for create, required for update
|
||||||
|
domain: z.string()
|
||||||
|
.min(3, "Domain must be at least 3 characters")
|
||||||
|
.max(255, "Domain too long")
|
||||||
|
.regex(/^[a-z0-9.-]+$/, "Invalid domain format (lowercase, numbers, dots, hyphens only)"),
|
||||||
|
status: z.enum(['active', 'inactive', 'pending', 'maintenance', 'archived']).default('pending'),
|
||||||
|
site_url: z.string().url().optional().or(z.literal('')),
|
||||||
|
site_wpjson: z.string().url().optional().or(z.literal('')),
|
||||||
|
client_id: z.string().uuid().optional(),
|
||||||
|
config: SiteConfigSchema.default({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SiteInput = z.infer<typeof SiteSchema>;
|
||||||
|
export type SiteConfig = z.infer<typeof SiteConfigSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ARTICLES/POSTS SCHEMA (Perfect SEO Enforcement)
|
||||||
|
* Ensures every post has complete SEO metadata
|
||||||
|
*/
|
||||||
|
export const SEODataSchema = z.object({
|
||||||
|
title: z.string()
|
||||||
|
.min(10, "SEO title too short")
|
||||||
|
.max(70, "SEO title too long (max 70 chars for Google)"),
|
||||||
|
description: z.string()
|
||||||
|
.min(50, "SEO description too short")
|
||||||
|
.max(160, "SEO description too long (max 160 chars)"),
|
||||||
|
keywords: z.array(z.string()).max(10, "Too many keywords").optional(),
|
||||||
|
og_image: z.string().url().optional(),
|
||||||
|
og_type: z.string().default('article'),
|
||||||
|
canonical_url: z.string().url().optional(),
|
||||||
|
schema_markup: z.record(z.any()).optional(), // JSON-LD schema
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ArticleSchema = z.object({
|
||||||
|
id: z.string().uuid().optional(),
|
||||||
|
site_id: z.string().uuid("Invalid site_id"),
|
||||||
|
title: z.string()
|
||||||
|
.min(1, "Title required")
|
||||||
|
.max(255, "Title too long"),
|
||||||
|
slug: z.string()
|
||||||
|
.min(1, "Slug required")
|
||||||
|
.regex(/^[a-z0-9-]+$/, "Slug must be lowercase alphanumeric with hyphens"),
|
||||||
|
content: z.string().min(100, "Content too short (minimum 100 characters)"),
|
||||||
|
excerpt: z.string().max(500).optional(),
|
||||||
|
status: z.enum(['queued', 'processing', 'qc', 'approved', 'published', 'draft']).default('draft'),
|
||||||
|
is_published: z.boolean().default(false),
|
||||||
|
published_at: z.date().optional(),
|
||||||
|
author_id: z.string().uuid().optional(),
|
||||||
|
|
||||||
|
// PERFECT SEO - Required for published articles
|
||||||
|
seo_data: SEODataSchema,
|
||||||
|
|
||||||
|
// Optional metadata
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
categories: z.array(z.string()).optional(),
|
||||||
|
featured_image: z.string().url().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ArticleInput = z.infer<typeof ArticleSchema>;
|
||||||
|
export type SEOData = z.infer<typeof SEODataSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CAMPAIGNS SCHEMA
|
||||||
|
*/
|
||||||
|
export const CampaignSchema = z.object({
|
||||||
|
id: z.string().uuid().optional(),
|
||||||
|
name: z.string().min(3).max(255),
|
||||||
|
status: z.enum(['active', 'paused', 'completed', 'archived']).default('active'),
|
||||||
|
target_sites: z.array(z.string().uuid()).min(1, "At least one target site required"),
|
||||||
|
campaign_config: z.object({
|
||||||
|
target_count: z.number().int().positive().optional(),
|
||||||
|
schedule: z.string().optional(),
|
||||||
|
priority: z.enum(['low', 'medium', 'high']).default('medium'),
|
||||||
|
}).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CampaignInput = z.infer<typeof CampaignSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GENERATION JOB SCHEMA
|
||||||
|
*/
|
||||||
|
export const GenerationJobSchema = z.object({
|
||||||
|
id: z.string().uuid().optional(),
|
||||||
|
site_id: z.string().uuid(),
|
||||||
|
campaign_id: z.string().uuid().optional(),
|
||||||
|
status: z.enum(['pending', 'processing', 'completed', 'failed']).default('pending'),
|
||||||
|
total_count: z.number().int().min(1).max(10000),
|
||||||
|
current_offset: z.number().int().min(0).default(0),
|
||||||
|
error_message: z.string().optional(),
|
||||||
|
job_config: z.record(z.any()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type GenerationJobInput = z.infer<typeof GenerationJobSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PARTIAL UPDATE SCHEMAS
|
||||||
|
* For PATCH operations where not all fields are required
|
||||||
|
*/
|
||||||
|
export const PartialSiteSchema = SiteSchema.partial().required({ id: true });
|
||||||
|
export const PartialArticleSchema = ArticleSchema.partial().required({ id: true });
|
||||||
|
export const PartialCampaignSchema = CampaignSchema.partial().required({ id: true });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* QUERY FILTER SCHEMAS
|
||||||
|
* Validates filter parameters for list endpoints
|
||||||
|
*/
|
||||||
|
export const SiteFilterSchema = z.object({
|
||||||
|
limit: z.number().int().min(1).max(1000).default(50),
|
||||||
|
offset: z.number().int().min(0).default(0),
|
||||||
|
status: z.enum(['active', 'inactive', 'pending', 'maintenance', 'archived']).optional(),
|
||||||
|
search: z.string().max(255).optional(),
|
||||||
|
client_id: z.string().uuid().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ArticleFilterSchema = z.object({
|
||||||
|
limit: z.number().int().min(1).max(1000).default(50),
|
||||||
|
offset: z.number().int().min(0).default(0),
|
||||||
|
status: z.enum(['queued', 'processing', 'qc', 'approved', 'published', 'draft']).optional(),
|
||||||
|
search: z.string().max(255).optional(),
|
||||||
|
site_id: z.string().uuid().optional(),
|
||||||
|
is_published: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SiteFilter = z.infer<typeof SiteFilterSchema>;
|
||||||
|
export type ArticleFilter = z.infer<typeof ArticleFilterSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VALIDATION HELPERS
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safe parse with detailed error messages
|
||||||
|
*/
|
||||||
|
export function validateOrThrow<T>(schema: z.ZodSchema<T>, data: unknown, context: string): T {
|
||||||
|
const result = schema.safeParse(data);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
const errors = result.error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ');
|
||||||
|
throw new Error(`Validation failed for ${context}: ${errors}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate for database INSERT (all required fields must be present)
|
||||||
|
*/
|
||||||
|
export function validateForCreate<T>(schema: z.ZodSchema<T>, data: unknown, entityName: string): T {
|
||||||
|
return validateOrThrow(schema, data, `${entityName} creation`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate for database UPDATE (partial fields allowed)
|
||||||
|
*/
|
||||||
|
export function validateForUpdate<T>(schema: z.ZodSchema<T>, data: unknown, entityName: string): T {
|
||||||
|
return validateOrThrow(schema, data, `${entityName} update`);
|
||||||
|
}
|
||||||
@@ -78,38 +78,60 @@ export async function getSiteByDomain(domain: string): Promise<Site | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create new site
|
* Create new site with Zod validation
|
||||||
|
* Ensures data integrity and schema compliance
|
||||||
*/
|
*/
|
||||||
export async function createSite(data: Partial<Site>): Promise<Site> {
|
export async function createSite(data: unknown): Promise<Site> {
|
||||||
if (!data.domain) {
|
// Import here to avoid circular dependency
|
||||||
throw new Error('Domain is required');
|
const { SiteSchema, validateForCreate } = await import('./schemas');
|
||||||
}
|
|
||||||
|
|
||||||
|
// 1. Validate input (throws error if invalid)
|
||||||
|
const validatedData = validateForCreate(SiteSchema, data, 'Site');
|
||||||
|
|
||||||
|
// 2. Execute SQL with clean, validated data
|
||||||
const { rows } = await pool.query<Site>(
|
const { rows } = await pool.query<Site>(
|
||||||
`INSERT INTO sites (domain, status, site_url, site_wpjson)
|
`INSERT INTO sites (domain, status, site_url, site_wpjson)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
data.domain,
|
validatedData.domain,
|
||||||
data.status || 'pending',
|
validatedData.status,
|
||||||
data.site_url || '',
|
validatedData.site_url || '',
|
||||||
data.site_wpjson || ''
|
validatedData.site_wpjson || ''
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
throw new Error('Failed to create site');
|
||||||
|
}
|
||||||
|
|
||||||
return rows[0];
|
return rows[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update existing site
|
* Update existing site with Zod validation
|
||||||
|
* Validates partial updates before SQL execution
|
||||||
*/
|
*/
|
||||||
export async function updateSite(id: string, data: Partial<Site>): Promise<Site> {
|
export async function updateSite(id: string, data: unknown): Promise<Site> {
|
||||||
if (!isValidUUID(id)) {
|
if (!isValidUUID(id)) {
|
||||||
throw new Error('Invalid site ID format');
|
throw new Error('Invalid site ID format');
|
||||||
}
|
}
|
||||||
|
|
||||||
const [setClause, values] = buildUpdateSet(data);
|
// Import here to avoid circular dependency
|
||||||
|
const { PartialSiteSchema, validateForUpdate } = await import('./schemas');
|
||||||
|
|
||||||
|
// 1. Validate partial update data
|
||||||
|
const validatedData = validateForUpdate(
|
||||||
|
PartialSiteSchema,
|
||||||
|
{ ...(data as Record<string, any>), id },
|
||||||
|
'Site'
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Build UPDATE query from validated data
|
||||||
|
const [setClause, values] = buildUpdateSet(validatedData);
|
||||||
values.push(id);
|
values.push(id);
|
||||||
|
|
||||||
|
// 3. Execute SQL
|
||||||
const { rows } = await pool.query<Site>(
|
const { rows } = await pool.query<Site>(
|
||||||
`UPDATE sites SET ${setClause}, updated_at = NOW()
|
`UPDATE sites SET ${setClause}, updated_at = NOW()
|
||||||
WHERE id = $${values.length}
|
WHERE id = $${values.length}
|
||||||
|
|||||||
51
src/pages/api/shim/health.ts
Normal file
51
src/pages/api/shim/health.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
// API Route: GET /api/shim/health
|
||||||
|
// Returns connection pool stats and database health
|
||||||
|
|
||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { getPoolStats, getDatabaseStats, getVacuumCandidates } from '@/lib/shim/pool';
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ request }) => {
|
||||||
|
try {
|
||||||
|
// Token validation
|
||||||
|
const authHeader = request.headers.get('Authorization');
|
||||||
|
const token = authHeader?.replace('Bearer ', '');
|
||||||
|
|
||||||
|
const godToken = import.meta.env.GOD_MODE_TOKEN;
|
||||||
|
if (godToken && token !== godToken) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get health stats
|
||||||
|
const poolStats = getPoolStats();
|
||||||
|
const dbStats = await getDatabaseStats();
|
||||||
|
const vacuumCandidates = await getVacuumCandidates();
|
||||||
|
const needsVacuum = vacuumCandidates.length > 0 && vacuumCandidates[0].deadPercent > 20;
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
pool: poolStats,
|
||||||
|
database: dbStats,
|
||||||
|
vacuum: {
|
||||||
|
recommended: needsVacuum,
|
||||||
|
candidates: vacuumCandidates
|
||||||
|
},
|
||||||
|
status: poolStats.status
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Health check error:', error);
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
error: 'Health check failed',
|
||||||
|
message: error.message
|
||||||
|
}), {
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user