Compare commits

...

5 commits

Author SHA1 Message Date
d473f61790 docs(notifications): update docs for the code -> NotificationLink mechanism
Reflects the switch from hard-coded FRONTEND_BASE_URL/utils.deep_links to
admin-managed NotificationLink rows: implementation.md documents the
resolution logic and the new migration, frontend-notification-click-actions.md
replaces the "confirm your URL scheme" ask with the actual code catalog
(now that the scheme itself is an admin panel concern, not something the
frontend team needs to weigh in on for us to ship), and
notification-click-actions.md gets a pointer at the top so it reads as the
historical first pass it now is, not the current mechanism.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 15:23:45 +03:30
707407bb52 FEAT(notifications): admin-managed code -> URL mapping for click actions
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 <noreply@anthropic.com>
2026-08-22 14:35:55 +03:30
354bbc26cf docs(notifications): add service overview, implementation guide, and frontend handoff doc
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 <noreply@anthropic.com>
2026-08-22 11:39:30 +03:30
8f83a5e84a 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 <noreply@anthropic.com>
2026-08-22 09:36:06 +03:30
6aedf7aa2f chore: ignore __pycache__ and compiled Python files
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 09:35:53 +03:30
11 changed files with 784 additions and 5 deletions

4
.gitignore vendored
View file

@ -2,3 +2,7 @@
.env .env
media media
/clients/ /clients/
__pycache__/
*.py[cod]
*$py.class

View file

@ -6,12 +6,19 @@ from django.contrib.admin.options import TO_FIELD_VAR
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from .models import PushUser, PushMessage, BulkPushMessage from .models import PushUser, PushMessage, BulkPushMessage, NotificationLink
admin.site.register(PushUser) 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 import forms
from django.shortcuts import render, redirect from django.shortcuts import render, redirect

View file

@ -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),
),
]

View file

@ -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),
),
]

View file

@ -44,7 +44,36 @@ class PushUser(BaseModel):
return 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): 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): class StateChoices(models.IntegerChoices):
INIT = 0, _('init') INIT = 0, _('init')
START = 1, _('Start') START = 1, _('Start')
@ -59,10 +88,42 @@ class PushMessage(BaseModel):
message = models.TextField() message = models.TextField()
priority = models.IntegerField(default=5) priority = models.IntegerField(default=5)
extras = models.JSONField(default=dict) 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) stats = models.IntegerField(default=StateChoices.INIT, choices=StateChoices.choices)
bulk = models.ForeignKey("BulkPushMessage", null=True, blank=True, on_delete=models.PROTECT) bulk = models.ForeignKey("BulkPushMessage", null=True, blank=True, on_delete=models.PROTECT)
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": click_url}
extras[self.GOTIFY_CLICK_EXTRA_KEY] = notification_extra
return extras
def send_push(self): def send_push(self):
from apps.push_notifications.tasks import send_push_notification from apps.push_notifications.tasks import send_push_notification
send_push_notification.delay(str(self.uuid)) send_push_notification.delay(str(self.uuid))
@ -98,7 +159,10 @@ class BulkPushMessage(BaseModel):
title=data[user_uuid].get('title'), title=data[user_uuid].get('title'),
message=data[user_uuid].get('message'), message=data[user_uuid].get('message'),
priority=data[user_uuid].get('priority', 5), 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,
code=data[user_uuid].get("code") or None,
click_object_id=data[user_uuid].get("click_object_id") or None)
push_message.send_push() push_message.send_push()
self.success_count += 1 self.success_count += 1
@ -125,14 +189,20 @@ class BulkPushMessage(BaseModel):
priority = int(float(str(sh.cell(rowx=row, colx=3).value).strip())) priority = int(float(str(sh.cell(rowx=row, colx=3).value).strip()))
extras_str = sh.cell(rowx=row, colx=4).value.strip() extras_str = sh.cell(rowx=row, colx=4).value.strip()
if extras_str: if extras_str:
extras = json.loads(extras) extras = json.loads(extras_str)
else: else:
extras = {} 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] = { push_dict[user_uuid] = {
"title" : title, "title" : title,
"message" : message, "message" : message,
"priority" : priority, "priority" : priority,
"extras" : extras, "extras" : extras,
"click_url": click_url or None,
"code": code or None,
"click_object_id": click_object_id or None,
} }
return push_dict return push_dict

View file

@ -21,6 +21,9 @@ class PushMessageSerializer(serializers.ModelSerializer):
"title", "title",
"message", "message",
"priority", "priority",
"extras" "extras",
"click_url",
"code",
"click_object_id",
) )
read_only_fields = ("push_user","application") read_only_fields = ("push_user","application")

View file

@ -10,7 +10,7 @@ def send_push_notification(push_message_uuid):
title=push_message.title, title=push_message.title,
message=push_message.message, message=push_message.message,
priority=push_message.priority, priority=push_message.priority,
extras=push_message.extras) extras=push_message.get_gotify_extras())
push_message.state = push_message.StateChoices.DONE push_message.state = push_message.StateChoices.DONE
push_message.save() push_message.save()

View file

@ -0,0 +1,134 @@
# 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.
## URLs are admin-configured, not hard-coded — nothing for you to build here
The actual destination string is no longer computed in backend code. Each
notification type has 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 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.
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.
## What's clickable today, by code
All of these are live in the `advertising` service:
### Billboards & content
| Code | Notification text (fa) |
|---|---|
| `billboard.viewed` | شخصی شروع به مشاهده بیلبورد شما کرد. |
| `billboard.created` | بیلبود شما با موفقیت ساخته شد. |
| `billboard.approved` | بیلبورد شما تایید شد. |
| `billboard.rejected` | بیلبود شما رد شد. |
| `billboard.commented` | یک نفر برای بیلبورد شما نظر گذاشت! |
| `billboard.replied` | یک نفر به نظر شما پاسخ داد! |
| `billboard.content_bought` | درآمد جدید دارید! یک نفر محتوای شما را خرید. |
| `billboard.content_supported` | یک نفر از محتوای شما حمایت کرد! |
| `billboard.pin_expiring` | زمان پین رو به اتمامه! |
| `billboard.pin_expired` | پین شما منقضی شد! |
| `billboard.credit_low` | اعتبار پین رو به اتمامه! |
| `billboard.credit_exhausted` | اعتبار پین شما به پایان رسید! |
Every 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
| 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 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.

149
docs/implementation.md Normal file
View file

@ -0,0 +1,149 @@
# 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/<user_uuid>/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 | 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 action mechanism
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`:
```python
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 {})
click_url = self.resolve_click_url()
if click_url:
notification_extra = dict(extras.get(self.GOTIFY_CLICK_EXTRA_KEY) or {})
notification_extra["click"] = {"url": click_url}
extras[self.GOTIFY_CLICK_EXTRA_KEY] = notification_extra
return extras
```
Design decisions worth knowing if you touch this:
- **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 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
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`.
- `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)
- `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.

View file

@ -0,0 +1,244 @@
# 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.
## 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/<user_uuid>/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/<user_uuid>/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.

111
docs/service-overview.md Normal file
View file

@ -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/<user_uuid>/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/<user_uuid>/` — 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.