Files
chore/.github/specs/feat-parent-chore-notifications.md
Ryan Kegel 0d50a324a3
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m19s
feat: add end-to-end tests for parent notifications feature
- 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.
2026-04-13 21:12:22 -04:00

20 KiB
Raw Blame History

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, 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
  • timezone: str — IANA timezone string captured from the browser (e.g. "America/New_York"); used to schedule the 9 pm digest
  • created_at: str — ISO timestamp

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

interface PushSubscription {
  id: string;
  user_id: string;
  endpoint: string;
  keys: {
    p256dh: string;
    auth: string;
  };
  timezone: string;
  created_at: string;
}

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 — save a new PushSubscription to TinyDB for the authenticated user
    • DELETE /push-subscription — remove the stored subscription for the authenticated user
  4. New DB helper: db/push_subscriptions.py — CRUD for PushSubscription records.
  5. In the chore confirmation flow (where send_event_for_current_user is called for a completed/pending chore), also look up the parent's stored PushSubscription and fire a web push payload containing: child name, chore name, child_id, entity_id (task ID), and entity_type: "chore". The frontend will use these to construct the deep link /parent/<child_id>?scrollTo=<entity_id>&entityType=chore.
  6. 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 the parent's stored PushSubscription and fire a web push payload containing: child name, reward name, reward cost, child_id, entity_id (reward ID), and entity_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.
  7. 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.
  8. New utility: utils/email_digest_scheduler.py
    • Uses APScheduler (already in use for the account deletion scheduler — follow the same pattern) with a BackgroundScheduler.
    • Runs a job every hour on the server. Each run inspects all users, derives their current local hour from their stored timezone (from PushSubscription, falling back to UTC if absent), and sends 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 9) with the assembled item list.
    • Skip sending if DB_ENV == 'e2e'.
  9. 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: "You are receiving this because you have the Reward App. If items are already resolved, you can ignore this email."
  10. 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.
  11. Add DIGEST_TOKEN_SECRET to environment config. Document it in the README.
  12. Register the new blueprint and start the digest scheduler in main.py.

Backend Tests

  • POST /push-subscription saves a subscription for the current user
  • POST /push-subscription rejects unauthenticated requests
  • DELETE /push-subscription removes 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-request removes the pending confirmation and fires the REQUEST_CANCELLED SSE event
  • POST /child/<id>/deny-reward-request returns 404 when no pending request exists
  • POST /child/<id>/deny-reward-request rejects 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'
  • DigestActionToken is created with correct fields, expiry, and valid HMAC signature
  • GET /digest-action/<token> executes the correct backend action for each combination of entity_type × action
  • GET /digest-action/<token> redirects to the correct deep-link URL on success
  • GET /digest-action/<token> returns 400 for expired tokens
  • GET /digest-action/<token> returns 400 for already-used tokens
  • GET /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

  1. 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, and entity_type
    • 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.
  2. On parent mount (e.g., ParentLayout.vue or App.vue), request notification permission and, if granted, retrieve the PushSubscription from the browser and POST it to /api/push-subscription, including the user's IANA timezone string: Intl.DateTimeFormat().resolvedOptions().timeZone.
  3. Clean up: if permission is denied or revoked, call DELETE /api/push-subscription.
  4. The Service Worker's notificationclick handler 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 } })
    • 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 } })
    • 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).
  5. ParentView already handles the scrollTo + entityType query params: it activates the corresponding tab (chore or reward) and scrolls the matching card into view. No changes to ParentView are required if this mechanism already works; verify and document any gaps.
  6. 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-chore and dismisses the notification without opening the app
  • Clicking "Deny" on a chore notification calls reject-chore and 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-reward and dismisses the notification without opening the app
  • Clicking "Deny" on a reward notification calls deny-reward-request and 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 POST body 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 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).
  • 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

  • PushSubscription model and DB helper exist and follow existing patterns
  • POST /push-subscription and DELETE /push-subscription endpoints 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-request endpoint is implemented, authenticated, fires the correct SSE event, and creates a tracking event
  • utils/email_digest_scheduler.py is implemented using APScheduler, runs hourly, and sends digests at 9 pm local time
  • send_digest_email() produces a well-formatted HTML email with per-child sections and View / Approve / Deny links for each pending item
  • GET /digest-action/<token> validates the token and performs the correct action
  • DigestActionToken is single-use: redeeming it marks it consumed and subsequent requests with the same token return 400
  • DIGEST_TOKEN_SECRET and VAPID keys are configurable via environment variables (not hardcoded)
  • All backend tests pass

Frontend

  • Service Worker is registered and handles push and notificationclick events
  • 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-chore without opening the app
  • Clicking "Deny" on a chore notification calls reject-chore without opening the app
  • Clicking "Approve" on a reward notification calls trigger-reward without opening the app
  • Clicking "Deny" on a reward notification calls deny-reward-request without 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 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
  • 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 e2e test environment