Files
chore/.github/specs/archive/feat-parent-chore-notifications.md
Ryan Kegel 2c7e9b8b5e
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 1m37s
feat: add admin endpoint to send digest emails for users
- Implemented a new endpoint `/admin/test/send-digest` in `admin_api.py` to trigger digest emails for specific users.
- Added a script `send_digest.py` to facilitate sending digest emails via the admin API.
- Enhanced the digest action handling in `digest_action_api.py` to support token peeking without consuming it.
- Updated the `send_digests` function in `digest_scheduler.py` to utilize the new `send_digest_for_user` function for sending emails.
- Introduced a new utility function `peek_token` in `digest_token.py` to validate tokens without consuming them.
- Modified the `ParentView.vue` component to handle digest actions upon receiving a digest token in the URL.
- Updated `.gitignore` to exclude sensitive certificate files.
2026-04-21 13:08:03 -04:00

348 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Feature: Notify parents of child activity requiring attention
## Overview
**Goal:** Alert parents in real-time when a child performs an action that requires their attention (completing a chore, or requesting an affordable reward), even when the parent does not have the app open in an active browser tab. Additionally, send a nightly email digest at 9 pm in the parent's local timezone summarising all still-unacknowledged items.
**User Story:**
As a parent, I want to receive a notification when:
- My child completes a chore so I can review and approve or reject it promptly.
- My child requests a reward they can afford so I can review and grant or deny it promptly.
I should receive these notifications without needing to have the app open.
Additionally, if any items are still unacknowledged at the end of the day, I want to receive a nightly summary email at 9 pm (my local time) that lists each pending chore and reward request with action links so I can approve or deny each item directly from the email without opening the app.
**Rules:**
- Follow `.github/copilot-instructions.md`
- Notifications must be user-opt-in (browser permission request)
- Every backend mutation must fire an SSE event (existing requirement)
- Web Push is the primary notification channel; in-app SSE toast remains the secondary channel
- Do not send email per event — email is reserved for account-level actions
- A reward request notification is only sent when the child has enough points to afford the reward; do not notify for requests the child cannot afford
- Tapping a notification must open (or focus) the app and navigate directly to the relevant child's `ParentView`, scrolling the pending item card into view within the corresponding section
- Each notification must include an **Approve** and **Deny** action button so the parent can act directly from the OS notification tray without opening the app
- Action button API calls from the Service Worker use short-lived signed action tokens (same `DigestActionToken` mechanism used for email digest links); tokens are generated server-side when the push payload is built and embedded in the push `data` — the SW never needs cookie access
- Browsers that do not support notification `actions` (e.g. iOS Safari) fall back gracefully: tapping the notification body still opens the app as normal
- The nightly digest email is sent at 9 pm in the parent's local timezone regardless of whether they have push notifications enabled
- The digest is only sent if there is at least one unacknowledged pending item at send time
- Approve/deny links in the email are protected by short-lived signed action tokens (valid for 24 hours); they do not rely on browser cookies
- Action tokens must encode the `user_id`, `child_id`, `entity_id`, `entity_type`, and `action` and be signed with a server secret using HMAC-SHA256
- Action tokens are single-use: once redeemed the backend marks them consumed so they cannot be replayed
---
## Data Model Changes
### Backend Model
New model: `PushSubscription`
- `id: str` — unique ID
- `user_id: str` — the parent user this subscription belongs to
- `endpoint: str` — browser-provided push endpoint URL
- `keys: dict``{ p256dh: str, auth: str }` from the browser `PushSubscription`
- `created_at: str` — ISO timestamp
A user may have **multiple** `PushSubscription` records (one per device/browser). The `user_id` + `endpoint` pair is the unique key; re-posting the same endpoint updates the existing record rather than creating a duplicate.
Existing model change: `User` — add two fields:
- `timezone: str | None` — IANA timezone string (e.g. `"America/New_York"`), updated whenever the frontend posts a push subscription. Used by the digest scheduler to determine the parent's local 9 pm. Defaults to `None` (falls back to UTC).
- `email_digest_enabled: bool` — whether the user receives the nightly digest email. Defaults to `True`. Toggled via the User Profile page or via the digest email unsubscribe link.
New model: `DigestActionToken`
- `id: str` — unique token ID (also used as the URL token)
- `user_id: str`
- `child_id: str`
- `entity_id: str`
- `entity_type: str``"chore"` or `"reward"`
- `action: str``"approve"` or `"deny"`
- `expires_at: str` — ISO timestamp (24 hours from creation)
- `used: bool` — set to `True` once redeemed
- `signature: str` — HMAC-SHA256 of the above fields, keyed by `DIGEST_TOKEN_SECRET` env var
### Frontend Model
```ts
interface PushSubscription {
id: string;
user_id: string;
endpoint: string;
keys: {
p256dh: string;
auth: string;
};
created_at: string;
}
```
Existing interface change: `User` — add:
```ts
timezone: string | null;
email_digest_enabled: boolean;
```
---
## Backend Implementation
1. Add `pywebpush` and `py-vapid` to `requirements.txt`.
2. Generate a VAPID key pair and store the public/private keys in environment config.
3. New API file: `api/push_subscription_api.py`
- `POST /push-subscription`**upsert**: if a record with matching `user_id` + `endpoint` already exists, update its `keys`; otherwise insert a new `PushSubscription`. Also update `User.timezone` with the submitted IANA timezone string.
- `DELETE /push-subscription` — accepts `{ endpoint }` in the request body and removes only that specific subscription for the authenticated user (not all subscriptions).
4. New DB helper: `db/push_subscriptions.py` — CRUD for `PushSubscription` records. Must support multiple records per user. Key methods: `get_subscriptions_by_user(user_id)` (returns a list), `upsert_subscription(user_id, endpoint, keys)`, `delete_by_endpoint(user_id, endpoint)`.
5. **Prerequisite — duplicate reward request guard:** In `request_reward()` in `child_api.py`, before creating a new `PendingConfirmation`, check whether one already exists for the same `child_id`, `entity_id` (`reward_id`), and `entity_type='reward'` with `status='pending'`. If so, return a `409 Conflict` with error code `DUPLICATE_REWARD_REQUEST`. This prevents the same reward from being requested twice.
6. In the chore confirmation flow (where `send_event_for_current_user` is called for a completed/pending chore), also look up **all** of the parent's stored `PushSubscription` records and fire a web push to each. The push payload must contain: `user_id`, child name, chore name, `child_id`, `entity_id` (task ID), `entity_type: "chore"`, plus `approve_token` and `deny_token` (two freshly generated `DigestActionToken` IDs for the approve and deny actions). The frontend/SW will use these to construct the deep link `/parent/<child_id>?scrollTo=<entity_id>&entityType=chore`.
7. In the reward request flow (`child_reward_request` with operation `REQUEST_CREATED`), check whether the child's current point balance is >= the reward's cost. If so, look up **all** of the parent's stored `PushSubscription` records and fire a web push to each. The push payload must contain: `user_id`, child name, reward name, reward cost, `child_id`, `entity_id` (reward ID), `entity_type: "reward"`, plus `approve_token` and `deny_token`. The frontend/SW constructs the deep link `/parent/<child_id>?scrollTo=<entity_id>&entityType=reward`. If the child cannot afford the reward, skip the push notification.
8. Add a new endpoint `POST /child/<id>/deny-reward-request` (with body `{ reward_id }`) that a parent can call to reject a child's pending reward request. It should remove the `PendingConfirmation` record, fire a `CHILD_REWARD_REQUEST` SSE event with operation `REQUEST_CANCELLED`, and create a tracking event with action `denied`. This is the parent-facing counterpart to the child's `cancel-request-reward`. **Graceful handling:** If no pending request exists (e.g. the reward was already granted or cancelled), return `200` with `{ "message": "This reward request has already been resolved." }` instead of a 404.
9. New utility: `utils/email_digest_scheduler.py`
- Uses `APScheduler` (already in use for the account deletion scheduler — follow the same pattern) with a `BackgroundScheduler`.
- **Important:** The scheduler job function must run inside `with app.app_context():` to access Flask extensions (Flask-Mail, config, TinyDB). Pass the Flask `app` instance to the scheduler setup function, and use `app.app_context()` as a context manager wrapping the job body.
- Runs a job **every hour** on the server. Each run inspects all users where `user.verified == True` and `user.email_digest_enabled == True`. For each matching user, derive their current local hour from `User.timezone` (falling back to UTC if `timezone` is `None`), and send the digest to any user whose local hour is `21` (9 pm) and who has at least one pending `PendingConfirmation`.
- For each pending item, generate a `DigestActionToken` for both `approve` and `deny` actions and persist them in TinyDB.
- Call `send_digest_email()` (see step 10) with the assembled item list.
- Skip sending if `DB_ENV == 'e2e'`.
10. New function `send_digest_email(to_email, items)` in `utils/email_sender.py`. Each item in `items` is a dict with keys: `child_name`, `entity_name`, `entity_type`, `view_url`, `approve_token`, `deny_token`. The HTML email body must:
- Open with a branded header ("Reward App — Daily Summary").
- Contain one section per child, listing that child's pending items.
- For each item show the item name and three links side by side:
- **View** — deep link to `<FRONTEND_URL>/parent/<child_id>?scrollTo=<entity_id>&entityType=<entity_type>`
- **Approve** — `<FRONTEND_URL>/api/digest-action/<approve_token>`
- **Deny** — `<FRONTEND_URL>/api/digest-action/<deny_token>`
- Use inline CSS styles consistent with the existing `send_pin_setup_email` format (no external stylesheets). Approve link styled green, Deny link styled red.
- Close with a footer containing an unsubscribe link: "You are receiving this because you have the Reward App. [Unsubscribe from daily digest](<FRONTEND_URL>/api/digest-unsubscribe/<unsubscribe_token>)." The `unsubscribe_token` is a signed HMAC token encoding the `user_id` with a 30-day expiry.
11. New API endpoint `GET /digest-action/<token>` in `api/digest_action_api.py`:
- Validates the token exists in TinyDB, has not expired, has not been used, and the HMAC signature is valid.
- If invalid/expired: return a plain 400 HTML error page (no redirect).
- If valid: mark the token `used = True`, then:
- `approve` + `chore` → call the same logic as `approve-chore`
- `deny` + `chore` → call the same logic as `reject-chore`
- `approve` + `reward` → call the same logic as `trigger-reward`
- `deny` + `reward` → call the same logic as `deny-reward-request`
- On success: **302 redirect** to `<FRONTEND_URL>/parent/<child_id>?scrollTo=<entity_id>&entityType=<entity_type>` so the parent lands on the correct view.
- This endpoint is **unauthenticated** — the signed token is the credential.
12. New API endpoint `GET /digest-unsubscribe/<token>` in `api/digest_action_api.py`:
- Validates the unsubscribe token signature and 30-day expiry.
- If valid: sets `User.email_digest_enabled = False` and returns a simple HTML confirmation page: "You have been unsubscribed from daily digest emails. To re-enable, visit your profile in the app."
- If invalid/expired: returns a plain 400 HTML error page.
13. Add `DIGEST_TOKEN_SECRET` to environment config. Document it in the README.
14. Extend `PUT /user/profile` in `api/user_api.py` to also accept an optional `email_digest_enabled: bool` field in the request body. When present, update `User.email_digest_enabled`. Include `email_digest_enabled` in the `GET /user/profile` response.
15. Register the new blueprints and start the digest scheduler in `main.py`.
## Backend Tests
- [x] `POST /push-subscription` saves a subscription for the current user
- [x] `POST /push-subscription` upserts when the same endpoint is posted again (updates keys, no duplicate)
- [x] `POST /push-subscription` supports multiple endpoints per user (one per device)
- [x] `POST /push-subscription` updates `User.timezone` with the submitted IANA timezone string
- [x] `POST /push-subscription` rejects unauthenticated requests
- [x] `DELETE /push-subscription` removes only the specified endpoint for the current user
- [x] Web push is fired to all stored subscriptions when a chore is marked complete
- [x] Web push payload includes `user_id`, `approve_token`, and `deny_token`
- [x] Web push is not fired when the parent has no stored subscription (no error raised)
- [x] Web push is fired when a child requests a reward they can afford and the parent has a stored subscription
- [x] Web push is NOT fired when a child requests a reward they cannot afford
- [x] Web push is not fired for reward requests when the parent has no stored subscription (no error raised)
- [x] `request_reward()` returns 409 Conflict for duplicate pending reward requests
- [x] `POST /child/<id>/deny-reward-request` removes the pending confirmation and fires the `REQUEST_CANCELLED` SSE event
- [x] `POST /child/<id>/deny-reward-request` returns 200 with message when reward already resolved (no pending request)
- [x] `POST /child/<id>/deny-reward-request` rejects unauthenticated requests
- [x] Digest scheduler identifies verified, digest-enabled users whose local time is 9 pm and who have pending items
- [x] Digest scheduler skips users with no pending items
- [x] Digest scheduler skips unverified users
- [x] Digest scheduler skips users with `email_digest_enabled == False`
- [x] Digest scheduler reads timezone from `User.timezone`, falling back to UTC
- [x] Digest scheduler skips sending when `DB_ENV == 'e2e'`
- [x] `DigestActionToken` is created with correct fields, expiry, and valid HMAC signature
- [x] `GET /digest-action/<token>` executes the correct backend action for each combination of `entity_type` × `action`
- [x] `GET /digest-action/<token>` redirects to the correct deep-link URL on success
- [x] `GET /digest-action/<token>` returns 400 for expired tokens
- [x] `GET /digest-action/<token>` returns 400 for already-used tokens
- [x] `GET /digest-action/<token>` returns 400 for tampered/invalid signatures
- [x] Digest email HTML contains per-child sections, item names, View / Approve / Deny links, and unsubscribe link
- [x] `GET /digest-unsubscribe/<token>` sets `email_digest_enabled = False` and returns confirmation HTML
- [x] `GET /digest-unsubscribe/<token>` returns 400 for expired or invalid tokens
- [x] `GET /user/profile` response includes `email_digest_enabled`
- [x] `PUT /user/profile` with `email_digest_enabled: false` updates the user and disables digest
- [x] `PUT /user/profile` with `email_digest_enabled: true` re-enables digest
---
## Frontend Implementation
1. **PWA manifest:** Create `public/manifest.json` with `name`, `short_name`, `start_url: "/"`, `display: "standalone"`, `theme_color`, `background_color`, and an `icons` array. Add `<link rel="manifest" href="/manifest.json">` to `index.html`. This is required for iOS "Add to Home Screen" and for push notifications on iOS Safari.
2. Register a Service Worker (`public/sw.js`) that listens for the `push` event and calls `self.registration.showNotification(...)` with:
- `title`, `body`, and a `data` object containing `child_id`, `entity_id`, `entity_type`, `approve_token`, and `deny_token`
- An `actions` array: `[{ action: 'approve', title: 'Approve' }, { action: 'deny', title: 'Deny' }]`
- Note: the `actions` field is silently ignored by browsers that do not support it (e.g. iOS Safari); the notification body tap still works normally.
3. In `LoginButton.vue` (`submit()`), after a successful PIN authentication, call `subscribeToPushWithResult()` (fire-and-forget, not awaited) — the PIN submit button click satisfies the browser's user-gesture requirement. If the parent has already granted permission, the browser returns the existing subscription silently and the call is idempotent. No floating opt-in banner is displayed anywhere in the app.
4. Clean up: The **Push Notifications** toggle in `UserProfile.vue` handles explicit unsubscription — toggling off calls `unsubscribeFromPush()` which sends `DELETE /api/push-subscription` with `{ endpoint }` in the body, removing that specific subscription for the current user.
5. The Service Worker's `notificationclick` handler must:
- Close the notification with `event.notification.close()`
- If `event.action === 'approve'`: `fetch('/api/digest-action/<approve_token>')` (GET, no credentials needed — the signed token IS the credential).
- If `event.action === 'deny'`: `fetch('/api/digest-action/<deny_token>')` (GET, no credentials needed).
- If no action (body tap): construct the deep-link URL `/parent/<child_id>?scrollTo=<entity_id>&entityType=<entity_type>`, check `clients.matchAll({ type: 'window' })` for an existing open window on the same origin — if found, call `client.focus()` and `client.navigate(url)`; otherwise call `clients.openWindow(url)`.
6. `ParentView` uses vertically stacked `ScrollingList` sections (not tabs). The existing `scrollTo` + `entityType` query params call `scrollToItem()` on the correct list ref, which scrolls the card into view and centers it. After scrolling, apply a temporary pulse/highlight CSS animation (e.g. a 2-second `@keyframes highlight-pulse` using `--item-card-ready-shadow` or `--accent`) to draw the parent's eye to the target card. The animation class should be removed after it completes.
7. **Parent mode timeout — return URL:** When the router guard redirects a non-parent-authenticated user away from a `/parent/...` deep link (e.g. from a notification body tap or email digest redirect), it saves the intended URL to `localStorage['parentReturnUrl']`. `LoginButton` checks for a pending return URL on mount and auto-opens the PIN modal. On successful PIN entry, `consumePendingReturnUrl()` is called and the user is redirected to the saved deep link instead of the default `/parent`. If the parent cancels the PIN modal, `clearPendingReturnUrl()` removes the saved URL so the prompt does not re-appear. The URL is validated to start with `/parent` before saving to prevent open redirect.
8. Existing SSE in-app toast/badge behavior is unchanged — it remains the secondary channel when the tab is active.
9. On `UserProfile.vue`, add a **Daily Digest** `ToggleField` and a **Push Notifications** `ToggleField` below the email field.
- **Daily Digest**: When toggled, call `PUT /api/user/profile` with `{ email_digest_enabled: <value> }`. Initialize from `GET /api/user/profile` (`email_digest_enabled` field).
- **Push Notifications**: When toggled on, call `subscribeToPushWithResult()` from `pushSubscription.ts`; on `permission_denied` show an inline error message. When toggled off, call `unsubscribeFromPush()`. Initialize by calling `isSubscribedToPush()` in `onMounted`. Disable the toggle (but keep it visible) when `getPushPermissionState() === 'denied'`.
## Frontend Tests
- [ ] Parent sees a browser notification when a chore is completed and the tab is backgrounded
- [ ] Chore notification includes "Approve" and "Deny" action buttons (on supporting browsers)
- [ ] Clicking "Approve" on a chore notification uses the signed action token URL (not cookie-authenticated fetch)
- [ ] Clicking "Deny" on a chore notification uses the signed action token URL and dismisses the notification without opening the app
- [ ] Tapping the chore notification body opens the app, scrolls to the pending chore within the tasks section of `ParentView`
- [x] Scrolled-to card receives a temporary highlight/pulse animation
- [ ] Parent sees a browser notification when a child requests an affordable reward and the tab is backgrounded
- [ ] Reward notification includes "Approve" and "Deny" action buttons (on supporting browsers)
- [ ] Clicking "Approve" on a reward notification uses the signed action token URL and dismisses the notification without opening the app
- [ ] Clicking "Deny" on a reward notification uses the signed action token URL and dismisses the notification without opening the app
- [ ] Tapping the reward notification body opens the app, scrolls to the pending reward within the rewards section of `ParentView`
- [ ] If the app is already open in another tab, tapping the notification body focuses that tab and navigates it (no duplicate window opened)
- [ ] No browser notification is shown for a reward request the child cannot afford
- [x] Notification permission is requested on parent PIN entry (LoginButton.vue submit)
- [x] If permission is denied, no subscription is posted to the backend
- [x] Push subscription `POST` body includes the browser's IANA timezone string
- [x] User Profile page shows an "Email Digest" toggle
- [x] Toggling Email Digest off calls `PUT /api/user/profile` with `email_digest_enabled: false`
- [x] Toggling Email Digest on calls `PUT /api/user/profile` with `email_digest_enabled: true`
- [x] User Profile page shows a "Push Notifications" toggle
- [x] Push Notifications toggle initialises to `true` when the browser already has an active push subscription
- [x] Push Notifications toggle initialises to `false` when no subscription exists
- [x] Toggling Push Notifications on triggers `subscribeToPushWithResult()` and posts subscription to the backend
- [x] Toggling Push Notifications off calls `unsubscribeFromPush()` and removes the subscription from the backend
- [x] Push Notifications toggle is disabled (not hidden) when browser notification permission is `"denied"`
- [x] An inline error message is shown when enabling push notifications is blocked by the browser
---
## Future Considerations
- **iOS PWA caveat**: Web Push on iOS Safari requires the user to have added the app to their Home Screen (PWA mode). Until then, the in-app SSE toast is the only delivery mechanism for iOS Safari users in a regular browser tab. Consider prompting parents to install the PWA. Note that notification `actions` buttons are also not supported on iOS Safari — the body-tap deep-link is the only interaction available on that platform.
- **Notification preferences**: Allow parents to toggle specific notification types (chore complete, reward request, etc.) per child.
- **Configurable digest time**: Allow the parent to choose what time the daily digest is sent (default 9 pm).
---
## E2E Test Plan
A full Playwright E2E test plan has been produced and saved to:
`frontend/vue-app/e2e/plans/parent-notifications.plan.md`
The plan covers 10 scenario groups (56 automated test cases + 1 manual QA checklist):
| # | Group | Cases |
| --- | ----------------------------------------- | ----- |
| 1 | Push subscription registration | 3 |
| 2 | Chore notification — approve flow | 8 |
| 3 | Chore notification — reject flow | 5 |
| 4 | Reward notification — grant flow | 7 |
| 5 | Reward notification — deny flow | 6 |
| 6 | ParentView deep-link navigation | 7 |
| 7 | Digest action token — happy paths | 8 |
| 8 | Digest action token — error paths | 5 |
| 9 | Service Worker push — manual QA checklist | — |
| 10 | User Profile notification toggles | 7 |
**Prerequisite noted in plan:** A backend test-only endpoint `POST /api/admin/test/digest-token` (active only when `DB_ENV=e2e`) is required before scenario groups 7 and 8 can run.
---
## Acceptance Criteria (Definition of Done)
### Backend
- [x] `PushSubscription` model and DB helper exist and follow existing patterns; supports multiple subscriptions per user (one per device)
- [x] `POST /push-subscription` upserts by `user_id` + `endpoint` and also updates `User.timezone`
- [x] `DELETE /push-subscription` removes only the specified endpoint for the authenticated user
- [x] Web push is sent to **all** stored subscriptions when a child completes a chore
- [x] Web push is sent to **all** stored subscriptions when a child requests an affordable reward
- [x] Web push payload includes `user_id`, `approve_token`, and `deny_token`
- [x] Web push is NOT sent when the child cannot afford the requested reward
- [x] `request_reward()` rejects duplicate pending reward requests with 409 Conflict
- [x] `POST /child/<id>/deny-reward-request` endpoint is implemented, authenticated, fires the correct SSE event, and creates a tracking event
- [x] `POST /child/<id>/deny-reward-request` returns 200 with message when reward already resolved
- [x] `utils/email_digest_scheduler.py` is implemented using APScheduler, runs hourly, uses `with app.app_context():`, and sends digests at 9 pm local time
- [x] Digest scheduler reads timezone from `User.timezone` (falling back to UTC) and only processes verified users with `email_digest_enabled == True`
- [x] `send_digest_email()` produces a well-formatted HTML email with per-child sections, View / Approve / Deny links, and an unsubscribe link
- [x] `GET /digest-action/<token>` validates the token and performs the correct action
- [x] `DigestActionToken` is single-use: redeeming it marks it consumed and subsequent requests with the same token return 400
- [x] `GET /digest-unsubscribe/<token>` sets `email_digest_enabled = False` and returns confirmation HTML
- [x] `GET /user/profile` includes `email_digest_enabled`; `PUT /user/profile` accepts `email_digest_enabled` toggle
- [x] `DIGEST_TOKEN_SECRET` and VAPID keys are configurable via environment variables (not hardcoded)
- [x] All backend tests pass
### Frontend
- [x] PWA manifest (`public/manifest.json`) is present and linked in `index.html`
- [x] Service Worker is registered and handles `push` and `notificationclick` events
- [x] Parent is prompted for notification permission on PIN entry (LoginButton.vue)
- [x] Subscription is posted to the backend on PIN entry when permission is granted; removed on explicit toggle-off in UserProfile
- [ ] Native notification appears when a chore is completed with the tab backgrounded, with "Approve" and "Deny" action buttons
- [ ] Native notification appears when a child requests an affordable reward with the tab backgrounded, with "Approve" and "Deny" action buttons
- [ ] No native notification appears for a reward request the child cannot afford
- [ ] Clicking "Approve" on a chore notification uses the signed action token (no cookie auth) without opening the app
- [ ] Clicking "Deny" on a chore notification uses the signed action token without opening the app
- [ ] Clicking "Approve" on a reward notification uses the signed action token without opening the app
- [ ] Clicking "Deny" on a reward notification uses the signed action token without opening the app
- [ ] Tapping a chore notification body opens (or focuses) the app and scrolls to the pending chore within the tasks section
- [ ] Tapping a reward notification body opens (or focuses) the app and scrolls to the pending reward within the rewards section
- [x] Scrolled-to card receives a temporary highlight/pulse animation
- [ ] If the app is already open, the existing tab is focused and navigated rather than opening a new window
- [ ] In-app SSE toast behavior is unchanged
- [x] Push subscription registration includes the browser's IANA timezone
- [x] User Profile page includes an "Email Digest" toggle that calls `PUT /api/user/profile` with `email_digest_enabled`
- [x] When a deep-link `/parent/...` is blocked by an expired parent session, the intended URL is saved to `localStorage` and the PIN modal is auto-opened; on successful PIN entry the parent is redirected to the saved URL
- [x] Cancelling the PIN modal clears the saved return URL (no repeat prompt)
- [ ] All frontend tests pass
### Email Digest
- [x] Digest email is sent at 9 pm in the parent's local timezone when pending items exist
- [x] Digest is not sent if there are no pending items
- [x] Email contains one section per child with pending items
- [x] Each item row shows the item name and three links: View, Approve, Deny
- [x] Approve link is visually styled green; Deny link is styled red
- [ ] Clicking Approve in the email performs the approve action and redirects to the correct `ParentView` deep link
- [ ] Clicking Deny in the email performs the deny action and redirects to the correct `ParentView` deep link
- [ ] Clicking View in the email opens the app to the correct `ParentView` deep link
- [x] Action tokens expire after 24 hours and return a 400 error page when used after expiry
- [x] Action tokens are single-use: a second click on the same link returns a 400 error page
- [x] Digest email includes an unsubscribe link in the footer
- [x] Unsubscribe link sets `email_digest_enabled = False` and shows a confirmation page
- [x] Digest is not sent to unverified users
- [x] Digest is not sent to users who have unsubscribed (`email_digest_enabled == False`)
- [x] Digest is not sent in `e2e` test environment
---
## Test Run Results
**Date:** Implementation complete
**Backend tests run:** `pytest tests/test_push_subscription_api.py tests/test_digest_token.py tests/test_digest_action_api.py -v`
**Results:** 36 passed, 0 failed
**Full suite:** `pytest tests/ -v` → 340 passed, 0 failed (no regressions)
**New test files:**
- `backend/tests/test_push_subscription_api.py` — 13 tests covering VAPID key endpoint, subscribe, upsert, timezone update, auth guard, unsubscribe
- `backend/tests/test_digest_token.py` — 12 tests covering token creation, validate/consume, expiry, tampered signature, unsubscribe token lifecycle
- `backend/tests/test_digest_action_api.py` — 11 tests covering approve/deny chore/reward, 302 redirect, invalid/used token 400, unsubscribe endpoint
- `backend/tests/test_web_push.py` — 6 tests covering push firing on chore confirm and reward request, payload fields, no-subscription no-error
- `backend/tests/test_digest_scheduler.py` — 15 tests covering scheduler eligibility logic, e2e skip, timezone fallback, email HTML content (child sections, item names, View/Approve/Deny, unsubscribe, color styling)