From dc3efee89d1e5e11828b3ffd7c0cd72a3d2ba6a2 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 16 Sep 2026 14:40:41 -0700 Subject: [PATCH] Documents: invite people by email from the Share card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hosts the invite panel built for #55 over /api/documents/{id}/invites, so both domains share one control instead of duplicating the flow — the sharing model is identical and the invite payloads are byte-identical, so EmailInvite and InvitePanel are reused as-is. Service lives in a new domain partial, Services/InterlinedApiClient.DocumentInvites.cs, rather than in .Documents.cs — partial-per-domain is this repo's convention and it keeps the contended file untouched. Shapes verified live 2026-09-16 against a throwaway document, then cleaned up: GET /api/documents/{id}/invites -> 200 {"invites":[{email,role, expiresAt,accepted,createdAt,token}]} POST /api/documents/{id}/invites -> 201 {email,role,expiresAt,url} DELETE /api/documents/{id}/invites/{token} -> 200 {"revoked":true} Role and expiresAt both round-trip (probed with role "manager" and a 7-day expiry). The one real difference from the list side is the ownership envelope: documents nest the payload under "document" where lists use "data", so GetDocumentOwnerUserIdAsync reads the other key. The panel leads the Share card rather than sitting under the share links, matching the web's "Share window leads with Invite people; Make public is secondary" ordering. Hosting it costs DocumentsView.xaml one line, and the InviteTargets factory one. Closes #56 Co-Authored-By: Claude Opus 5 --- .../Services/DocumentInviteTarget.cs | 38 +++++++++ InterlinedList/Services/IInviteTarget.cs | 3 +- .../InterlinedApiClient.DocumentInvites.cs | 84 +++++++++++++++++++ InterlinedList/Views/DocumentsView.xaml | 4 + 4 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 InterlinedList/Services/DocumentInviteTarget.cs create mode 100644 InterlinedList/Services/InterlinedApiClient.DocumentInvites.cs diff --git a/InterlinedList/Services/DocumentInviteTarget.cs b/InterlinedList/Services/DocumentInviteTarget.cs new file mode 100644 index 0000000..7f009cd --- /dev/null +++ b/InterlinedList/Services/DocumentInviteTarget.cs @@ -0,0 +1,38 @@ +using InterlinedList.Models; + +namespace InterlinedList.Services; + +/// +/// Binds the shared invite panel to one document's +/// /api/documents/{id}/invites routes. Mirror image of +/// — the sharing model is identical, so the panel +/// itself needs no document-specific branch. +/// +public sealed class DocumentInviteTarget : IInviteTarget +{ + private readonly InterlinedApiClient _api; + private readonly string _documentId; + + public DocumentInviteTarget(InterlinedApiClient api, string documentId) + { + _api = api; + _documentId = documentId; + } + + public string ResourceNoun => "document"; + + public Task> GetInvitesAsync(CancellationToken ct = default) + => _api.GetDocumentInvitesAsync(_documentId, ct); + + public Task CreateInviteAsync( + string email, string role, DateTimeOffset? expiresAt, CancellationToken ct = default) + => _api.CreateDocumentInviteAsync(_documentId, email, role, expiresAt, ct); + + public Task RevokeInviteAsync(string token, CancellationToken ct = default) + => _api.DeleteDocumentInviteAsync(_documentId, token, ct); + + public Task GetOwnerUserIdAsync(CancellationToken ct = default) + => _api.GetDocumentOwnerUserIdAsync(_documentId, ct); + + public string InviteUrlFor(string token) => $"{ApiConfig.BaseUrl}documents/invite/{token}"; +} diff --git a/InterlinedList/Services/IInviteTarget.cs b/InterlinedList/Services/IInviteTarget.cs index 6c8c52a..e9c312f 100644 --- a/InterlinedList/Services/IInviteTarget.cs +++ b/InterlinedList/Services/IInviteTarget.cs @@ -57,8 +57,7 @@ public static class InviteTargets return kind switch { InviteTargetKind.List => new ListInviteTarget(api, resourceId), - // Document invites land with issue #56 (same endpoints under - // /api/documents) and slot in here. + InviteTargetKind.Document => new DocumentInviteTarget(api, resourceId), _ => null, }; } diff --git a/InterlinedList/Services/InterlinedApiClient.DocumentInvites.cs b/InterlinedList/Services/InterlinedApiClient.DocumentInvites.cs new file mode 100644 index 0000000..02d6d96 --- /dev/null +++ b/InterlinedList/Services/InterlinedApiClient.DocumentInvites.cs @@ -0,0 +1,84 @@ +using System.Net.Http; +using System.Text.Json; +using InterlinedList.Models; + +namespace InterlinedList.Services; + +/// +/// Email invites to a document — the same sharing model as +/// InterlinedApiClient.ListInvites.cs, and the path the web Share window leads +/// with. Distinct from the share links and collaborators in +/// InterlinedApiClient.Documents.cs: a share link is a bearer capability and a +/// collaborator is an existing account, whereas an invite is bound to an email +/// address that may not have an account yet. +/// +/// All three routes are owner-only (a non-owner gets 404 — existence is never +/// leaked). Creating is subscriber-gated (403 for a free owner); listing and +/// revoking are not, so an owner whose subscription lapsed can always shut off +/// access they previously granted. +/// +/// Verified live 2026-09-16 against a throwaway document, and the payloads are +/// byte-identical to the list ones — hence the shared +/// wire type: +/// +/// GET /api/documents/{id}/invites → 200 { "invites": [ … ] } +/// POST /api/documents/{id}/invites → 201 { email, role, expiresAt, url } +/// DELETE /api/documents/{id}/invites/{token} → 200 { "revoked": true } +/// +/// +public sealed partial class InterlinedApiClient +{ + public async Task> GetDocumentInvitesAsync(string documentId, CancellationToken ct = default) + { + var json = await GetElementAsync($"api/documents/{documentId}/invites", ct); + return json.TryGetProperty("invites", out var arr) && arr.ValueKind == JsonValueKind.Array + ? arr.Deserialize>(JsonOptions) ?? new() + : new(); + } + + /// + /// Invite an email address to this document. Re-inviting the same address is + /// idempotent server-side: it re-issues a fresh token and resets the invite + /// to unclaimed. An invite email carrying the returned url is sent to the + /// address best-effort (fire-and-forget), so this is a real outbound email — + /// callers should not exercise it against addresses they don't own. + /// + /// The returned envelope IS live-verified, but it carries no token, so + /// callers still re-read afterwards to + /// get the token they need for . + /// + public Task CreateDocumentInviteAsync( + string documentId, + string email, + string role = ShareRoles.Viewer, + DateTimeOffset? expiresAt = null, + CancellationToken ct = default) + => SendJsonAsync(HttpMethod.Post, $"api/documents/{documentId}/invites", + new { email, role, expiresAt = expiresAt?.UtcDateTime }, ct); + + public Task DeleteDocumentInviteAsync(string documentId, string token, CancellationToken ct = default) + => SendVoidAsync(HttpMethod.Delete, + $"api/documents/{documentId}/invites/{Uri.EscapeDataString(token)}", null, ct); + + /// + /// The document owner's user id, read straight off the GET + /// /api/documents/{id} "document" envelope (verified live 2026-09-16 — the + /// payload carries userId, which does not + /// model). Only the true owner can manage sharing — not even a manager-role + /// collaborator can — so the share UI compares this against the signed-in + /// user rather than assuming the open document is owned. Returns null when + /// the field is missing. + /// + /// Note the envelope key differs from the list equivalent: documents nest + /// under "document", lists under "data". + /// + public async Task GetDocumentOwnerUserIdAsync(string documentId, CancellationToken ct = default) + { + var json = await GetElementAsync($"api/documents/{documentId}", ct); + return json.TryGetProperty("document", out var doc) + && doc.TryGetProperty("userId", out var userId) + && userId.ValueKind == JsonValueKind.String + ? userId.GetString() + : null; + } +} diff --git a/InterlinedList/Views/DocumentsView.xaml b/InterlinedList/Views/DocumentsView.xaml index 593ec5c..fa37537 100644 --- a/InterlinedList/Views/DocumentsView.xaml +++ b/InterlinedList/Views/DocumentsView.xaml @@ -434,6 +434,10 @@ Margin="20,0,20,20" Padding="16,12"> + + +