optimal-cli 0.1.0
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/README.md +175 -0
- package/dist/bin/optimal.d.ts +2 -0
- package/dist/bin/optimal.js +995 -0
- package/dist/lib/budget/projections.d.ts +115 -0
- package/dist/lib/budget/projections.js +384 -0
- package/dist/lib/budget/scenarios.d.ts +93 -0
- package/dist/lib/budget/scenarios.js +214 -0
- package/dist/lib/cms/publish-blog.d.ts +62 -0
- package/dist/lib/cms/publish-blog.js +74 -0
- package/dist/lib/cms/strapi-client.d.ts +123 -0
- package/dist/lib/cms/strapi-client.js +213 -0
- package/dist/lib/config.d.ts +55 -0
- package/dist/lib/config.js +206 -0
- package/dist/lib/infra/deploy.d.ts +29 -0
- package/dist/lib/infra/deploy.js +58 -0
- package/dist/lib/infra/migrate.d.ts +34 -0
- package/dist/lib/infra/migrate.js +103 -0
- package/dist/lib/kanban.d.ts +46 -0
- package/dist/lib/kanban.js +118 -0
- package/dist/lib/newsletter/distribute.d.ts +52 -0
- package/dist/lib/newsletter/distribute.js +193 -0
- package/dist/lib/newsletter/generate-insurance.d.ts +42 -0
- package/dist/lib/newsletter/generate-insurance.js +36 -0
- package/dist/lib/newsletter/generate.d.ts +104 -0
- package/dist/lib/newsletter/generate.js +571 -0
- package/dist/lib/returnpro/anomalies.d.ts +64 -0
- package/dist/lib/returnpro/anomalies.js +166 -0
- package/dist/lib/returnpro/audit.d.ts +32 -0
- package/dist/lib/returnpro/audit.js +147 -0
- package/dist/lib/returnpro/diagnose.d.ts +52 -0
- package/dist/lib/returnpro/diagnose.js +281 -0
- package/dist/lib/returnpro/kpis.d.ts +32 -0
- package/dist/lib/returnpro/kpis.js +192 -0
- package/dist/lib/returnpro/templates.d.ts +48 -0
- package/dist/lib/returnpro/templates.js +229 -0
- package/dist/lib/returnpro/upload-income.d.ts +25 -0
- package/dist/lib/returnpro/upload-income.js +235 -0
- package/dist/lib/returnpro/upload-netsuite.d.ts +37 -0
- package/dist/lib/returnpro/upload-netsuite.js +566 -0
- package/dist/lib/returnpro/upload-r1.d.ts +48 -0
- package/dist/lib/returnpro/upload-r1.js +398 -0
- package/dist/lib/social/post-generator.d.ts +83 -0
- package/dist/lib/social/post-generator.js +333 -0
- package/dist/lib/social/publish.d.ts +66 -0
- package/dist/lib/social/publish.js +226 -0
- package/dist/lib/social/scraper.d.ts +67 -0
- package/dist/lib/social/scraper.js +361 -0
- package/dist/lib/supabase.d.ts +4 -0
- package/dist/lib/supabase.js +20 -0
- package/dist/lib/transactions/delete-batch.d.ts +60 -0
- package/dist/lib/transactions/delete-batch.js +203 -0
- package/dist/lib/transactions/ingest.d.ts +43 -0
- package/dist/lib/transactions/ingest.js +555 -0
- package/dist/lib/transactions/stamp.d.ts +51 -0
- package/dist/lib/transactions/stamp.js +524 -0
- package/package.json +50 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Budget scenario manager — save, load, list, compare, and delete named scenarios.
|
|
3
|
+
*
|
|
4
|
+
* Scenarios are stored as JSON files on disk at:
|
|
5
|
+
* /home/optimal/optimal-cli/data/scenarios/{name}.json
|
|
6
|
+
*
|
|
7
|
+
* Each scenario captures a full snapshot of projected units after applying
|
|
8
|
+
* a uniform adjustment to the live fpa_wes_imports data.
|
|
9
|
+
*/
|
|
10
|
+
import 'dotenv/config';
|
|
11
|
+
import { mkdirSync, writeFileSync, readFileSync, readdirSync, unlinkSync } from 'node:fs';
|
|
12
|
+
import { join, dirname } from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import { fetchWesImports, initializeProjections, applyUniformAdjustment, calculateTotals, } from './projections.js';
|
|
15
|
+
// --- Directory resolution ---
|
|
16
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
17
|
+
const __dirname = dirname(__filename);
|
|
18
|
+
// Resolve relative to the repo root (lib/budget/ -> ../../data/scenarios/)
|
|
19
|
+
const SCENARIOS_DIR = join(__dirname, '..', '..', 'data', 'scenarios');
|
|
20
|
+
function ensureScenariosDir() {
|
|
21
|
+
mkdirSync(SCENARIOS_DIR, { recursive: true });
|
|
22
|
+
}
|
|
23
|
+
// --- Helpers ---
|
|
24
|
+
/**
|
|
25
|
+
* Sanitize a scenario name into a safe filename segment.
|
|
26
|
+
* Lowercases, replaces spaces and disallowed chars with hyphens,
|
|
27
|
+
* collapses repeated hyphens, and strips leading/trailing hyphens.
|
|
28
|
+
*/
|
|
29
|
+
function sanitizeName(name) {
|
|
30
|
+
return name
|
|
31
|
+
.toLowerCase()
|
|
32
|
+
.replace(/[^a-z0-9-_]+/g, '-')
|
|
33
|
+
.replace(/-{2,}/g, '-')
|
|
34
|
+
.replace(/^-|-$/g, '');
|
|
35
|
+
}
|
|
36
|
+
function scenarioPath(sanitized) {
|
|
37
|
+
return join(SCENARIOS_DIR, `${sanitized}.json`);
|
|
38
|
+
}
|
|
39
|
+
// --- Public API ---
|
|
40
|
+
/**
|
|
41
|
+
* Save current projections as a named scenario to disk.
|
|
42
|
+
*
|
|
43
|
+
* Fetches live data via fetchWesImports, applies the given adjustment,
|
|
44
|
+
* calculates totals, and writes the result as JSON.
|
|
45
|
+
*
|
|
46
|
+
* @returns The absolute path to the saved scenario file.
|
|
47
|
+
*/
|
|
48
|
+
export async function saveScenario(opts) {
|
|
49
|
+
ensureScenariosDir();
|
|
50
|
+
const sanitized = sanitizeName(opts.name);
|
|
51
|
+
if (!sanitized) {
|
|
52
|
+
throw new Error(`Invalid scenario name: "${opts.name}"`);
|
|
53
|
+
}
|
|
54
|
+
// Fetch and process data
|
|
55
|
+
const summary = await fetchWesImports({
|
|
56
|
+
fiscalYear: opts.fiscalYear,
|
|
57
|
+
userId: opts.userId,
|
|
58
|
+
});
|
|
59
|
+
const initialized = initializeProjections(summary);
|
|
60
|
+
const adjusted = applyUniformAdjustment(initialized, opts.adjustmentType, opts.adjustmentValue);
|
|
61
|
+
const totals = calculateTotals(adjusted);
|
|
62
|
+
const scenarioData = {
|
|
63
|
+
name: opts.name,
|
|
64
|
+
createdAt: new Date().toISOString(),
|
|
65
|
+
adjustmentType: opts.adjustmentType,
|
|
66
|
+
adjustmentValue: opts.adjustmentValue,
|
|
67
|
+
...(opts.description !== undefined ? { description: opts.description } : {}),
|
|
68
|
+
projections: adjusted.map((p) => ({
|
|
69
|
+
programCode: p.programCode,
|
|
70
|
+
masterProgram: p.masterProgram,
|
|
71
|
+
actualUnits: p.actualUnits,
|
|
72
|
+
projectedUnits: p.projectedUnits,
|
|
73
|
+
})),
|
|
74
|
+
totals: {
|
|
75
|
+
totalActual: totals.totalActual,
|
|
76
|
+
totalProjected: totals.totalProjected,
|
|
77
|
+
percentageChange: totals.percentageChange,
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
const filePath = scenarioPath(sanitized);
|
|
81
|
+
writeFileSync(filePath, JSON.stringify(scenarioData, null, 2), 'utf-8');
|
|
82
|
+
return filePath;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Load a saved scenario from disk by name.
|
|
86
|
+
*
|
|
87
|
+
* Accepts the original name (will be sanitized) or the sanitized form.
|
|
88
|
+
*/
|
|
89
|
+
export async function loadScenario(name) {
|
|
90
|
+
const sanitized = sanitizeName(name);
|
|
91
|
+
const filePath = scenarioPath(sanitized);
|
|
92
|
+
let raw;
|
|
93
|
+
try {
|
|
94
|
+
raw = readFileSync(filePath, 'utf-8');
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
throw new Error(`Scenario not found: "${name}" (looked for ${filePath})`);
|
|
98
|
+
}
|
|
99
|
+
return JSON.parse(raw);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* List all saved scenarios, returning lightweight summary objects.
|
|
103
|
+
*
|
|
104
|
+
* Scenarios with unreadable or malformed files are silently skipped.
|
|
105
|
+
*/
|
|
106
|
+
export async function listScenarios() {
|
|
107
|
+
ensureScenariosDir();
|
|
108
|
+
let files;
|
|
109
|
+
try {
|
|
110
|
+
files = readdirSync(SCENARIOS_DIR);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
const jsonFiles = files.filter((f) => f.endsWith('.json'));
|
|
116
|
+
const summaries = [];
|
|
117
|
+
for (const file of jsonFiles) {
|
|
118
|
+
const filePath = join(SCENARIOS_DIR, file);
|
|
119
|
+
try {
|
|
120
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
121
|
+
const data = JSON.parse(raw);
|
|
122
|
+
summaries.push({
|
|
123
|
+
name: data.name,
|
|
124
|
+
createdAt: data.createdAt,
|
|
125
|
+
adjustmentType: data.adjustmentType,
|
|
126
|
+
adjustmentValue: data.adjustmentValue,
|
|
127
|
+
...(data.description !== undefined ? { description: data.description } : {}),
|
|
128
|
+
totalProjected: data.totals.totalProjected,
|
|
129
|
+
percentageChange: data.totals.percentageChange,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
// Skip unreadable/malformed scenario files
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// Sort newest first
|
|
137
|
+
summaries.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
|
138
|
+
return summaries;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Compare two or more scenarios side by side.
|
|
142
|
+
*
|
|
143
|
+
* For each program that appears in any of the loaded scenarios, the result
|
|
144
|
+
* includes the actual unit count and the projected units from each scenario.
|
|
145
|
+
* Programs missing from a given scenario will have projectedUnits of 0.
|
|
146
|
+
*/
|
|
147
|
+
export async function compareScenarios(names) {
|
|
148
|
+
if (names.length < 2) {
|
|
149
|
+
throw new Error('compareScenarios requires at least 2 scenario names');
|
|
150
|
+
}
|
|
151
|
+
// Load all scenarios in parallel
|
|
152
|
+
const loaded = await Promise.all(names.map((n) => loadScenario(n)));
|
|
153
|
+
// Build a unified map of programCode -> { masterProgram, actual, projectedByScenario }
|
|
154
|
+
const programMap = new Map();
|
|
155
|
+
for (const scenario of loaded) {
|
|
156
|
+
for (const p of scenario.projections) {
|
|
157
|
+
const existing = programMap.get(p.programCode);
|
|
158
|
+
if (existing) {
|
|
159
|
+
existing.projectedByScenario[scenario.name] = p.projectedUnits;
|
|
160
|
+
// Keep the actual from whichever scenario we see first
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
programMap.set(p.programCode, {
|
|
164
|
+
masterProgram: p.masterProgram,
|
|
165
|
+
actual: p.actualUnits,
|
|
166
|
+
projectedByScenario: { [scenario.name]: p.projectedUnits },
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// Fill in zeros for scenarios that don't have a given program
|
|
172
|
+
for (const entry of programMap.values()) {
|
|
173
|
+
for (const scenario of loaded) {
|
|
174
|
+
if (!(scenario.name in entry.projectedByScenario)) {
|
|
175
|
+
entry.projectedByScenario[scenario.name] = 0;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const programs = Array.from(programMap.entries())
|
|
180
|
+
.map(([programCode, entry]) => ({
|
|
181
|
+
programCode,
|
|
182
|
+
masterProgram: entry.masterProgram,
|
|
183
|
+
actual: entry.actual,
|
|
184
|
+
projectedByScenario: entry.projectedByScenario,
|
|
185
|
+
}))
|
|
186
|
+
.sort((a, b) => a.programCode.localeCompare(b.programCode));
|
|
187
|
+
const totalsByScenario = {};
|
|
188
|
+
for (const scenario of loaded) {
|
|
189
|
+
totalsByScenario[scenario.name] = {
|
|
190
|
+
totalProjected: scenario.totals.totalProjected,
|
|
191
|
+
percentageChange: scenario.totals.percentageChange,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
scenarioNames: loaded.map((s) => s.name),
|
|
196
|
+
programs,
|
|
197
|
+
totalsByScenario,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Delete a scenario file from disk.
|
|
202
|
+
*
|
|
203
|
+
* Throws if the scenario does not exist.
|
|
204
|
+
*/
|
|
205
|
+
export async function deleteScenario(name) {
|
|
206
|
+
const sanitized = sanitizeName(name);
|
|
207
|
+
const filePath = scenarioPath(sanitized);
|
|
208
|
+
try {
|
|
209
|
+
unlinkSync(filePath);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
throw new Error(`Scenario not found: "${name}" (looked for ${filePath})`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
2
|
+
import { StrapiItem } from './strapi-client.js';
|
|
3
|
+
export interface PublishBlogOptions {
|
|
4
|
+
slug: string;
|
|
5
|
+
deployAfter?: boolean;
|
|
6
|
+
site?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface PublishBlogResult {
|
|
9
|
+
documentId: string;
|
|
10
|
+
slug: string;
|
|
11
|
+
published: boolean;
|
|
12
|
+
deployUrl?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface BlogPostData {
|
|
15
|
+
title: string;
|
|
16
|
+
slug: string;
|
|
17
|
+
content: string;
|
|
18
|
+
site: string;
|
|
19
|
+
tags?: string[];
|
|
20
|
+
excerpt?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface BlogPostSummary {
|
|
23
|
+
documentId: string;
|
|
24
|
+
title: string;
|
|
25
|
+
slug: string;
|
|
26
|
+
site: string;
|
|
27
|
+
createdAt: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Main orchestrator: find a blog post by slug, publish it in Strapi,
|
|
31
|
+
* and optionally deploy the portfolio site to Vercel.
|
|
32
|
+
*
|
|
33
|
+
* @throws If no blog post is found for the given slug.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* const result = await publishBlog({ slug: 'copper-investment-thesis-2026', deployAfter: true })
|
|
37
|
+
* console.log(result.deployUrl) // https://portfolio-2026.vercel.app
|
|
38
|
+
*/
|
|
39
|
+
export declare function publishBlog(opts: PublishBlogOptions): Promise<PublishBlogResult>;
|
|
40
|
+
/**
|
|
41
|
+
* Create a new blog post draft in Strapi.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* const post = await createBlogPost({
|
|
45
|
+
* title: 'Copper Investment Thesis 2026',
|
|
46
|
+
* slug: 'copper-investment-thesis-2026',
|
|
47
|
+
* content: '## Overview\n...',
|
|
48
|
+
* site: 'portfolio',
|
|
49
|
+
* tags: ['Automated Report'],
|
|
50
|
+
* })
|
|
51
|
+
*/
|
|
52
|
+
export declare function createBlogPost(data: BlogPostData): Promise<StrapiItem>;
|
|
53
|
+
/**
|
|
54
|
+
* List unpublished blog post drafts from Strapi, optionally filtered by site.
|
|
55
|
+
*
|
|
56
|
+
* @param site - Optional site key to filter by (e.g. 'portfolio', 'insurance').
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* const drafts = await listBlogDrafts('portfolio')
|
|
60
|
+
* drafts.forEach(d => console.log(d.slug, d.createdAt))
|
|
61
|
+
*/
|
|
62
|
+
export declare function listBlogDrafts(site?: string): Promise<BlogPostSummary[]>;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
2
|
+
import { deploy } from '../infra/deploy.js';
|
|
3
|
+
import { strapiGet, strapiPost, findBySlug, publish, } from './strapi-client.js';
|
|
4
|
+
// ── Functions ─────────────────────────────────────────────────────────
|
|
5
|
+
/**
|
|
6
|
+
* Main orchestrator: find a blog post by slug, publish it in Strapi,
|
|
7
|
+
* and optionally deploy the portfolio site to Vercel.
|
|
8
|
+
*
|
|
9
|
+
* @throws If no blog post is found for the given slug.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* const result = await publishBlog({ slug: 'copper-investment-thesis-2026', deployAfter: true })
|
|
13
|
+
* console.log(result.deployUrl) // https://portfolio-2026.vercel.app
|
|
14
|
+
*/
|
|
15
|
+
export async function publishBlog(opts) {
|
|
16
|
+
const { slug, deployAfter = false } = opts;
|
|
17
|
+
const item = await findBySlug('blog-posts', slug);
|
|
18
|
+
if (!item) {
|
|
19
|
+
throw new Error(`Blog post not found for slug: "${slug}"`);
|
|
20
|
+
}
|
|
21
|
+
const { documentId } = item;
|
|
22
|
+
await publish('blog-posts', documentId);
|
|
23
|
+
let deployUrl;
|
|
24
|
+
if (deployAfter) {
|
|
25
|
+
deployUrl = await deploy('portfolio', true);
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
documentId,
|
|
29
|
+
slug,
|
|
30
|
+
published: true,
|
|
31
|
+
...(deployUrl !== undefined ? { deployUrl } : {}),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Create a new blog post draft in Strapi.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* const post = await createBlogPost({
|
|
39
|
+
* title: 'Copper Investment Thesis 2026',
|
|
40
|
+
* slug: 'copper-investment-thesis-2026',
|
|
41
|
+
* content: '## Overview\n...',
|
|
42
|
+
* site: 'portfolio',
|
|
43
|
+
* tags: ['Automated Report'],
|
|
44
|
+
* })
|
|
45
|
+
*/
|
|
46
|
+
export async function createBlogPost(data) {
|
|
47
|
+
return strapiPost('/api/blog-posts', data);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* List unpublished blog post drafts from Strapi, optionally filtered by site.
|
|
51
|
+
*
|
|
52
|
+
* @param site - Optional site key to filter by (e.g. 'portfolio', 'insurance').
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* const drafts = await listBlogDrafts('portfolio')
|
|
56
|
+
* drafts.forEach(d => console.log(d.slug, d.createdAt))
|
|
57
|
+
*/
|
|
58
|
+
export async function listBlogDrafts(site) {
|
|
59
|
+
const params = {
|
|
60
|
+
status: 'draft',
|
|
61
|
+
'sort': 'createdAt:desc',
|
|
62
|
+
};
|
|
63
|
+
if (site) {
|
|
64
|
+
params['filters[site][$eq]'] = site;
|
|
65
|
+
}
|
|
66
|
+
const result = await strapiGet('/api/blog-posts', params);
|
|
67
|
+
return result.data.map((item) => ({
|
|
68
|
+
documentId: item.documentId,
|
|
69
|
+
title: String(item.title ?? ''),
|
|
70
|
+
slug: String(item.slug ?? ''),
|
|
71
|
+
site: String(item.site ?? ''),
|
|
72
|
+
createdAt: String(item.createdAt ?? ''),
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
2
|
+
export interface StrapiItem {
|
|
3
|
+
id: number;
|
|
4
|
+
documentId: string;
|
|
5
|
+
[key: string]: unknown;
|
|
6
|
+
}
|
|
7
|
+
export interface StrapiPagination {
|
|
8
|
+
page: number;
|
|
9
|
+
pageSize: number;
|
|
10
|
+
pageCount: number;
|
|
11
|
+
total: number;
|
|
12
|
+
}
|
|
13
|
+
export interface StrapiPage {
|
|
14
|
+
data: StrapiItem[];
|
|
15
|
+
meta: {
|
|
16
|
+
pagination: StrapiPagination;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export interface StrapiError {
|
|
20
|
+
status: number;
|
|
21
|
+
name: string;
|
|
22
|
+
message: string;
|
|
23
|
+
details?: Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
export declare class StrapiClientError extends Error {
|
|
26
|
+
status: number;
|
|
27
|
+
strapiError?: StrapiError | undefined;
|
|
28
|
+
constructor(message: string, status: number, strapiError?: StrapiError | undefined);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* GET a Strapi endpoint with optional query params.
|
|
32
|
+
* Returns the full parsed JSON response.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* const result = await strapiGet('/api/newsletters', { 'status': 'draft' })
|
|
36
|
+
*/
|
|
37
|
+
export declare function strapiGet<T = StrapiPage>(endpoint: string, params?: Record<string, string>): Promise<T>;
|
|
38
|
+
/**
|
|
39
|
+
* POST to a Strapi endpoint. Wraps data in `{ data }` per Strapi v5 convention.
|
|
40
|
+
* Returns the created item.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* const item = await strapiPost('/api/social-posts', {
|
|
44
|
+
* headline: 'New post',
|
|
45
|
+
* brand: 'LIFEINSUR',
|
|
46
|
+
* })
|
|
47
|
+
*/
|
|
48
|
+
export declare function strapiPost(endpoint: string, data: Record<string, unknown>): Promise<StrapiItem>;
|
|
49
|
+
/**
|
|
50
|
+
* PUT to a Strapi endpoint by documentId. Wraps data in `{ data }`.
|
|
51
|
+
*
|
|
52
|
+
* IMPORTANT: Strapi v5 uses documentId (UUID string), NOT numeric id.
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* await strapiPut('/api/newsletters', 'abc123-def456', { subject_line: 'Updated' })
|
|
56
|
+
*/
|
|
57
|
+
export declare function strapiPut(endpoint: string, documentId: string, data: Record<string, unknown>): Promise<StrapiItem>;
|
|
58
|
+
/**
|
|
59
|
+
* DELETE a Strapi item by documentId.
|
|
60
|
+
*
|
|
61
|
+
* IMPORTANT: Strapi v5 uses documentId (UUID string), NOT numeric id.
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* await strapiDelete('/api/social-posts', 'abc123-def456')
|
|
65
|
+
*/
|
|
66
|
+
export declare function strapiDelete(endpoint: string, documentId: string): Promise<void>;
|
|
67
|
+
/**
|
|
68
|
+
* Upload a file to Strapi's `/api/upload` endpoint via multipart form.
|
|
69
|
+
*
|
|
70
|
+
* Optionally link the upload to an existing entry via `refData`:
|
|
71
|
+
* - ref: content type UID (e.g. 'api::newsletter.newsletter')
|
|
72
|
+
* - refId: documentId of the entry to attach to
|
|
73
|
+
* - field: field name on the content type (e.g. 'cover_image')
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* const uploaded = await strapiUploadFile('/path/to/image.png')
|
|
77
|
+
* const linked = await strapiUploadFile('/path/to/cover.jpg', {
|
|
78
|
+
* ref: 'api::blog-post.blog-post',
|
|
79
|
+
* refId: 'abc123',
|
|
80
|
+
* field: 'cover',
|
|
81
|
+
* })
|
|
82
|
+
*/
|
|
83
|
+
export declare function strapiUploadFile(filePath: string, refData?: {
|
|
84
|
+
ref: string;
|
|
85
|
+
refId: string;
|
|
86
|
+
field: string;
|
|
87
|
+
}): Promise<StrapiItem[]>;
|
|
88
|
+
/**
|
|
89
|
+
* List items of a content type filtered by brand, with optional status filter.
|
|
90
|
+
* This is the most common query pattern in Optimal's multi-brand CMS setup.
|
|
91
|
+
*
|
|
92
|
+
* Content types: 'newsletters', 'social-posts', 'blog-posts'
|
|
93
|
+
* Brands: 'CRE-11TRUST', 'LIFEINSUR'
|
|
94
|
+
* Status: 'draft' or 'published' (Strapi's draftAndPublish)
|
|
95
|
+
*
|
|
96
|
+
* @example
|
|
97
|
+
* const drafts = await listByBrand('social-posts', 'LIFEINSUR', 'draft')
|
|
98
|
+
* const published = await listByBrand('newsletters', 'CRE-11TRUST', 'published')
|
|
99
|
+
* const all = await listByBrand('blog-posts', 'CRE-11TRUST')
|
|
100
|
+
*/
|
|
101
|
+
export declare function listByBrand(contentType: string, brand: string, status?: 'draft' | 'published'): Promise<StrapiPage>;
|
|
102
|
+
/**
|
|
103
|
+
* Find a single item by slug within a content type.
|
|
104
|
+
* Returns null if not found.
|
|
105
|
+
*
|
|
106
|
+
* @example
|
|
107
|
+
* const post = await findBySlug('blog-posts', 'copper-investment-thesis-2026')
|
|
108
|
+
*/
|
|
109
|
+
export declare function findBySlug(contentType: string, slug: string): Promise<StrapiItem | null>;
|
|
110
|
+
/**
|
|
111
|
+
* Publish an item by setting publishedAt via PUT.
|
|
112
|
+
*
|
|
113
|
+
* @example
|
|
114
|
+
* await publish('newsletters', 'abc123-def456')
|
|
115
|
+
*/
|
|
116
|
+
export declare function publish(contentType: string, documentId: string): Promise<StrapiItem>;
|
|
117
|
+
/**
|
|
118
|
+
* Unpublish (revert to draft) by clearing publishedAt.
|
|
119
|
+
*
|
|
120
|
+
* @example
|
|
121
|
+
* await unpublish('newsletters', 'abc123-def456')
|
|
122
|
+
*/
|
|
123
|
+
export declare function unpublish(contentType: string, documentId: string): Promise<StrapiItem>;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { basename } from 'node:path';
|
|
4
|
+
export class StrapiClientError extends Error {
|
|
5
|
+
status;
|
|
6
|
+
strapiError;
|
|
7
|
+
constructor(message, status, strapiError) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.strapiError = strapiError;
|
|
11
|
+
this.name = 'StrapiClientError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
// ── Config ───────────────────────────────────────────────────────────
|
|
15
|
+
function getConfig() {
|
|
16
|
+
const url = process.env.STRAPI_URL;
|
|
17
|
+
const token = process.env.STRAPI_API_TOKEN;
|
|
18
|
+
if (!url || !token) {
|
|
19
|
+
throw new Error('Missing env vars: STRAPI_URL, STRAPI_API_TOKEN');
|
|
20
|
+
}
|
|
21
|
+
return { url: url.replace(/\/+$/, ''), token };
|
|
22
|
+
}
|
|
23
|
+
// ── Internal request helper ──────────────────────────────────────────
|
|
24
|
+
async function request(path, opts = {}) {
|
|
25
|
+
const { url, token } = getConfig();
|
|
26
|
+
const fullUrl = `${url}${path}`;
|
|
27
|
+
const res = await fetch(fullUrl, {
|
|
28
|
+
...opts,
|
|
29
|
+
headers: {
|
|
30
|
+
Authorization: `Bearer ${token}`,
|
|
31
|
+
'Content-Type': 'application/json',
|
|
32
|
+
...opts.headers,
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
if (!res.ok) {
|
|
36
|
+
let strapiErr;
|
|
37
|
+
try {
|
|
38
|
+
const body = await res.json();
|
|
39
|
+
strapiErr = body?.error;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// non-JSON error body
|
|
43
|
+
}
|
|
44
|
+
throw new StrapiClientError(strapiErr?.message ?? `Strapi ${res.status}: ${res.statusText}`, res.status, strapiErr);
|
|
45
|
+
}
|
|
46
|
+
if (res.status === 204)
|
|
47
|
+
return undefined;
|
|
48
|
+
return res.json();
|
|
49
|
+
}
|
|
50
|
+
// ── CRUD Functions ───────────────────────────────────────────────────
|
|
51
|
+
/**
|
|
52
|
+
* GET a Strapi endpoint with optional query params.
|
|
53
|
+
* Returns the full parsed JSON response.
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* const result = await strapiGet('/api/newsletters', { 'status': 'draft' })
|
|
57
|
+
*/
|
|
58
|
+
export async function strapiGet(endpoint, params) {
|
|
59
|
+
const qs = params ? new URLSearchParams(params).toString() : '';
|
|
60
|
+
const path = qs ? `${endpoint}?${qs}` : endpoint;
|
|
61
|
+
return request(path);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* POST to a Strapi endpoint. Wraps data in `{ data }` per Strapi v5 convention.
|
|
65
|
+
* Returns the created item.
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* const item = await strapiPost('/api/social-posts', {
|
|
69
|
+
* headline: 'New post',
|
|
70
|
+
* brand: 'LIFEINSUR',
|
|
71
|
+
* })
|
|
72
|
+
*/
|
|
73
|
+
export async function strapiPost(endpoint, data) {
|
|
74
|
+
const result = await request(endpoint, {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
body: JSON.stringify({ data }),
|
|
77
|
+
});
|
|
78
|
+
return result.data;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* PUT to a Strapi endpoint by documentId. Wraps data in `{ data }`.
|
|
82
|
+
*
|
|
83
|
+
* IMPORTANT: Strapi v5 uses documentId (UUID string), NOT numeric id.
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* await strapiPut('/api/newsletters', 'abc123-def456', { subject_line: 'Updated' })
|
|
87
|
+
*/
|
|
88
|
+
export async function strapiPut(endpoint, documentId, data) {
|
|
89
|
+
const result = await request(`${endpoint}/${documentId}`, {
|
|
90
|
+
method: 'PUT',
|
|
91
|
+
body: JSON.stringify({ data }),
|
|
92
|
+
});
|
|
93
|
+
return result.data;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* DELETE a Strapi item by documentId.
|
|
97
|
+
*
|
|
98
|
+
* IMPORTANT: Strapi v5 uses documentId (UUID string), NOT numeric id.
|
|
99
|
+
*
|
|
100
|
+
* @example
|
|
101
|
+
* await strapiDelete('/api/social-posts', 'abc123-def456')
|
|
102
|
+
*/
|
|
103
|
+
export async function strapiDelete(endpoint, documentId) {
|
|
104
|
+
await request(`${endpoint}/${documentId}`, { method: 'DELETE' });
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Upload a file to Strapi's `/api/upload` endpoint via multipart form.
|
|
108
|
+
*
|
|
109
|
+
* Optionally link the upload to an existing entry via `refData`:
|
|
110
|
+
* - ref: content type UID (e.g. 'api::newsletter.newsletter')
|
|
111
|
+
* - refId: documentId of the entry to attach to
|
|
112
|
+
* - field: field name on the content type (e.g. 'cover_image')
|
|
113
|
+
*
|
|
114
|
+
* @example
|
|
115
|
+
* const uploaded = await strapiUploadFile('/path/to/image.png')
|
|
116
|
+
* const linked = await strapiUploadFile('/path/to/cover.jpg', {
|
|
117
|
+
* ref: 'api::blog-post.blog-post',
|
|
118
|
+
* refId: 'abc123',
|
|
119
|
+
* field: 'cover',
|
|
120
|
+
* })
|
|
121
|
+
*/
|
|
122
|
+
export async function strapiUploadFile(filePath, refData) {
|
|
123
|
+
const { url, token } = getConfig();
|
|
124
|
+
const fileBuffer = readFileSync(filePath);
|
|
125
|
+
const fileName = basename(filePath);
|
|
126
|
+
const formData = new FormData();
|
|
127
|
+
formData.append('files', new Blob([fileBuffer]), fileName);
|
|
128
|
+
if (refData) {
|
|
129
|
+
formData.append('ref', refData.ref);
|
|
130
|
+
formData.append('refId', refData.refId);
|
|
131
|
+
formData.append('field', refData.field);
|
|
132
|
+
}
|
|
133
|
+
const res = await fetch(`${url}/api/upload`, {
|
|
134
|
+
method: 'POST',
|
|
135
|
+
headers: {
|
|
136
|
+
Authorization: `Bearer ${token}`,
|
|
137
|
+
// Do NOT set Content-Type — fetch sets it with the multipart boundary
|
|
138
|
+
},
|
|
139
|
+
body: formData,
|
|
140
|
+
});
|
|
141
|
+
if (!res.ok) {
|
|
142
|
+
let strapiErr;
|
|
143
|
+
try {
|
|
144
|
+
const body = await res.json();
|
|
145
|
+
strapiErr = body?.error;
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
// non-JSON error body
|
|
149
|
+
}
|
|
150
|
+
throw new StrapiClientError(strapiErr?.message ?? `Upload failed ${res.status}: ${res.statusText}`, res.status, strapiErr);
|
|
151
|
+
}
|
|
152
|
+
return res.json();
|
|
153
|
+
}
|
|
154
|
+
// ── Convenience ──────────────────────────────────────────────────────
|
|
155
|
+
/**
|
|
156
|
+
* List items of a content type filtered by brand, with optional status filter.
|
|
157
|
+
* This is the most common query pattern in Optimal's multi-brand CMS setup.
|
|
158
|
+
*
|
|
159
|
+
* Content types: 'newsletters', 'social-posts', 'blog-posts'
|
|
160
|
+
* Brands: 'CRE-11TRUST', 'LIFEINSUR'
|
|
161
|
+
* Status: 'draft' or 'published' (Strapi's draftAndPublish)
|
|
162
|
+
*
|
|
163
|
+
* @example
|
|
164
|
+
* const drafts = await listByBrand('social-posts', 'LIFEINSUR', 'draft')
|
|
165
|
+
* const published = await listByBrand('newsletters', 'CRE-11TRUST', 'published')
|
|
166
|
+
* const all = await listByBrand('blog-posts', 'CRE-11TRUST')
|
|
167
|
+
*/
|
|
168
|
+
export async function listByBrand(contentType, brand, status) {
|
|
169
|
+
const params = {
|
|
170
|
+
'filters[brand][$eq]': brand,
|
|
171
|
+
'sort': 'createdAt:desc',
|
|
172
|
+
};
|
|
173
|
+
if (status) {
|
|
174
|
+
params['status'] = status;
|
|
175
|
+
}
|
|
176
|
+
return strapiGet(`/api/${contentType}`, params);
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Find a single item by slug within a content type.
|
|
180
|
+
* Returns null if not found.
|
|
181
|
+
*
|
|
182
|
+
* @example
|
|
183
|
+
* const post = await findBySlug('blog-posts', 'copper-investment-thesis-2026')
|
|
184
|
+
*/
|
|
185
|
+
export async function findBySlug(contentType, slug) {
|
|
186
|
+
const result = await strapiGet(`/api/${contentType}`, {
|
|
187
|
+
'filters[slug][$eq]': slug,
|
|
188
|
+
'pagination[pageSize]': '1',
|
|
189
|
+
});
|
|
190
|
+
return result.data[0] ?? null;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Publish an item by setting publishedAt via PUT.
|
|
194
|
+
*
|
|
195
|
+
* @example
|
|
196
|
+
* await publish('newsletters', 'abc123-def456')
|
|
197
|
+
*/
|
|
198
|
+
export async function publish(contentType, documentId) {
|
|
199
|
+
return strapiPut(`/api/${contentType}`, documentId, {
|
|
200
|
+
publishedAt: new Date().toISOString(),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Unpublish (revert to draft) by clearing publishedAt.
|
|
205
|
+
*
|
|
206
|
+
* @example
|
|
207
|
+
* await unpublish('newsletters', 'abc123-def456')
|
|
208
|
+
*/
|
|
209
|
+
export async function unpublish(contentType, documentId) {
|
|
210
|
+
return strapiPut(`/api/${contentType}`, documentId, {
|
|
211
|
+
publishedAt: null,
|
|
212
|
+
});
|
|
213
|
+
}
|