feature/notification-actions #5
5 changed files with 307 additions and 7 deletions
|
|
@ -127,12 +127,18 @@ class PushMessage(BaseModel):
|
|||
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."""
|
||||
set. Also passes action_code through as-is, when set — even if it
|
||||
didn't resolve to a click_url yet (e.g. no NotificationLink row for
|
||||
it) — so the frontend can see which action_code a notification
|
||||
carries, not just the URL it happened to resolve to."""
|
||||
extras = dict(self.extras or {})
|
||||
click_url = self.resolve_click_url()
|
||||
if click_url:
|
||||
if click_url or self.action_code:
|
||||
notification_extra = dict(extras.get(self.GOTIFY_CLICK_EXTRA_KEY) or {})
|
||||
notification_extra["click"] = {"url": click_url}
|
||||
if click_url:
|
||||
notification_extra["click"] = {"url": click_url}
|
||||
if self.action_code:
|
||||
notification_extra["action_code"] = self.action_code
|
||||
extras[self.GOTIFY_CLICK_EXTRA_KEY] = notification_extra
|
||||
return extras
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@ is clickable at all — is under a reserved key, per
|
|||
"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" }
|
||||
"click": { "url": "https://app.gooyal.ir/ads/8fb638c2-e6a4-4baf-aefe-83dab78fb5bd" },
|
||||
"action_code": "billboard.approved"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -34,6 +35,13 @@ is clickable at all — is under a reserved key, per
|
|||
Pull `extras["client::notification"]["click"]["url"]`. If it's present, tapping
|
||||
navigates there. **If it's absent, do nothing on tap** — see next section.
|
||||
|
||||
`extras["client::notification"]["action_code"]` rides along too, whenever the
|
||||
producer sent one — even if it hasn't resolved to a `click` URL yet (no
|
||||
`NotificationLink` row for it, or it's inactive). You don't need it to
|
||||
implement tap-to-navigate; it's there for client-side logic keyed off the
|
||||
notification *type* itself (grouping, icons, analytics, a fallback in-app
|
||||
handler for a code before its admin row exists), not just its destination.
|
||||
|
||||
## `click_url` is optional — most notifications are not clickable
|
||||
|
||||
Do not assume every notification carries a URL. The backend field this comes
|
||||
|
|
|
|||
|
|
@ -87,7 +87,10 @@ Two ways to set a destination on a `PushMessage`:
|
|||
GOTIFY_CLICK_EXTRA_KEY = "client::notification" # Gotify's own reserved namespace
|
||||
|
||||
click_url = models.URLField(max_length=1000, null=True, blank=True)
|
||||
action_code = models.SlugField(max_length=100, null=True, blank=True, db_index=True)
|
||||
# CharField, not SlugField -- the documented catalog is dotted
|
||||
# (billboard.approved, escrow.timeout, ...) and SlugField rejects dots.
|
||||
action_code = models.CharField(max_length=100, null=True, blank=True, db_index=True,
|
||||
validators=[validate_action_code])
|
||||
click_object_id_value = models.CharField(max_length=255, null=True, blank=True)
|
||||
|
||||
def resolve_click_url(self):
|
||||
|
|
@ -103,15 +106,19 @@ def resolve_click_url(self):
|
|||
def get_gotify_extras(self):
|
||||
extras = dict(self.extras or {})
|
||||
click_url = self.resolve_click_url()
|
||||
if click_url:
|
||||
if click_url or self.action_code:
|
||||
notification_extra = dict(extras.get(self.GOTIFY_CLICK_EXTRA_KEY) or {})
|
||||
notification_extra["click"] = {"url": click_url}
|
||||
if click_url:
|
||||
notification_extra["click"] = {"url": click_url}
|
||||
if self.action_code:
|
||||
notification_extra["action_code"] = self.action_code
|
||||
extras[self.GOTIFY_CLICK_EXTRA_KEY] = notification_extra
|
||||
return extras
|
||||
```
|
||||
|
||||
Design decisions worth knowing if you touch this:
|
||||
- **An unresolvable action_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 action_code before anyone's configured it in the admin; nothing breaks, the notification just isn't clickable yet.
|
||||
- **`action_code` itself rides along in extras, not just the URL it resolves to** — added after testing against a live Gotify container surfaced that the frontend had no way to see which action_code produced a notification, only its resolved destination (or nothing, if unresolved). It's included even when there's no click_url yet, so the frontend can key client-side logic off the notification type itself.
|
||||
- **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 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 `action_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 action_code catalog they need to fill in.
|
||||
|
|
|
|||
259
postman/notifications-click-actions.postman_collection.json
Normal file
259
postman/notifications-click-actions.postman_collection.json
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
{
|
||||
"info": {
|
||||
"name": "Notifications — click-action flow",
|
||||
"description": "End-to-end test of the push-notification click-action mechanism: get an OAuth2 token, fetch a user's Gotify client_token from the notifications service, send a push with action_code/click_object_id_value (or a raw click_url), then read the message back directly from Gotify to confirm the click.url resolved correctly.\n\nRun folders top to bottom. Requests that produce a token/id set it into a collection variable automatically (see each request's Tests tab) so later requests don't need manual copy-pasting.\n\nImport `notifications-staging.postman_environment.json` alongside this collection and select it before running.",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||
},
|
||||
"variable": [
|
||||
{ "key": "notifications_base_url", "value": "https://notifications-staging.gooyal.ir" },
|
||||
{ "key": "gotify_base_url", "value": "" },
|
||||
{ "key": "oauth2_token_url", "value": "" },
|
||||
{ "key": "client_id", "value": "" },
|
||||
{ "key": "client_secret", "value": "" },
|
||||
{ "key": "app_scopes", "value": "notifications.application.push:submit_message" },
|
||||
{ "key": "user_scopes", "value": "notifications.push:get_client_token" },
|
||||
{ "key": "user_username", "value": "" },
|
||||
{ "key": "user_password", "value": "" },
|
||||
{ "key": "app_access_token", "value": "" },
|
||||
{ "key": "user_access_token", "value": "" },
|
||||
{ "key": "user_uuid", "value": "" },
|
||||
{ "key": "client_token", "value": "" },
|
||||
{ "key": "application_token", "value": "" },
|
||||
{ "key": "gotify_since", "value": "0" }
|
||||
],
|
||||
"item": [
|
||||
{
|
||||
"name": "1. Auth",
|
||||
"item": [
|
||||
{
|
||||
"name": "Get app access token (client_credentials)",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{ "key": "Content-Type", "value": "application/x-www-form-urlencoded" }
|
||||
],
|
||||
"url": { "raw": "{{oauth2_token_url}}", "host": ["{{oauth2_token_url}}"] },
|
||||
"body": {
|
||||
"mode": "urlencoded",
|
||||
"urlencoded": [
|
||||
{ "key": "grant_type", "value": "client_credentials" },
|
||||
{ "key": "client_id", "value": "{{client_id}}" },
|
||||
{ "key": "client_secret", "value": "{{client_secret}}" },
|
||||
{ "key": "scope", "value": "{{app_scopes}}" }
|
||||
]
|
||||
},
|
||||
"description": "This is the credential a PRODUCER service (chat/promotions/advertising) uses server-to-server to call the push endpoint. Fill in oauth2_token_url/client_id/client_secret from the producer application's OAuth2 client registered against the Gooyal accounts service. On success, the Tests script below stores the token as {{app_access_token}}."
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"exec": [
|
||||
"if (pm.response.code === 200) {",
|
||||
" const json = pm.response.json();",
|
||||
" pm.collectionVariables.set('app_access_token', json.access_token);",
|
||||
" pm.test('got app access_token', () => pm.expect(json.access_token).to.be.a('string'));",
|
||||
"} else {",
|
||||
" pm.test('token request failed: ' + pm.response.code, () => pm.expect.fail(pm.response.text()));",
|
||||
"}"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Get user access token (password grant)",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{ "key": "Content-Type", "value": "application/x-www-form-urlencoded" }
|
||||
],
|
||||
"url": { "raw": "{{oauth2_token_url}}", "host": ["{{oauth2_token_url}}"] },
|
||||
"body": {
|
||||
"mode": "urlencoded",
|
||||
"urlencoded": [
|
||||
{ "key": "grant_type", "value": "password" },
|
||||
{ "key": "username", "value": "{{user_username}}" },
|
||||
{ "key": "password", "value": "{{user_password}}" },
|
||||
{ "key": "client_id", "value": "{{client_id}}" },
|
||||
{ "key": "client_secret", "value": "{{client_secret}}" },
|
||||
{ "key": "scope", "value": "{{user_scopes}}" }
|
||||
]
|
||||
},
|
||||
"description": "This is a REAL logged-in Gooyal user's own token — what the mobile/web frontend would carry. Swap for whatever grant your accounts service actually issues client tokens with (this assumes password grant for a quick staging test; use authorization_code in the real app). On success, stores {{user_access_token}}."
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"exec": [
|
||||
"if (pm.response.code === 200) {",
|
||||
" const json = pm.response.json();",
|
||||
" pm.collectionVariables.set('user_access_token', json.access_token);",
|
||||
" pm.test('got user access_token', () => pm.expect(json.access_token).to.be.a('string'));",
|
||||
"} else {",
|
||||
" pm.test('token request failed: ' + pm.response.code, () => pm.expect.fail(pm.response.text()));",
|
||||
"}"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "2. Notifications service",
|
||||
"item": [
|
||||
{
|
||||
"name": "Get my push_user (client_token)",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{ "key": "Authorization", "value": "Bearer {{user_access_token}}" }
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{notifications_base_url}}/push/user/push_user/",
|
||||
"host": ["{{notifications_base_url}}"],
|
||||
"path": ["push", "user", "push_user", ""]
|
||||
},
|
||||
"description": "Called AS THE LOGGED-IN USER (user_access_token, not app_access_token). Lazily provisions the user's Gotify identity on first call. Returns client_token (frontend uses this to read its own messages from Gotify) and application_token (backend-only — a producer service shouldn't leak this to a client; see notes in the notifications docs). Also captures user_uuid for the send-push request below."
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"exec": [
|
||||
"if (pm.response.code === 200) {",
|
||||
" const json = pm.response.json();",
|
||||
" pm.collectionVariables.set('user_uuid', json.user);",
|
||||
" pm.collectionVariables.set('client_token', json.client_token);",
|
||||
" pm.collectionVariables.set('application_token', json.application_token);",
|
||||
" pm.test('has client_token', () => pm.expect(json.client_token).to.be.a('string'));",
|
||||
"} else {",
|
||||
" pm.test('request failed: ' + pm.response.code, () => pm.expect.fail(pm.response.text()));",
|
||||
"}"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Send push — action_code + click_object_id_value",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{ "key": "Authorization", "value": "Bearer {{app_access_token}}" },
|
||||
{ "key": "Content-Type", "value": "application/json" }
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{notifications_base_url}}/push/application/{{user_uuid}}/application/",
|
||||
"host": ["{{notifications_base_url}}"],
|
||||
"path": ["push", "application", "{{user_uuid}}", "application", ""]
|
||||
},
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"title\": \"Billboard approved\",\n \"message\": \"Tap to view your billboard\",\n \"priority\": 5,\n \"extras\": {},\n \"action_code\": \"billboard.approved\",\n \"click_object_id_value\": \"8fb638c2-e6a4-4baf-aefe-83dab78fb5bd\"\n}"
|
||||
},
|
||||
"description": "Called AS THE PRODUCER SERVICE (app_access_token). Resolves action_code against the admin-managed NotificationLink table at send time — make sure that row exists and is active in staging (see the seed_notification_links management command), otherwise the message still sends but with no click_url. Response 201 means the PushMessage row was created and a Celery task was enqueued to actually deliver it to Gotify."
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"exec": [
|
||||
"pm.test('push accepted (201)', () => pm.expect(pm.response.code).to.equal(201));"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Send push — raw click_url (alternative)",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{ "key": "Authorization", "value": "Bearer {{app_access_token}}" },
|
||||
{ "key": "Content-Type", "value": "application/json" }
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{notifications_base_url}}/push/application/{{user_uuid}}/application/",
|
||||
"host": ["{{notifications_base_url}}"],
|
||||
"path": ["push", "application", "{{user_uuid}}", "application", ""]
|
||||
},
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"title\": \"Order shipped\",\n \"message\": \"Tap to view your order\",\n \"priority\": 5,\n \"extras\": {},\n \"click_url\": \"https://app.gooyal.ir/orders/123\"\n}"
|
||||
},
|
||||
"description": "click_url always wins over action_code if both are set. Useful to confirm the plumbing works independent of the NotificationLink admin table."
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"exec": [
|
||||
"pm.test('push accepted (201)', () => pm.expect(pm.response.code).to.equal(201));"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "3. Gotify (read back)",
|
||||
"item": [
|
||||
{
|
||||
"name": "Get my messages (client_token)",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{ "key": "X-Gotify-Key", "value": "{{client_token}}" }
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{gotify_base_url}}/message?limit=100",
|
||||
"host": ["{{gotify_base_url}}"],
|
||||
"path": ["message"],
|
||||
"query": [
|
||||
{ "key": "limit", "value": "100" }
|
||||
]
|
||||
},
|
||||
"description": "Reads Gotify DIRECTLY — not through the notifications service, which has no read endpoint of its own. Authenticates as the user via client_token (from step 2), so this only ever returns that user's own messages; Gotify enforces the isolation. Wait a second or two after sending for the Celery task to actually deliver before checking. Confirm extras[\"client::notification\"][\"click\"][\"url\"] matches what you expect from the action_code/click_url you sent."
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"exec": [
|
||||
"pm.test('200 OK', () => pm.expect(pm.response.code).to.equal(200));",
|
||||
"if (pm.response.code === 200) {",
|
||||
" const json = pm.response.json();",
|
||||
" console.log('messages:', JSON.stringify(json.messages, null, 2));",
|
||||
"}"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Get messages since a given id (incremental poll)",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{ "key": "X-Gotify-Key", "value": "{{client_token}}" }
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{gotify_base_url}}/message?since={{gotify_since}}&limit=100",
|
||||
"host": ["{{gotify_base_url}}"],
|
||||
"path": ["message"],
|
||||
"query": [
|
||||
{ "key": "since", "value": "{{gotify_since}}" },
|
||||
{ "key": "limit", "value": "100" }
|
||||
]
|
||||
},
|
||||
"description": "Same as above but only messages newer than gotify_since (a message id) — this is the shape a real client polling loop would use to avoid re-fetching everything. Set gotify_since to the paging.since value from a previous response to page forward."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
20
postman/notifications-staging.postman_environment.json
Normal file
20
postman/notifications-staging.postman_environment.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"name": "notifications-staging",
|
||||
"values": [
|
||||
{ "key": "notifications_base_url", "value": "https://notifications-staging.gooyal.ir", "enabled": true },
|
||||
{ "key": "gotify_base_url", "value": "FILL_IN_STAGING_GOTIFY_PUBLIC_URL", "enabled": true },
|
||||
{ "key": "oauth2_token_url", "value": "FILL_IN_ACCOUNTS_SERVICE_TOKEN_URL", "enabled": true },
|
||||
{ "key": "client_id", "value": "FILL_IN_OAUTH2_CLIENT_ID", "enabled": true },
|
||||
{ "key": "client_secret", "value": "FILL_IN_OAUTH2_CLIENT_SECRET", "enabled": true, "type": "secret" },
|
||||
{ "key": "app_scopes", "value": "notifications.application.push:submit_message", "enabled": true },
|
||||
{ "key": "user_scopes", "value": "notifications.push:get_client_token", "enabled": true },
|
||||
{ "key": "user_username", "value": "FILL_IN_TEST_USER_USERNAME", "enabled": true },
|
||||
{ "key": "user_password", "value": "FILL_IN_TEST_USER_PASSWORD", "enabled": true, "type": "secret" },
|
||||
{ "key": "app_access_token", "value": "", "enabled": true, "type": "secret" },
|
||||
{ "key": "user_access_token", "value": "", "enabled": true, "type": "secret" },
|
||||
{ "key": "user_uuid", "value": "", "enabled": true },
|
||||
{ "key": "client_token", "value": "", "enabled": true, "type": "secret" },
|
||||
{ "key": "application_token", "value": "", "enabled": true, "type": "secret" },
|
||||
{ "key": "gotify_since", "value": "0", "enabled": true }
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue