your_ai_insight 1.0.7

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1165bce9fe9219ffffaf726123fb6f47c403c9227706c46b55a0b33cca0f11cf
4
+ data.tar.gz: d7461640435eabdae0ac5ed47c217d8c9f6aac22532e96a3316c36806198299d
5
+ SHA512:
6
+ metadata.gz: 70a6dd041f904dee49762ec1a5f26d09bf3f55daa620fa510e89a0deb69743dad752d20c769b15160795006e43c013692e531f07d6407f55095645cf00a673c6
7
+ data.tar.gz: 937bac54feed81fbe9f66da49ec1b9fec4b7ca6543ffcd2056e05d3dfb908b97be230ee76cb0093fb71f7c1da8c8a5f34ae225476d219179c4c32c9be1c08de6
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 AllPro IFM
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,284 @@
1
+ # YourAiInsight — Rails Engine
2
+
3
+ > AI-powered **Summary Dashboards**, **Compliance & Audit Reports**, and a **live chat assistant** for facility management portals.
4
+ > Built for `portal.allproifm.com` and any Rails 6+ facility management app with a compatible schema.
5
+
6
+ ---
7
+
8
+ ## What it analyses (from your real schema)
9
+
10
+ | Data Source | Table | What AI uses |
11
+ |---|---|---|
12
+ | Jobs | `jobs` | status, priority, hold, sales_price, not_to_exceed, accepted/finaled dates |
13
+ | Tasks | `tasks` | status, budget, actual, start/complete dates |
14
+ | Bids | `bids` | status, amount, actual, active flag |
15
+ | Expenses | `expenses` | budget vs actual, expense_type |
16
+ | Location Budgets | `location_budgets` | month1–month12 per location/fiscal_year |
17
+ | Budget Requests | `budget_requests` | status (0=pending,1=approved,2=rejected), amount |
18
+ | Pay Requests | `pay_requests` | paid/declined/outstanding status |
19
+ | Locations | `locations` | name, customer scoping |
20
+ | Customers | `customers` | name, status |
21
+ | Vendors | `vendors` | linked via tasks & bids |
22
+
23
+ ---
24
+
25
+ ## Installation
26
+
27
+ ### 1. Add the gem
28
+
29
+ ```ruby
30
+ # Gemfile
31
+ gem "your_ai_insight"
32
+
33
+ # During development (local path):
34
+ # gem "your_ai_insight", path: "../your_ai_insight"
35
+ ```
36
+
37
+ ```bash
38
+ bundle install
39
+ ```
40
+
41
+ ### 2. Run the migration
42
+
43
+ This only creates the engine's own `your_ai_insight_reports` table.
44
+ **Your existing tables are never modified.**
45
+
46
+ ```bash
47
+ rails your_ai_insight:install:migrations
48
+ rails db:migrate
49
+ ```
50
+
51
+ ### 3. Mount the engine
52
+
53
+ ```ruby
54
+ # config/routes.rb
55
+ Rails.application.routes.draw do
56
+ mount YourAiInsight::Engine => "/your_ai_insight"
57
+ # ... your existing routes
58
+ end
59
+ ```
60
+
61
+ ### 4. Configure
62
+
63
+ ```ruby
64
+ # config/initializers/your_ai_insight.rb
65
+ YourAiInsight.configure do |config|
66
+ # Required
67
+ config.anthropic_api_key = ENV["ANTHROPIC_API_KEY"]
68
+
69
+ # Scope data to the current user's locations (recommended for multi-tenant)
70
+ # The proc receives current_user and returns an array of location IDs (or nil for all)
71
+ config.location_scope = ->(user) {
72
+ user.locations.pluck(:id) # if user has_many :locations via location_users
73
+ }
74
+
75
+ # Or scope by customer instead:
76
+ # config.customer_scope = ->(user) { [user.customer_id] }
77
+
78
+ # Branding (used in emails and chat widget)
79
+ config.company_name = "AllPro IFM"
80
+ config.logo_url = "https://portal.allproifm.com/logo.png" # optional
81
+
82
+ # Default email recipients for scheduled reports
83
+ config.default_recipients = ["ops@allproifm.com"]
84
+
85
+ # ── Schema overrides ──────────────────────────────────────────────────────
86
+ # Only needed if you install this in a different app with different column names.
87
+ # The defaults below match portal.allproifm.com schema.rb exactly.
88
+ #
89
+ # config.jobs_table = "jobs"
90
+ # config.jobs_status_col = "status"
91
+ # config.jobs_priority_col = "priority"
92
+ # config.jobs_sales_price_col = "sales_price"
93
+ # config.jobs_not_to_exceed_col = "not_to_exceed"
94
+ # config.jobs_location_id_col = "location_id"
95
+ # config.jobs_customer_id_col = "customer_id"
96
+ # config.jobs_accepted_dt_col = "accepted_dt"
97
+ # config.jobs_finaled_dt_col = "finaled_dt"
98
+ # config.jobs_hold_col = "hold"
99
+ # config.tasks_table = "tasks"
100
+ # config.tasks_status_col = "status"
101
+ # config.tasks_budget_col = "budget"
102
+ # config.tasks_actual_col = "actual"
103
+ # config.tasks_job_id_col = "job_id"
104
+ # config.tasks_complete_col = "complete"
105
+ # config.bids_table = "bids"
106
+ # config.bids_status_col = "status"
107
+ # config.bids_amount_col = "amount"
108
+ # config.bids_actual_col = "actual"
109
+ # config.expenses_table = "expenses"
110
+ # config.expenses_budget_col = "budget"
111
+ # config.expenses_actual_col = "actual"
112
+ # config.location_budgets_table = "location_budgets"
113
+ # config.budget_requests_table = "budget_requests"
114
+ end
115
+ ```
116
+
117
+ ### 5. Add the chat widget
118
+
119
+ In `app/views/layouts/application.html.erb`, just before `</body>`:
120
+
121
+ ```erb
122
+ <%= render "your_ai_insight/chat/widget" %>
123
+ ```
124
+
125
+ A floating **💡 AI Facility Assistant** button appears bottom-right. ✅
126
+
127
+ ---
128
+
129
+ ## Generating Reports
130
+
131
+ ### Via the API (JSON)
132
+
133
+ ```bash
134
+ # Summary dashboard — last 30 days
135
+ curl -X POST https://portal.allproifm.com/your_ai_insight/reports \
136
+ -H "Content-Type: application/json" \
137
+ -H "X-CSRF-Token: ..." \
138
+ -d '{"report_type":"summary_dashboard","start_date":"2024-01-01","end_date":"2024-01-31"}'
139
+
140
+ # Compliance & audit report
141
+ curl -X POST https://portal.allproifm.com/your_ai_insight/reports \
142
+ -H "Content-Type: application/json" \
143
+ -d '{"report_type":"compliance_audit"}'
144
+
145
+ # List recent reports
146
+ curl https://portal.allproifm.com/your_ai_insight/reports
147
+
148
+ # Fetch a specific report
149
+ curl https://portal.allproifm.com/your_ai_insight/reports/42
150
+ ```
151
+
152
+ ### Via Rake Tasks
153
+
154
+ ```bash
155
+ # Summary dashboard — last 7 days — email two people
156
+ rake "your_ai_insight:summary_dashboard[ops@allproifm.com+mgr@allproifm.com,7]"
157
+
158
+ # Compliance audit — last 30 days
159
+ rake "your_ai_insight:compliance_audit[ops@allproifm.com,30]"
160
+
161
+ # Test both reports immediately
162
+ REPORT_EMAILS=you@allproifm.com rake your_ai_insight:run_now
163
+ ```
164
+
165
+ ---
166
+
167
+ ## Scheduling with Delayed::Job
168
+
169
+ ### Option A — delayed_cron_job gem (recommended)
170
+
171
+ ```ruby
172
+ # config/initializers/your_ai_insight_schedule.rb
173
+ # Runs once on boot — Delayed::Job deduplicates by cron expression.
174
+
175
+ if defined?(Delayed::Job)
176
+ # Weekly summary dashboard — every Monday 8am
177
+ YourAiInsight::ScheduledReportJob.new(
178
+ report_type: "summary_dashboard",
179
+ recipient_emails: ["ops@allproifm.com", "exec@allproifm.com"],
180
+ days_back: 7
181
+ ).enqueue(cron: "0 8 * * 1")
182
+
183
+ # Monthly compliance audit — 1st of month 9am
184
+ YourAiInsight::ScheduledReportJob.new(
185
+ report_type: "compliance_audit",
186
+ recipient_emails: User.where(admin: true).pluck(:email),
187
+ days_back: 30
188
+ ).enqueue(cron: "0 9 1 * *")
189
+ end
190
+ ```
191
+
192
+ ### Option B — whenever gem
193
+
194
+ ```ruby
195
+ # config/schedule.rb
196
+ every :monday, at: "8:00 am" do
197
+ rake "your_ai_insight:summary_dashboard[ops@allproifm.com,7]"
198
+ end
199
+
200
+ every 1.month, at: "9:00 am" do
201
+ rake "your_ai_insight:compliance_audit[ops@allproifm.com,30]"
202
+ end
203
+ ```
204
+
205
+ ### Option C — Direct enqueue from your app
206
+
207
+ ```ruby
208
+ YourAiInsight::ScheduledReportJob.new(
209
+ report_type: "compliance_audit",
210
+ location_ids: [1, 2, 3], # scope to specific locations
211
+ recipient_emails: ["ops@allproifm.com"],
212
+ days_back: 30
213
+ ).enqueue(run_at: 1.hour.from_now)
214
+ ```
215
+
216
+ ---
217
+
218
+ ## Chat — example questions it can answer
219
+
220
+ Using your live `jobs`, `tasks`, `bids`, `expenses`, and `budget_requests` data:
221
+
222
+ - *"How many jobs are currently on hold and why might that be a problem?"*
223
+ - *"What's our budget vs actual across all expenses this month?"*
224
+ - *"Which tasks are overdue and what's the total risk?"*
225
+ - *"How many bids are waiting on a decision?"*
226
+ - *"What's the status of pending budget requests?"*
227
+ - *"Which locations have the most active jobs right now?"*
228
+ - *"Give me a health check on our job pipeline."*
229
+
230
+ ---
231
+
232
+ ## Selling to Other Clients
233
+
234
+ This engine is built to be white-labelled:
235
+
236
+ 1. **Package** as a private gem on Gemfury, Cloudsmith, or your own gem server
237
+ 2. **Scope data** per client using `location_scope` or `customer_scope`
238
+ 3. **White-label** by setting `company_name` and `logo_url` per client
239
+ 4. **License** clients via their own `ANTHROPIC_API_KEY`, or proxy yours and charge per report via `AiReport.count`
240
+
241
+ ### Pricing ideas
242
+ - Per report ($X/report, track via `YourAiInsight::AiReport.count`)
243
+ - Monthly subscription (unlimited reports)
244
+ - Included in your SaaS tier pricing
245
+
246
+ ---
247
+
248
+ ## API Reference
249
+
250
+ | Endpoint | Method | Params | Description |
251
+ |---|---|---|---|
252
+ | `/your_ai_insight/chat` | POST | `{ message }` | Chat with AI about live facility data |
253
+ | `/your_ai_insight/reports` | GET | — | List reports (newest first) |
254
+ | `/your_ai_insight/reports` | POST | `{ report_type, start_date?, end_date? }` | Generate report on-demand |
255
+ | `/your_ai_insight/reports/:id` | GET | — | Fetch a specific report |
256
+
257
+ ### Report types
258
+ | `report_type` | Description |
259
+ |---|---|
260
+ | `summary_dashboard` | Executive overview: health score, job pipeline, budget performance, vendor activity, recommendations |
261
+ | `compliance_audit` | Overdue tasks, jobs on hold, pending budget requests, outstanding pay requests, over-budget jobs |
262
+
263
+ ---
264
+
265
+ ## Requirements
266
+
267
+ - Ruby ≥ 2.6
268
+ - Rails 6.0–7.x
269
+ - PostgreSQL (uses `EXTRACT`, `DATE_TRUNC`, `jsonb`)
270
+ - Delayed::Job (any backend — ActiveRecord recommended)
271
+ - `ANTHROPIC_API_KEY` environment variable
272
+
273
+ ---
274
+
275
+ ## Changelog
276
+
277
+ ### 1.0.0
278
+ - Initial release
279
+ - Full schema mapping for portal.allproifm.com (jobs, tasks, bids, expenses, location_budgets, budget_requests, pay_requests)
280
+ - Summary Dashboard + Compliance & Audit report types
281
+ - Floating chat widget with quick-prompt chips
282
+ - Delayed::Job integration with cron support
283
+ - Multi-tenant location/customer scoping
284
+ - Branded HTML email reports
@@ -0,0 +1,35 @@
1
+ module YourAiInsight
2
+ class ApplicationController < ActionController::Base
3
+ protect_from_forgery with: :exception
4
+
5
+ private
6
+
7
+ def current_location_ids
8
+ cfg = YourAiInsight.config
9
+ return nil unless cfg.location_scope && respond_to?(:current_user, true) && current_user
10
+ cfg.location_scope.call(current_user)
11
+ end
12
+
13
+ def current_customer_ids
14
+ cfg = YourAiInsight.config
15
+ return nil unless cfg.customer_scope && respond_to?(:current_user, true) && current_user
16
+ cfg.customer_scope.call(current_user)
17
+ end
18
+
19
+ def parse_date_range
20
+ start_d = params[:start_date].present? ? Date.parse(params[:start_date]) : 30.days.ago.to_date
21
+ end_d = params[:end_date].present? ? Date.parse(params[:end_date]) : Date.today
22
+ start_d.beginning_of_day..end_d.end_of_day
23
+ rescue ArgumentError
24
+ 30.days.ago.beginning_of_day..Time.current
25
+ end
26
+
27
+ def data_service(date_range: parse_date_range)
28
+ FacilityDataService.new(
29
+ location_ids: current_location_ids,
30
+ customer_ids: current_customer_ids,
31
+ date_range: date_range
32
+ )
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,26 @@
1
+ ## app/controllers/your_ai_insight/chats_controller.rb
2
+ module YourAiInsight
3
+ class ChatsController < ApplicationController
4
+
5
+ # POST /your_ai_insight/chat
6
+ # Body: { "message": "How many jobs are on hold?" }
7
+ def create
8
+ message = params.require(:message).strip
9
+ return render json: { error: "Message cannot be blank" }, status: :unprocessable_entity if message.blank?
10
+
11
+ context = data_service.quick_context
12
+ response_text = ClaudeService.new.chat(message, context: context)
13
+
14
+ render json: { response: response_text }
15
+
16
+ rescue ActionController::ParameterMissing => e
17
+ render json: { error: e.message }, status: :unprocessable_entity
18
+ rescue YourAiInsight::ApiError => e
19
+ Rails.logger.error("[YourAiInsight::ChatsController] #{e.message}")
20
+ render json: { error: "AI service temporarily unavailable. Please try again shortly." }, status: :service_unavailable
21
+ rescue StandardError => e
22
+ Rails.logger.error("[YourAiInsight::ChatsController] #{e.class}: #{e.message}")
23
+ render json: { error: "An unexpected error occurred." }, status: :internal_server_error
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,25 @@
1
+ module YourAiInsight
2
+ class DashboardController < ApplicationController
3
+
4
+ # GET /your_ai_insight/dashboard
5
+ def index
6
+ @recent_reports = AiReport.recent.limit(10)
7
+ @report_counts = {
8
+ total: AiReport.count,
9
+ dashboards: AiReport.dashboards.count,
10
+ compliance: AiReport.compliance.count,
11
+ scheduled: AiReport.scheduled.count
12
+ }
13
+ @quick_context = data_service.quick_context rescue {}
14
+ end
15
+
16
+ # GET /your_ai_insight/dashboard/quick_context (AJAX – live stats panel)
17
+ def quick_context
18
+ ctx = data_service.quick_context
19
+ render json: ctx
20
+ rescue StandardError => e
21
+ render json: { error: e.message }, status: :service_unavailable
22
+ end
23
+
24
+ end
25
+ end
@@ -0,0 +1,64 @@
1
+ module YourAiInsight
2
+ class ReportsController < ApplicationController
3
+ VALID_TYPES = %w[summary_dashboard compliance_audit].freeze
4
+
5
+ # GET /your_ai_insight/reports
6
+ def index
7
+ reports = AiReport.order(created_at: :desc).limit(50)
8
+ render json: reports.map { |r|
9
+ { id: r.id, report_type: r.report_type, scheduled: r.scheduled,
10
+ period_start: r.period_start, period_end: r.period_end, created_at: r.created_at }
11
+ }
12
+ end
13
+
14
+ # POST /your_ai_insight/reports
15
+ # Body: { "report_type": "summary_dashboard", "start_date": "2024-01-01", "end_date": "2024-01-31" }
16
+ def create
17
+ type = params.require(:report_type)
18
+ return render json: { error: "Invalid report_type. Valid: #{VALID_TYPES.join(', ')}" }, status: :unprocessable_entity \
19
+ unless VALID_TYPES.include?(type)
20
+
21
+ svc = data_service
22
+ data = type == "summary_dashboard" ? svc.summary_dashboard_data : svc.compliance_audit_data
23
+
24
+ content = ClaudeService.new.generate_report(type.to_sym, data)
25
+ dr = parse_date_range
26
+
27
+ report = AiReport.create!(
28
+ report_type: type,
29
+ content: content,
30
+ raw_data: data,
31
+ period_start: dr.first.to_date,
32
+ period_end: dr.last.to_date,
33
+ scheduled: false
34
+ )
35
+
36
+ render json: { id: report.id, content: content }, status: :created
37
+
38
+ rescue ActionController::ParameterMissing => e
39
+ render json: { error: e.message }, status: :unprocessable_entity
40
+ rescue YourAiInsight::ApiError => e
41
+ Rails.logger.error("[YourAiInsight::ReportsController] #{e.message}")
42
+ render json: { error: "AI service temporarily unavailable." }, status: :service_unavailable
43
+ rescue StandardError => e
44
+ Rails.logger.error("[YourAiInsight::ReportsController] #{e.class}: #{e.message}")
45
+ render json: { error: "Report generation failed." }, status: :internal_server_error
46
+ end
47
+
48
+ # GET /your_ai_insight/reports/:id
49
+ def show
50
+ report = AiReport.find(params[:id])
51
+ render json: {
52
+ id: report.id,
53
+ report_type: report.report_type,
54
+ content: report.content,
55
+ period_start: report.period_start,
56
+ period_end: report.period_end,
57
+ scheduled: report.scheduled,
58
+ created_at: report.created_at
59
+ }
60
+ rescue ActiveRecord::RecordNotFound
61
+ render json: { error: "Report not found" }, status: :not_found
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,98 @@
1
+ module YourAiInsight
2
+ #
3
+ # Scheduled report job for Delayed::Job.
4
+ #
5
+ # USAGE — enqueue directly:
6
+ #
7
+ # YourAiInsight::ScheduledReportJob.new(
8
+ # report_type: "summary_dashboard",
9
+ # recipient_emails: ["ops@allproifm.com"],
10
+ # days_back: 7
11
+ # ).enqueue
12
+ #
13
+ # USAGE — schedule with delayed_cron_job gem:
14
+ #
15
+ # YourAiInsight::ScheduledReportJob.new(
16
+ # report_type: "compliance_audit",
17
+ # recipient_emails: User.where(admin: true).pluck(:email)
18
+ # ).enqueue(cron: "0 8 1 * *") # 1st of month at 8am
19
+ #
20
+ # USAGE — enqueue from a rake task / whenever:
21
+ #
22
+ # rake "your_ai_insight:compliance_audit[ops@allproifm.com,30]"
23
+ #
24
+ class ScheduledReportJob
25
+ VALID_TYPES = %w[summary_dashboard compliance_audit].freeze
26
+
27
+ attr_reader :report_type, :location_ids, :customer_ids, :recipient_emails, :days_back
28
+
29
+ def initialize(report_type:, location_ids: nil, customer_ids: nil, recipient_emails: [], days_back: 30)
30
+ raise ArgumentError, "Invalid report_type: #{report_type}" unless VALID_TYPES.include?(report_type.to_s)
31
+ @report_type = report_type.to_s
32
+ @location_ids = location_ids
33
+ @customer_ids = customer_ids
34
+ @recipient_emails = Array(recipient_emails).compact.map(&:strip).reject(&:blank?)
35
+ @days_back = days_back.to_i
36
+ end
37
+
38
+ # Called by Delayed::Job worker
39
+ def perform
40
+ Rails.logger.info("[YourAiInsight::ScheduledReportJob] Starting #{report_type} | days_back=#{days_back} | recipients=#{recipient_emails.count}")
41
+
42
+ date_range = days_back.days.ago.beginning_of_day..Time.current
43
+
44
+ svc = FacilityDataService.new(
45
+ location_ids: location_ids,
46
+ customer_ids: customer_ids,
47
+ date_range: date_range
48
+ )
49
+
50
+ data = case report_type
51
+ when "summary_dashboard" then svc.summary_dashboard_data
52
+ when "compliance_audit" then svc.compliance_audit_data
53
+ end
54
+
55
+ content = ClaudeService.new.generate_report(report_type.to_sym, data)
56
+
57
+ report = AiReport.create!(
58
+ report_type: report_type,
59
+ content: content,
60
+ raw_data: data,
61
+ period_start: date_range.first.to_date,
62
+ period_end: date_range.last.to_date,
63
+ scheduled: true
64
+ )
65
+
66
+ if recipient_emails.present?
67
+ AiReportMailer.scheduled_report(
68
+ report: report,
69
+ recipient_emails: recipient_emails,
70
+ days_back: days_back
71
+ ).deliver_now
72
+ Rails.logger.info("[YourAiInsight::ScheduledReportJob] Report ##{report.id} emailed to #{recipient_emails.join(', ')}")
73
+ else
74
+ Rails.logger.info("[YourAiInsight::ScheduledReportJob] Report ##{report.id} saved (no email recipients)")
75
+ end
76
+
77
+ report
78
+
79
+ rescue YourAiInsight::ApiError => e
80
+ Rails.logger.error("[YourAiInsight::ScheduledReportJob] Claude API error: #{e.message}")
81
+ raise # Let Delayed::Job retry
82
+ rescue StandardError => e
83
+ Rails.logger.error("[YourAiInsight::ScheduledReportJob] #{e.class}: #{e.message}")
84
+ e.backtrace&.first(5)&.each { |l| Rails.logger.error(" #{l}") }
85
+ raise
86
+ end
87
+
88
+ # Enqueue with Delayed::Job (pass options like run_at:, cron:, priority:)
89
+ def enqueue(options = {})
90
+ Delayed::Job.enqueue(self, { queue: "your_ai_insight_reports", priority: 5 }.merge(options))
91
+ end
92
+
93
+ # Shown in Delayed::Job admin UI
94
+ def display_name
95
+ "YourAiInsight::#{report_type.camelize}ReportJob"
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,80 @@
1
+ module YourAiInsight
2
+ class AiReportMailer < ActionMailer::Base
3
+ default from: -> { "#{YourAiInsight.config.company_name} <noreply@allproifm.com>" }
4
+
5
+ def scheduled_report(report:, recipient_emails:, days_back:)
6
+ @report = report
7
+ @days_back = days_back
8
+ @company = YourAiInsight.config.company_name
9
+ @logo_url = YourAiInsight.config.logo_url
10
+ @content = report.content
11
+
12
+ mail(
13
+ to: recipient_emails,
14
+ subject: "[#{@company}] #{report.report_type_label} — #{report.period_label}"
15
+ ) do |format|
16
+ format.html { render inline: email_html_template }
17
+ format.text { render plain: @content }
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def email_html_template
24
+ <<~HTML
25
+ <!DOCTYPE html>
26
+ <html>
27
+ <head>
28
+ <meta charset="utf-8">
29
+ <style>
30
+ body { font-family: -apple-system, Arial, sans-serif; color: #1a1a1a; margin: 0; padding: 0; background: #f5f5f5; }
31
+ .wrapper { max-width: 700px; margin: 32px auto; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,.08); }
32
+ .header { background: #1a56db; color: #fff; padding: 24px 32px; }
33
+ .header h1 { margin: 0; font-size: 20px; font-weight: 600; }
34
+ .header p { margin: 4px 0 0; opacity: .8; font-size: 13px; }
35
+ .body { padding: 32px; }
36
+ h2 { color: #1a56db; font-size: 16px; margin: 24px 0 8px; border-bottom: 1px solid #e8e8e8; padding-bottom: 6px; }
37
+ h3 { color: #374151; font-size: 14px; margin: 16px 0 6px; }
38
+ p, li { font-size: 14px; line-height: 1.65; color: #374151; }
39
+ ul { padding-left: 20px; }
40
+ strong { color: #111; }
41
+ .footer { background: #f9f9f9; padding: 16px 32px; font-size: 12px; color: #9ca3af; border-top: 1px solid #e5e7eb; }
42
+ .badge-critical { background: #fef2f2; color: #b91c1c; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 600; }
43
+ .badge-warning { background: #fffbeb; color: #92400e; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 600; }
44
+ </style>
45
+ </head>
46
+ <body>
47
+ <div class="wrapper">
48
+ <div class="header">
49
+ #{@logo_url ? "<img src='#{@logo_url}' height='32' style='margin-bottom:12px;'><br>" : ''}
50
+ <h1>#{@report.report_type_label}</h1>
51
+ <p>#{@report.period_label} &nbsp;·&nbsp; Generated #{Time.current.strftime('%b %d, %Y at %I:%M %p')}</p>
52
+ </div>
53
+ <div class="body">
54
+ #{markdown_to_html(@content)}
55
+ </div>
56
+ <div class="footer">
57
+ This report was automatically generated by YourAiInsight for #{@company}.<br>
58
+ Covering the past #{@days_back} days of facility data.
59
+ </div>
60
+ </div>
61
+ </body>
62
+ </html>
63
+ HTML
64
+ end
65
+
66
+ def markdown_to_html(text)
67
+ return "<p>#{text}</p>" if text.blank?
68
+ text
69
+ .gsub(/^## (.+)$/) { "<h2>#{$1.gsub(/⚠️|⚡/, '')}</h2>" }
70
+ .gsub(/^### (.+)$/) { "<h3>#{$1}</h3>" }
71
+ .gsub(/⚠️ CRITICAL/) { '<span class="badge-critical">⚠️ CRITICAL</span>' }
72
+ .gsub(/⚡ WARNING/) { '<span class="badge-warning">⚡ WARNING</span>' }
73
+ .gsub(/\*\*(.+?)\*\*/) { "<strong>#{$1}</strong>" }
74
+ .gsub(/^- (.+)$/) { "<li>#{$1}</li>" }
75
+ .gsub(%r{((?:<li>.*</li>\n?)+)}) { "<ul>#{$1}</ul>" }
76
+ .gsub(/\n\n+/, "</p><p>")
77
+ .then { |t| "<p>#{t}</p>" }
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,24 @@
1
+ module YourAiInsight
2
+ class AiReport < ActiveRecord::Base
3
+ self.table_name = "your_ai_insight_reports"
4
+
5
+ validates :report_type, presence: true,
6
+ inclusion: { in: %w[summary_dashboard compliance_audit] }
7
+ validates :content, presence: true
8
+
9
+ scope :dashboards, -> { where(report_type: "summary_dashboard") }
10
+ scope :compliance, -> { where(report_type: "compliance_audit") }
11
+ scope :scheduled, -> { where(scheduled: true) }
12
+ scope :on_demand, -> { where(scheduled: false) }
13
+ scope :recent, -> { order(created_at: :desc) }
14
+
15
+ def report_type_label
16
+ report_type == "summary_dashboard" ? "Summary Dashboard" : "Compliance & Audit Report"
17
+ end
18
+
19
+ def period_label
20
+ return "" unless period_start && period_end
21
+ "#{period_start.strftime('%b %d')} – #{period_end.strftime('%b %d, %Y')}"
22
+ end
23
+ end
24
+ end