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 7362f87..7f8347d 100644 --- a/InterlinedList/Views/DocumentsView.xaml +++ b/InterlinedList/Views/DocumentsView.xaml @@ -434,6 +434,10 @@ Margin="20,0,20,20" Padding="16,12"> + + +