Skip to content

feat(documents): email invites — send, list and revoke (#59) - #102

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

Adron merged 1 commit into
parity/queuefrom
issue/59-document-invites

Conversation

@Adron

@Adron Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member

Closes #59. Part of epic #57. Sets the pattern #58 mirrors for lists.

What changed

Email invites now sit alongside the existing collaborator roles on the Manage-access sheet: an
Invite by email form (address + role) and a Pending invites list showing role, status and
expiry with a per-row Revoke.

The contract came from /help/api/sharingEmail invites, and it differs from the issue text

POST /api/documents/{id}/invites body { email, role, expiresAt }
role is watcher | collaborator | manager not viewer/editor/admin
201 returns { email, role, expiresAt, url } no token — the token is recovered from the landing URL's last path segment, without which you cannot revoke an invite you just sent
GET .../invites { "invites": [ { email, role, expiresAt, accepted, createdAt, token } ] }
no status field the four states the web shows are derived client-side from accepted / expiresAt / revokedAt
DELETE .../invites/{token} { "revoked": true }, explicitly not subscriber-gated

Documented rejections: 400 bad_request, 403 for a free owner ("Subscribe to invite people to
documents."), 404 for a non-owner, 429 rate-limited. Re-inviting the same address is idempotent
— so "already invited" is not an error, and the UI replaces the existing row rather than showing a
failure.

The list and create projections differ, so DocumentInviteDto accepts both (accepted and
acceptedAt, plus the DocumentShareInvite schema's id/documentId/invitedByUserId/
revokedAt/acceptedByUserId), and the envelope accepts inline, {invite:…} and {data:…} forms.

Subscriber gate

Reuses the established pattern — getCurrentUser()customerStatus.isSubscriber — checked inside
sendInvite before the POST, returning AppError.SubscriptionRequired. Revoking is not gated.
It fails open when /api/user is unreadable, so a flaky lookup cannot block a paying subscriber;
the server stays authoritative.

Verification

./gradlew :app:assembleDebug testDebugUnitTest → BUILD SUCCESSFUL, 843 tests, 0 failures (37
new). Coverage includes 11 MockWebServer round-trips asserting exact paths and bodies, a
free-account send that makes only the /api/user GET and no POST, an invalid email that makes
zero requests, and a revoke that does no subscription lookup at all.

Reviewer note — a separate bug this work uncovered

The existing CollaboratorRole sends viewer/editor/admin to /collaborators, but the server's
vocabulary is watcher/collaborator/manager. I confirmed on the live API that
POST /api/documents/{id}/collaborators does not validate the role and silently falls back to
watcher — so every collaborator added from Android is read-only regardless of the role picked.
Filed as #101; deliberately not fixed here, as it is outside this issue.

Notes for #58

Copy wholesale, changing only documentslists: DocumentInvite.kt (the
InviteStatus/InviteRole/InviteEmail trio is domain-identical — the help centre says the two
forms differ only in the resource path and the claim response's id key), InviteDtos.kt,
InviteMappers.kt, InviteLabels.kt, and the PendingInviteRow/InviteByEmailSection composables.
Mirror the repository shape (getInvites / sendInvite(id, email, role) / revokeInvite(id, token),
with the gate and email guard inside sendInvite so no caller can bypass them) and the nested
UI-state object, which drops into the lists access screen unchanged.

Per repo convention each feature module is self-contained, so this is deliberate duplication. If it
becomes annoying, InviteStatus/InviteRole/InviteEmail/inviteExpiryLabel are the only
resource-agnostic pieces and would be the candidates to lift into :core:.

Known limitation

expiresAt is always sent as null (no expiry) — there is no expiry picker. The field is plumbed
through the request DTO, so adding one later is UI-only. Sending costs one extra GET /api/user per
invite; a cached customerStatus would remove it, but no such cache exists today.

Adds the email-invite flow to the document access sheet, alongside the
existing viewer/editor/admin collaborators rather than replacing them:

- POST/GET /api/documents/{id}/invites and DELETE .../invites/{token}
  plumbed through DocumentsRepository as sendInvite/getInvites/revokeInvite.
- An "Invite by email" form (address + Viewer/Editor/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.
- 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 the web app's Viewer / Editor / Admin labels, and the
create response's missing token is recovered from the returned landing URL
so a just-sent invite can be revoked.

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

Closes #59

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Adron
Adron merged commit 5516d79 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