Installation
composer require omnibase/office
It brings glitchr/omnimeet and omnimeet/direct,
which carry the video calls (Visio).
// config/bundles.php
Base\Office\OfficeBundle::class => ['all' => true],
Omnimeet\Bridge\Symfony\OmnimeetBundle::class => ['all' => true], // the video gateways; optional for the direct one alone
# config/routes.yaml
office_controller:
resource: "@OfficeBundle/src/Controller/Client"
type: attribute
office_admin_controller: # the agenda, the documents, the access log (needs omnibase/admin)
resource: "@OfficeBundle/src/Controller/Admin"
type: attribute
Then a migration (doctrine:migrations:diff): 14 tables, all prefixed office_ (and office).
Moments are stored in UTC through omnisense/omnibase's utc_datetime_immutable type
(Base\Database\Type\UtcDateTimeImmutableType, registered by omnibase): omnibase sets PHP's
timezone per visitor, and an appointment must not move with it. In a query, a moment is bound as
Base\Database\Type\Utc::from($moment). (The bundle had its own type of the same name and its own
Base\Office\Database\Utc until omnibase provided them: same name, same column, no migration.)
The master key of the vault, once, in the secrets vault:
php -r 'echo base64_encode(random_bytes(32));' | bin/console secrets:set SHARE_MASTER_KEY -
Configuration
# config/packages/office.yaml
office:
timezone: Europe/Paris # schedules are written in it
recipient: '%env(MAILER_CONTACT)%' # where the contact form goes
sender: '%env(MAILER_TECHNICAL)%' # the From of the office's e-mails
vocabulary:
practice: cabinet # how the practice is named in the texts: cabinet | etude | office | maison_de_sante, or its forms
contact:
notice: '@office.contact.notice' # the warning above the contact form (a regime chooses it)
retention_months: 12
templates:
layout: layout/layout1.html.twig # what the pages extend; their content is the block `content`
team: '@Office/client/team.html.twig'
member: '@Office/client/member.html.twig'
hours: '@Office/client/hours.html.twig'
contact: '@Office/client/contact.html.twig'
request: '@Office/client/request.html.twig' # a first appointment asked without an account
space_nav: ~ # a partial at the top of the client's pages
booking: # enabled: false turns it off
public_request: false # true opens /rendez-vous/demande: a first appointment asked without an account
min_notice: 120 # minutes: nothing closer can be booked online
horizon: 60 # days ahead
cancel_until: 24 # hours before which a client may still cancel
step: 0 # minutes between slot starts; 0: the type's duration + buffer
reminders: [24, 2] # hours before
space_route: office_space_appointments
share:
storage: local.share # a private Flysystem storage (declared by the bundle if you do not)
master_key: '%env(default::SHARE_MASTER_KEY)%'
max_size: 20971520
mime_types: [application/pdf, image/jpeg, image/png, image/webp, image/heic, text/plain]
space_route: office_space_documents
visio:
open_before: 10 # minutes before the appointment the room opens
close_after: 30
gateway: direct # the glitchr/omnimeet gateway new rooms open on (omnimeet.gateways.<name>)
names: host # whose name a gateway is given: the member's (host), or none
turn_probe: ~ # host:port the compliance check asks (a container's service name)
# config/packages/omnimeet.yaml - the direct gateway's relay (it is declared for you, without one)
omnimeet:
gateways:
direct:
factory: direct
options:
turn_secret: '%env(default::TURN_SECRET)%'
turn_urls: '%env(default::TURN_URLS)%' # a list, or one comma-separated variable
stun_urls: [] # none by default: no third party is asked where the browser is
turn_ttl: 3600
office.visio.turn_secret, turn_urls, stun_urls and turn_ttl, where these four were
written before the video's engine left for glitchr/omnimeet, are still read - as aliases, for
this version, with a deprecation notice.
Pages
| Route | Path | |
|---|---|---|
office_team, office_member |
/equipe, /equipe/{slug} |
the team, grouped by Member::category |
office_hours |
/horaires-acces |
omnibase's opening hours, the walk-in hours, each office |
office_contact |
/contact |
omnibase's ContactType, kept as ContactRequest |
office_booking… |
/rendez-vous… |
see Booking |
office_request |
/rendez-vous/demande |
a first appointment asked without an account, when booking.public_request is on (What a regime builds on) |
office_space_appointments, office_space_documents |
/espace/rendez-vous, /espace/documents |
the client's own |
office_visio_room |
/visio/{token} |
see Visio |
office_invitation |
/invitation/{token} |
a client invited opens their account |
The practice's name in the texts
The bundle's texts name the practice - "appelez le cabinet", "Au cabinet", "Téléphone du cabinet". A notary's is an étude or an office, a health centre a maison de santé:
office:
vocabulary:
practice: etude # cabinet (default) | etude | office | maison_de_sante
# or its forms, for another word (what is left out is built from the name with le / au / du):
# practice: { name: agence, the: l’agence, at: à l’agence, of: de l’agence }
| In a text | cabinet | etude | maison_de_sante |
|---|---|---|---|
{practice} |
cabinet | étude | maison de santé |
{the_practice}, {The_practice} |
le cabinet | l’étude | la maison de santé |
{at_the_practice}, {At_the_practice} |
au cabinet | à l’étude | à la maison de santé |
{of_the_practice} |
du cabinet | de l’étude | de la maison de santé |
They are global translation parameters (framework.translator.globals, set by the bundle from this
option): available in every domain, so a regime's or an application's own texts may use them too. An
application that had overridden the bundle's texts one by one in translations/office+intl-icu.fr.yaml
to change the word can set the option and drop those lines; the overrides it keeps still win.
The stylesheet (bundles/office/css/office.css) reads the site's colours from CSS variables:
--of-accent, --of-accent-ink, --of-ink, --of-muted, --of-line, --of-surface, --of-page,
--of-danger, --of-focus, --of-radius.
The team and its register
A Member has a registryId (an RPPS number, later a bar number). With glitchr/omnistate installed,
Base\Office\Service\MemberRegistry::refresh($member) asks whichever ProfessionalRegistryInterface
knows that identifier and keeps what it said on the member (registryData, registryCheckedAt) - the
back office's "Lire le registre".
Clients are invited
Base\Office\Service\ClientInvitations extends omnibase's invitations (Base\Service\Invitations,
Base\Entity\User\Invitation): invite($email, $name) mails a link; accepting it creates the account,
attaches the appointments taken for that address before the account existed, and dispatches
ClientAccountEvent::CREATED for the regime (health makes the patient's profile).
Compliance
ComplianceCheckInterface services (autoconfigured, tag office.compliance_check) answer a
ComplianceResult (ok, warning, missing, and what to do). The dashboard widget office_compliance
lists them. The office's own: the vault's key, the direct video gateway's TURN relay (configured,
and answering a STUN request), the legal notice's host. A regime adds its trade's.
Back office (with omnibase/admin)
CRUD screens for every entity; /admin/agenda (see Booking); /admin/documents and
/admin/access-log (see Share); the widgets office_today, office_waiting_room,
office_compliance, office_contact; a settings section (host, data-protection contact, retention).
More
Booking · Share · Visio · What a regime builds on · Configuration reference · Reference · Testing
Propose a change docs/index.md
Booking
The model
| Entity | |
|---|---|
AppointmentType |
what an appointment is for: duration, buffer after it, channel (in_person, video, home, phone), who may book it online (everyone, known: already seen by that member, staff: by phone only), mode (slots or request), manual confirmation, instructions, a price as printed; the members who offer it |
Schedule |
a member receives on that ISO weekday from - to, at an office (none: home visits, remote), for some types (none listed: all), valid from - until |
Absence |
a member away from - to |
ServiceArea |
where the office goes: postcodes, towns, or a radius around an office - contains($postcode, $town, $lat, $lng) |
Appointment |
member, type, start and end (none yet for a request), the client's account or a name taken on the phone, a beneficiary, status (requested, confirmed, cancelled, no_show, done), source, the reason encrypted, a series id |
The whole office closed is a closed special day of omnibase's opening hours, not an absence.
Free slots
$slots = $slotFinder->find($member, $type, $from, $to); // list<Slot>: start, end, office
$slotFinder->isFree($member, $type, $start);
$slotFinder->next($member, $type, 3);
Schedules, less absences, less closed days, less the appointments held and the buffer after each,
less what is closer than min_notice or further than horizon (clients only, not the staff).
compute() is the same on plain arrays, without a database. Times are walked in seconds from the
schedule's start written in the office's timezone: the night the clocks go back, a schedule from 1:00
to 4:00 lasts four hours and has its 2:00 slot twice. One member at a time: it is not a table planner.
Booking
$appointment = $booker->book($type, $member, $start, $client, AppointmentSource::ONLINE, $reason);
$booker->request($type, $member, $client, $message); // no time: the office fixes it
$booker->confirm($appointment, $start);
$booker->move($appointment, $newStart);
$booker->cancel($appointment); // BookingException too_late within cancel_until
$booker->series($type, $member, $first, 10, new \DateInterval('P1D'), $client, $address);
A slot is taken under a symfony/lock lock on the member's day, after asking the SlotFinder again;
Appointment::$slotKey ("member|start", emptied when the appointment no longer holds its slot) has a
unique index, which is the last word if two servers race. A refusal is a BookingException whose key
is a translation (booking.error.slot_taken, too_late, not_bookable, overlap…).
The reason is sealed by Base\Office\Share\Cipher before it is stored: without a key, a booking
with a reason fails rather than store it clear.
What the client is told
AppointmentEvent (BOOKED, REQUESTED, CONFIRMED, MOVED, CANCELLED, REMINDER).
BookingMailer listens: when, with whom, where, how (the channel), the .ics (omnibase's
Calendar\Ics) and the Google Calendar link, the link to cancel (a token mailed, only its hash kept).
Never the reason, never the type's name. Add a listener of your own for a push or an SMS.
A regime that lets clients book for someone else implements BeneficiaryProviderInterface.
Cron
*/15 * * * * bin/console office:booking:remind # each reminder of office.booking.reminders, once
55 23 * * * bin/console office:booking:close-day # past: done; a video one never joined: no-show; closed rooms purged
The agenda (/admin/agenda, ROLE_STAFF)
A day (a column a member) or a week (one member), drawn on a grid: the schedules in white, absences hatched, appointments in the member's colour. Each opens to: done, absent, cancel, move. Below: the quick booking taken on the phone - an existing account by its e-mail, or someone new who is then invited -, and the requests waiting for a time. A member reads the reason of their own appointments; anyone else only sees that the time is taken.
Propose a change docs/booking.md
Share
What is stored, and how
A Document belongs to a recipient (whose document it is) and has a sender. Its file is
encrypted on a private Flysystem storage:
- libsodium
crypto_secretstream_xchacha20poly1305, in 64 KiB chunks, each authenticated: a byte changed, a chunk missing or the last one cut off, and it does not decrypt (CorruptedException); - a random key per file, kept in the row wrapped (
crypto_secretbox) by a key derived from the master key (crypto_kdf); - the title and the file name sealed the same way; the SHA-256 of the clear file is kept and checked again after each decryption.
What stays readable in the database: who it is for, who sent it, its kind, size, type and dates.
Fail closed. The master key (office.share.master_key: base64 of 32 bytes, from the secrets
vault) missing or unusable, every call of Cipher throws KeyMissingException: nothing is stored
clear as a fallback, nothing is read. Cipher::generateKey() makes one.
$document = $vault->deposit($uploadedFile, $recipient, $sender, 'Compte rendu', DocumentKind::REPORT);
$stream = $vault->open($document); // decrypted to a temporary stream, checked, then handed out
$vault->revoke($document, $by);
$vault->destroy($document); // the file too; the log stays
deposit() refuses an empty file, one above max_size, a type outside mime_types
(ShareException).
Who may read
Base\Office\Security\Voter\DocumentVoter, three attributes:
| Attribute | |
|---|---|
OFFICE_DOCUMENT_VIEW |
see that it exists, its title |
OFFICE_DOCUMENT_READ |
open it, download it - never a revoked or expired one |
OFFICE_DOCUMENT_REVOKE |
its sender only |
The recipient and the sender see and read theirs. Nobody else - not the site's administrators
either - unless a regime's AudienceResolverInterface (autoconfigured, tag office.share_audience)
grants it; a resolver's false wins over any grant:
final class PartnersAudience implements AudienceResolverInterface
{
public function decide(string $attribute, Document $document, UserInterface $user): ?bool
{
return self::READ === $attribute && $this->isPartner($user) ? true : null;
}
}
How a file leaves
Only through ShareController::download: signed in, allowed by the voter, logged, sent with
Cache-Control: no-store, X-Content-Type-Options: nosniff, a sandboxing Content-Security-Policy,
and as an attachment (application/octet-stream). An image may be shown inline (?inline=1) in a
conversation. Someone else's document answers 404, and the refusal is logged.
The access log
AccessLog: document id, recipient id, who (and their name at the time), the action (view,
download, upload, share, revoke, denied), IP, user agent, when. Rows are never edited and
outlive the document. /admin/access-log shows it to ROLE_ADMIN.
What the recipient is told
DocumentEvent::DEPOSITED → ShareMailer: "a new document awaits you in your space", with a link to
office.share.space_route - where one signs in. Neither the document, nor its title, nor a link that
would open it.
Propose a change docs/share.md
Visio
A Room belongs to a subject (RoomSubjectInterface: a host, a guest, a start, an end, a key - an
Appointment is one). Rooms::forSubject($appointment) makes it the first time it is asked for and
keeps its times in step; it opens open_before minutes before the start and closes close_after
minutes after the end. Its address is a random token; only its two participants get anything, and
only while it is open.
The room is office's, the call a gateway's
What carries the call is a gateway of glitchr/omnimeet:
| Stays here (the meeting and its data) | Is the family's (the factory and the engine) |
|---|---|
Entity\Visio\Room: token, subject, host, guest, opening and closing, presence, waiting room, end - and the gateway that opened it, its reference there |
Meeting, Participant, Access, Capabilities, the gateways' Registry |
Visio\RoomSubjectInterface, Visio\Rooms (forSubject, canEnter, access, heartbeat, close, purge) |
the requests Open, Join, Close, Fetch, Notify |
Entity\Visio\Signal and its repository: where the handshake is stored |
omnimeet/direct: Signaling, the handshake's rules, on the repository as its SignalStoreInterface |
VisioController and the routes office_visio_*, the file dropped into the vault, the page, the waiting room's widget, the agenda |
omnimeet/direct: TurnCredentials and the engine visio.js |
office.visio.{open_before, close_after, gateway, names, turn_probe} |
omnimeet.gateways.<name>.options |
Rooms::forSubject()opens the room onoffice.visio.gatewayand keeps its reference.- The page asks
Rooms::access($room, $user)- the gateway'sJoin- and renders theAccess: omnimeet/direct's engine on the room's own endpoints, a provider's frame, or a button to its address. - Who may enter, who is there, the waiting room, the end are judged here, on the
Room, whatever the gateway. The guest's frame is not even loaded before the host is there. - A gateway is told as little as it needs: the room's token for a key (never the subject, never a
title), a role,
u<id>, and the member's name as the team page gives it (names: noneto withhold it). The client's name is never given. - A room nobody has entered follows the configured gateway (change
gateway, and the appointments to come change with it); a room someone has been in stays on the one it was opened on.
The direct gateway (the default)
The media go from browser to browser (WebRTC, DTLS-SRTP: encrypted end to end between the two). The room only relays their handshake, over plain HTTP - no WebSocket server to run:
GET /visio/{token} |
the page: camera check, waiting room, the call |
GET /visio/{token}/engine.js |
the gateway's script, served from its package (office_visio_engine) |
GET /visio/{token}/ice |
the ICE servers, with fresh TURN credentials |
POST /visio/{token}/signal |
`{type: hello |
GET /visio/{token}/signal?after=N |
what the other side sent since, who is there - asked every second while the call is set up, every five once it runs; it is also the "I am here" |
POST /visio/{token}/fin |
the host ends: the room closes, the handshake is purged |
POST /visio/{token}/document |
a file dropped during the call, kept in the vault for the guest |
The engine has no dependency: the host offers, the guest answers; mute, camera off, screen sharing, a chat on a data channel (never stored). Its markup and its rules are documented with omnimeet/direct.
TURN
When a network forbids the direct way, the media go through your own relay - still encrypted, never
through a third party. The direct gateway issues the "TURN REST API" credentials coturn checks with
use-auth-secret; nothing is stored, they lapse after turn_ttl.
# config/packages/omnimeet.yaml
omnimeet:
gateways:
direct:
factory: direct
options: { turn_secret: '%env(default::TURN_SECRET)%', turn_urls: '%env(default::TURN_URLS)%' }
# docker-compose.yml
coturn:
image: coturn/coturn:4.6
ports: ["3478:3478/udp", "3478:3478/tcp", "21080-21099:21080-21099/udp"]
command: [-n, --listening-port=3478, --min-port=21080, --max-port=21099, --use-auth-secret,
"--static-auth-secret=${TURN_SECRET}", "--realm=${TURN_REALM}", --no-cli, --no-tls, --no-dtls,
--no-multicast-peers, --denied-peer-ip=10.0.0.0-10.255.255.255, ...]
The relay ports (--min-port/--max-port, published as they are) sit below 32768: coturn's default
49152-65535 is the host's ephemeral range (macOS from 49152, Linux 32768-60999), where an outgoing
socket of the host may hold a port when the container starts, and publishing it fails. Give each
site on a machine a range of its own.
/visio/{token}?relay=1 forces every packet through the relay: the way to check the relay itself.
No STUN server is configured by default (stun_urls): none of a third party's is asked. The
compliance check "TURN relay" reads the direct gateway's options (Visio\Gateways::turn()); with
another gateway there is no relay of yours to check.
Another gateway
composer require omnimeet/jitsi
# config/packages/omnimeet.yaml (Omnimeet\Bridge\Symfony\OmnimeetBundle registered)
omnimeet:
gateways:
jitsi:
factory: jitsi
options: { domain: meet.example.org, app_id: '%env(JITSI_APP_ID)%', app_secret: '%env(JITSI_APP_SECRET)%' }
# config/packages/office.yaml
office:
visio:
gateway: jitsi
The room's page then shows the provider's frame, driven by the gateway's script, with the same
buttons, the same waiting room and the same end (public/js/visio-frame.js):
- the third party is named before anything is loaded from it (
visio.provider.notice), and nothing is loaded before the visitor enters; - with omnibase/consent on the site, it
waits for the visitor's yes: the page declares the feature
VISIOin the consent panel (Consent.use('VISIO', ...)), and entering is refused until it is on; - the response's
Permissions-Policylets the provider's origin use the camera and the microphone; your Content-Security-Policy must allow it inframe-srcandscript-src; - the host's "end" closes the room here whatever the provider does - the pages are told at their next "I am here" and drop the frame - and asks the provider's interface to end the conference where it can.
A gateway that only gives an address (Access of kind link) is rendered as a button, shown to
the guest once the host is there.
Without glitchr/omnimeet's bundle
An application that does not register Omnimeet\Bridge\Symfony\OmnimeetBundle keeps its video
room: DependencyInjection\Compiler\VisioGatewayPass makes the registry with the direct gateway
alone, its relay read from office.visio.{turn_secret, turn_urls, stun_urls, turn_ttl} - the
former place of these options, still read for this version (a deprecation notice says where they
go). Base\Office\Visio\TurnCredentials and Base\Office\Visio\Signaling remain as deprecated
names for Omnimeet\Direct\TurnCredentials and Omnimeet\Direct\Signaling.
Migration
office_visio_room gets two nullable columns, gateway and reference, and an index on
reference (doctrine:migrations:diff). A room made before them is opened on the configured
gateway the first time it is asked for.
The waiting room
The guest who is there while the host is not shows in the agenda (the appointment is outlined) and in
the dashboard widget office_waiting_room - whatever the gateway: it is the room's presence.
What it is not
A recording, a group call (the direct gateway takes two), or a teleconsultation solution listed by a health authority - a regime says what its trade requires (omnibase/health says the built-in video is not ANS-listed); such a solution would be one more gateway.
Propose a change docs/visio.md
What a regime builds on
A regime - omnibase/notary, omnibase/lawyer, omnibase/health - is the
profession's own rules on top of omnibase/office. What two regimes need is
written here once; a regime keeps what is its alone (its texts, its
compliance checks, its table names).
Fields of practice: Area
Base\Office\Entity\Area is a mapped superclass: the name, the slug, a
summary, the page's text, whether it is shown and where, who of the team
follows it, and the methods around them (getParagraphs(),
getPublicText() for the wording check, getItemsText() / setItemsText()
for a form that edits the list one a line). A regime's entity extends it,
names its table and maps the two things whose names are its own - the list
of what is done in the field, and the join table of the members:
namespace Base\Notary\Entity;
#[ORM\Entity(repositoryClass: AreaRepository::class)]
#[ORM\Table(name: 'notary_area')]
class Area extends \Base\Office\Entity\Area
{
#[ORM\Column(type: 'json')]
protected array $deeds = []; // a lawyer's: $matters
#[ORM\ManyToMany(targetEntity: Member::class)]
#[ORM\JoinTable(name: 'notary_area_member')]
protected Collection $members;
public function getItems(): array { return $this->deeds; }
public function setItems(?array $items): static { $this->deeds = self::lines($items); return $this; }
}
Its repository extends Base\Office\Repository\AreaRepository
(findActive(), findOneActiveBySlug(), findForMember()) and names the
entity in its constructor.
Base\Notary\Entity\Area and Base\Lawyer\Entity\Area were two copies of
that class; they extend it since 2026-10-05 with the same tables, columns and
methods (getDeeds(), getMatters()...): no migration, nothing to change in
an application.
The wording guard: Wording
Base\Office\Guard\Wording::scan($text, $extra, $patterns) finds what the
communication of a regulated practice must not carry - comparison with
colleagues, disparagement, advertising's superlatives ("le meilleur",
"numéro 1", "moins cher", "nos concurrents", "résultat garanti"...) - on the
text lowered and stripped of its accents and tags (Wording::plain()).
$extra: expressions of the site's own, matched as they are written;$patterns: a regime's own,label => regular expression, after the common ones. The lawyers add the mention of former judicial functions.
A regime keeps its own Wording (Base\Notary\Guard\Wording,
Base\Lawyer\Guard\Wording: the same scan(), isClean(), plain() as
before) citing its texts, and delegates here. A list of expressions helps the
person who writes; it judges nothing.
A first appointment asked without an account
A practice's clients are invited, and the booking pages ask to sign in: someone
new has no way in. Base\Office\Booking\AppointmentRequests is that way -
what can be asked for (offer(): the appointment types in request mode open to
everyone, and who offers them), the form as it opens (prepare(): the member a
link named with ?avec=their-slug, the only type), and send(), which
registers an appointment in request mode through Booker::request() under the
name, the e-mail and the phone given - the few words encrypted, the e-mails
those of any request.
The form is Base\Office\Form\AppointmentRequestType on
Base\Office\Form\Model\AppointmentRequest: the type, with whom, name,
e-mail, phone, a message, a field that only robots fill, and omnibase's
PrivacyType (the data-protection notice and its box). Its labels are keys
under label_prefix (@office.appointment_request).
- The bundle's own page:
office.booking.public_request: trueopens/rendez-vous/demande(office_request,@Office/client/request.html.twig, oroffice.templates.request). Off by default. - A regime's page extends
AbstractRequestControllerand answers withrespond(): its route, its template, its words.
class RequestController extends \Base\Office\Controller\Client\AbstractRequestController
{
#[Route('/rendez-vous/demande', name: 'notary_request', methods: ['GET', 'POST'], priority: 10)]
public function request(Request $request): Response
{
return $this->respond($request, '@Notary/client/request.html.twig', 'notary', '@notary.request');
}
}
The template receives form, sent, available and office. omnibase/notary
and omnibase/lawyer each had the form, its model and the whole action; they
keep their route (notary_request, lawyer_request), their page and their
translations.
The practice's name
office.vocabulary.practice (Configuration) gives every text
{the_practice}, {at_the_practice}, {of_the_practice}...: a regime's own
translations may use them rather than write "le cabinet" or "l’étude".
Propose a change docs/regimes.md
Configuration reference
The office tree
Dumped with Symfony's YamlReferenceDumper from
Base\Office\DependencyInjection\OfficeConfiguration. The keys are
explained where they act: the practice's words and templates in
omnibase/office, the booking in
Booking, the vault in Share, the video room and its
gateway in Visio.
office:
# The office's timezone: schedules are written in it, slots are given in it.
timezone: Europe/Paris
# Where a contact request is sent.
recipient: '%env(MAILER_CONTACT)%'
# The From of the e-mails the office sends (confirmations, reminders, "a document awaits").
sender: '%env(MAILER_TECHNICAL)%'
# How the practice is named in the bundle's texts ({the_practice}, {at_the_practice}, {of_the_practice}...). practice: cabinet | etude | office | maison_de_sante, or its forms: {practice: {name: centre de soins, the: le centre de soins, at: au centre de soins, of: du centre de soins}}.
vocabulary:
# Default:
practice: cabinet
contact:
# The warning above the contact form, a translation key: the regime chooses it (health: no medical data here, use your space).
notice: '@office.contact.notice'
retention_months: 12
# The public pages, a regime may give its own.
templates:
team: '@Office/client/team.html.twig'
member: '@Office/client/member.html.twig'
hours: '@Office/client/hours.html.twig'
contact: '@Office/client/contact.html.twig'
# The page of a first appointment asked without an account (booking.public_request).
request: '@Office/client/request.html.twig'
# A partial included at the top of the client's pages (appointments, documents): the regime's menu of the space.
space_nav: null
# The template the pages extend.
layout: base.html.twig
booking:
enabled: true
# Opens /rendez-vous/demande (office_request): a first appointment asked without an account, registered in request mode. A regime with its own page (notary, lawyer) does not need it.
public_request: false
# Minutes: a slot closer than this cannot be booked online.
min_notice: 120
# Days ahead a slot can be booked online.
horizon: 60
# Hours before the appointment the client may still cancel online.
cancel_until: 24
# Minutes between two slot starts; 0: the appointment type's duration plus its buffer.
step: 0
# When reminders are sent: hours before the appointment.
reminders:
# Defaults:
- 24
- 2
# The client's list of appointments, linked from the e-mails.
space_route: office_space_appointments
share:
enabled: true
# The Flysystem storage the encrypted files are written to: private, never under public/.
storage: local.share
# Base64 of 32 bytes, from the secrets vault. Missing: nothing is stored or read (fail closed).
master_key: '%env(default::SHARE_MASTER_KEY)%'
max_size: 20971520
mime_types:
# Defaults:
- application/pdf
- image/jpeg
- image/png
- image/webp
- image/heic
- text/plain
# Where the "a document awaits" e-mail sends the recipient (sign-in required).
space_route: office_space_documents
visio:
enabled: true
# Minutes before the appointment the room opens.
open_before: 10
# Minutes after its planned end the room closes.
close_after: 30
# What carries the calls: the name of a glitchr/omnimeet gateway (omnimeet.gateways.<name>). "direct" - from browser to browser, omnimeet/direct - is declared for you.
gateway: direct
# Whose name a gateway is given, to show the other: the host's - the member's, already public on the site - or none. The client's is never given: a provider would learn who consults.
names: host # One of "host"; "none"
# Deprecated alias of omnimeet.gateways.direct.options.turn_secret - coturn's static-auth-secret: the temporary TURN credentials are signed with it.
turn_secret: '%env(default::TURN_SECRET)%'
# Deprecated alias of omnimeet.gateways.direct.options.turn_urls.
turn_urls: []
# Deprecated alias of omnimeet.gateways.direct.options.stun_urls.
stun_urls: []
# Deprecated alias of omnimeet.gateways.direct.options.turn_ttl.
turn_ttl: 3600
# host:port the compliance check asks instead of the first TURN URL, when the server does not reach the relay by the address browsers use (a container's service name).
turn_probe: null
Each key is a container parameter (%office.timezone%,
%office.templates.layout%, %office.booking.space_route%...).
Propose a change docs/configuration.md
Reference
Routes
| Name | Path | Methods | Access |
|---|---|---|---|
office_team |
/equipe |
GET | everyone |
office_member |
/equipe/{slug} |
GET | everyone |
office_hours |
/horaires-acces |
GET | everyone |
office_contact |
/contact |
GET, POST | everyone |
office_request |
/rendez-vous/demande |
GET, POST | everyone, when booking.public_request is on |
office_booking |
/rendez-vous |
GET | everyone |
office_booking_member |
/rendez-vous/{slug} |
GET | everyone |
office_booking_slots |
/rendez-vous/{slug}/{type} |
GET, POST | everyone |
office_booking_confirm |
/rendez-vous/{slug}/{type}/{start} |
GET, POST | ROLE_USER |
office_booking_done |
/rendez-vous/confirmation/{id} |
GET | ROLE_USER |
office_booking_ics |
/rendez-vous/{id}.ics |
GET | ROLE_USER |
office_booking_cancel |
/rendez-vous/annuler/{token} |
GET, POST | the holder of the cancel token (48 hex characters) |
office_space_appointments |
/espace/rendez-vous |
GET | ROLE_USER |
office_space_cancel |
/espace/rendez-vous/{id}/annuler |
POST | ROLE_USER |
office_space_documents |
/espace/documents |
GET | ROLE_USER |
office_document_download |
/espace/documents/{id}/telecharger |
GET | ROLE_USER, the vault's audience |
office_document_deposit |
/espace/documents/deposer |
POST | ROLE_USER |
office_document_revoke |
/espace/documents/{id}/retirer |
POST | ROLE_USER |
office_invitation |
/invitation/{token} |
GET, POST | the invited client |
office_visio_room |
/visio/{token} |
GET | ROLE_USER |
office_visio_engine |
/visio/{token}/engine.js |
GET | ROLE_USER |
office_visio_ice |
/visio/{token}/ice |
GET | ROLE_USER |
office_visio_signal_send |
/visio/{token}/signal |
POST | ROLE_USER |
office_visio_signal_poll |
/visio/{token}/signal |
GET | ROLE_USER |
office_visio_end |
/visio/{token}/fin |
POST | ROLE_USER |
office_visio_document |
/visio/{token}/document |
POST | ROLE_USER |
office_admin_agenda |
/admin/agenda |
GET | ROLE_STAFF |
office_admin_agenda_book |
/admin/agenda |
POST | ROLE_STAFF |
office_admin_agenda_action |
/admin/agenda/{id}/{action} |
POST | ROLE_STAFF; done, no_show, cancel, confirm, move |
office_admin_documents |
/admin/documents |
GET | ROLE_STAFF |
office_admin_documents_send |
/admin/documents |
POST | ROLE_STAFF |
office_admin_documents_revoke |
/admin/documents/{id}/revoke |
POST | ROLE_STAFF |
office_admin_access_log |
/admin/access-log |
GET | ROLE_ADMIN |
The video room's token is 43 characters ([A-Za-z0-9_-]). The member CRUD has
an action registry (/{entityId}/registry) a regime fills in (the Annuaire
Santé for health).
Twig functions and filters
| Name | Kind | Returns |
|---|---|---|
office_main() |
function | the main office |
office_offices() |
function | every office, in order |
office_team() |
function | the visible members |
office_walk_ins() |
function | the active walk-in hours |
office_next_slots(member, type, count = 3) |
function | the member's next free slots of that type |
office_room(appointment) |
function | the video room of an active video appointment, or null |
office_unread_documents(user) |
function | how many documents the user has not opened |
office_compliance() |
function | the compliance checks' results |
office_person(user) |
function | the member's display name, else the account's |
office_layout() |
function | office.templates.layout |
office_space_nav() |
function | office.templates.space_nav |
office_timezone() |
function | office.timezone |
office_document_title |
filter | a document's title, decrypted for whoever may read it |
office_document_filename |
filter | its file name |
office_reason |
filter | an appointment's reason, decrypted |
office_decrypt |
filter | a sealed text, decrypted |
office_local |
filter | a date in office.timezone |
Events
| Event | Name | Carries | Listened to by |
|---|---|---|---|
AppointmentEvent::BOOKED |
office.appointment.booked |
the appointment, its cancel token, its series, whether the staff booked it | BookingMailer |
AppointmentEvent::REQUESTED |
office.appointment.requested |
the same | BookingMailer |
AppointmentEvent::CONFIRMED |
office.appointment.confirmed |
the same | BookingMailer |
AppointmentEvent::MOVED |
office.appointment.moved |
the same | BookingMailer |
AppointmentEvent::CANCELLED |
office.appointment.cancelled |
the same | BookingMailer |
AppointmentEvent::REMINDER |
office.appointment.reminder |
the appointment, the hours before | BookingMailer |
ClientAccountEvent::CREATED |
office.client.created |
the user, the invitation | a regime (health makes the patient's profile) |
DocumentEvent::DEPOSITED |
office.document.deposited |
the document, whether to notify | ShareMailer |
DocumentEvent::REVOKED |
office.document.revoked |
the document |
A regime adds its listeners at a higher priority (omnibase/health names the
appointment after the patient's profile on BOOKED and REQUESTED).
Commands
| Command | Does |
|---|---|
office:booking:remind |
sends the reminders that are due, each once (Booking) |
office:booking:close-day |
closes past appointments (done; a video one never joined: no-show) and purges the closed video rooms |
Enums
| Enum | Cases |
|---|---|
AppointmentStatus |
requested, confirmed, cancelled, no_show, done |
AppointmentSource |
online, phone, staff |
BookingMode |
slots, request |
BookableBy |
everyone, known, staff |
Channel |
in_person, video, home, phone |
DocumentKind |
result, prescription, report, letter, upload, other |
AccessAction |
view, download, upload, revoke, share, denied |
Extension points
| Tag or interface | For |
|---|---|
office.beneficiary_provider (Booking\BeneficiaryProviderInterface) |
whom a client books for besides themselves |
Share\AudienceResolverInterface |
who of the practice reads a document, asked by DocumentVoter (Share) |
ComplianceCheckInterface |
a regime's own checks on the compliance tile (office_compliance()) |
OfficeLegalSection |
the practice's legal settings on omnibase/admin's settings page |
Propose a change docs/reference.md
Testing
Running the suite
vendor/bin/phpunit
Inside a host application:
php vendor/bin/phpunit -c vendor/omnibase/office/phpunit.xml.dist
omnisense/omnibase-harness installs neither omnibase/office nor omnisense/omnimeet. Run there without a kernel, with this checkout and omnisense/omnimeet's (core, direct, jitsi) on the autoloader, the suite gives:
OK (61 tests, 190 assertions)
What the tests check
| Test | Checks |
|---|---|
Booking\SlotFinderTest |
twenty-minute slots; the buffer, and the last slot inside the schedule; an appointment held takes its slot and its buffer; an absence removes what it overlaps; the clients' notice and horizon, not the staff's; a schedule holds only its types and its validity; the two changes of clock |
Booking\AppointmentRequestsTest |
what is offered in request mode and by whom; the form opens on the member named and the only type; who takes the request; the request registered as given; nobody to take it is a booking error |
Controller\CrudRequiredFieldsTest |
omnibase/admin's CrudFieldsTestCase on the screens |
DependencyInjection\VisioGatewayTest |
the former keys read as written, the last file winning; with omnisense/omnimeet's bundle the direct gateway declared under the application's own registry; without it, the registry made here with the direct gateway alone; the bundle's own registry left alone |
DependencyInjection\VocabularyTest |
the presets; a practice's own forms; an unknown preset says which exist; the words handed to the translator; every placeholder of the texts is one of them |
Entity\AreaTest |
the slug from the name; the list edited one a line; paragraphs and public text; members |
Guard\WordingTest |
comparison and advertising found; the trade's plain words not; the site's expressions and a regime's patterns added |
Share\CipherTest |
a file comes back as it went; nothing of it readable once stored; an altered or truncated file refused; another master key does not open it; without a key nothing is stored; a text sealed and opened |
Visio\RoomTest, Visio\RoomsTest |
a room opens a little before and closes a while after; it keeps the gateway that opened it and its reference there; what a provider is told (a role, an opaque identifier, the member's name only); closing ends the room and purges the handshake |