feat(hq): schedule monthly aws cost pull and owner-gated manual pull route

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 2 weeks ago
parent ea8c2b23ad
commit 5495b42e30

@ -41,6 +41,7 @@ import { dashboardView } from './repos-dashboard'
import { import {
costRanking, listAwsUsage, previousMonth, upsertAwsUsage, type UpsertAwsUsageInput, costRanking, listAwsUsage, previousMonth, upsertAwsUsage, type UpsertAwsUsageInput,
} from './repos-aws' } from './repos-aws'
import { pullMonthlyCosts } from './aws-costs'
import { clientProfitability, duesAging, moduleRevenue, type DateRange } from './repos-reports' import { clientProfitability, duesAging, moduleRevenue, type DateRange } from './repos-reports'
import { documentHtml } from './templates' import { documentHtml } from './templates'
import { renderPdf } from './pdf' import { renderPdf } from './pdf'
@ -512,6 +513,24 @@ export function apiRouter(db: DB, gmailDeps?: Partial<GmailDeps>): Router {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) 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 ---------- // ---------- reports ----------
const rangeOf = (req: Request): DateRange => { const rangeOf = (req: Request): DateRange => {

@ -4,6 +4,7 @@ import type http from 'node:http'
import path from 'node:path' import path from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { apiRouter } from './api' import { apiRouter } from './api'
import { maybePullAwsCosts } from './aws-costs'
import { pollBounces } from './bounces' import { pollBounces } from './bounces'
import { openDb, type DB } from './db' import { openDb, type DB } from './db'
import { renderPdf } from './pdf' 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. */ /** Boot the scan + bounce poll now and every 6h. unref() so it never blocks exit. */
export function startScheduler(db: DB): NodeJS.Timeout { export function startScheduler(db: DB): NodeJS.Timeout {
const deps = schedulerDeps(db) 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 today = (): string => new Date().toISOString().slice(0, 10)
const tick = (): void => { const tick = (): void => {
void runDailyScan(db, deps, today()).catch((e: unknown) => console.error('[scheduler] scan failed', e)) 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)) 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() tick()
const handle = setInterval(tick, 6 * 60 * 60 * 1000) const handle = setInterval(tick, 6 * 60 * 60 * 1000)

@ -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)
})
})
Loading…
Cancel
Save