diff --git a/backend/api/child_api.py b/backend/api/child_api.py index 22f5b1a..0048168 100644 --- a/backend/api/child_api.py +++ b/backend/api/child_api.py @@ -10,7 +10,7 @@ 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.db import child_db, task_db, reward_db, pending_reward_db, pending_confirmations_db, users_db from db.tracking import insert_tracking_event from db.child_overrides import get_override, delete_override, delete_overrides_for_child from events.types.child_chore_confirmation import ChildChoreConfirmation @@ -929,25 +929,27 @@ def request_reward(id): 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}') + _push_user = users_db.get(Query().id == user_id) + if _push_user and _push_user.get('push_notifications_enabled', True): + 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}.', @@ -1139,25 +1141,27 @@ def confirm_chore(id): 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}') + _push_user = users_db.get(Query().id == user_id) + if _push_user and _push_user.get('push_notifications_enabled', True): + 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 diff --git a/backend/api/user_api.py b/backend/api/user_api.py index eb779fb..11caf6d 100644 --- a/backend/api/user_api.py +++ b/backend/api/user_api.py @@ -48,6 +48,7 @@ def get_profile(): 'email': user.email, 'image_id': user.image_id, 'email_digest_enabled': user.email_digest_enabled, + 'push_notifications_enabled': user.push_notifications_enabled, }), 200 @user_api.route('/user/profile', methods=['PUT']) @@ -59,11 +60,12 @@ def update_profile(): if not user: return jsonify({'error': 'Unauthorized'}), 401 data = request.get_json() - # Only allow first_name, last_name, image_id, email_digest_enabled to be updated + # Only allow first_name, last_name, image_id, email_digest_enabled, push_notifications_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') + push_notifications_enabled = data.get('push_notifications_enabled') if first_name is not None: user.first_name = first_name if last_name is not None: @@ -72,6 +74,8 @@ def update_profile(): user.image_id = image_id if email_digest_enabled is not None: user.email_digest_enabled = bool(email_digest_enabled) + if push_notifications_enabled is not None: + user.push_notifications_enabled = bool(push_notifications_enabled) users_db.update(user.to_dict(), UserQuery.email == user.email) # Create tracking event @@ -84,6 +88,8 @@ def update_profile(): metadata['image_updated'] = True if email_digest_enabled is not None: metadata['email_digest_enabled_updated'] = True + if push_notifications_enabled is not None: + metadata['push_notifications_enabled_updated'] = True tracking_event = TrackingEvent.create_event( user_id=user_id, diff --git a/backend/models/user.py b/backend/models/user.py index cc4a344..b9e5ab7 100644 --- a/backend/models/user.py +++ b/backend/models/user.py @@ -24,6 +24,7 @@ class User(BaseModel): token_version: int = 0 timezone: str | None = None email_digest_enabled: bool = True + push_notifications_enabled: bool = True @classmethod def from_dict(cls, d: dict): @@ -49,6 +50,7 @@ class User(BaseModel): token_version=d.get('token_version', 0), timezone=d.get('timezone'), email_digest_enabled=d.get('email_digest_enabled', True), + push_notifications_enabled=d.get('push_notifications_enabled', True), id=d.get('id'), created_at=d.get('created_at'), updated_at=d.get('updated_at') @@ -79,5 +81,6 @@ class User(BaseModel): 'token_version': self.token_version, 'timezone': self.timezone, 'email_digest_enabled': self.email_digest_enabled, + 'push_notifications_enabled': self.push_notifications_enabled, }) return base diff --git a/frontend/vue-app/src/__tests__/UserProfile.spec.ts b/frontend/vue-app/src/__tests__/UserProfile.spec.ts index 7e3f263..cfc8658 100644 --- a/frontend/vue-app/src/__tests__/UserProfile.spec.ts +++ b/frontend/vue-app/src/__tests__/UserProfile.spec.ts @@ -558,7 +558,7 @@ describe('UserProfile - Profile Update', () => { }) }) -describe('UserProfile - Email Digest Toggle', () => { +describe('UserProfile - Notification Toggles', () => { let wrapper: VueWrapper function mountWithDigest(emailDigestEnabled: boolean) { @@ -592,85 +592,97 @@ describe('UserProfile - Email Digest Toggle', () => { 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 () => { + it('initializes email_digest_enabled to true in initialData when profile returns true', async () => { wrapper = mountWithDigest(true) await flushPromises() await nextTick() - expect(wrapper.find('.toggle-btn').exists()).toBe(true) + expect(wrapper.vm.initialData.email_digest_enabled).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 () => { + it('initializes email_digest_enabled to false in initialData when profile returns 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') + expect(wrapper.vm.initialData.email_digest_enabled).toBe(false) }) - it('calls PUT /api/user/profile with email_digest_enabled: false when toggled off', async () => { + it('initializes push_enabled to false in initialData when not subscribed', async () => { + wrapper = mountWithDigest(true) + await flushPromises() + await nextTick() + + expect(wrapper.vm.initialData.push_enabled).toBe(false) + }) + + it('fields array includes email_digest_enabled as toggle type', async () => { + wrapper = mountWithDigest(true) + await flushPromises() + await nextTick() + + const digestField = wrapper.vm.fields.find((f: any) => f.name === 'email_digest_enabled') + expect(digestField).toBeDefined() + expect(digestField.type).toBe('toggle') + }) + + it('fields array includes push_enabled as toggle type', async () => { + wrapper = mountWithDigest(true) + await flushPromises() + await nextTick() + + const pushField = wrapper.vm.fields.find((f: any) => f.name === 'push_enabled') + expect(pushField).toBeDefined() + expect(pushField.type).toBe('toggle') + }) + + it('profile PUT includes email_digest_enabled when changed on submit', async () => { wrapper = mountWithDigest(true) await flushPromises() await nextTick() ;(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({}) }) - await wrapper.vm.toggleDigest() + await wrapper.vm.handleSubmit({ + image_id: null, + first_name: 'Test', + last_name: 'User', + email: 'test@example.com', + email_digest_enabled: false, + push_enabled: false, + }) await flushPromises() - expect(global.fetch).toHaveBeenCalledWith( - '/api/user/profile', - expect.objectContaining({ - method: 'PUT', - body: JSON.stringify({ email_digest_enabled: false }), - }), - ) + const putCall = (global.fetch as any).mock.calls.find((c: any[]) => c[1]?.method === 'PUT') + expect(putCall).toBeDefined() + const body = JSON.parse(putCall[1].body) + expect(body.email_digest_enabled).toBe(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 () => { + it('profile PUT omits email_digest_enabled when unchanged on submit', 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 wrapper.vm.handleSubmit({ + image_id: null, + first_name: 'Test', + last_name: 'User', + email: 'test@example.com', + email_digest_enabled: true, // same as initial + push_enabled: false, + }) await flushPromises() - expect(wrapper.vm.emailDigestEnabled).toBe(false) + const putCall = (global.fetch as any).mock.calls.find((c: any[]) => c[1]?.method === 'PUT') + expect(putCall).toBeDefined() + const body = JSON.parse(putCall[1].body) + expect(body.email_digest_enabled).toBeUndefined() }) }) diff --git a/frontend/vue-app/src/components/profile/UserProfile.vue b/frontend/vue-app/src/components/profile/UserProfile.vue index 3250fcf..cb34c4c 100644 --- a/frontend/vue-app/src/components/profile/UserProfile.vue +++ b/frontend/vue-app/src/components/profile/UserProfile.vue @@ -8,6 +8,7 @@ :loading="loading" :error="errorMsg" :title="'User Profile'" + :fieldErrors="{ push_enabled: pushError }" @submit="handleSubmit" @cancel="router.back" @add-image="onAddImage" @@ -15,22 +16,6 @@