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 = `
+
+ `
+ }
+ }
+
+ 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