diff --git a/Dockerfile b/Dockerfile index 7a3a241..c96206a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM ruby:3.4.4-slim-bookworm +FROM ruby:3.4.7-slim-bookworm ENV APP_HOME="/app_home" ENV BUNDLE_PATH="${APP_HOME}/vendor/bundle" diff --git a/Dockerfile.prod b/Dockerfile.prod index 41b6499..cdcd251 100644 --- a/Dockerfile.prod +++ b/Dockerfile.prod @@ -1,7 +1,7 @@ # syntax = docker/dockerfile:1 # Make sure it matches the Ruby version in .ruby-version and Gemfile -ARG RUBY_VERSION=3.4.4 +ARG RUBY_VERSION=3.4.7 FROM ruby:$RUBY_VERSION-slim AS base # Rails app lives here diff --git a/Gemfile b/Gemfile index 02c9020..0094429 100644 --- a/Gemfile +++ b/Gemfile @@ -1,7 +1,7 @@ source 'https://rubygems.org' git_source(:github) { |repo| "https://github.com/#{repo}.git" } -ruby '3.4.4' +ruby '3.4.7' gem 'puma' @@ -24,6 +24,9 @@ gem 'csv' gem 'haml-rails' +# AI integration for voice entry processing +gem 'ruby_llm', '~> 1.9' + # TODO: Dependency to remove # Use ransack for search and sorting of tables gem 'ransack' diff --git a/Gemfile.lock b/Gemfile.lock index c3811b6..acab37c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -104,6 +104,17 @@ GEM erubi (1.13.1) et-orbi (1.4.0) tzinfo + event_stream_parser (1.0.0) + faraday (2.14.0) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-multipart (1.1.1) + multipart-post (~> 2.0) + faraday-net_http (3.4.2) + net-http (~> 0.5) + faraday-retry (2.3.2) + faraday (~> 2.0) fugit (1.12.1) et-orbi (~> 1.4) raabro (~> 1.4) @@ -159,6 +170,9 @@ GEM stimulus-rails turbo-rails msgpack (1.8.0) + multipart-post (2.4.1) + net-http (0.8.0) + uri (>= 0.11.1) net-imap (0.5.12) date net-protocol @@ -289,6 +303,17 @@ GEM lint_roller (~> 1.1) rubocop (~> 1.81) ruby-progressbar (1.13.0) + ruby_llm (1.9.1) + base64 + event_stream_parser (~> 1) + faraday (>= 1.10.0) + faraday-multipart (>= 1) + faraday-net_http (>= 1) + faraday-retry (>= 1) + marcel (~> 1.0) + ruby_llm-schema (~> 0.2.1) + zeitwerk (~> 2) + ruby_llm-schema (0.2.5) securerandom (0.4.1) simplecov (0.22.0) docile (~> 1.1) @@ -357,6 +382,7 @@ DEPENDENCIES rubocop-performance rubocop-rails rubocop-rspec + ruby_llm (~> 1.9) simplecov solid_queue (~> 1.1) sqlite3 @@ -365,7 +391,7 @@ DEPENDENCIES web-console RUBY VERSION - ruby 3.4.4p34 + ruby 3.4.7p58 BUNDLED WITH 2.6.9 diff --git a/app/controllers/voice_entries_controller.rb b/app/controllers/voice_entries_controller.rb new file mode 100644 index 0000000..566ec15 --- /dev/null +++ b/app/controllers/voice_entries_controller.rb @@ -0,0 +1,73 @@ +class VoiceEntriesController < ApplicationController + # Rate limiting: 5 requests per minute per user + before_action :check_voice_entries_access + before_action :check_rate_limit + + def create + validate_audio! + + processor = VoiceEntryProcessor.new( + user: current_user, + audio_file: params[:audio_file].tempfile + ) + + result = processor.process + + if result[:success] + render json: result + else + render json: result, status: :unprocessable_entity + end + rescue => e + Rails.logger.error "Voice entry creation failed: #{e.message}" + render json: { + success: false, + error: e.message, + transcription: nil + }, status: :unprocessable_entity + end + + private + + def check_voice_entries_access + unless current_user.voice_entries_enabled? + render json: { + success: false, + error: 'Voice entries feature is not enabled for your account.', + transcription: nil + }, status: :forbidden + end + end + + def validate_audio! + audio = params[:audio_file] + + raise 'No audio file provided' unless audio.present? + + # Check content type + unless audio.content_type&.start_with?('audio/') + raise 'Invalid file type. Must be audio file.' + end + + # Check file size (max 5MB) + if audio.size > 5.megabytes + raise 'Audio file too large. Maximum size is 5MB.' + end + end + + def check_rate_limit + cache_key = "voice_entries:#{current_user.id}:#{Time.current.to_i / 60}" + count = Rails.cache.read(cache_key) || 0 + + if count >= 5 + render json: { + success: false, + error: 'Too many requests. Please wait a moment.', + transcription: nil + }, status: :too_many_requests + return + end + + Rails.cache.write(cache_key, count + 1, expires_in: 1.minute) + end +end diff --git a/app/javascript/controllers/voice_recorder_controller.js b/app/javascript/controllers/voice_recorder_controller.js new file mode 100644 index 0000000..ce3bceb --- /dev/null +++ b/app/javascript/controllers/voice_recorder_controller.js @@ -0,0 +1,170 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["button", "status", "indicator", "amountField", "dateField", "notesField", "categorySelect"] + static values = { url: String } + + connect() { + this.mediaRecorder = null + this.audioChunks = [] + this.isRecording = false + } + + async toggleRecording() { + if (this.isRecording) { + this.stopRecording() + } else { + await this.startRecording() + } + } + + async startRecording() { + try { + // Check if browser supports MediaRecorder + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + this.showError('Voice recording is not supported in your browser') + return + } + + // Request microphone permission + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) + + // Initialize MediaRecorder + this.mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' }) + this.audioChunks = [] + + this.mediaRecorder.addEventListener('dataavailable', (event) => { + this.audioChunks.push(event.data) + }) + + this.mediaRecorder.addEventListener('stop', () => { + const audioBlob = new Blob(this.audioChunks, { type: 'audio/webm' }) + this.submitAudio(audioBlob) + + // Stop all audio tracks to release microphone + stream.getTracks().forEach(track => track.stop()) + }) + + this.mediaRecorder.start() + this.isRecording = true + this.showRecording() + } catch (error) { + this.showError('Failed to start recording. Please check your microphone permissions.') + } + } + + stopRecording() { + if (this.mediaRecorder && this.isRecording) { + this.mediaRecorder.stop() + this.isRecording = false + this.showProcessing() + } + } + + async submitAudio(blob) { + try { + const formData = new FormData() + formData.append('audio_file', blob, 'recording.webm') + + // Get CSRF token + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content + + const response = await fetch(this.urlValue, { + method: 'POST', + headers: { + 'X-CSRF-Token': csrfToken + }, + body: formData + }) + + const result = await response.json() + + if (result.success) { + this.populateForm(result.data) + this.clearFeedback() + } else { + this.showError(result.error || 'Failed to process audio') + } + } catch (error) { + this.showError('Failed to upload audio. Please try again.') + } finally { + this.showIdle() + } + } + + populateForm(data) { + // Populate amount field + if (data.amount && this.hasAmountFieldTarget) { + this.amountFieldTarget.value = data.amount + } + + // Populate date field only if a date was extracted + // If null, leave the form's default date (today) + if (data.date && data.date !== null && this.hasDateFieldTarget) { + this.dateFieldTarget.value = data.date + } + + // Populate notes field + if (data.notes && this.hasNotesFieldTarget) { + this.notesFieldTarget.value = data.notes + } + + // Populate category select (case-insensitive match) + if (data.category_name && this.hasCategorySelectTarget) { + const categorySelect = this.categorySelectTarget + const options = Array.from(categorySelect.options) + + // Try to find matching category by text (case-insensitive) + const matchingOption = options.find(option => + option.text.toLowerCase() === data.category_name.toLowerCase() + ) + + if (matchingOption) { + categorySelect.value = matchingOption.value + } + } + } + + showRecording() { + if (this.hasButtonTarget) { + this.buttonTarget.innerHTML = '🔴 Stop Recording' + this.buttonTarget.disabled = false + } + if (this.hasIndicatorTarget) { + this.indicatorTarget.style.display = 'inline' + } + } + + showProcessing() { + if (this.hasButtonTarget) { + this.buttonTarget.innerHTML = 'Processing...' + this.buttonTarget.disabled = true + } + } + + showIdle() { + if (this.hasButtonTarget) { + this.buttonTarget.innerHTML = 'Record Voice Entry' + this.buttonTarget.disabled = false + } + if (this.hasIndicatorTarget) { + this.indicatorTarget.style.display = 'none' + } + } + + showError(message) { + if (this.hasStatusTarget) { + this.statusTarget.innerHTML = ` +
+

${message}

+
+ ` + } + } + + clearFeedback() { + if (this.hasStatusTarget) { + this.statusTarget.innerHTML = '' + } + } +} diff --git a/app/services/voice_entry_processor.rb b/app/services/voice_entry_processor.rb new file mode 100644 index 0000000..57acf51 --- /dev/null +++ b/app/services/voice_entry_processor.rb @@ -0,0 +1,78 @@ +class VoiceEntryProcessor + attr_reader :user, :audio_file + + def initialize(user:, audio_file:) + @user = user + @audio_file = audio_file + end + + def process + # Create a chat instance with Gemini + chat = RubyLLM.chat(model: 'gemini-2.5-flash-lite') + + # Ask the question with the audio file + response = chat.ask(build_system_prompt, with: audio_file.path) + + # Extract text from RubyLLM::Message object + response_text = if response.respond_to?(:content) + response.content + elsif response.respond_to?(:text) + response.text + else + response.to_s + end + + # Extract JSON from response (in case there's extra text) + json_match = response_text.match(/\{.*\}/m) + raise 'No JSON found in response' unless json_match + + data = JSON.parse(json_match[0]) + + { + success: true, + data: { + amount: data['amount'], + category_name: data['category_name'], + date: data['date'] || Date.today.to_s, + notes: data['notes'] + }, + transcription: response_text + } + rescue => e + Rails.logger.error "Voice entry processing failed: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + + { + success: false, + error: "Failed to process voice recording: #{e.message}", + transcription: nil + } + end + + private + + def build_system_prompt + categories_list = user.categories.pluck(:name).join(', ') + current_year = Date.today.year + + <<~PROMPT + You are a financial entry assistant. Extract transaction information from speech and return as JSON. + + Extract: + - amount: Dollar amount as a number (e.g., 45 or 45.50) + - category_name: Category that best matches the description from the available categories + - date: ONLY if a date is explicitly mentioned. Parse dates like "October 15th" as "#{current_year}-10-15", "yesterday", "last Tuesday", etc. If NO date is mentioned, set to null. + - notes: Any additional context or description + + USER'S AVAILABLE CATEGORIES: #{categories_list} + + Return ONLY a JSON object like: + {"amount": 45, "category_name": "Groceries", "date": "#{current_year}-10-15", "notes": "groceries at Trader Joe's"} + + IMPORTANT: + - Only include a date if the user explicitly mentions one. If no date is mentioned, use null for the date field. + - If you cannot extract amount or category, set them to null. + - The user may give instructions for formatting notes. Any additional details that the user gives about the transaction should be included in notes. + PROMPT + end +end diff --git a/app/views/entries/_form.html.haml b/app/views/entries/_form.html.haml index e4a578d..af759fe 100644 --- a/app/views/entries/_form.html.haml +++ b/app/views/entries/_form.html.haml @@ -9,16 +9,16 @@ %div.row{ style: 'display: flex; flex-direction: row; flex-wrap: wrap; gap: 6px;' } %div{ 'data-controller': 'date-picker', style: 'flex-grow: 1;' } = f.label :date - = f.date_field :date, { id: "#{@entry.id ? '' : 'date-picker'}" } + = f.date_field :date, { id: "#{@entry.id ? '' : 'date-picker'}", 'data-voice-recorder-target': 'dateField' } %div = f.label :amount - = f.number_field :amount, { placeholder: '$0.00', step: '.01', inputmode: "decimal", style: 'width: 130px;' } + = f.number_field :amount, { placeholder: '$0.00', step: '.01', inputmode: "decimal", style: 'width: 130px;', 'data-voice-recorder-target': 'amountField' } %div{ style: 'flex-grow: 3;' } = f.label :category - = f.collection_select :category_id, @categories, :id, :name, { include_blank: true } + = f.collection_select :category_id, @categories, :id, :name, { include_blank: true }, { 'data-voice-recorder-target': 'categorySelect' } %div{ style: 'width: 100%;' } = f.label :notes - = f.text_area :notes + = f.text_area :notes, { 'data-voice-recorder-target': 'notesField' } %fieldset{ 'data-controller': 'tag' } = f.fields_for :tag do |nested_form| = nested_form.label :name, 'Tag' diff --git a/app/views/entries/_voice_recorder.html.haml b/app/views/entries/_voice_recorder.html.haml new file mode 100644 index 0000000..b5ea5fc --- /dev/null +++ b/app/views/entries/_voice_recorder.html.haml @@ -0,0 +1,9 @@ +%button{ + type: "button", + data: { + voice_recorder_target: "button", + action: "click->voice-recorder#toggleRecording" + } +} + %span Record Voice Entry +%div#voice-feedback{ data: { voice_recorder_target: "status" } } diff --git a/app/views/entries/new.html.haml b/app/views/entries/new.html.haml index cd21846..ced8189 100644 --- a/app/views/entries/new.html.haml +++ b/app/views/entries/new.html.haml @@ -1,3 +1,12 @@ %h1 New entry -= render 'form' +- if current_user.voice_entries_enabled? + %div{ data: { controller: "voice-recorder", voice_recorder_url_value: voice_entries_path } } + = render 'voice_recorder' + + %div{ style: "margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid var(--pico-muted-border-color, #e0e0e0);" } + %p{ style: "text-align: center; color: var(--pico-muted-color, #666); font-size: 0.9rem;" } Or enter manually: + + = render 'form' +- else + = render 'form' diff --git a/config/application.rb b/config/application.rb index 33e60a8..c418b4a 100644 --- a/config/application.rb +++ b/config/application.rb @@ -9,7 +9,7 @@ module Sum class Application < Rails::Application # Initialize configuration defaults for originally generated Rails version. - config.load_defaults 8.0 + config.load_defaults 8.1 config.mission_control.jobs.base_controller_class = "MissionControlController" # Please, add to the `ignore` list any other `lib` subdirectories that do diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc index 13c7b9c..837bee1 100644 --- a/config/credentials.yml.enc +++ b/config/credentials.yml.enc @@ -1 +1 @@ -4fYxd5ijt335ZAL6Jr9c0FvJCigTAv8EdOdfhKQV8F9ldK71w03SvHi8PTZMPUBQUegy6lIsDiQF5xR9CPRdDU8X4teGD/Jv14KuasxDay3bg34XuAewfpG9AA/aQE+pbINRKDlYAMAltQgApWGu+mjq2+b4OnC9koBQVco8nhbsGzgT4fUGAcIOrASwM6uPWkVwP4uePlaP1CgEagoeBWMiBDcp745Hk9uxs35exURtwVM4GQYULMTGUzCHX+dQe7H6PuiB7QL4NRW1a7JPtYBhK8dSRec8f90z70cvnBbZ3fOThz7pDWP4xyJdd3oHtZR/p8qY0kiKYQbdCf24KtIZ+rrbkVnDcmPQiObOOKx/ir0Mev4FbBuQzdBASlatSW0rPdU+aXz/OL/5p/feIWpVN9+7QjP4G9L1Ml+hVlb+p/CgRVbrsMevQTtNQAeS2RGjZ5oduGcvf/1HPzGfemawK78HRLaSUESVo7AtoBs4uZ3NkimnEnG001W/FbF2B80mO0fwzpwdszP/aTBb9t8uuD9XNWgGCVyTXVwspphHh0hWpU3Ry0ZKqyDxoXIRU7BxMeKBPt4223Da1IjKuhm98yepI0j+rvbqce4KivVqT60hdsfiGexnSg==--dTM0kCqTXQldHWLd--IzuqnfvsHXRcH+azl24eXQ== \ No newline at end of file +BMFEDZ8EjyPIj2UxZ7E5uPo9BuHH04cp7hqAD3K90wd8JqJQqrrRodgNYkPRp3wLWmLdoSxpP87xsZaOOBgzaCbzV0MsBLd3WnT0ZdLTNxFRquZrV5a6htrK0Rk6Vc8No6nlrBDagv+P83pKmGYaACVuu8K1SK4Bs+xSqjHyfedtHluSozOhYOAccNnsTfAmrS0Qc3h4KZRV87zI/LvSQlTMqYyhFRiXIqaX2q0RunQEDFxjKkP1Jxox1s/WxNdt7U5/brJbsquALbXuTfrcZF5uN0tL8N9C6aDon7uB2dJ7qGBIRQZUEsGqhl94QYasBHzLkzZ3EzVBrcmAkxk5Wmlt81nDEBuGA5kgTsa3jTDCwyjy7/beXK1EySL+luWlO7ZOqx8Djtj8fbVwIGrrHrrmIdrfjP40buSO/5ysJMpIiueHhx9egjfr0tbHQDIQvQfpPY9U5CfiNhSWbWxQ7rviGAKrarfaqyQq3/W3oa8vnLgBlD92PEAShWYCko2GbWRNuoYvT0oZtCtxYYHM7EvtwJ85q483OgtEZyskXA2XLTN1WWA7weW/HPm8D48ZS6sEtavUCTlCmxzXQoKgOSWdULlNJsGkUT2kWWwlTiSNBe8H0/dGinWVjJjHnTQAmaFSU0GXlxPUuhGhZOauCodv54fvacQPWT/1QFRlWTHvpNf6BR5f3+wVZqlA5TmxVh833pyhJkdDysVYgWk=--4UN56E53YvkUOMIc--bJQMYy7lcTFIXFsD6iaD9Q== \ No newline at end of file diff --git a/config/initializers/ruby_llm.rb b/config/initializers/ruby_llm.rb new file mode 100644 index 0000000..6790e90 --- /dev/null +++ b/config/initializers/ruby_llm.rb @@ -0,0 +1,9 @@ +# Configure RubyLLM for voice entry processing with Google Gemini +RubyLLM.configure do |config| + # Set the Gemini API key from Rails credentials + # To add the API key, run: rails credentials:edit + # Then add: + # google: + # gemini_api_key: YOUR_API_KEY_HERE + config.gemini_api_key = Rails.application.credentials.dig(:google, :gemini_api_key) +end diff --git a/config/routes.rb b/config/routes.rb index 9bef0e4..4c1ec89 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -2,7 +2,7 @@ mount MissionControl::Jobs::Engine, at: '/jobs' get 'up' => 'rails/health#show', as: :rails_health_check - + # PWA routes get '/manifest.json', to: 'pwa#manifest' get '/service-worker.js', to: 'pwa#service_worker' @@ -10,6 +10,7 @@ resources :recurrables resources :tags resources :entries + resources :voice_entries, only: [:create] post '/filtered_entries', to: 'entries#index' get '/export/entries', to: 'entries#export' diff --git a/db/migrate/20251202132403_add_voice_entries_enabled_to_users.rb b/db/migrate/20251202132403_add_voice_entries_enabled_to_users.rb new file mode 100644 index 0000000..60412d3 --- /dev/null +++ b/db/migrate/20251202132403_add_voice_entries_enabled_to_users.rb @@ -0,0 +1,5 @@ +class AddVoiceEntriesEnabledToUsers < ActiveRecord::Migration[8.1] + def change + add_column :users, :voice_entries_enabled, :boolean, default: false, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 523c883..4623bdf 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,100 +10,100 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_07_12_201959) do +ActiveRecord::Schema[8.1].define(version: 2025_12_02_132403) do create_table "categories", force: :cascade do |t| - t.string "name" + t.datetime "created_at", null: false t.boolean "income" + t.string "name" t.boolean "untracked" - t.datetime "created_at", null: false t.datetime "updated_at", null: false end create_table "categories_users", id: false, force: :cascade do |t| - t.integer "user_id", null: false t.integer "category_id", null: false + t.integer "user_id", null: false end create_table "entries", force: :cascade do |t| - t.date "date" t.decimal "amount", precision: 8, scale: 2 - t.string "notes" + t.integer "category_id", null: false t.string "category_name" + t.datetime "created_at", null: false + t.date "date" t.boolean "income" + t.string "notes" + t.integer "tag_id" t.boolean "untracked" - t.integer "user_id", null: false - t.integer "category_id", null: false - t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.integer "tag_id" + t.integer "user_id", null: false t.index ["category_id"], name: "index_entries_on_category_id" t.index ["tag_id"], name: "index_entries_on_tag_id" t.index ["user_id"], name: "index_entries_on_user_id" end create_table "recurrables", force: :cascade do |t| - t.string "name" + t.decimal "amount", precision: 8, scale: 2 + t.integer "category_id", null: false + t.datetime "created_at", null: false t.integer "day_of_month" + t.string "name" + t.string "notes" t.text "schedule" t.string "schedule_string" - t.decimal "amount", precision: 8, scale: 2 - t.string "notes" - t.integer "category_id", null: false - t.integer "user_id", null: false t.integer "tag_id" - t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.integer "user_id", null: false t.index ["category_id"], name: "index_recurrables_on_category_id" t.index ["tag_id"], name: "index_recurrables_on_tag_id" t.index ["user_id"], name: "index_recurrables_on_user_id" end create_table "sessions", force: :cascade do |t| - t.integer "user_id", null: false - t.string "ip_address" - t.string "user_agent" t.datetime "created_at", null: false + t.string "ip_address" t.datetime "updated_at", null: false + t.string "user_agent" + t.integer "user_id", null: false t.index ["user_id"], name: "index_sessions_on_user_id" end create_table "solid_queue_blocked_executions", force: :cascade do |t| - t.bigint "job_id", null: false - t.string "queue_name", null: false - t.integer "priority", default: 0, null: false t.string "concurrency_key", null: false - t.datetime "expires_at", null: false t.datetime "created_at", null: false + t.datetime "expires_at", null: false + t.bigint "job_id", null: false + t.integer "priority", default: 0, null: false + t.string "queue_name", null: false t.index ["concurrency_key", "priority", "job_id"], name: "index_solid_queue_blocked_executions_for_release" t.index ["expires_at", "concurrency_key"], name: "index_solid_queue_blocked_executions_for_maintenance" t.index ["job_id"], name: "index_solid_queue_blocked_executions_on_job_id", unique: true end create_table "solid_queue_claimed_executions", force: :cascade do |t| + t.datetime "created_at", null: false t.bigint "job_id", null: false t.bigint "process_id" - t.datetime "created_at", null: false t.index ["job_id"], name: "index_solid_queue_claimed_executions_on_job_id", unique: true t.index ["process_id", "job_id"], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id" end create_table "solid_queue_failed_executions", force: :cascade do |t| - t.bigint "job_id", null: false - t.text "error" t.datetime "created_at", null: false + t.text "error" + t.bigint "job_id", null: false t.index ["job_id"], name: "index_solid_queue_failed_executions_on_job_id", unique: true end create_table "solid_queue_jobs", force: :cascade do |t| - t.string "queue_name", null: false - t.string "class_name", null: false - t.text "arguments" - t.integer "priority", default: 0, null: false t.string "active_job_id" - t.datetime "scheduled_at" - t.datetime "finished_at" + t.text "arguments" + t.string "class_name", null: false t.string "concurrency_key" t.datetime "created_at", null: false + t.datetime "finished_at" + t.integer "priority", default: 0, null: false + t.string "queue_name", null: false + t.datetime "scheduled_at" t.datetime "updated_at", null: false t.index ["active_job_id"], name: "index_solid_queue_jobs_on_active_job_id" t.index ["class_name"], name: "index_solid_queue_jobs_on_class_name" @@ -113,92 +113,93 @@ end create_table "solid_queue_pauses", force: :cascade do |t| - t.string "queue_name", null: false t.datetime "created_at", null: false + t.string "queue_name", null: false t.index ["queue_name"], name: "index_solid_queue_pauses_on_queue_name", unique: true end create_table "solid_queue_processes", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "hostname" t.string "kind", null: false t.datetime "last_heartbeat_at", null: false - t.bigint "supervisor_id" - t.integer "pid", null: false - t.string "hostname" t.text "metadata" - t.datetime "created_at", null: false t.string "name", null: false + t.integer "pid", null: false + t.bigint "supervisor_id" t.index ["last_heartbeat_at"], name: "index_solid_queue_processes_on_last_heartbeat_at" t.index ["name", "supervisor_id"], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true t.index ["supervisor_id"], name: "index_solid_queue_processes_on_supervisor_id" end create_table "solid_queue_ready_executions", force: :cascade do |t| + t.datetime "created_at", null: false t.bigint "job_id", null: false - t.string "queue_name", null: false t.integer "priority", default: 0, null: false - t.datetime "created_at", null: false + t.string "queue_name", null: false t.index ["job_id"], name: "index_solid_queue_ready_executions_on_job_id", unique: true t.index ["priority", "job_id"], name: "index_solid_queue_poll_all" t.index ["queue_name", "priority", "job_id"], name: "index_solid_queue_poll_by_queue" end create_table "solid_queue_recurring_executions", force: :cascade do |t| + t.datetime "created_at", null: false t.bigint "job_id", null: false - t.string "task_key", null: false t.datetime "run_at", null: false - t.datetime "created_at", null: false + t.string "task_key", null: false t.index ["job_id"], name: "index_solid_queue_recurring_executions_on_job_id", unique: true t.index ["task_key", "run_at"], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true end create_table "solid_queue_recurring_tasks", force: :cascade do |t| - t.string "key", null: false - t.string "schedule", null: false - t.string "command", limit: 2048 - t.string "class_name" t.text "arguments" - t.string "queue_name" + t.string "class_name" + t.string "command", limit: 2048 + t.datetime "created_at", null: false + t.text "description" + t.string "key", null: false t.integer "priority", default: 0 + t.string "queue_name" + t.string "schedule", null: false t.boolean "static", default: true, null: false - t.text "description" - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["key"], name: "index_solid_queue_recurring_tasks_on_key", unique: true t.index ["static"], name: "index_solid_queue_recurring_tasks_on_static" end create_table "solid_queue_scheduled_executions", force: :cascade do |t| + t.datetime "created_at", null: false t.bigint "job_id", null: false - t.string "queue_name", null: false t.integer "priority", default: 0, null: false + t.string "queue_name", null: false t.datetime "scheduled_at", null: false - t.datetime "created_at", null: false t.index ["job_id"], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true t.index ["scheduled_at", "priority", "job_id"], name: "index_solid_queue_dispatch_all" end create_table "solid_queue_semaphores", force: :cascade do |t| - t.string "key", null: false - t.integer "value", default: 1, null: false - t.datetime "expires_at", null: false t.datetime "created_at", null: false + t.datetime "expires_at", null: false + t.string "key", null: false t.datetime "updated_at", null: false + t.integer "value", default: 1, null: false t.index ["expires_at"], name: "index_solid_queue_semaphores_on_expires_at" t.index ["key", "value"], name: "index_solid_queue_semaphores_on_key_and_value" t.index ["key"], name: "index_solid_queue_semaphores_on_key", unique: true end create_table "tags", force: :cascade do |t| - t.string "name" t.datetime "created_at", null: false + t.string "name" t.datetime "updated_at", null: false end create_table "users", force: :cascade do |t| + t.datetime "created_at", null: false t.string "email_address", default: "", null: false t.string "password_digest", default: "", null: false - t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.boolean "voice_entries_enabled", default: false, null: false t.index ["email_address"], name: "index_users_on_email_address", unique: true end