- Implement tests for push subscription registration, chore and reward notifications, and deep-link navigation. - Cover scenarios for approving and denying chores and rewards, including token validation for digest actions. - Introduce a mock strategy for service worker push delivery to facilitate manual testing. - Ensure isolated test setups with appropriate cleanup after tests.
20 KiB
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, with the correct tab active and the pending item card scrolled into view - 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 must be authenticated using the parent's existing HttpOnly JWT cookie (
credentials: 'include') - 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, andactionand 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 IDuser_id: str— the parent user this subscription belongs toendpoint: str— browser-provided push endpoint URLkeys: dict—{ p256dh: str, auth: str }from the browserPushSubscriptiontimezone: str— IANA timezone string captured from the browser (e.g."America/New_York"); used to schedule the 9 pm digestcreated_at: str— ISO timestamp
New model: DigestActionToken
id: str— unique token ID (also used as the URL token)user_id: strchild_id: strentity_id: strentity_type: str—"chore"or"reward"action: str—"approve"or"deny"expires_at: str— ISO timestamp (24 hours from creation)used: bool— set toTrueonce redeemedsignature: str— HMAC-SHA256 of the above fields, keyed byDIGEST_TOKEN_SECRETenv var
Frontend Model
interface PushSubscription {
id: string;
user_id: string;
endpoint: string;
keys: {
p256dh: string;
auth: string;
};
timezone: string;
created_at: string;
}
Backend Implementation
- Add
pywebpushandpy-vapidtorequirements.txt. - Generate a VAPID key pair and store the public/private keys in environment config.
- New API file:
api/push_subscription_api.pyPOST /push-subscription— save a newPushSubscriptionto TinyDB for the authenticated userDELETE /push-subscription— remove the stored subscription for the authenticated user
- New DB helper:
db/push_subscriptions.py— CRUD forPushSubscriptionrecords. - In the chore confirmation flow (where
send_event_for_current_useris called for a completed/pending chore), also look up the parent's storedPushSubscriptionand fire a web push payload containing: child name, chore name,child_id,entity_id(task ID), andentity_type: "chore". The frontend will use these to construct the deep link/parent/<child_id>?scrollTo=<entity_id>&entityType=chore. - In the reward request flow (
child_reward_requestwith operationREQUEST_CREATED), check whether the child's current point balance is >= the reward's cost. If so, look up the parent's storedPushSubscriptionand fire a web push payload containing: child name, reward name, reward cost,child_id,entity_id(reward ID), andentity_type: "reward". The frontend constructs the deep link/parent/<child_id>?scrollTo=<entity_id>&entityType=reward. If the child cannot afford the reward, skip the push notification. - 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 thePendingConfirmationrecord, fire aCHILD_REWARD_REQUESTSSE event with operationREQUEST_CANCELLED, and create a tracking event with actiondenied. This is the parent-facing counterpart to the child'scancel-request-reward. - New utility:
utils/email_digest_scheduler.py- Uses
APScheduler(already in use for the account deletion scheduler — follow the same pattern) with aBackgroundScheduler. - Runs a job every hour on the server. Each run inspects all users, derives their current local hour from their stored
timezone(fromPushSubscription, falling back to UTC if absent), and sends the digest to any user whose local hour is21(9 pm) and who has at least one pendingPendingConfirmation. - For each pending item, generate a
DigestActionTokenfor bothapproveanddenyactions and persist them in TinyDB. - Call
send_digest_email()(see step 9) with the assembled item list. - Skip sending if
DB_ENV == 'e2e'.
- Uses
- New function
send_digest_email(to_email, items)inutils/email_sender.py. Each item initemsis 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>
- View — deep link to
- Use inline CSS styles consistent with the existing
send_pin_setup_emailformat (no external stylesheets). Approve link styled green, Deny link styled red. - Close with a footer: "You are receiving this because you have the Reward App. If items are already resolved, you can ignore this email."
- New API endpoint
GET /digest-action/<token>inapi/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 asapprove-choredeny+chore→ call the same logic asreject-choreapprove+reward→ call the same logic astrigger-rewarddeny+reward→ call the same logic asdeny-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.
- Add
DIGEST_TOKEN_SECRETto environment config. Document it in the README. - Register the new blueprint and start the digest scheduler in
main.py.
Backend Tests
POST /push-subscriptionsaves a subscription for the current userPOST /push-subscriptionrejects unauthenticated requestsDELETE /push-subscriptionremoves the subscription for the current user- Web push is fired when a chore is marked complete and the parent has a stored subscription
- Web push is not fired when the parent has no stored subscription (no error raised)
- Web push is fired when a child requests a reward they can afford and the parent has a stored subscription
- Web push is NOT fired when a child requests a reward they cannot afford
- Web push is not fired for reward requests when the parent has no stored subscription (no error raised)
POST /child/<id>/deny-reward-requestremoves the pending confirmation and fires theREQUEST_CANCELLEDSSE eventPOST /child/<id>/deny-reward-requestreturns 404 when no pending request existsPOST /child/<id>/deny-reward-requestrejects unauthenticated requests- Digest scheduler identifies users whose local time is 9 pm and who have pending items
- Digest scheduler skips users with no pending items
- Digest scheduler skips sending when
DB_ENV == 'e2e' DigestActionTokenis created with correct fields, expiry, and valid HMAC signatureGET /digest-action/<token>executes the correct backend action for each combination ofentity_type×actionGET /digest-action/<token>redirects to the correct deep-link URL on successGET /digest-action/<token>returns 400 for expired tokensGET /digest-action/<token>returns 400 for already-used tokensGET /digest-action/<token>returns 400 for tampered/invalid signatures- Digest email HTML contains per-child sections, item names, and View / Approve / Deny links
Frontend Implementation
- Register a Service Worker (
public/sw.js) that listens for thepushevent and callsself.registration.showNotification(...)with:title,body, and adataobject containingchild_id,entity_id, andentity_type- An
actionsarray:[{ action: 'approve', title: 'Approve' }, { action: 'deny', title: 'Deny' }] - Note: the
actionsfield is silently ignored by browsers that do not support it (e.g. iOS Safari); the notification body tap still works normally.
- On parent mount (e.g.,
ParentLayout.vueorApp.vue), request notification permission and, if granted, retrieve thePushSubscriptionfrom the browser andPOSTit to/api/push-subscription, including the user's IANA timezone string:Intl.DateTimeFormat().resolvedOptions().timeZone. - Clean up: if permission is denied or revoked, call
DELETE /api/push-subscription. - The Service Worker's
notificationclickhandler must:- Close the notification with
event.notification.close() - If
event.action === 'approve':- For
entity_type === 'chore':fetch('/api/child/<child_id>/approve-chore', { method: 'POST', credentials: 'include', body: { task_id: entity_id } }) - For
entity_type === 'reward':fetch('/api/child/<child_id>/trigger-reward', { method: 'POST', credentials: 'include', body: { reward_id: entity_id } })
- For
- If
event.action === 'deny':- For
entity_type === 'chore':fetch('/api/child/<child_id>/reject-chore', { method: 'POST', credentials: 'include', body: { task_id: entity_id } }) - For
entity_type === 'reward':fetch('/api/child/<child_id>/deny-reward-request', { method: 'POST', credentials: 'include', body: { reward_id: entity_id } })
- For
- If no action (body tap): construct the deep-link URL
/parent/<child_id>?scrollTo=<entity_id>&entityType=<entity_type>, checkclients.matchAll({ type: 'window' })for an existing open window on the same origin — if found, callclient.focus()andclient.navigate(url); otherwise callclients.openWindow(url).
- Close the notification with
ParentViewalready handles thescrollTo+entityTypequery params: it activates the corresponding tab (chore or reward) and scrolls the matching card into view. No changes toParentVieware required if this mechanism already works; verify and document any gaps.- Existing SSE in-app toast/badge behavior is unchanged — it remains the secondary channel when the tab is active.
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 calls
approve-choreand dismisses the notification without opening the app - Clicking "Deny" on a chore notification calls
reject-choreand dismisses the notification without opening the app - Tapping the chore notification body opens the app, activates the task tab in
ParentView, and scrolls the pending chore card into view - 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 calls
trigger-rewardand dismisses the notification without opening the app - Clicking "Deny" on a reward notification calls
deny-reward-requestand dismisses the notification without opening the app - Tapping the reward notification body opens the app, activates the reward tab in
ParentView, and scrolls the pending reward card into view - 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
- Notification permission is requested on parent login/mount
- If permission is denied, no subscription is posted to the backend
- Push subscription
POSTbody includes the browser's IANA timezone string
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
actionsbuttons 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).
- Digest opt-out: Allow parents to unsubscribe from the daily digest independently of push notifications.
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 9 scenario groups (41 automated test cases + 1 manual QA checklist):
| # | Group | Cases |
|---|---|---|
| 1 | Push subscription registration | 5 |
| 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 | — |
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
PushSubscriptionmodel and DB helper exist and follow existing patternsPOST /push-subscriptionandDELETE /push-subscriptionendpoints are implemented and authenticated- Web push is sent to the parent when a child completes a chore and a subscription is on file
- Web push is sent to the parent when a child requests an affordable reward and a subscription is on file
- Web push is NOT sent when the child cannot afford the requested reward
POST /child/<id>/deny-reward-requestendpoint is implemented, authenticated, fires the correct SSE event, and creates a tracking eventutils/email_digest_scheduler.pyis implemented using APScheduler, runs hourly, and sends digests at 9 pm local timesend_digest_email()produces a well-formatted HTML email with per-child sections and View / Approve / Deny links for each pending itemGET /digest-action/<token>validates the token and performs the correct actionDigestActionTokenis single-use: redeeming it marks it consumed and subsequent requests with the same token return 400DIGEST_TOKEN_SECRETand VAPID keys are configurable via environment variables (not hardcoded)- All backend tests pass
Frontend
- Service Worker is registered and handles
pushandnotificationclickevents - Parent is prompted for notification permission on mount
- Subscription is posted to the backend on permission grant and removed on revoke/deny
- 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 calls
approve-chorewithout opening the app - Clicking "Deny" on a chore notification calls
reject-chorewithout opening the app - Clicking "Approve" on a reward notification calls
trigger-rewardwithout opening the app - Clicking "Deny" on a reward notification calls
deny-reward-requestwithout opening the app - Tapping a chore notification body opens (or focuses) the app, activates the task tab, and scrolls the pending chore card into view
- Tapping a reward notification body opens (or focuses) the app, activates the reward tab, and scrolls the pending reward card into view
- 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
- Push subscription registration includes the browser's IANA timezone
- All frontend tests pass
Email Digest
- Digest email is sent at 9 pm in the parent's local timezone when pending items exist
- Digest is not sent if there are no pending items
- Email contains one section per child with pending items
- Each item row shows the item name and three links: View, Approve, Deny
- 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
ParentViewdeep link - Clicking Deny in the email performs the deny action and redirects to the correct
ParentViewdeep link - Clicking View in the email opens the app to the correct
ParentViewdeep link - Action tokens expire after 24 hours and return a 400 error page when used after expiry
- Action tokens are single-use: a second click on the same link returns a 400 error page
- Digest is not sent in
e2etest environment