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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +35 -0
- data/Rakefile +19 -0
- data/app/assets/stylesheets/aven/application.css +14 -0
- data/app/assets/stylesheets/aven/application.tailwind.css +7 -0
- data/app/assets/stylesheets/aven/tailwind.css +224 -0
- data/app/channels/aven/chat/thread_channel.rb +39 -0
- data/app/components/aven/application_view_component.rb +15 -0
- data/app/components/aven/views/admin/dashboard/index/component.html.erb +1 -0
- data/app/components/aven/views/admin/dashboard/index/component.rb +5 -0
- data/app/components/aven/views/articles/edit/component.html.erb +14 -0
- data/app/components/aven/views/articles/edit/component.rb +14 -0
- data/app/components/aven/views/articles/form/component.html.erb +45 -0
- data/app/components/aven/views/articles/form/component.rb +27 -0
- data/app/components/aven/views/articles/index/component.html.erb +93 -0
- data/app/components/aven/views/articles/index/component.rb +29 -0
- data/app/components/aven/views/articles/new/component.html.erb +13 -0
- data/app/components/aven/views/articles/new/component.rb +14 -0
- data/app/components/aven/views/articles/show/component.html.erb +110 -0
- data/app/components/aven/views/articles/show/component.rb +34 -0
- data/app/components/aven/views/oauth/error/component.html.erb +44 -0
- data/app/components/aven/views/oauth/error/component.rb +30 -0
- data/app/components/aven/views/static/index/component.html.erb +17 -0
- data/app/components/aven/views/static/index/component.rb +16 -0
- data/app/components/aven/views/static/index/controller.js +7 -0
- data/app/controllers/aven/admin/base.rb +16 -0
- data/app/controllers/aven/admin/dashboard_controller.rb +9 -0
- data/app/controllers/aven/agentic/agents_controller.rb +56 -0
- data/app/controllers/aven/agentic/documents_controller.rb +51 -0
- data/app/controllers/aven/agentic/mcp_controller.rb +124 -0
- data/app/controllers/aven/agentic/tools_controller.rb +37 -0
- data/app/controllers/aven/ai/text_controller.rb +41 -0
- data/app/controllers/aven/application_controller.rb +27 -0
- data/app/controllers/aven/articles_controller.rb +114 -0
- data/app/controllers/aven/auth_controller.rb +12 -0
- data/app/controllers/aven/chat/threads_controller.rb +67 -0
- data/app/controllers/aven/oauth/auth0_controller.rb +84 -0
- data/app/controllers/aven/oauth/base_controller.rb +183 -0
- data/app/controllers/aven/oauth/documentation/auth0.md +387 -0
- data/app/controllers/aven/oauth/documentation/entra_id.md +608 -0
- data/app/controllers/aven/oauth/documentation/github.md +329 -0
- data/app/controllers/aven/oauth/documentation/google.md +253 -0
- data/app/controllers/aven/oauth/entra_id_controller.rb +92 -0
- data/app/controllers/aven/oauth/github_controller.rb +91 -0
- data/app/controllers/aven/oauth/google_controller.rb +64 -0
- data/app/controllers/aven/static_controller.rb +7 -0
- data/app/controllers/aven/tags_controller.rb +44 -0
- data/app/controllers/aven/workspaces_controller.rb +20 -0
- data/app/controllers/concerns/aven/authentication.rb +49 -0
- data/app/controllers/concerns/aven/controller_helpers.rb +38 -0
- data/app/helpers/aven/application_helper.rb +16 -0
- data/app/javascript/aven/application.js +3 -0
- data/app/javascript/aven/controllers/application.js +5 -0
- data/app/javascript/aven/controllers/index.js +11 -0
- data/app/jobs/aven/agentic/document_embedding_job.rb +28 -0
- data/app/jobs/aven/agentic/document_ocr_job.rb +28 -0
- data/app/jobs/aven/application_job.rb +4 -0
- data/app/jobs/aven/chat/calculate_cost_job.rb +26 -0
- data/app/jobs/aven/chat/run_job.rb +27 -0
- data/app/mailers/aven/application_mailer.rb +6 -0
- data/app/models/aven/agentic/agent.rb +76 -0
- data/app/models/aven/agentic/agent_document.rb +37 -0
- data/app/models/aven/agentic/agent_tool.rb +37 -0
- data/app/models/aven/agentic/document.rb +162 -0
- data/app/models/aven/agentic/document_embedding.rb +39 -0
- data/app/models/aven/agentic/tool.rb +106 -0
- data/app/models/aven/agentic/tool_parameter.rb +56 -0
- data/app/models/aven/application_record.rb +5 -0
- data/app/models/aven/article.rb +86 -0
- data/app/models/aven/article_attachment.rb +18 -0
- data/app/models/aven/article_relationship.rb +26 -0
- data/app/models/aven/chat/message.rb +135 -0
- data/app/models/aven/chat/thread.rb +159 -0
- data/app/models/aven/import/entry.rb +45 -0
- data/app/models/aven/import/item_link.rb +36 -0
- data/app/models/aven/import/processor.rb +123 -0
- data/app/models/aven/import.rb +102 -0
- data/app/models/aven/item/embed.rb +54 -0
- data/app/models/aven/item/embeddable.rb +141 -0
- data/app/models/aven/item/linkable.rb +212 -0
- data/app/models/aven/item/schema/builder.rb +139 -0
- data/app/models/aven/item/schemaed.rb +252 -0
- data/app/models/aven/item/schemas/base.rb +108 -0
- data/app/models/aven/item.rb +128 -0
- data/app/models/aven/item_link.rb +43 -0
- data/app/models/aven/item_schema.rb +87 -0
- data/app/models/aven/log.rb +66 -0
- data/app/models/aven/loggable.rb +20 -0
- data/app/models/aven/user.rb +40 -0
- data/app/models/aven/workspace.rb +93 -0
- data/app/models/aven/workspace_role.rb +46 -0
- data/app/models/aven/workspace_user.rb +54 -0
- data/app/models/aven/workspace_user_role.rb +38 -0
- data/app/models/concerns/aven/agentic/document_embeddable.rb +58 -0
- data/app/models/concerns/aven/searchable.rb +61 -0
- data/app/services/aven/agentic/dynamic_tool_builder.rb +81 -0
- data/app/services/aven/agentic/mcp/adapter.rb +77 -0
- data/app/services/aven/agentic/mcp/result_formatter.rb +57 -0
- data/app/services/aven/agentic/mcp/server_factory.rb +43 -0
- data/app/services/aven/agentic/ocr/base_extractor.rb +39 -0
- data/app/services/aven/agentic/ocr/excel_extractor.rb +43 -0
- data/app/services/aven/agentic/ocr/image_extractor.rb +22 -0
- data/app/services/aven/agentic/ocr/pdf_extractor.rb +48 -0
- data/app/services/aven/agentic/ocr/processor.rb +36 -0
- data/app/services/aven/agentic/ocr/textract_client.rb +131 -0
- data/app/services/aven/agentic/ocr/word_extractor.rb +34 -0
- data/app/services/aven/agentic/tool_result_formatter.rb +76 -0
- data/app/services/aven/agentic/tools/base.rb +55 -0
- data/app/services/aven/agentic/tools/concerns/boolean_filtering.rb +40 -0
- data/app/services/aven/agentic/tools/concerns/enum_filtering.rb +47 -0
- data/app/services/aven/agentic/tools/concerns/geo_filtering.rb +56 -0
- data/app/services/aven/agentic/tools/concerns/range_filtering.rb +51 -0
- data/app/services/aven/chat/broadcaster.rb +59 -0
- data/app/services/aven/chat/config.rb +93 -0
- data/app/services/aven/chat/message_builder.rb +42 -0
- data/app/services/aven/chat/orchestrator.rb +69 -0
- data/app/services/aven/chat/runner.rb +105 -0
- data/app/services/aven/chat/title_generator.rb +61 -0
- data/app/services/aven/external/gmail_client.rb +173 -0
- data/app/services/aven/external/google_contacts_client.rb +95 -0
- data/app/views/layouts/aven/admin.html.erb +16 -0
- data/app/views/layouts/aven/application.html.erb +18 -0
- data/config/importmap.rb +16 -0
- data/config/routes.rb +63 -0
- data/db/migrate/20200101000001_create_aven_users.rb +19 -0
- data/db/migrate/20200101000002_create_aven_workspaces.rb +14 -0
- data/db/migrate/20200101000003_create_aven_workspace_users.rb +12 -0
- data/db/migrate/20200101000004_create_aven_workspace_roles.rb +13 -0
- data/db/migrate/20200101000005_create_aven_workspace_user_roles.rb +12 -0
- data/db/migrate/20200101000006_create_aven_logs.rb +21 -0
- data/db/migrate/20200101000009_create_aven_items.rb +17 -0
- data/db/migrate/20200101000010_create_aven_item_links.rb +17 -0
- data/db/migrate/20200101000011_create_aven_agentic_tools.rb +19 -0
- data/db/migrate/20200101000012_create_aven_agentic_tool_parameters.rb +20 -0
- data/db/migrate/20200101000013_create_aven_agentic_documents.rb +22 -0
- data/db/migrate/20200101000014_create_aven_agentic_document_embeddings.rb +18 -0
- data/db/migrate/20200101000015_create_aven_agentic_agents.rb +18 -0
- data/db/migrate/20200101000016_create_aven_agentic_agent_tools.rb +13 -0
- data/db/migrate/20200101000017_create_aven_agentic_agent_documents.rb +13 -0
- data/db/migrate/20200101000018_create_aven_chat_threads.rb +19 -0
- data/db/migrate/20200101000019_create_aven_chat_messages.rb +26 -0
- data/db/migrate/20200101000020_add_pg_search_support.rb +21 -0
- data/db/migrate/20200101000021_create_aven_item_schemas.rb +18 -0
- data/db/migrate/20200101000022_create_aven_imports.rb +23 -0
- data/db/migrate/20200101000023_create_aven_import_entries.rb +13 -0
- data/db/migrate/20200101000024_create_aven_import_item_links.rb +13 -0
- data/db/migrate/20200101000025_create_aven_articles.rb +19 -0
- data/db/migrate/20200101000026_create_aven_article_attachments.rb +13 -0
- data/db/migrate/20200101000027_create_aven_article_relationships.rb +15 -0
- data/lib/aven/configuration.rb +87 -0
- data/lib/aven/engine.rb +43 -0
- data/lib/aven/model/tenant_model.rb +91 -0
- data/lib/aven/model.rb +6 -0
- data/lib/aven/version.rb +3 -0
- data/lib/aven.rb +8 -0
- data/lib/tasks/annotate_rb.rake +10 -0
- data/lib/tasks/aven_tasks.rake +21 -0
- 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
|