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
1 change: 1 addition & 0 deletions .github/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ CI/CD and PR automation. Two groups: **build/deploy** pipelines and the **PR rev
| `frontend-ci.yml` | PR to main/develop, merge_group | In `apps/frontend`: `npm ci` → typecheck → lint → build → test. **Required check `frontend-ci`.** |
| `lambda-tests.yml` | push/PR main/develop, merge_group | Discover lambdas (excl. `tools/`); matrix per lambda spins Postgres 16, seeds `db_setup.sql`, starts dev-server, health-checks, `npm test`. **Required check `lambda-tests`.** |
| `lambda-deploy.yml` | push to main, paths `apps/backend/lambdas/**` or `shared/types/**` | Detect changed lambdas (all if none); build `npm ci --legacy-peer-deps` + `npm run package` → `lambda.zip`; `aws lambda update-function-code --function-name branch-<name>` (us-east-2). |
| `frontend-deploy.yml` | push to main, paths `apps/frontend/**` | Build static export (`npm run build` → `out/`) with `NEXT_PUBLIC_API_BASE_URL`; `aws s3 sync` to the frontend bucket + CloudFront invalidation. `production` env, OIDC apply role. |
| `lambda-readme.yml` | after `terraform-plan` completes, or manual | `node tools/lambda-cli.js generate-readme` (all), commit regenerated READMEs. |
| `regenerate-db-types.yaml` | `db_setup.sql` changes, after `lambda-readme`, or manual | Spin Postgres, apply schema, `kysely-codegen`, strip kysely import → local `ColumnType`, write `shared/types/db-types.d.ts`, `tsc --noEmit`, commit to PR branch (or comment "in sync"). |
| `terraform-plan.yml` | PR main/develop, merge_group | Detect changed TF dirs; `fmt` + terraform-docs (auto-commit); per-dir `init`/`validate`/`plan`, post plan PR comment. **Required check `terraform-plan-summary`.** |
Expand Down
63 changes: 63 additions & 0 deletions .github/workflows/frontend-deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
name: Frontend Deploy

on:
push:
branches: [main]
paths:
- 'apps/frontend/**'
workflow_dispatch:

# Requires the OIDC apply role (branch-ci-apply) from infrastructure/aws/oidc.tf
# to exist. Deploys the static export to S3 + invalidates CloudFront.
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
permissions:
id-token: write # assume the OIDC apply role
contents: read
defaults:
run:
working-directory: apps/frontend
steps:
- uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'

- name: Configure AWS credentials (write apply role via OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::489881683177:role/branch-ci-apply
aws-region: us-east-2

- name: Resolve backend API base URL
run: |
API_ID=$(aws apigateway get-rest-apis --query "items[?name=='branch-api'].id | [0]" --output text)
echo "NEXT_PUBLIC_API_BASE_URL=https://$API_ID.execute-api.us-east-2.amazonaws.com/prod" >> "$GITHUB_ENV"

- name: Install dependencies
run: npm ci

- name: Build (static export)
run: npm run build

- name: Resolve S3 bucket + CloudFront distribution
run: |
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
echo "BUCKET=branch-frontend-$ACCOUNT_ID" >> "$GITHUB_ENV"
DIST_ID=$(aws cloudfront list-distributions \
--query "DistributionList.Items[?Comment=='branch frontend (static SPA)'].Id | [0]" \
--output text)
echo "DIST_ID=$DIST_ID" >> "$GITHUB_ENV"

- name: Sync to S3
run: aws s3 sync out "s3://$BUCKET" --delete

- name: Invalidate CloudFront
run: aws cloudfront create-invalidation --distribution-id "$DIST_ID" --paths "/*"

- name: Summary
run: echo "Deployed apps/frontend to s3://$BUCKET and invalidated $DIST_ID"
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ BRANCH is a non-profit accounting platform (projects, donors, donations, expendi

- **Frontend:** Next.js 15.5 (App Router, Turbopack), React 19, Chakra UI v3 + Tailwind v4, JWT-in-localStorage auth.
- **Backend:** AWS Lambda (Node 20), TypeScript, Kysely + PostgreSQL, AWS Cognito auth. Each service is self-contained, deployed as `branch-{service}`.
- **Infra:** Terraform 1.13.0, state in S3 (`c4c-neu-terraform-state-files`) + DynamoDB lock, secrets via Infisical. Frontend on Amplify; backend on Lambda + API Gateway; RDS Postgres 17.
- **Infra:** Terraform 1.13.0, state in S3 (`c4c-neu-terraform-state-files`) + DynamoDB lock, secrets via Infisical. Frontend is a static export on S3 + CloudFront (SPA); backend on Lambda + API Gateway; RDS Postgres 17.
- **Monorepo:** Nx 16 (nx-cloud cache). Per-app `package.json` (not a single workspace install) — backend lambdas and frontend each `npm ci` independently.

## Shared packages (critical)
Expand Down
4 changes: 3 additions & 1 deletion apps/frontend/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

Next.js 15.5 app (App Router, Turbopack), React 19. Talks to the backend lambda microservices. UI: Chakra UI v3 (unstyled primitives) + Tailwind v4. Auth: JWT in localStorage via a React context.

**Deployed as a static SPA.** `next.config.ts` sets `output: 'export'` — `npm run build` emits `out/`, synced to S3 + served by CloudFront (see `infrastructure/aws/frontend_hosting.tf`, `.github/workflows/frontend-deploy.yml`). No server: everything is client-rendered. Dynamic routes (`projects/[id]`) need `generateStaticParams` in a **Server Component** — the page is a thin server wrapper delegating to a `'use client'` component; deep links resolve via the CloudFront SPA fallback. `NEXT_PUBLIC_API_BASE_URL` (set at build) points the client at API Gateway.

> `README.md` here is `create-next-app` boilerplate — ignore it.

## Commands
Expand Down Expand Up @@ -38,7 +40,7 @@ test/ # jest + RTL mirror of src/ (custom render in test/u

No React Query / SWR / Redux. Pattern: `useState` + `useEffect` + `apiFetch`, local component state.

`src/lib/api.ts` — `apiFetch<T>(path, { token?, ... })`. Routes by first path segment to a service port (auth→3006, projects→3002, donors→3003, expenditures→3004, reports→3005, users→3001), or to `NEXT_PUBLIC_API_BASE_URL` if set. Injects `Authorization: Bearer <token>`. Throws on non-2xx. `next.config.ts` rewrites mirror this routing for the dev server. New backend calls go through `apiFetch` — don't hand-roll `fetch`.
`src/lib/api.ts` — `apiFetch<T>(path, { token?, ... })`. Routes by first path segment to a service port (auth→3006, projects→3002, donors→3003, expenditures→3004, reports→3005, users→3001), or to `NEXT_PUBLIC_API_BASE_URL` if set. Injects `Authorization: Bearer <token>`. Throws on non-2xx. In production `NEXT_PUBLIC_API_BASE_URL` (API Gateway) is set at build so every call goes there with its full prefixed path; the localhost port map is the dev fallback. (Static export has no server, so there are no `next.config` rewrites.) New backend calls go through `apiFetch` — don't hand-roll `fetch`.

## Auth

Expand Down
39 changes: 7 additions & 32 deletions apps/frontend/next.config.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,13 @@
import type { NextConfig } from 'next';

// Static export (SPA) — the app is fully client-rendered (JWT in localStorage,
// all data via apiFetch). Output goes to `out/`, hosted on S3 + CloudFront.
// No server: `next.config` rewrites don't run in export, so prod routing to the
// backend is entirely via NEXT_PUBLIC_API_BASE_URL (see src/lib/api.ts).
const nextConfig: NextConfig = {
async rewrites() {
return [
{
source: '/auth/:path*',
destination: 'http://localhost:3006/auth/:path*',
},
{
source: '/expenditures/:path*',
destination: 'http://localhost:3004/expenditures/:path*',
},
{
source: '/expenditures',
destination: 'http://localhost:3004/expenditures',
},
{
source: '/projects/:id/members',
destination: 'http://localhost:3002/:id/members',
},
{
source: '/projects/:id/expenditures',
destination: 'http://localhost:3002/:id/expenditures',
},
{
source: '/projects/:id/donors',
destination: 'http://localhost:3002/:id/donors',
},
{
source: '/projects',
destination: 'http://localhost:3002/projects',
},
];
},
output: 'export',
trailingSlash: true, // emit /route/index.html — clean S3 key mapping
images: { unoptimized: true }, // no server image optimizer in export
};

export default nextConfig;
189 changes: 189 additions & 0 deletions apps/frontend/src/app/projects/[id]/ProjectDetailClient.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import { FaEdit } from 'react-icons/fa';
import { RxCaretRight } from 'react-icons/rx';
import NavBar from '../../components/Navbar';
import ExpensesTable from '../../components/ExpensesTable';
import StaffCard from '../../components/StaffCard';
import type { Expenditure } from '../../components/ExpensesTable';
import { apiFetch } from '@/lib/api';

type Project = {
project_id: number;
name: string;
description: string;
total_budget: string | null;
start_date: string | null;
end_date: string | null;
currency: string | null;
created_at: string | null;
};

type Member = {
user_id: number;
name: string;
email: string;
role: string;
};

const PREVIEW_EXPENSES = 8;
const PREVIEW_STAFF = 4;

export default function ProjectPage() {
const { id } = useParams<{ id: string }>();

const [project, setProject] = useState<Project | null>(null);
const [expenditures, setExpenditures] = useState<Expenditure[]>([]);
const [members, setMembers] = useState<Member[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

const token =
typeof window !== 'undefined'
? (localStorage.getItem('branch_access_token') ?? '')
: '';

useEffect(() => {
if (!id) return;

async function fetchAll() {
setLoading(true);
setError(null);
try {
const [projectData, expenditureData, memberData] = await Promise.all([
apiFetch<Project>(`/projects/${id}`, { token }),
apiFetch<Expenditure[]>(`/projects/${id}/expenditures`, { token }),
apiFetch<{ ok: boolean; body: { users: Member[] } }>(
`/projects/${id}/members`,
{ token },
),
]);
setProject(projectData);
setExpenditures(Array.isArray(expenditureData) ? expenditureData : []);
setMembers(memberData?.body?.users ?? []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load project');
} finally {
setLoading(false);
}
}

fetchAll();
}, [id, token]);

// financial info
const totalBudget = project?.total_budget ? parseFloat(project.total_budget) : 0;
const totalSpent = expenditures.reduce((sum, e) => sum + parseFloat(e.amount), 0);
const totalRemaining = totalBudget - totalSpent;

// Loading state
if (loading) {
return (
<div className="flex min-h-screen">
<NavBar role="admin" />
<div className="flex-1 flex items-center justify-center">
<p>Loading project...</p>
</div>
</div>
);
}

// error / not found state
if (error || !project) {
return (
<div className="flex min-h-screen">
<NavBar role="admin" />
<div className="flex-1 flex items-center justify-center">
<p style={{ color: 'var(--color-error-red)' }}>
{error ?? 'Project not found.'}
</p>
</div>
</div>
);
}

// main page
return (
<div className="flex min-h-screen">
<NavBar role="admin" />

<div className="!flex-1 bg-core-white !px-6 !py-6 lg:!px-10 lg:!py-8">

{/* Project title row */}
<div className="flex !justify-between !items-start !mb-3">
<h1>{project.name}</h1>
<button className="flex !items-center !gap-2 !bg-core-green !text-core-white !px-4 !py-2 !rounded-lg !text-sm !font-medium">
<FaEdit size={13} />
Edit Project
</button>
</div>

<p className="!text-core-black !mb-8">{project.description}</p>

{/* Stat cards */}
<div className="flex flex-col md:flex-row justify-between !mb-10 gap-4">
{[
{ label: 'Funding Received', value: totalBudget },
{ label: 'Total Spent', value: totalSpent },
{ label: 'Total Remaining', value: totalRemaining },
].map(({ label, value }) => (
<div
key={label}
className="!border !border-black-200 !rounded-xl !p-6 w-full md:w-[30%] lg:w-[28%] xl:w-[25%]"
>
<h4 className="!mb-2">{label}</h4>
<h1 className="!truncate">
${value.toLocaleString()}
</h1>
</div>
))}
</div>

<div className="!grid !grid-cols-[3fr_2fr] !gap-15">
{/* Expenses */}
<div>
<div className="!flex !justify-between !items-center !mb-3">
<h4>Expenses</h4>
<button className="!flex !items-center !gap-0.5 !text-core-black">
<RxCaretRight size={18} />
<h5>View More</h5>
</button>
</div>
{expenditures.length === 0 ? (
<p className="!text-sm !text-gray-500">No expenses recorded.</p>
) : (
<div className="!border !border-black-200 !overflow-hidden">
<ExpensesTable
expenditures={expenditures.slice(0, PREVIEW_EXPENSES)}
showDescription={false}
/>
</div>
)}
</div>

{/* Staff */}
<div>
<div className="!flex !justify-between !items-center !mb-3">
<h4>Staff</h4>
<button className="!flex !items-center !gap-0.5 !text-core-black">
<RxCaretRight size={18} />
<h5>View All</h5>
</button>
</div>
{members.length === 0 ? (
<p className="!text-sm !text-gray-500">No staff assigned.</p>
) : (
<div className="!grid !grid-cols-2 !gap-3">
{members.slice(0, PREVIEW_STAFF).map((member) => (
<StaffCard key={member.user_id} name={member.name} email={member.email} />
))}
</div>
)}
</div>

</div>
</div>
</div>
);
}
Loading
Loading