@meyicloud/meyi-cost-server 1.8.3 → 1.8.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meyicloud/meyi-cost-server",
3
- "version": "1.8.3",
3
+ "version": "1.8.5",
4
4
  "description": "Tenant-aware AWS CUR and Athena cost plugin server for MeyiConnect",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -117,11 +117,12 @@ export class CostController {
117
117
  try {
118
118
  const ctx = await this.contextService.resolve(req);
119
119
  if (!this.discoveryRepository) return res.json({ tenantId: ctx.tenant, jobs: [], logs: [] });
120
- const { jobs, logs } = await this.discoveryRepository.statusForTenant(ctx.tenant, req.query.logLimit);
120
+ const managementAccountId = String(req.query.managementAccountId || "").trim();
121
+ const { jobs, logs } = await this.discoveryRepository.statusForTenant(ctx.tenant, req.query.logLimit, managementAccountId || undefined);
121
122
  return res.json({
122
123
  tenantId: ctx.tenant,
123
124
  jobs: jobs.map((job) => ({
124
- id: String(job.id), connectionId: job.connection_id, status: job.status,
125
+ id: String(job.id), connectionId: job.connection_id, managementAccountId: job.management_account_id, status: job.status,
125
126
  attemptCount: Number(job.attempt_count || 0), bucket: job.cur_bucket,
126
127
  prefix: job.cur_prefix, region: job.cur_region, tenantPartition: job.tenant_partition,
127
128
  database: job.glue_database, table: job.glue_table, tableLocation: job.table_location,
@@ -154,22 +154,29 @@ export class CurDiscoveryRepository {
154
154
  * Discovery jobs for a tenant with their most recent log lines, for the
155
155
  * Cost > CUR Discovery screen. Read-only: the scheduler owns every write.
156
156
  */
157
- async statusForTenant(tenant, logLimit = 50) {
158
- const jobs = rows(await this.db.execute(sql`
159
- SELECT id, tenant_id, connection_id, cur_bucket, cur_prefix, cur_region,
160
- tenant_partition, status, attempt_count, next_run_at, last_started_at,
161
- last_finished_at, glue_database, glue_table, table_location, cur_s3_uri,
162
- sample_object_key, last_data_at, last_error, created_at, updated_at
163
- FROM ${sql.raw(this.jobsTable)}
164
- WHERE tenant_id = ${tenant}
165
- ORDER BY updated_at DESC
166
- `));
157
+ async statusForTenant(tenant, logLimit = 50, managementAccountId = undefined) {
158
+ const jobs = rows(await this.db.execute(sql`
159
+ SELECT j.id, j.tenant_id, j.connection_id, a.management_account_id, j.cur_bucket, j.cur_prefix, j.cur_region,
160
+ j.tenant_partition, j.status, j.attempt_count, j.next_run_at, j.last_started_at,
161
+ j.last_finished_at, j.glue_database, j.glue_table, j.table_location, j.cur_s3_uri,
162
+ j.sample_object_key, j.last_data_at, j.last_error, j.created_at, j.updated_at
163
+ FROM ${sql.raw(this.jobsTable)} j
164
+ JOIN ${sql.raw(this.connectionsTable)} a
165
+ ON a.tenant_id::text = j.tenant_id AND a.connection_id = j.connection_id
166
+ WHERE j.tenant_id = ${tenant}
167
+ AND (${managementAccountId || null}::text IS NULL OR a.management_account_id = ${managementAccountId || null})
168
+ ORDER BY j.updated_at DESC
169
+ `));
167
170
  if (!jobs.length) return { jobs: [], logs: [] };
168
171
  const logs = rows(await this.db.execute(sql`
169
- SELECT id, job_id, level, event, message, details, created_at
170
- FROM ${sql.raw(this.logsTable)}
171
- WHERE tenant_id = ${tenant}
172
- ORDER BY created_at DESC, id DESC
172
+ SELECT l.id, l.job_id, l.level, l.event, l.message, l.details, l.created_at
173
+ FROM ${sql.raw(this.logsTable)} l
174
+ JOIN ${sql.raw(this.jobsTable)} j ON j.id = l.job_id AND j.tenant_id = l.tenant_id
175
+ JOIN ${sql.raw(this.connectionsTable)} a
176
+ ON a.tenant_id::text = j.tenant_id AND a.connection_id = j.connection_id
177
+ WHERE l.tenant_id = ${tenant}
178
+ AND (${managementAccountId || null}::text IS NULL OR a.management_account_id = ${managementAccountId || null})
179
+ ORDER BY l.created_at DESC, l.id DESC
173
180
  LIMIT ${Math.min(Math.max(Number(logLimit) || 50, 1), 500)}
174
181
  `));
175
182
  return { jobs, logs };
@@ -40,25 +40,28 @@ export class AwsOnboardingRepository {
40
40
  if (!connections.length) return { accounts: [], meta: {}, sources: [] };
41
41
 
42
42
  const connectionIds = connections.map((connection) => connection.connection_id);
43
+ // Bind every connection ID independently. Passing an untyped JavaScript array
44
+ // to PostgreSQL ANY() fails for the varchar connection_id column.
45
+ const connectionIdList = sql.join(connectionIds.map((connectionId) => sql`${connectionId}`), sql`, `);
43
46
  const curConfigs = relations.cur_config_table ? rows(await this.db.execute(sql`
44
47
  SELECT export_arn, bucket, prefix, region, tenant_partition, status, last_data_at
45
48
  , connection_id
46
49
  FROM ${sql.raw(this.curConfigTable)}
47
- WHERE tenant_id::text = ${tenant} AND connection_id = ANY(${connectionIds})
50
+ WHERE tenant_id::text = ${tenant} AND connection_id IN (${connectionIdList})
48
51
  `)) : [];
49
52
 
50
53
  const discoveries = relations.cur_discovery_table ? rows(await this.db.execute(sql`
51
54
  SELECT id, status, attempt_count, glue_database, glue_table, table_location,
52
55
  cur_s3_uri, last_data_at, last_error, last_started_at, last_finished_at, next_run_at, connection_id
53
56
  FROM ${sql.raw(this.curDiscoveryTable)}
54
- WHERE tenant_id = ${tenant} AND connection_id = ANY(${connectionIds})
57
+ WHERE tenant_id = ${tenant} AND connection_id IN (${connectionIdList})
55
58
  `)) : [];
56
59
 
57
60
  const allAccounts = relations.accounts_table ? rows(await this.db.execute(sql`
58
61
  SELECT connection_id, account_id, account_name, status
59
62
  FROM ${sql.raw(this.accountsTable)}
60
63
  WHERE tenant_id::text = ${tenant}
61
- AND connection_id = ANY(${connectionIds})
64
+ AND connection_id IN (${connectionIdList})
62
65
  AND LOWER(status) = 'active'
63
66
  ORDER BY account_name
64
67
  `)) : [];
@@ -9,16 +9,16 @@ export class CostAnalysisReportRepository {
9
9
  this.reports = `${qSchema}.cost_ai_reports`;
10
10
  }
11
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;
12
+ async getSchedule(tenantId, managementAccountId) {
13
+ return rows(await this.db.execute(sql`SELECT * FROM ${sql.raw(this.schedules)} WHERE tenant_id = ${tenantId} AND management_account_id = ${managementAccountId}`))[0] || null;
14
14
  }
15
15
 
16
- async saveSchedule(tenantId, schedule, nextRunAt) {
16
+ async saveSchedule(tenantId, managementAccountId, schedule, nextRunAt) {
17
17
  return rows(await this.db.execute(sql`
18
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
19
+ (tenant_id, management_account_id, enabled, frequency, time_of_day, timezone, day_of_week, day_of_month, next_run_at)
20
+ VALUES (${tenantId}, ${managementAccountId}, ${schedule.enabled}, ${schedule.frequency}, ${schedule.time}, ${schedule.timezone}, ${schedule.dayOfWeek}, ${schedule.dayOfMonth}, ${nextRunAt})
21
+ ON CONFLICT (tenant_id, management_account_id) DO UPDATE SET
22
22
  enabled = EXCLUDED.enabled, frequency = EXCLUDED.frequency,
23
23
  time_of_day = EXCLUDED.time_of_day, timezone = EXCLUDED.timezone,
24
24
  day_of_week = EXCLUDED.day_of_week, day_of_month = EXCLUDED.day_of_month,
@@ -32,7 +32,7 @@ export class CostAnalysisReportRepository {
32
32
  const batchSize = Math.min(Math.max(Number(limit) || 10, 1), 50);
33
33
  return rows(await this.db.execute(sql`
34
34
  WITH due AS (
35
- SELECT tenant_id, next_run_at AS scheduled_for
35
+ SELECT tenant_id, management_account_id, next_run_at AS scheduled_for
36
36
  FROM ${sql.raw(this.schedules)}
37
37
  WHERE enabled = true AND next_run_at IS NOT NULL AND next_run_at <= now()
38
38
  ORDER BY next_run_at
@@ -42,16 +42,17 @@ export class CostAnalysisReportRepository {
42
42
  UPDATE ${sql.raw(this.schedules)} s
43
43
  SET next_run_at = ${leaseUntil}, updated_at = now()
44
44
  FROM due
45
- WHERE s.tenant_id = due.tenant_id
45
+ WHERE s.tenant_id = due.tenant_id AND s.management_account_id = due.management_account_id
46
46
  RETURNING s.*, due.scheduled_for
47
47
  `));
48
48
  }
49
49
 
50
- async setNextRun(tenantId, nextRunAt, completed = false, leaseUntil = null) {
50
+ async setNextRun(tenantId, managementAccountId, nextRunAt, completed = false, leaseUntil = null) {
51
51
  await this.db.execute(sql`
52
52
  UPDATE ${sql.raw(this.schedules)}
53
53
  SET next_run_at = ${nextRunAt}, last_run_at = CASE WHEN ${completed} THEN now() ELSE last_run_at END, updated_at = now()
54
54
  WHERE tenant_id = ${tenantId}
55
+ AND management_account_id = ${managementAccountId}
55
56
  AND (${leaseUntil}::timestamptz IS NULL OR next_run_at = ${leaseUntil})
56
57
  `);
57
58
  }
@@ -59,9 +60,9 @@ export class CostAnalysisReportRepository {
59
60
  async createReport(schedule, range) {
60
61
  const inserted = rows(await this.db.execute(sql`
61
62
  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
63
+ (id, tenant_id, management_account_id, schedule_frequency, scheduled_for, period_start, period_end)
64
+ VALUES (${randomUUID()}, ${schedule.tenant_id}, ${schedule.management_account_id}, ${schedule.frequency}, ${schedule.scheduled_for}, ${range.Start}, ${range.End})
65
+ ON CONFLICT (tenant_id, management_account_id, scheduled_for) DO NOTHING
65
66
  RETURNING *
66
67
  `));
67
68
  return inserted[0] || null;
@@ -132,14 +133,14 @@ export class CostAnalysisReportRepository {
132
133
  await this.db.execute(sql`UPDATE ${sql.raw(this.reports)} SET status = 'FAILED', ecs_task_arn = COALESCE(${error?.taskArn || null}, ecs_task_arn), error = ${String(error?.message || error).slice(0, 2000)}, completed_at = now(), updated_at = now() WHERE id = ${id}`);
133
134
  }
134
135
 
135
- async list(tenantId, limit = 100) {
136
+ async list(tenantId, managementAccountId, limit = 100) {
136
137
  return rows(await this.db.execute(sql`
137
- SELECT id, schedule_frequency, scheduled_for, period_start, period_end, status,
138
+ SELECT id, management_account_id, schedule_frequency, scheduled_for, period_start, period_end, status,
138
139
  attempt_count, model_id, provider_name, provider_source, data_source, facts, result, artifact_bucket,
139
140
  markdown_key, markdown_size, pdf_key, pdf_size, tickets_key, ticket_count,
140
141
  incident_sync_status, incident_sync_error, incident_synced_at, error,
141
142
  started_at, completed_at, created_at
142
- FROM ${sql.raw(this.reports)} WHERE tenant_id = ${tenantId}
143
+ FROM ${sql.raw(this.reports)} WHERE tenant_id = ${tenantId} AND management_account_id = ${managementAccountId}
143
144
  ORDER BY scheduled_for DESC LIMIT ${Math.min(Math.max(Number(limit) || 100, 1), 250)}
144
145
  `));
145
146
  }
@@ -149,7 +150,7 @@ export class CostAnalysisReportRepository {
149
150
  return rows(await this.db.execute(sql`SELECT * FROM ${sql.raw(this.reports)} WHERE tenant_id = ${tenantId} AND id = ${id} LIMIT 1`))[0] || null;
150
151
  }
151
152
  return rows(await this.db.execute(sql`
152
- SELECT id, schedule_frequency, scheduled_for, period_start, period_end, status,
153
+ SELECT id, management_account_id, schedule_frequency, scheduled_for, period_start, period_end, status,
153
154
  attempt_count, model_id, provider_name, provider_source, data_source, facts, result, artifact_bucket,
154
155
  markdown_key, markdown_size, pdf_key, pdf_size, tickets_key, ticket_count,
155
156
  incident_sync_status, incident_sync_error, incident_synced_at, error,
@@ -1,8 +1,10 @@
1
1
  import { sql } from "drizzle-orm";
2
+ import { rows } from "../lib/cost-utils.js";
2
3
 
3
4
  export async function installCostAnalysisReportSchema(db, qSchema) {
4
5
  await db.execute(sql.raw(`CREATE TABLE IF NOT EXISTS ${qSchema}.cost_ai_report_schedules (
5
- tenant_id text PRIMARY KEY,
6
+ tenant_id text NOT NULL,
7
+ management_account_id text NOT NULL DEFAULT '',
6
8
  enabled boolean NOT NULL DEFAULT false,
7
9
  frequency text NOT NULL DEFAULT 'weekly' CHECK (frequency IN ('daily', 'weekly', 'monthly')),
8
10
  time_of_day time NOT NULL DEFAULT '09:00:00',
@@ -12,13 +14,39 @@ export async function installCostAnalysisReportSchema(db, qSchema) {
12
14
  next_run_at timestamptz,
13
15
  last_run_at timestamptz,
14
16
  created_at timestamptz NOT NULL DEFAULT now(),
15
- updated_at timestamptz NOT NULL DEFAULT now()
17
+ updated_at timestamptz NOT NULL DEFAULT now(),
18
+ PRIMARY KEY (tenant_id, management_account_id)
16
19
  )`));
20
+ await db.execute(sql.raw(`ALTER TABLE ${qSchema}.cost_ai_report_schedules ADD COLUMN IF NOT EXISTS management_account_id text NOT NULL DEFAULT ''`));
21
+ const schemaName = qSchema.replace(/"/g, "");
22
+ const relations = rows(await db.execute(sql.raw(`SELECT to_regclass('${schemaName}.aws_connections') AS connections_table`)))[0] || {};
23
+ if (relations.connections_table) {
24
+ await db.execute(sql.raw(`UPDATE ${qSchema}.cost_ai_report_schedules s
25
+ SET management_account_id = COALESCE((
26
+ SELECT c.management_account_id FROM ${qSchema}.aws_connections c
27
+ WHERE c.tenant_id::text = s.tenant_id AND c.status = 'CONNECTED'
28
+ AND c.plugins ? 'Cost' AND c.management_account_id IS NOT NULL
29
+ ORDER BY c.connected_at DESC NULLS LAST, c.created_at DESC LIMIT 1
30
+ ), '')
31
+ WHERE s.management_account_id = ''`));
32
+ }
33
+ await db.execute(sql.raw(`DO $migration$
34
+ BEGIN
35
+ IF NOT EXISTS (
36
+ SELECT 1 FROM pg_constraint
37
+ WHERE conrelid = '${schemaName}.cost_ai_report_schedules'::regclass
38
+ AND contype = 'p' AND pg_get_constraintdef(oid) LIKE '%management_account_id%'
39
+ ) THEN
40
+ ALTER TABLE ${qSchema}.cost_ai_report_schedules DROP CONSTRAINT IF EXISTS cost_ai_report_schedules_pkey;
41
+ ALTER TABLE ${qSchema}.cost_ai_report_schedules ADD CONSTRAINT cost_ai_report_schedules_pkey PRIMARY KEY (tenant_id, management_account_id);
42
+ END IF;
43
+ END $migration$`));
17
44
  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
45
 
19
46
  await db.execute(sql.raw(`CREATE TABLE IF NOT EXISTS ${qSchema}.cost_ai_reports (
20
47
  id text PRIMARY KEY,
21
48
  tenant_id text NOT NULL,
49
+ management_account_id text NOT NULL DEFAULT '',
22
50
  schedule_frequency text NOT NULL CHECK (schedule_frequency IN ('daily', 'weekly', 'monthly')),
23
51
  scheduled_for timestamptz NOT NULL,
24
52
  period_start date NOT NULL,
@@ -47,9 +75,24 @@ export async function installCostAnalysisReportSchema(db, qSchema) {
47
75
  started_at timestamptz,
48
76
  completed_at timestamptz,
49
77
  created_at timestamptz NOT NULL DEFAULT now(),
50
- updated_at timestamptz NOT NULL DEFAULT now(),
51
- UNIQUE (tenant_id, scheduled_for)
78
+ updated_at timestamptz NOT NULL DEFAULT now()
52
79
  )`));
80
+ await db.execute(sql.raw(`ALTER TABLE ${qSchema}.cost_ai_reports ADD COLUMN IF NOT EXISTS management_account_id text NOT NULL DEFAULT ''`));
81
+ if (relations.connections_table) {
82
+ await db.execute(sql.raw(`UPDATE ${qSchema}.cost_ai_reports r
83
+ SET management_account_id = COALESCE(
84
+ (SELECT s.management_account_id FROM ${qSchema}.cost_ai_report_schedules s
85
+ WHERE s.tenant_id = r.tenant_id AND s.management_account_id <> '' LIMIT 1),
86
+ (SELECT c.management_account_id FROM ${qSchema}.aws_connections c
87
+ WHERE c.tenant_id::text = r.tenant_id AND c.status = 'CONNECTED'
88
+ AND c.plugins ? 'Cost' AND c.management_account_id IS NOT NULL
89
+ ORDER BY c.connected_at DESC NULLS LAST, c.created_at DESC LIMIT 1),
90
+ ''
91
+ )
92
+ WHERE r.management_account_id = ''`));
93
+ }
94
+ await db.execute(sql.raw(`ALTER TABLE ${qSchema}.cost_ai_reports DROP CONSTRAINT IF EXISTS cost_ai_reports_tenant_id_scheduled_for_key`));
95
+ await db.execute(sql.raw(`CREATE UNIQUE INDEX IF NOT EXISTS cost_ai_reports_tenant_account_scheduled_idx ON ${qSchema}.cost_ai_reports (tenant_id, management_account_id, scheduled_for)`));
53
96
  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)`));
54
97
  await db.execute(sql.raw(`CREATE INDEX IF NOT EXISTS cost_ai_reports_status_idx ON ${qSchema}.cost_ai_reports (status, created_at)`));
55
98
  await db.execute(sql.raw(`ALTER TABLE ${qSchema}.cost_ai_reports ADD COLUMN IF NOT EXISTS ecs_task_arn text`));
@@ -167,10 +167,10 @@ export class CostAnalyserService {
167
167
  return JSON.parse(await bodyText(response.Body));
168
168
  }
169
169
 
170
- async execute({ tenantId, reportId, range, frequency, onStarted = null }) {
170
+ async execute({ tenantId, managementAccountId, reportId, range, frequency, onStarted = null }) {
171
171
  this.validate();
172
172
  const provider = await this.resolveProvider(tenantId);
173
- const customer = await this.contextService.resolve({ headers: {}, user: { tenant_id: tenantId, tenantId } });
173
+ const customer = await this.contextService.resolve({ headers: {}, query: { managementAccountId }, user: { tenant_id: tenantId, tenantId } });
174
174
  const athena = this.athenaContextService.resolve(customer).config;
175
175
  if (!athena.enabled) throw Object.assign(new Error("CUR discovery has not produced a queryable Athena table for this tenant"), { name: "CurDataNotReadyError", statusCode: 503 });
176
176
  const targetRoleArn = required(customer.meta?.payerRoleArn, "customer TARGET_ROLE_ARN");
@@ -1,13 +1,13 @@
1
1
  import { nextScheduleRun, reportRange, validateSchedule } from "../lib/cost-analysis-schedule.js";
2
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 };
3
+ function scheduleDto(row, managementAccountId = "") {
4
+ if (!row) return { managementAccountId, enabled: false, frequency: "weekly", time: "09:00", timezone: "UTC", dayOfWeek: 1, dayOfMonth: 1, nextRunAt: null, lastRunAt: null };
5
+ return { managementAccountId: row.management_account_id, 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
6
  }
7
7
 
8
8
  function reportDto(row) {
9
9
  return {
10
- id: row.id, frequency: row.schedule_frequency, scheduledFor: row.scheduled_for,
10
+ id: row.id, managementAccountId: row.management_account_id, frequency: row.schedule_frequency, scheduledFor: row.scheduled_for,
11
11
  period: { Start: String(row.period_start).slice(0, 10), End: String(row.period_end).slice(0, 10) },
12
12
  status: row.status, attempts: Number(row.attempt_count || 0), provider: row.provider_name,
13
13
  providerSource: row.provider_source, modelId: row.model_id,
@@ -27,16 +27,37 @@ export class CostAnalysisReportService {
27
27
 
28
28
  tenant(req) { return this.contextService.tenantId(req); }
29
29
 
30
- async getSchedule(req) { return scheduleDto(await this.repository.getSchedule(this.tenant(req))); }
30
+ async scope(req, required = false) {
31
+ const context = await this.contextService.resolve(req);
32
+ const requested = String(req.query?.managementAccountId || "").trim();
33
+ const managementAccountId = requested || context.sources?.[0]?.managementAccountId || context.meta?.managementAccountId || "";
34
+ if (requested && !context.sources?.some((source) => source.managementAccountId === requested)) {
35
+ throw Object.assign(new Error("The selected management account is not connected for this tenant"), { statusCode: 404 });
36
+ }
37
+ if (required && !managementAccountId) {
38
+ throw Object.assign(new Error("Select a connected management account before configuring AI reports"), { statusCode: 409 });
39
+ }
40
+ return { tenantId: context.tenant, managementAccountId };
41
+ }
42
+
43
+ async getSchedule(req) {
44
+ const scope = await this.scope(req);
45
+ return scheduleDto(scope.managementAccountId ? await this.repository.getSchedule(scope.tenantId, scope.managementAccountId) : null, scope.managementAccountId);
46
+ }
31
47
 
32
48
  async saveSchedule(req, input) {
33
49
  if (String(req.user?.role || "").toLowerCase() !== "admin") throw Object.assign(new Error("Administrator access is required to change the AI report schedule"), { statusCode: 403 });
50
+ const scope = await this.scope(req, true);
34
51
  const schedule = validateSchedule(input);
35
52
  const nextRunAt = schedule.enabled ? nextScheduleRun(schedule) : null;
36
- return scheduleDto(await this.repository.saveSchedule(this.tenant(req), schedule, nextRunAt));
53
+ return scheduleDto(await this.repository.saveSchedule(scope.tenantId, scope.managementAccountId, schedule, nextRunAt));
37
54
  }
38
55
 
39
- async list(req, limit) { return (await this.repository.list(this.tenant(req), limit)).map(reportDto); }
56
+ async list(req, limit) {
57
+ const scope = await this.scope(req);
58
+ if (!scope.managementAccountId) return [];
59
+ return (await this.repository.list(scope.tenantId, scope.managementAccountId, limit)).map(reportDto);
60
+ }
40
61
 
41
62
  async latest(req) {
42
63
  const reports = await this.list(req, 1);
@@ -19,12 +19,13 @@ export class CostAnalysisReportWorker {
19
19
  const range = reportRange(normalized, schedule.scheduled_for);
20
20
  const report = await this.repository.createReport(schedule, range);
21
21
  const nextRunAt = nextScheduleRun(normalized, new Date());
22
- if (!report) return this.repository.setNextRun(schedule.tenant_id, nextRunAt, false, schedule.next_run_at);
22
+ if (!report) return this.repository.setNextRun(schedule.tenant_id, schedule.management_account_id, nextRunAt, false, schedule.next_run_at);
23
23
  const claimed = await this.repository.markRunning(report.id);
24
- if (!claimed) return this.repository.setNextRun(schedule.tenant_id, nextRunAt, false, schedule.next_run_at);
24
+ if (!claimed) return this.repository.setNextRun(schedule.tenant_id, schedule.management_account_id, nextRunAt, false, schedule.next_run_at);
25
25
  try {
26
26
  const result = await this.analyserService.execute({
27
27
  tenantId: schedule.tenant_id,
28
+ managementAccountId: schedule.management_account_id,
28
29
  reportId: report.id,
29
30
  range,
30
31
  frequency: schedule.frequency,
@@ -32,10 +33,10 @@ export class CostAnalysisReportWorker {
32
33
  });
33
34
  await this.repository.completeExternal(report.id, result);
34
35
  await this.incidentSyncService?.syncPending?.();
35
- await this.repository.setNextRun(schedule.tenant_id, nextRunAt, true, schedule.next_run_at);
36
+ await this.repository.setNextRun(schedule.tenant_id, schedule.management_account_id, nextRunAt, true, schedule.next_run_at);
36
37
  } catch (error) {
37
38
  await this.repository.fail(report.id, error);
38
- await this.repository.setNextRun(schedule.tenant_id, nextRunAt, true, schedule.next_run_at);
39
+ await this.repository.setNextRun(schedule.tenant_id, schedule.management_account_id, nextRunAt, true, schedule.next_run_at);
39
40
  this.logger.error?.("[Cost AI Reports] Scheduled analysis failed", { tenantId: schedule.tenant_id, reportId: report.id, message: error.message });
40
41
  }
41
42
  }