Reflects the switch from hard-coded FRONTEND_BASE_URL/utils.deep_links to admin-managed NotificationLink rows: implementation.md documents the resolution logic and the new migration, frontend-notification-click-actions.md replaces the "confirm your URL scheme" ask with the actual code catalog (now that the scheme itself is an admin panel concern, not something the frontend team needs to weigh in on for us to ship), and notification-click-actions.md gets a pointer at the top so it reads as the historical first pass it now is, not the current mechanism. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
11 KiB
Notification click actions
Update: this doc captures the initial implementation pass. Destinations are no longer built from a hard-coded
FRONTEND_BASE_URL— producers now send acode+click_object_id, resolved against the admin-managedNotificationLinktable. See implementation.md for the current mechanism and frontend-notification-click-actions.md for the current code catalog. The parts of this doc aboutclick_urlitself, optionality, and the escrow/billboard notification catalog are still accurate.
How a tapped push notification carries a destination, why that job splits across several repos, and what still needs building outside this service.
Your question, answered: frontend or backend?
Both, split cleanly:
- Choosing the destination is a backend job. A producer service (chat, promotions, advertising, ...) decides which screen a tap should open and hands that URL to this notifications service. That's what's implemented below.
- Acting on the tap is unavoidably client-side. Only the process that owns the tap event and the navigation stack — the Gooyal mobile/web app — can respond to it. No backend service can intercept a notification tap; Gotify's job ends the moment the message is delivered.
Not every notification is clickable, and click_url is a non-required
field end to end — see Optionality.
1. How push flows across Gooyal today
Three other services vendor a generated client for this one
(gooyal_notifications_client) and call into it over OAuth2
client-credentials. Gotify then holds the message until the user's device
fetches it.
chat / promotions / advertising
│ POST /push/application/<user_uuid>/application/
▼
notifications service (this repo)
│ create_message, extras: client::notification.click.url
▼
Gotify 2.6.3
│ push delivery
▼
Gooyal client app (repo not found in this workspace)
│ tap → must read extras and navigate
▼
? destination screen
What each producer sends today
| Service | Call site | Extras sent | Status |
|---|---|---|---|
| chat | apps/chat/events/publishers/push.py:40 |
Bespoke keys: {"conversation_uuid", "post_id"} — not Gotify's own convention |
live, fires on every chat message |
| promotions | apps/promotions/models.py:544 |
extras={} |
wired, unused |
| advertising | apps/users/models.py:91 |
— | call commented out entirely |
None of the three passed a click URL before this change. Chat's bespoke
conversation_uuid/post_id scheme implies some client already parses
custom keys per feature — every new use case would otherwise need its own
key and its own client-side handler. Standardizing on Gotify's own
client::notification.click.url convention means the client only needs one
tap handler instead of one per feature.
2. What changed in this repo (notifications)
Branch: feature/notification-actions.
Added a click_url field to PushMessage and one merge method that folds
it into Gotify's reserved client::notification extras namespace — the key
official Gotify clients already read on tap
(gotify.net/docs/pushmsg#extras).
Both message-creation paths funnel through this one method, so there is
exactly one place that builds the payload Gotify receives.
apps/push_notifications/models.py:
# reserved extras namespace — official clients open this url on tap
GOTIFY_CLICK_EXTRA_KEY = "client::notification"
click_url = models.URLField(max_length=1000, null=True, blank=True)
def get_gotify_extras(self):
extras = dict(self.extras or {})
if self.click_url:
notification_extra = dict(extras.get(self.GOTIFY_CLICK_EXTRA_KEY) or {})
notification_extra["click"] = {"url": self.click_url}
extras[self.GOTIFY_CLICK_EXTRA_KEY] = notification_extra
return extras
Any existing keys a producer already sends — chat's conversation_uuid,
for instance — pass through untouched. click_url is additive, never a
replacement.
Optionality: not every notification is clickable
click_url is null=True, blank=True on the model, which makes it
required=False / allow_null=True / allow_blank=True on the DRF
serializer automatically — confirmed directly:
>>> from apps.push_notifications.serializers import PushMessageSerializer
>>> PushMessageSerializer().get_fields()['click_url'].required
False
>>> PushMessageSerializer().get_fields()['click_url'].allow_null
True
When it's left unset, get_gotify_extras() returns self.extras completely
untouched — no client::notification key is added, so non-clickable
notifications are delivered exactly as before.
Both message-creation paths were wired through it
| Path | Entry point | Change |
|---|---|---|
| Single push — used by chat / promotions / advertising | POST /push/application/<user_uuid>/application/ |
Added click_url to PushMessageSerializer — optional, alongside title/message/extras |
| Bulk push — admin-triggered Excel import | BulkPushMessage.extract_data() |
Reads an optional 6th spreadsheet column as click_url; threaded through push_to_all() |
Bulk spreadsheet columns:
| Col | 0 | 1 | 2 | 3 | 4 | 5 (new) |
|---|---|---|---|---|---|---|
| Field | user_uuid | title | message | priority | extras (JSON) | click_url |
While extending extract_data() for the new column, fixed a pre-existing
bug on the same line: the extras cell was parsed with json.loads(extras)
— a name that was never assigned — instead of extras_str. Any bulk row
with a populated extras cell would have raised NameError before reaching
Gotify at all.
Request/response example
Request:
{
"title": "Order shipped",
"message": "Tap to view your order",
"priority": 5,
"extras": {},
"click_url": "https://gooyal.ir/orders/123"
}
Echoed back by a live local Gotify 2.6.3 container (sent alongside a
simulated chat-style conversation_uuid extra, to prove coexistence):
{
"title": "Order shipped",
"message": "Tap to view your order",
"extras": {
"conversation_uuid": "abc",
"client::notification": {
"click": { "url": "https://gooyal.ir/orders/123" }
}
}
}
Migration
apps/push_notifications/migrations/0004_pushmessage_click_url.py — adds
click_url to pushmessage. Applied cleanly against the local Postgres
container.
Verified
| Check | Result |
|---|---|
| Migration applied to live local Postgres | ✅ applied clean |
get_gotify_extras(): click_url only |
✅ merges correctly |
get_gotify_extras(): click_url + pre-existing custom extras |
✅ both keys coexist |
get_gotify_extras(): no click_url set |
✅ extras left untouched |
| Live round trip against local Gotify 2.6.3 container | ✅ accepted & echoed back correctly |
| Bulk path: mocked spreadsheet rows with/without a click_url cell | ✅ parses correctly, incl. the extras_str fix |
Serializer optionality (required=False, allow_null=True, allow_blank=True) |
✅ confirmed |
3. Producer services updated to pass click_url through
Each of the three services that call into this notifications service vendors
its own thin wrapper around the generated client. Added an optional
click_url=None parameter to each wrapper so producers can opt in without
being forced to — no existing call site was changed, so this is fully
backward compatible.
Workflow used in every repo: git checkout master → git pull origin master → git checkout -b feature/notification-client-update → edit.
| Service | Branch | File changed | Function | Tests |
|---|---|---|---|---|
| chat | feature/notification-client-update |
utils/clients/notifications_client.py |
push_user() / _PushBody |
47/47 passed (pytest) |
| promotions | feature/notification-client-update |
utils/clients/notifications_client.py |
notifications_push_user() |
no existing test suite for this file |
| advertising | feature/notification-client-update |
utils/clients/notifications_client.py |
notifications_push_user() |
apps.campaigns/apps.stores suites: same failures with and without the change (pre-existing, tied to a real staging OAuth call returning invalid_scope — unrelated to this edit) |
None of these commits were made — changes are sitting on each repo's
feature/notification-client-update branch, uncommitted, pending your
review.
Not done, and out of scope for this pass: wiring an actual click_url
value into chat's message-push call site, promotions' or advertising's push
calls. Which notifications should be clickable, and where they should
navigate, is a product decision for each feature — this pass only makes the
plumbing available.
4. Chat service: notification implementation review
- Location:
apps/chat/events/publishers/push.py,PushPublisher.publish(). Pushes every recipient in a conversation (except the sender) onMessageSentEvent, withextras={"conversation_uuid": ..., "post_id": ...}. Per-recipient failures are caught and logged, not raised — one broken recipient doesn't block the rest. - Is it in master? Yes. Commit
0d262c6("Send push notifications on new chat messages") is onmaster, and localmastermatchesorigin/masterexactly (9f8b723f...). Nothing related is stuck on an unmerged feature branch —feature/notificationis a stale, unrelated branch (conversation-details work), not the source of this feature. - Does it work?
python -m pytest apps/chat/tests/test_push_publisher.py→ 4/4 passed. Full suite (pytest) → 47/47 passed.python manage.py check→ no issues. - One local-only gap found:
NOTIFICATIONS_BASE_PUBLIC_URLis not set in the local dev.env(it defaults toNoneinmain/settings.py:277), even though.env.example/env.sampleboth document it (https://notifications-staging.gooyal.ir). Pushes would fail locally until that's added — this looks like an omission in the local.env, not a code issue. Left as-is since it's your local working file.
5. What's left, outside this repo
- Producer call sites don't pass
click_urlyet. The plumbing exists (§3); deciding which notifications should be clickable and what URL each should carry is a per-feature product decision. - The receiving client needs a tap handler. Checked every sibling
Gooyal repo in this workspace (
reservation-front,winofy-backend,crm_backend, etc.) — none of them handle Gotify push display or taps, so the actual mobile/web app isn't in this workspace. Can't confirm whether it already has a handler for chat'sconversation_uuid/post_idkeys that could be extended, or has none at all.