Add Direct PostgreSQL Shim Architecture - SSR + API routes for direct DB access
This commit is contained in:
151
src/lib/shim/sites.ts
Normal file
151
src/lib/shim/sites.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
// Sites table query functions - Direct PostgreSQL access
|
||||
|
||||
import { pool } from '@/lib/db';
|
||||
import type { Site, FilterOptions, PaginationResult } from './types';
|
||||
import { buildWhere, buildSearch, buildPagination, buildUpdateSet, getSingleResult, isValidUUID } from './utils';
|
||||
|
||||
/**
|
||||
* Get all sites with optional filtering and pagination
|
||||
*/
|
||||
export async function getSites(options: FilterOptions = {}): Promise<PaginationResult<Site>> {
|
||||
const { limit = 50, offset = 0, status, search } = options;
|
||||
|
||||
let sql = 'SELECT * FROM sites WHERE 1=1';
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// Add status filter
|
||||
if (status) {
|
||||
sql += ` AND status = $${paramIndex++}`;
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
// Add search filter
|
||||
if (search) {
|
||||
const [searchSql, searchParam] = buildSearch('domain', 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<Site>(sql, params);
|
||||
|
||||
// Get total count
|
||||
const countSql = 'SELECT COUNT(*) FROM sites WHERE 1=1' +
|
||||
(status ? ' AND status = $1' : '');
|
||||
const countParams = status ? [status] : [];
|
||||
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 site by ID
|
||||
*/
|
||||
export async function getSiteById(id: string): Promise<Site | null> {
|
||||
if (!isValidUUID(id)) {
|
||||
throw new Error('Invalid site ID format');
|
||||
}
|
||||
|
||||
const { rows } = await pool.query<Site>(
|
||||
'SELECT * FROM sites WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
return getSingleResult(rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get site by domain
|
||||
*/
|
||||
export async function getSiteByDomain(domain: string): Promise<Site | null> {
|
||||
const { rows } = await pool.query<Site>(
|
||||
'SELECT * FROM sites WHERE domain = $1',
|
||||
[domain]
|
||||
);
|
||||
return getSingleResult(rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new site
|
||||
*/
|
||||
export async function createSite(data: Partial<Site>): Promise<Site> {
|
||||
if (!data.domain) {
|
||||
throw new Error('Domain is required');
|
||||
}
|
||||
|
||||
const { rows } = await pool.query<Site>(
|
||||
`INSERT INTO sites (domain, status, site_url, site_wpjson)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *`,
|
||||
[
|
||||
data.domain,
|
||||
data.status || 'pending',
|
||||
data.site_url || '',
|
||||
data.site_wpjson || ''
|
||||
]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update existing site
|
||||
*/
|
||||
export async function updateSite(id: string, data: Partial<Site>): Promise<Site> {
|
||||
if (!isValidUUID(id)) {
|
||||
throw new Error('Invalid site ID format');
|
||||
}
|
||||
|
||||
const [setClause, values] = buildUpdateSet(data);
|
||||
values.push(id);
|
||||
|
||||
const { rows } = await pool.query<Site>(
|
||||
`UPDATE sites SET ${setClause}, updated_at = NOW()
|
||||
WHERE id = $${values.length}
|
||||
RETURNING *`,
|
||||
values
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
throw new Error('Site not found');
|
||||
}
|
||||
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete site
|
||||
*/
|
||||
export async function deleteSite(id: string): Promise<boolean> {
|
||||
if (!isValidUUID(id)) {
|
||||
throw new Error('Invalid site ID format');
|
||||
}
|
||||
|
||||
const result = await pool.query('DELETE FROM sites WHERE id = $1', [id]);
|
||||
return result.rowCount ? result.rowCount > 0 : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sites count by status
|
||||
*/
|
||||
export async function getSitesCountByStatus(): Promise<Record<string, number>> {
|
||||
const { rows } = await pool.query<{ status: string; count: string }>(
|
||||
'SELECT status, COUNT(*) as count FROM sites GROUP BY status'
|
||||
);
|
||||
|
||||
return rows.reduce((acc, row) => {
|
||||
acc[row.status] = parseInt(row.count);
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
}
|
||||
61
src/lib/shim/types.ts
Normal file
61
src/lib/shim/types.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
// Type definitions matching PostgreSQL schema
|
||||
// These mirror the database tables for type-safe queries
|
||||
|
||||
export interface Site {
|
||||
id: string;
|
||||
domain: string;
|
||||
status: 'active' | 'inactive' | 'pending';
|
||||
site_url: string;
|
||||
site_wpjson: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface Article {
|
||||
id: string;
|
||||
site_id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
status: 'queued' | 'processing' | 'qc' | 'approved' | 'published';
|
||||
is_published: boolean;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface Campaign {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'active' | 'paused' | 'completed';
|
||||
target_sites: string[];
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface GenerationJob {
|
||||
id: string;
|
||||
site_id: string;
|
||||
campaign_id?: string;
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
total_count: number;
|
||||
current_offset: number;
|
||||
error_message?: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface FilterOptions {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
status?: string;
|
||||
search?: string;
|
||||
siteId?: string;
|
||||
campaignId?: string;
|
||||
}
|
||||
|
||||
export interface PaginationResult<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
100
src/lib/shim/utils.ts
Normal file
100
src/lib/shim/utils.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
// Utility functions for building SQL queries safely
|
||||
|
||||
/**
|
||||
* Builds WHERE clause from filter object
|
||||
* Returns [sqlFragment, params] for safe parameterized queries
|
||||
*/
|
||||
export function buildWhere(
|
||||
filters: Record<string, any>,
|
||||
startIndex: number = 1
|
||||
): [string, any[]] {
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = startIndex;
|
||||
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
conditions.push(`${key} = $${paramIndex++}`);
|
||||
params.push(value);
|
||||
}
|
||||
});
|
||||
|
||||
const sql = conditions.length > 0 ? ` AND ${conditions.join(' AND ')}` : '';
|
||||
return [sql, params];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds LIKE clause for search
|
||||
*/
|
||||
export function buildSearch(
|
||||
column: string,
|
||||
searchTerm: string,
|
||||
paramIndex: number
|
||||
): [string, string] {
|
||||
return [` AND ${column} ILIKE $${paramIndex}`, `%${searchTerm}%`];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds pagination clause
|
||||
*/
|
||||
export function buildPagination(
|
||||
limit: number = 50,
|
||||
offset: number = 0,
|
||||
paramIndex: number
|
||||
): [string, number, number] {
|
||||
return [
|
||||
` LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||
Math.min(limit, 1000), // Cap at 1000
|
||||
Math.max(offset, 0)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes table/column names (only allow alphanumeric + underscore)
|
||||
*/
|
||||
export function sanitizeIdentifier(identifier: string): string {
|
||||
if (!/^[a-zA-Z0-9_]+$/.test(identifier)) {
|
||||
throw new Error(`Invalid identifier: ${identifier}`);
|
||||
}
|
||||
return identifier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds dynamic UPDATE SET clause
|
||||
*/
|
||||
export function buildUpdateSet(
|
||||
data: Record<string, any>,
|
||||
startIndex: number = 1
|
||||
): [string, any[]] {
|
||||
const fields: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramIndex = startIndex;
|
||||
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
if (value !== undefined && key !== 'id' && key !== 'created_at') {
|
||||
fields.push(`${sanitizeIdentifier(key)} = $${paramIndex++}`);
|
||||
values.push(value);
|
||||
}
|
||||
});
|
||||
|
||||
if (fields.length === 0) {
|
||||
throw new Error('No fields to update');
|
||||
}
|
||||
|
||||
return [fields.join(', '), values];
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a query and returns single result or null
|
||||
*/
|
||||
export function getSingleResult<T>(rows: T[]): T | null {
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates UUID format
|
||||
*/
|
||||
export function isValidUUID(uuid: string): boolean {
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
return uuidRegex.test(uuid);
|
||||
}
|
||||
Reference in New Issue
Block a user