Platform · Core intelligence

Opportunity Engine

Every night at 02:00 UTC, a Lambda function evaluates every customer across every active venue and produces a prioritised queue of Opportunity records — actionable signals that tell the venue team exactly who to reach out to and why.

EventBridge — daily at 02:00 UTC Lambda · 900 s · 512 MB 5 trigger types Fully idempotent

What it does

The engine in one paragraph

The engine loads all Square POS data for each active venue — visit history, spend, feedback — and tests each customer against five behavioural trigger conditions. When a trigger fires, it creates or refreshes an Opportunity record in DynamoDB with a computed priority score and estimated revenue value. When a trigger no longer fires, any open ready opportunity is automatically closed. The resulting work queue is read by the Angular app and surfaced to venue managers for action.

The engine writes directly to DynamoDB. The Angular frontend reads via AppSync — no additional sync step is needed.

Infrastructure

Lambda configuration

Defined in amplify/functions/opportunity-engine/resource.ts.

Trigger
EventBridge rule
Schedule
0 2 * * ? *
Timeout
900 seconds
Memory
512 MB

To invoke manually (find the deployed function name first):

# Find the deployed name aws lambda list-functions \ --query "Functions[?contains(FunctionName,'opportunity')].FunctionName" \ --output text # Invoke it aws lambda invoke \ --function-name <function-name> \ --payload '{}' /tmp/result.json && cat /tmp/result.json

Inputs

What data the engine uses

Currently the engine draws exclusively from Square POS data, synced into DynamoDB by the Square connector. Four fields on the Customer record drive all trigger conditions:

visitCount
Integer — total completed visits
lastVisitDate
ISO date string — most recent visit
totalSpend
Float — lifetime spend in venue currency
averageSpend
Float — per-visit average (fallback only)
FeedbackRecord is also loaded per customer. Sentiment values (negative or unresolved) and the needs_care status flag drive the grievance trigger — independent of visit data.

How it runs

Execution architecture

The engine uses two levels of parallelism to complete within the Lambda timeout even as venue and customer counts grow.

EventBridge (02:00 UTC) │ └─ opportunity-engine Lambda │ ├─ 1. Scan Venue table │ FilterExpression: status = 'active' (paginated) │ └─ 2. Process all active venues IN PARALLEL (Promise.allSettled) │ └─ For each venue: │ ├─ a. Load data — 3 DynamoDB queries IN PARALLEL (Promise.all) │ ├─ Customer PK venueId = :vid │ ├─ Opportunity PK venueId = :vid │ └─ FeedbackRecord PK venueId = :vid │ ├─ b. Build in-memory Maps │ ├─ oppsByCustomer Map<customerId, Opportunity[]> │ └─ feedbackByCustomer Map<customerId, FeedbackRecord[]> │ └─ c. Evaluate each customer (sequential loop) ├─ Skip if mergedIntoId is set ├─ Skip if no visit data ├─ Evaluate 5 trigger conditions → triggers[] ├─ Upsert triggered opps IN PARALLEL (Promise.allSettled) └─ Close stale opps IN PARALLEL (Promise.allSettled)

Venues are independent — they share no DynamoDB records — so parallel venue processing carries no risk of write conflicts. One failing venue does not abort the whole run; Promise.allSettled collects all outcomes and the final log line reports failed separately.


Core logic

Five trigger conditions

Each customer is tested against all five conditions independently. Zero or more can fire simultaneously — for example, a customer can be both lapsed_regular and have a grievance. The one exception is that high_value_going_quiet and quiet_period are mutually exclusive; when the higher-value condition fires, the lower-value one is skipped.

Grievance Priority 1.00 base

A FeedbackRecord exists for this customer where sentiment === 'negative' or sentiment === 'unresolved', or where status === 'needs_care' — AND no existing grievance Opportunity is already in a terminal state (sent, skipped, or returned).

Example: A guest leaves a 1-star note after a poor experience with service. Their FeedbackRecord is filed as negative. Next morning the engine creates a grievance opportunity — highest priority in the queue.

Note: unresolved means feedback was filed but not yet classified by staff. It is treated with the same urgency as confirmed negative sentiment.
High-value going quiet Priority 0.85 base

The customer's totalSpend is at least 3× the venue's configured averageSpend, AND their last visit was more than 30 days ago. Evaluated before quiet_period — if this fires, quiet_period is skipped.

Example: A regular who has spent $1,400 at a venue where the average is $120 per visit hasn't been in for 35 days. High-value signal at base priority 0.85 before recency decay is added.

Silence window: >30 days
0d30d60d90d+
Requires venue config. venue.averageSpend must be set in Venue Settings. If null or 0, this trigger never fires for any customer at that venue.
Lapsed regular Priority 0.70 base

Customer has visited at least 3 times AND their last visit was more than 60 days ago. Signals a formerly loyal customer who has gone silent.

Example: A guest who visited 7 times over the past year but hasn't been in for 11 weeks. They know the venue — they just need a reason to return.

Silence window: >60 days, visitCount ≥ 3
0d30d60d90d+
Quiet period Priority 0.50 base

Customer has visited at least 2 times, last visit was more than 45 days ago, and high_value_going_quiet did not fire. Also explicitly excluded if the customer already qualifies as a lapsed_regular (visitCount ≥ 3 and >60 days).

Example: A guest who visited twice in March and hasn't been back since — 50 days ago. Not yet a lapsed regular, but showing a pattern worth a gentle nudge.

Silence window: >45 days, visitCount ≥ 2
0d30d45d60d+
First visit — no return Priority 0.40 base

Customer has visited exactly 1 time AND that visit was between 7 and 60 days ago. The window starts at 7 days (too soon to contact) and ends at 60 (after which there's little point in a first-impression follow-up).

Example: A first-timer who came in 12 days ago and hasn't booked again. A warm personal note referencing their visit converts first-timers at high rates.

Action window: 7–60 days after first visit
0d7d30d60d90d+

Ranking

Priority scoring

Every opportunity gets a priority float from 0.0 to 1.0. Higher values surface first in the work queue. Computed by calcPriority() and refreshed on every nightly run for all active opportunities.

priority = BASE[type] + (floor(daysSinceLastVisit / 30) × 0.05) ← +0.05 per 30-day block + (totalSpend > venue.averageSpend × 5 ? 0.10 : 0) ← top-spender uplift priority = min(1.0, priority) ← capped at 1.0 then rounded to 2 decimal places
Type Base Visual
grievance 1.00
high_value_going_quiet 0.85
lapsed_regular 0.70
quiet_period 0.50
first_visit_no_return 0.40
Example: A lapsed_regular customer who has been away for 95 days and is a top spender:
0.70 + floor(95/30) × 0.05 + 0.10 = 0.70 + 0.15 + 0.10 = 0.95

Revenue signal

Estimated value

The estimatedValue field is an estimate of the revenue recoverable if the customer is re-engaged. Computed by calcEstimatedValue().

avgSpendPerVisit = customer.totalSpend / customer.visitCount (falls back to customer.averageSpend if totalSpend/visitCount missing) estimatedValue = avgSpendPerVisit × multiplier[type]
first_visit_no_return
×2
Lifetime value of a retained regular
lapsed_regular
×3
Multiple return visits expected
high_value_going_quiet
×4
High-value recovery premium
quiet_period
×1
Single visit recovery
grievance
×1
Relationship value, not revenue
Unknown avg spend
0
Falls back to 0 if no spend data

Write logic

Opportunity upsert decision tree

Each (customer, trigger type) pair goes through upsertOpportunity(). The outcome depends on whether an opportunity already exists and what its current status is.

For each (customer, type) trigger: │ ├─ Compute opportunityId = detOppId(venueId, customerId, type) │ ├─ Find existing opp in customerOpps where id matches │ ├─ Existing opp found? │ │ │ ├─ status in TERMINAL {sent, skipped, returned} │ │ → return 'terminal' — no write, do nothing │ │ │ └─ status in ACTIVE {ready, drafted, approved, needs_care} │ → UpdateCommand: refresh priority, estimatedValue, │ reason, suggestedAction, updatedAt │ → return 'updated' │ └─ No existing opp → PutCommand: create new Opportunity with status 'ready' → return 'created'
returned is terminal. A returned status means the customer came back — recovery was successful. The engine does not touch it. Overwriting returnedAt or revenueAmount with refreshed priority data would destroy attribution records.

Queue hygiene

Stale opportunity auto-close

After processing a customer's active triggers, the engine runs closeStaleOpportunities(). Any existing ready opportunity whose trigger is no longer true is set to skipped automatically.

Without this, a first_visit_no_return created on Tuesday stays in the work queue forever even if the customer walks in on Wednesday (making visitCount become 2). Stale records pollute the queue and misrepresent workload.

What gets closed
Any ready opp whose type is NOT in the current triggers[] array
What is left alone
drafted, approved, needs_care — humans are working on these. Terminal statuses — already done.

Idempotency

Deterministic opportunity IDs

Every opportunityId is derived from a SHA-256 hash rather than a random UUID. EventBridge uses at-least-once delivery, meaning the Lambda can fire twice for the same scheduled event. With random IDs, a double-fire creates two identical records. With deterministic IDs, both executions derive the same key and the DynamoDB PutCommand becomes a safe overwrite.

// detOppId() in handler.ts function detOppId(venueId, customerId, type) { const hex = SHA256(`${venueId}|${customerId}|${type}`); return `${hex.slice(0,8)}-${hex.slice(8,12)}-` + `${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20,32)}`; } // Output: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx (UUID format)

The same venueId + customerId + type triple always produces the same ID. The engine is fully idempotent at the key level.

State machine

Opportunity status lifecycle

An opportunity moves through statuses as the venue team works it. Terminal statuses are never modified by the engine — they preserve attribution history.

1
Engine creates opportunity

Trigger fires. detOppId() produces the key. DynamoDB PutCommand writes the record.

status: ready
2
Manager reviews and drafts

Manager opens the opportunity in the Angular app. Clicks "Draft" to trigger AI generation.

status: drafted
3
Draft approved

Manager reviews the AI draft, edits if needed, and approves for sending.

status: approved
4
Message sent — terminal

Outreach delivered to the customer. Engine will not touch this record again.

status: sent · TERMINAL
5
Customer returns — terminal

Customer visits again. returnedAt, revenueAmount, and attributionType are written to the record. Engine preserves this data permanently.

status: returned · TERMINAL
needs_care path: If a customer responds negatively, the opportunity moves to needs_care. The engine continues to refresh its priority nightly. Once the team resolves it, the status transitions to sent or skipped.
Stale cleanup: A ready opportunity whose trigger no longer fires is auto-set to skipped by the engine on the next run.

Data access

DynamoDB access patterns

The engine uses PK-only queries for all batch loads, avoiding GSI names entirely. Amplify Gen 2 generates DynamoDB GSI names that differ from the schema queryField names, making PK-based queries the only unambiguous approach. All queries are paginated via LastEvaluatedKey.

Table Operation Key used Notes
Venue ScanCommand FilterExpression: status = 'active'
Customer QueryCommand PK venueId All customers for venue, paginated
Opportunity QueryCommand PK venueId All opps for venue, paginated
FeedbackRecord QueryCommand PK venueId All feedback for venue, paginated
Opportunity UpdateCommand PK venueId + opportunityId Refresh active opportunities
Opportunity UpdateCommand PK venueId + opportunityId Close stale opportunities
Opportunity PutCommand PK venueId + opportunityId Create new opportunities

Observability

CloudWatch log format

The engine emits structured log lines at key stages. All are JSON-parseable.

# Run start [engine] start — source=aws.events time=2025-07-08T02:00:00Z [engine] tables — VENUE=... CUSTOMER=... OPP=... FEEDBACK=... [engine] found 3 active venue(s) # Per-venue completion [engine] venue done: { "venueId": "abc123", "customers": 412, ← total Customer records loaded "evaluated": 398, ← customers with visit data "created": 4, ← net-new Opportunity records "updated": 11, ← active opps refreshed "terminal": 2, ← trigger fired but opp already terminal "closed": 1, ← stale ready opps auto-skipped "durationMs": 2341 } # Run complete [engine] complete — venues=3 failed=0 {...totals} totalMs=4812
If a venue fails entirely, failed increments but the run continues and still reports totals for the venues that succeeded.

Edges and limits

Known constraints

ConstraintDetail
high_value_going_quiet
requires venue config
venue.averageSpend must be set in Venue Settings. If null or 0, this trigger never fires for any customer at that venue.
No per-venue threshold config All venues use the same day thresholds (7 / 30 / 45 / 60 days, 3× spend). Per-venue configurable thresholds are a planned future feature.
No contact method required The engine creates opportunities regardless of whether the customer has an email or phone. Draft and send steps handle this downstream.
One active grievance per customer Multiple unresolved FeedbackRecords for the same customer produce one grievance Opportunity, not one per record.
quiet_period and
high_value_going_quiet are mutually exclusive
high_value_going_quiet takes priority when both conditions are met for the same customer on the same run.
Stale cleanup only touches ready Opportunities in drafted, approved, or needs_care are left for humans to resolve even if the underlying trigger condition is gone.