@meyicloud/meyi-cost-server 1.4.1 → 1.5.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/AGENTS.md CHANGED
@@ -76,6 +76,7 @@ flowchart LR
76
76
  The plugin currently owns these route groups under `${apiBaseUri}/cost`:
77
77
 
78
78
  - `GET /accounts`, `/data-status`, `/overview`, `/filter-options`, `/reports`, `/tags`
79
+ - `GET`/`PUT /analysis/schedule`, `GET /analysis/reports`, and tenant-scoped PDF downloads
79
80
  - `GET /budgets` and `POST /budgets`
80
81
  - `DELETE /budgets/:id`
81
82
  - `GET /budget-alert-dismissals` and `POST /budget-alert-dismissals`
package/README.md CHANGED
@@ -30,6 +30,9 @@ and AWS onboarding. The plugin owns cost routes and its budget-related tables.
30
30
  - Explicit CUR setup, pending-data, and unavailable states
31
31
  - Tenant-scoped budget rules
32
32
  - User-, tenant-, month-, and status-scoped budget-alert dismissals
33
+ - Tenant-configurable daily, weekly, and monthly AI report schedules
34
+ - PostgreSQL-backed background report jobs with failure history
35
+ - Server-generated PDF reports available through tenant-scoped downloads
33
36
 
34
37
  Budgets are application rules stored in PostgreSQL; they are not AWS Budgets
35
38
  resources. The consuming application evaluates them against current cost and
@@ -94,7 +97,7 @@ For a local package installation test:
94
97
 
95
98
  ```bash
96
99
  npm pack
97
- npm install /path/to/meyicloud-meyi-cost-server-1.4.1.tgz
100
+ npm install /path/to/meyicloud-meyi-cost-server-1.5.0.tgz
98
101
  ```
99
102
 
100
103
  ## Publish to npm
@@ -257,17 +260,37 @@ SaaS Athena resources.
257
260
 
258
261
  AI analysis is optional and invokes Amazon Bedrock from the backend runtime.
259
262
  Only aggregated cost facts are sent to the model; tenant IDs, AWS credentials,
260
- resource IDs, and raw CUR rows are excluded.
263
+ resource IDs, and raw CUR rows are excluded. AI reports are generated by a
264
+ background worker, not by a browser request. Daily reports cover the previous
265
+ complete day, weekly reports cover the previous seven complete days, and
266
+ monthly reports cover the previous calendar month in the configured timezone.
267
+
268
+ The authenticated report API is:
269
+
270
+ | Method | Route | Purpose |
271
+ | --- | --- | --- |
272
+ | `GET` | `/analysis/schedule` | Read this tenant's schedule. |
273
+ | `PUT` | `/analysis/schedule` | Update the schedule; requires `req.user.role=admin`. |
274
+ | `GET` | `/analysis/reports` | List tenant report history without PDF bytes. |
275
+ | `GET` | `/analysis/reports/:id` | Read one tenant report. |
276
+ | `GET` | `/analysis/reports/:id/pdf` | Download a completed report PDF. |
277
+
278
+ Schedule and report rows live in `cost_ai_report_schedules` and
279
+ `cost_ai_reports`. The worker atomically claims due schedules with a lease, so
280
+ a restarted task can resume future work without relying on in-memory timers.
261
281
 
262
282
  | Variable | Default | Required | Purpose |
263
283
  | --- | --- | --- | --- |
264
284
  | `COST_AI_ENABLED` | `false` | No | Enables the AI analysis endpoints and Bedrock client. |
265
285
  | `COST_AI_REGION` | `AWS_REGION` or `us-east-1` | When enabled | Region used by the Bedrock Runtime client. |
266
286
  | `COST_AI_MODEL_ID` | Global Claude Sonnet inference profile | When enabled | Bedrock model or inference-profile ID. |
267
- | `COST_AI_MAX_TOKENS` | `1600` | No | Maximum response tokens, clamped from 600 to 3000. |
287
+ | `COST_AI_MAX_TOKENS` | `4000` | No | Maximum response tokens, clamped from 600 to 8000. |
268
288
  | `COST_AI_CACHE_TTL_MS` | `21600000` | No | Tenant analysis cache duration; defaults to six hours. |
269
289
  | `COST_AI_HOURLY_LIMIT` | `6` | No | Maximum non-cached generations per tenant per hour. |
270
290
  | `COST_AI_TIMEOUT_MS` | `120000` | No | Per-request timeout for key-based providers; Bedrock uses the SDK default. |
291
+ | `COST_AI_REPORT_POLL_INTERVAL_MS` | `60000` | No | How often the background worker claims due schedules; minimum 10 seconds. |
292
+ | `COST_AI_REPORT_LEASE_MS` | `1800000` | No | Claim lease used to recover from an interrupted worker. |
293
+ | `COST_AI_REPORT_BATCH_SIZE` | `5` | No | Maximum schedules claimed by one worker tick; clamped from 1 to 25. |
271
294
 
272
295
  ### Choosing a provider
273
296
 
@@ -305,8 +328,9 @@ tenant, including a `source` of `tenant-configuration` or `environment`.
305
328
 
306
329
  The runtime role needs `bedrock:InvokeModel` for the selected model or
307
330
  inference profile. Analysis results are stored in the tenant-scoped
308
- `cost_ai_analyses` table. Model or permission failures affect only the analysis
309
- request and do not change CUR, reports, or budgets.
331
+ `cost_ai_analyses` table and scheduled PDF jobs are stored in
332
+ `cost_ai_reports`. Model or permission failures mark only that scheduled report
333
+ as failed and do not change CUR, standard reports, onboarding, or budgets.
310
334
 
311
335
  ### CUR/Athena variables
312
336
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meyicloud/meyi-cost-server",
3
- "version": "1.4.1",
3
+ "version": "1.5.0",
4
4
  "description": "Tenant-aware AWS CUR and Athena cost plugin server for MeyiConnect",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -25,7 +25,9 @@
25
25
  "@aws-sdk/client-sts": "^3.850.0",
26
26
  "@aws-sdk/credential-providers": "^3.850.0",
27
27
  "drizzle-orm": "^0.44.7",
28
- "express": "^4.21.1"
28
+ "express": "^4.21.1",
29
+ "luxon": "^3.7.2",
30
+ "pdfkit": "^0.17.2"
29
31
  },
30
32
  "peerDependencies": {
31
33
  "pg": ">=8"
@@ -1,8 +1,7 @@
1
- import { dateRange } from "../lib/cost-utils.js";
2
-
3
1
  export class CostAnalysisController {
4
- constructor({ service }) {
2
+ constructor({ service, reportService }) {
5
3
  this.service = service;
4
+ this.reportService = reportService;
6
5
  }
7
6
 
8
7
  async status(req, res, next) {
@@ -17,12 +16,33 @@ export class CostAnalysisController {
17
16
  } catch (error) { return next(error); }
18
17
  }
19
18
 
20
- async generate(req, res, next) {
19
+ async schedule(req, res, next) {
20
+ try { return res.json(await this.reportService.getSchedule(req)); }
21
+ catch (error) { return next(error); }
22
+ }
23
+
24
+ async updateSchedule(req, res, next) {
25
+ try { return res.json(await this.reportService.saveSchedule(req, req.body || {})); }
26
+ catch (error) { return next(error); }
27
+ }
28
+
29
+ async reports(req, res, next) {
30
+ try { return res.json({ reports: await this.reportService.list(req, req.query.limit) }); }
31
+ catch (error) { return next(error); }
32
+ }
33
+
34
+ async report(req, res, next) {
35
+ try { return res.json(await this.reportService.get(req, req.params.id)); }
36
+ catch (error) { return next(error); }
37
+ }
38
+
39
+ async downloadPdf(req, res, next) {
21
40
  try {
22
- const range = dateRange(req.body || {});
23
- const durationDays = (new Date(`${range.End}T00:00:00Z`) - new Date(`${range.Start}T00:00:00Z`)) / 86_400_000;
24
- if (durationDays > 366) return res.status(400).json({ error: "AI analysis supports a maximum range of 366 days" });
25
- return res.json(await this.service.analyze(req, range));
41
+ const report = await this.reportService.pdf(req, req.params.id);
42
+ res.setHeader("Content-Type", "application/pdf");
43
+ res.setHeader("Content-Disposition", `attachment; filename="${report.filename}"`);
44
+ res.setHeader("Content-Length", report.data.length);
45
+ return res.send(report.data);
26
46
  } catch (error) { return next(error); }
27
47
  }
28
48
  }
@@ -0,0 +1,45 @@
1
+ import { DateTime, IANAZone } from "luxon";
2
+
3
+ const FREQUENCIES = new Set(["daily", "weekly", "monthly"]);
4
+
5
+ export function validateSchedule(input = {}) {
6
+ const frequency = String(input.frequency || "weekly").toLowerCase();
7
+ const timezone = String(input.timezone || "UTC").trim();
8
+ const time = String(input.time || "09:00").trim();
9
+ if (!FREQUENCIES.has(frequency)) throw Object.assign(new Error("Frequency must be daily, weekly, or monthly"), { statusCode: 400 });
10
+ if (!IANAZone.isValidZone(timezone)) throw Object.assign(new Error("A valid IANA timezone is required"), { statusCode: 400 });
11
+ if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(time)) throw Object.assign(new Error("Time must use 24-hour HH:mm format"), { statusCode: 400 });
12
+ const dayOfWeek = frequency === "weekly" ? Number(input.dayOfWeek ?? 1) : null;
13
+ const dayOfMonth = frequency === "monthly" ? Number(input.dayOfMonth ?? 1) : null;
14
+ if (dayOfWeek !== null && (!Number.isInteger(dayOfWeek) || dayOfWeek < 0 || dayOfWeek > 6)) throw Object.assign(new Error("Weekly day must be between 0 and 6"), { statusCode: 400 });
15
+ if (dayOfMonth !== null && (!Number.isInteger(dayOfMonth) || dayOfMonth < 1 || dayOfMonth > 28)) throw Object.assign(new Error("Monthly day must be between 1 and 28"), { statusCode: 400 });
16
+ return { enabled: Boolean(input.enabled), frequency, timezone, time, dayOfWeek, dayOfMonth };
17
+ }
18
+
19
+ function atTime(date, time) {
20
+ const [hour, minute] = time.split(":").map(Number);
21
+ return date.set({ hour, minute, second: 0, millisecond: 0 });
22
+ }
23
+
24
+ export function nextScheduleRun(schedule, after = new Date()) {
25
+ const local = DateTime.fromJSDate(after, { zone: schedule.timezone });
26
+ let candidate = atTime(local.startOf("day"), schedule.time);
27
+ if (schedule.frequency === "daily") {
28
+ if (candidate <= local) candidate = candidate.plus({ days: 1 });
29
+ } else if (schedule.frequency === "weekly") {
30
+ const targetWeekday = schedule.dayOfWeek === 0 ? 7 : schedule.dayOfWeek;
31
+ candidate = atTime(local.startOf("week").plus({ days: targetWeekday - 1 }), schedule.time);
32
+ if (candidate <= local) candidate = candidate.plus({ weeks: 1 });
33
+ } else {
34
+ candidate = atTime(local.startOf("month").set({ day: schedule.dayOfMonth }), schedule.time);
35
+ if (candidate <= local) candidate = atTime(candidate.plus({ months: 1 }).startOf("month").set({ day: schedule.dayOfMonth }), schedule.time);
36
+ }
37
+ return candidate.toUTC().toJSDate();
38
+ }
39
+
40
+ export function reportRange(schedule, scheduledFor) {
41
+ const scheduledDay = DateTime.fromJSDate(new Date(scheduledFor), { zone: schedule.timezone }).startOf("day");
42
+ const end = schedule.frequency === "monthly" ? scheduledDay.startOf("month") : scheduledDay;
43
+ const start = schedule.frequency === "monthly" ? end.minus({ months: 1 }) : end.minus({ days: schedule.frequency === "weekly" ? 7 : 1 });
44
+ return { Start: start.toISODate(), End: end.toISODate() };
45
+ }
package/src/plugin.js CHANGED
@@ -12,9 +12,13 @@ import { CostAnalysisController } from "./controllers/cost-analysis.controller.j
12
12
  import { createCostRouter } from "./routes/index.js";
13
13
  import { installCostBudgetSchema } from "./schema/cost-budget.schema.js";
14
14
  import { installCostAnalysisSchema } from "./schema/cost-analysis.schema.js";
15
+ import { installCostAnalysisReportSchema } from "./schema/cost-analysis-report.schema.js";
15
16
  import { CostAnalysisRepository } from "./repositories/cost-analysis.repository.js";
17
+ import { CostAnalysisReportRepository } from "./repositories/cost-analysis-report.repository.js";
16
18
  import { CostAnalysisDataService } from "./services/cost-analysis-data.service.js";
17
19
  import { CostAnalysisService } from "./services/cost-analysis.service.js";
20
+ import { CostAnalysisReportService } from "./services/cost-analysis-report.service.js";
21
+ import { CostAnalysisReportWorker } from "./workers/cost-analysis-report.worker.js";
18
22
  import { installCurDiscoverySchema } from "./cur-discovery/schema.js";
19
23
  import { CurDiscoveryRepository } from "./cur-discovery/cur-discovery.repository.js";
20
24
  import { CurDiscoveryAws } from "./cur-discovery/cur-discovery.aws.js";
@@ -32,6 +36,7 @@ export function createInsightCost({ app, db, apiBaseUri = "/api/v1", logger = co
32
36
  const budgetRepository = new BudgetRepository({ db, qSchema });
33
37
  const budgetAlertRepository = new BudgetAlertRepository({ db, qSchema });
34
38
  const costAnalysisRepository = new CostAnalysisRepository({ db, qSchema });
39
+ const costAnalysisReportRepository = new CostAnalysisReportRepository({ db, qSchema });
35
40
  const budgetService = new BudgetService({ repository: budgetRepository });
36
41
  const budgetAlertService = new BudgetAlertService({ repository: budgetAlertRepository });
37
42
  const athenaContextService = new SaasAthenaContextService();
@@ -53,7 +58,9 @@ export function createInsightCost({ app, db, apiBaseUri = "/api/v1", logger = co
53
58
  // providerResolver lets the host supply per-tenant LLM credentials (the AI
54
59
  // Providers screen). Null keeps the environment-only behaviour.
55
60
  const costAnalysisService = new CostAnalysisService({ contextService, dataService: costAnalysisDataService, repository: costAnalysisRepository, logger, providerResolver });
56
- const costAnalysisController = new CostAnalysisController({ service: costAnalysisService });
61
+ const costAnalysisReportService = new CostAnalysisReportService({ contextService, repository: costAnalysisReportRepository, analysisService: costAnalysisService });
62
+ const costAnalysisReportWorker = new CostAnalysisReportWorker({ repository: costAnalysisReportRepository, analysisService: costAnalysisService, logger });
63
+ const costAnalysisController = new CostAnalysisController({ service: costAnalysisService, reportService: costAnalysisReportService });
57
64
  const router = createCostRouter({ costController, budgetController, costAnalysisController }, logger);
58
65
  let mounted = false;
59
66
 
@@ -61,6 +68,7 @@ export function createInsightCost({ app, db, apiBaseUri = "/api/v1", logger = co
61
68
  async install() {
62
69
  await installCostBudgetSchema(db, qSchema);
63
70
  await installCostAnalysisSchema(db, qSchema);
71
+ await installCostAnalysisReportSchema(db, qSchema);
64
72
  await installCurDiscoverySchema(db, qSchema);
65
73
  logger.log?.("[Cost] Database migration verified");
66
74
  },
@@ -70,10 +78,12 @@ export function createInsightCost({ app, db, apiBaseUri = "/api/v1", logger = co
70
78
  mounted = true;
71
79
  }
72
80
  discoveryWorker.start();
81
+ costAnalysisReportWorker.start();
73
82
  logger.log?.(`[Cost] Routes active at ${apiBaseUri}/cost/*`);
74
83
  },
75
84
  async stop() {
76
85
  discoveryWorker.stop();
86
+ costAnalysisReportWorker.stop();
77
87
  },
78
88
  router,
79
89
  };
@@ -0,0 +1,109 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { sql } from "drizzle-orm";
3
+ import { rows } from "../lib/cost-utils.js";
4
+
5
+ export class CostAnalysisReportRepository {
6
+ constructor({ db, qSchema }) {
7
+ this.db = db;
8
+ this.schedules = `${qSchema}.cost_ai_report_schedules`;
9
+ this.reports = `${qSchema}.cost_ai_reports`;
10
+ }
11
+
12
+ async getSchedule(tenantId) {
13
+ return rows(await this.db.execute(sql`SELECT * FROM ${sql.raw(this.schedules)} WHERE tenant_id = ${tenantId}`))[0] || null;
14
+ }
15
+
16
+ async saveSchedule(tenantId, schedule, nextRunAt) {
17
+ return rows(await this.db.execute(sql`
18
+ INSERT INTO ${sql.raw(this.schedules)}
19
+ (tenant_id, enabled, frequency, time_of_day, timezone, day_of_week, day_of_month, next_run_at)
20
+ VALUES (${tenantId}, ${schedule.enabled}, ${schedule.frequency}, ${schedule.time}, ${schedule.timezone}, ${schedule.dayOfWeek}, ${schedule.dayOfMonth}, ${nextRunAt})
21
+ ON CONFLICT (tenant_id) DO UPDATE SET
22
+ enabled = EXCLUDED.enabled, frequency = EXCLUDED.frequency,
23
+ time_of_day = EXCLUDED.time_of_day, timezone = EXCLUDED.timezone,
24
+ day_of_week = EXCLUDED.day_of_week, day_of_month = EXCLUDED.day_of_month,
25
+ next_run_at = EXCLUDED.next_run_at, updated_at = now()
26
+ RETURNING *
27
+ `))[0];
28
+ }
29
+
30
+ async claimDue(limit, leaseMs) {
31
+ const leaseUntil = new Date(Date.now() + leaseMs);
32
+ const batchSize = Math.min(Math.max(Number(limit) || 10, 1), 50);
33
+ return rows(await this.db.execute(sql`
34
+ WITH due AS (
35
+ SELECT tenant_id, next_run_at AS scheduled_for
36
+ FROM ${sql.raw(this.schedules)}
37
+ WHERE enabled = true AND next_run_at IS NOT NULL AND next_run_at <= now()
38
+ ORDER BY next_run_at
39
+ FOR UPDATE SKIP LOCKED
40
+ LIMIT ${batchSize}
41
+ )
42
+ UPDATE ${sql.raw(this.schedules)} s
43
+ SET next_run_at = ${leaseUntil}, updated_at = now()
44
+ FROM due
45
+ WHERE s.tenant_id = due.tenant_id
46
+ RETURNING s.*, due.scheduled_for
47
+ `));
48
+ }
49
+
50
+ async setNextRun(tenantId, nextRunAt, completed = false, leaseUntil = null) {
51
+ await this.db.execute(sql`
52
+ UPDATE ${sql.raw(this.schedules)}
53
+ SET next_run_at = ${nextRunAt}, last_run_at = CASE WHEN ${completed} THEN now() ELSE last_run_at END, updated_at = now()
54
+ WHERE tenant_id = ${tenantId}
55
+ AND (${leaseUntil}::timestamptz IS NULL OR next_run_at = ${leaseUntil})
56
+ `);
57
+ }
58
+
59
+ async createReport(schedule, range) {
60
+ const inserted = rows(await this.db.execute(sql`
61
+ INSERT INTO ${sql.raw(this.reports)}
62
+ (id, tenant_id, schedule_frequency, scheduled_for, period_start, period_end)
63
+ VALUES (${randomUUID()}, ${schedule.tenant_id}, ${schedule.frequency}, ${schedule.scheduled_for}, ${range.Start}, ${range.End})
64
+ ON CONFLICT (tenant_id, scheduled_for) DO NOTHING
65
+ RETURNING *
66
+ `));
67
+ return inserted[0] || null;
68
+ }
69
+
70
+ async markRunning(id) {
71
+ return rows(await this.db.execute(sql`UPDATE ${sql.raw(this.reports)} SET status = 'RUNNING', attempt_count = attempt_count + 1, started_at = now(), error = NULL, updated_at = now() WHERE id = ${id} AND status = 'PENDING' RETURNING *`))[0] || null;
72
+ }
73
+
74
+ async complete(id, analysis, pdf) {
75
+ await this.db.execute(sql`
76
+ UPDATE ${sql.raw(this.reports)} SET status = 'COMPLETED', model_id = ${analysis.modelId}, data_source = ${analysis.dataSource || null},
77
+ facts = ${JSON.stringify(analysis.facts)}::jsonb,
78
+ result = ${JSON.stringify({ summary: analysis.summary, findings: analysis.findings, recommendations: analysis.recommendations, limitations: analysis.limitations })}::jsonb,
79
+ pdf_data = ${pdf}, pdf_size = ${pdf.length}, completed_at = now(), updated_at = now()
80
+ WHERE id = ${id}
81
+ `);
82
+ }
83
+
84
+ async fail(id, error) {
85
+ await this.db.execute(sql`UPDATE ${sql.raw(this.reports)} SET status = 'FAILED', error = ${String(error?.message || error).slice(0, 2000)}, completed_at = now(), updated_at = now() WHERE id = ${id}`);
86
+ }
87
+
88
+ async list(tenantId, limit = 100) {
89
+ return rows(await this.db.execute(sql`
90
+ SELECT id, schedule_frequency, scheduled_for, period_start, period_end, status,
91
+ attempt_count, model_id, data_source, facts, result, pdf_size, error,
92
+ started_at, completed_at, created_at
93
+ FROM ${sql.raw(this.reports)} WHERE tenant_id = ${tenantId}
94
+ ORDER BY scheduled_for DESC LIMIT ${Math.min(Math.max(Number(limit) || 100, 1), 250)}
95
+ `));
96
+ }
97
+
98
+ async find(tenantId, id, includePdf = false) {
99
+ if (includePdf) {
100
+ return rows(await this.db.execute(sql`SELECT * FROM ${sql.raw(this.reports)} WHERE tenant_id = ${tenantId} AND id = ${id} LIMIT 1`))[0] || null;
101
+ }
102
+ return rows(await this.db.execute(sql`
103
+ SELECT id, schedule_frequency, scheduled_for, period_start, period_end, status,
104
+ attempt_count, model_id, data_source, facts, result, pdf_size, error,
105
+ started_at, completed_at, created_at
106
+ FROM ${sql.raw(this.reports)} WHERE tenant_id = ${tenantId} AND id = ${id} LIMIT 1
107
+ `))[0] || null;
108
+ }
109
+ }
@@ -11,7 +11,11 @@ export function createCostRouter({ costController, budgetController, costAnalysi
11
11
  router.get("/cur-discovery", costController.curDiscovery.bind(costController));
12
12
  router.get("/analysis/status", costAnalysisController.status.bind(costAnalysisController));
13
13
  router.get("/analysis/latest", costAnalysisController.latest.bind(costAnalysisController));
14
- router.post("/analysis", costAnalysisController.generate.bind(costAnalysisController));
14
+ router.get("/analysis/schedule", costAnalysisController.schedule.bind(costAnalysisController));
15
+ router.put("/analysis/schedule", costAnalysisController.updateSchedule.bind(costAnalysisController));
16
+ router.get("/analysis/reports", costAnalysisController.reports.bind(costAnalysisController));
17
+ router.get("/analysis/reports/:id", costAnalysisController.report.bind(costAnalysisController));
18
+ router.get("/analysis/reports/:id/pdf", costAnalysisController.downloadPdf.bind(costAnalysisController));
15
19
  router.get("/budgets", budgetController.list.bind(budgetController));
16
20
  router.post("/budgets", budgetController.create.bind(budgetController));
17
21
  router.delete("/budgets/:id", budgetController.delete.bind(budgetController));
@@ -0,0 +1,43 @@
1
+ import { sql } from "drizzle-orm";
2
+
3
+ export async function installCostAnalysisReportSchema(db, qSchema) {
4
+ await db.execute(sql.raw(`CREATE TABLE IF NOT EXISTS ${qSchema}.cost_ai_report_schedules (
5
+ tenant_id text PRIMARY KEY,
6
+ enabled boolean NOT NULL DEFAULT false,
7
+ frequency text NOT NULL DEFAULT 'weekly' CHECK (frequency IN ('daily', 'weekly', 'monthly')),
8
+ time_of_day time NOT NULL DEFAULT '09:00:00',
9
+ timezone text NOT NULL DEFAULT 'UTC',
10
+ day_of_week smallint CHECK (day_of_week BETWEEN 0 AND 6),
11
+ day_of_month smallint CHECK (day_of_month BETWEEN 1 AND 28),
12
+ next_run_at timestamptz,
13
+ last_run_at timestamptz,
14
+ created_at timestamptz NOT NULL DEFAULT now(),
15
+ updated_at timestamptz NOT NULL DEFAULT now()
16
+ )`));
17
+ await db.execute(sql.raw(`CREATE INDEX IF NOT EXISTS cost_ai_report_schedules_due_idx ON ${qSchema}.cost_ai_report_schedules (enabled, next_run_at)`));
18
+
19
+ await db.execute(sql.raw(`CREATE TABLE IF NOT EXISTS ${qSchema}.cost_ai_reports (
20
+ id text PRIMARY KEY,
21
+ tenant_id text NOT NULL,
22
+ schedule_frequency text NOT NULL CHECK (schedule_frequency IN ('daily', 'weekly', 'monthly')),
23
+ scheduled_for timestamptz NOT NULL,
24
+ period_start date NOT NULL,
25
+ period_end date NOT NULL,
26
+ status text NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING', 'RUNNING', 'COMPLETED', 'FAILED')),
27
+ attempt_count integer NOT NULL DEFAULT 0,
28
+ model_id text,
29
+ data_source text,
30
+ facts jsonb,
31
+ result jsonb,
32
+ pdf_data bytea,
33
+ pdf_size integer,
34
+ error text,
35
+ started_at timestamptz,
36
+ completed_at timestamptz,
37
+ created_at timestamptz NOT NULL DEFAULT now(),
38
+ updated_at timestamptz NOT NULL DEFAULT now(),
39
+ UNIQUE (tenant_id, scheduled_for)
40
+ )`));
41
+ await db.execute(sql.raw(`CREATE INDEX IF NOT EXISTS cost_ai_reports_tenant_created_idx ON ${qSchema}.cost_ai_reports (tenant_id, created_at DESC)`));
42
+ await db.execute(sql.raw(`CREATE INDEX IF NOT EXISTS cost_ai_reports_status_idx ON ${qSchema}.cost_ai_reports (status, created_at)`));
43
+ }
@@ -0,0 +1,42 @@
1
+ import PDFDocument from "pdfkit";
2
+
3
+ const money = (value, currency = "USD") => new Intl.NumberFormat("en-US", { style: "currency", currency }).format(Number(value || 0));
4
+
5
+ export function createCostAnalysisPdf(analysis) {
6
+ return new Promise((resolve, reject) => {
7
+ const doc = new PDFDocument({ size: "A4", margin: 48, info: { Title: "Meyi Connect AI Cost Analysis", Author: "Meyi Connect" } });
8
+ const chunks = [];
9
+ doc.on("data", (chunk) => chunks.push(chunk));
10
+ doc.on("end", () => resolve(Buffer.concat(chunks)));
11
+ doc.on("error", reject);
12
+ const ensure = (height = 80) => { if (doc.y + height > doc.page.height - 48) doc.addPage(); };
13
+ const inclusiveEnd = new Date(`${analysis.period.End}T00:00:00Z`);
14
+ inclusiveEnd.setUTCDate(inclusiveEnd.getUTCDate() - 1);
15
+ doc.font("Helvetica-Bold").fontSize(20).fillColor("#122235").text("AI Cost Analysis");
16
+ doc.moveDown(0.35).font("Helvetica").fontSize(9).fillColor("#53657a").text(`Generated ${new Date(analysis.generatedAt).toUTCString()} | AWS CUR | ${analysis.period.Start} to ${inclusiveEnd.toISOString().slice(0, 10)}`);
17
+ doc.moveDown(1).font("Helvetica-Bold").fontSize(12).fillColor("#122235").text("Executive summary");
18
+ doc.moveDown(0.35).font("Helvetica").fontSize(10).fillColor("#27384a").text(analysis.summary, { lineGap: 3 });
19
+ doc.moveDown(1).font("Helvetica-Bold").fontSize(12).fillColor("#122235").text("Cost facts");
20
+ doc.moveDown(0.35).font("Helvetica").fontSize(10).fillColor("#27384a");
21
+ doc.text(`Period spend: ${money(analysis.facts.currentTotal, analysis.facts.currency)}`);
22
+ doc.text(`Previous period: ${money(analysis.facts.previousTotal, analysis.facts.currency)}`);
23
+ doc.text(`Change: ${money(analysis.facts.change, analysis.facts.currency)} (${analysis.facts.changePercentage == null ? "no baseline" : `${analysis.facts.changePercentage}%`})`);
24
+ doc.moveDown(1).font("Helvetica-Bold").fontSize(12).fillColor("#122235").text("Key findings");
25
+ for (const finding of analysis.findings || []) {
26
+ ensure();
27
+ doc.moveDown(0.5).font("Helvetica-Bold").fontSize(10).fillColor("#122235").text(`${String(finding.severity).toUpperCase()}: ${finding.title}`);
28
+ doc.font("Helvetica").fillColor("#27384a").text(finding.explanation, { lineGap: 2 });
29
+ doc.fontSize(9).fillColor("#53657a").text(`Evidence: ${finding.evidence}`);
30
+ }
31
+ ensure();
32
+ doc.moveDown(1).font("Helvetica-Bold").fontSize(12).fillColor("#122235").text("Recommended actions");
33
+ for (const item of [...(analysis.recommendations || [])].sort((a, b) => a.priority - b.priority)) {
34
+ ensure();
35
+ doc.moveDown(0.5).font("Helvetica-Bold").fontSize(10).fillColor("#122235").text(`${item.priority}. ${item.title}`);
36
+ doc.font("Helvetica").fillColor("#27384a").text(item.action, { lineGap: 2 });
37
+ doc.fontSize(9).fillColor("#53657a").text(item.rationale);
38
+ }
39
+ doc.moveDown(1).font("Helvetica-Oblique").fontSize(8).fillColor("#6b7785").text("AI recommendations are advisory. Review them before changing AWS resources.");
40
+ doc.end();
41
+ });
42
+ }
@@ -0,0 +1,52 @@
1
+ import { nextScheduleRun, reportRange, validateSchedule } from "../lib/cost-analysis-schedule.js";
2
+
3
+ function scheduleDto(row) {
4
+ if (!row) return { enabled: false, frequency: "weekly", time: "09:00", timezone: "UTC", dayOfWeek: 1, dayOfMonth: 1, nextRunAt: null, lastRunAt: null };
5
+ return { enabled: row.enabled, frequency: row.frequency, time: String(row.time_of_day).slice(0, 5), timezone: row.timezone, dayOfWeek: row.day_of_week, dayOfMonth: row.day_of_month, nextRunAt: row.next_run_at, lastRunAt: row.last_run_at };
6
+ }
7
+
8
+ function reportDto(row) {
9
+ return {
10
+ id: row.id, frequency: row.schedule_frequency, scheduledFor: row.scheduled_for,
11
+ period: { Start: String(row.period_start).slice(0, 10), End: String(row.period_end).slice(0, 10) },
12
+ status: row.status, attempts: Number(row.attempt_count || 0), modelId: row.model_id,
13
+ dataSource: row.data_source, facts: row.facts || null, ...(row.result || {}),
14
+ pdfReady: row.status === "COMPLETED" && Number(row.pdf_size || 0) > 0,
15
+ pdfSize: Number(row.pdf_size || 0), error: row.error, startedAt: row.started_at,
16
+ completedAt: row.completed_at, createdAt: row.created_at,
17
+ };
18
+ }
19
+
20
+ export class CostAnalysisReportService {
21
+ constructor({ contextService, repository, analysisService }) {
22
+ this.contextService = contextService;
23
+ this.repository = repository;
24
+ this.analysisService = analysisService;
25
+ }
26
+
27
+ tenant(req) { return this.contextService.tenantId(req); }
28
+
29
+ async getSchedule(req) { return scheduleDto(await this.repository.getSchedule(this.tenant(req))); }
30
+
31
+ async saveSchedule(req, input) {
32
+ if (String(req.user?.role || "").toLowerCase() !== "admin") throw Object.assign(new Error("Administrator access is required to change the AI report schedule"), { statusCode: 403 });
33
+ const schedule = validateSchedule(input);
34
+ const nextRunAt = schedule.enabled ? nextScheduleRun(schedule) : null;
35
+ return scheduleDto(await this.repository.saveSchedule(this.tenant(req), schedule, nextRunAt));
36
+ }
37
+
38
+ async list(req, limit) { return (await this.repository.list(this.tenant(req), limit)).map(reportDto); }
39
+
40
+ async get(req, id) {
41
+ const row = await this.repository.find(this.tenant(req), id);
42
+ if (!row) throw Object.assign(new Error("AI cost report was not found"), { statusCode: 404 });
43
+ return reportDto(row);
44
+ }
45
+
46
+ async pdf(req, id) {
47
+ const row = await this.repository.find(this.tenant(req), id, true);
48
+ if (!row) throw Object.assign(new Error("AI cost report was not found"), { statusCode: 404 });
49
+ if (row.status !== "COMPLETED" || !row.pdf_data) throw Object.assign(new Error("The PDF is not ready"), { statusCode: 409 });
50
+ return { data: Buffer.from(row.pdf_data), filename: `meyi-cost-analysis-${String(row.period_start).slice(0, 10)}-${id.slice(0, 8)}.pdf` };
51
+ }
52
+ }
@@ -0,0 +1,66 @@
1
+ import { nextScheduleRun, reportRange } from "../lib/cost-analysis-schedule.js";
2
+ import { createCostAnalysisPdf } from "../services/cost-analysis-pdf.service.js";
3
+
4
+ export class CostAnalysisReportWorker {
5
+ constructor({ repository, analysisService, logger = console, env = process.env, pdfGenerator = createCostAnalysisPdf }) {
6
+ this.repository = repository;
7
+ this.analysisService = analysisService;
8
+ this.logger = logger;
9
+ this.pdfGenerator = pdfGenerator;
10
+ this.enabled = String(env.COST_AI_ENABLED || "").toLowerCase() === "true";
11
+ this.pollMs = Math.max(Number(env.COST_AI_REPORT_POLL_INTERVAL_MS || 60_000), 10_000);
12
+ this.leaseMs = Math.max(Number(env.COST_AI_REPORT_LEASE_MS || 30 * 60_000), 60_000);
13
+ this.batchSize = Math.min(Math.max(Number(env.COST_AI_REPORT_BATCH_SIZE || 5), 1), 25);
14
+ this.running = false;
15
+ this.timer = null;
16
+ this.initialTimer = null;
17
+ }
18
+
19
+ async run(schedule) {
20
+ const normalized = { ...schedule, time: String(schedule.time_of_day).slice(0, 5), dayOfWeek: schedule.day_of_week, dayOfMonth: schedule.day_of_month };
21
+ const range = reportRange(normalized, schedule.scheduled_for);
22
+ const report = await this.repository.createReport(schedule, range);
23
+ const nextRunAt = nextScheduleRun(normalized, new Date());
24
+ if (!report) return this.repository.setNextRun(schedule.tenant_id, nextRunAt, false, schedule.next_run_at);
25
+ const claimed = await this.repository.markRunning(report.id);
26
+ if (!claimed) return this.repository.setNextRun(schedule.tenant_id, nextRunAt, false, schedule.next_run_at);
27
+ try {
28
+ const req = { body: { force: true }, headers: {}, user: { tenant_id: schedule.tenant_id, tenantId: schedule.tenant_id } };
29
+ const analysis = await this.analysisService.analyze(req, range);
30
+ const pdf = await this.pdfGenerator(analysis);
31
+ await this.repository.complete(report.id, analysis, pdf);
32
+ await this.repository.setNextRun(schedule.tenant_id, nextRunAt, true, schedule.next_run_at);
33
+ } catch (error) {
34
+ await this.repository.fail(report.id, error);
35
+ await this.repository.setNextRun(schedule.tenant_id, nextRunAt, true, schedule.next_run_at);
36
+ this.logger.error?.("[Cost AI Reports] Scheduled analysis failed", { tenantId: schedule.tenant_id, reportId: report.id, message: error.message });
37
+ }
38
+ }
39
+
40
+ async tick() {
41
+ if (!this.enabled || this.running) return;
42
+ this.running = true;
43
+ try {
44
+ const schedules = await this.repository.claimDue(this.batchSize, this.leaseMs);
45
+ for (const schedule of schedules) await this.run(schedule);
46
+ } catch (error) {
47
+ this.logger.error?.("[Cost AI Reports] Scheduler tick failed", error);
48
+ } finally { this.running = false; }
49
+ }
50
+
51
+ start() {
52
+ if (!this.enabled || this.timer) return;
53
+ this.initialTimer = setTimeout(() => void this.tick(), 2_000);
54
+ this.initialTimer.unref?.();
55
+ this.timer = setInterval(() => void this.tick(), this.pollMs);
56
+ this.timer.unref?.();
57
+ this.logger.log?.(`[Cost AI Reports] Scheduler started; poll=${Math.round(this.pollMs / 1000)}s`);
58
+ }
59
+
60
+ stop() {
61
+ if (this.initialTimer) clearTimeout(this.initialTimer);
62
+ if (this.timer) clearInterval(this.timer);
63
+ this.initialTimer = null;
64
+ this.timer = null;
65
+ }
66
+ }