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,196 @@
1
+ module Collavre
2
+ module Creatives
3
+ class FilterPipeline
4
+ Result = Struct.new(
5
+ :matched_ids,
6
+ :allowed_ids,
7
+ :progress_map,
8
+ :overall_progress,
9
+ keyword_init: true
10
+ )
11
+
12
+ # 필터 클래스 목록 - 순서대로 적용
13
+ FILTERS = [
14
+ Filters::ProgressFilter,
15
+ Filters::TagFilter,
16
+ Filters::SearchFilter,
17
+ Filters::CommentFilter,
18
+ Filters::DateFilter,
19
+ Filters::AssigneeFilter
20
+ ].freeze
21
+
22
+ def initialize(user:, params:, scope:)
23
+ @user = user
24
+ @params = params
25
+ @scope = scope
26
+ end
27
+
28
+ def call
29
+ matched_ids = apply_filters
30
+ return empty_result if matched_ids.empty?
31
+
32
+ # 조상 포함
33
+ allowed_ids = resolve_ancestors(matched_ids)
34
+
35
+ # O(1) 권한 필터링
36
+ allowed_ids = filter_by_permission(allowed_ids)
37
+
38
+ progress_map, overall = calculate_progress(allowed_ids, matched_ids)
39
+
40
+ Result.new(
41
+ matched_ids: matched_ids.to_set,
42
+ allowed_ids: allowed_ids.map(&:to_s).to_set,
43
+ progress_map: progress_map,
44
+ overall_progress: overall
45
+ )
46
+ end
47
+
48
+ def any_filter_active?
49
+ FILTERS.any? { |klass| klass.new(params: params, scope: scope).active? }
50
+ end
51
+
52
+ private
53
+
54
+ attr_reader :user, :params, :scope
55
+
56
+ def apply_filters
57
+ active_filters = FILTERS
58
+ .map { |klass| klass.new(params: params, scope: scope) }
59
+ .select(&:active?)
60
+
61
+ # 필터가 없으면 전체 반환
62
+ return scope.pluck(:id) if active_filters.empty?
63
+
64
+ # 모든 필터의 교집합
65
+ matched_sets = active_filters.map { |f| f.match.to_set }
66
+ matched_sets.reduce(:&).to_a
67
+ end
68
+
69
+ def resolve_ancestors(matched_ids)
70
+ ancestors = CreativeHierarchy
71
+ .where(descendant_id: matched_ids)
72
+ .pluck(:ancestor_id)
73
+
74
+ all_related = (matched_ids + ancestors).uniq
75
+
76
+ # Include Linked Creatives that reference any of these as origin
77
+ # (so they appear as virtual parents in the tree)
78
+ linked_to_related = Creative
79
+ .where(origin_id: all_related)
80
+ .pluck(:id)
81
+
82
+ return all_related if linked_to_related.empty?
83
+
84
+ # Also include ancestors of those Linked Creatives
85
+ linked_ancestors = CreativeHierarchy
86
+ .where(descendant_id: linked_to_related)
87
+ .pluck(:ancestor_id)
88
+
89
+ (all_related + linked_to_related + linked_ancestors).uniq
90
+ end
91
+
92
+ def filter_by_permission(ids)
93
+ # O(1) 캐시 테이블 조회
94
+ # no_access가 public share보다 우선하므로 별도 처리 필요
95
+ accessible_ids = Set.new
96
+
97
+ if user
98
+ # 사용자별 캐시 엔트리 확인
99
+ user_entries = CreativeSharesCache
100
+ .where(creative_id: ids, user_id: user.id)
101
+ .pluck(:creative_id, :permission)
102
+
103
+ user_accessible = []
104
+ user_denied = Set.new
105
+ user_entries.each do |cid, perm|
106
+ # perm is a string from enum (e.g., "no_access", "read")
107
+ if perm == "no_access"
108
+ user_denied << cid
109
+ else
110
+ user_accessible << cid
111
+ end
112
+ end
113
+ accessible_ids.merge(user_accessible)
114
+
115
+ # public share 확인 (no_access로 거부된 것 제외)
116
+ public_ids = CreativeSharesCache
117
+ .where(creative_id: ids, user_id: nil)
118
+ .where.not(permission: :no_access)
119
+ .pluck(:creative_id)
120
+ accessible_ids.merge(public_ids - user_denied.to_a)
121
+
122
+ # Fallback: owned creatives (for fixtures)
123
+ owned_ids = Creative.where(id: ids, user_id: user.id).pluck(:id)
124
+ accessible_ids.merge(owned_ids)
125
+ else
126
+ # 비로그인: public share만
127
+ accessible_ids = CreativeSharesCache
128
+ .where(creative_id: ids, user_id: nil)
129
+ .where.not(permission: :no_access)
130
+ .pluck(:creative_id)
131
+ .to_set
132
+ end
133
+
134
+ accessible_ids.to_a
135
+ end
136
+
137
+ def calculate_progress(allowed_ids, matched_ids)
138
+ return [ {}, 0.0 ] if allowed_ids.empty?
139
+
140
+ # Find "leaf-most" nodes: nodes that are NOT ancestors of other nodes in matched_ids
141
+ # These are the relevant nodes for overall progress calculation
142
+ superfluous_ancestors = CreativeHierarchy
143
+ .where(ancestor_id: matched_ids.to_a, descendant_id: matched_ids.to_a)
144
+ .where("generations > 0")
145
+ .pluck(:ancestor_id)
146
+ .uniq
147
+
148
+ relevant_ids = matched_ids.to_a - superfluous_ancestors
149
+
150
+ # Get progress values for all allowed creatives
151
+ creatives = Creative.where(id: allowed_ids).includes(:origin)
152
+ progress_values = creatives.to_h do |c|
153
+ # Shell Creative uses origin's progress
154
+ progress = c.origin_id.present? ? c.origin&.progress : c.progress
155
+ [ c.id, progress || 0.0 ]
156
+ end
157
+
158
+ # Calculate overall progress from relevant (leaf-most) nodes only
159
+ relevant_progress = relevant_ids.map { |id| progress_values[id] || 0.0 }
160
+ overall = relevant_progress.any? ? relevant_progress.sum / relevant_progress.size : 0.0
161
+
162
+ # Build progress_map: for each allowed_id, calculate average of its relevant descendants
163
+ relationships = CreativeHierarchy
164
+ .where(ancestor_id: allowed_ids, descendant_id: relevant_ids)
165
+ .pluck(:ancestor_id, :descendant_id)
166
+
167
+ aggregation = Hash.new { |h, k| h[k] = [] }
168
+ relationships.each do |anc_id, desc_id|
169
+ val = progress_values[desc_id]
170
+ aggregation[anc_id] << val if val
171
+ end
172
+
173
+ progress_map = {}
174
+ aggregation.each do |anc_id, values|
175
+ progress_map[anc_id.to_s] = values.sum / values.size
176
+ end
177
+
178
+ # Also include nodes that are in relevant_ids themselves
179
+ relevant_ids.each do |id|
180
+ progress_map[id.to_s] ||= progress_values[id] || 0.0
181
+ end
182
+
183
+ [ progress_map, overall ]
184
+ end
185
+
186
+ def empty_result
187
+ Result.new(
188
+ matched_ids: Set.new,
189
+ allowed_ids: Set.new,
190
+ progress_map: {},
191
+ overall_progress: 0.0
192
+ )
193
+ end
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,30 @@
1
+ module Collavre
2
+ module Creatives
3
+ module Filters
4
+ class AssigneeFilter < BaseFilter
5
+ def active?
6
+ params[:assignee_id].present? || params[:unassigned].present?
7
+ end
8
+
9
+ def match
10
+ if params[:unassigned] == "true"
11
+ # owner가 없는 Label을 가진 creative 또는 태그가 없는 creative
12
+ with_null_owner = scope.left_joins(tags: :label)
13
+ .where(labels: { owner_id: nil })
14
+ .pluck(:id)
15
+ without_tags = scope.left_joins(:tags)
16
+ .where(tags: { id: nil })
17
+ .pluck(:id)
18
+ (with_null_owner + without_tags).uniq
19
+ else
20
+ assignee_ids = Array(params[:assignee_id]).map(&:to_i)
21
+ scope.joins(tags: :label)
22
+ .where(labels: { owner_id: assignee_ids })
23
+ .distinct
24
+ .pluck(:id)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,24 @@
1
+ module Collavre
2
+ module Creatives
3
+ module Filters
4
+ class BaseFilter
5
+ def initialize(params:, scope:)
6
+ @params = params
7
+ @scope = scope
8
+ end
9
+
10
+ def active?
11
+ raise NotImplementedError
12
+ end
13
+
14
+ def match
15
+ raise NotImplementedError
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :params, :scope
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,21 @@
1
+ module Collavre
2
+ module Creatives
3
+ module Filters
4
+ class CommentFilter < BaseFilter
5
+ def active?
6
+ params[:has_comments].present? || params[:comment].present?
7
+ end
8
+
9
+ def match
10
+ has_comments = params[:has_comments] == "true" || params[:comment] == "true"
11
+
12
+ if has_comments
13
+ scope.joins(:comments).distinct.pluck(:id)
14
+ else
15
+ scope.left_joins(:comments).where(comments: { id: nil }).pluck(:id)
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,58 @@
1
+ module Collavre
2
+ module Creatives
3
+ module Filters
4
+ class DateFilter < BaseFilter
5
+ def active?
6
+ params[:due_before].present? ||
7
+ params[:due_after].present? ||
8
+ params[:has_due_date].present?
9
+ end
10
+
11
+ def match
12
+ # Label의 target_date 기반 필터링
13
+ if params[:has_due_date] == "false"
14
+ # target_date가 없는 creative
15
+ # 1. creatives with labels that have no target_date
16
+ # 2. creatives with no tags at all
17
+ with_null_date = scope.left_joins(tags: :label)
18
+ .where(labels: { target_date: nil })
19
+ .pluck(:id)
20
+ without_tags = scope.left_joins(:tags)
21
+ .where(tags: { id: nil })
22
+ .pluck(:id)
23
+ return (with_null_date + without_tags).uniq
24
+ end
25
+
26
+ result = scope.joins(:tags)
27
+ .joins("INNER JOIN labels ON tags.label_id = labels.id")
28
+
29
+ if params[:has_due_date] == "true"
30
+ result = result.where.not(labels: { target_date: nil })
31
+ end
32
+
33
+ if params[:due_before].present?
34
+ if (due_before = safe_parse_date(params[:due_before]))
35
+ result = result.where("labels.target_date <= ?", due_before)
36
+ end
37
+ end
38
+
39
+ if params[:due_after].present?
40
+ if (due_after = safe_parse_date(params[:due_after]))
41
+ result = result.where("labels.target_date >= ?", due_after)
42
+ end
43
+ end
44
+
45
+ result.distinct.pluck(:id)
46
+ end
47
+
48
+ private
49
+
50
+ def safe_parse_date(value)
51
+ Date.iso8601(value)
52
+ rescue ArgumentError, TypeError
53
+ nil
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,25 @@
1
+ module Collavre
2
+ module Creatives
3
+ module Filters
4
+ class ProgressFilter < BaseFilter
5
+ def active?
6
+ params[:min_progress].present? || params[:max_progress].present?
7
+ end
8
+
9
+ def match
10
+ min_val = params[:min_progress].presence&.to_f
11
+ max_val = params[:max_progress].presence&.to_f
12
+
13
+ # Linked Creatives use origin's progress, regular creatives use their own
14
+ query = scope
15
+ .joins("LEFT JOIN creatives origins ON creatives.origin_id = origins.id")
16
+
17
+ effective_progress = "COALESCE(origins.progress, creatives.progress)"
18
+ query = query.where("#{effective_progress} >= ?", min_val) if min_val
19
+ query = query.where("#{effective_progress} <= ?", max_val) if max_val
20
+ query.pluck("creatives.id")
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,28 @@
1
+ module Collavre
2
+ module Creatives
3
+ module Filters
4
+ class SearchFilter < BaseFilter
5
+ def active?
6
+ params[:search].present?
7
+ end
8
+
9
+ def match
10
+ query = "%#{sanitize_like(params[:search])}%"
11
+
12
+ # Search in description OR comments.content
13
+ scope
14
+ .left_joins(:comments)
15
+ .where("creatives.description LIKE :q OR comments.content LIKE :q", q: query)
16
+ .distinct
17
+ .pluck(:id)
18
+ end
19
+
20
+ private
21
+
22
+ def sanitize_like(str)
23
+ str.to_s.gsub(/[%_]/) { |m| "\\#{m}" }
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,16 @@
1
+ module Collavre
2
+ module Creatives
3
+ module Filters
4
+ class TagFilter < BaseFilter
5
+ def active?
6
+ params[:tags].present?
7
+ end
8
+
9
+ def match
10
+ tag_ids = Array(params[:tags]).map(&:to_s)
11
+ scope.joins(:tags).where(tags: { label_id: tag_ids }).pluck(:id)
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,47 @@
1
+ module Collavre
2
+ module Creatives
3
+ class Importer
4
+ class Error < StandardError; end
5
+ class UnsupportedFile < Error; end
6
+
7
+ MARKDOWN_MIME_TYPES = %w[text/markdown text/x-markdown application/octet-stream].freeze
8
+ PPT_MIME_TYPES = %w[
9
+ application/vnd.ms-powerpoint
10
+ application/vnd.openxmlformats-officedocument.presentationml.presentation
11
+ ].freeze
12
+
13
+ def initialize(file:, user:, parent: nil)
14
+ @file = file
15
+ @user = user
16
+ @parent = parent
17
+ end
18
+
19
+ def call
20
+ raise Error, "File required" if file.blank?
21
+
22
+ case mime_type
23
+ when *MARKDOWN_MIME_TYPES
24
+ content = read_file_content
25
+ MarkdownImporter.import(content, parent: parent, user: user, create_root: true)
26
+ when *PPT_MIME_TYPES
27
+ PptImporter.import(file.tempfile, parent: parent, user: user, create_root: true, filename: file.original_filename)
28
+ else
29
+ raise UnsupportedFile, "Invalid file type"
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ attr_reader :file, :user, :parent
36
+
37
+ def mime_type
38
+ file.content_type.presence || Rack::Mime.mime_type(File.extname(file.original_filename.to_s))
39
+ end
40
+
41
+ def read_file_content
42
+ file.rewind
43
+ file.read.to_s.force_encoding("UTF-8")
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,191 @@
1
+ module Collavre
2
+ module Creatives
3
+ class IndexQuery
4
+ Result = Struct.new(
5
+ :creatives,
6
+ :parent_creative,
7
+ :shared_creative,
8
+ :shared_list,
9
+ :overall_progress,
10
+ :allowed_creative_ids,
11
+ :progress_map,
12
+ keyword_init: true
13
+ )
14
+
15
+ def initialize(user:, params: {})
16
+ @user = user
17
+ @params = params.with_indifferent_access
18
+ end
19
+
20
+ def call
21
+ result = resolve_creatives
22
+ shared_creative = result[:parent] || result[:creatives]&.first
23
+ shared_list = shared_creative ? shared_creative.all_shared_users : []
24
+
25
+ Result.new(
26
+ creatives: result[:creatives],
27
+ parent_creative: result[:parent],
28
+ shared_creative: shared_creative,
29
+ shared_list: shared_list,
30
+ overall_progress: result[:overall_progress] || 0,
31
+ allowed_creative_ids: result[:allowed_ids],
32
+ progress_map: result[:progress_map]
33
+ )
34
+ end
35
+
36
+ private
37
+
38
+ attr_reader :user, :params
39
+
40
+ def resolve_creatives
41
+ if any_filter_active?
42
+ handle_filtered_query
43
+ elsif params[:id]
44
+ handle_id_query
45
+ else
46
+ handle_root_query
47
+ end
48
+ end
49
+
50
+ def any_filter_active?
51
+ params[:tags].present? ||
52
+ params[:min_progress].present? ||
53
+ params[:max_progress].present? ||
54
+ params[:search].present? ||
55
+ params[:comment] == "true" ||
56
+ params[:has_comments].present? ||
57
+ params[:due_before].present? ||
58
+ params[:due_after].present? ||
59
+ params[:has_due_date].present? ||
60
+ params[:assignee_id].present? ||
61
+ params[:unassigned].present?
62
+ end
63
+
64
+ def handle_filtered_query
65
+ scope = determine_scope
66
+
67
+ result = FilterPipeline.new(
68
+ user: user,
69
+ params: params,
70
+ scope: scope
71
+ ).call
72
+
73
+ return empty_result if result.matched_ids.empty?
74
+
75
+ # For search/comment filters, return matched items directly (flat results sorted by relevance)
76
+ # For other filters (tags, progress), return tree start nodes
77
+ if params[:search].present? || params[:comment] == "true"
78
+ matched_creatives = Creative.where(id: result.matched_ids.to_a)
79
+ .order(:sequence)
80
+ .select { |c| readable?(c) }
81
+
82
+ # Sort by comment updated_at for comment filter
83
+ if params[:comment] == "true"
84
+ matched_creatives = matched_creatives.sort_by { |c| c.comments.maximum(:updated_at) || c.updated_at }.reverse
85
+ end
86
+
87
+ parent = params[:id] ? Creative.find_by(id: params[:id]) : nil
88
+ {
89
+ creatives: matched_creatives,
90
+ parent: parent,
91
+ allowed_ids: result.allowed_ids,
92
+ overall_progress: result.overall_progress,
93
+ progress_map: result.progress_map
94
+ }
95
+ else
96
+ start_nodes = determine_start_nodes(result.allowed_ids)
97
+ parent = params[:id] ? Creative.find_by(id: params[:id]) : nil
98
+ {
99
+ creatives: start_nodes,
100
+ parent: parent,
101
+ allowed_ids: result.allowed_ids,
102
+ overall_progress: result.overall_progress,
103
+ progress_map: result.progress_map
104
+ }
105
+ end
106
+ end
107
+
108
+ def handle_id_query
109
+ creative = Creative.find_by(id: params[:id])
110
+ return empty_result unless creative && readable?(creative)
111
+
112
+ {
113
+ creatives: creative.children_with_permission(user, :read),
114
+ parent: creative,
115
+ allowed_ids: nil,
116
+ overall_progress: nil,
117
+ progress_map: nil
118
+ }
119
+ end
120
+
121
+ def handle_root_query
122
+ {
123
+ creatives: Creative.where(user: user).roots,
124
+ parent: nil,
125
+ allowed_ids: nil,
126
+ overall_progress: nil,
127
+ progress_map: nil
128
+ }
129
+ end
130
+
131
+ def determine_scope
132
+ if params[:id]
133
+ base = Creative.find_by(id: params[:id])&.effective_origin
134
+ return Creative.none unless base
135
+
136
+ # Use subqueries instead of loading IDs into memory
137
+ # 1. Actual descendants (via creative_hierarchies)
138
+ descendants_subquery = CreativeHierarchy
139
+ .where(ancestor_id: base.id)
140
+ .select(:descendant_id)
141
+
142
+ # 2. Linked descendants: origins of shell creatives -> their descendants
143
+ # First, find shell creatives in the subtree
144
+ shells_in_subtree = Creative
145
+ .where("creatives.id IN (?)", descendants_subquery)
146
+ .where.not(origin_id: nil)
147
+ .select(:origin_id)
148
+
149
+ # Then, get descendants of those origins
150
+ linked_descendants_subquery = CreativeHierarchy
151
+ .where("ancestor_id IN (?)", shells_in_subtree)
152
+ .select(:descendant_id)
153
+
154
+ # Combine both subqueries (use creatives.id to avoid ambiguity with joins)
155
+ Creative.where(
156
+ "creatives.id IN (?) OR creatives.id IN (?)",
157
+ descendants_subquery,
158
+ linked_descendants_subquery
159
+ )
160
+ else
161
+ Creative.where(origin_id: nil) # Only real creatives (not shells)
162
+ end
163
+ end
164
+
165
+ def determine_start_nodes(allowed_ids)
166
+ allowed_ids_array = allowed_ids.map { |id| id.to_s.to_i }
167
+ allowed_ids_set = allowed_ids.to_set
168
+
169
+ if params[:id]
170
+ parent = Creative.find_by(id: params[:id])
171
+ return [] unless parent && readable?(parent)
172
+
173
+ parent.children.where(id: allowed_ids_array).order(:sequence).to_a
174
+ else
175
+ # Root view: only show creatives with parent_id = nil
176
+ # This prevents Linked Creatives (which have parent_id) from appearing at root
177
+ # even if their ancestors are not in allowed_ids
178
+ Creative.where(id: allowed_ids_array, parent_id: nil).order(:sequence).to_a
179
+ end
180
+ end
181
+
182
+ def readable?(creative)
183
+ creative.user == user || creative.has_permission?(user, :read)
184
+ end
185
+
186
+ def empty_result
187
+ { creatives: [], parent: nil, allowed_ids: Set.new, overall_progress: 0, progress_map: {} }
188
+ end
189
+ end
190
+ end
191
+ end