GoodBooks GB5 — EIP & DirectAction Developer Reference
System Overview
The GB5 backend provides two complementary integration platforms for external actors to interact with the ERP:
| Platform | Trigger | Channel | Use Case |
|---|---|---|---|
| EIP Conversation Engine | Inbound text message | WhatsApp, Slack, Teams, Telegram, SMS, Postman | Multi-turn guided workflows (leave requests, order queries, HR bots) |
| DirectAction | Click a link / button | Email link, WhatsApp interactive button | One-click approval / rejection / return on an existing entity (workflow task, PO, leave) |
Both platforms share the Action Pipeline (TEVENTACTIONRUN → TACTIONOUTBOX → Dapr pub/sub → EmailActionHandler) for outbound notification delivery.
Architecture Map
EIP Conversational Engine
Every inbound message goes through a 6-phase sequential pipeline. Each phase produces a result consumed by the next. If any phase fails, the request is terminated with an error response.
Normalization Phase 2
Routing Phase 3
Flow Engine Phase 4
Capability Engine Phase 5
Action Engine Phase 6
Response Engine
The orchestrator is EIPConversationEngine.ProcessIncomingMessageAsync(). All phases receive the same EIPExecutionContextDTO, which accumulates state across phases.
Phase 1 — Channel Normalization
File: FrameworkBLL/EIPConversation/EIPHandlers/EIPEngine/ChannelNormalizer/EIPChannelNormalizer.cs
Converts vendor-specific webhook payloads into the internal EIPExecutionContextDTO.
| Input field | Source DTO | Notes |
|---|---|---|
TenantId | EIPConversationDTO.TenantId | Required; string cast to int |
ChannelType | EIPConversationDTO.ChannelType | Enum — see below |
UserIdentifier | EIPConversationDTO.UserIdentifier | Phone number, email, or user ID |
NormalizedMessage | EIPConversationDTO.Message (trimmed/lower) | Used for routing keyword match |
FlowCode | EIPConversationDTO.FlowCode | Optional; bypasses routing if set |
CorrelationId | Generated Guid | Present in all log lines for tracing |
EIPConversationDTO.Location is present, Phase 1 also sets context.Variables["_Location.Lat"], "_Location.Lng", and "_Location.Address" automatically before the flow engine runs.
EIPChannelType enum
| Value | Name | Webhook Provider |
|---|---|---|
| 0 | PostMan | Direct API call (testing) |
| 1 | Meta / Celitix | |
| 2 | Teams | Microsoft Teams |
| 3 | Telegram | Telegram Bot API |
| 4 | Slack | Slack Webhook |
| 5 | SMS | SMS gateway |
| 6 | AppChat | AppChatChannelHandler — no-op delivery; messages delivered via EIPChatHub SignalR on /hubs/eip-chat; all rich fields in EIPChatMessageDTO |
| 7 | Voice | VoiceChannelHandler — returns AdditionalData { TtsText, SsmlText, IsCompleted }; no outbound HTTP call |
Phase 2 — Routing Engine
File: FrameworkBLL/EIPConversation/EIPHandlers/EIPEngine/RoutingEngine/EIPRoutingEngine.cs
Loads active rules from MEIPROUTINGRULE, ordered by PRIORITY ascending (lower number = higher priority). Rules are matched in priority order; first match wins.
| MATCHTYPE | Operator | Example MATCHVALUE | Matches |
|---|---|---|---|
| 1 | EQUALS | order status | "order status" exactly |
| 2 | STARTS_WITH | order | "order 1234", "order help" |
| 3 | CONTAINS | approve | "please approve", "need to approve this" |
| 4 | REGEX | ^PO-\d{4,}$ | "PO-1234", "PO-99999" |
EIPConversationDTO.FlowCode is set by the caller, Phase 2 is bypassed and the supplied FlowCode is used directly. This is the mechanism for WhatsApp flows that know their target conversation.
Phase 3 — Flow Engine
File: FrameworkBLL/EIPConversation/EIPHandlers/EIPEngine/FlowEngine/EIPFlowEngine.cs
Executes the flow definition loaded from MEIPFLOWDEFINITION.JSONDEFINITION. The engine loops through steps (max 50 iterations — prevents infinite loops) dispatching to registered step handlers.
Flow Definition JSON Structure
{
"FlowCode": "LEAVE_REQUEST",
"StartStepCode": "GREETING",
"Steps": {
"GREETING": {
"StepType": "MESSAGE",
"MessageTemplate": "Hello {CustomerName}! I can help with your leave request. Type 'start' to begin.",
"NextStepCode": "COLLECT_DATES"
},
"COLLECT_DATES": {
"StepType": "INPUT",
"MessageTemplate": "Please enter your leave start date (YYYY-MM-DD):",
"ContextKey": "LeaveStartDate",
"Validation": { "Required": true, "Pattern": "^\\d{4}-\\d{2}-\\d{2}$" },
"NextStepCode": "COLLECT_REASON"
},
"COLLECT_REASON": {
"StepType": "CHOICE",
"MessageTemplate": "What type of leave is this?",
"Choices": {
"1. Annual Leave": "CONFIRM_ANNUAL",
"2. Sick Leave": "CONFIRM_SICK",
"3. Unpaid Leave": "CONFIRM_UNPAID"
}
},
"CONFIRM_ANNUAL": {
"StepType": "CONDITION",
"ConditionExpression": "confirm",
"NextStepCode": "SUBMIT_LEAVE"
},
"SUBMIT_LEAVE": {
"StepType": "ACTION",
"ActionType": "CALL_API",
"ApiConfig": {
"Endpoint": "/Leave/SaveLeave",
"Method": "POST",
"Body": "{\"LeaveType\": 1, \"StartDate\": \"{LeaveStartDate}\", \"UserId\": \"{UserId}\"}"
},
"ContextKey": "SubmitResult",
"NextStepCode": "DONE"
},
"DONE": {
"StepType": "END",
"MessageTemplate": "Your leave has been submitted successfully! Reference: {SubmitResult}"
}
}
}
Execution Rules
- Pause steps: MESSAGE, PROMPT, INPUT, CHOICE always pause — the engine saves session and waits for the next message. DYNAMIC_CHOICE (first visit), LOCATION (awaiting location) also pause.
- Continue steps: CONDITION, CONDITIONAL, ACTION, CAPABILITY, EXTERNAL_ACTION, QUALIFY, ENRICH, LINK, PRESENT — run synchronously without waiting for user input.
- End: When
NextStepCodeis null/empty or step type is END, the flow completes and the session is expired. - Variable substitution: Any
{Key}inMessageTemplateor action Body is replaced fromcontext.Variables. Dot-notation supported:{VarKey.SubKey}— if VarKey holds a JSON object string, SubKey is extracted from the root.
All Step Types
| StepType | Pauses? | Key Fields | Purpose |
|---|---|---|---|
| MESSAGE / PROMPT | Yes | MessageTemplate, NextStepCode |
Display text to user. Waits for any reply to proceed. |
| INPUT | Yes | MessageTemplate, ContextKey, Validation (Required, Pattern) |
Prompt for free-form input. Validates with regex if Pattern set. Stores reply in context.Variables[ContextKey]. |
| CHOICE | Yes | MessageTemplate, Choices (dict: label → NextStepCode) |
Present numbered menu. User reply is matched against keys; matching key's NextStepCode is followed. |
| CONDITION | No | ConditionExpression (keyword), NextStepCode |
Legacy: checks if NormalizedMessage contains ConditionExpression. Moves to NextStepCode if true. |
| CONDITIONAL | No | ConditionKey, Conditions[] (operator/value/NextStepCode), DefaultNextStepCode |
Multi-branch: evaluates context variable against each condition operator in order. Falls through to DefaultNextStepCode. |
| ACTION | No | ActionType (CALL_API / TRIGGER_MESSAGEHUB / DB_UPDATE), ApiConfig, ContextKey |
Executes a system operation. Stores result in context.Variables[ContextKey]. |
| CAPABILITY | No | CapabilityCode, MessageTemplate |
Invokes EIPCapabilityEngine for domain-specific business logic with optional risk/OTP gating. |
| EXTERNAL_ACTION | No | DecisionMap (decision → NextStepCode) |
Legacy backward-compat: maps incoming button/decision value to next step code. |
| END | — | MessageTemplate |
Sends final message, marks flow complete, expires session. |
| QUALIFY | No | QualifyConfig (BoundToType, InputKeys[], Stage, OutputFactKey, MapperCode, OutputMapKey, OnValidationErrorStepCode) |
Runs IQualifierFacadeImpl.ExecuteStageAsync; facts stored as JSON in OutputFactKey. Error findings route to OnValidationErrorStepCode and set _QualifyError variable. |
| ENRICH | No | EnrichConfig (MapperCode, InputKey, OutputKey) |
Runs IMappingEngineBLL.TransformAsync directly; result stored in context.Variables[OutputKey]. |
| DYNAMIC_CHOICE | Yes (first visit) | DynamicChoiceConfig (SourceKey, DataPath, LabelField, ValueField, StoreSelectedKey, StoreItemKey, MaxChoices) |
Builds buttons from a JSON array in context.Variables[SourceKey]. First visit pauses and returns buttons. On selection, stores chosen value in StoreSelectedKey and full item JSON in StoreItemKey. |
| LOCATION | Yes (awaiting location) | ContextKey |
Pauses until _Location.Lat is set in Variables (by Phase 1 normalizer on the next inbound message). Stores {"Lat":…,"Lng":…,"Address":…} JSON in ContextKey; sets RequestLocation=true on the response. |
| LINK | No | LinkButtons[] (Title, Url), ButtonType (REPLY / URL / CALL / QR) |
Emits one or more link/action buttons to the user; routes immediately to NextStepCode without pausing. |
| PRESENT | No | ContextKey, ResponseFormat (Type, Fields[], MaxRows, TitleField) |
Parses JSON from context.Variables[ContextKey] and produces an EIPStructuredDataDTO. AppChat renders natively; WhatsApp/SMS receive plain-text via EIPTextFormatter. |
CONDITIONAL Step — Operators
| Operator | Meaning |
|---|---|
| EXISTS | context.Variables[ConditionKey] is not null/empty |
| NOT_EXISTS | context.Variables[ConditionKey] is null or empty |
| EQUALS | exact string match (case-insensitive) |
| NOT_EQUALS | string mismatch |
| IN | value is in comma-separated list |
| GT | numeric greater-than |
| LT | numeric less-than |
Example: CONDITIONAL Step
{
"StepType": "CONDITIONAL",
"ConditionKey": "RiskScore",
"Conditions": [
{ "Operator": "GT", "Value": "80", "NextStepCode": "HIGH_RISK_BLOCK" },
{ "Operator": "GT", "Value": "50", "NextStepCode": "MEDIUM_RISK_OTP" }
],
"DefaultNextStepCode": "LOW_RISK_PROCEED"
}
Phase 4 — Capability Engine
File: FrameworkBLL/EIPConversation/EIPHandlers/EIPEngine/CapabilityEngine/EIPCapabilityEngine.cs
Only invoked for CAPABILITY steps. Sequence:
- Risk Assessment —
EIPRiskAssessment.EvaluateRiskAsync(). Returns risk level +RequiresOtpflag. Currently: keyword "PAYMENT" → HIGH risk. - OTP Generation (if high risk) —
EIPOtpService.GenerateOtpAsync(). Currently logs a 6-digit code only; no DB persistence yet. - Action Context Build — wraps CapabilityCode + context variables into
EIPActionContextDTOwithIdempotencyKey = CorrelationId. - Action Executor — routes to
IEIPActionExecutor.ExecuteAsync().
Phase 5 — Action Engine
File: FrameworkBLL/EIPConversation/EIPHandlers/EIPEngine/ActionEngine/EIPActionEngine.cs
Handler factory: case-insensitive dictionary of IEIPActionHandler keyed by ActionCode.
| ActionCode | Handler | What it does |
|---|---|---|
CRUD_OPERATION |
CrudOperationHandler | HTTP POST/PUT/GET/DELETE to an internal GB5 endpoint. Payload contains Endpoint, Method, Body. Result stored in context.Variables[ContextKey]. |
CALL_EXTERNAL_API |
IntegrationActionHandler | HTTP call to a 3rd-party URL. URL, headers, method, body all configured in the step's ApiConfig. |
SEND_NOTIFICATION |
NotificationActionHandler | Inserts TEVENTACTIONRUN + TACTIONOUTBOX rows. Background dispatcher publishes to Dapr topic action-exec-p{tenantId%5}. EmailActionHandler picks up and sends email. |
VALIDATION_ACTION |
ValidationActionHandler | Stub — validates context data against configured rules. |
CRUD_OPERATION Payload Example
// Flow step ApiConfig:
{
"Endpoint": "/Leave/SaveLeave",
"Method": "POST",
"Body": "{\"LeaveType\": 1, \"StartDate\": \"{LeaveStartDate}\"}"
}
// Handler resolves:
// SysJobSettings:ServiceBaseUrl + Endpoint = full URL
// Variables already substituted before handler call
Phase 6 — Response Engine
File: FrameworkBLL/EIPConversation/EIPHandlers/EIPEngine/ResponseEngine/EIPResponseEngine.cs
Sequence:
- Rate limit check — in-memory
ConcurrentDictionarykeyed"CHANNEL:RECIPIENT". 2-second window per recipient. Drops message silently if too frequent (protects downstream providers). - Handler selection — looks up
IEIPChannelHandlerby channel name (case-insensitive). Throws if no handler registered. - Delivery — calls
handler.SendAsync(context, login, ct). - Metadata update — records last sent time, recipient, channel, response status on context for audit.
Response Fields
The following fields are present on EIPFlowExecutionResultDTO, EIPExecutionResultDTO, and (where applicable) EIPChatMessageDTO to support extended channel capabilities introduced by the PRESENT, LOCATION, LINK, and Voice step types.
| Field | Type | Description |
|---|---|---|
StructuredData |
EIPStructuredDataDTO? |
TABLE / CARD / LIST result produced by a PRESENT step. AppChat renders natively; WhatsApp and SMS receive a plain-text fallback via EIPTextFormatter. |
QrCodeData |
string? |
Raw QR content string. AppChat renders a native canvas QR code; WhatsApp sends it as an image message type; Telegram sends it via sendPhoto. |
InputHint |
string? |
Widget hint for the AppChat input control. Valid values: text, number, date, email, phone, qr_scan, file, rating. |
RatingMax |
int? |
Maximum star value for the rating widget (default 5). Only relevant when InputHint = "rating". |
RequestLocation |
bool |
Signals the channel to show a location-share UI. WhatsApp sends a prompt; Telegram sends a ReplyKeyboardMarkup; AppChat activates HTML5 Geolocation. |
VoiceHint |
string? |
SSML markup string used by the Voice channel handler as SsmlText in AdditionalData. Ignored by all other channel handlers. |
EIPChannelResponseDTO now carries a Dictionary<string, object>? AdditionalData property. The VoiceChannelHandler uses this to return { "TtsText": "...", "SsmlText": "...", "IsCompleted": true/false } without making any outbound HTTP delivery call.
Session State
Table: TEIUSERSESSION | DAL: EIPSessionDAL
Sessions enable multi-turn conversations. The engine restores session before Phase 2, upserts after Phase 3 if paused, and expires on flow completion.
| Column | Type | Purpose |
|---|---|---|
| SESSIONID | uniqueidentifier PK | Internal session handle |
| USERIDENTIFIER | nvarchar(255) | Phone / email / user ID — identifies who the session belongs to |
| TENANTID | int | Multi-tenant isolation |
| CHANNELTYPE | int | Enum value (0–7) — WhatsApp session ≠ Teams session for same user |
| FLOWCODE | nvarchar(100) | Which flow this session is in |
| CURRENTSTEPCODE | nvarchar(100) | Step to resume at on next message |
| CONTEXTJSON | nvarchar(max) | JSON dict of all accumulated Variables |
| STARTEDAT | datetime2 | When conversation started |
| LASTACTIVITYAT | datetime2 | Updated on every message — used for 30-min TTL check |
| STATUS | int | 1 = active, 0 = expired |
Session Lifecycle
Multi-Turn Conversation Example
This traces a 3-turn leave request conversation, showing exact DB state at each turn.
Turn 1 — User sends "start leave"
POST /EIPConversation/Receive
{ "message": "start leave", "tenantId": "42", "channelType": 1,
"userIdentifier": "+9715551234" }
Phase 2: MEIPROUTINGRULE matches STARTS_WITH "start" → FlowCode="LEAVE_REQUEST"
Phase 3: Starts at "GREETING" (MESSAGE step) → pauses
Session saved: CurrentStepCode="COLLECT_DATES", FlowCode="LEAVE_REQUEST",
ContextJson="{}"
Response: "Hello! I can help with your leave request..."
Turn 2 — User sends "2024-07-15"
POST /EIPConversation/Receive
{ "message": "2024-07-15", "tenantId": "42", "channelType": 1,
"userIdentifier": "+9715551234" }
Session restored: CurrentStepCode="COLLECT_DATES"
Phase 3: INPUT step validates "2024-07-15" against pattern "^\d{4}-\d{2}-\d{2}$" → valid
Stores Variables["LeaveStartDate"] = "2024-07-15"
Moves to COLLECT_REASON (CHOICE step) → pauses
Session saved: CurrentStepCode="COLLECT_REASON",
ContextJson="{\"LeaveStartDate\":\"2024-07-15\"}"
Response: "What type of leave? 1.Annual 2.Sick 3.Unpaid"
Turn 3 — User sends "1"
POST /EIPConversation/Receive
{ "message": "1", "tenantId": "42", "channelType": 1,
"userIdentifier": "+9715551234" }
Session restored: CurrentStepCode="COLLECT_REASON", Variables={"LeaveStartDate":"2024-07-15"}
Phase 3: CHOICE "1" matches "1. Annual Leave" → NextStepCode="CONFIRM_ANNUAL"
CONFIRM_ANNUAL (ACTION/CALL_API) → POST /Leave/SaveLeave (no pause)
Returns result → DONE (END step) → sends final message
Session expired: STATUS=0
Response: "Your leave has been submitted! Reference: LV-2024-001"
Channel Handlers
| Channel | Handler Class | Message Types Supported |
|---|---|---|
| POSTMAN | PostmanChannelHandler | Text (echo/logging only — testing) |
| WhatsAppChannelHandler | Text, Media (image/video/doc), Interactive buttons, Template messages. Dual provider: Celitix or Meta Graph API v18. QR image (image type via qrserver.com link), RequestLocation plain-text prompt, EIPTextFormatter plain-text fallback for StructuredData. | |
| TEAMS | TeamsChannelHandler | Adaptive cards, text |
| SLACK | SlackChannelHandler | Block kit messages, text |
| TELEGRAM | TelegramChannelHandler | Text, inline keyboards. QR photo via sendPhoto API, RequestLocation via ReplyKeyboardMarkup { request_location: true }. |
| SMS | SmsChannelHandler | Plain text |
| APPCHAT | AppChatChannelHandler | No-op HTTP delivery — message delivered via EIPChatHub SignalR on /hubs/eip-chat. All rich fields (StructuredData, QrCodeData, InputHint, RatingMax, RequestLocation) forwarded in EIPChatMessageDTO. |
| VOICE | VoiceChannelHandler | No HTTP delivery. Returns AdditionalData { TtsText, SsmlText, IsCompleted }; VoiceHint used as SsmlText. Caller reads result from EIPChannelResponseDTO.AdditionalData. |
WhatsApp — Retry Policy
Polly exponential backoff: 3 retries with delays of 2s, 4s, 8s. Dual provider selection via MessageHubSettings:Celitix:Enable and MessageHubSettings:WhatsApp:Enable in appsettings.
Qualifier & Mapper Integration
QUALIFY Step
The QUALIFY step calls IQualifierFacadeImpl.ExecuteStageAsync() to run a named qualifier stage against input data assembled from context variables.
Execution Context Built by the Step Handler
var ctx = new QualifierExecutionContext
{
ClientId = login.ClientId,
EntityId = qualifyConfig.EntityId, // optional numeric entity PK
EntityCode = qualifyConfig.BoundToType, // qualifier bound-to type code
Stage = qualifyConfig.Stage, // e.g., "VALIDATE_ADDRESS"
Scope = qualifyConfig.Scope, // optional scope tag
Document = JsonDocument.Parse(inputsJson), // JSON built from InputKeys[]
CorrelationId = context.CorrelationId
};
Result Handling
- If
QualifierResult.IsSuccess == false— the engine routes toOnValidationErrorStepCodeand setscontext.Variables["_QualifyError"]to the first Error finding's message. - If
IsSuccess == true—QualifierResult.Facts.ExportAll()is serialized as JSON and stored incontext.Variables[OutputFactKey]. - If
MapperCodeis set —IMappingEngineBLL.TransformAsync(MapperCode, factsJson, login, ct)is called and the result is stored incontext.Variables[OutputMapKey].
ENRICH Step
The ENRICH step calls IMappingEngineBLL.TransformAsync() directly, without a qualifier stage:
var transformed = await _mappingEngine.TransformAsync(
enrichConfig.MapperCode,
context.Variables[enrichConfig.InputKey],
login,
ct);
context.Variables[enrichConfig.OutputKey] = transformed;
Dependency Injection
IQualifierFacadeImpl → QualifierFacadeImpl and IMappingEngineBLL → MappingEngineBLL are auto-registered by assembly scan at FrameworkSL/Program.cs:544. No manual services.AddScoped() entries are required when adding new qualifier or mapper implementations.
Structured Data & PRESENT Step
EIPStructuredDataDTO
The PRESENT step parses JSON from a context variable and produces an EIPStructuredDataDTO, which is placed on the flow execution result and forwarded to the channel handler.
| Field | Type | Description |
|---|---|---|
Type | string | TABLE, CARD, LIST, or TEXT |
Fields | List<EIPFieldDefinitionDTO> | Column/field definitions — each has Key and Label |
Rows | List<Dictionary<string, string>> | Data rows — each row is a key→value dict matching the field keys |
TotalCount | int | Total records available (before MaxRows truncation) |
IsTruncated | bool | True when Rows.Count < TotalCount |
EIPResponseFormatDTO — Step Configuration
Defined inside the PRESENT step's flow JSON under ResponseFormat:
| Field | Type | Default | Description |
|---|---|---|---|
Type | string | — | TABLE / CARD / LIST / TEXT |
DataPath | string? | null (root) | Dot-path into the context variable's JSON to the array of rows (e.g., "Data.Items") |
Fields | EIPFieldDefinitionDTO[] | — | Which fields to extract and their display labels |
MaxRows | int | 20 | Maximum rows to include; sets IsTruncated if source has more |
TitleField | string? | null | For CARD type: which field to use as the card title / heading |
EIPTextFormatter — Plain-Text Fallback
File: FrameworkBLL/EIPConversation/EIPHandlers/EIPEngine/FlowEngine/EIPTextFormatter.cs
EIPTextFormatter.FormatAsText(EIPStructuredDataDTO data) converts structured data to a plain-text string for channels that cannot render native structured output (WhatsApp, SMS):
- TABLE / CARD: Each row is rendered as
*Label*: value\nblocks, with rows separated by---. - LIST: Each row is rendered as a numbered list:
1. value\n,2. value\n, etc. - If
IsTruncatedis true, appends(Showing N of M)to the end of the output.
WhatsApp and SMS channel handlers call EIPTextFormatter.FormatAsText() automatically when context.StructuredData != null. AppChat receives the full EIPStructuredDataDTO object for native rendering.
QR Code & Location
QR Code Generation
When a step sets QrCodeData on the execution result, each channel handler renders it differently:
- WhatsApp: Sends an
"image"message type (not"media") with the QR server URL inpayload["image"]["link"](e.g.,https://api.qrserver.com/v1/create-qr-code/?data=...). - Telegram: Calls the
sendPhotoBot API method with the QR server URL asphoto. - AppChat: Receives the raw
QrCodeDatastring and renders a native canvas QR code client-side. - SMS / Voice: QR code is omitted; a plain-text fallback message is sent instead.
QR Scan Input
For INPUT steps requiring a barcode or QR scan, set Validation.InputType = "qr_scan" in the step definition. This causes the engine to set InputHint = "qr_scan" on the response. The AppChat widget activates the device camera / barcode scanner; when the user scans, the decoded text is sent back as a regular message and processed by the INPUT step validation.
Inbound Location Data
When the channel delivers a location payload, the inbound EIPConversationDTO.Location field is populated as an EIPLocationDTO:
| Field | Type | Description |
|---|---|---|
Latitude | double | WGS-84 latitude |
Longitude | double | WGS-84 longitude |
Address | string? | Reverse-geocoded address (if provided by channel) |
Accuracy | double? | Accuracy radius in metres (if provided) |
Name | string? | Place name (Telegram sends this for venue locations) |
Phase 1 (Channel Normalizer) detects a non-null Location and automatically sets context.Variables["_Location.Lat"], "_Location.Lng", and "_Location.Address" before the flow engine runs.
RequestLocation Rendering per Channel
When the engine sets RequestLocation = true on the response (produced by a LOCATION step on first visit), each channel reacts:
- WhatsApp: Appends a plain-text prompt (e.g., "Please share your location using the attachment button") — WhatsApp Cloud API does not support a native location-request button.
- Telegram: Sends a
ReplyKeyboardMarkupwith a single button:{ text: "📍 Share Location", request_location: true }. Tapping it sends the user's location automatically. - AppChat: Receives
RequestLocation = trueinEIPChatMessageDTO; the frontend activates the HTML5 Geolocation API and sends the coordinates back as a location payload.
DirectAction System
DirectAction enables external actors (users in email or WhatsApp) to trigger backend API calls by clicking a link or button — without logging in. The system is stateless (state is in the signed token), one-time-use, and multi-tenant isolated.
Token Format & Security
Payload
payload = "{actionCode}:{contextId}:{tenantId}:{assigneeUserId}:{expiresAtTicks}:{databaseName}"
token = Base64Url(payload) + "." + Base64Url(HMAC-SHA256(payload, secret))
Example payload (plaintext before encoding):
WORKFLOW_APPROVE:5001:42:99:638501234567890123:goodbooks_prod_42
Example signed token:
V09SS0ZMT1dfQVBQUk9WRTo1MDAx....Ojk5OjYzODUwMTIzNA.xK7f2mNq8...
| Field | Example | Purpose |
|---|---|---|
| actionCode | WORKFLOW_APPROVE | Maps to MDIRECTACTIONDETAIL row for config lookup |
| contextId | 5001 | PK of the entity being actioned (e.g., WorkflowTaskId) |
| tenantId | 42 | ClientId for multi-tenant isolation |
| assigneeUserId | 99 | The specific user this token was issued for; becomes LoginDTO.UserId for the API call |
| expiresAtTicks | 638501234567890123 | DateTime.Ticks UTC — used for expiry check. Default: 48 hours from generation. |
| databaseName | goodbooks_prod_42 | Tenant DB name — drives connection routing without appsettings lookup |
Security Properties
- HMAC-SHA256 signature — forgery requires the secret key (in HashiCorp Vault in production).
- Fixed-time comparison —
CryptographicOperations.FixedTimeEqualsprevents timing attacks. - Expiry enforcement — token rejected if
DateTime.UtcNow > expiresAt. - One-time-use — SHA256(token) stored in TDIRECTACTIONTOKEN on first use; subsequent uses return "Already Actioned".
- Tenant isolation — LoginDTO reconstructed only from token fields; client cannot forge a different tenantId.
Validation Order (failfast)
- Token is not null/empty
- Token contains exactly one
.separator - Base64Url decode succeeds
- HMAC signature matches (fixed-time compare)
- Payload has ≥ 6 fields; fields 2–5 parse as int/long
DateTime.UtcNow <= expiresAt
NeedsInput=0 Flow (Direct Click)
Used for simple one-click actions: Approve, Return, Forward.
NeedsInput=1 Flow (With Remarks)
Used when the action requires user-provided text (e.g., rejection reason, approval comment).
Why not record the token on the initial GET?
If the token were recorded when the form is shown, any network error, browser back-navigation, or accidental page load would permanently consume the one-time-use slot and the user would see "Already Actioned" if they re-submit. By deferring recording to the POST, the form is safe to reload multiple times.
WhatsApp Button Path
WhatsApp interactive buttons carry a payload string (max 256 chars). DirectAction uses the BUTTONPAYLOADPREFIX pattern to encode the ContextId.
loginDTO.UserId is used as the assigneeUserId. One-time-use is not enforced for WhatsApp buttons (clicking multiple times will call the API multiple times; the API must be idempotent).
PayloadTemplate Substitution
The PAYLOADTEMPLATE column in MDIRECTACTIONDETAIL is a JSON string with two supported placeholders:
| Placeholder | Replaced with | Source |
|---|---|---|
{ContextId} | Numeric entity PK | From token or WhatsApp button payload |
{Remarks} | User-provided text (or empty string) | From NeedsInput=1 form POST; empty if NeedsInput=0 |
Examples
-- NeedsInput=0: Approve without comments
PAYLOADTEMPLATE = '{"Items":[{"TaskId":{ContextId},"Action":1}]}'
After substitution (ContextId=5001):
{"Items":[{"TaskId":5001,"Action":1}]}
-- NeedsInput=1: Reject with reason
PAYLOADTEMPLATE = '{"Items":[{"TaskId":{ContextId},"Action":2,"Remarks":"{Remarks}"}]}'
After substitution (ContextId=5001, Remarks="Budget not available"):
{"Items":[{"TaskId":5001,"Action":2,"Remarks":"Budget not available"}]}
Resend Flow
POST /Action/Resend
Body: { "EventTypeId": 42, "ContextId": 5001 }
→ Publishes new OutboxEvent to TOUTBOX
→ Action pipeline re-executes → new TEVENTACTIONRUN
→ EmailActionHandler generates FRESH tokens (48h from resend time)
→ New email sent with new buttons
Old tokens remain valid until their original 48-hour expiry. The one-time-use check in TDIRECTACTIONTOKEN prevents double-execution — whichever token is clicked first wins. Old tokens can be manually revoked by setting STATUS=2 in TDIRECTACTIONTOKEN.
EIP Database Tables
MEIPFLOWDEFINITION
CREATE TABLE MEIPFLOWDEFINITION (
FLOWDEFINITIONID int NOT NULL PRIMARY KEY,
TENANTID int NOT NULL,
FLOWCODE nvarchar(100) NOT NULL,
FLOWVERSION int,
FLOWSTATUS int, -- 1=active, 0=archived
JSONDEFINITION nvarchar(max), -- full flow JSON (StartStepCode + Steps dict)
VERSION int,
STATUS int, -- 1=active (soft delete)
CREATEDBYID int,
CREATEDON datetime2,
MODIFIEDBYID int,
MODIFIEDON datetime2,
CONSTRAINT UX_FLOWCODE UNIQUE (FLOWCODE, TENANTID, STATUS)
);
MEIPROUTINGRULE
CREATE TABLE MEIPROUTINGRULE (
ROUTINGRULEID int NOT NULL PRIMARY KEY,
TENANTID int NOT NULL,
FLOWDEFINITIONID int NOT NULL, -- FK → MEIPFLOWDEFINITION
ENDPOINTID int NOT NULL, -- FK → MEIPCHANNELENDPOINT
MATCHTYPE int, -- 1=EQUALS, 2=STARTS_WITH, 3=CONTAINS, 4=REGEX
MATCHVALUE nvarchar(500),
PRIORITY int, -- lower = higher priority
STATUS int, -- 1=active
VERSION int,
CREATEDBYID int,
CREATEDUTC datetime2,
MODIFIEDBYID int,
MODIFIEDUTC datetime2
);
TEIUSERSESSION
CREATE TABLE TEIUSERSESSION (
SESSIONID uniqueidentifier NOT NULL PRIMARY KEY,
USERIDENTIFIER nvarchar(255), -- phone/email/userId
TENANTID int,
CHANNELTYPE int, -- EIPChannelType enum value
FLOWCODE nvarchar(100),
CURRENTSTEPCODE nvarchar(100),
CONTEXTJSON nvarchar(max), -- JSON dict of Variables
STARTEDAT datetime2,
LASTACTIVITYAT datetime2, -- 30-min TTL check
STATUS int -- 1=active, 0=expired
);
CREATE INDEX IX_TEIUSERSESSION_LOOKUP
ON TEIUSERSESSION (USERIDENTIFIER, TENANTID, CHANNELTYPE) WHERE STATUS = 1;
DirectAction Database Tables
MDIRECTACTION (Header)
CREATE TABLE MDIRECTACTION (
DIRECTACTIONID int NOT NULL PRIMARY KEY,
DIRECTACTIONCODE nvarchar(20), -- "WFAPPROVAL", "PO_APPROVAL"
DIRECTACTIONNAME nvarchar(200), -- display name
CONTEXTIDFIELD nvarchar(200), -- field name in entity payload for the PK
ASSIGNEEUSERIDFIELD nvarchar(200), -- field name for assignee user ID
TENANTID int, -- -1 = global template
STATUS int,
VERSION int,
SORTORDER int,
SOURCETYPE int,
CREATEDBYID int,
CREATEDON datetime2,
MODIFIEDBYID int,
MODIFIEDON datetime2
);
MDIRECTACTIONDETAIL (Buttons)
CREATE TABLE MDIRECTACTIONDETAIL (
DIRECTACTIONDETAILID int NOT NULL PRIMARY KEY,
DIRECTACTIONID int NOT NULL, -- FK → MDIRECTACTION
SLNO smallint, -- display order
ACTIONCODE nvarchar(20), -- "WORKFLOW_APPROVE"
ACTIONLABEL nvarchar(200), -- "Approve" (shown in form title for NeedsInput=1)
EMAILPLACEHOLDER nvarchar(100), -- "APPROVE_URL" → ##APPROVE_URL## in email template
BUTTONPAYLOADPREFIX nvarchar(100), -- "WF_APPROVE" → WhatsApp button payload prefix
APIENDPOINT nvarchar(500), -- "/WorkFlow/WorkFlowActions"
HTTPMETHOD nvarchar(10), -- "POST", "PUT", "PATCH", "DELETE"
PAYLOADTEMPLATE nvarchar(max), -- JSON with {ContextId} and {Remarks}
NEEDSINPUT tinyint, -- 0=direct, 1=show remarks form
FLOWID int -- FK → MEIPFLOWDEFINITION (-1 if N/A)
);
TDIRECTACTIONTOKEN (One-time-use audit)
CREATE TABLE TDIRECTACTIONTOKEN (
TOKENHASH nvarchar(64) NOT NULL PRIMARY KEY, -- SHA256 hex of signed token
CONTEXTID int,
ACTIONCODE nvarchar(20),
ASSIGNEEUSERID int,
TENANTID int,
EXPIRESAT datetime, -- original token expiry (48h from generation)
USEDAT datetime, -- when clicked (NULL = never used / recorded on click)
STATUS tinyint, -- 0=unused, 1=used, 2=revoked
REMARKS nvarchar(2000),-- user-provided remarks (NeedsInput=1 only)
CREATEDON datetime
);
CREATE INDEX IX_TDIRECTACTIONTOKEN_HASH_TENANT
ON TDIRECTACTIONTOKEN (TOKENHASH, TENANTID);
Action Pipeline Tables
TEVENTACTIONRUN
-- Tracks execution lifecycle of each triggered action
ACTIONRUNID int PK -- app-generated sequence
ACTIONID int -- FK → MACTION
TENANTID int
RUNSTATUS int -- 0=Pending, 1=InProgress, 2=Completed, 3=Failed
PAYLOAD nvarchar(max)-- entity JSON at time of trigger
RESULT nvarchar(max)-- execution outcome
ERRORMESSAGE nvarchar(max)
ATTEMPTS int
CORRELATIONKEY nvarchar(255)-- for deduplication: ActionId + entity PK
TACTIONOUTBOX
-- Transactional outbox: reliable publish to Dapr/RabbitMQ
OUTBOXID int PK
TENANTID int
DESTINATIONTOPIC nvarchar(100) -- "action-exec-p0" through "action-exec-p4" (tenantId % 5)
PAYLOAD nvarchar(max) -- ActionEventDto JSON
SENDSTATUS int -- 0=Pending, 1=Sent, 2=Failed
ATTEMPTS int
CREATEDON datetime2
Edge Cases & Known Issues
| Scenario | Behaviour | Notes |
|---|---|---|
| Expired token click | TryValidate returns false → "Link Expired or Invalid" HTML page | Log contains: DirectActionToken: expired |
| Already-used token click (NeedsInput=0) | TDIRECTACTIONTOKEN.UsedAt != null → "Already Actioned" page with timestamp | |
| Already-used token — NeedsInput=1 form re-submitted | ActionSubmitEndpoint finds existing record → "Already Actioned" page | Safe: token recorded only once |
| Revoked token (STATUS=2) | Found record with no UsedAt → "This link has been revoked." | Manual revocation: UPDATE TDIRECTACTIONTOKEN SET STATUS=2 WHERE TOKENHASH=... |
| Two users click same pool-approval token simultaneously | First INSERT wins; second finds existing record → "Already Actioned" | Race condition window is very small (<1ms). A UNIQUE index on TOKENHASH further protects. |
| No routing rule matches incoming EIP message | Phase 2 throws → error response returned to caller | Add a catch-all CONTAINS rule with low priority |
| Flow exceeds 50 steps | EIPFlowEngine throws InvalidOperationException("Maximum step count exceeded") |
Indicates a loop in the flow definition — check NextStepCode chains |
| EIP session expires (30 min idle) | Next message starts a fresh session; routing resolves FlowCode again | Variables from old session are lost — the conversation restarts |
| WhatsApp button clicked multiple times | Each click calls ExecuteAsync — API is called multiple times | API must be idempotent. No one-time-use enforcement for button path. |
| NeedsInput=1 token expired before user submits form | ActionSubmitEndpoint: TryValidate fails → "Link Expired or Invalid" | User must request a resend from the system |
QC Test Scenarios
DirectAction — Email Path
| # | Scenario | Input | Expected |
|---|---|---|---|
| DA-01 | Happy path NeedsInput=0 | Valid token, NeedsInput=0 action | 200 HTML "Action Successful"; TDIRECTACTIONTOKEN row inserted with UsedAt=now; internal API called once |
| DA-02 | Happy path NeedsInput=1 | Valid token, NeedsInput=1 action | GET → remarks form HTML; no TDIRECTACTIONTOKEN row yet; POST /Action/Submit → success HTML; row inserted with Remarks; API called once with Remarks in payload |
| DA-03 | Double click NeedsInput=0 | Same valid token clicked twice | First click: success. Second click: "Already Actioned" page with timestamp |
| DA-04 | Double submit NeedsInput=1 | Same form submitted twice | First submit: success. Second submit: "Already Actioned" page |
| DA-05 | Expired token | Token with past expiresAt | "Link Expired or Invalid" HTML page |
| DA-06 | Tampered token | Modify one character in payload section | "Link Expired or Invalid" (signature mismatch) |
| DA-07 | Tampered contextId | Decode payload, change contextId=9999, re-encode without re-signing | "Link Expired or Invalid" (signature mismatch) |
| DA-08 | Missing token param | GET /Action/Execute (no ?token=) | "Invalid Link" HTML page |
| DA-09 | Revoked token | Set TDIRECTACTIONTOKEN.STATUS=2, click link | "This link has been revoked." page |
| DA-10 | API returns 400 business error | Valid token, but target API returns Status=400 | Error HTML page with GeneralErrors message from API response |
| DA-11 | TDIRECTACTIONTOKEN.EXPIRESAT | Click valid 48h token immediately | TDIRECTACTIONTOKEN.EXPIRESAT = generation_time + 48h (NOT click time) |
| DA-12 | Resend | POST /Action/Resend with EventTypeId + ContextId | New email sent with fresh tokens; old tokens still work until their original expiry |
DirectAction — WhatsApp Button Path
| # | Scenario | Input | Expected |
|---|---|---|---|
| WA-01 | Happy path button press | Payload = "WF_APPROVE_5001", matching MDIRECTACTIONDETAIL.BUTTONPAYLOADPREFIX | ExecuteAsync called; API called; logged as success |
| WA-02 | Unknown button prefix | Payload = "UNKNOWN_PREFIX_99" | Warning logged: "did not match any DirectAction prefix"; no API call |
| WA-03 | Invalid ContextId in payload | Payload = "WF_APPROVE_abc" | Warning logged: "Could not parse ContextId"; no API call |
| WA-04 | Double press | Same button pressed twice | API called twice (no one-time-use). API must be idempotent. |
EIP Conversation Engine
| # | Scenario | Input | Expected |
|---|---|---|---|
| EIP-01 | New conversation | Message matching a routing rule | Session created; flow starts at StartStepCode; response sent via channel |
| EIP-02 | Resume existing session | Second message from same user/channel within 30 min | Session restored; flow resumes at CurrentStepCode |
| EIP-03 | INPUT validation fail | User sends "abc" to a step with pattern ^\d{4}-\d{2}-\d{2}$ | Validation error message returned; step repeats |
| EIP-04 | CHOICE invalid selection | User sends "4" when only 3 choices exist | Step repeats with "Invalid choice" message |
| EIP-05 | Session expiry | Second message after 31+ min idle | New session; flow starts from beginning (old variables lost) |
| EIP-06 | Flow completes (END step) | Conversation reaches END step | Final message sent; session STATUS=0 |
| EIP-07 | No matching routing rule | Message with no matching MEIPROUTINGRULE | Error response; error logged |
| EIP-08 | Max steps exceeded | Flow definition has circular NextStepCode | InvalidOperationException after 50 steps; error response |
| EIP-09 | Rate limit | >1 response to same user within 2 seconds | Second message dropped silently by ResponseEngine rate limiter |
| EIP-10 | ACTION step API fail | CALL_API endpoint returns 5xx | ActionEngine returns IsSuccess=false; flow error branch (or end if no error handling configured) |
| EIP-11 | QUALIFY happy path | Step type=QUALIFY; required input keys present in context; qualifier stage succeeds | QualifierFacadeImpl.ExecuteStageAsync called; IsSuccess=true; Facts exported as JSON stored in OutputFactKey; flow advances to NextStepCode |
| EIP-12 | QUALIFY error finding | QUALIFY step; qualifier returns Error finding | Flow routes to OnValidationErrorStepCode; context.Variables["_QualifyError"] set to error message from finding |
| EIP-13 | DYNAMIC_CHOICE first visit | SourceKey holds JSON array in context; first time step is reached | Buttons built from array using LabelField/ValueField; response returned with button list; step remains paused awaiting selection |
| EIP-14 | DYNAMIC_CHOICE selection | User sends a label or value matching one of the dynamic buttons | StoreSelectedKey populated with selected value; StoreItemKey populated with full item JSON; flow advances to NextStepCode |
| EIP-15 | LOCATION step resolution | Inbound payload contains Location.Latitude; LOCATION step is current | Phase 1 sets _Location.Lat, _Location.Lng, _Location.Address in Variables; LOCATION step resolves immediately and advances without pausing again |
| EIP-16 | QrCodeData delivery | MESSAGE step with QrCodeData set; sent to AppChat, WhatsApp, and Telegram channels | AppChat receives QrCodeData string; WhatsApp handler sends image message type with qrserver.com link; Telegram handler calls sendPhoto API |
| EIP-17 | INPUT InputType=date validation | User sends "abc" to an INPUT step with Validation.InputType="date" | Validation error re-prompt sent; step stays paused. User then sends valid date string → stored in ContextKey, flow advances |
| EIP-18 | INPUT InputType=rating validation | User sends "6" when RatingMax=5; then "3" | "6" → error re-prompt (exceeds RatingMax). "3" → stored in ContextKey, flow advances |
| EIP-19 | Voice channel end-to-end | POST /EIPConversation/Receive with ChannelType=7 (Voice) | Response Status="VOICE_READY"; AdditionalData contains TtsText (plain text) and SsmlText (SSML markup from VoiceHint); no HTTP delivery call made |
Key File Map
EIP Conversation Engine
| Component | Path (relative to GB5Framework/) |
|---|---|
| Main orchestrator | FrameworkBLL/EIPConversation/EIPHandlers/EIPConversationEngine/EIPConversationEngine.cs |
| BLL wrapper | FrameworkBLL/EIPConversation/EIPHandlers/EIPConversationBLL/EIPConversationBLL.cs |
| Phase 1 — Normalizer | FrameworkBLL/EIPConversation/EIPHandlers/EIPEngine/ChannelNormalizer/EIPChannelNormalizer.cs |
| Phase 2 — Routing | FrameworkBLL/EIPConversation/EIPHandlers/EIPEngine/RoutingEngine/EIPRoutingEngine.cs |
| Phase 3 — Flow Engine | FrameworkBLL/EIPConversation/EIPHandlers/EIPEngine/FlowEngine/EIPFlowEngine.cs |
| Phase 4 — Capability | FrameworkBLL/EIPConversation/EIPHandlers/EIPEngine/CapabilityEngine/EIPCapabilityEngine.cs |
| Phase 5 — Action Engine | FrameworkBLL/EIPConversation/EIPHandlers/EIPEngine/ActionEngine/EIPActionEngine.cs |
| Phase 5 — Action Handlers | FrameworkBLL/EIPConversation/EIPHandlers/EIPEngine/ActionHandler/*.cs |
| Phase 6 — Response Engine | FrameworkBLL/EIPConversation/EIPHandlers/EIPEngine/ResponseEngine/EIPResponseEngine.cs |
| Channel Handlers | FrameworkBLL/EIPConversation/EIPHandlers/EIPEngine/ChannelHandler/*.cs |
| Text Formatter | FrameworkBLL/EIPConversation/EIPHandlers/EIPEngine/FlowEngine/EIPTextFormatter.cs |
| Voice Handler | FrameworkBLL/EIPConversation/EIPHandlers/EIPEngine/ChannelHandler/VoiceChannelHandler.cs |
| Session DAL | FrameworkDAL/CustomCode/EIPConversation/EIPSession/EIPSessionDAL.cs |
| Flow QB (SQL) | FrameworkDAL/Query/EIPConversation/EIPFlow/EIPFlowQB.cs |
| Routing QB (SQL) | FrameworkDAL/Query/EIPConversation/EIPRouting/EIPRoutingQB.cs |
| Receive Endpoint | FrameworkSL/Endpoints/EIPConversation/ReceiveEIPConversation.cs |
| Webhook Endpoint (WhatsApp) | FrameworkSL/Endpoints/MessageHubGenerator/RequestMessageHubGenerator.cs |
Extended EIP DTOs
| DTO | Path (relative to GB5Framework/) |
|---|---|
EIPQualifyConfigDTO | FrameworkDAL/DTO/EIPConversation/EIPQualifyConfigDTO.cs |
EIPEnrichConfigDTO | FrameworkDAL/DTO/EIPConversation/EIPEnrichConfigDTO.cs |
EIPDynamicChoiceConfigDTO | FrameworkDAL/DTO/EIPConversation/EIPDynamicChoiceConfigDTO.cs |
EIPLocationDTO | FrameworkDAL/DTO/EIPConversation/EIPLocationDTO.cs |
EIPResponseFormatDTO | FrameworkDAL/DTO/EIPConversation/EIPResponseFormatDTO.cs |
EIPStructuredDataDTO | FrameworkDAL/DTO/EIPConversation/EIPStructuredDataDTO.cs |
EIPFlowExecutionResultDTO (extended) | FrameworkDAL/DTO/EIPConversation/EIPFlowExecutionResultDTO.cs |
DirectAction System
| Component | Path (relative to GB5Framework/) |
|---|---|
| Token Service (interface) | FrameworkBLL/DirectAction/IDirectActionTokenService.cs |
| Token Service (impl) | FrameworkBLL/DirectAction/DirectActionTokenService.cs |
| API Handler (interface) | FrameworkBLL/DirectAction/IGenericApiDirectActionHandler.cs |
| API Handler (impl) | FrameworkBLL/DirectAction/GenericApiDirectActionHandler.cs |
| Email Action Handler | FrameworkBLL/ActionProcessor/Handlers/EmailActionHandler.cs |
| Webhook BLL | FrameworkBLL/MessageHubGenerator/MessageHubWebhook/MessageHubWebhookBLL.cs |
| Execute Endpoint (NI=0) | FrameworkSL/Endpoints/Action/ActionExecuteEndpoint.cs |
| Submit Endpoint (NI=1) | FrameworkSL/Endpoints/Action/ActionSubmitEndpoint.cs |
| Resend Endpoint | FrameworkSL/Endpoints/Action/ResendDirectAction.cs |
| Config DAL | FrameworkDAL/CustomCode/DirectAction/DirectActionConfigDAL.cs |
| Token DAL | FrameworkDAL/CustomCode/DirectAction/DirectActionTokenDAL.cs |
| Config QB (SQL) | FrameworkDAL/Query/DirectAction/DirectActionConfigQB.cs |
| Token QB (SQL) | FrameworkDAL/Query/DirectAction/DirectActionTokenQB.cs |
| DTOs | FrameworkDAL/DTO/DirectAction/{DirectActionDTO, DirectActionDetailDTO, DirectActionTokenDTO}.cs |
| DB Migration — REMARKS | DB/Migrations/TDIRECTACTIONTOKEN_Add_REMARKS.sql |
GoodBooks GB5 — EIP & DirectAction Developer Reference — generated 2026-06-20