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 @@