collavre 0.1.1 → 0.2.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.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/collavre/comments_popup.css +293 -8
  3. data/app/assets/stylesheets/collavre/mention_menu.css +26 -0
  4. data/app/assets/stylesheets/collavre/popup.css +7 -0
  5. data/app/assets/stylesheets/collavre/print.css +18 -0
  6. data/app/channels/collavre/comments_presence_channel.rb +33 -0
  7. data/app/components/collavre/autocomplete_popup_component.html.erb +3 -0
  8. data/app/components/collavre/autocomplete_popup_component.rb +18 -0
  9. data/app/components/collavre/command_menu_component.rb +7 -0
  10. data/app/components/collavre/plans_timeline_component.html.erb +1 -1
  11. data/app/components/collavre/plans_timeline_component.rb +29 -32
  12. data/app/components/collavre/user_mention_menu_component.rb +4 -5
  13. data/app/controllers/collavre/comments_controller.rb +111 -10
  14. data/app/controllers/collavre/creatives_controller.rb +8 -0
  15. data/app/controllers/collavre/google_auth_controller.rb +5 -1
  16. data/app/controllers/collavre/plans_controller.rb +65 -9
  17. data/app/controllers/collavre/topics_controller.rb +42 -0
  18. data/app/controllers/collavre/users_controller.rb +4 -14
  19. data/app/errors/collavre/approval_pending_error.rb +54 -0
  20. data/app/errors/collavre/cancelled_error.rb +9 -0
  21. data/app/helpers/collavre/navigation_helper.rb +3 -1
  22. data/app/javascript/collavre.js +1 -0
  23. data/app/javascript/controllers/comments/__tests__/popup_controller.test.js +2 -1
  24. data/app/javascript/controllers/comments/form_controller.js +2 -1
  25. data/app/javascript/controllers/comments/list_controller.js +185 -2
  26. data/app/javascript/controllers/comments/popup_controller.js +95 -20
  27. data/app/javascript/controllers/comments/presence_controller.js +30 -1
  28. data/app/javascript/controllers/comments/topics_controller.js +314 -4
  29. data/app/javascript/modules/__tests__/creative_progress.test.js +50 -0
  30. data/app/javascript/modules/command_menu.js +116 -0
  31. data/app/javascript/modules/creative_progress.js +14 -0
  32. data/app/javascript/modules/creative_row_editor.js +104 -20
  33. data/app/javascript/modules/plans_timeline.js +15 -4
  34. data/app/javascript/modules/share_modal.js +3 -0
  35. data/app/jobs/collavre/ai_agent_job.rb +35 -21
  36. data/app/models/collavre/calendar_event.rb +7 -1
  37. data/app/models/collavre/comment.rb +35 -2
  38. data/app/models/collavre/creative.rb +1 -3
  39. data/app/models/collavre/mcp_tool.rb +4 -0
  40. data/app/models/collavre/plan.rb +23 -0
  41. data/app/models/collavre/topic.rb +12 -0
  42. data/app/models/collavre/user.rb +15 -1
  43. data/app/services/collavre/ai_agent_service.rb +174 -66
  44. data/app/services/collavre/ai_client.rb +31 -2
  45. data/app/services/collavre/comments/action_executor.rb +47 -1
  46. data/app/services/collavre/comments/calendar_command.rb +117 -18
  47. data/app/services/collavre/google_calendar_service.rb +38 -15
  48. data/app/services/collavre/markdown_importer.rb +47 -8
  49. data/app/services/collavre/mcp_service.rb +23 -10
  50. data/app/services/collavre/system_events/router.rb +50 -26
  51. data/app/services/collavre/tools/creative_create_service.rb +97 -0
  52. data/app/services/collavre/tools/creative_update_service.rb +116 -0
  53. data/app/views/collavre/comments/_comment.html.erb +2 -2
  54. data/app/views/collavre/comments/_comments_popup.html.erb +40 -6
  55. data/app/views/collavre/comments/fullscreen.html.erb +5 -0
  56. data/app/views/collavre/creatives/_inline_edit_form.html.erb +11 -3
  57. data/app/views/collavre/creatives/_integration_modals.html.erb +6 -0
  58. data/app/views/collavre/creatives/_integration_triggers.html.erb +8 -0
  59. data/app/views/collavre/creatives/_integrations_menu.html.erb +12 -0
  60. data/app/views/collavre/creatives/_mobile_actions_menu.html.erb +13 -1
  61. data/app/views/collavre/creatives/_share_button.html.erb +1 -1
  62. data/app/views/collavre/creatives/index.html.erb +22 -4
  63. data/app/views/collavre/users/edit_ai.html.erb +15 -0
  64. data/app/views/collavre/users/new_ai.html.erb +15 -0
  65. data/app/views/layouts/collavre/chat.html.erb +46 -0
  66. data/config/locales/ai_agent.en.yml +15 -0
  67. data/config/locales/ai_agent.ko.yml +15 -0
  68. data/config/locales/comments.en.yml +15 -3
  69. data/config/locales/comments.ko.yml +15 -3
  70. data/config/locales/creatives.en.yml +3 -31
  71. data/config/locales/creatives.ko.yml +3 -27
  72. data/config/locales/plans.en.yml +4 -0
  73. data/config/locales/plans.ko.yml +4 -0
  74. data/config/locales/users.en.yml +3 -0
  75. data/config/locales/users.ko.yml +3 -0
  76. data/config/routes.rb +8 -3
  77. data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +1 -1
  78. data/db/migrate/20260131100000_migrate_active_storage_attachment_record_types.rb +21 -0
  79. data/db/migrate/20260201100000_make_google_event_id_nullable.rb +5 -0
  80. data/lib/collavre/engine.rb +171 -6
  81. data/lib/collavre/integration_registry.rb +129 -0
  82. data/lib/collavre/version.rb +1 -1
  83. data/lib/collavre.rb +2 -0
  84. data/lib/navigation/registry.rb +130 -0
  85. metadata +22 -15
  86. data/app/components/collavre/user_mention_menu_component.html.erb +0 -3
  87. data/app/controllers/collavre/notion_auth_controller.rb +0 -25
  88. data/app/jobs/collavre/notion_export_job.rb +0 -30
  89. data/app/jobs/collavre/notion_sync_job.rb +0 -48
  90. data/app/models/collavre/notion_account.rb +0 -17
  91. data/app/models/collavre/notion_block_link.rb +0 -10
  92. data/app/models/collavre/notion_page_link.rb +0 -19
  93. data/app/services/collavre/notion_client.rb +0 -231
  94. data/app/services/collavre/notion_creative_exporter.rb +0 -296
  95. data/app/services/collavre/notion_service.rb +0 -249
  96. data/app/views/collavre/creatives/_notion_integration_modal.html.erb +0 -90
  97. data/db/migrate/20241201000000_create_notion_integrations.rb +0 -29
  98. data/db/migrate/20250312000000_create_notion_block_links.rb +0 -16
  99. data/db/migrate/20250312010000_allow_multiple_notion_blocks_per_creative.rb +0 -5
@@ -3,6 +3,7 @@ import apiQueue from '../lib/api/queue_manager'
3
3
  import { $getCharacterOffsets, $getSelection, $isRangeSelection, $isTextNode, $isRootOrShadowRoot } from 'lexical'
4
4
  import { createInlineEditor } from './lexical_inline_editor'
5
5
  import { renderCreativeTree, dispatchCreativeTreeUpdated } from '../creatives/tree_renderer'
6
+ import { isProgressComplete, progressBaselineValueFrom, progressValueChangedFrom } from './creative_progress'
6
7
  // Import Stimulus application from the global window (set by host app)
7
8
  const application = window.Stimulus
8
9
 
@@ -75,6 +76,9 @@ export function initializeCreativeRowEditor() {
75
76
  const editorContainer = template.querySelector('[data-lexical-editor-root]');
76
77
  const progressInput = document.getElementById('inline-creative-progress');
77
78
  const progressValue = document.getElementById('inline-progress-value');
79
+ const progressCompleteLabel = progressInput?.dataset.completeLabel || 'Complete';
80
+ const progressIncompleteLabel = progressInput?.dataset.incompleteLabel || 'Incomplete';
81
+ const progressHiddenInput = document.querySelector('input[type="hidden"][name="creative[progress]"]');
78
82
  const upBtn = document.getElementById('inline-move-up');
79
83
  const downBtn = document.getElementById('inline-move-down');
80
84
  const addBtn = document.getElementById('inline-add');
@@ -136,18 +140,53 @@ export function initializeCreativeRowEditor() {
136
140
  let originalProgress = 0;
137
141
  let originalOriginId = '';
138
142
  let isDirty = false;
143
+ let completionCascadePending = false;
139
144
 
140
145
  function formatProgressDisplay(value) {
141
- const numeric = Number(value);
142
- if (Number.isNaN(numeric)) return '0%';
143
- const percentage = Math.round(numeric * 100);
144
- return `${percentage}%`;
146
+ return isProgressComplete(value) ? progressCompleteLabel : progressIncompleteLabel;
147
+ }
148
+
149
+ function readProgressValue() {
150
+ if (!progressInput) return 0;
151
+ return progressInput.checked ? 1 : 0;
152
+ }
153
+
154
+ function progressValueChanged() {
155
+ if (!progressInput) return false;
156
+ return progressValueChangedFrom(originalProgress, progressInput.checked);
157
+ }
158
+
159
+ function setProgressState(value) {
160
+ if (!progressInput) return;
161
+ const complete = isProgressComplete(value);
162
+ progressInput.checked = complete;
163
+ if (progressValue) {
164
+ progressValue.textContent = formatProgressDisplay(value);
165
+ }
166
+ }
167
+
168
+ function updateProgressInputAvailability(value) {
169
+ if (!progressInput) return;
170
+ const hasChildren = currentRowHasChildren();
171
+ const complete = isProgressComplete(value);
172
+ const shouldDisable = hasChildren && complete;
173
+ progressInput.disabled = shouldDisable;
174
+ if (progressHiddenInput) {
175
+ progressHiddenInput.disabled = false;
176
+ progressHiddenInput.value = shouldDisable ? '1' : '0';
177
+ }
145
178
  }
146
179
 
147
180
  function treeRowElement(node) {
148
181
  return node && node.closest ? node.closest('creative-tree-row') : null;
149
182
  }
150
183
 
184
+ function currentRowHasChildren() {
185
+ const row = currentRowElement || (currentTree ? treeRowElement(currentTree) : null);
186
+ if (!row) return false;
187
+ return !!(row.hasChildren || row.getAttribute?.('has-children'));
188
+ }
189
+
151
190
  function hasDatasetValue(element, key) {
152
191
  if (!element || !element.dataset) return false;
153
192
  return Object.prototype.hasOwnProperty.call(element.dataset, key);
@@ -245,8 +284,9 @@ export function initializeCreativeRowEditor() {
245
284
  isDirty = false;
246
285
  const progressNumber = Number(data.progress ?? 0);
247
286
  const normalizedProgress = Number.isNaN(progressNumber) ? 0 : progressNumber;
248
- progressInput.value = normalizedProgress;
249
- progressValue.textContent = formatProgressDisplay(progressInput.value);
287
+ setProgressState(normalizedProgress);
288
+ updateProgressInputAvailability(normalizedProgress);
289
+ completionCascadePending = false;
250
290
  const fallbackParent = tree?.dataset?.parentId || '';
251
291
  parentInput.value = data.parent_id ?? fallbackParent ?? '';
252
292
  beforeInput.value = '';
@@ -892,8 +932,17 @@ export function initializeCreativeRowEditor() {
892
932
 
893
933
  // Capture values being saved to update dirty state on success
894
934
  const savedContent = descriptionInput.value;
895
- const savedProgress = progressInput.value;
935
+ const shouldPersistProgress = progressValueChanged();
936
+ const savedProgress = shouldPersistProgress ? readProgressValue() : progressBaselineValueFrom(originalProgress);
896
937
  const savedOriginId = originIdInput ? originIdInput.value : '';
938
+ const cascadeProgressUpdate = completionCascadePending;
939
+ const progressInputsDisabled = progressInput?.disabled ?? false;
940
+ const hiddenProgressDisabled = progressHiddenInput?.disabled ?? false;
941
+
942
+ if (!shouldPersistProgress) {
943
+ if (progressInput) progressInput.disabled = true;
944
+ if (progressHiddenInput) progressHiddenInput.disabled = true;
945
+ }
897
946
 
898
947
  savePromise = creativesApi.save(form.action, method, form).then(function (r) {
899
948
  if (!r.ok) return r;
@@ -902,12 +951,14 @@ export function initializeCreativeRowEditor() {
902
951
  }).then(function (data) {
903
952
  // Update dirty state to reflect successful save
904
953
  originalContent = savedContent;
905
- originalProgress = savedProgress;
954
+ if (shouldPersistProgress) {
955
+ originalProgress = savedProgress;
956
+ }
906
957
  originalOriginId = savedOriginId;
907
958
 
908
959
  // If current values match what was just saved, clear dirty flag
909
960
  if (descriptionInput.value === savedContent &&
910
- progressInput.value === savedProgress &&
961
+ readProgressValue() === savedProgress &&
911
962
  originIdInput.value === savedOriginId) {
912
963
  isDirty = false;
913
964
  }
@@ -954,6 +1005,10 @@ export function initializeCreativeRowEditor() {
954
1005
  } else if (method === 'PATCH') {
955
1006
  if (tree) refreshRow(tree);
956
1007
  }
1008
+ if (cascadeProgressUpdate && tree) {
1009
+ refreshChildren(tree);
1010
+ completionCascadePending = false;
1011
+ }
957
1012
 
958
1013
  // Delete removed attachments after successful save
959
1014
  if (lexicalEditor && typeof lexicalEditor.getDeletedAttachments === 'function') {
@@ -966,6 +1021,10 @@ export function initializeCreativeRowEditor() {
966
1021
  });
967
1022
  }).finally(function () {
968
1023
  saving = false;
1024
+ if (!shouldPersistProgress) {
1025
+ if (progressInput) progressInput.disabled = progressInputsDisabled;
1026
+ if (progressHiddenInput) progressHiddenInput.disabled = hiddenProgressDisabled;
1027
+ }
969
1028
  });
970
1029
  return savePromise;
971
1030
  });
@@ -1071,7 +1130,8 @@ export function initializeCreativeRowEditor() {
1071
1130
  // CRITICAL: Capture ALL values BEFORE awaiting, because the editor may switch
1072
1131
  // to a different creative while we're waiting for uploads
1073
1132
  let currentContent = descriptionInput.value;
1074
- let currentProgress = progressInput.value;
1133
+ let currentProgress = readProgressValue();
1134
+ let shouldPersistProgress = progressValueChanged();
1075
1135
  const currentParentId = tree.dataset.parentId || '';
1076
1136
  const currentBeforeId = tree.previousElementSibling ? creativeIdFrom(tree.previousElementSibling) : '';
1077
1137
  const currentAfterId = tree.nextElementSibling ? creativeIdFrom(tree.nextElementSibling) : '';
@@ -1092,17 +1152,21 @@ export function initializeCreativeRowEditor() {
1092
1152
  // This ensures we capture the final HTML with signed IDs instead of blob URLs
1093
1153
  if (form.dataset.creativeId === startCreativeId) {
1094
1154
  currentContent = descriptionInput.value;
1095
- currentProgress = progressInput.value;
1155
+ currentProgress = readProgressValue();
1156
+ shouldPersistProgress = progressValueChanged();
1096
1157
  }
1097
1158
 
1098
1159
  // Build request body
1099
1160
  // Note: before_id and after_id must be top-level params, not nested under creative[]
1100
1161
  // because CreativesController reads params[:before_id] and params[:after_id] for positioning
1101
1162
  const body = {
1102
- 'creative[description]': currentContent,
1103
- 'creative[progress]': currentProgress
1163
+ 'creative[description]': currentContent
1104
1164
  };
1105
1165
 
1166
+ if (shouldPersistProgress) {
1167
+ body['creative[progress]'] = currentProgress;
1168
+ }
1169
+
1106
1170
  // Always include parent_id, even if empty (for moving to root)
1107
1171
  body['creative[parent_id]'] = currentParentId;
1108
1172
 
@@ -1122,7 +1186,9 @@ export function initializeCreativeRowEditor() {
1122
1186
  row.dataset.descriptionHtml = currentContent;
1123
1187
  row.descriptionHtml = currentContent;
1124
1188
  row.dataset.descriptionRawHtml = currentContent;
1125
- row.dataset.progressValue = String(currentProgress);
1189
+ if (shouldPersistProgress) {
1190
+ row.dataset.progressValue = String(currentProgress);
1191
+ }
1126
1192
  if (currentParentId) {
1127
1193
  tree.dataset.parentId = currentParentId;
1128
1194
  row.parentId = currentParentId;
@@ -1156,6 +1222,9 @@ export function initializeCreativeRowEditor() {
1156
1222
 
1157
1223
  // Reset dirty state
1158
1224
  originalContent = currentContent;
1225
+ if (shouldPersistProgress) {
1226
+ originalProgress = currentProgress;
1227
+ }
1159
1228
  isDirty = false;
1160
1229
  pendingSave = false;
1161
1230
  clearTimeout(saveTimer);
@@ -1540,8 +1609,8 @@ export function initializeCreativeRowEditor() {
1540
1609
  resetOriginTracking();
1541
1610
  descriptionInput.value = '';
1542
1611
  lexicalEditor.reset(`new-${Date.now()}`);
1543
- progressInput.value = 0;
1544
- progressValue.textContent = formatProgressDisplay(0);
1612
+ setProgressState(0);
1613
+ originalProgress = 0;
1545
1614
  if (unconvertBtn) unconvertBtn.style.display = 'none';
1546
1615
  pendingSave = false;
1547
1616
  lexicalEditor.focus();
@@ -1666,10 +1735,25 @@ export function initializeCreativeRowEditor() {
1666
1735
  return !!node && $isRootOrShadowRoot(node);
1667
1736
  }
1668
1737
 
1669
- progressInput.addEventListener('input', function () {
1670
- progressValue.textContent = formatProgressDisplay(progressInput.value);
1671
- scheduleSave();
1672
- });
1738
+ if (progressInput) {
1739
+ progressInput.addEventListener('change', function () {
1740
+ if (progressValue) {
1741
+ progressValue.textContent = formatProgressDisplay(readProgressValue());
1742
+ }
1743
+ completionCascadePending = false;
1744
+ const hasChildren = currentRowHasChildren();
1745
+ const shouldCascade = hasChildren && progressValueChanged();
1746
+ if (shouldCascade) {
1747
+ completionCascadePending = true;
1748
+ const alertMessage = progressInput.dataset.childrenAlertMessage;
1749
+ if (alertMessage) {
1750
+ alert(alertMessage);
1751
+ }
1752
+ }
1753
+ updateProgressInputAvailability(readProgressValue());
1754
+ scheduleSave();
1755
+ });
1756
+ }
1673
1757
 
1674
1758
  if (parentSuggestBtn && parentSuggestions) {
1675
1759
  parentSuggestBtn.addEventListener('click', function () {
@@ -10,6 +10,9 @@ if (!plansTimelineScriptInitialized) {
10
10
  var plans = [];
11
11
  try { plans = JSON.parse(container.dataset.plans || '[]'); } catch (e) { }
12
12
  plans = plans.map(function (p) {
13
+ if (p.start_date) {
14
+ p.start_date = new Date(p.start_date);
15
+ }
13
16
  p.created_at = new Date(p.created_at);
14
17
  p.target_date = new Date(p.target_date);
15
18
  return p;
@@ -64,8 +67,9 @@ if (!plansTimelineScriptInitialized) {
64
67
  el.className = 'plan-bar';
65
68
  el.dataset.path = plan.path;
66
69
  el.dataset.id = plan.id;
67
- var left = dayDiff(plan.created_at, startDate) * dayWidth;
68
- var width = (dayDiff(plan.target_date, plan.created_at) + 1) * dayWidth;
70
+ var startDateValue = plan.start_date || plan.created_at;
71
+ var left = dayDiff(startDateValue, startDate) * dayWidth;
72
+ var width = (dayDiff(plan.target_date, startDateValue) + 1) * dayWidth;
69
73
  el.style.left = left + 'px';
70
74
  el.style.top = (idx * rowHeight + 40) + 'px';
71
75
  el.style.width = width + 'px';
@@ -146,8 +150,9 @@ if (!plansTimelineScriptInitialized) {
146
150
  var visibleWidth = dayDiff(endDate, startDate) * dayWidth;
147
151
  planEls.forEach(function (item, idx) {
148
152
  var plan = item.plan;
149
- var left = dayDiff(plan.created_at, startDate) * dayWidth;
150
- var width = (dayDiff(plan.target_date, plan.created_at) + 1) * dayWidth;
153
+ var startDateValue = plan.start_date || plan.created_at;
154
+ var left = dayDiff(startDateValue, startDate) * dayWidth;
155
+ var width = (dayDiff(plan.target_date, startDateValue) + 1) * dayWidth;
151
156
  var right = left + width;
152
157
 
153
158
  if (right < 0 || left > visibleWidth) {
@@ -193,6 +198,9 @@ if (!plansTimelineScriptInitialized) {
193
198
  .then(function (r) { return r.json(); })
194
199
  .then(function (newPlans) {
195
200
  plans = newPlans.map(function (p) {
201
+ if (p.start_date) {
202
+ p.start_date = new Date(p.start_date);
203
+ }
196
204
  p.created_at = new Date(p.created_at);
197
205
  p.target_date = new Date(p.target_date);
198
206
  return p;
@@ -362,6 +370,9 @@ if (!plansTimelineScriptInitialized) {
362
370
  return r.json().then(function (j) { throw j; });
363
371
  }).then(function (plan) {
364
372
  // Add to local plans if available via closure or re-fetch
373
+ if (plan.start_date) {
374
+ plan.start_date = new Date(plan.start_date);
375
+ }
365
376
  plan.created_at = new Date(plan.created_at);
366
377
  plan.target_date = new Date(plan.target_date);
367
378
 
@@ -69,5 +69,8 @@ document.addEventListener('turbo:load', function() {
69
69
  emailInput.dispatchEvent(new Event('blur'))
70
70
  }
71
71
  }
72
+ if (params.get('open_share') === 'true') {
73
+ shareBtn.click()
74
+ }
72
75
  }
73
76
  })
@@ -1,27 +1,41 @@
1
1
  module Collavre
2
- class AiAgentJob < ApplicationJob
3
- queue_as :default
2
+ class AiAgentJob < ApplicationJob
3
+ queue_as :default
4
4
 
5
- def perform(agent_id, event_name, context)
6
- agent = User.find(agent_id)
5
+ # Allow resuming a task that was pending approval
6
+ def perform(agent_id_or_task, event_name = nil, context = nil)
7
+ if agent_id_or_task.is_a?(Task)
8
+ # Resume existing task
9
+ task = agent_id_or_task
10
+ return if task.reload.status == "cancelled"
11
+ task.update!(status: "running")
12
+ else
13
+ # Create new task
14
+ agent = User.find(agent_id_or_task)
15
+ task = Task.create!(
16
+ name: "Response to #{event_name}",
17
+ status: "running",
18
+ trigger_event_name: event_name,
19
+ trigger_event_payload: context,
20
+ agent: agent
21
+ )
22
+ end
7
23
 
8
- # Create Task
9
- task = Task.create!(
10
- name: "Response to #{event_name}",
11
- status: "running",
12
- trigger_event_name: event_name,
13
- trigger_event_payload: context,
14
- agent: agent
15
- )
16
-
17
- begin
18
- AiAgentService.new(task).call
19
- task.update!(status: "done")
20
- rescue StandardError => e
21
- task.update!(status: "failed")
22
- Rails.logger.error("AiAgentJob failed for task #{task.id}: #{e.message}")
23
- raise e
24
+ begin
25
+ AiAgentService.new(task).call
26
+ task.update!(status: "done")
27
+ rescue ApprovalPendingError
28
+ # Task status already set to pending_approval by AiAgentService
29
+ # Don't mark as failed, just let the job complete gracefully
30
+ Rails.logger.info("AiAgentJob paused for task #{task.id}: awaiting tool approval")
31
+ rescue CancelledError
32
+ # Task status already set to "cancelled" by Comment callback
33
+ Rails.logger.info("AiAgentJob cancelled for task #{task.id}: trigger message deleted")
34
+ rescue StandardError => e
35
+ task.update!(status: "failed")
36
+ Rails.logger.error("AiAgentJob failed for task #{task.id}: #{e.message}")
37
+ raise e
38
+ end
24
39
  end
25
40
  end
26
41
  end
27
- end
@@ -5,13 +5,19 @@ module Collavre
5
5
  belongs_to :user, class_name: Collavre.configuration.user_class_name
6
6
  belongs_to :creative, class_name: "Collavre::Creative", optional: true
7
7
 
8
- validates :google_event_id, :start_time, :end_time, presence: true
8
+ validates :start_time, :end_time, presence: true
9
9
 
10
10
  after_commit :delete_google_event, on: :destroy
11
11
 
12
+ def synced_to_google?
13
+ google_event_id.present?
14
+ end
15
+
12
16
  private
13
17
 
14
18
  def delete_google_event
19
+ return unless google_event_id.present?
20
+
15
21
  GoogleCalendarService.new(user: user).delete_event(google_event_id)
16
22
  rescue StandardError => e
17
23
  Rails.logger.error("Failed to delete Google event #{google_event_id}: #{e.message}")
@@ -2,6 +2,8 @@ module Collavre
2
2
  class Comment < ApplicationRecord
3
3
  self.table_name = "comments"
4
4
 
5
+ STREAMING_PLACEHOLDER_CONTENT = "..."
6
+
5
7
  # Use non-namespaced partial path for backward compatibility
6
8
  def to_partial_path
7
9
  "comments/comment"
@@ -26,9 +28,9 @@ module Collavre
26
28
  validate :creative_must_be_origin_creative
27
29
  validate :images_must_be_images
28
30
 
29
- after_create_commit :broadcast_create, :notify_write_users, :notify_mentions, :broadcast_badges
31
+ after_create_commit :broadcast_create, :notify_write_users, :notify_mentions, :notify_approver, :broadcast_badges
30
32
  after_update_commit :broadcast_update
31
- after_destroy_commit :broadcast_destroy, :broadcast_badges
33
+ after_destroy_commit :broadcast_destroy, :broadcast_badges, :cancel_pending_tasks
32
34
 
33
35
  # public for db migration
34
36
  def creative_snippet
@@ -75,6 +77,14 @@ module Collavre
75
77
 
76
78
  private
77
79
 
80
+ def cancel_pending_tasks
81
+ Task.where(status: %w[pending running]).each do |task|
82
+ if task.trigger_event_payload&.dig("comment", "id") == id
83
+ task.update!(status: "cancelled")
84
+ end
85
+ end
86
+ end
87
+
78
88
  def create_inbox_item(owner, key, params = {})
79
89
  origin = creative&.effective_origin
80
90
  metadata = params.to_h.stringify_keys
@@ -95,6 +105,11 @@ module Collavre
95
105
  )
96
106
  end
97
107
 
108
+ # AI agent streaming placeholder: skip inbox notifications for "..." content
109
+ def streaming_placeholder?
110
+ user&.ai_user? && content == STREAMING_PLACEHOLDER_CONTENT
111
+ end
112
+
98
113
  def mentioned_emails
99
114
  return [] unless content
100
115
  content.scan(/@([\w.\-+]+@[a-zA-Z0-9\-.]+\.[a-zA-Z]{2,})/)
@@ -147,6 +162,7 @@ module Collavre
147
162
 
148
163
  def notify_write_users
149
164
  return if private? || !user
165
+ return if streaming_placeholder?
150
166
  base_creative = creative.effective_origin
151
167
  present_ids = CommentPresenceStore.list(base_creative.id)
152
168
  recipients = base_creative.all_shared_users(:write).map(&:user)
@@ -167,6 +183,7 @@ module Collavre
167
183
 
168
184
  def notify_mentions
169
185
  return if private?
186
+ return if streaming_placeholder?
170
187
  mentioned_users.each do |mentioned|
171
188
  create_inbox_item(
172
189
  mentioned,
@@ -176,6 +193,22 @@ module Collavre
176
193
  end
177
194
  end
178
195
 
196
+ def notify_approver
197
+ return unless approver.present? && action.present?
198
+ return if approver == user
199
+
200
+ create_inbox_item(
201
+ approver,
202
+ "inbox.approval_requested",
203
+ { user: user&.display_name, tool_name: parsed_action_tool_name, creative: creative_snippet }
204
+ )
205
+ end
206
+
207
+ def parsed_action_tool_name
208
+ parsed = JSON.parse(action) rescue nil
209
+ parsed&.dig("tool_name") || "unknown"
210
+ end
211
+
179
212
  def assign_default_user
180
213
  self.user ||= Collavre.current_user
181
214
  end
@@ -51,7 +51,7 @@ module Collavre
51
51
  attr_accessor :filtered_progress
52
52
 
53
53
  belongs_to :origin, class_name: "Collavre::Creative", optional: true
54
- has_many :linked_creatives, class_name: "Collavre::Creative", foreign_key: :origin_id, dependent: :delete_all
54
+ has_many :linked_creatives, class_name: "Collavre::Creative", foreign_key: :origin_id, dependent: :destroy
55
55
  belongs_to :user, class_name: Collavre.configuration.user_class_name, optional: true
56
56
 
57
57
  has_many :creative_shares, class_name: "Collavre::CreativeShare", dependent: :destroy
@@ -60,8 +60,6 @@ module Collavre
60
60
  has_many :creative_expanded_states, class_name: "Collavre::CreativeExpandedState", dependent: :delete_all
61
61
  has_many :invitations, class_name: "Collavre::Invitation", dependent: :delete_all
62
62
  has_many :github_repository_links, dependent: :destroy
63
- has_many :notion_page_links, dependent: :destroy
64
- has_many :notion_block_links, dependent: :destroy
65
63
  has_many :topics, class_name: "Collavre::Topic", dependent: :destroy
66
64
  has_many :mcp_tools, dependent: :destroy
67
65
  has_many :activity_logs, class_name: "Collavre::ActivityLog", dependent: :destroy
@@ -15,6 +15,10 @@ module Collavre
15
15
  approved_at.present?
16
16
  end
17
17
 
18
+ def requires_approval?
19
+ requires_approval == true
20
+ end
21
+
18
22
  def approve!
19
23
  # Register the tool immediately upon approval
20
24
  ::McpService.register_tool_from_source(source_code)
@@ -3,6 +3,7 @@ require "set"
3
3
  module Collavre
4
4
  class Plan < Label
5
5
  validates :target_date, presence: true
6
+ validate :start_date_not_after_target_date
6
7
 
7
8
  def progress(_user = nil)
8
9
  tagged_ids = Tag.where(label_id: id).pluck(:creative_id)
@@ -16,5 +17,27 @@ module Collavre
16
17
 
17
18
  values.sum.to_f / values.size
18
19
  end
20
+
21
+ # Delegate start_date to the associated creative's created_at
22
+ def start_date
23
+ creative&.created_at&.to_date
24
+ end
25
+
26
+ def start_date=(value)
27
+ return unless creative
28
+
29
+ date = value.is_a?(Date) ? value : Date.parse(value.to_s)
30
+ creative.update_column(:created_at, date.to_datetime)
31
+ end
32
+
33
+ private
34
+
35
+ def start_date_not_after_target_date
36
+ return if start_date.blank? || target_date.blank?
37
+
38
+ return unless start_date > target_date
39
+
40
+ errors.add(:start_date, "must be on or before target date")
41
+ end
19
42
  end
20
43
  end
@@ -8,5 +8,17 @@ module Collavre
8
8
  has_many :comments, class_name: "Collavre::Comment", dependent: :destroy
9
9
 
10
10
  validates :name, presence: true, uniqueness: { scope: :creative_id }
11
+
12
+ before_create :set_default_position
13
+
14
+ default_scope { order(:position) }
15
+
16
+ private
17
+
18
+ def set_default_position
19
+ return if position_changed? && position != 0
20
+
21
+ self.position = (Topic.unscoped.where(creative_id: creative_id).maximum(:position) || -1) + 1
22
+ end
11
23
  end
12
24
  end
@@ -17,7 +17,6 @@ module Collavre
17
17
  has_many :contact_users, through: :contacts
18
18
  has_many :contact_memberships, class_name: "Collavre::Contact", foreign_key: :contact_user_id, dependent: :destroy, inverse_of: :contact_user
19
19
  has_one :github_account, class_name: "Collavre::GithubAccount", dependent: :destroy
20
- has_one :notion_account, class_name: "Collavre::NotionAccount", dependent: :destroy
21
20
  has_many :tasks, class_name: "Collavre::Task", foreign_key: :agent_id, dependent: :destroy
22
21
 
23
22
  # Associations that reference creatives - must be destroyed BEFORE creatives
@@ -68,12 +67,27 @@ module Collavre
68
67
  attribute :llm_vendor, :string
69
68
  attribute :llm_model, :string
70
69
  attribute :llm_api_key, :string
70
+ attribute :gateway_url, :string
71
71
  attribute :tools, :json, default: -> { [] }
72
72
 
73
73
  encrypts :llm_api_key, deterministic: false
74
74
  encrypts :google_access_token, deterministic: false
75
75
  encrypts :google_refresh_token, deterministic: false
76
76
 
77
+ # Override encrypted attribute getters to handle decryption errors gracefully.
78
+ # This prevents the entire request from failing when a token can't be decrypted
79
+ # (e.g., if it was encrypted with a different key or is corrupted).
80
+ %i[google_access_token google_refresh_token llm_api_key].each do |attr|
81
+ define_method(attr) do
82
+ super()
83
+ rescue ActiveSupport::MessageEncryptor::InvalidMessage,
84
+ ActiveRecord::Encryption::Errors::Decryption,
85
+ OpenSSL::Cipher::CipherError => e
86
+ Rails.logger.error("Failed to decrypt #{attr} for user #{id}: #{e.class}")
87
+ nil
88
+ end
89
+ end
90
+
77
91
  SUPPORTED_LLM_MODELS = [
78
92
  "gemini-2.5-flash",
79
93
  "gemini-1.5-flash",