notifications/docs/notification-click-actions.md
Ali Asadi d473f61790 docs(notifications): update docs for the code -> NotificationLink mechanism
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>
2026-08-22 15:23:45 +03:30

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 a code + click_object_id, resolved against the admin-managed NotificationLink table. See implementation.md for the current mechanism and frontend-notification-click-actions.md for the current code catalog. The parts of this doc about click_url itself, 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) on MessageSentEvent, with extras={"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 on master, and local master matches origin/master exactly (9f8b723f...). Nothing related is stuck on an unmerged feature branch — feature/notification is 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_URL is not set in the local dev .env (it defaults to None in main/settings.py:277), even though .env.example/env.sample both 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

  1. Producer call sites don't pass click_url yet. The plumbing exists (§3); deciding which notifications should be clickable and what URL each should carry is a per-feature product decision.
  2. 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's conversation_uuid/post_id keys that could be extended, or has none at all.