Primitive spec

Bayta

The customer-voice primitive. Bayta owns what customers say back to the business and the work that answering them generates — internal support cases, an automated post-visit feedback funnel, consent-gated public testimonials, and processor disputes handled as cases. A 2★ rating is an incipient case; a chargeback is a case with a legal deadline.

What it owns

Bayta owns the workflow around customer conflict and customer praise. A complaint at the front desk, a refund request, a chargeback, and a low post-visit rating are all the same shape of work — something a staff member must take to resolution — so they all become a SupportCase in one queue, linked to the booking, order, invoice, or dispute they are about.

The primitives of record keep owning their facts. Payments owns the dispute wire object and evidence machinery; Mallow owns the refund money; Speaker owns every outbound send (consent-gated, fail-closed); Terminus owns the person and their consent record. Bayta composes them into "what does this customer need from me today?"

Concepts

SupportCase
A unit of customer-conflict work. Typed by kind (general, complaint, refund_request, dispute, feedback_followup) and source (manual, dispute, feedback, system), with a lifecycle of open → in_progress → waiting → resolved → closed (reopen allowed), a priority, a nullable personId (anonymous walk-in complaints are fine), an assignee, an optional dueAt deadline, and last-activity queue sorting.
CaseLink & CaseNote
A CaseLink ties a case to anything by opaque entityType/entityIdseldon_booking, hober_order, mallow_invoice, payments_dispute — so "cases about this booking" is one lookup from either side. CaseNote is the append-only timeline: human notes, auto-recorded status changes, and system annotations, with Media attachments. Corrections are new notes; nothing is deleted.
Feedback routing engine
Three entities run the funnel. FeedbackSettings (per tenant) holds the triggers, send delay (default 2h), per-person throttle (default 30 days), the happy/unhappy ratingThreshold (default 4), the public review URL keyed by sub-tenant, and autoCaseOnLowRating. FeedbackRequest is the ask — one per visit ever, with a tokenized link (hashed at rest) that expires after 14 days. FeedbackResponse is the answer — a 1–5 rating plus comment, the routing outcome recorded (public_redirect / private / none), the auto-opened caseId if the rating was low, and the customer's explicit consentPublicDisplay checkbox. Exactly one response per request.
Testimonial
Minted only from consented responses — never without consentPublicDisplay. Enters a moderation queue (pending_review → approved | rejected) with an operator-editable displayName (defaults to first name + last initial) and a trimmable excerpt. Only approved rows are publicly readable, through a sub-tenant-scoped projection of stars, excerpt, first name, and a coarse date — rendered on booking sites by the testimonials catalog component.
Disputes are cases
DisputeCaseSubscriber listens on the Payments outbox: dispute.opened idempotently opens a SupportCase(kind=dispute, priority=high) linked to the dispute, sets dueAt to the evidence deadline, and schedules staff nudges at −72h and −24h via Speaker. Each dispute.<status> update appends a system note; a win auto-resolves the case, a loss flags it and leaves it open for reconciliation. The case detail's evidence panel prefills from the linked transaction, invoice, order, person, and Media uploads — a signed waiver is exactly the evidence a no-show chargeback wants. Evidence is assembled and recorded in Foundation; sending it to the processor over the wire is on the Payments roadmap, and the console says so.
Triggers over the outbox
The funnel starts itself. FeedbackTriggerSubscriber watches Hober order.closed and Seldon booking.completed on the transactional outbox, applies throttle and one-per-visit checks, and schedules a feedback.request_due emission at +delaySec. FeedbackSendSubscriber then dispatches through Speaker — consent-gated, fail-closed; a consent skip marks the request skipped with the audit preserved. The send link carries the token in the URL fragment, so it never leaks via Referer.

API surface

Endpoints are versioned under /bayta/v1/. The public fill-out and testimonial routes are served through the render proxy with no login; everything else is operator-facing.

Bayta endpoints in the Foundation API reference OpenAPI 3.1 schema for Bayta with request/response shapes, parameters, and a try-it client.

Quick reference

MethodPathPurpose
POST / GET/bayta/v1/casesOpen a case; list the queue with status/kind/assignee filters.
GET/bayta/v1/cases/{id}Case detail with timeline and links.
POST/bayta/v1/cases/{id}/notes · /assign · /status · /linksWork the case: append a note, assign, move status, link an entity.
GET / PUT/bayta/v1/cases/{id}/dispute-evidence (+ POST …/submit)Prefill, draft, and record the dispute evidence bundle.
GET / PUT/bayta/v1/feedback/settingsThe funnel config: triggers, delay, throttle, threshold, review URL.
GET / POST/bayta/v1/public/feedback/{token}Public tokenized fill-out — fetch context, submit the rating (uniform 404, idempotent replay).
GET / POST/bayta/v1/testimonials, …/{id}/approve, …/{id}/rejectThe moderation queue.
GET/bayta/v1/public/testimonialsApproved-only public projection for the testimonials site component.

Example: a 2★ rating becomes a case

A salon visit closes in Hober; two hours later the customer gets a feedback link. A happy answer routes out to the public review page; an unhappy one stays private and opens work.

POST /bayta/v1/public/feedback/{token}
{
  "rating":  5,
  "comment": "Best color I've had in years.",
  "consentPublicDisplay": true
}
→ 200 OK
{
  "routing":     "public_redirect",
  "redirectUrl": "https://g.page/r/…/review",
  "thankYou":    "Thanks for your feedback!"
}
POST /bayta/v1/public/feedback/{token}
{
  "rating":  2,
  "comment": "Waited 40 minutes past my appointment time.",
  "consentPublicDisplay": false
}
→ 200 OK
{
  "routing":     "private",
  "redirectUrl": null,
  "thankYou":    "Thanks for your feedback!"
}
GET /bayta/v1/cases?status=open&kind=feedback_followup
→ 200 OK
{ "items": [ {
  "id":       "bcas_01JZW8…",
  "subject":  "Low rating follow-up (2★)",
  "kind":     "feedback_followup",
  "status":   "open",
  "priority": "normal",
  "source":   "feedback",
  "personId": "per_01JX2K…"
} ] }

The auto-opened case arrives already linked to the feedback response and the visit it was about; the 5★ consented comment lands in the testimonial moderation queue.

How it fits with the rest

flowchart TB
  Hb[Hober order.closed] --> OB[(Audit outbox)]
  Se[Seldon booking.completed] --> OB
  Pay[Payments dispute.opened] --> OB
  OB --> By(Bayta)
  By -- consent-gated ask --> Sp[Speaker]
  Sp --> Cust[Customer]
  Cust -. tokenized fill-out .-> By
  By --> Dz[Demerzel cases queue]
  By -- approved testimonials --> Site[Booking-site component]
            

Everything inbound rides the same transactional outbox: Hober order closes and Seldon booking completions trigger feedback asks, and Payments dispute events open and update dispute cases. Speaker sends every ask and every deadline nudge with the Terminus consent gate enforced fail-closed at dispatch time. Operators work the queue in the Demerzel console at /dashboard/cases, and approved testimonials flow back out to booking sites as a render component — the loop from a gym class attended to a review published, with the unhappy branch landing as work instead of a public one-star.