Not every notification is clickable, so click_url is optional end to end (null/blank on the model, required=False on the serializer). When set, it's merged into Gotify's own client::notification.click.url extras convention without disturbing any other extras a producer already sends, so official Gotify clients can open it on tap. Wired through both message-creation paths: the single-push REST API and the bulk Excel upload. Also fixes a pre-existing NameError in the bulk path (json.loads(extras) referenced an undefined name instead of extras_str). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
10 KiB
Notification click actions
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.