Skip to content

Commit ba53601

Browse files
authored
Merge pull request #909 from Srejoye/docs/808-dispatch-notifications-runbook
docs(cron): operational runbook, API reference, and secret governance for the push-dispatch pipeline
2 parents 07eb03f + 7f79391 commit ba53601

6 files changed

Lines changed: 536 additions & 51 deletions

File tree

TROUBLESHOOTING.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,49 @@ Welcome to the troubleshooting guide for Peer Learning! If you encounter problem
6969
- For OAuth, verify that the Client ID and Secret match the ones configured in your OAuth provider's developer console, and that the callback URL matches your Supabase project's redirect URL.
7070
- After updating environment variables, restart the development server before testing authentication again.
7171
- If you encounter a "Failed to fetch" error during signup, verify that your `.env` file contains valid Supabase credentials and that the application has been restarted after any configuration changes.
72+
73+
## 6. Push Notifications Not Delivering
74+
75+
### Symptom: Cron runs but `sent` is always 0
76+
77+
**Check VAPID configuration.** The response body will contain `{ "error": "Missing VAPID push server env" }` if `VAPID_PUBLIC_KEY` or `VAPID_PRIVATE_KEY` are not set in `backend/.env`. Generate keys and set them:
78+
79+
```bash
80+
npx web-push generate-vapid-keys
81+
```
82+
83+
Add to `backend/.env`:
84+
```env
85+
VAPID_PUBLIC_KEY=<generated>
86+
VAPID_PRIVATE_KEY=<generated>
87+
VAPID_SUBJECT=mailto:admin@yourdomain.com
88+
```
89+
90+
> Changing these keys invalidates all existing browser subscriptions. Users must re-subscribe.
91+
92+
### Symptom: Cron returns `HTTP 401` or `403`
93+
94+
`CRON_SECRET` is missing or does not match what the scheduler is sending. Verify the env var is set on the server and that the scheduler is passing `Authorization: Bearer <CRON_SECRET>`. Check `[AUDIT]` lines in server logs to confirm the secret type being presented.
95+
96+
### Symptom: Cron returns `HTTP 429` immediately
97+
98+
The 60-second per-route cooldown is active. The cron fired twice within 60 seconds (Vercel's at-least-once guarantee can cause this). Wait 60 seconds and retry. If this blocks manual drain, see the Manual Drain Procedure in `docs/smart-notifications.md`.
99+
100+
### Symptom: Notifications accumulate but are never delivered (queue keeps growing)
101+
102+
1. Confirm the cron scheduler is running and reaching the server (look for `[AUDIT]` log lines).
103+
2. Check queue depth:
104+
```sql
105+
SELECT COUNT(*) FROM notifications WHERE push_sent_at IS NULL;
106+
```
107+
3. If the queue is large, manually drain it — see `docs/smart-notifications.md`**Manual drain procedure**.
108+
109+
### Symptom: `sent` is much lower than `processed` on every run
110+
111+
Most subscriptions in `push_subscriptions` are likely expired (browser was uninstalled, permission was revoked, or VAPID keys changed). The cron absorbs individual push failures silently. Check:
112+
113+
```sql
114+
SELECT COUNT(*) FROM push_subscriptions;
115+
```
116+
117+
If zero or very low, users need to re-subscribe by visiting the site and granting notification permission again.

backend/controllers/cronController.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,37 @@ const getSupabaseClient = () => {
1212
return createClient(supabaseUrl, serviceRoleKey);
1313
};
1414

15+
/*
16+
* dispatchPushNotifications
17+
*
18+
* Bulk-delivers pending push notifications to subscribed browsers.
19+
*
20+
* Batch cap: fetches at most 100 rows per invocation (oldest-first). A
21+
* notification backlog drains at 100 rows/minute assuming a 1-minute cron
22+
* schedule. If queue depth grows faster than this, trigger additional manual
23+
* runs (after the 60-second cooldown) or mark stale rows sent via SQL — see
24+
* docs/smart-notifications.md → "Manual drain procedure".
25+
*
26+
* Failure absorption: Promise.allSettled is used intentionally so that a
27+
* single failing push (network error, expired subscription) does not block
28+
* delivery to other recipients. The `sent` vs `processed` delta in the
29+
* response reflects failures. Each notification row is stamped with
30+
* `push_sent_at` regardless of delivery outcome — there is no automatic retry
31+
* for failed pushes.
32+
*
33+
* Subscription expiry (HTTP 410 / 404): the web-push library returns a
34+
* rejection with `statusCode 410` (subscription expired) or `404` (endpoint
35+
* not found) when a browser unsubscribes or revokes permission. These
36+
* subscriptions should be deleted from `push_subscriptions` to avoid
37+
* accumulating dead endpoints. NOTE: this cleanup is currently implemented in
38+
* sendPushNotification (notificationController.js) but NOT here. A future
39+
* improvement should add equivalent cleanup to this handler.
40+
*
41+
* @route POST /api/cron/dispatch-notifications
42+
* @access CRON_SECRET (Authorization: Bearer)
43+
* @returns {{ sent: number, processed: number }}
44+
*/
45+
1546
export const dispatchPushNotifications = async (req, res, next) => {
1647
try {
1748
const vapidPublicKey = process.env.VAPID_PUBLIC_KEY;

backend/routers/notificationRoutes.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,28 @@ import {
1111

1212
const router = express.Router();
1313

14+
/*
15+
* verifyNotificationAuth
16+
*
17+
* Secures /api/notifications/send-push with the WEBHOOK_SECRET.
18+
*
19+
* This is a SEPARATE secret from CRON_SECRET (used on /api/cron/*).
20+
* The distinction is intentional:
21+
*
22+
* CRON_SECRET — held by the scheduler (Vercel Cron / pg_cron). Authorises
23+
* bulk operations that touch up to 100 rows per call.
24+
*
25+
* WEBHOOK_SECRET — held by trusted internal services or admin tooling.
26+
* Authorises single-user targeted push delivery.
27+
*
28+
* Keeping them separate means a compromised scheduler secret does not grant
29+
* arbitrary single-user push access, and vice versa. Both can be rotated
30+
* independently — see docs/smart-notifications.md → "Secrets Reference".
31+
*
32+
* Applies the same rate-limit and cooldown helpers as requireCronSecret to
33+
* prevent abuse via this endpoint too.
34+
*/
35+
1436
// Custom middleware to strictly verify WEBHOOK secret
1537
const verifyNotificationAuth = (req, res, next) => {
1638
const authHeader = req.headers.authorization;

backend/tests/docs.test.js

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { describe, it, expect } from "vitest";
2+
import { readFileSync } from "fs";
3+
import { resolve } from "path";
4+
5+
/**
6+
* Documentation completeness smoke-tests.
7+
*
8+
* These tests assert that production-facing API endpoints are mentioned in
9+
* docs/api.md. They are intentionally coarse — they check for the route path
10+
* string, not for a full schema — so they are fast and not brittle to prose
11+
* changes. Add a new assertion here whenever a new route is added to
12+
* cronRoutes.js or notificationRoutes.js.
13+
*/
14+
15+
const repoRoot = resolve(process.cwd(), "..");
16+
const apiDoc = readFileSync(resolve(repoRoot, "docs/api.md"), "utf-8");
17+
const notifDoc = readFileSync(resolve(repoRoot, "docs/smart-notifications.md"), "utf-8");
18+
19+
describe("API documentation completeness", () => {
20+
const requiredRoutes = [
21+
"/api/cron/dispatch-notifications",
22+
"/api/cron/reminders",
23+
"/api/cron/mentorship-reminders",
24+
"/api/notifications/send-push",
25+
];
26+
27+
for (const route of requiredRoutes) {
28+
it(`docs/api.md documents the route ${route}`, () => {
29+
expect(apiDoc).toContain(route);
30+
});
31+
}
32+
33+
it("docs/api.md documents the CRON_SECRET auth requirement", () => {
34+
expect(apiDoc).toContain("CRON_SECRET");
35+
});
36+
37+
it("docs/api.md documents the WEBHOOK_SECRET auth requirement", () => {
38+
expect(apiDoc).toContain("WEBHOOK_SECRET");
39+
});
40+
});
41+
42+
describe("Operational runbook completeness", () => {
43+
it("smart-notifications.md contains a queue-depth monitoring query", () => {
44+
expect(notifDoc).toContain("push_sent_at IS NULL");
45+
});
46+
47+
it("smart-notifications.md documents the 100-row batch cap", () => {
48+
expect(notifDoc).toContain("100");
49+
});
50+
51+
it("smart-notifications.md documents the manual drain procedure", () => {
52+
expect(notifDoc.toLowerCase()).toContain("manual drain");
53+
});
54+
55+
it("smart-notifications.md documents the CRON_SECRET / WEBHOOK_SECRET split", () => {
56+
expect(notifDoc).toContain("CRON_SECRET");
57+
expect(notifDoc).toContain("WEBHOOK_SECRET");
58+
});
59+
60+
it("smart-notifications.md documents the 60-second cooldown", () => {
61+
expect(notifDoc).toContain("60-second");
62+
});
63+
64+
it("smart-notifications.md covers the 410/404 subscription expiry contract", () => {
65+
expect(notifDoc).toContain("410");
66+
expect(notifDoc).toContain("404");
67+
});
68+
});
69+
70+
describe("TROUBLESHOOTING.md completeness", () => {
71+
const troubleshoot = readFileSync(resolve(repoRoot, "TROUBLESHOOTING.md"), "utf-8");
72+
73+
it("TROUBLESHOOTING.md has a push notification section", () => {
74+
expect(troubleshoot.toLowerCase()).toContain("push notification");
75+
});
76+
77+
it("TROUBLESHOOTING.md mentions VAPID configuration", () => {
78+
expect(troubleshoot).toContain("VAPID");
79+
});
80+
});

docs/api.md

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,109 @@ Generates an AI summary of a chat session.
6666
**Security & Rate Limiting**:
6767
- Requires a valid Supabase JWT token.
6868
- Protected by a custom, in-house rate limiter middleware (`backend/middlewares/rateLimiter.js`) to prevent abuse.
69+
70+
## 🔔 Cron Endpoints
71+
72+
All cron endpoints require `Authorization: Bearer <CRON_SECRET>` and are subject to a 5 req/min per-IP rate limit and a 60-second per-route cooldown. Invocations within the cooldown window return `HTTP 429`.
73+
74+
---
75+
76+
### `POST /api/cron/dispatch-notifications`
77+
78+
Dequeues up to 100 pending push notifications (`push_sent_at IS NULL`, oldest first) and delivers them to all registered browser push subscriptions for each recipient.
79+
80+
**Auth:** `Authorization: Bearer <CRON_SECRET>`
81+
82+
**Response `200`:**
83+
```json
84+
{
85+
"sent": 42,
86+
"processed": 45
87+
}
88+
```
89+
90+
| Field | Description |
91+
|---|---|
92+
| `processed` | Number of notification rows fetched (max 100) |
93+
| `sent` | Number of individual push deliveries that succeeded |
94+
95+
`processed - sent` reflects push failures (expired subscriptions, VAPID misconfiguration, etc.). Individual failures are absorbed and do not block delivery to other recipients. Each notification row is stamped with `push_sent_at` regardless of delivery outcome.
96+
97+
**Error responses:**
98+
99+
| Status | Condition |
100+
|---|---|
101+
| `401` | Missing or malformed `Authorization` header |
102+
| `403` | Secret mismatch |
103+
| `429` | Rate limit or 60-second cooldown exceeded |
104+
| `500` | VAPID keys not configured, or Supabase error |
105+
| `503` | `CRON_SECRET` env var not set on the server |
106+
107+
---
108+
109+
### `POST /api/cron/reminders`
110+
111+
Inserts `session_reminder` notifications for all sessions with `status = 'upcoming'` whose `start_time` falls within the next 14–16 minutes. Idempotent via `upsert` on `(user_id, entity_id, type)`.
112+
113+
**Auth:** `Authorization: Bearer <CRON_SECRET>`
114+
115+
**Response `200`:**
116+
```json
117+
{ "inserted": 12 }
118+
```
119+
120+
**Error responses:** same as `/api/cron/dispatch-notifications`.
121+
122+
---
123+
124+
### `POST /api/cron/mentorship-reminders`
125+
126+
Inserts `mentorship_reminder` notifications for incomplete milestones due within 24 hours or already overdue. Notifies both the mentor and mentee. Idempotent via `upsert` on `(user_id, entity_id, type)`.
127+
128+
**Auth:** `Authorization: Bearer <CRON_SECRET>`
129+
130+
**Response `200`:**
131+
```json
132+
{ "inserted": 4 }
133+
```
134+
135+
**Error responses:** same as `/api/cron/dispatch-notifications`.
136+
137+
---
138+
139+
## 🔔 Notification Endpoints
140+
141+
### `POST /api/notifications/send-push`
142+
143+
Delivers a push notification to a single user's registered browser subscriptions. Stale subscriptions (HTTP 410/404 from the push service) are automatically deleted.
144+
145+
**Auth:** `Authorization: Bearer <WEBHOOK_SECRET>`
146+
147+
> This endpoint uses a **separate secret** (`WEBHOOK_SECRET`) from the cron endpoints (`CRON_SECRET`). See the Secrets Reference in `docs/smart-notifications.md` for rotation guidance.
148+
149+
**Request body:**
150+
```json
151+
{
152+
"user_id": "uuid",
153+
"title": "string (max 100 chars)",
154+
"body": "string (max 500 chars)",
155+
"action_url": "/optional/path"
156+
}
157+
```
158+
159+
**Response `200`:**
160+
```json
161+
{
162+
"sent": 1,
163+
"failed": 0
164+
}
165+
```
166+
167+
**Error responses:**
168+
169+
| Status | Condition |
170+
|---|---|
171+
| `400` | Missing `user_id`, `title`, or `body`; payload type invalid; content too long |
172+
| `401` | Missing or invalid `Authorization` header |
173+
| `404` | No push subscriptions found for the given `user_id` |
174+
| `500` | VAPID keys not configured, or Supabase error |

0 commit comments

Comments
 (0)