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.
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.
0 2 * * ? *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:
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.
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.
unresolved means feedback was filed but not yet
classified by staff. It is treated with the same urgency as confirmed negative sentiment.
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.
venue.averageSpend must be set
in Venue Settings. If null or 0, this trigger never fires for any customer at that venue.
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.
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.
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.
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 |
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]
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.
ready opp whose type is NOT in the current triggers[] array
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.
Trigger fires. detOppId() produces the key. DynamoDB PutCommand writes the record.
Manager opens the opportunity in the Angular app. Clicks "Draft" to trigger AI generation.
status: draftedManager reviews the AI draft, edits if needed, and approves for sending.
status: approvedOutreach delivered to the customer. Engine will not touch this record again.
status: sent · TERMINALCustomer visits again. returnedAt, revenueAmount, and attributionType are written to the record. Engine preserves this data permanently.
needs_care. The engine continues to refresh its priority nightly.
Once the team resolves it, the status transitions to sent or skipped.
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
failed increments but the run continues
and still reports totals for the venues that succeeded.
Edges and limits
Known constraints
| Constraint | Detail |
|---|---|
high_value_going_quietrequires 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 andhigh_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. |