feature/notification-actions #1
3 changed files with 119 additions and 65 deletions
|
|
@ -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
|
below). Ignore keys you don't recognize; don't treat an unfamiliar key as an
|
||||||
error.
|
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
|
The actual destination string is no longer computed in backend code. Each
|
||||||
`FRONTEND_BASE_URL`, defaulting to `https://app.gooyal.ir`, with a path like
|
notification type has a **code**, and the notifications service resolves
|
||||||
`/ads/<uuid>` or `/escrow/<uuid>` appended. **Nobody has confirmed this
|
that code against an admin-managed table (`NotificationLink`: `code`,
|
||||||
matches your actual app's routing** — whether you use a custom URI scheme
|
`url_template`, `is_active`) at send time. `url_template` supports a
|
||||||
(`gooyal://ads/<uuid>`), a universal/app link (`https://...`), or something
|
`{object_id}` placeholder, e.g. `https://app.gooyal.ir/ads/{object_id}` or
|
||||||
else entirely (a route name + params instead of a URL at all).
|
`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
|
This means: **your routing scheme is a config decision, not a code change**.
|
||||||
us: every `click_url` in the system is built by one helper function per
|
Whoever manages the notifications service's admin panel enters one row per
|
||||||
producer service (`utils/deep_links.py` in `advertising`), not scattered
|
code below with your actual URL format, and every notification of that type
|
||||||
across dozens of call sites. **Please confirm your scheme and we'll update
|
immediately starts carrying it. If a code has no row yet (or its row is
|
||||||
it** — nothing about the client-side contract above changes either way,
|
marked inactive), that notification simply has no `click_url` — same as any
|
||||||
only the string inside `click.url`.
|
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
|
## What's clickable today, by code
|
||||||
`click_url` pointing at the ad (`/ads/<uuid>`) or escrow deal (`/escrow/<uuid>`):
|
|
||||||
|
|
||||||
### 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.viewed` | شخصی شروع به مشاهده بیلبورد شما کرد. |
|
||||||
| Billboard created | بیلبود شما با موفقیت ساخته شد. |
|
| `billboard.created` | بیلبود شما با موفقیت ساخته شد. |
|
||||||
| Billboard approved | بیلبورد شما تایید شد. |
|
| `billboard.approved` | بیلبورد شما تایید شد. |
|
||||||
| Billboard rejected | بیلبود شما رد شد. |
|
| `billboard.rejected` | بیلبود شما رد شد. |
|
||||||
| Someone commented on your billboard | یک نفر برای بیلبورد شما نظر گذاشت! |
|
| `billboard.commented` | یک نفر برای بیلبورد شما نظر گذاشت! |
|
||||||
| Someone replied to your comment | یک نفر به نظر شما پاسخ داد! |
|
| `billboard.replied` | یک نفر به نظر شما پاسخ داد! |
|
||||||
| Someone bought your content | درآمد جدید دارید! یک نفر محتوای شما را خرید. |
|
| `billboard.content_bought` | درآمد جدید دارید! یک نفر محتوای شما را خرید. |
|
||||||
| Someone supported your content | یک نفر از محتوای شما حمایت کرد! |
|
| `billboard.content_supported` | یک نفر از محتوای شما حمایت کرد! |
|
||||||
| Pin about to expire (time) | زمان پین رو به اتمامه! |
|
| `billboard.pin_expiring` | زمان پین رو به اتمامه! |
|
||||||
| Pin expired (time) | پین شما منقضی شد! |
|
| `billboard.pin_expired` | پین شما منقضی شد! |
|
||||||
| Pin credit about to run out (budget) | اعتبار پین رو به اتمامه! |
|
| `billboard.credit_low` | اعتبار پین رو به اتمامه! |
|
||||||
| Pin credit exhausted (budget) | اعتبار پین شما به پایان رسید! |
|
| `billboard.credit_exhausted` | اعتبار پین شما به پایان رسید! |
|
||||||
|
|
||||||
Note the last four are **two independent signals** — a billboard can lapse
|
Every code above sends the ad's `uuid` as `click_object_id`, so a
|
||||||
because its display *time* ran out, or because its *budget* (balance vs.
|
`url_template` of `https://app.gooyal.ir/ads/{object_id}` (or your app's
|
||||||
reward-per-view) ran out. Both currently point at the same ad detail page;
|
real equivalent) resolves correctly for all twelve. They're still separate
|
||||||
if your UI wants to show a different call-to-action (extend time vs. top up
|
codes rather than one shared one, on purpose: `billboard.pin_expiring` and
|
||||||
credit) based on which one fired, that distinction is in the `extras` payload
|
`billboard.credit_low` are genuinely different situations (time running out
|
||||||
via which notification title/text arrived, not in the URL itself — ask if
|
vs. budget running out) even though they'd point at the same screen today —
|
||||||
you need a structured signal here instead of parsing title text.
|
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,
|
| Code | Trigger |
|
||||||
confirm, dispute, payout/refund. All 11 possible state transitions notify
|
|---|---|
|
||||||
whichever party (buyer or seller) needs to act or be informed next, each
|
| `escrow.request_deal` | Buyer paid, seller needs to review |
|
||||||
with a `click_url` to that deal's detail page. If you're building an escrow
|
| `escrow.cancel_deal` | Buyer canceled before seller responded |
|
||||||
detail screen, treat every push in this domain as "go look at this deal" —
|
| `escrow.reject_deal` | Seller declined the deal |
|
||||||
the screen itself should reflect current state, not the specific notification
|
| `escrow.approve_deal` | Seller confirmed the deal |
|
||||||
that triggered the tap.
|
| `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
|
## 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.
|
- **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?
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
`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 |
|
| Col | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|
||||||
|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|---|
|
||||||
| Field | `user_uuid` | `title` | `message` | `priority` | `extras` (JSON string) | `click_url` |
|
| 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.
|
`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`:
|
`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
|
GOTIFY_CLICK_EXTRA_KEY = "client::notification" # Gotify's own reserved namespace
|
||||||
|
|
||||||
click_url = models.URLField(max_length=1000, null=True, blank=True)
|
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):
|
def get_gotify_extras(self):
|
||||||
extras = dict(self.extras or {})
|
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 = 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
|
extras[self.GOTIFY_CLICK_EXTRA_KEY] = notification_extra
|
||||||
return extras
|
return extras
|
||||||
```
|
```
|
||||||
|
|
||||||
Design decisions worth knowing if you touch this:
|
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.
|
- **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.
|
- **`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.
|
||||||
- **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 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.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Email flow
|
## Email flow
|
||||||
|
|
||||||
|
|
@ -111,7 +139,8 @@ This service is an OAuth2 **resource server** (`django-oauth-toolkit`), not an O
|
||||||
|
|
||||||
## Notable migrations
|
## 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)
|
## Known technical debt (implementation-level)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,13 @@
|
||||||
# Notification click actions
|
# 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
|
How a tapped push notification carries a destination, why that job splits across
|
||||||
several repos, and what still needs building outside this service.
|
several repos, and what still needs building outside this service.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue