From 5495b42e3012421ae63fb8d26d01470df18240c1 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Fri, 10 Jul 2026 21:45:17 +0530 Subject: [PATCH] feat(hq): schedule monthly aws cost pull and owner-gated manual pull route Co-Authored-By: Claude Fable 5 --- apps/hq/src/api.ts | 19 ++++++++++++++ apps/hq/src/server.ts | 10 ++++++++ apps/hq/test/aws-pull-route.test.ts | 39 +++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+) create mode 100644 apps/hq/test/aws-pull-route.test.ts diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index 3a001bd..4fea6d4 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -41,6 +41,7 @@ import { dashboardView } from './repos-dashboard' import { costRanking, listAwsUsage, previousMonth, upsertAwsUsage, type UpsertAwsUsageInput, } from './repos-aws' +import { pullMonthlyCosts } from './aws-costs' import { clientProfitability, duesAging, moduleRevenue, type DateRange } from './repos-reports' import { documentHtml } from './templates' import { renderPdf } from './pdf' @@ -512,6 +513,24 @@ export function apiRouter(db: DB, gmailDeps?: Partial): Router { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) + r.post('/aws/pull', requireAuth, requireOwner, (req, res) => { + const accessKeyId = process.env['AWS_ACCESS_KEY_ID'] ?? '' + const secretAccessKey = process.env['AWS_SECRET_ACCESS_KEY'] ?? '' + if (accessKeyId === '' || secretAccessKey === '') { + res.status(409).json({ ok: false, error: 'aws-credentials-not-configured' }); return + } + const today = new Date().toISOString().slice(0, 10) + const body = req.body as { month?: string } + const month = typeof body.month === 'string' ? body.month : previousMonth(today) + void (async () => { + try { + const out = await pullMonthlyCosts(db, { f: fetch, creds: { accessKeyId, secretAccessKey } }, month) + res.json({ ok: true, month, ...out }) + } catch (err) { + res.status(502).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + })() + }) // ---------- reports ---------- const rangeOf = (req: Request): DateRange => { diff --git a/apps/hq/src/server.ts b/apps/hq/src/server.ts index d766f1b..60feb70 100644 --- a/apps/hq/src/server.ts +++ b/apps/hq/src/server.ts @@ -4,6 +4,7 @@ import type http from 'node:http' import path from 'node:path' import { fileURLToPath } from 'node:url' import { apiRouter } from './api' +import { maybePullAwsCosts } from './aws-costs' import { pollBounces } from './bounces' import { openDb, type DB } from './db' import { renderPdf } from './pdf' @@ -32,10 +33,19 @@ function schedulerDeps(db: DB) { /** Boot the scan + bounce poll now and every 6h. unref() so it never blocks exit. */ export function startScheduler(db: DB): NodeJS.Timeout { const deps = schedulerDeps(db) + const awsCreds = { + accessKeyId: process.env['AWS_ACCESS_KEY_ID'] ?? '', + secretAccessKey: process.env['AWS_SECRET_ACCESS_KEY'] ?? '', + } const today = (): string => new Date().toISOString().slice(0, 10) const tick = (): void => { void runDailyScan(db, deps, today()).catch((e: unknown) => console.error('[scheduler] scan failed', e)) void pollBounces(db, deps).catch((e: unknown) => console.error('[scheduler] bounce poll failed', e)) + // AWS cost pull only when credentials are configured — env-gated, once per month. + if (awsCreds.accessKeyId !== '' && awsCreds.secretAccessKey !== '') { + void maybePullAwsCosts(db, { f: fetch, creds: awsCreds }, today()) + .catch((e: unknown) => console.error('[scheduler] aws cost pull failed', e)) + } } tick() const handle = setInterval(tick, 6 * 60 * 60 * 1000) diff --git a/apps/hq/test/aws-pull-route.test.ts b/apps/hq/test/aws-pull-route.test.ts new file mode 100644 index 0000000..7f49f75 --- /dev/null +++ b/apps/hq/test/aws-pull-route.test.ts @@ -0,0 +1,39 @@ +// apps/hq/test/aws-pull-route.test.ts +import { describe, it, expect, afterAll, beforeAll } from 'vitest' +import express from 'express' +import { openDb } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { createStaff } from '../src/auth' +import { apiRouter } from '../src/api' + +describe('POST /api/aws/pull without credentials', () => { + const prevKey = process.env['AWS_ACCESS_KEY_ID'] + const prevSecret = process.env['AWS_SECRET_ACCESS_KEY'] + beforeAll(() => { delete process.env['AWS_ACCESS_KEY_ID']; delete process.env['AWS_SECRET_ACCESS_KEY'] }) + afterAll(() => { + if (prevKey !== undefined) process.env['AWS_ACCESS_KEY_ID'] = prevKey + if (prevSecret !== undefined) process.env['AWS_SECRET_ACCESS_KEY'] = prevSecret + }) + + const db = openDb(':memory:'); seedIfEmpty(db) + createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' }) + createStaff(db, { email: 'staff@test.in', displayName: 'Staff', role: 'staff', password: 'staff-password' }) + const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db)) + const server = app.listen(0) + const base = `http://localhost:${(server.address() as { port: number }).port}/api` + afterAll(() => server.close()) + const login = async (email: string, password: string) => + (await (await fetch(`${base}/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email, password }) })).json() as any).token + + it('returns 409 when AWS creds are absent', async () => { + const token = await login('owner@test.in', 'owner-password') + const res = await fetch(`${base}/aws/pull`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, body: '{}' }) + expect(res.status).toBe(409) + expect((await res.json() as any).error).toBe('aws-credentials-not-configured') + }) + it('is owner-gated', async () => { + const token = await login('staff@test.in', 'staff-password') + const res = await fetch(`${base}/aws/pull`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, body: '{}' }) + expect(res.status).toBe(403) + }) +})