In-app notifications
The Vetty in-app notification channel sits alongside SMS, email, and WhatsApp as a first-class delivery mechanism for tenant → client communication. It's HTTP-pull rather than push — messages persist on the backend the moment they're sent, and every recipient sees them the next time their app or SPA opens their inbox.
What it's for
- Clinic-wide announcements (holiday hours, closures, vaccination drives) to the entire client base or a hand-picked subset.
- Recent-visitor follow-ups (post-visit check-in, seasonal reminders scoped to clients seen in the last N days).
- Internal staff broadcasts (shift changes, protocol updates, "please check the vaccine fridge" — visible in the vetdoctor app and the admin SPA inbox but never to pet owners).
- Targeted messages to specific clients or specific pets (the owner + active caregivers all receive it).
Where it appears
| Surface | Role of the surface |
|---|---|
| Admin SPA — Notifications module (Operations sidebar) | Compose form + sent-history list. Retract action soft-deletes the canonical row without touching what recipients already read. |
| Admin SPA — top bar bell | Personal inbox for the logged-in staff user. Polls unread count every 60 s. Popover shows the 20 newest messages with mark-read + delete. |
| vetdoctor Flutter app — megaphone icon | Compose flow, mirrors the admin SPA. |
| vetdoctor Flutter app — bell icon | Personal inbox, same shape as the admin SPA's popover but as a full screen. |
| vetyy Flutter app — bell in app bar | Owner-side inbox. Swipe-to-delete + deeplink routing (vetty://pet/<id>, vetty://invoice/<id>, etc.). |
Audience picker
The compose form supports five audience shapes:
| Kind | Who receives it |
|---|---|
| All clients | Every pet owner registered with the clinic. |
| Selected clients | Handpicked from a typeahead client search. |
| Selected pets | Handpicked from a typeahead pet search. For each pet, the message goes to the primary owner AND any active caregivers. |
| Recent visitors | Clients who had at least one appointment in the last N days (default 90, configurable at compose time between 1 and 365) that wasn't cancelled or a no-show. |
| Internal staff | Every active tenant_user row — vets, receptionists, pharmacy staff, admins. Delivered to the admin SPA and vetdoctor inboxes, never to pet owners. |
Selecting an audience resolves it to a concrete list of recipient user
IDs at send-time. The resulting set is written into
tenant_notification_recipients in a single transaction alongside the
canonical tenant_notifications row. That means the recipient list is
frozen at send-time — adding a new client to the clinic tomorrow
doesn't retroactively include them in yesterday's "All clients"
broadcast.
Deeplink support
The compose form has an optional deeplink field. When a recipient taps the notification in the vetyy app, they're routed based on the URI:
vetty://pet/123— Pet detail screen for pet id 123.vetty://invoice/456— Invoice detail screen.vetty://medications/12— Medications screen for pet 12.https://…— Opens in the system browser.
Empty deeplink = the tap just marks the notification read + closes the inbox item.
Permission model
Route middleware and role checks work together to keep the surface functional even against permission-catalog drift on existing tenants:
- Reading the sent-history list is not gated at the route level.
The list only contains tenant-scoped data (already gated by
tenant.resolve), and reading isn't privileged. - Sending + retracting is gated by
notification.sendOR — as a drift-tolerant fallback — the caller's tenant role beingtenant_adminorreceptionist. The role fallback exists because the permission catalog gainednotification.*after many tenants had been seeded; without it, un-reseeded tenants would 403 forever. - Personal inbox (
GET /me/notifications/*) requires onlyauth:sanctum. The controller filters byuser_idso it naturally serves both owner and staff.
Full re-sync (recommended on any deploy):
docker compose exec vetty-api php artisan db:seed --class=RolePermissionSeeder
deploy.sh runs this automatically now — see the deployment doc.
API surface
| Method | Path | Purpose |
|---|---|---|
| GET | /tenant/{tenant}/notifications | Paginated sent history |
| POST | /tenant/{tenant}/notifications | Send new announcement |
| DELETE | /tenant/{tenant}/notifications/{id} | Retract (soft-delete canonical row) |
| GET | /me/notifications | Personal inbox (paginated, undeleted) |
| GET | /me/notifications/unread-count | Bell badge counter |
| POST | /me/notifications/{id}/read | Mark one as read |
| POST | /me/notifications/mark-all-read | Mark every unread as read |
| DELETE | /me/notifications/{id} | User-side soft delete |
/me/notifications/* is a tenant-agnostic alias reused by both the
owner app (which used to call /owner/me/notifications) and the
staff surfaces.
Data model
Two tables:
tenant_notifications— the canonical broadcast. One row per compose action. Holds title, body, deeplink, audience JSON, sender user id, sent_at, and denormalised recipient_count for O(1) UI display.tenant_notification_recipients— one row per (notification, user) pair, plus optional pet id for pet-targeted broadcasts. Has its ownread_atanddeleted_atso each recipient can manage their inbox independently without touching the canonical row.
Insertion is chunked at 500 rows per statement so a 5000-client
"All clients" broadcast stays under MySQL's max_allowed_packet.
Not push — HTTP-pull
The channel deliberately does not use FCM / APNs at this stage. When a user opens their app or SPA, the bell widget polls the unread-count endpoint every 60 seconds while the app is foreground. Notifications land in the inbox on the next poll after they're sent, not the instant they're sent.
Full push delivery is on the roadmap but was descoped for the initial launch: FCM/APNs setup requires per-platform certificate management, a token registry, and a delivery service, all of which are separate from the announcement pipeline itself. The current design is forward-compatible — when push lands, it just fires alongside the existing DB write.