From c17838241ad757a608074887452a9e1de2fbf308 Mon Sep 17 00:00:00 2001 From: Ryan Kegel Date: Sat, 14 Feb 2026 17:00:43 -0500 Subject: [PATCH] WIP Sync --- .idea/.gitignore | 5 + .idea/Reward.iml | 15 +++ .idea/copilot.data.migration.ask2agent.xml | 6 + .idea/inspectionProfiles/Project_Default.xml | 10 ++ .../inspectionProfiles/profiles_settings.xml | 6 + .idea/misc.xml | 4 + .idea/modules.xml | 8 ++ .idea/vcs.xml | 6 + .vscode/tasks.json | 31 +++++ backend/api/user_api.py | 30 +++++ backend/events/types/event_types.py | 2 + backend/events/types/profile_updated.py | 12 ++ backend/test_data/db/child_overrides.json | 80 ++++++------- backend/tests/test_user_api.py | 18 +++ .../components/__tests__/LoginButton.spec.ts | 108 ++++++++++++++++++ .../src/components/auth/AuthLanding.vue | 2 +- .../src/components/auth/ForgotPassword.vue | 11 +- .../src/components/auth/ParentPinSetup.vue | 21 +++- .../src/components/auth/ResetPassword.vue | 21 +++- .../src/components/profile/UserProfile.vue | 82 ++++++------- .../src/components/shared/LoginButton.vue | 6 + frontend/vue-app/src/layout/AuthLayout.vue | 4 +- frontend/vue-app/src/layout/ParentLayout.vue | 14 ++- 23 files changed, 403 insertions(+), 99 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/Reward.iml create mode 100644 .idea/copilot.data.migration.ask2agent.xml create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 .vscode/tasks.json create mode 100644 backend/events/types/profile_updated.py create mode 100644 frontend/vue-app/src/components/__tests__/LoginButton.spec.ts diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..b58b603 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,5 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/Reward.iml b/.idea/Reward.iml new file mode 100644 index 0000000..4eaee5d --- /dev/null +++ b/.idea/Reward.iml @@ -0,0 +1,15 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/copilot.data.migration.ask2agent.xml b/.idea/copilot.data.migration.ask2agent.xml new file mode 100644 index 0000000..1f2ea11 --- /dev/null +++ b/.idea/copilot.data.migration.ask2agent.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..ace3249 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..c62fbfb --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..82877c8 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..dfac589 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,31 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Save Work In Progress", + "type": "shell", + "command": "git", + "args": ["savewip"], + "group": "build", + "presentation": { + "echo": true, + "reveal": "silent", + "focus": false, + "panel": "shared" + } + }, + { + "label": "Load Work In Progress", + "type": "shell", + "command": "git", + "args": ["loadwip"], + "group": "build", + "presentation": { + "echo": true, + "reveal": "silent", + "focus": false, + "panel": "shared" + } + } + ] +} \ No newline at end of file diff --git a/backend/api/user_api.py b/backend/api/user_api.py index 7ef3491..cd6fa53 100644 --- a/backend/api/user_api.py +++ b/backend/api/user_api.py @@ -12,6 +12,10 @@ from api.utils import get_validated_user_id, normalize_email, send_event_for_cur from api.error_codes import ACCOUNT_MARKED_FOR_DELETION, ALREADY_MARKED from events.types.event_types import EventType from events.types.event import Event +from events.types.profile_updated import ProfileUpdated +from utils.tracking_logger import log_tracking_event +from models.tracking_event import TrackingEvent +from db.tracking import insert_tracking_event user_api = Blueprint('user_api', __name__) UserQuery = Query() @@ -63,6 +67,32 @@ def update_profile(): if image_id is not None: user.image_id = image_id users_db.update(user.to_dict(), UserQuery.email == user.email) + + # Create tracking event + metadata = {} + if first_name is not None: + metadata['first_name_updated'] = True + if last_name is not None: + metadata['last_name_updated'] = True + if image_id is not None: + metadata['image_updated'] = True + + tracking_event = TrackingEvent.create_event( + user_id=user_id, + child_id=None, # No child for user profile + entity_type='user', + entity_id=user.id, + action='updated', + points_before=0, # Not relevant + points_after=0, + metadata=metadata + ) + insert_tracking_event(tracking_event) + log_tracking_event(tracking_event) + + # Send SSE event + send_event_for_current_user(Event(EventType.PROFILE_UPDATED.value, ProfileUpdated(user.id))) + return jsonify({'message': 'Profile updated'}), 200 @user_api.route('/user/image', methods=['PUT']) diff --git a/backend/events/types/event_types.py b/backend/events/types/event_types.py index 246397b..4ab7774 100644 --- a/backend/events/types/event_types.py +++ b/backend/events/types/event_types.py @@ -21,3 +21,5 @@ class EventType(Enum): CHILD_OVERRIDE_SET = "child_override_set" CHILD_OVERRIDE_DELETED = "child_override_deleted" + + PROFILE_UPDATED = "profile_updated" diff --git a/backend/events/types/profile_updated.py b/backend/events/types/profile_updated.py new file mode 100644 index 0000000..4da43ce --- /dev/null +++ b/backend/events/types/profile_updated.py @@ -0,0 +1,12 @@ +from events.types.payload import Payload + + +class ProfileUpdated(Payload): + def __init__(self, user_id: str): + super().__init__({ + 'user_id': user_id, + }) + + @property + def user_id(self) -> str: + return self.get("user_id") \ No newline at end of file diff --git a/backend/test_data/db/child_overrides.json b/backend/test_data/db/child_overrides.json index 646f176..560ef4d 100644 --- a/backend/test_data/db/child_overrides.json +++ b/backend/test_data/db/child_overrides.json @@ -1,92 +1,92 @@ { "_default": { "1": { - "id": "479920ee-4d2c-4ff9-a7e4-749691183903", - "created_at": 1770772299.9946082, - "updated_at": 1770772299.9946082, + "id": "0a380d32-881a-4886-9cd8-a8a84b4e1239", + "created_at": 1771031906.3919146, + "updated_at": 1771031906.3919146, "child_id": "child1", "entity_id": "task1", "entity_type": "task", "custom_value": 20 }, "2": { - "id": "e1212f17-1986-4ae2-9936-3e8c4a487a79", - "created_at": 1770772300.0246155, - "updated_at": 1770772300.0246155, + "id": "c3672d1a-3cef-4d11-a492-369aa657014d", + "created_at": 1771031906.4299235, + "updated_at": 1771031906.4299235, "child_id": "child2", "entity_id": "task2", "entity_type": "task", "custom_value": 25 }, "3": { - "id": "58068231-3bd8-425c-aba2-1e4444547f2b", - "created_at": 1770772300.0326169, - "updated_at": 1770772300.0326169, + "id": "641d7614-8c92-4c93-a157-bca97640d0a8", + "created_at": 1771031906.4359252, + "updated_at": 1771031906.4359252, "child_id": "child3", "entity_id": "task1", "entity_type": "task", "custom_value": 10 }, "4": { - "id": "21299d89-29d1-4876-abc8-080a919dfa27", - "created_at": 1770772300.0326169, - "updated_at": 1770772300.0326169, + "id": "ee56c0e9-a468-4e12-bf2c-baef5a06cd83", + "created_at": 1771031906.4359252, + "updated_at": 1771031906.4359252, "child_id": "child3", "entity_id": "task2", "entity_type": "task", "custom_value": 15 }, "5": { - "id": "4676589a-abcf-4407-806c-8d187a41dae3", - "created_at": 1770772300.0326169, - "updated_at": 1770772300.0326169, + "id": "19466c51-44fb-4759-ad64-6a62bf423e30", + "created_at": 1771031906.4359252, + "updated_at": 1771031906.4359252, "child_id": "child3", "entity_id": "reward1", "entity_type": "reward", "custom_value": 100 }, "33": { - "id": "cd1473e2-241c-4bfd-b4b2-c2b5402d95d6", - "created_at": 1770772307.3772185, - "updated_at": 1770772307.3772185, - "child_id": "351c9e7f-5406-425c-a15a-2268aadbfdd5", - "entity_id": "90279979-e91e-4f51-af78-88ad70ffab57", + "id": "3ac70695-0162-4ddb-b773-14cf31145619", + "created_at": 1771031913.7219265, + "updated_at": 1771031913.7219265, + "child_id": "79f98e0e-c893-4699-a63f-e7ce18e3125f", + "entity_id": "2e5e9abc-ccbe-4d26-a943-a3d739bc55eb", "entity_type": "task", "custom_value": 5 }, "34": { - "id": "57ecb6f8-dff3-47a8-81a9-66979e1ce7b4", - "created_at": 1770772307.3833773, - "updated_at": 1770772307.3833773, - "child_id": "f12a42a9-105a-4a6f-84e8-1c3a8e076d33", - "entity_id": "90279979-e91e-4f51-af78-88ad70ffab57", + "id": "8b71d091-0918-4077-a793-fc088b59e95c", + "created_at": 1771031913.7279284, + "updated_at": 1771031913.7279284, + "child_id": "9fac1589-718a-4cb5-bda6-bbb58d68b62e", + "entity_id": "2e5e9abc-ccbe-4d26-a943-a3d739bc55eb", "entity_type": "task", "custom_value": 20 }, "35": { - "id": "d55b8b5c-39fc-449c-9848-99c2d572fdd8", - "created_at": 1770772307.618762, - "updated_at": 1770772307.618762, - "child_id": "f84a1e5e-3f14-44fd-8b8b-b25ede62a1d2", - "entity_id": "b64435a0-8856-4c8d-bf77-8438ff5d9061", + "id": "4ad1c2e6-3316-4847-97ba-a895f3cc9c40", + "created_at": 1771031913.9652154, + "updated_at": 1771031913.9652154, + "child_id": "5dcea978-0d41-430b-b321-1645bed1e5b3", + "entity_id": "b5d9775e-7836-4d17-a2a2-59e4141b6c5f", "entity_type": "task", "custom_value": 0 }, "36": { - "id": "a9777db2-6912-4b21-b668-4f36566d4ef8", - "created_at": 1770772307.8648667, - "updated_at": 1770772307.8648667, - "child_id": "f84a1e5e-3f14-44fd-8b8b-b25ede62a1d2", - "entity_id": "35cf2bde-9f47-4458-ac7b-36713063deb4", + "id": "59eb1477-afbd-4e00-b0a1-13f5636e9360", + "created_at": 1771031914.2012715, + "updated_at": 1771031914.2012715, + "child_id": "5dcea978-0d41-430b-b321-1645bed1e5b3", + "entity_id": "e84a6c84-2eb3-4097-b3f2-30700b7e666b", "entity_type": "task", "custom_value": 10000 }, "37": { - "id": "04c54b24-914e-4ed6-b336-4263a4701c78", - "created_at": 1770772308.104657, - "updated_at": 1770772308.104657, - "child_id": "48bccc00-6d76-4bc9-a371-836d1a7db200", - "entity_id": "ba725bf7-2dc8-4bdb-8a82-6ed88519f2ff", + "id": "0f00c3d9-bf2c-4812-82de-391c9807a3cb", + "created_at": 1771031914.4634206, + "updated_at": 1771031914.4634206, + "child_id": "27736f73-7c95-47cb-b4e3-476c92c517e7", + "entity_id": "e08c01ea-90aa-46ab-bb8a-6922d0c71b19", "entity_type": "reward", "custom_value": 75 } diff --git a/backend/tests/test_user_api.py b/backend/tests/test_user_api.py index e3f9d10..cf4e6c1 100644 --- a/backend/tests/test_user_api.py +++ b/backend/tests/test_user_api.py @@ -176,3 +176,21 @@ def test_mark_for_deletion_with_invalid_jwt(client): assert response.status_code == 401 data = response.get_json() assert 'error' in data + +def test_update_profile_success(authenticated_client): + """Test successfully updating user profile.""" + response = authenticated_client.put('/user/profile', json={ + 'first_name': 'Updated', + 'last_name': 'Name', + 'image_id': 'new_image' + }) + assert response.status_code == 200 + data = response.get_json() + assert data['message'] == 'Profile updated' + + # Verify database was updated + UserQuery = Query() + user = users_db.search(UserQuery.email == TEST_EMAIL)[0] + assert user['first_name'] == 'Updated' + assert user['last_name'] == 'Name' + assert user['image_id'] == 'new_image' diff --git a/frontend/vue-app/src/components/__tests__/LoginButton.spec.ts b/frontend/vue-app/src/components/__tests__/LoginButton.spec.ts new file mode 100644 index 0000000..aed1972 --- /dev/null +++ b/frontend/vue-app/src/components/__tests__/LoginButton.spec.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mount, VueWrapper } from '@vue/test-utils' +import { nextTick } from 'vue' +import LoginButton from '../shared/LoginButton.vue' + +// Mock dependencies +vi.mock('vue-router', () => ({ + useRouter: vi.fn(() => ({ push: vi.fn() })), +})) + +vi.mock('../../stores/auth', () => ({ + authenticateParent: vi.fn(), + isParentAuthenticated: { value: false }, + logoutParent: vi.fn(), + logoutUser: vi.fn(), +})) + +vi.mock('@/common/imageCache', () => ({ + getCachedImageUrl: vi.fn(), + getCachedImageBlob: vi.fn(), +})) + +vi.mock('@/common/eventBus', () => ({ + eventBus: { + on: vi.fn(), + off: vi.fn(), + emit: vi.fn(), + }, +})) + +import { eventBus } from '@/common/eventBus' + +describe('LoginButton', () => { + let wrapper: VueWrapper + let mockFetch: any + + beforeEach(() => { + vi.clearAllMocks() + mockFetch = vi.fn() + vi.stubGlobal('fetch', mockFetch) + }) + + afterEach(() => { + if (wrapper) { + wrapper.unmount() + } + vi.unstubAllGlobals() + }) + + describe('Event Listeners', () => { + it('registers event listeners on mount', () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ first_name: '', last_name: '', email: '', image_id: null }), + }) + + wrapper = mount(LoginButton) + + expect(eventBus.on).toHaveBeenCalledWith('open-login', expect.any(Function)) + expect(eventBus.on).toHaveBeenCalledWith('profile_updated', expect.any(Function)) + }) + + it('unregisters event listeners on unmount', () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ first_name: '', last_name: '', email: '', image_id: null }), + }) + + wrapper = mount(LoginButton) + wrapper.unmount() + + expect(eventBus.off).toHaveBeenCalledWith('open-login', expect.any(Function)) + expect(eventBus.off).toHaveBeenCalledWith('profile_updated', expect.any(Function)) + }) + + it('refetches profile when profile_updated event is received', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ first_name: '', last_name: '', email: '', image_id: null }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + first_name: 'Updated', + last_name: 'User', + email: 'updated@example.com', + image_id: 'new-image-id', + }), + }) + + wrapper = mount(LoginButton) + + // Get the profile_updated callback + const profileUpdatedCall = eventBus.on.mock.calls.find( + (call) => call[0] === 'profile_updated', + ) + const profileUpdatedCallback = profileUpdatedCall[1] + + // Call the callback + await profileUpdatedCallback() + + // Check that fetch was called for profile + expect(mockFetch).toHaveBeenCalledWith('/api/user/profile', { credentials: 'include' }) + }) + }) +}) diff --git a/frontend/vue-app/src/components/auth/AuthLanding.vue b/frontend/vue-app/src/components/auth/AuthLanding.vue index 7df8276..d4ad9e1 100644 --- a/frontend/vue-app/src/components/auth/AuthLanding.vue +++ b/frontend/vue-app/src/components/auth/AuthLanding.vue @@ -4,7 +4,7 @@

Welcome

Please sign in or create an account to continue.

- +
diff --git a/frontend/vue-app/src/components/auth/ForgotPassword.vue b/frontend/vue-app/src/components/auth/ForgotPassword.vue index 6292b1c..fef3575 100644 --- a/frontend/vue-app/src/components/auth/ForgotPassword.vue +++ b/frontend/vue-app/src/components/auth/ForgotPassword.vue @@ -12,14 +12,14 @@ autocomplete="username" autofocus v-model="email" - :class="{ 'input-error': submitAttempted && !isEmailValid }" + :class="{ 'input-error': submitAttempted && !isFormValid }" required /> Email is required. @@ -40,7 +40,7 @@
-
@@ -92,12 +92,15 @@ const successMsg = ref('') const isEmailValidRef = computed(() => isEmailValid(email.value)) +// Add computed for form validity: email must be non-empty and valid +const isFormValid = computed(() => email.value.trim() !== '' && isEmailValidRef.value) + async function submitForm() { submitAttempted.value = true errorMsg.value = '' successMsg.value = '' - if (!isEmailValidRef.value) return + if (!isFormValid.value) return loading.value = true try { const res = await fetch('/api/request-password-reset', { diff --git a/frontend/vue-app/src/components/auth/ParentPinSetup.vue b/frontend/vue-app/src/components/auth/ParentPinSetup.vue index 067ecab..0fc4554 100644 --- a/frontend/vue-app/src/components/auth/ParentPinSetup.vue +++ b/frontend/vue-app/src/components/auth/ParentPinSetup.vue @@ -20,7 +20,14 @@

- + @@ -46,7 +53,7 @@ class="pin-input" placeholder="Confirm PIN" /> -
{{ error }}
@@ -60,7 +67,7 @@