Class: DailyFocusController

Inherits:
CrmController show all
Includes:
DailyFocusAudiencesHelper
Defined in:
app/controllers/daily_focus_controller.rb

Overview

Controller: daily focus.

Constant Summary collapse

DAILY_FOCUS_TOOL_SERVICES =

Daily focus tool services.

DailyFocus::TOOL_SERVICE_KEYS
DAILY_FOCUS_TOOL_HANDLES =

Daily focus tool handles.

%w[sales-management app-db support-cases content-search].freeze
WORK_DAY_INDICATOR_UNSET =

Work day indicator unset.

Object.new.freeze

Constants included from DailyFocusAudiencesHelper

DailyFocusAudiencesHelper::AUDIENCES, DailyFocusAudiencesHelper::AUDIENCE_LABELS

Constants included from Controllers::ReferenceFindable

Controllers::ReferenceFindable::ID_EMBEDDED_PATTERNS

Constants included from Controllers::AnalyticsEvents

Controllers::AnalyticsEvents::MAX_QUEUED_EVENTS, Controllers::AnalyticsEvents::SESSION_KEY

Constants included from Controllers::ErrorRendering

Controllers::ErrorRendering::NON_CONTENT_PATH_PREFIXES

Instance Method Summary collapse

Methods included from DailyFocusAudiencesHelper

#can_manage_audience?, #daily_focus_audience_label, #manageable_daily_focus_audiences, #sales_daily_focus_manager?, #technical_support_manager?

Methods inherited from CrmController

#access_denied, #context_id, #context_object, #crm_home_path, #current_ability, #default_url_options, #download_temp, #get_tempfile_path_for_download, #init_status_job_collector, #initialize_crm_lazy_chunks, #persist_enqueued_status_jobs, #record_not_found, #redirect_to_job_or_fallback, #render_edit_action, #set_context, #set_download_path, #stash_file_for_temp_download, #sync_admin_presence_cookie, #touch_employee_last_seen

Methods inherited from ApplicationController

#account_impersonated?, #add_to_flash, #after_sign_in_path_for, #bypass_forgery_protection?, #chat_enabled?, #cloudflare_cleared?, #default_catalog, #default_url_options, #enable_turbo_frames, #find_publication, #fix_invalid_accept_header, #init_js_utils, #is_globals_call?, #layout_by_resource, #locale_store, #redirect_to, #require_employee_for_crm, #set_base_host, #set_real_ip, #set_report_errors_for, #should_render_layout?, #skip_layout_for_turbo_frame?, #stamp_impersonation_context, #tab_frame_breakout_request?, #warmlyyours_canada_ip?, #warmlyyours_ip?, #y

Methods included from Controllers::ReturnPathHandling

#check_for_return_path, #redirect_to_return_path_or_default

Methods included from Controllers::AnalyticsEvents

#consume_queued_analytics_events, #registration_lead_type, #track_event

Methods included from Controllers::DeviceDetection

#device_detector, #is_ie?

Methods included from Controllers::SubdomainDetection

#is_crm_request?, #is_www_request?, #json_request?

Methods included from Controllers::TurboSafeRedirect

#redirect_to

Methods included from Controllers::TrackingDetection

#bot_request?, #gdpr_country?, #gdpr_country_data, #prevent_bots, #set_tracking_cookie, #track_visitor?

Methods included from Controllers::AcceleratedFileSending

#send_file_accelerated, #send_upload_accelerated

Methods included from Controllers::ErrorRendering

#excp_string, #mail_to_for_error_reporting, #render_400, #render_404, #render_406, #render_410, #render_500, #render_invalid_authenticity_token, #render_ip_spoof_error, #render_unpermitted_parameters, #safe_referer_or_fallback

Methods included from Controllers::TurnstileVerification

#load_turnstile_script_tag, #turnstile_lazy_widget, #turnstile_script_tag, #turnstile_widget, #validate_turnstile!

Methods included from Controllers::CloudflareCaching

edge_cached, #edge_cached_action?, #reset_cloudflare_cache, #set_cloudflare_cache, #skip_edge_cache!, #skip_session

Methods included from Controllers::Webpackable

#preload_webpack_fonts, #webpack_css_include, #webpack_css_url, #webpack_js_include, #wpd_is_running?

Methods included from Controllers::Localizable

#cloudflare_country_locale, #determine_request_locale, #geocoder_locale, #guest_user_locale_check, #locale_optional_www_auth_path?, #param_locale, #set_locale, #set_request_locale, #skip_localization?, #warmlyyours_ip_locale

Methods included from Controllers::Authenticable

#access_denied, #authenticate_account, #authenticate_account!, #authenticate_account_from_login_token!, #check_is_a_manager, #check_is_a_sales_manager, #check_is_an_admin, #check_is_an_employee, #check_party, #clear_mismatched_guest_user, #create_guest_user, #credentials?, #current_or_guest_user, #current_or_guest_user_id_read_only, #current_user, #devise_mapping, #fully_logged_in?, #generate_bot_id, #guest_user, #identifiable?, #init_current_user, #initialize_guest, #load_context_user, #logging_in, #resource, #resource_name, #restrict_access_for_non_employees, #scrubbed_request_path, #user_object, #warn_on_session_guest_id_leak

Methods included from UrlsHelper

#catalog_breadcrumb_links, #catalog_link, #catalog_link_for_product_line, #catalog_link_for_sku, #cms_link, #delocalized_path, #path_to_sales_product_sku, #path_to_sales_product_sku_for_product_line, #path_to_sales_product_sku_for_product_line_slug, #product_line_from_catalog_link, #protocol_neutral_url, #sanitize_external_url, #valid_external_url?

Instance Method Details

#approveObject

Approve a daily focus briefing and mark it as approved for its target employee.
Updates the conversation's user and title (using the conversation's daily_focus_date), removes the
daily_focus_target_employee_id and daily_focus_status metadata keys, enqueues an approval email to the
employee, and responds by replacing the review row and stats via Turbo Stream or by redirecting to the review page.
If the conversation has no daily_focus_target_employee_id, responds with HTTP 422 Unprocessable Entity.

Reads params[:id] (an AssistantConversation id, Integer or String).



72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# File 'app/controllers/daily_focus_controller.rb', line 72

def approve
  conversation = AssistantConversation.find(params.expect(:id))
  target_id = conversation.daily_focus_target_employee_id
  return head(:unprocessable_content) unless target_id

  rep = Employee.find(target_id)
  audience = conversation.daily_focus_audience.presence || 'sales'
  return access_denied(nil) unless can_manage_audience?(audience)

  brief_date = review_date_param_for(conversation)
  conversation.update!(
    user_id: rep.id,
    title: "Daily Focus — #{brief_date.strftime('%A, %B %-d, %Y')}"
  )
  AssistantConversation.where(id: conversation.id).update_all("metadata = metadata - 'daily_focus_target_employee_id' - 'daily_focus_status'")
  conversation.reload

  InternalReportsMailer.daily_focus_approved(employee: rep, conversation: conversation).deliver_later

  respond_to do |format|
    format.turbo_stream do
      rd = review_date_param_for(conversation)
      covered_ids = Array(conversation.daily_focus_covered_employee_ids).map(&:to_i)
      covered_by_id = Employee.where(id: covered_ids).index_by(&:id)
      covered_streams = covered_ids.filter_map do |cid|
        cov = covered_by_id[cid]
        next unless cov

        turbo_stream.replace(
          "daily-focus-row-#{cov.id}",
          partial: 'daily_focus/review_row',
          locals: review_row_locals(cov, :covered, conversation, for_date: rd, covering_rep: rep)
        )
      end
      render turbo_stream: [
        turbo_stream.replace(
          "daily-focus-row-#{rep.id}",
          partial: 'daily_focus/review_row',
          locals: review_row_locals(rep, :approved, conversation, for_date: rd)
        ),
        *covered_streams,
        turbo_stream.update("daily-focus-stats", partial: 'daily_focus/stats_cards', locals: stats_card_locals(for_date: rd, audience: audience))
      ]
    end
    format.html { redirect_to review_daily_focus_path(date: review_date_param_for(conversation).iso8601, audience: (audience if audience == 'support')), notice: "Daily focus approved for #{rep.full_name}" }
  end
end

#edit_coaching_noteObject

Renders the coaching-note editor offcanvas for one rep into the layout's
#modal turbo-frame. GET, turbo-frame only (mirrors edit_watch_list).



282
283
284
285
286
287
288
289
290
# File 'app/controllers/daily_focus_controller.rb', line 282

def edit_coaching_note
  return redirect_to(review_daily_focus_path(date: params[:date].presence)) unless turbo_frame_request?

  @employee = Employee.find(params.expect(:employee_id))
  return access_denied(nil) unless can_manage_audience?(DailyFocus.audience_for(@employee))

  @note = @employee.daily_focus_coaching_note
  render partial: 'coaching_note_offcanvas', layout: false
end

#generateObject

Creates a new "Daily Focus" briefing for the specified employee, marks it as processing, shares it with sales leadership, enqueues background analysis, and updates the review UI.

The action:

  • creates an Assistant conversation titled for the employee and today's date and stores daily-focus metadata on it,
  • shares the conversation with sales leadership,
  • enqueues DailyFocusAnalysisRepWorker to generate the briefing content,
  • responds with a Turbo Stream that replaces the employee's review row and the stats cards, or redirects to the review page with a notice for HTML requests.


197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
# File 'app/controllers/daily_focus_controller.rb', line 197

def generate
  employee = Employee.find(params.expect(:employee_id))
  audience = DailyFocus.audience_for(employee)
  return access_denied(nil) unless can_manage_audience?(audience)

  cleanup_stale_daily_focus(employee, audience: audience)

  title = "Daily Focus — #{employee.full_name}#{Date.current.strftime('%A, %B %-d, %Y')}"
  conversation = Assistant::SunnyAgent.create!(user: current_user, title: title)
  conversation.update!(metadata: conversation..merge(
    'daily_focus_date' => Date.current.iso8601,
    'daily_focus_target_employee_id' => employee.id,
    'daily_focus_covered_employee_ids' => [],
    'daily_focus_status' => 'processing',
    'daily_focus_audience' => audience,
    'conversation_type' => 'daily_briefing',
    'tool_services' => DailyFocus::TOOL_SERVICE_KEYS
  ))
  DailyFocus::ConversationSharing.share_with_leadership!(
    conversation, shared_by: current_user, audience: audience
  )

  DailyFocusAnalysisRepWorker.perform_async(employee.id, conversation.id, [], 'user')

  respond_to do |format|
    format.turbo_stream do
      render turbo_stream: [
        turbo_stream.replace(
          "daily-focus-row-#{employee.id}",
          partial: 'daily_focus/review_row',
          locals: review_row_locals(employee, :processing, conversation, for_date: Date.current)
        ),
        turbo_stream.update("daily-focus-stats", partial: 'daily_focus/stats_cards', locals: stats_card_locals(for_date: Date.current, audience: audience))
      ]
    end
    format.html { redirect_to review_daily_focus_path(date: Date.current.iso8601, audience: (audience if audience == 'support')), notice: "Daily focus generation queued for #{employee.full_name}" }
  end
end

#generate_with_instructionsObject

Creates a new "Daily Focus" briefing conversation for the specified employee, seeds it with tool-service
defaults and a prefill instruction prompt, shares it with leadership, and redirects the user to the
assistant view for that conversation.

The action:

  • Loads the target employee and removes any stale/failed processing briefings for that employee for today.
  • Creates an Assistant::SunnyAgent conversation and sets metadata including daily_focus_date,
    daily_focus_target_employee_id, daily_focus_status ('processing'), daily_focus_audience,
    conversation_type ('daily_briefing'), and tool_services (the configured daily-focus services).
  • Shares the conversation with audience leadership (sales or tech support managers).
  • Stores a long instructional prefill in conversation.draft_prefill and default tool handles in
    conversation.default_tool_handles so they survive any redirect and a refresh.
  • Redirects to the assistant view for the newly created conversation.


250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
# File 'app/controllers/daily_focus_controller.rb', line 250

def generate_with_instructions
  employee = Employee.find(params.expect(:employee_id))
  audience = DailyFocus.audience_for(employee)
  return access_denied(nil) unless can_manage_audience?(audience)

  cleanup_stale_daily_focus(employee, audience: audience)

  today = Date.current.strftime('%A, %B %-d, %Y')

  title = "Daily Focus — #{employee.full_name}#{today}"
  conversation = Assistant::SunnyAgent.create!(user: current_user, title: title)
  conversation.update!(metadata: conversation..merge(
    'daily_focus_date' => Date.current.iso8601,
    'daily_focus_target_employee_id' => employee.id,
    'daily_focus_covered_employee_ids' => [],
    'daily_focus_status' => 'processing',
    'daily_focus_audience' => audience,
    'conversation_type' => 'daily_briefing',
    'tool_services' => DailyFocus::TOOL_SERVICE_KEYS,
    'draft_prefill' => DailyFocus::Prompt.instructions_prefill(primary_employee: employee, covered_employees: []),
    'default_tool_handles' => DAILY_FOCUS_TOOL_HANDLES
  ))
  DailyFocus::ConversationSharing.share_with_leadership!(
    conversation, shared_by: current_user, audience: audience
  )

  redirect_to assistant_path(conversation)
end

#rejectObject

Deletes a daily focus briefing (pending, processing, or already-approved), removes its processing lock, destroys the associated conversation, and resets the review UI so it can be regenerated.

Finds the AssistantConversation by params[:id], releases its processing lock if held, and destroys it. Responds with HTTP 422 Unprocessable Entity unless the conversation is a daily briefing tied to a known rep.

Pending/processing briefings carry daily_focus_target_employee_id; approval strips it (and sets user_id to the rep), so the rep is resolved from either.

Responds to:

  • turbo_stream: replaces the target employee's review row (marking it as :missing) and refreshes the stats cards for the briefing date.
  • html: redirects to the review page for the briefing date with a notice.

The action uses the conversation's stored daily_focus_date to determine the review date and whether generation of missing briefings should be allowed in the replaced row.



132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
# File 'app/controllers/daily_focus_controller.rb', line 132

def reject
  conversation = AssistantConversation.find(params.expect(:id))
  return head(:unprocessable_content) unless conversation.conversation_type == 'daily_briefing'

  # Approval strips BOTH daily_focus_target_employee_id and daily_focus_status and sets
  # user_id to the rep. Pending/processing/failed keep a status and a manager user_id,
  # so only fall back to user_id in the stripped (approved) state — otherwise a bad-data
  # non-approved record would misattribute to the manager instead of returning 422.
  target_id = conversation.daily_focus_target_employee_id ||
              (conversation.daily_focus_status.nil? ? conversation.user_id : nil)
  rep = Employee.find_by(id: target_id)
  return head(:unprocessable_content) unless rep

  # Persisted audience only (legacy nil = sales) — re-deriving from the rep's
  # CURRENT roles would misroute a legacy sales briefing after a role change.
  audience = conversation.daily_focus_audience.presence || 'sales'
  return access_denied(nil) unless can_manage_audience?(audience)

  review_date = review_date_param_for(conversation)
  covered_ids = Array(conversation.daily_focus_covered_employee_ids).map(&:to_i)
  conversation.release_processing_lock! if conversation.processing_by_id.present?
  conversation.destroy!

  respond_to do |format|
    format.turbo_stream do
      ag = (review_date == Date.current)
      covered_by_id = Employee.where(id: covered_ids).index_by(&:id)
      covered_streams = covered_ids.filter_map do |cid|
        cov = covered_by_id[cid]
        next unless cov

        turbo_stream.replace(
          "daily-focus-row-#{cov.id}",
          partial: 'daily_focus/review_row',
          locals: review_row_locals(cov, :missing, nil, allow_generate_missing: ag, for_date: review_date)
        )
      end
      render turbo_stream: [
        turbo_stream.replace(
          "daily-focus-row-#{rep.id}",
          partial: 'daily_focus/review_row',
          locals: review_row_locals(
            rep,
            :missing,
            nil,
            allow_generate_missing: ag,
            for_date: review_date
          )
        ),
        *covered_streams,
        turbo_stream.update("daily-focus-stats", partial: 'daily_focus/stats_cards', locals: stats_card_locals(for_date: review_date, audience: audience))
      ]
    end
    format.html { redirect_to review_daily_focus_path(date: review_date.iso8601, audience: (audience if audience == 'support')), notice: "Daily focus rejected for #{rep.full_name}" }
  end
end

#reviewvoid

This method returns an undefined value.

Renders the daily focus review page, building the review data via build_review_data.



60
61
62
# File 'app/controllers/daily_focus_controller.rb', line 60

def review
  build_review_data
end

#summaryObject

Render the Daily Focus summary for the current date.

Computes daily focus counts for Date.current and renders the Crm::DailyFocusSummaryComponent
with processing, pending, approved, and missing counts.



38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# File 'app/controllers/daily_focus_controller.rb', line 38

def summary
  # One card per population, so a tech manager's card counts tech reps. It
  # used to take the audience-less default and report sales to everyone.
  audience = params[:audience].to_s == 'support' ? 'support' : 'sales'
  return access_denied(nil) unless can_manage_audience?(audience)

  counts = compute_focus_counts(Date.current, audience: audience)
  respond_to do |format|
    format.html do
      render Crm::DailyFocusSummaryComponent.new(
        processing_count: counts[:processing],
        pending_count: counts[:pending],
        approved_count: counts[:approved],
        missing_count: counts[:missing],
        audience: audience
      )
    end
  end
end

#update_coaching_noteObject

Upserts (or clears) a rep's standing coaching note. A blank body — or the
explicit Clear button (clear param) — deletes the note. Responds by
closing the offcanvas and swapping the rep's coaching-note button so its
"note present" state updates without a full page reload.



297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
# File 'app/controllers/daily_focus_controller.rb', line 297

def update_coaching_note
  employee = Employee.find(params.expect(:employee_id))
  return access_denied(nil) unless can_manage_audience?(DailyFocus.audience_for(employee))

  note = employee.daily_focus_coaching_note

  if params[:clear].present? || params[:body].blank?
    note&.destroy
    note = nil
  else
    note ||= employee.build_daily_focus_coaching_note
    note.update!(body: params[:body])
  end

  respond_to do |format|
    format.turbo_stream do
      render turbo_stream: [
        turbo_stream.bs_offcanvas_hide('coaching-note-offcanvas'),
        turbo_stream.replace(
          "coaching-note-btn-#{employee.id}",
          partial: 'daily_focus/coaching_note_button',
          locals: { rep: employee, note: note }
        )
      ]
    end
    format.html { redirect_to review_daily_focus_path(date: params[:date].presence), notice: 'Coaching note saved.' }
  end
end

#widget_indexObject

Render the Daily Focus widget for the target employee.
Ensures the target employee is loaded, sets @conversation to that employee's daily focus conversation, and renders the Crm::DailyFocusWidgetComponent as HTML.



25
26
27
28
29
30
31
# File 'app/controllers/daily_focus_controller.rb', line 25

def widget_index
  load_employee
  @conversation = find_daily_focus_conversation
  respond_to do |format|
    format.html { render Crm::DailyFocusWidgetComponent.new(employee: target_employee, conversation: @conversation) }
  end
end