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.
- 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.
- 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.
- Track status. Reports move through three states -
open,in_progress, andresolved- with enforced transitions. A coordinator cannot skip directly from open to resolved.
- 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.
- 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 S3CleanStreetReports 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 v4location: string; // free-text street namecategory: WasteCategory; // plastic | household_waste | construction_debris | otherseverity: Severity; // low | medium | highdescription: string;photoUrl?: string; // S3 object key (e.g. "photos/<uuid>.jpg")status: ReportStatus; // open | in_progress | resolvedcreatedAt: string; // ISO-8601 UTCupdatedAt: string;cleanupNote?: string; // required when resolvingafterPhotoUrl?: string; // S3 object keywasteCollectedKg?: number; // only populated when explicitly enteredisDemo: 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
wasteCollectedKgacross 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.gitcd cleanstreetcd backend && npm install && npm test# Expected: 24 tests pass (validation + hotspot scoring)
Deploy the backend
Bash
cd backendsam buildsam deploy --guided# Stack name: cleanstreet# Region: us-east-1# Allow SAM to create IAM roles: Y# Retrieve the API URL after deploymentaws cloudformation describe-stacks \--stack-name cleanstreet \--query "Stacks[0].Outputs[?OutputKey=='ApiUrl'].OutputValue" \--output text
Run the frontend locally
Bash
cd frontendnpm installcp .env.example .env.local# Edit .env.local: VITE_API_URL=https://<your-api-id>.execute-api.us-east-1.amazonaws.com/prodnpm run dev # http://localhost:5173
Deploy the frontend to S3
Bash
cd frontendVITE_API_URL=<your-api-gateway-url> npm run buildBUCKET=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 --deleteecho "Live at: http://$BUCKET.s3-website-us-east-1.amazonaws.com"
Seed demo data
Bash
cd backendTABLE_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
- 📂 Repository:
https://github.com/vinayreddy765/cleanstreet
- 🌐 Live demo: cleanstreet
- 🎥 Demo video: https://www.youtube.com/watch?v=0Nu5xTbSup4
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
Scanworks 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.)
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article