Rename ean -> gtin. Consolidate the migrations
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled

This commit is contained in:
Dan Milne
2025-09-09 16:20:00 +10:00
parent 2a413c685f
commit 58972ae518
33 changed files with 249 additions and 145 deletions

View File

@@ -69,7 +69,7 @@ class Components::Libraries::ShowView < Components::Base
end
div(class: "flex items-center mt-2 text-sm text-gray-500") do
span(class: "bg-gray-100 px-2 py-1 rounded") { product.product_type.humanize }
span(class: "ml-2") { "EAN: #{product.ean}" }
span(class: "ml-2") { "GTIN: #{product.gtin}" }
if library_item.condition.present?
span(class: "ml-2") { "Condition: #{library_item.condition}" }
end
@@ -83,7 +83,7 @@ class Components::Libraries::ShowView < Components::Base
end
div(class: "flex items-center space-x-2") do
a(href: "/#{product.ean}", class: "text-blue-600 hover:text-blue-800") { "View" }
a(href: "/#{product.gtin}", class: "text-blue-600 hover:text-blue-800") { "View" }
form(method: "post", action: "/library_items/#{library_item.id}", class: "inline") do
input(type: "hidden", name: "_method", value: "delete")
input(type: "hidden", name: "authenticity_token", value: helpers.form_authenticity_token)

View File

@@ -48,10 +48,10 @@ class Components::Products::DisplayView < Components::Base
p(class: "text-xs text-gray-500") { @product.publisher }
end
# EAN badge
# GTIN badge
div(class: "mt-2") do
span(class: "inline-block bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded font-mono") do
@product.ean
@product.gtin
end
end
end

View File

@@ -40,7 +40,7 @@ class Components::Products::IndexView < Components::Base
h2(class: "text-lg font-semibold text-gray-800 mb-4") { "Recent Additions" }
div(class: "space-y-3") do
@recent_products.each do |product|
a(href: "/#{product.ean}", class: "block") do
a(href: "/#{product.gtin}", class: "block") do
div(class: "flex items-center gap-3 p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors") do
div(class: "flex-shrink-0") do
if product.cover_image.attached?

View File

@@ -299,12 +299,12 @@ class Components::Scanners::AdaptiveView < Components::Base
div(class: "flex items-center gap-2") do
div(class: "text-xs text-gray-500") { scan.created_at.strftime("%H:%M") }
div(class: "flex-1 text-sm font-medium text-gray-800 truncate") do
scan.product.title || scan.product.ean
scan.product.title || scan.product.gtin
end
end
else
div(class: "text-sm text-gray-500") do
"EAN: #{scan.ean}"
"GTIN: #{scan.gtin}"
end
end
end

View File

@@ -211,12 +211,12 @@ class Components::Scanners::HorizontalView < Components::Base
div(class: "flex items-center gap-2") do
div(class: "text-xs text-gray-500") { scan.created_at.strftime("%H:%M") }
div(class: "flex-1 text-sm font-medium text-gray-800 truncate") do
scan.product.title || scan.product.ean
scan.product.title || scan.product.gtin
end
end
else
div(class: "text-sm text-gray-500") do
"EAN: #{scan.ean}"
"GTIN: #{scan.gtin}"
end
end
end

View File

@@ -337,12 +337,12 @@ class Components::Scanners::IndexView < Components::Base
div(class: "flex items-center gap-2") do
div(class: "text-xs text-gray-500") { scan.created_at.strftime("%H:%M") }
div(class: "flex-1 text-sm font-medium text-gray-800 truncate") do
scan.product.title || scan.product.ean
scan.product.title || scan.product.gtin
end
end
else
div(class: "text-sm text-gray-500") do
"EAN: #{scan.ean}"
"GTIN: #{scan.gtin}"
end
end
end

View File

@@ -3,6 +3,7 @@ module Components
class ScanItemView < Phlex::HTML
include ActionView::Helpers::DateHelper
include ActionView::RecordIdentifier
include Phlex::Rails::Helpers::URLFor
def initialize(scan:)
@scan = scan
@@ -35,7 +36,7 @@ module Components
# Product details
div(class: "flex-grow") do
h3(class: "font-semibold text-gray-900") do
a(href: "/#{@scan.product.ean}", class: "hover:text-blue-600") do
a(href: "/#{@scan.product.gtin}", class: "hover:text-blue-600") do
@scan.product.safe_title
end
end
@@ -45,7 +46,7 @@ module Components
end
p(class: "text-xs text-gray-500 mt-1") do
"EAN: #{@scan.product.ean}"
"GTIN: #{@scan.product.gtin}"
end
if @scan.user.present?

View File

@@ -34,13 +34,7 @@ class Components::Shared::NavigationView < Components::Base
"Libraries"
end
a(href: scans_path, class: "text-primary-600 hover:text-primary-700 px-3 py-2 rounded-md text-sm font-medium transition-colors") do
"My Scans"
end
a(href: users_path, class: "text-primary-600 hover:text-primary-700 px-3 py-2 rounded-md text-sm font-medium transition-colors") do
"Users"
end
a(href: "/api/v1", class: "text-primary-600 hover:text-primary-700 px-3 py-2 rounded-md text-sm font-medium transition-colors") do
"API"
"Scans"
end
end
end
@@ -55,7 +49,7 @@ class Components::Shared::NavigationView < Components::Base
end
a(
href: signout_path,
method: :delete,
data: { turbo_method: :delete },
class: "text-primary-600 hover:text-primary-700 px-3 py-2 rounded-md text-sm font-medium transition-colors"
) do
"Sign out"

View File

@@ -54,6 +54,56 @@ class Components::User::ProfileEditView < Components::Base
p(class: "mt-2 text-sm text-red-600") { @user.errors[:email_address].first }
end
end
end
end
# API Configuration Section
div(id: "api-settings", class: "px-6 py-6 border-t border-gray-200") do
h3(class: "text-lg font-medium text-gray-900 mb-6") { "API Configuration" }
form_with(url: "/profile/api_token", method: :patch, local: true, class: "space-y-6") do |api_form|
div do
api_form.label :thebookdb_api_token, "Personal TheBookDB API Token", class: "block text-sm font-medium text-gray-700"
p(class: "text-xs text-gray-500 mt-1 mb-2") do
plain "Enter your personal API token for TheBookDB.info service. "
if ENV["TBDB_API_TOKEN"].present?
plain "If left blank, the application will use the system default token."
else
plain "This is required as no system default is configured."
end
end
api_form.password_field :thebookdb_api_token,
value: @user.thebookdb_api_token,
placeholder: "Enter your personal API token...",
class: "mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500"
if @user.has_thebookdb_api_token?
p(class: "text-xs text-gray-400 mt-1") { "Leave blank to remove your personal token and use application default" }
end
end
div(class: "flex justify-end space-x-3") do
if @user.has_thebookdb_api_token?
a(
href: "/profile/api_token",
data: { method: :delete, confirm: "Are you sure you want to remove your personal API token?" },
class: "px-4 py-2 text-sm font-medium text-red-600 bg-white border border-red-300 rounded-md hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
) do
"Remove Token"
end
end
api_form.submit(@user.has_thebookdb_api_token? ? "Update Token" : "Set Token",
class: "px-4 py-2 text-sm font-medium text-white bg-primary-600 border border-transparent rounded-md hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
)
end
end
end
div(class: "px-6 py-6") do
form_with(model: @user, url: profile_path, method: :patch, local: true, class: "space-y-6") do |f|
div(class: "hidden") do
# Hidden fields to avoid affecting profile form
end
# Actions
div(class: "mt-8 flex justify-end space-x-4") do

View File

@@ -70,8 +70,8 @@ class Components::User::ProfileView < Components::Base
# Hide invalid barcodes setting
div(class: "flex items-center justify-between") do
div do
dt(class: "text-sm font-medium text-gray-700") { "Hide Invalid ISBNs/EAN13 barcodes" }
dd(class: "text-xs text-gray-500 mt-1") { "Hide products with invalid EAN-13 check digits or non-ISBN barcodes from listings" }
dt(class: "text-sm font-medium text-gray-700") { "Hide Invalid ISBNs/GTIN barcodes" }
dd(class: "text-xs text-gray-500 mt-1") { "Hide products with invalid GTIN check digits or non-ISBN barcodes from listings" }
end
div(class: "ml-4") do
label(class: "relative inline-flex items-center cursor-pointer") do
@@ -99,6 +99,37 @@ class Components::User::ProfileView < Components::Base
end
end
# API Configuration Section
div(class: "px-6 py-6 border-t border-gray-200") do
h3(class: "text-lg font-medium text-gray-900 mb-6") { "API Configuration" }
div(class: "space-y-4") do
div do
dt(class: "text-sm font-medium text-gray-700 mb-2") { "TheBookDB API Token" }
dd(class: "text-xs text-gray-500 mb-3") { "Personal API token for accessing TheBookDB.info service. Falls back to application default if not set." }
if @user.has_thebookdb_api_token?
div(class: "text-sm text-gray-900 font-mono bg-gray-50 px-3 py-2 rounded border") do
token = @user.thebookdb_api_token
masked_token = token[0..7] + "..." + token[-4..-1]
masked_token
end
div(class: "text-xs text-green-600 mt-1") { "Using personal token" }
else
if ENV["TBDB_API_TOKEN"].present?
div(class: "text-sm text-gray-600 bg-gray-50 px-3 py-2 rounded border") do
"Using application default"
end
else
div(class: "text-sm text-red-600 bg-red-50 px-3 py-2 rounded border border-red-200") do
"No token configured"
end
end
end
end
end
end
# Actions
div(class: "px-6 py-4 bg-gray-50 border-t border-gray-200") do
div(class: "flex justify-end space-x-4") do

View File

@@ -19,7 +19,7 @@ class Api::V1::ProductsController < Api::V1::BaseController
def product_json(product)
{
id: product.id,
ean: product.ean,
gtin: product.gtin,
title: product.title,
author: product.author,
publisher: product.publisher,

View File

@@ -17,7 +17,7 @@ class ProductsController < ApplicationController
end
def show
# For EAN-13 URLs, render as Turbo Stream for scanner integration
# For GTIN URLs, render as Turbo Stream for scanner integration
if request.headers["Accept"]&.include?("text/vnd.turbo-stream.html")
render turbo_stream: turbo_stream.replace(
"product-display",
@@ -87,10 +87,10 @@ class ProductsController < ApplicationController
private
def find_or_create_product
ean = params[:ean13] || params[:id]
gtin = params[:gtin13] || params[:id]
begin
@product = Product.find_or_create_by_ean(ean, {
@product = Product.find_or_create_by_gtin(gtin, {
product_type: "book" # Default assumption
})
rescue ArgumentError => e

View File

@@ -11,11 +11,11 @@ class ScansController < ApplicationController
end
def create
# Accept either product_id or ean parameter
# Accept either product_id or gtin parameter
if params[:product_id]
product = Product.find(params[:product_id])
elsif params[:ean]
product = Product.find_or_create_by_ean(params[:ean])
elsif params[:gtin]
product = Product.find_or_create_by_gtin(params[:gtin])
else
head :bad_request
return
@@ -41,7 +41,7 @@ class ScansController < ApplicationController
rescue ActiveRecord::RecordNotFound
head :not_found
rescue ArgumentError => e
Rails.logger.error "Invalid EAN: #{e.message}"
Rails.logger.error "Invalid GTIN: #{e.message}"
head :bad_request
rescue => e
Rails.logger.error "Failed to track scan: #{e.message}"

View File

@@ -60,6 +60,31 @@ class UserController < ApplicationController
end
end
def update_api_token
@user = Current.user
token = params[:thebookdb_api_token].present? ? params[:thebookdb_api_token].strip : nil
if token.present?
# Basic validation
if token.length < 10
redirect_to edit_profile_path, alert: "API token appears to be too short. Please check your token."
return
end
@user.thebookdb_api_token = token
redirect_to profile_path, notice: "Personal API token updated successfully."
else
@user.thebookdb_api_token = nil
redirect_to profile_path, notice: "Personal API token removed. Using application default."
end
end
def delete_api_token
@user = Current.user
@user.thebookdb_api_token = nil
redirect_to profile_path, notice: "Personal API token removed. Using application default."
end
private
def user_params

View File

@@ -254,7 +254,7 @@ export default class extends Controller {
onScanSuccess(decodedText, decodedResult) {
console.log(`Barcode detected (${this.currentOrientationValue} mode):`, decodedText)
// Validate EAN-13 format (13 digits)
// Validate GTIN-13 format (13 digits)
if (decodedText && /^\d{13}$/.test(decodedText)) {
// Update result display
const resultText = `✅ Scanned: ${decodedText}`
@@ -277,7 +277,7 @@ export default class extends Controller {
this.fetchProductData(decodedText)
}
} else {
console.log("Invalid EAN-13 format:", decodedText)
console.log("Invalid GTIN-13 format:", decodedText)
this.showError(`Invalid barcode format: ${decodedText}`)
}
}
@@ -287,10 +287,10 @@ export default class extends Controller {
// We don't need to log every failure as it's normal during scanning
}
async fetchProductData(ean) {
async fetchProductData(gtin) {
try {
// First, get the product data
const response = await fetch(`/${ean}`, {
const response = await fetch(`/${gtin}`, {
method: 'GET',
headers: {
'Accept': 'text/vnd.turbo-stream.html',
@@ -304,7 +304,7 @@ export default class extends Controller {
Turbo.renderStreamMessage(turboStreamData)
// Track the scan after successful product fetch
await this.trackScan(ean)
await this.trackScan(gtin)
} else {
this.showError('Failed to load product data')
}
@@ -314,7 +314,7 @@ export default class extends Controller {
}
}
async trackScan(ean) {
async trackScan(gtin) {
try {
await fetch('/scans', {
method: 'POST',
@@ -322,7 +322,7 @@ export default class extends Controller {
'Content-Type': 'application/json',
'X-CSRF-Token': this.getCSRFToken()
},
body: JSON.stringify({ ean: ean })
body: JSON.stringify({ gtin: gtin })
})
} catch (error) {
console.error('Error tracking scan:', error)
@@ -344,9 +344,9 @@ export default class extends Controller {
}
// Scanner page specific methods
handleScannerPageScan(ean) {
handleScannerPageScan(gtin) {
// Just track the scan - same as existing scanner
this.trackScan(ean)
this.trackScan(gtin)
// Continue scanning after a brief pause
setTimeout(() => {

View File

@@ -312,7 +312,7 @@ export default class extends Controller {
onScanSuccess(decodedText, decodedResult) {
console.log(`Barcode detected (${this.currentOrientationValue} mode):`, decodedText)
// Validate EAN-13 format (13 digits)
// Validate GTIN-13 format (13 digits)
if (decodedText && /^\d{13}$/.test(decodedText)) {
// Update result display
const resultText = `✅ Scanned: ${decodedText}`
@@ -335,7 +335,7 @@ export default class extends Controller {
this.fetchProductData(decodedText)
}
} else {
console.log("Invalid EAN-13 format:", decodedText)
console.log("Invalid GTIN-13 format:", decodedText)
this.showError(`Invalid barcode format: ${decodedText}`)
}
}
@@ -345,10 +345,10 @@ export default class extends Controller {
// We don't need to log every failure as it's normal during scanning
}
async fetchProductData(ean) {
async fetchProductData(gtin) {
try {
// First, get the product data
const response = await fetch(`/${ean}`, {
const response = await fetch(`/${gtin}`, {
method: 'GET',
headers: {
'Accept': 'text/vnd.turbo-stream.html',
@@ -362,7 +362,7 @@ export default class extends Controller {
Turbo.renderStreamMessage(turboStreamData)
// Track the scan after successful product fetch
await this.trackScan(ean)
await this.trackScan(gtin)
} else {
this.showError('Failed to load product data')
}
@@ -372,7 +372,7 @@ export default class extends Controller {
}
}
async trackScan(ean) {
async trackScan(gtin) {
try {
await fetch('/scans', {
method: 'POST',
@@ -380,7 +380,7 @@ export default class extends Controller {
'Content-Type': 'application/json',
'X-CSRF-Token': this.getCSRFToken()
},
body: JSON.stringify({ ean: ean })
body: JSON.stringify({ gtin: gtin })
})
} catch (error) {
console.error('Error tracking scan:', error)
@@ -483,9 +483,9 @@ export default class extends Controller {
}
// Scanner page specific methods
handleScannerPageScan(ean) {
handleScannerPageScan(gtin) {
// Just track the scan - same as existing scanner
this.trackScan(ean)
this.trackScan(gtin)
// Continue scanning after a brief pause
setTimeout(() => {

View File

@@ -149,7 +149,7 @@ export default class extends Controller {
onScanSuccess(decodedText, decodedResult) {
console.log("Barcode detected (horizontal):", decodedText)
// Validate EAN-13 format (13 digits)
// Validate GTIN-13 format (13 digits)
if (decodedText && /^\d{13}$/.test(decodedText)) {
// Update result display
const resultText = `✅ Scanned: ${decodedText}`
@@ -172,7 +172,7 @@ export default class extends Controller {
this.fetchProductData(decodedText)
}
} else {
console.log("Invalid EAN-13 format:", decodedText)
console.log("Invalid GTIN-13 format:", decodedText)
this.showError(`Invalid barcode format: ${decodedText}`)
}
}
@@ -182,10 +182,10 @@ export default class extends Controller {
// We don't need to log every failure as it's normal during scanning
}
async fetchProductData(ean) {
async fetchProductData(gtin) {
try {
// First, get the product data
const response = await fetch(`/${ean}`, {
const response = await fetch(`/${gtin}`, {
method: 'GET',
headers: {
'Accept': 'text/vnd.turbo-stream.html',
@@ -199,7 +199,7 @@ export default class extends Controller {
Turbo.renderStreamMessage(turboStreamData)
// Track the scan after successful product fetch
await this.trackScan(ean)
await this.trackScan(gtin)
} else {
this.showError('Failed to load product data')
}
@@ -209,7 +209,7 @@ export default class extends Controller {
}
}
async trackScan(ean) {
async trackScan(gtin) {
try {
await fetch('/scans', {
method: 'POST',
@@ -217,7 +217,7 @@ export default class extends Controller {
'Content-Type': 'application/json',
'X-CSRF-Token': this.getCSRFToken()
},
body: JSON.stringify({ ean: ean })
body: JSON.stringify({ gtin: gtin })
})
} catch (error) {
console.error('Error tracking scan:', error)
@@ -239,9 +239,9 @@ export default class extends Controller {
}
// Scanner page specific methods
handleScannerPageScan(ean) {
handleScannerPageScan(gtin) {
// Just track the scan - same as existing scanner
this.trackScan(ean)
this.trackScan(gtin)
// Continue scanning after a brief pause
setTimeout(() => {

View File

@@ -5,6 +5,6 @@ class ProductDataFetchJob < ApplicationJob
discard_on ActiveJob::DeserializationError
def perform(product)
ProductEnrichmentService.new(tbdb_client: TBDB.client).call(product)
ProductEnrichmentService.new.call(product)
end
end

View File

@@ -5,7 +5,7 @@ class Product < ApplicationRecord
has_one_attached :cover_image
validates :ean, presence: true, uniqueness: true, format: { with: /\A\d{13}\z/, message: "must be 13 digits" }
validates :gtin, presence: true, uniqueness: true, format: { with: /\A\d{13}\z/, message: "must be 13 digits" }
# validates :title, presence: true, on: :update
validates :product_type, presence: true
@@ -23,14 +23,14 @@ class Product < ApplicationRecord
scope :valid_barcodes, -> { where(valid_barcode: true) }
scope :invalid_barcodes, -> { where(valid_barcode: false) }
# Find or create by EAN with basic product info
def self.find_or_create_by_ean(ean, basic_info = {})
# Validate EAN format
unless ean&.match?(/\A\d{13}\z/)
raise ArgumentError, "Invalid EAN-13 format: #{ean}"
# Find or create by GTIN with basic product info
def self.find_or_create_by_gtin(gtin, basic_info = {})
# Validate GTIN format
unless gtin&.match?(/\A\d{13}\z/)
raise ArgumentError, "Invalid GTIN format: #{gtin}"
end
find_or_create_by(ean: ean) do |product|
find_or_create_by(gtin: gtin) do |product|
product.title = basic_info[:title]
product.author = basic_info[:author]
product.publisher = basic_info[:publisher]
@@ -38,7 +38,7 @@ class Product < ApplicationRecord
end
end
def self.findd(...) = find_or_create_by_ean(...)
def self.findd(...) = find_or_create_by_gtin(...)
# Check if product data has been successfully enriched from TBDB
def enriched?
@@ -65,7 +65,7 @@ class Product < ApplicationRecord
def enrich!
update(tbdb_data: {})
ProductEnrichmentService.new(tbdb_client: TBDB.client).call(self)
ProductEnrichmentService.new.call(self)
end
# Library helpers
@@ -77,9 +77,9 @@ class Product < ApplicationRecord
library_items.where(library: library).count
end
# Safe title that falls back to EAN if title is not set
# Safe title that falls back to GTIN if title is not set
def safe_title
title.presence || "Product #{ean}"
title.presence || "Product #{gtin}"
end
private
@@ -97,6 +97,6 @@ class Product < ApplicationRecord
# Validate and set the valid_barcode flag
def validate_barcode
self.valid_barcode = BarcodeValidationService.valid_barcode?(ean) if ean.present?
self.valid_barcode = BarcodeValidationService.valid_barcode?(gtin) if gtin.present?
end
end

View File

@@ -18,4 +18,21 @@ class User < ApplicationRecord
def get_setting(key, default = nil)
user_settings.fetch(key.to_s, default)
end
# API token management
def thebookdb_api_token
get_setting("thebookdb_api_token")
end
def thebookdb_api_token=(token)
update_setting("thebookdb_api_token", token.present? ? token.strip : nil)
end
def has_thebookdb_api_token?
thebookdb_api_token.present?
end
def effective_thebookdb_api_token
has_thebookdb_api_token? ? thebookdb_api_token : ENV["TBDB_API_TOKEN"]
end
end

View File

@@ -1,20 +1,20 @@
class BarcodeValidationService
# Validates EAN-13 barcodes and determines if they represent valid ISBNs
def self.valid_ean13?(ean)
new(ean).valid_ean13?
def self.valid_ean13?(gtin)
new(gtin).valid_ean13?
end
def self.valid_isbn?(ean)
new(ean).valid_isbn?
def self.valid_isbn?(gtin)
new(gtin).valid_isbn?
end
def self.valid_barcode?(ean)
new(ean).valid_barcode?
def self.valid_barcode?(gtin)
new(gtin).valid_barcode?
end
def initialize(ean)
@ean = ean.to_s.strip
def initialize(gtin)
@gtin = gtin.to_s.strip
end
# Check if the EAN-13 has a valid format and check digit
@@ -37,18 +37,18 @@ class BarcodeValidationService
private
def basic_format_valid?
@ean.match?(/\A\d{13}\z/)
@gtin.match?(/\A\d{13}\z/)
end
def isbn_prefix?
@ean.start_with?("978", "979")
@gtin.start_with?("978", "979")
end
# EAN-13 check digit validation using modulo 10 weighted sum
def valid_check_digit?
return false unless basic_format_valid?
digits = @ean.chars.map(&:to_i)
digits = @gtin.chars.map(&:to_i)
check_digit = digits.pop
# Calculate weighted sum (multiply odd positions by 1, even positions by 3)

View File

@@ -1,34 +1,34 @@
class ProductEnrichmentService
def initialize(tbdb_client: TBDB.client)
@tbdb_client = tbdb_client
def initialize(tbdb_service: nil, user: nil)
@tbdb_service = tbdb_service || ShelfLife::TbdbService.new(user: user)
end
def call(product)
Rails.logger.info "Enriching product #{product.ean} from TBDB"
Rails.logger.info "Enriching product #{product.gtin} from TBDB"
# Skip if already enriched
if product.enriched?
Rails.logger.debug "Product #{product.ean} already has TBDB data, skipping"
Rails.logger.debug "Product #{product.gtin} already has TBDB data, skipping"
return product
end
begin
tbdb_data = fetch_tbdb_data(product.ean)
tbdb_data = fetch_tbdb_data(product.gtin)
if tbdb_data.present?
update_product_attributes(product, tbdb_data)
attach_cover_image(product, tbdb_data["cover_url"]) if tbdb_data["cover_url"].present?
mark_enrichment_status(product, "success", tbdb_data)
Rails.logger.info "Successfully enriched product #{product.ean} with TBDB data"
Rails.logger.info "Successfully enriched product #{product.gtin} with TBDB data"
else
mark_enrichment_status(product, "not_found", "Product not found in TBDB database")
Rails.logger.info "Product #{product.ean} not found in TBDB database"
Rails.logger.info "Product #{product.gtin} not found in TBDB database"
end
product
rescue => e
Rails.logger.error "Error enriching product #{product.ean}: #{e.message}"
Rails.logger.error "Error enriching product #{product.gtin}: #{e.message}"
mark_enrichment_status(product, "error", e.message)
raise e # Re-raise to trigger job retry mechanism
end
@@ -36,19 +36,19 @@ class ProductEnrichmentService
private
def fetch_tbdb_data(ean)
tbdb_response = @tbdb_client.get_product(ean)
def fetch_tbdb_data(gtin)
tbdb_response = @tbdb_service.get_product(gtin)
return nil unless tbdb_response.present?
# Extract data from response structure
tbdb_data = tbdb_response["data"] if tbdb_response.key?("data")
tbdb_data ||= tbdb_response
# Verify EAN matches (TBDB should return exact match)
if tbdb_data["gtin"] == ean
# Verify GTIN matches (TBDB should return exact match)
if tbdb_data["gtin"] == gtin
tbdb_data
else
Rails.logger.warn "TBDB returned product with different EAN: expected #{ean}, got #{tbdb_data['gtin']}"
Rails.logger.warn "TBDB returned product with different GTIN: expected #{gtin}, got #{tbdb_data['gtin']}"
nil
end
end
@@ -87,26 +87,32 @@ class ProductEnrichmentService
return if product.cover_image.attached? # Don't overwrite existing image
begin
Rails.logger.info "Downloading cover image for product #{product.ean} from #{cover_url}"
Rails.logger.info "Downloading cover image for product #{product.gtin} from #{cover_url}"
# Download the image
response = HTTP.follow.get(cover_url)
return unless response.status.success?
uri = URI.parse(cover_url)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = uri.scheme == 'https'
request = Net::HTTP::Get.new(uri)
response = http.request(request)
return unless response.is_a?(Net::HTTPSuccess)
# Extract filename from URL or generate one
filename = extract_filename_from_url(cover_url) || "cover_#{product.ean}.jpg"
filename = extract_filename_from_url(cover_url) || "cover_#{product.gtin}.jpg"
# Attach the image
product.cover_image.attach(
io: StringIO.new(response.body.to_s),
io: StringIO.new(response.body),
filename: filename,
content_type: response.content_type.mime_type
content_type: response['content-type'] || 'image/jpeg'
)
Rails.logger.info "Successfully attached cover image for product #{product.ean}"
Rails.logger.info "Successfully attached cover image for product #{product.gtin}"
rescue => e
Rails.logger.error "Failed to download cover image for product #{product.ean}: #{e.message}"
Rails.logger.error "Failed to download cover image for product #{product.gtin}: #{e.message}"
# Don't re-raise - cover image failure shouldn't fail the whole enrichment
end
end

View File

@@ -24,6 +24,9 @@
<body>
<%= render Components::Shared::NavigationView.new %>
<%= render Components::Shared::FlashMessagesView.new %>
<main class="container mx-auto mt-20 px-5 flex">
<%= yield %>
</main>

View File

@@ -1,3 +1,6 @@
#!/bin/bash
docker buildx build --platform linux/amd64,linux/arm64 -t reg.getshelflife.app/shelflife:latest -t reg.getshelflife.app/shelflife:$(git rev-parse HEAD) --push .
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t shelflife/getshelflife:latest \
-t shelflife/getshelflife:$(git rev-parse HEAD) \
--push .

View File

@@ -72,4 +72,5 @@ Rails.application.configure do
# Apply autocorrection by RuboCop to files generated by `bin/rails generate`.
# config.generators.apply_rubocop_autocorrect_after_generate!
config.hosts << "7641b6bf7d4c.ngrok-free.app"
end

View File

@@ -50,6 +50,8 @@ Rails.application.routes.draw do
get "/profile/edit", to: "user#edit", as: :edit_profile
patch "/profile", to: "user#update"
patch "/profile/settings", to: "user#update_setting"
patch "/profile/api_token", to: "user#update_api_token"
delete "/profile/api_token", to: "user#delete_api_token"
get "/profile/change_password", to: "user#change_password", as: :change_password
patch "/profile/update_password", to: "user#update_password"
@@ -62,6 +64,6 @@ Rails.application.routes.draw do
end
end
# EAN-13 barcode route - must be last to avoid conflicts
get "/:ean13", to: "products#show", constraints: { ean13: /\d{13}/ }
# GTIN route - must be last to avoid conflicts
get "/:gtin", to: "products#show", constraints: { gtin: /\d{13}/ }
end

View File

@@ -2,7 +2,8 @@ class CreateProducts < ActiveRecord::Migration[8.0]
def change
create_table :products, id: false do |t|
t.string :id, primary_key: true, default: -> { "ULID()" }
t.string :ean, null: false
t.string :gtin, null: false
t.boolean :valid_barcode, default: true
t.string :title
t.string :subtitle
t.string :author
@@ -17,7 +18,7 @@ class CreateProducts < ActiveRecord::Migration[8.0]
t.timestamps
end
add_index :products, :ean, unique: true
add_index :products, :gtin, unique: true
add_index :products, :product_type
end
end

View File

@@ -3,6 +3,9 @@ class CreateUsers < ActiveRecord::Migration[8.0]
create_table :users, id: :string, default: -> { "ULID()" } do |t|
t.string :email_address, null: false
t.string :password_digest, null: false
t.string :name
t.json :user_settings, default: {}
t.timestamps
end

View File

@@ -1,5 +0,0 @@
class AddNameToUsers < ActiveRecord::Migration[8.0]
def change
add_column :users, :name, :string
end
end

View File

@@ -1,5 +0,0 @@
class AddUserSettingsToUsers < ActiveRecord::Migration[8.0]
def change
add_column :users, :user_settings, :json, default: {}
end
end

View File

@@ -1,5 +0,0 @@
class AddValidBarcodeToProducts < ActiveRecord::Migration[8.0]
def change
add_column :products, :valid_barcode, :boolean, default: true
end
end

View File

@@ -1,18 +0,0 @@
class BackfillProductValidBarcodes < ActiveRecord::Migration[8.0]
def up
say "Backfilling valid_barcode for existing products..."
Product.find_each do |product|
valid_barcode = BarcodeValidationService.valid_barcode?(product.ean)
product.update_column(:valid_barcode, valid_barcode)
print "."
end
say "\nBackfill complete!"
end
def down
# Set all products back to valid (the original default)
Product.update_all(valid_barcode: true)
end
end

View File

@@ -14,7 +14,7 @@ puts "Created #{Library.count} libraries: #{Library.pluck(:name).join(', ')}"
# Create some sample products for testing
sample_products = [
{
ean: '9780143058144',
gtin: '9780143058144',
title: 'The Hitchhiker\'s Guide to the Galaxy',
author: 'Douglas Adams',
publisher: 'Pan Books',
@@ -22,7 +22,7 @@ sample_products = [
genre: 'Science Fiction'
},
{
ean: '9780747532743',
gtin: '9780747532743',
title: 'Harry Potter and the Philosopher\'s Stone',
author: 'J.K. Rowling',
publisher: 'Bloomsbury',
@@ -32,7 +32,7 @@ sample_products = [
]
sample_products.each do |product_attrs|
Product.find_or_create_by(ean: product_attrs[:ean]) do |product|
Product.find_or_create_by(gtin: product_attrs[:gtin]) do |product|
product_attrs.each { |key, value| product.send("#{key}=", value) }
end
end