Skip to content

omnibase/office

A professional practice on omnibase - the office, its team, its hours and contact (core), appointments by slot or by request (Booking), an encrypted document vault per recipient with its access log (Share), a video room with a waiting room, its calls carried by a omnisense/omnimeet gateway - from browser to browser by default (Visio). Shared by the regulated professions: health, notaries, lawyers.

Floor
Prepared structure · Omnibase
Version
main
Verified for real
Its README does not say
Licence
MIT
composer require omnibase/office:dev-main

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 on office.visio.gateway and keeps its reference.
  • The page asks Rooms::access($room, $user) - the gateway's Join - and renders the Access: 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: none to 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 VISIO in the consent panel (Consent.use('VISIO', ...)), and entering is refused until it is on;
  • the response's Permissions-Policy lets the provider's origin use the camera and the microphone; your Content-Security-Policy must allow it in frame-src and script-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: true opens /rendez-vous/demande (office_request, @Office/client/request.html.twig, or office.templates.request). Off by default.
  • A regime's page extends AbstractRequestController and answers with respond(): 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

Propose a change docs/testing.md