Skip to main content

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

SurfaceRole 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 bellPersonal 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 iconCompose flow, mirrors the admin SPA.
vetdoctor Flutter app — bell iconPersonal inbox, same shape as the admin SPA's popover but as a full screen.
vetyy Flutter app — bell in app barOwner-side inbox. Swipe-to-delete + deeplink routing (vetty://pet/<id>, vetty://invoice/<id>, etc.).

Audience picker

The compose form supports five audience shapes:

KindWho receives it
All clientsEvery pet owner registered with the clinic.
Selected clientsHandpicked from a typeahead client search.
Selected petsHandpicked from a typeahead pet search. For each pet, the message goes to the primary owner AND any active caregivers.
Recent visitorsClients 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 staffEvery 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.

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.send OR — as a drift-tolerant fallback — the caller's tenant role being tenant_admin or receptionist. The role fallback exists because the permission catalog gained notification.* after many tenants had been seeded; without it, un-reseeded tenants would 403 forever.
  • Personal inbox (GET /me/notifications/*) requires only auth:sanctum. The controller filters by user_id so 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

MethodPathPurpose
GET/tenant/{tenant}/notificationsPaginated sent history
POST/tenant/{tenant}/notificationsSend new announcement
DELETE/tenant/{tenant}/notifications/{id}Retract (soft-delete canonical row)
GET/me/notificationsPersonal inbox (paginated, undeleted)
GET/me/notifications/unread-countBell badge counter
POST/me/notifications/{id}/readMark one as read
POST/me/notifications/mark-all-readMark 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 own read_at and deleted_at so 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.