collavre 0.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.
Files changed (410) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +221 -0
  3. data/Rakefile +8 -0
  4. data/app/assets/stylesheets/collavre/actiontext.css +577 -0
  5. data/app/assets/stylesheets/collavre/activity_logs.css +99 -0
  6. data/app/assets/stylesheets/collavre/comments_popup.css +692 -0
  7. data/app/assets/stylesheets/collavre/creatives.css +559 -0
  8. data/app/assets/stylesheets/collavre/dark_mode.css +118 -0
  9. data/app/assets/stylesheets/collavre/mention_menu.css +43 -0
  10. data/app/assets/stylesheets/collavre/popup.css +160 -0
  11. data/app/assets/stylesheets/collavre/print.css +37 -0
  12. data/app/assets/stylesheets/collavre/slide_view.css +79 -0
  13. data/app/assets/stylesheets/collavre/user_menu.css +34 -0
  14. data/app/channels/collavre/comments_presence_channel.rb +54 -0
  15. data/app/channels/collavre/slide_view_channel.rb +11 -0
  16. data/app/channels/collavre/topics_channel.rb +12 -0
  17. data/app/components/collavre/avatar_component.html.erb +15 -0
  18. data/app/components/collavre/avatar_component.rb +59 -0
  19. data/app/components/collavre/inbox/badge_component.html.erb +6 -0
  20. data/app/components/collavre/inbox/badge_component.rb +18 -0
  21. data/app/components/collavre/plans_timeline_component.html.erb +14 -0
  22. data/app/components/collavre/plans_timeline_component.rb +56 -0
  23. data/app/components/collavre/popup_menu_component.html.erb +6 -0
  24. data/app/components/collavre/popup_menu_component.rb +30 -0
  25. data/app/components/collavre/progress_filter_component.html.erb +5 -0
  26. data/app/components/collavre/progress_filter_component.rb +10 -0
  27. data/app/components/collavre/user_mention_menu_component.html.erb +3 -0
  28. data/app/components/collavre/user_mention_menu_component.rb +8 -0
  29. data/app/controllers/collavre/application_controller.rb +15 -0
  30. data/app/controllers/collavre/attachments_controller.rb +44 -0
  31. data/app/controllers/collavre/calendar_events_controller.rb +15 -0
  32. data/app/controllers/collavre/comment_read_pointers_controller.rb +80 -0
  33. data/app/controllers/collavre/comments/activity_logs_controller.rb +26 -0
  34. data/app/controllers/collavre/comments/reactions_controller.rb +82 -0
  35. data/app/controllers/collavre/comments_controller.rb +464 -0
  36. data/app/controllers/collavre/contacts_controller.rb +10 -0
  37. data/app/controllers/collavre/creative_expanded_states_controller.rb +27 -0
  38. data/app/controllers/collavre/creative_imports_controller.rb +24 -0
  39. data/app/controllers/collavre/creative_plans_controller.rb +69 -0
  40. data/app/controllers/collavre/creative_shares_controller.rb +79 -0
  41. data/app/controllers/collavre/creatives_controller.rb +535 -0
  42. data/app/controllers/collavre/devices_controller.rb +19 -0
  43. data/app/controllers/collavre/email_verifications_controller.rb +16 -0
  44. data/app/controllers/collavre/emails_controller.rb +11 -0
  45. data/app/controllers/collavre/github_auth_controller.rb +25 -0
  46. data/app/controllers/collavre/google_auth_controller.rb +43 -0
  47. data/app/controllers/collavre/inbox_items_controller.rb +64 -0
  48. data/app/controllers/collavre/invites_controller.rb +27 -0
  49. data/app/controllers/collavre/notion_auth_controller.rb +25 -0
  50. data/app/controllers/collavre/passwords_controller.rb +37 -0
  51. data/app/controllers/collavre/plans_controller.rb +110 -0
  52. data/app/controllers/collavre/sessions_controller.rb +57 -0
  53. data/app/controllers/collavre/topics_controller.rb +58 -0
  54. data/app/controllers/collavre/user_themes_controller.rb +58 -0
  55. data/app/controllers/collavre/users_controller.rb +390 -0
  56. data/app/helpers/collavre/application_helper.rb +4 -0
  57. data/app/helpers/collavre/comments_helper.rb +9 -0
  58. data/app/helpers/collavre/creatives_helper.rb +343 -0
  59. data/app/helpers/collavre/navigation_helper.rb +163 -0
  60. data/app/helpers/collavre/user_themes_helper.rb +4 -0
  61. data/app/javascript/collavre.js +26 -0
  62. data/app/javascript/components/InlineLexicalEditor.jsx +889 -0
  63. data/app/javascript/components/LinkPopup.jsx +112 -0
  64. data/app/javascript/components/creative_tree_row.js +503 -0
  65. data/app/javascript/components/plugins/attachment_cleanup_plugin.jsx +95 -0
  66. data/app/javascript/components/plugins/image_upload_plugin.jsx +162 -0
  67. data/app/javascript/controllers/click_target_controller.js +13 -0
  68. data/app/javascript/controllers/comment_controller.js +162 -0
  69. data/app/javascript/controllers/comments/__tests__/popup_controller.test.js +68 -0
  70. data/app/javascript/controllers/comments/form_controller.js +530 -0
  71. data/app/javascript/controllers/comments/list_controller.js +715 -0
  72. data/app/javascript/controllers/comments/mention_menu_controller.js +41 -0
  73. data/app/javascript/controllers/comments/popup_controller.js +385 -0
  74. data/app/javascript/controllers/comments/presence_controller.js +311 -0
  75. data/app/javascript/controllers/comments/topics_controller.js +338 -0
  76. data/app/javascript/controllers/common_popup_controller.js +55 -0
  77. data/app/javascript/controllers/creatives/drag_drop_controller.js +45 -0
  78. data/app/javascript/controllers/creatives/expansion_controller.js +222 -0
  79. data/app/javascript/controllers/creatives/import_controller.js +116 -0
  80. data/app/javascript/controllers/creatives/row_editor_controller.js +8 -0
  81. data/app/javascript/controllers/creatives/select_mode_controller.js +231 -0
  82. data/app/javascript/controllers/creatives/set_plan_modal_controller.js +107 -0
  83. data/app/javascript/controllers/creatives/tree_controller.js +218 -0
  84. data/app/javascript/controllers/index.js +79 -0
  85. data/app/javascript/controllers/link_creative_controller.js +91 -0
  86. data/app/javascript/controllers/popup_menu_controller.js +82 -0
  87. data/app/javascript/controllers/progress_filter_controller.js +35 -0
  88. data/app/javascript/controllers/reaction_picker_controller.js +107 -0
  89. data/app/javascript/controllers/share_invite_controller.js +15 -0
  90. data/app/javascript/controllers/share_user_search_controller.js +121 -0
  91. data/app/javascript/controllers/tabs_controller.js +43 -0
  92. data/app/javascript/creatives/drag_drop/dom.js +170 -0
  93. data/app/javascript/creatives/drag_drop/event_handlers.js +846 -0
  94. data/app/javascript/creatives/drag_drop/indicator.js +35 -0
  95. data/app/javascript/creatives/drag_drop/operations.js +116 -0
  96. data/app/javascript/creatives/drag_drop/state.js +31 -0
  97. data/app/javascript/creatives/tree_renderer.js +248 -0
  98. data/app/javascript/lib/api/__tests__/queue_manager.test.js +153 -0
  99. data/app/javascript/lib/api/creatives.js +79 -0
  100. data/app/javascript/lib/api/csrf_fetch.js +22 -0
  101. data/app/javascript/lib/api/drag_drop.js +31 -0
  102. data/app/javascript/lib/api/queue_manager.js +423 -0
  103. data/app/javascript/lib/apply_lexical_styles.js +15 -0
  104. data/app/javascript/lib/common_popup.js +195 -0
  105. data/app/javascript/lib/lexical/__tests__/action_text_attachment_node.test.jsx +91 -0
  106. data/app/javascript/lib/lexical/__tests__/attachment_payload.test.js +194 -0
  107. data/app/javascript/lib/lexical/action_text_attachment_node.js +459 -0
  108. data/app/javascript/lib/lexical/attachment_node.jsx +170 -0
  109. data/app/javascript/lib/lexical/attachment_payload.js +293 -0
  110. data/app/javascript/lib/lexical/dom_attachment_utils.js +66 -0
  111. data/app/javascript/lib/lexical/image_node.jsx +159 -0
  112. data/app/javascript/lib/lexical/style_attributes.js +40 -0
  113. data/app/javascript/lib/responsive_images.js +54 -0
  114. data/app/javascript/lib/turbo_stream_actions.js +33 -0
  115. data/app/javascript/lib/utils/markdown.js +23 -0
  116. data/app/javascript/modules/creative_guide.js +53 -0
  117. data/app/javascript/modules/creative_row_editor.js +1841 -0
  118. data/app/javascript/modules/creative_row_swipe.js +43 -0
  119. data/app/javascript/modules/creatives.js +15 -0
  120. data/app/javascript/modules/export_to_markdown.js +34 -0
  121. data/app/javascript/modules/inbox_panel.js +226 -0
  122. data/app/javascript/modules/lexical_inline_editor.jsx +133 -0
  123. data/app/javascript/modules/mention_menu.js +77 -0
  124. data/app/javascript/modules/plans_menu.js +39 -0
  125. data/app/javascript/modules/plans_timeline.js +397 -0
  126. data/app/javascript/modules/share_modal.js +73 -0
  127. data/app/javascript/modules/share_user_popup.js +77 -0
  128. data/app/javascript/modules/slide_view.js +163 -0
  129. data/app/javascript/services/cable.js +32 -0
  130. data/app/javascript/slide_view.js +2 -0
  131. data/app/javascript/utils/caret_position.js +42 -0
  132. data/app/javascript/utils/clipboard.js +40 -0
  133. data/app/jobs/collavre/ai_agent_job.rb +27 -0
  134. data/app/jobs/collavre/inbox_summary_job.rb +24 -0
  135. data/app/jobs/collavre/notion_export_job.rb +30 -0
  136. data/app/jobs/collavre/notion_sync_job.rb +48 -0
  137. data/app/jobs/collavre/permission_cache_cleanup_job.rb +36 -0
  138. data/app/jobs/collavre/permission_cache_job.rb +71 -0
  139. data/app/jobs/collavre/push_notification_job.rb +86 -0
  140. data/app/mailers/collavre/application_mailer.rb +17 -0
  141. data/app/mailers/collavre/creative_mailer.rb +9 -0
  142. data/app/mailers/collavre/email_verification_mailer.rb +20 -0
  143. data/app/mailers/collavre/inbox_mailer.rb +19 -0
  144. data/app/mailers/collavre/invitation_mailer.rb +16 -0
  145. data/app/mailers/collavre/passwords_mailer.rb +10 -0
  146. data/app/models/collavre/activity_log.rb +13 -0
  147. data/app/models/collavre/application_record.rb +5 -0
  148. data/app/models/collavre/calendar_event.rb +20 -0
  149. data/app/models/collavre/comment.rb +307 -0
  150. data/app/models/collavre/comment_presence_store.rb +30 -0
  151. data/app/models/collavre/comment_reaction.rb +11 -0
  152. data/app/models/collavre/comment_read_pointer.rb +26 -0
  153. data/app/models/collavre/contact.rb +23 -0
  154. data/app/models/collavre/creative.rb +413 -0
  155. data/app/models/collavre/creative_expanded_state.rb +11 -0
  156. data/app/models/collavre/creative_share.rb +122 -0
  157. data/app/models/collavre/creative_shares_cache.rb +18 -0
  158. data/app/models/collavre/current.rb +14 -0
  159. data/app/models/collavre/device.rb +13 -0
  160. data/app/models/collavre/email.rb +14 -0
  161. data/app/models/collavre/github_account.rb +10 -0
  162. data/app/models/collavre/github_repository_link.rb +19 -0
  163. data/app/models/collavre/inbox_item.rb +95 -0
  164. data/app/models/collavre/invitation.rb +22 -0
  165. data/app/models/collavre/label.rb +47 -0
  166. data/app/models/collavre/mcp_tool.rb +30 -0
  167. data/app/models/collavre/notion_account.rb +17 -0
  168. data/app/models/collavre/notion_block_link.rb +10 -0
  169. data/app/models/collavre/notion_page_link.rb +19 -0
  170. data/app/models/collavre/plan.rb +20 -0
  171. data/app/models/collavre/session.rb +24 -0
  172. data/app/models/collavre/system_setting.rb +144 -0
  173. data/app/models/collavre/tag.rb +10 -0
  174. data/app/models/collavre/task.rb +10 -0
  175. data/app/models/collavre/task_action.rb +10 -0
  176. data/app/models/collavre/topic.rb +12 -0
  177. data/app/models/collavre/user.rb +174 -0
  178. data/app/models/collavre/user_theme.rb +10 -0
  179. data/app/models/collavre/variation.rb +5 -0
  180. data/app/models/collavre/webauthn_credential.rb +11 -0
  181. data/app/services/collavre/ai_agent_service.rb +193 -0
  182. data/app/services/collavre/ai_client.rb +183 -0
  183. data/app/services/collavre/ai_system_prompt_renderer.rb +38 -0
  184. data/app/services/collavre/auto_theme_generator.rb +198 -0
  185. data/app/services/collavre/comment_link_formatter.rb +60 -0
  186. data/app/services/collavre/comments/action_executor.rb +262 -0
  187. data/app/services/collavre/comments/action_validator.rb +58 -0
  188. data/app/services/collavre/comments/calendar_command.rb +97 -0
  189. data/app/services/collavre/comments/command_processor.rb +37 -0
  190. data/app/services/collavre/comments/mcp_command.rb +109 -0
  191. data/app/services/collavre/comments/mcp_command_builder.rb +32 -0
  192. data/app/services/collavre/creatives/filter_pipeline.rb +196 -0
  193. data/app/services/collavre/creatives/filters/assignee_filter.rb +30 -0
  194. data/app/services/collavre/creatives/filters/base_filter.rb +24 -0
  195. data/app/services/collavre/creatives/filters/comment_filter.rb +21 -0
  196. data/app/services/collavre/creatives/filters/date_filter.rb +58 -0
  197. data/app/services/collavre/creatives/filters/progress_filter.rb +25 -0
  198. data/app/services/collavre/creatives/filters/search_filter.rb +28 -0
  199. data/app/services/collavre/creatives/filters/tag_filter.rb +16 -0
  200. data/app/services/collavre/creatives/importer.rb +47 -0
  201. data/app/services/collavre/creatives/index_query.rb +191 -0
  202. data/app/services/collavre/creatives/path_exporter.rb +131 -0
  203. data/app/services/collavre/creatives/permission_cache_builder.rb +194 -0
  204. data/app/services/collavre/creatives/permission_checker.rb +42 -0
  205. data/app/services/collavre/creatives/plan_tagger.rb +53 -0
  206. data/app/services/collavre/creatives/progress_service.rb +89 -0
  207. data/app/services/collavre/creatives/reorderer.rb +187 -0
  208. data/app/services/collavre/creatives/tree_builder.rb +231 -0
  209. data/app/services/collavre/creatives/tree_formatter.rb +36 -0
  210. data/app/services/collavre/gemini_parent_recommender.rb +77 -0
  211. data/app/services/collavre/github/client.rb +112 -0
  212. data/app/services/collavre/github/pull_request_analyzer.rb +280 -0
  213. data/app/services/collavre/github/pull_request_processor.rb +181 -0
  214. data/app/services/collavre/github/webhook_provisioner.rb +130 -0
  215. data/app/services/collavre/google_calendar_service.rb +149 -0
  216. data/app/services/collavre/link_preview_fetcher.rb +230 -0
  217. data/app/services/collavre/markdown_importer.rb +202 -0
  218. data/app/services/collavre/mcp_service.rb +217 -0
  219. data/app/services/collavre/notion_client.rb +231 -0
  220. data/app/services/collavre/notion_creative_exporter.rb +296 -0
  221. data/app/services/collavre/notion_service.rb +249 -0
  222. data/app/services/collavre/ppt_importer.rb +76 -0
  223. data/app/services/collavre/ruby_llm_interaction_logger.rb +34 -0
  224. data/app/services/collavre/system_events/context_builder.rb +41 -0
  225. data/app/services/collavre/system_events/dispatcher.rb +19 -0
  226. data/app/services/collavre/system_events/router.rb +72 -0
  227. data/app/services/collavre/tools/creative_retrieval_service.rb +138 -0
  228. data/app/views/admin/shared/_tabs.html.erb +4 -0
  229. data/app/views/collavre/comments/_activity_log_details.html.erb +23 -0
  230. data/app/views/collavre/comments/_comment.html.erb +147 -0
  231. data/app/views/collavre/comments/_comments_popup.html.erb +46 -0
  232. data/app/views/collavre/comments/_list.html.erb +10 -0
  233. data/app/views/collavre/comments/_presence_avatars.html.erb +8 -0
  234. data/app/views/collavre/comments/_reaction_picker.html.erb +15 -0
  235. data/app/views/collavre/comments/_read_receipts.html.erb +19 -0
  236. data/app/views/collavre/creatives/_add_button.html.erb +20 -0
  237. data/app/views/collavre/creatives/_delete_button.html.erb +12 -0
  238. data/app/views/collavre/creatives/_github_integration_modal.html.erb +77 -0
  239. data/app/views/collavre/creatives/_import_upload_zone.html.erb +10 -0
  240. data/app/views/collavre/creatives/_inline_edit_form.html.erb +89 -0
  241. data/app/views/collavre/creatives/_mobile_actions_menu.html.erb +24 -0
  242. data/app/views/collavre/creatives/_notion_integration_modal.html.erb +90 -0
  243. data/app/views/collavre/creatives/_set_plan_modal.html.erb +25 -0
  244. data/app/views/collavre/creatives/_share_button.html.erb +55 -0
  245. data/app/views/collavre/creatives/edit.html.erb +4 -0
  246. data/app/views/collavre/creatives/index.html.erb +192 -0
  247. data/app/views/collavre/creatives/new.html.erb +4 -0
  248. data/app/views/collavre/creatives/show.html.erb +29 -0
  249. data/app/views/collavre/creatives/slide_view.html.erb +20 -0
  250. data/app/views/collavre/email_verification_mailer/verify.html.erb +4 -0
  251. data/app/views/collavre/email_verification_mailer/verify.text.erb +2 -0
  252. data/app/views/collavre/emails/index.html.erb +19 -0
  253. data/app/views/collavre/emails/show.html.erb +5 -0
  254. data/app/views/collavre/inbox_items/_item.html.erb +18 -0
  255. data/app/views/collavre/inbox_items/_items.html.erb +3 -0
  256. data/app/views/collavre/inbox_items/_list.html.erb +7 -0
  257. data/app/views/collavre/inbox_items/index.html.erb +1 -0
  258. data/app/views/collavre/inbox_mailer/daily_summary.html.erb +11 -0
  259. data/app/views/collavre/inbox_mailer/daily_summary.text.erb +8 -0
  260. data/app/views/collavre/invitation_mailer/invite.html.erb +5 -0
  261. data/app/views/collavre/invitation_mailer/invite.text.erb +3 -0
  262. data/app/views/collavre/invites/show.html.erb +8 -0
  263. data/app/views/collavre/passwords/edit.html.erb +12 -0
  264. data/app/views/collavre/passwords/new.html.erb +17 -0
  265. data/app/views/collavre/passwords_mailer/reset.html.erb +4 -0
  266. data/app/views/collavre/passwords_mailer/reset.text.erb +2 -0
  267. data/app/views/collavre/sessions/new.html.erb +27 -0
  268. data/app/views/collavre/sessions/providers/_google.html.erb +7 -0
  269. data/app/views/collavre/sessions/providers/_passkey.html.erb +3 -0
  270. data/app/views/collavre/shared/_link_creative_modal.html.erb +6 -0
  271. data/app/views/collavre/shared/_navigation.html.erb +37 -0
  272. data/app/views/collavre/shared/navigation/_help_button.html.erb +1 -0
  273. data/app/views/collavre/shared/navigation/_inbox_button.html.erb +5 -0
  274. data/app/views/collavre/shared/navigation/_mobile_inbox_button.html.erb +3 -0
  275. data/app/views/collavre/shared/navigation/_mobile_plans_button.html.erb +1 -0
  276. data/app/views/collavre/shared/navigation/_panels.html.erb +18 -0
  277. data/app/views/collavre/shared/navigation/_plans_button.html.erb +3 -0
  278. data/app/views/collavre/shared/navigation/_search_form.html.erb +6 -0
  279. data/app/views/collavre/user_themes/index.html.erb +75 -0
  280. data/app/views/collavre/users/_app_header.html.erb +5 -0
  281. data/app/views/collavre/users/_contact_management.html.erb +77 -0
  282. data/app/views/collavre/users/_id_pwd_fields.html.erb +27 -0
  283. data/app/views/collavre/users/edit_ai.html.erb +79 -0
  284. data/app/views/collavre/users/edit_password.html.erb +27 -0
  285. data/app/views/collavre/users/index.html.erb +80 -0
  286. data/app/views/collavre/users/new.html.erb +33 -0
  287. data/app/views/collavre/users/new_ai.html.erb +87 -0
  288. data/app/views/collavre/users/passkeys.html.erb +43 -0
  289. data/app/views/collavre/users/show.html.erb +143 -0
  290. data/app/views/inbox/badge_component/_count.html.erb +2 -0
  291. data/app/views/layouts/collavre/slide.html.erb +29 -0
  292. data/config/locales/comments.en.yml +114 -0
  293. data/config/locales/comments.ko.yml +110 -0
  294. data/config/locales/contacts.en.yml +16 -0
  295. data/config/locales/contacts.ko.yml +16 -0
  296. data/config/locales/creatives.en.yml +183 -0
  297. data/config/locales/creatives.ko.yml +164 -0
  298. data/config/locales/invites.en.yml +19 -0
  299. data/config/locales/invites.ko.yml +17 -0
  300. data/config/locales/notifications.en.yml +8 -0
  301. data/config/locales/notifications.ko.yml +8 -0
  302. data/config/locales/plans.en.yml +12 -0
  303. data/config/locales/plans.ko.yml +19 -0
  304. data/config/locales/themes.en.yml +29 -0
  305. data/config/locales/themes.ko.yml +27 -0
  306. data/config/locales/users.en.yml +151 -0
  307. data/config/locales/users.ko.yml +146 -0
  308. data/config/routes.rb +92 -0
  309. data/db/migrate/20241201000000_create_notion_integrations.rb +29 -0
  310. data/db/migrate/20250128110017_create_creatives.rb +11 -0
  311. data/db/migrate/20250128120122_create_users.rb +11 -0
  312. data/db/migrate/20250128120123_create_sessions.rb +11 -0
  313. data/db/migrate/20250128123633_create_subscribers.rb +10 -0
  314. data/db/migrate/20250312000000_create_notion_block_links.rb +16 -0
  315. data/db/migrate/20250312010000_allow_multiple_notion_blocks_per_creative.rb +5 -0
  316. data/db/migrate/20250522115048_remove_name_from_creatives.rb +5 -0
  317. data/db/migrate/20250522190651_add_parent_id_to_creatives.rb +5 -0
  318. data/db/migrate/20250523133100_rename_inventory_count_to_progress.rb +13 -0
  319. data/db/migrate/20250523133101_add_sequence_to_creatives.rb +5 -0
  320. data/db/migrate/20250525205100_add_user_id_to_creatives.rb +10 -0
  321. data/db/migrate/20250527014217_create_creative_shares.rb +12 -0
  322. data/db/migrate/20250528142349_add_origin_id_to_creatives.rb +5 -0
  323. data/db/migrate/20250530060200_create_tags.rb +9 -0
  324. data/db/migrate/20250531105150_add_value_to_tags.rb +5 -0
  325. data/db/migrate/20250531140142_create_labels.rb +12 -0
  326. data/db/migrate/20250531140145_change_tag_to_label_reference.rb +6 -0
  327. data/db/migrate/20250601000000_create_comments.rb +10 -0
  328. data/db/migrate/20250601061830_drop_plans_and_variations.rb +6 -0
  329. data/db/migrate/20250604122600_add_owner_to_labels.rb +5 -0
  330. data/db/migrate/20250606000000_rename_email_address_to_email_in_users.rb +7 -0
  331. data/db/migrate/20250606150329_create_creative_hierarchies.rb +16 -0
  332. data/db/migrate/20250610142000_create_creative_expanded_states.rb +11 -0
  333. data/db/migrate/20250611030138_create_invitations.rb +15 -0
  334. data/db/migrate/20250611105524_create_inbox_items.rb +14 -0
  335. data/db/migrate/20250612150000_update_permissions.rb +43 -0
  336. data/db/migrate/20250612232913_add_email_verified_at_to_users.rb +5 -0
  337. data/db/migrate/20250616065905_add_avatar_to_users.rb +5 -0
  338. data/db/migrate/20250617092111_add_display_level_to_users.rb +5 -0
  339. data/db/migrate/20250620004558_create_emails.rb +13 -0
  340. data/db/migrate/20250621000000_create_comment_read_pointers.rb +11 -0
  341. data/db/migrate/20250622000000_add_completion_mark_to_users.rb +5 -0
  342. data/db/migrate/20250623000000_add_theme_to_users.rb +5 -0
  343. data/db/migrate/20250624000000_add_name_to_users.rb +18 -0
  344. data/db/migrate/20250714190000_create_devices.rb +17 -0
  345. data/db/migrate/20250715120000_add_notifications_enabled_to_users.rb +5 -0
  346. data/db/migrate/20250823000000_add_no_access_permission.rb +25 -0
  347. data/db/migrate/20250826000000_add_calendar_id_to_users.rb +5 -0
  348. data/db/migrate/20250827000000_add_google_oauth_tokens_to_users.rb +8 -0
  349. data/db/migrate/20250827061238_add_timezone_to_users.rb +5 -0
  350. data/db/migrate/20250828000000_create_calendar_events.rb +16 -0
  351. data/db/migrate/20250828060000_add_creative_to_calendar_events.rb +5 -0
  352. data/db/migrate/20250830141052_add_locale_to_users.rb +5 -0
  353. data/db/migrate/20250830141101_add_message_key_to_inbox_items.rb +6 -0
  354. data/db/migrate/20250902025423_remove_description_and_featured_image_from_creatives.rb +6 -0
  355. data/db/migrate/20250910000000_add_private_to_comments.rb +5 -0
  356. data/db/migrate/20250910105640_migrate_write_permissions_to_admin.rb +17 -0
  357. data/db/migrate/20250911084338_backfill_creative_in_comment_inbox_items.rb +27 -0
  358. data/db/migrate/20250923002959_deduplicate_device_fcm_tokens.rb +25 -0
  359. data/db/migrate/20250924000000_add_system_admin_to_users.rb +6 -0
  360. data/db/migrate/20250925000000_create_github_integrations.rb +26 -0
  361. data/db/migrate/20250927000000_add_webhook_secret_to_github_repository_links.rb +29 -0
  362. data/db/migrate/20250928000000_add_action_and_approver_to_comments.rb +6 -0
  363. data/db/migrate/20250928010000_add_action_execution_tracking_to_comments.rb +6 -0
  364. data/db/migrate/20250928105957_add_github_gemini_prompt_to_creatives.rb +5 -0
  365. data/db/migrate/20250929000000_add_comment_and_creative_refs_to_inbox_items.rb +6 -0
  366. data/db/migrate/20251001000001_create_contacts.rb +71 -0
  367. data/db/migrate/20251002000000_add_shared_by_to_creative_shares.rb +14 -0
  368. data/db/migrate/20251124120902_add_ai_fields_to_users.rb +7 -0
  369. data/db/migrate/20251124122218_add_created_by_id_to_users.rb +5 -0
  370. data/db/migrate/20251124124521_add_llm_api_key_to_users.rb +5 -0
  371. data/db/migrate/20251124130000_add_searchable_to_users.rb +6 -0
  372. data/db/migrate/20251125072705_migrate_linked_creative_children_to_origin.rb +28 -0
  373. data/db/migrate/20251126040752_add_description_to_creatives.rb +75 -0
  374. data/db/migrate/20251127000000_move_comments_from_linked_creatives_to_origins.rb +18 -0
  375. data/db/migrate/20251201073823_add_tools_to_users.rb +5 -0
  376. data/db/migrate/20251202062715_create_mcp_tools.rb +16 -0
  377. data/db/migrate/20251204125754_create_tasks_and_task_actions.rb +23 -0
  378. data/db/migrate/20251204125756_add_routing_expression_to_users.rb +5 -0
  379. data/db/migrate/20251204161133_set_default_routing_expression_for_ai_agents.rb +13 -0
  380. data/db/migrate/20251211033025_nullify_self_referencing_origins.rb +9 -0
  381. data/db/migrate/20251211080040_create_topics_and_add_topic_to_comments.rb +15 -0
  382. data/db/migrate/20251215143500_create_activity_logs.rb +16 -0
  383. data/db/migrate/20251222125727_create_webauthn_credentials.rb +14 -0
  384. data/db/migrate/20251222125839_add_webauthn_id_to_users.rb +5 -0
  385. data/db/migrate/20251223022315_create_comment_reactions.rb +13 -0
  386. data/db/migrate/20251223072625_create_user_themes.rb +11 -0
  387. data/db/migrate/20251230074456_add_creative_id_to_labels.rb +5 -0
  388. data/db/migrate/20251230113607_refactor_labels.rb +38 -0
  389. data/db/migrate/20251231010012_backfill_tags_for_labels.rb +15 -0
  390. data/db/migrate/20251231013234_drop_subscribers.rb +9 -0
  391. data/db/migrate/20260106090544_allow_null_user_id_in_creative_shares.rb +6 -0
  392. data/db/migrate/20260106160643_create_system_settings.rb +11 -0
  393. data/db/migrate/20260116000000_create_creative_shares_cache.rb +15 -0
  394. data/db/migrate/20260116000001_populate_creative_shares_cache.rb +23 -0
  395. data/db/migrate/20260119022933_make_source_share_id_nullable_in_creative_shares_caches.rb +7 -0
  396. data/db/migrate/20260119023446_populate_owner_cache_entries.rb +25 -0
  397. data/db/migrate/20260119100000_add_account_lockout_to_users.rb +6 -0
  398. data/db/migrate/20260119110000_add_last_active_at_to_sessions.rb +6 -0
  399. data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +60 -0
  400. data/db/migrate/20260120162259_remove_fk_from_creative_shares_caches.rb +7 -0
  401. data/db/migrate/20260120163856_remove_timestamps_from_creative_shares_caches.rb +6 -0
  402. data/lib/collavre/configuration.rb +14 -0
  403. data/lib/collavre/engine.rb +77 -0
  404. data/lib/collavre/user_extensions.rb +29 -0
  405. data/lib/collavre/version.rb +3 -0
  406. data/lib/collavre.rb +26 -0
  407. data/lib/generators/collavre/install/install_generator.rb +105 -0
  408. data/lib/generators/collavre/install/templates/build.cjs.tt +100 -0
  409. data/lib/tasks/collavre_assets.rake +15 -0
  410. metadata +591 -0
@@ -0,0 +1,464 @@
1
+ module Collavre
2
+ class CommentsController < ApplicationController
3
+ before_action :set_creative
4
+ before_action :set_comment, only: [ :destroy, :show, :update, :convert, :approve, :update_action ]
5
+
6
+ def index
7
+ limit = 20
8
+
9
+ visible_scope = @creative.comments.where(
10
+ "comments.private = ? OR comments.user_id = ? OR comments.approver_id = ?",
11
+ false,
12
+ Current.user.id,
13
+ Current.user.id
14
+ )
15
+ scope = visible_scope.with_attached_images.includes(:topic, :comment_reactions)
16
+
17
+ if params[:search].present?
18
+ search_term = ActiveRecord::Base.sanitize_sql_like(params[:search].to_s.strip.downcase)
19
+ scope = scope.where("LOWER(comments.content) LIKE ?", "%#{search_term}%")
20
+ end
21
+
22
+ # Filter by topic
23
+ # Logic:
24
+ # 1. Prefer params[:topic_id] if explicit.
25
+ # 2. If deep linking (around_comment_id), infer from target comment if valid.
26
+ # 3. Default to nil (Main).
27
+
28
+ effective_topic_id = params[:topic_id].presence
29
+
30
+ if params[:around_comment_id].present?
31
+ target_id = params[:around_comment_id].to_i
32
+ # Ensure target is visible and belongs to this creative
33
+ target_comment = visible_scope.find_by(id: target_id)
34
+
35
+ if target_comment
36
+ effective_topic_id = target_comment.topic_id
37
+ # Inform frontend about the topic switch
38
+ response.headers["X-Topic-Id"] = effective_topic_id.to_s
39
+ end
40
+ end
41
+
42
+ # Apply the Topic Filter
43
+ scope = scope.where(topic_id: effective_topic_id) if effective_topic_id.present?
44
+
45
+ # Default order: Newest first (created_at DESC)
46
+ # This matches the column-reverse layout where the first item in the list is the visual bottom (Newest).
47
+ scope = scope.order(created_at: :desc)
48
+
49
+
50
+ @comments = if params[:around_comment_id].present?
51
+ # Deep linking: Load context around a specific comment
52
+ target_id = params[:around_comment_id].to_i
53
+
54
+ # Newer messages have HIGHER IDs.
55
+ # Older messages have LOWER IDs.
56
+
57
+ # Newer bundle (including target): ID >= target_id
58
+ newer_bundle = scope.where("comments.id >= ?", target_id).reorder(created_at: :asc).limit(limit / 2 + 1)
59
+
60
+ # Older bundle: ID < target_id
61
+ older_bundle = scope.where("comments.id < ?", target_id).limit(limit / 2)
62
+
63
+ # Combine: [Newer (ASC) ... Target ... Older (DESC)]
64
+ # We need final output to be ASC due to restored view logic: [Oldest ... Target ... Newest]
65
+ (older_bundle.to_a.reverse + newer_bundle.to_a).uniq
66
+ elsif params[:after_id].present? && params[:before_id].present?
67
+ # Invalid state, prioritize before (loading older history)
68
+ scope.where("comments.id < ?", params[:before_id].to_i).limit(limit).to_a.reverse
69
+ elsif params[:before_id].present?
70
+ # Load OLDER messages (lower IDs)
71
+ # Visually scrolling UP
72
+ scope.where("comments.id < ?", params[:before_id].to_i).limit(limit).to_a.reverse
73
+ elsif params[:after_id].present?
74
+ # Load NEWER messages (higher IDs)
75
+ # Visually scrolling DOWN
76
+ # We want the ones immediately *after* the current newest.
77
+ # Since default sort is DESC (Newest first), "after" means id > after_id.
78
+ # But standard DESC query would give us the VERY Newest.
79
+ # We want the ones just above `after_id`.
80
+
81
+ # Use reorder(ASC) to get the ones immediately larger than after_id, then reverse back to DESC.
82
+ scope.where("comments.id > ?", params[:after_id].to_i).reorder(created_at: :asc).limit(limit)
83
+ else
84
+ # Initial Load (Latest messages)
85
+ scope.limit(limit).to_a.reverse
86
+ end
87
+
88
+ present_user_ids = CommentPresenceStore.list(@creative.id)
89
+
90
+ read_receipts = {}
91
+ if @comments.any?
92
+ # Fetch all read pointers for this creative that point to comments in the current list
93
+ # We only care about pointers that match the IDs of the comments we are displaying?
94
+ # Or rather, we want to show the 'line' on the comment that matches the pointer.
95
+
96
+ # Optimization: Fetch all pointers for participants of this creative.
97
+ # Scoped to the creative.
98
+ pointers = CommentReadPointer.where(creative: @creative)
99
+ .where.not(last_read_comment_id: nil)
100
+ .includes(user: { avatar_attachment: :blob })
101
+
102
+ # Fetch all visible IDs for correct read-receipt placement transparency
103
+ # Only map read receipts to PUBLIC comments.
104
+ # Users who read private comments will appear on the nearest preceding public comment.
105
+ public_ids = @creative.comments.where(private: false).order(id: :asc).pluck(:id)
106
+
107
+ pointers.each do |pointer|
108
+ effective_id = pointer.effective_comment_id(public_ids)
109
+ if effective_id
110
+ read_receipts[effective_id] ||= []
111
+ read_receipts[effective_id] << pointer.user
112
+ end
113
+ end
114
+ end
115
+
116
+ if params[:after_id].present? || params[:before_id].present?
117
+ render partial: "collavre/comments/comment",
118
+ collection: @comments,
119
+ as: :comment,
120
+ locals: { read_receipts: read_receipts, present_user_ids: present_user_ids, current_topic_id: effective_topic_id }
121
+ else
122
+ render partial: "collavre/comments/list", locals: {
123
+ comments: @comments,
124
+ creative: @creative,
125
+ read_receipts: read_receipts,
126
+ present_user_ids: present_user_ids,
127
+ current_topic_id: effective_topic_id
128
+ }
129
+ end
130
+ end
131
+
132
+ def create
133
+ unless @creative.has_permission?(Current.user, :feedback)
134
+ render json: { error: I18n.t("collavre.comments.no_permission") }, status: :forbidden and return
135
+ end
136
+
137
+ comment_attributes = comment_params.except(:images)
138
+ image_attachments = comment_params[:images]
139
+
140
+ @comment = @creative.comments.build(comment_attributes)
141
+
142
+ if @comment.topic_id.present? && !@creative.topics.where(id: @comment.topic_id).exists?
143
+ render json: { error: I18n.t("collavre.comments.invalid_topic") }, status: :unprocessable_entity and return
144
+ end
145
+
146
+ @comment.user = Current.user
147
+ @comment.images.attach(image_attachments) if image_attachments.present?
148
+ response = ::Comments::CommandProcessor.new(comment: @comment, user: Current.user).call
149
+ @comment.content = "#{@comment.content}\n\n#{response}" if response.present?
150
+ if @comment.save
151
+
152
+ # Dispatch system event
153
+ ::SystemEvents::Dispatcher.dispatch("comment_created", {
154
+ comment: {
155
+ id: @comment.id,
156
+ content: @comment.content,
157
+ user_id: @comment.user_id
158
+ },
159
+ creative: {
160
+ id: @creative.id,
161
+ description: @creative.description
162
+ },
163
+ chat: {
164
+ content: @comment.content
165
+ }
166
+ }) unless @comment.private?
167
+ @comment = Comment.with_attached_images.includes(:comment_reactions).find(@comment.id)
168
+ render partial: "collavre/comments/comment", locals: { comment: @comment, current_topic_id: current_topic_context }, status: :created
169
+ else
170
+ render json: { errors: @comment.errors.full_messages }, status: :unprocessable_entity
171
+ end
172
+ end
173
+
174
+ def update
175
+ if @comment.user == Current.user
176
+ safe_params = comment_params
177
+ if safe_params[:topic_id].present? && !@creative.topics.where(id: safe_params[:topic_id]).exists?
178
+ render json: { error: I18n.t("collavre.comments.invalid_topic") }, status: :unprocessable_entity and return
179
+ end
180
+
181
+ if @comment.update(safe_params)
182
+ @comment = Comment.with_attached_images.includes(:comment_reactions).find(@comment.id)
183
+ render partial: "collavre/comments/comment", locals: { comment: @comment, current_topic_id: current_topic_context }
184
+ else
185
+ render json: { errors: @comment.errors.full_messages }, status: :unprocessable_entity
186
+ end
187
+ else
188
+ render json: { error: I18n.t("collavre.comments.not_owner") }, status: :forbidden
189
+ end
190
+ end
191
+
192
+ def destroy
193
+ # @comment is set by before_action
194
+ is_owner = @comment.user == Current.user
195
+ is_admin = @creative.has_permission?(Current.user, :admin)
196
+ is_creative_owner = @creative.user == Current.user
197
+
198
+ if is_owner || is_admin || is_creative_owner
199
+ # If admin/creative owner is deleting someone else's comment, send notification
200
+ if (is_admin || is_creative_owner) && !is_owner && @comment.user.present? && !@comment.user.ai_user?
201
+ if @comment.user.present?
202
+ InboxItem.create!(
203
+ owner: @comment.user,
204
+ creative: @creative,
205
+ comment: @comment,
206
+ message_key: "inbox.comment_deleted_by_admin",
207
+ message_params: {
208
+ admin_name: Current.user.name,
209
+ creative_snippet: @creative.creative_snippet,
210
+ comment_content: @comment.content
211
+ },
212
+ link: creative_path(@creative)
213
+ )
214
+ end
215
+ end
216
+
217
+ @comment.destroy
218
+ head :no_content
219
+ else
220
+ render json: { error: I18n.t("collavre.comments.not_owner") }, status: :forbidden
221
+ end
222
+ end
223
+
224
+ def convert
225
+ unless can_convert_comment?
226
+ render json: { error: I18n.t("collavre.comments.convert_not_allowed") }, status: :forbidden and return
227
+ end
228
+
229
+ created_creatives = ::MarkdownImporter.import(
230
+ @comment.content,
231
+ parent: @creative,
232
+ user: @creative.user,
233
+ create_root: true
234
+ )
235
+
236
+ primary_creative = created_creatives.first
237
+ system_message = build_convert_system_message(primary_creative) if primary_creative
238
+
239
+ @comment.destroy
240
+
241
+ if system_message.present?
242
+ Current.set(session: nil) do
243
+ @creative.comments.create!(content: system_message, user: nil)
244
+ end
245
+ end
246
+
247
+ head :no_content
248
+ end
249
+
250
+ def approve
251
+ status = @comment.approval_status(Current.user)
252
+ if status != :ok
253
+ error_key = case status
254
+ when :invalid_action_format then "collavre.comments.approve_invalid_format"
255
+ when :missing_action then "collavre.comments.approve_missing_action"
256
+ when :missing_approver then "collavre.comments.approve_missing_approver"
257
+ when :admin_required then "collavre.comments.approve_admin_required"
258
+ else "collavre.comments.approve_not_allowed"
259
+ end
260
+ http_status = case status
261
+ when :invalid_action_format, :missing_action, :missing_approver
262
+ :unprocessable_entity
263
+ else
264
+ :forbidden
265
+ end
266
+ render json: { error: I18n.t(error_key) }, status: http_status and return
267
+ end
268
+
269
+ begin
270
+ ::Comments::ActionExecutor.new(comment: @comment, executor: Current.user).call
271
+ @comment = Comment.with_attached_images.includes(:comment_reactions).find(@comment.id)
272
+ render partial: "collavre/comments/comment", locals: { comment: @comment, current_topic_id: current_topic_context }
273
+ rescue ::Comments::ActionExecutor::ExecutionError => e
274
+ render json: { error: e.message }, status: :unprocessable_entity
275
+ end
276
+ end
277
+
278
+ def update_action
279
+ # Initial checks outside the lock
280
+ executed_error = false
281
+ update_success = false
282
+ approver_mismatch_error = false
283
+ status_error_key = nil
284
+ status_http_status = nil
285
+ validation_error_message = nil
286
+
287
+ @comment.with_lock do
288
+ @comment.reload
289
+
290
+ status_in_lock = @comment.approval_status(Current.user)
291
+ # Allow repairing invalid format if user is approver
292
+ if status_in_lock == :invalid_action_format
293
+ if @comment.approver_id == Current.user&.id
294
+ status_in_lock = :ok
295
+ else
296
+ status_in_lock = :not_allowed
297
+ end
298
+ end
299
+
300
+ if status_in_lock != :ok
301
+ approver_mismatch_error = true
302
+ status_error_key = case status_in_lock
303
+ when :invalid_action_format then "collavre.comments.approve_invalid_format"
304
+ when :missing_action then "collavre.comments.approve_missing_action"
305
+ when :missing_approver then "collavre.comments.approve_missing_approver"
306
+ when :admin_required then "collavre.comments.approve_admin_required"
307
+ else "collavre.comments.approve_not_allowed"
308
+ end
309
+ status_http_status = case status_in_lock
310
+ when :invalid_action_format, :missing_action, :missing_approver
311
+ :unprocessable_entity
312
+ else
313
+ :forbidden
314
+ end
315
+ elsif @comment.action_executed_at.present?
316
+ executed_error = true
317
+ else
318
+ action_payload = params.dig(:comment, :action)
319
+ if action_payload.blank?
320
+ validation_error_message = I18n.t("collavre.comments.approve_missing_action")
321
+ else
322
+ begin
323
+ validator = ::Comments::ActionValidator.new(comment: @comment)
324
+ parsed_payload = validator.validate!(action_payload)
325
+ normalized_action = JSON.pretty_generate(parsed_payload)
326
+ update_success = @comment.update(action: normalized_action)
327
+ rescue ::Comments::ActionValidator::ValidationError => e
328
+ validation_error_message = e.message
329
+ end
330
+ end
331
+ end
332
+ end
333
+
334
+ if approver_mismatch_error
335
+ render json: { error: I18n.t(status_error_key) }, status: status_http_status
336
+ elsif validation_error_message
337
+ render json: { error: validation_error_message }, status: :unprocessable_entity
338
+ elsif executed_error
339
+ render json: { error: I18n.t("collavre.comments.approve_already_executed") }, status: :unprocessable_entity
340
+ elsif update_success
341
+ @comment = Comment.with_attached_images.includes(:comment_reactions).find(@comment.id)
342
+ render partial: "collavre/comments/comment", locals: { comment: @comment, current_topic_id: current_topic_context }
343
+ else
344
+ error_message = @comment.errors.full_messages.to_sentence.presence || I18n.t("collavre.comments.action_update_error")
345
+ render json: { error: error_message }, status: :unprocessable_entity
346
+ end
347
+ rescue ::Comments::ActionValidator::ValidationError => e
348
+ render json: { error: e.message }, status: :unprocessable_entity
349
+ end
350
+
351
+ def show
352
+ redirect_to creative_path(@creative, comment_id: @comment.id)
353
+ end
354
+
355
+ def participants
356
+ users = [ @creative.user ].compact + @creative.all_shared_users(:feedback).map(&:user)
357
+ users = users.uniq
358
+ data = users.map do |u|
359
+ {
360
+ id: u.id,
361
+ email: u.email,
362
+ name: u.display_name,
363
+ avatar_url: view_context.user_avatar_url(u, size: 20),
364
+ default_avatar: !u.avatar.attached? && u.avatar_url.blank?,
365
+ initial: u.display_name[0].upcase
366
+ }
367
+ end
368
+ render json: data
369
+ end
370
+
371
+ def move
372
+ comment_ids = Array(params[:comment_ids]).map(&:presence).compact.map(&:to_i)
373
+ if comment_ids.empty?
374
+ render json: { error: I18n.t("collavre.comments.move_no_selection") }, status: :unprocessable_entity and return
375
+ end
376
+
377
+ target_creative = Creative.find_by(id: params[:target_creative_id])
378
+ if target_creative.nil?
379
+ render json: { error: I18n.t("collavre.comments.move_invalid_target") }, status: :unprocessable_entity and return
380
+ end
381
+
382
+ target_origin = target_creative.effective_origin
383
+
384
+ unless @creative.has_permission?(Current.user, :feedback) && target_origin.has_permission?(Current.user, :feedback)
385
+ render json: { error: I18n.t("collavre.comments.move_not_allowed") }, status: :forbidden and return
386
+ end
387
+
388
+ scope = @creative.comments.where(
389
+ "comments.private = ? OR comments.user_id = ? OR comments.approver_id = ?",
390
+ false,
391
+ Current.user.id,
392
+ Current.user.id
393
+ )
394
+
395
+ comments = scope.where(id: comment_ids).to_a
396
+
397
+ if comments.length != comment_ids.length
398
+ render json: { error: I18n.t("collavre.comments.move_not_allowed") }, status: :forbidden and return
399
+ end
400
+
401
+ ActiveRecord::Base.transaction do
402
+ comments.each do |comment|
403
+ next if comment.creative_id == target_origin.id
404
+
405
+ original_creative = comment.creative
406
+ comment.update!(creative: target_origin, topic_id: nil)
407
+ broadcast_move_removal(comment, original_creative)
408
+ end
409
+ end
410
+
411
+ Comment.broadcast_badges(@creative)
412
+ Comment.broadcast_badges(target_origin) unless target_origin == @creative
413
+
414
+ render json: { success: true }
415
+ rescue ActiveRecord::RecordInvalid => e
416
+ render json: { error: e.record.errors.full_messages.to_sentence.presence || I18n.t("collavre.comments.move_error") }, status: :unprocessable_entity
417
+ end
418
+
419
+ private
420
+
421
+ def set_creative
422
+ @creative = Creative.find(params[:creative_id]).effective_origin
423
+ end
424
+
425
+ def set_comment
426
+ @comment = @creative.comments
427
+ .where(
428
+ "comments.private = ? OR comments.user_id = ? OR comments.approver_id = ?",
429
+ false,
430
+ Current.user.id,
431
+ Current.user.id
432
+ )
433
+ .find(params[:id])
434
+ end
435
+
436
+ def comment_params
437
+ params.require(:comment).permit(:content, :private, :topic_id, images: [])
438
+ end
439
+
440
+ def can_convert_comment?
441
+ @comment.user == Current.user || @creative.has_permission?(Current.user, :admin)
442
+ end
443
+
444
+ def broadcast_move_removal(comment, original_creative)
445
+ return if comment.private?
446
+
447
+ Turbo::StreamsChannel.broadcast_remove_to(
448
+ [ original_creative, :comments ],
449
+ target: view_context.dom_id(comment)
450
+ )
451
+ end
452
+
453
+ def build_convert_system_message(creative)
454
+ title = helpers.strip_tags(creative.description).to_s.strip
455
+ title = I18n.t("collavre.comments.convert_system_message_default_title") if title.blank?
456
+ url = creative_path(creative)
457
+ I18n.t("collavre.comments.convert_system_message", title: title, url: url)
458
+ end
459
+
460
+ def current_topic_context
461
+ params[:topic_id].presence || params.dig(:comment, :topic_id).presence
462
+ end
463
+ end
464
+ end
@@ -0,0 +1,10 @@
1
+ module Collavre
2
+ class ContactsController < ApplicationController
3
+ def destroy
4
+ contact = Current.user.contacts.find(params[:id])
5
+ contact.destroy
6
+ redirect_to user_path(Current.user, tab: "contacts", contact_page: params[:contact_page]),
7
+ notice: t("collavre.contacts.notices.removed")
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,27 @@
1
+ module Collavre
2
+ class CreativeExpandedStatesController < ApplicationController
3
+ def toggle
4
+ creative_id = params[:creative_id]
5
+ node_id = params[:node_id].to_s
6
+ expanded = ActiveModel::Type::Boolean.new.cast(params[:expanded])
7
+
8
+ record = CreativeExpandedState.find_or_initialize_by(creative_id: creative_id, user_id: Current.user.id)
9
+ state = record.expanded_status || {}
10
+
11
+ if expanded
12
+ state[node_id] = true
13
+ else
14
+ state.delete(node_id)
15
+ end
16
+
17
+ record.expanded_status = state
18
+ if state.empty?
19
+ record.destroy if record.persisted?
20
+ else
21
+ record.save!
22
+ end
23
+
24
+ render json: { success: true }
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,24 @@
1
+ module Collavre
2
+ class CreativeImportsController < ApplicationController
3
+ allow_unauthenticated_access only: :create
4
+
5
+ def create
6
+ unless authenticated?
7
+ render json: { error: "Unauthorized" }, status: :unauthorized and return
8
+ end
9
+
10
+ parent = params[:parent_id].present? ? Creative.find_by(id: params[:parent_id]) : nil
11
+ created = ::Creatives::Importer.new(file: params[:markdown], user: Current.user, parent: parent).call
12
+
13
+ if created.any?
14
+ render json: { success: true, created: created.map(&:id) }
15
+ else
16
+ render json: { error: "No creatives created" }, status: :unprocessable_entity
17
+ end
18
+ rescue ::Creatives::Importer::UnsupportedFile
19
+ render json: { error: "Invalid file type" }, status: :unprocessable_entity
20
+ rescue ::Creatives::Importer::Error => e
21
+ render json: { error: e.message }, status: :unprocessable_entity
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,69 @@
1
+ module Collavre
2
+ class CreativePlansController < ApplicationController
3
+ before_action :require_authentication
4
+
5
+ def create
6
+ result = tagger.apply
7
+ respond_to do |format|
8
+ format.html do
9
+ flash[result.success? ? :notice : :alert] = translate_message(
10
+ result,
11
+ success_key: "collavre.creatives.index.plan_tags_applied",
12
+ success_default: "Plan tags applied to selected creatives.",
13
+ failure_key: "collavre.creatives.index.plan_tag_failed",
14
+ failure_default: "Please select a plan and at least one creative."
15
+ )
16
+ redirect_back fallback_location: creatives_path(select_mode: 1)
17
+ end
18
+ format.json do
19
+ if result.success?
20
+ render json: { message: translate_message(result, success_key: "collavre.creatives.index.plan_tags_applied", success_default: "Plan tags applied.", failure_key: "", failure_default: "") }, status: :ok
21
+ else
22
+ render json: { error: translate_message(result, success_key: "", success_default: "", failure_key: "collavre.creatives.index.plan_tag_failed", failure_default: "Failed to apply plan.") }, status: :unprocessable_entity
23
+ end
24
+ end
25
+ end
26
+ end
27
+
28
+ def destroy
29
+ result = tagger.remove
30
+ respond_to do |format|
31
+ format.html do
32
+ flash[result.success? ? :notice : :alert] = translate_message(
33
+ result,
34
+ success_key: "collavre.creatives.index.plan_tags_removed",
35
+ success_default: "Plan tag removed from selected creatives.",
36
+ failure_key: "collavre.creatives.index.plan_tag_remove_failed",
37
+ failure_default: "Please select a plan and at least one creative."
38
+ )
39
+ redirect_back fallback_location: creatives_path(select_mode: 1)
40
+ end
41
+ format.json do
42
+ if result.success?
43
+ render json: { message: translate_message(result, success_key: "collavre.creatives.index.plan_tags_removed", success_default: "Plan tag removed.", failure_key: "", failure_default: "") }, status: :ok
44
+ else
45
+ render json: { error: translate_message(result, success_key: "", success_default: "", failure_key: "collavre.creatives.index.plan_tag_remove_failed", failure_default: "Failed to remove plan.") }, status: :unprocessable_entity
46
+ end
47
+ end
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def tagger
54
+ ::Creatives::PlanTagger.new(plan_id: params[:plan_id], creative_ids: parsed_creative_ids)
55
+ end
56
+
57
+ def parsed_creative_ids
58
+ params[:creative_ids].to_s.split(",").map(&:strip).reject(&:blank?)
59
+ end
60
+
61
+ def translate_message(result, success_key:, success_default:, failure_key:, failure_default:)
62
+ if result.success?
63
+ I18n.t(success_key, default: success_default)
64
+ else
65
+ I18n.t(failure_key, default: failure_default)
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,79 @@
1
+ module Collavre
2
+ class CreativeSharesController < ApplicationController
3
+ def create
4
+ @creative = Creative.find(params[:creative_id]).effective_origin
5
+
6
+ user = nil
7
+ if params[:user_email].present?
8
+ user = User.find_by(email: params[:user_email])
9
+ unless user
10
+ invitation = Invitation.create!(email: params[:user_email], inviter: Current.user, creative: @creative, permission: params[:permission])
11
+ InvitationMailer.with(invitation: invitation).invite.deliver_later
12
+ flash[:notice] = t("collavre.invites.invite_sent")
13
+ redirect_back(fallback_location: creatives_path) and return
14
+ end
15
+ end
16
+
17
+ permission = params[:permission]
18
+
19
+ # Enforce read-only for public shares (no user email provided)
20
+ if params[:user_email].blank? && permission != "no_access" && permission != "read"
21
+ permission = "read"
22
+ end
23
+
24
+ ancestor_ids = @creative.ancestors.pluck(:id)
25
+ ancestor_shares = CreativeShare.where(creative_id: ancestor_ids, user: user)
26
+ .where("permission >= ? or permission = ?", CreativeShare.permissions[permission], CreativeShare.permissions[:no_access])
27
+ closest_parent_share = CreativeShare.closest_parent_share(ancestor_ids, ancestor_shares)
28
+
29
+ is_param_no_access = permission == :no_access.to_s
30
+ Rails.logger.debug "### closest_parent_share = #{closest_parent_share.inspect}, is_param_no_access: #{is_param_no_access}"
31
+ if closest_parent_share.present?
32
+ if closest_parent_share.permission == :no_access.to_s
33
+ flash[:alert] = t("collavre.creatives.share.can_not_share_by_no_access_in_parent")
34
+ redirect_back(fallback_location: creatives_path) and return
35
+ else
36
+ if is_param_no_access
37
+ # can set!
38
+ else
39
+ flash[:alert] = t("collavre.creatives.share.already_shared_in_parent")
40
+ redirect_back(fallback_location: creatives_path) and return
41
+ end
42
+ end
43
+ end
44
+
45
+ share = CreativeShare.find_or_initialize_by(creative: @creative, user: user)
46
+ share.shared_by ||= Current.user
47
+ share.permission = permission
48
+ if share.save and not is_param_no_access
49
+ if user
50
+ @creative.create_linked_creative_for_user(user)
51
+ Contact.ensure(user: Current.user, contact_user: user)
52
+ Contact.ensure(user: @creative.user, contact_user: user)
53
+ end
54
+ flash[:notice] = t("collavre.creatives.share.shared")
55
+ else
56
+ flash[:alert] = share.errors.full_messages.to_sentence
57
+ end
58
+ redirect_back(fallback_location: creatives_path)
59
+ end
60
+
61
+ def destroy
62
+ @creative_share = CreativeShare.find(params[:id])
63
+ @creative_share.destroy
64
+ # remove linked creative if it exists
65
+ linked_creative = Creative.find_by(origin_id: @creative_share.creative_id, user_id: @creative_share.user_id)
66
+ linked_creative&.destroy
67
+ respond_to do |format|
68
+ format.html { redirect_back fallback_location: main_app.root_path, notice: t("collavre.creatives.index.share_deleted") }
69
+ format.json { head :no_content }
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ def all_descendants(creative)
76
+ creative.children.flat_map { |child| [ child ] + all_descendants(child) }
77
+ end
78
+ end
79
+ end