diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index b74d77b..5d227b7 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -14,3 +14,4 @@ {"id":"nb-7","title":"People Resource - Full CRUD","description":"Implement full CRUD operations for People endpoint including list with filtering/pagination, create, show, update, delete, and search functionality with proper parameter handling","design":"Create lib/nationbuilder_api/resources/people.rb with methods: list, create, show, update, delete, search. Follow REST conventions. See roadmap item #14.","status":"open","priority":2,"issue_type":"feature","created_at":"2025-12-02T17:09:33.096076+07:00","updated_at":"2025-12-02T17:26:17.185816+07:00","dependencies":[{"issue_id":"nb-7","depends_on_id":"nb-5","type":"blocks","created_at":"2025-12-02T17:12:11.989747+07:00","created_by":"daemon"}]} {"id":"nb-8","title":"Fix OAuth VCR Test Failures","description":"Fix 5 failing OAuth integration tests due to VCR cassette configuration issues. Tests in oauth_flow_spec.rb and client_spec.rb fail with 'VCR does not know how to handle' errors.","design":"Review VCR cassette configuration, regenerate cassettes if needed, ensure Net::HTTP compatibility. See verification report from 2025-11-25-switch-to-net-http spec.","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-12-02T17:09:18.00717+07:00","updated_at":"2025-12-02T17:26:17.186112+07:00","closed_at":"2025-12-02T17:21:53.298386+07:00"} {"id":"nb-9","title":"Add Rate Limit Monitoring","description":"Log rate limit headers (X-RateLimit-Remaining, X-RateLimit-Reset) for proactive rate limit monitoring and debugging","design":"Add rate limit header logging in http_client.rb after response received. Log at info level. See roadmap item #10.","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-02T17:09:10.928534+07:00","updated_at":"2025-12-02T17:26:17.186369+07:00"} +{"id":"nb-f0q","title":"V1 API support for people taggings and tags","description":"Add V1 API methods for taggings and tags because V2 API doesn't return tag names.\n\nRequired methods:\n- client.people.taggings(person_id) - GET /api/v1/people/:person_id/taggings\n- client.people.add_tagging(person_id, tag_name) - PUT /api/v1/people/:person_id/taggings\n- client.people.remove_tagging(person_id, tag_name) - DELETE /api/v1/people/:person_id/taggings/:tag\n- client.tags.list - GET /api/v1/tags\n\nV1 API uses plain JSON format (not JSON:API), so response handling will differ from V2 endpoints.\n\nThis will allow Citizen app to replace hardcoded API calls with proper gem methods.","design":"Create or extend lib/nationbuilder_api/resources/people.rb to add V1 tagging methods. Create lib/nationbuilder_api/resources/tags.rb for V1 tags list endpoint. Handle plain JSON responses (not JSON:API format). Add integration tests verifying V1 endpoints work correctly.","status":"in_progress","priority":1,"issue_type":"feature","assignee":"claude","created_at":"2026-01-29T11:44:00.390272+07:00","updated_at":"2026-01-29T11:48:37.805016+07:00","external_ref":"https://github.com/ebrett/nationbuilder-client-v2/issues/11"} diff --git a/README.md b/README.md index 56c058d..1f1a150 100644 --- a/README.md +++ b/README.md @@ -139,22 +139,35 @@ client.delete("/api/v1/people/#{person[:id]}") ### People Resource -The People resource provides convenient methods for working with NationBuilder people data using the V2 API with JSON:API format: +The People resource provides convenient methods for working with NationBuilder people data: ```ruby # Fetch person details (V2 API - JSON:API format) person = client.people.show(123) # => { data: { type: "person", id: "123", attributes: { first_name: "John", ... } } } -# Fetch person with taggings sideloaded +# Fetch person with taggings sideloaded (V2 API - JSON:API format) person_with_tags = client.people.show(123, include_taggings: true) # => { data: { ... }, included: [{ type: "tagging", ... }] } # Get person's taggings/subscriptions (V2 API - JSON:API format) -# This is a convenience method that calls show() with include_taggings: true +# Note: V2 API returns tagging IDs but not tag names taggings = client.people.taggings(123) # => { data: { ... }, included: [{ type: "tagging", ... }] } +# Get person's taggings with tag names (V1 API - plain JSON format) +# Use this when you need tag names (V2 API only returns IDs) +taggings_with_names = client.people.list_taggings(123) +# => { taggings: [{ tag: "volunteer", person_id: 123 }, { tag: "donor", person_id: 123 }] } + +# Add a tag to a person (V1 API) +client.people.add_tagging(123, "volunteer") +# => { tagging: { tag: "volunteer", person_id: 123 } } + +# Remove a tag from a person (V1 API) +client.people.remove_tagging(123, "volunteer") +# => { status: "deleted" } + # Get person's event RSVPs (V2 API - JSON:API format) rsvps = client.people.rsvps(123) # => { data: [...], included: [... event details ...] } @@ -163,13 +176,25 @@ rsvps = client.people.rsvps(123) rsvps = client.people.rsvps(123, include_event: false) # => { data: [...] } -# Get person's recent activities (V1 API - will migrate to V2 when available) +# Get person's recent activities (V1 API) # Note: This endpoint may not be available on all NationBuilder accounts activities = client.people.activities(123) # => { results: [{ type: "email_sent", created_at: "...", ... }] } ``` -**Note**: The People resource uses the V2 API by default, which returns data in JSON:API format. Only the `activities()` method still uses V1 as the V2 equivalent is not yet available. +**Note**: The People resource primarily uses the V2 API with JSON:API format. However, tag management uses the V1 API because the V2 API does not include tag names or provide tag management endpoints. + +### Tags Resource + +The Tags resource provides access to tag data and management: + +```ruby +# List all tags (V1 API - plain JSON format) +tags = client.tags.list +# => { results: [{ name: "volunteer", path: "/tags/volunteer" }, { name: "donor", ... }] } +``` + +**Note**: Tag management uses the V1 API because the V2 API does not provide tag management endpoints. ## Configuration Options diff --git a/lib/nationbuilder_api.rb b/lib/nationbuilder_api.rb index 8b6ae3a..bf0b1ae 100644 --- a/lib/nationbuilder_api.rb +++ b/lib/nationbuilder_api.rb @@ -22,6 +22,7 @@ module TokenStorage module Resources autoload :Base, "nationbuilder_api/resources/base" autoload :People, "nationbuilder_api/resources/people" + autoload :Tags, "nationbuilder_api/resources/tags" end # OAuth scope constants diff --git a/lib/nationbuilder_api/client.rb b/lib/nationbuilder_api/client.rb index 5b9424b..6517204 100644 --- a/lib/nationbuilder_api/client.rb +++ b/lib/nationbuilder_api/client.rb @@ -151,6 +151,16 @@ def people @people ||= Resources::People.new(self) end + # Access Tags resource + # + # @return [Resources::Tags] Tags resource instance + # + # @example + # client.tags.list + def tags + @tags ||= Resources::Tags.new(self) + end + private # Extract OAuth base URL from API base URL diff --git a/lib/nationbuilder_api/resources/people.rb b/lib/nationbuilder_api/resources/people.rb index 6fce4a4..cd6899b 100644 --- a/lib/nationbuilder_api/resources/people.rb +++ b/lib/nationbuilder_api/resources/people.rb @@ -33,6 +33,9 @@ def show(id, include_taggings: false) # Fetch a person's taggings (subscriptions/lists) # Uses V2 API with JSON:API format via sideloading on the person endpoint # + # Note: V2 API returns tagging IDs but does not include tag names. + # For tag names and tag management, use list_taggings, add_tagging, and remove_tagging (V1 API). + # # @param id [String, Integer] Person ID # @return [Hash] Person data with taggings in JSON:API format # @@ -43,6 +46,45 @@ def taggings(id) show(id, include_taggings: true) end + # List a person's taggings with tag names + # Uses V1 API which returns tag names (unlike V2 API which only returns IDs) + # + # @param id [String, Integer] Person ID + # @return [Hash] Taggings data with tag names in V1 format + # + # @example + # client.people.list_taggings(123) + # # => { taggings: [{ tag: "volunteer", person_id: 123 }, { tag: "donor", person_id: 123 }] } + def list_taggings(id) + get("/api/v1/people/#{id}/taggings") + end + + # Add a tag to a person + # Uses V1 API for tag management + # + # @param id [String, Integer] Person ID + # @param tag_name [String] Tag name to add + # @return [Hash] Response from API + # + # @example + # client.people.add_tagging(123, "volunteer") + def add_tagging(id, tag_name) + put("/api/v1/people/#{id}/taggings", body: {tagging: {tag: tag_name}}) + end + + # Remove a tag from a person + # Uses V1 API for tag management + # + # @param id [String, Integer] Person ID + # @param tag_name [String] Tag name to remove + # @return [Hash] Response from API + # + # @example + # client.people.remove_tagging(123, "volunteer") + def remove_tagging(id, tag_name) + delete("/api/v1/people/#{id}/taggings/#{tag_name}") + end + # Fetch a person's event RSVPs # Uses V2 API with JSON:API format and filters by person_id # diff --git a/lib/nationbuilder_api/resources/tags.rb b/lib/nationbuilder_api/resources/tags.rb new file mode 100644 index 0000000..96e0b75 --- /dev/null +++ b/lib/nationbuilder_api/resources/tags.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module NationbuilderApi + module Resources + # Tags API resource + # Provides access to NationBuilder Tags endpoints for managing tags + class Tags < Base + # List all tags + # Uses V1 API as V2 API does not have tag management endpoints + # + # @return [Hash] Tags data in V1 format + # + # @example + # client.tags.list + # # => { results: [{ name: "volunteer", ... }, { name: "donor", ... }] } + def list + get("/api/v1/tags") + end + end + end +end diff --git a/spec/nationbuilder_api/resources/people_spec.rb b/spec/nationbuilder_api/resources/people_spec.rb index d26fb7d..04115ca 100644 --- a/spec/nationbuilder_api/resources/people_spec.rb +++ b/spec/nationbuilder_api/resources/people_spec.rb @@ -345,4 +345,93 @@ people.rsvps("abc123") end end + + describe "#list_taggings" do + it "makes GET request to /api/v1/people/:id/taggings" do + expect(client).to receive(:get).with("/api/v1/people/123/taggings", params: {}) + people.list_taggings(123) + end + + it "returns taggings data with tag names in V1 format" do + taggings_data = { + taggings: [ + {tag: "volunteer", person_id: 123}, + {tag: "donor", person_id: 123} + ] + } + + allow(client).to receive(:get).and_return(taggings_data) + result = people.list_taggings(123) + + expect(result).to eq(taggings_data) + expect(result[:taggings].length).to eq(2) + expect(result[:taggings].first[:tag]).to eq("volunteer") + end + + it "accepts string ID" do + expect(client).to receive(:get).with("/api/v1/people/456/taggings", params: {}) + people.list_taggings("456") + end + end + + describe "#add_tagging" do + it "makes PUT request to /api/v1/people/:id/taggings" do + expected_body = {tagging: {tag: "volunteer"}} + expect(client).to receive(:put) + .with("/api/v1/people/123/taggings", body: expected_body) + + people.add_tagging(123, "volunteer") + end + + it "accepts string ID" do + expected_body = {tagging: {tag: "donor"}} + expect(client).to receive(:put) + .with("/api/v1/people/456/taggings", body: expected_body) + + people.add_tagging("456", "donor") + end + + it "returns response from API" do + response_data = { + tagging: {tag: "volunteer", person_id: 123} + } + + allow(client).to receive(:put).and_return(response_data) + result = people.add_tagging(123, "volunteer") + + expect(result).to eq(response_data) + end + end + + describe "#remove_tagging" do + it "makes DELETE request to /api/v1/people/:id/taggings/:tag" do + expect(client).to receive(:delete) + .with("/api/v1/people/123/taggings/volunteer") + + people.remove_tagging(123, "volunteer") + end + + it "accepts string ID" do + expect(client).to receive(:delete) + .with("/api/v1/people/456/taggings/donor") + + people.remove_tagging("456", "donor") + end + + it "URL encodes tag name with spaces" do + expect(client).to receive(:delete) + .with("/api/v1/people/123/taggings/needs follow-up") + + people.remove_tagging(123, "needs follow-up") + end + + it "returns response from API" do + response_data = {status: "deleted"} + + allow(client).to receive(:delete).and_return(response_data) + result = people.remove_tagging(123, "volunteer") + + expect(result).to eq(response_data) + end + end end diff --git a/spec/nationbuilder_api/resources/tags_spec.rb b/spec/nationbuilder_api/resources/tags_spec.rb new file mode 100644 index 0000000..bf98be0 --- /dev/null +++ b/spec/nationbuilder_api/resources/tags_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +RSpec.describe NationbuilderApi::Resources::Tags do + let(:client) do + instance_double( + NationbuilderApi::Client, + get: nil + ) + end + + subject(:tags) { described_class.new(client) } + + describe "#list" do + it "makes GET request to /api/v1/tags" do + expect(client).to receive(:get).with("/api/v1/tags", params: {}) + tags.list + end + + it "returns tags data in V1 format" do + tags_data = { + results: [ + {name: "volunteer", path: "/tags/volunteer"}, + {name: "donor", path: "/tags/donor"}, + {name: "activist", path: "/tags/activist"} + ] + } + + allow(client).to receive(:get).and_return(tags_data) + result = tags.list + + expect(result).to eq(tags_data) + expect(result[:results].length).to eq(3) + expect(result[:results].first[:name]).to eq("volunteer") + end + end +end