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}