diff --git a/people.go b/people.go index 98a30c0dba2bf8d526b79a4e043925fe9ad29bab..7032d2399bcf1893fa982cc6c07dd63aa9140e6e 100644 --- a/people.go +++ b/people.go @@ -67,6 +67,44 @@ func (r createPersonRequest) MarshalJSON() ([]byte, error) { return data, nil } +// updatePersonRequest specifies which fields to change on a person. +type updatePersonRequest struct { + FirstName *string `json:"first_name,omitempty"` + LastName *string `json:"last_name,omitempty"` + RelationshipStrength *RelationshipStrength `json:"relationship_strength,omitempty"` + CustomFields map[string]any `json:"-"` +} + +// MarshalJSON flattens CustomFields to top-level JSON fields. +func (r updatePersonRequest) MarshalJSON() ([]byte, error) { + // Alias to avoid infinite recursion + type plain updatePersonRequest + + base, err := json.Marshal(plain(r)) + if err != nil { + return nil, fmt.Errorf("marshaling person update request: %w", err) + } + + if len(r.CustomFields) == 0 { + return base, nil + } + + // Merge custom fields into the base object + var merged map[string]any + if err := json.Unmarshal(base, &merged); err != nil { + return nil, fmt.Errorf("unmarshaling person update request for merge: %w", err) + } + + maps.Copy(merged, r.CustomFields) + + data, err := json.Marshal(merged) + if err != nil { + return nil, fmt.Errorf("marshaling merged person update request: %w", err) + } + + return data, nil +} + // personResponse wraps a single person from the API. type personResponse struct { Person Person `json:"person"` @@ -164,3 +202,60 @@ func (b *PersonBuilder) WithCustomField(key string, value any) *PersonBuilder { func (b *PersonBuilder) Create(ctx context.Context) (*Person, error) { return create(ctx, b.client, "/people", b.req, func(r personResponse) Person { return r.Person }) } + +// PersonUpdateBuilder constructs and updates a person via method chaining. +// Only fields you set will be modified; others remain unchanged. +// +// person, err := client.NewPersonUpdate(personID). +// WithRelationshipStrength(lunatask.RelationshipCloseFriend). +// Update(ctx) +type PersonUpdateBuilder struct { + client *Client + personID string + req updatePersonRequest +} + +// NewPersonUpdate starts building a person update for the given person ID. +func (c *Client) NewPersonUpdate(personID string) *PersonUpdateBuilder { + return &PersonUpdateBuilder{client: c, personID: personID} +} + +// FirstName changes the person's first name. +func (b *PersonUpdateBuilder) FirstName(firstName string) *PersonUpdateBuilder { + b.req.FirstName = &firstName + + return b +} + +// LastName changes the person's last name. +func (b *PersonUpdateBuilder) LastName(lastName string) *PersonUpdateBuilder { + b.req.LastName = &lastName + + return b +} + +// WithRelationshipStrength categorizes the closeness of the relationship. +// Use one of the Relationship* constants (e.g., [RelationshipCloseFriend]). +func (b *PersonUpdateBuilder) WithRelationshipStrength(strength RelationshipStrength) *PersonUpdateBuilder { + b.req.RelationshipStrength = &strength + + return b +} + +// WithCustomField sets an arbitrary custom field. Lunatask supports "email", +// "birthday", and "phone" out of the box; other fields must first be defined +// in the app or [ErrUnprocessableEntity] is returned. +func (b *PersonUpdateBuilder) WithCustomField(key string, value any) *PersonUpdateBuilder { + if b.req.CustomFields == nil { + b.req.CustomFields = make(map[string]any) + } + + b.req.CustomFields[key] = value + + return b +} + +// Update sends the changes to Lunatask. +func (b *PersonUpdateBuilder) Update(ctx context.Context) (*Person, error) { + return update(ctx, b.client, "/people", b.personID, "person", b.req, func(r personResponse) Person { return r.Person }) +} diff --git a/people_test.go b/people_test.go index fa78580d89daecbbea9da0de7911a0bcaa12d411..43f32c327499cf8eec55dd9fe2371ed7265b6002 100644 --- a/people_test.go +++ b/people_test.go @@ -213,6 +213,76 @@ func TestCreatePerson_Errors(t *testing.T) { }) } +// --- UpdatePerson --- + +func TestUpdatePerson_Success(t *testing.T) { + t.Parallel() + + server, capture := newPUTServer(t, "/people/"+personID, singlePersonResponseBody) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + + person, err := client.NewPersonUpdate(personID). + FirstName("Jane"). + LastName("Smith"). + WithRelationshipStrength(lunatask.RelationshipCloseFriend). + Update(ctx()) + if err != nil { + t.Fatalf("error = %v", err) + } + + if person == nil { + t.Fatal("returned nil") + } + + if person.ID != personID { + t.Errorf("ID = %q, want %q", person.ID, personID) + } + + assertBodyField(t, capture.Body, "first_name", "Jane") + assertBodyField(t, capture.Body, "last_name", "Smith") + assertBodyField(t, capture.Body, "relationship_strength", "close-friends") +} + +func TestUpdatePerson_AllFields(t *testing.T) { + t.Parallel() + + server, capture := newPUTServer(t, "/people/"+personID, singlePersonResponseBody) + defer server.Close() + + client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL)) + + _, err := client.NewPersonUpdate(personID). + FirstName("Ada"). + LastName("Lovelace"). + WithRelationshipStrength(lunatask.RelationshipFamily). + WithCustomField("email", "ada@example.com"). + WithCustomField("phone", "+1234567890"). + WithCustomField("birthday", "1815-12-10"). + Update(ctx()) + if err != nil { + t.Fatalf("error = %v", err) + } + + assertBodyField(t, capture.Body, "first_name", "Ada") + assertBodyField(t, capture.Body, "last_name", "Lovelace") + assertBodyField(t, capture.Body, "relationship_strength", "family") + assertBodyField(t, capture.Body, "email", "ada@example.com") + assertBodyField(t, capture.Body, "phone", "+1234567890") + assertBodyField(t, capture.Body, "birthday", "1815-12-10") +} + +func TestUpdatePerson_Errors(t *testing.T) { + t.Parallel() + + testErrorCases(t, func(c *lunatask.Client) error { + _, err := c.NewPersonUpdate(personID).FirstName("x").Update(ctx()) + + return err //nolint:wrapcheck // test helper + }) +} + // --- DeletePerson --- func TestDeletePerson_Success(t *testing.T) {