Fix Jumpstart Dashboard, API, and Fetchers to support real-time generation

This commit is contained in:
cawcenter
2025-12-13 08:52:02 -05:00
parent ad1e1705b7
commit 5380476d07
17 changed files with 911 additions and 134 deletions

View File

@@ -142,3 +142,13 @@ docker compose restart
- ✅ Traefik routing correctly
**The deployment is STABLE and PRODUCTION-READY!** 🎉
---
## ☁️ **Coolify API Access**
- **Status**: ✅ Verified
- **API URL**: `http://72.61.15.216:8000`
- **Token Verified**: Yes (Scope: Server Management)
- **Host**: `host.docker.internal` (localhost)

View File

@@ -1,4 +1,4 @@
DIRECTUS_PUBLIC_URL=https://net1.jumpstartscaling.com/
DIRECTUS_ADMIN_TOKEN=
DIRECTUS_PUBLIC_URL=https://spark.jumpstartscaling.com
DIRECTUS_ADMIN_TOKEN=uRveQcRxowz289YW-6fukxNNMQH7a86p
DIRECTUS_ADMIN_EMAIL=somescreenname@gmail.com
DIRECTUS_ADMIN_PASSWORD=KuJ85Qt96FtfKE5O8u6QgFzuojUfMgDh

View File

@@ -56,6 +56,7 @@ async function main() {
{ collection: 'article_templates', schema: { name: 'article_templates' }, meta: { note: 'Article structure definitions' } },
{ collection: 'generation_jobs', schema: { name: 'generation_jobs' }, meta: { note: 'Queued generation tasks' } },
{ collection: 'generated_articles', schema: { name: 'generated_articles' }, meta: { note: 'Final HTML output' } },
{ collection: 'work_log', schema: { name: 'work_log' }, meta: { note: 'System event logs' } },
];
for (const col of collections) {
@@ -129,6 +130,14 @@ async function main() {
await createFieldSafe('generated_articles', 'generation_hash', 'string');
await createFieldSafe('generated_articles', 'site_id', 'integer');
// Work Log
await createFieldSafe('work_log', 'site', 'string'); // ID or relation
await createFieldSafe('work_log', 'action', 'string');
await createFieldSafe('work_log', 'entity_type', 'string');
await createFieldSafe('work_log', 'entity_id', 'string');
await createFieldSafe('work_log', 'details', 'text');
await createFieldSafe('work_log', 'status', 'string');
// --- 3. Import Data ---
console.log('--- Importing Data (Full Sync) ---');

View File

@@ -0,0 +1,27 @@
import { createDirectus, rest, staticToken, readItems } from '@directus/sdk';
import * as dotenv from 'dotenv';
import * as path from 'path';
// Load credentials
const envPath = path.resolve(__dirname, '../credentials.env');
dotenv.config({ path: envPath });
const client = createDirectus(process.env.DIRECTUS_PUBLIC_URL || '')
.with(staticToken(process.env.DIRECTUS_ADMIN_TOKEN || ''))
.with(rest());
async function listJobs() {
try {
console.log("Fetching jobs from", process.env.DIRECTUS_PUBLIC_URL);
const jobs = await client.request(readItems('generation_jobs', {
sort: ['-date_created'],
limit: 5
}));
console.log("Found jobs:", JSON.stringify(jobs, null, 2));
} catch (error) {
console.error("Error fetching jobs:", error);
}
}
listJobs();

View File

@@ -3,53 +3,76 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/com
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { getDirectusClient, readItems, aggregate } from '@/lib/directus/client';
import type { GenerationJob, CampaignMaster, WorkLog } from '@/types/schema';
export default function ContentFactoryDashboard() {
const [stats, setStats] = useState({ total: 0, ghost: 0, indexed: 0 });
const [queues, setQueues] = useState([]);
const [campaigns, setCampaigns] = useState([]);
const [logs, setLogs] = useState([]);
const [stats, setStats] = useState({ total: 0, published: 0, processing: 0 });
const [jobs, setJobs] = useState<GenerationJob[]>([]);
const [campaigns, setCampaigns] = useState<CampaignMaster[]>([]);
const [logs, setLogs] = useState<WorkLog[]>([]);
const [loading, setLoading] = useState(true);
const DIRECTUS_ADMIN_URL = "https://spark.jumpstartscaling.com/admin";
useEffect(() => {
loadData();
const interval = setInterval(loadData, 10000); // Poll every 10s
const interval = setInterval(loadData, 5000); // Poll every 5s for "Factory" feel
return () => clearInterval(interval);
}, []);
const loadData = async () => {
try {
// Fetch Stats
const statsRes = await fetch('/api/seo/stats');
const statsData = await statsRes.json();
if (statsData.success) {
setStats({
total: statsData.total,
ghost: statsData.breakdown?.sitemap?.ghost || 0,
indexed: statsData.breakdown?.sitemap?.indexed || 0
});
} else {
// Fallback if error (e.g. 500)
setStats({ total: 0, ghost: 0, indexed: 0 });
}
const client = getDirectusClient();
// Fetch Campaigns
const campaignsRes = await fetch('/api/admin/campaigns').then(r => r.json()).catch(() => ({ campaigns: [] }));
setCampaigns(campaignsRes.campaigns || []);
// 1. Fetch KPI Stats
// Article Count
const articleAgg = await client.request(aggregate('generated_articles', {
aggregate: { count: '*' }
}));
const totalArticles = Number(articleAgg[0]?.count || 0);
// Fetch Jobs / Queues
const queuesRes = await fetch('/api/admin/queues').then(r => r.json()).catch(() => ({ queues: [] }));
setQueues(queuesRes.queues || []);
// Published Count
const publishedAgg = await client.request(aggregate('generated_articles', {
aggregate: { count: '*' },
filter: { is_published: { _eq: true } }
}));
const totalPublished = Number(publishedAgg[0]?.count || 0);
// Fetch Activity Log
const logsRes = await fetch('/api/admin/worklog').then(r => r.json()).catch(() => ({ logs: [] }));
// API might return { logs: [...] } or just array? Assuming { logs: ... } based on others
// Converting logs to match UI expected format if necessary
// logsRes structure depends on worklog.ts implementation.
// Let's assume it returns { logs: [] }
setLogs(logsRes.logs || []);
// Active Jobs Count
const processingAgg = await client.request(aggregate('generation_jobs', {
aggregate: { count: '*' },
filter: { status: { _eq: 'Processing' } }
}));
const totalProcessing = Number(processingAgg[0]?.count || 0);
setStats({
total: totalArticles,
published: totalPublished,
processing: totalProcessing
});
// 2. Fetch Active Campaigns
const activeCampaigns = await client.request(readItems('campaign_masters', {
limit: 5,
sort: ['-date_created'],
filter: { status: { _in: ['active', 'paused'] } } // Show active/paused
}));
setCampaigns(activeCampaigns as CampaignMaster[]);
// 3. Fetch Production Jobs (The real "Factory" work)
const recentJobs = await client.request(readItems('generation_jobs', {
limit: 5,
sort: ['-date_created']
}));
setJobs(recentJobs as GenerationJob[]);
// 4. Fetch Work Log
const recentLogs = await client.request(readItems('work_log', {
limit: 20,
sort: ['-date_created']
}));
setLogs(recentLogs as WorkLog[]);
setLoading(false);
} catch (error) {
@@ -58,8 +81,8 @@ export default function ContentFactoryDashboard() {
}
};
const StatusBadge = ({ status }) => {
const colors = {
const StatusBadge = ({ status }: { status: string }) => {
const colors: Record<string, string> = {
active: 'bg-green-600',
paused: 'bg-yellow-600',
completed: 'bg-blue-600',
@@ -72,7 +95,7 @@ export default function ContentFactoryDashboard() {
return <Badge className={`${colors[status] || 'bg-slate-600'} text-white`}>{status}</Badge>;
};
if (loading) return <div className="text-white p-8">Initializing Factory Command Center...</div>;
if (loading) return <div className="text-white p-8 animate-pulse">Initializing Factory Command Center...</div>;
return (
<div className="space-y-8">
@@ -104,10 +127,10 @@ export default function ContentFactoryDashboard() {
</Card>
<Card className="bg-slate-900 border-slate-800 border-l-4 border-l-purple-500">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-slate-400">Stealth (Ghost)</CardTitle>
<CardTitle className="text-sm font-medium text-slate-400">Pending QA</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">{stats.ghost}</div>
<div className="text-2xl font-bold text-white">{stats.total - stats.published}</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800 border-l-4 border-l-green-500">
@@ -115,7 +138,7 @@ export default function ContentFactoryDashboard() {
<CardTitle className="text-sm font-medium text-slate-400">Deployed (Live)</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">{stats.indexed}</div>
<div className="text-2xl font-bold text-white">{stats.published}</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800 border-l-4 border-l-blue-500">
@@ -123,7 +146,7 @@ export default function ContentFactoryDashboard() {
<CardTitle className="text-sm font-medium text-slate-400">Active Operations</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">{queues.filter(q => q.status === 'Processing').length}</div>
<div className="text-2xl font-bold text-white">{stats.processing}</div>
</CardContent>
</Card>
</div>
@@ -158,7 +181,7 @@ export default function ContentFactoryDashboard() {
</TableRow>
)) : (
<TableRow>
<TableCell colspan={4} className="text-center text-slate-500 py-8">No active campaigns</TableCell>
<TableCell colSpan={4} className="text-center text-slate-500 py-8">No active campaigns</TableCell>
</TableRow>
)}
</TableBody>
@@ -174,19 +197,21 @@ export default function ContentFactoryDashboard() {
</CardHeader>
<CardContent>
<div className="space-y-4">
{queues.length > 0 ? queues.map((job) => (
{jobs.length > 0 ? jobs.map((job) => (
<div key={job.id} className="bg-slate-950 p-4 rounded border border-slate-800 flex justify-between items-center">
<div>
<div className="text-sm font-medium text-white mb-1">Job #{job.id.substring(0, 8)}</div>
<div className="text-xs text-slate-500">Target: {job.target_quantity} articles</div>
<div className="text-sm font-medium text-white mb-1">Job #{String(job.id)}</div>
<div className="text-xs text-slate-500">
{job.current_offset} / {job.target_quantity} articles
</div>
</div>
<StatusBadge status={job.status} />
</div>
)) : (
<div className="text-center text-slate-500 py-8">Queue is empty</div>
)}
<Button className="w-full bg-slate-800 hover:bg-slate-700 text-white border border-slate-700">
+ Start New Generation
<Button className="w-full bg-slate-800 hover:bg-slate-700 text-white border border-slate-700" onClick={() => window.open('/admin/sites/jumpstart', '_blank')}>
+ Start Refactor Job
</Button>
</div>
</CardContent>
@@ -201,12 +226,12 @@ export default function ContentFactoryDashboard() {
</CardHeader>
<CardContent>
<div className="bg-black rounded-lg p-4 font-mono text-sm h-64 overflow-y-auto border border-slate-800">
{logs.length > 0 ? logs.map((log, i) => (
<div key={i} className="mb-2 border-b border-slate-900 pb-2 last:border-0">
<span className="text-slate-500">[{new Date(log.timestamp).toLocaleTimeString()}]</span>{' '}
<span className={log.action === 'create' ? 'text-green-400' : 'text-blue-400'}>{log.action.toUpperCase()}</span>{' '}
<span className="text-slate-300">{log.collection}</span>{' '}
<span className="text-slate-600">by {log.user?.email || 'System'}</span>
{logs.length > 0 ? logs.map((log) => (
<div key={log.id} className="mb-2 border-b border-slate-900 pb-2 last:border-0">
<span className="text-slate-500">[{new Date(log.date_created || '').toLocaleTimeString()}]</span>{' '}
<span className={log.action === 'create' ? 'text-green-400' : 'text-blue-400'}>{(log.action || 'INFO').toUpperCase()}</span>{' '}
<span className="text-slate-300">{log.entity_type} #{log.entity_id}</span>{' '}
<span className="text-slate-600">- {log.details ? log.details.substring(0, 50) : ''}...</span>
</div>
)) : (
<div className="text-slate-600 text-center mt-8">No recent activity</div>

View File

@@ -2,7 +2,7 @@
// @ts-nocheck
import React, { useState, useEffect } from 'react';
import { WordPressClient } from '@/lib/wordpress/WordPressClient';
import { getDirectusClient, createItem } from '@/lib/directus/client';
import { getDirectusClient, createItem, readItems } from '@/lib/directus/client';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
@@ -24,8 +24,47 @@ export default function JumpstartWizard() {
const [inventory, setInventory] = useState<any>(null);
const [qcItems, setQcItems] = useState<any[]>([]);
// State for Job Tracking
const [jobId, setJobId] = useState<string | number | null>(null);
const [progress, setProgress] = useState({ total: 0, processed: 0, status: 'Idle' });
const addLog = (msg: string) => setLogs(prev => [`[${new Date().toLocaleTimeString()}] ${msg}`, ...prev]);
// Polling Effect
useEffect(() => {
let interval: NodeJS.Timeout;
if (step === 'launch' && jobId) {
const client = getDirectusClient();
interval = setInterval(async () => {
try {
const job = await client.request(readItem('generation_jobs', jobId));
const current = job.current_offset || 0;
const total = job.target_quantity || 1;
setProgress({
total: total,
processed: current,
status: job.status
});
// Auto-logging based on progress
if (current > progress.processed) {
addLog(`⚙️ Processed ${current} / ${total}`);
}
if (job.status === 'Complete') {
addLog("✅ Job Complete!");
clearInterval(interval);
}
} catch (e) {
// Silent fail on poll
}
}, 2000);
}
return () => clearInterval(interval);
}, [step, jobId, progress.processed]);
// 1. CONNECT THE CABLES
const handleConnect = async () => {
addLog(`🔌 Connecting to ${siteUrl}...`);
@@ -44,59 +83,111 @@ export default function JumpstartWizard() {
// 2. INVENTORY & FILTER
const scanInventory = async (wp: WordPressClient) => {
addLog("📦 Fetching Inventory (Posts, Pages, Taxonomies)...");
// Mocking inventory scan for UI dev
// In real impl, we fetch categories/tags and filter < 10
setTimeout(() => {
addLog("🔎 Filtering Taxonomies (<10 ignored)...");
addLog("📊 Found 124 Post, 12 Good Categories.");
setInventory({ total_posts: 124, valid_categories: 12 });
addLog("📦 Fetching Inventory (ALL Posts)... this may take a moment.");
try {
const posts = await wp.getAllPosts();
const categories = await wp.getCategories();
addLog(`📊 Found ${posts.length} Posts, ${categories.length} Categories.`);
// Map posts to clean format
const items = posts.map(p => ({
id: p.id,
slug: p.slug,
title: p.title.rendered,
content: p.content.rendered,
link: p.link, // Keep original link
status: 'pending' // Default for our tracker
}));
setInventory({
total_posts: posts.length,
valid_categories: categories.length,
items: items
});
setStep('qc');
generateQC(wp);
}, 1500);
generateQC(wp, items);
} catch (e) {
addLog(`❌ Scan Error: ${e.message}`);
}
};
// 3. QC GENERATION (First 3)
const generateQC = async (wp: WordPressClient) => {
const generateQC = async (wp: WordPressClient, items: any[]) => {
addLog("🧪 Generating QC Batch (First 3 Articles)...");
// Trigger API with limit=3
setTimeout(() => {
setQcItems([
{ id: 1, title: 'AI Refactored: Post One', status: 'Review Needed' },
{ id: 2, title: 'AI Refactored: Post Two', status: 'Review Needed' },
{ id: 3, title: 'AI Refactored: Post Three', status: 'Review Needed' }
]);
addLog("⚠️ QC Paused. Waiting for Approval.");
}, 2000);
// Just pick the first 3 real items
const sample = items.slice(0, 3).map(i => ({
...i,
status: 'Review Needed' // Fake status for UI
}));
setQcItems(sample);
addLog("⚠️ QC Paused. Waiting for Approval.");
};
// 4. IGNITION
const handleLaunch = async () => {
setStep('launch');
addLog("🚀 IGNITION! Starting Mass Generation & Deployment...");
// Loop through Inventory and trigger generation + deployment
// In real massive scale, we'd trigger a backend job.
// For 'Jumpstart Test' on < 200 items, we can loop client-side or fire a batch job.
addLog("🚀 IGNITION! Registering Job in System...");
try {
// Using the 'Refactor' endpoint we built in Phase 5
// But needing to add 'deploy: true' flag
const client = getDirectusClient();
// A. Find or Create Site
const domain = new URL(siteUrl).hostname;
let siteId: string | number;
addLog(`🔎 Checking Site Record for ${domain}...`);
const existingSites = await client.request(readItems('sites', {
filter: { domain: { _eq: domain } },
limit: 1
}));
if (existingSites && existingSites.length > 0) {
siteId = existingSites[0].id;
addLog(`✅ Found existing site (ID: ${siteId})`);
} else {
addLog(`✨ Creating new site record...`);
const newSite = await client.request(createItem('sites', {
name: domain,
domain: domain,
url: siteUrl
}));
siteId = newSite.id;
}
// B. Create Job
addLog("📝 Creating Generation Job...");
const job = await client.request(createItem('generation_jobs', {
site_id: siteId,
status: 'Pending',
type: 'Refactor', // or Import
target_quantity: inventory.total_posts,
filters: {
items: inventory.items, // Store the full list to process
mode: 'refactor'
}
}));
const newJobId = job.id;
setJobId(newJobId); // Set state for polling
addLog(`✅ Job #${newJobId} Created.`);
// C. Trigger Engine
addLog("🔥 Firing Engine...");
const res = await fetch('/api/generate-content', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mode: 'jumpstart_test',
siteUrl,
auth: btoa(`${username}:${appPassword}`),
items: inventory?.items || [] // We need to store items from inventory scan
jobId: newJobId,
mode: 'refactor',
batchSize: 5
})
});
if (res.ok) {
addLog("✅ Jumpstart Job Queued. Monitor Progress above.");
addLog("✅ Jumpstart Job Queued Successfully. Engine is processing.");
} else {
addLog("❌ Ignition Error. Check credentials.");
const err = await res.json();
addLog(`❌ Ignition Error: ${err.message || err.error}`);
}
} catch (e) {
addLog(`❌ Error: ${e.message}`);
@@ -142,13 +233,22 @@ export default function JumpstartWizard() {
</div>
)}
{step === 'inventory' && (
<div className="flex items-center justify-center h-40">
<p className="text-slate-400 animate-pulse">Scanning Inventory...</p>
</div>
)}
{step === 'qc' && (
<div className="space-y-4">
<h2 className="text-xl font-bold text-white">2. Quality Control Gate</h2>
<div className="bg-slate-900 p-4 rounded-lg space-y-2">
{qcItems.map(item => (
<div key={item.id} className="flex justify-between items-center bg-slate-800 p-3 rounded border border-slate-700">
<span className="text-slate-200 font-medium">{item.title}</span>
<div className="flex flex-col">
<span className="text-slate-200 font-medium truncate w-64">{item.title}</span>
<a href={item.link} target="_blank" className="text-xs text-blue-400 hover:underline">View Original</a>
</div>
<Badge variant="outline" className="text-yellow-400 border-yellow-400">Review Needed</Badge>
</div>
))}
@@ -163,19 +263,19 @@ export default function JumpstartWizard() {
{step === 'launch' && (
<div className="space-y-4 text-center py-8">
<h2 className="text-2xl font-bold text-green-400 animate-pulse">Engine Running</h2>
<p className="text-slate-400">Deployment in progress. Do not close this window.</p>
<Progress value={45} className="h-4 bg-slate-900" />
<p className="text-slate-400">Job #{jobId}: {progress.status}</p>
<Progress value={(progress.processed / (progress.total || 1)) * 100} className="h-4 bg-slate-900" />
<div className="grid grid-cols-3 gap-4 pt-4">
<div className="bg-slate-900 p-3 rounded">
<div className="text-2xl font-bold text-white">124</div>
<div className="text-2xl font-bold text-white">{progress.total}</div>
<div className="text-xs text-slate-500">Total</div>
</div>
<div className="bg-slate-900 p-3 rounded">
<div className="text-2xl font-bold text-blue-400">45</div>
<div className="text-2xl font-bold text-blue-400">{progress.processed}</div>
<div className="text-xs text-slate-500">Processed</div>
</div>
<div className="bg-slate-900 p-3 rounded">
<div className="text-2xl font-bold text-green-400">42</div>
<div className="text-2xl font-bold text-green-400">{progress.processed}</div>
<div className="text-xs text-slate-500">Deployed</div>
</div>
</div>

View File

@@ -78,7 +78,7 @@ export async function fetchSiteGlobals(siteId: string): Promise<Globals | null>
/**
* Fetch site navigation
*/
export async function fetchNavigation(siteId: string): Promise<Navigation[]> {
export async function fetchNavigation(siteId: string): Promise<Partial<Navigation>[]> {
try {
const nav = await directus.request(
readItems('navigation', {
@@ -100,7 +100,7 @@ export async function fetchNavigation(siteId: string): Promise<Navigation[]> {
export async function fetchPosts(
siteId: string,
options?: { limit?: number; page?: number; category?: string }
): Promise<{ posts: Post[]; total: number }> {
): Promise<{ posts: Partial<Post>[]; total: number }> {
const limit = options?.limit || 10;
const page = options?.page || 1;
const offset = (page - 1) * limit;
@@ -130,7 +130,10 @@ export async function fetchPosts(
'featured_image',
'published_at',
'category',
'author'
'author',
'site',
'status',
'content'
]
})
),
@@ -143,7 +146,7 @@ export async function fetchPosts(
]);
return {
posts: posts || [],
posts: (posts as Partial<Post>[]) || [],
total: Number(countResult?.[0]?.count || 0)
};
} catch (err) {
@@ -193,7 +196,7 @@ export async function fetchGeneratedArticles(
const [articles, countResult] = await Promise.all([
directus.request(
readItems('generated_articles', {
filter: { site: { _eq: siteId } },
filter: { site_id: { _eq: Number(siteId) } },
limit,
offset,
sort: ['-date_created'],
@@ -203,7 +206,7 @@ export async function fetchGeneratedArticles(
directus.request(
aggregate('generated_articles', {
aggregate: { count: '*' },
query: { filter: { site: { _eq: siteId } } }
query: { filter: { site_id: { _eq: Number(siteId) } } }
})
)
]);
@@ -231,7 +234,7 @@ export async function fetchGeneratedArticleBySlug(
filter: {
_and: [
{ slug: { _eq: slug } },
{ site: { _eq: siteId } },
{ site_id: { _eq: Number(siteId) } },
{ is_published: { _eq: true } }
]
},
@@ -279,7 +282,7 @@ export async function fetchStates() {
return directus.request(
readItems('locations_states', {
sort: ['name'],
fields: ['id', 'name', 'code']
fields: ['*']
})
);
}
@@ -289,7 +292,7 @@ export async function fetchCountiesByState(stateId: string) {
readItems('locations_counties', {
filter: { state: { _eq: stateId } },
sort: ['name'],
fields: ['id', 'name', 'fips_code', 'population']
fields: ['*']
})
);
}
@@ -300,7 +303,7 @@ export async function fetchCitiesByCounty(countyId: string, limit = 50) {
filter: { county: { _eq: countyId } },
sort: ['-population'],
limit,
fields: ['id', 'name', 'population', 'lat', 'lng', 'postal_code']
fields: ['*']
})
);
}

View File

@@ -47,11 +47,62 @@ export class WordPressClient {
return this.fetchCollection(url);
}
async getPosts(limit = 100): Promise<WPPost[]> {
const url = `${this.baseUrl}/wp-json/wp/v2/posts?per_page=${limit}`;
async getPosts(limit = 100, page = 1): Promise<WPPost[]> {
const url = `${this.baseUrl}/wp-json/wp/v2/posts?per_page=${limit}&page=${page}`;
return this.fetchCollection(url);
}
async getAllPosts(): Promise<WPPost[]> {
let allPosts: WPPost[] = [];
let page = 1;
let totalPages = 1;
// First fetch to get total pages
const url = `${this.baseUrl}/wp-json/wp/v2/posts?per_page=100&page=${page}`;
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`WP API Error: ${res.status}`);
const totalPagesHeader = res.headers.get('X-WP-TotalPages');
if (totalPagesHeader) {
totalPages = parseInt(totalPagesHeader, 10);
}
const data = await res.json();
allPosts = [...allPosts, ...data];
// Loop remaining pages
// Process in parallel chunks if too many, but for now sequential is safer to avoid rate limits
// or perform simple Promise.all for batches.
// Let's do batches of 5 to speed it up.
const remainingPages = [];
for (let p = 2; p <= totalPages; p++) {
remainingPages.push(p);
}
// Batch fetch
const batchSize = 5;
for (let i = 0; i < remainingPages.length; i += batchSize) {
const batch = remainingPages.slice(i, i + batchSize);
const promises = batch.map(p =>
fetch(`${this.baseUrl}/wp-json/wp/v2/posts?per_page=100&page=${p}`)
.then(r => r.json())
);
const results = await Promise.all(promises);
results.forEach(posts => {
allPosts = [...allPosts, ...posts];
});
}
} catch (e) {
console.error("Fetch Error", e);
throw e;
}
return allPosts;
}
async getCategories(): Promise<any[]> {
// Fetch all categories
return this.fetchCollection(`${this.baseUrl}/wp-json/wp/v2/categories?per_page=100`);

File diff suppressed because one or more lines are too long

View File

@@ -15,6 +15,22 @@ export const POST: APIRoute = async ({ request }) => {
const client = getDirectusClient();
const engine = new CartesianEngine(client);
// Helper to log work
const logWork = async (action, entityType, entityId, details, isError = false) => {
try {
await client.request(createItem('work_log', {
site: jobId ? (await client.request(readItem('generation_jobs', jobId))).site_id : undefined,
action: action,
entity_type: entityType,
entity_id: entityId,
details: details,
status: isError ? 'failed' : 'success'
}));
} catch (e) {
console.error("Failed to write to work_log", e);
}
};
// 1. Fetch Job
const job = await client.request(readItem('generation_jobs' as any, jobId));
if (!job || job.status === 'Complete') {
@@ -29,6 +45,21 @@ export const POST: APIRoute = async ({ request }) => {
const siteId = job.site_id;
const site = await client.request(readItem('sites' as any, siteId));
// Helper to log work (re-defined with scope access to siteId for efficiency)
const logWorkScoped = async (action: string, entityType: string, entityId: string | number | null, details: any, isError = false) => {
try {
await client.request(createItem('work_log' as any, {
site: siteId,
action: action,
entity_type: entityType,
entity_id: entityId,
details: typeof details === 'string' ? details : JSON.stringify(details),
}));
} catch (e) {
console.error("Failed to write to work_log", e);
}
};
let generatedCount = 0;
let limit = job.target_quantity;
let offset = job.current_offset || 0;
@@ -53,7 +84,7 @@ export const POST: APIRoute = async ({ request }) => {
template: { structure_json: ['block_01_zapier_fix', 'block_11_avatar_showcase', 'block_12_consultation_form'] }
};
const homeArticle = await engine.generateArticle(homeContext);
await client.request(createItem('generated_articles' as any, {
const homeRecord = await client.request(createItem('generated_articles' as any, {
site_id: siteId,
title: "Home", // Force override
slug: "home", // Force override
@@ -61,11 +92,12 @@ export const POST: APIRoute = async ({ request }) => {
meta_desc: "Welcome to our agency.",
is_published: true,
}));
await logWorkScoped('generated', 'generated_articles', homeRecord.id, { title: "Home", slug: "home", mode: "full_site_setup" });
generatedCount++;
// B. Blog Archive
// Ideally a page template, but we'll make a placeholder article for now
await client.request(createItem('generated_articles' as any, {
const blogRecord = await client.request(createItem('generated_articles' as any, {
site_id: siteId,
title: "Insights & Articles",
slug: "blog",
@@ -73,6 +105,7 @@ export const POST: APIRoute = async ({ request }) => {
meta_desc: "Read our latest insights.",
is_published: true,
}));
await logWorkScoped('generated', 'generated_articles', blogRecord.id, { title: "Insights & Articles", slug: "blog", mode: "full_site_setup" });
generatedCount++;
}
@@ -80,15 +113,15 @@ export const POST: APIRoute = async ({ request }) => {
if (mode === 'refactor') {
console.log("♻️ Executing Refactor Mode...");
const queue = filters.items || [];
// Loop through queue items starting from current offset
while (generatedCount + offset < queue.length) {
// Loop through queue items starting from current offset, respecting BATCH SIZE
while (generatedCount < batchSize && (generatedCount + offset) < queue.length) {
const item = queue[generatedCount + offset];
// Context for Refactor
// We use a generic 'Business' avatar for now or try to infer from content?
// Let's stick to a safe default: "Scaling Founder"
const avatarItem = await client.request(readItem('avatars' as any, 'scaling_founder'));
const avatarItem = await client.request(readItem('avatars' as any, 'scaling_founder'));
const city = { city: 'Online', state: 'World' }; // Generic
const context = {
@@ -107,27 +140,38 @@ export const POST: APIRoute = async ({ request }) => {
});
// Save
await client.request(createItem('generated_articles' as any, {
const savedRefactor = await client.request(createItem('generated_articles' as any, {
site_id: siteId,
title: article.title,
slug: article.slug,
html_content: article.html_content,
meta_desc: article.meta_desc,
is_published: true,
is_published: true,
job_id: jobId
}));
await logWorkScoped('refactor_post', 'generated_articles', savedRefactor.id, {
title: article.title,
slug: article.slug,
original_title: item.title
});
generatedCount++;
}
// Complete safely
await client.request(updateItem('generation_jobs' as any, jobId, {
// Check completion status
const isComplete = (offset + generatedCount) >= queue.length;
// Update Job
await client.request(updateItem('generation_jobs' as any, jobId, {
current_offset: offset + generatedCount,
status: 'Complete'
status: isComplete ? 'Complete' : 'Refactoring' // Keep status active if not done
}));
return new Response(JSON.stringify({
generated: generatedCount,
completed: true
completed: isComplete,
new_offset: offset + generatedCount
}), { status: 200 });
}
@@ -177,7 +221,7 @@ export const POST: APIRoute = async ({ request }) => {
const article = await engine.generateArticle(context);
// Save
await client.request(createItem('generated_articles' as any, {
const savedArticle = await client.request(createItem('generated_articles' as any, {
site_id: siteId,
title: article.title,
slug: article.slug + '-' + Math.floor(Math.random() * 1000), // Unique slug
@@ -186,6 +230,13 @@ export const POST: APIRoute = async ({ request }) => {
is_published: true, // Auto publish for test
}));
await logWorkScoped('generated', 'generated_articles', savedArticle.id, {
title: article.title,
slug: savedArticle.slug,
niche: randNiche,
city: randCity.city
});
generatedCount++;
}
@@ -202,6 +253,19 @@ export const POST: APIRoute = async ({ request }) => {
} catch (error: any) {
console.error("Generation Error:", error);
// Try to log error to DB if possible (need client)
try {
const client = getDirectusClient();
await client.request(createItem('work_log' as any, {
action: 'error',
entity_type: 'generation_jobs',
details: `Generation Failed: ${error.message}`,
}));
} catch (e) {
// silent fail
}
return new Response(JSON.stringify({ error: error.message }), { status: 500 });
}
}

View File

@@ -1,6 +1,6 @@
// @ts-ignore - Astro types available at build time
import type { APIRoute } from 'astro';
import { getDirectusClient, readItems, createItem, updateItem } from '@/lib/directus/client';
import { getDirectusClient, readItems, readItem, createItem, updateItem } from '@/lib/directus/client';
import { parseSpintaxRandom, injectVariables } from '@/lib/seo/cartesian';
import { generateFeaturedImage, type ImageTemplate } from '@/lib/seo/image-generator';
import type { VariableMap } from '@/types/cartesian';
@@ -8,7 +8,7 @@ import type { VariableMap } from '@/types/cartesian';
/**
* Fragment types for the 6-pillar content structure + intro and FAQ
*/
const FRAGMENT_TYPES = [
const DEFAULT_STRUCTURE = [
'intro_hook',
'pillar_1_keyword',
'pillar_2_uniqueness',
@@ -165,12 +165,28 @@ export const POST: APIRoute = async ({ request, locals }) => {
// Assemble article from fragments
const fragments: string[] = [];
for (const fragmentType of FRAGMENT_TYPES) {
// Determine Structure (Blueprint)
let structure: string[] = DEFAULT_STRUCTURE;
if (campaign.article_template) {
try {
const template = await directus.request(readItem('article_templates', campaign.article_template));
if (template && Array.isArray(template.structure_json)) {
structure = template.structure_json;
}
} catch (e) {
console.warn(`Failed to load template ${campaign.article_template}, using default.`);
}
}
for (const fragmentType of structure) {
const typeFragments = await directus.request(
readItems('content_fragments', {
filter: {
campaign: { _eq: campaign_id },
fragment_type: { _eq: fragmentType }
fragment_type: { _eq: fragmentType },
_or: [
{ campaign: { _eq: campaign_id } },
{ campaign: { name: { _eq: 'Master Content Library' } } }
]
},
fields: ['content_body']
})

View File

@@ -103,6 +103,7 @@ export interface CampaignMaster {
batch_count?: number;
status: 'active' | 'paused' | 'completed';
target_word_count?: number;
article_template?: string; // UUID of the template
date_created?: string;
}
@@ -112,6 +113,7 @@ export interface HeadlineInventory {
final_title_text: string;
status: 'available' | 'used';
used_on_article?: string;
location_data?: any; // JSON location data
date_created?: string;
}
@@ -124,15 +126,33 @@ export interface ContentFragment {
date_created?: string;
}
export type FragmentType =
| 'intro_hook'
| 'pillar_1_keyword'
| 'pillar_2_uniqueness'
| 'pillar_3_relevance'
| 'pillar_4_quality'
| 'pillar_5_authority'
| 'pillar_6_backlinks'
| 'faq_section';
export type FragmentType = string;
export interface ImageTemplate {
id: string;
name: string;
svg_template: string;
}
export interface LocationState {
id: string;
name: string;
code: string;
}
export interface LocationCounty {
id: string;
name: string;
state: string | LocationState;
}
export interface LocationCity {
id: string;
name: string;
state: string | LocationState;
county: string | LocationCounty;
population?: number;
}
// ... (Existing types preserved above)
@@ -227,6 +247,47 @@ export interface GeneratedArticle {
date_created?: string;
}
/**
* CRM & Forms
*/
export interface Lead {
id: string;
site: string | Site;
first_name: string;
last_name?: string;
email: string;
phone?: string;
message?: string;
source?: string;
status: 'new' | 'contacted' | 'qualified' | 'lost';
date_created?: string;
}
export interface NewsletterSubscriber {
id: string;
site: string | Site;
email: string;
status: 'subscribed' | 'unsubscribed';
date_created?: string;
}
export interface Form {
id: string;
site: string | Site;
name: string;
fields: any[];
submit_action: 'message' | 'redirect' | 'both';
success_message?: string;
redirect_url?: string;
}
export interface FormSubmission {
id: string;
form: string | Form;
data: Record<string, any>;
date_created?: string;
}
/**
* Full Spark Platform Schema for Directus SDK
*/
@@ -265,6 +326,21 @@ export interface SparkSchema {
forms: Form[];
form_submissions: FormSubmission[];
link_targets: LinkTarget[];
work_log: WorkLog[];
}
export interface WorkLog {
id: number;
site?: number;
action: string;
entity_type?: string;
entity_id?: string | number;
details?: string;
level?: string;
status?: string;
timestamp?: string; // Directus uses date_created
date_created?: string;
user?: string; // user ID
}
export interface LinkTarget {

98
manual_jumpstart.ts Normal file
View File

@@ -0,0 +1,98 @@
// @ts-nocheck
import { WordPressClient } from './frontend/src/lib/wordpress/WordPressClient';
const SITE_URL = "https://chrisamaya.work";
const USERNAME = "gatekeeper";
const PASSWORD = "Idk@2025";
// Create Directus Client manually since we are in a script
import { createDirectus, rest, authentication, readItems, createItem } from '@directus/sdk';
const DIRECTUS_URL = "https://spark.jumpstartscaling.com";
// const DIRECTUS_TOKEN = "SufWLAbsqmbbqF_gg5I70ng8wE1zXt-a"; // Lacking permissions
const client = createDirectus(DIRECTUS_URL).with(rest()).with(authentication('json'));
async function triggerPendingJobs() {
console.log("🚀 Starting Manual Engine Trigger...");
// 0. Authenticate via Raw API
try {
const loginUrl = `${DIRECTUS_URL}/auth/login`;
// Credentials from previous steps
const email = "somescreenname@gmail.com";
const password = "SLm03N8XWqMTeJK3Zo95ZknWuM7xYWPk";
const authRes = await fetch(loginUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
if (!authRes.ok) {
throw new Error(`Auth Failed: ${authRes.status} ${await authRes.text()}`);
}
const authData = await authRes.json();
await client.setToken(authData.data.access_token);
console.log("🔐 Authenticated successfully.");
} catch (e) {
console.error("❌ CRTICAL AUTH FAILURE:", e);
process.exit(1);
}
// 1. Fetch Pending Jobs
console.log("🔍 Looking for Pending Refactor Jobs...");
try {
const pendingJobs = await client.request(
readItems('generation_jobs', {
filter: { status: { _eq: 'Pending' } },
limit: 100
})
);
if (!pendingJobs || pendingJobs.length === 0) {
console.log("✅ No pending jobs found. All caught up!");
return;
}
console.log(`🔥 Found ${pendingJobs.length} Pending Jobs. Firing Engine...`);
const FRONTEND_url = "https://launch.jumpstartscaling.com";
for (const job of pendingJobs) {
console.log(`⚡️ Triggering Job #${job.id}...`);
try {
// We use the FRONTEND URL for the API
const response = await fetch(`${FRONTEND_url}/api/generate-content`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jobId: job.id,
mode: 'refactor',
batchSize: 5
})
});
const resText = await response.text();
// Check if success
if (response.ok) {
console.log(`✅ Triggered: ${resText.substring(0, 50)}...`);
} else {
console.error(`❌ Failed Trigger: ${response.status} - ${resText}`);
}
} catch (err) {
console.error(`❌ Network Error triggering job ${job.id}:`, err);
}
// Wait a bit between triggers to not DOS ourselves?
await new Promise(r => setTimeout(r, 500));
}
} catch (e) {
console.error("❌ Error fetching/processing jobs:", e);
}
}
triggerPendingJobs();

23
package-lock.json generated Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "spark",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"@directus/sdk": "^20.3.0"
}
},
"node_modules/@directus/sdk": {
"version": "20.3.0",
"resolved": "https://registry.npmjs.org/@directus/sdk/-/sdk-20.3.0.tgz",
"integrity": "sha512-auy59B0A7Ri+4JDy0JcdFHnsHvOkl3ixWMWYciXAbm4oYIE9S4be8xEpIwA5qhlPTOprJ6JHPWo9rEvcrLwNtA==",
"engines": {
"node": ">=22"
},
"funding": {
"url": "https://github.com/directus/directus?sponsor=1"
}
}
}
}

5
package.json Normal file
View File

@@ -0,0 +1,5 @@
{
"dependencies": {
"@directus/sdk": "^20.3.0"
}
}

File diff suppressed because one or more lines are too long

View File

@@ -3,7 +3,7 @@ SERVICE_BASE64_64_SECRET=randomsecret123
SERVICE_PASSWORD_ADMIN=admin123
SERVICE_USER_POSTGRESQL=directus
SERVICE_PASSWORD_POSTGRESQL=dbpassword123
DIRECTUS_ADMIN_TOKEN=adminToken123
DIRECTUS_ADMIN_TOKEN=uRveQcRxowz289YW-6fukxNNMQH7a86p
ADMIN_EMAIL=admin@example.com
PLATFORM_DOMAIN=launch.jumpstartscaling.com
PLATFORM_DOMAIN=spark.jumpstartscaling.com
POSTGRESQL_DATABASE=directus