feature/notification-actions #1

Merged
Ghasemi merged 5 commits from feature/notification-actions into master 2026-08-23 03:42:40 -04:00
3 changed files with 348 additions and 0 deletions
Showing only changes of commit 354bbc26cf - Show all commits

View file

@ -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/<uuid>` or `/escrow/<uuid>` appended. **Nobody has confirmed this
matches your actual app's routing** — whether you use a custom URI scheme
(`gooyal://ads/<uuid>`), a universal/app link (`https://...`), or something
else entirely (a route name + params instead of a URL at all).
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/<uuid>`) or escrow deal (`/escrow/<uuid>`):
### Billboards & content (→ `/ads/<uuid>`)
| 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/<uuid>`)
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?

120
docs/implementation.md Normal file
View file

@ -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/<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 |
|---|---|---|---|---|---|---|
| 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.

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.