diff --git a/.github/specs/feat-parent-chore-notifications.md b/.github/specs/feat-parent-chore-notifications.md new file mode 100644 index 0000000..a77e568 --- /dev/null +++ b/.github/specs/feat-parent-chore-notifications.md @@ -0,0 +1,266 @@ +# 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 + +```ts +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/?scrollTo=&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/?scrollTo=&entityType=reward`. If the child cannot afford the reward, skip the push notification. +7. Add a new endpoint `POST /child//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 `/parent/?scrollTo=&entityType=` + - **Approve** — `/api/digest-action/` + - **Deny** — `/api/digest-action/` + - 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/` 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 `/parent/?scrollTo=&entityType=` 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//deny-reward-request` removes the pending confirmation and fires the `REQUEST_CANCELLED` SSE event +- [ ] `POST /child//deny-reward-request` returns 404 when no pending request exists +- [ ] `POST /child//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/` executes the correct backend action for each combination of `entity_type` × `action` +- [ ] `GET /digest-action/` redirects to the correct deep-link URL on success +- [ ] `GET /digest-action/` returns 400 for expired tokens +- [ ] `GET /digest-action/` returns 400 for already-used tokens +- [ ] `GET /digest-action/` 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//approve-chore', { method: 'POST', credentials: 'include', body: { task_id: entity_id } })` + - For `entity_type === 'reward'`: `fetch('/api/child//trigger-reward', { method: 'POST', credentials: 'include', body: { reward_id: entity_id } })` + - If `event.action === 'deny'`: + - For `entity_type === 'chore'`: `fetch('/api/child//reject-chore', { method: 'POST', credentials: 'include', body: { task_id: entity_id } })` + - For `entity_type === 'reward'`: `fetch('/api/child//deny-reward-request', { method: 'POST', credentials: 'include', body: { reward_id: entity_id } })` + - If no action (body tap): construct the deep-link URL `/parent/?scrollTo=&entityType=`, 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//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/` 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 diff --git a/frontend/vue-app/e2e/plans/parent-notifications.plan.md b/frontend/vue-app/e2e/plans/parent-notifications.plan.md new file mode 100644 index 0000000..1b5df57 --- /dev/null +++ b/frontend/vue-app/e2e/plans/parent-notifications.plan.md @@ -0,0 +1,559 @@ +# Parent Notifications E2E Tests + +## Application Overview + +The parent notifications feature adds two channels for alerting parents when a child performs an action requiring their attention: + +1. **Web Push Notifications** — When a child confirms a chore is complete, or requests an affordable reward, the parent receives a native browser push notification via a Service Worker. The payload contains `child_id`, `entity_id`, and `entity_type`. Supporting browsers (Chrome desktop) show "Approve" and "Deny" action buttons; clicking an action button calls the matching backend API (`approve-chore`, `reject-chore`, `trigger-reward`, or `deny-reward-request`) without opening the app. Tapping the notification body opens or focuses the app and navigates to `/parent/:id?scrollTo=&entityType=`. + +2. **Daily Email Digest** — At 9 pm in the parent's local timezone, a HTML digest email is sent for any still-pending items (only if at least one exists). Each item has three links: **View** (ParentView deep link), **Approve** (`GET /api/digest-action/`), and **Deny** (`GET /api/digest-action/`). Tokens are HMAC-SHA256 signed, valid for 24 hours, and single-use. On success the endpoint executes the action and 302-redirects to the ParentView deep link; on error (expired, already-used, tampered) it returns a 400 HTML page with no redirect. + +**In-app notification view** — `/parent/notifications` lists all pending confirmations via `GET /api/pending-confirmations`. Clicking an item navigates to `/parent/:id?scrollTo=&entityType=`. Approval and rejection happen via **ParentView**: + +- **Pending chores**: card shows a `.chore-stamp.pending-stamp` "PENDING" badge; clicking the card opens `ChoreApproveDialog` which has Approve and Reject buttons. +- **Pending rewards**: card shows a `.pending` "PENDING" badge when `item.redeeming === true`; clicking opens a reward-confirm dialog that calls `POST trigger-reward`. A **Deny** button (added by this feature) calls `POST deny-reward-request`. + +The notification view refreshes reactively via SSE events: `child_chore_confirmation` (operations: `CONFIRMED`, `APPROVED`, `REJECTED`, `CANCELLED`) and `child_reward_request` (operations: `CREATED`, `CANCELLED`, `GRANTED`). + +**Spec covered:** `feat-parent-chore-notifications.md` + +--- + +## Implementation + +Tests live in `e2e/mode_parent/notifications/`. Each spec creates its own isolated child and entities via API so files can run in parallel within the bucket. All created resources are deleted in `afterAll`. + +## Playwright Projects + +| Project | testMatch | Notes | +| ------------------------------- | -------------------------------------------- | ----------------------- | +| `chromium-parent-notifications` | `/mode_parent\/notifications\/.+\.spec\.ts/` | Depends on `setup` only | + +**Config:** Add project to `playwright.config.ts` with `dependencies: ['setup']` and `storageState: STORAGE_STATE`. Add `/mode_parent\/notifications\//` to the `testIgnore` list of any existing catch-all project so notification specs do not run in two buckets simultaneously. + +--- + +## Seed Strategy + +Each spec file uses `beforeAll` to create isolated children, tasks, and rewards via the authenticated `request` fixture, and deletes them all in `afterAll`. The standard pattern is: pre-delete by name, create via `PUT`, re-fetch to get the ID. + +**Push subscription tests:** Service Worker registration and `PushManager.subscribe()` require a VAPID public key configured in the dev server environment. Tests that intercept the subscription POST use `page.context().grantPermissions(['notifications'])` before navigation and `page.route()` to capture the outgoing request. If the VAPID key is absent, test 1.1 must be skipped with `test.skip()`. + +**Digest action token prerequisite:** The email digest scheduler is disabled in `DB_ENV=e2e` (to avoid sending real email). To exercise `GET /api/digest-action/` in E2E tests, the backend must expose a test-only helper endpoint active **only** when `DB_ENV=e2e`: + +``` +POST /api/admin/test/digest-token +Auth: required (uses the E2E parent session) +Body: { child_id, entity_id, entity_type, action, expires_in_hours? } +Response 200: { token: string } +``` + +This endpoint creates a valid `DigestActionToken` (with correct HMAC-SHA256 signature) in TinyDB and returns the token string. All digest-action tests call this helper in `beforeAll` to obtain tokens for their scenarios. + +--- + +## Test Scenarios + +### 1. Push Subscription Registration + +**File:** `e2e/mode_parent/notifications/push-subscription.spec.ts` + +**Seed:** No child/task setup required. Tests use `page.context().grantPermissions(['notifications'])` as needed. No `afterAll` cleanup. + +#### 1.1. Subscription POST is sent on parent mount when permission is pre-granted + +- Call `page.context().grantPermissions(['notifications'])` +- Intercept `POST /api/push-subscription` with `page.route()` to capture request body +- Navigate to `/parent/tasks/chores` (triggers ParentLayout mount → SW registration → subscribe → POST) +- expect: Intercepted request body contains a non-empty `endpoint` string +- expect: Intercepted request body contains non-empty `keys.p256dh` and `keys.auth` strings +- expect: Intercepted request body contains a non-empty `timezone` string + +#### 1.2. Subscription POST body includes the browser's IANA timezone string + +- Grant notifications permission, intercept `POST /api/push-subscription` +- Navigate to `/parent/tasks/chores` +- Obtain browser timezone via `page.evaluate(() => Intl.DateTimeFormat().resolvedOptions().timeZone)` +- expect: The `timezone` field in the intercepted POST body equals the value from `page.evaluate()` + +#### 1.3. No subscription POST is sent when notification permission is not granted + +- Do NOT grant notification permission (leave default) +- Track calls to `POST /api/push-subscription` via `page.route()` +- Navigate to `/parent/tasks/chores`, wait for page to settle +- expect: No POST to `/api/push-subscription` was captured + +#### 1.4. Backend rejects unauthenticated subscription POST (401) + +- Create a fresh `APIRequestContext` without storageState (no auth cookies) via `playwright.request.newContext()` +- POST to `/api/push-subscription` with a minimal payload +- expect: Response status is 401 + +#### 1.5. Backend rejects unauthenticated subscription DELETE (401) + +- Using the same unauthenticated context as 1.4, send `DELETE /api/push-subscription` +- expect: Response status is 401 + +--- + +### 2. Chore Notification — Appearance and In-App Approval + +**File:** `e2e/mode_parent/notifications/chore-notification-approve.spec.ts` + +**Seed:** Creates child `ChoreNotifApproveChild` and chore `ChoreNotifApproveChore` (5 points, type `chore`) via API. Assigns chore to child. Sets a daily interval schedule (`mode: 'interval', interval_days: 1, interval_has_deadline: false`) so the chore persists post-approval. In `beforeAll`, calls `POST /child//confirm-chore` with `{ task_id: choreId }`. Deletes child and task in `afterAll`. + +#### 2.1. Confirmed chore appears in the notification view + +- Navigate to `/parent/notifications` +- expect: A list item is visible containing the text `ChoreNotifApproveChore` +- expect: The item contains the word "completed" (not "requested") + +#### 2.2. Notification item displays the child's name + +- Navigate to `/parent/notifications` +- expect: The notification item for `ChoreNotifApproveChore` also shows `ChoreNotifApproveChild` + +#### 2.3. Clicking notification navigates to ParentView with correct query params + +- Navigate to `/parent/notifications` +- Click the `ChoreNotifApproveChore` notification item +- `await page.waitForURL(new RegExp('/parent/' + childId + '\\?scrollTo=.*&entityType=chore'), { timeout: 5000 })` +- expect: URL contains `scrollTo=` and `entityType=chore` +- expect: The "Chores" section heading is visible + +#### 2.4. Pending chore card shows PENDING stamp in ParentView + +- Navigate to `/parent/` +- Locate the chore card for `ChoreNotifApproveChore` in the Chores section +- expect: Card contains an element with text "PENDING" + +#### 2.5. Clicking a pending chore card opens ChoreApproveDialog + +- Navigate to `/parent/` +- Click the `ChoreNotifApproveChore` card +- expect: A dialog element is visible containing both an "Approve" button and a "Reject" button + +#### 2.6. Approving via ChoreApproveDialog removes the PENDING stamp + +- Click the "Approve" button in the dialog +- expect: The dialog closes +- expect: The "PENDING" stamp is no longer present on the `ChoreNotifApproveChore` card + +#### 2.7. Approving a chore clears it from the notification view + +- After approving (2.6), navigate to `/parent/notifications` +- expect: No list item containing `ChoreNotifApproveChore` is present + +#### 2.8. Approving a chore awards points to the child + +- Note child's point balance before approval +- Approve the chore via the dialog +- expect: Child's point balance increases by the chore's point value (5) + +--- + +### 3. Chore Notification — In-App Rejection + +**File:** `e2e/mode_parent/notifications/chore-notification-deny.spec.ts` + +**Seed:** Creates child `ChoreNotifDenyChild` and chore `ChoreNotifDenyChore` (5 points) via API. Assigns chore. Sets daily interval schedule. In `beforeAll`, calls `POST /child//confirm-chore`. Deletes all in `afterAll`. + +#### 3.1. Confirmed chore appears in the notification view + +- Navigate to `/parent/notifications` +- expect: A list item is visible containing `ChoreNotifDenyChore` + +#### 3.2. ChoreApproveDialog shows both Approve and Reject buttons + +- Navigate to `/parent/`, click the `ChoreNotifDenyChore` card +- expect: Dialog with "Approve" and "Reject" buttons is visible + +#### 3.3. Rejecting via ChoreApproveDialog removes the PENDING stamp + +- Click the "Reject" button +- expect: Dialog closes +- expect: "PENDING" stamp is no longer present on the `ChoreNotifDenyChore` card + +#### 3.4. Rejecting a chore clears it from the notification view + +- After rejecting (3.3), navigate to `/parent/notifications` +- expect: No list item containing `ChoreNotifDenyChore` is present + +#### 3.5. Rejecting a chore does not award points + +- Note child's point balance before rejection +- Reject the pending chore via ChoreApproveDialog +- expect: Child's point balance is unchanged + +--- + +### 4. Reward Notification — Appearance and In-App Grant + +**File:** `e2e/mode_parent/notifications/reward-notification-approve.spec.ts` + +**Seed:** Creates child `RewardNotifApproveChild` with `points = 30` via API. Creates reward `RewardNotifApproveReward` with `cost = 10`. Assigns reward to child. In `beforeAll`, calls `POST /child//request-reward` with `{ reward_id: rewardId }`. Deletes child and reward in `afterAll`. + +#### 4.1. Requested reward appears in the notification view + +- Navigate to `/parent/notifications` +- expect: A list item is visible containing `RewardNotifApproveReward` +- expect: The item contains the word "requested" (not "completed") + +#### 4.2. Notification item displays the child's name + +- Navigate to `/parent/notifications` +- expect: The notification item for `RewardNotifApproveReward` also shows `RewardNotifApproveChild` + +#### 4.3. Clicking reward notification navigates to ParentView with correct query params + +- Navigate to `/parent/notifications` +- Click the `RewardNotifApproveReward` notification item +- `await page.waitForURL(new RegExp('/parent/' + childId + '\\?scrollTo=.*&entityType=reward'), { timeout: 5000 })` +- expect: URL contains `scrollTo=` and `entityType=reward` + +#### 4.4. Pending reward card shows PENDING badge in ParentView + +- Navigate to `/parent/` +- Locate the reward card for `RewardNotifApproveReward` in the Rewards section +- expect: Card contains an element with text "PENDING" + +#### 4.5. Clicking a pending reward card opens the grant confirmation dialog + +- Navigate to `/parent/`, click the `RewardNotifApproveReward` card +- expect: A reward confirmation dialog appears + +#### 4.6. Confirming the grant removes the PENDING badge and updates child points + +- In the grant confirmation dialog, click the confirm button +- expect: Dialog closes +- expect: The "PENDING" badge is no longer visible on the reward card +- expect: Child's point balance decreases by the reward cost (30 → 20) + +#### 4.7. Granting a reward clears it from the notification view + +- After confirming the grant (4.6), navigate to `/parent/notifications` +- expect: No list item containing `RewardNotifApproveReward` is present + +--- + +### 5. Reward Notification — In-App Denial + +**File:** `e2e/mode_parent/notifications/reward-notification-deny.spec.ts` + +**Seed:** Creates child `RewardNotifDenyChild` with `points = 30`. Creates reward `RewardNotifDenyReward` with `cost = 10`. Assigns reward to child. Uses `beforeEach` to call `POST /child//request-reward` (creates a fresh pending request before each test) and `afterEach` to call `POST /child//cancel-request-reward` to clean up any unconsumed requests. Deletes child and reward in `afterAll`. + +#### 5.1. Requested reward appears in the notification view + +- Navigate to `/parent/notifications` +- expect: A list item is visible containing `RewardNotifDenyReward` + +#### 5.2. Pending reward card shows PENDING badge in ParentView + +- Navigate to `/parent/` +- expect: The `RewardNotifDenyReward` card shows a "PENDING" badge + +#### 5.3. Denying a reward request removes the PENDING badge + +- Trigger the deny action for the pending reward (target the Deny button by its label or role — exact placement determined by implementation) +- expect: The "PENDING" badge disappears from the reward card +- expect: Child's point balance is unchanged + +#### 5.4. Denying a reward request clears it from the notification view + +- After denying (5.3), navigate to `/parent/notifications` +- expect: No list item containing `RewardNotifDenyReward` is present + +#### 5.5. `POST deny-reward-request` returns success when a pending request exists (API layer) + +- Via the authenticated `request` fixture, call `POST /child//deny-reward-request` with `{ reward_id: rewardId }` +- expect: Response status is 200 + +#### 5.6. `POST deny-reward-request` returns 404 when no pending request exists (API layer) + +- Cancel any existing pending request first via `POST /child//cancel-request-reward` +- Call `POST /child//deny-reward-request` with `{ reward_id: rewardId }` +- expect: Response status is 404 + +--- + +### 6. ParentView Deep-Link Navigation + +**File:** `e2e/mode_parent/notifications/deep-link-navigation.spec.ts` + +**Seed:** Creates child `DeepLinkChild` with `points = 100`. Creates 10 chores `DeepLinkChore1` … `DeepLinkChore10` (each 5 points, type `chore`). Creates reward `DeepLinkReward` with `cost = 20`. Assigns all 10 chores and the reward to the child. Confirms `DeepLinkChore10` via `POST /child//confirm-chore`. Requests the reward via `POST /child//request-reward`. Uses narrow viewport `{ width: 480, height: 800 }` so cards near the bottom require actual scrolling. Deletes child, all tasks, and reward in `afterAll`. + +#### 6.1. Deep link to chore — correct card is scrolled into viewport + +- `await page.setViewportSize({ width: 480, height: 800 })` +- Navigate to `/parent/?scrollTo=&entityType=chore` +- Wait for chore list to load and allow 800 ms for the scroll delay to complete +- expect: The `DeepLinkChore10` card is within the visible viewport + +#### 6.2. Deep link to reward — correct card is scrolled into viewport + +- `await page.setViewportSize({ width: 480, height: 800 })` +- Navigate to `/parent/?scrollTo=&entityType=reward` +- Wait for reward list to load and scroll delay +- expect: The `DeepLinkReward` card is within the visible viewport + +#### 6.3. Deep link with unknown `entityType` — page loads without JS error + +- Listen for `page.on('pageerror', ...)` before navigating +- Navigate to `/parent/?scrollTo=&entityType=unknown` +- expect: No pageerror event fires +- expect: Page loads and the "Chores" section heading is visible + +#### 6.4. Deep link with missing `scrollTo` — page loads normally + +- Navigate to `/parent/?entityType=chore` (no `scrollTo` query param) +- expect: Page loads without error and "Chores" section is visible + +#### 6.5. Deep link with no query params — page loads normally + +- Navigate to `/parent/` (bare URL, no query params) +- expect: Page loads and both "Chores" and "Rewards" section headings are visible + +#### 6.6. Clicking a chore notification produces the correct deep link + +- Navigate to `/parent/notifications` +- Click the `DeepLinkChore10` notification item (confirmed chore) +- expect: URL matches `/parent/?scrollTo=&entityType=chore` +- Wait 800 ms for scroll delay +- expect: `DeepLinkChore10` card is in viewport + +#### 6.7. Clicking a reward notification produces the correct deep link + +- Navigate to `/parent/notifications` +- Click the `DeepLinkReward` notification item +- expect: URL matches `/parent/?scrollTo=&entityType=reward` +- Wait 800 ms +- expect: `DeepLinkReward` card is in viewport + +--- + +### 7. Digest Action Token — Happy Paths + +Each combination of `entity_type × action` gets its own spec file to keep seeds isolated and specs independently runnable. + +**Files:** + +- `e2e/mode_parent/notifications/digest-action-approve-chore.spec.ts` +- `e2e/mode_parent/notifications/digest-action-deny-chore.spec.ts` +- `e2e/mode_parent/notifications/digest-action-approve-reward.spec.ts` +- `e2e/mode_parent/notifications/digest-action-deny-reward.spec.ts` + +**Seed (shared pattern for all four specs):** Creates an isolated child, task or reward, assigns it, creates a pending confirmation (via `confirm-chore` or `request-reward`), then calls the authenticated `POST /api/admin/test/digest-token` helper to obtain a valid signed token for the required action. All `GET /digest-action/` calls use a **fresh unauthenticated `APIRequestContext`** (no cookies) created via `playwright.request.newContext({ ignoreHTTPSErrors: true })`. The verifying `GET /api/pending-confirmations` and `GET /api/child/` calls use the authenticated `request` fixture. Deletes child and task/reward in `afterAll`. + +#### 7.1. Approve-chore token — 302 redirect to correct ParentView URL + +- Call `GET /api/digest-action/` (unauthenticated, redirect following disabled) +- expect: Response status is 302 +- expect: `response.headers()['location']` contains `/parent/`, `scrollTo=`, and `entityType=chore` + +#### 7.2. Approve-chore token — approve action is performed, points awarded + +- After calling token (7.1), wait 200 ms for the action to complete +- Via authenticated `request`: `GET /api/pending-confirmations` +- expect: No pending confirmation record exists for this chore and child +- Via authenticated `request`: `GET /api/child/` +- expect: Points increased by the chore's point value + +#### 7.3. Deny-chore token — 302 redirect to correct ParentView URL + +- Call `GET /api/digest-action/` (unauthenticated, redirect following disabled) +- expect: Response status is 302 +- expect: Location header contains the correct ParentView deep link (`entityType=chore`) + +#### 7.4. Deny-chore token — rejection action is performed, no points awarded + +- After calling token (7.3), verify: +- expect: Pending confirmation for this chore is removed from `GET /api/pending-confirmations` +- expect: Child's point balance is unchanged + +#### 7.5. Approve-reward token — 302 redirect to correct ParentView URL + +- Call `GET /api/digest-action/` (unauthenticated, redirect following disabled) +- expect: Response status is 302 +- expect: Location header contains `/parent/`, `scrollTo=`, and `entityType=reward` + +#### 7.6. Approve-reward token — reward is triggered and child points are deducted + +- After calling token (7.5): +- expect: Pending confirmation for this reward is gone from `GET /api/pending-confirmations` +- expect: Child's point balance decreased by the reward cost + +#### 7.7. Deny-reward token — 302 redirect to correct ParentView URL + +- Call `GET /api/digest-action/` (unauthenticated, redirect following disabled) +- expect: Response status is 302 +- expect: Location header contains the correct ParentView deep link for this child and reward + +#### 7.8. Deny-reward token — pending reward request is removed, points unchanged + +- After calling token (7.7): +- expect: Pending confirmation for this reward is gone +- expect: Child's point balance is unchanged + +--- + +### 8. Digest Action Token — Error Paths + +**File:** `e2e/mode_parent/notifications/digest-action-errors.spec.ts` + +**Seed:** Creates child `DigestErrorChild` and chore `DigestErrorChore` via API. Assigns chore. Calls `POST /child//confirm-chore` to create a pending confirmation. Uses `POST /api/admin/test/digest-token` to generate tokens with specific expiry configurations. All `GET /digest-action/` calls use a fresh unauthenticated context. Deletes child and task in `afterAll`. + +#### 8.1. Expired token returns 400 + +- Call `POST /api/admin/test/digest-token` with `expires_in_hours: -1` (already expired at creation) +- Call `GET /api/digest-action/` (unauthenticated, redirect following disabled) +- expect: Response status is 400 +- expect: No `Location` header is present (no redirect occurred) + +#### 8.2. Already-used token returns 400 on second use + +- Create a valid token via test helper +- First call: `GET /api/digest-action/` — expect: status 302 (token consumed, action executed) +- Second call: `GET /api/digest-action/` with the same token string +- expect: Response status is 400 + +#### 8.3. Tampered token returns 400 + +- Create a valid token via test helper; store the raw token string +- Modify one character of the token (e.g. replace the final character with a different letter) +- Call `GET /api/digest-action/` (unauthenticated) +- expect: Response status is 400 + +#### 8.4. Completely fake token returns 400 + +- Call `GET /api/digest-action/this-token-does-not-exist` +- expect: Response status is 400 + +#### 8.5. Error response is a plain HTML page with no redirect + +- For any 400 response from 8.1–8.4: +- expect: `content-type` header contains `text/html` +- expect: The response body does not include a `Location` header + +--- + +### 9. Service Worker Push Delivery — Manual Tests and Mock Strategy + +> **Playwright limitation:** The following scenarios **cannot be automated** with standard Playwright: +> +> - Playwright has no API to dispatch a `push` event to a registered Service Worker. +> - OS-level notification tray interactions ("Approve"/"Deny" action buttons) are outside the browser's JavaScript sandbox. +> - Delivering a real push message requires a live Web Push server sending to the browser's push endpoint, which is impractical in a local E2E environment. +> +> These scenarios must be verified **manually** during feature QA, or via the **mock strategy** below. + +#### Recommended Mock Strategy — Service Worker Test Hook + +Add a test-only message handler to `public/sw.js`, guarded by `self.location.hostname === 'localhost'`: + +```js +// sw.js — localhost test hook only +self.addEventListener('message', async (event) => { + if (event.data?.type !== '__TEST_NOTIFICATION_CLICK__') return + const { action, childId, entityId, entityType } = event.data + + if (action === 'approve') { + const url = + entityType === 'chore' + ? `/api/child/${childId}/approve-chore` + : `/api/child/${childId}/trigger-reward` + const body = + entityType === 'chore' + ? JSON.stringify({ task_id: entityId }) + : JSON.stringify({ reward_id: entityId }) + await fetch(url, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body, + }) + } else if (action === 'deny') { + const url = + entityType === 'chore' + ? `/api/child/${childId}/reject-chore` + : `/api/child/${childId}/deny-reward-request` + const body = + entityType === 'chore' + ? JSON.stringify({ task_id: entityId }) + : JSON.stringify({ reward_id: entityId }) + await fetch(url, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body, + }) + } else { + // body tap — focus or open the app at the deep link + const deepLink = `/parent/${childId}?scrollTo=${entityId}&entityType=${entityType}` + const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }) + if (clients[0]) { + await clients[0].focus() + await clients[0].navigate(deepLink) + } else { + await self.clients.openWindow(deepLink) + } + } +}) +``` + +Invoke from a Playwright spec after the SW is registered and controlling the page: + +```ts +await page.evaluate( + ({ childId, entityId, entityType, action }) => { + navigator.serviceWorker.controller?.postMessage({ + type: '__TEST_NOTIFICATION_CLICK__', + action, + childId, + entityId, + entityType, + }) + }, + { childId, entityId, entityType, action: 'approve' }, +) +``` + +This allows a spec to simulate notification action clicks without OS interaction, enabling API verification and navigation assertions on top of the mock. + +#### Manual QA Checklist (no spec file — document only) + +- Parent tab active: SSE in-app toast appears; no OS push notification is sent +- Parent tab in background or closed: OS push notification appears showing the child name and chore/reward name +- Notification does NOT appear for a reward request when the child cannot afford the reward +- Notification shows "Approve" and "Deny" action buttons on Chrome desktop; no action buttons on iOS Safari (fallback: body tap only) +- Clicking "Approve" on a chore notification calls `POST approve-chore`, dismisses the notification tray entry, and does NOT open or focus the app +- Clicking "Deny" on a chore notification calls `POST reject-chore`, dismisses, no app focus +- Clicking "Approve" on a reward notification calls `POST trigger-reward`, dismisses, no app focus +- Clicking "Deny" on a reward notification calls `POST deny-reward-request`, dismisses, no app focus +- Tapping the chore notification body opens (or focuses) the app at `/parent/?scrollTo=&entityType=chore` with the chore card scrolled into view +- Tapping the reward notification body opens/focuses the app at `/parent/?scrollTo=&entityType=reward` with the reward card scrolled into view +- If the app is already open in another tab, tapping the notification body focuses the existing tab and navigates it — a second tab is NOT opened +- iOS Safari fall-back: notification body tap still navigates correctly to the deep link; no action buttons are rendered +- After the app tab is fully closed and a push notification arrives and is tapped, the app correctly opens at the deep-link URL + +--- + +## Test Summary + +| # | Group | Spec File(s) | Test Cases | +| --- | --------------------------------- | -------------------------------------- | ---------- | +| 1 | Push subscription registration | `push-subscription.spec.ts` | 5 | +| 2 | Chore notification — approve flow | `chore-notification-approve.spec.ts` | 8 | +| 3 | Chore notification — reject flow | `chore-notification-deny.spec.ts` | 5 | +| 4 | Reward notification — grant flow | `reward-notification-approve.spec.ts` | 7 | +| 5 | Reward notification — deny flow | `reward-notification-deny.spec.ts` | 6 | +| 6 | ParentView deep-link navigation | `deep-link-navigation.spec.ts` | 7 | +| 7 | Digest token — happy paths | 4 spec files (one per `entity×action`) | 8 | +| 8 | Digest token — error paths | `digest-action-errors.spec.ts` | 5 | +| 9 | SW push — manual/mock | (no spec file) | checklist | + +## Implementation Notes + +- A backend test-only endpoint `POST /api/admin/test/digest-token` (guarded by `DB_ENV=e2e`) is a **prerequisite** for scenario groups 7 and 8 to work. +- The exact UI location of the reward-denial button (group 5) is TBD by the feature implementation — update selectors once the button exists. +- The SW mock strategy in group 9 requires a `__TEST_NOTIFICATION_CLICK__` message handler added to `sw.js` (localhost-only, not present in production builds). +- The `playwright.config.ts` catch-all project must get `e2e/mode_parent/notifications/` added to its `testIgnore` list to prevent double-running.