attio-ruby 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +5 -0
- data/README.md +57 -12
- data/examples/app_specific_typed_record.md +1613 -0
- data/examples/deals.rb +112 -0
- data/examples/oauth_flow.rb +26 -49
- data/examples/typed_records_example.rb +10 -7
- data/lib/attio/internal/record.rb +17 -8
- data/lib/attio/resources/company.rb +26 -24
- data/lib/attio/resources/deal.rb +288 -0
- data/lib/attio/resources/meta.rb +43 -12
- data/lib/attio/resources/object.rb +24 -4
- data/lib/attio/resources/person.rb +22 -18
- data/lib/attio/resources/typed_record.rb +49 -6
- data/lib/attio/resources/workspace_member.rb +17 -4
- data/lib/attio/version.rb +1 -1
- data/lib/attio.rb +1 -0
- metadata +4 -2
- data/attio-ruby.gemspec +0 -61
@@ -0,0 +1,1613 @@
|
|
1
|
+
# Creating App-Specific TypedRecord Classes
|
2
|
+
|
3
|
+
This guide demonstrates how to create custom record classes for your own Attio objects by inheriting from `Attio::TypedRecord`. This allows you to work with your custom objects using the same clean, object-oriented interface that the gem provides for People and Companies.
|
4
|
+
|
5
|
+
## Table of Contents
|
6
|
+
- [Overview](#overview)
|
7
|
+
- [Basic Structure](#basic-structure)
|
8
|
+
- [Common Scenario: Deal Records](#common-scenario-deal-records)
|
9
|
+
- [Naming Conventions](#naming-conventions)
|
10
|
+
- [Required Methods](#required-methods)
|
11
|
+
- [Optional Overrides](#optional-overrides)
|
12
|
+
- [Advanced Examples](#advanced-examples)
|
13
|
+
- [Best Practices](#best-practices)
|
14
|
+
- [Integration with Other Records](#integration-with-other-records)
|
15
|
+
|
16
|
+
## Overview
|
17
|
+
|
18
|
+
The `Attio::TypedRecord` class is designed to be extended for any custom object you've created in your Attio workspace. It provides:
|
19
|
+
|
20
|
+
- Automatic object type injection in all API calls
|
21
|
+
- Simplified CRUD operations
|
22
|
+
- Consistent interface matching Person and Company classes
|
23
|
+
- Built-in change tracking and persistence
|
24
|
+
- Support for custom attribute accessors and business logic
|
25
|
+
|
26
|
+
## Basic Structure
|
27
|
+
|
28
|
+
Every custom record class must:
|
29
|
+
|
30
|
+
1. Inherit from `Attio::TypedRecord`
|
31
|
+
2. Define the `object_type` (using your object's slug or UUID)
|
32
|
+
3. Optionally add convenience methods for attributes
|
33
|
+
4. Optionally override class methods for custom creation/search logic
|
34
|
+
|
35
|
+
```ruby
|
36
|
+
module Attio
|
37
|
+
class YourCustomRecord < TypedRecord
|
38
|
+
object_type "your_object_slug" # Required!
|
39
|
+
|
40
|
+
# Your custom methods here
|
41
|
+
end
|
42
|
+
end
|
43
|
+
```
|
44
|
+
|
45
|
+
## Common Scenario: Deal Records
|
46
|
+
|
47
|
+
Here's a complete implementation of a Deal record class showing all common patterns:
|
48
|
+
|
49
|
+
```ruby
|
50
|
+
# lib/attio/resources/deal.rb
|
51
|
+
# frozen_string_literal: true
|
52
|
+
|
53
|
+
require_relative "typed_record"
|
54
|
+
|
55
|
+
module Attio
|
56
|
+
# Represents a deal/opportunity record in Attio
|
57
|
+
# Provides convenient methods for working with sales pipeline data
|
58
|
+
class Deal < TypedRecord
|
59
|
+
# REQUIRED: Set the object type to match your Attio object
|
60
|
+
# This can be either a slug (like "deals") or a UUID
|
61
|
+
object_type "deals"
|
62
|
+
|
63
|
+
# ==========================================
|
64
|
+
# ATTRIBUTE ACCESSORS (following conventions)
|
65
|
+
# ==========================================
|
66
|
+
|
67
|
+
# Simple string attribute setter/getter
|
68
|
+
# Convention: Use simple assignment for single-value attributes
|
69
|
+
def name=(name)
|
70
|
+
self[:name] = name
|
71
|
+
end
|
72
|
+
|
73
|
+
def name
|
74
|
+
self[:name]
|
75
|
+
end
|
76
|
+
|
77
|
+
# Numeric attribute with type conversion
|
78
|
+
# Convention: Convert types when it makes sense
|
79
|
+
def amount=(value)
|
80
|
+
self[:amount] = value.to_f if value
|
81
|
+
end
|
82
|
+
|
83
|
+
def amount
|
84
|
+
self[:amount]
|
85
|
+
end
|
86
|
+
|
87
|
+
# Select/dropdown attribute
|
88
|
+
# Convention: Validate allowed values if known
|
89
|
+
VALID_STAGES = ["prospecting", "qualification", "proposal", "negotiation", "closed_won", "closed_lost"].freeze
|
90
|
+
|
91
|
+
def stage=(stage)
|
92
|
+
if stage && !VALID_STAGES.include?(stage.to_s)
|
93
|
+
raise ArgumentError, "Invalid stage: #{stage}. Must be one of: #{VALID_STAGES.join(', ')}"
|
94
|
+
end
|
95
|
+
self[:stage] = stage
|
96
|
+
end
|
97
|
+
|
98
|
+
def stage
|
99
|
+
self[:stage]
|
100
|
+
end
|
101
|
+
|
102
|
+
# Date attribute with parsing
|
103
|
+
# Convention: Accept multiple date formats for convenience
|
104
|
+
def close_date=(date)
|
105
|
+
case date
|
106
|
+
when String
|
107
|
+
self[:close_date] = Date.parse(date).iso8601
|
108
|
+
when Date
|
109
|
+
self[:close_date] = date.iso8601
|
110
|
+
when Time, DateTime
|
111
|
+
self[:close_date] = date.to_date.iso8601
|
112
|
+
when nil
|
113
|
+
self[:close_date] = nil
|
114
|
+
else
|
115
|
+
raise ArgumentError, "Close date must be a String, Date, Time, or DateTime"
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
def close_date
|
120
|
+
date_str = self[:close_date]
|
121
|
+
Date.parse(date_str) if date_str
|
122
|
+
end
|
123
|
+
|
124
|
+
# Percentage attribute with validation
|
125
|
+
# Convention: Validate business rules
|
126
|
+
def probability=(value)
|
127
|
+
prob = value.to_f
|
128
|
+
if prob < 0 || prob > 100
|
129
|
+
raise ArgumentError, "Probability must be between 0 and 100"
|
130
|
+
end
|
131
|
+
self[:probability] = prob
|
132
|
+
end
|
133
|
+
|
134
|
+
def probability
|
135
|
+
self[:probability]
|
136
|
+
end
|
137
|
+
|
138
|
+
# Reference to another object (similar to Person#company=)
|
139
|
+
# Convention: Support both object instances and ID strings
|
140
|
+
def account=(account)
|
141
|
+
if account.is_a?(Company)
|
142
|
+
# Extract ID properly from company instance
|
143
|
+
company_id = account.id.is_a?(Hash) ? account.id["record_id"] : account.id
|
144
|
+
self[:account] = [{
|
145
|
+
target_object: "companies",
|
146
|
+
target_record_id: company_id
|
147
|
+
}]
|
148
|
+
elsif account.is_a?(String)
|
149
|
+
self[:account] = [{
|
150
|
+
target_object: "companies",
|
151
|
+
target_record_id: account
|
152
|
+
}]
|
153
|
+
elsif account.nil?
|
154
|
+
self[:account] = nil
|
155
|
+
else
|
156
|
+
raise ArgumentError, "Account must be a Company instance or ID string"
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
# Reference to Person (deal owner)
|
161
|
+
def owner=(person)
|
162
|
+
if person.is_a?(Person)
|
163
|
+
person_id = person.id.is_a?(Hash) ? person.id["record_id"] : person.id
|
164
|
+
self[:owner] = [{
|
165
|
+
target_object: "people",
|
166
|
+
target_record_id: person_id
|
167
|
+
}]
|
168
|
+
elsif person.is_a?(String)
|
169
|
+
self[:owner] = [{
|
170
|
+
target_object: "people",
|
171
|
+
target_record_id: person
|
172
|
+
}]
|
173
|
+
elsif person.nil?
|
174
|
+
self[:owner] = nil
|
175
|
+
else
|
176
|
+
raise ArgumentError, "Owner must be a Person instance or ID string"
|
177
|
+
end
|
178
|
+
end
|
179
|
+
|
180
|
+
# Multi-select or array attribute
|
181
|
+
# Convention: Provide both array and add/remove methods
|
182
|
+
def tags=(tags_array)
|
183
|
+
self[:tags] = Array(tags_array)
|
184
|
+
end
|
185
|
+
|
186
|
+
def tags
|
187
|
+
self[:tags] || []
|
188
|
+
end
|
189
|
+
|
190
|
+
def add_tag(tag)
|
191
|
+
current_tags = tags
|
192
|
+
self[:tags] = (current_tags << tag).uniq
|
193
|
+
end
|
194
|
+
|
195
|
+
def remove_tag(tag)
|
196
|
+
current_tags = tags
|
197
|
+
self[:tags] = current_tags - [tag]
|
198
|
+
end
|
199
|
+
|
200
|
+
# Text/notes field
|
201
|
+
def notes=(notes)
|
202
|
+
self[:notes] = notes
|
203
|
+
end
|
204
|
+
|
205
|
+
def notes
|
206
|
+
self[:notes]
|
207
|
+
end
|
208
|
+
|
209
|
+
# ==========================================
|
210
|
+
# COMPUTED PROPERTIES AND BUSINESS LOGIC
|
211
|
+
# ==========================================
|
212
|
+
|
213
|
+
# Calculate weighted pipeline value
|
214
|
+
def weighted_value
|
215
|
+
return 0 unless amount && probability
|
216
|
+
amount * (probability / 100.0)
|
217
|
+
end
|
218
|
+
|
219
|
+
# Check if deal is in active pipeline
|
220
|
+
def active?
|
221
|
+
!closed?
|
222
|
+
end
|
223
|
+
|
224
|
+
def closed?
|
225
|
+
["closed_won", "closed_lost"].include?(stage)
|
226
|
+
end
|
227
|
+
|
228
|
+
def won?
|
229
|
+
stage == "closed_won"
|
230
|
+
end
|
231
|
+
|
232
|
+
def lost?
|
233
|
+
stage == "closed_lost"
|
234
|
+
end
|
235
|
+
|
236
|
+
# Days until close date
|
237
|
+
def days_to_close
|
238
|
+
return nil unless close_date
|
239
|
+
(close_date - Date.today).to_i
|
240
|
+
end
|
241
|
+
|
242
|
+
def overdue?
|
243
|
+
return false unless close_date
|
244
|
+
close_date < Date.today && !closed?
|
245
|
+
end
|
246
|
+
|
247
|
+
# ==========================================
|
248
|
+
# CLASS METHODS (following Person/Company patterns)
|
249
|
+
# ==========================================
|
250
|
+
|
251
|
+
class << self
|
252
|
+
# Override create to provide a more intuitive interface
|
253
|
+
# Convention: List common attributes as named parameters
|
254
|
+
def create(name:, amount: nil, stage: "prospecting", owner: nil,
|
255
|
+
account: nil, close_date: nil, probability: nil,
|
256
|
+
tags: nil, notes: nil, values: {}, **opts)
|
257
|
+
# Build the values hash
|
258
|
+
values[:name] = name
|
259
|
+
values[:amount] = amount if amount
|
260
|
+
values[:stage] = stage
|
261
|
+
|
262
|
+
# Handle references
|
263
|
+
if owner && !values[:owner]
|
264
|
+
owner_ref = if owner.is_a?(Person)
|
265
|
+
owner_id = owner.id.is_a?(Hash) ? owner.id["record_id"] : owner.id
|
266
|
+
{
|
267
|
+
target_object: "people",
|
268
|
+
target_record_id: owner_id
|
269
|
+
}
|
270
|
+
elsif owner.is_a?(String)
|
271
|
+
{
|
272
|
+
target_object: "people",
|
273
|
+
target_record_id: owner
|
274
|
+
}
|
275
|
+
end
|
276
|
+
values[:owner] = [owner_ref] if owner_ref
|
277
|
+
end
|
278
|
+
|
279
|
+
if account && !values[:account]
|
280
|
+
account_ref = if account.is_a?(Company)
|
281
|
+
account_id = account.id.is_a?(Hash) ? account.id["record_id"] : account.id
|
282
|
+
{
|
283
|
+
target_object: "companies",
|
284
|
+
target_record_id: account_id
|
285
|
+
}
|
286
|
+
elsif account.is_a?(String)
|
287
|
+
{
|
288
|
+
target_object: "companies",
|
289
|
+
target_record_id: account
|
290
|
+
}
|
291
|
+
end
|
292
|
+
values[:account] = [account_ref] if account_ref
|
293
|
+
end
|
294
|
+
|
295
|
+
# Handle dates
|
296
|
+
if close_date && !values[:close_date]
|
297
|
+
values[:close_date] = case close_date
|
298
|
+
when String
|
299
|
+
Date.parse(close_date).iso8601
|
300
|
+
when Date
|
301
|
+
close_date.iso8601
|
302
|
+
when Time, DateTime
|
303
|
+
close_date.to_date.iso8601
|
304
|
+
end
|
305
|
+
end
|
306
|
+
|
307
|
+
values[:probability] = probability if probability
|
308
|
+
values[:tags] = Array(tags) if tags
|
309
|
+
values[:notes] = notes if notes
|
310
|
+
|
311
|
+
super(values: values, **opts)
|
312
|
+
end
|
313
|
+
|
314
|
+
# Find deals by stage
|
315
|
+
# Convention: Provide find_by_* methods for common queries
|
316
|
+
def find_by_stage(stage, **opts)
|
317
|
+
list(**opts.merge(
|
318
|
+
filter: {
|
319
|
+
stage: { "$eq": stage }
|
320
|
+
}
|
321
|
+
))
|
322
|
+
end
|
323
|
+
|
324
|
+
# Find deals by owner
|
325
|
+
def find_by_owner(owner, **opts)
|
326
|
+
owner_id = case owner
|
327
|
+
when Person
|
328
|
+
owner.id.is_a?(Hash) ? owner.id["record_id"] : owner.id
|
329
|
+
when String
|
330
|
+
owner
|
331
|
+
else
|
332
|
+
raise ArgumentError, "Owner must be a Person instance or ID string"
|
333
|
+
end
|
334
|
+
|
335
|
+
list(**opts.merge(
|
336
|
+
filter: {
|
337
|
+
owner: { "$references": owner_id }
|
338
|
+
}
|
339
|
+
))
|
340
|
+
end
|
341
|
+
|
342
|
+
# Find deals by account
|
343
|
+
def find_by_account(account, **opts)
|
344
|
+
account_id = case account
|
345
|
+
when Company
|
346
|
+
account.id.is_a?(Hash) ? account.id["record_id"] : account.id
|
347
|
+
when String
|
348
|
+
account
|
349
|
+
else
|
350
|
+
raise ArgumentError, "Account must be a Company instance or ID string"
|
351
|
+
end
|
352
|
+
|
353
|
+
list(**opts.merge(
|
354
|
+
filter: {
|
355
|
+
account: { "$references": account_id }
|
356
|
+
}
|
357
|
+
))
|
358
|
+
end
|
359
|
+
|
360
|
+
# Find deals closing in the next N days
|
361
|
+
def closing_soon(days = 30, **opts)
|
362
|
+
today = Date.today
|
363
|
+
future_date = today + days
|
364
|
+
|
365
|
+
list(**opts.merge(
|
366
|
+
filter: {
|
367
|
+
"$and": [
|
368
|
+
{ close_date: { "$gte": today.iso8601 } },
|
369
|
+
{ close_date: { "$lte": future_date.iso8601 } },
|
370
|
+
{ stage: { "$nin": ["closed_won", "closed_lost"] } }
|
371
|
+
]
|
372
|
+
}
|
373
|
+
))
|
374
|
+
end
|
375
|
+
|
376
|
+
# Find overdue deals
|
377
|
+
def overdue(**opts)
|
378
|
+
list(**opts.merge(
|
379
|
+
filter: {
|
380
|
+
"$and": [
|
381
|
+
{ close_date: { "$lt": Date.today.iso8601 } },
|
382
|
+
{ stage: { "$nin": ["closed_won", "closed_lost"] } }
|
383
|
+
]
|
384
|
+
}
|
385
|
+
))
|
386
|
+
end
|
387
|
+
|
388
|
+
# Find high-value deals
|
389
|
+
def high_value(threshold = 100000, **opts)
|
390
|
+
list(**opts.merge(
|
391
|
+
filter: {
|
392
|
+
amount: { "$gte": threshold }
|
393
|
+
}
|
394
|
+
))
|
395
|
+
end
|
396
|
+
|
397
|
+
# Search deals by name
|
398
|
+
# Convention: Override search to provide meaningful search behavior
|
399
|
+
def search(query, **opts)
|
400
|
+
list(**opts.merge(
|
401
|
+
filter: {
|
402
|
+
name: { "$contains": query }
|
403
|
+
}
|
404
|
+
))
|
405
|
+
end
|
406
|
+
|
407
|
+
# Get pipeline summary
|
408
|
+
def pipeline_summary(**opts)
|
409
|
+
# This would need to aggregate data client-side
|
410
|
+
# as Attio doesn't support aggregation queries
|
411
|
+
deals = all(**opts)
|
412
|
+
|
413
|
+
stages = {}
|
414
|
+
VALID_STAGES.each do |stage|
|
415
|
+
stage_deals = deals.select { |d| d.stage == stage }
|
416
|
+
stages[stage] = {
|
417
|
+
count: stage_deals.size,
|
418
|
+
total_value: stage_deals.sum(&:amount),
|
419
|
+
weighted_value: stage_deals.sum(&:weighted_value)
|
420
|
+
}
|
421
|
+
end
|
422
|
+
|
423
|
+
stages
|
424
|
+
end
|
425
|
+
end
|
426
|
+
end
|
427
|
+
|
428
|
+
# Convenience alias (following Person/People pattern)
|
429
|
+
Deals = Deal
|
430
|
+
end
|
431
|
+
```
|
432
|
+
|
433
|
+
## Naming Conventions
|
434
|
+
|
435
|
+
Follow these conventions to maintain consistency with the built-in Person and Company classes:
|
436
|
+
|
437
|
+
### Class Naming
|
438
|
+
- Use singular form: `Deal`, not `Deals`
|
439
|
+
- Add a plural constant alias: `Deals = Deal`
|
440
|
+
- Use PascalCase for the class name
|
441
|
+
|
442
|
+
### Object Type
|
443
|
+
- Use the plural form from your Attio workspace: `"deals"`, `"tickets"`, `"projects"`
|
444
|
+
- Can also use the object's UUID if you don't have a slug
|
445
|
+
|
446
|
+
### Attribute Methods
|
447
|
+
- Simple attributes: `name=` / `name`
|
448
|
+
- Boolean attributes: `active?`, `closed?`, `overdue?`
|
449
|
+
- Add methods: `add_tag`, `add_comment`, `add_attachment`
|
450
|
+
- Remove methods: `remove_tag`, `remove_comment`
|
451
|
+
- Set methods for complex attributes: `set_priority`, `set_status`
|
452
|
+
|
453
|
+
### Class Methods
|
454
|
+
- `create` - Override with named parameters for common attributes
|
455
|
+
- `find_by_*` - Specific finders like `find_by_email`, `find_by_stage`
|
456
|
+
- `search` - Override to define how text search works
|
457
|
+
- Domain-specific queries: `overdue`, `high_priority`, `closing_soon`
|
458
|
+
|
459
|
+
## Required Methods
|
460
|
+
|
461
|
+
The only truly required element is the `object_type` declaration:
|
462
|
+
|
463
|
+
```ruby
|
464
|
+
class YourRecord < TypedRecord
|
465
|
+
object_type "your_object_slug" # THIS IS REQUIRED!
|
466
|
+
end
|
467
|
+
```
|
468
|
+
|
469
|
+
Everything else is optional, but you should consider implementing:
|
470
|
+
|
471
|
+
1. **Attribute accessors** for all your object's fields
|
472
|
+
2. **create class method** with named parameters
|
473
|
+
3. **search class method** with appropriate filters
|
474
|
+
|
475
|
+
## Optional Overrides
|
476
|
+
|
477
|
+
All these methods can be overridden but have sensible defaults:
|
478
|
+
|
479
|
+
### Class Methods (already implemented in TypedRecord)
|
480
|
+
- `list(**opts)` - Automatically includes object type
|
481
|
+
- `retrieve(record_id, **opts)` - Automatically includes object type
|
482
|
+
- `update(record_id, values:, **opts)` - Automatically includes object type
|
483
|
+
- `delete(record_id, **opts)` - Automatically includes object type
|
484
|
+
- `find(record_id, **opts)` - Alias for retrieve
|
485
|
+
- `all(**opts)` - Alias for list
|
486
|
+
- `find_by(attribute, value, **opts)` - Generic attribute finder
|
487
|
+
|
488
|
+
### Instance Methods (inherited from TypedRecord)
|
489
|
+
- `save(**opts)` - Saves changes if any
|
490
|
+
- `destroy(**opts)` - Deletes the record
|
491
|
+
- `persisted?` - Checks if record exists in Attio
|
492
|
+
- `changed?` - Checks if there are unsaved changes
|
493
|
+
|
494
|
+
## Advanced Examples
|
495
|
+
|
496
|
+
### Project Management System
|
497
|
+
|
498
|
+
```ruby
|
499
|
+
module Attio
|
500
|
+
class Project < TypedRecord
|
501
|
+
object_type "projects"
|
502
|
+
|
503
|
+
STATUSES = ["planning", "active", "on_hold", "completed", "cancelled"].freeze
|
504
|
+
PRIORITIES = ["low", "medium", "high", "critical"].freeze
|
505
|
+
|
506
|
+
# Basic attributes
|
507
|
+
def name=(name)
|
508
|
+
self[:name] = name
|
509
|
+
end
|
510
|
+
|
511
|
+
def description=(desc)
|
512
|
+
self[:description] = desc
|
513
|
+
end
|
514
|
+
|
515
|
+
def status=(status)
|
516
|
+
unless STATUSES.include?(status)
|
517
|
+
raise ArgumentError, "Invalid status: #{status}"
|
518
|
+
end
|
519
|
+
self[:status] = status
|
520
|
+
end
|
521
|
+
|
522
|
+
def priority=(priority)
|
523
|
+
unless PRIORITIES.include?(priority)
|
524
|
+
raise ArgumentError, "Invalid priority: #{priority}"
|
525
|
+
end
|
526
|
+
self[:priority] = priority
|
527
|
+
end
|
528
|
+
|
529
|
+
# Date handling
|
530
|
+
def start_date=(date)
|
531
|
+
self[:start_date] = parse_date(date)
|
532
|
+
end
|
533
|
+
|
534
|
+
def end_date=(date)
|
535
|
+
self[:end_date] = parse_date(date)
|
536
|
+
end
|
537
|
+
|
538
|
+
def start_date
|
539
|
+
Date.parse(self[:start_date]) if self[:start_date]
|
540
|
+
end
|
541
|
+
|
542
|
+
def end_date
|
543
|
+
Date.parse(self[:end_date]) if self[:end_date]
|
544
|
+
end
|
545
|
+
|
546
|
+
# Team members (array of person references)
|
547
|
+
def team_members=(people)
|
548
|
+
self[:team_members] = people.map do |person|
|
549
|
+
person_id = case person
|
550
|
+
when Person
|
551
|
+
person.id.is_a?(Hash) ? person.id["record_id"] : person.id
|
552
|
+
when String
|
553
|
+
person
|
554
|
+
else
|
555
|
+
raise ArgumentError, "Team members must be Person instances or ID strings"
|
556
|
+
end
|
557
|
+
|
558
|
+
{
|
559
|
+
target_object: "people",
|
560
|
+
target_record_id: person_id
|
561
|
+
}
|
562
|
+
end
|
563
|
+
end
|
564
|
+
|
565
|
+
def add_team_member(person)
|
566
|
+
current = self[:team_members] || []
|
567
|
+
person_ref = case person
|
568
|
+
when Person
|
569
|
+
person_id = person.id.is_a?(Hash) ? person.id["record_id"] : person.id
|
570
|
+
{
|
571
|
+
target_object: "people",
|
572
|
+
target_record_id: person_id
|
573
|
+
}
|
574
|
+
when String
|
575
|
+
{
|
576
|
+
target_object: "people",
|
577
|
+
target_record_id: person
|
578
|
+
}
|
579
|
+
end
|
580
|
+
|
581
|
+
self[:team_members] = current + [person_ref]
|
582
|
+
end
|
583
|
+
|
584
|
+
# Computed properties
|
585
|
+
def duration_days
|
586
|
+
return nil unless start_date && end_date
|
587
|
+
(end_date - start_date).to_i
|
588
|
+
end
|
589
|
+
|
590
|
+
def active?
|
591
|
+
status == "active"
|
592
|
+
end
|
593
|
+
|
594
|
+
def completed?
|
595
|
+
status == "completed"
|
596
|
+
end
|
597
|
+
|
598
|
+
def overdue?
|
599
|
+
return false unless end_date
|
600
|
+
end_date < Date.today && !["completed", "cancelled"].include?(status)
|
601
|
+
end
|
602
|
+
|
603
|
+
private
|
604
|
+
|
605
|
+
def parse_date(date)
|
606
|
+
case date
|
607
|
+
when String
|
608
|
+
Date.parse(date).iso8601
|
609
|
+
when Date
|
610
|
+
date.iso8601
|
611
|
+
when Time, DateTime
|
612
|
+
date.to_date.iso8601
|
613
|
+
else
|
614
|
+
raise ArgumentError, "Date must be a String, Date, Time, or DateTime"
|
615
|
+
end
|
616
|
+
end
|
617
|
+
|
618
|
+
class << self
|
619
|
+
def create(name:, status: "planning", priority: "medium",
|
620
|
+
description: nil, start_date: nil, end_date: nil,
|
621
|
+
team_members: nil, values: {}, **opts)
|
622
|
+
values[:name] = name
|
623
|
+
values[:status] = status
|
624
|
+
values[:priority] = priority
|
625
|
+
values[:description] = description if description
|
626
|
+
|
627
|
+
if start_date
|
628
|
+
values[:start_date] = case start_date
|
629
|
+
when String then Date.parse(start_date).iso8601
|
630
|
+
when Date then start_date.iso8601
|
631
|
+
when Time, DateTime then start_date.to_date.iso8601
|
632
|
+
end
|
633
|
+
end
|
634
|
+
|
635
|
+
if end_date
|
636
|
+
values[:end_date] = case end_date
|
637
|
+
when String then Date.parse(end_date).iso8601
|
638
|
+
when Date then end_date.iso8601
|
639
|
+
when Time, DateTime then end_date.to_date.iso8601
|
640
|
+
end
|
641
|
+
end
|
642
|
+
|
643
|
+
if team_members
|
644
|
+
values[:team_members] = team_members.map do |person|
|
645
|
+
person_id = case person
|
646
|
+
when Person
|
647
|
+
person.id.is_a?(Hash) ? person.id["record_id"] : person.id
|
648
|
+
when String
|
649
|
+
person
|
650
|
+
end
|
651
|
+
|
652
|
+
{
|
653
|
+
target_object: "people",
|
654
|
+
target_record_id: person_id
|
655
|
+
}
|
656
|
+
end
|
657
|
+
end
|
658
|
+
|
659
|
+
super(values: values, **opts)
|
660
|
+
end
|
661
|
+
|
662
|
+
def active(**opts)
|
663
|
+
find_by_status("active", **opts)
|
664
|
+
end
|
665
|
+
|
666
|
+
def find_by_status(status, **opts)
|
667
|
+
list(**opts.merge(
|
668
|
+
filter: { status: { "$eq": status } }
|
669
|
+
))
|
670
|
+
end
|
671
|
+
|
672
|
+
def find_by_priority(priority, **opts)
|
673
|
+
list(**opts.merge(
|
674
|
+
filter: { priority: { "$eq": priority } }
|
675
|
+
))
|
676
|
+
end
|
677
|
+
|
678
|
+
def high_priority(**opts)
|
679
|
+
list(**opts.merge(
|
680
|
+
filter: {
|
681
|
+
priority: { "$in": ["high", "critical"] }
|
682
|
+
}
|
683
|
+
))
|
684
|
+
end
|
685
|
+
|
686
|
+
def overdue(**opts)
|
687
|
+
list(**opts.merge(
|
688
|
+
filter: {
|
689
|
+
"$and": [
|
690
|
+
{ end_date: { "$lt": Date.today.iso8601 } },
|
691
|
+
{ status: { "$nin": ["completed", "cancelled"] } }
|
692
|
+
]
|
693
|
+
}
|
694
|
+
))
|
695
|
+
end
|
696
|
+
|
697
|
+
def starting_soon(days = 7, **opts)
|
698
|
+
today = Date.today
|
699
|
+
future_date = today + days
|
700
|
+
|
701
|
+
list(**opts.merge(
|
702
|
+
filter: {
|
703
|
+
"$and": [
|
704
|
+
{ start_date: { "$gte": today.iso8601 } },
|
705
|
+
{ start_date: { "$lte": future_date.iso8601 } }
|
706
|
+
]
|
707
|
+
}
|
708
|
+
))
|
709
|
+
end
|
710
|
+
end
|
711
|
+
end
|
712
|
+
|
713
|
+
Projects = Project
|
714
|
+
end
|
715
|
+
```
|
716
|
+
|
717
|
+
### Customer Support Tickets
|
718
|
+
|
719
|
+
```ruby
|
720
|
+
module Attio
|
721
|
+
class Ticket < TypedRecord
|
722
|
+
object_type "support_tickets"
|
723
|
+
|
724
|
+
PRIORITIES = ["low", "normal", "high", "urgent"].freeze
|
725
|
+
STATUSES = ["new", "open", "pending", "resolved", "closed"].freeze
|
726
|
+
CATEGORIES = ["bug", "feature_request", "question", "complaint", "other"].freeze
|
727
|
+
|
728
|
+
# Basic attributes
|
729
|
+
def subject=(subject)
|
730
|
+
self[:subject] = subject
|
731
|
+
end
|
732
|
+
|
733
|
+
def subject
|
734
|
+
self[:subject]
|
735
|
+
end
|
736
|
+
|
737
|
+
def description=(desc)
|
738
|
+
self[:description] = desc
|
739
|
+
end
|
740
|
+
|
741
|
+
def priority=(priority)
|
742
|
+
unless PRIORITIES.include?(priority)
|
743
|
+
raise ArgumentError, "Invalid priority: #{priority}"
|
744
|
+
end
|
745
|
+
self[:priority] = priority
|
746
|
+
end
|
747
|
+
|
748
|
+
def status=(status)
|
749
|
+
unless STATUSES.include?(status)
|
750
|
+
raise ArgumentError, "Invalid status: #{status}"
|
751
|
+
end
|
752
|
+
self[:status] = status
|
753
|
+
end
|
754
|
+
|
755
|
+
def category=(category)
|
756
|
+
unless CATEGORIES.include?(category)
|
757
|
+
raise ArgumentError, "Invalid category: #{category}"
|
758
|
+
end
|
759
|
+
self[:category] = category
|
760
|
+
end
|
761
|
+
|
762
|
+
# Customer reference
|
763
|
+
def customer=(person)
|
764
|
+
if person.is_a?(Person)
|
765
|
+
person_id = person.id.is_a?(Hash) ? person.id["record_id"] : person.id
|
766
|
+
self[:customer] = [{
|
767
|
+
target_object: "people",
|
768
|
+
target_record_id: person_id
|
769
|
+
}]
|
770
|
+
elsif person.is_a?(String)
|
771
|
+
self[:customer] = [{
|
772
|
+
target_object: "people",
|
773
|
+
target_record_id: person
|
774
|
+
}]
|
775
|
+
else
|
776
|
+
raise ArgumentError, "Customer must be a Person instance or ID string"
|
777
|
+
end
|
778
|
+
end
|
779
|
+
|
780
|
+
# Assigned agent
|
781
|
+
def assigned_to=(person)
|
782
|
+
if person.is_a?(Person)
|
783
|
+
person_id = person.id.is_a?(Hash) ? person.id["record_id"] : person.id
|
784
|
+
self[:assigned_to] = [{
|
785
|
+
target_object: "people",
|
786
|
+
target_record_id: person_id
|
787
|
+
}]
|
788
|
+
elsif person.is_a?(String)
|
789
|
+
self[:assigned_to] = [{
|
790
|
+
target_object: "people",
|
791
|
+
target_record_id: person
|
792
|
+
}]
|
793
|
+
elsif person.nil?
|
794
|
+
self[:assigned_to] = nil
|
795
|
+
else
|
796
|
+
raise ArgumentError, "Assigned person must be a Person instance or ID string"
|
797
|
+
end
|
798
|
+
end
|
799
|
+
|
800
|
+
# SLA and timing
|
801
|
+
def created_at
|
802
|
+
Time.parse(self[:created_at]) if self[:created_at]
|
803
|
+
end
|
804
|
+
|
805
|
+
def resolved_at=(time)
|
806
|
+
self[:resolved_at] = time&.iso8601
|
807
|
+
end
|
808
|
+
|
809
|
+
def resolved_at
|
810
|
+
Time.parse(self[:resolved_at]) if self[:resolved_at]
|
811
|
+
end
|
812
|
+
|
813
|
+
def first_response_at=(time)
|
814
|
+
self[:first_response_at] = time&.iso8601
|
815
|
+
end
|
816
|
+
|
817
|
+
def first_response_at
|
818
|
+
Time.parse(self[:first_response_at]) if self[:first_response_at]
|
819
|
+
end
|
820
|
+
|
821
|
+
# Computed properties
|
822
|
+
def open?
|
823
|
+
["new", "open", "pending"].include?(status)
|
824
|
+
end
|
825
|
+
|
826
|
+
def closed?
|
827
|
+
["resolved", "closed"].include?(status)
|
828
|
+
end
|
829
|
+
|
830
|
+
def response_time_hours
|
831
|
+
return nil unless created_at && first_response_at
|
832
|
+
((first_response_at - created_at) / 3600).round(2)
|
833
|
+
end
|
834
|
+
|
835
|
+
def resolution_time_hours
|
836
|
+
return nil unless created_at && resolved_at
|
837
|
+
((resolved_at - created_at) / 3600).round(2)
|
838
|
+
end
|
839
|
+
|
840
|
+
def breached_sla?
|
841
|
+
return false unless self[:sla_hours]
|
842
|
+
return false unless created_at
|
843
|
+
|
844
|
+
if open?
|
845
|
+
hours_open = (Time.now - created_at) / 3600
|
846
|
+
hours_open > self[:sla_hours]
|
847
|
+
else
|
848
|
+
resolution_time_hours && resolution_time_hours > self[:sla_hours]
|
849
|
+
end
|
850
|
+
end
|
851
|
+
|
852
|
+
# Comments/notes handling
|
853
|
+
def add_comment(text, author: nil)
|
854
|
+
comments = self[:comments] || []
|
855
|
+
comment = {
|
856
|
+
text: text,
|
857
|
+
created_at: Time.now.iso8601
|
858
|
+
}
|
859
|
+
|
860
|
+
if author
|
861
|
+
author_id = case author
|
862
|
+
when Person
|
863
|
+
author.id.is_a?(Hash) ? author.id["record_id"] : author.id
|
864
|
+
when String
|
865
|
+
author
|
866
|
+
end
|
867
|
+
|
868
|
+
comment[:author] = {
|
869
|
+
target_object: "people",
|
870
|
+
target_record_id: author_id
|
871
|
+
}
|
872
|
+
end
|
873
|
+
|
874
|
+
self[:comments] = comments + [comment]
|
875
|
+
end
|
876
|
+
|
877
|
+
class << self
|
878
|
+
def create(subject:, description:, customer:, priority: "normal",
|
879
|
+
status: "new", category: "other", assigned_to: nil,
|
880
|
+
sla_hours: nil, values: {}, **opts)
|
881
|
+
values[:subject] = subject
|
882
|
+
values[:description] = description
|
883
|
+
values[:priority] = priority
|
884
|
+
values[:status] = status
|
885
|
+
values[:category] = category
|
886
|
+
values[:sla_hours] = sla_hours if sla_hours
|
887
|
+
|
888
|
+
# Handle customer reference
|
889
|
+
customer_ref = case customer
|
890
|
+
when Person
|
891
|
+
customer_id = customer.id.is_a?(Hash) ? customer.id["record_id"] : customer.id
|
892
|
+
{
|
893
|
+
target_object: "people",
|
894
|
+
target_record_id: customer_id
|
895
|
+
}
|
896
|
+
when String
|
897
|
+
{
|
898
|
+
target_object: "people",
|
899
|
+
target_record_id: customer
|
900
|
+
}
|
901
|
+
end
|
902
|
+
values[:customer] = [customer_ref]
|
903
|
+
|
904
|
+
# Handle assigned_to reference
|
905
|
+
if assigned_to
|
906
|
+
assigned_ref = case assigned_to
|
907
|
+
when Person
|
908
|
+
assigned_id = assigned_to.id.is_a?(Hash) ? assigned_to.id["record_id"] : assigned_to.id
|
909
|
+
{
|
910
|
+
target_object: "people",
|
911
|
+
target_record_id: assigned_id
|
912
|
+
}
|
913
|
+
when String
|
914
|
+
{
|
915
|
+
target_object: "people",
|
916
|
+
target_record_id: assigned_to
|
917
|
+
}
|
918
|
+
end
|
919
|
+
values[:assigned_to] = [assigned_ref]
|
920
|
+
end
|
921
|
+
|
922
|
+
super(values: values, **opts)
|
923
|
+
end
|
924
|
+
|
925
|
+
def open_tickets(**opts)
|
926
|
+
list(**opts.merge(
|
927
|
+
filter: {
|
928
|
+
status: { "$in": ["new", "open", "pending"] }
|
929
|
+
}
|
930
|
+
))
|
931
|
+
end
|
932
|
+
|
933
|
+
def unassigned(**opts)
|
934
|
+
list(**opts.merge(
|
935
|
+
filter: {
|
936
|
+
assigned_to: { "$exists": false }
|
937
|
+
}
|
938
|
+
))
|
939
|
+
end
|
940
|
+
|
941
|
+
def find_by_customer(customer, **opts)
|
942
|
+
customer_id = case customer
|
943
|
+
when Person
|
944
|
+
customer.id.is_a?(Hash) ? customer.id["record_id"] : customer.id
|
945
|
+
when String
|
946
|
+
customer
|
947
|
+
end
|
948
|
+
|
949
|
+
list(**opts.merge(
|
950
|
+
filter: {
|
951
|
+
customer: { "$references": customer_id }
|
952
|
+
}
|
953
|
+
))
|
954
|
+
end
|
955
|
+
|
956
|
+
def find_by_status(status, **opts)
|
957
|
+
list(**opts.merge(
|
958
|
+
filter: {
|
959
|
+
status: { "$eq": status }
|
960
|
+
}
|
961
|
+
))
|
962
|
+
end
|
963
|
+
|
964
|
+
def high_priority(**opts)
|
965
|
+
list(**opts.merge(
|
966
|
+
filter: {
|
967
|
+
priority: { "$in": ["high", "urgent"] }
|
968
|
+
}
|
969
|
+
))
|
970
|
+
end
|
971
|
+
|
972
|
+
def breached_sla(**opts)
|
973
|
+
# This would need to be filtered client-side
|
974
|
+
# as Attio doesn't support computed field queries
|
975
|
+
tickets = open_tickets(**opts)
|
976
|
+
tickets.select(&:breached_sla?)
|
977
|
+
end
|
978
|
+
|
979
|
+
def search(query, **opts)
|
980
|
+
list(**opts.merge(
|
981
|
+
filter: {
|
982
|
+
"$or": [
|
983
|
+
{ subject: { "$contains": query } },
|
984
|
+
{ description: { "$contains": query } }
|
985
|
+
]
|
986
|
+
}
|
987
|
+
))
|
988
|
+
end
|
989
|
+
end
|
990
|
+
end
|
991
|
+
|
992
|
+
Tickets = Ticket
|
993
|
+
end
|
994
|
+
```
|
995
|
+
|
996
|
+
### Invoice Records
|
997
|
+
|
998
|
+
```ruby
|
999
|
+
module Attio
|
1000
|
+
class Invoice < TypedRecord
|
1001
|
+
object_type "invoices"
|
1002
|
+
|
1003
|
+
STATUSES = ["draft", "sent", "paid", "overdue", "cancelled"].freeze
|
1004
|
+
|
1005
|
+
# Basic attributes
|
1006
|
+
def invoice_number=(number)
|
1007
|
+
self[:invoice_number] = number
|
1008
|
+
end
|
1009
|
+
|
1010
|
+
def invoice_number
|
1011
|
+
self[:invoice_number]
|
1012
|
+
end
|
1013
|
+
|
1014
|
+
def amount=(value)
|
1015
|
+
self[:amount] = value.to_f
|
1016
|
+
end
|
1017
|
+
|
1018
|
+
def amount
|
1019
|
+
self[:amount]
|
1020
|
+
end
|
1021
|
+
|
1022
|
+
def currency=(currency)
|
1023
|
+
self[:currency] = currency.upcase
|
1024
|
+
end
|
1025
|
+
|
1026
|
+
def currency
|
1027
|
+
self[:currency] || "USD"
|
1028
|
+
end
|
1029
|
+
|
1030
|
+
def status=(status)
|
1031
|
+
unless STATUSES.include?(status)
|
1032
|
+
raise ArgumentError, "Invalid status: #{status}"
|
1033
|
+
end
|
1034
|
+
self[:status] = status
|
1035
|
+
end
|
1036
|
+
|
1037
|
+
def status
|
1038
|
+
self[:status]
|
1039
|
+
end
|
1040
|
+
|
1041
|
+
# Date handling
|
1042
|
+
def issue_date=(date)
|
1043
|
+
self[:issue_date] = parse_date(date)
|
1044
|
+
end
|
1045
|
+
|
1046
|
+
def issue_date
|
1047
|
+
Date.parse(self[:issue_date]) if self[:issue_date]
|
1048
|
+
end
|
1049
|
+
|
1050
|
+
def due_date=(date)
|
1051
|
+
self[:due_date] = parse_date(date)
|
1052
|
+
end
|
1053
|
+
|
1054
|
+
def due_date
|
1055
|
+
Date.parse(self[:due_date]) if self[:due_date]
|
1056
|
+
end
|
1057
|
+
|
1058
|
+
def paid_date=(date)
|
1059
|
+
self[:paid_date] = date ? parse_date(date) : nil
|
1060
|
+
end
|
1061
|
+
|
1062
|
+
def paid_date
|
1063
|
+
Date.parse(self[:paid_date]) if self[:paid_date]
|
1064
|
+
end
|
1065
|
+
|
1066
|
+
# Customer reference
|
1067
|
+
def customer=(company)
|
1068
|
+
if company.is_a?(Company)
|
1069
|
+
company_id = company.id.is_a?(Hash) ? company.id["record_id"] : company.id
|
1070
|
+
self[:customer] = [{
|
1071
|
+
target_object: "companies",
|
1072
|
+
target_record_id: company_id
|
1073
|
+
}]
|
1074
|
+
elsif company.is_a?(String)
|
1075
|
+
self[:customer] = [{
|
1076
|
+
target_object: "companies",
|
1077
|
+
target_record_id: company
|
1078
|
+
}]
|
1079
|
+
else
|
1080
|
+
raise ArgumentError, "Customer must be a Company instance or ID string"
|
1081
|
+
end
|
1082
|
+
end
|
1083
|
+
|
1084
|
+
# Line items (array of hashes)
|
1085
|
+
def line_items=(items)
|
1086
|
+
self[:line_items] = items.map do |item|
|
1087
|
+
{
|
1088
|
+
description: item[:description],
|
1089
|
+
quantity: item[:quantity].to_f,
|
1090
|
+
unit_price: item[:unit_price].to_f,
|
1091
|
+
total: (item[:quantity].to_f * item[:unit_price].to_f)
|
1092
|
+
}
|
1093
|
+
end
|
1094
|
+
end
|
1095
|
+
|
1096
|
+
def add_line_item(description:, quantity:, unit_price:)
|
1097
|
+
items = self[:line_items] || []
|
1098
|
+
items << {
|
1099
|
+
description: description,
|
1100
|
+
quantity: quantity.to_f,
|
1101
|
+
unit_price: unit_price.to_f,
|
1102
|
+
total: (quantity.to_f * unit_price.to_f)
|
1103
|
+
}
|
1104
|
+
self[:line_items] = items
|
1105
|
+
|
1106
|
+
# Recalculate total
|
1107
|
+
self[:amount] = calculate_total
|
1108
|
+
end
|
1109
|
+
|
1110
|
+
# Computed properties
|
1111
|
+
def calculate_total
|
1112
|
+
return 0 unless self[:line_items]
|
1113
|
+
|
1114
|
+
self[:line_items].sum { |item| item[:total] || 0 }
|
1115
|
+
end
|
1116
|
+
|
1117
|
+
def days_overdue
|
1118
|
+
return nil unless due_date && !paid?
|
1119
|
+
return 0 unless Date.today > due_date
|
1120
|
+
|
1121
|
+
(Date.today - due_date).to_i
|
1122
|
+
end
|
1123
|
+
|
1124
|
+
def paid?
|
1125
|
+
status == "paid"
|
1126
|
+
end
|
1127
|
+
|
1128
|
+
def overdue?
|
1129
|
+
return false if paid?
|
1130
|
+
due_date && due_date < Date.today
|
1131
|
+
end
|
1132
|
+
|
1133
|
+
def mark_as_paid(payment_date = Date.today)
|
1134
|
+
self.status = "paid"
|
1135
|
+
self.paid_date = payment_date
|
1136
|
+
end
|
1137
|
+
|
1138
|
+
private
|
1139
|
+
|
1140
|
+
def parse_date(date)
|
1141
|
+
case date
|
1142
|
+
when String
|
1143
|
+
Date.parse(date).iso8601
|
1144
|
+
when Date
|
1145
|
+
date.iso8601
|
1146
|
+
when Time, DateTime
|
1147
|
+
date.to_date.iso8601
|
1148
|
+
else
|
1149
|
+
raise ArgumentError, "Date must be a String, Date, Time, or DateTime"
|
1150
|
+
end
|
1151
|
+
end
|
1152
|
+
|
1153
|
+
class << self
|
1154
|
+
def create(invoice_number:, customer:, amount:, due_date:,
|
1155
|
+
currency: "USD", status: "draft", issue_date: Date.today,
|
1156
|
+
line_items: nil, values: {}, **opts)
|
1157
|
+
values[:invoice_number] = invoice_number
|
1158
|
+
values[:amount] = amount.to_f
|
1159
|
+
values[:currency] = currency.upcase
|
1160
|
+
values[:status] = status
|
1161
|
+
|
1162
|
+
# Handle dates
|
1163
|
+
values[:issue_date] = case issue_date
|
1164
|
+
when String then Date.parse(issue_date).iso8601
|
1165
|
+
when Date then issue_date.iso8601
|
1166
|
+
when Time, DateTime then issue_date.to_date.iso8601
|
1167
|
+
end
|
1168
|
+
|
1169
|
+
values[:due_date] = case due_date
|
1170
|
+
when String then Date.parse(due_date).iso8601
|
1171
|
+
when Date then due_date.iso8601
|
1172
|
+
when Time, DateTime then due_date.to_date.iso8601
|
1173
|
+
end
|
1174
|
+
|
1175
|
+
# Handle customer reference
|
1176
|
+
customer_ref = case customer
|
1177
|
+
when Company
|
1178
|
+
customer_id = customer.id.is_a?(Hash) ? customer.id["record_id"] : customer.id
|
1179
|
+
{
|
1180
|
+
target_object: "companies",
|
1181
|
+
target_record_id: customer_id
|
1182
|
+
}
|
1183
|
+
when String
|
1184
|
+
{
|
1185
|
+
target_object: "companies",
|
1186
|
+
target_record_id: customer
|
1187
|
+
}
|
1188
|
+
end
|
1189
|
+
values[:customer] = [customer_ref]
|
1190
|
+
|
1191
|
+
# Handle line items
|
1192
|
+
if line_items
|
1193
|
+
values[:line_items] = line_items.map do |item|
|
1194
|
+
{
|
1195
|
+
description: item[:description],
|
1196
|
+
quantity: item[:quantity].to_f,
|
1197
|
+
unit_price: item[:unit_price].to_f,
|
1198
|
+
total: (item[:quantity].to_f * item[:unit_price].to_f)
|
1199
|
+
}
|
1200
|
+
end
|
1201
|
+
end
|
1202
|
+
|
1203
|
+
super(values: values, **opts)
|
1204
|
+
end
|
1205
|
+
|
1206
|
+
def find_by_invoice_number(number, **opts)
|
1207
|
+
list(**opts.merge(
|
1208
|
+
filter: {
|
1209
|
+
invoice_number: { "$eq": number }
|
1210
|
+
}
|
1211
|
+
)).first
|
1212
|
+
end
|
1213
|
+
|
1214
|
+
def find_by_customer(customer, **opts)
|
1215
|
+
customer_id = case customer
|
1216
|
+
when Company
|
1217
|
+
customer.id.is_a?(Hash) ? customer.id["record_id"] : customer.id
|
1218
|
+
when String
|
1219
|
+
customer
|
1220
|
+
end
|
1221
|
+
|
1222
|
+
list(**opts.merge(
|
1223
|
+
filter: {
|
1224
|
+
customer: { "$references": customer_id }
|
1225
|
+
}
|
1226
|
+
))
|
1227
|
+
end
|
1228
|
+
|
1229
|
+
def unpaid(**opts)
|
1230
|
+
list(**opts.merge(
|
1231
|
+
filter: {
|
1232
|
+
status: { "$ne": "paid" }
|
1233
|
+
}
|
1234
|
+
))
|
1235
|
+
end
|
1236
|
+
|
1237
|
+
def overdue(**opts)
|
1238
|
+
list(**opts.merge(
|
1239
|
+
filter: {
|
1240
|
+
"$and": [
|
1241
|
+
{ status: { "$ne": "paid" } },
|
1242
|
+
{ due_date: { "$lt": Date.today.iso8601 } }
|
1243
|
+
]
|
1244
|
+
}
|
1245
|
+
))
|
1246
|
+
end
|
1247
|
+
|
1248
|
+
def paid_between(start_date, end_date, **opts)
|
1249
|
+
list(**opts.merge(
|
1250
|
+
filter: {
|
1251
|
+
"$and": [
|
1252
|
+
{ status: { "$eq": "paid" } },
|
1253
|
+
{ paid_date: { "$gte": parse_date(start_date) } },
|
1254
|
+
{ paid_date: { "$lte": parse_date(end_date) } }
|
1255
|
+
]
|
1256
|
+
}
|
1257
|
+
))
|
1258
|
+
end
|
1259
|
+
|
1260
|
+
def total_revenue(start_date = nil, end_date = nil, currency: "USD", **opts)
|
1261
|
+
filters = [
|
1262
|
+
{ status: { "$eq": "paid" } },
|
1263
|
+
{ currency: { "$eq": currency } }
|
1264
|
+
]
|
1265
|
+
|
1266
|
+
if start_date
|
1267
|
+
filters << { paid_date: { "$gte": parse_date(start_date) } }
|
1268
|
+
end
|
1269
|
+
|
1270
|
+
if end_date
|
1271
|
+
filters << { paid_date: { "$lte": parse_date(end_date) } }
|
1272
|
+
end
|
1273
|
+
|
1274
|
+
invoices = list(**opts.merge(
|
1275
|
+
filter: { "$and": filters }
|
1276
|
+
))
|
1277
|
+
|
1278
|
+
invoices.sum(&:amount)
|
1279
|
+
end
|
1280
|
+
|
1281
|
+
private
|
1282
|
+
|
1283
|
+
def parse_date(date)
|
1284
|
+
case date
|
1285
|
+
when String then Date.parse(date).iso8601
|
1286
|
+
when Date then date.iso8601
|
1287
|
+
when Time, DateTime then date.to_date.iso8601
|
1288
|
+
else date
|
1289
|
+
end
|
1290
|
+
end
|
1291
|
+
end
|
1292
|
+
end
|
1293
|
+
|
1294
|
+
Invoices = Invoice
|
1295
|
+
end
|
1296
|
+
```
|
1297
|
+
|
1298
|
+
## Best Practices
|
1299
|
+
|
1300
|
+
### 1. Attribute Handling
|
1301
|
+
|
1302
|
+
Always provide both setter and getter methods for attributes:
|
1303
|
+
|
1304
|
+
```ruby
|
1305
|
+
# Good
|
1306
|
+
def status=(value)
|
1307
|
+
self[:status] = value
|
1308
|
+
end
|
1309
|
+
|
1310
|
+
def status
|
1311
|
+
self[:status]
|
1312
|
+
end
|
1313
|
+
|
1314
|
+
# Better - with validation
|
1315
|
+
def status=(value)
|
1316
|
+
unless VALID_STATUSES.include?(value)
|
1317
|
+
raise ArgumentError, "Invalid status: #{value}"
|
1318
|
+
end
|
1319
|
+
self[:status] = value
|
1320
|
+
end
|
1321
|
+
```
|
1322
|
+
|
1323
|
+
### 2. Date Handling
|
1324
|
+
|
1325
|
+
Be flexible with date inputs:
|
1326
|
+
|
1327
|
+
```ruby
|
1328
|
+
def due_date=(date)
|
1329
|
+
self[:due_date] = case date
|
1330
|
+
when String
|
1331
|
+
Date.parse(date).iso8601
|
1332
|
+
when Date
|
1333
|
+
date.iso8601
|
1334
|
+
when Time, DateTime
|
1335
|
+
date.to_date.iso8601
|
1336
|
+
when nil
|
1337
|
+
nil
|
1338
|
+
else
|
1339
|
+
raise ArgumentError, "Date must be a String, Date, Time, or DateTime"
|
1340
|
+
end
|
1341
|
+
end
|
1342
|
+
|
1343
|
+
def due_date
|
1344
|
+
Date.parse(self[:due_date]) if self[:due_date]
|
1345
|
+
end
|
1346
|
+
```
|
1347
|
+
|
1348
|
+
### 3. Reference Handling
|
1349
|
+
|
1350
|
+
Support both object instances and ID strings:
|
1351
|
+
|
1352
|
+
```ruby
|
1353
|
+
def owner=(person)
|
1354
|
+
if person.is_a?(Person)
|
1355
|
+
person_id = person.id.is_a?(Hash) ? person.id["record_id"] : person.id
|
1356
|
+
self[:owner] = [{
|
1357
|
+
target_object: "people",
|
1358
|
+
target_record_id: person_id
|
1359
|
+
}]
|
1360
|
+
elsif person.is_a?(String)
|
1361
|
+
self[:owner] = [{
|
1362
|
+
target_object: "people",
|
1363
|
+
target_record_id: person
|
1364
|
+
}]
|
1365
|
+
elsif person.nil?
|
1366
|
+
self[:owner] = nil
|
1367
|
+
else
|
1368
|
+
raise ArgumentError, "Owner must be a Person instance or ID string"
|
1369
|
+
end
|
1370
|
+
end
|
1371
|
+
```
|
1372
|
+
|
1373
|
+
### 4. Array Attributes
|
1374
|
+
|
1375
|
+
Provide both bulk and individual manipulation methods:
|
1376
|
+
|
1377
|
+
```ruby
|
1378
|
+
def tags=(tags_array)
|
1379
|
+
self[:tags] = Array(tags_array)
|
1380
|
+
end
|
1381
|
+
|
1382
|
+
def tags
|
1383
|
+
self[:tags] || []
|
1384
|
+
end
|
1385
|
+
|
1386
|
+
def add_tag(tag)
|
1387
|
+
self[:tags] = (tags || []) << tag
|
1388
|
+
end
|
1389
|
+
|
1390
|
+
def remove_tag(tag)
|
1391
|
+
self[:tags] = tags - [tag]
|
1392
|
+
end
|
1393
|
+
|
1394
|
+
def has_tag?(tag)
|
1395
|
+
tags.include?(tag)
|
1396
|
+
end
|
1397
|
+
```
|
1398
|
+
|
1399
|
+
### 5. Search and Filtering
|
1400
|
+
|
1401
|
+
Use Attio's filter syntax properly:
|
1402
|
+
|
1403
|
+
```ruby
|
1404
|
+
# Text search
|
1405
|
+
def self.search(query, **opts)
|
1406
|
+
list(**opts.merge(
|
1407
|
+
filter: {
|
1408
|
+
"$or": [
|
1409
|
+
{ name: { "$contains": query } },
|
1410
|
+
{ description: { "$contains": query } }
|
1411
|
+
]
|
1412
|
+
}
|
1413
|
+
))
|
1414
|
+
end
|
1415
|
+
|
1416
|
+
# Reference filtering
|
1417
|
+
def self.find_by_owner(owner, **opts)
|
1418
|
+
owner_id = extract_id(owner)
|
1419
|
+
list(**opts.merge(
|
1420
|
+
filter: {
|
1421
|
+
owner: { "$references": owner_id }
|
1422
|
+
}
|
1423
|
+
))
|
1424
|
+
end
|
1425
|
+
|
1426
|
+
# Date range filtering
|
1427
|
+
def self.created_between(start_date, end_date, **opts)
|
1428
|
+
list(**opts.merge(
|
1429
|
+
filter: {
|
1430
|
+
"$and": [
|
1431
|
+
{ created_at: { "$gte": start_date.iso8601 } },
|
1432
|
+
{ created_at: { "$lte": end_date.iso8601 } }
|
1433
|
+
]
|
1434
|
+
}
|
1435
|
+
))
|
1436
|
+
end
|
1437
|
+
```
|
1438
|
+
|
1439
|
+
### 6. Error Handling
|
1440
|
+
|
1441
|
+
Always validate inputs and provide clear error messages:
|
1442
|
+
|
1443
|
+
```ruby
|
1444
|
+
def priority=(value)
|
1445
|
+
unless VALID_PRIORITIES.include?(value)
|
1446
|
+
raise ArgumentError, "Invalid priority: #{value}. Must be one of: #{VALID_PRIORITIES.join(', ')}"
|
1447
|
+
end
|
1448
|
+
self[:priority] = value
|
1449
|
+
end
|
1450
|
+
```
|
1451
|
+
|
1452
|
+
### 7. Computed Properties
|
1453
|
+
|
1454
|
+
Add methods that calculate values based on attributes:
|
1455
|
+
|
1456
|
+
```ruby
|
1457
|
+
def days_until_due
|
1458
|
+
return nil unless due_date
|
1459
|
+
(due_date - Date.today).to_i
|
1460
|
+
end
|
1461
|
+
|
1462
|
+
def completion_percentage
|
1463
|
+
return 0 unless total_tasks && completed_tasks
|
1464
|
+
((completed_tasks.to_f / total_tasks) * 100).round
|
1465
|
+
end
|
1466
|
+
```
|
1467
|
+
|
1468
|
+
## Integration with Other Records
|
1469
|
+
|
1470
|
+
Your custom records can reference and interact with other records:
|
1471
|
+
|
1472
|
+
```ruby
|
1473
|
+
class Deal < TypedRecord
|
1474
|
+
object_type "deals"
|
1475
|
+
|
1476
|
+
# Reference to a Company
|
1477
|
+
def account=(company)
|
1478
|
+
# ... handle Company reference
|
1479
|
+
end
|
1480
|
+
|
1481
|
+
# Reference to a Person
|
1482
|
+
def owner=(person)
|
1483
|
+
# ... handle Person reference
|
1484
|
+
end
|
1485
|
+
|
1486
|
+
# Get all activities related to this deal
|
1487
|
+
def activities(**opts)
|
1488
|
+
Activity.find_by_deal(self, **opts)
|
1489
|
+
end
|
1490
|
+
|
1491
|
+
# Create a related activity
|
1492
|
+
def create_activity(type:, description:, **opts)
|
1493
|
+
Activity.create(
|
1494
|
+
deal: self,
|
1495
|
+
type: type,
|
1496
|
+
description: description,
|
1497
|
+
**opts
|
1498
|
+
)
|
1499
|
+
end
|
1500
|
+
end
|
1501
|
+
|
1502
|
+
class Activity < TypedRecord
|
1503
|
+
object_type "activities"
|
1504
|
+
|
1505
|
+
# Reference back to deal
|
1506
|
+
def deal=(deal)
|
1507
|
+
if deal.is_a?(Deal)
|
1508
|
+
deal_id = deal.id.is_a?(Hash) ? deal.id["record_id"] : deal.id
|
1509
|
+
self[:deal] = [{
|
1510
|
+
target_object: "deals",
|
1511
|
+
target_record_id: deal_id
|
1512
|
+
}]
|
1513
|
+
elsif deal.is_a?(String)
|
1514
|
+
self[:deal] = [{
|
1515
|
+
target_object: "deals",
|
1516
|
+
target_record_id: deal
|
1517
|
+
}]
|
1518
|
+
end
|
1519
|
+
end
|
1520
|
+
|
1521
|
+
class << self
|
1522
|
+
def find_by_deal(deal, **opts)
|
1523
|
+
deal_id = case deal
|
1524
|
+
when Deal
|
1525
|
+
deal.id.is_a?(Hash) ? deal.id["record_id"] : deal.id
|
1526
|
+
when String
|
1527
|
+
deal
|
1528
|
+
end
|
1529
|
+
|
1530
|
+
list(**opts.merge(
|
1531
|
+
filter: {
|
1532
|
+
deal: { "$references": deal_id }
|
1533
|
+
}
|
1534
|
+
))
|
1535
|
+
end
|
1536
|
+
end
|
1537
|
+
end
|
1538
|
+
```
|
1539
|
+
|
1540
|
+
## Testing Your Custom Records
|
1541
|
+
|
1542
|
+
Here's an example of how to test your custom record class:
|
1543
|
+
|
1544
|
+
```ruby
|
1545
|
+
# spec/attio/resources/deal_spec.rb
|
1546
|
+
require "spec_helper"
|
1547
|
+
|
1548
|
+
RSpec.describe Attio::Deal do
|
1549
|
+
describe "object_type" do
|
1550
|
+
it "returns the correct object type" do
|
1551
|
+
expect(described_class.object_type).to eq("deals")
|
1552
|
+
end
|
1553
|
+
end
|
1554
|
+
|
1555
|
+
describe ".create" do
|
1556
|
+
it "creates a deal with all attributes" do
|
1557
|
+
deal = described_class.create(
|
1558
|
+
name: "Big Sale",
|
1559
|
+
amount: 100000,
|
1560
|
+
stage: "negotiation",
|
1561
|
+
close_date: "2024-12-31"
|
1562
|
+
)
|
1563
|
+
|
1564
|
+
expect(deal.name).to eq("Big Sale")
|
1565
|
+
expect(deal.amount).to eq(100000.0)
|
1566
|
+
expect(deal.stage).to eq("negotiation")
|
1567
|
+
expect(deal.close_date).to eq(Date.parse("2024-12-31"))
|
1568
|
+
end
|
1569
|
+
|
1570
|
+
it "validates stage values" do
|
1571
|
+
expect {
|
1572
|
+
described_class.new.stage = "invalid_stage"
|
1573
|
+
}.to raise_error(ArgumentError, /Invalid stage/)
|
1574
|
+
end
|
1575
|
+
end
|
1576
|
+
|
1577
|
+
describe "#weighted_value" do
|
1578
|
+
it "calculates weighted pipeline value" do
|
1579
|
+
deal = described_class.new
|
1580
|
+
deal.amount = 100000
|
1581
|
+
deal.probability = 75
|
1582
|
+
|
1583
|
+
expect(deal.weighted_value).to eq(75000.0)
|
1584
|
+
end
|
1585
|
+
end
|
1586
|
+
|
1587
|
+
describe ".closing_soon" do
|
1588
|
+
it "finds deals closing in the next N days" do
|
1589
|
+
VCR.use_cassette("deals_closing_soon") do
|
1590
|
+
deals = described_class.closing_soon(30)
|
1591
|
+
|
1592
|
+
expect(deals).to all(be_a(Attio::Deal))
|
1593
|
+
expect(deals).to all(satisfy { |d|
|
1594
|
+
d.close_date && d.close_date <= Date.today + 30
|
1595
|
+
})
|
1596
|
+
end
|
1597
|
+
end
|
1598
|
+
end
|
1599
|
+
end
|
1600
|
+
```
|
1601
|
+
|
1602
|
+
## Summary
|
1603
|
+
|
1604
|
+
Creating custom TypedRecord classes allows you to:
|
1605
|
+
|
1606
|
+
1. Work with your custom Attio objects using clean Ruby syntax
|
1607
|
+
2. Add business logic and computed properties
|
1608
|
+
3. Validate data before sending to the API
|
1609
|
+
4. Create intuitive query methods
|
1610
|
+
5. Handle complex relationships between objects
|
1611
|
+
6. Maintain consistency with the gem's built-in classes
|
1612
|
+
|
1613
|
+
The key is to follow the patterns established by the Person and Company classes while adapting them to your specific business needs.
|