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
Propose a change 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).
Propose a change 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.
Propose a change 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);
- 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. - Inside: a
HomeCareRequest,pending. The note is stored encrypted; the prescription, if any, is a document of the vault. - 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. Orrefuse().
The members offered are those who have a home-visit appointment type (channel: home).
Propose a change 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.jsonand 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, untilhealth: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.
Propose a change 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).
Propose a change 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');
Propose a change 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).
Propose a change 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 |