Aller au contenu

omnibase/health

The health regime on omnibase/office: a practice (multi-professional, nursing, physiotherapy...), its practitioners read from the RPPS, patients and their dependants, home care requests, fees, results sent through the encrypted vault, medical secrecy in who may read what, the emergency banner, GDPR export.

Étage
Structure préparée · Omnibase
Version
main
Vérifié en réel
Son README ne le dit pas
Licence
MIT
composer require omnibase/health:dev-main

omnibase/health is a health practice's site on omnibase/office - a multiprofessional practice (MSP), a health centre, a nursing or physiotherapy practice, a midwife: the team with its fees, appointments, care at home, the patients' space and their data, medical secrecy applied to the documents, the emergency numbers on every page. Its namespace is Base\Health\. Nothing in it is written for one profession only.

Requirements

  • PHP 8.2 or later; doctrine/orm 2.15 or 3;
  • omnisense/omnibase 3.0 or 4.0, omnibridge/symfony 1.x, omnibase/office;
  • Symfony 6.4, 7 or 8 (config, console, dependency-injection, form, http-kernel, security-bundle).

Suggested: omnibase/admin (the back office), omnibase/mailbox (patients write to the secretariat or their practitioner), omnisense/omnistate with omnistate/annuaire-sante (the team read from the RPPS), drenso/symfony-oidc-bundle (FranceConnect, Pro Santé Connect). The messages are in French only (domain health).

Installation

composer require omnibase/health          # brings omnibase/office
composer require omnibase/mailbox omnisense/omnistate omnistate/annuaire-sante   # suggested
// config/bundles.php
Base\Office\OfficeBundle::class => ['all' => true],
Base\Health\HealthBundle::class => ['all' => true],
# config/routes.yaml - after omnibase/office's
health_controller:
    resource: "@HealthBundle/src/Controller/Client"
    type: attribute
health_admin_controller:
    resource: "@HealthBundle/src/Controller/Admin"
    type: attribute
# config/packages/security.yaml
security:
    role_hierarchy:
        ROLE_PRACTITIONER: [ROLE_STAFF]
        ROLE_SECRETARY:    [ROLE_STAFF]
        ROLE_STAFF:        [ROLE_USER]
        ROLE_ADMIN:        [ROLE_STAFF, ROLE_SECRETARY]    # the practice's coordination

The roles are given by omnibase's groups (Base\Entity\User\Group): a group "Praticiens" whose roles are [ROLE_PRACTITIONER], a group "Secrétariat" with [ROLE_SECRETARY]. No membership table of the bundle's own.

Then a migration: 7 tables prefixed health_.

Configuration

# config/packages/health.yaml
health:
    roles: { practitioner: ROLE_PRACTITIONER, secretary: ROLE_SECRETARY, staff: ROLE_STAFF, coordination: ROLE_ADMIN }
    emergency: { samu: '15', europe: '112', deaf: '114', pharmacy: '3237', oncall: '116 117' }
    retention:                        # months, for health:purge - to set with the practice's DPO
        appointments: 36
        home_care: 36
        access_logs: 36
        signals: 1
        documents_after_expiry: 1
    teleconsultation_referenced: false   # true only if the video solution is listed by the ANS

The bundle sets, for omnibase/office: the team's and the member's templates (practitioners, with fees and next slots), the contact form's warning ("no medical data here"), the patient space's menu.

Pages

Route Path
health_fees /tarifs each practitioner's fees and the practice's
health_home_care /soins-a-domicile the areas, the request (Home care)
health_teleconsultation /teleconsultation who offers video, how it works, what it is not
health_emergencies /urgences the numbers, the practice's instructions
health_space… /espace, /espace/profil, /espace/proches, /espace/donnees the patient's space (Patients' data)

In the site's layout, on every page:

{% include '@Health/partials/_emergency.html.twig' %}
<link rel="stylesheet" href="{{ asset('bundles/health/css/health.css') }}">

Twig: health_practice(), health_practitioner(member), health_fees(practitioner), health_emergency(), health_profile(user), health_dependents(user), health_care_team(user).

A Practice has a type; isHomeCareFirst() is true for a nursing practice: a site puts care at home before appointments on its home page. Nothing in the bundle is written for "a doctor".

The team read from the Annuaire Santé

A member's registryId is their RPPS number. "Lire l'Annuaire Santé" (PractitionerRegistry::refresh) writes the profession and the MSSanté address on the Practitioner. The key is the one typed in the back office (API keys: api.annuaire_sante.key) or omnistate.annuaire_sante.api_key.

Back office

CRUD: practice, practitioners, patients, home care requests, fees. /admin/agenda/soins-a-domicile: the requests waiting. Widgets: health_requests, health_inbox (counts only). Settings: the HDS host; API keys: the Annuaire Santé's.

Compliance

Added to omnibase/office's widget: the mailbox's encryption and key, a second factor for every staff account, fees displayed for every practitioner, out-of-hours instructions, an HDS-certified host declared, and the reminder that the built-in video is not ANS-listed.

Cron

20 4 * * *  bin/console health:purge     # what has outlived health.retention; --dry-run counts

Demonstration accounts

In omnisense/omnibase's demo environment (its docs/20-architecture/demo.md) the sign-in page offers one button for each role of a practice. Base\Health\Demo\HealthDemoAccounts declares them:

Identifier Role
docteur, infirmiere, kine group "Praticiens" (ROLE_PRACTITIONER) the agenda, the patients, the documents sent
secretariat group "Secrétariat" (ROLE_SECRETARY) everyone's agenda, the desk's messages; sends documents, does not read them
coordination ROLE_ADMIN the practice's administration: team, fees, access log, compliance
patient, patiente group "Patients" appointments, a dependant, a result, a home care request

The password is the identifier. The fixtures take the accounts from omnibase's factory and attach what makes them worth signing in as - a team member and an agenda, a file and appointments:

public function __construct(private readonly \Base\Demo\DemoAccountFactory $accounts) {}

$docteur = $this->accounts->account('docteur', $manager);   // created with its group, or the database's

A practice without one of these roles leaves it out (base.demo.exclude: [docteur, kine] for a nursing practice); a site that renames one declares the same identifier in its own provider. The second factor required of the staff (base.security.two_factor.required_roles) is lifted for these accounts in demo, and only there. The labels are demo.<identifier>.label and .description in the health domain.

Reserved

Base\Health\Transmission\MssanteTransmitterInterface: sending to Mon espace santé through MSSanté takes an operator and a CPS card; the contract is there, no implementation.

More

Concepts · Medical secrecy · Home care · Patients' data · Identity · The back office · Configuration reference · Testing

Proposer une modification docs/index.md

Concepts

omnibase/health adds seven entities beside omnibase/office's (Office, Member, the agenda, the vault's Document): each one completes an office's record rather than repeating it.

The practice and its team

Entity Table Holds
Practice health_practice one to one with omnibase/office's Office: its type (PracticeType), FINESS number, presentation, emergency instructions, out-of-hours text, whether it accepts new patients, offers teleconsultation, care at home
Practitioner health_practitioner one to one with an office Member: profession code and profession (from the Annuaire Santé), specialty, sector (Sector), Carte Vitale, third-party payment, accepts new patients, MSSanté address
Fee health_fee a practitioner's fee, or the practice's when it has none: category, label, amount and reimbursement as printed, position

PracticeType: msp, health_centre, medical, nursing, physiotherapy, midwifery, paramedical. Practice::isHomeCareFirst() is true when the practice offers care at home and its type puts it first (a nursing practice): a site then shows care at home before appointments.

Sector: sector_1, sector_2, optam, not_contracted, contracted. Practitioner::getRpps() is the member's registryId; isPhysician() is profession code 10.

The patients

Entity Table Holds
PatientProfile health_patient_profile one to one with the account: family name, given names, birth date, phone, address, the referring physician (a Practitioner), the opposition to sharing within the team, when it was erased
Dependent health_dependent someone the account books for: names, birth date, relation (Relation: child, parent, spouse, other)
IdentityLink health_identity_link an account's identity at FranceConnect or Pro Santé Connect: provider, sub (unique per provider), the level (acr), first and last use (used())

A profile is not a medical record: nothing clinical is kept in it. PatientProfiles::of($user) makes it when first needed, and on omnibase/office's ClientAccountEvent::CREATED (an account opened from the practice's invitation). PatientProfile::erase() empties it.

An appointment booked or requested online takes the patient's name as their profile writes it ("Camille ÉRABLE"), not the account's identifier (AppointmentNaming, on AppointmentEvent::BOOKED and REQUESTED). DependentBeneficiaries offers the dependants to omnibase/office's booking.

Care at home

HomeCareRequest (health_home_care_request): the patient (and the dependant it is for), the address and phone, careType (injection, dressing, blood_test, hygiene, infusion, monitoring, medication, other), frequency (CareFrequency: once, daily, every_other_day, weekly), start and end dates, preferred time, the prescription (a vault Document), the notes (encrypted, notesCipher), status (CareStatus: pending, accepted, refused, cancelled), the service area it fell in, who was assigned, the series of appointments made, when it was sent and decided. getVisitCount() counts the visits its frequency gives between the dates, in calendar days (Care at home).

The roles

Role (health.roles) Default Given by
practitioner ROLE_PRACTITIONER the group "Praticiens"
secretary ROLE_SECRETARY the group "Secrétariat"
staff ROLE_STAFF both, through the role hierarchy
coordination ROLE_ADMIN the practice's administration

What each one reads of a patient's documents is the voter's (Medical secrecy).

Proposer une modification docs/concepts.md

Medical secrecy

Documents

omnibase/office's vault lets the recipient and the sender read a document, and nobody else unless a regime says so. Base\Health\Security\CareTeamAudience is that regime:

Who Sees that it exists (title) Reads it
The patient yes yes
Its author (whoever sent it) yes yes
A practitioner who follows the patient yes yes - unless the patient objected
The secretariat yes no (a document not marked confidential - an administrative letter - yes)
Another practitioner, the coordination, anyone else no no

"Follows the patient" is Base\Health\Service\CareTeam: the members the patient has, or had, an appointment with (not a cancelled one), and the practice's physician they declared as their "médecin traitant". The patient's objection is PatientProfile::sharingOpposition, set in their own profile.

Every view, download, deposit, withdrawal - and every refusal - is in the access log, which the patient reads in /espace/donnees and the coordination in the back office.

E-mails

None holds health data: a booking's e-mail says when and with whom, not why nor what for; "a document awaits you" has neither the document nor its title; "a message awaits you" has neither its text nor its subject. All of it is read signed in.

At rest

Documents, their titles and file names, appointments' reasons, home care notes: encrypted by omnibase/office's Cipher. Messages and their subjects: by omnibase/mailbox (encrypt: true). Both fail closed without their key.

The staff's second factor

Whoever reads patients' data signs in with more than a password. The application requires a second factor of the staff's role through omnisense/omnibase (its docs/20-architecture/account-security.md):

# config/packages/base.yaml
base:
    security:
        two_factor: { required_roles: [ROLE_STAFF], postpone: false }

A staff account (ROLE_STAFF, held directly or through the role hierarchy) with no second factor is sent to its enrolment page (/settings/security-required) from every page until it has one - no "not now" with postpone: false - and cannot switch its last factor off afterwards. Patients choose.

The bundle sets nothing itself: without these lines nothing is required, and the compliance widget says so ("Double authentification de l'équipe": missing). It then counts the staff accounts that have no second factor yet (a warning until each has one).

required_roles is fixed when the container is built. A site whose development and test accounts have no second factor lifts the obligation per environment - through a parameter, because a list given again under when@dev is added to the first one, not put in its place:

# config/packages/base.yaml
base:
    security:
        two_factor: { required_roles: '%app.second_factor_roles%', postpone: false }

parameters:
    app.second_factor_roles: [ROLE_STAFF]

when@dev: &no_second_factor
    parameters:
        app.second_factor_roles: []
when@test: *no_second_factor

(Until omnibase had the rule, the bundle redirected by itself - StaffTwoFactorSubscriber, switched by health.staff_two_factor - to /settings. Both are gone: an application that still sets health.staff_two_factor removes the line, and a test that asserted the redirect to /settings expects /settings/security-required.)

The video

End to end between the two browsers, the relay one's own. It is not listed by the ANS: no reimbursable teleconsultation through it - the compliance widget and /teleconsultation say so.

Hosting

Health data must be hosted by an HDS-certified host. The bundle cannot check that; it asks for the host's name (Settings), prints it in the legal notice, and marks it missing until it is given.

Proposer une modification docs/secrecy.md

Home care

$request = $homeCare->submit($patient, [
    'street' => '8 rue du Moulin', 'postalCode' => '67998', 'city' => 'Aubach', 'phone' => '…',
    'careType' => 'dressing',            // injection, dressing, blood_test, hygiene, infusion, monitoring, medication, other
    'frequency' => 'daily',              // once, daily, every_other_day, weekly
    'startDate' => $from, 'endDate' => $until, 'preferredTime' => 'morning', 'notes' => '…',
], $dependent, $prescription);
  1. The address is checked first against omnibase/office's ServiceAreas (postcodes, towns, or a radius). Outside all of them: HomeCareException('home_care.error.outside_area'), and nothing is kept - not the request, not the prescription.
  2. Inside: a HomeCareRequest, pending. The note is stored encrypted; the prescription, if any, is a document of the vault.
  3. The practice answers in /admin/agenda/soins-a-domicile: who goes, the day and hour of the first visit. HomeCare::accept() books the visits as a series (Booker::series()): one at the request's frequency from the first visit to the end date, counted in calendar days, all or none. Or refuse().

The members offered are those who have a home-visit appointment type (channel: home).

Proposer une modification docs/home-care.md

Patients' data

/espace (ROLE_USER): the next appointments (join the video room, add to a calendar, cancel), the documents not read yet, the home care asked, the care team. /espace/profil: who they are for the practice, their "médecin traitant", and their say on sharing within the team. /espace/proches: their Dependents - who they book for (the booking then offers them, through omnibase/office's BeneficiaryProviderInterface).

A PatientProfile is not a medical record: nothing clinical is kept in it.

Rights (GDPR art. 15, 17, 20)

Base\Health\Service\PatientData, from /espace/donnees:

  • export($user): one array - account, profile, dependants, linked identities, appointments with their reasons decrypted, home care requests, the list of documents, who accessed them;
  • zip($user): data.json and each available document, decrypted;
  • erase($user): the profile emptied, the dependants removed, the appointments to come cancelled, the pending requests cancelled, the documents they deposited destroyed, those sent to them withdrawn. What must be kept stays, bare, until health:purge.

Retention

health:purge removes, after health.retention months: past appointments, decided home care requests, the access log; documents expired or withdrawn, their files destroyed; contact requests (omnibase/office's contact.retention_months); closed video rooms' handshakes. The durations are the practice's to set with its DPO; the defaults are placeholders, not legal advice.

Proposer une modification docs/patients-data.md

Identity

The bundle provides one thing: Base\Health\Entity\IdentityLink - a provider, its sub, the account

  • with a unique index on (provider, sub), and its repository (findOneBySub()).

An identity is attached by its sub, never by an e-mail address. An address proves nothing about who someone is; attaching a health account to whoever shows the same address would hand it over.

The protocol itself is not the bundle's: an application uses drenso/symfony-oidc-bundle (two clients) and writes its linker. The rule it should follow:

  • an identity already linked signs in its account;
  • someone already signed in who comes back from the provider links it to their account;
  • Pro Santé Connect, nobody signed in: the practitioner of the team whose RPPS number it is (PractitionerRepository::findOneByRpps() - the national identifier is "8" + RPPS); nobody of the team: refused;
  • FranceConnect, never seen: a first visit - sign in to an existing account to link it, or open a patient's account from the identity.

FranceConnect v2 answers its userinfo as a signed JWT, which drenso's client does not read (it json_decode()s the body): verify it against the provider's JWKS (lcobucci/jwt).

Proposer une modification docs/identity.md

The back office

The screens are loaded when omnibase/admin is installed. Each one opens its writes to a role of the practice with omnibase/office's OpenToTrait.

The screens

Screen Fields Who writes
PracticeCrudController the office, the type, FINESS, presentation, emergency instructions, out of hours, new patients, teleconsultation, care at home ROLE_ADMIN
PractitionerCrudController the member, profession, profession code, specialty, sector, MSSanté, Carte Vitale, third-party payment, new patients; the action "Lire l'Annuaire Santé" ROLE_ADMIN
FeeCrudController label, amount, position, practitioner (none: the practice's), category, reimbursement ROLE_ADMIN
PatientProfileCrudController names, birth date, account, phone, address, referring physician, sharing opposition; no "new", no delete ROLE_STAFF
HomeCareRequestCrudController date, patient, town, care, dates, status, assigned to; read only (no "new", edit or delete) ROLE_STAFF

Reading the Annuaire Santé

The practitioners' action registry (/{entityId}/registry) calls PractitionerRegistry::refresh($practitioner): with the member's RPPS number (registryId), the Annuaire Santé gives the profession and the MSSanté address, written on the Practitioner. It flashes admin.registry.read with the name and profession, admin.registry.no_rpps without a number, admin.registry.unknown when the registry does not know it, admin.registry.unavailable with the error when it cannot be read. The key is the API keys page's (api.annuaire_sante.key), else omnistate.annuaire_sante.api_key.

The home care requests

/admin/agenda/soins-a-domicile (health_admin_home_care, ROLE_STAFF) lists the requests waiting, inside the back office's frame. Each is accepted or refused with a POST to /admin/agenda/soins-a-domicile/{id}/accept or /refuse (health_admin_home_care_decide): accepting takes the member who goes and the first visit, and books the series (Care at home); the flash says how many visits were booked and for whom (admin.home_care.flash.accepted), or that the request was refused; a refusal of the booking (a slot taken) is flashed with its own message.

The tiles

Widget Shows
health_requests how many home care requests wait, and the five latest
health_inbox the signed-in member's unread messages and the desk's waiting ones, counts only (with omnibase/mailbox)

Settings and keys

On omnibase/admin's settings page, health.legal.hds: the host certified for health data (HDS) the practice declares. On the API keys page, the Annuaire Santé's key.

Compliance

Six checks are added to omnibase/office's compliance tile:

Check Warns when
MailboxKeyCheck with omnibase/mailbox: its encryption is off, or on without a key
StaffTwoFactorCheck a staff account signs in without a second factor, or nothing requires one of the staff's role
FeesDisplayedCheck an active practitioner has no fee, and the practice has none of its own (it names them)
EmergencyCheck the main practice has no out-of-hours text
HdsHostCheck no HDS-certified host is declared
TeleconsultationCheck the built-in video is on and health.teleconsultation_referenced is false: it is not ANS-listed
yield MenuItem::block('health_requests', 'Soins à domicile', 'fa-solid fa-house-medical')->setSize(2);
yield MenuItem::block('health_inbox', 'Messages', 'fa-solid fa-envelope')->setSize(2);
yield MenuItem::linkToCrud(\Base\Health\Entity\PatientProfile::class, 'Patients', 'fa-solid fa-user');
yield MenuItem::linkToRoute('health_admin_home_care', [], 'Soins à domicile', 'fa-solid fa-house-medical');

Proposer une modification docs/back-office.md

Configuration reference

The health tree

Dumped with Symfony's YamlReferenceDumper from Base\Health\DependencyInjection\HealthConfiguration:

health:

    # The roles the groups give (security.yaml's hierarchy puts both under ROLE_STAFF).
    roles:
        practitioner:         ROLE_PRACTITIONER
        secretary:            ROLE_SECRETARY
        staff:                ROLE_STAFF
        coordination:         ROLE_ADMIN

    # The permanent banner: who to call.
    emergency:
        samu:                 '15'
        europe:               '112'
        deaf:                 '114'
        pharmacy:             '3237'

        # The out-of-hours medical line (permanence des soins).
        oncall:               '116 117'

    # Months each thing is kept once over (health:purge), to be set by the practice with its DPO.
    retention:
        appointments:         36
        home_care:            36
        access_logs:          36
        signals:              1
        documents_after_expiry: 1

    # Whether the video solution is listed by the ANS (reimbursable teleconsultation). The one built in is not: the back office says so.
    teleconsultation_referenced: false
Key Default Read by
roles.practitioner, roles.secretary, roles.staff, roles.coordination ROLE_PRACTITIONER, ROLE_SECRETARY, ROLE_STAFF, ROLE_ADMIN the voter, the care team, the back office's writes, the two-factor check
emergency.samu, europe, deaf, pharmacy, oncall 15, 112, 114, 3237, 116 117 the banner on every page and /urgences (health_emergency())
retention.appointments, home_care, access_logs 36 months health:purge
retention.signals 1 month health:purge: the closed video rooms' handshakes
retention.documents_after_expiry 1 month health:purge: documents expired or withdrawn
teleconsultation_referenced false /teleconsultation and the compliance check

Each key is a container parameter (%health.roles.staff%, %health.retention.appointments%, %health.teleconsultation_referenced%...). The retention defaults are placeholders, to be set by the practice with its DPO.

The command

bin/console health:purge removes what has outlived its retention (Patients' data); --dry-run says what would go and removes nothing. Run it from cron, once a day.

What the bundle sets for omnibase/office

The team's and the member's templates (practitioners, with their fees and next slots), the contact form's warning (no medical data there), the patient space's menu. Its services plug into omnibase/office's and omnibase/mailbox's interfaces:

Service Interface or event
Security\CareTeamAudience who reads a vault document
Service\DependentBeneficiaries omnibase/office's BeneficiaryProviderInterface: the dependants in the booking
Mailbox\CareTeamRecipients whom a patient may write to besides the secretariat
Service\AppointmentNaming AppointmentEvent::BOOKED, REQUESTED
Service\PatientProfiles ClientAccountEvent::CREATED
Service\ResultSender sends a result to a patient through the vault
Service\HomeCare submit(), accept(), refuse()
Service\PatientData export(), zip(), erase()
Service\PractitionerRegistry refresh()
Compliance\* the six checks of the compliance tile
Twig\HealthExtension the Twig functions

Base\Health\Transmission\MssanteTransmitterInterface is a contract without an implementation (sending to Mon espace santé takes an operator and a CPS card).

Twig functions

Function Returns
health_practice() the main practice
health_practitioner(member) a member's Practitioner
health_fees(practitioner = null) a practitioner's fees; without one, every fee in order
health_emergency() the emergency numbers
health_profile(user) the patient's profile
health_dependents(user) their dependants
health_care_team(user) the members who follow them
health_types(member) the appointment types a patient may book with that member (not the staff-only ones)
health_pending_care() how many home care requests wait
health_teleconsultation_referenced() health.teleconsultation_referenced

Translations

The domain health, in French only (ICU format).

Proposer une modification docs/configuration.md

Testing

Running the suite

Inside a host application that has omnibase/office (the suite boots the host's kernel once, for omnisense/omnibase's class aliases):

php vendor/bin/phpunit -c vendor/omnibase/health/phpunit.xml.dist

The bootstrap also reads the omnibase/* packages the bundle requires or suggests from beside the checkout, when the host has not installed them.

omnisense/omnibase-harness has no omnibase/office: its test health stops at the first class it needs (Base\Office\Controller\Admin\OpenToTrait). With omnibase/office's checkout on the harness's autoloader and no kernel, 14 of the 23 tests pass; the 9 others (CareTeamAudienceTest, four of StaffTwoFactorCheckTest) need the kernel's App\Entity\User aliases, so a host with omnibase/office.

What the tests check

Test Checks
Compliance\StaffTwoFactorCheckTest missing while nothing requires a second factor of the staff; a role the staff do not all hold does not count; a warning while staff accounts have none; satisfied once every one has one; a role under the staff's covers them
Controller\CrudRequiredFieldsTest omnibase/admin's CrudFieldsTestCase on the five screens
Demo\HealthDemoAccountsTest one demonstration account for each role of the practice; nobody above the practice's administrator; each has its label and its sentence in the catalogue
Enum\CareFrequencyTest visits counted in calendar days; the hour and the timezone of the first visit add no day; through the night the clocks change; a request's visit count
Security\CareTeamAudienceTest a practitioner who follows the patient reads; one who does not, does not; the patient's opposition closes the team's access; the secretariat sees the title, not the content; nobody outside the staff

Proposer une modification docs/testing.md