All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m35s
- Implement tests for approving and denying rewards and chores, including token generation and validation. - Create tests for error handling scenarios with expired, tampered, and fake tokens. - Add tests for push subscription registration and user profile notification settings. - Ensure that notifications reflect the correct state of rewards and chores in the UI. - Validate that toggles for email digest and push notifications function correctly based on user permissions and server state.
348 lines
30 KiB
Markdown
348 lines
30 KiB
Markdown
# 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)
|