Add push notification functionality with tests and digest scheduler
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m14s

- Implemented push subscription API with tests for subscribing and unsubscribing users.
- Created web push notification tests triggered by child actions.
- Added digest scheduler to send email digests to users at 9 PM local time.
- Developed utility functions for creating and validating digest action tokens.
- Integrated web push sender to handle sending notifications to users.
- Added service worker for handling push notifications in the frontend.
- Created a push opt-in component for user notification preferences.
- Implemented tests for the push opt-in component to ensure correct behavior.
- Updated frontend services to manage push subscriptions and permissions.
This commit is contained in:
2026-04-15 21:56:10 -04:00
parent 0d50a324a3
commit ad2bdf4c4f
47 changed files with 3177 additions and 197 deletions

View File

@@ -22,9 +22,9 @@ Additionally, if any items are still unacknowledged at the end of the day, I wan
- 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
- 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 must be authenticated using the parent's existing HttpOnly JWT cookie (`credentials: 'include'`)
- 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
@@ -44,9 +44,15 @@ New model: `PushSubscription`
- `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
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)
@@ -70,11 +76,17 @@ interface PushSubscription {
p256dh: string;
auth: string;
};
timezone: string;
created_at: string;
}
```
Existing interface change: `User` — add:
```ts
timezone: string | null;
email_digest_enabled: boolean;
```
---
## Backend Implementation
@@ -82,28 +94,30 @@ interface PushSubscription {
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`
- `POST /push-subscription`**upsert**: 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`.
- 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`.
- **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 9) with the assembled item list.
- Call `send_digest_email()` (see step 10) 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`:
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:
@@ -113,72 +127,93 @@ interface PushSubscription {
- `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`.
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` 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
- [x] `POST /push-subscription` saves a subscription for the current user
- [x] `POST /push-subscription` upserts when the same endpoint is posted again (updates keys, no duplicate)
- [x] `POST /push-subscription` supports multiple endpoints per user (one per device)
- [x] `POST /push-subscription` updates `User.timezone` with the submitted IANA timezone string
- [x] `POST /push-subscription` rejects unauthenticated requests
- [x] `DELETE /push-subscription` removes only the specified endpoint for the current user
- [x] Web push is fired to all stored subscriptions when a chore is marked complete
- [x] Web push payload includes `user_id`, `approve_token`, and `deny_token`
- [x] Web push is not fired when the parent has no stored subscription (no error raised)
- [x] Web push is fired when a child requests a reward they can afford and the parent has a stored subscription
- [x] Web push is NOT fired when a child requests a reward they cannot afford
- [x] Web push is not fired for reward requests when the parent has no stored subscription (no error raised)
- [x] `request_reward()` returns 409 Conflict for duplicate pending reward requests
- [x] `POST /child/<id>/deny-reward-request` removes the pending confirmation and fires the `REQUEST_CANCELLED` SSE event
- [x] `POST /child/<id>/deny-reward-request` returns 200 with message when reward already resolved (no pending request)
- [x] `POST /child/<id>/deny-reward-request` rejects unauthenticated requests
- [x] Digest scheduler identifies verified, digest-enabled users whose local time is 9 pm and who have pending items
- [x] Digest scheduler skips users with no pending items
- [x] Digest scheduler skips unverified users
- [x] Digest scheduler skips users with `email_digest_enabled == False`
- [x] Digest scheduler reads timezone from `User.timezone`, falling back to UTC
- [x] Digest scheduler skips sending when `DB_ENV == 'e2e'`
- [x] `DigestActionToken` is created with correct fields, expiry, and valid HMAC signature
- [x] `GET /digest-action/<token>` executes the correct backend action for each combination of `entity_type` × `action`
- [x] `GET /digest-action/<token>` redirects to the correct deep-link URL on success
- [x] `GET /digest-action/<token>` returns 400 for expired tokens
- [x] `GET /digest-action/<token>` returns 400 for already-used tokens
- [x] `GET /digest-action/<token>` returns 400 for tampered/invalid signatures
- [x] Digest email HTML contains per-child sections, item names, View / Approve / Deny links, and unsubscribe link
- [x] `GET /digest-unsubscribe/<token>` sets `email_digest_enabled = False` and returns confirmation HTML
- [x] `GET /digest-unsubscribe/<token>` returns 400 for expired or invalid tokens
- [x] `GET /user/profile` response includes `email_digest_enabled`
- [x] `PUT /user/profile` with `email_digest_enabled: false` updates the user and disables digest
- [x] `PUT /user/profile` with `email_digest_enabled: true` re-enables digest
---
## 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`
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.
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:
3. 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`.
4. Clean up: if permission is denied or revoked, call `DELETE /api/push-subscription` with `{ endpoint }` in the body to remove that specific subscription.
5. 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 `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)`.
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.
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 an "Email Digest" toggle below the existing action buttons. When toggled, call `PUT /api/user/profile` with `{ email_digest_enabled: <value> }`. Initialize the toggle state from the `GET /api/user/profile` response (which now includes `email_digest_enabled`).
## Frontend Tests
- [ ] Parent sees a browser notification when a chore is completed and the tab is backgrounded
- [ ] Chore notification includes "Approve" and "Deny" action buttons (on supporting browsers)
- [ ] Clicking "Approve" on a chore notification 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
- [ ] 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`
- [x] 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 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
- [ ] Clicking "Approve" on a reward notification uses the signed action token URL and dismisses the notification without opening the app
- [ ] Clicking "Deny" on a reward notification uses the signed action token URL and dismisses the notification without opening the app
- [ ] Tapping the reward notification body opens the app, scrolls to the pending reward within the rewards section of `ParentView`
- [ ] If the app is already open in another tab, tapping the notification body focuses that tab and navigates it (no duplicate window opened)
- [ ] No browser notification is shown for a reward request the child cannot afford
- [ ] Notification permission is requested on parent login/mount
- [ ] If permission is denied, no subscription is posted to the backend
- [ ] Push subscription `POST` body includes the browser's IANA timezone string
- [x] Notification permission is requested on parent login/mount
- [x] If permission is denied, no subscription is posted to the backend
- [x] Push subscription `POST` body includes the browser's IANA timezone string
- [x] User Profile page shows an "Email Digest" toggle
- [x] Toggling Email Digest off calls `PUT /api/user/profile` with `email_digest_enabled: false`
- [x] Toggling Email Digest on calls `PUT /api/user/profile` with `email_digest_enabled: true`
---
@@ -187,7 +222,6 @@ interface PushSubscription {
- **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.
---
@@ -219,48 +253,85 @@ The plan covers 9 scenario groups (41 automated test cases + 1 manual QA checkli
### 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
- [x] `PushSubscription` model and DB helper exist and follow existing patterns; supports multiple subscriptions per user (one per device)
- [x] `POST /push-subscription` upserts by `user_id` + `endpoint` and also updates `User.timezone`
- [x] `DELETE /push-subscription` removes only the specified endpoint for the authenticated user
- [x] Web push is sent to **all** stored subscriptions when a child completes a chore
- [x] Web push is sent to **all** stored subscriptions when a child requests an affordable reward
- [x] Web push payload includes `user_id`, `approve_token`, and `deny_token`
- [x] Web push is NOT sent when the child cannot afford the requested reward
- [x] `request_reward()` rejects duplicate pending reward requests with 409 Conflict
- [x] `POST /child/<id>/deny-reward-request` endpoint is implemented, authenticated, fires the correct SSE event, and creates a tracking event
- [x] `POST /child/<id>/deny-reward-request` returns 200 with message when reward already resolved
- [x] `utils/email_digest_scheduler.py` is implemented using APScheduler, runs hourly, uses `with app.app_context():`, and sends digests at 9 pm local time
- [x] Digest scheduler reads timezone from `User.timezone` (falling back to UTC) and only processes verified users with `email_digest_enabled == True`
- [x] `send_digest_email()` produces a well-formatted HTML email with per-child sections, View / Approve / Deny links, and an unsubscribe link
- [x] `GET /digest-action/<token>` validates the token and performs the correct action
- [x] `DigestActionToken` is single-use: redeeming it marks it consumed and subsequent requests with the same token return 400
- [x] `GET /digest-unsubscribe/<token>` sets `email_digest_enabled = False` and returns confirmation HTML
- [x] `GET /user/profile` includes `email_digest_enabled`; `PUT /user/profile` accepts `email_digest_enabled` toggle
- [x] `DIGEST_TOKEN_SECRET` and VAPID keys are configurable via environment variables (not hardcoded)
- [x] 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
- [x] PWA manifest (`public/manifest.json`) is present and linked in `index.html`
- [x] Service Worker is registered and handles `push` and `notificationclick` events
- [x] Parent is prompted for notification permission on mount
- [x] 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
- [ ] 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
- [x] 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
- [x] Push subscription registration includes the browser's IANA timezone
- [x] User Profile page includes an "Email Digest" toggle that calls `PUT /api/user/profile` with `email_digest_enabled`
- [x] 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
- [x] 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
- [x] Digest email is sent at 9 pm in the parent's local timezone when pending items exist
- [x] Digest is not sent if there are no pending items
- [x] Email contains one section per child with pending items
- [x] Each item row shows the item name and three links: View, Approve, Deny
- [x] 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
- [x] Action tokens expire after 24 hours and return a 400 error page when used after expiry
- [x] Action tokens are single-use: a second click on the same link returns a 400 error page
- [x] Digest email includes an unsubscribe link in the footer
- [x] Unsubscribe link sets `email_digest_enabled = False` and shows a confirmation page
- [x] Digest is not sent to unverified users
- [x] Digest is not sent to users who have unsubscribed (`email_digest_enabled == False`)
- [x] 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)

View File

@@ -0,0 +1,257 @@
"""Shared business logic for chore confirmation and reward actions.
Called from both child_api.py (JWT-authenticated endpoints) and
digest_action_api.py (token-authenticated endpoints). All functions take
user_id explicitly rather than reading it from the Flask request context.
"""
import logging
from datetime import datetime, timezone
from tinydb import Query
from db.db import child_db, task_db, reward_db, pending_confirmations_db
from db.child_overrides import get_override
from db.chore_schedules import get_schedule
from db.tracking import insert_tracking_event
from events.sse import send_event_to_user
from events.types.child_chore_confirmation import ChildChoreConfirmation
from events.types.child_reward_request import ChildRewardRequest
from events.types.child_reward_triggered import ChildRewardTriggered
from events.types.child_task_triggered import ChildTaskTriggered
from events.types.tracking_event_created import TrackingEventCreated
from events.types.event import Event
from events.types.event_types import EventType
from models.child import Child
from models.reward import Reward
from models.task import Task
from models.tracking_event import TrackingEvent
from utils.tracking_logger import log_tracking_event
logger = logging.getLogger(__name__)
def approve_chore(user_id: str, child_id: str, task_id: str) -> dict | None:
"""Award points for a completed chore and mark the pending confirmation approved.
Returns a result dict on success, or None if the confirmation was already resolved.
Raises ValueError if the child or task cannot be found.
"""
ChildQ = Query()
child_result = child_db.get((ChildQ.id == child_id) & (ChildQ.user_id == user_id))
if not child_result:
raise ValueError(f'Child {child_id} not found for user {user_id}')
child = Child.from_dict(child_result)
PendingQ = Query()
existing = pending_confirmations_db.get(
(PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) &
(PendingQ.entity_type == 'chore') & (PendingQ.status == 'pending') &
(PendingQ.user_id == user_id)
)
if not existing:
logger.info(f'No pending chore for child {child_id}, task {task_id} — already resolved')
return None
TaskQ = Query()
task_result = task_db.get(
(TaskQ.id == task_id) & ((TaskQ.user_id == user_id) | (TaskQ.user_id == None))
)
if not task_result:
raise ValueError(f'Task {task_id} not found')
task = Task.from_dict(task_result)
override = get_override(child_id, task_id)
points_value = override.custom_value if override else task.points
points_before = child.points
child.points += points_value
child_db.update({'points': child.points}, ChildQ.id == child_id)
schedule = get_schedule(child_id, task_id)
if schedule:
now_str = datetime.now(timezone.utc).isoformat()
pending_confirmations_db.update(
{'status': 'approved', 'approved_at': now_str},
(PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) &
(PendingQ.entity_type == 'chore') & (PendingQ.user_id == user_id)
)
else:
pending_confirmations_db.remove(
(PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) &
(PendingQ.entity_type == 'chore') & (PendingQ.user_id == user_id)
)
tracking_metadata = {
'task_name': task.name,
'task_type': task.type,
'default_points': task.points,
}
if override:
tracking_metadata['custom_points'] = override.custom_value
tracking_metadata['has_override'] = True
tracking_event = TrackingEvent.create_event(
user_id=user_id, child_id=child_id, entity_type='chore', entity_id=task_id,
action='approved', points_before=points_before, points_after=child.points,
metadata=tracking_metadata,
)
insert_tracking_event(tracking_event)
log_tracking_event(tracking_event)
send_event_to_user(user_id, Event(EventType.TRACKING_EVENT_CREATED.value,
TrackingEventCreated(tracking_event.id, child_id, 'chore', 'approved')))
send_event_to_user(user_id, Event(EventType.CHILD_CHORE_CONFIRMATION.value,
ChildChoreConfirmation(child_id, task_id, ChildChoreConfirmation.OPERATION_APPROVED)))
send_event_to_user(user_id, Event(EventType.CHILD_TASK_TRIGGERED.value,
ChildTaskTriggered(task_id, child_id, child.points)))
return {'task_name': task.name, 'child_name': child.name, 'child_id': child_id, 'points': child.points}
def reject_chore(user_id: str, child_id: str, task_id: str) -> None:
"""Reject a pending chore confirmation. No-op if already resolved."""
PendingQ = Query()
existing = pending_confirmations_db.get(
(PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) &
(PendingQ.entity_type == 'chore') & (PendingQ.status == 'pending') &
(PendingQ.user_id == user_id)
)
if not existing:
logger.info(f'No pending chore for child {child_id}, task {task_id} — already resolved')
return
pending_confirmations_db.remove(
(PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) &
(PendingQ.entity_type == 'chore') & (PendingQ.user_id == user_id)
)
ChildQ = Query()
child_result = child_db.get((ChildQ.id == child_id) & (ChildQ.user_id == user_id))
if child_result:
child = Child.from_dict(child_result)
TaskQ = Query()
task_result = task_db.get(
(TaskQ.id == task_id) & ((TaskQ.user_id == user_id) | (TaskQ.user_id == None))
)
task_name = task_result.get('name') if task_result else 'Unknown'
tracking_event = TrackingEvent.create_event(
user_id=user_id, child_id=child_id, entity_type='chore', entity_id=task_id,
action='rejected', points_before=child.points, points_after=child.points,
metadata={'task_name': task_name},
)
insert_tracking_event(tracking_event)
log_tracking_event(tracking_event)
send_event_to_user(user_id, Event(EventType.TRACKING_EVENT_CREATED.value,
TrackingEventCreated(tracking_event.id, child_id, 'chore', 'rejected')))
send_event_to_user(user_id, Event(EventType.CHILD_CHORE_CONFIRMATION.value,
ChildChoreConfirmation(child_id, task_id, ChildChoreConfirmation.OPERATION_REJECTED)))
def approve_reward_request(user_id: str, child_id: str, reward_id: str) -> dict:
"""Approve a child's pending reward request: deduct points and fire SSE events.
Returns a result dict on success.
Raises ValueError if child/reward not found or the child has insufficient points.
"""
ChildQ = Query()
child_result = child_db.get((ChildQ.id == child_id) & (ChildQ.user_id == user_id))
if not child_result:
raise ValueError(f'Child {child_id} not found for user {user_id}')
child = Child.from_dict(child_result)
RewardQ = Query()
reward_result = reward_db.get(
(RewardQ.id == reward_id) & ((RewardQ.user_id == user_id) | (RewardQ.user_id == None))
)
if not reward_result:
raise ValueError(f'Reward {reward_id} not found')
reward = Reward.from_dict(reward_result)
override = get_override(child_id, reward_id)
cost_value = override.custom_value if override else reward.cost
if child.points < cost_value:
raise ValueError(f'Child {child_id} has insufficient points for reward {reward_id}')
PendingQ = Query()
removed = pending_confirmations_db.remove(
(PendingQ.child_id == child_id) & (PendingQ.entity_id == reward_id) &
(PendingQ.entity_type == 'reward')
)
if removed:
send_event_to_user(user_id, Event(EventType.CHILD_REWARD_REQUEST.value,
ChildRewardRequest(child_id, reward_id, ChildRewardRequest.REQUEST_GRANTED)))
points_before = child.points
child.points -= cost_value
child_db.update({'points': child.points}, ChildQ.id == child_id)
tracking_metadata = {
'reward_name': reward.name,
'reward_cost': reward.cost,
'default_cost': reward.cost,
}
if override:
tracking_metadata['custom_cost'] = override.custom_value
tracking_metadata['has_override'] = True
tracking_event = TrackingEvent.create_event(
user_id=user_id, child_id=child_id, entity_type='reward', entity_id=reward_id,
action='redeemed', points_before=points_before, points_after=child.points,
metadata=tracking_metadata,
)
insert_tracking_event(tracking_event)
log_tracking_event(tracking_event)
send_event_to_user(user_id, Event(EventType.TRACKING_EVENT_CREATED.value,
TrackingEventCreated(tracking_event.id, child_id, 'reward', 'redeemed')))
send_event_to_user(user_id, Event(EventType.CHILD_REWARD_TRIGGERED.value,
ChildRewardTriggered(reward_id, child_id, child.points)))
return {'reward_name': reward.name, 'child_name': child.name, 'child_id': child_id, 'points': child.points}
def deny_reward(user_id: str, child_id: str, reward_id: str) -> dict | None:
"""Deny a child's pending reward request. No-op if already resolved.
Returns a result dict on success, or None if already resolved.
"""
PendingQ = Query()
existing = pending_confirmations_db.get(
(PendingQ.child_id == child_id) & (PendingQ.entity_id == reward_id) &
(PendingQ.entity_type == 'reward') & (PendingQ.status == 'pending') &
(PendingQ.user_id == user_id)
)
if not existing:
logger.info(f'Reward request for child {child_id}, reward {reward_id} already resolved')
return None
pending_confirmations_db.remove(
(PendingQ.child_id == child_id) & (PendingQ.entity_id == reward_id) &
(PendingQ.entity_type == 'reward') & (PendingQ.user_id == user_id)
)
ChildQ = Query()
child_result = child_db.get((ChildQ.id == child_id) & (ChildQ.user_id == user_id))
child_name = 'Unknown'
if child_result:
child = Child.from_dict(child_result)
child_name = child.name
tracking_event = TrackingEvent.create_event(
user_id=user_id, child_id=child_id, entity_type='reward', entity_id=reward_id,
action='denied', points_before=child.points, points_after=child.points,
metadata={},
)
insert_tracking_event(tracking_event)
log_tracking_event(tracking_event)
send_event_to_user(user_id, Event(EventType.TRACKING_EVENT_CREATED.value,
TrackingEventCreated(tracking_event.id, child_id, 'reward', 'denied')))
send_event_to_user(user_id, Event(EventType.CHILD_REWARD_REQUEST.value,
ChildRewardRequest(child_id, reward_id, ChildRewardRequest.REQUEST_CANCELLED)))
return {'child_name': child_name}

View File

@@ -9,6 +9,7 @@ from api.child_tasks import ChildTask
from api.pending_confirmation import PendingConfirmationResponse
from api.reward_status import RewardStatus
from api.utils import send_event_for_current_user, get_validated_user_id
import api.child_action_helpers as chore_actions
from db.db import child_db, task_db, reward_db, pending_reward_db, pending_confirmations_db
from db.tracking import insert_tracking_event
from db.child_overrides import get_override, delete_override, delete_overrides_for_child
@@ -29,6 +30,8 @@ from models.reward import Reward
from models.task import Task
from models.tracking_event import TrackingEvent
from utils.tracking_logger import log_tracking_event
from utils.push_sender import send_push_to_user
from utils.digest_token import create_action_token
from collections import defaultdict
from db.chore_schedules import get_schedule
from db.task_extensions import get_extension_for_child_task
@@ -330,7 +333,6 @@ def list_assignable_tasks(id):
all_tasks = [t for t in task_db.all() if t and t.get('id') and t.get('id') not in assigned_ids]
# Group by name
from collections import defaultdict
name_to_tasks = defaultdict(list)
for t in all_tasks:
name_to_tasks[t.get('name')].append(t)
@@ -539,7 +541,6 @@ def list_all_rewards(id):
ChildRewardQuery = Query()
all_rewards = reward_db.search((ChildRewardQuery.user_id == user_id) | (ChildRewardQuery.user_id == None))
from collections import defaultdict
name_to_rewards = defaultdict(list)
for r in all_rewards:
name_to_rewards[r.get('name')].append(r)
@@ -691,7 +692,6 @@ def list_assignable_rewards(id):
all_rewards = [r for r in reward_db.all() if r and r.get('id') and r.get('id') not in assigned_ids]
# Group by name
from collections import defaultdict
name_to_rewards = defaultdict(list)
for r in all_rewards:
name_to_rewards[r.get('name')].append(r)
@@ -895,6 +895,16 @@ def request_reward(id):
'reward_cost': reward.cost
}), 400
# Check for duplicate pending request
DupQuery = Query()
duplicate = pending_confirmations_db.get(
(DupQuery.child_id == child.id) & (DupQuery.entity_id == reward.id) &
(DupQuery.entity_type == 'reward') & (DupQuery.status == 'pending') &
(DupQuery.user_id == user_id)
)
if duplicate:
return jsonify({'error': 'Reward request already pending', 'code': 'DUPLICATE_REWARD_REQUEST'}), 409
pending = PendingConfirmation(child_id=child.id, entity_id=reward.id, entity_type='reward', user_id=user_id)
pending_confirmations_db.insert(pending.to_dict())
logger.info(f'Pending reward request created for child {child.name} for reward {reward.name}')
@@ -917,6 +927,28 @@ def request_reward(id):
send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value, TrackingEventCreated(tracking_event.id, child.id, 'reward', 'requested')))
send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(child.id, reward.id, ChildRewardRequest.REQUEST_CREATED)))
# Fire web push notification to all parent subscriptions
try:
approve_token = create_action_token(user_id, child.id, reward.id, 'reward', 'approve')
deny_token = create_action_token(user_id, child.id, reward.id, 'reward', 'deny')
push_payload = {
'type': 'reward_requested',
'title': f'{child.name} wants a reward',
'body': f'{reward.name} costs {reward.cost} points.',
'user_id': user_id,
'child_id': child.id,
'child_name': child.name,
'entity_id': reward.id,
'entity_type': 'reward',
'entity_name': reward.name,
'approve_token': approve_token.id,
'deny_token': deny_token.id,
}
send_push_to_user(user_id, push_payload)
except Exception as _push_err:
logger.warning(f'Push notification failed for reward request: {_push_err}')
return jsonify({
'message': f'Reward request for {reward.name} submitted for {child.name}.',
'reward_id': reward.id,
@@ -1105,6 +1137,28 @@ def confirm_chore(id):
TrackingEventCreated(tracking_event.id, id, 'chore', 'confirmed')))
send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value,
ChildChoreConfirmation(id, task_id, ChildChoreConfirmation.OPERATION_CONFIRMED)))
# Fire web push notification to all parent subscriptions
try:
approve_token = create_action_token(user_id, id, task_id, 'chore', 'approve')
deny_token = create_action_token(user_id, id, task_id, 'chore', 'deny')
push_payload = {
'type': 'chore_confirmed',
'title': f'{child.name} completed a chore',
'body': f'{task.name} is waiting for your approval.',
'user_id': user_id,
'child_id': id,
'child_name': child.name,
'entity_id': task_id,
'entity_type': 'chore',
'entity_name': task.name,
'approve_token': approve_token.id,
'deny_token': deny_token.id,
}
send_push_to_user(user_id, push_payload)
except Exception as _push_err:
logger.warning(f'Push notification failed for chore confirmation: {_push_err}')
return jsonify({'message': f'Chore {task.name} confirmed by {child.name}.', 'confirmation_id': confirmation.id}), 200
@@ -1171,77 +1225,18 @@ def approve_chore(id):
if not task_id:
return jsonify({'error': 'task_id is required'}), 400
ChildQuery = Query()
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id))
if not result:
return jsonify({'error': 'Child not found'}), 404
child = Child.from_dict(result[0])
try:
result = chore_actions.approve_chore(user_id, id, task_id)
except ValueError:
return jsonify({'error': 'Child or task not found'}), 404
PendingQuery = Query()
existing = pending_confirmations_db.get(
(PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) &
(PendingQuery.entity_type == 'chore') & (PendingQuery.status == 'pending') &
(PendingQuery.user_id == user_id)
)
if not existing:
if result is None:
return jsonify({'error': 'No pending confirmation found', 'code': 'PENDING_NOT_FOUND'}), 400
TaskQuery = Query()
task_result = task_db.get((TaskQuery.id == task_id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
if not task_result:
return jsonify({'error': 'Task not found'}), 404
task = Task.from_dict(task_result)
# Award points
override = get_override(id, task_id)
points_value = override.custom_value if override else task.points
points_before = child.points
child.points += points_value
child_db.update({'points': child.points}, ChildQuery.id == id)
# Update confirmation to approved
# For general (non-scheduled) chores, remove the confirmation so chore resets to normal
schedule = get_schedule(id, task_id)
if schedule:
now_str = datetime.now(timezone.utc).isoformat()
pending_confirmations_db.update(
{'status': 'approved', 'approved_at': now_str},
(PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) &
(PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id)
)
else:
pending_confirmations_db.remove(
(PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) &
(PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id)
)
tracking_metadata = {
'task_name': task.name,
'task_type': task.type,
'default_points': task.points
}
if override:
tracking_metadata['custom_points'] = override.custom_value
tracking_metadata['has_override'] = True
tracking_event = TrackingEvent.create_event(
user_id=user_id, child_id=id, entity_type='chore', entity_id=task_id,
action='approved', points_before=points_before, points_after=child.points,
metadata=tracking_metadata
)
insert_tracking_event(tracking_event)
log_tracking_event(tracking_event)
send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value,
TrackingEventCreated(tracking_event.id, id, 'chore', 'approved')))
send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value,
ChildChoreConfirmation(id, task_id, ChildChoreConfirmation.OPERATION_APPROVED)))
send_event_for_current_user(Event(EventType.CHILD_TASK_TRIGGERED.value,
ChildTaskTriggered(task_id, id, child.points)))
return jsonify({
'message': f'Chore {task.name} approved for {child.name}.',
'points': child.points,
'id': child.id
'message': f'Chore {result["task_name"]} approved for {result["child_name"]}.',
'points': result['points'],
'id': result['child_id']
}), 200
@@ -1260,7 +1255,6 @@ def reject_chore(id):
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id))
if not result:
return jsonify({'error': 'Child not found'}), 404
child = Child.from_dict(result[0])
PendingQuery = Query()
existing = pending_confirmations_db.get(
@@ -1271,27 +1265,7 @@ def reject_chore(id):
if not existing:
return jsonify({'error': 'No pending confirmation found', 'code': 'PENDING_NOT_FOUND'}), 400
pending_confirmations_db.remove(
(PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) &
(PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id)
)
TaskQuery = Query()
task_result = task_db.get((TaskQuery.id == task_id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
task_name = task_result.get('name') if task_result else 'Unknown'
tracking_event = TrackingEvent.create_event(
user_id=user_id, child_id=id, entity_type='chore', entity_id=task_id,
action='rejected', points_before=child.points, points_after=child.points,
metadata={'task_name': task_name}
)
insert_tracking_event(tracking_event)
log_tracking_event(tracking_event)
send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value,
TrackingEventCreated(tracking_event.id, id, 'chore', 'rejected')))
send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value,
ChildChoreConfirmation(id, task_id, ChildChoreConfirmation.OPERATION_REJECTED)))
chore_actions.reject_chore(user_id, id, task_id)
return jsonify({'message': 'Chore confirmation rejected.'}), 200
@@ -1344,3 +1318,22 @@ def reset_chore(id):
ChildChoreConfirmation(id, task_id, ChildChoreConfirmation.OPERATION_RESET)))
return jsonify({'message': 'Chore reset to available.'}), 200
@child_api.route('/child/<id>/deny-reward-request', methods=['POST'])
def deny_reward_request(id):
"""Parent denies a child's pending reward request."""
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json()
reward_id = data.get('reward_id')
if not reward_id:
return jsonify({'error': 'reward_id is required'}), 400
result = chore_actions.deny_reward(user_id, id, reward_id)
if result is None:
return jsonify({'message': 'This reward request has already been resolved.'}), 200
return jsonify({'message': f'Reward request denied for {result["child_name"]}.'}), 200

View File

@@ -0,0 +1,88 @@
import logging
from flask import Blueprint, redirect, make_response
from tinydb import Query
from utils.digest_token import validate_and_consume_token, validate_unsubscribe_token
from db.db import users_db
from api.child_action_helpers import approve_chore, reject_chore, approve_reward_request, deny_reward
digest_action_api = Blueprint('digest_action_api', __name__)
logger = logging.getLogger(__name__)
_ERROR_HTML = """<!DOCTYPE html>
<html><head><title>Link Error</title></head>
<body style="font-family:sans-serif;text-align:center;margin-top:60px;">
<h2>This link is invalid or has expired.</h2>
<p>Action links expire after 24 hours and can only be used once.</p>
</body></html>"""
_UNSUB_HTML = """<!DOCTYPE html>
<html><head><title>Unsubscribed</title></head>
<body style="font-family:sans-serif;text-align:center;margin-top:60px;">
<h2>You have been unsubscribed from daily digest emails.</h2>
<p>To re-enable, visit your profile in the app.</p>
</body></html>"""
_UNSUB_ERROR_HTML = """<!DOCTYPE html>
<html><head><title>Link Error</title></head>
<body style="font-family:sans-serif;text-align:center;margin-top:60px;">
<h2>This unsubscribe link is invalid or has expired.</h2>
</body></html>"""
@digest_action_api.route('/digest-action/<token_id>', methods=['GET'])
def handle_digest_action(token_id: str):
"""
Validate a digest action token and execute the corresponding action.
On success: 302 redirect to the ParentView deep link.
On failure: 400 HTML error page.
"""
from flask import current_app
frontend_url = current_app.config.get('FRONTEND_URL', 'https://localhost:5173')
token = validate_and_consume_token(token_id)
if not token:
return make_response(_ERROR_HTML, 400)
user_id = token.user_id
child_id = token.child_id
entity_id = token.entity_id
entity_type = token.entity_type
action = token.action
try:
if entity_type == 'chore' and action == 'approve':
approve_chore(user_id, child_id, entity_id)
elif entity_type == 'chore' and action == 'deny':
reject_chore(user_id, child_id, entity_id)
elif entity_type == 'reward' and action == 'approve':
approve_reward_request(user_id, child_id, entity_id)
elif entity_type == 'reward' and action == 'deny':
deny_reward(user_id, child_id, entity_id)
else:
return make_response(_ERROR_HTML, 400)
except Exception as e:
logger.error(f'Error executing digest action {action}/{entity_type}: {e}')
return make_response(_ERROR_HTML, 400)
deep_link = (
f"{frontend_url}/parent/{child_id}"
f"?scrollTo={entity_id}&entityType={entity_type}"
)
return redirect(deep_link, 302)
@digest_action_api.route('/digest-unsubscribe/<token>', methods=['GET'])
def handle_digest_unsubscribe(token: str):
user_id = validate_unsubscribe_token(token)
if not user_id:
return make_response(_UNSUB_ERROR_HTML, 400)
UserQ = Query()
users_db.update({'email_digest_enabled': False}, UserQ.id == user_id)
logger.info(f'User {user_id} unsubscribed from digest via email link')
return make_response(_UNSUB_HTML, 200)

View File

@@ -35,3 +35,4 @@ class ErrorCodes:
PENDING_NOT_FOUND = "PENDING_NOT_FOUND"
INSUFFICIENT_POINTS = "INSUFFICIENT_POINTS"
INVALID_TASK_TYPE = "INVALID_TASK_TYPE"
DUPLICATE_REWARD_REQUEST = "DUPLICATE_REWARD_REQUEST"

View File

@@ -0,0 +1,59 @@
from flask import Blueprint, request, jsonify, current_app
from tinydb import Query
from api.utils import get_validated_user_id
from db.push_subscriptions import upsert_subscription, delete_by_endpoint
from db.db import users_db
push_subscription_api = Blueprint('push_subscription_api', __name__)
@push_subscription_api.route('/push-vapid-key', methods=['GET'])
def get_vapid_public_key():
"""Return the VAPID public key for the frontend to use when subscribing."""
public_key = current_app.config.get('VAPID_PUBLIC_KEY', '')
return jsonify({'public_key': public_key}), 200
@push_subscription_api.route('/push-subscription', methods=['POST'])
def subscribe():
"""Upsert a push subscription for the current user."""
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json() or {}
endpoint = data.get('endpoint')
keys = data.get('keys')
timezone_str = data.get('timezone')
if not endpoint or not isinstance(keys, dict):
return jsonify({'error': 'endpoint and keys are required', 'code': 'MISSING_FIELDS'}), 400
if 'p256dh' not in keys or 'auth' not in keys:
return jsonify({'error': 'keys must contain p256dh and auth', 'code': 'MISSING_FIELDS'}), 400
sub = upsert_subscription(user_id=user_id, endpoint=endpoint, keys=keys)
# Update user timezone if provided
if timezone_str:
UserQ = Query()
users_db.update({'timezone': timezone_str}, UserQ.id == user_id)
return jsonify({'message': 'Subscription saved', 'id': sub.id}), 200
@push_subscription_api.route('/push-subscription', methods=['DELETE'])
def unsubscribe():
"""Remove a push subscription for the current user by endpoint."""
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json() or {}
endpoint = data.get('endpoint')
if not endpoint:
return jsonify({'error': 'endpoint is required', 'code': 'MISSING_FIELDS'}), 400
removed = delete_by_endpoint(user_id=user_id, endpoint=endpoint)
return jsonify({'message': 'Subscription removed', 'removed': removed}), 200

View File

@@ -46,7 +46,8 @@ def get_profile():
'first_name': user.first_name,
'last_name': user.last_name,
'email': user.email,
'image_id': user.image_id
'image_id': user.image_id,
'email_digest_enabled': user.email_digest_enabled,
}), 200
@user_api.route('/user/profile', methods=['PUT'])
@@ -58,16 +59,19 @@ def update_profile():
if not user:
return jsonify({'error': 'Unauthorized'}), 401
data = request.get_json()
# Only allow first_name, last_name, image_id to be updated
# Only allow first_name, last_name, image_id, email_digest_enabled to be updated
first_name = data.get('first_name')
last_name = data.get('last_name')
image_id = data.get('image_id')
email_digest_enabled = data.get('email_digest_enabled')
if first_name is not None:
user.first_name = first_name
if last_name is not None:
user.last_name = last_name
if image_id is not None:
user.image_id = image_id
if email_digest_enabled is not None:
user.email_digest_enabled = bool(email_digest_enabled)
users_db.update(user.to_dict(), UserQuery.email == user.email)
# Create tracking event
@@ -78,6 +82,8 @@ def update_profile():
metadata['last_name_updated'] = True
if image_id is not None:
metadata['image_updated'] = True
if email_digest_enabled is not None:
metadata['email_digest_enabled_updated'] = True
tracking_event = TrackingEvent.create_event(
user_id=user_id,

View File

@@ -79,6 +79,8 @@ child_overrides_path = os.path.join(base_dir, 'child_overrides.json')
chore_schedules_path = os.path.join(base_dir, 'chore_schedules.json')
task_extensions_path = os.path.join(base_dir, 'task_extensions.json')
refresh_tokens_path = os.path.join(base_dir, 'refresh_tokens.json')
push_subscriptions_path = os.path.join(base_dir, 'push_subscriptions.json')
digest_action_tokens_path = os.path.join(base_dir, 'digest_action_tokens.json')
# Use separate TinyDB instances/files for each collection
_child_db = TinyDB(child_path, indent=2)
@@ -93,6 +95,8 @@ _child_overrides_db = TinyDB(child_overrides_path, indent=2)
_chore_schedules_db = TinyDB(chore_schedules_path, indent=2)
_task_extensions_db = TinyDB(task_extensions_path, indent=2)
_refresh_tokens_db = TinyDB(refresh_tokens_path, indent=2)
_push_subscriptions_db = TinyDB(push_subscriptions_path, indent=2)
_digest_action_tokens_db = TinyDB(digest_action_tokens_path, indent=2)
# Expose table objects wrapped with locking
child_db = LockedTable(_child_db)
@@ -107,6 +111,8 @@ child_overrides_db = LockedTable(_child_overrides_db)
chore_schedules_db = LockedTable(_chore_schedules_db)
task_extensions_db = LockedTable(_task_extensions_db)
refresh_tokens_db = LockedTable(_refresh_tokens_db)
push_subscriptions_db = LockedTable(_push_subscriptions_db)
digest_action_tokens_db = LockedTable(_digest_action_tokens_db)
if os.environ.get('DB_ENV', 'prod') == 'test':
child_db.truncate()
@@ -121,4 +127,6 @@ if os.environ.get('DB_ENV', 'prod') == 'test':
chore_schedules_db.truncate()
task_extensions_db.truncate()
refresh_tokens_db.truncate()
push_subscriptions_db.truncate()
digest_action_tokens_db.truncate()

View File

@@ -0,0 +1,18 @@
from tinydb import Query
from db.db import digest_action_tokens_db
from models.digest_action_token import DigestActionToken
def insert_token(token: DigestActionToken) -> None:
digest_action_tokens_db.insert(token.to_dict())
def get_token_by_id(token_id: str) -> DigestActionToken | None:
Q = Query()
result = digest_action_tokens_db.get(Q.id == token_id)
return DigestActionToken.from_dict(result) if result else None
def mark_token_used(token_id: str) -> None:
Q = Query()
digest_action_tokens_db.update({'used': True}, Q.id == token_id)

View File

@@ -0,0 +1,38 @@
from tinydb import Query
from db.db import push_subscriptions_db
from models.push_subscription import PushSubscription
def get_subscriptions_by_user(user_id: str) -> list[PushSubscription]:
"""Return all push subscriptions for a user."""
Q = Query()
results = push_subscriptions_db.search(Q.user_id == user_id)
return [PushSubscription.from_dict(r) for r in results]
def upsert_subscription(user_id: str, endpoint: str, keys: dict) -> PushSubscription:
"""Insert or update a subscription for the given user+endpoint pair."""
Q = Query()
existing = push_subscriptions_db.get((Q.user_id == user_id) & (Q.endpoint == endpoint))
if existing:
sub = PushSubscription.from_dict(existing)
sub.keys = keys
sub.touch()
push_subscriptions_db.update(sub.to_dict(), (Q.user_id == user_id) & (Q.endpoint == endpoint))
return sub
sub = PushSubscription(user_id=user_id, endpoint=endpoint, keys=keys)
push_subscriptions_db.insert(sub.to_dict())
return sub
def delete_by_endpoint(user_id: str, endpoint: str) -> int:
"""Remove the subscription with the given endpoint for this user. Returns count removed."""
Q = Query()
removed = push_subscriptions_db.remove((Q.user_id == user_id) & (Q.endpoint == endpoint))
return len(removed)
def delete_subscription_by_id(subscription_id: str) -> None:
"""Remove a subscription by its ID (used when push delivery fails)."""
Q = Query()
push_subscriptions_db.remove(Q.id == subscription_id)

View File

@@ -17,6 +17,8 @@ from api.reward_api import reward_api
from api.task_api import task_api
from api.tracking_api import tracking_api
from api.user_api import user_api
from api.push_subscription_api import push_subscription_api
from api.digest_action_api import digest_action_api
from config.version import get_full_version
from db.default import initializeImages, createDefaultTasks, createDefaultRewards
@@ -24,6 +26,7 @@ from events.broadcaster import Broadcaster
from events.sse import sse_response_for_user, send_to_user
from api.utils import get_current_user_id
from utils.account_deletion_scheduler import start_deletion_scheduler
from utils.digest_scheduler import start_digest_scheduler
# Configure logging once at application startup
logging.basicConfig(
@@ -54,6 +57,8 @@ app.register_blueprint(image_api)
app.register_blueprint(auth_api, url_prefix='/auth')
app.register_blueprint(user_api)
app.register_blueprint(tracking_api)
app.register_blueprint(push_subscription_api)
app.register_blueprint(digest_action_api)
app.config.update(
MAIL_SERVER='smtp.gmail.com',
@@ -63,6 +68,7 @@ app.config.update(
MAIL_PASSWORD='ruyj hxjf nmrz buar',
MAIL_DEFAULT_SENDER='ryan.kegel@gmail.com',
FRONTEND_URL=os.environ.get('FRONTEND_URL', 'https://localhost:5173'), # Dynamic via env var, defaults to localhost
VAPID_CLAIMS_EMAIL=os.environ.get('VAPID_CLAIMS_EMAIL', 'admin@reward-app.local'),
)
# Security: require SECRET_KEY and REFRESH_TOKEN_EXPIRY_DAYS from environment
@@ -81,6 +87,24 @@ try:
except ValueError:
raise RuntimeError('REFRESH_TOKEN_EXPIRY_DAYS must be an integer.')
_digest_token_secret = os.environ.get('DIGEST_TOKEN_SECRET')
if not _digest_token_secret:
raise RuntimeError(
'DIGEST_TOKEN_SECRET environment variable is required. '
'Set it to a random string (e.g. python -c "import secrets; print(secrets.token_urlsafe(64))")')
app.config['DIGEST_TOKEN_SECRET'] = _digest_token_secret
_vapid_public_key = os.environ.get('VAPID_PUBLIC_KEY')
_vapid_private_key = os.environ.get('VAPID_PRIVATE_KEY')
if not _vapid_public_key or not _vapid_private_key:
raise RuntimeError(
'VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY environment variables are required. '
'Generate a key pair with: python -c "from py_vapid import Vapid; v=Vapid(); '
'v.generate_keys(); print(v.public_key.public_bytes_raw().hex(), v.private_key.private_bytes_raw().hex())"'
)
app.config['VAPID_PUBLIC_KEY'] = _vapid_public_key
app.config['VAPID_PRIVATE_KEY'] = _vapid_private_key
@app.route("/version")
def api_version():
return jsonify({"version": get_full_version()})
@@ -115,6 +139,7 @@ createDefaultTasks()
createDefaultRewards()
start_background_threads()
start_deletion_scheduler()
start_digest_scheduler(app)
if __name__ == '__main__':
app.run(debug=False, host='0.0.0.0', port=5000, threaded=True)

View File

@@ -0,0 +1,44 @@
from dataclasses import dataclass
from models.base import BaseModel
@dataclass
class DigestActionToken(BaseModel):
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
used: bool = False
signature: str = ''
@classmethod
def from_dict(cls, d: dict) -> 'DigestActionToken':
return cls(
user_id=d.get('user_id'),
child_id=d.get('child_id'),
entity_id=d.get('entity_id'),
entity_type=d.get('entity_type'),
action=d.get('action'),
expires_at=d.get('expires_at'),
used=d.get('used', False),
signature=d.get('signature', ''),
id=d.get('id'),
created_at=d.get('created_at'),
updated_at=d.get('updated_at'),
)
def to_dict(self) -> dict:
base = super().to_dict()
base.update({
'user_id': self.user_id,
'child_id': self.child_id,
'entity_id': self.entity_id,
'entity_type': self.entity_type,
'action': self.action,
'expires_at': self.expires_at,
'used': self.used,
'signature': self.signature,
})
return base

View File

@@ -0,0 +1,29 @@
from dataclasses import dataclass
from models.base import BaseModel
@dataclass
class PushSubscription(BaseModel):
user_id: str
endpoint: str
keys: dict # {'p256dh': str, 'auth': str}
@classmethod
def from_dict(cls, d: dict) -> 'PushSubscription':
return cls(
user_id=d.get('user_id'),
endpoint=d.get('endpoint'),
keys=d.get('keys', {}),
id=d.get('id'),
created_at=d.get('created_at'),
updated_at=d.get('updated_at'),
)
def to_dict(self) -> dict:
base = super().to_dict()
base.update({
'user_id': self.user_id,
'endpoint': self.endpoint,
'keys': self.keys,
})
return base

View File

@@ -22,6 +22,8 @@ class User(BaseModel):
deletion_attempted_at: str | None = None
role: str = 'user'
token_version: int = 0
timezone: str | None = None
email_digest_enabled: bool = True
@classmethod
def from_dict(cls, d: dict):
@@ -45,6 +47,8 @@ class User(BaseModel):
deletion_attempted_at=d.get('deletion_attempted_at'),
role=d.get('role', 'user'),
token_version=d.get('token_version', 0),
timezone=d.get('timezone'),
email_digest_enabled=d.get('email_digest_enabled', True),
id=d.get('id'),
created_at=d.get('created_at'),
updated_at=d.get('updated_at')
@@ -73,5 +77,7 @@ class User(BaseModel):
'deletion_attempted_at': self.deletion_attempted_at,
'role': self.role,
'token_version': self.token_version,
'timezone': self.timezone,
'email_digest_enabled': self.email_digest_enabled,
})
return base

Binary file not shown.

View File

@@ -2,6 +2,9 @@ import os
os.environ['DB_ENV'] = 'test'
os.environ.setdefault('SECRET_KEY', 'test-secret-key')
os.environ.setdefault('REFRESH_TOKEN_EXPIRY_DAYS', '90')
os.environ.setdefault('DIGEST_TOKEN_SECRET', 'test-digest-secret')
os.environ.setdefault('VAPID_PUBLIC_KEY', 'test-vapid-public-key')
os.environ.setdefault('VAPID_PRIVATE_KEY', 'test-vapid-private-key')
import sys
import pytest

View File

@@ -5,7 +5,7 @@ import os
from flask import Flask
from api.child_api import child_api
from api.auth_api import auth_api
from db.db import child_db, reward_db, task_db, users_db, chore_schedules_db, task_extensions_db
from db.db import child_db, reward_db, task_db, users_db, chore_schedules_db, task_extensions_db, pending_confirmations_db
from tinydb import Query
from models.child import Child
import jwt
@@ -514,4 +514,100 @@ def test_list_child_tasks_no_server_side_filtering(client):
returned_ids = {t['id'] for t in resp.get_json()['tasks']}
# Both good tasks must be present; server never filters based on schedule/time
assert TASK_GOOD_ID in returned_ids
assert extra_id in returned_ids
assert extra_id in returned_ids
# ---------------------------------------------------------------------------
# request-reward: duplicate guard
# ---------------------------------------------------------------------------
def test_request_reward_duplicate_returns_409(client):
"""Requesting the same reward twice without resolution returns 409 Conflict."""
reward_db.insert({'id': 'r_dup', 'name': 'Duplicate Reward', 'cost': 5, 'user_id': 'testuserid'})
child_db.insert({
'id': 'child_dup',
'name': 'Dupe Kid',
'age': 8,
'points': 20,
'tasks': [],
'rewards': ['r_dup'],
'user_id': 'testuserid',
})
first = client.post('/child/child_dup/request-reward', json={'reward_id': 'r_dup'})
assert first.status_code == 200
second = client.post('/child/child_dup/request-reward', json={'reward_id': 'r_dup'})
assert second.status_code == 409
assert second.get_json()['code'] == 'DUPLICATE_REWARD_REQUEST'
# Cleanup
pending_confirmations_db.remove(Query().child_id == 'child_dup')
child_db.remove(Query().id == 'child_dup')
reward_db.remove(Query().id == 'r_dup')
# ---------------------------------------------------------------------------
# deny-reward-request endpoint
# ---------------------------------------------------------------------------
def test_deny_reward_request_removes_pending(client):
"""Denying a reward request removes the pending confirmation and fires REQUEST_CANCELLED."""
reward_db.insert({'id': 'r_deny1', 'name': 'Deny Reward', 'cost': 5, 'user_id': 'testuserid'})
child_db.insert({
'id': 'child_deny1',
'name': 'Deny Kid',
'age': 9,
'points': 10,
'tasks': [],
'rewards': ['r_deny1'],
'user_id': 'testuserid',
})
pending_confirmations_db.insert({
'id': 'pend_deny1',
'child_id': 'child_deny1',
'entity_id': 'r_deny1',
'entity_type': 'reward',
'user_id': 'testuserid',
'status': 'pending',
'approved_at': None,
})
resp = client.post('/child/child_deny1/deny-reward-request', json={'reward_id': 'r_deny1'})
assert resp.status_code == 200
remaining = pending_confirmations_db.get(
(Query().child_id == 'child_deny1') & (Query().entity_id == 'r_deny1')
)
assert remaining is None
# Cleanup
child_db.remove(Query().id == 'child_deny1')
reward_db.remove(Query().id == 'r_deny1')
def test_deny_reward_request_already_resolved_returns_200(client):
"""If no pending request exists, denying returns 200 with an informational message."""
resp = client.post('/child/child_gone/deny-reward-request', json={'reward_id': 'r_gone'})
assert resp.status_code == 200
data = resp.get_json()
assert 'already been resolved' in data['message']
def test_deny_reward_request_requires_auth(client):
"""Deny-reward-request endpoint rejects unauthenticated requests."""
# Create a fresh unauthenticated client
from flask import Flask
from api.child_api import child_api as child_api_bp
from api.auth_api import auth_api as auth_api_bp
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
app2 = Flask(__name__)
app2.register_blueprint(child_api_bp)
app2.register_blueprint(auth_api_bp, url_prefix='/auth')
app2.config['TESTING'] = True
app2.config['SECRET_KEY'] = TEST_SECRET_KEY
app2.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS
with app2.test_client() as anon:
resp = anon.post('/child/someid/deny-reward-request', json={'reward_id': 'r1'})
assert resp.status_code == 401

View File

@@ -0,0 +1,190 @@
import os
import pytest
from flask import Flask
from werkzeug.security import generate_password_hash
from tinydb import Query
from api.digest_action_api import digest_action_api
from db.db import (
users_db, child_db, task_db, reward_db,
pending_confirmations_db, digest_action_tokens_db,
tracking_events_db,
)
from utils.digest_token import (
create_action_token, create_unsubscribe_token, validate_unsubscribe_token
)
from tests.conftest import TEST_SECRET_KEY
TEST_USER_ID = "daa_user_id"
TEST_CHILD_ID = "daa_child_id"
TEST_TASK_ID = "daa_task_id"
TEST_REWARD_ID = "daa_reward_id"
FRONTEND_URL = "http://localhost:5173"
def seed_data():
users_db.remove(Query().id == TEST_USER_ID)
child_db.remove(Query().id == TEST_CHILD_ID)
task_db.remove(Query().id == TEST_TASK_ID)
reward_db.remove(Query().id == TEST_REWARD_ID)
pending_confirmations_db.remove(Query().user_id == TEST_USER_ID)
digest_action_tokens_db.truncate()
tracking_events_db.remove(Query().user_id == TEST_USER_ID)
users_db.insert({
"id": TEST_USER_ID,
"first_name": "Digest",
"last_name": "Tester",
"email": "digest@example.com",
"password": generate_password_hash("password"),
"verified": True,
"role": "user",
"image_id": None,
"marked_for_deletion": False,
"marked_for_deletion_at": None,
"email_digest_enabled": True,
"timezone": "America/New_York",
})
child_db.insert({
"id": TEST_CHILD_ID,
"user_id": TEST_USER_ID,
"name": "Test Child",
"age": 8,
"tasks": [TEST_TASK_ID],
"rewards": [TEST_REWARD_ID],
"points": 100,
"image_id": None,
})
task_db.insert({
"id": TEST_TASK_ID,
"user_id": TEST_USER_ID,
"name": "Clean Room",
"points": 10,
"type": "chore",
"description": "",
"image_id": None,
})
reward_db.insert({
"id": TEST_REWARD_ID,
"user_id": TEST_USER_ID,
"name": "Extra Screen Time",
"cost": 20,
"description": "",
"image_id": None,
})
def add_pending_chore():
pending_confirmations_db.insert({
"id": "pending_chore_id",
"user_id": TEST_USER_ID,
"child_id": TEST_CHILD_ID,
"entity_id": TEST_TASK_ID,
"entity_type": "chore",
"status": "pending",
"created_at": "2024-01-01T10:00:00+00:00",
})
def add_pending_reward():
pending_confirmations_db.insert({
"id": "pending_reward_id",
"user_id": TEST_USER_ID,
"child_id": TEST_CHILD_ID,
"entity_id": TEST_REWARD_ID,
"entity_type": "reward",
"status": "pending",
"created_at": "2024-01-01T10:00:00+00:00",
})
@pytest.fixture
def client():
app = Flask(__name__)
app.register_blueprint(digest_action_api)
app.config['TESTING'] = True
app.config['SECRET_KEY'] = TEST_SECRET_KEY
app.config['FRONTEND_URL'] = FRONTEND_URL
seed_data()
with app.test_client() as c:
yield c
seed_data()
class TestHandleDigestAction:
def test_approve_chore_redirects_to_child_view(self, client):
add_pending_chore()
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve')
res = client.get(f'/digest-action/{token.id}')
assert res.status_code == 302
location = res.headers['Location']
assert f'/parent/{TEST_CHILD_ID}' in location
assert 'scrollTo=' in location
def test_approve_chore_awards_points(self, client):
add_pending_chore()
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve')
client.get(f'/digest-action/{token.id}')
child = child_db.get(Query().id == TEST_CHILD_ID)
assert child['points'] == 110 # 100 + 10
def test_deny_chore_removes_pending(self, client):
add_pending_chore()
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'deny')
client.get(f'/digest-action/{token.id}')
pending = pending_confirmations_db.get(
(Query().child_id == TEST_CHILD_ID) & (Query().entity_id == TEST_TASK_ID)
)
assert pending is None
def test_approve_reward_deducts_points(self, client):
add_pending_reward()
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_REWARD_ID, 'reward', 'approve')
client.get(f'/digest-action/{token.id}')
child = child_db.get(Query().id == TEST_CHILD_ID)
assert child['points'] == 80 # 100 - 20
def test_deny_reward_removes_pending(self, client):
add_pending_reward()
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_REWARD_ID, 'reward', 'deny')
client.get(f'/digest-action/{token.id}')
pending = pending_confirmations_db.get(
(Query().child_id == TEST_CHILD_ID) & (Query().entity_id == TEST_REWARD_ID)
)
assert pending is None
def test_invalid_token_returns_400(self, client):
res = client.get('/digest-action/nonexistent-token-id')
assert res.status_code == 400
def test_used_token_returns_400(self, client):
add_pending_chore()
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve')
client.get(f'/digest-action/{token.id}') # first use
res = client.get(f'/digest-action/{token.id}') # second use
assert res.status_code == 400
def test_approve_already_resolved_chore_still_redirects(self, client):
"""If the chore was already resolved, action still returns a redirect (idempotent)."""
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve')
# No pending chore added — already resolved
res = client.get(f'/digest-action/{token.id}')
assert res.status_code == 302
class TestHandleDigestUnsubscribe:
def test_valid_token_unsubscribes_user(self, client):
token = create_unsubscribe_token(TEST_USER_ID)
res = client.get(f'/digest-unsubscribe/{token}')
assert res.status_code == 200
user = users_db.get(Query().id == TEST_USER_ID)
assert user['email_digest_enabled'] is False
def test_invalid_token_returns_400(self, client):
res = client.get('/digest-unsubscribe/garbage-token')
assert res.status_code == 400
def test_response_contains_unsubscribed_message(self, client):
token = create_unsubscribe_token(TEST_USER_ID)
res = client.get(f'/digest-unsubscribe/{token}')
assert b'unsubscribed' in res.data.lower()

View File

@@ -0,0 +1,262 @@
"""Tests for the digest scheduler and email HTML generation."""
import os
import pytest
from unittest.mock import patch, MagicMock
from flask import Flask
from werkzeug.security import generate_password_hash
from tinydb import Query
from utils.digest_scheduler import send_digests
from utils.email_sender import send_digest_email
from db.db import users_db, pending_confirmations_db, child_db, task_db, reward_db
from tests.conftest import TEST_SECRET_KEY
SCHED_USER_ID = "sched_test_user"
SCHED_EMAIL = "schedtest@example.com"
SCHED_CHILD_ID = "sched_child_id"
SCHED_TASK_ID = "sched_task_id"
SCHED_REWARD_ID = "sched_reward_id"
def seed_scheduler_data(
verified=True,
email_digest_enabled=True,
timezone="UTC",
has_pending=True,
):
users_db.remove(Query().id == SCHED_USER_ID)
child_db.remove(Query().id == SCHED_CHILD_ID)
task_db.remove(Query().id == SCHED_TASK_ID)
reward_db.remove(Query().id == SCHED_REWARD_ID)
pending_confirmations_db.remove(Query().user_id == SCHED_USER_ID)
users_db.insert({
"id": SCHED_USER_ID,
"first_name": "Sched",
"last_name": "Tester",
"email": SCHED_EMAIL,
"password": generate_password_hash("schedpass"),
"verified": verified,
"role": "user",
"image_id": None,
"marked_for_deletion": False,
"marked_for_deletion_at": None,
"timezone": timezone,
"email_digest_enabled": email_digest_enabled,
})
child_db.insert({
"id": SCHED_CHILD_ID,
"user_id": SCHED_USER_ID,
"name": "Sched Child",
"age": 8,
"points": 50,
"tasks": [SCHED_TASK_ID],
"rewards": [SCHED_REWARD_ID],
"image_id": None,
})
task_db.insert({
"id": SCHED_TASK_ID,
"user_id": SCHED_USER_ID,
"name": "Clean Room",
"points": 10,
"type": "chore",
"image_id": None,
})
reward_db.insert({
"id": SCHED_REWARD_ID,
"user_id": SCHED_USER_ID,
"name": "Movie Night",
"cost": 20,
"image_id": None,
})
if has_pending:
pending_confirmations_db.insert({
"id": "sched_pending_id",
"user_id": SCHED_USER_ID,
"child_id": SCHED_CHILD_ID,
"entity_id": SCHED_TASK_ID,
"entity_type": "chore",
"status": "pending",
"approved_at": None,
})
def cleanup_scheduler_data():
users_db.remove(Query().id == SCHED_USER_ID)
child_db.remove(Query().id == SCHED_CHILD_ID)
task_db.remove(Query().id == SCHED_TASK_ID)
reward_db.remove(Query().id == SCHED_REWARD_ID)
pending_confirmations_db.remove(Query().user_id == SCHED_USER_ID)
@pytest.fixture
def app():
flask_app = Flask(__name__)
flask_app.config['TESTING'] = True
flask_app.config['SECRET_KEY'] = TEST_SECRET_KEY
flask_app.config['FRONTEND_URL'] = 'http://localhost:5173'
flask_app.config['MAIL_DEFAULT_SENDER'] = 'no-reply@reward-app.local'
return flask_app
class TestSendDigests:
def setup_method(self):
cleanup_scheduler_data()
def teardown_method(self):
cleanup_scheduler_data()
def test_sends_digest_to_eligible_user_at_9pm(self, app):
"""Identifies verified, digest-enabled users whose local time is 9 pm and sends digest."""
seed_scheduler_data()
with patch('utils.digest_scheduler._get_local_hour', return_value=21), \
patch('utils.email_sender.Mail') as mock_mail:
send_digests(app)
assert mock_mail.return_value.send.called
def test_skips_user_with_no_pending_items(self, app):
"""Digest is not sent if there are no pending items."""
seed_scheduler_data(has_pending=False)
with patch('utils.digest_scheduler._get_local_hour', return_value=21), \
patch('utils.email_sender.Mail') as mock_mail:
send_digests(app)
assert not mock_mail.return_value.send.called
def test_skips_unverified_user(self, app):
"""Digest is not sent to unverified users."""
seed_scheduler_data(verified=False)
with patch('utils.digest_scheduler._get_local_hour', return_value=21), \
patch('utils.email_sender.Mail') as mock_mail:
send_digests(app)
assert not mock_mail.return_value.send.called
def test_skips_user_with_digest_disabled(self, app):
"""Digest is not sent to users who have email_digest_enabled == False."""
seed_scheduler_data(email_digest_enabled=False)
with patch('utils.digest_scheduler._get_local_hour', return_value=21), \
patch('utils.email_sender.Mail') as mock_mail:
send_digests(app)
assert not mock_mail.return_value.send.called
def test_skips_user_not_at_9pm(self, app):
"""Digest is not sent when the user's local time is not 21."""
seed_scheduler_data()
with patch('utils.digest_scheduler._get_local_hour', return_value=10), \
patch('utils.email_sender.Mail') as mock_mail:
send_digests(app)
assert not mock_mail.return_value.send.called
def test_reads_timezone_and_falls_back_to_utc(self, app):
"""_get_local_hour uses User.timezone; None falls back to UTC."""
from utils.digest_scheduler import _get_local_hour
from datetime import datetime, timezone as tz
utc_hour = datetime.now(tz.utc).hour
assert _get_local_hour(None) == utc_hour
# A real timezone that differs from UTC (New York is UTC-4 or UTC-5)
result = _get_local_hour("America/New_York")
assert 0 <= result <= 23
def test_skips_when_db_env_is_e2e(self, app):
"""Digest scheduler does nothing in the e2e test environment."""
seed_scheduler_data()
original = os.environ.get('DB_ENV')
try:
os.environ['DB_ENV'] = 'e2e'
with patch('utils.email_sender.Mail') as mock_mail:
send_digests(app)
assert not mock_mail.return_value.send.called
finally:
if original is not None:
os.environ['DB_ENV'] = original
else:
os.environ.pop('DB_ENV', None)
class TestDigestEmailHtml:
"""Tests for the HTML content of send_digest_email."""
@pytest.fixture
def app_ctx(self, app):
with app.app_context():
yield
def _build_items(self):
return [
{
'child_name': 'Alice',
'entity_name': 'Clean Room',
'entity_type': 'chore',
'view_url': 'http://localhost:5173/parent/child1?scrollTo=task1&entityType=chore',
'approve_url': 'http://localhost:5173/api/digest-action/approve_tok',
'deny_url': 'http://localhost:5173/api/digest-action/deny_tok',
'child_id': 'child1',
'entity_id': 'task1',
},
{
'child_name': 'Alice',
'entity_name': 'Movie Night',
'entity_type': 'reward',
'view_url': 'http://localhost:5173/parent/child1?scrollTo=reward1&entityType=reward',
'approve_url': 'http://localhost:5173/api/digest-action/approve_tok2',
'deny_url': 'http://localhost:5173/api/digest-action/deny_tok2',
'child_id': 'child1',
'entity_id': 'reward1',
},
]
def test_email_contains_child_section(self, app_ctx):
"""Email HTML contains a section for each child."""
with patch('utils.email_sender.Mail') as mock_mail:
send_digest_email(SCHED_EMAIL, self._build_items(), 'unsub_tok')
msg = mock_mail.return_value.send.call_args[0][0]
assert 'Alice' in msg.html
def test_email_contains_item_names(self, app_ctx):
"""Email HTML lists each pending item's name."""
with patch('utils.email_sender.Mail') as mock_mail:
send_digest_email(SCHED_EMAIL, self._build_items(), 'unsub_tok')
msg = mock_mail.return_value.send.call_args[0][0]
assert 'Clean Room' in msg.html
assert 'Movie Night' in msg.html
def test_email_contains_approve_and_deny_links(self, app_ctx):
"""Email HTML contains Approve and Deny links for each item."""
with patch('utils.email_sender.Mail') as mock_mail:
send_digest_email(SCHED_EMAIL, self._build_items(), 'unsub_tok')
msg = mock_mail.return_value.send.call_args[0][0]
assert 'approve_tok' in msg.html
assert 'deny_tok' in msg.html
assert 'Approve' in msg.html
assert 'Deny' in msg.html
def test_email_contains_view_links(self, app_ctx):
"""Email HTML contains a View link for each item."""
with patch('utils.email_sender.Mail') as mock_mail:
send_digest_email(SCHED_EMAIL, self._build_items(), 'unsub_tok')
msg = mock_mail.return_value.send.call_args[0][0]
assert 'View' in msg.html
assert 'scrollTo=task1' in msg.html
def test_email_contains_unsubscribe_link(self, app_ctx):
"""Email HTML footer contains the unsubscribe link."""
with patch('utils.email_sender.Mail') as mock_mail:
send_digest_email(SCHED_EMAIL, self._build_items(), 'unsub_tok')
msg = mock_mail.return_value.send.call_args[0][0]
assert 'unsub_tok' in msg.html
assert 'Unsubscribe' in msg.html
def test_approve_link_styled_green(self, app_ctx):
"""Approve links use green color styling."""
with patch('utils.email_sender.Mail') as mock_mail:
send_digest_email(SCHED_EMAIL, self._build_items(), 'unsub_tok')
msg = mock_mail.return_value.send.call_args[0][0]
# Find the Approve link and verify green color is adjacent
assert '#22863a' in msg.html # green used for Approve
def test_deny_link_styled_red(self, app_ctx):
"""Deny links use red color styling."""
with patch('utils.email_sender.Mail') as mock_mail:
send_digest_email(SCHED_EMAIL, self._build_items(), 'unsub_tok')
msg = mock_mail.return_value.send.call_args[0][0]
assert '#cb2431' in msg.html # red used for Deny

View File

@@ -0,0 +1,133 @@
import os
import time
import pytest
from tinydb import Query
from db.db import digest_action_tokens_db
from utils.digest_token import (
create_action_token,
validate_and_consume_token,
create_unsubscribe_token,
validate_unsubscribe_token,
)
def cleanup():
digest_action_tokens_db.truncate()
@pytest.fixture(autouse=True)
def clear_tokens():
cleanup()
yield
cleanup()
class TestCreateActionToken:
def test_returns_token_with_correct_fields(self):
token = create_action_token('u1', 'c1', 'e1', 'chore', 'approve')
assert token.user_id == 'u1'
assert token.child_id == 'c1'
assert token.entity_id == 'e1'
assert token.entity_type == 'chore'
assert token.action == 'approve'
assert token.used is False
assert token.signature
def test_persists_to_db(self):
token = create_action_token('u1', 'c1', 'e1', 'chore', 'approve')
stored = digest_action_tokens_db.get(Query().id == token.id)
assert stored is not None
assert stored['entity_type'] == 'chore'
def test_different_tokens_have_unique_ids(self):
t1 = create_action_token('u1', 'c1', 'e1', 'chore', 'approve')
t2 = create_action_token('u1', 'c1', 'e1', 'reward', 'deny')
assert t1.id != t2.id
class TestValidateAndConsumeToken:
def test_valid_token_is_returned_and_marked_used(self):
token = create_action_token('u1', 'c1', 'e1', 'chore', 'approve')
result = validate_and_consume_token(token.id)
assert result is not None
assert result.id == token.id
stored = digest_action_tokens_db.get(Query().id == token.id)
assert stored['used'] is True
def test_nonexistent_token_returns_none(self):
assert validate_and_consume_token('nonexistent-id') is None
def test_already_used_token_returns_none(self):
token = create_action_token('u1', 'c1', 'e1', 'chore', 'approve')
validate_and_consume_token(token.id) # first use
result = validate_and_consume_token(token.id) # second use
assert result is None
def test_expired_token_returns_none(self):
# Create a token that is already expired
from datetime import datetime, timedelta, timezone
from db.digest_action_tokens import insert_token
from models.digest_action_token import DigestActionToken
import uuid, json, hmac, hashlib
token_id = str(uuid.uuid4())
expires_at = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
payload = {
'id': token_id,
'user_id': 'u1',
'child_id': 'c1',
'entity_id': 'e1',
'entity_type': 'chore',
'action': 'approve',
'expires_at': expires_at,
}
secret = os.environ.get('DIGEST_TOKEN_SECRET')
canonical = json.dumps(payload, sort_keys=True, separators=(',', ':'))
sig = hmac.new(secret.encode(), canonical.encode(), hashlib.sha256).hexdigest()
token = DigestActionToken(
id=token_id, user_id='u1', child_id='c1', entity_id='e1',
entity_type='chore', action='approve', expires_at=expires_at,
used=False, signature=sig,
)
insert_token(token)
assert validate_and_consume_token(token_id) is None
def test_tampered_signature_returns_none(self):
token = create_action_token('u1', 'c1', 'e1', 'chore', 'approve')
# Tamper the signature in DB
digest_action_tokens_db.update(
{'signature': 'deadbeef' * 8},
Query().id == token.id
)
assert validate_and_consume_token(token.id) is None
class TestUnsubscribeToken:
def test_create_and_validate(self):
token = create_unsubscribe_token('user123')
assert token
result = validate_unsubscribe_token(token)
assert result == 'user123'
def test_invalid_token_returns_none(self):
assert validate_unsubscribe_token('garbage-token') is None
def test_tampered_token_returns_none(self):
token = create_unsubscribe_token('user123')
# Change one character
tampered = token[:-1] + ('A' if token[-1] != 'A' else 'B')
assert validate_unsubscribe_token(tampered) is None
def test_expired_token_returns_none(self):
"""Build a token with a past expiry timestamp by mocking time."""
import base64, hmac, hashlib
user_id = 'user_expired'
expiry_ts = int(time.time()) - 1 # already expired
payload_str = f"{user_id}:{expiry_ts}"
secret = os.environ.get('DIGEST_TOKEN_SECRET')
sig = hmac.new(secret.encode(), payload_str.encode(), hashlib.sha256).hexdigest()
raw = f"{payload_str}:{sig}"
token = base64.urlsafe_b64encode(raw.encode()).decode()
assert validate_unsubscribe_token(token) is None

View File

@@ -0,0 +1,170 @@
import os
import pytest
from flask import Flask
from werkzeug.security import generate_password_hash
from tinydb import Query
from api.push_subscription_api import push_subscription_api
from api.auth_api import auth_api
from db.db import users_db, push_subscriptions_db
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
TEST_EMAIL = "pushtest@example.com"
TEST_PASSWORD = "pushpassword123"
TEST_USER_ID = "push_test_user_id"
TEST_ENDPOINT = "https://fcm.googleapis.com/fcm/send/test-endpoint-abc"
TEST_KEYS = {"p256dh": "BNgz3XcMv1", "auth": "abc123"}
def seed_user():
users_db.remove(Query().email == TEST_EMAIL)
users_db.insert({
"id": TEST_USER_ID,
"first_name": "Push",
"last_name": "Tester",
"email": TEST_EMAIL,
"password": generate_password_hash(TEST_PASSWORD),
"verified": True,
"image_id": None,
"marked_for_deletion": False,
"marked_for_deletion_at": None,
"role": "user",
"timezone": None,
"email_digest_enabled": True,
})
def cleanup():
users_db.remove(Query().email == TEST_EMAIL)
push_subscriptions_db.remove(Query().user_id == TEST_USER_ID)
@pytest.fixture
def client():
app = Flask(__name__)
app.register_blueprint(push_subscription_api)
app.register_blueprint(auth_api, url_prefix='/auth')
app.config['TESTING'] = True
app.config['SECRET_KEY'] = TEST_SECRET_KEY
app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS
app.config['VAPID_PUBLIC_KEY'] = 'test-vapid-public-key'
app.config['FRONTEND_URL'] = 'http://localhost:5173'
cleanup()
seed_user()
with app.test_client() as c:
yield c
cleanup()
@pytest.fixture
def auth_client(client):
client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD})
return client
class TestVapidKey:
def test_returns_public_key(self, client):
res = client.get('/push-vapid-key')
assert res.status_code == 200
assert res.get_json()['public_key'] == 'test-vapid-public-key'
def test_unauthenticated_ok(self, client):
# VAPID key endpoint is public
res = client.get('/push-vapid-key')
assert res.status_code == 200
class TestSubscribe:
def test_subscribe_stores_subscription(self, auth_client):
res = auth_client.post('/push-subscription', json={
"endpoint": TEST_ENDPOINT,
"keys": TEST_KEYS,
})
assert res.status_code == 200
data = res.get_json()
assert 'id' in data
saved = push_subscriptions_db.get(
(Query().user_id == TEST_USER_ID) & (Query().endpoint == TEST_ENDPOINT)
)
assert saved is not None
def test_subscribe_updates_timezone(self, auth_client):
auth_client.post('/push-subscription', json={
"endpoint": TEST_ENDPOINT,
"keys": TEST_KEYS,
"timezone": "America/New_York",
})
user = users_db.get(Query().id == TEST_USER_ID)
assert user['timezone'] == 'America/New_York'
def test_subscribe_upserts_same_endpoint(self, auth_client):
auth_client.post('/push-subscription', json={"endpoint": TEST_ENDPOINT, "keys": TEST_KEYS})
auth_client.post('/push-subscription', json={"endpoint": TEST_ENDPOINT, "keys": TEST_KEYS})
count = len(push_subscriptions_db.search(
(Query().user_id == TEST_USER_ID) & (Query().endpoint == TEST_ENDPOINT)
))
assert count == 1
def test_subscribe_requires_endpoint(self, auth_client):
res = auth_client.post('/push-subscription', json={"keys": TEST_KEYS})
assert res.status_code == 400
def test_subscribe_requires_keys(self, auth_client):
res = auth_client.post('/push-subscription', json={"endpoint": TEST_ENDPOINT})
assert res.status_code == 400
def test_subscribe_requires_p256dh_and_auth(self, auth_client):
res = auth_client.post('/push-subscription', json={
"endpoint": TEST_ENDPOINT,
"keys": {"p256dh": "only-one-key"},
})
assert res.status_code == 400
def test_subscribe_requires_auth(self, client):
res = client.post('/push-subscription', json={
"endpoint": TEST_ENDPOINT,
"keys": TEST_KEYS,
})
assert res.status_code == 401
def test_subscribe_multiple_endpoints_per_user(self, auth_client):
"""Multiple subscriptions can coexist for the same user (one per device)."""
endpoint2 = "https://fcm.googleapis.com/fcm/send/second-device-endpoint"
keys2 = {"p256dh": "BNgz3XcMv2", "auth": "def456"}
auth_client.post('/push-subscription', json={"endpoint": TEST_ENDPOINT, "keys": TEST_KEYS})
auth_client.post('/push-subscription', json={"endpoint": endpoint2, "keys": keys2})
subs = push_subscriptions_db.search(Query().user_id == TEST_USER_ID)
assert len(subs) == 2
endpoints = {s['endpoint'] for s in subs}
assert TEST_ENDPOINT in endpoints
assert endpoint2 in endpoints
class TestUnsubscribe:
def test_unsubscribe_removes_subscription(self, auth_client):
auth_client.post('/push-subscription', json={"endpoint": TEST_ENDPOINT, "keys": TEST_KEYS})
res = auth_client.delete('/push-subscription', json={"endpoint": TEST_ENDPOINT})
assert res.status_code == 200
data = res.get_json()
assert data['removed'] >= 1
saved = push_subscriptions_db.get(
(Query().user_id == TEST_USER_ID) & (Query().endpoint == TEST_ENDPOINT)
)
assert saved is None
def test_unsubscribe_nonexistent_endpoint_ok(self, auth_client):
res = auth_client.delete('/push-subscription', json={"endpoint": "https://nonexistent"})
assert res.status_code == 200
assert res.get_json()['removed'] == 0
def test_unsubscribe_requires_endpoint(self, auth_client):
res = auth_client.delete('/push-subscription', json={})
assert res.status_code == 400
def test_unsubscribe_requires_auth(self, client):
res = client.delete('/push-subscription', json={"endpoint": TEST_ENDPOINT})
assert res.status_code == 401

View File

@@ -227,3 +227,36 @@ def test_update_profile_success(authenticated_client):
assert user['first_name'] == 'Updated'
assert user['last_name'] == 'Name'
assert user['image_id'] == 'new_image'
def test_get_profile_includes_email_digest_enabled(authenticated_client):
"""GET /user/profile response includes email_digest_enabled field."""
response = authenticated_client.get('/user/profile')
assert response.status_code == 200
data = response.get_json()
assert 'email_digest_enabled' in data
assert isinstance(data['email_digest_enabled'], bool)
def test_update_profile_disables_digest(authenticated_client):
"""PUT /user/profile with email_digest_enabled: false disables the digest."""
# Ensure it starts enabled
users_db.update({'email_digest_enabled': True}, Query().email == TEST_EMAIL)
response = authenticated_client.put('/user/profile', json={'email_digest_enabled': False})
assert response.status_code == 200
user = users_db.search(Query().email == TEST_EMAIL)[0]
assert user['email_digest_enabled'] is False
def test_update_profile_enables_digest(authenticated_client):
"""PUT /user/profile with email_digest_enabled: true re-enables the digest."""
# Start disabled
users_db.update({'email_digest_enabled': False}, Query().email == TEST_EMAIL)
response = authenticated_client.put('/user/profile', json={'email_digest_enabled': True})
assert response.status_code == 200
user = users_db.search(Query().email == TEST_EMAIL)[0]
assert user['email_digest_enabled'] is True

View File

@@ -0,0 +1,201 @@
"""Tests for web push notifications triggered by child actions.
Patches `utils.push_sender.webpush` so no real HTTP requests are made.
A push subscription is seeded in push_subscriptions_db so send_push_to_user
actually calls webpush (rather than short-circuiting at "no subscriptions").
"""
import json
import pytest
from unittest.mock import patch, MagicMock
from flask import Flask
from werkzeug.security import generate_password_hash
from tinydb import Query
from api.child_api import child_api
from api.auth_api import auth_api
from db.db import (
child_db, task_db, reward_db, users_db,
pending_confirmations_db, push_subscriptions_db,
)
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
TEST_USER_ID = "wp_test_user_id"
TEST_EMAIL = "wptest@example.com"
TEST_PASSWORD = "wptestpass"
TEST_CHILD_ID = "wp_child_id"
TEST_TASK_ID = "wp_task_id"
TEST_REWARD_ID_AFFORD = "wp_reward_afford"
TEST_REWARD_ID_CANT = "wp_reward_cant"
TEST_ENDPOINT = "https://push.example.com/wp_endpoint"
def seed_data():
users_db.remove(Query().id == TEST_USER_ID)
child_db.remove(Query().id == TEST_CHILD_ID)
task_db.remove(Query().id == TEST_TASK_ID)
reward_db.remove((Query().id == TEST_REWARD_ID_AFFORD) | (Query().id == TEST_REWARD_ID_CANT))
pending_confirmations_db.remove(Query().user_id == TEST_USER_ID)
push_subscriptions_db.remove(Query().user_id == TEST_USER_ID)
users_db.insert({
"id": TEST_USER_ID,
"first_name": "Push",
"last_name": "Tester",
"email": TEST_EMAIL,
"password": generate_password_hash(TEST_PASSWORD),
"verified": True,
"role": "user",
"image_id": None,
"marked_for_deletion": False,
"marked_for_deletion_at": None,
"timezone": "America/New_York",
"email_digest_enabled": True,
})
child_db.insert({
"id": TEST_CHILD_ID,
"user_id": TEST_USER_ID,
"name": "Push Child",
"age": 8,
"points": 50,
"tasks": [TEST_TASK_ID],
"rewards": [TEST_REWARD_ID_AFFORD, TEST_REWARD_ID_CANT],
"image_id": None,
})
task_db.insert({
"id": TEST_TASK_ID,
"user_id": TEST_USER_ID,
"name": "Clean Room",
"points": 10,
"type": "chore",
"image_id": None,
})
# Affordable reward (cost <= child.points = 50)
reward_db.insert({
"id": TEST_REWARD_ID_AFFORD,
"user_id": TEST_USER_ID,
"name": "Movie Night",
"cost": 20,
"image_id": None,
})
# Unaffordable reward (cost > child.points = 50)
reward_db.insert({
"id": TEST_REWARD_ID_CANT,
"user_id": TEST_USER_ID,
"name": "New Bike",
"cost": 200,
"image_id": None,
})
def seed_subscription():
push_subscriptions_db.insert({
"id": "wp_sub_id",
"user_id": TEST_USER_ID,
"endpoint": TEST_ENDPOINT,
"keys": {"p256dh": "BNgz3test", "auth": "authtest"},
"created_at": "2024-01-01T00:00:00+00:00",
})
def cleanup():
users_db.remove(Query().id == TEST_USER_ID)
child_db.remove(Query().id == TEST_CHILD_ID)
task_db.remove(Query().id == TEST_TASK_ID)
reward_db.remove((Query().id == TEST_REWARD_ID_AFFORD) | (Query().id == TEST_REWARD_ID_CANT))
pending_confirmations_db.remove(Query().user_id == TEST_USER_ID)
push_subscriptions_db.remove(Query().user_id == TEST_USER_ID)
@pytest.fixture
def client():
app = Flask(__name__)
app.register_blueprint(child_api)
app.register_blueprint(auth_api, url_prefix='/auth')
app.config['TESTING'] = True
app.config['SECRET_KEY'] = TEST_SECRET_KEY
app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS
app.config['FRONTEND_URL'] = 'http://localhost:5173'
cleanup()
seed_data()
with app.test_client() as c:
c.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD})
yield c
cleanup()
class TestWebPushOnChoreConfirm:
def test_push_fired_to_all_subscriptions_on_chore_confirm(self, client):
"""Web push is sent to all stored subscriptions when a chore is confirmed."""
seed_subscription()
with patch('utils.push_sender.webpush') as mock_webpush:
resp = client.post(
f'/child/{TEST_CHILD_ID}/confirm-chore',
json={'task_id': TEST_TASK_ID},
)
assert resp.status_code == 200
assert mock_webpush.called
def test_push_payload_includes_required_fields(self, client):
"""Push payload includes user_id, approve_token, and deny_token."""
seed_subscription()
with patch('utils.push_sender.webpush') as mock_webpush:
client.post(
f'/child/{TEST_CHILD_ID}/confirm-chore',
json={'task_id': TEST_TASK_ID},
)
call_kwargs = mock_webpush.call_args[1]
payload = json.loads(call_kwargs['data'])
assert payload['user_id'] == TEST_USER_ID
assert 'approve_token' in payload
assert 'deny_token' in payload
def test_push_not_fired_when_no_subscriptions(self, client):
"""No error is raised when the parent has no push subscriptions."""
push_subscriptions_db.remove(Query().user_id == TEST_USER_ID)
with patch('utils.push_sender.webpush') as mock_webpush:
resp = client.post(
f'/child/{TEST_CHILD_ID}/confirm-chore',
json={'task_id': TEST_TASK_ID},
)
assert resp.status_code == 200
assert not mock_webpush.called
class TestWebPushOnRewardRequest:
def test_push_fired_for_affordable_reward_request(self, client):
"""Web push is fired when a child requests a reward they can afford."""
seed_subscription()
with patch('utils.push_sender.webpush') as mock_webpush:
resp = client.post(
f'/child/{TEST_CHILD_ID}/request-reward',
json={'reward_id': TEST_REWARD_ID_AFFORD},
)
assert resp.status_code == 200
assert mock_webpush.called
# Cleanup pending
pending_confirmations_db.remove(Query().child_id == TEST_CHILD_ID)
def test_push_not_fired_for_unaffordable_reward(self, client):
"""Web push is NOT fired when a child requests a reward they cannot afford."""
seed_subscription()
with patch('utils.push_sender.webpush') as mock_webpush:
resp = client.post(
f'/child/{TEST_CHILD_ID}/request-reward',
json={'reward_id': TEST_REWARD_ID_CANT},
)
# request-reward rejects unaffordable requests with 400
assert resp.status_code == 400
assert not mock_webpush.called
def test_push_not_fired_for_reward_when_no_subscriptions(self, client):
"""No error is raised for reward requests when the parent has no subscriptions."""
push_subscriptions_db.remove(Query().user_id == TEST_USER_ID)
with patch('utils.push_sender.webpush') as mock_webpush:
resp = client.post(
f'/child/{TEST_CHILD_ID}/request-reward',
json={'reward_id': TEST_REWARD_ID_AFFORD},
)
assert resp.status_code == 200
assert not mock_webpush.called
# Cleanup pending
pending_confirmations_db.remove(Query().child_id == TEST_CHILD_ID)

View File

@@ -0,0 +1,157 @@
import logging
import os
from datetime import datetime, timezone
from apscheduler.schedulers.background import BackgroundScheduler
from tinydb import Query
logger = logging.getLogger(__name__)
def _get_local_hour(tz_str: str | None) -> int:
"""Return the current hour (0-23) in the given IANA timezone, falling back to UTC."""
try:
if tz_str:
from zoneinfo import ZoneInfo # Python 3.9+
local_now = datetime.now(ZoneInfo(tz_str))
return local_now.hour
except Exception:
pass
return datetime.now(timezone.utc).hour
def send_digests(app) -> None:
"""Hourly job: send the 9pm digest to every eligible user whose local time is 21."""
if os.environ.get('DB_ENV') == 'e2e':
return
with app.app_context():
try:
from db.db import users_db, pending_confirmations_db, child_db, task_db, reward_db
from utils.email_sender import send_digest_email
from utils.digest_token import create_action_token, create_unsubscribe_token
from flask import current_app
frontend_url = current_app.config.get('FRONTEND_URL', 'https://localhost:5173')
UserQ = Query()
users = users_db.search(
(UserQ.verified == True) & (UserQ.email_digest_enabled == True)
)
for user_dict in users:
user_id = user_dict.get('id')
user_email = user_dict.get('email')
user_tz = user_dict.get('timezone')
local_hour = _get_local_hour(user_tz)
if local_hour != 21:
continue
# Gather pending items for this user
PendingQ = Query()
pending_items = pending_confirmations_db.search(
(PendingQ.user_id == user_id) & (PendingQ.status == 'pending')
)
if not pending_items:
continue
ChildQ = Query()
TaskQ = Query()
RewardQ = Query()
items = []
for p in pending_items:
child_id = p.get('child_id')
entity_id = p.get('entity_id')
entity_type = p.get('entity_type')
child_result = child_db.get(ChildQ.id == child_id)
if not child_result:
logger.warning(f'Digest: child {child_id} not found for pending item {p.get("id")} (user {user_id})')
continue
child_name = child_result.get('name')
if not child_name:
logger.warning(f'Digest: child {child_id} has no name (user {user_id})')
child_name = 'Unknown'
if entity_type == 'chore':
entity_result = task_db.get(
(TaskQ.id == entity_id) &
((TaskQ.user_id == user_id) | (TaskQ.user_id == None))
)
else:
entity_result = reward_db.get(
(RewardQ.id == entity_id) &
((RewardQ.user_id == user_id) | (RewardQ.user_id == None))
)
if not entity_result:
logger.warning(f'Digest: {entity_type} {entity_id} not found for pending item {p.get("id")} (user {user_id})')
continue
entity_name = entity_result.get('name')
if not entity_name:
logger.warning(f'Digest: {entity_type} {entity_id} has no name (user {user_id})')
entity_name = 'Unknown'
approve_token = create_action_token(
user_id=user_id,
child_id=child_id,
entity_id=entity_id,
entity_type=entity_type,
action='approve',
)
deny_token = create_action_token(
user_id=user_id,
child_id=child_id,
entity_id=entity_id,
entity_type=entity_type,
action='deny',
)
view_url = (
f"{frontend_url}/parent/{child_id}"
f"?scrollTo={entity_id}&entityType={entity_type}"
)
approve_url = f"{frontend_url}/api/digest-action/{approve_token.id}"
deny_url = f"{frontend_url}/api/digest-action/{deny_token.id}"
items.append({
'child_name': child_name,
'entity_name': entity_name,
'entity_type': entity_type,
'view_url': view_url,
'approve_url': approve_url,
'deny_url': deny_url,
'child_id': child_id,
'entity_id': entity_id,
})
if not items:
continue
unsubscribe_token = create_unsubscribe_token(user_id)
try:
send_digest_email(user_email, items, unsubscribe_token)
except Exception as e:
logger.error(f'Failed to send digest to {user_email}: {e}')
except Exception as e:
logger.error(f'Error in send_digests job: {e}')
def start_digest_scheduler(app) -> BackgroundScheduler:
"""Start the hourly digest scheduler. Returns the scheduler instance."""
scheduler = BackgroundScheduler()
scheduler.add_job(
send_digests,
'cron',
minute=0,
args=[app],
id='digest_scheduler',
replace_existing=True,
)
scheduler.start()
logger.info('Digest scheduler started (hourly)')
return scheduler

View File

@@ -0,0 +1,141 @@
import hashlib
import hmac
import json
import os
import time
import uuid
from datetime import datetime, timedelta, timezone
from db.digest_action_tokens import insert_token, get_token_by_id, mark_token_used
from models.digest_action_token import DigestActionToken
def _get_secret() -> bytes:
secret = os.environ.get('DIGEST_TOKEN_SECRET')
if not secret:
raise RuntimeError('DIGEST_TOKEN_SECRET environment variable is not set.')
return secret.encode('utf-8')
def _sign(payload: dict) -> str:
"""Return HMAC-SHA256 hex digest of the canonical JSON payload."""
canonical = json.dumps(payload, sort_keys=True, separators=(',', ':'))
return hmac.new(_get_secret(), canonical.encode('utf-8'), hashlib.sha256).hexdigest()
def create_action_token(
user_id: str,
child_id: str,
entity_id: str,
entity_type: str,
action: str,
expiry_hours: int = 24,
) -> DigestActionToken:
"""Create and persist a signed action token. Returns the saved token."""
token_id = str(uuid.uuid4())
expires_at = (datetime.now(timezone.utc) + timedelta(hours=expiry_hours)).isoformat()
payload = {
'id': token_id,
'user_id': user_id,
'child_id': child_id,
'entity_id': entity_id,
'entity_type': entity_type,
'action': action,
'expires_at': expires_at,
}
signature = _sign(payload)
token = DigestActionToken(
id=token_id,
user_id=user_id,
child_id=child_id,
entity_id=entity_id,
entity_type=entity_type,
action=action,
expires_at=expires_at,
used=False,
signature=signature,
)
insert_token(token)
return token
def validate_and_consume_token(token_id: str) -> DigestActionToken | None:
"""
Validate a token. Returns the token if valid and marks it as used.
Returns None if token is missing, expired, used, or has an invalid signature.
"""
token = get_token_by_id(token_id)
if not token:
return None
if token.used:
return None
# Check expiry
try:
expires_at = datetime.fromisoformat(token.expires_at)
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
if datetime.now(timezone.utc) > expires_at:
return None
except (ValueError, TypeError):
return None
# Verify HMAC signature
payload = {
'id': token.id,
'user_id': token.user_id,
'child_id': token.child_id,
'entity_id': token.entity_id,
'entity_type': token.entity_type,
'action': token.action,
'expires_at': token.expires_at,
}
expected_sig = _sign(payload)
if not hmac.compare_digest(token.signature, expected_sig):
return None
mark_token_used(token_id)
return token
def create_unsubscribe_token(user_id: str) -> str:
"""
Create a simple signed unsubscribe token string (not persisted in DB).
Format: '<user_id>.<expiry_ts>.<signature>'
"""
expiry_ts = int(time.time()) + 30 * 24 * 3600 # 30 days
payload_str = f"{user_id}:{expiry_ts}"
sig = hmac.new(_get_secret(), payload_str.encode('utf-8'), hashlib.sha256).hexdigest()
import base64
token = base64.urlsafe_b64encode(f"{payload_str}:{sig}".encode()).decode()
return token
def validate_unsubscribe_token(token: str) -> str | None:
"""
Validate an unsubscribe token.
Returns user_id if valid, None otherwise.
"""
import base64
try:
decoded = base64.urlsafe_b64decode(token.encode()).decode()
parts = decoded.rsplit(':', 1)
if len(parts) != 2:
return None
payload_str, sig = parts
expected_sig = hmac.new(_get_secret(), payload_str.encode('utf-8'), hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected_sig):
return None
sub_parts = payload_str.split(':', 1)
if len(sub_parts) != 2:
return None
user_id, expiry_str = sub_parts
expiry_ts = int(expiry_str)
if time.time() > expiry_ts:
return None
return user_id
except Exception:
return None

View File

@@ -60,4 +60,83 @@ def send_pin_setup_email(to_email: str, code: str) -> None:
Mail(current_app).send(msg)
print(f"[EMAIL to {to_email}] Parent PIN setup code: {code}")
except Exception:
print(f"Failed to send email to {to_email}. Parent PIN setup code: {code}")
print(f"Failed to send email to {to_email}. Parent PIN setup code: {code}")
def send_digest_email(to_email: str, items: list, unsubscribe_token: str) -> None:
"""
Send the nightly digest email listing pending chore/reward items.
Each item in `items` is a dict with keys:
child_name, entity_name, entity_type, view_url, approve_url, deny_url
Items are grouped by child_name in the email.
"""
frontend_url = current_app.config.get('FRONTEND_URL', 'https://localhost:5173')
unsubscribe_url = f"{frontend_url}/api/digest-unsubscribe/{unsubscribe_token}"
# Group items by child
from collections import defaultdict
by_child: dict = defaultdict(list)
for item in items:
by_child[item['child_name']].append(item)
child_sections = ''
for child_name, child_items in by_child.items():
rows = ''
for item in child_items:
entity_label = 'Chore' if item['entity_type'] == 'chore' else 'Reward'
rows += f"""
<tr>
<td style="padding:8px 4px;border-bottom:1px solid #eee;">
<span style="font-weight:500;">{item['entity_name']}</span>
<span style="color:#888;font-size:0.85em;margin-left:6px;">{entity_label}</span>
</td>
<td style="padding:8px 4px;border-bottom:1px solid #eee;text-align:center;">
<a href="{item['view_url']}"
style="color:#0066cc;text-decoration:underline;margin-right:8px;">View</a>
<a href="{item['approve_url']}"
style="color:#22863a;font-weight:bold;text-decoration:underline;margin-right:8px;">Approve</a>
<a href="{item['deny_url']}"
style="color:#cb2431;font-weight:bold;text-decoration:underline;">Deny</a>
</td>
</tr>"""
child_sections += f"""
<div style="margin-bottom:24px;">
<h3 style="color:#333;margin:0 0 8px 0;font-size:1rem;">{child_name}</h3>
<table style="width:100%;border-collapse:collapse;font-size:0.92rem;">
<tbody>{rows}</tbody>
</table>
</div>"""
html_body = f"""
<div style="font-family:sans-serif;max-width:600px;margin:0 auto;">
<div style="background:#4a90e2;color:#fff;padding:18px 24px;border-radius:6px 6px 0 0;">
<h2 style="margin:0;font-size:1.2rem;">Reward App &mdash; Daily Summary</h2>
</div>
<div style="background:#fff;padding:24px;border:1px solid #ddd;border-top:none;border-radius:0 0 6px 6px;">
<p style="color:#555;margin:0 0 20px 0;">
You have pending items that need your attention:
</p>
{child_sections}
</div>
<div style="color:#aaa;font-size:0.8rem;padding:12px 0;text-align:center;">
Reward App &bull;
<a href="{unsubscribe_url}" style="color:#aaa;">Unsubscribe from daily digest</a>
</div>
</div>
"""
msg = Message(
subject='Reward App — Daily Summary',
recipients=[to_email],
html=html_body,
sender=current_app.config.get('MAIL_DEFAULT_SENDER', 'no-reply@reward-app.local'),
)
try:
if os.environ.get('DB_ENV') == 'e2e':
return
Mail(current_app).send(msg)
print(f"[EMAIL to {to_email}] Daily digest sent ({len(items)} items)")
except Exception:
print(f"Failed to send digest email to {to_email}")

View File

@@ -0,0 +1,68 @@
import json
import logging
import os
from pywebpush import webpush, WebPushException
from db.push_subscriptions import get_subscriptions_by_user, delete_subscription_by_id
logger = logging.getLogger(__name__)
def _get_vapid_private_key() -> str | None:
return os.environ.get('VAPID_PRIVATE_KEY')
def _get_vapid_claims_email() -> str:
return os.environ.get('VAPID_CLAIMS_EMAIL', 'admin@reward-app.local')
def send_push_to_user(user_id: str, payload: dict) -> int:
"""
Send a web push notification to all stored subscriptions for the user.
Returns the number of subscriptions successfully notified.
Stale/expired subscriptions (HTTP 404/410) are removed automatically.
"""
private_key = _get_vapid_private_key()
if not private_key:
logger.warning('VAPID_PRIVATE_KEY not configured; skipping push notification')
return 0
subscriptions = get_subscriptions_by_user(user_id)
if not subscriptions:
return 0
claims_email = _get_vapid_claims_email()
sent = 0
for sub in subscriptions:
try:
subscription_info = {
'endpoint': sub.endpoint,
'keys': sub.keys,
}
webpush(
subscription_info=subscription_info,
data=json.dumps(payload),
vapid_private_key=private_key,
vapid_claims={'sub': f'mailto:{claims_email}'},
ttl=86400,
)
sent += 1
except WebPushException as e:
status_code = e.response.status_code if e.response is not None else None
if status_code in (404, 410):
# Subscription is gone — clean it up
logger.info(
f'Removing stale push subscription {sub.id} for user {user_id} (HTTP {status_code})'
)
delete_subscription_by_id(sub.id)
else:
logger.error(
f'WebPushException for subscription {sub.id} (user {user_id}): {e}'
)
except Exception as e:
logger.error(f'Unexpected error sending push to subscription {sub.id}: {e}')
return sent

View File

@@ -11,6 +11,9 @@ services:
- FRONTEND_URL=https://devserver.lan:446 # Add this for test env
- SECRET_KEY=${SECRET_KEY}
- REFRESH_TOKEN_EXPIRY_DAYS=${REFRESH_TOKEN_EXPIRY_DAYS}
- DIGEST_TOKEN_SECRET=${DIGEST_TOKEN_SECRET}
- VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY}
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
# Add volumes, networks, etc., as needed
chores-test-app-frontend: # Test frontend service name

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<link rel="manifest" href="/manifest.json" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
@@ -12,5 +13,14 @@
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', function () {
navigator.serviceWorker.register('/sw.js').catch(function (err) {
console.warn('Service Worker registration failed:', err)
})
})
}
</script>
</body>
</html>

View File

@@ -0,0 +1,16 @@
{
"name": "Reward App",
"short_name": "Rewards",
"start_url": "/",
"display": "standalone",
"theme_color": "#4a90e2",
"background_color": "#ffffff",
"description": "Track chores and rewards for your family",
"icons": [
{
"src": "/favicon.ico",
"sizes": "48x48",
"type": "image/x-icon"
}
]
}

View File

@@ -0,0 +1,68 @@
/* Service Worker for Reward App push notifications */
self.addEventListener('push', function (event) {
if (!event.data) return
let payload
try {
payload = event.data.json()
} catch (e) {
payload = { title: 'Reward App', body: event.data.text() }
}
const title = payload.title || 'Reward App'
const options = {
body: payload.body || '',
data: payload,
actions: [
{ action: 'approve', title: 'Approve' },
{ action: 'deny', title: 'Deny' },
],
}
event.waitUntil(self.registration.showNotification(title, options))
})
self.addEventListener('notificationclick', function (event) {
event.notification.close()
const payload = event.notification.data || {}
const approveToken = payload.approve_token
const denyToken = payload.deny_token
const childId = payload.child_id
const entityId = payload.entity_id
const entityType = payload.entity_type
if (event.action === 'approve' && approveToken) {
event.waitUntil(fetch('/api/digest-action/' + approveToken))
return
}
if (event.action === 'deny' && denyToken) {
event.waitUntil(fetch('/api/digest-action/' + denyToken))
return
}
// Body tap — open or focus the app then navigate to the deep link
const deepLinkUrl =
childId && entityId && entityType
? '/parent/' + childId + '?scrollTo=' + entityId + '&entityType=' + entityType
: '/parent'
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function (clientList) {
const sameOriginClients = clientList.filter(function (c) {
return new URL(c.url).origin === self.location.origin
})
if (sameOriginClients.length > 0) {
const client = sameOriginClients[0]
return client.focus().then(function () {
return client.navigate(deepLinkUrl)
})
}
return clients.openWindow(deepLinkUrl)
}),
)
})

View File

@@ -549,3 +549,119 @@ describe('UserProfile - Profile Update', () => {
expect(wrapper.vm.loading).toBe(false)
})
})
describe('UserProfile - Email Digest Toggle', () => {
let wrapper: VueWrapper<any>
function mountWithDigest(emailDigestEnabled: boolean) {
;(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({
image_id: null,
first_name: 'Test',
last_name: 'User',
email: 'test@example.com',
email_digest_enabled: emailDigestEnabled,
}),
})
return mount(UserProfile, {
global: {
plugins: [mockRouter],
stubs: {
EntityEditForm: {
template: '<div><slot /></div>',
},
ModalDialog: {
template: '<div class="mock-modal" v-if="show"><slot /></div>',
props: ['show'],
},
},
},
})
}
beforeEach(() => {
vi.clearAllMocks()
;(global.fetch as any).mockClear()
// Default response for any extra fetch calls (e.g. PUT)
;(global.fetch as any).mockResolvedValue({
ok: true,
json: async () => ({}),
})
})
it('renders the email digest toggle button', async () => {
wrapper = mountWithDigest(true)
await flushPromises()
await nextTick()
expect(wrapper.find('.toggle-btn').exists()).toBe(true)
})
it('initializes toggle as enabled when email_digest_enabled is true', async () => {
wrapper = mountWithDigest(true)
await flushPromises()
await nextTick()
expect(wrapper.vm.emailDigestEnabled).toBe(true)
expect(wrapper.find('.toggle-btn').attributes('aria-pressed')).toBe('true')
})
it('initializes toggle as disabled when email_digest_enabled is false', async () => {
wrapper = mountWithDigest(false)
await flushPromises()
await nextTick()
expect(wrapper.vm.emailDigestEnabled).toBe(false)
expect(wrapper.find('.toggle-btn').attributes('aria-pressed')).toBe('false')
})
it('calls PUT /api/user/profile with email_digest_enabled: false when toggled off', async () => {
wrapper = mountWithDigest(true)
await flushPromises()
await nextTick()
;(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({}) })
await wrapper.vm.toggleDigest()
await flushPromises()
expect(global.fetch).toHaveBeenCalledWith(
'/api/user/profile',
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({ email_digest_enabled: false }),
}),
)
})
it('calls PUT /api/user/profile with email_digest_enabled: true when toggled on', async () => {
wrapper = mountWithDigest(false)
await flushPromises()
await nextTick()
;(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({}) })
await wrapper.vm.toggleDigest()
await flushPromises()
expect(global.fetch).toHaveBeenCalledWith(
'/api/user/profile',
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({ email_digest_enabled: true }),
}),
)
})
it('flips emailDigestEnabled state after toggle', async () => {
wrapper = mountWithDigest(true)
await flushPromises()
await nextTick()
;(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({}) })
expect(wrapper.vm.emailDigestEnabled).toBe(true)
await wrapper.vm.toggleDigest()
await flushPromises()
expect(wrapper.vm.emailDigestEnabled).toBe(false)
})
})

View File

@@ -63,6 +63,8 @@ export interface User {
deletion_in_progress: boolean
deletion_attempted_at: string | null
role: string
timezone: string | null
email_digest_enabled: boolean
}
export interface Child {

View File

@@ -14,6 +14,9 @@ vi.mock('../../stores/auth', () => ({
isParentPersistent: { value: false },
logoutParent: vi.fn(),
logoutUser: vi.fn(),
consumePendingReturnUrl: vi.fn(() => null),
hasPendingReturnUrl: vi.fn(() => false),
clearPendingReturnUrl: vi.fn(),
}))
vi.mock('@/common/imageCache', () => ({

View File

@@ -612,6 +612,22 @@ async function fetchChildData(id: string | number) {
}
}
function applyHighlightPulse(itemId: string) {
// Delay slightly so the scroll animation has time to settle
setTimeout(() => {
const el = document.querySelector(`[data-item-id="${itemId}"]`)
if (!el) return
el.classList.add('highlight-pulse')
el.addEventListener(
'animationend',
() => {
el.classList.remove('highlight-pulse')
},
{ once: true },
)
}, 200)
}
onMounted(async () => {
try {
eventBus.on('child_task_triggered', handleTaskTriggered)
@@ -648,8 +664,10 @@ onMounted(async () => {
setTimeout(() => {
if (entityType === 'chore') {
childChoreListRef.value?.scrollToItem(scrollToId)
applyHighlightPulse(scrollToId)
} else if (entityType === 'reward') {
childRewardListRef.value?.scrollToItem(scrollToId)
applyHighlightPulse(scrollToId)
}
}, 500)
}
@@ -1404,4 +1422,23 @@ function goToAssignRewards() {
.input-group input.invalid {
border-color: var(--error-color, #e53e3e);
}
@keyframes highlight-pulse {
0% {
box-shadow: 0 0 0 0 var(--accent, #4a90e2);
outline: 2px solid var(--accent, #4a90e2);
}
50% {
box-shadow: 0 0 16px 4px var(--accent, #4a90e2);
outline: 2px solid var(--accent, #4a90e2);
}
100% {
box-shadow: none;
outline: none;
}
}
:global(.highlight-pulse) {
animation: highlight-pulse 1.8s ease-out forwards;
}
</style>

View File

@@ -538,4 +538,56 @@ describe('ParentView', () => {
expect(wrapper.vm.selectedChoreId).toBe(null)
})
})
describe('Highlight pulse animation', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('adds highlight-pulse class to element with matching data-item-id', async () => {
const el = document.createElement('div')
el.setAttribute('data-item-id', 'task-1')
document.body.appendChild(el)
wrapper = mount(ParentView, mountOptions)
wrapper.vm.applyHighlightPulse('task-1')
vi.advanceTimersByTime(200)
expect(el.classList.contains('highlight-pulse')).toBe(true)
document.body.removeChild(el)
})
it('removes highlight-pulse class when animationend fires', async () => {
const el = document.createElement('div')
el.setAttribute('data-item-id', 'task-1')
document.body.appendChild(el)
wrapper = mount(ParentView, mountOptions)
wrapper.vm.applyHighlightPulse('task-1')
vi.advanceTimersByTime(200)
expect(el.classList.contains('highlight-pulse')).toBe(true)
el.dispatchEvent(new Event('animationend'))
expect(el.classList.contains('highlight-pulse')).toBe(false)
document.body.removeChild(el)
})
it('does nothing when no element with matching data-item-id exists', () => {
wrapper = mount(ParentView, mountOptions)
// Should not throw
expect(() => {
wrapper.vm.applyHighlightPulse('nonexistent-id')
vi.advanceTimersByTime(200)
}).not.toThrow()
})
})
})

View File

@@ -0,0 +1,91 @@
<template>
<div v-if="showBanner" class="push-opt-in-banner" role="status" aria-live="polite">
<template v-if="permissionState === 'default'">
<span class="push-opt-in-text"
>Enable notifications to stay updated on chores and rewards.</span
>
<button class="btn btn-primary push-opt-in-btn" @click="onEnable">Enable</button>
<button class="push-opt-in-dismiss" @click="dismiss" aria-label="Dismiss"></button>
</template>
<template v-else-if="permissionState === 'denied'">
<span class="push-opt-in-text"
>Notifications are blocked. Enable them in your browser settings to receive alerts.</span
>
<button class="push-opt-in-dismiss" @click="dismiss" aria-label="Dismiss"></button>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { subscribeToPush, getPushPermissionState } from '@/services/pushSubscription'
const permissionState = ref<NotificationPermission | 'unsupported'>('default')
const dismissed = ref(false)
const showBanner = ref(false)
onMounted(() => {
if (!('Notification' in window)) {
return
}
permissionState.value = Notification.permission
if (permissionState.value === 'granted') {
// Already granted — ensure subscription is registered silently
subscribeToPush()
return
}
showBanner.value = true
})
async function onEnable() {
const success = await subscribeToPush()
if (success) {
permissionState.value = 'granted'
showBanner.value = false
} else {
permissionState.value = Notification.permission as NotificationPermission
}
}
function dismiss() {
dismissed.value = true
showBanner.value = false
}
</script>
<style scoped>
.push-opt-in-banner {
display: flex;
align-items: center;
gap: 0.75rem;
background: var(--list-item-bg, #f0f4ff);
border: 1px solid var(--btn-primary, #4a90e2);
border-radius: 8px;
padding: 0.6rem 1rem;
margin: 0.5rem 0;
font-size: 0.88rem;
flex-wrap: wrap;
}
.push-opt-in-text {
flex: 1;
min-width: 0;
}
.push-opt-in-btn {
font-size: 0.85rem;
padding: 0.35rem 0.9rem;
white-space: nowrap;
}
.push-opt-in-dismiss {
background: none;
border: none;
cursor: pointer;
color: var(--text-muted, #888);
font-size: 1rem;
padding: 0 0.25rem;
line-height: 1;
}
</style>

View File

@@ -0,0 +1,133 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import PushOptIn from '../PushOptIn.vue'
const mockSubscribeToPush = vi.fn()
const mockGetPushPermissionState = vi.fn()
vi.mock('@/services/pushSubscription', () => ({
subscribeToPush: () => mockSubscribeToPush(),
getPushPermissionState: () => mockGetPushPermissionState(),
}))
function setupNotificationMock(permission: NotificationPermission) {
Object.defineProperty(window, 'Notification', {
writable: true,
value: { permission },
})
}
describe('PushOptIn', () => {
afterEach(() => {
vi.clearAllMocks()
})
describe('permission already granted on mount', () => {
beforeEach(() => {
setupNotificationMock('granted')
mockSubscribeToPush.mockResolvedValue(true)
})
it('silently calls subscribeToPush without showing the banner', async () => {
const wrapper = mount(PushOptIn)
await flushPromises()
expect(mockSubscribeToPush).toHaveBeenCalledOnce()
expect(wrapper.find('.push-opt-in-banner').exists()).toBe(false)
})
})
describe('permission default (not yet asked)', () => {
beforeEach(() => {
setupNotificationMock('default')
})
it('shows the opt-in banner with Enable button', async () => {
const wrapper = mount(PushOptIn)
await flushPromises()
expect(wrapper.find('.push-opt-in-banner').exists()).toBe(true)
expect(wrapper.find('.push-opt-in-btn').text()).toBe('Enable')
})
it('does NOT call subscribeToPush on mount', async () => {
mount(PushOptIn)
await flushPromises()
expect(mockSubscribeToPush).not.toHaveBeenCalled()
})
it('calls subscribeToPush when Enable button is clicked', async () => {
mockSubscribeToPush.mockResolvedValue(true)
const wrapper = mount(PushOptIn)
await flushPromises()
await wrapper.find('.push-opt-in-btn').trigger('click')
await flushPromises()
expect(mockSubscribeToPush).toHaveBeenCalledOnce()
})
it('hides banner after Enable is clicked and subscription succeeds', async () => {
mockSubscribeToPush.mockResolvedValue(true)
const wrapper = mount(PushOptIn)
await flushPromises()
await wrapper.find('.push-opt-in-btn').trigger('click')
await flushPromises()
expect(wrapper.find('.push-opt-in-banner').exists()).toBe(false)
})
it('does NOT post subscription to backend when subscribeToPush returns false', async () => {
mockSubscribeToPush.mockResolvedValue(false)
const wrapper = mount(PushOptIn)
await flushPromises()
await wrapper.find('.push-opt-in-btn').trigger('click')
await flushPromises()
// Banner should still be visible (permission was denied)
expect(wrapper.find('.push-opt-in-banner').exists()).toBe(true)
})
it('hides banner on dismiss without calling subscribeToPush', async () => {
const wrapper = mount(PushOptIn)
await flushPromises()
await wrapper.find('.push-opt-in-dismiss').trigger('click')
await flushPromises()
expect(mockSubscribeToPush).not.toHaveBeenCalled()
expect(wrapper.find('.push-opt-in-banner').exists()).toBe(false)
})
})
describe('permission denied', () => {
beforeEach(() => {
setupNotificationMock('denied')
})
it('shows the blocked message banner', async () => {
const wrapper = mount(PushOptIn)
await flushPromises()
expect(wrapper.find('.push-opt-in-banner').exists()).toBe(true)
expect(wrapper.find('.push-opt-in-banner').text()).toContain('blocked')
})
it('does NOT show the Enable button when permission is denied', async () => {
const wrapper = mount(PushOptIn)
await flushPromises()
expect(wrapper.find('.push-opt-in-btn').exists()).toBe(false)
})
it('does NOT call subscribeToPush on mount when already denied', async () => {
mount(PushOptIn)
await flushPromises()
expect(mockSubscribeToPush).not.toHaveBeenCalled()
})
})
})

View File

@@ -32,6 +32,29 @@
</div>
</template>
</EntityEditForm>
<div class="digest-section">
<div class="digest-row">
<div class="digest-label">
<span class="digest-title">Daily Email Digest</span>
<span class="digest-desc"
>Receive a 9pm summary of pending chore and reward requests with one-click approve/deny
links.</span
>
</div>
<button
type="button"
:class="['toggle-btn', { active: emailDigestEnabled }]"
:aria-pressed="emailDigestEnabled"
:disabled="savingDigest"
@click="toggleDigest"
>
<span class="toggle-knob" />
</button>
</div>
<div v-if="digestError" class="error-message digest-error">{{ digestError }}</div>
</div>
<div v-if="errorMsg" class="error-message" aria-live="polite">{{ errorMsg }}</div>
<ModalDialog
v-if="showModal"
@@ -124,6 +147,10 @@ const showDeleteSuccess = ref(false)
const showDeleteError = ref(false)
const deleteErrorMessage = ref('')
const emailDigestEnabled = ref(true)
const savingDigest = ref(false)
const digestError = ref('')
const initialData = ref<{
image_id: string | null
first_name: string
@@ -162,6 +189,7 @@ onMounted(async () => {
last_name: data.last_name || '',
email: data.email || '',
}
emailDigestEnabled.value = data.email_digest_enabled !== false
} catch {
errorMsg.value = 'Could not load user profile.'
} finally {
@@ -283,6 +311,25 @@ async function resetPassword() {
}
}
async function toggleDigest() {
digestError.value = ''
const newValue = !emailDigestEnabled.value
savingDigest.value = true
try {
const res = await fetch('/api/user/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email_digest_enabled: newValue }),
})
if (!res.ok) throw new Error('Failed to update preference')
emailDigestEnabled.value = newValue
} catch {
digestError.value = 'Failed to save notification preference.'
} finally {
savingDigest.value = false
}
}
function goToChangeParentPin() {
router.push({ name: 'ParentPinSetup' })
}
@@ -429,4 +476,80 @@ function closeDeleteError() {
outline: none;
border-color: var(--btn-primary, #4a90e2);
}
.digest-section {
margin-top: 1.5rem;
padding: 1rem 1.2rem;
border-radius: 10px;
background: var(--list-item-bg, #f9f9f9);
border: 1px solid var(--form-input-border, #e6e6e6);
}
.digest-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.digest-label {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.digest-title {
font-weight: 600;
font-size: 0.97rem;
color: var(--text, #1a1a1a);
}
.digest-desc {
font-size: 0.83rem;
color: var(--text-muted, #666);
line-height: 1.4;
}
.digest-error {
margin-top: 0.5rem;
}
.toggle-btn {
flex-shrink: 0;
width: 44px;
height: 24px;
border-radius: 12px;
border: none;
background: var(--form-input-border, #ccc);
cursor: pointer;
position: relative;
transition: background 0.2s;
padding: 0;
}
.toggle-btn.active {
background: var(--btn-primary, #4a90e2);
}
.toggle-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.toggle-knob {
display: block;
width: 18px;
height: 18px;
border-radius: 50%;
background: white;
position: absolute;
top: 3px;
left: 3px;
transition: left 0.2s;
pointer-events: none;
}
.toggle-btn.active .toggle-knob {
left: 23px;
}
</style>

View File

@@ -8,6 +8,9 @@ import {
isParentPersistent,
logoutParent,
logoutUser,
consumePendingReturnUrl,
hasPendingReturnUrl,
clearPendingReturnUrl,
} from '../../stores/auth'
import { getCachedImageUrl, getCachedImageBlob } from '@/common/imageCache'
import '@/assets/styles.css'
@@ -105,6 +108,7 @@ const close = () => {
show.value = false
error.value = ''
stayInParentMode.value = false
clearPendingReturnUrl()
}
const submit = async () => {
@@ -136,7 +140,8 @@ const submit = async () => {
// Authenticate parent and navigate
authenticateParent(stayInParentMode.value)
close()
router.push('/parent')
const returnUrl = consumePendingReturnUrl()
router.push(returnUrl || '/parent')
} catch (e) {
error.value = 'Network error'
}
@@ -253,6 +258,9 @@ onMounted(() => {
eventBus.on('profile_updated', fetchUserProfile)
document.addEventListener('mousedown', handleClickOutside)
fetchUserProfile()
if (!isParentAuthenticated.value && hasPendingReturnUrl()) {
open()
}
})
onUnmounted(() => {

View File

@@ -206,6 +206,7 @@ onBeforeUnmount(() => {
props.getItemClass?.(item),
{ 'item-ready': props.readyItemId === item.id },
]"
:data-item-id="item.id"
:ref="(el) => (itemRefs[item.id] = el)"
@click.stop="handleClicked(item)"
>

View File

@@ -32,6 +32,9 @@ vi.mock('../../../stores/auth', () => ({
isParentPersistent: isParentPersistentRef,
logoutParent: vi.fn(),
logoutUser: vi.fn(),
consumePendingReturnUrl: vi.fn(() => null),
hasPendingReturnUrl: vi.fn(() => false),
clearPendingReturnUrl: vi.fn(),
}))
global.fetch = vi.fn()

View File

@@ -2,6 +2,7 @@
import { useRouter, useRoute } from 'vue-router'
import { computed, ref, onMounted, onUnmounted } from 'vue'
import LoginButton from '../components/shared/LoginButton.vue'
import PushOptIn from '../components/notification/PushOptIn.vue'
import { eventBus } from '@/common/eventBus'
import type {
Event,
@@ -165,6 +166,7 @@ onUnmounted(() => {
</header>
<main class="main-content">
<PushOptIn />
<router-view :key="$route.fullPath" />
</main>

View File

@@ -13,6 +13,7 @@ vi.mock('@/stores/auth', () => ({
isUserLoggedIn: isUserLoggedInMock,
isParentAuthenticated: isParentAuthenticatedMock,
enforceParentExpiry: vi.fn(),
setPendingReturnUrl: vi.fn(),
}))
// Import router AFTER mocks are in place

View File

@@ -29,6 +29,7 @@ import {
isParentAuthenticated,
isAuthReady,
enforceParentExpiry,
setPendingReturnUrl,
} from '../stores/auth'
import ParentPinSetup from '@/components/auth/ParentPinSetup.vue'
import LandingPage from '@/components/landing/LandingPage.vue'
@@ -303,6 +304,9 @@ router.beforeEach(async (to, from, next) => {
if (isParentAuthenticated.value) {
return next('/parent')
} else {
if (to.path.startsWith('/parent')) {
setPendingReturnUrl(to.fullPath)
}
return next('/child')
}
})

View File

@@ -0,0 +1,103 @@
/**
* Push subscription manager.
* Handles requesting notification permission, subscribing to push,
* and syncing the subscription with the backend.
*/
async function fetchVapidPublicKey(): Promise<string | null> {
try {
const res = await fetch('/api/push-vapid-key')
if (!res.ok) return null
const data = await res.json()
return data.public_key || null
} catch {
return null
}
}
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
const rawData = window.atob(base64)
return Uint8Array.from([...rawData].map((c) => c.charCodeAt(0)))
}
/**
* Request notification permission and subscribe to push notifications.
* Posts the subscription to the backend, including the user's timezone.
* Returns true if subscription was successfully registered.
*/
export async function subscribeToPush(): Promise<boolean> {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
return false
}
try {
const permission = await Notification.requestPermission()
if (permission !== 'granted') {
return false
}
const vapidKey = await fetchVapidPublicKey()
if (!vapidKey) {
console.warn('[Push] VAPID public key not available; skipping subscription')
return false
}
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidKey),
})
const subJson = subscription.toJSON()
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
const res = await fetch('/api/push-subscription', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
endpoint: subJson.endpoint,
keys: subJson.keys,
timezone,
}),
})
return res.ok
} catch (err) {
console.warn('[Push] Failed to subscribe:', err)
return false
}
}
/**
* Unsubscribe from push notifications and remove the subscription from the backend.
*/
export async function unsubscribeFromPush(): Promise<void> {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return
try {
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.getSubscription()
if (!subscription) return
const endpoint = subscription.endpoint
await subscription.unsubscribe()
await fetch('/api/push-subscription', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint }),
})
} catch (err) {
console.warn('[Push] Failed to unsubscribe:', err)
}
}
/**
* Check the current push permission state without prompting.
*/
export function getPushPermissionState(): NotificationPermission | 'unsupported' {
if (!('Notification' in window)) return 'unsupported'
return Notification.permission
}

View File

@@ -4,6 +4,7 @@ const hasLocalStorage =
typeof localStorage !== 'undefined' && typeof localStorage.getItem === 'function'
const AUTH_SYNC_EVENT_KEY = 'authSyncEvent'
const PARENT_AUTH_KEY = 'parentAuth'
const PARENT_RETURN_URL_KEY = 'parentReturnUrl'
const PARENT_AUTH_EXPIRY_NON_PERSISTENT = 60_000 // 1 minute
const PARENT_AUTH_EXPIRY_PERSISTENT = 172_800_000 // 2 days
@@ -71,6 +72,33 @@ export function enforceParentExpiry() {
runExpiryCheck()
}
export function setPendingReturnUrl(url: string) {
if (!url.startsWith('/parent')) return
if (hasLocalStorage) {
localStorage.setItem(PARENT_RETURN_URL_KEY, url)
}
}
export function consumePendingReturnUrl(): string | null {
if (!hasLocalStorage) return null
const url = localStorage.getItem(PARENT_RETURN_URL_KEY)
if (url) {
localStorage.removeItem(PARENT_RETURN_URL_KEY)
}
return url
}
export function hasPendingReturnUrl(): boolean {
if (!hasLocalStorage) return false
return localStorage.getItem(PARENT_RETURN_URL_KEY) !== null
}
export function clearPendingReturnUrl() {
if (hasLocalStorage) {
localStorage.removeItem(PARENT_RETURN_URL_KEY)
}
}
export function authenticateParent(persistent: boolean) {
const duration = persistent ? PARENT_AUTH_EXPIRY_PERSISTENT : PARENT_AUTH_EXPIRY_NON_PERSISTENT
parentAuthExpiresAt.value = Date.now() + duration