Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,9 +140,9 @@ Three cooperating processes (`Procfile`) plus the mobile client:
- **web** — `gunicorn project.wsgi` — the DRF API and Django Admin (the primary researcher
surface; see Researcher Dashboard below for the read-only monitoring API alongside it).
- **worker** — `celery -A project.celery worker` — executes the JITAI / notification tasks.
- **beat** — `celery -A project.celery beat` — fires four periodic tasks (`CELERY_BEAT_SCHEDULE`
in `settings.py`). Three run every **180 s** and live in `app/tasks.py`:
`ingest_wearable_data`, `evaluate_jitai_triggers`, `send_checkin_reminders`. The fourth,
- **beat** — `celery -A project.celery beat` — fires five periodic tasks (`CELERY_BEAT_SCHEDULE`
in `settings.py`). Four run every **180 s** and live in `app/tasks.py`:
`ingest_wearable_data`, `evaluate_jitai_triggers`, `send_checkin_reminders`, `check_expo_receipts`. The fourth,
`dashboard.tasks.recompute_monitoring_metrics`, runs every **600 s** — an aggregate refresh
paced to the Labfront batch cadence, so a shorter interval would only reread the same rows.

Expand All @@ -165,6 +165,13 @@ as one is just the slot length; it also sends the **outcome check-in reminder**
**silent JITAI prompt** (`evaluate_jitai_triggers`, sent only after a
newly completed EMA passes eligibility + randomization).

`send_jitai_prompt` stores Expo's ticket ID on `JITAILog.expo_ticket_id`. `check_expo_receipts` asks Expo for
the receipt 15 min to 23 h after the send (Expo keeps receipts about 24 h) and records `expo_receipt_status` /
`expo_receipt_error`. An `ok` receipt moves `delivery_status` from `accepted_by_expo` to `handed_to_provider`
(Apple/Google accepted it, not proof the participant saw it); an error receipt sets `failed` with
`delivery_error='expo receipt: <code>'`, and `DeviceNotRegistered` clears `User.push_token`. A row already
`received_on_device` is never downgraded. Check-in reminders do not store a ticket.

**Every available decision point opens a time-locked outcome window, sent or not**
(`_latest_outcome_window` in `app/views.py`, protocol §9.2).
- For the first `OUTCOME_CHECK_IN_DELAY_MINUTES` (60, provisional) `/ema/next/` serves nothing but the C0
Expand Down Expand Up @@ -331,7 +338,7 @@ but that file doesn't exist in the repo — don't chase it.)
| GET | `/telemetry/stress/{user_id}/` | Fetch recent stress samples (dashboard use) |
| POST | `/telemetry/phone/` | Ingest compose surface event from mobile app |
| POST | `/telemetry/engagement/` | Ingest EMA/notification engagement event from mobile app |
| GET | `/dashboard/participants/` | Per-participant sync/push/receipt status + staleness |
| GET | `/dashboard/participants/` | Per-participant sync/push/receipt status + staleness (labelled by study ID, no email) |
| GET | `/dashboard/latency-events/` | Recent push→receipt latency events |
| GET | `/api/monitor/cohort` | Latest cohort snapshot: benchmarks, Wilson bounds, 14-day series |
| GET | `/api/monitor/grid` | `MetricsDaily` pivoted to participant × study_day for one metric |
Expand Down Expand Up @@ -480,6 +487,10 @@ so a stray space or lowercase code in an export still resolves. It is deliberate
from `user_id` rather than reused as a foreign key target, for the same reason `DistressFlag`
stores only signal codes — it's a crosswalk, not a join key into anything sensitive. Set on
`ParticipantEnrollmentForm` (optional, validated unique case-insensitively) or in Django Admin.
`dashboard/serializers.py`'s `participant_label` is the one place that names a participant in the monitoring
endpoints and `/dashboard/participants/`: study ID, else Labfront ID, else `user_id`. No monitoring payload
carries email. The admin "Enroll for notifications" shortcut writes a `TEST-<user_id>` Labfront ID, so never use it on
a real participant (user 102 / RS02 was enrolled that way and needs the real ID set).

A suppressed decision point is logged with `send_prompt=False`, `randomization_draw` and
`randomization_probability` both null, `status` and `delivery_status` both `'suppressed'` (never
Expand Down
Binary file modified Resources/JITAI_Task_Overview.docx
Binary file not shown.
Binary file modified Resources/REACT_Architecture.docx
Binary file not shown.
3 changes: 2 additions & 1 deletion backend/app/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,7 @@ class JITAILogAdmin(ReadableAdminMixin, admin.ModelAdmin):
date_hierarchy = "triggered_at"
ordering = ("-triggered_at",)
autocomplete_fields = ("user",)
readonly_fields = ("triggered_at", "decision_made_at", "push_sent_at", "device_received_at", "receipt_reported_at", "receipt_event_id", "suppression_reason")
readonly_fields = ("triggered_at", "decision_made_at", "push_sent_at", "device_received_at", "receipt_reported_at", "receipt_event_id", "suppression_reason", "expo_ticket_id", "expo_receipt_status", "expo_receipt_error", "expo_receipt_checked_at")
fieldsets = (
("Prompt", {
"fields": ("user", "prompt_id", "triggered_at", "status"),
Expand All @@ -451,6 +451,7 @@ class JITAILogAdmin(ReadableAdminMixin, admin.ModelAdmin):
"delivery_status", "decision_made_at", "push_sent_at",
"device_received_at", "receipt_reported_at",
"receipt_event_id", "receipt_platform", "receipt_app_state", "delivery_error",
"expo_ticket_id", "expo_receipt_status", "expo_receipt_error", "expo_receipt_checked_at",
),
}),
)
Expand Down
38 changes: 38 additions & 0 deletions backend/app/migrations/0053_jitailog_expo_receipt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Generated by Django 6.0.6 on 2026-10-08 21:11

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('app', '0052_merge_20261005_0027'),
]

operations = [
migrations.AddField(
model_name='jitailog',
name='expo_receipt_checked_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='jitailog',
name='expo_receipt_error',
field=models.CharField(blank=True, default='', max_length=64),
),
migrations.AddField(
model_name='jitailog',
name='expo_receipt_status',
field=models.CharField(blank=True, default='', max_length=16),
),
migrations.AddField(
model_name='jitailog',
name='expo_ticket_id',
field=models.CharField(blank=True, default='', max_length=64),
),
migrations.AlterField(
model_name='jitailog',
name='delivery_status',
field=models.CharField(choices=[('pending', 'Pending'), ('not_sent', 'Not Sent'), ('suppressed', 'Suppressed'), ('accepted_by_expo', 'Accepted by Expo'), ('handed_to_provider', 'Handed to Apple/Google'), ('received_on_device', 'Received on Device'), ('failed', 'Failed')], db_index=True, default='pending', max_length=32),
),
]
5 changes: 5 additions & 0 deletions backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,7 @@ class JITAILog(models.Model):
('not_sent', 'Not Sent'),
('suppressed', 'Suppressed'),
('accepted_by_expo', 'Accepted by Expo'),
('handed_to_provider', 'Handed to Apple/Google'),
('received_on_device', 'Received on Device'),
('failed', 'Failed'),
]
Expand Down Expand Up @@ -394,6 +395,10 @@ class JITAILog(models.Model):
db_index=True,
)
delivery_error = models.TextField(blank=True, default='')
expo_ticket_id = models.CharField(max_length=64, blank=True, default='')
expo_receipt_status = models.CharField(max_length=16, blank=True, default='')
expo_receipt_error = models.CharField(max_length=64, blank=True, default='')
expo_receipt_checked_at = models.DateTimeField(null=True, blank=True)
receipt_platform = models.CharField(max_length=16, blank=True, default='')
receipt_app_state = models.CharField(max_length=32, blank=True, default='')
trigger_signal = models.CharField(max_length=32, null=True, blank=True)
Expand Down
21 changes: 18 additions & 3 deletions backend/app/notification_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import logging
import random
from pathlib import Path
from types import SimpleNamespace

from django.utils import timezone

Expand Down Expand Up @@ -202,16 +203,29 @@ def mark_delivery_failed(jitai_log, error: str) -> None:
jitai_log.save(update_fields=['status', 'delivery_status', 'delivery_error'])


def mark_delivery_accepted(jitai_log) -> None:
def mark_delivery_accepted(jitai_log, ticket_id: str = '') -> None:
jitai_log.status = 'delivered'
jitai_log.delivery_status = 'accepted_by_expo'
jitai_log.push_sent_at = timezone.now()
jitai_log.delivery_error = ''
jitai_log.expo_ticket_id = ticket_id
jitai_log.save(
update_fields=['status', 'delivery_status', 'push_sent_at', 'delivery_error']
update_fields=[
'status', 'delivery_status', 'push_sent_at', 'delivery_error', 'expo_ticket_id',
]
)


def fetch_expo_receipts(ticket_ids: list[str]) -> list:
tickets = [SimpleNamespace(id=ticket_id) for ticket_id in ticket_ids]
return PushClient().check_receipts_multiple(tickets)


def receipt_error_code(receipt) -> str:
details = receipt.details if isinstance(receipt.details, dict) else {}
return str(details.get('error') or 'unknown')[:64]


def build_jitai_push_message(user, jitai_log) -> PushMessage:
return PushMessage(
to=user.push_token,
Expand Down Expand Up @@ -247,7 +261,8 @@ def send_jitai_prompt(user, jitai_log) -> bool:
try:
response = PushClient().publish(message)
response.validate_response()
mark_delivery_accepted(jitai_log)
ticket_id = response.id if isinstance(response.id, str) else ''
mark_delivery_accepted(jitai_log, ticket_id)
logger.info(
"Expo push sent: user_id=%s prompt_id=%s",
user.user_id, jitai_log.prompt_id,
Expand Down
6 changes: 5 additions & 1 deletion backend/app/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,12 +123,16 @@ class Meta:
'send_prompt', 'status', 'decision_made_at', 'push_sent_at',
'device_received_at', 'receipt_reported_at', 'receipt_event_id',
'delivery_status', 'delivery_error', 'receipt_platform', 'receipt_app_state',
'expo_ticket_id', 'expo_receipt_status', 'expo_receipt_error', 'expo_receipt_checked_at',
'send_prompt', 'status',
'trigger_signal', 'ema_mood', 'ema_stress', 'ema_energy',
'eligible_prompt_ids', 'evaluated_items', 'matched_categories',
'category_drawn', 'fallback_reason', 'suppression_reason',
]
read_only_fields = ('id', 'triggered_at', 'suppression_reason')
read_only_fields = (
'id', 'triggered_at', 'suppression_reason',
'expo_ticket_id', 'expo_receipt_status', 'expo_receipt_error', 'expo_receipt_checked_at',
)


class JITAIReceiptSerializer(serializers.Serializer):
Expand Down
70 changes: 70 additions & 0 deletions backend/app/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
CheckinReminder, EMA, HeartRateSample, HRVSample, JITAILog, StressSample, User,
)
from app.notification_service import (
fetch_expo_receipts,
mark_delivery_failed,
receipt_error_code,
select_control_prompt,
select_prompt,
send_checkin_reminder,
Expand Down Expand Up @@ -391,6 +393,74 @@ def _evaluate_user(user, p):
mark_delivery_failed(jitai_log, 'missing push token')


EXPO_RECEIPT_MIN_AGE = timedelta(minutes=15)
EXPO_RECEIPT_MAX_AGE = timedelta(hours=23)


def _apply_expo_receipt(jitai_log, receipt, now):
jitai_log.expo_receipt_checked_at = now
fields = ['expo_receipt_checked_at', 'expo_receipt_status', 'expo_receipt_error']
if receipt.status == 'ok':
jitai_log.expo_receipt_status = 'ok'
jitai_log.expo_receipt_error = ''
if jitai_log.delivery_status == 'accepted_by_expo':
jitai_log.delivery_status = 'handed_to_provider'
fields.append('delivery_status')
else:
code = receipt_error_code(receipt)
jitai_log.expo_receipt_status = 'error'
jitai_log.expo_receipt_error = code
if jitai_log.delivery_status != 'received_on_device':
jitai_log.status = 'failed'
jitai_log.delivery_status = 'failed'
jitai_log.delivery_error = f'expo receipt: {code}'
fields += ['status', 'delivery_status', 'delivery_error']
if code == 'DeviceNotRegistered':
jitai_log.user.push_token = None
jitai_log.user.save(update_fields=['push_token'])
jitai_log.save(update_fields=fields)


@shared_task
def check_expo_receipts():
now = django_timezone.now()
pending = list(
JITAILog.objects.filter(
expo_ticket_id__gt='',
expo_receipt_checked_at__isnull=True,
push_sent_at__gte=now - EXPO_RECEIPT_MAX_AGE,
push_sent_at__lte=now - EXPO_RECEIPT_MIN_AGE,
).select_related('user')
)
if not pending:
return 0

try:
receipts = fetch_expo_receipts([log.expo_ticket_id for log in pending])
except Exception:
logger.exception("check_expo_receipts: receipt request failed")
return 0

by_ticket = {log.expo_ticket_id: log for log in pending}
checked = 0
for receipt in receipts:
jitai_log = by_ticket.pop(receipt.id, None)
if jitai_log is None:
continue
try:
_apply_expo_receipt(jitai_log, receipt, now)
checked += 1
except Exception:
logger.exception(
"check_expo_receipts failed for jitai_log_id=%s", jitai_log.pk
)
if by_ticket:
logger.warning(
"check_expo_receipts: %s ticket(s) had no receipt yet", len(by_ticket)
)
return checked


@shared_task
def send_checkin_reminders():
now = django_timezone.now()
Expand Down
Loading
Loading