Offerings (this directory is the retired "Services" spec)
A catalog of what your clinic offers—consultation sessions, procedures, therapy programs.
RETIRED NAMING + NOT BUILT — reconciled 2026-08-02
"Service" is a forbidden term for this concept. The canonical name is Offering: a clinical service the clinic offers patients ("Initial Assessment", "Follow-up Consultation"). The glossary wins every naming dispute, and architectural code never uses the word "service" for a domain concept — it is reserved for the API/media/telemetry services. This directory keeps its services/ path only so existing links resolve; treat the path as legacy, the content as the Offerings spec.
Rename map for everything on this page:
| This page says | Canonical |
|---|---|
services | offerings |
service_specialists | offering_specialists |
service_forms | offering_forms |
service_attachments | offering_attachments (deferred with F2.1's boundary — see below) |
service_plans, patient_service_plans | deferred, and the name is unsettled |
products, service_plan_products | deferred |
calendar.service_id, appointment.service_id | calendars.offering_id, appointments.offering_id |
appointment_templates | offerings (the legacy table this replaces) |
Offering ≠ access-offer. The shipped access_offers (F14 commerce — shop and campaign access grants) is a different concept that happens to share a word. Conflating the two already cost one wrong scope decision.
Nothing on this page is built. No offerings table, no services table, no domain, no routes, zero permission rows.
Scope settled 2026-08-02 — F2.1 only
Only the catalog identity ships, in migration 000041, because three in-scope features have NOT NULL FKs into it: calendars.offering_id (F4), appointments.offering_id (F5), and offering_forms — which is the form-generation mechanism (F3). The offering answers "which forms does this appointment get"; the appointment does not.
In scope (F2.1): offerings (~15 columns), offering_specialists (with priority), offering_forms (with slot + sort_order, landing in F3's migration since it needs form_templates).
Explicitly out: no pricing column, no plans, no products, no purchase path, no entitlement binding on offerings. If a task starts reaching for those, it has left scope.
Deferred — the whole commerce half of this page. service_plans / patient_service_plans / patient_tier_inclusions / products are largely redundant against the shipped access chain: patient_tiers → patient_subscriptions → access_offers → patient_content_grants → the play gate. Two competing access models in one platform is a foundation problem, not a feature gap. Anything built here must reconcile with what already ships, not run beside it.
The service_plans → enrollments rename target in the glossary is blocked. enrollment is taken: protocols.kind IN ('prescription','enrollment') is shipped and live in production (000023_sessions.up.sql), where it means "patient self-enrolled in a guided program." F2.2 needs a different name.
Open, not decided: appointment-package tracking ("this patient has N sessions of Offering X remaining", decremented as appointments are consumed). protocols.kind='enrollment' covers program enrolment only and does not cover this. There is no platform equivalent today. It is F2.2-adjacent and likely deferred with it — but F5 must not silently assume it exists.
What this enables
One-off bookings: Define any service your clinic offers—a single consultation, a physiotherapy session, an examination—and let patients book it directly through a calendar. No plan or package required.
Multi-calendar promotion: Run one service (Mobility Eval) as multiple calendars—default 30-min at 200 RON, or promotional "Quick 20-min Check" at 100 RON.
Specialist credentials: Mark which specialists are trained to do each service type.
Service plans: Offer session packages ("10 kinetotherapy sessions over 90 days"), time-based subscriptions ("3-month telerehab access"), or hybrid bundles that combine both. Plans layer on top of services — a service can be booked standalone or as part of a plan.
Plan tracking: Know exactly when sessions expire, when patient access is ending, and generate reports per service.
Bundled products: Package physical goods (elastic bands, devices) with service plans—patient gets the device shipped automatically when they enroll.
How it works
- Admin creates a service: "Mobility Evaluation—30 min, 200 RON, requires intake form"
- Admin links specialists: "Dr. Smith and Dr. Jones can do Mobility Evals"
- Admin creates calendars (booking windows): "Mobility Eval" (standard), "Free Mobility Campaign" (free, Wed 10-12), "Quick Check" (20 min, cheaper)
- Patient books: Books from a calendar → creates appointment using that service's rules
- Program enrollment (optional): Patient buys "10-session package" → system tracks sessions, expires after 90 days or 10 appointments
- Bundled products (optional): Enroll in "Cervical Recovery Kit" → automatically ships pillow to home, unlocks telerehab access
Technical Reference
Overview
The service catalog defines what the organization offers. A Service is a pure business/catalog entity that knows nothing about booking rules, scheduling, or holds. It answers: "What is this offering?"
This feature replaces the old appointment_templates table, which conflated service definition with scheduling configuration.
Core Concept
Service = What we offer (business domain)
Calendar = When/how to book it (scheduling domain) → See scheduling/
Appointment = What was booked (clinical domain) → See appointments/A service defines:
- What it is (name, description, category)
- How long it takes (duration + buffer are intrinsic to the procedure)
- How much it costs (base price)
- What forms are needed (survey, disclaimer, etc.)
- Who can provide it (service_specialists)
A service does NOT define:
- Booking horizon or cooldown (that's on calendars)
- Specialist availability hours (that's on specialist_weekly_hours)
- Holds or slot reservations (that's in the scheduling feature)
Key Tables
| Table | Status | Purpose |
|---|---|---|
offerings | F2.1 — in scope, 000041 | Catalog identity: id, organization_id NOT NULL, title, slug, description, specialty_id FK specialties(id) ON DELETE RESTRICT, default_duration_minutes, cover_url, video_url, minicrm_title, is_public, published, published_at, deleted_at, created_at, updated_at. UNIQUE (slug, organization_id); GIN (immutable_unaccent(title) gin_trgm_ops) |
offering_specialists | F2.1 — in scope | Who CAN provide this offering, plus the priority INT the assignment engine walks. PK (offering_id, specialist_id), denormalised organization_id NOT NULL |
offering_forms | In scope, ships with F3 | Which form templates attach at appointment creation, keyed by slot + sort_order. Needs form_templates, so it lands in F3's migration |
offering_attachments | Deferred | Downloadable files. Nothing in F3/F4/F5/F6 depends on it |
service_plans | Deferred | Commerce half. Name unsettled — enrollments is taken by the live protocols.kind |
patient_service_plans | Deferred | Redundant against the shipped patient_subscriptions / patient_content_grants chain |
products, service_plan_products | Deferred | Reference catalog; nothing depends on it |
Every in-scope table carries organization_id NOT NULL + RLS calling current_app_has_permission(resource, action), including the junctions. Offerings also need a patient-side SELECT policy via current_human_patient_profile_ids() — without it the portal's bookable-offering browse returns zero rows.
minicrm_title overrides the display title in outbound CRM payloads, the same pattern as specialists.minicrm_name.
Service Catalog vs Scheduling
┌─────────────────────────────────────────┐
│ SERVICE CATALOG │
│ "What we offer" │
├─────────────────────────────────────────┤
│ services │
│ ├── Mobility Evaluation │
│ │ duration: 30min, price: 200 RON │
│ ├── Flexibility Measuring │
│ │ duration: 30min, price: 300 RON │
│ └── Pain Psychology │
│ duration: 20min, price: 100 RON │
└─────────────────────────────────────────┘
↓ linked via service_id
┌─────────────────────────────────────────┐
│ CALENDARS (Scheduling) │
│ "When/how to book" │
├─────────────────────────────────────────┤
│ calendars │
│ ├── Mobility Eval (default) │
│ │ uses service defaults │
│ ├── Free Mobility Campaign │
│ │ override: is_free=true │
│ │ override: hours Wed 10-12 only │
│ └── Flexibility Promo │
│ override: duration=20min, price=100│
└─────────────────────────────────────────┘Duration and Buffer on Services
Why duration/buffer are here: They are intrinsic to the service type. "Mobility Evaluation takes 30 minutes" is a property of the procedure itself, not a scheduling configuration.
How it works:
- Service defines default duration:
duration_minutes = 30 - Calendars can override for promotions:
override_duration_minutes = 20 - Scheduling engine reads:
calendar.override_duration_minutes ?? service.duration_minutes
This allows promotional calendars like "Quick 20-Minute Eval" that use a shorter version of the same service.
Service Specialists vs Calendar Specialists
Two different relationships:
service_specialists (this feature)
Capability: "Dr. Smith CAN do mobility evaluations"
- Catalog-level relationship
- Defines who is qualified/trained for this service
- Optional custom pricing per specialist
calendar_specialists (scheduling feature)
Assignment: "Dr. Smith IS assigned to the Free Mobility Campaign calendar"
- Scheduling-level relationship
- Defines who is actively taking bookings on this calendar
- Has priority, round-robin tracking, optional hour overrides
Service Plans & Plan Types — DEFERRED (design record only)
Everything from here to "Service Categories" is deferred and partly superseded
None of service_plans, patient_service_plans, products or service_plan_products is in scope, and the design below predates the shipped access substrate. Read it as history, not as a plan.
What actually ships today, in production: patient_tiers → patient_subscriptions (with current_period_ends_at; NULL = perpetual, plus a daily expiry sweep) → access_offers / access_offer_items → patient_content_grants → the play gate. That chain already answers "can this patient access this content, and until when." The telerehab_access / library_access boolean pair below is a second answer to the same question.
Also superseded: every reference below to patient_treatment_plans or a treatment-plans feature. There is no treatment_plans table and there will not be one — the content hierarchy is exercises → sessions → programs, and the patient side is protocols (kind ∈ prescription | enrollment). The treatment_plan_assignments_total counter that used to be specced on service_plans is retired with it.
SQL below uses integer PKs (patient_id = 123, pdf_template_id = 5). Platform PKs are UUIDv7 (P26).
Service plans define how patients access services over time. Three plan types are supported:
Session-Based Plans (Traditional)
Count-down session packages linked to a specific service:
-- Service: Individual kinetotherapy session
INSERT INTO services (name, duration_minutes, base_price)
VALUES ('Kinetotherapy', 30, 100);
-- Service Plan: 10-session package
INSERT INTO service_plans (name, service_id, plan_type, sessions_total, validity_days, total_price)
VALUES ('Kinetotherapy 10 Sessions', service_e_id, 'session_based', 10, 90, 1000);Patient books appointments → sessions_completed increments → exhausted or expired.
Time-Based Plans (Subscriptions)
Access window without session counting. Used for telerehab subscriptions and library access:
-- Telerehab subscription: specialist assigns treatment plans for 3 months
INSERT INTO service_plans (name, plan_type, access_months, telerehab_access, total_price)
VALUES ('Telerehab 3 Months', 'time_based', 3, TRUE, 500);
-- Full library access: patient self-browses and self-assigns premade plans for 1 year
INSERT INTO service_plans (name, plan_type, access_months, telerehab_access, library_access, total_price)
VALUES ('Full Access 1 Year', 'time_based', 12, TRUE, TRUE, 1200);Note: service_id is NULL for platform-level plans not tied to a specific service.
Hybrid Plans (Appointments + Telerehab)
Bundles clinic appointment credits with telerehab time access:
-- Recovery Package: 10 clinic sessions + 1 month telerehab
INSERT INTO service_plans (
name, service_id, plan_type,
sessions_total, validity_days,
access_months, telerehab_access,
total_price
) VALUES (
'Recovery Package', service_e_id, 'hybrid',
10, 120, -- 10 appointments within 120 days
1, TRUE, -- + 1 month telerehab access
1500
);Patient gets clinic appointments AND telerehab from day one. Specialist can transition the patient from in-clinic to home exercises seamlessly.
Access Grants
Service plans control what the patient can do via two boolean flags:
| Flag | Meaning | Who Uses It |
|---|---|---|
telerehab_access | Specialist can assign telerehab treatment plans to this patient | Specialist assigns, patient executes |
library_access | Patient can browse premade treatment plans and self-assign | Patient browses, picks, and starts |
Access check flow:
- Patient (or specialist) tries to assign a treatment plan
- Backend checks: does patient have an active
patient_service_planwith the required access grant? - For telerehab:
telerehab_access = TRUEandaccess_expires_at > NOW() - For library self-assign:
library_access = TRUEandaccess_expires_at > NOW() - If no valid plan found → 403 "Active plan with telerehab/library access required"
Patient Enrollment Flow
- Specialist recommends program after initial consultation (or patient buys directly)
- Patient enrolls → creates
patient_service_planrecord - Access starts immediately —
access_starts_atset to enrollment time - For session-based: patient books appointments,
sessions_completedincrements - For time-based: patient has access until
access_expires_at - For hybrid: both tracking mechanisms run in parallel
- Completion/Expiry → status changes to
completedorexpired
Progress Tracking
SELECT
psp.id,
psp.sessions_total,
psp.sessions_completed,
psp.sessions_cancelled,
CASE WHEN psp.sessions_total IS NOT NULL
THEN (psp.sessions_total - psp.sessions_completed - psp.sessions_cancelled)
ELSE NULL
END AS sessions_remaining,
psp.status,
psp.access_starts_at,
psp.access_expires_at,
psp.expires_at
FROM patient_service_plans psp
WHERE psp.patient_id = 123 AND psp.status = 'active';Add-On Services
Services marked with is_addon = TRUE can be added to appointments in progress:
-- During appointment, specialist notices heart issue
PATCH /v1/appointments/456
{
"additional_service_ids": [2] -- Heart Examination (is_addon: true)
}Backend:
- Validates
service.is_addon = TRUE - Updates
appointment.additional_service_ids - Extends
appointment.ended_atby service duration - Generates forms for the added service
- Updates appointment total
Products (Catalog Only)
Physical products (resistance bands, braces, therapy equipment) are stored as a simple reference catalog. Products can be bundled with service plans so that staff knows what's included in a patient's program.
Bundling with Service Plans
Admin defines which products are associated with a service plan:
-- Service Plan: "Cervical Recovery Kit" (device + telerehab access)
INSERT INTO service_plans (name, plan_type, access_months, telerehab_access, library_access, total_price)
VALUES ('Cervical Recovery Kit', 'time_based', NULL, TRUE, TRUE, 89);
-- Associate the pillow with this plan
INSERT INTO service_plan_products (service_plan_id, product_id, quantity)
VALUES (plan_id, cervical_pillow_id, 1);When a patient enrolls in this plan, staff sees that the plan includes a cervical pillow and handles fulfillment offline.
Products in Appointments
Specialists can note product recommendations during appointments via appointment.additional_product_ids[]. This is informational — it records which products the specialist recommended, but does not trigger any order or fulfillment flow.
What the platform does NOT do
The platform is not an e-commerce system. There is no order tracking, no fulfillment lifecycle, no payment processing for products, and no delivery tracking. Product sales happen at the clinic or through external channels.
The nearest shipped thing is F14 Commerce & Access Offers, which is deliberately not an e-commerce build either: shops stay external, and a paid order or a free campaign claim mints platform access through one shared provisioner. See access-offers/ and integrations/shop-commerce. (The gaps/ directory this line used to link to was retired in the 2026-08-02 doc pass.)
Service Categories
category ENUM:
- consultation -- Initial evaluations, assessments
- therapy -- Treatment sessions (PT, kinetotherapy)
- examination -- Diagnostic procedures
- procedure -- Clinical proceduresForms — offering_forms
Offerings define which form templates auto-attach. This is the form-generation mechanism, and it is why F2.1 blocks F3: "which forms does this appointment get" is answered by the offering, not by the appointment.
INSERT INTO offering_forms (offering_id, form_template_id, organization_id, slot, sort_order) VALUES
(offering_a_id, survey_template_id, org_id, 'survey', 1),
(offering_a_id, disclaimer_template_id, org_id, 'disclaimer', 2);When an appointment is created:
- Collect
offering_formsWHEREoffering_id = appointment.offering_id - Collect
calendar_formsWHEREcalendar_id = appointment.calendar_id - Merge and deduplicate
- Generate form instances in one INSERT, in one transaction, each carrying a
fieldsJSONB snapshot +template_versionfrozen at creation
Step 4 is non-negotiable on both counts. leo creates instances through N+1 client-side POSTs in a for await loop and carries a standing in-code TODO that navigating away mid-loop corrupts the form. And leo has no snapshot at all — editing a template retroactively rewrites how every historical form renders, which its own migration docs call the #1 reason to redesign.
Form-slot cardinality (business rule, carried from the live system)
slot ∈ disclaimer | survey | parameters | analysis | advice — the five attachable values, and the CHECK admits no others. The slot is derived from form_templates.type at attach time and is never supplied by the caller, exactly as leo reads it out of FORM_TYPE_TO_SLOT[type]; that is also why the primary key is (offering_id, form_template_id). A report or medical_prescription template (never bare prescription — see glossary.md → Two senses of prescription) is refused with 409 form_type_not_attachable. Real cardinality rules learned from running a clinic:
disclaimer— multiplesurvey— multipleparameters— singleanalysis— single, and it deliberately does not pre-create value rows (the mobility-evaluation flow writes them from measurements instead)advice— singlereport/prescription— not attachable slots. Report is generated via a separate path; prescription has no attach route at all
Two more rules that are easy to lose: a global (appointment-null) form gates every appointment — that is how clinic-wide disclaimers work — and detaching a form must never delete shared profile-level values, which is easy to get wrong here precisely because auto-fill copies values in.
Example: Service with Multiple Calendars
-- Service A: Mobility Evaluation (30min, 200 RON)
INSERT INTO services (name, slug, duration_minutes, base_price, published, is_public)
VALUES ('Mobility Evaluation', 'mobility-evaluation', 30, 200, true, true);
-- Default Calendar (uses service defaults)
INSERT INTO calendars (name, slug, service_id, published, is_public)
VALUES ('Mobility Evaluation', 'mobility-evaluation', service_a_id, true, true);
-- Campaign Calendar (free, limited hours)
INSERT INTO calendars (name, slug, service_id, is_free, slots_open_at, slots_close_at, published, is_public)
VALUES ('Free Mobility Feb', 'free-mobility-feb', service_a_id, true, '2026-02-15', '2026-02-20', true, true);
INSERT INTO calendar_specialists (calendar_id, specialist_id, priority, override_weekly_hours)
VALUES (campaign_cal_id, specialist_a_id, 1, '{"wed": [{"start": "10:00", "end": "12:00"}]}');
-- Specialist available ONLY Wed 10-12 on this campaign (not their default hours)
-- Promotional Calendar (shorter, cheaper)
INSERT INTO calendars (name, slug, service_id, override_duration_minutes, override_price, published, is_public)
VALUES ('Quick Mobility Check', 'quick-mobility', service_a_id, 20, 100, true, true);
-- 20min instead of 30min, 100 RON instead of 200 RONAPI Endpoints
See api.md for full API documentation.
Key endpoints:
GET /v1/services- Browse service catalogPOST /v1/services- Create service (admin)GET /v1/services/{id}/specialists- Who can provide this servicePOST /v1/service-plans/{id}/enroll- Enroll patient in programGET /v1/patient-service-plans/{id}- View program progress
Integration Points
With Scheduling Feature
- Services linked to calendars via
calendar.service_id(required FK) - Scheduling engine reads
service.duration_minutesandservice.buffer_minutes - Calendars can override duration/buffer for promotions
- Service specialists feed into calendar specialist assignments
With Appointments Feature
- Appointments reference service via
appointment.service_id - Add-on services tracked in
appointment.additional_service_ids[] - Service plans linked via
appointment.patient_service_plan_id - Forms generated from
service_forms+calendar_forms
With Programs & Protocols — SUPERSEDED
The "treatment plans" integration this section used to describe is gone. There is no treatment_plans table, no patient_treatment_plans, and no features/treatment-plans/ spec. The shipped model is exercises → sessions → programs on the content side and protocols (kind ∈ prescription | enrollment) on the patient side — see programs-and-assignments/.
Access is decided by the shipped chain (patient_tiers → patient_subscriptions → access_offers → patient_content_grants → the play gate), not by telerehab_access / library_access booleans on a plan. Offerings have no access semantics at all in F2.1.
With Forms Feature
offering_formsjunction defines which forms to generate (see above)- Form generation happens at appointment creation, in one transaction, with a template-version snapshot
- Forms auto-fill by copying from
patient_profilesandcustom_field_valuesinto the snapshot; saving performs a separate, separately audited write-back. Never a live pointer into a shared value store
Migration from the legacy appointment_template
The legacy entity is one row doing three jobs, and the platform splits it three ways:
| Legacy job | Lands in |
|---|---|
Catalog identity — title, slug, description, cover, video, specialty, is_public, minicrm_title, attachments | offerings (F2.1) |
| Booking configuration — slot duration, gap, cooldown, min lead time, horizon, open/close window | calendars (F4) |
| Form-slot binding + specialist roster | offering_forms (F3) + offering_specialists (F2.1) |
Field mapping for the catalog half:
- Keep:
title,slug,description,specialty_id,published,is_public,cover_url,video_url,minicrm_title(it is load-bearing — it overrides the display title in outbound CRM payloads) - Add:
default_duration_minutes,deleted_at - Move out:
use_even_distribution→calendars.assignment_strategy(in the live system, "even distribution" is expressed as all roster priorities set to 0, not as a separate mode) - Not carried:
base_price,is_addon,buffer_minutes,category— pricing is out of F2.1 scope, and the rest have no consumer in F3–F6
Junction renames: appointment_template_forms → offering_forms, appointment_template_specialists → offering_specialists, appointment_template_attachments → offering_attachments (deferred).
Not carried across at all: the legacy schema itself. It has exactly one NOT NULL column across 94 tables (and it is id), zero foreign keys, zero indexes, and no RLS. Every table here gets organization_id NOT NULL, real FKs with explicit ON DELETE, indexes on every filtered/sorted column, and RLS policies. See leo-port-map.md §7 for the full anti-pattern catalogue.
Slug preservation is an open Phase-3 question: leo's public URLs are {specialty.slug}/{offering.slug} with random slugs, and preserving them verbatim keeps live links working.
Design Principles
- Offerings are catalog, not scheduling - No booking horizons, cooldowns, or holds on offerings
- Duration is intrinsic - "This procedure takes 30 minutes" is an offering property; the calendar may override it
- Calendars provide the booking layer - One offering → many calendars with different rules
offering_specialists= capability + roster priority - Different fromcalendar_specialists(per-calendar assignment)- Multi-tenant by design - Every table has
organization_id NOT NULLwith RLS, junctions included - Soft delete -
offerings.deleted_at, because appointments and generated documents reference offerings by FK for years. (The earlier "no soft delete" line on this page was wrong: offerings are referenced by clinical records even though they are themselves configuration.specialtiesandcalendarsmay hard-delete, pre-checked and audited; offerings may not.) - F2.1 boundary - No pricing, no plans, no products, no purchase path, no entitlement binding