Refactor TBDB connections, drop API Token and go with Dynamic OAuth. Many other improvements
This commit is contained in:
@@ -137,7 +137,11 @@ User-specific tracking of barcode scans:
|
||||
- **Primary Keys**: Standard Rails integer auto-increment primary keys (no ULIDs)
|
||||
- **Book Identification**: Books identified by EAN-13 barcodes (13-digit strings)
|
||||
- **Image Handling**: Products have `cover_image` (Active Storage) and `cover_image_url` fields
|
||||
- **TBDB Integration**: Single shared `TbdbConnection` singleton for OAuth (not per-user)
|
||||
- **TBDB Integration**:
|
||||
- Single shared `TbdbConnection` singleton for OAuth (not per-user)
|
||||
- `Tbdb::Client` - API client for making authenticated requests (lazy validation)
|
||||
- `Tbdb::OauthService` - OAuth lifecycle management (register, exchange, refresh, revoke)
|
||||
- Client initialization is cheap (just DB query), validation happens on first request
|
||||
- Pagination handled by Pagy gem
|
||||
- the MCP gitea is available for the repository dkam/shelf-life for git actions
|
||||
|
||||
|
||||
@@ -10,5 +10,4 @@
|
||||
*
|
||||
*= require_tree .
|
||||
*= require_self
|
||||
*= require choices
|
||||
*/
|
||||
@@ -1,9 +1,10 @@
|
||||
class Components::Libraries::ShowView < Components::Base
|
||||
include ActionView::Helpers::FormTagHelper
|
||||
include Phlex::Rails::Helpers::TurboStreamFrom
|
||||
def initialize(library:, library_items:, pagy: nil)
|
||||
def initialize(library:, products: [], grouped_items: {}, pagy: nil)
|
||||
@library = library
|
||||
@library_items = library_items
|
||||
@products = products
|
||||
@grouped_items = grouped_items
|
||||
@pagy = pagy
|
||||
end
|
||||
|
||||
@@ -18,7 +19,7 @@ class Components::Libraries::ShowView < Components::Base
|
||||
)
|
||||
|
||||
div(class: "pt-20 px-4", id: "blahblahblah") do
|
||||
div(class: "max-w-4xl mx-auto") do
|
||||
div(class: "max-w-7xl mx-auto") do
|
||||
div(class: "mb-8") do
|
||||
div(class: "flex items-center justify-between") do
|
||||
div do
|
||||
@@ -27,27 +28,36 @@ class Components::Libraries::ShowView < Components::Base
|
||||
p(class: "text-gray-600 mt-2") { @library.description }
|
||||
end
|
||||
end
|
||||
|
||||
div(class: "flex gap-2") do
|
||||
a(href: import_library_path(@library), class: "bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition-colors") { "Import" }
|
||||
a(href: export_library_path(@library, format: :csv), class: "bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 transition-colors") { "Export CSV" }
|
||||
a(href: edit_library_path(@library), class: "bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors") { "Edit Library" }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if @library_items.any?
|
||||
if @products.any?
|
||||
# Pagination at top
|
||||
render_pagination if @pagy && @pagy.pages > 1
|
||||
|
||||
div(class: "grid gap-4") do
|
||||
@library_items.each do |library_item|
|
||||
render Components::Libraries::LibraryItemView.new(library_item: library_item)
|
||||
|
||||
# Render grouped products
|
||||
div(class: "mt-4") do
|
||||
@products.each do |product|
|
||||
library_items = @grouped_items[product]
|
||||
render Components::Libraries::ProductGroupView.new(
|
||||
product: product,
|
||||
library_items: library_items
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# Pagination at bottom
|
||||
render_pagination if @pagy && @pagy.pages > 1
|
||||
# Pagination and action buttons at bottom
|
||||
div(class: "mt-8") do
|
||||
div(class: "flex justify-center") do
|
||||
render_pagination if @pagy && @pagy.pages > 1
|
||||
end
|
||||
|
||||
div(class: "flex justify-center gap-2 mt-4") do
|
||||
a(href: import_library_path(@library), class: "bg-green-600 text-white px-3 py-1.5 text-sm rounded-lg hover:bg-green-700 transition-colors") { "Import" }
|
||||
a(href: export_library_path(@library, format: :csv), class: "bg-purple-600 text-white px-3 py-1.5 text-sm rounded-lg hover:bg-purple-700 transition-colors") { "Export CSV" }
|
||||
a(href: edit_library_path(@library), class: "bg-blue-600 text-white px-3 py-1.5 text-sm rounded-lg hover:bg-blue-700 transition-colors") { "Edit Library" }
|
||||
end
|
||||
end
|
||||
else
|
||||
div(class: "bg-white rounded-lg shadow-md p-8 text-center") do
|
||||
div(class: "text-6xl mb-4") { "📚" }
|
||||
@@ -66,50 +76,52 @@ class Components::Libraries::ShowView < Components::Base
|
||||
private
|
||||
|
||||
def render_pagination
|
||||
div(class: "mt-8 flex justify-center") do
|
||||
nav(class: "flex space-x-2") do
|
||||
# Previous button
|
||||
nav(class: "flex items-center gap-1 text-sm") do
|
||||
# Previous link
|
||||
if @pagy.prev
|
||||
a(href: library_path(@library, page: @pagy.prev),
|
||||
class: "px-3 py-2 bg-white border rounded-md hover:bg-gray-50") do
|
||||
"Previous"
|
||||
class: "text-blue-600 hover:text-blue-800 hover:underline px-2") do
|
||||
"← Previous"
|
||||
end
|
||||
else
|
||||
span(class: "px-3 py-2 text-gray-300 bg-gray-100 border rounded-md cursor-not-allowed") do
|
||||
"Previous"
|
||||
span(class: "text-gray-400 px-2") do
|
||||
"← Previous"
|
||||
end
|
||||
end
|
||||
|
||||
span(class: "text-gray-400 mx-1") { "|" }
|
||||
|
||||
# Page numbers
|
||||
start_page = [@pagy.page - 2, 1].max
|
||||
end_page = [@pagy.page + 2, @pagy.pages].min
|
||||
|
||||
(start_page..end_page).each do |page_num|
|
||||
if page_num == @pagy.page
|
||||
span(class: "px-3 py-2 text-white bg-blue-600 border rounded-md mr-1") do
|
||||
span(class: "font-semibold text-gray-900 px-2") do
|
||||
page_num.to_s
|
||||
end
|
||||
else
|
||||
a(href: library_path(@library, page: page_num),
|
||||
class: "px-3 py-2 bg-white border rounded-md hover:bg-gray-50 mr-1") do
|
||||
class: "text-blue-600 hover:text-blue-800 hover:underline px-2") do
|
||||
page_num.to_s
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Next button
|
||||
span(class: "text-gray-400 mx-1") { "|" }
|
||||
|
||||
# Next link
|
||||
if @pagy.next
|
||||
a(href: library_path(@library, page: @pagy.next),
|
||||
class: "px-3 py-2 bg-white border rounded-md hover:bg-gray-50") do
|
||||
"Next"
|
||||
class: "text-blue-600 hover:text-blue-800 hover:underline px-2") do
|
||||
"Next →"
|
||||
end
|
||||
else
|
||||
span(class: "px-3 py-2 text-gray-300 bg-gray-100 border rounded-md cursor-not-allowed") do
|
||||
"Next"
|
||||
span(class: "text-gray-400 px-2") do
|
||||
"Next →"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
@@ -56,8 +56,18 @@ class Components::Products::DisplayView < Components::Base
|
||||
# Library Selection Dropdown
|
||||
render Components::Shared::LibraryDropdownView.new(product: @product)
|
||||
|
||||
# Delete Product Button
|
||||
div(class: "mt-4 pt-4 border-t border-gray-300") do
|
||||
# Refresh and Delete Product Buttons
|
||||
div(class: "mt-4 pt-4 border-t border-gray-300 flex gap-2") do
|
||||
# Refresh Data Button
|
||||
form(method: "post", action: refresh_product_path(@product), class: "inline") do
|
||||
input(type: "hidden", name: "authenticity_token", value: form_authenticity_token)
|
||||
button(
|
||||
type: "submit",
|
||||
class: "bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors text-sm"
|
||||
) { "Refresh Data" }
|
||||
end
|
||||
|
||||
# Delete Product Button
|
||||
form(method: "post", action: product_path(@product), class: "inline") do
|
||||
input(type: "hidden", name: "_method", value: "delete")
|
||||
input(type: "hidden", name: "authenticity_token", value: form_authenticity_token)
|
||||
|
||||
@@ -122,9 +122,6 @@ class Components::User::ProfileView < Components::Base
|
||||
if @connection.last_error.present?
|
||||
div(class: "text-xs text-red-600 mt-1") { @connection.last_error }
|
||||
end
|
||||
if @connection.api_base_url.present?
|
||||
div(class: "text-xs text-red-500 mt-1") { "OAuth tokens from: #{@connection.api_base_url}" }
|
||||
end
|
||||
end
|
||||
a(
|
||||
href: auth_tbdb_path,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
class ApplicationController < ActionController::Base
|
||||
include Authentication
|
||||
include Pagy::Backend
|
||||
|
||||
# Enable Pagy array extra for paginating arrays
|
||||
require 'pagy/extras/array'
|
||||
|
||||
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
|
||||
allow_browser versions: :modern
|
||||
|
||||
|
||||
@@ -14,8 +14,19 @@ class LibrariesController < ApplicationController
|
||||
library_items = library_items.joins(:product).where(products: { valid_barcode: true })
|
||||
end
|
||||
|
||||
@pagy, @library_items = pagy(library_items, overflow: :last_page)
|
||||
render Components::Libraries::ShowView.new(library: @library, library_items: @library_items, pagy: @pagy)
|
||||
# Group library items by product for display
|
||||
@grouped_items = library_items.group_by(&:product)
|
||||
|
||||
# Paginate by unique products, not individual items
|
||||
products_array = @grouped_items.keys
|
||||
@pagy, @products = pagy_array(products_array, overflow: :last_page)
|
||||
|
||||
render Components::Libraries::ShowView.new(
|
||||
library: @library,
|
||||
products: @products,
|
||||
grouped_items: @grouped_items,
|
||||
pagy: @pagy
|
||||
)
|
||||
end
|
||||
|
||||
def edit
|
||||
|
||||
@@ -1,15 +1,37 @@
|
||||
class LibraryItemsController < ApplicationController
|
||||
before_action :set_library_item, only: [:show, :edit, :update, :destroy]
|
||||
|
||||
def show
|
||||
render Components::LibraryItems::ShowView.new(library_item: @library_item)
|
||||
end
|
||||
|
||||
def edit
|
||||
render Components::LibraryItems::EditView.new(library_item: @library_item)
|
||||
end
|
||||
|
||||
def update
|
||||
# Convert tags string to array if present
|
||||
if params[:library_item][:tags].present?
|
||||
params[:library_item][:tags] = params[:library_item][:tags].split(',').map(&:strip).reject(&:blank?)
|
||||
end
|
||||
|
||||
if @library_item.update(library_item_params)
|
||||
redirect_to library_path(@library_item.library), notice: "Item updated successfully."
|
||||
else
|
||||
render Components::LibraryItems::EditView.new(library_item: @library_item), status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def create
|
||||
handle_exist_checkbox
|
||||
end
|
||||
|
||||
def destroy
|
||||
@library_item = LibraryItem.find(params[:id])
|
||||
@product = @library_item.product
|
||||
@library = @library_item.library
|
||||
|
||||
|
||||
@library_item.destroy
|
||||
|
||||
|
||||
respond_to do |format|
|
||||
format.turbo_stream
|
||||
format.html { redirect_back_or_to libraries_path, notice: "Removed from library." }
|
||||
@@ -18,6 +40,33 @@ class LibraryItemsController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def set_library_item
|
||||
@library_item = LibraryItem.find(params[:id])
|
||||
end
|
||||
|
||||
def library_item_params
|
||||
params.require(:library_item).permit(
|
||||
:location,
|
||||
:condition_id,
|
||||
:condition_notes,
|
||||
:notes,
|
||||
:private_notes,
|
||||
:acquisition_date,
|
||||
:acquisition_price,
|
||||
:acquisition_source_id,
|
||||
:ownership_status_id,
|
||||
:item_status_id,
|
||||
:copy_identifier,
|
||||
:replacement_cost,
|
||||
:original_retail_price,
|
||||
:current_market_value,
|
||||
:lent_to,
|
||||
:due_date,
|
||||
:is_favorite,
|
||||
tags: []
|
||||
)
|
||||
end
|
||||
|
||||
def handle_exist_checkbox
|
||||
@product = Product.find(params[:library_item][:product_id])
|
||||
@library = Library.find(params[:library_item][:library_id])
|
||||
|
||||
@@ -2,12 +2,12 @@ class OauthController < ApplicationController
|
||||
before_action :require_authentication
|
||||
|
||||
def tbdb
|
||||
oauth_service = TbdbOauthService.new
|
||||
oauth_service = Tbdb::OauthService.new
|
||||
|
||||
begin
|
||||
authorization_url = oauth_service.authorization_url
|
||||
redirect_to authorization_url, allow_other_host: true
|
||||
rescue TbdbOauthService::OAuthError => e
|
||||
rescue Tbdb::OauthService::OAuthError => e
|
||||
Rails.logger.error "OAuth initiation failed: #{e.message}"
|
||||
redirect_to profile_path, alert: "Failed to connect to TBDB: #{e.message}"
|
||||
end
|
||||
@@ -24,7 +24,7 @@ class OauthController < ApplicationController
|
||||
if error == "invalid_client" && error_hint == "client_not_found"
|
||||
Rails.logger.info "OAuth client not found on TBDB, clearing credentials and re-registering"
|
||||
|
||||
oauth_service = TbdbOauthService.new
|
||||
oauth_service = Tbdb::OauthService.new
|
||||
|
||||
begin
|
||||
# Clear the invalid credentials
|
||||
@@ -50,19 +50,19 @@ class OauthController < ApplicationController
|
||||
return
|
||||
end
|
||||
|
||||
oauth_service = TbdbOauthService.new
|
||||
oauth_service = Tbdb::OauthService.new
|
||||
|
||||
begin
|
||||
oauth_service.exchange_code_for_token(code, state)
|
||||
redirect_to profile_path, notice: "Successfully connected to TBDB!"
|
||||
rescue TbdbOauthService::OAuthError => e
|
||||
rescue Tbdb::OauthService::OAuthError => e
|
||||
Rails.logger.error "OAuth token exchange failed: #{e.message}"
|
||||
redirect_to profile_path, alert: "Failed to complete TBDB connection: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
def tbdb_disconnect
|
||||
oauth_service = TbdbOauthService.new
|
||||
oauth_service = Tbdb::OauthService.new
|
||||
oauth_service.revoke_tokens
|
||||
redirect_to profile_path, notice: "Disconnected from TBDB"
|
||||
end
|
||||
|
||||
@@ -79,6 +79,13 @@ class ProductsController < ApplicationController
|
||||
)
|
||||
end
|
||||
|
||||
def refresh
|
||||
@product = Product.find(params[:id])
|
||||
ProductDataFetchJob.set(queue: :high_priority).perform_later(@product, true)
|
||||
|
||||
redirect_to "/#{@product.gtin}", notice: "Refreshing data for #{@product.safe_title}..."
|
||||
end
|
||||
|
||||
def destroy
|
||||
@product = Product.find(params[:id])
|
||||
product_title = @product.safe_title
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Entry point for the build script in your package.json
|
||||
import "@hotwired/turbo-rails"
|
||||
import "./controllers"
|
||||
import "slim-select/styles"
|
||||
|
||||
@@ -6,9 +6,11 @@ import HelloController from "./hello_controller"
|
||||
import LibraryDropdownController from "./library_dropdown_controller"
|
||||
import LibraryFormController from "./library_form_controller"
|
||||
import SettingsFormController from "./settings_form_controller"
|
||||
import SlimSelectController from "./slim_select_controller"
|
||||
|
||||
application.register("barcode-scanner", BarcodeScannerController)
|
||||
application.register("hello", HelloController)
|
||||
application.register("library-dropdown", LibraryDropdownController)
|
||||
application.register("library-form", LibraryFormController)
|
||||
application.register("settings-form", SettingsFormController)
|
||||
application.register("settings-form", SettingsFormController)
|
||||
application.register("slim-select", SlimSelectController)
|
||||
@@ -64,7 +64,7 @@ export default class extends Controller {
|
||||
|
||||
// Update UI
|
||||
this.buttonTextTarget.textContent = "Stop";
|
||||
this.startButtonTarget.classList.remove("bg-booko", "hover:bg-booko-darker");
|
||||
this.startButtonTarget.classList.remove("bg-indigo-600", "hover:bg-indigo-700");
|
||||
this.startButtonTarget.classList.add("bg-red-600", "hover:bg-red-700");
|
||||
|
||||
// Show scanner
|
||||
@@ -148,7 +148,7 @@ export default class extends Controller {
|
||||
// Reset UI
|
||||
this.buttonTextTarget.textContent = "Scan";
|
||||
this.startButtonTarget.classList.remove("bg-red-600", "hover:bg-red-700");
|
||||
this.startButtonTarget.classList.add("bg-booko", "hover:bg-booko-darker");
|
||||
this.startButtonTarget.classList.add("bg-indigo-600", "hover:bg-indigo-700");
|
||||
|
||||
// Hide scanner
|
||||
this.scannerContainerTarget.style.display = "none";
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
class LibraryItem < ApplicationRecord
|
||||
belongs_to :product, foreign_key: :product_id
|
||||
belongs_to :library, foreign_key: :library_id
|
||||
belongs_to :condition, optional: true
|
||||
belongs_to :item_status, optional: true
|
||||
belongs_to :acquisition_source, optional: true
|
||||
belongs_to :ownership_status, optional: true
|
||||
|
||||
serialize :tags, type: Array, coder: JSON
|
||||
|
||||
validates :product_id, presence: true
|
||||
validates :library_id, presence: true
|
||||
validates :acquisition_price, :replacement_cost, :original_retail_price, :current_market_value,
|
||||
@@ -19,6 +22,7 @@ class LibraryItem < ApplicationRecord
|
||||
scope :owned_items, -> { joins(:library).where(libraries: { virtual: false }) }
|
||||
scope :favorites, -> { where(is_favorite: true) }
|
||||
scope :by_condition, ->(condition) { where(condition: condition) }
|
||||
scope :with_condition, ->(condition_name) { joins(:condition).where(conditions: { name: condition_name }) }
|
||||
scope :overdue, -> { where("due_date < ?", Date.current) }
|
||||
scope :with_status, ->(status_name) { joins(:item_status).where(item_statuses: { name: status_name }) }
|
||||
scope :available, -> { joins(:item_status).where(item_statuses: { name: "Available" }) }
|
||||
@@ -81,9 +85,12 @@ class LibraryItem < ApplicationRecord
|
||||
|
||||
def update_condition(new_condition, notes = nil)
|
||||
return false if virtual_item?
|
||||
|
||||
|
||||
condition_record = new_condition.is_a?(Condition) ? new_condition : Condition.find_by(name: new_condition)
|
||||
return false unless condition_record
|
||||
|
||||
update(
|
||||
condition: new_condition,
|
||||
condition: condition_record,
|
||||
condition_notes: notes,
|
||||
last_condition_check: Date.current
|
||||
)
|
||||
@@ -99,16 +106,7 @@ class LibraryItem < ApplicationRecord
|
||||
end
|
||||
|
||||
def condition_status
|
||||
return "Unknown" if condition.blank?
|
||||
condition.humanize
|
||||
end
|
||||
|
||||
def tag_list
|
||||
tags&.split(",")&.map(&:strip) || []
|
||||
end
|
||||
|
||||
def tag_list=(tag_array)
|
||||
self.tags = Array(tag_array).map(&:strip).reject(&:blank?).join(", ")
|
||||
condition&.name || "Unknown"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
class TbdbConnection < ApplicationRecord
|
||||
# Singleton pattern - only one TBDB connection per ShelfLife instance
|
||||
# This connection is used for all TBDB API product data lookups
|
||||
|
||||
# See app/services/tbdb for tbdb client, oauth service and error definitions.
|
||||
|
||||
VERIFICATION_TTL = 10.minutes
|
||||
|
||||
def self.instance
|
||||
@@ -42,7 +43,12 @@ class TbdbConnection < ApplicationRecord
|
||||
def mark_invalid!(error_message)
|
||||
update!(
|
||||
status: 'invalid',
|
||||
last_error: error_message
|
||||
last_error: error_message,
|
||||
quota_remaining: nil,
|
||||
quota_limit: nil,
|
||||
quota_percentage: nil,
|
||||
quota_reset_at: nil,
|
||||
quota_updated_at: nil
|
||||
)
|
||||
end
|
||||
|
||||
@@ -69,7 +75,53 @@ class TbdbConnection < ApplicationRecord
|
||||
api_base_url: nil,
|
||||
status: 'connected',
|
||||
verified_at: nil,
|
||||
last_error: nil
|
||||
last_error: nil,
|
||||
quota_remaining: nil,
|
||||
quota_limit: nil,
|
||||
quota_percentage: nil,
|
||||
quota_reset_at: nil,
|
||||
quota_updated_at: nil
|
||||
)
|
||||
end
|
||||
|
||||
# Update quota information from API response
|
||||
def update_quota(remaining:, limit:, reset_at: nil)
|
||||
percentage = if limit && limit > 0
|
||||
(remaining.to_f / limit * 100).round(1)
|
||||
else
|
||||
0.0
|
||||
end
|
||||
|
||||
update!(
|
||||
quota_remaining: remaining,
|
||||
quota_limit: limit,
|
||||
quota_percentage: percentage,
|
||||
quota_reset_at: reset_at,
|
||||
quota_updated_at: Time.current
|
||||
)
|
||||
end
|
||||
|
||||
# Clear quota information (when connection becomes invalid)
|
||||
def clear_quota
|
||||
update!(
|
||||
quota_remaining: nil,
|
||||
quota_limit: nil,
|
||||
quota_percentage: nil,
|
||||
quota_reset_at: nil,
|
||||
quota_updated_at: nil
|
||||
)
|
||||
end
|
||||
|
||||
# Get quota status as a hash (for compatibility with cached format)
|
||||
def quota_status
|
||||
return nil unless quota_remaining && quota_limit
|
||||
|
||||
{
|
||||
remaining: quota_remaining,
|
||||
limit: quota_limit,
|
||||
percentage: quota_percentage,
|
||||
reset_at: quota_reset_at,
|
||||
updated_at: quota_updated_at
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
class ProductEnrichmentService
|
||||
def initialize(tbdb_service: nil)
|
||||
@tbdb_service = tbdb_service || ShelfLife::TbdbService.new
|
||||
def initialize(tbdb_client: nil)
|
||||
@tbdb_client = tbdb_client || Tbdb::Client.new
|
||||
end
|
||||
|
||||
def call(product, force = false)
|
||||
@@ -62,7 +62,7 @@ class ProductEnrichmentService
|
||||
end
|
||||
|
||||
def fetch_tbdb_data(gtin)
|
||||
tbdb_response = @tbdb_service.get_product(gtin)
|
||||
tbdb_response = @tbdb_client.get_product(gtin)
|
||||
return nil unless tbdb_response.present?
|
||||
|
||||
# Extract data from response structure
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
require_relative '../tbdb'
|
||||
|
||||
module ShelfLife
|
||||
class TbdbService
|
||||
attr_reader :client
|
||||
|
||||
def initialize
|
||||
@client = get_or_create_client
|
||||
end
|
||||
|
||||
# Delegate common methods to the TBDB client
|
||||
def get_product(product_id)
|
||||
client.get_product(product_id)
|
||||
end
|
||||
|
||||
def search_products(query, options = {})
|
||||
client.search_products(query, options)
|
||||
end
|
||||
|
||||
# Convenience class method
|
||||
def self.client
|
||||
new.client
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def get_or_create_client
|
||||
connection = TbdbConnection.instance
|
||||
|
||||
# Create cache key based on connection status and updated timestamp
|
||||
# This ensures cache is invalidated when connection changes
|
||||
cache_key = "tbdb_client:#{connection.status}:#{connection.updated_at.to_i}"
|
||||
|
||||
Rails.cache.fetch(cache_key, expires_in: 25.minutes) do
|
||||
Tbdb::Client.new
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,395 +1,9 @@
|
||||
require "net/http"
|
||||
require "json"
|
||||
require "uri"
|
||||
# Tbdb module namespace loader
|
||||
# Ensures all Tbdb classes are available for autoloading
|
||||
|
||||
require_relative "tbdb/errors"
|
||||
|
||||
module Tbdb
|
||||
class RateLimitError < StandardError
|
||||
attr_accessor :reset_time, :retry_after
|
||||
end
|
||||
|
||||
class QuotaExhaustedError < StandardError
|
||||
attr_accessor :reset_time, :retry_after
|
||||
end
|
||||
|
||||
class AuthenticationError < StandardError
|
||||
end
|
||||
|
||||
class ConnectionRequiredError < StandardError
|
||||
end
|
||||
|
||||
class Client
|
||||
VERSION = "0.3"
|
||||
|
||||
# Fallback base URI if connection doesn't specify one
|
||||
DEFAULT_BASE_URI = ENV.fetch("TBDB_API_URI", "https://api.thebookdb.info").freeze
|
||||
|
||||
attr_reader :jwt_token, :jwt_expires_at, :base_uri, :last_request_time, :calculated_delay
|
||||
|
||||
def initialize(base_uri: nil)
|
||||
@connection = TbdbConnection.instance
|
||||
@last_request_time = nil
|
||||
@calculated_delay = nil
|
||||
|
||||
# Ensure we have a valid OAuth connection
|
||||
ensure_oauth_connected!
|
||||
|
||||
# Use connection's base URL (set during OAuth), or fallback
|
||||
effective_base_uri = base_uri || @connection.api_base_url || DEFAULT_BASE_URI
|
||||
@base_uri = URI(effective_base_uri)
|
||||
|
||||
# Verify base URI matches connection's base URI
|
||||
verify_base_uri_match!
|
||||
|
||||
# Load JWT from OAuth (access token IS the JWT)
|
||||
load_jwt_from_oauth
|
||||
end
|
||||
|
||||
def user_agent
|
||||
"ShelfLife-Bot/#{VERSION} (#{Rails.application.class.module_parent_name})"
|
||||
end
|
||||
|
||||
# Main API methods
|
||||
def get_product(product_id)
|
||||
make_request("/api/v1/products/#{product_id}")
|
||||
end
|
||||
|
||||
def search_products(query, options = {})
|
||||
params = { q: query }
|
||||
params[:ptype] = options[:product_type] if options[:product_type]
|
||||
params[:per_page] = [options[:per_page] || 20, 100].min
|
||||
params[:page] = [options[:page] || 1, 1].max
|
||||
|
||||
make_request("/search", method: :get, params: params)
|
||||
end
|
||||
|
||||
def create_product(product_data)
|
||||
make_request("/api/v1/products", method: :post, params: product_data)
|
||||
end
|
||||
|
||||
def update_product(product_id, product_data)
|
||||
make_request("/api/v1/products/#{product_id}", method: :patch, params: product_data)
|
||||
end
|
||||
|
||||
def get_me
|
||||
make_request("/api/v1/me")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_oauth_connected!
|
||||
# Check if connection is marked as invalid
|
||||
if @connection.status == 'invalid'
|
||||
error_msg = @connection.last_error || "TBDB connection is invalid. Please reconnect at /profile"
|
||||
Rails.logger.error "TBDB connection invalid: #{error_msg}"
|
||||
raise ConnectionRequiredError, error_msg
|
||||
end
|
||||
|
||||
# Check if we have OAuth tokens
|
||||
unless @connection.access_token.present?
|
||||
error_msg = "No TBDB OAuth connection. Please connect at /profile"
|
||||
Rails.logger.error error_msg
|
||||
raise ConnectionRequiredError, error_msg
|
||||
end
|
||||
|
||||
# Check if token is expired
|
||||
if @connection.token_expired?
|
||||
Rails.logger.debug "OAuth token expired, attempting refresh..."
|
||||
refresh_oauth_token
|
||||
end
|
||||
end
|
||||
|
||||
def verify_base_uri_match!
|
||||
# Warn if using different base URI than what connection was registered with
|
||||
if @connection.api_base_url.present? && @connection.api_base_url != @base_uri.to_s
|
||||
Rails.logger.warn "⚠️ Base URI mismatch: connection=#{@connection.api_base_url}, client=#{@base_uri}"
|
||||
end
|
||||
end
|
||||
|
||||
def load_jwt_from_oauth
|
||||
# OAuth access tokens ARE JWTs - use directly
|
||||
@jwt_token = @connection.access_token
|
||||
@jwt_expires_at = @connection.expires_at
|
||||
|
||||
Rails.logger.debug "Using OAuth JWT (expires at #{@jwt_expires_at})"
|
||||
end
|
||||
|
||||
def refresh_oauth_token
|
||||
oauth_service = TbdbOauthService.new
|
||||
|
||||
if oauth_service.refresh_access_token
|
||||
# Reload connection to get fresh token
|
||||
@connection.reload
|
||||
@jwt_token = @connection.access_token
|
||||
@jwt_expires_at = @connection.expires_at
|
||||
Rails.logger.debug "OAuth token refreshed successfully"
|
||||
else
|
||||
error_msg = "Failed to refresh OAuth token. Please reconnect at /profile"
|
||||
@connection.mark_invalid!(error_msg)
|
||||
Rails.logger.error error_msg
|
||||
raise AuthenticationError, error_msg
|
||||
end
|
||||
end
|
||||
|
||||
def make_request(path, method: :get, params: {}, retry_count: 0)
|
||||
# Check if token needs refresh before making request
|
||||
if @connection.token_expired?
|
||||
refresh_oauth_token
|
||||
end
|
||||
|
||||
throttle_request
|
||||
|
||||
# Ensure path starts with /
|
||||
api_path = path.start_with?("/") ? path : "/#{path}"
|
||||
uri = URI.join(@base_uri.to_s.chomp("/") + "/", api_path.sub(/^\//, ""))
|
||||
|
||||
# Add query parameters for GET requests
|
||||
if method == :get && params.any?
|
||||
uri.query = URI.encode_www_form(params)
|
||||
end
|
||||
|
||||
Rails.logger.debug "TBDB API Request: #{method.upcase} #{uri}"
|
||||
|
||||
# Create request object
|
||||
request = case method
|
||||
when :get then Net::HTTP::Get.new(uri)
|
||||
when :post then Net::HTTP::Post.new(uri)
|
||||
when :patch then Net::HTTP::Patch.new(uri)
|
||||
when :delete then Net::HTTP::Delete.new(uri)
|
||||
else raise ArgumentError, "Unsupported HTTP method: #{method}"
|
||||
end
|
||||
|
||||
# Set headers with OAuth JWT
|
||||
request["Authorization"] = "Bearer #{@jwt_token}"
|
||||
request["Content-Type"] = "application/json"
|
||||
request["Accept"] = "application/json"
|
||||
request["User-Agent"] = user_agent
|
||||
|
||||
# Add body for non-GET requests
|
||||
if method != :get && params.any?
|
||||
request.body = JSON.generate(params)
|
||||
end
|
||||
|
||||
# Make the request
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
http.use_ssl = uri.scheme == "https"
|
||||
|
||||
response = http.request(request)
|
||||
@last_request_time = Time.now
|
||||
|
||||
# Extract rate limit and quota info from headers
|
||||
store_rate_limit_info(response)
|
||||
check_quota_status(response)
|
||||
|
||||
# Handle response
|
||||
handle_response(response, path, method, params, retry_count)
|
||||
end
|
||||
|
||||
def handle_response(response, path, method, params, retry_count)
|
||||
case response
|
||||
when Net::HTTPSuccess
|
||||
return {} if response.body.nil? || response.body.empty?
|
||||
|
||||
begin
|
||||
parsed_body = JSON.parse(response.body)
|
||||
# Extract quota from response body if present (e.g., from /me endpoint)
|
||||
check_quota_from_body(parsed_body)
|
||||
parsed_body
|
||||
rescue JSON::ParserError => e
|
||||
Rails.logger.error "Failed to parse TBDB API response as JSON: #{e.message}"
|
||||
nil
|
||||
end
|
||||
|
||||
when Net::HTTPUnauthorized # 401
|
||||
handle_401_error(response)
|
||||
|
||||
else
|
||||
# Handle other status codes
|
||||
case response.code
|
||||
when "429"
|
||||
handle_429_response(response, path, method, params, retry_count)
|
||||
when "503"
|
||||
handle_503_response(response)
|
||||
else
|
||||
Rails.logger.error "TBDB API request failed: #{response.code} - #{response.message}"
|
||||
log_error_details(response)
|
||||
nil # Return nil for other errors (404, 400, etc.)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def handle_401_error(response)
|
||||
Rails.logger.error "TBDB API request failed: 401 - Unauthorized"
|
||||
log_error_details(response)
|
||||
|
||||
# Mark OAuth connection as invalid
|
||||
error_msg = if @connection.api_base_url.present? && @connection.api_base_url != @base_uri.to_s
|
||||
"OAuth tokens from #{@connection.api_base_url} cannot access #{@base_uri}. Please reconnect to the correct TBDB instance."
|
||||
else
|
||||
"OAuth tokens are invalid or expired. Please reconnect to TBDB."
|
||||
end
|
||||
|
||||
Rails.logger.error "Marking OAuth connection as invalid: #{error_msg}"
|
||||
@connection.mark_invalid!(error_msg)
|
||||
|
||||
# Clear cached clients
|
||||
Rails.cache.delete_matched("tbdb_client:*")
|
||||
|
||||
raise AuthenticationError, error_msg
|
||||
end
|
||||
|
||||
def log_error_details(response)
|
||||
begin
|
||||
error_data = JSON.parse(response.body)
|
||||
Rails.logger.error "Error details: #{error_data.inspect}"
|
||||
rescue JSON::ParserError
|
||||
Rails.logger.error "Response: #{response.body}"
|
||||
end
|
||||
end
|
||||
|
||||
def throttle_request
|
||||
return unless @last_request_time
|
||||
|
||||
# Use dynamic delay from headers, fallback to 1.1s
|
||||
min_interval = @calculated_delay || 1.1
|
||||
|
||||
time_since_last = Time.now - @last_request_time
|
||||
if time_since_last < min_interval
|
||||
sleep_time = min_interval - time_since_last
|
||||
Rails.logger.debug "Throttling request: sleeping #{sleep_time.round(2)}s (interval: #{min_interval}s)"
|
||||
sleep(sleep_time)
|
||||
end
|
||||
end
|
||||
|
||||
def calculate_backoff_time(retry_count)
|
||||
# Exponential backoff: 2^retry_count + 1 second (1s buffer for rate limit)
|
||||
base_wait = 2 ** retry_count
|
||||
base_wait + 1
|
||||
end
|
||||
|
||||
def store_rate_limit_info(response)
|
||||
limit = response["X-RateLimit-Limit"]&.to_f
|
||||
window = response["X-RateLimit-Window"]&.to_f
|
||||
|
||||
if limit && window && limit > 0
|
||||
@calculated_delay = window / limit
|
||||
Rails.logger.debug "Rate limit extracted: #{limit} requests per #{window}s = #{@calculated_delay}s delay"
|
||||
end
|
||||
end
|
||||
|
||||
def check_quota_status(response)
|
||||
remaining = response["X-Quota-Remaining"]&.to_i
|
||||
limit = response["X-Quota-Limit"]&.to_i
|
||||
reset_time = response["X-Quota-Reset"]&.to_i
|
||||
|
||||
if remaining && limit && remaining > 0
|
||||
store_quota_in_cache(remaining, limit, reset_time)
|
||||
end
|
||||
end
|
||||
|
||||
def check_quota_from_body(body)
|
||||
# Extract quota from /me endpoint response body
|
||||
return unless body.is_a?(Hash) && body["rate_limits"]
|
||||
|
||||
rate_limits = body["rate_limits"]
|
||||
limits = rate_limits["limits"] || {}
|
||||
usage = rate_limits["usage"] || {}
|
||||
|
||||
quota_max = limits["quota_max"]
|
||||
current_usage = usage["current_quota"] || 0
|
||||
quota_expires_at = usage["quota_expires_at"]
|
||||
|
||||
if quota_max
|
||||
remaining = quota_max - current_usage
|
||||
# Use quota_expires_at from API if present, otherwise fallback to quota_window calculation
|
||||
reset_time = if quota_expires_at.present?
|
||||
Time.parse(quota_expires_at).to_i
|
||||
else
|
||||
Time.now.to_i + (limits["quota_window"] || 86400)
|
||||
end
|
||||
Rails.logger.debug "Extracted quota from response body: #{remaining}/#{quota_max}, resets at #{Time.at(reset_time)}"
|
||||
store_quota_in_cache(remaining, quota_max, reset_time)
|
||||
end
|
||||
end
|
||||
|
||||
def store_quota_in_cache(remaining, limit, reset_time)
|
||||
# Handle division by zero and calculate percentage
|
||||
percentage = if limit && limit > 0
|
||||
(remaining.to_f / limit * 100).round(1)
|
||||
else
|
||||
0.0
|
||||
end
|
||||
|
||||
Rails.logger.debug "TBDB quota: #{remaining}/#{limit || 'unknown'} remaining (#{percentage}%)"
|
||||
|
||||
# Cache quota info for display in UI
|
||||
quota_data = {
|
||||
remaining: remaining,
|
||||
limit: limit,
|
||||
percentage: percentage,
|
||||
reset_at: reset_time ? Time.at(reset_time) : nil,
|
||||
updated_at: Time.now
|
||||
}
|
||||
|
||||
# Store in cache with single shared key for entire instance
|
||||
Rails.cache.write("tbdb_quota_status:default", quota_data, expires_in: 1.hour)
|
||||
|
||||
if remaining == 0
|
||||
Rails.logger.error "❌ TBDB quota exhausted: #{remaining}/#{limit || 'unknown'} remaining"
|
||||
elsif limit && limit > 0 && remaining < (limit * 0.1)
|
||||
Rails.logger.warn "⚠️ TBDB quota low: #{remaining}/#{limit} remaining (#{percentage}%)"
|
||||
end
|
||||
end
|
||||
|
||||
def handle_429_response(response, path, method, params, retry_count)
|
||||
retry_after = response["Retry-After"]&.to_i || 60
|
||||
reset_time_header = response["X-Quota-Reset"]&.to_i
|
||||
|
||||
# Check if this is quota exhaustion (long retry) vs rate limit (short retry)
|
||||
if retry_after > 60 # Quota exhausted
|
||||
error = QuotaExhaustedError.new("TBDB daily quota exhausted")
|
||||
error.reset_time = reset_time_header ? Time.at(reset_time_header) : (Time.now + retry_after)
|
||||
error.retry_after = retry_after
|
||||
|
||||
Rails.logger.error "TBDB quota exhausted. Resets at #{error.reset_time}. Retry in #{retry_after}s"
|
||||
raise error
|
||||
elsif retry_count < 3 # Rate limit, retry with backoff
|
||||
wait_time = calculate_backoff_time(retry_count)
|
||||
Rails.logger.warn "Rate limited (429), retrying in #{wait_time}s (attempt #{retry_count + 1}/3)"
|
||||
|
||||
sleep(wait_time)
|
||||
return make_request(path, method: method, params: params, retry_count: retry_count + 1)
|
||||
else # Rate limit but out of retries
|
||||
error = RateLimitError.new("TBDB API rate limit exceeded after #{retry_count + 1} attempts")
|
||||
error.retry_after = retry_after
|
||||
error.reset_time = reset_time_header ? Time.at(reset_time_header) : nil
|
||||
|
||||
Rails.logger.error "Rate limit exceeded after #{retry_count + 1} attempts"
|
||||
raise error
|
||||
end
|
||||
end
|
||||
|
||||
def handle_503_response(response)
|
||||
# Parse retry time from response body (e.g., "service unavailable: Retry in 600")
|
||||
retry_after = 600 # Default to 10 minutes
|
||||
|
||||
begin
|
||||
body = response.body
|
||||
if body =~ /Retry in (\d+)/
|
||||
retry_after = $1.to_i
|
||||
end
|
||||
rescue
|
||||
# Use default if parsing fails
|
||||
end
|
||||
|
||||
error = QuotaExhaustedError.new("TBDB service unavailable")
|
||||
error.reset_time = Time.now + retry_after
|
||||
error.retry_after = retry_after
|
||||
|
||||
Rails.logger.error "TBDB service unavailable (503). Retry in #{retry_after}s (#{(retry_after / 60.0).round(1)} minutes)"
|
||||
raise error
|
||||
end
|
||||
end
|
||||
|
||||
# Convenience method for creating a client instance
|
||||
def self.client
|
||||
@client ||= Client.new
|
||||
@@ -404,8 +18,8 @@ module Tbdb
|
||||
client.search_products(query, options)
|
||||
end
|
||||
|
||||
# Retrieve cached quota status for the instance
|
||||
# Retrieve quota status from the connection
|
||||
def self.quota_status
|
||||
Rails.cache.read("tbdb_quota_status:default")
|
||||
TbdbConnection.instance.quota_status
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
require "net/http"
|
||||
require "json"
|
||||
require "uri"
|
||||
require "securerandom"
|
||||
|
||||
class TbdbOauthService
|
||||
class OAuthError < StandardError; end
|
||||
|
||||
# OAuth endpoints (authorization, token exchange, etc.)
|
||||
TBDB_OAUTH_URL = ENV.fetch("TBDB_OAUTH_URL", "http://thebookdb.localhost:3000").freeze
|
||||
|
||||
# API endpoints (for making authenticated API calls)
|
||||
TBDB_API_URL = ENV.fetch("TBDB_API_URI", "http://api.thebookdb.localhost:3000").freeze
|
||||
|
||||
def initialize
|
||||
@connection = TbdbConnection.instance
|
||||
end
|
||||
|
||||
# Step 1: Register OAuth client dynamically if needed
|
||||
def ensure_oauth_client
|
||||
return if @connection.registered?
|
||||
|
||||
register_oauth_client
|
||||
end
|
||||
|
||||
# Step 2: Generate authorization URL for user to visit
|
||||
def authorization_url
|
||||
ensure_oauth_client
|
||||
|
||||
state = SecureRandom.hex(16)
|
||||
Rails.cache.write("oauth_state", state, expires_in: 10.minutes)
|
||||
|
||||
params = {
|
||||
response_type: "code",
|
||||
client_id: @connection.client_id,
|
||||
redirect_uri: redirect_uri,
|
||||
state: state,
|
||||
scope: "data:read"
|
||||
}
|
||||
|
||||
"#{TBDB_OAUTH_URL}/oauth/authorize?#{URI.encode_www_form(params)}"
|
||||
end
|
||||
|
||||
# Step 3: Exchange authorization code for access token
|
||||
def exchange_code_for_token(code, state)
|
||||
# Verify state parameter
|
||||
cached_state = Rails.cache.read("oauth_state")
|
||||
raise OAuthError, "Invalid state parameter" if state != cached_state
|
||||
|
||||
Rails.cache.delete("oauth_state")
|
||||
|
||||
uri = URI("#{TBDB_OAUTH_URL}/oauth/token")
|
||||
request = Net::HTTP::Post.new(uri)
|
||||
request["Content-Type"] = "application/json"
|
||||
request["Accept"] = "application/json"
|
||||
|
||||
request.body = JSON.generate({
|
||||
grant_type: "authorization_code",
|
||||
client_id: @connection.client_id,
|
||||
client_secret: @connection.client_secret,
|
||||
code: code,
|
||||
redirect_uri: redirect_uri
|
||||
})
|
||||
|
||||
# Debug logging
|
||||
Rails.logger.debug "=== OAuth Token Exchange Debug ==="
|
||||
Rails.logger.debug "Client ID: #{@connection.client_id}"
|
||||
Rails.logger.debug "Redirect URI: #{redirect_uri}"
|
||||
Rails.logger.debug "Authorization Code: #{code[0..10]}..." # Only log first part for security
|
||||
Rails.logger.debug "Request Body: #{request.body}"
|
||||
|
||||
response = make_http_request(uri, request)
|
||||
|
||||
Rails.logger.debug "Response Status: #{response.code}"
|
||||
Rails.logger.debug "Response Body: #{response.body}"
|
||||
|
||||
token_data = JSON.parse(response.body)
|
||||
|
||||
if response.is_a?(Net::HTTPSuccess)
|
||||
store_tokens(token_data)
|
||||
else
|
||||
raise OAuthError, "Token exchange failed: #{token_data['error'] || response.message}"
|
||||
end
|
||||
end
|
||||
|
||||
# Refresh access token using refresh token
|
||||
def refresh_access_token
|
||||
return false unless @connection.refresh_token.present?
|
||||
|
||||
uri = URI("#{TBDB_OAUTH_URL}/oauth/token")
|
||||
request = Net::HTTP::Post.new(uri)
|
||||
request["Content-Type"] = "application/json"
|
||||
request["Accept"] = "application/json"
|
||||
|
||||
request.body = JSON.generate({
|
||||
grant_type: "refresh_token",
|
||||
client_id: @connection.client_id,
|
||||
client_secret: @connection.client_secret,
|
||||
refresh_token: @connection.refresh_token
|
||||
})
|
||||
|
||||
response = make_http_request(uri, request)
|
||||
|
||||
if response.is_a?(Net::HTTPSuccess)
|
||||
token_data = JSON.parse(response.body)
|
||||
store_tokens(token_data)
|
||||
true
|
||||
else
|
||||
Rails.logger.error "OAuth token refresh failed: #{response.body}"
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
# Revoke OAuth tokens
|
||||
def revoke_tokens
|
||||
return unless @connection.access_token.present?
|
||||
|
||||
uri = URI("#{TBDB_OAUTH_URL}/oauth/revoke")
|
||||
request = Net::HTTP::Post.new(uri)
|
||||
request["Content-Type"] = "application/json"
|
||||
request["Accept"] = "application/json"
|
||||
|
||||
request.body = JSON.generate({
|
||||
client_id: @connection.client_id,
|
||||
client_secret: @connection.client_secret,
|
||||
token: @connection.access_token
|
||||
})
|
||||
|
||||
make_http_request(uri, request)
|
||||
@connection.clear_connection
|
||||
end
|
||||
|
||||
# Clear client credentials (for re-registration scenarios)
|
||||
def clear_client_credentials
|
||||
@connection.clear_registration
|
||||
Rails.logger.info "Cleared OAuth client credentials"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def redirect_uri
|
||||
# Generate redirect URI at runtime to avoid host issues
|
||||
if Rails.env.development?
|
||||
"http://localhost:4001/auth/tbdb/callback"
|
||||
else
|
||||
"#{Rails.application.routes.url_helpers.root_url.chomp('/')}/auth/tbdb/callback"
|
||||
end
|
||||
end
|
||||
|
||||
def register_oauth_client
|
||||
uri = URI("#{TBDB_OAUTH_URL}/oauth/register")
|
||||
request = Net::HTTP::Post.new(uri)
|
||||
request["Content-Type"] = "application/json"
|
||||
request["Accept"] = "application/json"
|
||||
|
||||
client_name = "ShelfLife Instance"
|
||||
|
||||
request.body = JSON.generate({
|
||||
client_name: client_name,
|
||||
redirect_uris: [redirect_uri], # Single redirect URI for this ShelfLife instance
|
||||
scope: "data:read",
|
||||
application_type: "web",
|
||||
token_endpoint_auth_method: "client_secret_post"
|
||||
})
|
||||
|
||||
response = make_http_request(uri, request)
|
||||
|
||||
if response.is_a?(Net::HTTPSuccess)
|
||||
client_data = JSON.parse(response.body)
|
||||
@connection.update!(
|
||||
client_id: client_data["client_id"],
|
||||
client_secret: client_data["client_secret"],
|
||||
api_base_url: TBDB_API_URL
|
||||
)
|
||||
Rails.logger.info "Registered OAuth client for ShelfLife instance with #{TBDB_API_URL}"
|
||||
else
|
||||
error_data = JSON.parse(response.body) rescue { "error" => response.message }
|
||||
raise OAuthError, "Client registration failed: #{error_data['error']}"
|
||||
end
|
||||
end
|
||||
|
||||
def store_tokens(token_data)
|
||||
expires_at = if token_data["expires_in"]
|
||||
Time.current + token_data["expires_in"].to_i.seconds
|
||||
else
|
||||
1.hour.from_now # Default fallback
|
||||
end
|
||||
|
||||
@connection.update!(
|
||||
access_token: token_data["access_token"],
|
||||
refresh_token: token_data["refresh_token"],
|
||||
expires_at: expires_at,
|
||||
api_base_url: TBDB_API_URL, # Ensure base URL is always set
|
||||
verified_at: Time.current # Mark as verified since we just got tokens
|
||||
)
|
||||
|
||||
Rails.logger.info "Stored OAuth tokens for ShelfLife instance (#{TBDB_API_URL})"
|
||||
end
|
||||
|
||||
def make_http_request(uri, request)
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
http.use_ssl = uri.scheme == "https"
|
||||
http.request(request)
|
||||
end
|
||||
end
|
||||
@@ -1,8 +1,8 @@
|
||||
<% content_for :tags do %>
|
||||
<meta name="description" content="Advanced barcode scanner for book prices - find the cheapest books with Booko's improved scanner" >
|
||||
<meta name="description" content="Advanced barcode scanner for books, DVDs, and board games - scan and manage your library" >
|
||||
<meta name="keywords" content="barcode scanner, book prices, ISBN scanner, quagga2, advanced scanner"/>
|
||||
<meta property="og:description" content="Advanced Booko Barcode Scanner" >
|
||||
<meta property="og:site_name" content="Booko" >
|
||||
<meta property="og:description" content="ShelfLife Barcode Scanner" >
|
||||
<meta property="og:site_name" content="ShelfLife" >
|
||||
<!-- script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.min.js"></script-->
|
||||
<% end %>
|
||||
|
||||
@@ -14,16 +14,16 @@
|
||||
|
||||
<!-- Scanner Controls -->
|
||||
<div class="text-center mb-6">
|
||||
<button data-quagga2-scanner-target="startButton" data-action="click->quagga2-scanner#toggleCamera"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-medium rounded-md text-white bg-booko hover:bg-booko-darker focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-booko mr-4">
|
||||
<button data-quagga2-scanner-target="startButton" data-action="click->quagga2-scanner#toggleCamera"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 mr-4">
|
||||
<svg class="w-6 h-6 mr-2" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M23 4.5V8h-1V4.5A1.502 1.502 0 0 0 20.5 3H17V2h3.5A2.503 2.503 0 0 1 23 4.5zM4.5 22A1.502 1.502 0 0 1 3 20.5V17H2v3.5A2.503 2.503 0 0 0 4.5 23H8v-1zM22 20.5a1.502 1.502 0 0 1-1.5 1.5H17v1h3.5a2.503 2.503 0 0 0 2.5-2.5V17h-1zM3 4.5A1.502 1.502 0 0 1 4.5 3H8V2H4.5A2.503 2.503 0 0 0 2 4.5V8h1zM10 19V6H9v13zM6 6v13h2V6zm8 13V6h-2v13zm3-13v13h2V6zm-2 0v13h1V6z"/>
|
||||
</svg>
|
||||
<span data-quagga2-scanner-target="buttonText">Start Scan</span>
|
||||
</button>
|
||||
|
||||
<button data-quagga2-scanner-target="flashButton" data-action="click->quagga2-scanner#toggleFlash"
|
||||
class="inline-flex items-center px-4 py-3 border border-gray-300 text-base font-medium rounded-md text-gray-700 dark:text-gray-200 bg-white dark:bg-tertiary-600 hover:bg-gray-50 dark:hover:bg-tertiary-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-booko"
|
||||
<button data-quagga2-scanner-target="flashButton" data-action="click->quagga2-scanner#toggleFlash"
|
||||
class="inline-flex items-center px-4 py-3 border border-gray-300 text-base font-medium rounded-md text-gray-700 dark:text-gray-200 bg-white dark:bg-tertiary-600 hover:bg-gray-50 dark:hover:bg-tertiary-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
style="display: none;">
|
||||
<span data-quagga2-scanner-target="flashText">💡 Torch Off</span>
|
||||
</button>
|
||||
@@ -76,7 +76,7 @@
|
||||
placeholder="Enter ISBN or barcode (e.g., 9781234567890)"
|
||||
class="flex-1 min-w-0 block w-full px-3 py-2 rounded-md border border-gray-300 dark:border-tertiary-500 bg-white dark:bg-tertiary-600 text-gray-900 dark:text-white focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
|
||||
<button data-action="click->quagga2-scanner#searchManual"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-booko hover:bg-booko-darker focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-booko">
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
@@ -106,8 +106,7 @@
|
||||
<!-- Navigation Links -->
|
||||
<div class="text-center space-y-4">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Having issues? <a href="/faq" class="text-indigo-600 hover:text-indigo-500 dark:text-indigo-400">Check our FAQ</a> or
|
||||
<a href="mailto:support@booko.com.au" class="text-indigo-600 hover:text-indigo-500 dark:text-indigo-400">contact support</a>
|
||||
Having issues? <a href="/faq" class="text-indigo-600 hover:text-indigo-500 dark:text-indigo-400">Check our FAQ</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ development:
|
||||
<<: *default
|
||||
database: storage/development.sqlite3
|
||||
cache:
|
||||
<<: *default
|
||||
<<: *default
|
||||
database: storage/development_cache.sqlite3
|
||||
migrations_paths: db/cache_migrate
|
||||
queue:
|
||||
|
||||
@@ -48,7 +48,8 @@ Rails.application.configure do
|
||||
|
||||
# Replace the default in-process memory cache store with a durable alternative.
|
||||
config.cache_store = :solid_cache_store
|
||||
config.solid_cache.connects_to = { database: { writing: :cache } }
|
||||
# Already specified in cache.yml
|
||||
# config.solid_cache.connects_to = { database: { writing: :cache } }
|
||||
|
||||
# Replace the default in-process and non-durable queuing backend for Active Job.
|
||||
config.active_job.queue_adapter = :solid_queue
|
||||
|
||||
@@ -29,7 +29,11 @@ Rails.application.routes.draw do
|
||||
delete "/signout", to: "sessions#destroy", as: :signout
|
||||
|
||||
# Product management routes
|
||||
resources :products, only: [ :index, :show, :destroy ]
|
||||
resources :products, only: [ :index, :show, :destroy ] do
|
||||
member do
|
||||
post :refresh
|
||||
end
|
||||
end
|
||||
|
||||
# Library management routes
|
||||
resources :libraries, only: [ :index, :show, :edit, :update ] do
|
||||
@@ -39,7 +43,7 @@ Rails.application.routes.draw do
|
||||
get :export
|
||||
end
|
||||
end
|
||||
resources :library_items, only: [ :create, :destroy ]
|
||||
resources :library_items, only: [ :show, :edit, :update, :create, :destroy ]
|
||||
|
||||
# Scanner routes
|
||||
get "/scanner", to: "scanners#index", as: :scanner
|
||||
|
||||
20
db/schema.rb
generated
20
db/schema.rb
generated
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_10_11_014729) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_10_18_035415) do
|
||||
create_table "acquisition_sources", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.text "description"
|
||||
@@ -49,6 +49,15 @@ ActiveRecord::Schema[8.0].define(version: 2025_10_11_014729) do
|
||||
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
|
||||
end
|
||||
|
||||
create_table "conditions", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.text "description"
|
||||
t.integer "sort_order", default: 0
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["name"], name: "index_conditions_on_name", unique: true
|
||||
end
|
||||
|
||||
create_table "item_statuses", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.text "description"
|
||||
@@ -73,7 +82,6 @@ ActiveRecord::Schema[8.0].define(version: 2025_10_11_014729) do
|
||||
create_table "library_items", force: :cascade do |t|
|
||||
t.integer "product_id", null: false
|
||||
t.integer "library_id", null: false
|
||||
t.string "condition"
|
||||
t.string "location"
|
||||
t.text "notes"
|
||||
t.datetime "date_added", default: -> { "CURRENT_TIMESTAMP" }
|
||||
@@ -97,7 +105,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_10_11_014729) do
|
||||
t.boolean "is_favorite", default: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "condition_id"
|
||||
t.index ["acquisition_source_id"], name: "index_library_items_on_acquisition_source_id"
|
||||
t.index ["condition_id"], name: "index_library_items_on_condition_id"
|
||||
t.index ["item_status_id"], name: "index_library_items_on_item_status_id"
|
||||
t.index ["library_id"], name: "index_library_items_on_library_id"
|
||||
t.index ["ownership_status_id"], name: "index_library_items_on_ownership_status_id"
|
||||
@@ -170,6 +180,11 @@ ActiveRecord::Schema[8.0].define(version: 2025_10_11_014729) do
|
||||
t.string "status", default: "connected"
|
||||
t.datetime "verified_at"
|
||||
t.text "last_error"
|
||||
t.integer "quota_remaining"
|
||||
t.integer "quota_limit"
|
||||
t.decimal "quota_percentage"
|
||||
t.datetime "quota_reset_at"
|
||||
t.datetime "quota_updated_at"
|
||||
end
|
||||
|
||||
create_table "users", force: :cascade do |t|
|
||||
@@ -186,6 +201,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_10_11_014729) do
|
||||
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "library_items", "acquisition_sources"
|
||||
add_foreign_key "library_items", "conditions"
|
||||
add_foreign_key "library_items", "item_statuses"
|
||||
add_foreign_key "library_items", "libraries"
|
||||
add_foreign_key "library_items", "ownership_statuses"
|
||||
|
||||
20
db/seeds.rb
20
db/seeds.rb
@@ -11,6 +11,26 @@ end
|
||||
|
||||
puts "Created #{Library.count} libraries: #{Library.pluck(:name).join(', ')}"
|
||||
|
||||
# Create default conditions
|
||||
default_conditions = [
|
||||
{ name: 'Mint', description: 'Perfect condition, like new', sort_order: 1 },
|
||||
{ name: 'Like New', description: 'Minimal wear, excellent condition', sort_order: 2 },
|
||||
{ name: 'Very Good', description: 'Minor wear, still in great shape', sort_order: 3 },
|
||||
{ name: 'Good', description: 'Normal wear from use', sort_order: 4 },
|
||||
{ name: 'Fair', description: 'Noticeable wear but fully functional', sort_order: 5 },
|
||||
{ name: 'Poor', description: 'Heavy wear, may have damage', sort_order: 6 },
|
||||
{ name: 'Damaged', description: 'Significant damage affecting functionality', sort_order: 7 }
|
||||
]
|
||||
|
||||
default_conditions.each do |condition_attrs|
|
||||
Condition.find_or_create_by(name: condition_attrs[:name]) do |condition|
|
||||
condition.description = condition_attrs[:description]
|
||||
condition.sort_order = condition_attrs[:sort_order]
|
||||
end
|
||||
end
|
||||
|
||||
puts "Created #{Condition.count} conditions: #{Condition.pluck(:name).join(', ')}"
|
||||
|
||||
# Create some sample products for testing
|
||||
sample_products = [
|
||||
{
|
||||
|
||||
9
package-lock.json
generated
9
package-lock.json
generated
@@ -8,7 +8,8 @@
|
||||
"dependencies": {
|
||||
"@hotwired/stimulus": "^3.2.2",
|
||||
"@hotwired/turbo-rails": "^8.0.16",
|
||||
"html5-qrcode": "^2.3.8"
|
||||
"html5-qrcode": "^2.3.8",
|
||||
"slim-select": "^2.13.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.25.8"
|
||||
@@ -534,6 +535,12 @@
|
||||
"resolved": "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz",
|
||||
"integrity": "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/slim-select": {
|
||||
"version": "2.13.1",
|
||||
"resolved": "https://registry.npmjs.org/slim-select/-/slim-select-2.13.1.tgz",
|
||||
"integrity": "sha512-0/j1SAYzwaCgb4mWEOIr+QSzznVPEfT/o6FHAK3yk9qZTM+79DC/J3YRZz7lC4JvteZTzxoGXxPF6CAG7ezjyg==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"dependencies": {
|
||||
"@hotwired/stimulus": "^3.2.2",
|
||||
"@hotwired/turbo-rails": "^8.0.16",
|
||||
"html5-qrcode": "^2.3.8"
|
||||
"html5-qrcode": "^2.3.8",
|
||||
"slim-select": "^2.13.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.25.8"
|
||||
|
||||
Reference in New Issue
Block a user