Skip to content

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.

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.

StatusSet byVisible to agents?
DraftUserNo
ActiveUserYes
PausedUserNo
ExpiredSystem (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.

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.

All endpoints enforce permissions via the OfferResource trait. Every query is scoped to the caller’s organization_id.

MethodRoutePermissionDescription
POST/api/offers/listCollection:ListList offers with optional filters
GET/api/offers/{id}Instance:ViewGet a single offer
POST/api/offersCollection:CreateCreate an offer
PUT/api/offers/{id}Instance:UpdateUpdate an offer
DELETE/api/offers/{id}Instance:DeleteDelete an offer (cascades to images)
MethodRoutePermissionDescription
POST/api/offers/{id}/images/uploadInstance:UpdateUpload image (multipart)
GET/api/offers/{id}/images/{img_id}/serveInstance:ViewServe image (redirect or stream)
DELETE/api/offers/{id}/images/{img_id}Instance:UpdateDelete image
POST/api/offers/{id}/images/{img_id}/set-primaryInstance:UpdateSet primary image
PUT/api/offers/{id}/images/reorderInstance:UpdateReorder images

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.

{
"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.

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.

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-ups

Both 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.

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.

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).

ComponentPurpose
OffersListViewGrid of OfferCard components at /sales/offers
CreateOfferViewForm at /sales/offers/new
OfferDetailViewDetail + edit view at /sales/offers/:id
OfferFormFieldsSectioned form: details, availability, advanced (collapsible)
OfferImagesSectionDrag-drop upload grid, reorder, set primary, delete
OfferStatusBadgeColor-coded status indicator
OfferTypeBadgeOffer type label

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