# Cron push alerts (weather, mall digests, crop prices)

**Date:** 2026-09-20  
**Decision:** Cron hits three secret HTTP endpoints. Backend groups users, writes one inbox row, sends one FCM. No per-tap call/share pushes.

The app only registers `POST /devices` and `POST /user-locations`. It does not poll weather. Tap routing uses `data.screen`.

## How cron auth works (not a user login)

These URLs are on the public internet (`https://farm-backend-kiei.onrender.com/internal/alerts/weather`). Without a lock, anyone who finds them could fire rain/mall pushes to every farmer.

**`CRON_SECRET` is just a long password.** The Flutter app never sees it. Users never type it. It is not OTP, not JWT, not Firebase.

1. Value format is **`GrowKit-` + random hex** (created in local `.env`, gitignored). Same string in **two production places only**:
   - Render → Environment → `CRON_SECRET`
   - GitHub → repo **Settings → Secrets and variables → Actions** → `CRON_SECRET`
2. GitHub Actions (same idea as today’s keep-alive ping to `/health`) POSTs the endpoint every 1–3 hours and sends that password in a header.
3. Rails checks the header. Wrong or missing → `401`, no pushes.

Do not put the real value in git, the spec, or the Flutter app. Local Docker loads it from `.env` (`env_file`).

```bash
curl -X POST "https://farm-backend-kiei.onrender.com/internal/alerts/weather" \
  -H "X-Cron-Secret: $CRON_SECRET"
```

Missing/wrong secret → `401`. No user JWT on these three routes.

`GET /health` stays public (keep-alive). Alert routes do not.

## Endpoints

| Cron | Method | Path | Job |
|---|---|---|---|
| Every 1–3h | `POST` | `/internal/alerts/weather` | Rain in 6–24h, grouped by lat/lng |
| Every 1–3h | `POST` | `/internal/alerts/mall` | Call/share/boost/view **digests**, 24h unread reminder, quiet listing, empty stall |
| 06:00 IST daily | `POST` | `/internal/alerts/crop-prices` | Morning mandi ping (`screen: crop_prices`) |

Each returns `{ "ok": true, "sent": N }` so cron logs are useful. Safe to retry: dedup keys below.

## FCM payload

Every push uses the same shape. All `data` values are strings.

```json
{
  "notification": {
    "title": "20 members tried to call you",
    "body": "They wanted your Murrah buffalo. Open Mall before they call someone else."
  },
  "data": {
    "screen": "listing",
    "type": "mall_digest",
    "listingId": "42",
    "notificationId": "n_123"
  }
}
```

| Event | `data.screen` | `data.type` | Extra |
|---|---|---|---|
| Rain | `weather` | `weather_rain` | — |
| Mall digest (calls/shares/boosts/views) | `listing` if one listing, else `inbox` | `mall_digest` | `listingId` when one listing; `calls` / `shares` / `boosts` / `views` counts |
| 24h reminder | `inbox` | `mall_reminder` | — |
| Quiet listing | `listing` | `listing_quiet` | `listingId` |
| Empty stall | `my_mall` | `mall_nudge` | — |
| 6AM mandi | `crop_prices` | `crop_prices` | — |

## 1. Weather

1. Users with active location (lat + lng) and at least one device token.
2. Group by lat/lng rounded to **2 decimals** (~1 km). One Open-Meteo call per group.
3. Hourly precipitation, next 24h. Alert if any hour in **6–24h** has probability ≥ 50% **or** precipitation ≥ 0.5 mm.
4. Copy uses village + time of day (morning / afternoon / evening).
5. Inbox `kind: weather_rain`. Dedup: one rain push per user per **18 hours**.

Open-Meteo only. No mandi fetch in this job.

## 2. Mall — grouped activity (not one push per tap)

**Stop** FCM (and stop extra inbox rows) inside `NotificationRecorder.from_engagement!` for `call` / `share` / `boost`. Those events stay in `mall_engagements` only.

**Views:** `GET /mall/listings/:id` records a `view` engagement when the viewer is signed in and is not the owner. Unique per `(user, listing)` per calendar day IST so scrolling does not inflate counts.

**Digest window:** engagements since that seller’s last `mall_digest`, or last **24 hours**, whichever is shorter. Counts are unique people per type (`COUNT DISTINCT user_id`). Skip if every count is 0.

**Grouping (one push per seller per mall run)**

Do not send four pushes if they had calls and shares. One inbox row + one FCM, counts in the copy.

- Lead with the largest count (usually calls).
- One listing → `screen: listing` + `listingId`.  
  Title: *20 members tried to call you*  
  Body: *8 shares on your Murrah buffalo. Open before they call someone else.*
- Several listings → `screen: inbox`.  
  Title: *20 members tried to call you*  
  Body: *12 shares and 9 views across 3 listings.*

If only shares (no calls): title *{n} shares on your listing*. Same for boosts/views.

Minimum total unique actors ≥ **1**. First call waits for the next mall cron (up to 3h), never a tap-by-tap push.

Inbox `kind: mall_digest` with `view_count` = total unique callers (or actors). `data.type: mall_digest`. Payload also includes string counts `calls`, `shares`, `boosts`, `views` when > 0.

`negotiate` and `report` stay **instant** (one buyer, seller must see it now).

## 3. Mall — 24h no action

If a `mall_digest` inbox row is **still unread after 24 hours**, send **one** reminder, then stop.

- Title: *Buyer is waiting*
- Body: *Someone tried to reach you yesterday. They will call the next seller.*
- `screen: inbox`, `type: mall_reminder`
- Dedup: one reminder per original digest row.

“No action” = notification still unread. We cannot know if the seller called the buyer back.

## 4. Mall — quiet listing

Active listing older than **24 hours**, zero call/share/boost/view in that time, owner has a device. One push per listing per **7 days**.

- Title: *Village hasn’t seen you yet*
- Body: *{title} is live but silent. Boost it so it lands on more Home feeds.*
- `screen: listing`

## 5. Mall — empty stall

Signed-up user, **zero listings**, device token, no `mall_nudge` in **3 days**.

- Title: *Mall is waiting*
- Body: *Buyers in {village} are already scrolling. Your stall is empty.*
- `screen: my_mall`

## 6. Crop prices (6AM)

No data.gov call in this pass. Broadcast to signed-up users with a device. Dedup: one per user per **IST calendar day**.

Cron should fire at **00:30 UTC** (06:00 IST). The endpoint itself only enforces the daily dedup.

## Copy pool (rotate; later `user.language`)

**Rain**

- *Rain this evening, {village}* — Cover fodder and keep calves in. Tap for the hour-by-hour.
- *Wet night coming* — Showers likely after 6pm near {village}. Don’t leave hay out.

**Call digest**

- *{n} members tried to call you* — They wanted your {title}. Open Mall before they call someone else.
- *{n} missed calls on Mall* — Buyers do not wait. Tap to see who wanted {title}.

**Share digest**

- *{n} shares on your listing* — {title} is moving through the village. Be ready when they call.
- *Your listing is travelling* — {n} people shared {title}. Buyers are looking.

**Boost / views**

- *Eyes on your animal* — {n} people opened {title}. One reply can close the deal.
- *They’re browsing you* — {n} views on {title} since yesterday.

**Reminder / quiet / empty / mandi** — as in sections 3–6.

English in v1. `user.language` (`en` / `hi` / `te` / `ta`) can swap strings later without changing endpoints.

## Out of scope

- Live data.gov price-jump detection (6AM is “go check”, not “paddy jumped 8%”).
- Doctor / weather-detail / crop API on this backend.
- Instant FCM on every call, share, boost, or view.
- Fake activity when counts are zero.

## App contract notes

- Inbox list gains `mall_digest`, `weather_rain`, `mall_nudge`, `mall_reminder`, `listing_quiet`, `crop_prices`.
- Digest rows use `count` (unique callers, or unique actors if no calls).
- Existing per-event `call` / `share` / `boost` inbox rows remain in history; new activity is one `mall_digest` per cron window.
- Flutter reads `data.screen` on notification tap (`weather`, `crop_prices`, `mall`, `my_mall`, `inbox`, `listing`, `doctor`).
