Skip to content

feat: key DeviceToken on (AppId, InstallationId) — register upsert + adoption, AppId send filter - #1693

Merged
renemadsen merged 3 commits into
stablefrom
feat/device-token-identity-model
Aug 30, 2026
Merged

feat: key DeviceToken on (AppId, InstallationId) — register upsert + adoption, AppId send filter#1693
renemadsen merged 3 commits into
stablefrom
feat/device-token-identity-model

Conversation

@renemadsen

@renemadsen renemadsen commented Aug 30, 2026

Copy link
Copy Markdown
Member

Task 8 of the device-token identity model (plan 2026-08-30-device-token-identity-model.md, spec 2026-08-30-device-token-identity-model-design.md).

Identity moves from the FCM token to the app install:

  • Proto device_token.proto gains app_id = 4 and installation_id = 5 (field 3 stays build_number).
  • Register upserts on (AppId, InstallationId) including soft-deleted rows and explicitly revives them (today a pruned row is Update()d without touching WorkflowState, so a pruned device goes permanently dark).
  • Legacy adoption: when no (AppId, InstallationId) row matches, a row matching (AppId, FcmToken) is adopted — its InstallationId is overwritten with the real one. Without this, every device that existed before the base migration would end up with both its backfilled legacy:<id> row and a new row, both live, both selected by the sender: doubled pushes forever.
  • Send filters on AppId == "time" and reads FcmToken.
  • SenderIdMismatch joins the prune branch, but only when some token in the same send succeeded. A send where every targeted token mismatches is a wrong-credential fault (TimePlanningBaseSettings:FirebaseServiceAccountJson pointing at the wrong Firebase project); pruning there would silently wipe the tenant's whole token set. Logged at warn with a Sentry capture.
  • Microting.TimePlanningBase 10.0.59 → 10.0.60.

Tests run in the existing DeviceTokenServiceTests (shard c) and PushNotificationServiceTests (shard f) classes; no new class, so no shard-filter change is needed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HknsjYQGfBzXpCtSXgU8Nk

Deploy ordering — read before merging

app_id and installation_id are required: an empty one is rejected with an unsuccessful OperationResponse. Proto3 gives an old client binary empty strings, not absent fields, so once this is deployed the currently-shipped flutter-time app (whose proto still has only 3 fields) cannot register until Task 10 ships. Already-registered rows keep receiving pushes — the send path does not need the new fields — but a token rotation or a fresh install during the window goes dark.

Synthesizing a server-side installation id instead was considered and rejected: any token- or site-derived value re-creates the collision the base migration's legacy:<Id> backfill exists to avoid, and it would hand a device a permanent identity it can never reconcile with its real UUID. Deploy this plugin together with, or after, the flutter-time release that adds the two proto fields.

Pins the (AppId, InstallationId) identity: rotated token updates in place,
soft-deleted rows revive, a migration-backfilled legacy:<id> row is adopted
rather than duplicated, adoption never crosses AppId, and the send path only
sees this app's tokens. Also pins the SENDER_ID_MISMATCH prune rule: prune a
mismatching token only when other tokens in the same send succeeded, since a
wholesale mismatch is a wrong-credential fault.

Expected red until the register/send paths and the 10.0.60 base bump land.
Copilot AI lite review requested due to automatic review settings August 30, 2026 09:54

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The updated tests reference new service APIs/properties (e.g., RegisterAsync overload, TimePlanningAppId, FcmToken, PruneSenderIdMismatchesAsync) that are not present in the current production code, so the solution will not compile as-is.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Updates the TimePlanning.Pn test suite to validate a new “device-install identity” model for push notification tokens (AppId/InstallationId keyed identity, sender filtering, and pruning behavior).

Changes:

  • Refactors DeviceTokenServiceTests to exercise registration/upsert/adoption scenarios keyed on (AppId, InstallationId) and to assert workflow-state revival after soft-delete.
  • Extends PushNotificationServiceTests to assert sender token selection excludes soft-deleted and foreign-app tokens, and adds tests for SENDER_ID_MISMATCH pruning behavior.
  • Updates token property expectations from Token to FcmToken throughout the affected tests.
File summaries
File Description
eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PushNotificationServiceTests.cs Adds/updates tests for token selection filtering and sender-id-mismatch pruning behavior; switches to FcmToken.
eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DeviceTokenServiceTests.cs Reworks registration tests around (AppId, InstallationId) identity, revival/adoption cases, and updated token field naming.
Review details

Suppressed comments (2)

eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DeviceTokenServiceTests.cs:125

  • This assertion references DeviceTokenService.TimePlanningAppId, but that constant/member does not exist in the current DeviceTokenService implementation, so the test will not compile unless the production service is updated accordingly.
        var stored = await TimePlanningPnDbContext!.DeviceTokens.SingleAsync();
        Assert.That(stored.AppId, Is.EqualTo(DeviceTokenService.TimePlanningAppId),
            "this service only ever mints tokens for the time app");

eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PushNotificationServiceTests.cs:147

  • These tests call PushNotificationService.PruneSenderIdMismatchesAsync, but that method does not exist in the current PushNotificationService implementation. The PR needs to add the method (and its logic) or remove/adjust these tests so the suite compiles.
        await CreateService().PruneSenderIdMismatchesAsync(
            new List<DeviceToken> { mismatching }, targetedCount: 2, targetSdkSiteId: 500);
  • Files reviewed: 2/2 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +43 to 45
var result = await _service.RegisterAsync(
42, "fcm-token-abc", "android", 0, "time", "inst-new");

Comment on lines 85 to 87
var tokens = await service.ResolveTargetTokensAsync(7, minBuild: 31221);
var picked = tokens.Select(t => t.Token).ToList();
var picked = tokens.Select(t => t.FcmToken).ToList();

Register upserts on the install identity instead of the FCM token, including
soft-deleted rows, and explicitly resets WorkflowState to created - Update()
left it alone, so a device pruned after an FCM permanent failure stayed dark
forever.

When no (AppId, InstallationId) row matches, a row carrying the same
(AppId, FcmToken) is adopted and its InstallationId rewritten. The base
migration backfills pre-existing rows with 'legacy:<Id>', so without adoption
every pre-existing device would end up with two live rows and doubled pushes
indefinitely; FCM never prunes either, since the token is still valid.

The send path filters on AppId (the leading column of
IX_DeviceTokens_AppId_SdkSiteId_WorkflowState, which replaced the dropped
site-only index) and reads FcmToken. SenderIdMismatch now prunes, but only
when other tokens in the same send succeeded: a send where every token
mismatches means this sender holds the wrong Firebase credential, and pruning
would wipe the tenant's whole token set.

Bumps Microting.TimePlanningBase to 10.0.60.
The sender's AppId and the registrar's AppId are the same value by
definition; PushNotificationService now derives it from
DeviceTokenService.TimePlanningAppId rather than repeating the literal.
@renemadsen
renemadsen merged commit 8c18efb into stable Aug 30, 2026
39 checks passed
@renemadsen
renemadsen deleted the feat/device-token-identity-model branch August 30, 2026 10:39
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