From a84e2b3793838244af6abb1ca062ff1a7c3a2d11 Mon Sep 17 00:00:00 2001 From: Ali Asadi Date: Wed, 2 Sep 2026 11:35:33 +0330 Subject: [PATCH] FEAT(notifications): pass action_code through in Gotify extras get_gotify_extras() previously only ever surfaced the *resolved* click_url, never the action_code that produced it -- so the frontend had no way to know which action_code a notification carried, and got nothing at all for a code that hasn't been configured in NotificationLink yet. Now action_code rides along under client::notification.action_code whenever it's set, independent of whether it resolved to a click_url. Verified live against a running Gotify container, both resolved and unresolved. Co-Authored-By: Claude Sonnet 5 --- apps/push_notifications/models.py | 12 +++++++++--- docs/frontend-notification-click-actions.md | 10 +++++++++- docs/implementation.md | 13 ++++++++++--- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/apps/push_notifications/models.py b/apps/push_notifications/models.py index 2995e6a..31ef19e 100644 --- a/apps/push_notifications/models.py +++ b/apps/push_notifications/models.py @@ -127,12 +127,18 @@ class PushMessage(BaseModel): def get_gotify_extras(self): """Merge the resolved click url into extras using Gotify's click-action convention, without disturbing any other extras keys the caller already - set.""" + set. Also passes action_code through as-is, when set — even if it + didn't resolve to a click_url yet (e.g. no NotificationLink row for + it) — so the frontend can see which action_code a notification + carries, not just the URL it happened to resolve to.""" extras = dict(self.extras or {}) click_url = self.resolve_click_url() - if click_url: + if click_url or self.action_code: notification_extra = dict(extras.get(self.GOTIFY_CLICK_EXTRA_KEY) or {}) - notification_extra["click"] = {"url": click_url} + if click_url: + notification_extra["click"] = {"url": click_url} + if self.action_code: + notification_extra["action_code"] = self.action_code extras[self.GOTIFY_CLICK_EXTRA_KEY] = notification_extra return extras diff --git a/docs/frontend-notification-click-actions.md b/docs/frontend-notification-click-actions.md index 12104e5..58ec5c1 100644 --- a/docs/frontend-notification-click-actions.md +++ b/docs/frontend-notification-click-actions.md @@ -25,7 +25,8 @@ is clickable at all — is under a reserved key, per "ads::ad::approve": true, "ad_uuid": "8fb638c2-e6a4-4baf-aefe-83dab78fb5bd", "client::notification": { - "click": { "url": "https://app.gooyal.ir/ads/8fb638c2-e6a4-4baf-aefe-83dab78fb5bd" } + "click": { "url": "https://app.gooyal.ir/ads/8fb638c2-e6a4-4baf-aefe-83dab78fb5bd" }, + "action_code": "billboard.approved" } } } @@ -34,6 +35,13 @@ is clickable at all — is under a reserved key, per Pull `extras["client::notification"]["click"]["url"]`. If it's present, tapping navigates there. **If it's absent, do nothing on tap** — see next section. +`extras["client::notification"]["action_code"]` rides along too, whenever the +producer sent one — even if it hasn't resolved to a `click` URL yet (no +`NotificationLink` row for it, or it's inactive). You don't need it to +implement tap-to-navigate; it's there for client-side logic keyed off the +notification *type* itself (grouping, icons, analytics, a fallback in-app +handler for a code before its admin row exists), not just its destination. + ## `click_url` is optional — most notifications are not clickable Do not assume every notification carries a URL. The backend field this comes diff --git a/docs/implementation.md b/docs/implementation.md index 9a70641..e66d5a3 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -87,7 +87,10 @@ Two ways to set a destination on a `PushMessage`: GOTIFY_CLICK_EXTRA_KEY = "client::notification" # Gotify's own reserved namespace click_url = models.URLField(max_length=1000, null=True, blank=True) -action_code = models.SlugField(max_length=100, null=True, blank=True, db_index=True) +# CharField, not SlugField -- the documented catalog is dotted +# (billboard.approved, escrow.timeout, ...) and SlugField rejects dots. +action_code = models.CharField(max_length=100, null=True, blank=True, db_index=True, + validators=[validate_action_code]) click_object_id_value = models.CharField(max_length=255, null=True, blank=True) def resolve_click_url(self): @@ -103,15 +106,19 @@ def resolve_click_url(self): def get_gotify_extras(self): extras = dict(self.extras or {}) click_url = self.resolve_click_url() - if click_url: + if click_url or self.action_code: notification_extra = dict(extras.get(self.GOTIFY_CLICK_EXTRA_KEY) or {}) - notification_extra["click"] = {"url": click_url} + if click_url: + notification_extra["click"] = {"url": click_url} + if self.action_code: + notification_extra["action_code"] = self.action_code extras[self.GOTIFY_CLICK_EXTRA_KEY] = notification_extra return extras ``` Design decisions worth knowing if you touch this: - **An unresolvable action_code is not an error** — no matching `NotificationLink`, or one marked `is_active=False`, just means no click action, same as a message with nothing set at all. A producer can start sending a new action_code before anyone's configured it in the admin; nothing breaks, the notification just isn't clickable yet. +- **`action_code` itself rides along in extras, not just the URL it resolves to** — added after testing against a live Gotify container surfaced that the frontend had no way to see which action_code produced a notification, only its resolved destination (or nothing, if unresolved). It's included even when there's no click_url yet, so the frontend can key client-side logic off the notification type itself. - **The merge is additive**: any other `extras` keys a producer already sends (e.g. chat's `conversation_uuid`/`post_id`) pass through untouched — `get_gotify_extras()` only ever adds the `client::notification` key, never removes others. - **`get_gotify_extras()` is the single place resolution happens** — `tasks.py` calls it instead of reading `push_message.extras`/`click_url` directly, so the single-push path, the bulk-push path, and both `click_url` and `action_code` all go through one function. - **The admin table is deliberately not seeded with rows by any migration.** Populating it is an ops/product decision (which is the entire point of moving it out of code) — see [frontend-notification-click-actions.md](frontend-notification-click-actions.md) for the current action_code catalog they need to fill in.