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>
This commit is contained in:
Ali Asadi 2026-08-22 15:23:45 +03:30
parent 707407bb52
commit d473f61790
3 changed files with 119 additions and 65 deletions

View file

@ -49,69 +49,86 @@ You may also see other keys under `extras` alongside (or instead of)
below). Ignore keys you don't recognize; don't treat an unfamiliar key as an
error.
## ⚠️ The URL format below is a placeholder — needs your input
## URLs are admin-configured, not hard-coded — nothing for you to build here
Every `click_url` currently being sent is built from a setting called
`FRONTEND_BASE_URL`, defaulting to `https://app.gooyal.ir`, with a path like
`/ads/<uuid>` or `/escrow/<uuid>` appended. **Nobody has confirmed this
matches your actual app's routing** — whether you use a custom URI scheme
(`gooyal://ads/<uuid>`), a universal/app link (`https://...`), or something
else entirely (a route name + params instead of a URL at all).
The actual destination string is no longer computed in backend code. Each
notification type has a **code**, and the notifications service resolves
that code against an admin-managed table (`NotificationLink`: `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 was built centrally on purpose so it's a one-line change once you tell
us: every `click_url` in the system is built by one helper function per
producer service (`utils/deep_links.py` in `advertising`), not scattered
across dozens of call sites. **Please confirm your scheme and we'll update
it** — nothing about the client-side contract above changes either way,
only the string inside `click.url`.
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
code below with your actual URL format, and every notification of that type
immediately starts carrying it. If a 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.
## What's clickable today
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 code, ask whoever owns that panel
to update it.
All of the following are live in the `advertising` service and carry a real
`click_url` pointing at the ad (`/ads/<uuid>`) or escrow deal (`/escrow/<uuid>`):
## What's clickable today, by code
### Billboards & content (→ `/ads/<uuid>`)
All of these are live in the `advertising` service:
| Event | Notification text (fa) |
### Billboards & content
| Code | Notification text (fa) |
|---|---|
| Someone viewed your billboard | شخصی شروع به مشاهده بیلبورد شما کرد. |
| Billboard created | بیلبود شما با موفقیت ساخته شد. |
| Billboard approved | بیلبورد شما تایید شد. |
| Billboard rejected | بیلبود شما رد شد. |
| Someone commented on your billboard | یک نفر برای بیلبورد شما نظر گذاشت! |
| Someone replied to your comment | یک نفر به نظر شما پاسخ داد! |
| Someone bought your content | درآمد جدید دارید! یک نفر محتوای شما را خرید. |
| Someone supported your content | یک نفر از محتوای شما حمایت کرد! |
| Pin about to expire (time) | زمان پین رو به اتمامه! |
| Pin expired (time) | پین شما منقضی شد! |
| Pin credit about to run out (budget) | اعتبار پین رو به اتمامه! |
| Pin credit exhausted (budget) | اعتبار پین شما به پایان رسید! |
| `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` | اعتبار پین شما به پایان رسید! |
Note the last four are **two independent signals** — a billboard can lapse
because its display *time* ran out, or because its *budget* (balance vs.
reward-per-view) ran out. Both currently point at the same ad detail page;
if your UI wants to show a different call-to-action (extend time vs. top up
credit) based on which one fired, that distinction is in the `extras` payload
via which notification title/text arrived, not in the URL itself — ask if
you need a structured signal here instead of parsing title text.
Every code above sends the ad's `uuid` as `click_object_id`, 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 (→ `/escrow/<uuid>`)
### Escrow deals
The full P2P deal lifecycle — buyer pays, seller approves/rejects, delivery,
confirm, dispute, payout/refund. All 11 possible state transitions notify
whichever party (buyer or seller) needs to act or be informed next, each
with a `click_url` to that deal's detail page. If you're building an escrow
detail screen, treat every push in this domain as "go look at this deal" —
the screen itself should reflect current state, not the specific notification
that triggered the tap.
| 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`. 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 `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. If you'd rather chat also send a `click_url` once your routing scheme is confirmed, that's a small change on our side; let us know.
- **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.
## Questions for you before we finalize
1. What's your app's actual deep-link scheme — custom URI, universal link, or something else?
2. Do you want the "time expiring" vs. "credit expiring" pin notifications distinguished by something other than title text?
3. Do you want chat migrated onto `click_url` too, or is your existing `conversation_uuid`/`post_id` handling staying as-is?

View file

@ -52,15 +52,32 @@ An admin action (`BulkPushMessageAdmin`, `apps/push_notifications/admin.py`) tri
`extract_data()` reads a legacy `.xls` file (via `xlrd` — only `.xls`, not `.xlsx`, since `xlrd` dropped xlsx support at 2.0) with columns:
| Col | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| Field | `user_uuid` | `title` | `message` | `priority` | `extras` (JSON string) | `click_url` |
| Col | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| Field | `user_uuid` | `title` | `message` | `priority` | `extras` (JSON string) | `click_url` | `code` | `click_object_id` |
`push_to_all()` looks up each row's `PushUser` by `user_id`; missing users are counted as failures rather than raising, so one bad row doesn't abort the batch. Each successful row creates a `PushMessage` and calls `send_push()` — it goes through the exact same Celery task and Gotify call as a single push, so there's no separate bulk-specific delivery code path to keep in sync.
## The click_url mechanism
## The click action mechanism
Added in migration `0004_pushmessage_click_url`. The design constraint: **most notifications aren't clickable**, so this had to be fully optional at every layer, and had to work identically for both the single and bulk paths without a second implementation.
Added in migration `0004_pushmessage_click_url`, extended in `0005` with
`code`/`click_object_id` and the `NotificationLink` model. The design
constraint throughout: **most notifications aren't clickable**, so this had
to be fully optional at every layer, and had to work identically for both
the single and bulk paths without a second implementation.
Two ways to set a destination on a `PushMessage`:
1. **`click_url`** — a raw URL the caller already knows. Always wins if set.
2. **`code` + `click_object_id`** — the preferred path. `code` is a documented,
per-notification-type string (e.g. `billboard.approved`, `escrow.timeout` —
see [notification-click-actions.md](notification-click-actions.md) for the
full catalog); `click_object_id` is the id of the thing the notification is
about (an ad's or escrow deal's uuid, typically). Resolved at send time
against `NotificationLink`, an admin-managed table
(`code`, `url_template`, `is_active`) — `url_template` supports a
`{object_id}` placeholder. This is what moved the actual destination
strings out of Python and into the Django admin, so a routing-scheme
change is a config edit, not a deploy across every producer service.
`apps/push_notifications/models.py`:
@ -68,23 +85,34 @@ Added in migration `0004_pushmessage_click_url`. The design constraint: **most n
GOTIFY_CLICK_EXTRA_KEY = "client::notification" # Gotify's own reserved namespace
click_url = models.URLField(max_length=1000, null=True, blank=True)
code = models.SlugField(max_length=100, null=True, blank=True, db_index=True)
click_object_id = models.CharField(max_length=255, null=True, blank=True)
def resolve_click_url(self):
if self.click_url:
return self.click_url
if not self.code:
return None
link = NotificationLink.objects.filter(code=self.code, is_active=True).first()
if not link:
return None
return link.resolve(self.click_object_id)
def get_gotify_extras(self):
extras = dict(self.extras or {})
if self.click_url:
click_url = self.resolve_click_url()
if click_url:
notification_extra = dict(extras.get(self.GOTIFY_CLICK_EXTRA_KEY) or {})
notification_extra["click"] = {"url": self.click_url}
notification_extra["click"] = {"url": click_url}
extras[self.GOTIFY_CLICK_EXTRA_KEY] = notification_extra
return extras
```
Design decisions worth knowing if you touch this:
- **`click_url` is a real column, not just an `extras` key** — so it's queryable/auditable independently, and callers don't need to know Gotify's raw extras convention to use it.
- **An unresolvable 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 code before anyone's configured it in the admin; nothing breaks, the notification just isn't clickable yet.
- **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 this merge happens** — `tasks.py` calls it instead of reading `push_message.extras` directly, so both the single-push and bulk-push paths (which both eventually call the same task) get it for free.
- **Optionality is enforced at three layers**, not just the DB: `null=True, blank=True` on the field → DRF `ModelSerializer` derives `required=False, allow_null=True, allow_blank=True` automatically → and behaviorally, an unset `click_url` leaves `extras` completely untouched (verified: `get_gotify_extras()` on a message with no `click_url` returns the original `extras` dict unchanged).
The actual URL values are built by producer services from their own `utils/deep_links.py`-style helpers (currently only `advertising` has one) against a placeholder base — see [frontend-notification-click-actions.md](frontend-notification-click-actions.md) for why that's still a placeholder and what needs to happen before it's final.
- **`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 `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 code catalog they need to fill in.
## Email flow
@ -111,7 +139,8 @@ This service is an OAuth2 **resource server** (`django-oauth-toolkit`), not an O
## Notable migrations
- `0004_pushmessage_click_url` — adds `click_url`. See "The click_url mechanism" above.
- `0004_pushmessage_click_url` — adds `click_url`.
- `0005_notificationlink_pushmessage_click_object_id_and_more` — adds `NotificationLink`, `PushMessage.code`, `PushMessage.click_object_id`. See "The click action mechanism" above.
## Known technical debt (implementation-level)

View file

@ -1,5 +1,13 @@
# 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.