Skip to content

feat(lists): email invites — send, list and revoke (#58) - #119

Merged
Adron merged 1 commit into
parity/queuefrom
issue/58-list-invites
Sep 16, 2026
Merged

Adron merged 1 commit into
parity/queuefrom
issue/58-list-invites

Conversation

@Adron

@Adron Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member

Closes #58. Part of epic #57. Mirrors #59 (documents).

What changed

Email invites for lists, on the list's access screen (WatchersScreen — the per-person access
surface, the list counterpart of the document collaborators sheet), as a nested state.invites
object.

  • The subscriber gate and email guard live inside sendInvite, so no caller can bypass them. A
    free account issues no write at all (one /api/user read, then a local SubscriptionRequired);
    an invalid address issues no request whatsoever.
  • Revoking is never gatedrevokeInvite makes exactly one DELETE and no subscription lookup,
    asserted with server.requestCount == 1.
  • The 201 carries no token, so it is recovered from the landing URL's last path segment —
    otherwise a just-sent invite could not be revoked.
  • Status is derived client-side (revokedAtaccepted → expiry vs. clock → pending); there is no
    server status field.
  • Re-inviting the same address replaces the existing row rather than surfacing an error, matching
    the server's idempotent re-issue.

Accepting an invite is not implemented — that is #60, which covers lists and documents together.

Verification

./gradlew :app:assembleDebug testDebugUnitTest → BUILD SUCCESSFUL, 1298 tests, 0 failures (39
new). 6 Compose tests compile, not executed (no emulator).

Four things worth your attention

  1. One deliberate divergence from Invites: send and revoke document invites #59, in the safe direction. currentUserIsSubscriber() treats
    CustomerStatus.UNKNOWN as unreadable (fail open) rather than as not a subscriber. An
    unrecognised customerStatus string — say a future subscriber:lifetime — maps to UNKNOWN,
    whose isSubscriber is false, which would lock a paying owner out of their own invite form
    without ever asking the server
    . Covered by a test. :feature:documents has the same latent
    bug
    and needs the same one-line fix; filed separately rather than touched here.
  2. A user-visible label mismatch now exists. Lists label the roles Read-only / Edit / Admin
    (this issue's wording); documents label the same API values Viewer / Editor / Admin (Invites: send and revoke document invites #59's).
    The help centre's own table says Viewer/Editor/Admin. That is a product decision — say which you
    want and it is a one-line change in InviteLabels.
  3. Deliberate duplication, left in place. InviteStatus/InviteRole/InviteEmail/
    inviteExpiryLabel are now near-identical in both modules, per the self-contained-module
    convention. If a consolidation pass ever happens, InviteEmail and inviteExpiryLabel are the
    genuinely generic pieces; InviteRole is not, precisely because of point 2.
  4. DefaultListsRepository gained userApi: InterlinedListApi as its second constructor
    parameter.
    Hilt provides it with no DI-module change, but a parallel branch constructing the
    repository directly will conflict on those seven test setUps.

expiresAt is modelled end to end but the UI never sets it, so invites are always created with no
expiry; the "Expired" state is reachable only for invites created elsewhere.

Adds the email-invite flow to the list access (watchers) screen, alongside
the existing per-person watcher roles rather than replacing them, mirroring
the document flow landed in #59:

- POST/GET /api/lists/{id}/invites and DELETE .../invites/{token} plumbed
  through ListsRepository as sendInvite/getInvites/revokeInvite.
- An "Invite by email" form (address + Read-only/Edit/Admin role) and a
  "Pending invites" list showing role, derived status (Pending / Accepted /
  Expired / Revoked) and expiry, each row revocable.
- Sending is gated on CustomerStatus.isSubscriber via the shared
  GET /api/user check, so a free account issues no write at all; listing
  and revoking are never gated, matching the server — a lapsed owner can
  always shut off access they previously granted.
- Addresses are validated client-side before any request, and server
  rejections are surfaced with the server's own message instead of a
  generic failure.

Invite roles use the server's sharing vocabulary (watcher / collaborator /
manager) behind this module's Read-only / Edit / Admin labels, and the
create response's missing token is recovered from the returned landing URL
so a just-sent invite can be revoked. Re-inviting an address replaces the
existing row, because the server treats it as idempotent rather than an
error.

Accepting an invite (/api/lists/invite/{token}) is deliberately out of
scope; it is covered by #60 for lists and documents together.

Closes #58

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Adron
Adron merged commit a62186c into parity/queue Sep 16, 2026
1 check passed
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.

1 participant