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>
244 lines
11 KiB
Markdown
244 lines
11 KiB
Markdown
# 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](implementation.md#the-click-action-mechanism)
|
|
> for the current mechanism and [frontend-notification-click-actions.md](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](#optionality-not-every-notification-is-clickable).
|
|
|
|
## 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](https://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`:
|
|
|
|
```python
|
|
# 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:
|
|
|
|
```python
|
|
>>> 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:
|
|
|
|
```json
|
|
{
|
|
"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):
|
|
|
|
```json
|
|
{
|
|
"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.
|