diff --git a/docs/frontend-notification-click-actions.md b/docs/frontend-notification-click-actions.md new file mode 100644 index 0000000..40dc0f2 --- /dev/null +++ b/docs/frontend-notification-click-actions.md @@ -0,0 +1,117 @@ +# Frontend integration guide: notification click actions + +**Audience**: the team building the Gooyal client app(s) that receive Gotify +push notifications. This is everything you need to start building tap-to-navigate, +independent of the backend repos. + +## The one thing to build + +When a push notification is tapped, read a URL out of the notification's +payload and navigate to it. That's the entire client-side contract — one +handler, used by every notification type below, present or future. + +## Where the URL lives + +Gotify delivers a JSON payload per message. The URL — when the notification +is clickable at all — is under a reserved key, per +[Gotify's own convention](https://gotify.net/docs/pushmsg#extras): + +```json +{ + "title": "بیلبورد شما تایید شد.", + "message": "بیلبورد گربه (تست) تایید شد.", + "priority": 5, + "extras": { + "ads::ad::approve": true, + "ad_uuid": "8fb638c2-e6a4-4baf-aefe-83dab78fb5bd", + "client::notification": { + "click": { "url": "https://app.gooyal.ir/ads/8fb638c2-e6a4-4baf-aefe-83dab78fb5bd" } + } + } +} +``` + +Pull `extras["client::notification"]["click"]["url"]`. If it's present, tapping +navigates there. **If it's absent, do nothing on tap** — see next section. + +## `click_url` is optional — most notifications are not clickable + +Do not assume every notification carries a URL. The backend field this comes +from is explicitly optional (nullable end-to-end, not just "sometimes empty +string") specifically because not every notification type has a sensible +destination. Any notification without a `client::notification.click.url` key +should render and behave exactly as a non-interactive notification — no +error, no dead tap target, just no navigation. + +You may also see other keys under `extras` alongside (or instead of) +`client::notification` — e.g. chat currently sends `conversation_uuid` and +`post_id` directly, not yet through this mechanism (see "Not yet migrated" +below). Ignore keys you don't recognize; don't treat an unfamiliar key as an +error. + +## ⚠️ The URL format below is a placeholder — needs your input + +Every `click_url` currently being sent is built from a setting called +`FRONTEND_BASE_URL`, defaulting to `https://app.gooyal.ir`, with a path like +`/ads/` or `/escrow/` appended. **Nobody has confirmed this +matches your actual app's routing** — whether you use a custom URI scheme +(`gooyal://ads/`), a universal/app link (`https://...`), or something +else entirely (a route name + params instead of a URL at all). + +This was built centrally on purpose so it's a one-line change once you tell +us: every `click_url` in the system is built by one helper function per +producer service (`utils/deep_links.py` in `advertising`), not scattered +across dozens of call sites. **Please confirm your scheme and we'll update +it** — nothing about the client-side contract above changes either way, +only the string inside `click.url`. + +## What's clickable today + +All of the following are live in the `advertising` service and carry a real +`click_url` pointing at the ad (`/ads/`) or escrow deal (`/escrow/`): + +### Billboards & content (→ `/ads/`) + +| Event | Notification text (fa) | +|---|---| +| Someone viewed your billboard | شخصی شروع به مشاهده بیلبورد شما کرد. | +| Billboard created | بیلبود شما با موفقیت ساخته شد. | +| Billboard approved | بیلبورد شما تایید شد. | +| Billboard rejected | بیلبود شما رد شد. | +| Someone commented on your billboard | یک نفر برای بیلبورد شما نظر گذاشت! | +| Someone replied to your comment | یک نفر به نظر شما پاسخ داد! | +| Someone bought your content | درآمد جدید دارید! یک نفر محتوای شما را خرید. | +| Someone supported your content | یک نفر از محتوای شما حمایت کرد! | +| Pin about to expire (time) | زمان پین رو به اتمامه! | +| Pin expired (time) | پین شما منقضی شد! | +| Pin credit about to run out (budget) | اعتبار پین رو به اتمامه! | +| Pin credit exhausted (budget) | اعتبار پین شما به پایان رسید! | + +Note the last four are **two independent signals** — a billboard can lapse +because its display *time* ran out, or because its *budget* (balance vs. +reward-per-view) ran out. Both currently point at the same ad detail page; +if your UI wants to show a different call-to-action (extend time vs. top up +credit) based on which one fired, that distinction is in the `extras` payload +via which notification title/text arrived, not in the URL itself — ask if +you need a structured signal here instead of parsing title text. + +### Escrow deals (→ `/escrow/`) + +The full P2P deal lifecycle — buyer pays, seller approves/rejects, delivery, +confirm, dispute, payout/refund. All 11 possible state transitions notify +whichever party (buyer or seller) needs to act or be informed next, each +with a `click_url` to that deal's detail page. If you're building an escrow +detail screen, treat every push in this domain as "go look at this deal" — +the screen itself should reflect current state, not the specific notification +that triggered the tap. + +## Not yet migrated to this mechanism + +- **chat**: sends `extras = {"conversation_uuid": ..., "post_id": ...}` directly, without a `click_url`. If your client already has custom handling for these two keys (built before this mechanism existed), it keeps working — this doc doesn't change that. If you'd rather chat also send a `click_url` once your routing scheme is confirmed, that's a small change on our side; let us know. +- **promotions**: integrated with the notifications service but not currently sending any notification with real content (`extras={}` today) — nothing to build against yet. + +## Questions for you before we finalize + +1. What's your app's actual deep-link scheme — custom URI, universal link, or something else? +2. Do you want the "time expiring" vs. "credit expiring" pin notifications distinguished by something other than title text? +3. Do you want chat migrated onto `click_url` too, or is your existing `conversation_uuid`/`post_id` handling staying as-is? diff --git a/docs/implementation.md b/docs/implementation.md new file mode 100644 index 0000000..7c44ae7 --- /dev/null +++ b/docs/implementation.md @@ -0,0 +1,120 @@ +# Notifications Service — Implementation Guide + +Technical walkthrough of how the service is actually built, for anyone +maintaining or extending it. For what the service *does* and how to +integrate with it, see [service-overview.md](service-overview.md). + +## Code layout + +``` +apps/ + core/ root URL ("/") — a login-gated placeholder home view, nothing else + users/ local User model, mirrored by uuid from the accounts service + gooyal_oauth2/ OAuth2 resource-server wiring: token validator, permission class, + get_application() helper + push_notifications/ push domain: PushUser, PushMessage, BulkPushMessage + emails/ email domain: Email, EmailQueue +utils/ + clients/gotify.py the only code that talks to the Gotify HTTP API + models.py BaseModel (uuid pk, created_at/updated_at) +gotify_rest_api_client/ generated OpenAPI client for Gotify (do not hand-edit) +main/ + settings.py, celery.py, urls.py, wsgi.py/asgi.py +``` + +## Push notification flow + +### Single push + +``` +POST /push/application//application/ + ↓ ApplicationPushUserViewSet.perform_create() (apps/push_notifications/views.py) + 1. resolve target User by user_uuid + 2. resolve/create their PushUser (Gotify identity) — PushUser.objects.submit(user) + 3. resolve the calling Application from the OAuth2 token + 4. serializer.save(push_user=..., application=...) → PushMessage row + 5. instance.send_push() → send_push_notification.delay(uuid) (Celery) + ↓ apps/push_notifications/tasks.py + gotify.send_notif(token=push_user.application_token, + title, message, priority, + extras=push_message.get_gotify_extras()) + push_message.state = DONE +``` + +`PushUser.objects.submit(user)` provisions three things in Gotify on first use, all via `utils/clients/gotify.py`, and persists the resulting tokens on `PushUser`: +1. `submit_user` — a Gotify *user* named after the Gooyal `user_uuid` (admin-token auth) +2. `submit_client` — a Gotify *client*, whose token becomes `PushUser.client_token` (basic-auth as the just-created user) +3. `submit_application` — a Gotify *application* under that client, whose token becomes `PushUser.application_token` — **this is the token push messages are actually sent with** + +### Bulk push + +An admin action (`BulkPushMessageAdmin`, `apps/push_notifications/admin.py`) triggers `BulkPushMessage.bulk_push_task()` → Celery `bulk_push` task → `BulkPushMessage.push_to_all(extract_data())`. + +`extract_data()` reads a legacy `.xls` file (via `xlrd` — only `.xls`, not `.xlsx`, since `xlrd` dropped xlsx support at 2.0) with columns: + +| Col | 0 | 1 | 2 | 3 | 4 | 5 | +|---|---|---|---|---|---|---| +| Field | `user_uuid` | `title` | `message` | `priority` | `extras` (JSON string) | `click_url` | + +`push_to_all()` looks up each row's `PushUser` by `user_id`; missing users are counted as failures rather than raising, so one bad row doesn't abort the batch. Each successful row creates a `PushMessage` and calls `send_push()` — it goes through the exact same Celery task and Gotify call as a single push, so there's no separate bulk-specific delivery code path to keep in sync. + +## The click_url mechanism + +Added in migration `0004_pushmessage_click_url`. The design constraint: **most notifications aren't clickable**, so this had to be fully optional at every layer, and had to work identically for both the single and bulk paths without a second implementation. + +`apps/push_notifications/models.py`: + +```python +GOTIFY_CLICK_EXTRA_KEY = "client::notification" # Gotify's own reserved namespace + +click_url = models.URLField(max_length=1000, null=True, blank=True) + +def get_gotify_extras(self): + extras = dict(self.extras or {}) + if self.click_url: + notification_extra = dict(extras.get(self.GOTIFY_CLICK_EXTRA_KEY) or {}) + notification_extra["click"] = {"url": self.click_url} + extras[self.GOTIFY_CLICK_EXTRA_KEY] = notification_extra + return extras +``` + +Design decisions worth knowing if you touch this: +- **`click_url` is a real column, not just an `extras` key** — so it's queryable/auditable independently, and callers don't need to know Gotify's raw extras convention to use it. +- **The merge is additive**: any other `extras` keys a producer already sends (e.g. chat's `conversation_uuid`/`post_id`) pass through untouched — `get_gotify_extras()` only ever adds the `client::notification` key, never removes others. +- **`get_gotify_extras()` is the single place this merge happens** — `tasks.py` calls it instead of reading `push_message.extras` directly, so both the single-push and bulk-push paths (which both eventually call the same task) get it for free. +- **Optionality is enforced at three layers**, not just the DB: `null=True, blank=True` on the field → DRF `ModelSerializer` derives `required=False, allow_null=True, allow_blank=True` automatically → and behaviorally, an unset `click_url` leaves `extras` completely untouched (verified: `get_gotify_extras()` on a message with no `click_url` returns the original `extras` dict unchanged). + +The actual URL values are built by producer services from their own `utils/deep_links.py`-style helpers (currently only `advertising` has one) against a placeholder base — see [frontend-notification-click-actions.md](frontend-notification-click-actions.md) for why that's still a placeholder and what needs to happen before it's final. + +## Email flow + +Two paths, both in `apps/emails/`: + +- **Immediate**: `Email.send()` → `send_email` Celery task → `Email._send_email()` → `django.core.mail.send_mail()`. +- **Queued digest**: `Email.objects.create(..., queue=some_queue)` — `EmailQueue` rows are checked every 10 seconds by `send_email_for_queued_events` (registered via `celery_app.conf.beat_schedule` directly in `apps/emails/tasks.py`, not through the `CELERY_BEAT_SCHEDULE` Django setting other Gooyal services use — see the overview doc's known-gaps section). A queue only actually sends once it's been at least 10 minutes since its `last_sent`, batching every `Email` created since then into one message. + +**`_send_email()` currently hard-codes the recipient** to a literal test address instead of `self.user`'s email — this means the immediate-send path doesn't actually reach the intended recipient today. Flagging this here since it's the kind of thing that's easy to assume "must already work" when reading the flow. + +## Auth internals + +This service is an OAuth2 **resource server** (`django-oauth-toolkit`), not an OAuth2 provider — it doesn't issue tokens, it validates ones issued by the Gooyal accounts service via introspection (`OAUTH2_PROVIDER['RESOURCE_SERVER_INTROSPECTION_URL']`, using this service's own `CLIENT_ID`/`CLIENT_SECRET` as the introspection credentials). + +`apps.gooyal_oauth2.rest_framework.IsAuthenticatedOrTokenMatchesOASRequirements` is the permission class every producer-facing endpoint uses. It accepts either: +- a plain authenticated (non-OAuth2) request, **or** +- an OAuth2 token whose scopes satisfy the view's `required_alternate_scopes` + +`apps.gooyal_oauth2.utils.get_application(request)` pulls the calling `Application` off `request.auth.application` — this is how a `PushMessage`/`Email` row knows which producer service created it, without trusting a client-supplied field. + +## Testing status + +`apps/push_notifications/tests.py` and `apps/emails/tests.py` are both empty stubs — `python manage.py test` reports 0 tests. Everything described in this doc and in [notification-click-actions.md](notification-click-actions.md) was verified manually (unit-level checks via `manage.py shell`, and live round-trips against the local Gotify 2.6.3 container), not via an automated suite. If you're adding tests, `push_notifications` is the higher-value target — it's the domain three other services actively depend on. + +## Notable migrations + +- `0004_pushmessage_click_url` — adds `click_url`. See "The click_url mechanism" above. + +## Known technical debt (implementation-level) + +- `apps/push_notifications/admin.py` has a half-built `PushMessageAdmin` custom form class (`PushMessageForm`, a `custom-action2` URL) that is **never registered** — `admin.site.register(PushMessage)` uses the plain default `ModelAdmin` instead. Dead code, safe to ignore or remove. +- Duplicate `app_name = 'push_notifications'` across `application_urls.py`/`user_urls.py` (see overview doc). +- Bulk import is locked to legacy `.xls` by the `xlrd` dependency; there's no `.xlsx` path. diff --git a/docs/service-overview.md b/docs/service-overview.md new file mode 100644 index 0000000..b22c57c --- /dev/null +++ b/docs/service-overview.md @@ -0,0 +1,111 @@ +# Notifications Service — Overview + +## What it is + +A Django/DRF microservice in the Gooyal/Winsoo ecosystem that delivers **push +notifications** (via a self-hosted [Gotify](https://gotify.net) server) and +**email** on behalf of other backend services. It does not originate any +notifications itself — every message is submitted by a producer service +(chat, promotions, advertising, ...) over an authenticated API call. + +``` +chat / promotions / advertising / ... + │ OAuth2 client-credentials + ▼ +notifications service (this repo) + │ │ + ▼ ▼ + Gotify server SMTP server + (push delivery) (email delivery) +``` + +## Tech stack + +| Layer | Choice | +|---|---| +| Framework | Django 5.2 + Django REST Framework | +| Auth | `django-oauth-toolkit` — this service is an OAuth2 **resource server**, validating caller tokens by introspection against the Gooyal accounts service | +| Push backend | Gotify **2.6.3** (pinned — see local dev setup below), driven through a generated client (`gotify_rest_api_client/`) | +| Async | Celery + Celery Beat, Redis as broker/result backend | +| DB | PostgreSQL | +| Email | Django's SMTP backend | +| Bulk import | `xlrd` (legacy `.xls` only) | +| Docs | drf-spectacular (OpenAPI/Swagger at `/swagger/`) | + +## Capabilities + +### 1. Push notifications +- **Single push**: `POST /push/application//application/` — a producer service pushes one message to one user. +- **Bulk push**: an admin-triggered Excel upload (`BulkPushMessage`) fans out a push per row to many users at once. +- **Click actions**: an optional `click_url` on either path is delivered to the client as Gotify's own `client::notification.click.url` extra. See [notification-click-actions.md](notification-click-actions.md) and [frontend-notification-click-actions.md](frontend-notification-click-actions.md) for the full mechanism and integration contract. + +### 2. Email +- **Single email**: `POST /email/application//` — sent immediately via Celery. +- **Queued digest**: emails can be attached to an `EmailQueue`; a periodic task (`send_email_for_queued_events`) batches queued emails per queue and sends a single digest, throttled to once per 10 minutes per queue. + +## How a producer service integrates + +Every producer vendors a generated OpenAPI client (`gooyal_notifications_client`, built from this service's own swagger spec) and wraps it in a small `utils/clients/notifications_client.py`: + +1. Obtains an access token via the OAuth2 **client-credentials** grant against the Gooyal accounts service, using its own `CLIENT_ID`/`CLIENT_SECRET` (cached until near expiry). +2. Calls `push_application_application_create` (or `email_application_application_create`) against `NOTIFICATIONS_BASE_PUBLIC_URL`, scoped to the target `user_uuid`. +3. This service resolves the calling application from the token (`apps.gooyal_oauth2.utils.get_application`) and records it against the created `PushMessage`/`Email` row — the producer is never sent as free-form data, it's derived from the authenticated client. + +Endpoints are scope-gated per action (`notifications.application.push:submit_message`, `notifications.application.email:submit_email`, `notifications.push:get_client_token`) via `IsAuthenticatedOrTokenMatchesOASRequirements`. + +As of this writing, three services integrate: **chat** (live, sends on every new message), **promotions** (wired, currently sends empty extras), **advertising** (the only service actually using `click_url` today — see the implementation doc). + +## Domain model + +| Model | Purpose | +|---|---| +| `PushUser` | Per-user Gotify identity — owns a Gotify user account, a Gotify client token, and an application token. Created lazily on first push (`PushUser.objects.submit(user)`). | +| `PushMessage` | One push notification: title, message, priority, `extras` (free-form JSON), `click_url` (optional), delivery state. | +| `BulkPushMessage` | An uploaded spreadsheet of push messages plus success/failure counters and state. | +| `Email` | One email: title, message, `extras`, optional `queue`, delivery state. | +| `EmailQueue` | A named digest queue; batches `Email` rows created since it last sent. | + +## Configuration (environment variables) + +| Variable | Purpose | +|---|---| +| `DEBUG` | Django debug flag | +| `DB_NAME` / `DB_USER` / `DB_PASSWORD` / `DB_HOST` / `DB_PORT` | Postgres connection | +| `REDIS_BASE_URL` | Celery broker/result backend + cache | +| `GOTIFY_BASE_PUBLIC_URL` | Base URL of the Gotify server | +| `GOTIFY_ADMIN_CLIENT_TOKEN` | Admin token used to provision Gotify users/clients/applications | +| `BASE_OAUTH2_PROVIDER_PUBLIC_URL` / `BASE_OAUTH2_PROVIDER_PRIVATE_URL` | Gooyal accounts service, for issuing and introspecting tokens | +| `CLIENT_ID` / `CLIENT_SECRET` | This service's own OAuth2 introspection credentials | +| `SCOPES` | OAuth2 scopes this service requests | +| `EMAIL_HOST` / `EMAIL_PORT` / `EMAIL_HOST_USER` / `EMAIL_HOST_PASSWORD` / `EMAIL_USE_TLS` / `EMAIL_USE_SSL` / `EMAIL_TIMEOUT` | SMTP config | + +## Local development + +Three Docker containers back local dev (see `.env`): + +| Container | Image | Host port | +|---|---|---| +| `notif_postgres` | `postgres:16` | 5433 | +| `notif_redis` | `redis:7-alpine` | 6380 | +| `notif_gotify` | `gotify/server:2.6.3` | 8888 | + +**The Gotify image must stay pinned to `2.6.3`** — later versions (v3+) issue longer tokens that don't fit this service's `client_token`/`application_token` `CharField(max_length=32)` columns. + +```bash +docker start notif_postgres notif_redis notif_gotify +python manage.py migrate +python manage.py runserver +``` + +## Deployment + +- `Dockerfile` — Debian-based image, installs `requirements.txt`. +- `run.sh` — waits for Postgres, runs migrations, serves via `gunicorn main.wsgi:application`. +- `celery.sh` — runs a combined worker + beat process (`celery -A main worker -B`). + +## Known gaps + +- **No automated tests** in `apps/push_notifications` or `apps/emails` (both `tests.py` are stubs). +- **`Email._send_email()` hard-codes the recipient** (`xdshia49@gmail.com`) instead of `self.user`'s real email — looks like a debugging leftover, not wired to actual user emails yet. +- **Celery Beat is registered two different ways**: `apps.emails.tasks` assigns `celery_app.conf.beat_schedule` directly at import time, separate from the more conventional `CELERY_BEAT_SCHEDULE` Django setting used elsewhere in the Gooyal codebase (e.g. `advertising`). Both work, but it's an inconsistency worth normalizing. +- **URL namespace collision**: `apps/push_notifications/application_urls.py` and `user_urls.py` both set `app_name = 'push_notifications'`, producing a `urls.W005` warning on every `manage.py check`. Harmless (URLs still resolve) but ambiguous for reverse lookups.