moco-ruby 1.0.0.alpha → 1.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cda64cf9e66f0409e61b5fc1b0ad5b0bc440acfe3e1202d9baab08c87c21d6b1
4
- data.tar.gz: cc6e0952ee9c89a4ac73f08c0093f1723563346f8bd2cfec27901faeed943ae8
3
+ metadata.gz: 5e60f0a59b12881fc99571967ff77dd1ae8c55ff8f3d45cd467bed9e6bf85c5d
4
+ data.tar.gz: a2070fba649482eb59f4ab96367cc5d8f12b5bfb0f6923bc7e7f6d2c69135566
5
5
  SHA512:
6
- metadata.gz: f3bc2329dca452e3efbecc83b015532f5f32102d4e26a8bd787e036df3ac2384ac6f3ae95b0a67999cb9858197357cc0ee5beb744ad07168268e3067d6f774e7
7
- data.tar.gz: 8189ab5b3015b78d1c93c0231497283e1d1d89ef1673c02d0762f161fcb6d277e4350f052fe799b80993b14cd645db0290ee0379c9455c840abe3646afa54415
6
+ metadata.gz: 867202c4431ae5a1d86fe9a8b18451c4cff6465d2119f5e9738d8a1b3018c6b81c726c00860762cf97ad233632df61bc38588ee0cf2d468f9516e36d65d3ed9f
7
+ data.tar.gz: 5a7cebee47463f888880ae2f9c0fa672e294adb2a0a1abcf3f098cfe241ac35eb84091607c035d36658cf5eb386880f3fc7f2db2abd0c5de8668c9ae72622333
data/CHANGELOG.md CHANGED
@@ -1,7 +1,43 @@
1
- # Changelog
1
+ # # Changelog
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.0.0] - 2025-10-08
6
+
7
+ ### Fixed
8
+ - Fixed Project `leader` and `co_leader` associations to return User objects instead of Hashes
9
+ - Fixed Expense associations to use proper `association()` method for embedded objects
10
+ - Fixed nested resource proxy caching issue - tasks and expenses now always return fresh data
11
+ - Fixed `NestedCollectionProxy` path construction for expenses (was double-prefixing with "projects/")
12
+ - Fixed `entity_path` warnings by converting instance methods to class methods in Holiday and WebHook classes
13
+ - Fixed embedded entity conversion for leader, co_leader, and user associations in BaseEntity
14
+
15
+ ### Added
16
+ - Added comprehensive test suite using test-unit framework:
17
+ - `test/test_comprehensive.rb` - 27 integration tests covering all CRUD operations
18
+ - `test/test_holidays_expenses.rb` - 9 tests for holidays and expenses (nested resources)
19
+ - `test/test_v2_api.rb` - 4 unit tests with mocked API responses
20
+ - `test/test_helper.rb` - Shared test configuration
21
+ - Added `Project#expenses` method for nested expense access
22
+ - Added proper entity type mappings for `:leader`, `:co_leader`, and `:user` in BaseEntity
23
+ - Added dotenv gem as development dependency for test environment configuration
24
+
25
+ ### Changed
26
+ - Moved all `require_relative` statements out of methods and into file-level requires
27
+ - Improved load order in `lib/moco.rb` - core classes now load before entities
28
+ - Updated `Expense` entity to use `association()` method for project and user relationships
29
+ - Refactored nested resource access in Project class to return fresh proxies instead of cached ones
30
+ - Enhanced `NestedCollectionProxy` to properly handle nested resource paths without double-prefixing
31
+
32
+ ### Removed
33
+ - Removed manual test scripts (converted to proper test-unit tests)
34
+
35
+ ## [1.0.0.beta] - 2025-04-10
36
+
37
+ ### Fixed
38
+ - Fixed activity synchronization to properly identify existing activities in target system
39
+ - Added remote_id accessor to Activity class to prevent duplicate activity creation
40
+
5
41
  ## [1.0.0.alpha] - 2025-04-10
6
42
 
7
43
  ### Added
@@ -10,7 +46,7 @@
10
46
  - Supports proper path construction for nested API endpoints
11
47
  - Implements `destroy_all` method for bulk deletion of nested resources
12
48
 
13
- ## [1.0.0] - 2025-04-10
49
+ ## [1.0.0.alpha-initial] - 2025-04-10
14
50
 
15
51
  ### Added
16
52
  - Implemented ActiveRecord-style query interface (`where`, `find`, `find_by`, `first`, `all`, `each`) via `CollectionProxy`.
@@ -89,9 +125,11 @@
89
125
  ## [0.1.0] - 2024-02-27
90
126
  - Initial release
91
127
 
92
- [Unreleased]: https://github.com/starsong-consulting/moco-ruby/compare/v1.0.0.alpha...HEAD
93
- [1.0.0.alpha]: https://github.com/starsong-consulting/moco-ruby/compare/v1.0.0...v1.0.0.alpha
94
- [1.0.0]: https://github.com/starsong-consulting/moco-ruby/compare/v0.1.2...v1.0.0
128
+ [Unreleased]: https://github.com/starsong-consulting/moco-ruby/compare/v1.0.0...HEAD
129
+ [1.0.0]: https://github.com/starsong-consulting/moco-ruby/compare/v1.0.0.beta...v1.0.0
130
+ [1.0.0.beta]: https://github.com/starsong-consulting/moco-ruby/compare/v1.0.0.alpha...v1.0.0.beta
131
+ [1.0.0.alpha]: https://github.com/starsong-consulting/moco-ruby/compare/v1.0.0.alpha-initial...v1.0.0.alpha
132
+ [1.0.0.alpha-initial]: https://github.com/starsong-consulting/moco-ruby/compare/v0.1.2...v1.0.0.alpha-initial
95
133
  [0.1.2]: https://github.com/starsong-consulting/moco-ruby/compare/v0.1.1...v0.1.2
96
134
  [0.1.1]: https://github.com/starsong-consulting/moco-ruby/compare/v0.1.0...v0.1.1
97
135
  [0.1.0]: https://github.com/starsong-consulting/moco-ruby/releases/tag/v0.1.0
data/Gemfile CHANGED
@@ -8,3 +8,4 @@ gem "rake", "~> 13.0"
8
8
  gem "rubocop", "~> 1.21"
9
9
  gem "test-unit", "~> 3.5"
10
10
  gem "webmock", "~> 3.18"
11
+ gem "dotenv", "~> 2.8"
data/Gemfile.lock CHANGED
@@ -1,9 +1,9 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- moco-ruby (1.0.0.alpha)
5
- activesupport (~> 7.0)
6
- faraday (~> 2.9.0)
4
+ moco-ruby (1.0.0)
5
+ activesupport (>= 7.0)
6
+ faraday (>= 2.0)
7
7
  fuzzy_match (~> 2.1.0)
8
8
 
9
9
  GEM
@@ -32,6 +32,7 @@ GEM
32
32
  crack (1.0.0)
33
33
  bigdecimal
34
34
  rexml
35
+ dotenv (2.8.1)
35
36
  drb (2.2.1)
36
37
  faraday (2.9.2)
37
38
  faraday-net_http (>= 2.0, < 3.2)
@@ -59,7 +60,7 @@ GEM
59
60
  rainbow (3.1.1)
60
61
  rake (13.2.1)
61
62
  regexp_parser (2.10.0)
62
- rexml (3.4.1)
63
+ rexml (3.4.4)
63
64
  rubocop (1.75.2)
64
65
  json (~> 2.3)
65
66
  language_server-protocol (~> 3.17.0.2)
@@ -94,6 +95,7 @@ PLATFORMS
94
95
  arm64-darwin-23
95
96
 
96
97
  DEPENDENCIES
98
+ dotenv (~> 2.8)
97
99
  moco-ruby!
98
100
  rake (~> 13.0)
99
101
  rubocop (~> 1.21)
data/README.md CHANGED
@@ -226,15 +226,51 @@ Usage: sync_activity.rb [options] source_subdomain target_subdomain
226
226
  --match-project-threshold VALUE
227
227
  Fuzzy match threshold for projects (0.0 - 1.0), default 0.8
228
228
  --match-task-threshold VALUE Fuzzy match threshold for tasks (0.0 - 1.0), default 0.45
229
+ --default-task TASK_NAME Map unmatched tasks to this default task instead of creating new tasks
230
+ -d, --debug Enable debug output
229
231
  -h, --help Show this message
230
232
  ```
231
233
  **Example:** `sync_activity.rb --from 2024-04-01 --to 2024-04-10 --dry-run source-instance target-instance`
232
234
 
235
+ **Using Default Task Mapping:** If your target account has limited permissions and cannot create tasks, or if you want to consolidate multiple source tasks into a single target task, use the `--default-task` flag:
236
+
237
+ ```bash
238
+ sync_activity.rb --from 2024-04-01 --to 2024-04-10 --default-task "Other" source-instance target-instance
239
+ ```
240
+
241
+ This will map any unmatched source tasks to a task named "Other" in the corresponding target project, avoiding the need to create new tasks.
242
+
233
243
  ## Development
234
244
 
235
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
245
+ After checking out the repo, run `bin/setup` to install dependencies.
246
+
247
+ ### Running Tests
248
+
249
+ The gem includes a comprehensive test suite with both unit tests (mocked) and integration tests (live API):
250
+
251
+ ```bash
252
+ # Run all tests
253
+ ruby test/test_v2_api.rb # Unit tests (mocked, fast)
254
+ ruby test/test_comprehensive.rb # Integration tests (requires .env)
255
+ ruby test/test_holidays_expenses.rb # Holidays & Expenses tests (requires .env)
256
+
257
+ # Or run individually
258
+ ruby test/test_v2_api.rb
259
+ ```
260
+
261
+ For integration tests, create a `.env` file with your test instance credentials:
262
+ ```
263
+ MOCO_API_TEST_SUBDOMAIN=your-test-subdomain
264
+ MOCO_API_TEST_API_KEY=your-test-api-key
265
+ ```
266
+
267
+ **Note:** The MOCO API has rate limits (120 requests per 2 minutes on standard plans). Integration tests make real API calls.
268
+
269
+ ### Installation
270
+
271
+ To install this gem onto your local machine, run `bundle exec rake install`.
236
272
 
237
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
273
+ To release a new version, update the version number in `version.rb`, update the `CHANGELOG.md`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
238
274
 
239
275
  ## Contributing
240
276
 
data/copy_project.rb ADDED
@@ -0,0 +1,337 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "optparse"
5
+ require "yaml"
6
+ require "fuzzy_match"
7
+ require_relative "lib/moco"
8
+
9
+ options = {
10
+ dry_run: false,
11
+ verbose: false,
12
+ copy_activities: true
13
+ }
14
+
15
+ OptionParser.new do |opts|
16
+ opts.banner = "Usage: #{$PROGRAM_NAME} [options] source_instance target_instance project_identifier"
17
+
18
+ opts.on("-n", "--dry-run", "Show what would be copied without making changes") do
19
+ options[:dry_run] = true
20
+ end
21
+
22
+ opts.on("-v", "--verbose", "Enable verbose output") do
23
+ options[:verbose] = true
24
+ end
25
+
26
+ opts.on("--no-activities", "Skip copying activities (only copy project and tasks)") do
27
+ options[:copy_activities] = false
28
+ end
29
+
30
+ opts.on("-h", "--help", "Show this message") do
31
+ puts opts
32
+ exit
33
+ end
34
+ end.parse!
35
+
36
+ source_instance = ARGV.shift
37
+ target_instance = ARGV.shift
38
+ project_identifier = ARGV.shift
39
+
40
+ if source_instance.nil? || target_instance.nil? || project_identifier.nil?
41
+ warn "Error: source_instance, target_instance, and project_identifier are required"
42
+ warn "Usage: #{$PROGRAM_NAME} [options] source_instance target_instance project_identifier"
43
+ exit 1
44
+ end
45
+
46
+ # Load configuration
47
+ config = YAML.load_file("config.yml")
48
+ source_config = config["instances"].fetch(source_instance, nil)
49
+ target_config = config["instances"].fetch(target_instance, nil)
50
+
51
+ if source_config.nil?
52
+ warn "Error: Source instance '#{source_instance}' not found in config.yml"
53
+ exit 1
54
+ end
55
+
56
+ if target_config.nil?
57
+ warn "Error: Target instance '#{target_instance}' not found in config.yml"
58
+ exit 1
59
+ end
60
+
61
+ # Initialize clients
62
+ puts "Connecting to instances..."
63
+ source_client = MOCO::Client.new(subdomain: source_instance, api_key: source_config["api_key"])
64
+ target_client = MOCO::Client.new(subdomain: target_instance, api_key: target_config["api_key"])
65
+
66
+ def log(message, verbose: false, options:)
67
+ return if verbose && !options[:verbose]
68
+ puts message
69
+ end
70
+
71
+ def get_id(obj)
72
+ return nil if obj.nil?
73
+ return obj[:id] if obj.is_a?(Hash)
74
+ return obj.id if obj.respond_to?(:id)
75
+ nil
76
+ end
77
+
78
+ def find_or_create_customer(source_customer, target_client, options)
79
+ log("Looking for customer '#{source_customer.name}' in target...", options: options)
80
+
81
+ # Try to find the customer by name
82
+ target_customers = target_client.companies.all
83
+ target_customer = target_customers.find { |c| c.name == source_customer.name }
84
+
85
+ if target_customer
86
+ log("✅ Found existing customer: #{target_customer.name} (ID: #{target_customer.id})", options: options)
87
+ return target_customer
88
+ end
89
+
90
+ log("Customer not found, creating new customer...", options: options)
91
+
92
+ if options[:dry_run]
93
+ log("🔍 [DRY RUN] Would create customer: #{source_customer.name}", options: options)
94
+ return nil
95
+ end
96
+
97
+ # Create the customer
98
+ customer_attrs = {
99
+ name: source_customer.name,
100
+ type: "customer"
101
+ }
102
+
103
+ # Add optional attributes if they exist
104
+ customer_attrs[:type] = source_customer.type if source_customer.respond_to?(:type) && source_customer.type
105
+ customer_attrs[:currency] = source_customer.currency if source_customer.respond_to?(:currency) && source_customer.currency
106
+ customer_attrs[:website] = source_customer.website if source_customer.respond_to?(:website) && source_customer.website
107
+ customer_attrs[:address] = source_customer.address if source_customer.respond_to?(:address) && source_customer.address
108
+ customer_attrs[:info] = source_customer.info if source_customer.respond_to?(:info) && source_customer.info
109
+ customer_attrs[:custom_properties] = source_customer.custom_properties if source_customer.respond_to?(:custom_properties) && source_customer.custom_properties
110
+ customer_attrs[:labels] = source_customer.labels if source_customer.respond_to?(:labels) && source_customer.labels
111
+
112
+ log("Creating customer: #{customer_attrs[:name]}", verbose: true, options: options)
113
+ new_customer = target_client.companies.create(customer_attrs)
114
+ log("✅ Created customer: #{new_customer.name} (ID: #{new_customer.id})", options: options)
115
+
116
+ new_customer
117
+ end
118
+
119
+ def copy_tasks(source_project, target_project, target_client, options)
120
+ log("\nCopying tasks...", options: options)
121
+ task_mapping = {}
122
+
123
+ source_tasks = source_project.tasks
124
+ log("Found #{source_tasks.count} tasks in source project", options: options)
125
+
126
+ source_tasks.each do |source_task|
127
+ log(" Task: #{source_task.name}", verbose: true, options: options)
128
+
129
+ if options[:dry_run]
130
+ log(" 🔍 [DRY RUN] Would create task: #{source_task.name}", options: options)
131
+ next
132
+ end
133
+
134
+ # Create the task in the target project
135
+ task_attrs = {
136
+ name: source_task.name,
137
+ billable: source_task.billable,
138
+ active: source_task.active
139
+ }
140
+
141
+ # Add optional attributes if they exist
142
+ source_task_attrs = source_task.instance_variable_get(:@attributes)
143
+ task_attrs[:budget] = source_task_attrs[:budget] if source_task_attrs[:budget]
144
+ task_attrs[:hourly_rate] = source_task_attrs[:hourly_rate] if source_task_attrs[:hourly_rate]
145
+
146
+ log(" Creating task: #{task_attrs[:name]}", verbose: true, options: options)
147
+ # Use the NestedCollectionProxy to create the task
148
+ task_proxy = MOCO::NestedCollectionProxy.new(target_client, target_project, :tasks, "Task")
149
+ new_task = task_proxy.create(task_attrs)
150
+ task_mapping[source_task.id] = new_task
151
+ log(" ✅ Created task: #{new_task.name} (ID: #{new_task.id})", options: options)
152
+ end
153
+
154
+ task_mapping
155
+ end
156
+
157
+ def copy_activities(source_project, target_project, task_mapping, source_client, target_client, options)
158
+ log("\nCopying activities...", options: options)
159
+
160
+ # Get all activities for the source project
161
+ source_activities = source_client.activities.where(project_id: source_project.id).all
162
+ log("Found #{source_activities.count} activities in source project", options: options)
163
+
164
+ created_count = 0
165
+ skipped_count = 0
166
+
167
+ source_activities.each do |source_activity|
168
+ log(" Activity: #{source_activity.date} - #{source_activity.hours}h - #{source_activity.description}", verbose: true, options: options)
169
+
170
+ if options[:dry_run]
171
+ log(" 🔍 [DRY RUN] Would create activity: #{source_activity.date} - #{source_activity.hours}h", options: options)
172
+ next
173
+ end
174
+
175
+ # Map the task
176
+ target_task = task_mapping[source_activity.task&.id]
177
+
178
+ if source_activity.task && !target_task
179
+ log(" ⚠️ Skipping activity - task not mapped: #{source_activity.task.name}", options: options)
180
+ skipped_count += 1
181
+ next
182
+ end
183
+
184
+ # Create the activity in the target project
185
+ activity_attrs = {
186
+ date: source_activity.date,
187
+ hours: source_activity.hours,
188
+ description: source_activity.description,
189
+ project_id: target_project.id,
190
+ billable: source_activity.billable,
191
+ tag: source_activity.tag,
192
+ remote_service: source_activity.remote_service,
193
+ remote_id: source_activity.id.to_s # Store original ID for reference
194
+ }
195
+
196
+ activity_attrs[:task_id] = target_task.id if target_task
197
+
198
+ log(" Creating activity: #{activity_attrs[:date]} - #{activity_attrs[:hours]}h", verbose: true, options: options)
199
+
200
+ begin
201
+ new_activity = target_client.activities.create(activity_attrs)
202
+ created_count += 1
203
+ log(" ✅ Created activity: #{new_activity.date} - #{new_activity.hours}h (ID: #{new_activity.id})", verbose: true, options: options)
204
+ rescue => e
205
+ log(" ❌ Error creating activity: #{e.message}", options: options)
206
+ skipped_count += 1
207
+ end
208
+ end
209
+
210
+ log("\n✅ Created #{created_count} activities", options: options)
211
+ log("⚠️ Skipped #{skipped_count} activities", options: options) if skipped_count > 0
212
+ end
213
+
214
+ # Main execution
215
+ begin
216
+ log("=" * 80, options: options)
217
+ log("MOCO Project Copy Tool", options: options)
218
+ log("=" * 80, options: options)
219
+ log("Source: #{source_instance}", options: options)
220
+ log("Target: #{target_instance}", options: options)
221
+ log("Project: #{project_identifier}", options: options)
222
+ log("Mode: #{options[:dry_run] ? 'DRY RUN' : 'LIVE'}", options: options)
223
+ log("=" * 80, options: options)
224
+
225
+ # Find the source project
226
+ log("\nFinding source project...", options: options)
227
+ source_project = source_client.projects.where(identifier: project_identifier).all.first
228
+
229
+ if source_project.nil?
230
+ warn "Error: Project '#{project_identifier}' not found in source instance"
231
+ exit 1
232
+ end
233
+
234
+ log("✅ Found project: #{source_project.name} (ID: #{source_project.id})", options: options)
235
+ log(" Identifier: #{source_project.identifier}", verbose: true, options: options)
236
+ log(" Status: #{source_project.active ? 'Active' : 'Inactive'}", verbose: true, options: options)
237
+
238
+ # Get the customer
239
+ source_customer = source_project.customer
240
+ if source_customer.nil?
241
+ warn "Error: Project has no associated customer"
242
+ exit 1
243
+ end
244
+
245
+ log(" Customer: #{source_customer.name} (ID: #{source_customer.id})", options: options)
246
+
247
+ # Find or create the customer in the target
248
+ target_customer = find_or_create_customer(source_customer, target_client, options)
249
+
250
+ if options[:dry_run]
251
+ log("\n🔍 [DRY RUN] Would create project: #{source_project.name}", options: options)
252
+ log(" Identifier: #{source_project.identifier}", options: options)
253
+ log(" Customer: #{source_customer.name}", options: options)
254
+
255
+ # Still show what tasks would be copied
256
+ copy_tasks(source_project, source_project, target_client, options)
257
+
258
+ if options[:copy_activities]
259
+ log("\n🔍 [DRY RUN] Would copy activities...", options: options)
260
+ source_activities = source_client.activities.where(project_id: source_project.id).all
261
+ log(" Found #{source_activities.count} activities to copy", options: options)
262
+ end
263
+
264
+ log("\n" + "=" * 80, options: options)
265
+ log("DRY RUN COMPLETE - No changes made", options: options)
266
+ log("=" * 80, options: options)
267
+ exit 0
268
+ end
269
+
270
+ # Create the project in the target
271
+ log("\nCreating project in target...", options: options)
272
+
273
+ # Access attributes directly from the source project
274
+ source_attrs = source_project.instance_variable_get(:@attributes)
275
+
276
+ project_attrs = {
277
+ name: source_project.name,
278
+ identifier: source_project.identifier,
279
+ customer_id: target_customer.id,
280
+ currency: source_project.currency,
281
+ billable: source_project.billable,
282
+ fixed_price: source_attrs[:fixed_price] || false,
283
+ retainer: source_attrs[:retainer] || false,
284
+ finish_date: source_attrs[:finish_date],
285
+ start_date: source_attrs[:start_date]
286
+ }
287
+
288
+ # Get a default user from the target instance as leader
289
+ # TODO: implement proper user mapping between instances (match by name/email)
290
+ target_users = target_client.users.all
291
+ if target_users.empty?
292
+ raise "No users found in target instance - cannot set project leader"
293
+ end
294
+ project_attrs[:leader_id] = target_users.first.id
295
+ log("Using default leader: #{target_users.first.firstname} #{target_users.first.lastname} (ID: #{target_users.first.id})", verbose: true, options: options)
296
+
297
+ # Add optional attributes if they exist
298
+ project_attrs[:co_leader_id] = get_id(source_attrs[:co_leader]) if source_attrs[:co_leader]
299
+ project_attrs[:budget] = source_attrs[:budget] if source_attrs[:budget]
300
+ project_attrs[:budget_monthly] = source_attrs[:budget_monthly] if source_attrs[:budget_monthly]
301
+ project_attrs[:budget_expenses] = source_attrs[:budget_expenses] if source_attrs[:budget_expenses]
302
+ project_attrs[:hourly_rate] = source_attrs[:hourly_rate] if source_attrs[:hourly_rate]
303
+ project_attrs[:custom_properties] = source_attrs[:custom_properties] if source_attrs[:custom_properties]
304
+ project_attrs[:labels] = source_attrs[:labels] if source_attrs[:labels]
305
+ project_attrs[:tags] = source_attrs[:tags] if source_attrs[:tags]
306
+ project_attrs[:info] = source_attrs[:info] if source_attrs[:info]
307
+ project_attrs[:billing_address] = source_attrs[:billing_address] if source_attrs[:billing_address]
308
+ project_attrs[:billing_variant] = source_attrs[:billing_variant] if source_attrs[:billing_variant]
309
+
310
+ log("Creating project: #{project_attrs[:name]}", verbose: true, options: options)
311
+ target_project = target_client.projects.create(project_attrs)
312
+ log("✅ Created project: #{target_project.name} (ID: #{target_project.id})", options: options)
313
+
314
+ # Copy tasks
315
+ task_mapping = copy_tasks(source_project, target_project, target_client, options)
316
+
317
+ # Copy activities if requested
318
+ if options[:copy_activities]
319
+ copy_activities(source_project, target_project, task_mapping, source_client, target_client, options)
320
+ else
321
+ log("\nSkipping activities (--no-activities specified)", options: options)
322
+ end
323
+
324
+ log("\n" + "=" * 80, options: options)
325
+ log("✅ PROJECT COPY COMPLETE", options: options)
326
+ log("=" * 80, options: options)
327
+ log("Source project: #{source_project.name} (#{source_instance})", options: options)
328
+ log("Target project: #{target_project.name} (#{target_instance})", options: options)
329
+ log("Target project ID: #{target_project.id}", options: options)
330
+ log("Target project URL: https://#{target_instance}.mocoapp.com/projects/#{target_project.id}", options: options)
331
+ log("=" * 80, options: options)
332
+
333
+ rescue => e
334
+ warn "\n❌ Error: #{e.message}"
335
+ warn e.backtrace.join("\n") if options[:verbose]
336
+ exit 1
337
+ end
data/lib/moco/client.rb CHANGED
@@ -6,8 +6,8 @@ module MOCO
6
6
  class Client
7
7
  attr_reader :connection
8
8
 
9
- def initialize(subdomain:, api_key:)
10
- @connection = Connection.new(self, subdomain, api_key)
9
+ def initialize(subdomain:, api_key:, debug: false)
10
+ @connection = Connection.new(self, subdomain, api_key, debug: debug)
11
11
  @collections = {}
12
12
  end
13
13
 
@@ -54,6 +54,13 @@ module MOCO
54
54
  self
55
55
  end
56
56
 
57
+ # Modifies the base path to fetch assigned resources. Returns self.
58
+ def assigned
59
+ # Ensure this is only called once or handle idempotency if needed
60
+ @base_path += "/assigned"
61
+ self
62
+ end
63
+
57
64
  # --- Methods Triggering API Call ---
58
65
 
59
66
  # Fetches all records matching the current filters.
@@ -130,6 +137,9 @@ module MOCO
130
137
  # MOCO API might use 'per_page' instead of 'limit' for pagination control
131
138
  # Adjust if necessary based on API docs. Assuming 'limit' works for now.
132
139
 
140
+ # Filter out nil values before sending to avoid empty params like ?from&to=
141
+ query_params.compact!
142
+
133
143
  response = client.get(@base_path, query_params)
134
144
  @records = wrap_response(response) # wrap_response should return an Array here
135
145
  @loaded = true
@@ -7,12 +7,13 @@ module MOCO
7
7
  # Handles HTTP communication with the MOCO API
8
8
  # Responsible for building API requests and converting responses to entity objects
9
9
  class Connection
10
- attr_reader :client, :subdomain, :api_key
10
+ attr_reader :client, :subdomain, :api_key, :debug
11
11
 
12
- def initialize(client, subdomain, api_key)
12
+ def initialize(client, subdomain, api_key, debug: false)
13
13
  @client = client
14
14
  @subdomain = subdomain
15
15
  @api_key = api_key
16
+ @debug = debug
16
17
  @conn = Faraday.new do |f|
17
18
  f.request :json
18
19
  f.response :json
@@ -25,6 +26,11 @@ module MOCO
25
26
  # These methods send the request and return the raw parsed JSON response body.
26
27
  %w[get post put patch delete].each do |http_method|
27
28
  define_method(http_method) do |path, params = {}|
29
+ # Log URL if debug is enabled
30
+ if @debug
31
+ full_url = @conn.build_url(path, params).to_s
32
+ warn "[DEBUG] Fetching URL: #{http_method.upcase} #{full_url}"
33
+ end
28
34
  response = @conn.send(http_method, path, params)
29
35
 
30
36
  # Raise an error for non-successful responses
@@ -89,8 +89,13 @@ module MOCO
89
89
  end
90
90
  end
91
91
 
92
+ # Access the remote_id attribute
93
+ def remote_id
94
+ attributes[:remote_id]
95
+ end
96
+
92
97
  def to_s
93
- "#{date} - #{hours}h - #{project&.name} - #{task&.name} - #{description}"
98
+ "#{attributes[:date]} - #{attributes[:hours]}h - #{project&.name} - #{task&.name} - #{attributes[:description]}"
94
99
  end
95
100
  end
96
101
  end
@@ -29,6 +29,11 @@ module MOCO
29
29
  define_attribute_methods
30
30
  end
31
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
+
32
37
  # Returns the entity's ID.
33
38
  def id
34
39
  attributes[:id] || attributes["id"]
@@ -185,9 +190,11 @@ module MOCO
185
190
  # Return cached collection if available
186
191
  return @_association_cache[association_name] if @_association_cache.key?(association_name)
187
192
 
188
- # If the association data is already in attributes and is an array of entities, use it directly
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
189
196
  association_data = attributes[association_name]
190
- if association_data.is_a?(Array) && association_data.all? { |item| item.is_a?(MOCO::BaseEntity) }
197
+ if !nested && association_data.is_a?(Array) && association_data.all? { |item| item.is_a?(MOCO::BaseEntity) }
191
198
  return @_association_cache[association_name] = association_data
192
199
  end
193
200
 
@@ -201,7 +208,6 @@ module MOCO
201
208
  # Check if this is a nested resource
202
209
  if nested
203
210
  # For nested resources, create a NestedCollectionProxy
204
- require_relative "../nested_collection_proxy"
205
211
  @_association_cache[association_name] = MOCO::NestedCollectionProxy.new(
206
212
  client,
207
213
  self,
@@ -273,9 +279,12 @@ module MOCO
273
279
 
274
280
  # Infer type from the key_hint if :type attribute is missing
275
281
  if type_name.nil? && key_hint
276
- # Special case: map :customer key to Company class
277
- type_name = if key_hint == :customer
282
+ # Special cases: map certain keys to specific entity classes
283
+ type_name = case key_hint
284
+ when :customer
278
285
  "Company"
286
+ when :leader, :co_leader, :user
287
+ "User"
279
288
  else
280
289
  # General case: singularize the key hint (e.g., :tasks -> "task")
281
290
  ActiveSupport::Inflector.singularize(key_hint.to_s)
@@ -4,6 +4,12 @@ module MOCO
4
4
  # Represents a MOCO expense
5
5
  # Provides methods for expense-specific operations and associations
6
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
+
7
13
  # Class methods for bulk operations
8
14
  def self.disregard(client, expense_ids:)
9
15
  client.post("projects/expenses/disregard", { expense_ids: })
@@ -15,11 +21,13 @@ module MOCO
15
21
 
16
22
  # Associations
17
23
  def project
18
- @project ||= client.projects.find(project_id) if project_id
24
+ # Use the association method which handles embedded objects
25
+ association(:project, "Project")
19
26
  end
20
27
 
21
28
  def user
22
- @user ||= client.users.find(user_id) if user_id
29
+ # Use the association method which handles embedded objects
30
+ association(:user, "User")
23
31
  end
24
32
 
25
33
  def to_s