From 6aedf7aa2f9730fa84191d2ec0155f5e791b48e9 Mon Sep 17 00:00:00 2001 From: Ali Asadi Date: Sat, 22 Aug 2026 09:35:53 +0330 Subject: [PATCH 1/5] chore: ignore __pycache__ and compiled Python files Co-Authored-By: Claude Sonnet 5 --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index ee024d2..389f0ed 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,7 @@ .env media /clients/ + +__pycache__/ +*.py[cod] +*$py.class -- 2.45.3 From 8f83a5e84a84bd41d2ef5dad403aa78342da494d Mon Sep 17 00:00:00 2001 From: Ali Asadi Date: Sat, 22 Aug 2026 09:36:06 +0330 Subject: [PATCH 2/5] FEAT(notifications): add optional click_url for notification tap actions Not every notification is clickable, so click_url is optional end to end (null/blank on the model, required=False on the serializer). When set, it's merged into Gotify's own client::notification.click.url extras convention without disturbing any other extras a producer already sends, so official Gotify clients can open it on tap. Wired through both message-creation paths: the single-push REST API and the bulk Excel upload. Also fixes a pre-existing NameError in the bulk path (json.loads(extras) referenced an undefined name instead of extras_str). Co-Authored-By: Claude Sonnet 5 --- .../migrations/0004_pushmessage_click_url.py | 18 ++ apps/push_notifications/models.py | 23 +- apps/push_notifications/serializers.py | 3 +- apps/push_notifications/tasks.py | 2 +- docs/notification-click-actions.md | 236 ++++++++++++++++++ 5 files changed, 278 insertions(+), 4 deletions(-) create mode 100644 apps/push_notifications/migrations/0004_pushmessage_click_url.py create mode 100644 docs/notification-click-actions.md diff --git a/apps/push_notifications/migrations/0004_pushmessage_click_url.py b/apps/push_notifications/migrations/0004_pushmessage_click_url.py new file mode 100644 index 0000000..3845293 --- /dev/null +++ b/apps/push_notifications/migrations/0004_pushmessage_click_url.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.6 on 2026-08-22 05:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('push_notifications', '0003_bulkpushmessage_pushmessage_bulk'), + ] + + operations = [ + migrations.AddField( + model_name='pushmessage', + name='click_url', + field=models.URLField(blank=True, max_length=1000, null=True), + ), + ] diff --git a/apps/push_notifications/models.py b/apps/push_notifications/models.py index 42fc57e..b176b43 100644 --- a/apps/push_notifications/models.py +++ b/apps/push_notifications/models.py @@ -45,6 +45,11 @@ class PushUser(BaseModel): class PushMessage(BaseModel): + # Gotify's reserved extras namespace for client-side actions. Official + # clients (Android/iOS/web) open this URL when the notification is tapped: + # https://gotify.net/docs/pushmsg#extras + GOTIFY_CLICK_EXTRA_KEY = "client::notification" + class StateChoices(models.IntegerChoices): INIT = 0, _('init') START = 1, _('Start') @@ -59,10 +64,21 @@ class PushMessage(BaseModel): message = models.TextField() priority = models.IntegerField(default=5) extras = models.JSONField(default=dict) + click_url = models.URLField(max_length=1000, null=True, blank=True) stats = models.IntegerField(default=StateChoices.INIT, choices=StateChoices.choices) bulk = models.ForeignKey("BulkPushMessage", null=True, blank=True, on_delete=models.PROTECT) + def get_gotify_extras(self): + """Merge click_url into extras using Gotify's click-action convention, + without disturbing any other extras keys the caller already set.""" + 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 + def send_push(self): from apps.push_notifications.tasks import send_push_notification send_push_notification.delay(str(self.uuid)) @@ -98,7 +114,8 @@ class BulkPushMessage(BaseModel): title=data[user_uuid].get('title'), message=data[user_uuid].get('message'), priority=data[user_uuid].get('priority', 5), - extras=data[user_uuid].get("extras", {})) + extras=data[user_uuid].get("extras", {}), + click_url=data[user_uuid].get("click_url") or None) push_message.send_push() self.success_count += 1 @@ -125,14 +142,16 @@ class BulkPushMessage(BaseModel): priority = int(float(str(sh.cell(rowx=row, colx=3).value).strip())) extras_str = sh.cell(rowx=row, colx=4).value.strip() if extras_str: - extras = json.loads(extras) + extras = json.loads(extras_str) else: extras = {} + click_url = str(sh.cell(rowx=row, colx=5).value).strip() if sh.ncols > 5 else "" push_dict[user_uuid] = { "title" : title, "message" : message, "priority" : priority, "extras" : extras, + "click_url": click_url or None, } return push_dict diff --git a/apps/push_notifications/serializers.py b/apps/push_notifications/serializers.py index f266f55..5601759 100644 --- a/apps/push_notifications/serializers.py +++ b/apps/push_notifications/serializers.py @@ -21,6 +21,7 @@ class PushMessageSerializer(serializers.ModelSerializer): "title", "message", "priority", - "extras" + "extras", + "click_url" ) read_only_fields = ("push_user","application") diff --git a/apps/push_notifications/tasks.py b/apps/push_notifications/tasks.py index 05b13e3..9577403 100644 --- a/apps/push_notifications/tasks.py +++ b/apps/push_notifications/tasks.py @@ -10,7 +10,7 @@ def send_push_notification(push_message_uuid): title=push_message.title, message=push_message.message, priority=push_message.priority, - extras=push_message.extras) + extras=push_message.get_gotify_extras()) push_message.state = push_message.StateChoices.DONE push_message.save() diff --git a/docs/notification-click-actions.md b/docs/notification-click-actions.md new file mode 100644 index 0000000..86fc0aa --- /dev/null +++ b/docs/notification-click-actions.md @@ -0,0 +1,236 @@ +# Notification click actions + +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//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//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. -- 2.45.3 From 354bbc26cf936397b2f885e1683d385507c49665 Mon Sep 17 00:00:00 2001 From: Ali Asadi Date: Sat, 22 Aug 2026 11:39:30 +0330 Subject: [PATCH 3/5] docs(notifications): add service overview, implementation guide, and frontend handoff doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three docs for three audiences: service-overview.md for anyone integrating with or operating the service, implementation.md for engineers maintaining this codebase, and frontend-notification-click-actions.md as a self-contained handoff for the client team to start building tap-to-navigate against — it flags the click_url URL format as an unconfirmed placeholder needing their input, and catalogs every notification type currently sending one. Co-Authored-By: Claude Sonnet 5 --- docs/frontend-notification-click-actions.md | 117 +++++++++++++++++++ docs/implementation.md | 120 ++++++++++++++++++++ docs/service-overview.md | 111 ++++++++++++++++++ 3 files changed, 348 insertions(+) create mode 100644 docs/frontend-notification-click-actions.md create mode 100644 docs/implementation.md create mode 100644 docs/service-overview.md diff --git a/docs/frontend-notification-click-actions.md b/docs/frontend-notification-click-actions.md new file mode 100644 index 0000000..40dc0f2 --- /dev/null +++ b/docs/frontend-notification-click-actions.md @@ -0,0 +1,117 @@ +# 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" } + } + } +} +``` + +Pull `extras["client::notification"]["click"]["url"]`. If it's present, tapping +navigates there. **If it's absent, do nothing on tap** — see next section. + +## `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. + +## ⚠️ The URL format below is a placeholder — needs your input + +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/` or `/escrow/` appended. **Nobody has confirmed this +matches your actual app's routing** — whether you use a custom URI scheme +(`gooyal://ads/`), a universal/app link (`https://...`), or something +else entirely (a route name + params instead of a URL at all). + +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`. + +## What's clickable today + +All of the following are live in the `advertising` service and carry a real +`click_url` pointing at the ad (`/ads/`) or escrow deal (`/escrow/`): + +### Billboards & content (→ `/ads/`) + +| Event | 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) | اعتبار پین شما به پایان رسید! | + +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. + +### Escrow deals (→ `/escrow/`) + +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. + +## 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. +- **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? diff --git a/docs/implementation.md b/docs/implementation.md new file mode 100644 index 0000000..7c44ae7 --- /dev/null +++ b/docs/implementation.md @@ -0,0 +1,120 @@ +# Notifications Service — Implementation Guide + +Technical walkthrough of how the service is actually built, for anyone +maintaining or extending it. For what the service *does* and how to +integrate with it, see [service-overview.md](service-overview.md). + +## Code layout + +``` +apps/ + core/ root URL ("/") — a login-gated placeholder home view, nothing else + users/ local User model, mirrored by uuid from the accounts service + gooyal_oauth2/ OAuth2 resource-server wiring: token validator, permission class, + get_application() helper + push_notifications/ push domain: PushUser, PushMessage, BulkPushMessage + emails/ email domain: Email, EmailQueue +utils/ + clients/gotify.py the only code that talks to the Gotify HTTP API + models.py BaseModel (uuid pk, created_at/updated_at) +gotify_rest_api_client/ generated OpenAPI client for Gotify (do not hand-edit) +main/ + settings.py, celery.py, urls.py, wsgi.py/asgi.py +``` + +## Push notification flow + +### Single push + +``` +POST /push/application//application/ + ↓ ApplicationPushUserViewSet.perform_create() (apps/push_notifications/views.py) + 1. resolve target User by user_uuid + 2. resolve/create their PushUser (Gotify identity) — PushUser.objects.submit(user) + 3. resolve the calling Application from the OAuth2 token + 4. serializer.save(push_user=..., application=...) → PushMessage row + 5. instance.send_push() → send_push_notification.delay(uuid) (Celery) + ↓ apps/push_notifications/tasks.py + gotify.send_notif(token=push_user.application_token, + title, message, priority, + extras=push_message.get_gotify_extras()) + push_message.state = DONE +``` + +`PushUser.objects.submit(user)` provisions three things in Gotify on first use, all via `utils/clients/gotify.py`, and persists the resulting tokens on `PushUser`: +1. `submit_user` — a Gotify *user* named after the Gooyal `user_uuid` (admin-token auth) +2. `submit_client` — a Gotify *client*, whose token becomes `PushUser.client_token` (basic-auth as the just-created user) +3. `submit_application` — a Gotify *application* under that client, whose token becomes `PushUser.application_token` — **this is the token push messages are actually sent with** + +### Bulk push + +An admin action (`BulkPushMessageAdmin`, `apps/push_notifications/admin.py`) triggers `BulkPushMessage.bulk_push_task()` → Celery `bulk_push` task → `BulkPushMessage.push_to_all(extract_data())`. + +`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` | + +`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 + +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. + +`apps/push_notifications/models.py`: + +```python +GOTIFY_CLICK_EXTRA_KEY = "client::notification" # Gotify's own reserved namespace + +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 +``` + +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. +- **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. + +## Email flow + +Two paths, both in `apps/emails/`: + +- **Immediate**: `Email.send()` → `send_email` Celery task → `Email._send_email()` → `django.core.mail.send_mail()`. +- **Queued digest**: `Email.objects.create(..., queue=some_queue)` — `EmailQueue` rows are checked every 10 seconds by `send_email_for_queued_events` (registered via `celery_app.conf.beat_schedule` directly in `apps/emails/tasks.py`, not through the `CELERY_BEAT_SCHEDULE` Django setting other Gooyal services use — see the overview doc's known-gaps section). A queue only actually sends once it's been at least 10 minutes since its `last_sent`, batching every `Email` created since then into one message. + +**`_send_email()` currently hard-codes the recipient** to a literal test address instead of `self.user`'s email — this means the immediate-send path doesn't actually reach the intended recipient today. Flagging this here since it's the kind of thing that's easy to assume "must already work" when reading the flow. + +## Auth internals + +This service is an OAuth2 **resource server** (`django-oauth-toolkit`), not an OAuth2 provider — it doesn't issue tokens, it validates ones issued by the Gooyal accounts service via introspection (`OAUTH2_PROVIDER['RESOURCE_SERVER_INTROSPECTION_URL']`, using this service's own `CLIENT_ID`/`CLIENT_SECRET` as the introspection credentials). + +`apps.gooyal_oauth2.rest_framework.IsAuthenticatedOrTokenMatchesOASRequirements` is the permission class every producer-facing endpoint uses. It accepts either: +- a plain authenticated (non-OAuth2) request, **or** +- an OAuth2 token whose scopes satisfy the view's `required_alternate_scopes` + +`apps.gooyal_oauth2.utils.get_application(request)` pulls the calling `Application` off `request.auth.application` — this is how a `PushMessage`/`Email` row knows which producer service created it, without trusting a client-supplied field. + +## Testing status + +`apps/push_notifications/tests.py` and `apps/emails/tests.py` are both empty stubs — `python manage.py test` reports 0 tests. Everything described in this doc and in [notification-click-actions.md](notification-click-actions.md) was verified manually (unit-level checks via `manage.py shell`, and live round-trips against the local Gotify 2.6.3 container), not via an automated suite. If you're adding tests, `push_notifications` is the higher-value target — it's the domain three other services actively depend on. + +## Notable migrations + +- `0004_pushmessage_click_url` — adds `click_url`. See "The click_url mechanism" above. + +## Known technical debt (implementation-level) + +- `apps/push_notifications/admin.py` has a half-built `PushMessageAdmin` custom form class (`PushMessageForm`, a `custom-action2` URL) that is **never registered** — `admin.site.register(PushMessage)` uses the plain default `ModelAdmin` instead. Dead code, safe to ignore or remove. +- Duplicate `app_name = 'push_notifications'` across `application_urls.py`/`user_urls.py` (see overview doc). +- Bulk import is locked to legacy `.xls` by the `xlrd` dependency; there's no `.xlsx` path. diff --git a/docs/service-overview.md b/docs/service-overview.md new file mode 100644 index 0000000..b22c57c --- /dev/null +++ b/docs/service-overview.md @@ -0,0 +1,111 @@ +# Notifications Service — Overview + +## What it is + +A Django/DRF microservice in the Gooyal/Winsoo ecosystem that delivers **push +notifications** (via a self-hosted [Gotify](https://gotify.net) server) and +**email** on behalf of other backend services. It does not originate any +notifications itself — every message is submitted by a producer service +(chat, promotions, advertising, ...) over an authenticated API call. + +``` +chat / promotions / advertising / ... + │ OAuth2 client-credentials + ▼ +notifications service (this repo) + │ │ + ▼ ▼ + Gotify server SMTP server + (push delivery) (email delivery) +``` + +## Tech stack + +| Layer | Choice | +|---|---| +| Framework | Django 5.2 + Django REST Framework | +| Auth | `django-oauth-toolkit` — this service is an OAuth2 **resource server**, validating caller tokens by introspection against the Gooyal accounts service | +| Push backend | Gotify **2.6.3** (pinned — see local dev setup below), driven through a generated client (`gotify_rest_api_client/`) | +| Async | Celery + Celery Beat, Redis as broker/result backend | +| DB | PostgreSQL | +| Email | Django's SMTP backend | +| Bulk import | `xlrd` (legacy `.xls` only) | +| Docs | drf-spectacular (OpenAPI/Swagger at `/swagger/`) | + +## Capabilities + +### 1. Push notifications +- **Single push**: `POST /push/application//application/` — a producer service pushes one message to one user. +- **Bulk push**: an admin-triggered Excel upload (`BulkPushMessage`) fans out a push per row to many users at once. +- **Click actions**: an optional `click_url` on either path is delivered to the client as Gotify's own `client::notification.click.url` extra. See [notification-click-actions.md](notification-click-actions.md) and [frontend-notification-click-actions.md](frontend-notification-click-actions.md) for the full mechanism and integration contract. + +### 2. Email +- **Single email**: `POST /email/application//` — sent immediately via Celery. +- **Queued digest**: emails can be attached to an `EmailQueue`; a periodic task (`send_email_for_queued_events`) batches queued emails per queue and sends a single digest, throttled to once per 10 minutes per queue. + +## How a producer service integrates + +Every producer vendors a generated OpenAPI client (`gooyal_notifications_client`, built from this service's own swagger spec) and wraps it in a small `utils/clients/notifications_client.py`: + +1. Obtains an access token via the OAuth2 **client-credentials** grant against the Gooyal accounts service, using its own `CLIENT_ID`/`CLIENT_SECRET` (cached until near expiry). +2. Calls `push_application_application_create` (or `email_application_application_create`) against `NOTIFICATIONS_BASE_PUBLIC_URL`, scoped to the target `user_uuid`. +3. This service resolves the calling application from the token (`apps.gooyal_oauth2.utils.get_application`) and records it against the created `PushMessage`/`Email` row — the producer is never sent as free-form data, it's derived from the authenticated client. + +Endpoints are scope-gated per action (`notifications.application.push:submit_message`, `notifications.application.email:submit_email`, `notifications.push:get_client_token`) via `IsAuthenticatedOrTokenMatchesOASRequirements`. + +As of this writing, three services integrate: **chat** (live, sends on every new message), **promotions** (wired, currently sends empty extras), **advertising** (the only service actually using `click_url` today — see the implementation doc). + +## Domain model + +| Model | Purpose | +|---|---| +| `PushUser` | Per-user Gotify identity — owns a Gotify user account, a Gotify client token, and an application token. Created lazily on first push (`PushUser.objects.submit(user)`). | +| `PushMessage` | One push notification: title, message, priority, `extras` (free-form JSON), `click_url` (optional), delivery state. | +| `BulkPushMessage` | An uploaded spreadsheet of push messages plus success/failure counters and state. | +| `Email` | One email: title, message, `extras`, optional `queue`, delivery state. | +| `EmailQueue` | A named digest queue; batches `Email` rows created since it last sent. | + +## Configuration (environment variables) + +| Variable | Purpose | +|---|---| +| `DEBUG` | Django debug flag | +| `DB_NAME` / `DB_USER` / `DB_PASSWORD` / `DB_HOST` / `DB_PORT` | Postgres connection | +| `REDIS_BASE_URL` | Celery broker/result backend + cache | +| `GOTIFY_BASE_PUBLIC_URL` | Base URL of the Gotify server | +| `GOTIFY_ADMIN_CLIENT_TOKEN` | Admin token used to provision Gotify users/clients/applications | +| `BASE_OAUTH2_PROVIDER_PUBLIC_URL` / `BASE_OAUTH2_PROVIDER_PRIVATE_URL` | Gooyal accounts service, for issuing and introspecting tokens | +| `CLIENT_ID` / `CLIENT_SECRET` | This service's own OAuth2 introspection credentials | +| `SCOPES` | OAuth2 scopes this service requests | +| `EMAIL_HOST` / `EMAIL_PORT` / `EMAIL_HOST_USER` / `EMAIL_HOST_PASSWORD` / `EMAIL_USE_TLS` / `EMAIL_USE_SSL` / `EMAIL_TIMEOUT` | SMTP config | + +## Local development + +Three Docker containers back local dev (see `.env`): + +| Container | Image | Host port | +|---|---|---| +| `notif_postgres` | `postgres:16` | 5433 | +| `notif_redis` | `redis:7-alpine` | 6380 | +| `notif_gotify` | `gotify/server:2.6.3` | 8888 | + +**The Gotify image must stay pinned to `2.6.3`** — later versions (v3+) issue longer tokens that don't fit this service's `client_token`/`application_token` `CharField(max_length=32)` columns. + +```bash +docker start notif_postgres notif_redis notif_gotify +python manage.py migrate +python manage.py runserver +``` + +## Deployment + +- `Dockerfile` — Debian-based image, installs `requirements.txt`. +- `run.sh` — waits for Postgres, runs migrations, serves via `gunicorn main.wsgi:application`. +- `celery.sh` — runs a combined worker + beat process (`celery -A main worker -B`). + +## Known gaps + +- **No automated tests** in `apps/push_notifications` or `apps/emails` (both `tests.py` are stubs). +- **`Email._send_email()` hard-codes the recipient** (`xdshia49@gmail.com`) instead of `self.user`'s real email — looks like a debugging leftover, not wired to actual user emails yet. +- **Celery Beat is registered two different ways**: `apps.emails.tasks` assigns `celery_app.conf.beat_schedule` directly at import time, separate from the more conventional `CELERY_BEAT_SCHEDULE` Django setting used elsewhere in the Gooyal codebase (e.g. `advertising`). Both work, but it's an inconsistency worth normalizing. +- **URL namespace collision**: `apps/push_notifications/application_urls.py` and `user_urls.py` both set `app_name = 'push_notifications'`, producing a `urls.W005` warning on every `manage.py check`. Harmless (URLs still resolve) but ambiguous for reverse lookups. -- 2.45.3 From 707407bb52699bfabcb081e5697b17cf55780dbb Mon Sep 17 00:00:00 2001 From: Ali Asadi Date: Sat, 22 Aug 2026 14:35:55 +0330 Subject: [PATCH 4/5] FEAT(notifications): admin-managed code -> URL mapping for click actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The click_url a producer sends had to be built in that producer's own Python code (utils/deep_links.py in advertising), so changing the URL scheme meant a code deploy. Adds NotificationLink: an admin-editable (code, url_template, is_active) row per notification type, with url_template supporting a {object_id} placeholder. PushMessage gains `code` and `click_object_id` — a producer can send either a raw click_url (unchanged, still wins if both are given) or a code + the referenced object's id, and resolve_click_url() looks up the template at send time. An unset or not-yet-configured/inactive code resolves to no click action rather than an error, consistent with "not every notification is clickable." Wired through both the single-push API (serializer) and the bulk Excel path (two new optional columns). Co-Authored-By: Claude Sonnet 5 --- apps/push_notifications/admin.py | 9 ++- ...nk_pushmessage_click_object_id_and_more.py | 39 ++++++++++++ apps/push_notifications/models.py | 63 +++++++++++++++++-- apps/push_notifications/serializers.py | 4 +- 4 files changed, 107 insertions(+), 8 deletions(-) create mode 100644 apps/push_notifications/migrations/0005_notificationlink_pushmessage_click_object_id_and_more.py diff --git a/apps/push_notifications/admin.py b/apps/push_notifications/admin.py index 145b537..1af021d 100644 --- a/apps/push_notifications/admin.py +++ b/apps/push_notifications/admin.py @@ -6,12 +6,19 @@ from django.contrib.admin.options import TO_FIELD_VAR from django.core.exceptions import PermissionDenied from django.template.response import TemplateResponse -from .models import PushUser, PushMessage, BulkPushMessage +from .models import PushUser, PushMessage, BulkPushMessage, NotificationLink admin.site.register(PushUser) +@admin.register(NotificationLink) +class NotificationLinkAdmin(admin.ModelAdmin): + list_display = ['code', 'url_template', 'is_active', 'description'] + list_filter = ['is_active'] + search_fields = ['code', 'description'] + + from django import forms from django.shortcuts import render, redirect diff --git a/apps/push_notifications/migrations/0005_notificationlink_pushmessage_click_object_id_and_more.py b/apps/push_notifications/migrations/0005_notificationlink_pushmessage_click_object_id_and_more.py new file mode 100644 index 0000000..00a2640 --- /dev/null +++ b/apps/push_notifications/migrations/0005_notificationlink_pushmessage_click_object_id_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 5.2.6 on 2026-08-22 10:59 + +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('push_notifications', '0004_pushmessage_click_url'), + ] + + operations = [ + migrations.CreateModel( + name='NotificationLink', + fields=[ + ('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True, db_index=True)), + ('code', models.SlugField(max_length=100, unique=True)), + ('description', models.CharField(blank=True, max_length=255)), + ('url_template', models.CharField(help_text='Use {object_id} as a placeholder, e.g. https://app.gooyal.ir/ads/{object_id}', max_length=1000)), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='pushmessage', + name='click_object_id', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='pushmessage', + name='code', + field=models.SlugField(blank=True, max_length=100, null=True), + ), + ] diff --git a/apps/push_notifications/models.py b/apps/push_notifications/models.py index b176b43..07dcadf 100644 --- a/apps/push_notifications/models.py +++ b/apps/push_notifications/models.py @@ -44,6 +44,30 @@ class PushUser(BaseModel): return +class NotificationLink(BaseModel): + """ + Admin-managed code -> URL mapping for notification click actions. Producer + services send a `code` (documented per notification type) instead of a raw + URL, so where a tap should navigate is a config change here, not a code + deploy across every producer service. + """ + code = models.SlugField(max_length=100, unique=True, db_index=True) + description = models.CharField(max_length=255, blank=True) + url_template = models.CharField( + max_length=1000, + help_text="Use {object_id} as a placeholder, e.g. https://app.gooyal.ir/ads/{object_id}", + ) + is_active = models.BooleanField(default=True) + + def __str__(self): + return self.code + + def resolve(self, object_id=None): + if object_id and '{object_id}' in self.url_template: + return self.url_template.format(object_id=object_id) + return self.url_template + + class PushMessage(BaseModel): # Gotify's reserved extras namespace for client-side actions. Official # clients (Android/iOS/web) open this URL when the notification is tapped: @@ -65,17 +89,38 @@ class PushMessage(BaseModel): priority = models.IntegerField(default=5) extras = models.JSONField(default=dict) click_url = models.URLField(max_length=1000, null=True, blank=True) + # Alternative to a raw click_url: a documented per-notification-type code, + # resolved against NotificationLink at send time (see get_gotify_extras). + # click_object_id is substituted into that code's {object_id} placeholder. + 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) stats = models.IntegerField(default=StateChoices.INIT, choices=StateChoices.choices) bulk = models.ForeignKey("BulkPushMessage", null=True, blank=True, on_delete=models.PROTECT) - def get_gotify_extras(self): - """Merge click_url into extras using Gotify's click-action convention, - without disturbing any other extras keys the caller already set.""" - extras = dict(self.extras or {}) + def resolve_click_url(self): + """An explicit click_url always wins; otherwise resolve via `code` + against NotificationLink. Returns None (not an error) if `code` isn't + set, or isn't configured/active yet — not every notification is + clickable, and a not-yet-configured code shouldn't block delivery.""" 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): + """Merge the resolved click url into extras using Gotify's click-action + convention, without disturbing any other extras keys the caller already + set.""" + extras = dict(self.extras or {}) + 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 @@ -115,7 +160,9 @@ class BulkPushMessage(BaseModel): message=data[user_uuid].get('message'), priority=data[user_uuid].get('priority', 5), extras=data[user_uuid].get("extras", {}), - click_url=data[user_uuid].get("click_url") or None) + click_url=data[user_uuid].get("click_url") or None, + code=data[user_uuid].get("code") or None, + click_object_id=data[user_uuid].get("click_object_id") or None) push_message.send_push() self.success_count += 1 @@ -146,6 +193,8 @@ class BulkPushMessage(BaseModel): else: extras = {} click_url = str(sh.cell(rowx=row, colx=5).value).strip() if sh.ncols > 5 else "" + code = str(sh.cell(rowx=row, colx=6).value).strip() if sh.ncols > 6 else "" + click_object_id = str(sh.cell(rowx=row, colx=7).value).strip() if sh.ncols > 7 else "" push_dict[user_uuid] = { "title" : title, @@ -153,5 +202,7 @@ class BulkPushMessage(BaseModel): "priority" : priority, "extras" : extras, "click_url": click_url or None, + "code": code or None, + "click_object_id": click_object_id or None, } return push_dict diff --git a/apps/push_notifications/serializers.py b/apps/push_notifications/serializers.py index 5601759..1723f1d 100644 --- a/apps/push_notifications/serializers.py +++ b/apps/push_notifications/serializers.py @@ -22,6 +22,8 @@ class PushMessageSerializer(serializers.ModelSerializer): "message", "priority", "extras", - "click_url" + "click_url", + "code", + "click_object_id", ) read_only_fields = ("push_user","application") -- 2.45.3 From d473f61790b8ebe5037b1ee7388d6d383ed552d0 Mon Sep 17 00:00:00 2001 From: Ali Asadi Date: Sat, 22 Aug 2026 15:23:45 +0330 Subject: [PATCH 5/5] 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 --- docs/frontend-notification-click-actions.md | 121 +++++++++++--------- docs/implementation.md | 55 ++++++--- docs/notification-click-actions.md | 8 ++ 3 files changed, 119 insertions(+), 65 deletions(-) diff --git a/docs/frontend-notification-click-actions.md b/docs/frontend-notification-click-actions.md index 40dc0f2..14b1135 100644 --- a/docs/frontend-notification-click-actions.md +++ b/docs/frontend-notification-click-actions.md @@ -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/` or `/escrow/` appended. **Nobody has confirmed this -matches your actual app's routing** — whether you use a custom URI scheme -(`gooyal://ads/`), 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/`) or escrow deal (`/escrow/`): +## What's clickable today, by code -### Billboards & content (→ `/ads/`) +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/`) +### 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? diff --git a/docs/implementation.md b/docs/implementation.md index 7c44ae7..55b5189 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -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) diff --git a/docs/notification-click-actions.md b/docs/notification-click-actions.md index 86fc0aa..90dc1c8 100644 --- a/docs/notification-click-actions.md +++ b/docs/notification-click-actions.md @@ -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. -- 2.45.3