Skip to content

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 saysCanonical
servicesofferings
service_specialistsoffering_specialists
service_formsoffering_forms
service_attachmentsoffering_attachments (deferred with F2.1's boundary — see below)
service_plans, patient_service_plansdeferred, and the name is unsettled
products, service_plan_productsdeferred
calendar.service_id, appointment.service_idcalendars.offering_id, appointments.offering_id
appointment_templatesofferings (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_tierspatient_subscriptionsaccess_offerspatient_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

  1. Admin creates a service: "Mobility Evaluation—30 min, 200 RON, requires intake form"
  2. Admin links specialists: "Dr. Smith and Dr. Jones can do Mobility Evals"
  3. Admin creates calendars (booking windows): "Mobility Eval" (standard), "Free Mobility Campaign" (free, Wed 10-12), "Quick Check" (20 min, cheaper)
  4. Patient books: Books from a calendar → creates appointment using that service's rules
  5. Program enrollment (optional): Patient buys "10-session package" → system tracks sessions, expires after 90 days or 10 appointments
  6. 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

TableStatusPurpose
offeringsF2.1 — in scope, 000041Catalog 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_specialistsF2.1 — in scopeWho CAN provide this offering, plus the priority INT the assignment engine walks. PK (offering_id, specialist_id), denormalised organization_id NOT NULL
offering_formsIn scope, ships with F3Which form templates attach at appointment creation, keyed by slot + sort_order. Needs form_templates, so it lands in F3's migration
offering_attachmentsDeferredDownloadable files. Nothing in F3/F4/F5/F6 depends on it
service_plansDeferredCommerce half. Name unsettled — enrollments is taken by the live protocols.kind
patient_service_plansDeferredRedundant against the shipped patient_subscriptions / patient_content_grants chain
products, service_plan_productsDeferredReference 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_tierspatient_subscriptions (with current_period_ends_at; NULL = perpetual, plus a daily expiry sweep) → access_offers / access_offer_itemspatient_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 exercisessessionsprograms, 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:

sql
-- 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:

sql
-- 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:

sql
-- 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:

FlagMeaningWho Uses It
telerehab_accessSpecialist can assign telerehab treatment plans to this patientSpecialist assigns, patient executes
library_accessPatient can browse premade treatment plans and self-assignPatient browses, picks, and starts

Access check flow:

  1. Patient (or specialist) tries to assign a treatment plan
  2. Backend checks: does patient have an active patient_service_plan with the required access grant?
  3. For telerehab: telerehab_access = TRUE and access_expires_at > NOW()
  4. For library self-assign: library_access = TRUE and access_expires_at > NOW()
  5. If no valid plan found → 403 "Active plan with telerehab/library access required"

Patient Enrollment Flow

  1. Specialist recommends program after initial consultation (or patient buys directly)
  2. Patient enrolls → creates patient_service_plan record
  3. Access starts immediatelyaccess_starts_at set to enrollment time
  4. For session-based: patient books appointments, sessions_completed increments
  5. For time-based: patient has access until access_expires_at
  6. For hybrid: both tracking mechanisms run in parallel
  7. Completion/Expiry → status changes to completed or expired

Progress Tracking

sql
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:

sql
-- 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_at by 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:

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

sql
category ENUM:
  - consultation    -- Initial evaluations, assessments
  - therapy         -- Treatment sessions (PT, kinetotherapy)
  - examination     -- Diagnostic procedures
  - procedure       -- Clinical procedures

Forms — 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.

sql
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:

  1. Collect offering_forms WHERE offering_id = appointment.offering_id
  2. Collect calendar_forms WHERE calendar_id = appointment.calendar_id
  3. Merge and deduplicate
  4. Generate form instances in one INSERT, in one transaction, each carrying a fields JSONB snapshot + template_version frozen 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:

  • disclaimermultiple
  • surveymultiple
  • parameters — single
  • analysis — single, and it deliberately does not pre-create value rows (the mobility-evaluation flow writes them from measurements instead)
  • advice — single
  • report / prescriptionnot 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

sql
-- 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 RON

API Endpoints

See api.md for full API documentation.

Key endpoints:

  • GET /v1/services - Browse service catalog
  • POST /v1/services - Create service (admin)
  • GET /v1/services/{id}/specialists - Who can provide this service
  • POST /v1/service-plans/{id}/enroll - Enroll patient in program
  • GET /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_minutes and service.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 exercisessessionsprograms 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_tierspatient_subscriptionsaccess_offerspatient_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_forms junction 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_profiles and custom_field_values into 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 jobLands in
Catalog identity — title, slug, description, cover, video, specialty, is_public, minicrm_title, attachmentsofferings (F2.1)
Booking configuration — slot duration, gap, cooldown, min lead time, horizon, open/close windowcalendars (F4)
Form-slot binding + specialist rosteroffering_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_distributioncalendars.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_formsoffering_forms, appointment_template_specialistsoffering_specialists, appointment_template_attachmentsoffering_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

  1. Offerings are catalog, not scheduling - No booking horizons, cooldowns, or holds on offerings
  2. Duration is intrinsic - "This procedure takes 30 minutes" is an offering property; the calendar may override it
  3. Calendars provide the booking layer - One offering → many calendars with different rules
  4. offering_specialists = capability + roster priority - Different from calendar_specialists (per-calendar assignment)
  5. Multi-tenant by design - Every table has organization_id NOT NULL with RLS, junctions included
  6. 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. specialties and calendars may hard-delete, pre-checked and audited; offerings may not.)
  7. F2.1 boundary - No pricing, no plans, no products, no purchase path, no entitlement binding