Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .beads/issues.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
35 changes: 30 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 ...] }
Expand All @@ -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

Expand Down
1 change: 1 addition & 0 deletions lib/nationbuilder_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions lib/nationbuilder_api/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions lib/nationbuilder_api/resources/people.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
#
Expand All @@ -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
#
Expand Down
21 changes: 21 additions & 0 deletions lib/nationbuilder_api/resources/tags.rb
Original file line number Diff line number Diff line change
@@ -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
89 changes: 89 additions & 0 deletions spec/nationbuilder_api/resources/people_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
36 changes: 36 additions & 0 deletions spec/nationbuilder_api/resources/tags_spec.rb
Original file line number Diff line number Diff line change
@@ -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