Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 24 additions & 7 deletions .github/workflows/preview-env.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@ on:
types: [labeled, synchronize, unlabeled, closed]

# Concurrency is set PER JOB, not workflow-wide, on purpose:
# - deploy uses a `preview-deploy-<pr>` group with cancel-in-progress so
# back-to-back commits cancel the older run (the survivor rebuilds from the
# full base...HEAD diff, so the final state matches the latest commit).
# - teardown uses a SEPARATE `preview-teardown-<pr>` group with
# cancel-in-progress: false so a teardown is NEVER cancelled by a deploy.
# - deploy uses a `preview-deploy-<pr>` group, so back-to-back commits queue
# behind the running deploy; GitHub cancels every pending run but the newest,
# so the survivor still rebuilds from the full base...HEAD diff.
# - teardown uses a SEPARATE `preview-teardown-<pr>` group so a teardown is
# NEVER cancelled by a deploy.
# Neither group cancels in progress: both run terraform, and terraform killed
# mid-apply is unrecoverable (see `deploy`).
# A single shared group would let a push (e.g. right after removing the label)
# cancel an in-flight teardown, leaking the stack. Separate groups + the
# DynamoDB state lock (which serializes any overlapping terraform) keep cleanup
Expand All @@ -46,9 +48,15 @@ jobs:
(github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'test-environment'))
runs-on: ubuntu-latest
concurrency:
# Back-to-back commits: newer deploy cancels the older one for this PR.
# Back-to-back commits: the newest run supersedes older PENDING runs but never
# kills one already in flight. This job runs `terraform apply`, and cancelling
# that mid-apply cannot be recovered from: the runner is killed before
# Terraform persists state or releases its DynamoDB lock, so everything it had
# created so far is orphaned and the workspace stays locked. The wreckage is
# a REST API with no `prod` stage, which answers every request with a bare 403
# carrying no CORS headers -- reaching the browser as an opaque CORS error.
group: preview-deploy-${{ github.event.pull_request.number }}
cancel-in-progress: true
cancel-in-progress: false
environment: preview
permissions:
id-token: write
Expand Down Expand Up @@ -156,6 +164,7 @@ jobs:
id: stack
working-directory: infrastructure/preview
run: |
set -euo pipefail
if [ "${{ steps.mode.outputs.create }}" = "true" ]; then
API_URL=$(terraform output -raw api_gateway_url)
else
Expand All @@ -165,6 +174,14 @@ jobs:
echo "::error::No preview API for PR #${PR}. Re-add the ${LABEL} label to (re)create it."
exit 1
fi
# The API existing is not enough. A stack whose apply died partway has
# the resources but no stage, and every call to it 403s without CORS
# headers, so the browser blames CORS. Fail here rather than posting a
# green "updated in place" on an environment that cannot serve a request.
if ! aws apigateway get-stage --rest-api-id "$API_ID" --stage-name prod >/dev/null 2>&1; then
echo "::error::Preview API ${API_ID} for PR #${PR} has no 'prod' stage — the stack is incomplete. Remove and re-add the ${LABEL} label to rebuild it."
exit 1
fi
API_URL="https://${API_ID}.execute-api.${AWS_REGION}.amazonaws.com/prod"
fi
echo "api_url=$API_URL" >> "$GITHUB_OUTPUT"
Expand Down
136 changes: 103 additions & 33 deletions apps/backend/lambdas/projects/handler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { sql } from 'kysely';
import db from './db';
import { ProjectValidationUtils } from './validation-utils';
import {
Expand Down Expand Up @@ -46,85 +47,154 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
}

try {
// Cards read "this year" / "active projects", so spend is scoped to the
// calendar year and the count to projects that have not ended. The
// per-project budget breakdown below stays lifetime-to-date.
const now = new Date();
const year = now.getUTCFullYear();
const yearStart = `${year}-01-01`;
const yearEnd = `${year}-12-31`;
const today = now.toISOString().slice(0, 10);

// Projects stay active until their end_date passes; a null end_date
// never ends. Shared by the count and by the spend feeding the average
// so the two can never drift out of agreement.
const isActive = (column: any) => (eb: any) =>
eb.or([eb(column, 'is', null), eb(column, '>=', today as any)]);

// Postgres does the month bucketing. Selecting raw rows and grouping them
// in JS moved one row per expenditure into the lambda to produce at most
// 12 x categories of them, and read a DATE through the runtime's local
// timezone, which only lands on the right month because lambda runs UTC.
const monthExpr = sql<string>`to_char(date_trunc('month', spent_on), 'YYYY-MM')`;

const [
totalSpentRow,
totalProjectsRow,
topCategoryRow,
activeSpentRow,
projectRows,
spentByProject,
staffByProject,
monthRows,
] = await Promise.all([
db.selectFrom('branch.expenditures')
.select(db.fn.sum('amount').as('total'))
.where('spent_on', '>=', yearStart as any)
.where('spent_on', '<=', yearEnd as any)
.executeTakeFirst(),
db.selectFrom('branch.projects')
.select(db.fn.count('project_id').as('count'))
.where(isActive('end_date'))
.executeTakeFirst(),
db.selectFrom('branch.expenditures')
.select(['category', db.fn.sum('amount').as('total')])
.where('category', 'is not', null)
.where('spent_on', '>=', yearStart as any)
.where('spent_on', '<=', yearEnd as any)
.groupBy('category')
.orderBy(db.fn.sum('amount'), 'desc')
.limit(1)
.executeTakeFirst(),
db.selectFrom('branch.projects')
.selectAll()
.orderBy('project_id', 'asc')
.execute(),
db.selectFrom('branch.expenditures')
.select(['project_id', db.fn.sum('amount').as('total')])
.groupBy('project_id')
.execute(),
db.selectFrom('branch.project_memberships')
.select(['project_id', db.fn.count('user_id').as('count')])
.groupBy('project_id')
// Numerator for the average: this year's spend on the very projects the
// denominator counts. expenditures.project_id is NOT NULL against a FK,
// so the join can never drop a row.
db.selectFrom('branch.expenditures as e')
.innerJoin('branch.projects as p', 'p.project_id', 'e.project_id')
.select((eb) => eb.fn.sum('e.amount').as('total'))
.where('e.spent_on', '>=', yearStart as any)
.where('e.spent_on', '<=', yearEnd as any)
.where(isActive('p.end_date'))
.executeTakeFirst(),
// Spend and headcount arrive as pre-aggregated subqueries. Joining the
// raw tables onto projects instead would multiply every expenditure by
// the membership count and silently inflate `spent`.
db.selectFrom('branch.projects as p')
.leftJoin(
(eb) =>
eb.selectFrom('branch.expenditures')
.select('project_id')
.select((sub) => sub.fn.sum('amount').as('total'))
.groupBy('project_id')
.as('spend'),
(join) => join.onRef('spend.project_id', '=', 'p.project_id'),
)
.leftJoin(
(eb) =>
eb.selectFrom('branch.project_memberships')
.select('project_id')
.select((sub) => sub.fn.count('user_id').as('count'))
.groupBy('project_id')
.as('staff'),
(join) => join.onRef('staff.project_id', '=', 'p.project_id'),
)
.select([
'p.project_id',
'p.name',
'p.total_budget',
'p.currency',
'spend.total as spent',
'staff.count as staff_count',
])
.orderBy('p.project_id', 'asc')
.execute(),
db.selectFrom('branch.expenditures')
.select(['spent_on', 'category', 'amount'])
.select([monthExpr.as('month'), 'category', db.fn.sum('amount').as('total')])
.where('category', 'is not', null)
.where('spent_on', '>=', yearStart as any)
.where('spent_on', '<=', yearEnd as any)
.groupBy([monthExpr, 'category'])
.orderBy(monthExpr)
.orderBy('category')
.execute(),
]);

const totalSpent = Number(totalSpentRow?.total ?? 0);
const totalProjects = Number(totalProjectsRow?.count ?? 0);
const averageSpendPerProject = totalProjects > 0 ? totalSpent / totalProjects : 0;

const spentMap = new Map(spentByProject.map((r) => [r.project_id, Number(r.total)]));
const staffMap = new Map(staffByProject.map((r) => [r.project_id, Number(r.count)]));
// True aggregate over active projects: this year's spend on active
// projects divided by how many there are. Dividing the all-projects total
// by the active count inflated the figure whenever a project ended
// mid-year, since its spend stayed in the numerator.
const activeSpent = Number(activeSpentRow?.total ?? 0);
const averageSpendPerProject = totalProjects > 0 ? activeSpent / totalProjects : 0;

const projects = projectRows.map((p) => {
const budget = p.total_budget !== null ? Number(p.total_budget) : null;
const spent = spentMap.get(p.project_id) ?? 0;
const spent = Number(p.spent ?? 0);
const spentPercentage = budget && budget > 0 ? (spent / budget) * 100 : 0;
return {
project_id: p.project_id,
name: p.name,
total_budget: budget,
currency: p.currency,
spent,
staff_count: staffMap.get(p.project_id) ?? 0,
staff_count: Number(p.staff_count ?? 0),
spent_percentage: Number(spentPercentage.toFixed(2)),
};
});

const monthMap = new Map<string, { month: string; category: string; amount: number }>();
for (const row of monthRows) {
const d = new Date(row.spent_on as any);
const month = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
const category = row.category as string;
const key = `${month}|${category}`;
const prev = monthMap.get(key);
if (prev) prev.amount += Number(row.amount);
else monthMap.set(key, { month, category, amount: Number(row.amount) });
}
const expensesByMonth = [...monthMap.values()].sort((a, b) => a.month.localeCompare(b.month));
const expensesByMonth = monthRows.map((r) => ({
month: r.month,
category: r.category as string,
amount: Number(r.total),
}));

// Computed here, not client-side: totalSpent is the divisor and may be 0.
const topCategoryAmount = Number(topCategoryRow?.total ?? 0);
const topExpenseCategory = topCategoryRow
? {
category: topCategoryRow.category,
amount: topCategoryAmount,
percentage:
totalSpent > 0
? Number(((topCategoryAmount / totalSpent) * 100).toFixed(2))
: 0,
}
: null;

return json(200, {
year,
summary: {
topExpenseCategory: topCategoryRow
? { category: topCategoryRow.category, amount: Number(topCategoryRow.total ?? 0) }
: null,
topExpenseCategory,
totalSpent,
totalProjects,
averageSpendPerProject: Number(averageSpendPerProject.toFixed(2)),
Expand Down
96 changes: 72 additions & 24 deletions apps/backend/lambdas/projects/test/dashboard.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,33 +81,26 @@ describe('GET /dashboard unit tests', () => {
describe('Response shape', () => {
beforeEach(() => {
mockDb.selectFrom = jest.fn();
// 1) totalSpent
// 1) totalSpent (every project, this year)
mockDb.selectFrom.mockReturnValueOnce(chain({ total: '18000.00' }));
// 2) totalProjects
// 2) totalProjects (active only)
mockDb.selectFrom.mockReturnValueOnce(chain({ count: '4' }));
// 3) topCategory
mockDb.selectFrom.mockReturnValueOnce(chain({ category: 'Travel', total: '6800.00' }));
// 4) projectRows
mockDb.selectFrom.mockReturnValueOnce(chain([
{ project_id: 1, name: 'P1', total_budget: '500000.00', currency: 'USD' },
{ project_id: 2, name: 'P2', total_budget: '300000.00', currency: 'USD' },
{ project_id: 3, name: 'P3', total_budget: null, currency: 'USD' },
]));
// 5) spentByProject
mockDb.selectFrom.mockReturnValueOnce(chain([
{ project_id: 1, total: '9200.00' },
{ project_id: 2, total: '4500.00' },
{ project_id: 3, total: '4300.00' },
]));
// 6) staffByProject
// 4) activeSpent — numerator of the average. Every project is active here,
// so it matches totalSpent and the average stays 18000/4.
mockDb.selectFrom.mockReturnValueOnce(chain({ total: '18000.00' }));
// 5) projectRows, with spend/headcount already joined by the database.
// P3 carries the LEFT JOIN misses as nulls.
mockDb.selectFrom.mockReturnValueOnce(chain([
{ project_id: 1, count: '2' },
{ project_id: 2, count: '1' },
{ project_id: 1, name: 'P1', total_budget: '500000.00', currency: 'USD', spent: '9200.00', staff_count: '2' },
{ project_id: 2, name: 'P2', total_budget: '300000.00', currency: 'USD', spent: '4500.00', staff_count: '1' },
{ project_id: 3, name: 'P3', total_budget: null, currency: 'USD', spent: '4300.00', staff_count: null },
]));
// 7) raw expenditure rows (handler buckets by YYYY-MM in JS)
// 6) expenses already grouped into YYYY-MM x category by the database
mockDb.selectFrom.mockReturnValueOnce(chain([
{ spent_on: new Date('2025-02-10'), category: 'Travel', amount: '5000.00' },
{ spent_on: new Date('2025-03-22'), category: 'Travel Foreign', amount: '4200.00' },
{ month: '2025-02', category: 'Travel', total: '5000.00' },
{ month: '2025-03', category: 'Travel Foreign', total: '4200.00' },
]));
});

Expand All @@ -119,7 +112,16 @@ describe('GET /dashboard unit tests', () => {
expect(body.summary.totalSpent).toBe(18000);
expect(body.summary.totalProjects).toBe(4);
expect(body.summary.averageSpendPerProject).toBe(4500);
expect(body.summary.topExpenseCategory).toEqual({ category: 'Travel', amount: 6800 });
expect(body.summary.topExpenseCategory).toEqual({
category: 'Travel',
amount: 6800,
percentage: 37.78,
});
});

test('200: response is stamped with the year the aggregates cover', async () => {
const res = await handler(getEvent());
expect(JSON.parse(res.body).year).toBe(new Date().getUTCFullYear());
});

test('200: projects breakdown joins spent and staff_count by project_id', async () => {
Expand All @@ -142,7 +144,7 @@ describe('GET /dashboard unit tests', () => {
expect(body.projects[2].spent_percentage).toBe(0);
});

test('200: expensesByMonth buckets raw rows into YYYY-MM', async () => {
test('200: expensesByMonth passes through the database buckets', async () => {
const res = await handler(getEvent());
const body = JSON.parse(res.body);
expect(body.expensesByMonth).toEqual([
Expand All @@ -164,8 +166,7 @@ describe('GET /dashboard unit tests', () => {
mockDb.selectFrom.mockReturnValueOnce(chain({ total: null }));
mockDb.selectFrom.mockReturnValueOnce(chain({ count: '0' }));
mockDb.selectFrom.mockReturnValueOnce(chain(undefined));
mockDb.selectFrom.mockReturnValueOnce(chain([]));
mockDb.selectFrom.mockReturnValueOnce(chain([]));
mockDb.selectFrom.mockReturnValueOnce(chain({ total: null }));
mockDb.selectFrom.mockReturnValueOnce(chain([]));
mockDb.selectFrom.mockReturnValueOnce(chain([]));

Expand All @@ -180,6 +181,53 @@ describe('GET /dashboard unit tests', () => {
expect(body.expensesByMonth).toEqual([]);
});

test('200: top category percentage is 0 rather than NaN when nothing was spent', async () => {
mockDb.selectFrom = jest.fn();
mockDb.selectFrom.mockReturnValueOnce(chain({ total: '0' }));
mockDb.selectFrom.mockReturnValueOnce(chain({ count: '2' }));
mockDb.selectFrom.mockReturnValueOnce(chain({ category: 'Travel', total: '0' }));
mockDb.selectFrom.mockReturnValueOnce(chain({ total: '0' }));
mockDb.selectFrom.mockReturnValueOnce(chain([]));
mockDb.selectFrom.mockReturnValueOnce(chain([]));

const res = await handler(getEvent());
const body = JSON.parse(res.body);
expect(body.summary.topExpenseCategory.percentage).toBe(0);
});

test('200: average aggregates active projects only, on both sides of the divide', async () => {
mockDb.selectFrom = jest.fn();
// 18000 spent this year across every project...
mockDb.selectFrom.mockReturnValueOnce(chain({ total: '18000.00' }));
// ...but only 4 projects are still active...
mockDb.selectFrom.mockReturnValueOnce(chain({ count: '4' }));
mockDb.selectFrom.mockReturnValueOnce(chain({ category: 'Travel', total: '6800.00' }));
// ...and only 12000 of that spend belongs to them.
mockDb.selectFrom.mockReturnValueOnce(chain({ total: '12000.00' }));
mockDb.selectFrom.mockReturnValueOnce(chain([]));
mockDb.selectFrom.mockReturnValueOnce(chain([]));

const body = JSON.parse((await handler(getEvent())).body);
// 12000/4, not 18000/4: the 6000 belonging to projects that have already
// ended is out of the numerator, matching the denominator.
expect(body.summary.averageSpendPerProject).toBe(3000);
// The headline total still reports every project's spend.
expect(body.summary.totalSpent).toBe(18000);
});

test('200: average is 0 when active projects exist but none of them spent', async () => {
mockDb.selectFrom = jest.fn();
mockDb.selectFrom.mockReturnValueOnce(chain({ total: '5000.00' }));
mockDb.selectFrom.mockReturnValueOnce(chain({ count: '3' }));
mockDb.selectFrom.mockReturnValueOnce(chain({ category: 'Travel', total: '5000.00' }));
mockDb.selectFrom.mockReturnValueOnce(chain({ total: null }));
mockDb.selectFrom.mockReturnValueOnce(chain([]));
mockDb.selectFrom.mockReturnValueOnce(chain([]));

const body = JSON.parse((await handler(getEvent())).body);
expect(body.summary.averageSpendPerProject).toBe(0);
});

test('500: db failure surfaces as 500', async () => {
mockDb.selectFrom = jest.fn().mockImplementation(() => {
throw new Error('boom');
Expand Down
Loading
Loading