Sales: Offers & Promotions
The sales module adds an Offers & Promotions hub. Organization members create offers through a dedicated UI; voice and text agents automatically receive active offers in their system prompt and can look up details via tools.
Data Model
Section titled “Data Model”pub struct Offer { pub id: Uuid, pub name: String, pub description: String, pub offer_type: OfferType, // Discount | FreeTrial | Bonus | Bundle | Other pub value: String, // e.g. "20% off all services" pub promo_code: Option<String>, pub valid_from: Option<String>, // "YYYY-MM-DD" pub valid_until: Option<String>, // "YYYY-MM-DD" pub eligibility_notes: Option<String>, pub status: OfferStatus, // Draft | Active | Paused | Expired pub target_tags: Vec<String>, // Contact tags this offer targets pub terms_conditions: Option<String>, pub created_by: Uuid, pub created_at: String, pub updated_at: String, pub images: Vec<OfferImage>,}Dates use String instead of chrono types for WASM compatibility.
OfferStatus Lifecycle
Section titled “OfferStatus Lifecycle”| Status | Set by | Visible to agents? |
|---|---|---|
Draft | User | No |
Active | User | Yes |
Paused | User | No |
Expired | System (daily job) | No |
The ExpireOffersJob runs daily at 00:10 UTC. It flips any Active offer whose valid_until is in the past to Expired.
OfferImage
Section titled “OfferImage”pub struct OfferImage { pub id: Uuid, pub offer_id: Uuid, pub url: String, // /api/offers/{offer_id}/images/{id}/serve pub file_name: Option<String>, pub content_type: String, pub file_size: Option<i32>, pub is_primary: bool, // At most one per offer (DB-enforced) pub sort_order: i32,}Limits: 12 images per offer, 10 MB per file. Accepted types: JPEG, PNG, WebP, GIF, HEIC/HEIF. Content type is verified via magic-byte detection (infer crate), not the upload header.
API Endpoints
Section titled “API Endpoints”All endpoints enforce permissions via the OfferResource trait. Every query is scoped to the caller’s organization_id.
| Method | Route | Permission | Description |
|---|---|---|---|
POST | /api/offers/list | Collection:List | List offers with optional filters |
GET | /api/offers/{id} | Instance:View | Get a single offer |
POST | /api/offers | Collection:Create | Create an offer |
PUT | /api/offers/{id} | Instance:Update | Update an offer |
DELETE | /api/offers/{id} | Instance:Delete | Delete an offer (cascades to images) |
Image Endpoints
Section titled “Image Endpoints”| Method | Route | Permission | Description |
|---|---|---|---|
POST | /api/offers/{id}/images/upload | Instance:Update | Upload image (multipart) |
GET | /api/offers/{id}/images/{img_id}/serve | Instance:View | Serve image (redirect or stream) |
DELETE | /api/offers/{id}/images/{img_id} | Instance:Update | Delete image |
POST | /api/offers/{id}/images/{img_id}/set-primary | Instance:Update | Set primary image |
PUT | /api/offers/{id}/images/reorder | Instance:Update | Reorder images |
List Filters
Section titled “List Filters”Pass any combination in the POST /api/offers/list body:
{ "status": "active", "offer_type": "discount", "date_window": "currently-valid"}date_window accepts "currently-valid" or "expiring-soon". Results are capped at 200 and sorted by updated_at DESC.
Create / Update Payload
Section titled “Create / Update Payload”{ "name": "Spring 20% Off", "description": "20% discount on all services for new customers", "offer_type": "discount", "value": "20% off all services", "promo_code": "SPRING20", "valid_from": "2026-03-01", "valid_until": "2026-05-31", "eligibility_notes": "New customers only", "status": "active", "target_tags": ["new"], "terms_conditions": "One per customer."}Validation rejects empty required fields (name, description, value), status = "expired" (system-managed), and valid_until < valid_from.
AI Integration
Section titled “AI Integration”Active offers are injected into both voice and text agent system prompts automatically. No configuration is needed — if an org has active offers, agents see them.
Prompt Injection
Section titled “Prompt Injection”build_offers_prompt_block() fetches up to 5 most-recent active offers and returns a markdown block:
## Active Offers- Spring 20% Off — 20% off all services, code SPRING20, valid through 2026-05-31- Free Trial Month — 30-day free trial for new sign-upsBoth build_text_agent_context_service and build_voice_agent_context_service call this function and append the block to the system prompt when it’s non-empty.
Agent Tools
Section titled “Agent Tools”Two tools are registered when the caller has Offer:Collection:List permission:
get_active_offers— Returns up to 5 active offers (summary list)get_offer_details— Fetches full offer JSON by UUID or exact name
See Offer Tools for tool schemas and examples.
Permissions
Section titled “Permissions”Offer { Actions { View, Update, Delete } Instance { View, Update, Delete } Collection { List, Create }}Permission checks happen at two layers: collection-level for list/create, instance-level for view/update/delete (which also validates org ownership).
UI Components
Section titled “UI Components”| Component | Purpose |
|---|---|
OffersListView | Grid of OfferCard components at /sales/offers |
CreateOfferView | Form at /sales/offers/new |
OfferDetailView | Detail + edit view at /sales/offers/:id |
OfferFormFields | Sectioned form: details, availability, advanced (collapsible) |
OfferImagesSection | Drag-drop upload grid, reorder, set primary, delete |
OfferStatusBadge | Color-coded status indicator |
OfferTypeBadge | Offer type label |
Database
Section titled “Database”Two tables: offer (13 columns) and offer_image. Key indexes:
- Composite index on
(organization_id, valid_until)— supports the daily expiration query - Partial unique index on
offer_image (offer_id) WHERE is_primary = true— enforces at-most-one primary image per offer