feat: use tracking pixel for article view count (#11559)

This commit is contained in:
Shivam Mishra 2025-05-29 17:01:38 +05:30 committed by GitHub
parent 873cfa08d8
commit b3a76289cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 80 additions and 3 deletions

View File

@ -1,7 +1,7 @@
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show, :index] before_action :ensure_custom_domain_request, only: [:show, :index]
before_action :portal before_action :portal
before_action :set_category, except: [:index, :show] before_action :set_category, except: [:index, :show, :tracking_pixel]
before_action :set_article, only: [:show] before_action :set_article, only: [:show]
layout 'portal' layout 'portal'
@ -15,6 +15,21 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def show; end def show; end
def tracking_pixel
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
return head :not_found unless @article
@article.increment_view_count if @article.published?
# Serve the 1x1 tracking pixel with 24-hour private cache
# Private cache bypasses CDN but allows browser caching to prevent duplicate views from same user
expires_in 24.hours, public: false
response.headers['Content-Type'] = 'image/png'
pixel_path = Rails.public_path.join('assets/images/tracking-pixel.png')
send_file pixel_path, type: 'image/png', disposition: 'inline'
end
private private
def limit_results def limit_results
@ -39,7 +54,6 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def set_article def set_article
@article = @portal.articles.find_by(slug: permitted_params[:article_slug]) @article = @portal.articles.find_by(slug: permitted_params[:article_slug])
@article.increment_view_count if @article.published?
@parsed_content = render_article_content(@article.content) @parsed_content = render_article_content(@article.content)
end end

View File

@ -73,6 +73,30 @@ By default, it renders:
<% end %> <% end %>
</main> </main>
</div> </div>
<% if @article.present? %>
<script>
(function() {
let viewTracked = false;
const trackView = function() {
if (!viewTracked) {
viewTracked = true;
const img = new Image();
img.src = '<%= request.base_url %>/hc/<%= @portal.slug %>/articles/<%= @article.slug %>.png';
}
};
const addTrackingListeners = function() {
const events = ['mouseenter', 'touchstart', 'focus'];
events.forEach(event => {
document.body.addEventListener(event, function() {
setTimeout(trackView, 5000);
}, { once: true });
});
};
addTrackingListeners();
})();
</script>
<% end %>
</body> </body>
<style> <style>
html.dark { html.dark {

View File

@ -449,6 +449,7 @@ Rails.application.routes.draw do
get 'hc/:slug/:locale/categories', to: 'public/api/v1/portals/categories#index' get 'hc/:slug/:locale/categories', to: 'public/api/v1/portals/categories#index'
get 'hc/:slug/:locale/categories/:category_slug', to: 'public/api/v1/portals/categories#show' get 'hc/:slug/:locale/categories/:category_slug', to: 'public/api/v1/portals/categories#show'
get 'hc/:slug/:locale/categories/:category_slug/articles', to: 'public/api/v1/portals/articles#index' get 'hc/:slug/:locale/categories/:category_slug/articles', to: 'public/api/v1/portals/articles#index'
get 'hc/:slug/articles/:article_slug.png', to: 'public/api/v1/portals/articles#tracking_pixel'
get 'hc/:slug/articles/:article_slug', to: 'public/api/v1/portals/articles#show' get 'hc/:slug/articles/:article_slug', to: 'public/api/v1/portals/articles#show'
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 B

View File

@ -82,7 +82,7 @@ RSpec.describe 'Public Articles API', type: :request do
get "/hc/#{portal.slug}/articles/#{article.slug}" get "/hc/#{portal.slug}/articles/#{article.slug}"
expect(response).to have_http_status(:success) expect(response).to have_http_status(:success)
expect(response.body).to include(ChatwootMarkdownRenderer.new(article.content).render_article) expect(response.body).to include(ChatwootMarkdownRenderer.new(article.content).render_article)
expect(article.reload.views).to eq 1 expect(article.reload.views).to eq 0 # View count should not increment on show
end end
it 'does not increment the view count if the article is not published' do it 'does not increment the view count if the article is not published' do
@ -98,4 +98,42 @@ RSpec.describe 'Public Articles API', type: :request do
expect(response).to have_http_status(:success) expect(response).to have_http_status(:success)
end end
end end
describe 'GET /public/api/v1/portals/:slug/articles/:slug.png (tracking pixel)' do
it 'serves a PNG image and increments view count for published article' do
get "/hc/#{portal.slug}/articles/#{article.slug}.png"
expect(response).to have_http_status(:success)
expect(response.headers['Content-Type']).to eq('image/png')
expect(response.headers['Cache-Control']).to include('max-age=86400')
expect(response.headers['Cache-Control']).to include('private')
expect(article.reload.views).to eq 1
end
it 'serves a PNG image but does not increment view count for draft article' do
draft_article = create(:article, category: category, status: :draft, portal: portal, account_id: account.id, author_id: agent.id, views: 0)
get "/hc/#{portal.slug}/articles/#{draft_article.slug}.png"
expect(response).to have_http_status(:success)
expect(response.headers['Content-Type']).to eq('image/png')
expect(response.headers['Cache-Control']).to include('max-age=86400')
expect(response.headers['Cache-Control']).to include('private')
expect(draft_article.reload.views).to eq 0
end
it 'returns 404 if article does not exist' do
get "/hc/#{portal.slug}/articles/non-existent-article.png"
expect(response).to have_http_status(:not_found)
end
it 'sets proper cache headers for performance' do
get "/hc/#{portal.slug}/articles/#{article.slug}.png"
expect(response.headers['Cache-Control']).to include('max-age=86400')
expect(response.headers['Cache-Control']).to include('private')
expect(response.headers['Content-Type']).to eq('image/png')
end
end
end end