A Progressive Web App (PWA) that lets patients log in and view their records and communicate with their clinic via in-app messaging with push notifications. Serves both GP and Dental sector patients.
URL: https://patient.veripath.co.uk
Tech Stack:
patient_portal app in the main Django project)patient-portal-pwa Docker container (nginx:alpine serving built files)User's Browser
↓
patient.veripath.co.uk (nginx on host, port 443)
↓
patient-portal-pwa (Docker container, port 8081)
├── / (static files — Vue SPA)
└── /api/* (proxy_pass → gp_booking_app:8000)
└── Django REST Framework
└── patient_portal app
├── /api/patient/dashboard/ → aggregated summary
├── /api/patient/appointments/ → GP appointments
├── /api/patient/dental/appointments/ → Dental appointments
├── /api/patient/clinical-notes/ → GP clinical notes
├── /api/patient/dental/clinical-notes/→ Dental clinical notes
├── /api/patient/prescriptions/ → GP prescriptions
├── /api/patient/dental/transactions/ → Dental payment history
├── /api/patient/profile/ → Patient profile
└── /api/patient/messaging/* → Conversations + messages + push
The PWA uses DRF's TokenAuthentication. Patients obtain a token by POSTing to /api-token-auth/ with username and password. The token is stored in localStorage.
curl -X POST https://patient.veripath.co.uk/api-token-auth/ \
-H "Content-Type: application/json" \
-d '{"username":"patient_username","password":"patient_password"}'
# Returns: {"token": "abc123..."}
All API calls include the header: Authorization: Token abc123...
The patient subdomain is whitelisted in the tenancy middleware (NON_TENANT_SUBDOMAINS in tenancy/middleware.py) so the Patient Portal bypasses tenant-based routing.
Staff can reset a patient's password from the receptionist patient detail page:
URL: /dashboard/receptionist/patients/<id>/
A red "Reset Password" button generates a random 12-character temporary password via secrets.token_urlsafe(9). The password is displayed once in a yellow banner with a copy-to-clipboard button. Staff share this with the patient, who can change it in their Profile after logging in.
View: ReceptionistPatientResetPasswordView in dashboards/views/dashboards.py
Template: templates/dashboards/receptionist_patient_detail.html
URL pattern: receptionist/patients/<int:pk>/reset-password/
Two-way secure messaging between clinic staff and patients:
test-client.gp.veripath.co.ukAll in patient_portal/models.py:
| Model | Fields | Purpose |
|---|---|---|
Conversation |
patient (FK User), clinic (FK Practice), subject, created_at, updated_at |
Groups messages between a patient and a clinic |
Message |
conversation (FK), sender (FK User), body (text), is_read (bool), created_at |
Individual messages in a thread |
PushSubscription |
patient (FK User), endpoint, auth_key, p256dh_key, user_agent, created_at |
Browser push subscription per device |
All under /api/patient/messaging/. Require Authorization: Token <token> header. All enforce IsPatientUser permission.
| Endpoint | Method | Description |
|---|---|---|
/api/patient/messaging/conversations/ |
GET | List patient's conversations with last message preview + unread count |
/api/patient/messaging/conversations/<id>/messages/ |
GET | Paginated message history |
/api/patient/messaging/conversations/<id>/messages/ |
POST | Patient sends a reply |
/api/patient/messaging/push/subscribe/ |
POST | Register browser push subscription |
/api/patient/messaging/push/unsubscribe/ |
POST | Remove push subscription |
/api/patient/messaging/unread-count/ |
GET | Unread badge count for nav |
All under /api/staff/messaging/. Use session authentication (Django session cookies).
| Endpoint | Method | Description |
|---|---|---|
/api/staff/messaging/conversations/ |
GET | List conversations for staff's practice |
/api/staff/messaging/patients/<id>/messages/ |
GET | Message history for a patient conversation |
/api/staff/messaging/patients/<id>/messages/ |
POST | Staff sends a message to patient (triggers push) |
Staff views: StaffConversationViewSet and StaffMessageViewSet in patient_portal/views_staff.py.
The _resolve_practice_id() helper resolves the clinic context from (in order):
StaffRoleRecord for the logged-in usercurrent_practice_idFile: patient_portal/services/push_service.py
Uses pywebpush library with VAPID keys. When a staff member sends a message:
Messages is saved to the databasesend_message_push_notification.delay() enqueues a Celery tasknotify_patient() which looks up all PushSubscription records for the patientsend_push() which sends a Web Push via the browser's push service (FCM on Android)VAPID keys are stored in the Django environment config (env.production):
WEBPUSH_VAPID_PRIVATE_KEYWEBPUSH_VAPID_PUBLIC_KEYWEBPUSH_CLAIM_EMAILThe URL-safe base64 public key is also set in patient-portal-pwa/src/main.js as VAPID_PUBLIC_KEY for the browser's pushManager.subscribe() call.
File: patient-portal-pwa/src/sw.js (custom SW built with injectManifest strategy)
Capabilities:
push event — displays a notification with a "Reply" action (text input) + vibrate patternnotificationclick event — handles inline reply by POSTing to /api/patient/messaging/conversations/<id>/messages/ with the auth token from IndexedDB; clicking the notification body opens the conversation in the PWAmessage event — receives the auth token from the main thread and stores it in IndexedDB for the SW's notification reply handlerskipWaiting() + clients.claim() for immediate SW updates on deployOn login (in patient-portal-pwa/src/main.js):
Notification.permission → request if neededpushManager.subscribe() with the VAPID public key/api/patient/messaging/push/subscribe//api/patient/messaging/push/unsubscribe/The auth token is bridged to the SW via postMessage() in App.vue.
New views in patient-portal-pwa/src/views/:
ConversationsView.vue — conversation list with clinic name, last message preview, unread badge, 15s auto-refreshConversationDetailView.vue — chat bubble UI with auto-scroll, timestamped messages, 5s polling, text input + send buttonNew store: patient-portal-pwa/src/stores/messages.js — Pinia store with fetchConversations(), fetchMessages(), sendMessage(), fetchUnreadCount()
Nav update: Messages nav item added in App.vue between Records and Images, with unread count badge (red bubble, polls every 30s).
Views: MessagingDashboardView + MessagingDetailView in dashboards/views/messaging.py
Templates:
templates/dashboards/staff_messaging_list.html — conversation list for staff with unread badgestemplates/dashboards/staff_messaging_detail.html — chat UI with 5s polling, send-on-Enter, CSV-protected via sessionNavigation:
/dashboard/receptionist/messaging//dashboard/receptionist/messaging/<patient_id>/All read-only endpoints require Authorization: Token <token> header. All enforce IsPatientUser permission.
| Endpoint | Method | Description |
|---|---|---|
/api/patient/dashboard/ |
GET | Aggregated dashboard (upcoming appts, recent notes, active Rx, recent transactions) |
/api/patient/appointments/ |
GET | GP appointments for current patient |
/api/patient/appointments/{id}/ |
GET | Single GP appointment detail |
/api/patient/dental/appointments/ |
GET | Dental appointments |
/api/patient/clinical-notes/ |
GET | GP clinical notes |
/api/patient/clinical-notes/{id}/ |
GET | Single clinical note detail |
/api/patient/dental/clinical-notes/ |
GET | Dental clinical notes |
/api/patient/prescriptions/ |
GET | GP prescriptions |
/api/patient/dental/transactions/ |
GET | Dental payment history |
/api/patient/profile/ |
GET | Patient personal details |
/api/patient/messaging/conversations/ |
GET | Patient's conversations with unread counts |
/api/patient/messaging/conversations/{id}/messages/ |
GET | Message history |
/api/patient/messaging/conversations/{id}/messages/ |
POST | Send reply |
/api/patient/messaging/push/subscribe/ |
POST | Register push subscription |
/api/patient/messaging/push/unsubscribe/ |
POST | Remove push subscription |
/api/patient/messaging/unread-count/ |
GET | Unread count for nav badge |
/api/staff/messaging/conversations/ |
GET | Staff's practice conversations |
/api/staff/messaging/patients/{id}/messages/ |
GET/POST | Staff message history / send |
patient.veripath.co.uk → server IP/opt/deploy-patient-portal.sh
This script:
npm run build)docker build -t patient-portal-pwa .)/var/www/html/gp_booking_app/gp_booking_app Django containercd /root/work/patient-portal-pwa
npm run build
docker build -t patient-portal-pwa .
docker stop patient-portal-pwa && docker rm patient-portal-pwa
docker run -d --name patient-portal-pwa --restart unless-stopped -p 8081:80 patient-portal-pwa
docker network connect gp_booking_app_gp_booking_network patient-portal-pwa
cp -r /root/work/dental_booking_app/patient_portal /root/gp_booking_app_dev/patient_portal
cp -r /root/work/dental_booking_app/patient_portal /var/www/html/gp_booking_app/patient_portal
cp /root/work/dental_booking_app/dashboards/views/messaging.py /root/gp_booking_app_dev/dashboards/views/
cp /root/work/dental_booking_app/templates/dashboards/staff_messaging_*.html /root/gp_booking_app_dev/templates/dashboards/
docker restart gp_booking_app
Generate once, add to env.production and src/main.js:
cd /root/work/dental_booking_app
./venv/bin/python -c "
from py_vapid import Vapid
v = Vapid()
v.generate_keys()
print('VAPID PRIVATE KEY:', v.private_pem().decode().strip())
print('VAPID PUBLIC KEY:', v.public_pem().decode().strip())
"
For the frontend, encode the public key as URL-safe base64:
./venv/bin/python -c "
from py_vapid import Vapid
from cryptography.hazmat.primitives import serialization
import base64
v = Vapid()
v.generate_keys()
raw = v.public_key.public_bytes(encoding=serialization.Encoding.X962, format=serialization.PublicFormat.UncompressedPoint)
print(base64.urlsafe_b64encode(raw).rstrip(b'=').decode())
"
cd /root/work/patient-portal-pwa
npm run dev # Dev server on :5173 with API proxy
npm run build # Production build to dist/
cd /root/work/dental_booking_app
# Edit files in patient_portal/, dashboards/, templates/
# Copy to deployment (see above)
docker restart gp_booking_app
/root/work/patient-portal-pwa/)src/
├── main.js # Vue boot, Pinia, Router, push subscription on login
├── App.vue # Shell with nav bar, auth token bridge to SW
├── sw.js # Custom service worker (push + reply + notificationclick)
├── router/index.js # Route definitions + auth guard
├── stores/auth.js # Auth store (token, login, logout, apiFetch)
├── stores/messages.js # Messages store (conversations, messages, unread)
├── assets/main.css # Global styles
├── views/
│ ├── LoginView.vue # Login form
│ ├── DashboardView.vue # Aggregated dashboard
│ ├── AppointmentsView.vue
│ ├── AppointmentDetailView.vue
│ ├── ClinicalNotesView.vue
│ ├── ClinicalNoteDetailView.vue
│ ├── PrescriptionsView.vue
│ ├── PaymentsView.vue
│ ├── ImagesView.vue
│ ├── ProfileView.vue
│ ├── ConversationsView.vue # Messaging: conversation list
│ └── ConversationDetailView.vue # Messaging: chat interface
Dockerfile # nginx:alpine serving dist/
nginx.conf # PWA nginx config (static + API proxy)
vite.config.js # Vite + PWA plugin config (injectManifest)
/root/work/dental_booking_app/)patient_portal/
├── permissions.py # IsPatientUser permission
├── serializers.py # All patient-facing serializers (incl. messaging)
├── views.py # Patient ViewSets + messaging + push subscribe
├── views_staff.py # Staff messaging API views
├── urls.py # API route definitions (patient + staff)
├── services/
│ └── push_service.py # Web Push delivery (pywebpush + VAPID)
├── tasks.py # Celery task for async push notification sending
└── migrations/
└── 0001_initial.py # Conversation, Message, PushSubscription tables
dashboards/views/
├── dashboards.py # ReceptionistPatientResetPasswordView
└── messaging.py # MessagingDashboardView, MessagingDetailView
templates/dashboards/
├── receptionist_dashboard.html # "Messaging" quick action button
├── receptionist_patient_detail.html # "Reset Password" button + temp password banner
├── staff_messaging_list.html # Staff conversation list
└── staff_messaging_detail.html # Staff chat interface with polling
dsp_clinic/
├── settings.py # VAPID settings, infrastructure in INSTALLED_APPS
└── urls.py # api-token-auth + patient_portal include
tenancy/
└── middleware.py # 'patient' added to NON_TENANT_SUBDOMAINS
skipWaiting() + clients.claim() for seamless PWA updatesinfrastructure to INSTALLED_APPS (was crash-looping the container on startup)patient_portal.urls include to main dsp_clinic/urls.py (endpoints weren't accessible)api-token-auth/ route to dsp_clinic/urls.py (login endpoint was missing, causing all patient logins to fail with 404)patient subdomain to NON_TENANT_SUBDOMAINS in tenancy middleware (previously returned "Unknown tenant or partner organisation")PatientImage modelrelated_name clash between patient_portal.Message.sender and clinical_data.Message.sender