feature/notification-actions #1
3 changed files with 348 additions and 0 deletions
117
docs/frontend-notification-click-actions.md
Normal file
117
docs/frontend-notification-click-actions.md
Normal 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
120
docs/implementation.md
Normal 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
111
docs/service-overview.md
Normal 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.
|
||||
Loading…
Add table
Reference in a new issue