Files
chore/.github/specs/feat-parent-chore-notifications.md
Ryan Kegel fd28c89cbf
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m43s
feat: update push notification subscription flow and remove deprecated opt-in component
2026-04-19 22:16:38 -04:00

30 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, 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

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

  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-subscriptionupsert: 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

  • POST /push-subscription saves a subscription for the current user
  • POST /push-subscription upserts when the same endpoint is posted again (updates keys, no duplicate)
  • POST /push-subscription supports multiple endpoints per user (one per device)
  • POST /push-subscription updates User.timezone with the submitted IANA timezone string
  • POST /push-subscription rejects unauthenticated requests
  • DELETE /push-subscription removes 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, and deny_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 requests
  • POST /child/<id>/deny-reward-request removes the pending confirmation and fires the REQUEST_CANCELLED SSE event
  • POST /child/<id>/deny-reward-request returns 200 with message when reward already resolved (no pending request)
  • POST /child/<id>/deny-reward-request rejects 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'
  • 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, View / Approve / Deny links, and unsubscribe link
  • GET /digest-unsubscribe/<token> sets email_digest_enabled = False and returns confirmation HTML
  • GET /digest-unsubscribe/<token> returns 400 for expired or invalid tokens
  • GET /user/profile response includes email_digest_enabled
  • PUT /user/profile with email_digest_enabled: false updates the user and disables digest
  • 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
  • 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 PIN entry (LoginButton.vue submit)
  • If permission is denied, no subscription is posted to the backend
  • Push subscription POST body includes the browser's IANA timezone string
  • User Profile page shows an "Email Digest" toggle
  • Toggling Email Digest off calls PUT /api/user/profile with email_digest_enabled: false
  • Toggling Email Digest on calls PUT /api/user/profile with email_digest_enabled: true
  • User Profile page shows a "Push Notifications" toggle
  • Push Notifications toggle initialises to true when the browser already has an active push subscription
  • Push Notifications toggle initialises to false when no subscription exists
  • Toggling Push Notifications on triggers subscribeToPushWithResult() and posts subscription to the backend
  • Toggling Push Notifications off calls unsubscribeFromPush() and removes the subscription from the backend
  • Push Notifications toggle is disabled (not hidden) when browser notification permission is "denied"
  • 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 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; supports multiple subscriptions per user (one per device)
  • POST /push-subscription upserts by user_id + endpoint and also updates User.timezone
  • DELETE /push-subscription removes 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, and deny_token
  • Web push is NOT sent when the child cannot afford the requested reward
  • request_reward() rejects duplicate pending reward requests with 409 Conflict
  • POST /child/<id>/deny-reward-request endpoint is implemented, authenticated, fires the correct SSE event, and creates a tracking event
  • POST /child/<id>/deny-reward-request returns 200 with message when reward already resolved
  • utils/email_digest_scheduler.py is implemented using APScheduler, runs hourly, uses with 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 with email_digest_enabled == True
  • send_digest_email() produces a well-formatted HTML email with per-child sections, View / Approve / Deny links, and an unsubscribe link
  • 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
  • GET /digest-unsubscribe/<token> sets email_digest_enabled = False and returns confirmation HTML
  • GET /user/profile includes email_digest_enabled; PUT /user/profile accepts email_digest_enabled toggle
  • DIGEST_TOKEN_SECRET and VAPID keys are configurable via environment variables (not hardcoded)
  • All backend tests pass

Frontend

  • PWA manifest (public/manifest.json) is present and linked in index.html
  • Service Worker is registered and handles push and notificationclick events
  • Parent is prompted for notification permission on PIN entry (LoginButton.vue)
  • 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
  • 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/profile with email_digest_enabled
  • 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
  • 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 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 email includes an unsubscribe link in the footer
  • Unsubscribe link sets email_digest_enabled = False and 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 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)