notifications/docs/frontend-notification-click-actions.md
Ali Asadi a84e2b3793 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 <noreply@anthropic.com>
2026-09-02 11:35:33 +03:30

142 lines
7.3 KiB
Markdown

# Frontend integration guide: notification click actions
**Audience**: the team building the Gooyal client app(s) that receive Gotify
push notifications. This is everything you need to start building tap-to-navigate,
independent of the backend repos.
## The one thing to build
When a push notification is tapped, read a URL out of the notification's
payload and navigate to it. That's the entire client-side contract — one
handler, used by every notification type below, present or future.
## Where the URL lives
Gotify delivers a JSON payload per message. The URL — when the notification
is clickable at all — is under a reserved key, per
[Gotify's own convention](https://gotify.net/docs/pushmsg#extras):
```json
{
"title": "بیلبورد شما تایید شد.",
"message": "بیلبورد گربه (تست) تایید شد.",
"priority": 5,
"extras": {
"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" },
"action_code": "billboard.approved"
}
}
}
```
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
from is explicitly optional (nullable end-to-end, not just "sometimes empty
string") specifically because not every notification type has a sensible
destination. Any notification without a `client::notification.click.url` key
should render and behave exactly as a non-interactive notification — no
error, no dead tap target, just no navigation.
You may also see other keys under `extras` alongside (or instead of)
`client::notification` — e.g. chat currently sends `conversation_uuid` and
`post_id` directly, not yet through this mechanism (see "Not yet migrated"
below). Ignore keys you don't recognize; don't treat an unfamiliar key as an
error.
## URLs are admin-configured, not hard-coded — nothing for you to build here
The actual destination string is no longer computed in backend code. Each
notification type has an **action_code**, and the notifications service
resolves that action_code against an admin-managed table (`NotificationLink`:
`action_code`, `url_template`, `is_active`) at send time. `url_template`
supports a `{object_id}` placeholder, e.g. `https://app.gooyal.ir/ads/{object_id}`
or `gooyal://ads/{object_id}` — whichever scheme your app actually uses.
This means: **your routing scheme is a config decision, not a code change**.
Whoever manages the notifications service's admin panel enters one row per
action_code below with your actual URL format, and every notification of
that type immediately starts carrying it. If an action_code has no row yet
(or its row is marked inactive), that notification simply has no
`click_url` — same as any other non-clickable notification, no error.
Practically, this doesn't change anything about what you build — you still
just read `extras["client::notification"]["click"]["url"]` and navigate if
it's present. It changes who's responsible for the URL *string itself*: not
a backend deploy, just an admin panel entry. If you want a different value
than what's currently configured for any action_code, ask whoever owns that
panel to update it.
## What's clickable today, by action_code
All of these are live in the `advertising` service:
### Billboards & content
| Action code | Notification text (fa) |
|---|---|
| `billboard.viewed` | شخصی شروع به مشاهده بیلبورد شما کرد. |
| `billboard.created` | بیلبود شما با موفقیت ساخته شد. |
| `billboard.approved` | بیلبورد شما تایید شد. |
| `billboard.rejected` | بیلبود شما رد شد. |
| `billboard.commented` | یک نفر برای بیلبورد شما نظر گذاشت! |
| `billboard.replied` | یک نفر به نظر شما پاسخ داد! |
| `billboard.content_bought` | درآمد جدید دارید! یک نفر محتوای شما را خرید. |
| `billboard.content_supported` | یک نفر از محتوای شما حمایت کرد! |
| `billboard.pin_expiring` | زمان پین رو به اتمامه! |
| `billboard.pin_expired` | پین شما منقضی شد! |
| `billboard.credit_low` | اعتبار پین رو به اتمامه! |
| `billboard.credit_exhausted` | اعتبار پین شما به پایان رسید! |
Every action_code above sends the ad's `uuid` as `click_object_id_value`, so a
`url_template` of `https://app.gooyal.ir/ads/{object_id}` (or your app's
real equivalent) resolves correctly for all twelve. They're still separate
codes rather than one shared one, on purpose: `billboard.pin_expiring` and
`billboard.credit_low` are genuinely different situations (time running out
vs. budget running out) even though they'd point at the same screen today —
giving each its own code means that can diverge later (e.g. deep-linking
straight to an "extend time" vs. "top up credit" action) without any code
change, just a new admin row.
### Escrow deals
| Action code | Trigger |
|---|---|
| `escrow.request_deal` | Buyer paid, seller needs to review |
| `escrow.cancel_deal` | Buyer canceled before seller responded |
| `escrow.reject_deal` | Seller declined the deal |
| `escrow.approve_deal` | Seller confirmed the deal |
| `escrow.cancel_deal_by_seller` | Seller canceled an active deal |
| `escrow.request_cancel` | Buyer requested cancellation |
| `escrow.approve_cancel` | Seller accepted the cancellation |
| `escrow.reject_cancel` | Seller declined the cancellation |
| `escrow.confirm_by_seller` | Seller marked it delivered |
| `escrow.confirm_by_buyer` | Buyer confirmed receipt — deal completed |
| `escrow.request_judge` | A dispute was opened |
| `escrow.cancel_judge` | Buyer withdrew their dispute |
| `escrow.approve_judge` | Dispute resolved for the buyer |
| `escrow.reject_judge` | Dispute resolved for the seller |
| `escrow.timeout` | Deal expired without a response |
All fifteen send the escrow deal's `uuid` as `click_object_id_value`. Same
one-code-per-situation reasoning as billboards — they all currently resolve
to the same escrow-detail destination, but that's an admin config choice,
not a hard-coded one, so it's free to diverge later.
## Not yet migrated to this mechanism
- **chat**: sends `extras = {"conversation_uuid": ..., "post_id": ...}` directly, without a code or `click_url`. If your client already has custom handling for these two keys (built before this mechanism existed), it keeps working — this doc doesn't change that. Let us know if you'd rather chat send a code too.
- **promotions**: integrated with the notifications service but not currently sending any notification with real content (`extras={}` today) — nothing to build against yet.