notifications/docs/notification-click-actions.md
Ali Asadi 2dbe021fe5 REFACTOR(notifications): rename code/click_object_id to action_code/click_object_id_value
`code` was ambiguous next to Python's own use of "code"; `action_code` names
its actual role (a click-action lookup key). click_object_id_value pairs it
consistently with action_code. Renamed on both PushMessage and
NotificationLink via a data-preserving RenameField migration, threaded
through the serializer, admin, bulk-push path, and docs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 12:13:29 +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 an action_code + click_object_id_value, 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.