← All case studies
Custom SaaS Platforms
Full, ready-to-run software products built from scratch around one business’s exact workflow, not just an automation, a whole system.
All 6 case studies in this section follow in full.
Contents
Custom SaaS Platforms
Case studies in full
AI-Powered Construction Report SaaS (MeinBüromeister)
Turning construction-site voice notes, photos, and messages into structured reports through an automated AI-powered backend for German trade companies.
Section: Custom SaaS Platforms
Overview
MeinBüromeister is a B2B SaaS platform for German construction and trade companies. It allows workers to submit job updates through WhatsApp instead of preparing every report manually. The backend organizes incoming information, generates structured reports, and asks workers to review them before submission.
The Business Problem
Construction workers often send separate voice notes, photos, and text corrections from job sites. Office staff must identify the correct customer and project, organize materials and working hours, and format the information into professional reports. This creates repetitive administrative work and makes report preparation more time-consuming. The goal was to reduce this manual effort while keeping workers involved in reviewing and approving their reports.
The Solution
An n8n-based backend receives WhatsApp messages and groups related inputs into a single session. OpenAI Whisper transcribes voice recordings, while Claude AI extracts relevant report information into structured data. After the worker reviews and approves the report, the backend stores it in Supabase for delivery to the office dashboard. Supporting workflows automate customer onboarding, subscription management, team access, and error handling.
Project Snapshot
A production backend connecting WhatsApp messaging, AI-powered report extraction, worker approval, report storage, customer onboarding, and subscription lifecycle management.
How the System Works
System Architecture
The backend is organized around an n8n automation layer that sits between WhatsApp, the AI services used for transcription and extraction, and the Supabase/Stripe infrastructure that powers the office dashboard.
Supporting Integrations
- Stripe: Checkout, subscription lifecycle events, plan changes, and subscription-based access rules.
- Supabase Auth: Authentication support for the application.
- Supabase Storage: Storage for uploaded photos and other media.
- Docker: Supports the self-hosted deployment environment.
Core Automation Workflows
The backend is built around interconnected n8n workflows that automate WhatsApp message handling, AI-powered report generation, customer onboarding, and subscription management. Each workflow handles a specific responsibility and integrates with the relevant APIs and backend services to support the complete reporting lifecycle. Some main automation workflows are shown below.
1. Core Onboarding & Workflow Management
This workflow handles company onboarding, setup-code verification, worker registration, subscription checks, report approval, project management, and notifications through interconnected n8n automation nodes.
Image: Core onboarding and workflow management: company setup, worker registration, subscription checks, report approval, project handling, and notifications across 15 interconnected automation groups.
2. AI-Powered Report Processing Workflow
This workflow processes WhatsApp messages using Whisper and Claude AI to extract structured report data, enrich customer and project details, validate reports, and handle database updates and notifications.
Image: AI-powered report processing: message intake, voice transcription, Claude-based data extraction, report generation, and save/notification handling.
Product Experience
Report inbox
Image: The office inbox organizes incoming reports by date, technician, customer/project, category, and status.
Customers and projects
Image: The project view groups customers and highlights open-report counts and recent work.
WhatsApp work history
Image: Workers review and approve each AI-generated report directly in WhatsApp before it goes to the office.
- <30s: Voice note to structured report
Image: Once approved, the report is saved, confirmed back to the worker on WhatsApp, and delivered to the office as a PDF.
Core Backend Capabilities
- AI Report Extraction: Supports four report categories (Rapport, Aufmaß, Neues Angebot, and Notiz). The AI extracts work descriptions, materials, dimensions, and working hours while using prompt guardrails to reduce invented details.
- Multi-Input Session Handling: Groups related voice notes, photos, captions, and corrections into a single processing session instead of treating every incoming message as a separate report.
- Project Matching and Clarification: Uses available employee and project records to associate reports with the correct destination. When a match is ambiguous, the workflow can request clarification.
- Onboarding and Access Management: Supports company setup, worker invitations, registration tracking, and access checks to ensure incoming messages are routed correctly.
- Subscription Lifecycle Management: Handles relevant Stripe events and subscription states, including active, canceling, and canceled subscriptions. It also manages plan mapping and seat limits.
- Failure Handling and Validation: Validates incoming data and AI-generated output, handles incomplete sessions and invalid actions, and provides controlled paths for processing failures.
Results & Metrics
- 5: Production workflows
- 5: Testing rounds
- 30+: Test scenarios per round
- 25+: Bugs found & fixed
The project delivered five production workflows connecting WhatsApp, AI-powered report extraction, Supabase, and Stripe. Across five rounds of end-to-end testing, more than 25 bugs were identified and fixed, with over 30 test scenarios executed per round.
Technical Challenges & Solutions
During development and end-to-end testing, several integration and AI reliability issues required targeted solutions.
1. Stripe Subscription Data Handling
Challenge
The Stripe checkout event did not always contain all the subscription information required by the workflow.
Solution
Retrieved the complete subscription details directly through the Stripe API before continuing with subscription processing.
Outcome
Improved the reliability of subscription data handling.
2. Subscription Plan Mapping
Challenge
Mapping subscription plans based on payment amounts could lead to incorrect plan identification.
Solution
Used Stripe product IDs to identify plans instead of relying on monetary amounts.
Outcome
Made plan identification more consistent across subscription events.
3. AI Hallucination Control
Challenge
Claude AI could occasionally introduce materials or details that were not present in the worker’s input.
Solution
Refined the AI prompt with guardrails to discourage unsupported details and keep extraction grounded in the supplied information.
Outcome
Improved the consistency of structured report generation.
4. Supabase PATCH Response Handling
Challenge
Some Supabase PATCH operations returned empty responses, creating difficulties for downstream workflow nodes.
Solution
Explicitly referenced upstream node outputs where required instead of relying on the PATCH response to provide all subsequent data.
Outcome
Made downstream data handling more reliable.
5. End-to-End Integration Testing
Challenge
The system depended on multiple connected services, including WhatsApp, AI models, Supabase, and Stripe, making integration failures difficult to isolate.
Solution
Conducted five rounds of end-to-end testing, covering more than 30 scenarios per round, and fixed over 25 identified bugs.
Outcome
Improved integration reliability across the production workflows.
Key Takeaways & Future Development
Key Takeaways
- Workflow automation: Built and integrated five production n8n workflows connecting WhatsApp, AI services, Supabase, and Stripe.
- AI reliability: Improved structured report extraction through prompt engineering, validation, and guardrails against unsupported details.
- Integration engineering: Resolved issues involving Stripe subscription events, plan mapping, and Supabase response handling.
- End-to-end testing: Completed five testing rounds, identifying and fixing more than 25 bugs across the integrated system.
- Cross-functional collaboration: Coordinated with the frontend engineer on database requirements and data contracts to connect backend workflows with the React dashboard.
Closing Summary
MeinBüromeister demonstrates how workflow automation, AI-powered information extraction, and integrated SaaS infrastructure can simplify construction reporting. The primary contribution was engineering the backend workflows and integrations that connect field communication with structured reports, subscription management, and the office dashboard.
Page · Plain text
Contract Intake Tool
AI-Powered Contract Validation, Human Review, Automated Google Drive Filing, Folder Organization, Missing-Term Detection & Document Tracking.
Section: Custom SaaS Platforms
Overview
The Contract Intake Tool is a web app that uses Claude AI to extract key terms from supplier contracts, flags missing information for human review, and automatically files approved contracts into the correct Google Drive folders. It also maintains a record of all filing activities..
Business Problem
Supplier contracts such as hotel, venue, and bus services) Were reviewed and filed manually, making the process time-consuming. Important terms like cancellation clauses, fees, and tax rates could be missed, and contracts sometimes ended up in the wrong Google Drive folders, making them difficult to find later for billing or disputes.
Proposed Solution
The solution is a web app that uses Claude AI to extract required terms from supplier contracts (PDFs), allows users to review missing information, and automatically files approved contracts into the correct Google Drive folders. PostgreSQL maintains a record of each filing, such as who filed it, what was filed, where it was filed, and when
How the System Works
Image: Figure: AI-Powered Supplier Contract Processing Workflow
Technology Stack
The application is built using the following technologies:
- Next.js: Web application framework.
- TypeScript: Application development.
- Claude AI: Extracts and checks required contract terms.
- Google Drive API: Files contracts into the correct folders.
- PostgreSQL: Stores filing records (who filed it, what was filed, where it was filed, and when).
- JavaScript PDF Library: Extracts text from uploaded PDF contracts.
Key Features
- AI-Powered Term Extraction: Extracts required contract terms using Claude AI.
- Missing Term Review: Flags missing information (Not Found) for manual review before filing.
- Automated Google Drive Filing: Files contracts into the correct folder based on group name, date, and category.
- Filing Activity Log: Records who filed the contract, what was filed, where it was stored, and when.
- Category-Based Checks: Checks contract terms according to the selected category (Hotel / Venue / Buses / Other).
Key Safeguards
- Human Verification: Contracts with missing terms (Not Found) require manual review before filing.
- Controlled Folder Creation: Only the Chapter and Category folders are created automatically; Year, Season, and University folders must already exist in Google Drive.
- Filing Audit Log: Each filing records who filed the contract, what was filed, where it was stored, and when.
Closing Summary
The Contract Intake Tool simplifies supplier contract processing by combining AI-powered term extraction, human review, and automated Google Drive filing. It helps reduce manual work, identify missing terms, and maintain a clear record of filing activities.
Page · Plain text
Paid List vs. Rooming List
Compare payment and rooming records, identify discrepancies, and keep group travel organized with confidence through faster, smarter, and more accurate data reconciliation.
Section: Custom SaaS Platforms
Project Overview
The Paid List vs. Rooming List is a web application designed for a group travel company to compare payment records with room assignments. It identifies missing guests, unpaid balances, and similar-looking names, helping staff detect discrepancies quickly and maintain accurate travel records. Users can upload CSV files exported from Google Sheets and receive categorized comparison results without manually checking hundreds of rows.
Business Problem
The group travel company managed payment records and room assignments in separate Google Sheets, making it difficult to keep both lists consistent. Staff had to manually check hundreds of rows to identify guests who had paid but were missing from the rooming list, guests assigned to rooms without payment records, and names with slight spelling differences. This process was time-consuming, error-prone, and made it harder to identify discrepancies quickly.
Proposed Solution
The solution is a web application that compares the Paid List and Rooming List using uploaded CSV files exported from Google Sheets. It automatically matches guest names, verifies payment status, and categorizes results into three groups: Matched, Paid List Issues, and Rooming List Issues. It also highlights similar-looking names to help staff identify possible spelling differences and avoid overlooking guests. Each results table can be sorted and downloaded separately as a CSV file, making the reconciliation process faster and more reliable.
End-to-End Workflow
Image: Figure: Workflow Overview
How It Works
1. CSV Upload and Parsing: Staff upload the Paid List and Rooming List as CSV files exported from Google Sheets. The system handles differences in file formats, including header rows, optional row-number columns, and variations in payment column names.
2. Rooming List Processing: Rooming List rows are converted into individual guest records, including multiple occupants assigned to the same room.
3. Guest Name Matching: The system compares names using case-insensitive exact matching. If an exact match is not found, it checks for similar names based on shared surnames, minor spelling differences, or possible nicknames.
4. Discrepancy Detection: The application identifies guests missing from either list, flags payment statuses that are not marked “Paid in Full,” and highlights potential name matches for further review.
5. Categorized Results: Results are displayed in three separate tables: Matched, Paid List Issues, and Rooming List Issues. Red highlights indicate discrepancies, while yellow highlights indicate possible similar-name matches.
6. Review and Export: Staff can sort the results and download each table as a separate CSV file for further review and record-keeping.
Results Categories
Product Experience
The application provides a simple, browser-based interface that allows staff to upload CSV files, compare guest records, and review discrepancies in organized tables. Results are separated into three categories
so users can quickly identify records that need attention. The tables support sorting and individual CSV downloads, making it easier to investigate issues and share results with the team.
Image: The upload screen: a staff member adds the Rooming List and Paid List CSVs exported from the group’s Google Sheet and clicks Compare.
Image: The results summary and the Paid List issues. Red rows are real discrepancies and yellow rows are similar-name warnings. Names, amounts, room and confirmation numbers are blurred for privacy.
Image: The Rooming List issues: people on the rooming list who are not on the paid list. Names and booking numbers are blurred for privacy.
Image: The matched list: people who paid in full and are on the rooming list, with no action needed. Names, amounts and booking numbers are blurred for privacy.
Technology Stack
| Technology | Purpose |
|---|
| TypeScript | Core application logic and type-safe data processing |
| Node.js | Runs the backend using a plain HTTP server without a web framework |
| HTML, CSS & Vanilla JavaScript | Provides the browser interface, interactive result tables, sorting, and CSV downloads |
| Google Sheets & CSV | Source of the Paid List and Rooming List data, uploaded as CSV files |
| Railway | Cloud deployment and application hosting |
Data Handling & Limitations
The application processes uploaded CSV files in real time and returns comparison results directly in the browser. It is stateless, meaning uploaded data is not saved to disk. Each comparison runs independently, and users can download the Matched, Paid List Issues, and Rooming List Issues tables as separate CSV files.
Limitations: The accuracy of the results depends on the quality and consistency of the uploaded data. Similar-name detection helps identify potential matches but may require manual verification. Staff should review flagged discrepancies before making changes to payment records or room assignments.
Closing Summary
The Paid List vs. Rooming List application simplifies group travel reconciliation by automatically comparing payment records with room assignments. Through exact name matching, similar-name detection, payment status validation, and categorized discrepancy reports, it helps staff identify issues faster and reduce manual checking. Built with TypeScript, Node.js, and a lightweight web interface, the tool provides a practical and efficient way to maintain accurate guest records and support smoother group travel operations.
Page · Plain text
AI Lead Qualification & Sales Routing Agent
An end-to-end system for automated inbound lead scoring, enrichment, and real-time sales routing, powered by GPT-4.1 and n8n.
Section: Custom SaaS Platforms
Image: The lead form with a sample lead and the AI results card: score, HOT category, intent, budget, urgency, ICP match, summary and recommended action.
- Type: Systems Case Study
- Platforms: Node.js · Express · OpenAI GPT-4.1 · n8n · Slack · Airtable
- Data Sources: Website Lead Forms · n8n Webhooks · Direct API Submissions
- Integrations: Clearbit (enrichment) · Airtable (CRM) · Slack (alerts) · Email (SMTP)
Why we built this
Most sales teams receive inbound leads from multiple sources (website contact forms, paid ad landing pages, partner referrals, and API integrations) and have no consistent, fast way to decide which ones deserve immediate attention.
The core problems the team was facing:
- No consistent scoring methodology: qualification decisions depended on whoever happened to read the form first, leading to missed opportunities and wasted rep time
- Hot leads were going cold: without instant routing, a prospect who said “ASAP, budget approved” could wait hours before a rep saw the submission
- Company intelligence had to be looked up manually: reps spent time researching the company behind an email address before they could even decide whether to respond
- No tiered escalation: there was no system to distinguish between a lead that needed a call in the next hour versus one that could go into a drip sequence
- No unified lead record: repeat contacts from the same email were logged as separate entries, losing the interaction history
The goal was to build a fully automated intake-to-routing pipeline: AI scoring that runs in under two seconds, automatic company enrichment from the email domain, tiered alerts that reach the right person at the right time, and a central record of every lead with full analysis history.
What this system does
The system is composed of three interconnected layers: a lead intake API, a backend processing pipeline, and a real-time alerting engine, all orchestrated through n8n and a Node.js Express server.
Lead intake
- Accepts inbound leads via a hosted landing page form, a direct REST API call, an embedded form webhook, or an n8n-triggered workflow
- Validates required fields (name, email) and normalises all input before processing begins
- Detects repeat submissions from the same email address: existing records are updated with the new interaction rather than duplicated
- Supports a source field to track which channel each lead arrived from (website, paid ad, referral, n8n, etc.)
AI scoring engine
- Each lead is analysed by GPT-4.1 across five dimensions: budget signals, urgency language, purchase intent, ICP (Ideal Customer Profile) match, and email quality
- Returns a structured score from 0–100 and classifies the lead as HOT, WARM, or COLD
- Produces a 12-field analysis object: score, category, intent, budget, urgency, ICP match, AI summary, recommended action, key signals, risk factors, estimated deal size, and sales rep assignment
- Falls back to a rule-based keyword scoring engine if the OpenAI API is unavailable, guaranteeing zero-downtime operation
Enrichment layer
- Extracts the company domain from the lead’s email address and enriches it with external company intelligence
- With Clearbit: pulls company name, industry, employee count, estimated revenue, tech stack, LinkedIn URL, and headquarters location
- Without Clearbit: performs basic domain analysis, infers company name, identifies TLD-based region, and confirms it is a business (not free) email
- Free email providers (Gmail, Yahoo, Hotmail, etc.) are skipped for enrichment and flagged accordingly
Alerting and routing
- Fires Slack alerts for every HOT lead: rich Block Kit messages, including all 12 analysis fields, AI summary, estimated deal size, and assigned sales team
- Routes WARM leads to a nurture queue for email sequence follow-up
- Archives COLD leads automatically with no sales action triggered
- All notification steps run asynchronously, so they never delay the API response back to the form or caller
CRM integration
- Pushes every processed lead to Airtable with 18 mapped fields, including all analysis outputs
- Designed to be swapped for HubSpot or any other CRM with a single service file change
- Skips gracefully if CRM credentials are not configured: the pipeline continues without interruption
Benefits
- Hot leads reach a rep in seconds, not hours, the moment a high-intent submission arrives, a Slack alert lands in the right channel with everything the rep needs to act
- Consistent, bias-free scoring: every lead is evaluated against the same GPT-4.1 criteria, removing the inconsistency of human triage
- Company intelligence without manual research: reps open a Slack alert and already know the company’s industry, size, and tech stack
- Zero-downtime resilience: if the AI API goes down, rule-based fallback scoring keeps the pipeline running
- Unified lead history: repeat contacts are detected, and interactions are appended to the original record, preserving full context
- Clean data from day one: lead sources are tracked, free email submissions are flagged, and all records share a consistent schema
- Scalable without config changes: new lead sources plug in via the webhook endpoint; the pipeline handles volume automatically
How it works (simplified)
- A lead arrives via the landing page form, a direct API call, an embedded form, or an n8n webhook, all routes converge on the same processing pipeline
- The system checks for a repeat submission by looking up the email address in the lead store; if found, the new interaction is logged against the existing record
- The lead is enriched: the email domain is used to pull company data from Clearbit, or a basic domain analysis runs as a fallback
- GPT-4.1 analyses the lead: the full enriched record is submitted to the AI with a structured scoring prompt; a 12-field JSON response is returned in under two seconds
- The lead is stored in the in-memory data layer (replaceable with PostgreSQL or Supabase) and a full analysis record is written
- HOT lead → Slack alert is sent to the relevant sales channel, tagging the assigned rep team with the full AI summary and deal estimate
- WARM lead → nurture queue: logged for drip email sequence follow-up
- COLD lead → archived: stored for reference with no immediate sales action
- The CRM is updated asynchronously: Airtable receives the full 18-field record without blocking the API response
- The caller receives an instant response: the full analysis JSON is returned to the form or API caller within the same two-second window
System architecture (simplified)
Image: Architecture: four lead sources feed an Express backend with a five-step pipeline (validate, repeat detection, Clearbit enrichment, GPT-4.1 scoring, store), which routes to Slack alerts, an Airtable CRM and a nurture queue.
Ops-controlled settings (thresholds, routing rules, and CRM field mappings) are managed through environment configuration:
| Setting | Where Configured | What It Controls |
|---|
| OPENAI_API_KEY | .env file | Enables AI scoring via GPT-4.1 |
| SLACK_WEBHOOK_URL | .env file | Destination channel for HOT lead alerts |
| AIRTABLE_API_KEY + BASE_ID | .env file | CRM destination for all processed leads |
| CLEARBIT_API_KEY | .env file | Enables full company enrichment (optional) |
| Alert Toggle (per lead) | Google Sheets control layer | Enable or disable alerts per lead source |
| Score thresholds | Prompt config in aiService.js | Adjustable scoring bands per business context |
Dashboard overview
Lead analysis results panel
The lead form renders a live results card immediately after submission, displaying the AI score, category, intent rating, budget signal, urgency level, ICP match, and a plain-English summary of why the lead scored the way it did, along with a specific recommended next action.
Renders in the browser within ~2 seconds of form submission
Slack alert panel
When a lead is classified as HOT, a Block Kit alert fires to the configured Slack channel. The message includes: lead name and company, email, phone, all five scoring dimensions, the AI-generated summary, recommended action, estimated deal size, and assigned sales team, formatted as a structured card with section dividers.
Delivered to the #sales-alerts channel within seconds of submission
Analytics dashboard
The /dashboard page provides a live operations view of all processed leads: total volume, HOT / WARM / COLD breakdown, average score, conversion rate, leads today, leads this week, top sources by volume, and a filterable lead feed. Data is drawn directly from the in-memory store and refreshes on page load.
Accessible at /dashboard: no login required in base configuration
Project evolution & key milestones
The system scope grew significantly from the initial lead scoring proof-of-concept. Key additions and refinements delivered across the project lifecycle:
Core scoring engine
- Initial GPT-4 integration replaced with GPT-4.1 for faster, more consistent structured output
- AI temperature set to 0.3 after testing: balances consistency with contextual sensitivity
- Strict JSON output schema enforced via system prompt to eliminate parsing failures
- Rule-based fallback engine added to ensure zero downtime when the OpenAI API is unavailable
- Score validation and sanitisation layer added (score clamped 0–100; category constrained to enum)
Enrichment layer
- Clearbit integration added as the primary enrichment source for business email domains
- Domain-only fallback implemented: company name inference, TLD region mapping, business email detection
- Free email provider list maintained to skip enrichment for Gmail, Yahoo, Hotmail, iCloud, ProtonMail, and similar
Alerting system
- Slack alerts upgraded from plain text to full Block Kit format with eight labelled fields and section dividers
- Tiered routing logic added: HOT → Slack, WARM → nurture queue, COLD → archive
- All notification steps moved to asynchronous execution to keep main API response time under two seconds
- CRM push also made non-blocking: Airtable delays no longer affect the caller experience
Data and integration
- n8n webhook route added (/api/webhook/n8n) to support visual workflow orchestration
- Embedded form endpoint added (/api/webhook/form) for third-party form providers
- Importable n8n workflow JSON file shipped with the project for instant setup
- Airtable CRM integration added with 18 mapped fields; designed as a swappable service module
Memory layer and analytics
- Repeat lead detection added: duplicate email submissions update the existing record rather than creating a new one
- Interaction history log added to each lead record for full contact context
- Analytics aggregation engine built into the storage service: computes category counts, average score, conversion rate, score distribution, top sources, and temporal metrics on demand
- Dashboard feed endpoint added with configurable pagination and category/source filters
Security and production hardening
- Helmet HTTP security headers added across all routes
- Rate limiter set to 100 requests per 15 minutes per IP on all /api/* routes
- Request body size capped at 1 MB to prevent oversized payload attacks
- Explicit timeouts set on all external API calls (OpenAI: 30s · Clearbit: 5s · Airtable: 10s · Slack: 5s)
- Global error handler middleware added to prevent stack traces leaking to API callers
Data flow architecture
How data moves through each stage and at what cadence, a key reference for ops teams integrating this system into existing stacks:
| Stage | Source | Destination | Trigger |
|---|
| Intake | Form / API / n8n webhook | Express backend | On submission (real-time) |
| Enrichment | Clearbit API / domain analysis | Lead record (in memory) | Per lead, synchronous |
| AI Scoring | OpenAI GPT-4.1 | Lead record (in memory) | Per lead, synchronous |
| CRM Push | In-memory lead record | Airtable (18 fields) | Per lead, async (non-blocking) |
| Slack Alert | In-memory lead record | Slack channel (HOT only) | Per lead, async (non-blocking) |
| Dashboard | In-memory lead store | Browser via /dashboard | On page load / manual refresh |
Note
The current storage layer uses an in-memory Map for speed and simplicity. For production deployments requiring persistence across restarts or horizontal scaling, this is designed to be swapped for PostgreSQL, Supabase, or MongoDB, the storage service exposes a clean interface with no direct database calls in the route layer.
Lead scoring & classification
The AI scoring engine evaluates every lead across five weighted dimensions and assigns a 0–100 score. Classification thresholds are:
| Category | Score Range | Routing Action | Criteria |
|---|
| HOT | 80 – 100 | Immediate Slack alert + rep assignment | Clear budget, urgency signal, decision-maker identified, strong ICP match |
| WARM | 50 – 79 | Added to nurture email sequence | Some buying signals, partial ICP match, needs further qualification |
| COLD | 0 – 49 | Archived, no immediate action | No clear intent, no budget signals, poor ICP fit or generic enquiry |
The five scoring dimensions GPT-4.1 evaluates are:
- Budget: explicit dollar amounts, investment language, company size inference
- Urgency: timeline pressure, ASAP / deadline keywords, quarter-based mentions
- Intent: active buying vs. passive browsing language (“want to buy” vs. “just exploring”)
- ICP Match: B2B vs. B2C, company size (50–500 employees = Excellent), industry alignment
- Email Quality: business domain vs. free provider (Gmail, Yahoo, Hotmail, etc.)
Page · Plain text
LeadFlow AI (Dubai Real Estate Lead Automation)
AI-powered SaaS platform for Dubai real estate brokerages. Capture inbound leads, classify with GPT-4o, auto-reply via WhatsApp, assign to agents, and track follow-ups, all behind Stripe subscription billing.
Section: Custom SaaS Platforms
Image: The LeadFlow AI landing page: “Never miss a hot lead again”.
- Type: Product Case Study, SaaS Platform
- Product: LeadFlow AI, Dubai Real Estate Lead Automation
- Industry: Proptech / Real Estate (Dubai, UAE)
- Frontend: React (Vite) + Tailwind CSS, hosted on Vercel
- Backend: Node.js + Express, hosted on Render
- Database: Firebase Firestore + Firebase Auth
- AI Engine: OpenAI GPT-4o
- Messaging: WhatsApp Cloud API (Meta Graph API v21.0)
- Payments: Stripe (AED subscriptions with 7-day trial)
Why we built this
Dubai’s real estate market moves fast. Leads come in around the clock from buyers, renters, and investors often in multiple languages, often with a specific unit, area, or budget in mind. Brokerages that respond within minutes close deals; those that respond the next day don’t.
The core problems Dubai brokerages were facing:
- No consistent lead qualification: every agent applied their own judgment to decide which leads to call first, leading to hot buyers being left to wait while cold inquiries were chased
- Response time was too slow: manually reading, sorting, and replying to leads during business hours meant international prospects in different time zones received no reply at all
- WhatsApp was unmanaged: the primary communication channel in the UAE was handled informally, with no message history, no threading, and no record-keeping in the CRM
- Language was a barrier: Dubai’s buyer pool is global; leads arrived in Arabic, Russian, Chinese, French, and English, and most brokerages had no way to reply professionally in anything other than English
- Agent workload was unbalanced: lead distribution depended on who was online or who a manager happened to message, with no fair rotation system
- Follow-up fell through the cracks: with no automated reminders, leads that needed a call in three days were simply forgotten
The goal was to build a turnkey SaaS platform that any Dubai brokerage could subscribe to, embed a lead capture form on their website, and immediately have every inbound enquiry processed, classified, replied to, and assigned, automatically, in any language, within seconds.
What this system does
LeadFlow AI is a multi-tenant SaaS platform that gives Dubai real estate brokerages two things: an automated lead pipeline and a dashboard for managing it. Every brokerage gets its own isolated data environment, its own agent team, and its own Stripe subscription.
Automated lead pipeline
- Accepts inbound leads via a REST API endpoint that can be embedded into any website contact form, landing page, or chatbot
- Validates and checks the brokerage subscription before processing: if the Basic plan’s 100-lead monthly limit is reached, the endpoint returns a clear upgrade prompt
- Sends the lead through GPT-4o for classification: language detection, intent classification (buy / rent / invest / general inquiry), temperature scoring (hot/warm / cold), and AI-generated reply
- Assigns the lead to an agent via atomic round-robin rotation, a Firestore transaction ensures fair distribution even under concurrent submissions
- Sends the AI-generated reply directly to the lead’s WhatsApp number via the Meta Cloud API, in the same language the lead wrote in
- Stores the complete lead record, AI analysis, and message thread in Firestore for full history
AI engine (GPT-4o)
- System prompt defines a “premium Dubai luxury real estate” tone, professional, no emojis, no slang, concise but warm
- Detects lead language automatically (ISO 639-1 codes: en, ar, ru, zh, fr, and others) and replies in that language
- Classifies intent as buy, rent, invest, or inquiry based on message content
- Tags lead temperature: hot (specific budget/area / timeline), warm (genuine interest, lacks specifics), or cold (general browsing)
- Runs at temperature 0.4 with JSON response format enforced, consistent, structured output on every call
- Falls back to a safe default reply if the AI call fails, ensuring no lead is ever left without an acknowledgement
WhatsApp integration (Meta Cloud API)
- Outbound: sends AI replies and agent messages to leads via the WhatsApp Cloud API (Meta Graph API v21.0)
- Inbound: receives replies from leads via a verified Meta webhook, matches the sender’s phone number to the correct lead record, and stores the message in the thread
- Webhook verification uses Meta’s challenge-response flow with a custom verify token
- All incoming payloads are validated via HMAC-SHA256 signature using the Meta App Secret before processing
- Phone numbers are cleaned (stripped of spaces, dashes, and plus signs) before every API call
Owner dashboard
- Displays total leads, hot leads count, follow-ups due today, and new leads this week
- Shows agent performance table: total leads assigned vs. leads closed per agent
- Lists the 10 most recent leads with full status, temperature, and intent data
- The owner can reassign leads between agents directly from the dashboard
- Visible only to users with the owner role: enforced server-side via requireRole middleware
Agent dashboard
- Shows assigned leads, active (non-closed) leads, and follow-ups currently due
- Lists the agent’s 10 most recent assigned leads
- Agents can only view and interact with leads assigned to them, enforced in every route handler
- Agents can send messages to leads, update lead status, and set follow-up dates
Subscription billing (Stripe)
- Two plans available: Basic (AED 149/month, 100 leads) and Pro (AED 399/month, unlimited leads + analytics)
- Both plans include a 7-day free trial before billing begins
- Stripe Checkout handles the payment flow; Stripe Billing Portal handles plan changes and cancellations
- Stripe webhooks update Firestore user records in real time on subscription activation, update, and cancellation
- Agents automatically inherit their owner’s subscription status: if the owner’s plan lapses, agents lose access too
Follow-up automation
- Agents set a follow-up date on any lead directly from the lead detail page
- A scheduled cron job runs every day at 8:00 AM UAE time (UTC+4) via node-cron
- Any lead with a follow-up date on or before today and a non-closed status is flagged followUpDue: true in Firestore
- The agent dashboard surfaces these flags as a follow-up due count, creating a daily task list without manual tracking
Benefits
- Instant first response at any hour: every lead gets a professional WhatsApp reply within seconds, regardless of time zone or day of the week
- Replies in the lead’s language: Arabic, Russian, Chinese, French, and English buyers all receive a response in their own language without any manual effort
- Fair, automatic agent assignment: round-robin distribution via Firestore transactions means no agent is overlooked and no manager needs to manually route leads
- Hot leads surface immediately: buyers who mention a specific budget, area, or timeline are flagged HOT so agents know exactly who to call first
- Complete conversation history: every WhatsApp message in and out is stored against the lead record, giving agents full context before they pick up the phone
- No missed follow-ups: the daily cron job ensures agents see overdue leads on their dashboard each morning without any manual diary management
- Subscription-gated access: brokerages pay per month; if a plan lapses, access is revoked automatically via Stripe webhook sync
- Multi-tenant by design: each brokerage’s data is isolated by ownerUid; no lead or agent data is ever visible across brokerage accounts
How it works (simplified)
- A lead submits a form on the brokerage’s website, the form POSTs to the public /api/leads endpoint with name, email, phone, message, and ownerUid
- Subscription and plan limits are checked: the server verifies the brokerage has an active or trialing Stripe subscription, and that the Basic plan lead cap has not been reached
- GPT-4o processes the lead: language is detected, intent classified, temperature scored, and a professional reply generated in the lead’s own language
- The lead is assigned to an agent: a Firestore transaction reads the round-robin counter, selects the next agent in the rotation, and atomically increments the counter
- The lead record is written to Firestore, all fields stored: name, contact details, message, AI analysis (language, intent, temperature), assigned agent, status, and timestamps
- The AI reply is sent via WhatsApp: if a phone number was provided, the generated reply is dispatched through the Meta Cloud API, and the WhatsApp message ID is stored
- The API returns a success response: the caller receives the lead ID, AI reply text, intent, temperature, and assigned agent ID
- The agent is notified via their dashboard: the new lead appears in their assigned leads list with full AI analysis visible
- If the lead replies on WhatsApp: the message arrives at the Meta webhook, is validated, matched to the lead by phone number, and stored in the messages collection
- Follow-up dates are tracked automatically: the daily cron job at 8 AM UAE time flags any lead whose follow-up date has arrived, surfacing it on the agent’s dashboard
System architecture (simplified)
Image: Architecture: inbound leads reach a public API and pass a five-step pipeline (plan limits, GPT-4o classification and reply, round-robin agent assignment, Firestore write, WhatsApp reply), with a Stripe billing layer and a daily follow-up job.
Subscription plans
LeadFlow AI is sold as a monthly SaaS subscription in UAE Dirhams. Both plans include a 7-day free trial before the first charge. Stripe Checkout handles the payment flow; Stripe Billing Portal handles self-service plan changes and cancellations.
| Plan | Price | Lead Limit | Included Features |
|---|
| Basic | AED 149 / month | 100 leads / month | AI classification, WhatsApp auto-reply, agent assignment, follow-up tracking |
| Pro | AED 399 / month | Unlimited leads | Everything in Basic + analytics dashboard, agent performance tracking, priority support |
The Basic plan lead limit is enforced server-side on every lead submission. When the 100-lead monthly cap is reached, the API returns an HTTP 429 response with a clear upgrade message. Pro plan subscribers have no cap.
Dashboard overview
Owner dashboard
The owner's view is the brokerage’s operational command centre. It shows four headline KPIs (total leads, hot leads, follow-ups today, new leads this week), an agent performance breakdown, and a live feed of the most recent leads. Owners can reassign any lead to a different agent directly from this view.
Role-restricted: visible only to owner accounts
Agent dashboard
The agent's view is designed for daily use. It shows the agent’s assigned lead count, active (non-closed) lead count, and follow-ups currently due, the three numbers that define their working day. Below the KPIs is a list of their most recently assigned leads.
Agents only see leads assigned to them: enforced at the API level
Lead detail page
Each lead has a dedicated detail page showing all contact information, AI classification results (language, intent, temperature), the full WhatsApp message thread, and status controls. Agents can update the lead’s status, set or clear a follow-up date, and send new messages directly from this page.
Message thread includes AI replies, agent messages, and incoming WhatsApp replies in chronological order
Pricing page
Public-facing pricing page displays both plans with feature lists and a “Start Free Trial” call-to-action. Clicking a plan triggers Stripe Checkout in AED, with the 7-day trial clearly communicated before any payment details are entered.
Currency displayed in AED; trial communicated pre-checkout
Lead classification & AI scoring
Every inbound lead is processed by GPT-4o using a structured system prompt that enforces a consistent professional tone and returns strict JSON. The model evaluates four dimensions simultaneously and returns them in a single API call.
| Temperature | Score | Routing Action | Classification Criteria |
|---|
| HOT | High | Immediate agent assignment + WhatsApp reply | Mentions specific budget, area, or timeline. Clearly ready to act. |
| WARM | Medium | Assigned + follow-up date set | Shows genuine interest but lacks specifics about budget, area, or timing. |
| COLD | Low | Stored, lower priority nurture | General inquiry, browsing behaviour, or unclear intent. |
The AI prompt explicitly defines the classification rules for each temperature band, prohibits emojis and slang, and instructs the model to respond in the detected language of the lead’s message. The system prompt is fixed (not user-editable) to ensure brand consistency across all brokerages.
Project evolution & key milestones
The platform scope expanded significantly from an initial lead capture proof-of-concept. Key additions and refinements delivered across the build:
AI & classification
- GPT-4 integration upgraded to GPT-4o for higher accuracy and lower latency
- response_format: { type: 'json_object' } enforced to eliminate markdown-wrapped JSON parsing failures
- Temperature set to 0.4 after testing: lower values produced overly rigid replies; higher values produced inconsistent classification
- Fallback reply added, so no lead is ever unanswered if the OpenAI API is unavailable
- Intent classification expanded from buy/rent to include invest and inquiry as distinct categories
WhatsApp integration
- Initial outbound-only integration expanded to support two-way messaging via the Meta webhook
- Incoming message matching logic added: sender phone matched to the most recent lead record in Firestore
- HMAC-SHA256 signature validation added using the Meta App Secret for all incoming webhook payloads
- Phone number cleaning function added to handle +, spaces, and dashes from various form inputs
Agent assignment
- Initial random assignment replaced with deterministic round-robin using a Firestore atomic counter transaction
- Counter document stored per brokerage (assignment_{ownerUid}) to ensure correct rotation per team
- Graceful fallback: if no agents exist, the lead is stored unassigned rather than failing
Subscription billing
- Stripe integration added with two recurring AED products (Basic and Pro)
- 7-day free trial added to subscription_data at checkout session creation
- Stripe webhook handler added for checkout.session.completed, customer.subscription.updated, and customer.subscription.deleted events
- Subscription status synced to Firestore user record in real time on every Stripe event
- Basic plan 100-lead monthly cap enforced server-side via Firestore count() query
- Stripe Billing Portal endpoint added for self-service plan management
Security & access control
- Firebase Auth middleware added to all protected routes; public lead capture endpoint intentionally left unprotected
- requireSubscription middleware added to check subscription status for both owners and agents (agents inherit from the owner)
- requireRole middleware added for owner-only routes (owner dashboard, agent management)
- Raw body preservation implemented for Stripe and WhatsApp webhook routes (bypassing Express JSON parsing)
Follow-up system
- followUpDate field added to lead schema; agents can set it from the lead detail page
- Daily cron job added via node-cron: runs at 8:00 AM UAE time (UTC+4 = 04:00 UTC)
- Batch Firestore update sets followUpDue: true on all leads whose date has arrived, for efficient dashboard display
Data flow architecture
A reference overview of how data moves through each stage of the system and at what cadence:
| Stage | Source | Destination | Trigger / Cadence |
|---|
| Lead intake | Website form / API call | Express backend | On submission (real-time) |
| AI processing | OpenAI GPT-4o | Firestore lead record | Per lead, synchronous |
| Agent assignment | Firestore counters (round-robin) | Lead record (assignedAgentId) | Per lead, synchronous |
| WhatsApp reply | AI-generated reply text | Lead's phone (Meta API) | Per lead, async |
| Follow-up check | Firestore leads collection | followUpDue flag on each lead | Daily at 8:00 AM UAE time |
| Dashboard refresh | Firestore (Realtime queries) | Owner / Agent dashboard | On page load / request |
| Subscription sync | Stripe webhook events | Firestore user record | On Stripe event (real-time) |
Note
Firestore is the single source of truth for all lead records, message threads, user profiles, subscription status, and round-robin counters. The Firebase Admin SDK (backend) and Firebase Web SDK (frontend) both read from the same Firestore project, with security rules and server-side middleware providing access control.
API reference
All endpoints are prefixed with /api/. Protected routes require a Firebase ID token in the Authorization: Bearer <token> header. The lead capture endpoint (/api/leads POST) is intentionally public to allow embedding on any brokerage website without authentication.
| Method | Endpoint | Description |
|---|
| POST | /api/leads | Public lead capture: no auth required. Runs AI, assigns agent, sends WhatsApp. |
| GET | /api/leads | List leads (owners see all, agents see assigned only). Filterable by status, temperature, intent. |
| GET | /api/leads/:id | Single lead detail with access control (role-based). |
| PATCH | /api/leads/:id | Update lead status, follow-up date, or agent assignment. |
| GET | /api/leads/:id/messages | Retrieve full message thread for a lead. |
| POST | /api/leads/:id/messages | Agent sends a message, delivered via WhatsApp if phone is available. |
| GET | /api/dashboard/owner | Owner dashboard: total leads, hot leads, follow-ups today, agent performance. |
| GET | /api/dashboard/agent | Agent dashboard: assigned leads, active leads, follow-ups due. |
| POST | /api/stripe/create-checkout | Create Stripe Checkout session (Basic or Pro plan, AED, 7-day trial). |
| POST | /api/stripe/portal | Create Stripe Billing Portal session for plan management. |
| POST | /api/stripe/webhook | Receive Stripe events: checkout.session.completed, subscription.updated/deleted. |
| GET | /api/whatsapp/webhook | Meta webhook verification challenge (subscribe mode). |
| POST | /api/whatsapp/webhook | Receive incoming WhatsApp messages, match to lead by phone, store in messages collection. |
| GET | /api/health | Health check: returns server status and timestamp. |
Security
LeadFlow AI is built with multi-tenant security as a first-class requirement. Each security layer is implemented independently so that no single point of failure exposes brokerage data.
| Security Layer | Implementation |
|---|
| Firebase ID Token Auth | All protected routes verify Bearer token via Firebase Admin SDK. Invalid or expired tokens return 401. |
| Subscription Gate | Middleware checks owner subscription status (active / trialing) before allowing access. Agents inherit their owner’s subscription. |
| Role-Based Access Control | requireRole(‘owner’ | ‘agent’) middleware enforces role boundaries. Agents can only access their assigned leads. |
| WhatsApp Signature Validation | Incoming Meta webhooks validated via HMAC-SHA256 using App Secret. Invalid signatures return 401. |
| Stripe Webhook Verification | Stripe events verified using stripe.webhooks.constructEvent with the webhook signing secret. |
| Helmet HTTP Headers | Sets security headers (XSS, clickjacking, content-type sniffing) via the Helmet middleware. |
| CORS Restriction | Requests accepted only from the configured FRONTEND_URL. Credentials are required for authenticated calls. |
| Plan Limit Enforcement | Basic plan lead cap (100/month) enforced server-side via Firestore count query on every lead submission. |
Image: The pricing page: a Basic plan at AED 149 per month and a Pro plan at AED 399 per month, both with a 7-day free trial.
Image: The live demo: a sample enquiry is classified by GPT-4o as a HOT lead, with intent, urgency, language and an AI-generated WhatsApp reply.
Page · Plain text
Estato (Real Estate Management Dashboard)
A full-stack MERN real estate operations platform with CRUD, Google Auth, live charts, and Cloudinary image management, built on the Refine framework with Material UI.
Section: Custom SaaS Platforms
Image: The Estato dashboard: revenue, property, agent and customer tiles, a monthly revenue chart and a property referrals breakdown.
- Type: Full-Stack MERN Application Case Study
- Product: Estato, Real Estate Management Dashboard
- Frontend: React 18 + TypeScript · Refine Framework · Material UI · ApexCharts
- Backend: Node.js + Express · ESM modules · port 8080
- Database: MongoDB Atlas via Mongoose ODM
- Auth: Google OAuth (Google Identity Services) · JWT · localStorage
- Media Storage: Cloudinary (property photo upload and delivery)
- Frontend Host: Vercel (demo: mern-dashboard-app.vercel.app)
- License: MIT
Why we built this
Real estate teams at any scale face the same structural problem: their properties, agents, and performance data live in disconnected places. Listing portals hold property records. Spreadsheets track agent performance. Photos are stored in email threads or local drives. There is no single screen that shows the full picture.
The core problems Estato was designed to solve:
- No centralised property database: listings were scattered across portals and spreadsheets with no consistent schema for type, location, price, or description
- Agent visibility was absent: managers had no way to see, at a glance, which agents held the most listings or how the portfolio was distributed across the team
- Creating and editing listings was slow: no standard form, no structured property types, and no direct photo upload meant listings were inconsistent and time-consuming to publish
- No dashboard-level KPIs: total properties for sale, total for rent, total customers, and total agents were metrics that required manual counting across multiple systems
- No referral source insight: the team could not measure which channels (social media, marketplaces, websites, digital ads) were driving enquiries
- Authentication was unmanaged: agents accessed tools via shared passwords with no individual accountability and no way to tie listings back to a specific user
The goal was a unified MERN-stack dashboard where every agent logs in with Google, can create and manage their own listings with photo upload, and managers can see live KPIs, revenue trends, and the full agent roster in one place.
What this system does
Estato is a full-stack MERN application that gives real estate teams a complete operations interface: live analytics, a full property CRUD system, an agent directory, individual profiles, and Google-authenticated access. The Refine framework handles data fetching, routing, and auth integration; Material UI provides the component layer; ApexCharts and custom donut charts deliver the analytics.
Operations dashboard
- Four donut-style KPI tiles at the top of the home page give instant visibility: Properties for Sale (live count from the API), Properties for Rent, Total Customers, and Total Agents, each with a percentage ring showing the proportion against a target
- Total Revenue area chart (ApexCharts) renders monthly revenue data across the full year with a smooth curve and gradient fill
- Property Referrals horizontal bar chart shows the contribution of each lead source: Social Media (64%), Digital Ads (80%), Websites (50%), Marketplace (40%), and Others (15%).
- Latest Properties section beneath the charts shows the 4 most recently added properties as live cards pulled from the API in real time
Property management (full CRUD)
- All Properties page renders the full listing catalogue as a responsive card grid, with live search by title, filter by 8 property types (Apartment, Villa, Farmhouse, Condos, Townhouse, Chalet, Studio, Duplex), price sort toggle, and server-side pagination with a configurable page-size selector (5, 10, 20, 30, 40, 50, All)
- Property Detail page shows the full-size property photo, property type with a 5-star rating, title, location, price, and description on the left. The right sidebar shows the creator agent’s avatar, name, property count, and location. Context-aware action buttons adapt based on the logged-in user: Message and Call for other agents’ listings; Edit and Delete for the current user’s own listings
- Create Property form collects title, property type, description, location, price, and photo. The photo is read into base64 via the FileReader API and submitted to the backend, which uploads it to Cloudinary and stores the CDN URL
- Edit Property form pre-populates all existing field values and allows any field to be changed, including the photo. If the photo is unchanged, the existing Cloudinary URL is preserved
- Delete uses a Mongoose session transaction to remove the property document and simultaneously pull it from the creator’s allProperties array, keeping both collections in sync atomically
Agent management
- Agents List page displays every registered user as an agent card with their Google avatar, name, email address, and total property count
- Agent Profile page shows the full agent detail view via the shared Profile component, including the agent’s complete property portfolio
- My Profile page uses the same shared Profile component for the currently authenticated user, with data fetched by their own MongoDB user ID
Authentication
- The login page renders a Google Sign-In button using the Google Identity Services API. The credential response is a signed JWT
- parseJwt utility decodes the Google JWT on the client to extract name, email, avatar (picture), and sub (user ID) from the payload
- On every login, the user profile is POST-ed to /api/v1/users, which is idempotent: it returns the existing MongoDB document if the email already exists, or creates a new user record if not
- The raw Google credential token is stored in localStorage as token. Axios’s request interceptor attaches it as Authorization: Bearer <token> to every subsequent API call
- On logout, token and user are removed from localStorage and google.accounts.id.revoke() is called to invalidate the Google session
Light and dark mode
- ColorModeContextProvider wraps the entire application and exposes a setColorMode function through React context
- The current mode is stored in localStorage to persist the user’s preference across sessions. On first load, the system’s prefers-color-scheme media query is checked to set the default
- Two custom Material UI themes are defined: overridedLightTheme and overridedDarkTheme, each extending Refine’s built-in theme objects with custom palette overrides. The active theme is passed to ThemeProvider at runtime
Benefits
- Single source of truth for the portfolio: every property, agent, and relationship lives in one MongoDB database with a consistent schema, accessible from any device
- Live KPIs on every login: four dashboard tiles give the team instant situational awareness without opening a single spreadsheet
- Consistent listing creation: the structured form with defined property types ensures every listing is created to the same standard, with a real photo uploaded directly to Cloudinary
- Role-aware property actions: agents can only edit or delete their own listings; other agents’ listings show contact options instead
- Atomic data integrity: create and delete operations use Mongoose session transactions to keep the Property collection and the User.allProperties array perfectly synchronised
- Zero password management: Google OAuth means no passwords to store, reset, or manage; every agent has their own authenticated identity
- Scalable Refine architecture: adding a new resource (reviews, messages, enquiries) requires registering it in App.tsx; the data provider, routing, and UI scaffolding are automatically available
- Dark/light mode out of the box: system preference is respected on first load; the preference is then stored in localStorage and toggleable at any time
How it works (simplified)
- Agent visits the login page and clicks ‘Sign in with Google’. Google Identity Services returns a signed JWT credential
- parseJwt decodes the JWT client-side to extract the user’s name, email, and avatar. The profile is POST-ed to /api/v1/users, which creates or finds the user in MongoDB
- Token stored in localStorage; Axios interceptor attaches it as a Bearer token to every subsequent request. Refine’s checkAuth hook reads localStorage on each route change to decide whether to redirect to login
- Dashboard loads and fires a useList call to /api/v1/properties (pageSize: 4) for the Latest Properties grid. The 4 KPI donut tiles and both ApexCharts components render with static or aggregate data
- All Properties page uses Refine’s useTable hook, which manages current page, page size, sorter, and filters as state and translates them into query params on the API call (_start, _end, _sort, _order, title_like, propertyType)
- Creating a property: the agent fills the form. On photo selection, FileReader reads the file as a base64 data URL. On submit, onFinish POSTs the full payload (including the base64 photo and the agent’s email) to /api/v1/properties
- Server receives the POST, finds the user by email, uploads the base64 photo to Cloudinary, receives back a CDN URL, then opens a Mongoose session and atomically creates the Property document and pushes its _id to user.allProperties
- Property Detail page calls useShow to fetch /api/v1/properties/:id with the creator user document populated. Action buttons (Edit/Delete vs Message/Call) are conditionally rendered by comparing user.email with propertyDetails.creator.email
- Deleting a property opens a browser confirmation dialog. On confirm, useDelete fires a DELETE request. The server opens a Mongoose session, removes the Property document, and pulls its _id from creator.allProperties, then commits the transaction
- Agent and profile pages call useList / useShow against /api/v1/users, which returns user documents with all properties populated. The shared Profile component renders the same layout for both Agent Profile and My Profile
System architecture
Image: Architecture: a React 18 and TypeScript browser app built on Refine with Material UI talks over HTTPS to an Express server that uses MongoDB Atlas and Cloudinary.
Page inventory
Estato consists of 9 pages, each a React TypeScript component managed by Refine’s routing and data layer.
| Page | File | Key Content |
|---|
| Dashboard (Home) | home.tsx | 4 PieChart KPI tiles, TotalRevenue ApexChart, PropertyReferrals bar chart, Latest Properties card grid (4 items live from API). |
| All Properties | all-properties.tsx | Full property grid with search by title, filter by 8 property types, sort by price (asc/desc), page size selector, Previous/Next pagination. |
| Property Details | property-details.tsx | Full-width photo, property type + 5-star rating, title, location, price, description. Agent sidebar: avatar, name, property count, location, Google Map, Book Now, Message/Edit + Call/Delete buttons. |
| Create Property | create-property.tsx | Delegates to shared Form component. Handles image file selection (FileReader to base64), attaches logged-in user email before submitting to API. |
| Edit Property | edit-property.tsx | Same shared Form component as Create. Pre-populates existing property data. Allows photo re-upload; photo URL preserved if unchanged. |
| Agents List | agent.tsx | Grid of all users (agents) from /api/v1/users. Each AgentCard shows avatar, name, email, and property count. Clicking navigates to agent profile. |
| Agent Profile | agent-profile.tsx | Full agent detail using shared Profile component. Shows avatar, name, email, properties count and full property list. |
| My Profile | my-profile.tsx | Logged-in user’s own profile using the same shared Profile component. Data fetched from /api/v1/users/:userId. |
| Login | login.tsx | Google Sign-In button using Google Identity Services. On success, JWT decoded, user POSTed to /api/v1/users, token stored in localStorage. |
Data models
Two Mongoose schemas define the complete data layer. The bidirectional relationship between Property and User is maintained by the server using session transactions on create and delete operations.
| Collection | Field | Type | Notes |
|---|
| Property | title | String (required) | Listing headline |
| Property | description | String (required) | Full description text |
| Property | propertyType | String (required) | Apartment, Villa, Farmhouse, Condos, Townhouse, Chalet, Studio, or Duplex |
| Property | location | String (required) | Free-text location / address |
| Property | price | Number (required) | Numeric value (rendered as $price per day in the UI) |
| Property | photo | String (required) | Cloudinary CDN URL after base64 upload |
| Property | creator | ObjectId → User | Populated on read to include full user document |
| User | name | String (required) | Full name from Google profile |
| User | email | String (required) | Google account email; used as unique identifier |
| User | avatar | String (required) | Google profile picture URL |
| User | allProperties | [ObjectId] → Property | Bidirectional reference array; maintained via Mongoose transactions |
Note
The User schema does not require a password field because authentication is handled entirely by Google OAuth. The email address serves as the unique identifier and is used both for login matching and for associating new properties with the correct creator.
API reference
All endpoints are prefixed with /api/v1/ and served by the Express server on port 8080. No authentication middleware is currently applied server-side; auth is enforced client-side by Refine’s checkAuth hook and the Axios Bearer token interceptor.
| Method | Endpoint | Description |
|---|
| GET | /api/v1/properties | List all properties. Supports _start, _end, _sort, _order, title_like, propertyType query params. Returns x-total-count header for Refine pagination. |
| GET | /api/v1/properties/:id | Fetch a single property with creator user populated (name, email, avatar, allProperties). |
| POST | /api/v1/properties | Create a new property. Uploads photo to Cloudinary via base64. Uses Mongoose session transaction to atomically create property and push to user.allProperties. |
| PATCH | /api/v1/properties/:id | Update an existing property. Re-uploads photo to Cloudinary. Falls back to original photo URL if upload fails. |
| DELETE | /api/v1/properties/:id | Delete a property. Uses Mongoose session transaction to remove from DB and pull from creator’s allProperties array atomically. |
| GET | /api/v1/users | List all users (agents). Supports _end for limit. Returns full user documents including allProperties references. |
| POST | /api/v1/users | Create or find a user by email (idempotent). Called on every Google OAuth login to ensure the user record exists in MongoDB. |
| GET | /api/v1/users/:id | Fetch a single user by MongoDB _id with allProperties populated. Used by agent profile and my profile pages. |
Dashboard overview
Home: KPI dashboard
The home page opens with four donut-style KPI tiles: Properties for Sale (live count from the API), Properties for Rent, Total Customers (5,684), and Total Agents (684). Each tile uses a two-segment ApexCharts donut series to visualise the proportion as a ring. Below the tiles, a TotalRevenue area chart spans the full width alongside a PropertyReferrals horizontal bar chart.
Route: / (DashboardPage)
All Properties
The properties page renders the full listing catalogue as a card grid. The toolbar above the grid provides a Sort price button (toggles asc/desc), a title search text field, and a property type dropdown filter. Pagination controls at the bottom include Previous/Next buttons, a current page indicator, and a page-size selector. All filter, sort, and page state is managed by Refine’s useTable hook and translated into API query parameters automatically.
Route: /properties
Property Details
The detail page is a two-column layout. The left column shows the full-width property photo (546px height), property type, a 5-star rating row, title, location pin with text, price in blue, and a description block. The right sidebar contains the agent card (Google avatar, name, agent role, location, property count) followed by action buttons that are context-aware: Message + Call for listings the current user does not own; Edit + Delete for their own listings. A Google Maps screenshot and a Book Now button fill the remaining sidebar space.
Route: /properties/show/:id
Create / Edit Property
The shared Form component renders the same layout for both Create and Edit. Fields: Title, Property Type (dropdown: Apartment, Villa, Farmhouse, Condos, Townhouse, Chalet, Studio, Duplex), Description, Location, Price, and Photo (file input). On Edit, all fields are pre-populated from the existing record. The photo preview renders below the file input once a file is selected. Form submission is handled by Refine’s useForm hook via @pankod/refine-react-hook-form.
Route: /properties/create | /properties/edit/:id
Agents List
The agents page uses useList against /api/v1/users to fetch all registered users. Each AgentCard displays the agent’s Google avatar, full name, email address, and total number of properties they have listed. Clicking a card navigates to that agent’s full profile.
Route: /agents
Agent Profile / My Profile
Both the agent profile and my profile use the same shared Profile component. It fetches the user by ID from /api/v1/users/:id with allProperties populated, and renders the agent’s full detail view, including their complete property portfolio. My Profile loads the currently authenticated user’s own data using their userId from localStorage.
Route: /agents/show/:id | /my-profile
Technology stack
The complete dependency surface for both client and server packages:
| Layer | Package / Version | Role |
|---|
| UI Framework | @pankod/refine-core ^3.103 | Data fetching (useList, useShow, useDelete, useGetIdentity), auth provider, routing |
| Component Library | @pankod/refine-mui ^3.63 | Material UI integration for Refine: layout, notifications, theme provider |
| Forms | @pankod/refine-react-hook-form | react-hook-form integration for create/edit property forms with Refine’s onFinish lifecycle |
| Routing | @pankod/refine-react-router-v6 | React Router v6 adapter; all navigation uses useNavigate, useParams |
| Data Provider | @pankod/refine-simple-rest | Maps Refine resource operations to REST calls against the Express API |
| Charts | apexcharts + react-apexcharts ^1.4 | TotalRevenue (area chart) and PropertyReferrals (horizontal bar chart) |
| Charts (KPI) | Custom PieChart component | 4 donut-style KPI tiles on the dashboard (Properties for Sale/Rent, Customers, Agents) |
| HTTP | axios ^0.28 | All API calls; Axios interceptor attaches Bearer JWT token to every outbound request |
| Loading State | react-loader-spinner ^5.4 | Consistent loading indicator shown across all async pages via the shared Loader component |
| Icons | @mui/icons-material ^5.15 | Star, Place, Phone, ChatBubble, Edit, Delete, Add, VillaOutlined, PeopleAltOutlined |
| Server | express ^4.18 | REST API, CORS, JSON body parsing (50 MB limit for base64 photo uploads) |
| ODM | mongoose ^6.9 | Schemas, validation, populate, and session-based transactions |
| Media | cloudinary ^1.33 | Receives base64-encoded image from client, stores and returns CDN URL |
| Dev Server | nodemon ^2.0 | Auto-restarts Express server on file changes during development |
Project evolution & key engineering decisions
Refine the data and the auth layer
- Refine’s authProvider interface cleanly separates login, logout, checkAuth, checkError, getPermissions, and getUserIdentity into distinct lifecycle methods. This meant the Google OAuth integration did not need custom redirect handling or session management, Refine’s router integration handles all protected route redirects
- The dataProvider from @pankod/refine-simple-rest maps Refine’s resource operations (list, show, create, edit, delete) directly to REST endpoints. Adding a new resource requires only one entry in the resources array in App.tsx, no custom data fetching code needed
- useTable was chosen over a manual useState + useEffect approach because it automatically manages pagination state (current page, page size), sorter, and filters as a unified state object, and syncs them to API query params on every change
Mongoose session transactions for data integrity
- Create property uses startSession + startTransaction to ensure the Property document and the User.allProperties push happens atomically. If either operation fails, the transaction is rolled back, and neither change persists
- Delete property follows the same pattern: the property is removed, and the creator’s allProperties array is updated in the same transaction, preventing orphaned references in either direction
Cloudinary for photo management
- The client reads the selected file using the FileReader API into a base64 data URL and includes it directly in the POST/PATCH request body. This avoids multipart form handling on the server
- The Express controller calls cloudinary.uploader.upload(photo) with the base64 string. Cloudinary returns a response object; the photo URL is extracted as photoUrl.url and stored in the Property document
- On edit, if the user does not change the photo, the existing URL from the database is kept as-is. The upload only runs if a new base64 string is submitted
Idempotent user creation
- The POST /api/v1/users endpoint first checks whether a user with the submitted email already exists. If found, it returns the existing document; if not, it creates a new one. This allows the same login flow to be used on both first login and repeat visits without duplicating user records
- The userId returned from this endpoint is stored in localStorage alongside the Google profile and is used throughout the app to fetch the agent’s own data
Shared Form and Profile components
- The Form component is shared between CreateProperty and EditProperty. A type prop (‘Create’ | ‘Edit’) controls the form title and submit button label. This eliminates duplication while keeping both pages independently routable
- The Profile component is shared between AgentProfile and MyProfile. It receives a userId and fetches the user’s data internally. This reduces the surface area for bugs and ensures both profile views render identically
Property type standardisation
- Eight property types are hardcoded in the all-properties filter dropdown: Apartment, Villa, Farmhouse, Condos, Townhouse, Chalet, Studio, and Duplex. The same values appear in the create/edit form. This ensures that filter queries always match a valid propertyType in the database, avoiding empty results from typos or free-text input
Project structure
Image: The project folder structure of the client and the server.
Setup & environment
Required environment variables to run Estato locally:
| Variable | Location | Description |
|---|
| REACT_APP_GOOGLE_CLIENT_ID | client/.env | Google Cloud Console OAuth 2.0 Client ID |
| REACT_APP_GOOGLE_CLIENT_SECRET | client/.env | Google Cloud Console OAuth 2.0 Client Secret |
| REACT_APP_SERVER_URL | client/.env | Backend base URL e.g. http://localhost:8080/api/v1 |
| MONGODB_URL | server/.env | MongoDB Atlas connection string |
| CLOUDINARY_CLOUD_NAME | server/.env | Cloudinary account cloud name |
| CLOUDINARY_API_KEY | server/.env | Cloudinary API key |
| CLOUDINARY_API_SECRET | server/.env | Cloudinary API secret |
Image: The Agents page, with agent cards showing listings, revenue and experience.
Page · Plain text