aven 0.0.3

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 (159) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +35 -0
  4. data/Rakefile +19 -0
  5. data/app/assets/stylesheets/aven/application.css +14 -0
  6. data/app/assets/stylesheets/aven/application.tailwind.css +7 -0
  7. data/app/assets/stylesheets/aven/tailwind.css +224 -0
  8. data/app/channels/aven/chat/thread_channel.rb +39 -0
  9. data/app/components/aven/application_view_component.rb +15 -0
  10. data/app/components/aven/views/admin/dashboard/index/component.html.erb +1 -0
  11. data/app/components/aven/views/admin/dashboard/index/component.rb +5 -0
  12. data/app/components/aven/views/articles/edit/component.html.erb +14 -0
  13. data/app/components/aven/views/articles/edit/component.rb +14 -0
  14. data/app/components/aven/views/articles/form/component.html.erb +45 -0
  15. data/app/components/aven/views/articles/form/component.rb +27 -0
  16. data/app/components/aven/views/articles/index/component.html.erb +93 -0
  17. data/app/components/aven/views/articles/index/component.rb +29 -0
  18. data/app/components/aven/views/articles/new/component.html.erb +13 -0
  19. data/app/components/aven/views/articles/new/component.rb +14 -0
  20. data/app/components/aven/views/articles/show/component.html.erb +110 -0
  21. data/app/components/aven/views/articles/show/component.rb +34 -0
  22. data/app/components/aven/views/oauth/error/component.html.erb +44 -0
  23. data/app/components/aven/views/oauth/error/component.rb +30 -0
  24. data/app/components/aven/views/static/index/component.html.erb +17 -0
  25. data/app/components/aven/views/static/index/component.rb +16 -0
  26. data/app/components/aven/views/static/index/controller.js +7 -0
  27. data/app/controllers/aven/admin/base.rb +16 -0
  28. data/app/controllers/aven/admin/dashboard_controller.rb +9 -0
  29. data/app/controllers/aven/agentic/agents_controller.rb +56 -0
  30. data/app/controllers/aven/agentic/documents_controller.rb +51 -0
  31. data/app/controllers/aven/agentic/mcp_controller.rb +124 -0
  32. data/app/controllers/aven/agentic/tools_controller.rb +37 -0
  33. data/app/controllers/aven/ai/text_controller.rb +41 -0
  34. data/app/controllers/aven/application_controller.rb +27 -0
  35. data/app/controllers/aven/articles_controller.rb +114 -0
  36. data/app/controllers/aven/auth_controller.rb +12 -0
  37. data/app/controllers/aven/chat/threads_controller.rb +67 -0
  38. data/app/controllers/aven/oauth/auth0_controller.rb +84 -0
  39. data/app/controllers/aven/oauth/base_controller.rb +183 -0
  40. data/app/controllers/aven/oauth/documentation/auth0.md +387 -0
  41. data/app/controllers/aven/oauth/documentation/entra_id.md +608 -0
  42. data/app/controllers/aven/oauth/documentation/github.md +329 -0
  43. data/app/controllers/aven/oauth/documentation/google.md +253 -0
  44. data/app/controllers/aven/oauth/entra_id_controller.rb +92 -0
  45. data/app/controllers/aven/oauth/github_controller.rb +91 -0
  46. data/app/controllers/aven/oauth/google_controller.rb +64 -0
  47. data/app/controllers/aven/static_controller.rb +7 -0
  48. data/app/controllers/aven/tags_controller.rb +44 -0
  49. data/app/controllers/aven/workspaces_controller.rb +20 -0
  50. data/app/controllers/concerns/aven/authentication.rb +49 -0
  51. data/app/controllers/concerns/aven/controller_helpers.rb +38 -0
  52. data/app/helpers/aven/application_helper.rb +16 -0
  53. data/app/javascript/aven/application.js +3 -0
  54. data/app/javascript/aven/controllers/application.js +5 -0
  55. data/app/javascript/aven/controllers/index.js +11 -0
  56. data/app/jobs/aven/agentic/document_embedding_job.rb +28 -0
  57. data/app/jobs/aven/agentic/document_ocr_job.rb +28 -0
  58. data/app/jobs/aven/application_job.rb +4 -0
  59. data/app/jobs/aven/chat/calculate_cost_job.rb +26 -0
  60. data/app/jobs/aven/chat/run_job.rb +27 -0
  61. data/app/mailers/aven/application_mailer.rb +6 -0
  62. data/app/models/aven/agentic/agent.rb +76 -0
  63. data/app/models/aven/agentic/agent_document.rb +37 -0
  64. data/app/models/aven/agentic/agent_tool.rb +37 -0
  65. data/app/models/aven/agentic/document.rb +162 -0
  66. data/app/models/aven/agentic/document_embedding.rb +39 -0
  67. data/app/models/aven/agentic/tool.rb +106 -0
  68. data/app/models/aven/agentic/tool_parameter.rb +56 -0
  69. data/app/models/aven/application_record.rb +5 -0
  70. data/app/models/aven/article.rb +86 -0
  71. data/app/models/aven/article_attachment.rb +18 -0
  72. data/app/models/aven/article_relationship.rb +26 -0
  73. data/app/models/aven/chat/message.rb +135 -0
  74. data/app/models/aven/chat/thread.rb +159 -0
  75. data/app/models/aven/import/entry.rb +45 -0
  76. data/app/models/aven/import/item_link.rb +36 -0
  77. data/app/models/aven/import/processor.rb +123 -0
  78. data/app/models/aven/import.rb +102 -0
  79. data/app/models/aven/item/embed.rb +54 -0
  80. data/app/models/aven/item/embeddable.rb +141 -0
  81. data/app/models/aven/item/linkable.rb +212 -0
  82. data/app/models/aven/item/schema/builder.rb +139 -0
  83. data/app/models/aven/item/schemaed.rb +252 -0
  84. data/app/models/aven/item/schemas/base.rb +108 -0
  85. data/app/models/aven/item.rb +128 -0
  86. data/app/models/aven/item_link.rb +43 -0
  87. data/app/models/aven/item_schema.rb +87 -0
  88. data/app/models/aven/log.rb +66 -0
  89. data/app/models/aven/loggable.rb +20 -0
  90. data/app/models/aven/user.rb +40 -0
  91. data/app/models/aven/workspace.rb +93 -0
  92. data/app/models/aven/workspace_role.rb +46 -0
  93. data/app/models/aven/workspace_user.rb +54 -0
  94. data/app/models/aven/workspace_user_role.rb +38 -0
  95. data/app/models/concerns/aven/agentic/document_embeddable.rb +58 -0
  96. data/app/models/concerns/aven/searchable.rb +61 -0
  97. data/app/services/aven/agentic/dynamic_tool_builder.rb +81 -0
  98. data/app/services/aven/agentic/mcp/adapter.rb +77 -0
  99. data/app/services/aven/agentic/mcp/result_formatter.rb +57 -0
  100. data/app/services/aven/agentic/mcp/server_factory.rb +43 -0
  101. data/app/services/aven/agentic/ocr/base_extractor.rb +39 -0
  102. data/app/services/aven/agentic/ocr/excel_extractor.rb +43 -0
  103. data/app/services/aven/agentic/ocr/image_extractor.rb +22 -0
  104. data/app/services/aven/agentic/ocr/pdf_extractor.rb +48 -0
  105. data/app/services/aven/agentic/ocr/processor.rb +36 -0
  106. data/app/services/aven/agentic/ocr/textract_client.rb +131 -0
  107. data/app/services/aven/agentic/ocr/word_extractor.rb +34 -0
  108. data/app/services/aven/agentic/tool_result_formatter.rb +76 -0
  109. data/app/services/aven/agentic/tools/base.rb +55 -0
  110. data/app/services/aven/agentic/tools/concerns/boolean_filtering.rb +40 -0
  111. data/app/services/aven/agentic/tools/concerns/enum_filtering.rb +47 -0
  112. data/app/services/aven/agentic/tools/concerns/geo_filtering.rb +56 -0
  113. data/app/services/aven/agentic/tools/concerns/range_filtering.rb +51 -0
  114. data/app/services/aven/chat/broadcaster.rb +59 -0
  115. data/app/services/aven/chat/config.rb +93 -0
  116. data/app/services/aven/chat/message_builder.rb +42 -0
  117. data/app/services/aven/chat/orchestrator.rb +69 -0
  118. data/app/services/aven/chat/runner.rb +105 -0
  119. data/app/services/aven/chat/title_generator.rb +61 -0
  120. data/app/services/aven/external/gmail_client.rb +173 -0
  121. data/app/services/aven/external/google_contacts_client.rb +95 -0
  122. data/app/views/layouts/aven/admin.html.erb +16 -0
  123. data/app/views/layouts/aven/application.html.erb +18 -0
  124. data/config/importmap.rb +16 -0
  125. data/config/routes.rb +63 -0
  126. data/db/migrate/20200101000001_create_aven_users.rb +19 -0
  127. data/db/migrate/20200101000002_create_aven_workspaces.rb +14 -0
  128. data/db/migrate/20200101000003_create_aven_workspace_users.rb +12 -0
  129. data/db/migrate/20200101000004_create_aven_workspace_roles.rb +13 -0
  130. data/db/migrate/20200101000005_create_aven_workspace_user_roles.rb +12 -0
  131. data/db/migrate/20200101000006_create_aven_logs.rb +21 -0
  132. data/db/migrate/20200101000009_create_aven_items.rb +17 -0
  133. data/db/migrate/20200101000010_create_aven_item_links.rb +17 -0
  134. data/db/migrate/20200101000011_create_aven_agentic_tools.rb +19 -0
  135. data/db/migrate/20200101000012_create_aven_agentic_tool_parameters.rb +20 -0
  136. data/db/migrate/20200101000013_create_aven_agentic_documents.rb +22 -0
  137. data/db/migrate/20200101000014_create_aven_agentic_document_embeddings.rb +18 -0
  138. data/db/migrate/20200101000015_create_aven_agentic_agents.rb +18 -0
  139. data/db/migrate/20200101000016_create_aven_agentic_agent_tools.rb +13 -0
  140. data/db/migrate/20200101000017_create_aven_agentic_agent_documents.rb +13 -0
  141. data/db/migrate/20200101000018_create_aven_chat_threads.rb +19 -0
  142. data/db/migrate/20200101000019_create_aven_chat_messages.rb +26 -0
  143. data/db/migrate/20200101000020_add_pg_search_support.rb +21 -0
  144. data/db/migrate/20200101000021_create_aven_item_schemas.rb +18 -0
  145. data/db/migrate/20200101000022_create_aven_imports.rb +23 -0
  146. data/db/migrate/20200101000023_create_aven_import_entries.rb +13 -0
  147. data/db/migrate/20200101000024_create_aven_import_item_links.rb +13 -0
  148. data/db/migrate/20200101000025_create_aven_articles.rb +19 -0
  149. data/db/migrate/20200101000026_create_aven_article_attachments.rb +13 -0
  150. data/db/migrate/20200101000027_create_aven_article_relationships.rb +15 -0
  151. data/lib/aven/configuration.rb +87 -0
  152. data/lib/aven/engine.rb +43 -0
  153. data/lib/aven/model/tenant_model.rb +91 -0
  154. data/lib/aven/model.rb +6 -0
  155. data/lib/aven/version.rb +3 -0
  156. data/lib/aven.rb +8 -0
  157. data/lib/tasks/annotate_rb.rake +10 -0
  158. data/lib/tasks/aven_tasks.rake +21 -0
  159. metadata +426 -0
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ # == Schema Information
4
+ #
5
+ # Table name: aven_chat_threads
6
+ #
7
+ # id :bigint not null, primary key
8
+ # context_markdown :text
9
+ # documents :jsonb
10
+ # title :string
11
+ # tools :jsonb
12
+ # created_at :datetime not null
13
+ # updated_at :datetime not null
14
+ # agent_id :bigint
15
+ # user_id :bigint not null
16
+ # workspace_id :bigint not null
17
+ #
18
+ # Indexes
19
+ #
20
+ # index_aven_chat_threads_on_agent_id (agent_id)
21
+ # index_aven_chat_threads_on_created_at (created_at)
22
+ # index_aven_chat_threads_on_user_id (user_id)
23
+ # index_aven_chat_threads_on_workspace_id (workspace_id)
24
+ # index_aven_chat_threads_on_workspace_id_and_user_id (workspace_id,user_id)
25
+ #
26
+ # Foreign Keys
27
+ #
28
+ # fk_rails_... (agent_id => aven_agentic_agents.id)
29
+ # fk_rails_... (user_id => aven_users.id)
30
+ # fk_rails_... (workspace_id => aven_workspaces.id)
31
+ #
32
+ module Aven
33
+ module Chat
34
+ class Thread < Aven::ApplicationRecord
35
+ self.table_name = "aven_chat_threads"
36
+
37
+ include Aven::Model::TenantModel
38
+
39
+ belongs_to :user, class_name: "Aven::User"
40
+ belongs_to :agent, class_name: "Aven::Agentic::Agent", optional: true
41
+
42
+ has_many :messages,
43
+ class_name: "Aven::Chat::Message",
44
+ foreign_key: :thread_id,
45
+ dependent: :destroy
46
+
47
+ validates :user, presence: true
48
+
49
+ after_update :broadcast_update, if: :saved_change_to_title?
50
+
51
+ scope :recent, -> { order(created_at: :desc) }
52
+
53
+ # Tools are locked on first agent use and never modified after.
54
+ # - nil means all tools are available (free-form chat)
55
+ # - Array of tool names means only those tools are available
56
+ def tools_locked?
57
+ tools.present?
58
+ end
59
+
60
+ # Lock tools to a specific set of tool names.
61
+ def lock_tools!(tool_names)
62
+ return if tools_locked?
63
+
64
+ update!(tools: tool_names)
65
+ end
66
+
67
+ # Documents are locked on first agent use and never modified after.
68
+ def documents_locked?
69
+ documents.present?
70
+ end
71
+
72
+ # Lock documents to a specific set of document IDs.
73
+ def lock_documents!(document_ids)
74
+ return if documents_locked?
75
+ return if document_ids.blank?
76
+
77
+ update!(documents: document_ids)
78
+ end
79
+
80
+ # Returns the locked documents with OCR content for system prompt injection
81
+ def locked_documents
82
+ return [] unless documents_locked?
83
+
84
+ Aven::Agentic::Document.where(id: documents).where(ocr_status: "completed")
85
+ end
86
+
87
+ # Check if thread has an agent locked
88
+ def agent_locked?
89
+ agent_id.present?
90
+ end
91
+
92
+ # Lock the agent reference. Called once when an agent is first used.
93
+ def lock_agent!(agent)
94
+ return if agent_locked?
95
+
96
+ update!(agent:)
97
+ end
98
+
99
+ # Ask a question and trigger chat processing
100
+ def ask(question)
101
+ user_message = messages.create!(
102
+ role: :user,
103
+ content: question,
104
+ status: :success
105
+ )
106
+ Aven::Chat::RunJob.perform_later(id, user_message.id)
107
+ user_message
108
+ end
109
+
110
+ # Ask with an agent, locking agent/tools/documents on first use
111
+ def ask_with_agent(agent, question = nil)
112
+ unless agent_locked?
113
+ lock_agent!(agent)
114
+ lock_tools!(agent.tool_names)
115
+ lock_documents!(agent.document_ids)
116
+ end
117
+
118
+ # Create hidden system message with agent's system prompt
119
+ if agent.system_prompt.present?
120
+ messages.create!(
121
+ role: :system,
122
+ content: agent.system_prompt,
123
+ status: :success
124
+ )
125
+ end
126
+
127
+ ask(question || agent.user_facing_question)
128
+ end
129
+
130
+ # Calculate usage statistics
131
+ def usage_stats
132
+ result = messages
133
+ .where.not(role: :user)
134
+ .pick(
135
+ Arel.sql("SUM(input_tokens)"),
136
+ Arel.sql("SUM(output_tokens)"),
137
+ Arel.sql("SUM(total_tokens)"),
138
+ Arel.sql("SUM(cost_usd)")
139
+ )
140
+
141
+ {
142
+ input: result[0] || 0,
143
+ output: result[1] || 0,
144
+ total: result[2] || 0,
145
+ cost: result[3] || 0.0
146
+ }
147
+ end
148
+
149
+ private
150
+
151
+ def broadcast_update
152
+ Aven::Chat::ThreadChannel.broadcast_to(self, {
153
+ type: "thread_update",
154
+ thread: { id:, title: }
155
+ })
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ # == Schema Information
4
+ #
5
+ # Table name: aven_import_entries
6
+ #
7
+ # id :bigint not null, primary key
8
+ # data :jsonb not null
9
+ # created_at :datetime not null
10
+ # updated_at :datetime not null
11
+ # import_id :bigint not null
12
+ #
13
+ # Indexes
14
+ #
15
+ # index_aven_import_entries_on_data (data) USING gin
16
+ # index_aven_import_entries_on_import_id (import_id)
17
+ #
18
+ # Foreign Keys
19
+ #
20
+ # fk_rails_... (import_id => aven_imports.id)
21
+ #
22
+ module Aven
23
+ class Import::Entry < ApplicationRecord
24
+ self.table_name = "aven_import_entries"
25
+
26
+ belongs_to :import, class_name: "Aven::Import"
27
+ has_many :item_links, class_name: "Aven::Import::ItemLink", foreign_key: :entry_id, dependent: :destroy
28
+ has_many :items, through: :item_links, class_name: "Aven::Item"
29
+
30
+ validates :data, presence: true
31
+
32
+ scope :linked, -> { joins(:item_links).distinct }
33
+ scope :unlinked, -> { where.missing(:item_links) }
34
+
35
+ delegate :workspace, to: :import
36
+
37
+ def linked?
38
+ item_links.exists?
39
+ end
40
+
41
+ def link_to_item!(item)
42
+ item_links.create!(item:)
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ # == Schema Information
4
+ #
5
+ # Table name: aven_import_item_links
6
+ #
7
+ # id :bigint not null, primary key
8
+ # created_at :datetime not null
9
+ # updated_at :datetime not null
10
+ # entry_id :bigint not null
11
+ # item_id :bigint not null
12
+ #
13
+ # Indexes
14
+ #
15
+ # index_aven_import_item_links_on_entry_id (entry_id)
16
+ # index_aven_import_item_links_on_entry_id_and_item_id (entry_id,item_id) UNIQUE
17
+ # index_aven_import_item_links_on_item_id (item_id)
18
+ #
19
+ # Foreign Keys
20
+ #
21
+ # fk_rails_... (entry_id => aven_import_entries.id)
22
+ # fk_rails_... (item_id => aven_items.id)
23
+ #
24
+ module Aven
25
+ class Import::ItemLink < ApplicationRecord
26
+ self.table_name = "aven_import_item_links"
27
+
28
+ belongs_to :entry, class_name: "Aven::Import::Entry"
29
+ belongs_to :item, class_name: "Aven::Item"
30
+
31
+ validates :entry_id, uniqueness: { scope: :item_id }
32
+
33
+ delegate :import, :workspace, to: :entry
34
+ delegate :schema_slug, to: :item
35
+ end
36
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aven
4
+ class Import::Processor
5
+ attr_reader :import
6
+
7
+ def initialize(import)
8
+ @import = import
9
+ end
10
+
11
+ def run
12
+ import.mark_processing!
13
+
14
+ import.entries.unlinked.find_each do |entry|
15
+ process_entry(entry)
16
+ import.increment_processed!
17
+ end
18
+
19
+ import.mark_completed!
20
+ rescue StandardError => e
21
+ import.mark_failed!(e.message)
22
+ raise
23
+ end
24
+
25
+ private
26
+
27
+ def process_entry(entry)
28
+ return skip_entry(entry, "No email found") if entry_email(entry).blank?
29
+ return skip_duplicate(entry) if duplicate?(entry)
30
+
31
+ item = create_item(entry)
32
+ entry.link_to_item!(item)
33
+ import.increment_imported!
34
+ rescue ActiveRecord::RecordInvalid => e
35
+ import.increment_skipped!
36
+ import.log_error("Failed to create item for entry #{entry.id}: #{e.message}")
37
+ end
38
+
39
+ def entry_email(entry)
40
+ entry.data["email"]
41
+ end
42
+
43
+ def duplicate?(entry)
44
+ email = entry_email(entry)
45
+ Aven::Item.by_schema(target_schema)
46
+ .where(workspace_id: import.workspace_id)
47
+ .where("data->>'email' = ?", email)
48
+ .exists?
49
+ end
50
+
51
+ def skip_entry(entry, reason)
52
+ import.increment_skipped!
53
+ import.log_error("Skipped entry #{entry.id}: #{reason}")
54
+ end
55
+
56
+ def skip_duplicate(entry)
57
+ import.increment_skipped!
58
+ import.log_error("Duplicate email: #{entry_email(entry)}")
59
+ end
60
+
61
+ def create_item(entry)
62
+ Aven::Item.create!(
63
+ workspace_id: import.workspace_id,
64
+ schema_slug: target_schema,
65
+ data: build_item_data(entry)
66
+ )
67
+ end
68
+
69
+ def target_schema
70
+ case import.source
71
+ when "google_contacts", "gmail_emails"
72
+ "contact"
73
+ else
74
+ raise "Unknown source: #{import.source}"
75
+ end
76
+ end
77
+
78
+ def build_item_data(entry)
79
+ case import.source
80
+ when "google_contacts"
81
+ build_google_contact_data(entry.data)
82
+ when "gmail_emails"
83
+ build_gmail_email_data(entry.data)
84
+ else
85
+ entry.data
86
+ end
87
+ end
88
+
89
+ def build_google_contact_data(data)
90
+ {
91
+ "first_name" => data["first_name"].presence || "Unknown",
92
+ "last_name" => data["last_name"],
93
+ "email" => data["email"],
94
+ "phone" => data["phone"]
95
+ }.compact
96
+ end
97
+
98
+ def build_gmail_email_data(data)
99
+ first_name, last_name = parse_name(data["name"], data["email"])
100
+
101
+ {
102
+ "first_name" => first_name,
103
+ "last_name" => last_name,
104
+ "email" => data["email"]
105
+ }.compact
106
+ end
107
+
108
+ def parse_name(name, email)
109
+ if name.present?
110
+ parts = name.split(/\s+/, 2)
111
+ [parts[0], parts[1] || "Contact"]
112
+ else
113
+ local = email.to_s.split("@").first.to_s
114
+ parts = local.split(/[._-]/)
115
+ if parts.length >= 2
116
+ [parts[0].titleize, parts[1..].join(" ").titleize]
117
+ else
118
+ [local.titleize, "Contact"]
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ # == Schema Information
4
+ #
5
+ # Table name: aven_imports
6
+ #
7
+ # id :bigint not null, primary key
8
+ # completed_at :datetime
9
+ # error_message :text
10
+ # errors_log :jsonb
11
+ # imported_count :integer default(0)
12
+ # processed_count :integer default(0)
13
+ # skipped_count :integer default(0)
14
+ # source :string not null
15
+ # started_at :datetime
16
+ # status :string default("pending"), not null
17
+ # total_count :integer default(0)
18
+ # created_at :datetime not null
19
+ # updated_at :datetime not null
20
+ # workspace_id :bigint not null
21
+ #
22
+ # Indexes
23
+ #
24
+ # index_aven_imports_on_source (source)
25
+ # index_aven_imports_on_status (status)
26
+ # index_aven_imports_on_workspace_id (workspace_id)
27
+ #
28
+ # Foreign Keys
29
+ #
30
+ # fk_rails_... (workspace_id => aven_workspaces.id)
31
+ #
32
+ module Aven
33
+ class Import < ApplicationRecord
34
+ include Aven::Model::TenantModel
35
+
36
+ self.table_name = "aven_imports"
37
+
38
+ SOURCES = %w[google_contacts gmail_emails].freeze
39
+ STATUSES = %w[pending fetching processing completed failed].freeze
40
+
41
+ has_many :entries, class_name: "Aven::Import::Entry", dependent: :destroy
42
+
43
+ validates :source, presence: true, inclusion: { in: SOURCES }
44
+ validates :status, presence: true, inclusion: { in: STATUSES }
45
+
46
+ scope :in_progress, -> { where(status: %w[pending fetching processing]) }
47
+ scope :recent, -> { order(created_at: :desc) }
48
+ scope :by_source, ->(source) { where(source:) }
49
+
50
+ def in_progress?
51
+ %w[pending fetching processing].include?(status)
52
+ end
53
+
54
+ def completed?
55
+ status == "completed"
56
+ end
57
+
58
+ def failed?
59
+ status == "failed"
60
+ end
61
+
62
+ def progress_percentage
63
+ return 0 if total_count.zero?
64
+ (processed_count * 100.0 / total_count).round
65
+ end
66
+
67
+ def mark_fetching!(total: nil)
68
+ attrs = { status: "fetching", started_at: Time.current }
69
+ attrs[:total_count] = total if total
70
+ update!(attrs)
71
+ end
72
+
73
+ def mark_processing!
74
+ update!(status: "processing")
75
+ end
76
+
77
+ def increment_processed!
78
+ increment!(:processed_count)
79
+ end
80
+
81
+ def increment_imported!
82
+ increment!(:imported_count)
83
+ end
84
+
85
+ def increment_skipped!
86
+ increment!(:skipped_count)
87
+ end
88
+
89
+ def mark_completed!
90
+ update!(status: "completed", completed_at: Time.current)
91
+ end
92
+
93
+ def mark_failed!(message)
94
+ update!(status: "failed", error_message: message, completed_at: Time.current)
95
+ end
96
+
97
+ def log_error(error)
98
+ self.errors_log = (errors_log || []) << { at: Time.current.iso8601, message: error }
99
+ save!
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aven
4
+ class Item::Embed
5
+ include ActiveModel::Model
6
+ include ActiveModel::Attributes
7
+
8
+ attr_accessor :id, :_destroy
9
+
10
+ def initialize(attrs = {})
11
+ @attributes = (attrs || {}).with_indifferent_access
12
+ @id = @attributes["id"]
13
+ end
14
+
15
+ def [](key)
16
+ @attributes[key]
17
+ end
18
+
19
+ def []=(key, value)
20
+ @attributes[key] = value
21
+ end
22
+
23
+ def to_h
24
+ @attributes.to_h
25
+ end
26
+
27
+ alias_method :to_hash, :to_h
28
+
29
+ def persisted?
30
+ id.present?
31
+ end
32
+
33
+ def new_record?
34
+ !persisted?
35
+ end
36
+
37
+ def marked_for_destruction?
38
+ _destroy == "1" || _destroy == true
39
+ end
40
+
41
+ def method_missing(method, *args)
42
+ key = method.to_s
43
+ if key.end_with?("=")
44
+ @attributes[key.chomp("=")] = args.first
45
+ else
46
+ @attributes[key]
47
+ end
48
+ end
49
+
50
+ def respond_to_missing?(_method, _include_private = false)
51
+ true
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aven
4
+ module Item::Embeddable
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ before_validation :assign_embed_ids
9
+ end
10
+
11
+ private
12
+
13
+ def assign_embed_ids
14
+ return if data.nil?
15
+ return if schema_slug.blank?
16
+
17
+ # Guard against schema not found - let validation handle the error
18
+ embeds = begin
19
+ schema_embeds
20
+ rescue ActiveRecord::RecordNotFound
21
+ {}
22
+ end
23
+
24
+ return if embeds.blank?
25
+
26
+ embeds.each do |name, config|
27
+ if config[:cardinality] == :many
28
+ assign_many_embed_ids(name)
29
+ else
30
+ assign_one_embed_id(name)
31
+ end
32
+ end
33
+ end
34
+
35
+ def assign_many_embed_ids(name)
36
+ embeds = data[name.to_s]
37
+ return if embeds.blank?
38
+
39
+ embeds.each do |embed|
40
+ embed["id"] ||= SecureRandom.uuid
41
+ end
42
+ end
43
+
44
+ def assign_one_embed_id(name)
45
+ embed = data[name.to_s]
46
+ return if embed.blank?
47
+
48
+ embed["id"] ||= SecureRandom.uuid
49
+ end
50
+
51
+ def process_embed_attributes(name, attrs)
52
+ config = schema_embeds[name]
53
+ return unless config
54
+
55
+ # Clear cache
56
+ cache_key = "@_embed_cache_#{name}"
57
+ remove_instance_variable(cache_key) if instance_variable_defined?(cache_key)
58
+
59
+ if config[:cardinality] == :many
60
+ process_many_embed_attributes(name, attrs)
61
+ else
62
+ process_one_embed_attributes(name, attrs)
63
+ end
64
+ end
65
+
66
+ def process_many_embed_attributes(name, attrs)
67
+ normalized = normalize_attrs(attrs)
68
+ existing = (data[name.to_s] || []).dup
69
+
70
+ normalized.each do |attr_hash|
71
+ if destroy_flag?(attr_hash)
72
+ # Remove by id if present
73
+ existing.reject! { |e| e["id"] == attr_hash["id"] } if attr_hash["id"]
74
+ elsif attr_hash["id"].present?
75
+ # Update existing
76
+ idx = existing.index { |e| e["id"] == attr_hash["id"] }
77
+ if idx
78
+ existing[idx] = existing[idx].merge(clean_attrs(attr_hash))
79
+ else
80
+ existing << clean_attrs(attr_hash.merge("id" => SecureRandom.uuid))
81
+ end
82
+ else
83
+ # New embed
84
+ existing << clean_attrs(attr_hash.merge("id" => SecureRandom.uuid))
85
+ end
86
+ end
87
+
88
+ data[name.to_s] = existing
89
+ end
90
+
91
+ def process_one_embed_attributes(name, attrs)
92
+ attr_hash = single_attrs(attrs)
93
+
94
+ if destroy_flag?(attr_hash)
95
+ data[name.to_s] = nil
96
+ elsif data[name.to_s].present?
97
+ data[name.to_s] = data[name.to_s].merge(clean_attrs(attr_hash))
98
+ else
99
+ data[name.to_s] = clean_attrs(attr_hash.merge("id" => SecureRandom.uuid))
100
+ end
101
+ end
102
+
103
+ def normalize_attrs(attrs)
104
+ case attrs
105
+ when Array
106
+ attrs.map(&:with_indifferent_access)
107
+ when Hash
108
+ # Could be indexed hash like {"0" => {...}, "1" => {...}}
109
+ if attrs.keys.all? { |k| k.to_s =~ /\A\d+\z/ }
110
+ attrs.values.map(&:with_indifferent_access)
111
+ else
112
+ [attrs.with_indifferent_access]
113
+ end
114
+ else
115
+ []
116
+ end
117
+ end
118
+
119
+ def single_attrs(attrs)
120
+ case attrs
121
+ when Hash
122
+ if attrs.keys.all? { |k| k.to_s =~ /\A\d+\z/ }
123
+ attrs.values.first&.with_indifferent_access || {}.with_indifferent_access
124
+ else
125
+ attrs.with_indifferent_access
126
+ end
127
+ else
128
+ {}.with_indifferent_access
129
+ end
130
+ end
131
+
132
+ def clean_attrs(hash)
133
+ hash.except(:_destroy, "_destroy").transform_keys(&:to_s)
134
+ end
135
+
136
+ def destroy_flag?(attrs)
137
+ val = attrs[:_destroy] || attrs["_destroy"]
138
+ val == "1" || val == true
139
+ end
140
+ end
141
+ end