people.go

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: AGPL-3.0-or-later
  4
  5package lunatask
  6
  7import (
  8	"context"
  9	"encoding/json"
 10	"fmt"
 11	"maps"
 12	"time"
 13)
 14
 15// Person is a contact in Lunatask's relationship tracker.
 16// FirstName and LastName are encrypted client-side and will be null when read.
 17type Person struct {
 18	ID                   string                `json:"id"`
 19	RelationshipStrength *RelationshipStrength `json:"relationship_strength"`
 20	Sources              []Source              `json:"sources"`
 21	CreatedAt            time.Time             `json:"created_at"`
 22	UpdatedAt            time.Time             `json:"updated_at"`
 23}
 24
 25// createPersonRequest defines a new person for JSON serialization.
 26//
 27// CustomFields allows setting arbitrary custom fields. Lunatask supports
 28// "email", "birthday", and "phone" out of the box; other fields must first be
 29// defined in the app or [ErrUnprocessableEntity] is returned. These are
 30// flattened to top-level JSON fields when marshaled.
 31type createPersonRequest struct {
 32	FirstName            *string               `json:"first_name,omitempty"`
 33	LastName             *string               `json:"last_name,omitempty"`
 34	RelationshipStrength *RelationshipStrength `json:"relationship_strength,omitempty"`
 35	Source               *string               `json:"source,omitempty"`
 36	SourceID             *string               `json:"source_id,omitempty"`
 37	CustomFields         map[string]any        `json:"-"`
 38}
 39
 40// MarshalJSON flattens CustomFields to top-level JSON fields.
 41func (r createPersonRequest) MarshalJSON() ([]byte, error) {
 42	// Alias to avoid infinite recursion
 43	type plain createPersonRequest
 44
 45	base, err := json.Marshal(plain(r))
 46	if err != nil {
 47		return nil, fmt.Errorf("marshaling person request: %w", err)
 48	}
 49
 50	if len(r.CustomFields) == 0 {
 51		return base, nil
 52	}
 53
 54	// Merge custom fields into the base object
 55	var merged map[string]any
 56	if err := json.Unmarshal(base, &merged); err != nil {
 57		return nil, fmt.Errorf("unmarshaling person request for merge: %w", err)
 58	}
 59
 60	maps.Copy(merged, r.CustomFields)
 61
 62	data, err := json.Marshal(merged)
 63	if err != nil {
 64		return nil, fmt.Errorf("marshaling merged person request: %w", err)
 65	}
 66
 67	return data, nil
 68}
 69
 70// updatePersonRequest specifies which fields to change on a person.
 71type updatePersonRequest struct {
 72	FirstName            *string               `json:"first_name,omitempty"`
 73	LastName             *string               `json:"last_name,omitempty"`
 74	RelationshipStrength *RelationshipStrength `json:"relationship_strength,omitempty"`
 75	CustomFields         map[string]any        `json:"-"`
 76}
 77
 78// MarshalJSON flattens CustomFields to top-level JSON fields.
 79func (r updatePersonRequest) MarshalJSON() ([]byte, error) {
 80	// Alias to avoid infinite recursion
 81	type plain updatePersonRequest
 82
 83	base, err := json.Marshal(plain(r))
 84	if err != nil {
 85		return nil, fmt.Errorf("marshaling person update request: %w", err)
 86	}
 87
 88	if len(r.CustomFields) == 0 {
 89		return base, nil
 90	}
 91
 92	// Merge custom fields into the base object
 93	var merged map[string]any
 94	if err := json.Unmarshal(base, &merged); err != nil {
 95		return nil, fmt.Errorf("unmarshaling person update request for merge: %w", err)
 96	}
 97
 98	maps.Copy(merged, r.CustomFields)
 99
100	data, err := json.Marshal(merged)
101	if err != nil {
102		return nil, fmt.Errorf("marshaling merged person update request: %w", err)
103	}
104
105	return data, nil
106}
107
108// personResponse wraps a single person from the API.
109type personResponse struct {
110	Person Person `json:"person"`
111}
112
113// peopleResponse wraps a list of people from the API.
114type peopleResponse struct {
115	People []Person `json:"people"`
116}
117
118// ListPeopleOptions filters people by source integration.
119type ListPeopleOptions struct {
120	Source   *string
121	SourceID *string
122}
123
124// GetSource implements [SourceFilter].
125func (o *ListPeopleOptions) GetSource() *string { return o.Source }
126
127// GetSourceID implements [SourceFilter].
128func (o *ListPeopleOptions) GetSourceID() *string { return o.SourceID }
129
130// ListPeople returns all people, optionally filtered. Pass nil for all.
131func (c *Client) ListPeople(ctx context.Context, opts *ListPeopleOptions) ([]Person, error) {
132	var filter SourceFilter
133	if opts != nil {
134		filter = opts
135	}
136
137	return list(ctx, c, "/people", filter, func(r peopleResponse) []Person { return r.People })
138}
139
140// GetPerson fetches a person by ID. Name fields will be null (E2EE).
141func (c *Client) GetPerson(ctx context.Context, personID string) (*Person, error) {
142	return get(ctx, c, "/people", personID, "person", func(r personResponse) Person { return r.Person })
143}
144
145// DeletePerson removes a person and returns their final state.
146func (c *Client) DeletePerson(ctx context.Context, personID string) (*Person, error) {
147	return del(ctx, c, "/people", personID, "person", func(r personResponse) Person { return r.Person })
148}
149
150// PersonBuilder constructs and creates a person via method chaining.
151// Name fields are encrypted client-side; the API accepts them on create but
152// returns null on read.
153//
154//	person, err := client.NewPerson("Ada", "Lovelace").
155//		WithRelationshipStrength(lunatask.RelationshipCloseFriend).
156//		Create(ctx)
157type PersonBuilder struct {
158	client *Client
159	req    createPersonRequest
160}
161
162// NewPerson starts building a person entry with the given name.
163func (c *Client) NewPerson(firstName, lastName string) *PersonBuilder {
164	return &PersonBuilder{
165		client: c,
166		req:    createPersonRequest{FirstName: &firstName, LastName: &lastName},
167	}
168}
169
170// WithRelationshipStrength categorizes the closeness of the relationship.
171// Use one of the Relationship* constants (e.g., [RelationshipCloseFriend]).
172func (b *PersonBuilder) WithRelationshipStrength(strength RelationshipStrength) *PersonBuilder {
173	b.req.RelationshipStrength = &strength
174
175	return b
176}
177
178// FromSource tags the person with a free-form origin identifier, useful for
179// tracking entries created by scripts or external integrations.
180func (b *PersonBuilder) FromSource(source, sourceID string) *PersonBuilder {
181	b.req.Source = &source
182	b.req.SourceID = &sourceID
183
184	return b
185}
186
187// WithCustomField sets an arbitrary custom field. Lunatask supports "email",
188// "birthday", and "phone" out of the box; other fields must first be defined
189// in the app or [ErrUnprocessableEntity] is returned.
190func (b *PersonBuilder) WithCustomField(key string, value any) *PersonBuilder {
191	if b.req.CustomFields == nil {
192		b.req.CustomFields = make(map[string]any)
193	}
194
195	b.req.CustomFields[key] = value
196
197	return b
198}
199
200// Create sends the person to Lunatask.
201// Returns (nil, nil) if a duplicate exists with matching source/source_id.
202func (b *PersonBuilder) Create(ctx context.Context) (*Person, error) {
203	return create(ctx, b.client, "/people", b.req, func(r personResponse) Person { return r.Person })
204}
205
206// PersonUpdateBuilder constructs and updates a person via method chaining.
207// Only fields you set will be modified; others remain unchanged.
208//
209//	person, err := client.NewPersonUpdate(personID).
210//		WithRelationshipStrength(lunatask.RelationshipCloseFriend).
211//		Update(ctx)
212type PersonUpdateBuilder struct {
213	client   *Client
214	personID string
215	req      updatePersonRequest
216}
217
218// NewPersonUpdate starts building a person update for the given person ID.
219func (c *Client) NewPersonUpdate(personID string) *PersonUpdateBuilder {
220	return &PersonUpdateBuilder{client: c, personID: personID}
221}
222
223// FirstName changes the person's first name.
224func (b *PersonUpdateBuilder) FirstName(firstName string) *PersonUpdateBuilder {
225	b.req.FirstName = &firstName
226
227	return b
228}
229
230// LastName changes the person's last name.
231func (b *PersonUpdateBuilder) LastName(lastName string) *PersonUpdateBuilder {
232	b.req.LastName = &lastName
233
234	return b
235}
236
237// WithRelationshipStrength categorizes the closeness of the relationship.
238// Use one of the Relationship* constants (e.g., [RelationshipCloseFriend]).
239func (b *PersonUpdateBuilder) WithRelationshipStrength(strength RelationshipStrength) *PersonUpdateBuilder {
240	b.req.RelationshipStrength = &strength
241
242	return b
243}
244
245// WithCustomField sets an arbitrary custom field. Lunatask supports "email",
246// "birthday", and "phone" out of the box; other fields must first be defined
247// in the app or [ErrUnprocessableEntity] is returned.
248func (b *PersonUpdateBuilder) WithCustomField(key string, value any) *PersonUpdateBuilder {
249	if b.req.CustomFields == nil {
250		b.req.CustomFields = make(map[string]any)
251	}
252
253	b.req.CustomFields[key] = value
254
255	return b
256}
257
258// Update sends the changes to Lunatask.
259func (b *PersonUpdateBuilder) Update(ctx context.Context) (*Person, error) {
260	return update(ctx, b.client, "/people", b.personID, "person", b.req, func(r personResponse) Person { return r.Person })
261}