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,131 @@
1
+ module Collavre
2
+ module Creatives
3
+ class PathExporter
4
+ Entry = Struct.new(
5
+ :creative_id,
6
+ :progress,
7
+ :path,
8
+ :path_with_ids,
9
+ :path_with_ids_and_progress,
10
+ :full_path_with_ids,
11
+ :full_path_with_ids_and_progress,
12
+ :leaf,
13
+ keyword_init: true
14
+ )
15
+
16
+ def initialize(creative, use_effective_origin: true)
17
+ @creative = use_effective_origin ? creative.effective_origin : creative
18
+ end
19
+
20
+ def paths
21
+ entries.map(&:path)
22
+ end
23
+
24
+ def paths_with_ids
25
+ entries.map(&:path_with_ids)
26
+ end
27
+
28
+ def paths_with_ids_and_progress
29
+ entries.map(&:path_with_ids_and_progress)
30
+ end
31
+
32
+ def path_for(id)
33
+ entry_map[id.to_i]&.path
34
+ end
35
+
36
+ def path_with_ids_for(id)
37
+ entry_map[id.to_i]&.path_with_ids
38
+ end
39
+
40
+ def path_with_ids_and_progress_for(id)
41
+ entry_map[id.to_i]&.path_with_ids_and_progress
42
+ end
43
+
44
+ def full_paths_with_ids
45
+ entries.map(&:full_path_with_ids)
46
+ end
47
+
48
+ def full_paths_with_ids_and_progress
49
+ entries.map(&:full_path_with_ids_and_progress)
50
+ end
51
+
52
+ def full_path_with_ids_for(id)
53
+ entry_map[id.to_i]&.full_path_with_ids
54
+ end
55
+
56
+ def full_path_with_ids_and_progress_for(id)
57
+ entry_map[id.to_i]&.full_path_with_ids_and_progress
58
+ end
59
+
60
+ def full_paths_with_ids_and_progress_with_leaf
61
+ entries.map do |entry|
62
+ {
63
+ path: entry.full_path_with_ids_and_progress,
64
+ leaf: entry.leaf
65
+ }
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ def entries
72
+ @entries ||= begin
73
+ results = []
74
+ traverse(@creative, [], [], [], results)
75
+ results
76
+ end
77
+ end
78
+
79
+ def entry_map
80
+ @entry_map ||= entries.index_by(&:creative_id)
81
+ end
82
+
83
+ def traverse(node, ancestors, ancestors_with_ids, ancestors_with_ids_and_progress, results)
84
+ label = extract_label(node)
85
+ label_with_id = "[#{node.id}] #{label}"
86
+ label_with_progress = label_with_progress(node, label_with_id)
87
+ current_path = (ancestors + [ label ]).join(" > ")
88
+ current_path_with_ids = (ancestors_with_ids + [ label_with_id ]).join(" > ")
89
+ current_path_with_ids_and_progress = (ancestors_with_ids_and_progress + [ label_with_progress ]).join(" > ")
90
+
91
+ children = node.children.order(:sequence).to_a
92
+
93
+ results << Entry.new(
94
+ creative_id: node.id,
95
+ progress: node.progress,
96
+ path: current_path,
97
+ path_with_ids: label_with_id,
98
+ path_with_ids_and_progress: label_with_progress,
99
+ full_path_with_ids: current_path_with_ids,
100
+ full_path_with_ids_and_progress: current_path_with_ids_and_progress,
101
+ leaf: children.empty?
102
+ )
103
+
104
+ children.each do |child|
105
+ traverse(
106
+ child,
107
+ ancestors + [ label ],
108
+ ancestors_with_ids + [ label_with_id ],
109
+ ancestors_with_ids_and_progress + [ label_with_progress ],
110
+ results
111
+ )
112
+ end
113
+ end
114
+
115
+ def extract_label(creative)
116
+ html = creative.effective_description(nil, true).to_s
117
+ sanitized = ActionView::Base.full_sanitizer.sanitize(html).to_s
118
+ text = sanitized.gsub(/[\r\n]+/, " ").squeeze(" ").strip
119
+ text.presence || "Creative ##{creative.id}"
120
+ end
121
+
122
+ def label_with_progress(node, base_label)
123
+ progress = node.progress
124
+ return base_label if progress.nil?
125
+
126
+ percentage = (progress.to_f * 100).round
127
+ "#{base_label} (progress #{percentage}%)"
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,194 @@
1
+ module Collavre
2
+ module Creatives
3
+ class PermissionCacheBuilder
4
+ # Creative 생성 시 소유자 캐시 추가
5
+ def self.cache_owner(creative)
6
+ return unless creative.user_id
7
+
8
+ CreativeSharesCache.upsert(
9
+ {
10
+ creative_id: creative.id,
11
+ user_id: creative.user_id,
12
+ permission: CreativeShare.permissions[:admin],
13
+ source_share_id: nil
14
+ },
15
+ unique_by: [ :creative_id, :user_id ],
16
+ update_only: [ :permission, :source_share_id ]
17
+ )
18
+ end
19
+
20
+ # Creative user_id 변경 시 호출
21
+ def self.update_owner(creative, old_user_id, new_user_id)
22
+ # 기존 소유자의 owner 캐시 삭제 (source_share_id가 nil인 것만)
23
+ if old_user_id
24
+ CreativeSharesCache.where(
25
+ creative_id: creative.id,
26
+ user_id: old_user_id,
27
+ source_share_id: nil
28
+ ).delete_all
29
+ end
30
+
31
+ # 새 소유자 캐시 추가
32
+ cache_owner(creative) if new_user_id
33
+ end
34
+
35
+ # CreativeShare 생성/업데이트 시 호출
36
+ def self.propagate_share(creative_share)
37
+ return if creative_share.destroyed?
38
+
39
+ creative = creative_share.creative
40
+ user_id = creative_share.user_id
41
+ permission = creative_share.permission
42
+
43
+ # 해당 creative + 모든 자손 ID (closure_tree 사용)
44
+ all_descendant_ids = [ creative.id ] + creative.descendant_ids
45
+
46
+ # "closest share wins" 의미론 적용:
47
+ # 이 share의 자손 중 더 가까운 share가 있는 creative는 제외
48
+ # (더 가까운 share가 해당 서브트리를 담당)
49
+ # no_access도 closer share로 인정 (public share보다 우선)
50
+ closer_share_creative_ids = CreativeShare
51
+ .where(user_id: user_id)
52
+ .where(creative_id: creative.descendant_ids) # 이 creative의 strict descendants에 있는 share들
53
+ .where.not(id: creative_share.id) # 자기 자신 제외
54
+ .pluck(:creative_id)
55
+
56
+ # 더 가까운 share가 있는 creative와 그 자손들은 제외
57
+ excluded_ids = Set.new
58
+ if closer_share_creative_ids.any?
59
+ excluded_ids = CreativeHierarchy
60
+ .where(ancestor_id: closer_share_creative_ids)
61
+ .pluck(:descendant_id)
62
+ .to_set
63
+ end
64
+
65
+ ids_to_update = all_descendant_ids.reject { |id| excluded_ids.include?(id) }
66
+
67
+ return if ids_to_update.empty?
68
+
69
+ permission_value = CreativeShare.permissions[permission]
70
+
71
+ # Handle NULL user_id (public shares) separately since SQLite treats NULL as distinct in unique indexes
72
+ if user_id.nil?
73
+ ids_to_update.each do |cid|
74
+ CreativeSharesCache.find_or_initialize_by(creative_id: cid, user_id: nil).tap do |cache|
75
+ cache.assign_attributes(
76
+ permission: permission_value,
77
+ source_share_id: creative_share.id
78
+ )
79
+ cache.save!
80
+ end
81
+ end
82
+ else
83
+ records = ids_to_update.map do |cid|
84
+ {
85
+ creative_id: cid,
86
+ user_id: user_id,
87
+ permission: permission_value,
88
+ source_share_id: creative_share.id
89
+ }
90
+ end
91
+
92
+ # Single bulk operation instead of N individual saves
93
+ CreativeSharesCache.upsert_all(
94
+ records,
95
+ unique_by: [ :creative_id, :user_id ],
96
+ update_only: [ :permission, :source_share_id ]
97
+ )
98
+ end
99
+ end
100
+
101
+ # CreativeShare 삭제 시 호출
102
+ def self.remove_share(creative_share)
103
+ CreativeSharesCache.where(source_share_id: creative_share.id).delete_all
104
+
105
+ # 삭제 후 조상에서 다른 share가 있으면 다시 전파
106
+ rebuild_from_ancestors(creative_share.creative, creative_share.user_id)
107
+ end
108
+
109
+ # Public wrapper for rebuild_from_ancestors (used by PermissionCacheJob)
110
+ def self.rebuild_from_ancestors_for_user(creative, user_id)
111
+ rebuild_from_ancestors(creative, user_id)
112
+ end
113
+
114
+ # CreativeShare의 creative_id 또는 user_id 변경 시 이전 위치/사용자에 대해 호출
115
+ # 특정 사용자의 캐시를 서브트리에서 재구축 (조상 + 서브트리 내 직접 share 모두 고려)
116
+ # user_id는 nil (public share)일 수 있음
117
+ def self.rebuild_user_cache_for_subtree(creative, user_id)
118
+ return unless creative
119
+
120
+ descendant_ids = [ creative.id ] + creative.descendant_ids
121
+
122
+ # 해당 사용자의 캐시만 삭제 (owner entries 유지를 위해 source_share_id가 있는 것만)
123
+ CreativeSharesCache.where(creative_id: descendant_ids, user_id: user_id)
124
+ .where.not(source_share_id: nil)
125
+ .delete_all
126
+
127
+ # 조상에서 해당 사용자의 share 찾아서 전파
128
+ rebuild_from_ancestors(creative, user_id)
129
+
130
+ # 서브트리 내의 해당 사용자 직접 share들도 재적용
131
+ # no_access도 포함 - 캐시에 저장하여 public share를 override
132
+ CreativeShare.where(creative_id: descendant_ids, user_id: user_id)
133
+ .find_each { |share| propagate_share(share) }
134
+ end
135
+
136
+ # Creative parent_id 변경 시 호출
137
+ def self.rebuild_for_creative(creative)
138
+ descendant_ids = creative.self_and_descendant_ids
139
+
140
+ # 해당 creative와 자손들의 캐시 삭제 (share 기반만 - owner entries 유지)
141
+ CreativeSharesCache.where(creative_id: descendant_ids)
142
+ .where.not(source_share_id: nil)
143
+ .delete_all
144
+
145
+ # 새 부모 기준으로 조상 share 전파
146
+ rebuild_from_ancestors_for_subtree(creative)
147
+
148
+ # 이동된 서브트리 내의 직접 share들 재적용
149
+ # (closest-share-wins 의미론으로 올바르게 전파됨)
150
+ # no_access도 포함 - 캐시에 저장하여 public share를 override
151
+ CreativeShare.where(creative_id: descendant_ids)
152
+ .find_each { |share| propagate_share(share) }
153
+
154
+ # 소유자 캐시 재확인 (혹시 누락된 경우)
155
+ creative.self_and_descendants.each do |c|
156
+ cache_owner(c) if c.user_id
157
+ end
158
+ end
159
+
160
+ class << self
161
+ private
162
+
163
+ def rebuild_from_ancestors(creative, user_id)
164
+ # 조상들 중 해당 user에 대한 share 찾기 (가장 가까운 조상 우선)
165
+ # no_access도 포함 - 캐시에 저장하여 public share를 override
166
+ ancestor_ids = creative.ancestor_ids
167
+ return if ancestor_ids.empty?
168
+
169
+ ancestor_share = CreativeShare
170
+ .where(creative_id: ancestor_ids, user_id: user_id)
171
+ .joins("INNER JOIN creative_hierarchies ch ON creative_shares.creative_id = ch.ancestor_id")
172
+ .where("ch.descendant_id = ?", creative.id)
173
+ .order("ch.generations ASC")
174
+ .first
175
+
176
+ propagate_share(ancestor_share) if ancestor_share
177
+ end
178
+
179
+ def rebuild_from_ancestors_for_subtree(creative)
180
+ ancestor_ids = creative.ancestor_ids
181
+ return if ancestor_ids.empty?
182
+
183
+ # 조상들의 모든 share 가져오기
184
+ # no_access도 포함 - 캐시에 저장하여 public share를 override
185
+ ancestor_shares = CreativeShare.where(creative_id: ancestor_ids)
186
+
187
+ ancestor_shares.each do |share|
188
+ propagate_share(share)
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,42 @@
1
+ module Collavre
2
+ module Creatives
3
+ class PermissionChecker
4
+ def initialize(creative, user)
5
+ @creative = creative
6
+ @user = user
7
+ end
8
+
9
+ def allowed?(required_permission = :read)
10
+ base = creative.origin_id.nil? ? creative : creative.origin
11
+
12
+ # Owner always has admin permission (fallback for fixtures and missing cache entries)
13
+ return true if base.user_id == user&.id
14
+
15
+ # O(1) 캐시 테이블 조회
16
+ # 사용자별 엔트리를 먼저 확인 (no_access가 public share보다 우선)
17
+ if user
18
+ user_entry = CreativeSharesCache.find_by(creative_id: base.id, user_id: user.id)
19
+ if user_entry
20
+ # no_access는 명시적 거부 - public share가 있어도 차단
21
+ return false if user_entry.no_access?
22
+ return permission_rank(user_entry.permission) >= permission_rank(required_permission)
23
+ end
24
+ end
25
+
26
+ # 사용자별 엔트리 없으면 public share 확인
27
+ public_entry = CreativeSharesCache.find_by(creative_id: base.id, user_id: nil)
28
+ return false unless public_entry
29
+
30
+ permission_rank(public_entry.permission) >= permission_rank(required_permission)
31
+ end
32
+
33
+ private
34
+
35
+ attr_reader :creative, :user
36
+
37
+ def permission_rank(value)
38
+ CreativeShare.permissions[value.to_s]
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,53 @@
1
+ module Collavre
2
+ module Creatives
3
+ class PlanTagger
4
+ Result = Struct.new(:success?, :message, keyword_init: true)
5
+
6
+ def initialize(plan_id:, creative_ids: [])
7
+ @plan = Plan.find_by(id: plan_id)
8
+ @creative_ids = Array(creative_ids).map(&:presence).compact
9
+ end
10
+
11
+ def apply
12
+ return failure("Please select a plan and at least one creative.") unless valid?
13
+
14
+ creatives.find_each do |creative|
15
+ creative.tags.find_or_create_by(label: plan, creative_id: creative.id)
16
+ end
17
+
18
+ success("Plan tags applied to selected creatives.")
19
+ end
20
+
21
+ def remove
22
+ return failure("Please select a plan and at least one creative.") unless valid?
23
+
24
+ creatives.find_each do |creative|
25
+ tag = creative.tags.find_by(label: plan, creative_id: creative.id)
26
+ tag&.destroy
27
+ end
28
+
29
+ success("Plan tag removed from selected creatives.")
30
+ end
31
+
32
+ private
33
+
34
+ attr_reader :plan, :creative_ids
35
+
36
+ def creatives
37
+ Creative.where(id: creative_ids)
38
+ end
39
+
40
+ def valid?
41
+ plan.present? && creative_ids.any?
42
+ end
43
+
44
+ def success(message)
45
+ Result.new(success?: true, message: message)
46
+ end
47
+
48
+ def failure(message)
49
+ Result.new(success?: false, message: message)
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,89 @@
1
+ require "set"
2
+
3
+ module Collavre
4
+ module Creatives
5
+ class ProgressService
6
+ def initialize(creative)
7
+ @creative = creative
8
+ end
9
+
10
+ def update_progress_from_children!
11
+ if creative.children.any?
12
+ # Use Ruby calculation to get effective progress (handling delegation for linked creatives)
13
+ # instead of SQL average which reads potentially stale DB columns.
14
+ new_progress = creative.children.map(&:progress).sum.to_f / creative.children.size
15
+ creative.update(progress: new_progress)
16
+ else
17
+ creative.update(progress: 0)
18
+ end
19
+ end
20
+
21
+ def update_parent_progress!(visited_ids = Set.new)
22
+ return if visited_ids.include?(creative.id)
23
+ visited_ids.add(creative.id)
24
+
25
+ creative.linked_creatives.find_each do |linked|
26
+ # Linked creatives delegate progress to origin.
27
+ # We don't update them directly (forbidden by validation).
28
+ # We must ensure their PARENTS are updated.
29
+ # Recurse with visited_ids to prevent cycles.
30
+ ProgressService.new(linked).update_parent_progress!(visited_ids)
31
+ end
32
+ parent = creative.parent
33
+ return unless parent
34
+
35
+ begin
36
+ parent.reload
37
+ rescue ActiveRecord::RecordNotFound
38
+ return
39
+ end
40
+ new_progress = if parent.children.any?
41
+ parent.children.map(&:progress).sum.to_f / parent.children.size
42
+ else
43
+ 0
44
+ end
45
+
46
+ # Avoid infinite recursion
47
+ if (parent.progress - new_progress).abs > 0.0001
48
+ parent.update(progress: new_progress)
49
+ end
50
+ end
51
+
52
+ def progress_for_tags(tag_ids, user)
53
+ return creative.progress if tag_ids.blank?
54
+
55
+ tag_ids = Array(tag_ids).map(&:to_s)
56
+ visible_children = creative.children_with_permission(user)
57
+ child_values = visible_children.map do |child|
58
+ self.class.new(child).progress_for_tags(tag_ids, user)
59
+ end.compact
60
+
61
+ if child_values.any?
62
+ child_values.sum.to_f / child_values.size
63
+ else
64
+ own_label_ids = creative.tags.pluck(:label_id).map(&:to_s)
65
+ if (own_label_ids & tag_ids).any?
66
+ visible_children.any? ? 1.0 : creative.progress
67
+ end
68
+ end
69
+ end
70
+
71
+ # `tagged_ids` should be a Set of creative IDs tagged with the plan.
72
+ def progress_for_plan(tagged_ids)
73
+ child_values = creative.children.map do |child|
74
+ self.class.new(child).progress_for_plan(tagged_ids)
75
+ end.compact
76
+
77
+ if child_values.any?
78
+ child_values.sum.to_f / child_values.size
79
+ elsif tagged_ids.include?(creative.id)
80
+ creative.children.any? ? 1.0 : creative.progress
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ attr_reader :creative
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,187 @@
1
+ module Collavre
2
+ module Creatives
3
+ class Reorderer
4
+ class Error < StandardError; end
5
+
6
+ LinkDropResult = Struct.new(:new_creative, :parent, :direction, keyword_init: true)
7
+
8
+ def initialize(user:)
9
+ @user = user
10
+ end
11
+
12
+ def reorder(dragged_id:, target_id:, direction:)
13
+ dragged, target = fetch_creatives(dragged_id, target_id)
14
+ validate_direction!(direction)
15
+ raise Error, "Invalid creatives" unless dragged && target
16
+
17
+ if direction == "child"
18
+ reorder_as_child(dragged, target)
19
+ else
20
+ reorder_as_sibling(dragged, target, direction)
21
+ end
22
+
23
+ true
24
+ end
25
+
26
+ def reorder_multiple(dragged_ids:, target_id:, direction:)
27
+ ids = Array(dragged_ids).map(&:presence).compact
28
+ validate_direction!(direction)
29
+ raise Error, "Invalid creatives" if ids.empty?
30
+
31
+ target = Creative.find_by(id: target_id)
32
+ raise Error, "Invalid creatives" unless target
33
+
34
+ dragged_lookup = Creative.where(id: ids).index_by { |creative| creative.id.to_s }
35
+ ordered_dragged = ids.map { |id| dragged_lookup[id.to_s] }.compact
36
+ raise Error, "Invalid creatives" unless ordered_dragged.size == ids.size
37
+
38
+ if ordered_dragged.any? { |creative| creative.id == target.id }
39
+ raise Error, "Invalid creatives"
40
+ end
41
+
42
+ target_ancestor_ids = target.ancestors.pluck(:id)
43
+ if ordered_dragged.any? { |creative| target_ancestor_ids.include?(creative.id) }
44
+ raise Error, "Invalid creatives"
45
+ end
46
+
47
+ if direction == "child"
48
+ reorder_multiple_as_child(ordered_dragged, target)
49
+ else
50
+ reorder_multiple_as_sibling(ordered_dragged, target, direction)
51
+ end
52
+
53
+ true
54
+ rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e
55
+ raise Error, e.message
56
+ end
57
+
58
+ def link_drop(dragged_id:, target_id:, direction:)
59
+ dragged, target = fetch_creatives(dragged_id, target_id)
60
+ validate_direction!(direction)
61
+ raise Error, "Invalid creatives" unless dragged && target
62
+
63
+ origin = dragged.effective_origin
64
+ new_parent = direction == "child" ? target : target.parent
65
+
66
+ if new_parent.present?
67
+ origin_descendant_ids = origin.self_and_descendants.pluck(:id)
68
+
69
+ new_parent.self_and_ancestors.each do |ancestor|
70
+ ancestor_origin_id = ancestor.origin_id.presence || ancestor.id
71
+
72
+ if origin_descendant_ids.include?(ancestor_origin_id)
73
+ raise Error, "Invalid creatives"
74
+ end
75
+ end
76
+ end
77
+
78
+ new_creative = nil
79
+ Creative.transaction do
80
+ new_creative = Creative.create!(
81
+ origin_id: origin.id,
82
+ parent: new_parent,
83
+ user: new_parent&.user || user
84
+ )
85
+
86
+ siblings = sibling_scope(new_parent)
87
+ siblings.reject! { |s| s.id == new_creative.id }
88
+
89
+ if direction == "child"
90
+ siblings << new_creative
91
+ else
92
+ target_index = siblings.index { |s| s.id == target.id } || 0
93
+ insert_index = direction == "up" ? target_index : target_index + 1
94
+ insert_index = [ [ insert_index, 0 ].max, siblings.size ].min
95
+ siblings.insert(insert_index, new_creative)
96
+ end
97
+
98
+ resequence!(siblings)
99
+ end
100
+
101
+ LinkDropResult.new(new_creative: new_creative, parent: new_parent, direction: direction)
102
+ rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e
103
+ raise Error, e.message
104
+ end
105
+
106
+ private
107
+
108
+ attr_reader :user
109
+
110
+ def fetch_creatives(dragged_id, target_id)
111
+ [ Creative.find_by(id: dragged_id), Creative.find_by(id: target_id) ]
112
+ end
113
+
114
+ def validate_direction!(direction)
115
+ raise Error, "Invalid direction" unless %w[up down child].include?(direction)
116
+ end
117
+
118
+ def reorder_as_child(dragged, target)
119
+ dragged.update!(parent: target)
120
+ siblings = target.children.order(:sequence).to_a
121
+ siblings.reject! { |s| s.id == dragged.id }
122
+ siblings << dragged
123
+ resequence!(siblings)
124
+ end
125
+
126
+ def reorder_as_sibling(dragged, target, direction)
127
+ if dragged.parent_id != target.parent_id
128
+ dragged.update!(parent: target.parent)
129
+ end
130
+
131
+ siblings = sibling_scope(dragged.parent)
132
+ siblings.reject! { |s| s.id == dragged.id }
133
+ target_index = siblings.index { |s| s.id == target.id }
134
+ new_index = direction == "up" ? target_index : target_index.to_i + 1
135
+ siblings.insert(new_index, dragged)
136
+ resequence!(siblings)
137
+ end
138
+
139
+ def reorder_multiple_as_child(dragged_creatives, target)
140
+ Creative.transaction do
141
+ siblings = target.children.order(:sequence).to_a
142
+ dragged_ids = dragged_creatives.map(&:id)
143
+ siblings.reject! { |s| dragged_ids.include?(s.id) }
144
+
145
+ dragged_creatives.each do |dragged|
146
+ dragged.update!(parent: target)
147
+ end
148
+
149
+ siblings.concat(dragged_creatives)
150
+ resequence!(siblings)
151
+ end
152
+ end
153
+
154
+ def reorder_multiple_as_sibling(dragged_creatives, target, direction)
155
+ Creative.transaction do
156
+ parent = target.parent
157
+ siblings = sibling_scope(parent)
158
+ dragged_ids = dragged_creatives.map(&:id)
159
+ siblings.reject! { |s| dragged_ids.include?(s.id) }
160
+
161
+ dragged_creatives.each do |dragged|
162
+ dragged.update!(parent: parent)
163
+ end
164
+
165
+ target_index = siblings.index { |s| s.id == target.id }
166
+ raise Error, "Invalid creatives" if target_index.nil?
167
+
168
+ insert_index = direction == "up" ? target_index : target_index + 1
169
+ insert_index = [ [ insert_index, 0 ].max, siblings.size ].min
170
+
171
+ siblings.insert(insert_index, *dragged_creatives)
172
+ resequence!(siblings)
173
+ end
174
+ end
175
+
176
+ def sibling_scope(parent)
177
+ parent ? parent.children.order(:sequence).to_a : Creative.roots.order(:sequence).to_a
178
+ end
179
+
180
+ def resequence!(creatives)
181
+ creatives.each_with_index do |creative, idx|
182
+ creative.update_column(:sequence, idx)
183
+ end
184
+ end
185
+ end
186
+ end
187
+ end