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 := lunatask.NewPerson().
117// WithFirstName("Ada").
118// WithLastName("Lovelace").
119// WithRelationshipStrength(lunatask.RelationshipCloseFriend).
120// Create(ctx, client)
121type PersonBuilder struct {
122 req createPersonRequest
123}
124
125// NewPerson starts building a person entry.
126func NewPerson() *PersonBuilder {
127 return &PersonBuilder{} //nolint:exhaustruct
128}
129
130// WithFirstName sets the person's first name.
131func (b *PersonBuilder) WithFirstName(name string) *PersonBuilder {
132 b.req.FirstName = &name
133
134 return b
135}
136
137// WithLastName sets the person's last name.
138func (b *PersonBuilder) WithLastName(name string) *PersonBuilder {
139 b.req.LastName = &name
140
141 return b
142}
143
144// WithRelationshipStrength categorizes the closeness of the relationship.
145// Use one of the Relationship* constants (e.g., [RelationshipCloseFriend]).
146func (b *PersonBuilder) WithRelationshipStrength(strength RelationshipStrength) *PersonBuilder {
147 b.req.RelationshipStrength = &strength
148
149 return b
150}
151
152// FromSource tags the person with a free-form origin identifier, useful for
153// tracking entries created by scripts or external integrations.
154func (b *PersonBuilder) FromSource(source, sourceID string) *PersonBuilder {
155 b.req.Source = &source
156 b.req.SourceID = &sourceID
157
158 return b
159}
160
161// WithCustomField sets an arbitrary custom field. Lunatask supports "email",
162// "birthday", and "phone" out of the box; other fields must first be defined
163// in the app or [ErrUnprocessableEntity] is returned.
164func (b *PersonBuilder) WithCustomField(key string, value any) *PersonBuilder {
165 if b.req.CustomFields == nil {
166 b.req.CustomFields = make(map[string]any)
167 }
168
169 b.req.CustomFields[key] = value
170
171 return b
172}
173
174// Create sends the person to Lunatask. Returns (nil, nil) if a duplicate exists
175// with matching source/source_id.
176func (b *PersonBuilder) Create(ctx context.Context, c *Client) (*Person, error) {
177 return create(ctx, c, "/people", b.req, func(r personResponse) Person { return r.Person })
178}