@intranefr/superbackend 1.4.4 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +5 -0
- package/README.md +11 -0
- package/index.js +39 -1
- package/package.json +11 -3
- package/public/sdk/ui-components.iife.js +191 -0
- package/sdk/ui-components/browser/src/index.js +228 -0
- package/src/admin/endpointRegistry.js +120 -0
- package/src/controllers/admin.controller.js +111 -5
- package/src/controllers/adminBlockDefinitions.controller.js +127 -0
- package/src/controllers/adminBlockDefinitionsAi.controller.js +54 -0
- package/src/controllers/adminCache.controller.js +342 -0
- package/src/controllers/adminContextBlockDefinitions.controller.js +141 -0
- package/src/controllers/adminCrons.controller.js +388 -0
- package/src/controllers/adminDbBrowser.controller.js +124 -0
- package/src/controllers/adminEjsVirtual.controller.js +13 -3
- package/src/controllers/adminHeadless.controller.js +91 -2
- package/src/controllers/adminHealthChecks.controller.js +570 -0
- package/src/controllers/adminI18n.controller.js +51 -29
- package/src/controllers/adminLlm.controller.js +126 -2
- package/src/controllers/adminPages.controller.js +720 -0
- package/src/controllers/adminPagesContextBlocksAi.controller.js +54 -0
- package/src/controllers/adminProxy.controller.js +113 -0
- package/src/controllers/adminRateLimits.controller.js +138 -0
- package/src/controllers/adminRbac.controller.js +803 -0
- package/src/controllers/adminScripts.controller.js +320 -0
- package/src/controllers/adminSeoConfig.controller.js +71 -48
- package/src/controllers/adminTerminals.controller.js +39 -0
- package/src/controllers/adminUiComponents.controller.js +315 -0
- package/src/controllers/adminUiComponentsAi.controller.js +34 -0
- package/src/controllers/blogAdmin.controller.js +279 -0
- package/src/controllers/blogAiAdmin.controller.js +224 -0
- package/src/controllers/blogAutomationAdmin.controller.js +141 -0
- package/src/controllers/blogInternal.controller.js +26 -0
- package/src/controllers/blogPublic.controller.js +89 -0
- package/src/controllers/fileManager.controller.js +190 -0
- package/src/controllers/fileManagerStoragePolicy.controller.js +23 -0
- package/src/controllers/healthChecksPublic.controller.js +196 -0
- package/src/controllers/metrics.controller.js +64 -4
- package/src/controllers/orgAdmin.controller.js +366 -0
- package/src/controllers/uiComponentsPublic.controller.js +118 -0
- package/src/middleware/auth.js +7 -0
- package/src/middleware/internalCronAuth.js +29 -0
- package/src/middleware/rbac.js +62 -0
- package/src/middleware.js +879 -56
- package/src/models/BlockDefinition.js +27 -0
- package/src/models/BlogAutomationLock.js +14 -0
- package/src/models/BlogAutomationRun.js +39 -0
- package/src/models/BlogPost.js +42 -0
- package/src/models/CacheEntry.js +26 -0
- package/src/models/ConsoleEntry.js +32 -0
- package/src/models/ConsoleLog.js +23 -0
- package/src/models/ContextBlockDefinition.js +33 -0
- package/src/models/CronExecution.js +47 -0
- package/src/models/CronJob.js +70 -0
- package/src/models/ExternalDbConnection.js +49 -0
- package/src/models/FileEntry.js +22 -0
- package/src/models/HeadlessModelDefinition.js +10 -0
- package/src/models/HealthAutoHealAttempt.js +57 -0
- package/src/models/HealthCheck.js +132 -0
- package/src/models/HealthCheckRun.js +51 -0
- package/src/models/HealthIncident.js +49 -0
- package/src/models/Page.js +95 -0
- package/src/models/PageCollection.js +42 -0
- package/src/models/ProxyEntry.js +66 -0
- package/src/models/RateLimitCounter.js +19 -0
- package/src/models/RateLimitMetricBucket.js +20 -0
- package/src/models/RbacGrant.js +25 -0
- package/src/models/RbacGroup.js +16 -0
- package/src/models/RbacGroupMember.js +13 -0
- package/src/models/RbacGroupRole.js +13 -0
- package/src/models/RbacRole.js +25 -0
- package/src/models/RbacUserRole.js +13 -0
- package/src/models/ScriptDefinition.js +42 -0
- package/src/models/ScriptRun.js +22 -0
- package/src/models/UiComponent.js +29 -0
- package/src/models/UiComponentProject.js +26 -0
- package/src/models/UiComponentProjectComponent.js +18 -0
- package/src/routes/admin.routes.js +1 -0
- package/src/routes/adminBlog.routes.js +21 -0
- package/src/routes/adminBlogAi.routes.js +16 -0
- package/src/routes/adminBlogAutomation.routes.js +27 -0
- package/src/routes/adminCache.routes.js +20 -0
- package/src/routes/adminConsoleManager.routes.js +302 -0
- package/src/routes/adminCrons.routes.js +25 -0
- package/src/routes/adminDbBrowser.routes.js +65 -0
- package/src/routes/adminEjsVirtual.routes.js +2 -1
- package/src/routes/adminHeadless.routes.js +8 -1
- package/src/routes/adminHealthChecks.routes.js +28 -0
- package/src/routes/adminI18n.routes.js +4 -3
- package/src/routes/adminLlm.routes.js +4 -2
- package/src/routes/adminPages.routes.js +55 -0
- package/src/routes/adminProxy.routes.js +15 -0
- package/src/routes/adminRateLimits.routes.js +17 -0
- package/src/routes/adminRbac.routes.js +38 -0
- package/src/routes/adminScripts.routes.js +21 -0
- package/src/routes/adminSeoConfig.routes.js +5 -4
- package/src/routes/adminTerminals.routes.js +13 -0
- package/src/routes/adminUiComponents.routes.js +30 -0
- package/src/routes/blogInternal.routes.js +14 -0
- package/src/routes/blogPublic.routes.js +9 -0
- package/src/routes/fileManager.routes.js +62 -0
- package/src/routes/fileManagerStoragePolicy.routes.js +9 -0
- package/src/routes/healthChecksPublic.routes.js +9 -0
- package/src/routes/log.routes.js +43 -60
- package/src/routes/metrics.routes.js +4 -2
- package/src/routes/orgAdmin.routes.js +6 -0
- package/src/routes/pages.routes.js +123 -0
- package/src/routes/proxy.routes.js +46 -0
- package/src/routes/rbac.routes.js +47 -0
- package/src/routes/uiComponentsPublic.routes.js +9 -0
- package/src/routes/webhook.routes.js +2 -1
- package/src/routes/workflows.routes.js +4 -0
- package/src/services/blockDefinitionsAi.service.js +247 -0
- package/src/services/blog.service.js +99 -0
- package/src/services/blogAutomation.service.js +978 -0
- package/src/services/blogCronsBootstrap.service.js +184 -0
- package/src/services/blogPublishing.service.js +58 -0
- package/src/services/cacheLayer.service.js +696 -0
- package/src/services/consoleManager.service.js +700 -0
- package/src/services/consoleOverride.service.js +6 -1
- package/src/services/cronScheduler.service.js +350 -0
- package/src/services/dbBrowser.service.js +536 -0
- package/src/services/ejsVirtual.service.js +102 -32
- package/src/services/fileManager.service.js +475 -0
- package/src/services/fileManagerStoragePolicy.service.js +285 -0
- package/src/services/headlessExternalModels.service.js +292 -0
- package/src/services/headlessModels.service.js +26 -6
- package/src/services/healthChecks.service.js +650 -0
- package/src/services/healthChecksBootstrap.service.js +109 -0
- package/src/services/healthChecksScheduler.service.js +106 -0
- package/src/services/llmDefaults.service.js +190 -0
- package/src/services/migrationAssets/s3.js +2 -2
- package/src/services/pages.service.js +602 -0
- package/src/services/pagesContext.service.js +331 -0
- package/src/services/pagesContextBlocksAi.service.js +349 -0
- package/src/services/proxy.service.js +535 -0
- package/src/services/rateLimiter.service.js +623 -0
- package/src/services/rbac.service.js +212 -0
- package/src/services/scriptsRunner.service.js +259 -0
- package/src/services/terminals.service.js +152 -0
- package/src/services/terminalsWs.service.js +100 -0
- package/src/services/uiComponentsAi.service.js +299 -0
- package/src/services/uiComponentsCrypto.service.js +39 -0
- package/src/services/workflow.service.js +23 -8
- package/src/utils/orgRoles.js +14 -0
- package/src/utils/rbac/engine.js +60 -0
- package/src/utils/rbac/rightsRegistry.js +29 -0
- package/views/admin-blog-automation.ejs +877 -0
- package/views/admin-blog-edit.ejs +542 -0
- package/views/admin-blog.ejs +399 -0
- package/views/admin-cache.ejs +681 -0
- package/views/admin-console-manager.ejs +680 -0
- package/views/admin-crons.ejs +645 -0
- package/views/admin-db-browser.ejs +445 -0
- package/views/admin-ejs-virtual.ejs +16 -10
- package/views/admin-file-manager.ejs +942 -0
- package/views/admin-headless.ejs +294 -24
- package/views/admin-health-checks.ejs +725 -0
- package/views/admin-i18n.ejs +59 -5
- package/views/admin-llm.ejs +99 -1
- package/views/admin-organizations.ejs +528 -10
- package/views/admin-pages.ejs +2424 -0
- package/views/admin-proxy.ejs +491 -0
- package/views/admin-rate-limiter.ejs +625 -0
- package/views/admin-rbac.ejs +1331 -0
- package/views/admin-scripts.ejs +497 -0
- package/views/admin-seo-config.ejs +61 -7
- package/views/admin-terminals.ejs +328 -0
- package/views/admin-ui-components.ejs +741 -0
- package/views/admin-users.ejs +261 -4
- package/views/admin-workflows.ejs +7 -7
- package/views/file-manager.ejs +866 -0
- package/views/pages/blocks/contact.ejs +27 -0
- package/views/pages/blocks/cta.ejs +18 -0
- package/views/pages/blocks/faq.ejs +20 -0
- package/views/pages/blocks/features.ejs +19 -0
- package/views/pages/blocks/hero.ejs +13 -0
- package/views/pages/blocks/html.ejs +5 -0
- package/views/pages/blocks/image.ejs +14 -0
- package/views/pages/blocks/testimonials.ejs +26 -0
- package/views/pages/blocks/text.ejs +10 -0
- package/views/pages/layouts/default.ejs +51 -0
- package/views/pages/layouts/minimal.ejs +42 -0
- package/views/pages/layouts/sidebar.ejs +54 -0
- package/views/pages/partials/footer.ejs +13 -0
- package/views/pages/partials/header.ejs +12 -0
- package/views/pages/partials/sidebar.ejs +8 -0
- package/views/pages/runtime/page.ejs +10 -0
- package/views/pages/templates/article.ejs +20 -0
- package/views/pages/templates/default.ejs +12 -0
- package/views/pages/templates/landing.ejs +14 -0
- package/views/pages/templates/listing.ejs +15 -0
- package/views/partials/admin-image-upload-modal.ejs +221 -0
- package/views/partials/dashboard/nav-items.ejs +14 -0
- package/views/partials/llm-provider-model-picker.ejs +183 -0
|
@@ -0,0 +1,978 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
const { marked } = require('marked');
|
|
3
|
+
|
|
4
|
+
const BlogPost = require('../models/BlogPost');
|
|
5
|
+
const BlogAutomationRun = require('../models/BlogAutomationRun');
|
|
6
|
+
const BlogAutomationLock = require('../models/BlogAutomationLock');
|
|
7
|
+
const llmService = require('./llm.service');
|
|
8
|
+
const GlobalSetting = require('../models/GlobalSetting');
|
|
9
|
+
const globalSettingsService = require('./globalSettings.service');
|
|
10
|
+
const objectStorage = require('./objectStorage.service');
|
|
11
|
+
const uploadNamespacesService = require('./uploadNamespaces.service');
|
|
12
|
+
const Asset = require('../models/Asset');
|
|
13
|
+
|
|
14
|
+
const {
|
|
15
|
+
extractExcerptFromMarkdown,
|
|
16
|
+
generateUniqueBlogSlug,
|
|
17
|
+
normalizeTags,
|
|
18
|
+
} = require('./blog.service');
|
|
19
|
+
|
|
20
|
+
const BLOG_AUTOMATION_CONFIG_KEY = 'blog.automation.config';
|
|
21
|
+
const BLOG_AUTOMATION_CONFIGS_KEY = 'blog.automation.configs';
|
|
22
|
+
const BLOG_AUTOMATION_STYLE_GUIDE_KEY = 'blog.automation.styleGuide';
|
|
23
|
+
|
|
24
|
+
function defaultBlogAutomationConfig() {
|
|
25
|
+
return {
|
|
26
|
+
enabled: false,
|
|
27
|
+
runsPerDayLimit: 1,
|
|
28
|
+
maxPostsPerRun: 1,
|
|
29
|
+
dedupeWindowDays: 30,
|
|
30
|
+
citations: { enabled: true, format: 'bullets' },
|
|
31
|
+
topics: [
|
|
32
|
+
{ key: 'operations', label: 'Operations', weight: 4, keywords: [] },
|
|
33
|
+
{ key: 'micro-exits', label: 'Micro exits', weight: 4, keywords: [] },
|
|
34
|
+
{ key: 'saas', label: 'SaaS', weight: 3, keywords: [] },
|
|
35
|
+
],
|
|
36
|
+
research: {
|
|
37
|
+
providerKey: 'Perplexity',
|
|
38
|
+
model: 'sonar',
|
|
39
|
+
temperature: 0.2,
|
|
40
|
+
maxTokens: 900,
|
|
41
|
+
},
|
|
42
|
+
generation: {
|
|
43
|
+
providerKey: 'OpenRouter',
|
|
44
|
+
model: 'google/gemini-2.5-flash-lite',
|
|
45
|
+
temperature: 0.6,
|
|
46
|
+
maxTokens: 2800,
|
|
47
|
+
},
|
|
48
|
+
textGeneration: {
|
|
49
|
+
providerKey: 'OpenRouter',
|
|
50
|
+
model: 'google/gemini-2.5-flash-lite',
|
|
51
|
+
temperature: 0.6,
|
|
52
|
+
maxTokens: 2800,
|
|
53
|
+
},
|
|
54
|
+
imageGeneration: {
|
|
55
|
+
providerKey: 'OpenRouter',
|
|
56
|
+
model: 'google/gemini-2.5-flash-image',
|
|
57
|
+
},
|
|
58
|
+
images: {
|
|
59
|
+
enabled: false,
|
|
60
|
+
maxImagesTotal: 2,
|
|
61
|
+
assetNamespace: 'blog-images',
|
|
62
|
+
assetVisibility: 'public',
|
|
63
|
+
promptExtraInstruction: '',
|
|
64
|
+
cover: {
|
|
65
|
+
enabled: false,
|
|
66
|
+
providerKey: 'OpenRouter',
|
|
67
|
+
model: 'google/gemini-2.5-flash-image',
|
|
68
|
+
},
|
|
69
|
+
inline: {
|
|
70
|
+
enabled: false,
|
|
71
|
+
providerKey: 'OpenRouter',
|
|
72
|
+
model: 'google/gemini-2.5-flash-image',
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
dryRun: false,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function previewPromptsByConfigId(configId) {
|
|
80
|
+
const cfg = await getBlogAutomationConfigById(configId);
|
|
81
|
+
const styleGuide = await getEffectiveStyleGuideForConfig(cfg);
|
|
82
|
+
const topic = Array.isArray(cfg.topics) && cfg.topics.length ? cfg.topics[0] : { key: 'topic', label: 'Topic' };
|
|
83
|
+
const idea = { angle: `Example angle about ${topic.label}`, searchQuery: `Example query about ${topic.label}`, audience: 'operators' };
|
|
84
|
+
const research = { summary: 'Example research summary', keyPoints: [], sources: [] };
|
|
85
|
+
const ctx = { theme: topic, idea, research };
|
|
86
|
+
const citationsEnabled = Boolean(cfg?.citations?.enabled);
|
|
87
|
+
const title = 'Example blog post title';
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
postPrompt: buildPostPrompt({ styleGuide, ctx, citationsEnabled }),
|
|
91
|
+
imageCoverPrompt: buildImagePrompt({ kind: 'cover', title, extraInstruction: cfg?.images?.promptExtraInstruction }),
|
|
92
|
+
imageInlinePrompt: buildImagePrompt({ kind: 'inline', title, extraInstruction: cfg?.images?.promptExtraInstruction }),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function defaultBlogAutomationConfigs() {
|
|
97
|
+
const base = defaultBlogAutomationConfig();
|
|
98
|
+
return {
|
|
99
|
+
version: 1,
|
|
100
|
+
items: [
|
|
101
|
+
{
|
|
102
|
+
id: crypto.randomUUID(),
|
|
103
|
+
name: 'Default',
|
|
104
|
+
schedule: {
|
|
105
|
+
managedBy: 'cronScheduler',
|
|
106
|
+
cronExpression: '0 9 * * 2,4',
|
|
107
|
+
timezone: 'UTC',
|
|
108
|
+
},
|
|
109
|
+
styleGuideOverride: '',
|
|
110
|
+
...base,
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function defaultBlogAutomationStyleGuide() {
|
|
117
|
+
return (
|
|
118
|
+
'You are writing for superbackend blog readers.\n' +
|
|
119
|
+
'Tone: practical, clear, direct. Avoid fluff.\n' +
|
|
120
|
+
'Structure: short paragraphs, concrete steps, examples, checklists where helpful.\n' +
|
|
121
|
+
'Include a short "Sources" section at the end when citations are enabled.'
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function ensureSettingExists({ key, type, description, defaultValue }) {
|
|
126
|
+
const existing = await GlobalSetting.findOne({ key }).lean();
|
|
127
|
+
if (existing) return;
|
|
128
|
+
await GlobalSetting.create({
|
|
129
|
+
key,
|
|
130
|
+
type,
|
|
131
|
+
description,
|
|
132
|
+
value: type === 'json' ? JSON.stringify(defaultValue) : String(defaultValue ?? ''),
|
|
133
|
+
templateVariables: [],
|
|
134
|
+
public: false,
|
|
135
|
+
});
|
|
136
|
+
globalSettingsService.clearSettingsCache();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function normalizeAutomationConfigForSave(cfg) {
|
|
140
|
+
const base = defaultBlogAutomationConfig();
|
|
141
|
+
const merged = { ...base, ...(cfg || {}) };
|
|
142
|
+
merged.enabled = Boolean(merged.enabled);
|
|
143
|
+
merged.runsPerDayLimit = Math.max(0, Number(merged.runsPerDayLimit || 0) || 0);
|
|
144
|
+
merged.maxPostsPerRun = Math.max(1, Number(merged.maxPostsPerRun || 1) || 1);
|
|
145
|
+
merged.dedupeWindowDays = Math.max(0, Number(merged.dedupeWindowDays || 0) || 0);
|
|
146
|
+
if (!Array.isArray(merged.topics)) merged.topics = base.topics;
|
|
147
|
+
if (!merged.citations) merged.citations = { enabled: true, format: 'bullets' };
|
|
148
|
+
if (!merged.images) merged.images = base.images;
|
|
149
|
+
|
|
150
|
+
// Backward compatibility: treat legacy `generation` as the source of truth if `textGeneration` is not present.
|
|
151
|
+
if (!merged.textGeneration && merged.generation) {
|
|
152
|
+
merged.textGeneration = merged.generation;
|
|
153
|
+
}
|
|
154
|
+
if (merged.textGeneration && !merged.generation) {
|
|
155
|
+
merged.generation = merged.textGeneration;
|
|
156
|
+
}
|
|
157
|
+
if (!merged.imageGeneration) {
|
|
158
|
+
merged.imageGeneration = base.imageGeneration;
|
|
159
|
+
}
|
|
160
|
+
if (merged.images && typeof merged.images === 'object') {
|
|
161
|
+
merged.images.promptExtraInstruction = String(merged.images.promptExtraInstruction || '').trim();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return merged;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function buildPostPrompt({ styleGuide, ctx, citationsEnabled }) {
|
|
168
|
+
return (
|
|
169
|
+
'Write a blog post based on the research and constraints below.\n' +
|
|
170
|
+
'Return JSON with keys: title, excerpt, category, tags(array), seoTitle, seoDescription, markdown.\n' +
|
|
171
|
+
'Ensure markdown is complete and publish-ready.\n' +
|
|
172
|
+
(citationsEnabled
|
|
173
|
+
? "Include a 'Sources' section at the end with bullet links based on sources[].\n"
|
|
174
|
+
: '') +
|
|
175
|
+
'\nStyle guide:\n' +
|
|
176
|
+
String(styleGuide || '') +
|
|
177
|
+
'\n\nContext (JSON):\n' +
|
|
178
|
+
JSON.stringify(ctx || {}, null, 2)
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function buildImagePrompt({ kind, title, extraInstruction }) {
|
|
183
|
+
const base =
|
|
184
|
+
kind === 'cover'
|
|
185
|
+
? `Generate a clean cover image (no text) for a blog post about: ${title}.`
|
|
186
|
+
: `Generate a single inline illustrative image (no text) to complement the blog post: ${title}.`;
|
|
187
|
+
const extra = String(extraInstruction || '').trim();
|
|
188
|
+
if (!extra) return base;
|
|
189
|
+
return base + '\n\nExtra instructions:\n' + extra;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function normalizeAutomationConfigItemForSave(item) {
|
|
193
|
+
const base = defaultBlogAutomationConfig();
|
|
194
|
+
const raw = item && typeof item === 'object' ? item : {};
|
|
195
|
+
const id = String(raw.id || '').trim() || crypto.randomUUID();
|
|
196
|
+
const name = String(raw.name || '').trim() || 'Untitled';
|
|
197
|
+
const scheduleRaw = raw.schedule && typeof raw.schedule === 'object' ? raw.schedule : {};
|
|
198
|
+
const schedule = {
|
|
199
|
+
managedBy: scheduleRaw.managedBy === 'manualOnly' ? 'manualOnly' : 'cronScheduler',
|
|
200
|
+
cronExpression: String(scheduleRaw.cronExpression || '').trim() || '0 9 * * 2,4',
|
|
201
|
+
timezone: String(scheduleRaw.timezone || '').trim() || 'UTC',
|
|
202
|
+
};
|
|
203
|
+
const styleGuideOverride = String(raw.styleGuideOverride || '').trim();
|
|
204
|
+
|
|
205
|
+
const merged = normalizeAutomationConfigForSave(raw);
|
|
206
|
+
return {
|
|
207
|
+
id,
|
|
208
|
+
name,
|
|
209
|
+
schedule,
|
|
210
|
+
styleGuideOverride,
|
|
211
|
+
...merged,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function getBlogAutomationConfigs() {
|
|
216
|
+
// Ensure legacy exists to allow migration if needed
|
|
217
|
+
await ensureSettingExists({
|
|
218
|
+
key: BLOG_AUTOMATION_CONFIG_KEY,
|
|
219
|
+
type: 'json',
|
|
220
|
+
description: 'Blog automation configuration (JSON)',
|
|
221
|
+
defaultValue: defaultBlogAutomationConfig(),
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
await ensureSettingExists({
|
|
225
|
+
key: BLOG_AUTOMATION_CONFIGS_KEY,
|
|
226
|
+
type: 'json',
|
|
227
|
+
description: 'Blog automation configurations (JSON)',
|
|
228
|
+
defaultValue: defaultBlogAutomationConfigs(),
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const raw = await globalSettingsService.getSettingValue(
|
|
232
|
+
BLOG_AUTOMATION_CONFIGS_KEY,
|
|
233
|
+
JSON.stringify(defaultBlogAutomationConfigs()),
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
let parsed;
|
|
237
|
+
try {
|
|
238
|
+
parsed = JSON.parse(raw);
|
|
239
|
+
} catch {
|
|
240
|
+
parsed = null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const items = Array.isArray(parsed?.items) ? parsed.items : null;
|
|
244
|
+
if (items && items.length) {
|
|
245
|
+
return {
|
|
246
|
+
version: Number(parsed?.version || 1) || 1,
|
|
247
|
+
items: items.map(normalizeAutomationConfigItemForSave),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Migrate from legacy single config if configs are empty/invalid
|
|
252
|
+
const legacyRaw = await globalSettingsService.getSettingValue(
|
|
253
|
+
BLOG_AUTOMATION_CONFIG_KEY,
|
|
254
|
+
JSON.stringify(defaultBlogAutomationConfig()),
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
let legacy;
|
|
258
|
+
try {
|
|
259
|
+
legacy = JSON.parse(legacyRaw);
|
|
260
|
+
} catch {
|
|
261
|
+
legacy = {};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const migrated = {
|
|
265
|
+
version: 1,
|
|
266
|
+
items: [
|
|
267
|
+
normalizeAutomationConfigItemForSave({
|
|
268
|
+
id: crypto.randomUUID(),
|
|
269
|
+
name: 'Default',
|
|
270
|
+
schedule: {
|
|
271
|
+
managedBy: 'cronScheduler',
|
|
272
|
+
cronExpression: '0 9 * * 2,4',
|
|
273
|
+
timezone: 'UTC',
|
|
274
|
+
},
|
|
275
|
+
styleGuideOverride: '',
|
|
276
|
+
...normalizeAutomationConfigForSave(legacy),
|
|
277
|
+
}),
|
|
278
|
+
],
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const doc = await GlobalSetting.findOne({ key: BLOG_AUTOMATION_CONFIGS_KEY });
|
|
282
|
+
doc.type = 'json';
|
|
283
|
+
doc.value = JSON.stringify(migrated);
|
|
284
|
+
if (!doc.description) doc.description = 'Blog automation configurations (JSON)';
|
|
285
|
+
await doc.save();
|
|
286
|
+
globalSettingsService.clearSettingsCache();
|
|
287
|
+
|
|
288
|
+
return migrated;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Legacy single-config getter (kept for backward compatibility and migration support).
|
|
292
|
+
// Multi-config (`blog.automation.configs`) is the primary source of truth.
|
|
293
|
+
async function getBlogAutomationConfig() {
|
|
294
|
+
await ensureSettingExists({
|
|
295
|
+
key: BLOG_AUTOMATION_CONFIG_KEY,
|
|
296
|
+
type: 'json',
|
|
297
|
+
description: 'Blog automation configuration (JSON)',
|
|
298
|
+
defaultValue: defaultBlogAutomationConfig(),
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
const raw = await globalSettingsService.getSettingValue(
|
|
302
|
+
BLOG_AUTOMATION_CONFIG_KEY,
|
|
303
|
+
JSON.stringify(defaultBlogAutomationConfig()),
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
let parsed;
|
|
307
|
+
try {
|
|
308
|
+
parsed = JSON.parse(raw);
|
|
309
|
+
} catch {
|
|
310
|
+
parsed = {};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return normalizeAutomationConfigForSave(parsed || {});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function getBlogAutomationConfigById(configId) {
|
|
317
|
+
const id = String(configId || '').trim();
|
|
318
|
+
if (!id) {
|
|
319
|
+
const err = new Error('configId is required');
|
|
320
|
+
err.statusCode = 400;
|
|
321
|
+
throw err;
|
|
322
|
+
}
|
|
323
|
+
const { items } = await getBlogAutomationConfigs();
|
|
324
|
+
const found = items.find((i) => String(i.id) === id);
|
|
325
|
+
if (!found) {
|
|
326
|
+
const err = new Error('Config not found');
|
|
327
|
+
err.statusCode = 404;
|
|
328
|
+
throw err;
|
|
329
|
+
}
|
|
330
|
+
return found;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function getBlogAutomationStyleGuide() {
|
|
334
|
+
await ensureSettingExists({
|
|
335
|
+
key: BLOG_AUTOMATION_STYLE_GUIDE_KEY,
|
|
336
|
+
type: 'string',
|
|
337
|
+
description: 'Blog automation writing style guide',
|
|
338
|
+
defaultValue: defaultBlogAutomationStyleGuide(),
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const raw = await globalSettingsService.getSettingValue(
|
|
342
|
+
BLOG_AUTOMATION_STYLE_GUIDE_KEY,
|
|
343
|
+
defaultBlogAutomationStyleGuide(),
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
return String(raw ?? '');
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function getEffectiveStyleGuideForConfig(config) {
|
|
350
|
+
const globalGuide = await getBlogAutomationStyleGuide();
|
|
351
|
+
const override = String(config?.styleGuideOverride || '').trim();
|
|
352
|
+
return override ? override : globalGuide;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function safeJsonParseLoose(text) {
|
|
356
|
+
const raw = String(text || '').trim();
|
|
357
|
+
if (!raw) return null;
|
|
358
|
+
let cleaned = raw;
|
|
359
|
+
if (cleaned.startsWith('```')) {
|
|
360
|
+
cleaned = cleaned.replace(/^```[a-zA-Z]*\s*/m, '').replace(/```\s*$/m, '').trim();
|
|
361
|
+
}
|
|
362
|
+
const firstObj = cleaned.indexOf('{');
|
|
363
|
+
const lastObj = cleaned.lastIndexOf('}');
|
|
364
|
+
if (firstObj !== -1 && lastObj !== -1 && lastObj > firstObj) {
|
|
365
|
+
cleaned = cleaned.slice(firstObj, lastObj + 1);
|
|
366
|
+
}
|
|
367
|
+
try {
|
|
368
|
+
return JSON.parse(cleaned);
|
|
369
|
+
} catch {
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function pickWeightedTopic(topics) {
|
|
375
|
+
const usable = Array.isArray(topics) ? topics.filter((t) => t && t.key) : [];
|
|
376
|
+
if (!usable.length) return { key: 'general', label: 'General', weight: 1 };
|
|
377
|
+
|
|
378
|
+
const weights = usable.map((t) => Math.max(0, Number(t.weight || 0) || 0));
|
|
379
|
+
const sum = weights.reduce((a, b) => a + b, 0);
|
|
380
|
+
if (!sum) return usable[0];
|
|
381
|
+
|
|
382
|
+
let r = Math.random() * sum;
|
|
383
|
+
for (let i = 0; i < usable.length; i++) {
|
|
384
|
+
r -= weights[i];
|
|
385
|
+
if (r <= 0) return usable[i];
|
|
386
|
+
}
|
|
387
|
+
return usable[usable.length - 1];
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function acquireLock({ ttlMs = 15 * 60 * 1000 } = {}) {
|
|
391
|
+
const now = new Date();
|
|
392
|
+
const ownerId = crypto.randomUUID();
|
|
393
|
+
const lockedUntil = new Date(Date.now() + ttlMs);
|
|
394
|
+
const key = 'blog-automation';
|
|
395
|
+
|
|
396
|
+
const doc = await BlogAutomationLock.findOneAndUpdate(
|
|
397
|
+
{
|
|
398
|
+
key,
|
|
399
|
+
$or: [{ lockedUntil: { $lte: now } }, { lockedUntil: { $exists: false } }],
|
|
400
|
+
},
|
|
401
|
+
{ $set: { key, lockedUntil, ownerId } },
|
|
402
|
+
{ upsert: true, new: true },
|
|
403
|
+
).catch(() => null);
|
|
404
|
+
|
|
405
|
+
if (!doc) return null;
|
|
406
|
+
if (String(doc.ownerId) !== String(ownerId)) return null;
|
|
407
|
+
return doc;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async function releaseLock(lock) {
|
|
411
|
+
if (!lock?.key || !lock?.ownerId) return;
|
|
412
|
+
await BlogAutomationLock.deleteOne({ key: lock.key, ownerId: lock.ownerId }).catch(() => {});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function shouldSkipScheduledRun({ runsPerDayLimit }) {
|
|
416
|
+
const limit = Number(runsPerDayLimit || 0);
|
|
417
|
+
if (!Number.isFinite(limit) || limit <= 0) return false;
|
|
418
|
+
|
|
419
|
+
const start = new Date();
|
|
420
|
+
start.setUTCHours(0, 0, 0, 0);
|
|
421
|
+
const end = new Date();
|
|
422
|
+
end.setUTCHours(23, 59, 59, 999);
|
|
423
|
+
|
|
424
|
+
const count = await BlogAutomationRun.countDocuments({
|
|
425
|
+
trigger: 'scheduled',
|
|
426
|
+
createdAt: { $gte: start, $lte: end },
|
|
427
|
+
status: { $ne: 'skipped' },
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
return count >= limit;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async function uploadBufferAsPublicAsset({ buffer, contentType, originalName, namespace, visibility }) {
|
|
434
|
+
const namespaceConfig = await uploadNamespacesService.resolveNamespace(namespace);
|
|
435
|
+
const hardCap = await uploadNamespacesService.getEffectiveHardCapMaxFileSizeBytes();
|
|
436
|
+
|
|
437
|
+
const validation = uploadNamespacesService.validateUpload({
|
|
438
|
+
namespaceConfig,
|
|
439
|
+
contentType,
|
|
440
|
+
sizeBytes: buffer.length,
|
|
441
|
+
hardCapMaxFileSizeBytes: hardCap,
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
if (!validation.ok) {
|
|
445
|
+
const err = new Error('Upload rejected by namespace policy');
|
|
446
|
+
err.meta = { validation };
|
|
447
|
+
throw err;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const key = uploadNamespacesService.generateObjectKey({ namespaceConfig, originalName });
|
|
451
|
+
const computedVisibility = uploadNamespacesService.computeVisibility({
|
|
452
|
+
namespaceConfig,
|
|
453
|
+
requestedVisibility: visibility,
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
const { provider, bucket } = await objectStorage.putObject({
|
|
457
|
+
key,
|
|
458
|
+
body: buffer,
|
|
459
|
+
contentType,
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
const asset = await Asset.create({
|
|
463
|
+
key,
|
|
464
|
+
provider,
|
|
465
|
+
bucket,
|
|
466
|
+
originalName,
|
|
467
|
+
contentType,
|
|
468
|
+
sizeBytes: buffer.length,
|
|
469
|
+
visibility: computedVisibility,
|
|
470
|
+
namespace: namespaceConfig.key,
|
|
471
|
+
visibilityEnforced: Boolean(namespaceConfig.enforceVisibility),
|
|
472
|
+
ownerUserId: null,
|
|
473
|
+
orgId: null,
|
|
474
|
+
status: 'uploaded',
|
|
475
|
+
tags: [],
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
const publicUrl = computedVisibility === 'public' ? `/public/assets/${asset.key}` : null;
|
|
479
|
+
|
|
480
|
+
return { asset: asset.toObject(), publicUrl };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async function runBlogAutomation({ trigger, configId }) {
|
|
484
|
+
const cfg = await getBlogAutomationConfigById(configId);
|
|
485
|
+
const styleGuide = await getEffectiveStyleGuideForConfig(cfg);
|
|
486
|
+
|
|
487
|
+
if (!cfg.enabled) {
|
|
488
|
+
const run = await BlogAutomationRun.create({
|
|
489
|
+
trigger,
|
|
490
|
+
status: 'skipped',
|
|
491
|
+
configId: String(cfg.id),
|
|
492
|
+
configSnapshot: cfg,
|
|
493
|
+
error: 'Blog automation is disabled',
|
|
494
|
+
});
|
|
495
|
+
return run.toObject();
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (trigger === 'scheduled') {
|
|
499
|
+
const skip = await shouldSkipScheduledRun({ runsPerDayLimit: cfg.runsPerDayLimit });
|
|
500
|
+
if (skip) {
|
|
501
|
+
const run = await BlogAutomationRun.create({
|
|
502
|
+
trigger,
|
|
503
|
+
status: 'skipped',
|
|
504
|
+
configId: String(cfg.id),
|
|
505
|
+
configSnapshot: cfg,
|
|
506
|
+
error: 'runsPerDayLimit reached',
|
|
507
|
+
});
|
|
508
|
+
return run.toObject();
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const lock = await acquireLock();
|
|
513
|
+
if (!lock) {
|
|
514
|
+
const run = await BlogAutomationRun.create({
|
|
515
|
+
trigger,
|
|
516
|
+
status: 'skipped',
|
|
517
|
+
configId: String(cfg.id),
|
|
518
|
+
configSnapshot: cfg,
|
|
519
|
+
error: 'Another run is already in progress',
|
|
520
|
+
});
|
|
521
|
+
return run.toObject();
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const run = await BlogAutomationRun.create({
|
|
525
|
+
trigger,
|
|
526
|
+
status: 'running',
|
|
527
|
+
startedAt: new Date(),
|
|
528
|
+
configId: String(cfg.id),
|
|
529
|
+
configSnapshot: cfg,
|
|
530
|
+
steps: [],
|
|
531
|
+
results: {},
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
try {
|
|
535
|
+
const topic = pickWeightedTopic(cfg.topics);
|
|
536
|
+
run.topic = { topicKey: topic.key, topicLabel: topic.label };
|
|
537
|
+
run.steps.push({ step: 'topic', at: new Date().toISOString(), topic });
|
|
538
|
+
|
|
539
|
+
const citationsEnabled = Boolean(cfg?.citations?.enabled);
|
|
540
|
+
|
|
541
|
+
// Step 1: idea generation (text generation provider)
|
|
542
|
+
const ideaResp = await llmService.callAdhoc(
|
|
543
|
+
{
|
|
544
|
+
providerKey: cfg.textGeneration.providerKey,
|
|
545
|
+
model: cfg.textGeneration.model,
|
|
546
|
+
promptKeyForAudit: 'blog.automation.idea',
|
|
547
|
+
messages: [
|
|
548
|
+
{
|
|
549
|
+
role: 'system',
|
|
550
|
+
content:
|
|
551
|
+
'Return ONLY valid JSON (no markdown fences). You are creating a blog post idea.',
|
|
552
|
+
},
|
|
553
|
+
{
|
|
554
|
+
role: 'user',
|
|
555
|
+
content:
|
|
556
|
+
'Given the theme below, propose a specific article angle and a single web research query.\n' +
|
|
557
|
+
'Return JSON with keys: angle, searchQuery, audience.\n\n' +
|
|
558
|
+
`Theme: ${topic.label} (${topic.key})`,
|
|
559
|
+
},
|
|
560
|
+
],
|
|
561
|
+
},
|
|
562
|
+
{ temperature: 0.6, max_tokens: 500 },
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
const idea =
|
|
566
|
+
safeJsonParseLoose(ideaResp.content) ||
|
|
567
|
+
{
|
|
568
|
+
angle: `Practical lessons about ${topic.label}`,
|
|
569
|
+
searchQuery: `latest insights about ${topic.label} for operators`,
|
|
570
|
+
audience: 'operators',
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
run.steps.push({ step: 'idea', at: new Date().toISOString(), idea });
|
|
574
|
+
|
|
575
|
+
// Step 2: research (research provider)
|
|
576
|
+
const researchResp = await llmService.callAdhoc(
|
|
577
|
+
{
|
|
578
|
+
providerKey: cfg.research.providerKey,
|
|
579
|
+
model: cfg.research.model,
|
|
580
|
+
promptKeyForAudit: 'blog.automation.research',
|
|
581
|
+
messages: [
|
|
582
|
+
{
|
|
583
|
+
role: 'system',
|
|
584
|
+
content:
|
|
585
|
+
'Perform web research and return ONLY valid JSON (no markdown fences). Include citations as sources[].',
|
|
586
|
+
},
|
|
587
|
+
{
|
|
588
|
+
role: 'user',
|
|
589
|
+
content:
|
|
590
|
+
'Collect up-to-date information for the following query and return structured research.\n' +
|
|
591
|
+
'Return JSON with keys: summary, keyPoints[], sources[] where sources items include title,url,snippet.\n\n' +
|
|
592
|
+
`Query: ${idea.searchQuery}`,
|
|
593
|
+
},
|
|
594
|
+
],
|
|
595
|
+
},
|
|
596
|
+
{ temperature: cfg.research.temperature, max_tokens: cfg.research.maxTokens },
|
|
597
|
+
);
|
|
598
|
+
|
|
599
|
+
const research =
|
|
600
|
+
safeJsonParseLoose(researchResp.content) ||
|
|
601
|
+
{
|
|
602
|
+
summary: String(researchResp.content || ''),
|
|
603
|
+
keyPoints: [],
|
|
604
|
+
sources: [],
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
run.steps.push({ step: 'research', at: new Date().toISOString(), research });
|
|
608
|
+
|
|
609
|
+
// Step 3: generate post
|
|
610
|
+
const ctx = { theme: topic, idea, research };
|
|
611
|
+
const basePostPrompt = buildPostPrompt({ styleGuide, ctx, citationsEnabled });
|
|
612
|
+
|
|
613
|
+
const postResp = await llmService.callAdhoc(
|
|
614
|
+
{
|
|
615
|
+
providerKey: cfg.textGeneration.providerKey,
|
|
616
|
+
model: cfg.textGeneration.model,
|
|
617
|
+
promptKeyForAudit: 'blog.automation.generate_post',
|
|
618
|
+
messages: [
|
|
619
|
+
{ role: 'system', content: 'Return ONLY valid JSON (no markdown fences).' },
|
|
620
|
+
{ role: 'user', content: basePostPrompt },
|
|
621
|
+
],
|
|
622
|
+
},
|
|
623
|
+
{ temperature: cfg.textGeneration.temperature, max_tokens: cfg.textGeneration.maxTokens },
|
|
624
|
+
);
|
|
625
|
+
|
|
626
|
+
let postJson = safeJsonParseLoose(postResp.content);
|
|
627
|
+
let usedFallback = false;
|
|
628
|
+
|
|
629
|
+
if (!postJson || !postJson.markdown || !postJson.title) {
|
|
630
|
+
// Fallback: markdown-only
|
|
631
|
+
const mdOnly = await llmService.callAdhoc(
|
|
632
|
+
{
|
|
633
|
+
providerKey: cfg.textGeneration.providerKey,
|
|
634
|
+
model: cfg.textGeneration.model,
|
|
635
|
+
promptKeyForAudit: 'blog.automation.generate_markdown_only',
|
|
636
|
+
messages: [
|
|
637
|
+
{
|
|
638
|
+
role: 'system',
|
|
639
|
+
content: 'Return ONLY markdown. Do not wrap in code fences.',
|
|
640
|
+
},
|
|
641
|
+
{
|
|
642
|
+
role: 'user',
|
|
643
|
+
content:
|
|
644
|
+
'Write the full blog post in markdown.\n' +
|
|
645
|
+
(citationsEnabled
|
|
646
|
+
? "Include a 'Sources' section at the end with bullet links based on sources[].\n"
|
|
647
|
+
: '') +
|
|
648
|
+
'\nStyle guide:\n' +
|
|
649
|
+
styleGuide +
|
|
650
|
+
'\n\nContext (JSON):\n' +
|
|
651
|
+
JSON.stringify(ctx, null, 2),
|
|
652
|
+
},
|
|
653
|
+
],
|
|
654
|
+
},
|
|
655
|
+
{ temperature: cfg.textGeneration.temperature, max_tokens: cfg.textGeneration.maxTokens },
|
|
656
|
+
);
|
|
657
|
+
|
|
658
|
+
const fallbackMarkdown = String(mdOnly.content || '').trim();
|
|
659
|
+
if (!fallbackMarkdown) throw new Error('LLM returned invalid blog post');
|
|
660
|
+
|
|
661
|
+
postJson = {
|
|
662
|
+
title: String(topic.label || 'Blog post'),
|
|
663
|
+
excerpt: '',
|
|
664
|
+
category: String(topic.label || ''),
|
|
665
|
+
tags: [String(topic.key || '')].filter(Boolean),
|
|
666
|
+
seoTitle: '',
|
|
667
|
+
seoDescription: '',
|
|
668
|
+
markdown: fallbackMarkdown,
|
|
669
|
+
};
|
|
670
|
+
usedFallback = true;
|
|
671
|
+
run.steps.push({ step: 'generation_fallback_markdown', at: new Date().toISOString() });
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
let markdown = String(postJson.markdown || '');
|
|
675
|
+
const title = String(postJson.title || '').trim();
|
|
676
|
+
const excerpt = String(postJson.excerpt || '').trim();
|
|
677
|
+
const category = String(postJson.category || '').trim();
|
|
678
|
+
const tags = normalizeTags(postJson.tags);
|
|
679
|
+
const seoTitle = String(postJson.seoTitle || '').trim();
|
|
680
|
+
const seoDescription = String(postJson.seoDescription || '').trim();
|
|
681
|
+
|
|
682
|
+
// Optional images
|
|
683
|
+
let coverImageUrl = '';
|
|
684
|
+
const createdAssetIds = [];
|
|
685
|
+
const imageErrors = [];
|
|
686
|
+
let hadImageError = false;
|
|
687
|
+
|
|
688
|
+
if (cfg?.images?.enabled) {
|
|
689
|
+
const namespace = String(cfg.images.assetNamespace || 'blog-images').trim();
|
|
690
|
+
const visibility = String(cfg.images.assetVisibility || 'public').trim();
|
|
691
|
+
|
|
692
|
+
const coverEnabled = Boolean(cfg?.images?.cover?.enabled);
|
|
693
|
+
const inlineEnabled = Boolean(cfg?.images?.inline?.enabled);
|
|
694
|
+
const maxImagesTotal = Math.max(0, Number(cfg.images.maxImagesTotal || 0) || 0);
|
|
695
|
+
let imagesLeft = maxImagesTotal;
|
|
696
|
+
|
|
697
|
+
const dataUrlRegex = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.*)$/;
|
|
698
|
+
|
|
699
|
+
const generateImage = async ({ providerKey, model, prompt, name }) => {
|
|
700
|
+
const imgResp = await llmService.callAdhoc(
|
|
701
|
+
{
|
|
702
|
+
providerKey,
|
|
703
|
+
model,
|
|
704
|
+
promptKeyForAudit: 'blog.automation.generate_image',
|
|
705
|
+
messages: [
|
|
706
|
+
{
|
|
707
|
+
role: 'system',
|
|
708
|
+
content:
|
|
709
|
+
'Return ONLY a single data URL like data:image/png;base64,... with no extra text.',
|
|
710
|
+
},
|
|
711
|
+
{ role: 'user', content: prompt },
|
|
712
|
+
],
|
|
713
|
+
},
|
|
714
|
+
{ temperature: 0.4, max_tokens: 2000 },
|
|
715
|
+
);
|
|
716
|
+
|
|
717
|
+
let candidate = String(imgResp.content || '').trim();
|
|
718
|
+
if (candidate.startsWith('```')) {
|
|
719
|
+
candidate = candidate.replace(/^```[a-zA-Z]*\s*/m, '').replace(/```\s*$/m, '').trim();
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const match = candidate.match(dataUrlRegex);
|
|
723
|
+
if (!match) {
|
|
724
|
+
const err = new Error('Image generation did not return a data URL');
|
|
725
|
+
err.meta = { preview: candidate.slice(0, 200), providerKey, model };
|
|
726
|
+
throw err;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const mime = match[1];
|
|
730
|
+
const b64 = match[2];
|
|
731
|
+
const buffer = Buffer.from(b64, 'base64');
|
|
732
|
+
const ext = (mime.split('/')[1] || 'png').toLowerCase();
|
|
733
|
+
|
|
734
|
+
const { asset, publicUrl } = await uploadBufferAsPublicAsset({
|
|
735
|
+
buffer,
|
|
736
|
+
contentType: mime,
|
|
737
|
+
originalName: `${name}.${ext}`,
|
|
738
|
+
namespace,
|
|
739
|
+
visibility,
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
if (asset?._id) createdAssetIds.push(String(asset._id));
|
|
743
|
+
return publicUrl;
|
|
744
|
+
};
|
|
745
|
+
|
|
746
|
+
const onImageError = (kind, error) => {
|
|
747
|
+
hadImageError = true;
|
|
748
|
+
imageErrors.push({ kind, message: String(error?.message || error || '') });
|
|
749
|
+
run.steps.push({ step: 'image_error', at: new Date().toISOString(), kind, message: String(error?.message || error || '') });
|
|
750
|
+
};
|
|
751
|
+
|
|
752
|
+
if (coverEnabled && imagesLeft > 0) {
|
|
753
|
+
try {
|
|
754
|
+
coverImageUrl = await generateImage({
|
|
755
|
+
providerKey: String(cfg.images.cover.providerKey || cfg.imageGeneration.providerKey || cfg.textGeneration.providerKey),
|
|
756
|
+
model: String(cfg.images.cover.model || cfg.imageGeneration.model || ''),
|
|
757
|
+
prompt: buildImagePrompt({ kind: 'cover', title, extraInstruction: cfg?.images?.promptExtraInstruction }),
|
|
758
|
+
name: 'blog-cover',
|
|
759
|
+
});
|
|
760
|
+
} catch (e) {
|
|
761
|
+
onImageError('cover', e);
|
|
762
|
+
}
|
|
763
|
+
imagesLeft -= 1;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
if (inlineEnabled && imagesLeft > 0) {
|
|
767
|
+
try {
|
|
768
|
+
const inlineUrl = await generateImage({
|
|
769
|
+
providerKey: String(cfg.images.inline.providerKey || cfg.imageGeneration.providerKey || cfg.textGeneration.providerKey),
|
|
770
|
+
model: String(cfg.images.inline.model || cfg.imageGeneration.model || ''),
|
|
771
|
+
prompt: buildImagePrompt({ kind: 'inline', title, extraInstruction: cfg?.images?.promptExtraInstruction }),
|
|
772
|
+
name: 'blog-inline',
|
|
773
|
+
});
|
|
774
|
+
markdown = `\n\n\n` + markdown;
|
|
775
|
+
} catch (e) {
|
|
776
|
+
onImageError('inline', e);
|
|
777
|
+
}
|
|
778
|
+
imagesLeft -= 1;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const html = marked.parse(markdown);
|
|
783
|
+
const finalExcerpt = excerpt || extractExcerptFromMarkdown(markdown);
|
|
784
|
+
const finalSlug = await generateUniqueBlogSlug(title);
|
|
785
|
+
|
|
786
|
+
run.steps.push({
|
|
787
|
+
step: 'post',
|
|
788
|
+
at: new Date().toISOString(),
|
|
789
|
+
title,
|
|
790
|
+
slug: finalSlug,
|
|
791
|
+
coverImageUrl,
|
|
792
|
+
tags,
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
if (cfg.dryRun) {
|
|
796
|
+
run.status = hadImageError || usedFallback ? 'partial' : 'succeeded';
|
|
797
|
+
run.finishedAt = new Date();
|
|
798
|
+
run.results = {
|
|
799
|
+
dryRun: true,
|
|
800
|
+
title,
|
|
801
|
+
slug: finalSlug,
|
|
802
|
+
coverImageUrl,
|
|
803
|
+
createdAssetIds,
|
|
804
|
+
imageErrors,
|
|
805
|
+
usedFallback,
|
|
806
|
+
};
|
|
807
|
+
await run.save();
|
|
808
|
+
return run.toObject();
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const post = await BlogPost.create({
|
|
812
|
+
title,
|
|
813
|
+
slug: finalSlug,
|
|
814
|
+
status: 'draft',
|
|
815
|
+
excerpt: finalExcerpt,
|
|
816
|
+
markdown,
|
|
817
|
+
html,
|
|
818
|
+
coverImageUrl,
|
|
819
|
+
category,
|
|
820
|
+
tags,
|
|
821
|
+
authorName: 'superbackend',
|
|
822
|
+
seoTitle,
|
|
823
|
+
seoDescription,
|
|
824
|
+
scheduledAt: null,
|
|
825
|
+
publishedAt: null,
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
run.status = hadImageError || usedFallback ? 'partial' : 'succeeded';
|
|
829
|
+
run.finishedAt = new Date();
|
|
830
|
+
run.results = {
|
|
831
|
+
postId: String(post._id),
|
|
832
|
+
slug: post.slug,
|
|
833
|
+
title: post.title,
|
|
834
|
+
coverImageUrl,
|
|
835
|
+
createdAssetIds,
|
|
836
|
+
imageErrors,
|
|
837
|
+
usedFallback,
|
|
838
|
+
};
|
|
839
|
+
await run.save();
|
|
840
|
+
|
|
841
|
+
return run.toObject();
|
|
842
|
+
} catch (err) {
|
|
843
|
+
run.status = 'failed';
|
|
844
|
+
run.finishedAt = new Date();
|
|
845
|
+
run.error = String(err?.message || err || 'Unknown error');
|
|
846
|
+
run.steps.push({ step: 'error', at: new Date().toISOString(), error: run.error });
|
|
847
|
+
await run.save();
|
|
848
|
+
return run.toObject();
|
|
849
|
+
} finally {
|
|
850
|
+
await releaseLock(lock);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
async function listRuns({ limit = 30, configId } = {}) {
|
|
855
|
+
const l = Math.min(100, Math.max(1, Number(limit) || 30));
|
|
856
|
+
const filter = {};
|
|
857
|
+
if (String(configId || '').trim()) filter.configId = String(configId).trim();
|
|
858
|
+
const runs = await BlogAutomationRun.find(filter).sort({ createdAt: -1 }).limit(l).lean();
|
|
859
|
+
return runs;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
async function saveBlogAutomationConfigs(configs) {
|
|
863
|
+
await ensureSettingExists({
|
|
864
|
+
key: BLOG_AUTOMATION_CONFIGS_KEY,
|
|
865
|
+
type: 'json',
|
|
866
|
+
description: 'Blog automation configurations (JSON)',
|
|
867
|
+
defaultValue: defaultBlogAutomationConfigs(),
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
const version = Number(configs?.version || 1) || 1;
|
|
871
|
+
const items = Array.isArray(configs?.items) ? configs.items : [];
|
|
872
|
+
const normalized = { version, items: items.map(normalizeAutomationConfigItemForSave) };
|
|
873
|
+
|
|
874
|
+
const doc = await GlobalSetting.findOne({ key: BLOG_AUTOMATION_CONFIGS_KEY });
|
|
875
|
+
doc.type = 'json';
|
|
876
|
+
doc.value = JSON.stringify(normalized);
|
|
877
|
+
if (!doc.description) doc.description = 'Blog automation configurations (JSON)';
|
|
878
|
+
await doc.save();
|
|
879
|
+
globalSettingsService.clearSettingsCache();
|
|
880
|
+
|
|
881
|
+
return normalized;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
async function createAutomationConfig({ name } = {}) {
|
|
885
|
+
const configs = await getBlogAutomationConfigs();
|
|
886
|
+
const item = normalizeAutomationConfigItemForSave({
|
|
887
|
+
id: crypto.randomUUID(),
|
|
888
|
+
name: String(name || '').trim() || 'New configuration',
|
|
889
|
+
schedule: {
|
|
890
|
+
managedBy: 'cronScheduler',
|
|
891
|
+
cronExpression: '0 9 * * 2,4',
|
|
892
|
+
timezone: 'UTC',
|
|
893
|
+
},
|
|
894
|
+
styleGuideOverride: '',
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
configs.items.unshift(item);
|
|
898
|
+
await saveBlogAutomationConfigs(configs);
|
|
899
|
+
return item;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
async function updateAutomationConfig(configId, patch) {
|
|
903
|
+
const id = String(configId || '').trim();
|
|
904
|
+
if (!id) {
|
|
905
|
+
const err = new Error('configId is required');
|
|
906
|
+
err.statusCode = 400;
|
|
907
|
+
throw err;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const configs = await getBlogAutomationConfigs();
|
|
911
|
+
const idx = configs.items.findIndex((i) => String(i.id) === id);
|
|
912
|
+
if (idx === -1) {
|
|
913
|
+
const err = new Error('Config not found');
|
|
914
|
+
err.statusCode = 404;
|
|
915
|
+
throw err;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const updated = normalizeAutomationConfigItemForSave({ ...configs.items[idx], ...(patch || {}), id });
|
|
919
|
+
configs.items[idx] = updated;
|
|
920
|
+
await saveBlogAutomationConfigs(configs);
|
|
921
|
+
return updated;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
async function deleteAutomationConfig(configId) {
|
|
925
|
+
const id = String(configId || '').trim();
|
|
926
|
+
if (!id) {
|
|
927
|
+
const err = new Error('configId is required');
|
|
928
|
+
err.statusCode = 400;
|
|
929
|
+
throw err;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
const configs = await getBlogAutomationConfigs();
|
|
933
|
+
const before = configs.items.length;
|
|
934
|
+
configs.items = configs.items.filter((i) => String(i.id) !== id);
|
|
935
|
+
if (configs.items.length === before) {
|
|
936
|
+
const err = new Error('Config not found');
|
|
937
|
+
err.statusCode = 404;
|
|
938
|
+
throw err;
|
|
939
|
+
}
|
|
940
|
+
await saveBlogAutomationConfigs(configs);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
async function updateStyleGuide(styleGuide) {
|
|
944
|
+
await ensureSettingExists({
|
|
945
|
+
key: BLOG_AUTOMATION_STYLE_GUIDE_KEY,
|
|
946
|
+
type: 'string',
|
|
947
|
+
description: 'Blog automation writing style guide',
|
|
948
|
+
defaultValue: defaultBlogAutomationStyleGuide(),
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
const doc = await GlobalSetting.findOne({ key: BLOG_AUTOMATION_STYLE_GUIDE_KEY });
|
|
952
|
+
doc.type = 'string';
|
|
953
|
+
doc.value = String(styleGuide ?? '');
|
|
954
|
+
if (!doc.description) doc.description = 'Blog automation writing style guide';
|
|
955
|
+
await doc.save();
|
|
956
|
+
globalSettingsService.clearSettingsCache();
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
module.exports = {
|
|
960
|
+
BLOG_AUTOMATION_CONFIG_KEY,
|
|
961
|
+
BLOG_AUTOMATION_CONFIGS_KEY,
|
|
962
|
+
BLOG_AUTOMATION_STYLE_GUIDE_KEY,
|
|
963
|
+
defaultBlogAutomationConfig,
|
|
964
|
+
defaultBlogAutomationConfigs,
|
|
965
|
+
defaultBlogAutomationStyleGuide,
|
|
966
|
+
getBlogAutomationConfig,
|
|
967
|
+
getBlogAutomationConfigs,
|
|
968
|
+
getBlogAutomationConfigById,
|
|
969
|
+
getBlogAutomationStyleGuide,
|
|
970
|
+
saveBlogAutomationConfigs,
|
|
971
|
+
createAutomationConfig,
|
|
972
|
+
updateAutomationConfig,
|
|
973
|
+
deleteAutomationConfig,
|
|
974
|
+
updateStyleGuide,
|
|
975
|
+
listRuns,
|
|
976
|
+
runBlogAutomation,
|
|
977
|
+
previewPromptsByConfigId,
|
|
978
|
+
};
|