optimal-cli 1.0.1 → 1.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.
Files changed (185) hide show
  1. package/.claude-plugin/marketplace.json +18 -0
  2. package/.claude-plugin/plugin.json +10 -0
  3. package/.env.example +17 -0
  4. package/CLAUDE.md +67 -0
  5. package/COMMANDS.md +264 -0
  6. package/PUBLISH.md +70 -0
  7. package/agents/content-ops.md +2 -2
  8. package/agents/financial-ops.md +2 -2
  9. package/agents/infra-ops.md +2 -2
  10. package/apps/.gitkeep +0 -0
  11. package/bin/optimal.ts +1418 -0
  12. package/docs/MIGRATION_NEEDED.md +37 -0
  13. package/docs/plans/.gitkeep +0 -0
  14. package/docs/plans/optimal-cli-config-registry-v1.md +71 -0
  15. package/hooks/.gitkeep +0 -0
  16. package/lib/budget/projections.ts +561 -0
  17. package/lib/budget/scenarios.ts +312 -0
  18. package/lib/cms/publish-blog.ts +129 -0
  19. package/lib/cms/strapi-client.ts +302 -0
  20. package/lib/config/registry.ts +229 -0
  21. package/lib/config/schema.ts +58 -0
  22. package/lib/config.ts +247 -0
  23. package/lib/infra/.gitkeep +0 -0
  24. package/lib/infra/deploy.ts +70 -0
  25. package/lib/infra/migrate.ts +141 -0
  26. package/lib/kanban-obsidian.ts +232 -0
  27. package/lib/kanban-sync.ts +258 -0
  28. package/lib/kanban.ts +239 -0
  29. package/lib/newsletter/.gitkeep +0 -0
  30. package/lib/newsletter/distribute.ts +256 -0
  31. package/{dist/lib/newsletter/generate-insurance.d.ts → lib/newsletter/generate-insurance.ts} +24 -7
  32. package/lib/newsletter/generate.ts +735 -0
  33. package/lib/obsidian-tasks.ts +231 -0
  34. package/lib/returnpro/.gitkeep +0 -0
  35. package/lib/returnpro/anomalies.ts +258 -0
  36. package/lib/returnpro/audit.ts +194 -0
  37. package/lib/returnpro/diagnose.ts +400 -0
  38. package/lib/returnpro/kpis.ts +255 -0
  39. package/lib/returnpro/templates.ts +323 -0
  40. package/lib/returnpro/upload-income.ts +311 -0
  41. package/lib/returnpro/upload-netsuite.ts +696 -0
  42. package/lib/returnpro/upload-r1.ts +563 -0
  43. package/lib/social/post-generator.ts +468 -0
  44. package/lib/social/publish.ts +301 -0
  45. package/lib/social/scraper.ts +503 -0
  46. package/lib/supabase.ts +25 -0
  47. package/lib/transactions/delete-batch.ts +258 -0
  48. package/lib/transactions/ingest.ts +659 -0
  49. package/lib/transactions/stamp.ts +654 -0
  50. package/package.json +5 -18
  51. package/pnpm-workspace.yaml +3 -0
  52. package/scripts/check-table.ts +24 -0
  53. package/scripts/create-tables.ts +94 -0
  54. package/scripts/migrate-kanban.sh +28 -0
  55. package/scripts/migrate-v2.ts +78 -0
  56. package/scripts/migrate.ts +79 -0
  57. package/scripts/run-migration.ts +59 -0
  58. package/scripts/seed-board.ts +203 -0
  59. package/scripts/test-kanban.ts +21 -0
  60. package/skills/audit-financials/SKILL.md +33 -0
  61. package/skills/board-create/SKILL.md +28 -0
  62. package/skills/board-update/SKILL.md +27 -0
  63. package/skills/board-view/SKILL.md +27 -0
  64. package/skills/delete-batch/SKILL.md +77 -0
  65. package/skills/deploy/SKILL.md +40 -0
  66. package/skills/diagnose-months/SKILL.md +68 -0
  67. package/skills/distribute-newsletter/SKILL.md +58 -0
  68. package/skills/export-budget/SKILL.md +44 -0
  69. package/skills/export-kpis/SKILL.md +52 -0
  70. package/skills/generate-netsuite-template/SKILL.md +51 -0
  71. package/skills/generate-newsletter/SKILL.md +53 -0
  72. package/skills/generate-newsletter-insurance/SKILL.md +59 -0
  73. package/skills/generate-social-posts/SKILL.md +67 -0
  74. package/skills/health-check/SKILL.md +42 -0
  75. package/skills/ingest-transactions/SKILL.md +51 -0
  76. package/skills/manage-cms/SKILL.md +50 -0
  77. package/skills/manage-scenarios/SKILL.md +83 -0
  78. package/skills/migrate-db/SKILL.md +79 -0
  79. package/skills/preview-newsletter/SKILL.md +50 -0
  80. package/skills/project-budget/SKILL.md +60 -0
  81. package/skills/publish-blog/SKILL.md +70 -0
  82. package/skills/publish-social-posts/SKILL.md +70 -0
  83. package/skills/rate-anomalies/SKILL.md +62 -0
  84. package/skills/scrape-ads/SKILL.md +49 -0
  85. package/skills/stamp-transactions/SKILL.md +62 -0
  86. package/skills/upload-income-statements/SKILL.md +54 -0
  87. package/skills/upload-netsuite/SKILL.md +56 -0
  88. package/skills/upload-r1/SKILL.md +45 -0
  89. package/supabase/.temp/cli-latest +1 -0
  90. package/supabase/migrations/.gitkeep +0 -0
  91. package/supabase/migrations/20250305000001_create_agent_configs.sql +36 -0
  92. package/supabase/migrations/20260305111300_create_cli_config_registry.sql +22 -0
  93. package/supabase/migrations/20260306195000_create_kanban_tables.sql +97 -0
  94. package/tests/config-command-smoke.test.ts +395 -0
  95. package/tests/config-registry.test.ts +173 -0
  96. package/tsconfig.json +19 -0
  97. package/agents/profiles.json +0 -5
  98. package/dist/bin/optimal.d.ts +0 -2
  99. package/dist/bin/optimal.js +0 -1590
  100. package/dist/lib/assets/index.d.ts +0 -79
  101. package/dist/lib/assets/index.js +0 -153
  102. package/dist/lib/assets.d.ts +0 -20
  103. package/dist/lib/assets.js +0 -112
  104. package/dist/lib/auth/index.d.ts +0 -83
  105. package/dist/lib/auth/index.js +0 -146
  106. package/dist/lib/board/index.d.ts +0 -39
  107. package/dist/lib/board/index.js +0 -285
  108. package/dist/lib/board/types.d.ts +0 -111
  109. package/dist/lib/board/types.js +0 -1
  110. package/dist/lib/bot/claim.d.ts +0 -3
  111. package/dist/lib/bot/claim.js +0 -20
  112. package/dist/lib/bot/coordinator.d.ts +0 -27
  113. package/dist/lib/bot/coordinator.js +0 -178
  114. package/dist/lib/bot/heartbeat.d.ts +0 -6
  115. package/dist/lib/bot/heartbeat.js +0 -30
  116. package/dist/lib/bot/index.d.ts +0 -9
  117. package/dist/lib/bot/index.js +0 -6
  118. package/dist/lib/bot/protocol.d.ts +0 -12
  119. package/dist/lib/bot/protocol.js +0 -74
  120. package/dist/lib/bot/reporter.d.ts +0 -3
  121. package/dist/lib/bot/reporter.js +0 -27
  122. package/dist/lib/bot/skills.d.ts +0 -26
  123. package/dist/lib/bot/skills.js +0 -69
  124. package/dist/lib/budget/projections.d.ts +0 -115
  125. package/dist/lib/budget/projections.js +0 -384
  126. package/dist/lib/budget/scenarios.d.ts +0 -93
  127. package/dist/lib/budget/scenarios.js +0 -214
  128. package/dist/lib/cms/publish-blog.d.ts +0 -62
  129. package/dist/lib/cms/publish-blog.js +0 -74
  130. package/dist/lib/cms/strapi-client.d.ts +0 -123
  131. package/dist/lib/cms/strapi-client.js +0 -213
  132. package/dist/lib/config/registry.d.ts +0 -17
  133. package/dist/lib/config/registry.js +0 -182
  134. package/dist/lib/config/schema.d.ts +0 -31
  135. package/dist/lib/config/schema.js +0 -25
  136. package/dist/lib/config.d.ts +0 -55
  137. package/dist/lib/config.js +0 -206
  138. package/dist/lib/errors.d.ts +0 -25
  139. package/dist/lib/errors.js +0 -91
  140. package/dist/lib/format.d.ts +0 -28
  141. package/dist/lib/format.js +0 -98
  142. package/dist/lib/infra/deploy.d.ts +0 -29
  143. package/dist/lib/infra/deploy.js +0 -58
  144. package/dist/lib/infra/migrate.d.ts +0 -34
  145. package/dist/lib/infra/migrate.js +0 -103
  146. package/dist/lib/newsletter/distribute.d.ts +0 -52
  147. package/dist/lib/newsletter/distribute.js +0 -193
  148. package/dist/lib/newsletter/generate-insurance.js +0 -36
  149. package/dist/lib/newsletter/generate.d.ts +0 -104
  150. package/dist/lib/newsletter/generate.js +0 -571
  151. package/dist/lib/returnpro/anomalies.d.ts +0 -64
  152. package/dist/lib/returnpro/anomalies.js +0 -166
  153. package/dist/lib/returnpro/audit.d.ts +0 -32
  154. package/dist/lib/returnpro/audit.js +0 -147
  155. package/dist/lib/returnpro/diagnose.d.ts +0 -52
  156. package/dist/lib/returnpro/diagnose.js +0 -281
  157. package/dist/lib/returnpro/kpis.d.ts +0 -32
  158. package/dist/lib/returnpro/kpis.js +0 -192
  159. package/dist/lib/returnpro/templates.d.ts +0 -48
  160. package/dist/lib/returnpro/templates.js +0 -229
  161. package/dist/lib/returnpro/upload-income.d.ts +0 -25
  162. package/dist/lib/returnpro/upload-income.js +0 -235
  163. package/dist/lib/returnpro/upload-netsuite.d.ts +0 -37
  164. package/dist/lib/returnpro/upload-netsuite.js +0 -566
  165. package/dist/lib/returnpro/upload-r1.d.ts +0 -48
  166. package/dist/lib/returnpro/upload-r1.js +0 -398
  167. package/dist/lib/returnpro/validate.d.ts +0 -37
  168. package/dist/lib/returnpro/validate.js +0 -124
  169. package/dist/lib/social/meta.d.ts +0 -90
  170. package/dist/lib/social/meta.js +0 -160
  171. package/dist/lib/social/post-generator.d.ts +0 -83
  172. package/dist/lib/social/post-generator.js +0 -333
  173. package/dist/lib/social/publish.d.ts +0 -66
  174. package/dist/lib/social/publish.js +0 -226
  175. package/dist/lib/social/scraper.d.ts +0 -67
  176. package/dist/lib/social/scraper.js +0 -361
  177. package/dist/lib/supabase.d.ts +0 -4
  178. package/dist/lib/supabase.js +0 -20
  179. package/dist/lib/transactions/delete-batch.d.ts +0 -60
  180. package/dist/lib/transactions/delete-batch.js +0 -203
  181. package/dist/lib/transactions/ingest.d.ts +0 -43
  182. package/dist/lib/transactions/ingest.js +0 -555
  183. package/dist/lib/transactions/stamp.d.ts +0 -51
  184. package/dist/lib/transactions/stamp.js +0 -524
  185. package/docs/CLI-REFERENCE.md +0 -361
@@ -1,398 +0,0 @@
1
- import * as fs from 'fs';
2
- import * as path from 'path';
3
- import ExcelJS from 'exceljs';
4
- import { getSupabase } from '../supabase.js';
5
- // ---------------------------------------------------------------------------
6
- // Constants
7
- // ---------------------------------------------------------------------------
8
- /**
9
- * Account code / ID for Checked-In Qty, which is the primary volume type
10
- * produced by a standard R1 upload. This matches the dashboard's volume-configs.ts.
11
- *
12
- * account_code: "Checked-In Qty"
13
- * account_id: 130
14
- */
15
- const CHECKED_IN_ACCOUNT_CODE = 'Checked-In Qty';
16
- const CHECKED_IN_ACCOUNT_ID = 130;
17
- const CHUNK_SIZE = 500;
18
- const PAGE_SIZE = 1000;
19
- // ---------------------------------------------------------------------------
20
- // Helpers
21
- // ---------------------------------------------------------------------------
22
- /**
23
- * Extract location from a ProgramName / program code string.
24
- * - If starts with "DS-", location is "DS"
25
- * - Otherwise, first 5 characters (uppercased)
26
- * Mirrors dashboard-returnpro/lib/r1-monthly/processing.ts extractLocation()
27
- */
28
- function extractLocation(programName) {
29
- const trimmed = programName.trim();
30
- if (!trimmed)
31
- return 'UNKNOWN';
32
- if (trimmed.startsWith('DS-'))
33
- return 'DS';
34
- return trimmed.substring(0, 5).toUpperCase();
35
- }
36
- /**
37
- * Split an array into fixed-size chunks for batched inserts.
38
- */
39
- function chunk(arr, size) {
40
- const out = [];
41
- for (let i = 0; i < arr.length; i += size) {
42
- out.push(arr.slice(i, i + size));
43
- }
44
- return out;
45
- }
46
- // ---------------------------------------------------------------------------
47
- // Supabase dim table lookups
48
- // ---------------------------------------------------------------------------
49
- /**
50
- * Fetch all dim_program_id rows and build a map: program_code -> program_id_key.
51
- * Fetches ALL rows in pages to avoid the 1000-row Supabase cap.
52
- * First occurrence wins for any duplicate program_code.
53
- */
54
- async function buildProgramIdKeyMap(supabaseUrl, supabaseKey) {
55
- const map = new Map();
56
- let offset = 0;
57
- while (true) {
58
- const url = `${supabaseUrl}/rest/v1/dim_program_id` +
59
- `?select=program_id_key,program_code` +
60
- `&order=program_id_key` +
61
- `&offset=${offset}&limit=${PAGE_SIZE}`;
62
- const res = await fetch(url, {
63
- headers: { apikey: supabaseKey, Authorization: `Bearer ${supabaseKey}` },
64
- });
65
- if (!res.ok) {
66
- const text = await res.text();
67
- throw new Error(`Failed to fetch dim_program_id: ${text}`);
68
- }
69
- const rows = (await res.json());
70
- for (const row of rows) {
71
- if (!map.has(row.program_code)) {
72
- map.set(row.program_code, row.program_id_key);
73
- }
74
- }
75
- if (rows.length < PAGE_SIZE)
76
- break;
77
- offset += PAGE_SIZE;
78
- }
79
- return map;
80
- }
81
- /**
82
- * Fetch all dim_master_program rows and build a map: master_name -> {master_program_id, client_id}.
83
- * Fetches ALL rows in pages to avoid the 1000-row Supabase cap.
84
- */
85
- async function buildMasterProgramMap(supabaseUrl, supabaseKey) {
86
- const map = new Map();
87
- let offset = 0;
88
- while (true) {
89
- const url = `${supabaseUrl}/rest/v1/dim_master_program` +
90
- `?select=master_program_id,master_name,client_id` +
91
- `&order=master_program_id` +
92
- `&offset=${offset}&limit=${PAGE_SIZE}`;
93
- const res = await fetch(url, {
94
- headers: { apikey: supabaseKey, Authorization: `Bearer ${supabaseKey}` },
95
- });
96
- if (!res.ok) {
97
- const text = await res.text();
98
- throw new Error(`Failed to fetch dim_master_program: ${text}`);
99
- }
100
- const rows = (await res.json());
101
- for (const row of rows) {
102
- if (!map.has(row.master_name)) {
103
- map.set(row.master_name, row);
104
- }
105
- }
106
- if (rows.length < PAGE_SIZE)
107
- break;
108
- offset += PAGE_SIZE;
109
- }
110
- return map;
111
- }
112
- // ---------------------------------------------------------------------------
113
- // XLSX parsing
114
- // ---------------------------------------------------------------------------
115
- /**
116
- * Parse the first sheet of an R1 XLSX file into R1Row records.
117
- *
118
- * Required columns (case-sensitive, matching the Rust WASM parser):
119
- * ProgramName
120
- * Master Program Name
121
- * TRGID
122
- *
123
- * Optional columns:
124
- * LocationID
125
- * MR_LMR_UPC_AverageCategoryRetail (or "RetailPrice" / "Retail Price")
126
- *
127
- * Returns { rows, totalRead, skipped, warnings }
128
- */
129
- async function parseR1Xlsx(filePath) {
130
- const warnings = [];
131
- const workbook = new ExcelJS.Workbook();
132
- await workbook.xlsx.readFile(filePath);
133
- const worksheet = workbook.worksheets[0];
134
- if (!worksheet) {
135
- throw new Error(`R1 XLSX has no worksheets: ${filePath}`);
136
- }
137
- // Read header row (row 1)
138
- const headerRow = worksheet.getRow(1);
139
- const headers = new Map();
140
- headerRow.eachCell({ includeEmpty: false }, (cell, colNum) => {
141
- const val = String(cell.value ?? '').trim();
142
- if (val)
143
- headers.set(val, colNum);
144
- });
145
- // Resolve required column indices
146
- const programCol = headers.get('ProgramName');
147
- const masterCol = headers.get('Master Program Name');
148
- const trgidCol = headers.get('TRGID');
149
- if (programCol === undefined || masterCol === undefined || trgidCol === undefined) {
150
- throw new Error(`R1 XLSX missing required columns. ` +
151
- `Expected: ProgramName, Master Program Name, TRGID. ` +
152
- `Found: ${[...headers.keys()].join(', ')}`);
153
- }
154
- // Resolve optional column indices
155
- const locationCol = headers.get('LocationID');
156
- const retailCol = headers.get('MR_LMR_UPC_AverageCategoryRetail') ??
157
- headers.get('RetailPrice') ??
158
- headers.get('Retail Price');
159
- const rows = [];
160
- let totalRead = 0;
161
- let skipped = 0;
162
- worksheet.eachRow({ includeEmpty: false }, (row, rowNum) => {
163
- if (rowNum === 1)
164
- return; // skip header
165
- totalRead++;
166
- const programCode = String(row.getCell(programCol).value ?? '').trim();
167
- const masterProgram = String(row.getCell(masterCol).value ?? '').trim();
168
- const trgid = String(row.getCell(trgidCol).value ?? '').trim();
169
- // Skip rows missing the three required values
170
- if (!programCode || !masterProgram || !trgid) {
171
- skipped++;
172
- return;
173
- }
174
- const locationId = locationCol
175
- ? String(row.getCell(locationCol).value ?? '').trim()
176
- : '';
177
- let avgRetail = null;
178
- if (retailCol !== undefined) {
179
- const rawRetail = row.getCell(retailCol).value;
180
- if (rawRetail !== null && rawRetail !== undefined && rawRetail !== '') {
181
- const parsed = typeof rawRetail === 'number' ? rawRetail : parseFloat(String(rawRetail));
182
- if (!isNaN(parsed) && parsed > 0)
183
- avgRetail = parsed;
184
- }
185
- }
186
- rows.push({ programCode, masterProgram, trgid, locationId, avgRetail });
187
- });
188
- if (rows.length === 0 && totalRead > 0) {
189
- warnings.push(`All ${totalRead} rows were skipped (missing ProgramName, Master Program Name, or TRGID). ` +
190
- `Check that the first sheet contains data with the expected column headers.`);
191
- }
192
- return { rows, totalRead, skipped, warnings };
193
- }
194
- // ---------------------------------------------------------------------------
195
- // Aggregation
196
- // ---------------------------------------------------------------------------
197
- /**
198
- * Aggregate raw R1 rows into per-(masterProgram, programCode, location) groups.
199
- * For each group we track distinct TRGIDs and distinct LocationIDs.
200
- * This mirrors the Rust WASM aggregation in wasm/r1-parser/src/lib.rs.
201
- *
202
- * The count stored in `amount` uses distinct TRGID count (Checked-In Qty
203
- * for most programs). The LocationID set is retained for callers that need it.
204
- */
205
- function aggregateRows(rows) {
206
- const groups = new Map();
207
- for (const row of rows) {
208
- const location = extractLocation(row.programCode);
209
- const key = `${row.masterProgram}|||${row.programCode}|||${location}`;
210
- let group = groups.get(key);
211
- if (!group) {
212
- group = {
213
- masterProgram: row.masterProgram,
214
- masterProgramId: null,
215
- programCode: row.programCode,
216
- programIdKey: null,
217
- clientId: null,
218
- location,
219
- trgidSet: new Set(),
220
- locationIdSet: new Set(),
221
- };
222
- groups.set(key, group);
223
- }
224
- group.trgidSet.add(row.trgid);
225
- if (row.locationId)
226
- group.locationIdSet.add(row.locationId);
227
- }
228
- return groups;
229
- }
230
- // ---------------------------------------------------------------------------
231
- // Insertion helpers
232
- // ---------------------------------------------------------------------------
233
- /**
234
- * Insert a batch of rows directly into stg_financials_raw via PostgREST.
235
- * Returns the number of rows inserted (or throws on failure).
236
- */
237
- async function insertBatch(supabaseUrl, supabaseKey, rows) {
238
- const res = await fetch(`${supabaseUrl}/rest/v1/stg_financials_raw`, {
239
- method: 'POST',
240
- headers: {
241
- apikey: supabaseKey,
242
- Authorization: `Bearer ${supabaseKey}`,
243
- 'Content-Type': 'application/json',
244
- Prefer: 'return=minimal',
245
- },
246
- body: JSON.stringify(rows),
247
- });
248
- if (!res.ok) {
249
- const text = await res.text();
250
- let message = text || res.statusText;
251
- try {
252
- const payload = JSON.parse(text);
253
- message = payload.message ?? message;
254
- if (payload.hint)
255
- message += ` (Hint: ${payload.hint})`;
256
- if (payload.details)
257
- message += ` (Details: ${payload.details})`;
258
- }
259
- catch {
260
- // use raw text
261
- }
262
- throw new Error(`Insert batch failed: ${message}`);
263
- }
264
- return rows.length;
265
- }
266
- // ---------------------------------------------------------------------------
267
- // Main export
268
- // ---------------------------------------------------------------------------
269
- /**
270
- * Parse an R1 XLSX file, aggregate financial data by program, and insert
271
- * into the ReturnPro `stg_financials_raw` staging table.
272
- *
273
- * Flow:
274
- * 1. Read and parse the XLSX (first sheet, required columns: ProgramName,
275
- * Master Program Name, TRGID).
276
- * 2. Aggregate rows into (masterProgram, programCode, location) groups,
277
- * counting distinct TRGIDs per group.
278
- * 3. Look up dim_master_program and dim_program_id to resolve FK columns.
279
- * 4. Insert into stg_financials_raw in batches of 500.
280
- *
281
- * @param filePath Absolute path to the R1 XLSX file on disk.
282
- * @param userId The user_id to stamp on each inserted row.
283
- * @param monthYear Target month in "YYYY-MM" format (e.g. "2025-10").
284
- * Stored as the `date` column as "YYYY-MM-01".
285
- */
286
- export async function processR1Upload(filePath, userId, monthYear) {
287
- if (!fs.existsSync(filePath)) {
288
- throw new Error(`File not found: ${filePath}`);
289
- }
290
- // Validate monthYear format
291
- if (!/^\d{4}-\d{2}$/.test(monthYear)) {
292
- throw new Error(`monthYear must be in YYYY-MM format (e.g. "2025-10"), got: "${monthYear}"`);
293
- }
294
- const sourceFileName = path.basename(filePath);
295
- const dateStr = `${monthYear}-01`;
296
- const loadedAt = new Date().toISOString();
297
- const warnings = [];
298
- // -------------------------------------------------------------------------
299
- // 1. Parse XLSX
300
- // -------------------------------------------------------------------------
301
- const { rows: rawRows, totalRead, skipped, warnings: parseWarnings } = await parseR1Xlsx(filePath);
302
- warnings.push(...parseWarnings);
303
- if (rawRows.length === 0) {
304
- return {
305
- sourceFileName,
306
- date: dateStr,
307
- totalRowsRead: totalRead,
308
- rowsSkipped: skipped,
309
- programGroupsFound: 0,
310
- rowsInserted: 0,
311
- warnings,
312
- };
313
- }
314
- // -------------------------------------------------------------------------
315
- // 2. Aggregate rows into groups
316
- // -------------------------------------------------------------------------
317
- const groups = aggregateRows(rawRows);
318
- // -------------------------------------------------------------------------
319
- // 3. Fetch dim tables for FK resolution
320
- // -------------------------------------------------------------------------
321
- const sb = getSupabase('returnpro');
322
- // Pull the connection URL + key from the client's config via env (same env
323
- // vars that supabase.ts reads from process.env)
324
- const supabaseUrl = process.env['RETURNPRO_SUPABASE_URL'];
325
- const supabaseKey = process.env['RETURNPRO_SUPABASE_SERVICE_KEY'];
326
- if (!supabaseUrl || !supabaseKey) {
327
- throw new Error('Missing env vars: RETURNPRO_SUPABASE_URL, RETURNPRO_SUPABASE_SERVICE_KEY');
328
- }
329
- const [programIdKeyMap, masterProgramMap] = await Promise.all([
330
- buildProgramIdKeyMap(supabaseUrl, supabaseKey),
331
- buildMasterProgramMap(supabaseUrl, supabaseKey),
332
- ]);
333
- // Track master programs not found in dim_master_program
334
- const unknownMasterPrograms = new Set();
335
- // -------------------------------------------------------------------------
336
- // 4. Build insert rows
337
- // -------------------------------------------------------------------------
338
- const insertRows = [];
339
- for (const [, group] of groups) {
340
- const masterDim = masterProgramMap.get(group.masterProgram);
341
- if (!masterDim) {
342
- unknownMasterPrograms.add(group.masterProgram);
343
- }
344
- const trgidCount = group.trgidSet.size;
345
- if (trgidCount === 0)
346
- continue;
347
- insertRows.push({
348
- source_file_name: sourceFileName,
349
- loaded_at: loadedAt,
350
- user_id: userId,
351
- location: group.location,
352
- master_program: group.masterProgram,
353
- program_code: group.programCode,
354
- program_id_key: programIdKeyMap.get(group.programCode) ?? null,
355
- date: dateStr,
356
- account_code: CHECKED_IN_ACCOUNT_CODE,
357
- account_id: CHECKED_IN_ACCOUNT_ID,
358
- // amount is TEXT in stg_financials_raw — store as string
359
- amount: String(trgidCount),
360
- mode: 'actual',
361
- master_program_id: masterDim?.master_program_id ?? null,
362
- client_id: masterDim?.client_id ?? null,
363
- });
364
- }
365
- if (unknownMasterPrograms.size > 0) {
366
- warnings.push(`${unknownMasterPrograms.size} master program(s) not found in dim_master_program ` +
367
- `(master_program_id and client_id will be NULL): ` +
368
- [...unknownMasterPrograms].sort().join(', '));
369
- }
370
- // -------------------------------------------------------------------------
371
- // 5. Insert in batches of CHUNK_SIZE
372
- // -------------------------------------------------------------------------
373
- let totalInserted = 0;
374
- const batches = chunk(insertRows, CHUNK_SIZE);
375
- for (const [i, batch] of batches.entries()) {
376
- try {
377
- const inserted = await insertBatch(supabaseUrl, supabaseKey, batch);
378
- totalInserted += inserted;
379
- }
380
- catch (err) {
381
- const message = err instanceof Error ? err.message : String(err);
382
- throw new Error(`Batch ${i + 1}/${batches.length} insert failed after ${totalInserted} rows inserted: ${message}`);
383
- }
384
- }
385
- // Suppress unused variable warning — sb is a valid Supabase client kept
386
- // as a reference for future use (e.g. RPC calls). The dim lookups above
387
- // use raw fetch for pagination control (no .range() on PostgREST URL).
388
- void sb;
389
- return {
390
- sourceFileName,
391
- date: dateStr,
392
- totalRowsRead: totalRead,
393
- rowsSkipped: skipped,
394
- programGroupsFound: groups.size,
395
- rowsInserted: totalInserted,
396
- warnings,
397
- };
398
- }
@@ -1,37 +0,0 @@
1
- export interface ValidationResult {
2
- valid: boolean;
3
- errors: string[];
4
- }
5
- export interface BatchValidationResult {
6
- totalRows: number;
7
- validRows: number;
8
- invalidRows: number;
9
- errors: Array<{
10
- row: number;
11
- errors: string[];
12
- }>;
13
- }
14
- /**
15
- * Validate a single row destined for `stg_financials_raw`.
16
- *
17
- * Checks:
18
- * - Required fields: client, program, account_code, amount, period
19
- * - `amount` is numeric (the DB column is TEXT, so non-numeric strings are a common bug)
20
- * - `period` matches YYYY-MM format
21
- */
22
- export declare function validateFinancialRow(row: Record<string, unknown>): ValidationResult;
23
- /**
24
- * Validate a batch of rows destined for `stg_financials_raw`.
25
- *
26
- * Runs `validateFinancialRow` on each row and aggregates results.
27
- */
28
- export declare function validateBatch(rows: Record<string, unknown>[]): BatchValidationResult;
29
- /**
30
- * Validate a single row destined for `confirmed_income_statements`.
31
- *
32
- * Checks:
33
- * - Required fields: account_id, client_id, amount, period, source
34
- * - `amount` is numeric
35
- * - `period` matches YYYY-MM format
36
- */
37
- export declare function validateIncomeStatementRow(row: Record<string, unknown>): ValidationResult;
@@ -1,124 +0,0 @@
1
- // ---------------------------------------------------------------------------
2
- // ReturnPro data validation
3
- // ---------------------------------------------------------------------------
4
- //
5
- // Pre-insert validators for stg_financials_raw and confirmed_income_statements.
6
- // No external dependencies — pure TypeScript, no Supabase calls.
7
- // ---------------------------------------------------------------------------
8
- // --- Constants ---
9
- /** Required fields for a stg_financials_raw row. */
10
- const FINANCIAL_REQUIRED_FIELDS = ['client', 'program', 'account_code', 'amount', 'period'];
11
- /** Required fields for a confirmed_income_statements row. */
12
- const INCOME_REQUIRED_FIELDS = ['account_id', 'client_id', 'amount', 'period', 'source'];
13
- /** Matches YYYY-MM format (e.g. "2025-04"). */
14
- const PERIOD_REGEX = /^\d{4}-(0[1-9]|1[0-2])$/;
15
- // --- Helpers ---
16
- /**
17
- * Check whether a value is present (not null, undefined, or empty string).
18
- */
19
- function isPresent(value) {
20
- if (value === null || value === undefined)
21
- return false;
22
- if (typeof value === 'string' && value.trim() === '')
23
- return false;
24
- return true;
25
- }
26
- /**
27
- * Check whether a value can be parsed as a finite number.
28
- * Accepts numbers and numeric strings. Rejects NaN, Infinity, empty strings.
29
- */
30
- function isNumeric(value) {
31
- if (typeof value === 'number')
32
- return isFinite(value);
33
- if (typeof value === 'string') {
34
- const trimmed = value.trim();
35
- if (trimmed === '')
36
- return false;
37
- const parsed = Number(trimmed);
38
- return isFinite(parsed);
39
- }
40
- return false;
41
- }
42
- // --- Core validators ---
43
- /**
44
- * Validate a single row destined for `stg_financials_raw`.
45
- *
46
- * Checks:
47
- * - Required fields: client, program, account_code, amount, period
48
- * - `amount` is numeric (the DB column is TEXT, so non-numeric strings are a common bug)
49
- * - `period` matches YYYY-MM format
50
- */
51
- export function validateFinancialRow(row) {
52
- const errors = [];
53
- // Check required fields
54
- for (const field of FINANCIAL_REQUIRED_FIELDS) {
55
- if (!isPresent(row[field])) {
56
- errors.push(`Missing required field: ${field}`);
57
- }
58
- }
59
- // Validate amount is numeric (only if present, to avoid duplicate error)
60
- if (isPresent(row.amount) && !isNumeric(row.amount)) {
61
- errors.push(`amount must be numeric, got: ${JSON.stringify(row.amount)}`);
62
- }
63
- // Validate period format (only if present)
64
- if (isPresent(row.period)) {
65
- const period = String(row.period).trim();
66
- if (!PERIOD_REGEX.test(period)) {
67
- errors.push(`period must be YYYY-MM format (e.g. "2025-04"), got: "${period}"`);
68
- }
69
- }
70
- return { valid: errors.length === 0, errors };
71
- }
72
- /**
73
- * Validate a batch of rows destined for `stg_financials_raw`.
74
- *
75
- * Runs `validateFinancialRow` on each row and aggregates results.
76
- */
77
- export function validateBatch(rows) {
78
- const batchErrors = [];
79
- let validRows = 0;
80
- for (let i = 0; i < rows.length; i++) {
81
- const result = validateFinancialRow(rows[i]);
82
- if (result.valid) {
83
- validRows++;
84
- }
85
- else {
86
- batchErrors.push({ row: i, errors: result.errors });
87
- }
88
- }
89
- return {
90
- totalRows: rows.length,
91
- validRows,
92
- invalidRows: rows.length - validRows,
93
- errors: batchErrors,
94
- };
95
- }
96
- /**
97
- * Validate a single row destined for `confirmed_income_statements`.
98
- *
99
- * Checks:
100
- * - Required fields: account_id, client_id, amount, period, source
101
- * - `amount` is numeric
102
- * - `period` matches YYYY-MM format
103
- */
104
- export function validateIncomeStatementRow(row) {
105
- const errors = [];
106
- // Check required fields
107
- for (const field of INCOME_REQUIRED_FIELDS) {
108
- if (!isPresent(row[field])) {
109
- errors.push(`Missing required field: ${field}`);
110
- }
111
- }
112
- // Validate amount is numeric (only if present)
113
- if (isPresent(row.amount) && !isNumeric(row.amount)) {
114
- errors.push(`amount must be numeric, got: ${JSON.stringify(row.amount)}`);
115
- }
116
- // Validate period format (only if present)
117
- if (isPresent(row.period)) {
118
- const period = String(row.period).trim();
119
- if (!PERIOD_REGEX.test(period)) {
120
- errors.push(`period must be YYYY-MM format (e.g. "2025-04"), got: "${period}"`);
121
- }
122
- }
123
- return { valid: errors.length === 0, errors };
124
- }
@@ -1,90 +0,0 @@
1
- /**
2
- * Meta Graph API — Instagram Content Publishing
3
- *
4
- * Direct Instagram publishing via Meta's Content Publishing API.
5
- * Replaces n8n webhook intermediary for IG posts.
6
- *
7
- * Functions:
8
- * publishIgPhoto() — Publish a single image post to Instagram
9
- * publishIgCarousel() — Publish a carousel (multi-image) post to Instagram
10
- * getMetaConfig() — Read Meta credentials from env vars
11
- * getMetaConfigForBrand() — Read brand-specific Meta credentials
12
- */
13
- export interface MetaConfig {
14
- accessToken: string;
15
- igAccountId: string;
16
- }
17
- export interface PublishIgResult {
18
- containerId: string;
19
- mediaId: string;
20
- }
21
- export interface PublishIgPhotoOptions {
22
- imageUrl: string;
23
- caption: string;
24
- }
25
- export interface CarouselItem {
26
- imageUrl: string;
27
- }
28
- export interface PublishIgCarouselOptions {
29
- caption: string;
30
- items: CarouselItem[];
31
- }
32
- export declare class MetaApiError extends Error {
33
- status: number;
34
- metaError?: {
35
- message: string;
36
- type?: string;
37
- code?: number;
38
- } | undefined;
39
- constructor(message: string, status: number, metaError?: {
40
- message: string;
41
- type?: string;
42
- code?: number;
43
- } | undefined);
44
- }
45
- export declare function setFetchForTests(fn: typeof globalThis.fetch): void;
46
- export declare function resetFetchForTests(): void;
47
- /**
48
- * Read Meta API credentials from environment variables.
49
- * Requires: META_ACCESS_TOKEN, META_IG_ACCOUNT_ID
50
- */
51
- export declare function getMetaConfig(): MetaConfig;
52
- /**
53
- * Read Meta API credentials for a specific brand.
54
- * Looks for META_IG_ACCOUNT_ID_{BRAND} first, falls back to META_IG_ACCOUNT_ID.
55
- * Brand key is normalized: hyphens become underscores (CRE-11TRUST → CRE_11TRUST).
56
- */
57
- export declare function getMetaConfigForBrand(brand: string): MetaConfig;
58
- /**
59
- * Publish a single photo to Instagram.
60
- *
61
- * Two-step process per Meta Content Publishing API:
62
- * 1. Create media container with image_url + caption
63
- * 2. Publish the container
64
- *
65
- * @example
66
- * const result = await publishIgPhoto(config, {
67
- * imageUrl: 'https://cdn.example.com/photo.jpg',
68
- * caption: 'Check out our latest listing! #realestate',
69
- * })
70
- * console.log(`Published: ${result.mediaId}`)
71
- */
72
- export declare function publishIgPhoto(config: MetaConfig, opts: PublishIgPhotoOptions): Promise<PublishIgResult>;
73
- /**
74
- * Publish a carousel (multi-image) post to Instagram.
75
- *
76
- * Three-step process:
77
- * 1. Create individual item containers (is_carousel_item=true)
78
- * 2. Create carousel container referencing all item IDs
79
- * 3. Publish the carousel container
80
- *
81
- * @example
82
- * const result = await publishIgCarousel(config, {
83
- * caption: 'Property tour highlights',
84
- * items: [
85
- * { imageUrl: 'https://cdn.example.com/1.jpg' },
86
- * { imageUrl: 'https://cdn.example.com/2.jpg' },
87
- * ],
88
- * })
89
- */
90
- export declare function publishIgCarousel(config: MetaConfig, opts: PublishIgCarouselOptions): Promise<PublishIgResult>;