- Implemented push subscription API with tests for subscribing and unsubscribing users. - Created web push notification tests triggered by child actions. - Added digest scheduler to send email digests to users at 9 PM local time. - Developed utility functions for creating and validating digest action tokens. - Integrated web push sender to handle sending notifications to users. - Added service worker for handling push notifications in the frontend. - Created a push opt-in component for user notification preferences. - Implemented tests for the push opt-in component to ensure correct behavior. - Updated frontend services to manage push subscriptions and permissions.
28 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, 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
DigestActionTokenmechanism used for email digest links); tokens are generated server-side when the push payload is built and embedded in the pushdata— 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, 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 browserPushSubscriptioncreated_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 toNone(falls back to UTC).email_digest_enabled: bool— whether the user receives the nightly digest email. Defaults toTrue. 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: 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;
};
created_at: string;
}
Existing interface change: User — add:
timezone: string | null;
email_digest_enabled: boolean;
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— upsert: if a record with matchinguser_id+endpointalready exists, update itskeys; otherwise insert a newPushSubscription. Also updateUser.timezonewith 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).
- New DB helper:
db/push_subscriptions.py— CRUD forPushSubscriptionrecords. 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). - Prerequisite — duplicate reward request guard: In
request_reward()inchild_api.py, before creating a newPendingConfirmation, check whether one already exists for the samechild_id,entity_id(reward_id), andentity_type='reward'withstatus='pending'. If so, return a409 Conflictwith error codeDUPLICATE_REWARD_REQUEST. This prevents the same reward from being requested twice. - In the chore confirmation flow (where
send_event_for_current_useris called for a completed/pending chore), also look up all of the parent's storedPushSubscriptionrecords 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", plusapprove_tokenanddeny_token(two freshly generatedDigestActionTokenIDs 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. - 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 all of the parent's storedPushSubscriptionrecords 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", plusapprove_tokenanddeny_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. - 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. Graceful handling: If no pending request exists (e.g. the reward was already granted or cancelled), return200with{ "message": "This reward request has already been resolved." }instead of a 404. - New utility:
utils/email_digest_scheduler.py- Uses
APScheduler(already in use for the account deletion scheduler — follow the same pattern) with aBackgroundScheduler. - Important: The scheduler job function must run inside
with app.app_context():to access Flask extensions (Flask-Mail, config, TinyDB). Pass the Flaskappinstance to the scheduler setup function, and useapp.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 == Trueanduser.email_digest_enabled == True. For each matching user, derive their current local hour fromUser.timezone(falling back to UTC iftimezoneisNone), and send 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 10) 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 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_tokenis a signed HMAC token encoding theuser_idwith a 30-day expiry.
- 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.
- New API endpoint
GET /digest-unsubscribe/<token>inapi/digest_action_api.py:- Validates the unsubscribe token signature and 30-day expiry.
- If valid: sets
User.email_digest_enabled = Falseand 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.
- Add
DIGEST_TOKEN_SECRETto environment config. Document it in the README. - Extend
PUT /user/profileinapi/user_api.pyto also accept an optionalemail_digest_enabled: boolfield in the request body. When present, updateUser.email_digest_enabled. Includeemail_digest_enabledin theGET /user/profileresponse. - Register the new blueprints and start the digest scheduler in
main.py.
Backend Tests
POST /push-subscriptionsaves a subscription for the current userPOST /push-subscriptionupserts when the same endpoint is posted again (updates keys, no duplicate)POST /push-subscriptionsupports multiple endpoints per user (one per device)POST /push-subscriptionupdatesUser.timezonewith the submitted IANA timezone stringPOST /push-subscriptionrejects unauthenticated requestsDELETE /push-subscriptionremoves only the specified endpoint for the current user- Web push is fired to all stored subscriptions when a chore is marked complete
- Web push payload includes
user_id,approve_token, anddeny_token - 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)
request_reward()returns 409 Conflict for duplicate pending reward requestsPOST /child/<id>/deny-reward-requestremoves the pending confirmation and fires theREQUEST_CANCELLEDSSE eventPOST /child/<id>/deny-reward-requestreturns 200 with message when reward already resolved (no pending request)POST /child/<id>/deny-reward-requestrejects unauthenticated requests- Digest scheduler identifies verified, digest-enabled users whose local time is 9 pm and who have pending items
- Digest scheduler skips users with no pending items
- Digest scheduler skips unverified users
- Digest scheduler skips users with
email_digest_enabled == False - Digest scheduler reads timezone from
User.timezone, falling back to UTC - 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, View / Approve / Deny links, and unsubscribe link
GET /digest-unsubscribe/<token>setsemail_digest_enabled = Falseand returns confirmation HTMLGET /digest-unsubscribe/<token>returns 400 for expired or invalid tokensGET /user/profileresponse includesemail_digest_enabledPUT /user/profilewithemail_digest_enabled: falseupdates the user and disables digestPUT /user/profilewithemail_digest_enabled: truere-enables digest
Frontend Implementation
- PWA manifest: Create
public/manifest.jsonwithname,short_name,start_url: "/",display: "standalone",theme_color,background_color, and aniconsarray. Add<link rel="manifest" href="/manifest.json">toindex.html. This is required for iOS "Add to Home Screen" and for push notifications on iOS Safari. - Register a Service Worker (
public/sw.js) that listens for thepushevent and callsself.registration.showNotification(...)with:title,body, and adataobject containingchild_id,entity_id,entity_type,approve_token, anddeny_token- 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-subscriptionwith{ endpoint }in the body to remove that specific subscription. - The Service Worker's
notificationclickhandler 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>, 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
ParentViewuses vertically stackedScrollingListsections (not tabs). The existingscrollTo+entityTypequery params callscrollToItem()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-pulseusing--item-card-ready-shadowor--accent) to draw the parent's eye to the target card. The animation class should be removed after it completes.- 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 tolocalStorage['parentReturnUrl'].LoginButtonchecks 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/parentbefore saving to prevent open redirect. - Existing SSE in-app toast/badge behavior is unchanged — it remains the secondary channel when the tab is active.
- On
UserProfile.vue, add an "Email Digest" toggle below the existing action buttons. When toggled, callPUT /api/user/profilewith{ email_digest_enabled: <value> }. Initialize the toggle state from theGET /api/user/profileresponse (which now includesemail_digest_enabled).
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 - 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
- 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 - User Profile page shows an "Email Digest" toggle
- Toggling Email Digest off calls
PUT /api/user/profilewithemail_digest_enabled: false - Toggling Email Digest on calls
PUT /api/user/profilewithemail_digest_enabled: true
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).
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 patterns; supports multiple subscriptions per user (one per device)POST /push-subscriptionupserts byuser_id+endpointand also updatesUser.timezoneDELETE /push-subscriptionremoves only the specified endpoint for the authenticated user- Web push is sent to all stored subscriptions when a child completes a chore
- Web push is sent to all stored subscriptions when a child requests an affordable reward
- Web push payload includes
user_id,approve_token, anddeny_token - Web push is NOT sent when the child cannot afford the requested reward
request_reward()rejects duplicate pending reward requests with 409 ConflictPOST /child/<id>/deny-reward-requestendpoint is implemented, authenticated, fires the correct SSE event, and creates a tracking eventPOST /child/<id>/deny-reward-requestreturns 200 with message when reward already resolvedutils/email_digest_scheduler.pyis implemented using APScheduler, runs hourly, useswith app.app_context():, and sends digests at 9 pm local time- Digest scheduler reads timezone from
User.timezone(falling back to UTC) and only processes verified users withemail_digest_enabled == True send_digest_email()produces a well-formatted HTML email with per-child sections, View / Approve / Deny links, and an unsubscribe linkGET /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 400GET /digest-unsubscribe/<token>setsemail_digest_enabled = Falseand returns confirmation HTMLGET /user/profileincludesemail_digest_enabled;PUT /user/profileacceptsemail_digest_enabledtoggleDIGEST_TOKEN_SECRETand VAPID keys are configurable via environment variables (not hardcoded)- All backend tests pass
Frontend
- PWA manifest (
public/manifest.json) is present and linked inindex.html - 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 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
- 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
- Push subscription registration includes the browser's IANA timezone
- User Profile page includes an "Email Digest" toggle that calls
PUT /api/user/profilewithemail_digest_enabled - When a deep-link
/parent/...is blocked by an expired parent session, the intended URL is saved tolocalStorageand the PIN modal is auto-opened; on successful PIN entry the parent is redirected to the saved URL - Cancelling the PIN modal clears the saved return URL (no repeat prompt)
- 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 email includes an unsubscribe link in the footer
- Unsubscribe link sets
email_digest_enabled = Falseand shows a confirmation page - Digest is not sent to unverified users
- Digest is not sent to users who have unsubscribed (
email_digest_enabled == False) - Digest is not sent in
e2etest 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)