Class: Crm::PublicationsController

Inherits:
CrmController show all
Includes:
Controllers::RequireTurboFrame, Crm::Publications::PdfActions
Defined in:
app/controllers/crm/publications_controller.rb

Overview

Controller: publications.

Constant Summary collapse

TAB_ACTIONS =

Tab fragments never render standalone — non-frame hits redirect to show ?tab=.

%i[tab_overview tab_attributes tab_description tab_ai_embedding tab_revisions tab_store_items tab_catalogs tab_specific_items].freeze

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 Controllers::RequireTurboFrame

require_in_pane_tab_frames, require_turbo_frame

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

#analyze_imagesObject

Analyze the PDF: Vision AI image descriptions AND a docling text
re-extraction (refreshing uploads.extracted_markdown → items.search_text →
embedding via UploadMarkdownExtractionWorker), so this one button reruns
the whole AI pipeline for the publication.



233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
# File 'app/controllers/crm/publications_controller.rb', line 233

def analyze_images
  load_publication
  authorize!(:update, @publication)

  unless @publication.literature&.attachment_stored?
    flash[:error] = 'No PDF attached to this publication'
    redirect_to_return_path_or_default publication_path(@publication, tab: 'ai_embedding')
    return
  end

  # Worker also redirects here after completion; use return_path when provided
  fallback_path = publication_path(@publication, tab: 'ai_embedding')
  redirect_path = @return_path || fallback_path

  jid = PublicationVisionWorker.perform_async(@publication.id, { force: true, redirect_to: redirect_path })
  extraction_jid = UploadMarkdownExtractionWorker.perform_async(@publication.literature_id, { 'force' => true })
  ErrorReporting.info('Analyze PDF queued', { publication_id: @publication.id, vision_jid: jid, extraction_jid: })

  redirect_to_job_or_fallback(jid, redirect_path)
end

#createObject

Creates a publication via Publication::Saver.



87
88
89
90
91
92
93
94
95
96
97
# File 'app/controllers/crm/publications_controller.rb', line 87

def create
  authorize!(:create, Item)
  res = Publication::Saver.call(publication_params: params[:publication], user: current_user)
  if res.saved?
    redirect_to publication_path(res.publication), notice: 'Publication was created'
  else
    @publication = res.publication
    set_image_ransack
    render :new, status: :unprocessable_content
  end
end

#destroyObject

Destroys the publication.



127
128
129
130
131
132
133
# File 'app/controllers/crm/publications_controller.rb', line 127

def destroy
  load_publication
  authorize!(:destroy, @publication)
  @publication.destroy
  flash[:info] = "#{@publication.name} destroyed"
  redirect_to publications_path
end

#editObject

Renders the form to edit an existing publication.



80
81
82
83
84
# File 'app/controllers/crm/publications_controller.rb', line 80

def edit
  load_publication
  set_image_ransack
  authorize!(:update, @publication)
end

#edit_stockObject

Renders the form to adjust the publication's US/Canada stock counts.



136
137
138
139
140
# File 'app/controllers/crm/publications_controller.rb', line 136

def edit_stock
  load_publication
  authorize!(:update, @publication)
  load_stock
end

#generate_cover_imageObject

Generates/refreshes the publication cover via the AI variation generator.
Ensures a base cover exists (extracts the PDF's first page when missing),
then hands off to the image generator pre-set to US Letter ratio with that
cover as a reference image. Accepting the variation sets it as the new cover
(see GeneratedImageImporter's Item callback) — nothing is applied until the
user reviews and imports.



166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
# File 'app/controllers/crm/publications_controller.rb', line 166

def generate_cover_image
  load_publication
  authorize!(:update, @publication)
  cover = @publication.primary_image || @publication.create_primary_image_from_pdf
  if cover
    redirect_to new_generated_image_path(
      source_image_id:    cover.id,
      aspect_ratio:       'doc:17:22',
      source_record_type: 'Item',
      source_record_id:   @publication.id,
      prompt:             publication_cover_prompt(@publication)
    )
  else
    flash[:error] = 'No cover image, and none could be generated from the PDF.'
    redirect_to publication_path(@publication)
  end
end

#indexObject

Lists publications, ransack-filtered and searched (keyword/AI/hybrid mode).



14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
# File 'app/controllers/crm/publications_controller.rb', line 14

def index
  authorize!(:read, Item)

  # Unified search: search_mode determines how search_query is processed
  # - keyword: Traditional Ransack keyword_search (default)
  # - ai: Semantic search using text embeddings from PDF content
  # - hybrid: Both AI semantic + keyword search combined
  @search_mode = params[:search_mode].presence || 'keyword'
  @search_query = params[:search_query].to_s.strip.presence

  # Route search query to appropriate Ransack scope based on mode
  params[:q] ||= {}
  case @search_mode
  when 'ai'
    params[:q][:ai_search_publications] = @search_query if @search_query.present?
  when 'hybrid'
    params[:q][:hybrid_search_publications] = @search_query if @search_query.present?
  when 'keyword'
    params[:q][:keywords_search] = @search_query if @search_query.present?
  end

  # Set default for discontinued filter
  params[:q][:is_discontinued_true] = false if params[:q][:is_discontinued_true].blank?

  @q = Item.publications
           .joins(:literature)
           .includes(:literature, :primary_image, :product_category)
           .ransack(params[:q])

  # AI/hybrid modes preserve similarity order from ai_search_publications scope
  # Keyword mode uses default created_at desc
  @q.sorts = 'created_at DESC' if @q.sorts.blank? && @search_mode == 'keyword'

  @pagy, @publications = pagy(@q.result, limit: 50)

  # Check if AI search had issues and warn the user
  if @search_mode.in?(%w[ai hybrid]) && Item.ai_search_warning?
    flash.now[:warning] = Item.last_ai_search_warning
    Item.clear_ai_search_warning!
  end

  # Precompute most recent revisions to avoid N+1 queries
  @most_recent_revisions = Publication::MostRecentRevisionLookup.new(@publications).lookup_hash

  # Precompute cover image URLs to avoid expensive URL generation in view
  @cover_image_urls = @publications.to_h do |pub|
    [pub.id, pub.primary_image&.image_url(format: 'jpeg', width: 200)]
  end
end

#newObject

Renders the form for a new publication.



71
72
73
74
75
76
77
# File 'app/controllers/crm/publications_controller.rb', line 71

def new
  defaults = { product_category_id: ProductCategory.publications_root_id, cycle_count_grouping: 'not_set' }
  defaults.merge!(params[:publication]) if params[:publication].present?
  @publication = Item.new(defaults)
  set_image_ransack
  authorize!(:create, @publication)
end

#purge_cacheObject

Manual counterpart to Publication::CachePurgeHandler: drops every edge-cached
page that surfaces this publication, its own /publications/.pdf, and the
ImageKit copy of the cover. Needed when the automatic purge off
Events::PublicationUpdated didn't reach a page (or an edit landed through a
path that skips callbacks) — otherwise the stale copy sits there for its TTL.



189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
# File 'app/controllers/crm/publications_controller.rb', line 189

def purge_cache
  load_publication
  authorize!(:update, @publication)

  begin
    urls = @publication.purge_publication_cache
    flash[:info] = if urls.is_a?(Array)
                     "Purging #{urls.size} cached URL(s) and the cover image for #{@publication.sku}."
                   else
                     "Edge cache is disabled in #{Rails.env} — nothing to purge."
                   end
  rescue StandardError => e
    # The page purge is already enqueued when this raises; only the cover
    # failed. Say so rather than 500 — the operator needs to know the cover is
    # still stale, which a generic error page would hide.
    flash[:error] = "Pages queued for purging, but the cover image was not purged: #{e.message}"
  end
  redirect_to_return_path_or_default publication_path(@publication)
end

#refresh_embeddingObject

Refresh embedding for the publication



210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
# File 'app/controllers/crm/publications_controller.rb', line 210

def refresh_embedding
  load_publication
  authorize!(:update, @publication)

  begin
    if @publication.respond_to?(:needs_chunking?) && @publication.needs_chunking?
      @publication.generate_chunked_embeddings!(force: true)
      flash[:success] = "Generated #{@publication.content_embeddings.unified_primary.count} embedding chunks for publication"
    else
      @publication.generate_embedding!(:primary, force: true)
      flash[:success] = 'Embedding refreshed successfully'
    end
  rescue StandardError => e
    flash[:error] = "Failed to generate embedding: #{e.message}"
  end

  redirect_to_return_path_or_default publication_path(@publication, tab: 'ai_embedding')
end

#reviseObject

Clones the publication into a new revision for editing.



113
114
115
116
117
118
119
120
121
122
123
124
# File 'app/controllers/crm/publications_controller.rb', line 113

def revise
  authorize!(:create, Item)
  @original_publication = Item.publications.find(params[:id])
  res = Publication::Reviser.new.process(@original_publication)
  if res.success?
    flash[:info] = 'Publication was revised, please make the necessary updates to the revision and save.'
    redirect_to edit_publication_path(res.new_publication)
  else
    flash[:error] = "Publication cannot be cloned due to the following errors. #{res.errors.to_sentence}"
    redirect_to publication_path(@original_publication)
  end
end

#showObject

Shows a single publication.



65
66
67
68
# File 'app/controllers/crm/publications_controller.rb', line 65

def show
  load_publication
  authorize!(:read, @publication)
end

#tab_ai_embeddingObject

In-pane tab: AI embedding status.



278
279
280
281
282
# File 'app/controllers/crm/publications_controller.rb', line 278

def tab_ai_embedding
  load_publication
  authorize!(:read, @publication)
  render partial: 'crm/publications/tab_ai_embedding', locals: { publication: @publication }
end

#tab_attributesObject

In-pane tab: attributes.



264
265
266
267
268
# File 'app/controllers/crm/publications_controller.rb', line 264

def tab_attributes
  load_publication
  authorize!(:read, @publication)
  render partial: 'crm/publications/tab_attributes', locals: { publication: @publication }
end

#tab_catalogsObject

In-pane tab: linked catalogs.



299
300
301
302
303
# File 'app/controllers/crm/publications_controller.rb', line 299

def tab_catalogs
  load_publication
  authorize!(:read, @publication)
  render partial: 'crm/publications/tab_catalogs', locals: { publication: @publication }
end

#tab_descriptionObject

In-pane tab: description.



271
272
273
274
275
# File 'app/controllers/crm/publications_controller.rb', line 271

def tab_description
  load_publication
  authorize!(:read, @publication)
  render partial: 'crm/publications/tab_description', locals: { publication: @publication }
end

#tab_overviewObject

Tab actions for turbo frame navigation



255
256
257
258
259
260
261
# File 'app/controllers/crm/publications_controller.rb', line 255

def tab_overview
  load_publication
  authorize!(:read, @publication)
  render partial: 'crm/publications/tab_overview',
         locals: { publication: @publication,
                   traffic: Publication::CloudflareTrafficSummary.for(@publication) }
end

#tab_revisionsObject

In-pane tab: revision history.



285
286
287
288
289
# File 'app/controllers/crm/publications_controller.rb', line 285

def tab_revisions
  load_publication
  authorize!(:read, @publication)
  render partial: 'crm/publications/tab_revisions', locals: { publication: @publication }
end

#tab_specific_itemsObject

In-pane tab: items this publication is specifically attached to.



306
307
308
309
310
# File 'app/controllers/crm/publications_controller.rb', line 306

def tab_specific_items
  load_publication
  authorize!(:read, @publication)
  render partial: 'crm/publications/tab_specific_items', locals: { publication: @publication }
end

#tab_store_itemsObject

In-pane tab: linked store items.



292
293
294
295
296
# File 'app/controllers/crm/publications_controller.rb', line 292

def tab_store_items
  load_publication
  authorize!(:read, @publication)
  render partial: 'crm/publications/tab_store_items', locals: { publication: @publication }
end

#updateObject

Updates a publication via Publication::Saver.



100
101
102
103
104
105
106
107
108
109
110
# File 'app/controllers/crm/publications_controller.rb', line 100

def update
  load_publication
  authorize!(:update, @publication)
  res = Publication::Saver.call(publication: @publication, publication_params: params[:publication], user: current_user)
  if res.saved?
    redirect_to publication_path(res.publication), notice: 'Publication was updated'
  else
    set_image_ransack
    render :edit, status: :unprocessable_content
  end
end

#update_stockObject

Applies a manual inventory adjustment to bring US/Canada stock counts to
the submitted values.



144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
# File 'app/controllers/crm/publications_controller.rb', line 144

def update_stock
  load_publication
  authorize!(:update, @publication)
  load_stock
  if params[:usa_count].present? && @usa_stock && (params[:usa_count].to_i != @usa_stock)
    adjustment = -(@usa_stock - params[:usa_count].to_i)
    ItemLedgerEntry.inventory_adjustment(Store.find(Store::WARMLYYOURS_US_ID), { 0 => { store_item_id: @usa_store_item.id, qty: adjustment } }, Date.current, 'Adjust publication stock')
  end
  if params[:canada_count].present? && @canada_stock && (params[:canada_count].to_i != @canada_stock)
    adjustment = -(@canada_stock - params[:canada_count].to_i)
    ItemLedgerEntry.inventory_adjustment(Store.find(Store::WARMLYYOURS_CA_ID), { 0 => { store_item_id: @canada_store_item.id, qty: adjustment } }, Date.current, 'Adjust publication stock')
  end
  flash[:info] = 'Stock adjusted.'
  redirect_to publication_path(@publication)
end