Skip to content

feat(reminders): opt-in push notifications for due flashcards - #69

Merged
tpaulshippy merged 22 commits into
mainfrom
feature/study-due-reminders
Sep 13, 2026
Merged

tpaulshippy merged 22 commits into
mainfrom
feature/study-due-reminders

Conversation

@tpaulshippy

@tpaulshippy tpaulshippy commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Follow-up to #49: study-due push reminders

Base: feature/roadmap-07-spaced-repetition (stacked; includes one review follow-up commit from the #49 review: atomic review, truthful hints, null-review guard).

What changed

  • Backend: Device.notify_study_due opt-in flag (default off, migration 0055), Device.notify_study_reminder() (digest-only devices stay digest-only), new send_study_reminders cron-friendly command — single due deck deep-links to its study session, several decks aggregate to the deck list, users with nothing due are skipped
  • Frontend: 'Study reminders' toggle in Settings → Notifications (disabled under digest-only, like the other instant pushes) + onboarding parity; study_due push-tap deep link opens the due study session (or deck list when several decks are due)
  • Teen access (review follow-up): teens get a PIN-free Settings screen (/settings) with only the Study reminders opt-in — parent surveillance flags stay behind the PIN at /parent/settings and are preserved untouched on save; the drawer routes teens to /settings, parents to /parent/settings
  • Teen hardening (Copilot review): teen-delegated sessions may only write notify_study_due (anything else → 403, DELETE → 403, creates force parent-flag defaults), teen device lists are token-lookup-only, and teen detail routes are UUID-only; reminder copy is counts-only (no sibling deck names) and reminder taps fall back to the deck list when the tapped deck has nothing loadable for that profile
  • Tests: backend command tests (single/multi-deck copy, future cards ignored, opt-out/deleted/digest-only skipped, no cross-user leak) + teen device-permission/enumeration tests + device API, settings toggle, reminder-fallback, and deep-link hook tests — backend and frontend suites green, tsc/eslint/ruff clean

Demo

  1. As a teen: Settings → enable Study reminders (or as a parent: Settings → Notifications → Study reminders)
  2. Run python manage.py send_study_reminders (schedule a few times a day):
Study reminder sent to device 131b81cc: 6 cards due — time to review!
Study reminder sent to device fea715dd: 6 cards due — time to review!
2 study reminder push(es) sent
  1. Tap the push → lands on the due study session (single deck) or deck list (several decks, or a deck with nothing loadable for that profile)

Evidence

Teen Settings with Study reminders opt-in

Parent Notifications with Study reminders toggle

Deck list with due badge

Study session with truthful interval hints

pr69-study-reminders.mp4

…w guard)

- Backend: wrap flashcard reschedule + review log in transaction.atomic;
  require authentication on DeckViewSet (study_queue was 404 not 401)
- Study screen: per-card SM-2 interval hint previews instead of the
  static 1d/3d/7d legend; don't advance when review POST fails (null
  fallback); completion timestamp captured in handler; honor mode param
- Deck list: guard malformed last_studied_at timestamps
- E2E: fix study_queue/.json typo
- Backend: Device.notify_study_due flag (default off) + migration,
  Device.notify_study_reminder (digest-only safe), send_study_reminders
  command (single deck deep-links to its study session, several decks
  aggregate to the deck list, no-due users skipped)
- Frontend: 'Study reminders' toggle in Settings + onboarding parity,
  study_due push-tap deep link to the due study session
- Tests: 8 backend command tests, device API + settings + deep-link tests
Base automatically changed from feature/roadmap-07-spaced-repetition to main September 12, 2026 14:58
@tpaulshippy

Copy link
Copy Markdown
Owner Author

The opt in needs to be on a screen accessible to teens, as they are likely the ones who need it. Probably should give them settings menu and only pin gate the settings that need it.

Parent Settings (/parent/settings) is PIN-gated and hidden from
teen-delegated sessions, so teens could never reach the Study reminders
toggle. Add a teen-safe /settings screen exposing only notify_study_due
(read-modify-write preserves parent flags; disabled with an explainer
while digest-only suppresses it) and route the drawer Settings item
there for teens (/parent/settings unchanged for parents).
@tpaulshippy

Copy link
Copy Markdown
Owner Author

Addressed the teen-accessibility feedback in 4652904:

  • New teen-safe /settings screen (no PIN gate) exposing only the Study reminders opt-in. Parent surveillance flags (new chat / each message / digest-only) stay behind the PIN at /parent/settings and are preserved untouched on save (read-modify-write). While digest-only is on, the switch is disabled with an explainer pointing at parent Settings.
  • Drawer now shows Settings to everyone: teens route to /settings, parents to /parent/settings (unknown session fails closed to the teen-safe route). useDelegatedRouteGuard needed no change since /settings is outside /parent/*.
  • Tests: new teenSettings-test.tsx (only study toggle rendered, flag load, parent-flag preservation, digest-disabled state) + drawer routing tests; tsc clean, eslint clean (touched files), targeted suites green (14 passed).

@copilot review this pull request

Copilot AI commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Addressed the teen-accessibility feedback in 4652904:

  • New teen-safe /settings screen (no PIN gate) exposing only the Study reminders opt-in. Parent surveillance flags (new chat / each message...

Reviewed the follow-up and found two blocking issues: teen sessions can still change the parent-only device flags by sending a crafted PUT to /devices, and send_study_reminders is still user-wide so a teen who opts in can receive sibling deck reminder content. No code changes made in this pass.

01-teen-login asserted teens never see a Settings drawer item; with the
teen-safe /settings screen they now do (study reminders only, no parent
surfaces). Guard test gains an explicit /settings kid-safe case.
Copilot review found two blocking issues with the teen-safe opt-in:

- Teen-delegated JWTs authenticate as the parent user, so IsOwner alone
  let a crafted PUT flip parent surveillance flags (oversight evasion).
  DeviceViewSet now restricts teen writes to notify_study_due only:
  any other changed field -> 403, DELETE -> 403, and teen creates force
  parent-only flags to model defaults. Benign full-object round-trips
  (identical values, as the teen client sends) still pass.
- send_study_reminders grouped by parent user, so a single-deck body
  naming the deck could push a sibling's deck name to a teen device.
  Bodies are now counts-only (deep-link routing unchanged; deck APIs
  stay profile-scoped so sibling deep links 404).
@tpaulshippy

Copy link
Copy Markdown
Owner Author

Both blocking issues fixed in 3ec335e — thanks for catching them:

  1. Teen PUT on parent-only flags — confirmed: teen-delegated JWTs authenticate as the parent user, so IsOwner passed. DeviceViewSet now restricts teen-delegated writes to notify_study_due only: any other changed field → 403, DELETE → 403, and teen creates force parent-only flags to model defaults. Benign full-object round-trips (identical values, which is what the teen client sends) still return 200. Covered by new test_device_teen_permissions.py (7 tests, incl. a parent-regression case).
  2. Sibling deck content in pushes — confirmed for the single-deck body (it interpolated the deck name). Bodies are now counts-only (N cards due — time to review!); deep-link routing unchanged, and deck/study APIs are already profile-scoped so a sibling deep link 404s instead of leaking cards. Copy tests updated + a no-deck-name regression test added.

Verification: new + neighboring backend suites green (16 + 105 passed), ruff check clean, frontend tsc/eslint clean, targeted jest suites 23 passed.

@copilot review this pull request again

Copilot AI commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Both blocking issues fixed in 3ec335e — thanks for catching them:

  1. Teen PUT on parent-only flags — confirmed: teen-delegated JWTs authenticate as the parent user, so IsOwner passed. `Devi...

Re-reviewed 3ec335e. The teen-write restriction looks good now, but there’s still one blocking issue: send_study_reminders is still grouped and delivered at the parent-account level, so a teen device can receive sibling-triggered reminder counts, and single-deck pushes still carry a sibling deck_id that routes to an inaccessible study screen.

A single-deck push can name a deck this profile can't load (sibling
deck on a shared device: deck APIs are profile-scoped, so the queue
comes back empty). Tag push taps with source=reminder; an empty queue
on such a tap replaces to the deck list (due badges show what's due)
instead of stranding the user on Nothing-due for a push that promised
cards. Plain navigation keeps the celebratory empty state.
@tpaulshippy

Copy link
Copy Markdown
Owner Author

Follow-up on the remaining concern (account-level delivery) in 65a8a0d:

  • Sibling deck_id dead end — fixed. Push taps now carry source=reminder; when the tapped deck loads nothing for this profile (sibling deck on a shared device, or already studied), the study screen replaces to the deck list — whose due badges show what's actually due — instead of stranding the user on Nothing-due for a push that promised cards. Plain navigation keeps the celebratory empty state. Tests added on both sides (tap params + empty-on-reminder → deck list, empty-on-plain → empty state).
  • Sibling-triggered counts — by design, no content leak. Devices are per-account objects with no device↔profile mapping, so delivery is inherently account-level; but since 3ec335e the body is counts-only (no deck names, no profile info), and every tap target resolves through profile-scoped APIs (own study session, else deck list). A generic “time to review” nudge is the whole point of an opt-in family reminder. Per-teen scoping would need a device↔profile link (schema + client reporting) — happy to spin that up as follow-up work if you want it, but I kept it out of this PR as speculative.

Verification: jest suites green (22 passed incl. new cases), tsc clean, eslint clean on touched files.

@copilot review this pull request again

Copilot AI commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Follow-up on the remaining concern (account-level delivery) in 65a8a0d:

  • Sibling deck_id dead end — fixed. Push taps now carry source=reminder; when the tapped deck loads nothing for this ...

Re-reviewed 65a8a0d and found one remaining issue: the new reminder fallback also triggers when fetchStudyQueue() fails, because that helper returns [] on non-auth errors. An offline/5xx failure on a reminder tap would silently redirect to /flashcards instead of surfacing a load failure.

fetchStudyQueue() swallowed every failure to [], so the reminder-tap
fallback could not tell 'sibling/inaccessible deck' from 'offline/5xx'
and would silently redirect on a failed load. It now resolves null on
failure (same nullable pattern reviewFlashcard already uses): the study
screen only redirects reminder taps on a confirmed-empty queue and keeps
the previous empty state on null. Callers updated (study-all-anyway
null-guards); the apiClient test mock gains real error classes so the
shared request() failure path works under test.
@tpaulshippy

Copy link
Copy Markdown
Owner Author

Good catch — fixed in 3866bc7. fetchStudyQueue() swallowed every failure to [], so the fallback couldn't tell “inaccessible deck” from “offline/5xx”. It now resolves null on failure (the same nullable pattern reviewFlashcard already uses): the study screen only redirects reminder taps on a confirmed-empty queue and keeps the previous empty state on null. Only caller updated (study-all-anyway null-guards), plus tests for both branches and the API-level null-on-failure contract. The apiClient test mock also gains real error classes so the shared request() failure path works under test.

Verification: 6 related suites green (52 passed), tsc clean, eslint clean.

@copilot review this pull request again

Copilot AI commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Good catch — fixed in 3866bc7. fetchStudyQueue() swallowed every failure to [], so the fallback couldn't tell “inaccessible deck” from “offline/5xx”. It now resolves null on failure (t...

Re-reviewed 3866bc7 and found one remaining issue: reminder taps for sibling/inaccessible decks still fall through to the false “Nothing due” state, because fetchStudyQueue() now returns null for 404 not-found as well as real load failures, and the study screen only redirects on confirmed [].

Splitting 'nothing loadable' three ways: 404 (deck inaccessible to this
profile, e.g. a sibling deck on a shared device) resolves [] so reminder
taps redirect to the deck list; offline/5xx resolve null so reminder taps
keep the empty state instead of silently redirecting on a failed load.
Genuine 200-empty is unchanged.
@tpaulshippy

Copy link
Copy Markdown
Owner Author

Right again — the null lumped 404 in with real failures. Fixed in c584e8e by splitting three ways in fetchStudyQueue (verified the API raises DRF NotFound → 404 for profile-inaccessible decks via get_object_by_uuid_or_id):

  • 200 with no cards → [] (genuinely empty)
  • 404 (sibling/inaccessible deck) → [], so reminder taps redirect to the deck list instead of a false Nothing-due
  • offline/5xx (or null response) → null, so reminder taps keep the empty state instead of silently redirecting on a failed load

Tests: API-level cases for all three branches; study-screen branch tests unchanged and green. Suites green, tsc/eslint clean.

@copilot review this pull request again

Copilot AI commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Both fixed in 961227d:

  1. Stale launch tap — the hook now clears the launch response after handling it (guarded for older native runtimes; index reads it first since child effects run before pa...

Re-reviewed 961227d. The launch-response clearing and teen-create deleted_at=None fix both look right.

I do still see one remaining issue in the final PR state: token-based device lookup still returns deleted rows. Both the teen settings flow and onboarding reuse fetchDeviceByToken(token) and then upsertDevice() that full object back unchanged, so an already-deleted device stays deleted and never re-enters the parent list or reminder-delivery queries.

Concretely:

  • DeviceViewSet.get_queryset() token lookups filter only by user + notification_token, not deleted_at=None
  • front/api/devices.ts returns that deleted row from fetchDeviceByToken
  • settings/onboarding then PUT it back unchanged
  • reminder delivery still filters deleted_at=None

So the new create guard fixes fresh crafted creates, but an existing deleted row with the same token is still unrecoverable through the normal re-registration path.

Token lookups excluded dead rows, but creates then 400'd on the unique
token (validator saw deleted rows) while updates PUT the dead row back
unchanged — a re-registering device stayed deleted and reminder-less
forever. The serializer drops the table-wide UniqueValidator; creates
revive the soft-deleted row in place (same device_id, teen flag rules
apply) and only reject live duplicates with a 400.
@tpaulshippy

Copy link
Copy Markdown
Owner Author

Fixed in f17a077. Token lookups (teen and parent) now exclude soft-deleted rows, and creates revive a soft-deleted row with the same token in place — same device_id, teen flag rules applied on revive — instead of 400ing on the unique validator or PUTing the dead row back unchanged. Live duplicates still 400 as before. Tests: lookup exclusions, parent + teen revive, live-duplicate 400. 19 + 41 green, ruff clean.

@copilot review this pull request again

Copilot AI commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Fixed in f17a077. Token lookups (teen and parent) now exclude soft-deleted rows, and creates revive a soft-deleted row with the same token in place — same device_id, teen flag rules applied on reviv...

Re-reviewed f17a077. The token-lookup / revive fix itself looks good, and the current CI run is green.

I do still see two remaining issues in the overall PR state:

  1. Soft-deleted devices still round-trip through the detail route. get_object() still resolves against Device.objects.all(), so a client with a cached device_id for a soft-deleted row can still GET/PUT that row. perform_update() then preserves its existing deleted_at, which means the user can toggle settings successfully but the device stays excluded from the parent list and from reminder/chat delivery queries that filter deleted_at=None.

  2. Duplicate-token updates lost validation. Removing the serializer uniqueness validator was the right move for create-time revival, but I don’t see any replacement check on the update path. A PUT that changes one live device’s notification_token to another live device’s token now falls through to the DB unique constraint instead of a clean 400, and I don’t see an exception handler on this viewset path that would prevent that from surfacing as a 500.

@tpaulshippy

Copy link
Copy Markdown
Owner Author

@copilot please review the latest commit f17a077 (soft-deleted device revive on re-registration) and confirm whether anything is still outstanding on PR 69.

Copilot AI commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

@copilot please review the latest commit f17a077 (soft-deleted device revive on re-registration) and confirm whether anything is still outstanding on PR 69.

Reviewed f17a077 and re-checked the current PR CI run/logs. The soft-deleted re-registration fix itself looks good, but there are still two outstanding issues on PR 69:

  1. Soft-deleted devices still round-trip through the detail route via cached device_id, so a GET/PUT can preserve deleted_at and leave the device excluded from parent lists and notification delivery.
  2. Duplicate-token updates no longer have validation on the update path, so changing one live device’s notification_token to another live device’s token appears to fall through to the DB unique constraint instead of returning a clean 400.

@tpaulshippy

Copy link
Copy Markdown
Owner Author

Copilot, please review the latest commit f17a077 (soft-deleted device revive on re-registration) and confirm whether anything is still outstanding on PR 69.

…moves

- get_object scopes to live rows: a cached device_id for a dead row now
  404s instead of round-tripping (and preserving) deleted_at while the
  device stays out of lists and delivery. Recovery is via create-revive.
- perform_update replaces the dropped table-wide UniqueValidator: moving
  a live device onto another live device's token is a clean 400, not a
  DB-constraint 500.
@tpaulshippy

Copy link
Copy Markdown
Owner Author

Both fixed in 42a5c1d:

  1. Dead rows via detail route — get_object now scopes to live rows (same as the lists), so a cached device_id for a soft-deleted row 404s for everyone instead of round-tripping deleted_at. Recovery stays via create-revive.
  2. Token moves on update — perform_update replaces the dropped table-wide UniqueValidator: retargeting a live device onto another live token is a clean 400 with the same field shape, not a DB 500.

Tests: detail-hides-deleted (parent + teen GET, parent PUT stays deleted) and live-token-move 400. 21 + 51 green, ruff clean.

@copilot review this pull request again

Copilot AI commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Both fixed in 42a5c1d:

  1. Dead rows via detail route — get_object now scopes to live rows (same as the lists), so a cached device_id for a soft-deleted row 404s for everyone instead of round-tr...

Re-reviewed 42a5c1d. The two device-lifecycle issues look fixed, and the current PR CI run is green.

I do still see one remaining issue in PR 69: send_study_reminders still counts due flashcards from soft-deleted profiles. The query in send_study_reminders.py filters only on due_at__lte=now, then groups by deck__profile__user, so a removed profile can still contribute reminder counts/payloads (including profile_id) and trigger pushes. This should likely exclude deck__profile__deleted_at the same way other active-profile reads do.

Removed profiles must not contribute counts, profile_id payloads, or
trigger pushes.
@tpaulshippy

Copy link
Copy Markdown
Owner Author

Fixed in bcbf3fb: the reminder query now filters deck__profile__deleted_at__isnull, matching every other active-profile read. Test: soft-deleted profile with due cards + opted-in device sends nothing. 11 green, ruff clean.

@copilot review this pull request again

Copilot AI commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Fixed in bcbf3fb: the reminder query now filters deck__profile__deleted_at__isnull, matching every other active-profile read. Test: soft-deleted profile with due cards + opted-in device sends nothing....

Re-reviewed bcbf3fb. The deleted-profile reminder fix looks right.

I do still see one remaining issue on PR 69: the teen update guard’s _field_unchanged() helper mishandles string booleans. current == incoming.lower() in ('true', '1') is a chained comparison in Python, so unchanged values like True + 'true' (or False + 'false') evaluate false and can wrongly 403 a teen PUT if the request body comes through as strings instead of JSON booleans.

current == incoming.lower() in (...) parsed as a chained comparison,
so unchanged string booleans ('true' from form-encoded PUTs) never
matched and wrongly 403'd. Covered by a multipart regression test.
@tpaulshippy

Copy link
Copy Markdown
Owner Author

Fixed in bc163ec — good spot, that was a real chained-comparison bug: string true/false from form-encoded PUTs never matched and wrongly 403d. Parenthesized, with a multipart regression test proving unchanged string flags pass. 22 green, ruff clean.

@copilot review this pull request again

Copilot AI commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Fixed in bc163ec — good spot, that was a real chained-comparison bug: string true/false from form-encoded PUTs never matched and wrongly 403d. Parenthesized, with a multipart regression test proving...

Re-reviewed bc163ec. The teen string-boolean fix looks right, and the current PR CI run is green.

I don’t see anything else outstanding on PR 69.

Co-authored-by: tpaulshippy <3137263+tpaulshippy@users.noreply.github.com>
@tpaulshippy
tpaulshippy merged commit dcf4029 into main Sep 13, 2026
6 checks passed
@tpaulshippy
tpaulshippy deleted the feature/study-due-reminders branch September 13, 2026 21:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants