Beauty Forward
A donation logistics platform for beauty product redistribution, built for Isan Elba. Two production apps — donation pickup orchestration and AI-assisted inventory management — plus an analytics dashboard in design.
Problem
Beauty Forward routes donated, unused beauty products from individuals to shelters. The operational reality has three pieces: getting product physically out of donors' homes (courier logistics, payment verification, scheduling), getting it onto shelter shelves with accurate inventory records (intake, classification, batching), and giving shelters visibility into what's coming and what they have on hand (analytics).
Three apps. Two in production. One in design.
Architecture
- Angular 21
- Firebase (Functions, Firestore, Data Connect)
- Gemini 2.5 Flash
- Roadie
- Givebutter
- HubSpot
- Resend
donation-delivery-app is the donor-facing pickup scheduler. Angular 21 frontend, Firebase Cloud Functions backend, Firestore for state. Integrates Roadie for last-mile courier dispatch, Givebutter for donation processing and payment verification, HubSpot for CRM upserts, and Resend for transactional email.
inventory-management-system is used by the warehouse manager to catalog and track donated products. Same Angular + Firebase shape, plus Firebase Data Connect for relational queries and Gemini 2.5 Flash for photo-to-inventory extraction at intake.
The analytics dashboard is the third app, currently in design. See "What's next" for the planned shape.
Technical decisions
-
Three-path payment + courier orchestration with webhook recovery
donation-delivery-app/functions/src/services/dispatch.service.ts:26–100 gates courier dispatch on Givebutter payment verification with three fallback paths: verified → dispatch immediately; Givebutter unreachable or slow → mark
awaiting_paymentand rely on the webhook backstop; verification rejected → markpayment_verification_failedand send a recovery email. The webhook handler at donation-delivery-app/functions/src/index.ts:448–605 re-dispatches when payment confirms later. It guards against the third-party APIs being down or slow, and keeps working anyway. -
Idempotent email send across concurrent trigger paths
Confirmation emails can fire from three places: the donor-submit callable, the Firestore
onCreatetrigger, and the Givebutter webhook recovery path. AsendEmailOnce()pattern in donation-delivery-app/functions/src/services/resend.service.ts storesconfirmationEmailSentAton the donation document and checks it before sending, so duplicate sends from concurrent triggers are impossible by construction — not merely unlikely. -
Point the camera, get an inventory record
Intake used to mean typing every donated product in by hand. Now the warehouse manager points the phone camera at an item and the form fills itself. inventory-management-system/functions/src/index.ts:219–287 sends the photo to Gemini 2.5 Flash at temperature 0.2 (low, near-deterministic), under a JSON-schema constraint covering a 30-entry product-type enum plus a high/medium/low confidence field. The prompt tells the model to leave fields blank when it isn't sure rather than guess, and the confidence value drives a UI hint that flags low-confidence extractions for a human glance before they save. Barcode scanning is the second intake path: scan the UPC and, when the photo read is thin, a fallback chain fills the gaps — Open Beauty Facts → Open Food Facts → UPCitemdb → text-only Gemini, in that order. Same record either way; the manager picks whichever is faster for the item in their hand.
-
Normalizing messy extractions into categories that actually count
A model reading a label gives you whatever the label says — "moisturising lotion," "body moisturizer," "daily lotion" are three strings for one thing. Inventory only works if they collapse to one category, so the real engineering here is in the normalization, not the model call. [path — fill in] takes the free-form extracted text and matches it against a fixed set of predefined categories, so totals, batching, and downstream reporting count the same product as the same product every time instead of fragmenting across near-duplicate labels. The enum constrains what the model is allowed to return; this step handles everything that still slips through as raw text.
-
Donations flow from the delivery app into inventory
Intake isn't only what's physically scanned at the warehouse. Donations captured in the donation-delivery-app — what's coming, from whom, how much — flow straight into the inventory-management-system and surface as a tab the warehouse manager works from. [path — fill in] wires the cross-app read, so incoming product shows up as something to receive against rather than something to re-key from scratch. The two apps share one picture of what's in the building and what's on the way.
What's next
-
App 3 — analytics dashboard. Currently in design, built on the data the two production apps already capture. The plan is to turn that data into insight for brands: which of their products get donated most, how much product gets diverted from landfill and where it ends up; and what's actually in demand.
-
Real Givebutter session creation. The current donation flow builds a Givebutter checkout URL; the longer-term shape is a server-side session-creation API call so the platform can carry session metadata end-to-end and tighten reconciliation.
-
Shelter acceptance rule enforcement. The data model already captures what each shelter takes and doesn't take —
acceptedTypes,rejectedTypes,preferredBrands, andcapacityPerBatch— but batch prep currently relies on user judgment to line up with those preferences. The next step is to automate the enforcement, so a batch can't be created with products a shelter doesn't accept.