Engineering notes · React · Express · PostgreSQL

How I structured event-driven automation in a real estate CRM

An implementation-focused walkthrough of a property CRM where lead updates, scheduled viewings and overdue tasks can trigger configurable workflows.

This article was prepared with AI writing assistance, reviewed against the linked project source, and distinguishes implemented behavior from recommended improvements. It does not report production benchmarks.

1. Architecture: from a CRM action to a workflow

Property operations combine records that are related but not identical: a lead can become a client, a client can schedule a viewing, and an appointment can lead to a follow-up task. Putting every side effect inside a route handler would make those operations difficult to expand and test. The project therefore separates business endpoints from an automation engine and from background checks.

In this codebase, the interface is built with React and TypeScript, while an Express API works with PostgreSQL/Supabase through Drizzle ORM. Zod schemas validate API inputs; shared workspace packages describe API and database contracts. The README also documents modules for properties, appointments, notifications, reporting and workflows.

  1. Domain eventLead or appointment data changes in an API route.
  2. Workflow selectionThe engine finds active rules matching the event type.
  3. Condition evaluationConditions are checked against the current event payload.
  4. Action dispatchEligible workflows perform configured actions.
  5. Execution historySuccess and failure records are written for inspection.

This division matters for maintainability: routes focus on the domain request, workflows decide what extra action is appropriate, and a scheduler checks time-sensitive records independently.

2. A new lead emits a typed domain event

The POST /leads endpoint first parses the request with a Zod schema. After inserting the lead and recording an activity entry, it invokes the automation engine with a LEAD_CREATED event. The event includes a lead identifier and useful attributes such as status, score, source and assigned agent.

const parsed = CreateLeadBody.safeParse(req.body);
if (!parsed.success) {
  res.status(400).json({ error: parsed.error.message });
  return;
}

// After the record and activity log are written:
await automationEngine.triggerEvent("LEAD_CREATED", {
  entityType: "lead",
  entityId: lead.id,
  data: { status: lead.status, score: lead.score /* ... */ }
});

Code illustration: the compact extract above omits unrelated fields. Check the full lead route ↗ for the complete source.

The important point is that LEAD_CREATED is a domain event, not a button-specific callback. Other parts of the application can react to the same meaningful transition without duplicating logic inside each screen. The repository also emits events for lead updates and client creation.

3. Workflow rules combine event matches and conditions

The engine loads active workflows and selects rules whose trigger matches the event name, or the catch-all ANY trigger. Conditions can be represented as JSON rules or a simple textual expression. Examples in this domain might compare a lead's numeric score or budget; the actual configured rules determine what happens.

const matchingWorkflows = activeWorkflows.filter(
  (wf) => wf.triggerEvent === triggerEvent || wf.triggerEvent === "ANY"
);

for (const wf of matchingWorkflows) {
  let shouldExecute = evaluateConditions(wf.conditions, payload.data);
  // When previousData is available, check the earlier condition state.
  if (shouldExecute) await executeWorkflow(wf, payload);
}

Simplified pseudocode representing the selection loop in automationEngine.ts ↗. The real implementation also checks earlier values supplied in previousData and records logging and error states.

Why previous state matters

Suppose a rule should run when a lead becomes qualified. On an update, the engine can compare the new payload with the previous values. If the condition was already true before the update, the current implementation can skip the repeated action. That prevents routine edits from re-triggering the same conditional transition unnecessarily.

Respecting manual decisions

In the agent-assignment action, the engine queries the current lead. When an agent is already assigned, it records a skipped automation action instead of replacing the manually assigned agent. This is a useful human-in-the-loop rule: automation should not silently override a staff member's explicit work.

Successful and failed workflow attempts are written to an execution history table, with action outcomes recorded separately. The source also updates an execution counter and last-run timestamp. See the execution-history implementation ↗.

4. Scheduled checks handle reminders and overdue tasks

Not every CRM event originates from a request. An appointment may simply move within the next 24 hours, or a task may pass its due date without a user opening the application. The scheduler handles these time-driven conditions.

For appointment reminders, the code finds scheduled appointments starting between now and 24 hours ahead. It selects active APPOINTMENT_REMINDER workflows, loads records previously completed successfully for each workflow, and skips those appointment IDs before evaluating a reminder rule.

const alreadyProcessed = await db
  .select({ id: workflowExecutionsTable.triggerEntityId })
  .from(workflowExecutionsTable)
  .where(/* workflow ID + entity type + successful status */);

const processedIds = new Set(alreadyProcessed.map((r) => r.id));
for (const appointment of upcoming) {
  if (processedIds.has(appointment.id)) continue;
  // Evaluate reminder conditions and execute eligible workflow.
}

Illustrative reduction of the scheduler's persisted-history check. The full scheduler.ts source ↗ includes the complete database filters, payload construction and workflow execution.

The overdue-task check follows a related path: it excludes completed tasks, calculates which due dates have passed, and checks historical successful executions before dispatching a TASK_OVERDUE workflow.

In the repository, startScheduler schedules a first check shortly after startup and then runs both checks at a configurable interval (60 seconds by default). This is an implementation detail, not proof that the background process is continuously running in every deployment environment.

5. Reliability: what works, and what still needs hardening

The implementation already has several useful safeguards: event-specific workflow selection, previous-value condition checks, manual-assignment preservation, success/failure history and scheduler-level processed-ID sets. Those reduce common accidental duplicates. They are not the same as a globally guaranteed exactly-once execution model.

What the source implements

  • Active workflow filtering and rule evaluation
  • Persisted execution records and statuses
  • Successful-history checks in scheduled jobs
  • Per-process flags to prevent overlapping scheduler checks
  • Skipping automation that would replace manual assignment

Improvements I would prioritize

  • Database-enforced idempotency keys for each workflow/entity/event
  • Transactional writes or an outbox for reliable event delivery
  • Multi-worker coordination instead of only in-memory flags
  • Explicit retry policy and alerting for failed actions
  • Consistent timezone handling and tests around due-date boundaries

There is also an important rules-engine tradeoff: the current condition parser logs a parsing failure and defaults to permitting the workflow. A stricter, fail-closed approach—returning false for invalid conditions—would be safer when automatic actions modify CRM state. This is an engineering recommendation, not a claim that such a change has already been made.

For a larger deployment, I would pair the database constraints with concurrency tests for overlapping updates, duplicate scheduler ticks and partial action failure. Performance and throughput should then be measured under realistic load before making any scaling claims.

6. Lessons for a reusable automation architecture

The most useful abstraction in this project is not a particular dashboard component. It is the event-to-rule-to-action boundary: an API operation publishes meaningful domain context, while a separate engine chooses the response. That makes future workflow types easier to add without filling every endpoint with business-specific branches.

Equally important is being specific about reliability. Avoiding duplicate work in a single scheduler tick is valuable, but production-grade idempotency needs persisted, concurrency-safe guarantees. Keeping those distinctions explicit makes architecture discussions more useful to engineering teams and clients.

For the complete implementation, see the Real Estate Automation Agent repository ↗. For a business-oriented overview of the product, see the Real Estate Operations case study. If you're building a similar CRM, dashboard or SaaS application, review my full-stack development services.

Building a CRM, SaaS dashboard or automation workflow?

I work with React, Express, Supabase and PostgreSQL for remote development projects.