diff --git a/backend/app/models.py b/backend/app/models.py index db149cd..9bbf08a 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -248,6 +248,17 @@ class CheckinReminder(models.Model): 'JITAILog', on_delete=models.SET_NULL, null=True, blank=True, related_name='outcome_reminders' ) + @property + def prompt_id(self): + return f'EMA-REMINDER-{self.pk}' + + @property + def expires_at(self): + # Derived from the immutable send record, never from a read/open time. + from datetime import timedelta + from .ema_catalog import EMA_RESPONSE_WINDOW_MINUTES + return self.sent_at + timedelta(minutes=EMA_RESPONSE_WINDOW_MINUTES) + class Meta: ordering = ['-sent_at'] diff --git a/backend/app/notification_service.py b/backend/app/notification_service.py index f3fbc98..2b8cdcf 100644 --- a/backend/app/notification_service.py +++ b/backend/app/notification_service.py @@ -224,6 +224,8 @@ def build_jitai_push_message(user, jitai_log) -> PushMessage: def send_jitai_prompt(user, jitai_log) -> bool: + # A worker may have loaded this user before the device logged out. + user.refresh_from_db(fields=['push_token']) if not user.push_token: logger.warning("No push token for user_id=%s — skipping", user.user_id) mark_delivery_failed(jitai_log, 'missing push token') @@ -295,6 +297,7 @@ def build_checkin_reminder_message(user) -> PushMessage: def send_checkin_reminder(user) -> bool: + user.refresh_from_db(fields=['push_token']) if not is_valid_expo_push_token(user.push_token): if user.push_token: logger.warning( diff --git a/backend/app/test_checkin_deadline.py b/backend/app/test_checkin_deadline.py new file mode 100644 index 0000000..68dd1c4 --- /dev/null +++ b/backend/app/test_checkin_deadline.py @@ -0,0 +1,178 @@ +"""Notification-based deadlines, including Dr. Chang's five-minute reopen case.""" +from datetime import timedelta +from unittest.mock import patch + +from django.test import TestCase, override_settings +from django.utils import timezone +from django.utils.dateparse import parse_datetime + +from app.models import CheckinReminder, EMA, JITAILog +from app.tests import FAST_HASHERS, authenticated_client, make_user + + +@override_settings(PASSWORD_HASHERS=FAST_HASHERS) +class CheckinDeadlineTests(TestCase): + def setUp(self): + self.user = make_user(email='deadline@test.com') + self.client = authenticated_client(self.user) + self.reminder = CheckinReminder.objects.create(user=self.user, daily_count_at_send=0) + self.sent = self.reminder.sent_at + self.deadline = self.sent + timedelta(minutes=30) + + def at(self, minutes): + return patch('app.views.django_timezone.now', return_value=self.sent + timedelta(minutes=minutes)) + + def submit(self, **overrides): + payload = { + 'prompt_id': self.reminder.prompt_id, + 'ema_type': 'scheduled_check_in', + 'responses': [{'sub_item_id': 'B1_valence', 'value': 4}], + } + payload.update(overrides) + return self.client.post('/ema/responses/', payload, format='json') + + def test_open_at_five_minutes_has_twenty_five_minutes_left(self): + with self.at(5): + data = self.client.get('/ema/next/').json() + self.assertTrue(data['should_show']) + self.assertEqual(data['prompt_id'], self.reminder.prompt_id) + self.assertEqual(parse_datetime(data['expires_at']), self.deadline) + self.assertEqual((parse_datetime(data['expires_at']) - (self.sent + timedelta(minutes=5))).total_seconds(), 1500) + + def test_repeated_opens_never_extend_deadline_or_create_survey_rows(self): + for minutes in (0, 0.5, 5, 20, 29.99): + with self.at(minutes): + data = self.client.get('/ema/next/').json() + self.assertTrue(data['should_show']) + self.assertEqual(data['prompt_id'], self.reminder.prompt_id) + self.assertEqual(parse_datetime(data['expires_at']), self.deadline) + self.assertEqual(EMA.objects.count(), 0) + + def test_exact_deadline_and_later_hide_and_reject_survey(self): + for minutes in (30, 31, 120): + with self.at(minutes): + self.assertFalse(self.client.get('/ema/next/').json()['should_show']) + self.assertEqual(self.submit().status_code, 410) + self.assertFalse(EMA.objects.exists()) + + def test_submit_keeps_original_deadline_and_completed_survey_disappears(self): + with self.at(25): + response = self.submit() + self.assertEqual(response.status_code, 201) + self.assertEqual(parse_datetime(response.json()['expires_at']), self.deadline) + self.assertFalse(self.client.get('/ema/next/').json()['should_show']) + self.assertEqual(self.submit().status_code, 409) + self.assertEqual(EMA.objects.count(), 1) + + def test_notification_required(self): + self.reminder.delete() + self.assertFalse(self.client.get('/ema/next/').json()['should_show']) + self.assertEqual(self.submit(prompt_id='EMA-invented').status_code, 400) + + def test_other_users_notification_cannot_be_read_or_submitted(self): + other = make_user(email='other-deadline@test.com') + self.client = authenticated_client(other) + self.assertFalse(self.client.get('/ema/next/').json()['should_show']) + self.assertEqual(self.submit().status_code, 404) + + def test_alias_prompt_id_cannot_bypass_duplicate_checks(self): + alias = f'EMA-REMINDER-0{self.reminder.pk}' + self.assertEqual(self.submit(prompt_id=alias).status_code, 400) + self.assertFalse(EMA.objects.exists()) + + def test_feedback_type_cannot_bypass_deadline(self): + with self.at(31): + self.assertEqual(self.submit(ema_type='prompt_feedback').status_code, 400) + self.assertFalse(EMA.objects.exists()) + + def test_new_notification_does_not_extend_old_survey(self): + with self.at(31): + new = CheckinReminder.objects.create(user=self.user, daily_count_at_send=1) + data = self.client.get('/ema/next/').json() + self.assertEqual(data['prompt_id'], new.prompt_id) + self.assertEqual(parse_datetime(data['expires_at']), new.expires_at) + self.assertEqual(self.submit().status_code, 410) + + def test_future_notification_is_not_available(self): + with self.at(-1): + self.assertFalse(self.client.get('/ema/next/').json()['should_show']) + self.assertEqual(self.submit().status_code, 410) + + def test_outcome_checkin_uses_its_own_reminder_in_both_arms(self): + for draw in (0.2, 0.9): + with self.subTest(draw=draw): + log = JITAILog.objects.create( + user=self.user, trigger_reason='test', decision_point_id=f'ema_{draw}', + decision_made_at=self.sent - timedelta(minutes=65), + randomization_draw=draw, randomization_probability=0.5, + send_prompt=draw < 0.5, + ) + reminder = CheckinReminder.objects.create(user=self.user, jitai_log=log) + with self.at(5): + data = self.client.get('/ema/next/').json() + self.assertTrue(data['should_show']) + self.assertEqual(data['prompt_id'], reminder.prompt_id) + self.assertEqual(parse_datetime(data['expires_at']), reminder.expires_at) + response = self.submit( + prompt_id=reminder.prompt_id, ema_type='extra_check_in', jitai_log_id=log.pk, + outcome_window_start=self.sent.isoformat(), outcome_window_end=self.deadline.isoformat(), + ) + self.assertEqual(response.status_code, 201) + self.assertEqual(parse_datetime(response.json()['outcome_window_start']), log.decision_made_at) + self.assertEqual(parse_datetime(response.json()['expires_at']), reminder.expires_at) + self.assertFalse(self.client.get('/ema/next/').json()['should_show']) + log.delete() + + def test_outcome_window_without_sent_reminder_does_not_create_checkin(self): + JITAILog.objects.create( + user=self.user, trigger_reason='test', decision_point_id='ema_pending', + decision_made_at=self.sent - timedelta(minutes=65), randomization_draw=0.9, + ) + with self.at(5): + self.assertFalse(self.client.get('/ema/next/').json()['should_show']) + + def _old_feedback(self): + return JITAILog.objects.create( + user=self.user, trigger_reason='earlier intervention', send_prompt=True, + push_sent_at=self.sent - timedelta(days=7), + ) + + def test_checkin_only_skips_old_feedback_and_preserves_deadline_on_open(self): + feedback = self._old_feedback() + with self.at(5): + default = self.client.get('/ema/next/').json() + self.assertEqual(default['ema_type'], 'prompt_feedback') + self.assertEqual(default['jitai_log_id'], feedback.pk) + # Availability and opening the card both use this request mode. + for _ in range(2): + checkin = self.client.get('/ema/next/', {'checkin_only': '1'}).json() + self.assertEqual(checkin['ema_type'], 'scheduled_check_in') + self.assertEqual(checkin['prompt_id'], self.reminder.prompt_id) + self.assertEqual(parse_datetime(checkin['expires_at']), self.deadline) + self.assertEqual(self.submit().status_code, 201) + self.assertFalse(self.client.get('/ema/next/', {'checkin_only': '1'}).json()['should_show']) + # Bypassing feedback neither answers nor dismisses it. + self.assertEqual(self.client.get('/ema/next/').json()['jitai_log_id'], feedback.pk) + self.assertFalse(EMA.objects.filter(source_jitai_log=feedback).exists()) + + def test_old_feedback_alone_never_becomes_home_screen_checkin(self): + self._old_feedback() + for minutes in (30, 60): + with self.at(minutes): + self.assertTrue(self.client.get('/ema/next/').json()['should_show']) + self.assertFalse(self.client.get('/ema/next/', {'checkin_only': '1'}).json()['should_show']) + self.reminder.delete() + self.assertFalse(self.client.get('/ema/next/', {'checkin_only': '1'}).json()['should_show']) + + def test_old_feedback_does_not_block_outcome_checkin_card(self): + self._old_feedback() + log = JITAILog.objects.create( + user=self.user, trigger_reason='outcome', decision_point_id='ema_outcome', + decision_made_at=self.sent - timedelta(minutes=65), randomization_draw=0.9, + ) + reminder = CheckinReminder.objects.create(user=self.user, jitai_log=log) + with self.at(5): + data = self.client.get('/ema/next/', {'checkin_only': '1'}).json() + self.assertEqual(data['ema_type'], 'extra_check_in') + self.assertEqual(data['prompt_id'], reminder.prompt_id) + self.assertEqual(parse_datetime(data['expires_at']), reminder.expires_at) diff --git a/backend/app/test_push_logout.py b/backend/app/test_push_logout.py new file mode 100644 index 0000000..e1b3361 --- /dev/null +++ b/backend/app/test_push_logout.py @@ -0,0 +1,72 @@ +from unittest.mock import patch + +from django.test import TestCase, override_settings +from rest_framework.test import APIClient + +from app.models import JITAILog, User +from app.notification_service import send_checkin_reminder, send_jitai_prompt +from app.tests import authenticated_client, make_user + + +@override_settings(API_KEY='test-key', PASSWORD_HASHERS=['django.contrib.auth.hashers.MD5PasswordHasher']) +class PushLogoutTests(TestCase): + def setUp(self): + self.user = make_user(push_token='ExponentPushToken[device-a]') + self.client = APIClient() + self.client.credentials(HTTP_X_API_KEY='test-key') + + def unregister(self, **overrides): + body = {'user_id': self.user.pk, 'push_token': self.user.push_token} + body.update(overrides) + return self.client.post('/notifications/unregister/', body, format='json') + + def test_expired_jwt_can_revoke_and_repeated_cleanup_is_idempotent(self): + self.client.credentials(HTTP_X_API_KEY='test-key', HTTP_AUTHORIZATION='Bearer expired') + self.assertEqual(self.unregister().status_code, 204) + self.assertEqual(self.unregister().status_code, 204) + self.user.refresh_from_db() + self.assertIsNone(self.user.push_token) + with patch('app.notification_service.PushClient') as expo: + self.assertFalse(send_checkin_reminder(self.user)) + log = JITAILog.objects.create(user=self.user, prompt_id='TEMPLATE_001') + self.assertFalse(send_jitai_prompt(self.user, log)) + expo.assert_not_called() + + def test_wrong_token_cannot_revoke_another_device(self): + self.assertEqual(self.unregister(push_token='ExponentPushToken[other]').status_code, 204) + self.user.refresh_from_db() + self.assertEqual(self.user.push_token, 'ExponentPushToken[device-a]') + + def test_worker_with_stale_user_rechecks_token_before_sending(self): + stale_user = User.objects.get(pk=self.user.pk) + self.assertEqual(self.unregister().status_code, 204) + with patch('app.notification_service.PushClient') as expo: + self.assertFalse(send_checkin_reminder(stale_user)) + log = JITAILog.objects.create(user=self.user, prompt_id='TEMPLATE_001') + self.assertFalse(send_jitai_prompt(self.user, log)) + expo.assert_not_called() + + def test_stale_logout_does_not_remove_new_device_registration(self): + User.objects.filter(pk=self.user.pk).update(push_token='ExponentPushToken[device-b]') + self.assertEqual(self.unregister().status_code, 204) + self.user.refresh_from_db() + self.assertEqual(self.user.push_token, 'ExponentPushToken[device-b]') + + def test_api_key_is_required(self): + self.client.credentials() + self.assertEqual(self.unregister().status_code, 403) + + def test_invalid_payloads_are_rejected(self): + for token in [None, '', ' ', 123, 'x' * 129]: + self.assertEqual(self.unregister(push_token=token).status_code, 400) + for user_id in [None, True, '1', -1, 10**100]: + self.assertEqual(self.unregister(user_id=user_id).status_code, 400) + + def test_login_can_register_again(self): + self.assertEqual(self.unregister().status_code, 204) + client = authenticated_client(self.user) + result = client.put(f'/user/{self.user.pk}/', {'push_token': self.user.push_token}, + format='json', HTTP_X_API_KEY='test-key') + self.assertEqual(result.status_code, 200) + self.user.refresh_from_db() + self.assertEqual(self.user.push_token, 'ExponentPushToken[device-a]') diff --git a/backend/app/tests.py b/backend/app/tests.py index 90a43e4..7406dd9 100644 --- a/backend/app/tests.py +++ b/backend/app/tests.py @@ -4158,6 +4158,7 @@ class EMARotationEndpointTests(TestCase): def setUp(self): self.user = make_user(email='ema-rotation@test.com') self.client = authenticated_client(self.user) + self.reminder = CheckinReminder.objects.create(user=self.user) def test_next_returns_scheduled_items_without_active_jitai(self): response = self.client.get('/ema/next/') @@ -4190,12 +4191,15 @@ def test_next_returns_prompt_feedback_before_post_prompt_items(self): def _decision(self, minutes_ago, draw, p=0.5, point='ema_1', user=None): decided_at = timezone.now() - timedelta(minutes=minutes_ago) sent = draw is not None and draw < p - return JITAILog.objects.create( + jitai_log = JITAILog.objects.create( user=user or self.user, prompt_id='JITAI-EMA-1' if sent else '', trigger_reason='prompt sent', decision_point_id=point, decision_made_at=decided_at, randomization_probability=p, randomization_draw=draw, send_prompt=sent, push_sent_at=decided_at + timedelta(seconds=5) if sent else None, ) + if draw is not None and OUTCOME_CHECK_IN_DELAY_MINUTES <= minutes_ago < 120: + CheckinReminder.objects.create(user=user or self.user, jitai_log=jitai_log) + return jitai_log def test_next_returns_post_prompt_items_during_outcome_window(self): jitai_log = self._decision(minutes_ago=65, draw=0.2) @@ -4229,6 +4233,8 @@ def _randomized_log(self, send_prompt, message_arm=None, draw=0.9, triggered_at=decided_at, decision_made_at=decided_at, ) jitai_log.refresh_from_db() + if draw is not None and OUTCOME_CHECK_IN_DELAY_MINUTES <= minutes_ago < 120: + CheckinReminder.objects.create(user=self.user, jitai_log=jitai_log) return jitai_log def test_outcome_window_opens_for_randomized_out_decision(self): @@ -4238,7 +4244,7 @@ def test_outcome_window_opens_for_randomized_out_decision(self): self.assertTrue(data['should_show']) self.assertTrue(data['outcome_window_active']) - self.assertEqual(data['ema_type'], 'post_prompt') + self.assertEqual(data['ema_type'], 'extra_check_in') self.assertEqual(data['jitai_log_id'], jitai_log.id) self.assertEqual(parse_datetime(data['outcome_window_start']), jitai_log.decision_made_at) self.assertEqual( @@ -4292,13 +4298,10 @@ def test_outcome_window_one_response_per_unsent_window(self): self.assertEqual(data['reason'], 'outcome_window_already_completed') def test_scheduled_check_in_advertises_response_window(self): - before = timezone.now() data = self.client.get('/ema/next/').json() - after = timezone.now() expires = parse_datetime(data['expires_at']) - self.assertGreaterEqual(expires, before + timedelta(minutes=EMA_RESPONSE_WINDOW_MINUTES)) - self.assertLessEqual(expires, after + timedelta(minutes=EMA_RESPONSE_WINDOW_MINUTES)) + self.assertEqual(expires, self.reminder.expires_at) def test_post_prompt_response_window_is_independent_of_outcome_window(self): jitai_log = self._decision(minutes_ago=65, draw=0.2) @@ -4307,15 +4310,12 @@ def test_post_prompt_response_window_is_independent_of_outcome_window(self): ema_type='prompt_feedback', source_jitai_log=jitai_log, ) - before = timezone.now() data = self.client.get('/ema/next/').json() - after = timezone.now() - # The response window is 30 minutes from now; the outcome window stays - # 2 hours from the decision. Aliasing the two is the bug this guards. + # Response expiry is anchored to the reminder; the outcome window + # remains anchored to the decision. expires = parse_datetime(data['expires_at']) - self.assertGreaterEqual(expires, before + timedelta(minutes=EMA_RESPONSE_WINDOW_MINUTES)) - self.assertLessEqual(expires, after + timedelta(minutes=EMA_RESPONSE_WINDOW_MINUTES)) + self.assertEqual(expires, CheckinReminder.objects.get(jitai_log=jitai_log).expires_at) outcome_end = parse_datetime(data['outcome_window_end']) self.assertEqual(outcome_end, jitai_log.decision_made_at + timedelta(hours=2)) @@ -4327,7 +4327,7 @@ def test_control_arm_decision_gets_outcome_check_in_after_delay(self): data = self.client.get('/ema/next/').json() self.assertTrue(data['should_show']) - self.assertEqual(data['ema_type'], 'post_prompt') + self.assertEqual(data['ema_type'], 'extra_check_in') self.assertEqual(data['jitai_log_id'], jitai_log.id) self.assertEqual(parse_datetime(data['outcome_window_start']), jitai_log.decision_made_at) self.assertEqual([item['item_id'] for item in data['items']], ['B1', 'B2', 'B4', 'B5', 'B6', 'B7']) @@ -4377,7 +4377,6 @@ def test_outcome_window_closes_two_hours_after_decision(self): def test_extra_check_in_served_after_outcome_reminder(self): jitai_log = self._decision(minutes_ago=65, draw=0.9) - CheckinReminder.objects.create(user=self.user, jitai_log=jitai_log) data = self.client.get('/ema/next/').json() @@ -4387,7 +4386,7 @@ def test_extra_check_in_served_after_outcome_reminder(self): def test_submitted_ema_persists_response_window(self): response = self.client.post('/ema/responses/', { - 'prompt_id': 'EMA-WINDOW-PERSIST', + 'prompt_id': self.reminder.prompt_id, 'ema_type': 'scheduled_check_in', 'responses': [ {'sub_item_id': 'B1_valence', 'value': 4}, @@ -4395,18 +4394,12 @@ def test_submitted_ema_persists_response_window(self): }, format='json') self.assertEqual(response.status_code, 201) - ema = EMA.objects.get(prompt_id='EMA-WINDOW-PERSIST') - # Previously NULL for scheduled check-ins: expires_at was read from the - # client-supplied outcome_window_end, which these never carry. - self.assertIsNotNone(ema.expires_at) - # Within a second: sent_at is auto_now_add and so fires marginally after - # the `now` that expires_at is derived from. - window = (ema.expires_at - ema.sent_at).total_seconds() - self.assertAlmostEqual(window, EMA_RESPONSE_WINDOW_MINUTES * 60, delta=1.0) + ema = EMA.objects.get(prompt_id=self.reminder.prompt_id) + self.assertEqual(ema.expires_at, self.reminder.expires_at) def test_submit_variable_ema_responses(self): response = self.client.post('/ema/responses/', { - 'prompt_id': 'EMA-ROTATION-1', + 'prompt_id': self.reminder.prompt_id, 'ema_type': 'scheduled_check_in', 'responses': [ {'sub_item_id': 'B1_valence', 'value': 5}, @@ -4417,7 +4410,7 @@ def test_submit_variable_ema_responses(self): self.assertEqual(response.status_code, 201) data = response.json() - self.assertEqual(data['prompt_id'], 'EMA-ROTATION-1') + self.assertEqual(data['prompt_id'], self.reminder.prompt_id) self.assertEqual(data['status'], 'completed') self.assertEqual(len(data['item_responses']), 3) self.assertEqual(EMAItemResponse.objects.filter(ema_id=data['id']).count(), 3) @@ -4532,6 +4525,7 @@ def setUp(self): self.client = authenticated_client(self.user) def test_b3_absent_without_event_day(self): + CheckinReminder.objects.create(user=self.user) response = self.client.get('/ema/next/') item_ids = [item['item_id'] for item in response.json()['items']] @@ -4540,6 +4534,7 @@ def test_b3_absent_without_event_day(self): def test_b3_present_with_event_day(self): EventDay.objects.create(date=eastern_today(), sport='football') + CheckinReminder.objects.create(user=self.user) response = self.client.get('/ema/next/') item_ids = [item['item_id'] for item in response.json()['items']] @@ -4550,6 +4545,7 @@ def test_b8_only_on_first_check_in_of_day(self): 'B1': 'B1_valence', 'B2': 'B2_stress', 'B3': 'B3_importance', 'B4': 'B4_urge', 'B5': 'B5_success', 'B6': 'B6_urge', 'B7': 'B7_urge', 'B8': 'B8_sleep', } + CheckinReminder.objects.create(user=self.user) first = self.client.get('/ema/next/') first_items = [item['item_id'] for item in first.json()['items']] self.assertIn('B8', first_items) @@ -4559,6 +4555,7 @@ def test_b8_only_on_first_check_in_of_day(self): 'responses': [{'sub_item_id': representative_sub_item[i], 'value': 4} for i in first_items], }, format='json') + CheckinReminder.objects.create(user=self.user) second = self.client.get('/ema/next/') second_items = [item['item_id'] for item in second.json()['items']] self.assertNotIn('B8', second_items) @@ -4568,6 +4565,7 @@ def test_rotating_items_balance_across_check_ins(self): EMAItemResponse.objects.create(ema=EMA_, item_id='B4', sub_item_id='B4_urge', response_type='likert', value_numeric=5) EMAItemResponse.objects.create(ema=EMA_, item_id='B5', sub_item_id='B5_success', response_type='likert', value_numeric=5) + CheckinReminder.objects.create(user=self.user) response = self.client.get('/ema/next/') item_ids = [item['item_id'] for item in response.json()['items']] @@ -4582,6 +4580,7 @@ def test_evening_check_in_forces_b6(self, mock_now): from zoneinfo import ZoneInfo mock_now.return_value = datetime(2026, 8, 13, 21, 0, 0, tzinfo=ZoneInfo('America/New_York')) + CheckinReminder.objects.create(user=self.user) response = self.client.get('/ema/next/') item_ids = [item['item_id'] for item in response.json()['items']] @@ -4607,6 +4606,7 @@ def test_first_afternoon_check_in_includes_b6_plan(self, mock_now): mock_now.return_value = datetime(2026, 8, 13, 13, 0, 0, tzinfo=tz) self._seed_rotation_usage(datetime(2026, 8, 13, 9, 0, 0, tzinfo=tz)) + CheckinReminder.objects.create(user=self.user) response = self.client.get('/ema/next/') data = response.json() @@ -4623,6 +4623,7 @@ def test_second_afternoon_check_in_excludes_b6_plan(self, mock_now): already_afternoon = EMA.objects.create(user=self.user, prompt_id='seed2', status='completed') EMA.objects.filter(pk=already_afternoon.pk).update(sent_at=datetime(2026, 8, 13, 12, 30, 0, tzinfo=tz)) + CheckinReminder.objects.create(user=self.user) response = self.client.get('/ema/next/') data = response.json() @@ -4637,6 +4638,7 @@ def test_morning_check_in_excludes_b6_plan(self, mock_now): mock_now.return_value = datetime(2026, 8, 13, 9, 0, 0, tzinfo=tz) self._seed_rotation_usage(datetime(2026, 8, 13, 7, 0, 0, tzinfo=tz)) + CheckinReminder.objects.create(user=self.user) response = self.client.get('/ema/next/') data = response.json() @@ -4881,7 +4883,7 @@ def _submit(self, valence=None, stress=None, sad=None, anxious=None): for sub_item_id, value in pairs if value is not None ] return self.client.post('/ema/responses/', { - 'prompt_id': 'EMA-DISTRESS', + 'prompt_id': CheckinReminder.objects.create(user=self.user).prompt_id, 'ema_type': 'scheduled_check_in', 'responses': responses, }, format='json') diff --git a/backend/app/views.py b/backend/app/views.py index ae0a64c..7645bcf 100644 --- a/backend/app/views.py +++ b/backend/app/views.py @@ -23,6 +23,7 @@ from .models import ( EMA, EMAItemResponse, + CheckinReminder, EngagementLog, EventDay, HeartRateSample, @@ -319,6 +320,24 @@ def put(self, request, user_id): return Response(serializer.data) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) +class PushUnregisterView(APIView): + # Possession of the exact push token permits revocation only. This must + # work after JWT expiry; the API-key middleware still applies. + authentication_classes = [] + permission_classes = [AllowAny] + + def post(self, request): + user_id = request.data.get('user_id') + token = request.data.get('push_token') + if (not isinstance(user_id, int) or isinstance(user_id, bool) + or not 0 < user_id <= 2147483647 or not isinstance(token, str) + or not token.strip() or len(token) > 128): + return Response(status=status.HTTP_400_BAD_REQUEST) + # Conditional update protects a newer registration on another device. + User.objects.filter(user_id=user_id, push_token=token).update(push_token=None) + return Response(status=status.HTTP_204_NO_CONTENT) + + class CheckEmailView(APIView): permission_classes = [AllowAny] @@ -679,6 +698,23 @@ def get(self, request, user_id): return Response(EMASerializer(emas, many=True).data) +def _open_reminder(user, now, jitai_log=None): + reminders = CheckinReminder.objects.filter( + user=user, jitai_log=jitai_log, + sent_at__gt=now - timedelta(minutes=EMA_RESPONSE_WINDOW_MINUTES), + sent_at__lte=now, + ).order_by('-sent_at', '-pk') + for reminder in reminders: + if not EMA.objects.filter(user=user, prompt_id=reminder.prompt_id).exists(): + return reminder + return None + + +def _no_checkin(): + return Response({'should_show': False, 'reason': 'no_active_check_in', + 'outcome_window_active': False}) + + class EMANextView(APIView): permission_classes = [IsAuthenticated] @@ -689,7 +725,10 @@ def get(self, request): now = django_timezone.now() - feedback_jitai = _latest_jitai_awaiting_feedback(app_user) + # Home-screen availability and reminder opens skip C0 feedback, which + # remains eligible through the intervention notification flow. + checkin_only = request.query_params.get('checkin_only') == '1' + feedback_jitai = None if checkin_only else _latest_jitai_awaiting_feedback(app_user) if feedback_jitai is not None: feedback_items = _ema_items(PROMPT_FEEDBACK_ITEM_IDS) return Response({ @@ -733,7 +772,10 @@ def get(self, request): }) post_prompt_count = _today_post_prompt_count(app_user, now) - ema_type = 'extra_check_in' if active_jitai.outcome_reminders.exists() else 'post_prompt' + reminder = _open_reminder(app_user, now, active_jitai) + if reminder is None: + return _no_checkin() + ema_type = 'extra_check_in' if post_prompt_count >= POST_PROMPT_CHECK_IN_DAILY_CAP: return Response({ 'should_show': False, @@ -750,13 +792,13 @@ def get(self, request): ) return Response({ 'should_show': True, - 'prompt_id': f'EMA-JITAI-{active_jitai.id}', + 'prompt_id': reminder.prompt_id, 'ema_type': ema_type, 'jitai_log_id': active_jitai.id, 'outcome_window_active': True, 'outcome_window_start': outcome_start, 'outcome_window_end': outcome_end, - 'expires_at': now + timedelta(minutes=EMA_RESPONSE_WINDOW_MINUTES), + 'expires_at': reminder.expires_at, 'daily_cap': POST_PROMPT_CHECK_IN_DAILY_CAP, 'daily_count': post_prompt_count, 'items': outcome_items, @@ -773,17 +815,21 @@ def get(self, request): 'daily_count': scheduled_count, }) + reminder = _open_reminder(app_user, now) + if reminder is None: + return _no_checkin() + item_ids = _select_scheduled_items(app_user, now, scheduled_count) scheduled_items = _filter_conditional_sub_items( _ema_items(item_ids), _satisfied_schedule_conditions(app_user, now) ) return Response({ 'should_show': True, - 'prompt_id': f'EMA-{app_user.user_id}-{now.strftime("%Y%m%d%H%M%S")}', + 'prompt_id': reminder.prompt_id, 'ema_type': 'scheduled_check_in', 'jitai_log_id': None, 'outcome_window_active': False, - 'expires_at': now + timedelta(minutes=EMA_RESPONSE_WINDOW_MINUTES), + 'expires_at': reminder.expires_at, 'daily_cap': SCHEDULED_CHECK_IN_DAILY_CAP, 'daily_count': scheduled_count, 'items': scheduled_items, @@ -815,6 +861,41 @@ def post(self, request): now = django_timezone.now() ema_type = data.get('ema_type', 'scheduled_check_in') + reminder = None + if ema_type == 'prompt_feedback' and data['prompt_id'].startswith('EMA-REMINDER-'): + return Response({'error': 'Check-in type does not match its notification.'}, status=status.HTTP_400_BAD_REQUEST) + if ema_type != 'prompt_feedback': + prefix = 'EMA-REMINDER-' + reminder_id = data['prompt_id'].removeprefix(prefix) + if not data['prompt_id'].startswith(prefix) or not reminder_id.isdecimal(): + return Response({'error': 'Unknown check-in.'}, status=status.HTTP_400_BAD_REQUEST) + reminder = CheckinReminder.objects.select_for_update().filter( + pk=reminder_id, user=app_user, + ).first() + if reminder is None: + return Response({'error': 'Check-in not found.'}, status=status.HTTP_404_NOT_FOUND) + if data['prompt_id'] != reminder.prompt_id: + return Response({'error': 'Unknown check-in.'}, status=status.HTTP_400_BAD_REQUEST) + # Re-read the clock after acquiring the lock, including concurrent submissions. + now = django_timezone.now() + if now >= reminder.expires_at or now < reminder.sent_at: + return Response({'error': 'This check-in has expired.'}, status=status.HTTP_410_GONE) + if EMA.objects.filter(user=app_user, prompt_id=reminder.prompt_id).exists(): + return Response({'error': 'This check-in was already completed.'}, status=status.HTTP_409_CONFLICT) + expected_type = 'extra_check_in' if reminder.jitai_log_id else 'scheduled_check_in' + if ema_type != expected_type or jitai_log_id != reminder.jitai_log_id: + return Response({'error': 'Check-in does not match its notification.'}, status=status.HTTP_400_BAD_REQUEST) + # Outcome boundaries come from the server, not client-supplied timestamps. + if reminder.jitai_log_id: + window = _latest_outcome_window(app_user, now) + if window is None or window.jitai_log.pk != reminder.jitai_log_id or now < window.opens_at: + return Response({'error': 'This check-in is no longer available.'}, status=status.HTTP_410_GONE) + if EMA.objects.filter(user=app_user, source_jitai_log=jitai_log, + ema_type__in=['post_prompt', 'extra_check_in'], status='completed').exists(): + return Response({'error': 'This check-in was already completed.'}, status=status.HTTP_409_CONFLICT) + data['outcome_window_start'], data['outcome_window_end'] = window.start, window.end + else: + data['outcome_window_start'] = data['outcome_window_end'] = None # A dismissed C0 rating is submitted with no responses — recorded as # missing, not as a negative answer, per the measures doc. dismissed = ema_type == 'prompt_feedback' and not data['responses'] @@ -830,11 +911,7 @@ def post(self, request): source_jitai_log=jitai_log, outcome_window_start=data.get('outcome_window_start'), outcome_window_end=data.get('outcome_window_end'), - # Server-derived, never client-supplied, and independent of the - # outcome window: aliasing the two is what previously gave scheduled - # check-ins a NULL expires_at and post-prompt EMAs a 2-hour one. - # sent_at is auto_now_add, so it equals `now` for this row. - expires_at=now + timedelta(minutes=EMA_RESPONSE_WINDOW_MINUTES), + expires_at=reminder.expires_at if reminder else now + timedelta(minutes=EMA_RESPONSE_WINDOW_MINUTES), served_sub_item_ids=served, ) diff --git a/backend/project/urls.py b/backend/project/urls.py index 588f68a..220032c 100644 --- a/backend/project/urls.py +++ b/backend/project/urls.py @@ -15,6 +15,7 @@ 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ from django.contrib import admin +from app.views import PushUnregisterView from django.urls import path from rest_framework_simplejwt.views import TokenRefreshView from app.views import index, CreateUserView, UserLoginView, UserUpdateView, CheckEmailView, TelemetryIngestView, WearableDeviceView, EMAView, EMANextView, EMAResponseView, JITAILogView, JITAIReceiptView, DashboardParticipantStatusView, DashboardLatencyEventsView, HeartRateListView, StressListView, PhoneTelemetryView, EngagementLogView, MeView @@ -55,6 +56,7 @@ path('user/login/', UserLoginView.as_view(), name='user-login'), path('auth/token/refresh/', TokenRefreshView.as_view(), name='token-refresh'), path('auth/me/', MeView.as_view(), name='auth-me'), + path('notifications/unregister/', PushUnregisterView.as_view(), name='push-unregister'), path('user/checkemail/', CheckEmailView.as_view(), name='check-user-email'), path('wearable/', WearableDeviceView.as_view(), name='wearable-create'), path('wearable//', WearableDeviceView.as_view(), name='wearable-detail'), diff --git a/mobile/App.tsx b/mobile/App.tsx index a46c64f..9509521 100644 --- a/mobile/App.tsx +++ b/mobile/App.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { ActivityIndicator, AppState, Platform, View } from 'react-native'; import * as Notifications from 'expo-notifications'; +import NetInfo from '@react-native-community/netinfo'; import { useFonts } from 'expo-font'; import { Ionicons } from '@expo/vector-icons'; import { useAuthStore } from './src/store/authStore'; @@ -8,10 +9,12 @@ import LoginScreen from './src/screens/LoginScreen'; import RegisterScreen from './src/screens/RegisterScreen'; import ComposeScreen from './src/screens/ComposeScreen'; import EMAScreen from './src/screens/EMAScreen'; +import { useAvailableCheckin } from './src/ema/useAvailableCheckin'; import { flushQueue, startNetworkListener } from './src/telemetry/offlineQueue'; import { registerForPushNotifications } from './src/notifications/pushToken'; import { parsePushData, shouldOpenEMA } from './src/notifications/payload'; -import { jitai, user as userApi, telemetry } from './src/api/endpoints'; +import { jitai, telemetry } from './src/api/endpoints'; +import { pushRegistration } from './src/notifications/session'; import NotificationToast from './src/components/NotificationToast'; import AppAlert from './src/components/AppAlert'; import { log } from './src/utils/logger'; @@ -19,19 +22,19 @@ import { colors } from './src/theme'; Notifications.setNotificationHandler({ handleNotification: async () => ({ - shouldShowAlert: true, - shouldPlaySound: true, + shouldShowAlert: useAuthStore.getState().isAuthenticated, + shouldPlaySound: useAuthStore.getState().isAuthenticated, shouldSetBadge: false, - shouldShowBanner: true, - shouldShowList: true, + shouldShowBanner: useAuthStore.getState().isAuthenticated, + shouldShowList: useAuthStore.getState().isAuthenticated, }), }); type Screen = 'login' | 'register' | 'app'; -type ActiveEMA = { jitaiLogId?: number }; +type ActiveEMA = { jitaiLogId?: number; checkinOnly?: boolean }; type ReceiptAppState = 'foreground' | 'background' | 'killed'; -const COLD_START_MAX_AGE_MS = 120000; +const COLD_START_MAX_AGE_MS = 30 * 60 * 1000; export default function App() { const [fontsLoaded] = useFonts({ @@ -42,6 +45,7 @@ export default function App() { const [toastMessage, setToastMessage] = useState(null); const [toastVariant, setToastVariant] = useState<'info' | 'success' | 'error'>('info'); const [activeEMA, setActiveEMA] = useState(null); + const checkin = useAvailableCheckin(isAuthenticated ? userId : null, activeEMA !== null); const toastTimer = useRef | null>(null); function showToast(message: string, variant: 'info' | 'success' | 'error' = 'info') { @@ -58,19 +62,63 @@ export default function App() { }, []); useEffect(() => { - if (!isAuthenticated) return; + if (!isAuthenticated) { + setActiveEMA(null); + return; + } flushQueue(); }, [isAuthenticated]); + useEffect(() => { + if (isLoading) return; + if (!isAuthenticated) { + setActiveEMA(null); + setToastMessage(null); + } + const retry = () => { + const action = useAuthStore.getState().isAuthenticated + ? pushRegistration.retry() : pushRegistration.unregister(); + action.catch(() => log('[PushToken] Cleanup still pending')); + }; + retry(); + const unsubscribe = NetInfo.addEventListener((state) => { + if (state.isConnected) retry(); + }); + const subscription = AppState.addEventListener('change', (state) => { + if (state === 'active') retry(); + }); + return () => { unsubscribe(); subscription.remove(); }; + }, [isAuthenticated, isLoading]); + useEffect(() => { if (!isAuthenticated || !userId) return; + let cancelled = false; + const isCurrent = () => !cancelled && useAuthStore.getState().isAuthenticated + && useAuthStore.getState().userId === userId; - registerForPushNotifications().then((token) => { - if (token) { - userApi.update(userId, { push_token: token }).catch((e) => { - log('[PushToken] Failed to register with backend:', e?.response?.status); - }); + let registering = false; + let registered = false; + const ensureRegistered = async () => { + if (!isCurrent() || registering || registered) return; + registering = true; + try { + const token = await registerForPushNotifications(); + if (token) { + await pushRegistration.register(userId, token, isCurrent); + registered = isCurrent(); + } + } catch { + log('[PushToken] Failed to register with backend; will retry'); + } finally { + registering = false; } + }; + void ensureRegistered(); + const registrationNetworkSub = NetInfo.addEventListener((state) => { + if (state.isConnected) void ensureRegistered(); + }); + const registrationAppSub = AppState.addEventListener('change', (state) => { + if (state === 'active') void ensureRegistered(); }); const reportedReceipts = new Set(); @@ -90,14 +138,20 @@ export default function App() { } function openFromPush(parsed: ReturnType) { + if (!isCurrent()) return; if (!shouldOpenEMA(parsed.type)) return; - setActiveEMA({ jitaiLogId: parsed.jitaiLogId }); + setActiveEMA({ jitaiLogId: parsed.jitaiLogId, checkinOnly: parsed.type === 'checkin_reminder' }); } function onTapped(response: Notifications.NotificationResponse, appState: ReceiptAppState) { + if (!isCurrent()) return; const identifier = response.notification.request.identifier; if (handledTaps.has(identifier)) return; handledTaps.add(identifier); + // Consume the response so an icon launch does not replay a previous tap. + Notifications.clearLastNotificationResponseAsync().catch((e) => { + log('[Push] Could not clear handled notification response:', e?.message); + }); const data = response.notification.request.content.data as Record; const parsed = parsePushData(data); @@ -120,6 +174,7 @@ export default function App() { } const foregroundSub = Notifications.addNotificationReceivedListener((notification) => { + if (!isCurrent()) return; const content = notification.request.content; const data = content.data as Record; const parsed = parsePushData(data); @@ -151,6 +206,9 @@ export default function App() { }); return () => { + cancelled = true; + registrationNetworkSub(); + registrationAppSub.remove(); foregroundSub.remove(); tapSub.remove(); }; @@ -169,7 +227,12 @@ export default function App() { {isAuthenticated ? ( - setActiveEMA({})} /> + setActiveEMA({ checkinOnly: true })} + availableCheckin={checkin.available} + checkinError={checkin.error} + onRefreshCheckin={checkin.refresh} + /> ) : screen === 'register' ? ( setScreen('login')} /> ) : ( @@ -177,8 +240,9 @@ export default function App() { )} setActiveEMA(null)} /> diff --git a/mobile/README.md b/mobile/README.md index 89e9be3..1c01a43 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -69,7 +69,15 @@ The app needs no special handling: `GET /ema/next/` decides what to serve, inclu **JITAI prompt** (silent): no title or body. Data is `{ type: "ema_prompt", prompt_id, jitai_log_id }`. Not on a clock. `evaluate_jitai_triggers` also runs every 180 seconds, but it only considers sending after a new completed EMA, then eligibility plus a coin flip. The app has no local prompt catalog, so JITAI message text is not shown on device. -Tapping either type opens EMA (`GET /ema/next/`, then `POST /ema/responses/`). A foreground check-in reminder also opens EMA. JITAI receipts (`POST /jitai/receipt/`) fire when `jitai_log_id` is present (foreground, tap, or cold start). Check-in reminders have no `jitai_log_id`, so they do not report a JITAI receipt. +The home screen checks `GET /ema/next/?checkin_only=1` after sign-in, on return to the foreground, and after closing a survey. An available check-in appears in a card with a **Start check-in** button and its closing time. The card disappears at expiry or after completion. Failed availability requests show a retry button. The card and check-in reminder opens use `checkin_only=1` to skip unanswered C0 feedback. C0 feedback opens through intervention notifications using the default endpoint; it has no home-screen card. + +Scheduled and outcome check-ins require a successful notification send record (`CheckinReminder`). Their stable prompt ID is `EMA-REMINDER-` and their deadline is that record's `sent_at + 30 minutes`. The record is written after Expo accepts the push; this is server send time, not confirmed arrival on the phone. Opening or refreshing the app does not extend the deadline. The server rejects expired submissions (410), duplicate completions (409), and unknown/mismatched check-ins (400/404). Immediate C0 prompt feedback keeps its existing separate behavior. No database migration is needed: the deadline is derived from the existing send record. + +Deploy the backend change together with the mobile update. An older app fetching a new survey can echo the new prompt ID, but surveys already open with old prompt IDs must be closed and reopened. A locally simulated notification does not create a server send record; use a real test-account reminder to validate availability. The legacy `backend/full_circle_test.py` script's arbitrary survey submissions also no longer satisfy the notification requirement. + +For device QA on an ST test account: background the app, receive a check-in reminder, wait five minutes without tapping it, then launch from the home-screen icon. Confirm the card appears and shows the original closing time. Open, dismiss, and reopen the check-in; the deadline must stay the same. Confirm completion removes the card, and reopening after 30 minutes does not recover the expired check-in. Repeat with the app terminated, and verify notification taps and a failed-network retry. + +Tapping an intervention notification opens EMA using `GET /ema/next/`; check-in reminders use `GET /ema/next/?checkin_only=1`. Both submit through `POST /ema/responses/`. A foreground check-in reminder also opens EMA. JITAI receipts (`POST /jitai/receipt/`) fire when `jitai_log_id` is present (foreground, tap, or cold start). Check-in reminders have no `jitai_log_id`, so they do not report a JITAI receipt. ## Local backend diff --git a/mobile/package.json b/mobile/package.json index a3f5e86..75d6207 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -25,6 +25,7 @@ "typescript": "~6.0.3" }, "scripts": { + "test": "node --test tests/*.test.cjs", "start": "expo start", "android": "expo run:android", "ios": "expo run:ios", diff --git a/mobile/src/api/endpoints.ts b/mobile/src/api/endpoints.ts index 9350e78..a2822be 100644 --- a/mobile/src/api/endpoints.ts +++ b/mobile/src/api/endpoints.ts @@ -41,8 +41,10 @@ export const user = { // ─── EMA ────────────────────────────────────────────────────────────────────── export const ema = { - next: () => - client.get('/ema/next/'), + next: (checkinOnly = false) => + client.get('/ema/next/', { + params: checkinOnly ? { checkin_only: '1' } : undefined, + }), submitResponses: (body: EMASubmitRequest) => client.post('/ema/responses/', body), diff --git a/mobile/src/api/types.ts b/mobile/src/api/types.ts index dcd0110..cc5f3a8 100644 --- a/mobile/src/api/types.ts +++ b/mobile/src/api/types.ts @@ -1,4 +1,4 @@ -export type EMAType = 'scheduled_check_in' | 'post_prompt' | 'extra_check_in'; +export type EMAType = 'scheduled_check_in' | 'post_prompt' | 'extra_check_in' | 'prompt_feedback'; export type EMAResponseType = 'likert' | 'single_choice' | 'multi_choice' | 'yes_no' | 'number'; @@ -39,9 +39,9 @@ export type EMANextShowResponse = { outcome_window_active: boolean; outcome_window_start?: string | null; outcome_window_end?: string | null; - expires_at: string; - daily_cap: number; - daily_count: number; + expires_at?: string | null; + daily_cap?: number; + daily_count?: number; items: EMAItem[]; }; diff --git a/mobile/src/ema/useAvailableCheckin.ts b/mobile/src/ema/useAvailableCheckin.ts new file mode 100644 index 0000000..bf66070 --- /dev/null +++ b/mobile/src/ema/useAvailableCheckin.ts @@ -0,0 +1,66 @@ +import { useCallback, useEffect, useState } from 'react'; +import { AppState } from 'react-native'; +import * as Notifications from 'expo-notifications'; +import { ema } from '../api/endpoints'; +import { EMANextShowResponse } from '../api/types'; +import { parsePushData, shouldOpenEMA } from '../notifications/payload'; + +// Availability reads never extend the server's notification-based deadline. +export function useAvailableCheckin(userId: number | null, surveyOpen: boolean) { + const [available, setAvailable] = useState(null); + const [error, setError] = useState(false); + const [revision, setRevision] = useState(0); + const refresh = useCallback(() => setRevision((value) => value + 1), []); + + useEffect(() => { + let disposed = false; + let request = 0; + let expiryTimer: ReturnType | undefined; + setAvailable(null); + setError(false); + if (!userId || surveyOpen) return; + + async function check() { + const current = ++request; + clearTimeout(expiryTimer); + setAvailable(null); + setError(false); + try { + const { data } = await ema.next(true); + if (disposed || current !== request) return; + // Also guard against older servers that ignore checkin_only. + if (!data.should_show || data.ema_type === 'prompt_feedback' || !data.expires_at) return; + const remaining = data.expires_at ? Date.parse(data.expires_at) - Date.now() : null; + if (remaining !== null && (!Number.isFinite(remaining) || remaining <= 0)) return; + setAvailable(data); + if (remaining !== null) { + expiryTimer = setTimeout(() => setAvailable(null), Math.min(remaining, 2147483647)); + } + } catch { + if (!disposed && current === request) setError(true); + } + } + + void check(); + const appState = AppState.addEventListener('change', (state) => { + if (state === 'active') void check(); + else { + // Discard a response that arrives after the app goes into the background. + ++request; + clearTimeout(expiryTimer); + setAvailable(null); + } + }); + const push = Notifications.addNotificationReceivedListener((notification) => { + if (shouldOpenEMA(parsePushData(notification.request.content.data).type)) void check(); + }); + return () => { + disposed = true; + clearTimeout(expiryTimer); + appState.remove(); + push.remove(); + }; + }, [userId, surveyOpen, revision]); + + return { available, error, refresh }; +} diff --git a/mobile/src/notifications/registration.ts b/mobile/src/notifications/registration.ts new file mode 100644 index 0000000..92c831b --- /dev/null +++ b/mobile/src/notifications/registration.ts @@ -0,0 +1,47 @@ +type Registration = { userId: number; token: string; pending: boolean }; + +type Dependencies = { + read: () => Promise; + write: (value: Registration | null) => Promise; + register: (userId: number, token: string) => Promise; + unregister: (userId: number, token: string) => Promise; +}; + +// Serialize registration and revocation so a late registration cannot undo logout. +export function createPushRegistration(deps: Dependencies) { + let tail: Promise = Promise.resolve(); + function enqueue(operation: () => Promise): Promise { + const result = tail.then(operation); + tail = result.catch(() => undefined); + return result; + } + + async function remove(record: Registration) { + await deps.write({ ...record, pending: true }); + await deps.unregister(record.userId, record.token); + await deps.write(null); + } + + return { + register: (userId: number, token: string, isCurrent: () => boolean) => enqueue(async () => { + if (!isCurrent()) return; + const previous = await deps.read(); + if (previous) await remove(previous); + if (!isCurrent()) return; + // Persist before sending: even a lost response may mean the server saved it. + const record = { userId, token, pending: true }; + await deps.write(record); + await deps.register(userId, token); + if (isCurrent()) await deps.write({ ...record, pending: false }); + else await remove(record); + }), + unregister: () => enqueue(async () => { + const record = await deps.read(); + if (record) await remove(record); + }), + retry: () => enqueue(async () => { + const record = await deps.read(); + if (record?.pending) await remove(record); + }), + }; +} diff --git a/mobile/src/notifications/session.ts b/mobile/src/notifications/session.ts new file mode 100644 index 0000000..0dfa42a --- /dev/null +++ b/mobile/src/notifications/session.ts @@ -0,0 +1,23 @@ +import axios from 'axios'; +import * as SecureStore from 'expo-secure-store'; +import { API_KEY, BASE_URL } from '../api/config'; +import { user } from '../api/endpoints'; +import { createPushRegistration } from './registration'; + +const KEY = 'push_registration'; + +export const pushRegistration = createPushRegistration({ + read: async () => { + const raw = await SecureStore.getItemAsync(KEY); + return raw ? JSON.parse(raw) : null; + }, + write: async (record) => { + if (record) await SecureStore.setItemAsync(KEY, JSON.stringify(record)); + else await SecureStore.deleteItemAsync(KEY); + }, + register: (userId, token) => user.update(userId, { push_token: token }), + // Bypass JWT refresh/logout interceptors: revocation also works after expiry. + unregister: (userId, token) => axios.post(`${BASE_URL}/notifications/unregister/`, { + user_id: userId, push_token: token, + }, { headers: { 'X-API-Key': API_KEY }, timeout: 10000 }), +}); diff --git a/mobile/src/screens/ComposeScreen.tsx b/mobile/src/screens/ComposeScreen.tsx index 1bf04fe..481bdca 100644 --- a/mobile/src/screens/ComposeScreen.tsx +++ b/mobile/src/screens/ComposeScreen.tsx @@ -15,12 +15,18 @@ import { useTelemetryStore, TelemetryEvent } from '../telemetry/telemetryStore'; import { useAuthStore } from '../store/authStore'; import { useAlertStore } from '../store/alertStore'; import { jitai } from '../api/endpoints'; +import { EMANextShowResponse } from '../api/types'; import { log } from '../utils/logger'; import { colors, radius } from '../theme'; -type Props = { onOpenEMA: () => void }; +type Props = { + onOpenEMA: () => void; + availableCheckin: EMANextShowResponse | null; + checkinError: boolean; + onRefreshCheckin: () => void; +}; -export default function ComposeScreen({ onOpenEMA }: Props) { +export default function ComposeScreen({ onOpenEMA, availableCheckin, checkinError, onRefreshCheckin }: Props) { const [text, setText] = useState(''); const events = useTelemetryStore((s) => s.events); const flush = useTelemetryStore((s) => s.flush); @@ -109,6 +115,32 @@ export default function ComposeScreen({ onOpenEMA }: Props) { )} + {availableCheckin && availableCheckin.ema_type !== 'prompt_feedback' && ( + + + Check-in available + + {availableCheckin.expires_at && ( + + Available until {new Date(availableCheckin.expires_at).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })} + + )} + + + Start check-in + + + + )} + {checkinError && ( + + Could not check for a check-in. + + Try again + + + )} + Compose @@ -153,6 +185,14 @@ export default function ComposeScreen({ onOpenEMA }: Props) { } const styles = StyleSheet.create({ + checkinCard: { + margin: 16, padding: 16, borderRadius: 12, + backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.primary, + }, + checkinTitle: { fontSize: 20, fontWeight: '600', color: colors.primary }, + checkinDetail: { fontSize: 14, color: colors.primary, marginTop: 8 }, + checkinButton: { backgroundColor: colors.primary, borderRadius: 8, padding: 12, marginTop: 12 }, + checkinButtonText: { color: '#fff', fontWeight: '600', textAlign: 'center' }, container: { flex: 1, backgroundColor: '#f5f5f7', diff --git a/mobile/src/screens/EMAScreen.tsx b/mobile/src/screens/EMAScreen.tsx index 3541c42..ca50bd6 100644 --- a/mobile/src/screens/EMAScreen.tsx +++ b/mobile/src/screens/EMAScreen.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { ActivityIndicator, + AppState, LayoutAnimation, Linking, Modal, @@ -37,6 +38,7 @@ import { log } from '../utils/logger'; import { colors, radius, typography } from '../theme'; const NO_SHOW_COPY: Record = { + no_active_check_in: 'There are no available check-ins right now. We’ll notify you when the next one is ready.', daily_cap_reached: "You're all caught up on check-ins for today. Thanks for taking part!", outcome_window_already_completed: "You've already completed this check-in. Thank you!", }; @@ -52,6 +54,7 @@ const DIALABLE = /^[0-9+\-\s()]+$/; type Props = { visible: boolean; jitaiLogId?: number; + checkinOnly?: boolean; onClose: () => void; }; @@ -65,7 +68,7 @@ function isSupported(sub: EMASubItem) { ); } -export default function EMAScreen({ visible, jitaiLogId, onClose }: Props) { +export default function EMAScreen({ visible, jitaiLogId, checkinOnly = false, onClose }: Props) { const userId = useAuthStore((s) => s.userId); const showAlert = useAlertStore((s) => s.show); @@ -111,10 +114,10 @@ export default function EMAScreen({ visible, jitaiLogId, onClose }: Props) { sectionOffsets.current = {}; try { - const { data } = await emaApi.next(); + const { data } = await emaApi.next(checkinOnly); - if (!data.should_show) { - setNoShowReason(data.reason ?? null); + if (!data.should_show || (checkinOnly && data.ema_type === 'prompt_feedback')) { + setNoShowReason(data.should_show ? 'no_active_check_in' : data.reason ?? null); setPhase('noshow'); return; } @@ -133,12 +136,12 @@ export default function EMAScreen({ visible, jitaiLogId, onClose }: Props) { log('[EMA] next failed:', e?.response?.status, e?.response?.data ?? e?.message); setPhase('error'); } - }, [logEngagement]); + }, [logEngagement, checkinOnly]); useEffect(() => { if (!visible) return; load(); - }, [visible]); + }, [visible, load]); useEffect(() => { if (phase !== 'form' || !survey) return; @@ -149,7 +152,13 @@ export default function EMAScreen({ visible, jitaiLogId, onClose }: Props) { return; } const timer = setTimeout(() => setPhase('expired'), remaining); - return () => clearTimeout(timer); + const appState = AppState.addEventListener('change', (state) => { + if (state === 'active') { + const left = msUntil(survey.expires_at); + if (left !== null && left <= 0) setPhase('expired'); + } + }); + return () => { clearTimeout(timer); appState.remove(); }; }, [phase, survey]); function handleScroll(e: NativeSyntheticEvent) { @@ -182,6 +191,11 @@ export default function EMAScreen({ visible, jitaiLogId, onClose }: Props) { async function handleSubmit() { if (!survey || !complete || submitting) return; + const remaining = msUntil(survey.expires_at); + if (remaining !== null && remaining <= 0) { + setPhase('expired'); + return; + } setSubmitting(true); try { @@ -207,9 +221,14 @@ export default function EMAScreen({ visible, jitaiLogId, onClose }: Props) { } catch (e: any) { const status = e?.response?.status; log('[EMA] submit failed:', status, e?.response?.data); + if (status === 410) { + setPhase('expired'); + setSubmitting(false); + return; + } showAlert( 'Could not submit', - status === 400 || status === 404 + status === 400 || status === 404 || status === 409 ? 'This check-in is no longer valid. Close and try again later.' : 'Check your connection and try again.', ); @@ -374,7 +393,7 @@ export default function EMAScreen({ visible, jitaiLogId, onClose }: Props) { {!activeSectionTitle && phase === 'form' && typeof survey?.daily_cap === 'number' && ( - {survey.daily_count + 1} of {survey.daily_cap} + {(survey.daily_count ?? 0) + 1} of {survey.daily_cap} )} diff --git a/mobile/src/store/authStore.ts b/mobile/src/store/authStore.ts index 99f7e08..31c8755 100644 --- a/mobile/src/store/authStore.ts +++ b/mobile/src/store/authStore.ts @@ -3,6 +3,10 @@ import * as SecureStore from 'expo-secure-store'; import { setTokens, clearTokens, setOnAuthFailure } from '../api/client'; import { auth } from '../api/endpoints'; import { log } from '../utils/logger'; +import * as Notifications from 'expo-notifications'; +import { pushRegistration } from '../notifications/session'; + +let logoutPromise: Promise | null = null; const KEYS = { tokens: 'auth_tokens', @@ -40,10 +44,27 @@ export const useAuthStore = create((set) => ({ }, logout: async () => { - await SecureStore.deleteItemAsync(KEYS.tokens); - await SecureStore.deleteItemAsync(KEYS.user); - clearTokens(); - set({ user: null, userId: null, isAuthenticated: false }); + if (logoutPromise) return logoutPromise; + set({ user: null, userId: null, isAuthenticated: false, isLoading: true }); + logoutPromise = (async () => { + try { + await pushRegistration.unregister().catch(() => { + log('[PushToken] Cleanup pending; will retry on reconnect'); + }); + await Promise.allSettled([ + Notifications.cancelAllScheduledNotificationsAsync(), + Notifications.dismissAllNotificationsAsync(), + Notifications.clearLastNotificationResponseAsync(), + ]); + await SecureStore.deleteItemAsync(KEYS.tokens); + await SecureStore.deleteItemAsync(KEYS.user); + } finally { + clearTokens(); + set({ isLoading: false }); + logoutPromise = null; + } + })(); + return logoutPromise; }, restoreSession: async () => { diff --git a/mobile/tests/logout.test.cjs b/mobile/tests/logout.test.cjs new file mode 100644 index 0000000..6877ad8 --- /dev/null +++ b/mobile/tests/logout.test.cjs @@ -0,0 +1,81 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const ts = require('typescript'); + +function loadStore(unregister) { + const events = []; + let onAuthFailure; + const mocks = { + zustand: { create: factory => { + let state; + const set = patch => { state = { ...state, ...patch }; }; + state = factory(set); + return { getState: () => state }; + } }, + 'expo-secure-store': { + setItemAsync: async () => {}, + deleteItemAsync: async key => { events.push(key); }, + }, + 'expo-notifications': Object.fromEntries([ + 'cancelAllScheduledNotificationsAsync', 'dismissAllNotificationsAsync', + 'clearLastNotificationResponseAsync', + ].map(name => [name, async () => { events.push(name); }])), + '../api/client': { + setTokens: () => {}, + clearTokens: () => { events.push('clearTokens'); }, + setOnAuthFailure: fn => { onAuthFailure = fn; }, + }, + '../api/endpoints': { auth: {} }, + '../utils/logger': { log: () => {} }, + '../notifications/session': { pushRegistration: { + unregister: async () => { events.push('unregister'); await unregister(); }, + } }, + }; + const source = fs.readFileSync(require.resolve('../src/store/authStore.ts'), 'utf8'); + const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }); + const exports = {}; + vm.runInNewContext(compiled.outputText, { exports, require: name => { + assert.ok(name in mocks, `Unexpected dependency: ${name}`); + return mocks[name]; + } }); + return { store: exports.useAuthStore, events, failAuth: () => onAuthFailure() }; +} + +test('logout disables the UI immediately and revokes before clearing credentials', async () => { + let release; + const pending = new Promise(resolve => { release = resolve; }); + const { store, events } = loadStore(() => pending); + await store.getState().login('access', 'refresh', { user_id: 1 }); + const logout = store.getState().logout(); + assert.equal(store.getState().isAuthenticated, false); + assert.equal(store.getState().isLoading, true); + assert.deepEqual(events, ['unregister']); + const secondLogout = store.getState().logout(); + release(); + await Promise.all([logout, secondLogout]); + assert.equal(events.filter(event => event === 'unregister').length, 1); + assert.ok(events.indexOf('auth_tokens') > events.indexOf('unregister')); + assert.ok(events.includes('cancelAllScheduledNotificationsAsync')); + assert.ok(events.includes('dismissAllNotificationsAsync')); + assert.equal(store.getState().isLoading, false); +}); + +test('failed remote cleanup still completes local logout', async () => { + const { store, events } = loadStore(async () => { throw new Error('offline'); }); + await store.getState().login('access', 'refresh', { user_id: 1 }); + await store.getState().logout(); + assert.equal(store.getState().isAuthenticated, false); + assert.equal(store.getState().userId, null); + assert.ok(events.includes('clearTokens')); +}); + +test('expired sessions use the same notification cleanup', async () => { + const { store, events, failAuth } = loadStore(async () => {}); + await store.getState().login('access', 'refresh', { user_id: 1 }); + failAuth(); + await store.getState().logout(); + assert.equal(store.getState().isAuthenticated, false); + assert.equal(events.filter(event => event === 'unregister').length, 1); +}); diff --git a/mobile/tests/pushRegistration.test.cjs b/mobile/tests/pushRegistration.test.cjs new file mode 100644 index 0000000..f990715 --- /dev/null +++ b/mobile/tests/pushRegistration.test.cjs @@ -0,0 +1,96 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const ts = require('typescript'); + +// Exercise the actual TypeScript state machine without loading native Expo modules. +const source = fs.readFileSync(require.resolve('../src/notifications/registration.ts'), 'utf8'); +const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }); +const moduleExports = {}; +vm.runInNewContext(compiled.outputText, { exports: moduleExports }); +const { createPushRegistration } = moduleExports; + +function fixture() { + const state = { saved: null, remote: null, offline: false, register: null }; + const dependencies = { + read: async () => state.saved, + write: async value => { state.saved = value; }, + register: async (userId, token) => { + state.remote = { userId, token }; + if (state.register) await state.register(); + }, + unregister: async (userId, token) => { + if (state.offline) throw new Error('offline'); + if (state.remote?.userId === userId && state.remote?.token === token) state.remote = null; + }, + }; + return { state, dependencies, manager: createPushRegistration(dependencies) }; +} + +test('logout removes registration; login restores it', async () => { + const { state, manager } = fixture(); + await manager.register(1, 'device', () => true); + await manager.unregister(); + assert.equal(state.remote, null); + assert.equal(state.saved, null); + await manager.register(1, 'device', () => true); + assert.equal(state.remote.token, 'device'); +}); + +test('offline cleanup survives restart and retries without login credentials', async () => { + const { state, manager, dependencies } = fixture(); + await manager.register(1, 'device', () => true); + state.offline = true; + await assert.rejects(manager.unregister()); + assert.equal(state.saved.pending, true); + state.offline = false; + await createPushRegistration(dependencies).retry(); + assert.equal(state.remote, null); + assert.equal(state.saved, null); +}); + +test('logout during an in-flight registration cannot leave the token registered', async () => { + const { state, manager } = fixture(); + let release; + let started; + let current = true; + const ready = new Promise(resolve => { started = resolve; }); + state.register = () => new Promise(resolve => { release = resolve; started(); }); + const registration = manager.register(1, 'device', () => current); + await ready; + current = false; + const logout = manager.unregister(); + release(); + await Promise.all([registration, logout]); + assert.equal(state.remote, null); +}); + +test('permission/token lookup completing after logout cannot register', async () => { + const { state, manager } = fixture(); + await manager.register(1, 'device', () => false); + assert.equal(state.remote, null); +}); + +test('a lost registration response still leaves enough information to revoke', async () => { + const { state, manager } = fixture(); + state.register = async () => { throw new Error('response lost'); }; + await assert.rejects(manager.register(1, 'device', () => true)); + assert.equal(state.saved.pending, true); + await manager.unregister(); + assert.equal(state.remote, null); +}); + +test('pending cleanup completes before account switching and does not revoke new login', async () => { + const { state, manager } = fixture(); + await manager.register(1, 'device', () => true); + state.offline = true; + await assert.rejects(manager.unregister()); + await assert.rejects(manager.register(2, 'device', () => true)); + assert.equal(state.remote.userId, 1); + state.offline = false; + await manager.register(2, 'device', () => true); + await manager.retry(); + assert.equal(state.remote.userId, 2); + assert.equal(state.saved.pending, false); +});