moco-ruby 0.1.2 → 1.0.0

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.
@@ -0,0 +1,312 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/inflector" # Ensure ActiveSupport::Inflector is available
4
+
5
+ module MOCO
6
+ # Base class for all MOCO API entities
7
+ class BaseEntity
8
+ attr_reader :client, :attributes
9
+
10
+ # Initializes an entity instance from raw API response data (Hash).
11
+ # Recursively processes nested hashes and arrays, converting known
12
+ # entity structures into corresponding MOCO::Entity instances.
13
+ def initialize(client, response_data)
14
+ @client = client
15
+
16
+ # Ensure response_data is a Hash before proceeding
17
+ unless response_data.is_a?(Hash)
18
+ raise ArgumentError, "BaseEntity must be initialized with a Hash, got: #{response_data.class}"
19
+ end
20
+
21
+ # Process the top-level hash: transform keys and process nested values.
22
+ @attributes = response_data.transform_keys(&:to_sym)
23
+ .each_with_object({}) do |(k, v), acc|
24
+ # Use process_value only for the *values* within the hash
25
+ acc[k] = process_value(v, k)
26
+ end
27
+
28
+ # Define attribute methods based on the processed attributes hash
29
+ define_attribute_methods
30
+ end
31
+
32
+ # Provides a basic string representation (can be overridden by subclasses).
33
+ def to_s
34
+ "#{self.class.name.split("::").last} ##{id}"
35
+ end
36
+
37
+ # Returns the entity's ID.
38
+ def id
39
+ attributes[:id] || attributes["id"]
40
+ end
41
+
42
+ # Compares two entities based on class and ID.
43
+ def ==(other)
44
+ self.class == other.class && !id.nil? && id == other.id
45
+ end
46
+
47
+ # Converts the entity to a Hash, recursively converting nested entities.
48
+ def to_h
49
+ attributes.transform_values do |value|
50
+ case value
51
+ when BaseEntity then value.to_h
52
+ when Array then value.map { |item| item.is_a?(BaseEntity) ? item.to_h : item }
53
+ else value
54
+ end
55
+ end
56
+ end
57
+
58
+ # Converts the entity to a JSON string.
59
+ def to_json(*options)
60
+ to_h.to_json(*options)
61
+ end
62
+
63
+ # Provides a string representation of the entity.
64
+ def inspect
65
+ "#<#{self.class.name}:#{object_id} @attributes=#{@attributes.inspect}>"
66
+ end
67
+
68
+ # Saves changes to the entity back to the API.
69
+ # Returns self on success for method chaining.
70
+ def save
71
+ return self if id.nil? # Can't save without an ID
72
+
73
+ # Determine the collection name from the class name
74
+ collection_name = ActiveSupport::Inflector.tableize(self.class.name.split("::").last).to_sym
75
+
76
+ # Check if the client responds to the collection method
77
+ if client.respond_to?(collection_name)
78
+ # Use the collection proxy to update the entity
79
+ updated_data = client.send(collection_name).update(id, attributes)
80
+
81
+ # Update local attributes with the response data
82
+ @attributes = updated_data.attributes if updated_data
83
+ else
84
+ warn "Warning: Client does not respond to collection '#{collection_name}' for saving entity."
85
+ end
86
+
87
+ self # Return self for method chaining
88
+ end
89
+
90
+ # Updates attributes and saves the entity in one step.
91
+ # Returns self on success for method chaining.
92
+ def update(new_attributes)
93
+ # Update attributes
94
+ new_attributes.each do |key, value|
95
+ send("#{key}=", value) if respond_to?("#{key}=")
96
+ end
97
+
98
+ # Save changes
99
+ save
100
+ end
101
+
102
+ # Deletes the entity from the API.
103
+ # Returns true on success, false on failure.
104
+ def destroy
105
+ return false if id.nil? # Can't destroy without an ID
106
+
107
+ # Determine the collection name from the class name
108
+ collection_name = ActiveSupport::Inflector.tableize(self.class.name.split("::").last).to_sym
109
+
110
+ # Check if the client responds to the collection method
111
+ if client.respond_to?(collection_name)
112
+ # Use the collection proxy to delete the entity
113
+ client.send(collection_name).delete(id)
114
+ true
115
+ else
116
+ warn "Warning: Client does not respond to collection '#{collection_name}' for destroying entity."
117
+ false
118
+ end
119
+ end
120
+
121
+ # Reloads the entity from the API.
122
+ # Returns self on success for method chaining.
123
+ def reload
124
+ return self if id.nil? # Can't reload without an ID
125
+
126
+ # Determine the collection name from the class name
127
+ collection_name = ActiveSupport::Inflector.tableize(self.class.name.split("::").last).to_sym
128
+
129
+ # Check if the client responds to the collection method
130
+ if client.respond_to?(collection_name)
131
+ # Use the collection proxy to find the entity
132
+ reloaded = client.send(collection_name).find(id)
133
+
134
+ # Update attributes with the reloaded data
135
+ @attributes = reloaded.attributes if reloaded
136
+ else
137
+ warn "Warning: Client does not respond to collection '#{collection_name}' for reloading entity."
138
+ end
139
+
140
+ self # Return self for method chaining
141
+ end
142
+
143
+ # Helper method to fetch associated objects based on data in attributes.
144
+ # Uses memoization to avoid repeated API calls.
145
+ # association_name: Symbol representing the association (e.g., :project, :customer).
146
+ # target_class_name_override: String specifying the target class if it differs
147
+ # from the classified association name (e.g., "Company" for :customer).
148
+ def association(association_name, target_class_name_override = nil)
149
+ # Initialize cache if it doesn't exist
150
+ @_association_cache ||= {}
151
+ # Return cached object if available
152
+ return @_association_cache[association_name] if @_association_cache.key?(association_name)
153
+
154
+ association_data = attributes[association_name]
155
+
156
+ # If data is already a BaseEntity object (processed during initialization), use it directly.
157
+ return @_association_cache[association_name] = association_data if association_data.is_a?(MOCO::BaseEntity)
158
+
159
+ # If data is a hash containing an ID, fetch the object.
160
+ if association_data.is_a?(Hash) && association_data[:id]
161
+ assoc_id = association_data[:id]
162
+ target_class_name = target_class_name_override || ActiveSupport::Inflector.classify(association_name.to_s)
163
+ collection_name = ActiveSupport::Inflector.tableize(target_class_name).to_sym # e.g., "Project" -> :projects
164
+
165
+ # Check if the client responds to the collection method (e.g., client.projects)
166
+ if client.respond_to?(collection_name)
167
+ # Fetch the object using the appropriate collection proxy
168
+ fetched_object = client.send(collection_name).find(assoc_id)
169
+ return @_association_cache[association_name] = fetched_object
170
+ else
171
+ warn "Warning: Client does not respond to collection '#{collection_name}' for association '#{association_name}'."
172
+ return @_association_cache[association_name] = nil
173
+ end
174
+ end
175
+
176
+ # If data is not an object or a hash with an ID, return nil.
177
+ @_association_cache[association_name] = nil
178
+ end
179
+
180
+ # Helper method to fetch associated collections based on a foreign key.
181
+ # Uses memoization to avoid repeated API calls.
182
+ # association_name: Symbol representing the association (e.g., :tasks, :activities).
183
+ # foreign_key: Symbol representing the foreign key to use (e.g., :project_id).
184
+ # target_class_name_override: String specifying the target class if it differs
185
+ # from the classified association name (e.g., "Task" for :tasks).
186
+ # nested: Boolean indicating if this is a nested resource (e.g., project.tasks)
187
+ def has_many(association_name, foreign_key = nil, target_class_name_override = nil, nested = false)
188
+ # Initialize cache if it doesn't exist
189
+ @_association_cache ||= {}
190
+ # Return cached collection if available
191
+ return @_association_cache[association_name] if @_association_cache.key?(association_name)
192
+
193
+ # If the association data is already in attributes and is an array of entities
194
+ # AND this is NOT a nested resource, use it directly
195
+ # For nested resources, we always create a proxy to ensure CRUD operations work
196
+ association_data = attributes[association_name]
197
+ if !nested && association_data.is_a?(Array) && association_data.all? { |item| item.is_a?(MOCO::BaseEntity) }
198
+ return @_association_cache[association_name] = association_data
199
+ end
200
+
201
+ # Otherwise, fetch the collection from the API
202
+ target_class_name = target_class_name_override || ActiveSupport::Inflector.classify(association_name.to_s)
203
+ collection_name = ActiveSupport::Inflector.tableize(target_class_name).to_sym # e.g., "Task" -> :tasks
204
+
205
+ # Determine the foreign key to use
206
+ fk = foreign_key || :"#{ActiveSupport::Inflector.underscore(self.class.name.split("::").last)}_id"
207
+
208
+ # Check if this is a nested resource
209
+ if nested
210
+ # For nested resources, create a NestedCollectionProxy
211
+ @_association_cache[association_name] = MOCO::NestedCollectionProxy.new(
212
+ client,
213
+ self,
214
+ collection_name,
215
+ target_class_name
216
+ )
217
+ elsif client.respond_to?(collection_name)
218
+ # For regular resources, use the standard collection proxy with a filter
219
+ @_association_cache[association_name] = client.send(collection_name).where(fk => id)
220
+ else
221
+ warn "Warning: Client does not respond to collection '#{collection_name}' for association '#{association_name}'."
222
+ @_association_cache[association_name] = []
223
+ end
224
+ end
225
+
226
+ private
227
+
228
+ # Defines getter and setter methods for each key in the @attributes hash.
229
+ def define_attribute_methods
230
+ attributes.each_key do |key|
231
+ # Skip if the key is nil or a method with this name already exists.
232
+ next if key.nil? || respond_to?(key)
233
+
234
+ define_singleton_method(key) { attributes[key] }
235
+ define_singleton_method("#{key}=") { |v| attributes[key] = process_value(v, key) } # Process assigned value too
236
+ end
237
+ end
238
+
239
+ # Recursively processes a value from the API response.
240
+ # - Hashes representing known entities are converted to Entity instances.
241
+ # - Other Hashes have their values processed recursively.
242
+ # - Arrays have their items processed recursively.
243
+ # - Primitives are returned as is.
244
+ # key_hint: The key under which this value was found in its parent Hash.
245
+ def process_value(value, key_hint = nil)
246
+ case value
247
+ when Hash
248
+ # Check if this hash represents a known MOCO entity class
249
+ klass = entity_class_for(value, key_hint)
250
+ if klass
251
+ # If yes, create an instance of that class (recursive initialize)
252
+ klass.new(client, value)
253
+ else
254
+ # If no, treat as a generic hash: process its values recursively
255
+ value.transform_keys(&:to_sym)
256
+ .each_with_object({}) do |(k, v), acc|
257
+ # Pass the key 'k' as a hint for nested processing
258
+ acc[k] = process_value(v, k)
259
+ end
260
+ end
261
+ when Array
262
+ # Recursively process each item in the array.
263
+ # Pass a singularized key_hint if available (e.g., :tasks -> :task)
264
+ singular_hint = key_hint ? ActiveSupport::Inflector.singularize(key_hint.to_s).to_sym : nil
265
+ value.map { |item| process_value(item, singular_hint) }
266
+ else
267
+ # Return primitive values (String, Integer, Boolean, nil, etc.) as is
268
+ value
269
+ end
270
+ end
271
+
272
+ # Determines the specific MOCO::Entity class for a given hash, if possible.
273
+ # Returns the class constant or nil if no specific entity type is identified.
274
+ # data: The hash to analyze.
275
+ # key_hint: The key under which this hash was found in its parent.
276
+ def entity_class_for(data, key_hint = nil)
277
+ # data is always a Hash when called from process_value
278
+ type_name = data[:type] || data["type"]
279
+
280
+ # Infer type from the key_hint if :type attribute is missing
281
+ if type_name.nil? && key_hint
282
+ # Special cases: map certain keys to specific entity classes
283
+ type_name = case key_hint
284
+ when :customer
285
+ "Company"
286
+ when :leader, :co_leader, :user
287
+ "User"
288
+ else
289
+ # General case: singularize the key hint (e.g., :tasks -> "task")
290
+ ActiveSupport::Inflector.singularize(key_hint.to_s)
291
+ end
292
+ end
293
+
294
+ # If no type name could be determined, it's not a known entity structure
295
+ return nil unless type_name
296
+
297
+ # Convert type name (e.g., "project", "user", "company", "Task") to class name
298
+ # If type_name is already classified (like "Company" from the special case),
299
+ # classify won't hurt it.
300
+ class_name = ActiveSupport::Inflector.classify(type_name)
301
+
302
+ # Check if the class exists within the MOCO module and is a BaseEntity subclass
303
+ if MOCO.const_defined?(class_name, false)
304
+ klass = MOCO.const_get(class_name)
305
+ return klass if klass.is_a?(Class) && klass <= MOCO::BaseEntity
306
+ end
307
+
308
+ # Fallback: No matching entity class found
309
+ nil
310
+ end
311
+ end
312
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MOCO
4
+ # Represents a MOCO company (customer)
5
+ # Provides methods for company-specific associations
6
+ class Company < BaseEntity
7
+ # Associations
8
+ def projects
9
+ has_many(:projects)
10
+ end
11
+
12
+ def invoices
13
+ has_many(:invoices)
14
+ end
15
+
16
+ def deals
17
+ has_many(:deals)
18
+ end
19
+
20
+ def contacts
21
+ has_many(:contacts)
22
+ end
23
+
24
+ def to_s
25
+ name
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MOCO
4
+ # Represents a MOCO deal
5
+ # Provides methods for deal-specific associations
6
+ class Deal < BaseEntity
7
+ # Associations
8
+ def company
9
+ association(:company) || association(:customer, "Company")
10
+ end
11
+
12
+ def user
13
+ association(:user)
14
+ end
15
+
16
+ def category
17
+ association(:category, "DealCategory")
18
+ end
19
+
20
+ def to_s
21
+ "#{name} (#{company&.name})"
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MOCO
4
+ # Represents a MOCO expense
5
+ # Provides methods for expense-specific operations and associations
6
+ class Expense < BaseEntity
7
+ # Override entity_path to use the global expenses endpoint
8
+ # Note: Expenses can also be accessed via projects/{id}/expenses
9
+ def self.entity_path
10
+ "projects/expenses"
11
+ end
12
+
13
+ # Class methods for bulk operations
14
+ def self.disregard(client, expense_ids:)
15
+ client.post("projects/expenses/disregard", { expense_ids: })
16
+ end
17
+
18
+ def self.bulk_create(client, project_id, expenses)
19
+ client.post("projects/#{project_id}/expenses/bulk", { expenses: })
20
+ end
21
+
22
+ # Associations
23
+ def project
24
+ # Use the association method which handles embedded objects
25
+ association(:project, "Project")
26
+ end
27
+
28
+ def user
29
+ # Use the association method which handles embedded objects
30
+ association(:user, "User")
31
+ end
32
+
33
+ def to_s
34
+ "#{date} - #{title} (#{amount})"
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MOCO
4
+ # Represents a MOCO holiday entry
5
+ # Provides methods for holiday-specific associations
6
+ class Holiday < BaseEntity
7
+ # Override entity_path to match API path
8
+ def self.entity_path
9
+ "users/holidays"
10
+ end
11
+
12
+ # Associations
13
+ def user
14
+ @user ||= client.users.find(user_id) if user_id
15
+ end
16
+
17
+ def creator
18
+ @creator ||= client.users.find(creator_id) if creator_id
19
+ end
20
+
21
+ def to_s
22
+ "#{year} - #{title} - #{days} days (#{hours} hours) - #{user&.full_name}"
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MOCO
4
+ # Represents a MOCO invoice
5
+ # Provides methods for invoice-specific operations and associations
6
+ class Invoice < BaseEntity
7
+ # Instance methods for invoice-specific operations
8
+ def update_status(status)
9
+ client.put("invoices/#{id}/update_status", { status: })
10
+ self
11
+ end
12
+
13
+ def pdf
14
+ client.get("invoices/#{id}.pdf")
15
+ end
16
+
17
+ def timesheet
18
+ client.get("invoices/#{id}/timesheet")
19
+ end
20
+
21
+ def timesheet_pdf
22
+ client.get("invoices/#{id}/timesheet.pdf")
23
+ end
24
+
25
+ def expenses
26
+ client.get("invoices/#{id}/expenses")
27
+ end
28
+
29
+ def send_email(recipient:, subject:, text:, **options)
30
+ payload = {
31
+ recipient:,
32
+ subject:,
33
+ text:
34
+ }.merge(options)
35
+
36
+ client.post("invoices/#{id}/send_email", payload)
37
+ self
38
+ end
39
+
40
+ # Associations
41
+ def company
42
+ association(:customer, "Company")
43
+ end
44
+
45
+ def project
46
+ association(:project)
47
+ end
48
+
49
+ def to_s
50
+ "#{identifier} - #{title} (#{date})"
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MOCO
4
+ # Represents a MOCO planning entry
5
+ # Provides methods for planning entry-specific associations
6
+ class PlanningEntry < BaseEntity
7
+ # Associations
8
+ def user
9
+ @user ||= client.users.find(user_id) if user_id
10
+ end
11
+
12
+ def project
13
+ @project ||= client.projects.find(project_id) if project_id
14
+ end
15
+
16
+ def deal
17
+ @deal ||= client.deals.find(deal_id) if deal_id
18
+ end
19
+
20
+ def to_s
21
+ period = starts_on == ends_on ? starts_on : "#{starts_on} to #{ends_on}"
22
+ resource = project || deal
23
+ "#{period} - #{hours_per_day}h/day - #{user&.full_name} - #{resource&.name}"
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MOCO
4
+ # Represents a MOCO presence entry
5
+ # Provides methods for presence-specific operations and associations
6
+ class Presence < BaseEntity
7
+ # Define the specific API path for this entity as a class method
8
+ def self.entity_path
9
+ "users/presences"
10
+ end
11
+
12
+ # Class methods for special operations
13
+ def self.touch(client, is_home_office: false, override: nil)
14
+ payload = {}
15
+ payload[:is_home_office] = is_home_office if is_home_office
16
+ payload[:override] = override if override
17
+
18
+ client.post("users/presences/touch", payload)
19
+ end
20
+
21
+ # Associations
22
+ def user
23
+ @user ||= client.users.find(user_id) if user_id
24
+ end
25
+
26
+ def to_s
27
+ "#{date} - #{from} to #{to} - #{user&.full_name}"
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MOCO
4
+ class Project < BaseEntity
5
+ def customer
6
+ # Use the association method to fetch the customer
7
+ association(:customer, "Company")
8
+ end
9
+
10
+ def leader
11
+ # Use the association method to fetch the leader
12
+ association(:leader, "User")
13
+ end
14
+
15
+ def co_leader
16
+ # Use the association method to fetch the co_leader
17
+ association(:co_leader, "User")
18
+ end
19
+
20
+ # Fetches activities associated with this project.
21
+ def activities
22
+ # Use the has_many method to fetch activities
23
+ has_many(:activities)
24
+ end
25
+
26
+ # Fetches expenses associated with this project.
27
+ def expenses
28
+ # Don't cache the proxy - create a fresh one each time
29
+ # This ensures we get fresh data when expenses are created/updated/deleted
30
+ MOCO::NestedCollectionProxy.new(client, self, :expenses, "Expense")
31
+ end
32
+
33
+ # Fetches tasks associated with this project.
34
+ def tasks
35
+ # Don't cache the proxy - create a fresh one each time
36
+ # This ensures we get fresh data when tasks are created/updated/deleted
37
+ MOCO::NestedCollectionProxy.new(client, self, :tasks, "Task")
38
+ end
39
+
40
+ def to_s
41
+ "Project #{identifier} \"#{name}\" (#{id})"
42
+ end
43
+
44
+ def active?
45
+ status == "active"
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MOCO
4
+ # Represents a MOCO schedule entry
5
+ # Provides methods for schedule-specific associations
6
+ class Schedule < BaseEntity
7
+ # Associations
8
+ def user
9
+ @user ||= client.users.find(user_id) if user_id
10
+ end
11
+
12
+ def assignment
13
+ return nil unless assignment_id
14
+
15
+ @assignment ||= if assignment_type == "Absence"
16
+ client.absences.find(assignment_id)
17
+ else
18
+ client.projects.find(assignment_id)
19
+ end
20
+ end
21
+
22
+ def to_s
23
+ "#{date} - #{user&.full_name} - #{assignment&.name}"
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MOCO
4
+ # Represents a MOCO task
5
+ # Provides methods for task-specific associations
6
+ class Task < BaseEntity
7
+ # Associations
8
+ def project
9
+ @project ||= client.projects.find(project_id) if project_id
10
+ end
11
+
12
+ def activities
13
+ client.activities.where(task_id: id)
14
+ end
15
+
16
+ def to_s
17
+ name
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MOCO
4
+ # Represents a MOCO user
5
+ # Provides methods for user-specific operations and associations
6
+ class User < BaseEntity
7
+ # Instance methods for user-specific operations
8
+ def performance_report
9
+ client.get("users/#{id}/performance_report")
10
+ end
11
+
12
+ # Associations
13
+ def activities
14
+ has_many(:activities)
15
+ end
16
+
17
+ def presences
18
+ has_many(:presences)
19
+ end
20
+
21
+ def holidays
22
+ has_many(:holidays)
23
+ end
24
+
25
+ def full_name
26
+ "#{firstname} #{lastname}"
27
+ end
28
+
29
+ def to_s
30
+ full_name
31
+ end
32
+ end
33
+ end