AWS Builder Center

CleanStreet: Building an AWS-Powered Roadside Litter Reporting and Cleanup Platform

CleanStreet is an environmental hackathon project that lets citizens report roadside waste, administrators coordinate cleanups, and the whole community track progress

Introduction

Roadside litter is a visible, persistent problem. The bigger challenge is not awareness - most people can see the issue - it is coordination. Reports get phoned in, posted to social media, or emailed to councils and then disappear into inboxes. Cleanup teams have no shared view of where problems are, how severe they are, or whether a previous report was ever acted on.
For a recent environmental hackathon, we set out to build something small enough to finish in a day but complete enough to demonstrate an end-to-end workflow: report a problem, prioritize it transparently, track it through to resolution, and record real cleanup evidence. The result is CleanStreet - a serverless, AWS-native platform that connects the people who spot litter with the teams who clean it up.



The Problem We Wanted to Solve

Consider the perspective of a community cleanup coordinator. Residents call in reports through whichever channel they prefer - phone, social media, email, or council web forms. The coordinator has no unified view. They cannot easily answer: Which street has the most active reports? Has this specific pile been reported before? Did the cleanup team who attended yesterday actually finish the job?
CleanStreet is built for two users: the resident who submits a report from their phone and optionally attaches a photo, and the coordinator who triages reports, dispatches cleanup, and marks work as resolved with evidence. It is a hackathon project, not a production council system, but the workflow it models is real.



Introducing CleanStreet

CleanStreet's core workflow has five steps.
  1. Submit a report. A resident provides a street name, chooses a waste category (plastic, household waste, construction debris, or other), sets a severity level (low, medium, or high), writes a description, and optionally uploads a before-photo directly from their device.
  1. Prioritize automatically. Every active report receives a priority score computed from three factors: severity, recency, and whether multiple reports share the same location string. The top five hotspots appear on the dashboard and on a dedicated Hotspots page.
  1. Track status. Reports move through three states - open ,  in_progress , and  resolved  - with enforced transitions. A coordinator cannot skip directly from open to resolved.
  1. Record evidence. When resolving a report, the coordinator must write a cleanup note, and can optionally upload an after-photo and enter a waste quantity in kilograms.
  1. Measure impact honestly. The Environmental Impact page shows the count of resolved reports and the total waste collected - but only the quantity that was explicitly recorded. We do not estimate or extrapolate.



System Architecture

The architecture is deliberately minimal. A single AWS Lambda function handles all seven API routes, and AWS SAM manages the entire infrastructure stack.
Plain text
Browser (React + Vite SPA)
│ HTTPS (VITE_API_URL)
▼
Amazon API Gateway (REST, prod stage)
│
▼
AWS Lambda — cleanstreet-ReportsFunction (Node.js 20, TypeScript, esbuild)
├── GET/POST /reports
├── GET/PATCH /reports/{id}
├── GET /dashboard
├── POST /uploads/presign → issues S3 presigned PUT URL
└── GET /uploads/view → issues S3 presigned GET URL
│ │
▼ ▼
Amazon DynamoDB Amazon S3
CleanStreetReports Private photo bucket
(PAY_PER_REQUEST) (no public access)
StatusIndex GSI

Amazon API Gateway exposes a REST API with a single  prod  stage and CORS configured for all origins (intentionally open for the hackathon demo).
AWS Lambda runs a single TypeScript function compiled by esbuild. The function uses a simple path-and-method router rather than a framework, keeping the cold-start surface small and the code easy to read. Lambda logs flow automatically to Amazon CloudWatch.
Amazon DynamoDB stores all reports in a single  CleanStreetReports  table with on-demand (PAY_PER_REQUEST) billing. A Global Secondary Index ( StatusIndex ) on the  status  and  createdAt  attributes enables efficient status-filtered queries without scanning the entire table.
Amazon S3 (photos bucket) stores uploaded images with Block Public Access fully enabled. Objects are never served directly. Every read and write goes through a presigned URL generated by Lambda.
Amazon S3 (frontend bucket) hosts the static React build as a website. This is a separate bucket with public-read access scoped only to  s3:GetObject  — it is entirely separate from the private photos bucket.
AWS SAM defines every resource in  template.yaml  and handles build (via esbuild) and deployment. The  cleanstreet  CloudFormation stack can be created, updated, or torn down with a single command.



The Data Model

The  Report  type is the core entity. Here it is exactly as defined in the backend:
TypeScript
export interface Report {
id: string; // UUID v4
location: string; // free-text street name
category: WasteCategory; // plastic | household_waste | construction_debris | other
severity: Severity; // low | medium | high
description: string;
photoUrl?: string; // S3 object key (e.g. "photos/<uuid>.jpg")
status: ReportStatus; // open | in_progress | resolved
createdAt: string; // ISO-8601 UTC
updatedAt: string;
cleanupNote?: string; // required when resolving
afterPhotoUrl?: string; // S3 object key
wasteCollectedKg?: number; // only populated when explicitly entered
isDemo: boolean; // distinguishes seeded demo data
}

The  isDemo  flag is worth noting. We pre-seed eight sample reports with fixed UUIDs (so the seed script is idempotent) and mark them  isDemo: true . The application can display them without mixing them with real submissions.



API Endpoints

Method
Path
Description
GET
/dashboard
Stats, top-5 hotspots, 10 recent reports
GET
/reports
List reports; optional ?status=, ?category=, ?severity=
POST
/reports
Create a report
GET
/reports/{id}
Fetch a single report
PATCH
/reports/{id}
Update status, cleanup note, photos, waste quantity
POST
/uploads/presign
Get a presigned S3 PUT URL for a photo
GET
/uploads/view
Get a presigned S3 GET URL for a stored photo key
Status-filtered queries use the  StatusIndex  GSI for efficiency. Category and severity filters are applied in memory after the initial query or scan. Full-table scans are acceptable at the data volumes of a hackathon demo; a production system would add pagination and additional GSIs.
All error responses follow a consistent shape:
JSON
{ "error": "Validation failed", "details": "location is required; severity must be one of: low, medium, high" }




Hotspot Prioritization

The priority scoring logic lives entirely in  backend/src/lib/priority.ts . Every active (non-resolved) report receives a score from three independently computed components:
TypeScript
function severityScore(severity: string): number {
switch (severity) {
case 'high': return 3;
case 'medium': return 2;
case 'low': return 1;
default: return 1;
}
}

function recencyScore(createdAt: string): number {
const ageDays = (Date.now() - new Date(createdAt).getTime()) / 86_400_000;
if (ageDays <= 7) return 3;
if (ageDays <= 30) return 2;
return 1;
}

A  repeatBonus  of +1 is added if two or more open or in-progress reports share the same location string (case-insensitive, trimmed). The maximum possible score is 7 (high severity + reported within 7 days + repeat location).
Every response from  GET /dashboard  includes a  breakdown  string per hotspot — for example:  "Severity: high (+3), Reported 2d ago (+3), 2 reports at this location (+1)" . This transparency was a deliberate design choice: coordinators should understand why a location is ranked first, not just that it is.
One known limitation: location matching is string-based.  "Oak St"  and  "Oak Street"  are treated as different locations. Adding geocoordinates would improve this but was out of scope for a one-day build.



Implementing Secure Photo Uploads

Uploading files through Lambda would waste memory and add latency. Instead, we used S3 presigned URLs to let the browser upload directly to S3 while keeping the bucket private. Here is how the flow works in practice.
Step 1 : Client-side validation. The  ImageUpload  React component rejects files before any network call if they are not  image/jpeg ,  image/png , or  image/webp , or if they exceed 5 MB.
Step 2 : Request a presigned URL. The frontend calls  POST /uploads/presign  with the file's content type and size in bytes. Lambda validates both fields again server-side:
TypeScript
const ALLOWED_CONTENT_TYPES = {
'image/jpeg': 'jpg',
'image/jpg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
};
const MAX_BYTES = 5 * 1024 * 1024;

if (!ALLOWED_CONTENT_TYPES[contentType]) {
return badRequest(`Unsupported file type: ${contentType}`);
}
if (fileSizeBytes > MAX_BYTES) {
return badRequest(`File too large: ${(fileSizeBytes / 1048576).toFixed(1)} MB. Maximum: 5 MB`);
}

A UUID-based key is generated ( photos/<uuid>.jpg ) to prevent collisions and path traversal. Lambda then issues two signed URLs:
  • A presigned PUT URL valid for 5 minutes, used by the browser to upload the file.
  • A presigned GET URL valid for 1 hour, returned immediately for preview after upload.
TypeScript
const uploadUrl = await getSignedUrl(s3, new PutObjectCommand({
Bucket: BUCKET, Key: key, ContentType: contentType,
}), { expiresIn: 300 });

const viewUrl = await getSignedUrl(s3, new GetObjectCommand({
Bucket: BUCKET, Key: key,
}), { expiresIn: 3600 });

Step 3 : Direct S3 upload. The browser uses  XMLHttpRequest  (not  fetch ) to stream the file to the presigned PUT URL with real upload progress events. No AWS credentials ever reach the frontend — the signed URL is the only authorization token, and it expires after five minutes.
TypeScript
uploadToS3(uploadUrl, file, onProgress): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('PUT', uploadUrl);
xhr.setRequestHeader('Content-Type', file.type);
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) onProgress(Math.round(e.loaded / e.total * 100));
});
xhr.addEventListener('load', () =>
xhr.status < 300 ? resolve() : reject(new Error(`HTTP ${xhr.status}`))
);
xhr.send(file);
});
}

Step 4 : Store the key, not the URL. The S3 object key ( photos/<uuid>.jpg ) is stored in DynamoDB. When a report detail panel opens, the frontend calls  GET /uploads/view?key=photos/...  to request a fresh presigned GET URL. The key pattern is validated server-side against a regex ( /^photos\/[a-f0-9-]{36}\.(jpg|png|webp)$/ ) before any S3 call, blocking path traversal attempts.
Authorization note. Presigned URLs are signed with the Lambda execution role's credentials. Anyone who obtains a GET URL can view the image for up to one hour. For a hackathon demo this is acceptable; a production system would scope the signing role more tightly and potentially add authentication before issuing view URLs.



Engineering Challenges

Single Lambda, multiple routes. Rather than one Lambda per endpoint (and the associated cold-start and deployment complexity), we implemented a lightweight path-and-method router directly in  index.ts . This keeps the project simple at hackathon scale. The tradeoff is that one function handles all load; splitting is straightforward if a route needs different memory or timeout settings later.
Status transition enforcement. The  patchReport  handler enforces a transition map:
TypeScript
const STATUS_TRANSITIONS: Record<string, string[]> = {
open: ['in_progress'],
in_progress: ['resolved', 'open'],
resolved: [],
};

A resolved report cannot be reopened. An open report cannot be resolved without first being moved to  in_progress . This prevents coordinators from skipping steps and ensures the audit trail is meaningful.
Double validation. File type and size are validated both in the React component (for fast user feedback) and in the Lambda handler (as the authoritative check). Client-side validation can always be bypassed; the server is the source of truth.
Keeping dist and .env out of git. The Vite build bakes  VITE_API_URL  into the JavaScript bundle at build time. Committing  frontend/dist/  would expose the live API URL in the repository history. The  .gitignore  excludes  dist/ ,  node_modules/ ,  .aws-sam/ , and all  .env.local  files. Only  .env.example  with a placeholder URL is committed.



Environmental Impact - What We Do and Don't Claim

The Impact page displays two numbers:
  • Sites cleaned up - the count of reports with  status === 'resolved' .
  • Waste collected (kg) - the sum of  wasteCollectedKg  across resolved reports, shown only when at least one report has a non-zero value entered.
That is it. We do not estimate litres of water saved, carbon emissions avoided, or species protected. A coordinator records a waste quantity when they resolve a report. If they do not enter one, the figure is simply absent. This design was deliberate: we wanted the platform to be honest about what it knows and avoid presenting computed guesses as verified outcomes.
The demo data included in the repository (eight reports seeded via  src/seed.ts ) is clearly marked with  isDemo: true  in DynamoDB. Sample records were used during development and testing and do not represent real cleanup events.



Getting Started

Prerequisites

  • Node.js 20+
  • AWS SAM CLI
  • AWS CLI with credentials configured ( aws configure )
  • An AWS account with permissions to create Lambda, API Gateway, DynamoDB, S3, IAM roles

Clone and install

Bash
git clone https://github.com/<YOUR_USERNAME>/cleanstreet.git
cd cleanstreet

cd backend && npm install && npm test
# Expected: 24 tests pass (validation + hotspot scoring)


Deploy the backend

Bash
cd backend
sam build
sam deploy --guided
# Stack name: cleanstreet
# Region: us-east-1
# Allow SAM to create IAM roles: Y

# Retrieve the API URL after deployment
aws cloudformation describe-stacks \
--stack-name cleanstreet \
--query "Stacks[0].Outputs[?OutputKey=='ApiUrl'].OutputValue" \
--output text


Run the frontend locally

Bash
cd frontend
npm install
cp .env.example .env.local
# Edit .env.local: VITE_API_URL=https://<your-api-id>.execute-api.us-east-1.amazonaws.com/prod
npm run dev # http://localhost:5173


Deploy the frontend to S3

Bash
cd frontend
VITE_API_URL=<your-api-gateway-url> npm run build

BUCKET=cleanstreet-frontend-<your-account-id>
aws s3 mb s3://$BUCKET --region us-east-1
# (Configure public-read bucket policy and static website hosting — see README)
aws s3 sync dist/ s3://$BUCKET/ --region us-east-1 --delete
echo "Live at: http://$BUCKET.s3-website-us-east-1.amazonaws.com"


Seed demo data

Bash
cd backend
TABLE_NAME=CleanStreetReports AWS_REGION=us-east-1 \
npx ts-node --project tsconfig.json src/seed.ts

# Remove demo data:
TABLE_NAME=CleanStreetReports AWS_REGION=us-east-1 \
npx ts-node --project tsconfig.json src/seed.ts clear




Demo and Future Improvements

What we would build next

  • Authentication - Amazon Cognito to distinguish residents (can report) from coordinators (can resolve). Currently any user can update any report.
  • HTTPS frontend - Adding a CloudFront distribution in front of the S3 bucket would provide HTTPS and a custom domain at minimal cost.
  • Pagination - The current DynamoDB  Scan  works at hackathon scale but needs cursor-based pagination for production volumes.
  • Geographic coordinates - Storing lat/lon alongside the free-text location would enable proximity-based repeat-location detection and a map view.
  • Push notifications - An EventBridge rule triggered by DynamoDB Streams could notify coordinators when a high-severity report is submitted.
  • S3 lifecycle policy - Photos should be automatically deleted or archived after reports reach a certain age to control storage costs.
  • Tighter CORS - The current  AllowOrigin: '*'  is intentional for the demo. A production deployment would restrict this to the specific frontend domain.



Conclusion

CleanStreet demonstrates that a meaningful civic tool can be built on AWS in a short time with a small, focused set of services. The interesting engineering is not in any individual component — DynamoDB, Lambda, and S3 are all well-understood — but in the decisions that connect them: keeping the photo bucket private while still allowing direct browser uploads, enforcing status transitions at the API layer rather than trusting the client, and designing the priority score to be legible rather than opaque.
The broader lesson from the hackathon is that serverless AWS services lower the activation energy for this kind of project significantly. SAM handles IAM, API Gateway, and Lambda wiring in a single template. DynamoDB requires no capacity planning. S3 presigned URLs solve the file-upload problem without any upload infrastructure. The result is a system where almost all the work is application logic, not infrastructure plumbing.
If you are building something similar — a community reporting tool, an inspection workflow, or any application where field evidence needs to be collected, prioritized, and verified — we hope CleanStreet gives you a useful starting point.


Built for the environmental hackathon. All code is open source.





Mermaid Architecture Diagram

(Based directly on the verified implementation in  backend/src/index.ts ,  template.yaml , and the frontend API client.)


Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article