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// personResponse wraps a single person from the API.
 71type personResponse struct {
 72	Person Person `json:"person"`
 73}
 74
 75// peopleResponse wraps a list of people from the API.
 76type peopleResponse struct {
 77	People []Person `json:"people"`
 78}
 79
 80// ListPeopleOptions filters people by source integration.
 81type ListPeopleOptions struct {
 82	Source   *string
 83	SourceID *string
 84}
 85
 86// GetSource implements [SourceFilter].
 87func (o *ListPeopleOptions) GetSource() *string { return o.Source }
 88
 89// GetSourceID implements [SourceFilter].
 90func (o *ListPeopleOptions) GetSourceID() *string { return o.SourceID }
 91
 92// ListPeople returns all people, optionally filtered. Pass nil for all.
 93func (c *Client) ListPeople(ctx context.Context, opts *ListPeopleOptions) ([]Person, error) {
 94	var filter SourceFilter
 95	if opts != nil {
 96		filter = opts
 97	}
 98
 99	return list(ctx, c, "/people", filter, func(r peopleResponse) []Person { return r.People })
100}
101
102// GetPerson fetches a person by ID. Name fields will be null (E2EE).
103func (c *Client) GetPerson(ctx context.Context, personID string) (*Person, error) {
104	return get(ctx, c, "/people", personID, "person", func(r personResponse) Person { return r.Person })
105}
106
107// DeletePerson removes a person and returns their final state.
108func (c *Client) DeletePerson(ctx context.Context, personID string) (*Person, error) {
109	return del(ctx, c, "/people", personID, "person", func(r personResponse) Person { return r.Person })
110}
111
112// PersonBuilder constructs and creates a person via method chaining.
113// Name fields are encrypted client-side; the API accepts them on create but
114// returns null on read.
115//
116//	person, err := client.NewPerson("Ada", "Lovelace").
117//		WithRelationshipStrength(lunatask.RelationshipCloseFriend).
118//		Create(ctx)
119type PersonBuilder struct {
120	client *Client
121	req    createPersonRequest
122}
123
124// NewPerson starts building a person entry with the given name.
125func (c *Client) NewPerson(firstName, lastName string) *PersonBuilder {
126	return &PersonBuilder{
127		client: c,
128		req:    createPersonRequest{FirstName: &firstName, LastName: &lastName},
129	}
130}
131
132// WithRelationshipStrength categorizes the closeness of the relationship.
133// Use one of the Relationship* constants (e.g., [RelationshipCloseFriend]).
134func (b *PersonBuilder) WithRelationshipStrength(strength RelationshipStrength) *PersonBuilder {
135	b.req.RelationshipStrength = &strength
136
137	return b
138}
139
140// FromSource tags the person with a free-form origin identifier, useful for
141// tracking entries created by scripts or external integrations.
142func (b *PersonBuilder) FromSource(source, sourceID string) *PersonBuilder {
143	b.req.Source = &source
144	b.req.SourceID = &sourceID
145
146	return b
147}
148
149// WithCustomField sets an arbitrary custom field. Lunatask supports "email",
150// "birthday", and "phone" out of the box; other fields must first be defined
151// in the app or [ErrUnprocessableEntity] is returned.
152func (b *PersonBuilder) WithCustomField(key string, value any) *PersonBuilder {
153	if b.req.CustomFields == nil {
154		b.req.CustomFields = make(map[string]any)
155	}
156
157	b.req.CustomFields[key] = value
158
159	return b
160}
161
162// Create sends the person to Lunatask.
163// Returns (nil, nil) if a duplicate exists with matching source/source_id.
164func (b *PersonBuilder) Create(ctx context.Context) (*Person, error) {
165	return create(ctx, b.client, "/people", b.req, func(r personResponse) Person { return r.Person })
166}