Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile.prod
Original file line number Diff line number Diff line change
@@ -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
Expand Down
5 changes: 4 additions & 1 deletion Gemfile
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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'
Expand Down
28 changes: 27 additions & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -357,6 +382,7 @@ DEPENDENCIES
rubocop-performance
rubocop-rails
rubocop-rspec
ruby_llm (~> 1.9)
simplecov
solid_queue (~> 1.1)
sqlite3
Expand All @@ -365,7 +391,7 @@ DEPENDENCIES
web-console

RUBY VERSION
ruby 3.4.4p34
ruby 3.4.7p58

BUNDLED WITH
2.6.9
73 changes: 73 additions & 0 deletions app/controllers/voice_entries_controller.rb
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +9 to +11
Copy link

Copilot AI Dec 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tempfile from the uploaded audio could be deleted before the VoiceEntryProcessor completes if the request processing is slow or if garbage collection occurs. Consider reading the file content into memory first or ensuring the tempfile persists for the duration of processing:

processor = VoiceEntryProcessor.new(
  user: current_user,
  audio_file: params[:audio_file].tempfile.tap(&:rewind)
)

Alternatively, handle the file content directly rather than passing the path.

Suggested change
processor = VoiceEntryProcessor.new(
user: current_user,
audio_file: params[:audio_file].tempfile
# Read the uploaded audio file into memory to avoid tempfile deletion issues
require 'stringio'
audio_io = StringIO.new(params[:audio_file].read)
audio_io.set_encoding(params[:audio_file].tempfile.external_encoding) if params[:audio_file].tempfile.respond_to?(:external_encoding)
processor = VoiceEntryProcessor.new(
user: current_user,
audio_file: audio_io

Copilot uses AI. Check for mistakes.
)

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,
Comment on lines +23 to +25
Copy link

Copilot AI Dec 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generic error messages expose internal implementation details to users. The error message e.message could contain sensitive information like file paths, API details, or stack traces. Use a more generic error message for the user while logging the full error internally:

rescue => e
  Rails.logger.error "Voice entry creation failed: #{e.message}"
  Rails.logger.error e.backtrace.join("\n")
  render json: {
    success: false,
    error: 'An error occurred while processing your request. Please try again.',
    transcription: nil
  }, status: :unprocessable_entity
end
Suggested change
render json: {
success: false,
error: e.message,
Rails.logger.error e.backtrace.join("\n")
render json: {
success: false,
error: 'An error occurred while processing your request. Please try again.',

Copilot uses AI. Check for mistakes.
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
Comment on lines +42 to +56
Copy link

Copilot AI Dec 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The audio file validation raises generic exceptions with string messages that are caught and exposed to users. Use custom exception classes for better error handling and more specific error responses:

class AudioValidationError < StandardError; end

def validate_audio!
  audio = params[:audio_file]
  
  raise AudioValidationError, 'No audio file provided' unless audio.present?
  raise AudioValidationError, 'Invalid file type. Must be audio file.' unless audio.content_type&.start_with?('audio/')
  raise AudioValidationError, 'Audio file too large. Maximum size is 5MB.' if audio.size > 5.megabytes
end

Then catch AudioValidationError specifically in the rescue clause.

Copilot uses AI. Check for mistakes.

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)
Comment on lines +60 to +71
Copy link

Copilot AI Dec 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The check_rate_limit method has a race condition. Between reading and writing the cache, multiple requests could pass the limit check simultaneously. Consider using atomic cache operations like Rails.cache.increment or implement a proper distributed lock mechanism:

def check_rate_limit
  cache_key = "voice_entries:#{current_user.id}:#{Time.current.to_i / 60}"
  count = Rails.cache.increment(cache_key, 1, expires_in: 1.minute, initial: 0)

  if count > 5
    render json: {
      success: false,
      error: 'Too many requests. Please wait a moment.',
      transcription: nil
    }, status: :too_many_requests
  end
end
Suggested change
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)
count = Rails.cache.increment(cache_key, 1, expires_in: 1.minute, initial: 0)
if count > 5
render json: {
success: false,
error: 'Too many requests. Please wait a moment.',
transcription: nil
}, status: :too_many_requests
end

Copilot uses AI. Check for mistakes.
end
end
Comment on lines +1 to +73
Copy link

Copilot AI Dec 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no test coverage for the new VoiceEntriesController. Given that the repository has comprehensive test coverage for other controllers (e.g., spec/controllers/entries_spec.rb), tests should be added to cover:

  • Authentication and authorization checks
  • Rate limiting behavior
  • Audio file validation
  • Success and error scenarios
  • Voice entries feature flag check

Copilot uses AI. Check for mistakes.
170 changes: 170 additions & 0 deletions app/javascript/controllers/voice_recorder_controller.js
Original file line number Diff line number Diff line change
@@ -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) {
Copy link

Copilot AI Dec 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The populateForm method doesn't handle null values for amount correctly. When data.amount is null (as documented in the prompt at line 73 of voice_entry_processor.rb), the truthy check if (data.amount && ...) will skip setting the field, but a better approach would be to explicitly handle null vs. zero vs. missing values. Consider:

if (data.amount !== null && data.amount !== undefined && this.hasAmountFieldTarget) {
  this.amountFieldTarget.value = data.amount
}

This ensures zero values are properly populated while null values are not.

Suggested change
if (data.amount && this.hasAmountFieldTarget) {
if (data.amount !== null && data.amount !== undefined && this.hasAmountFieldTarget) {

Copilot uses AI. Check for mistakes.
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) {
Copy link

Copilot AI Dec 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant null check: The condition data.date !== null is checked twice (data.date && data.date !== null). The first check data.date already filters out null values. Simplify to:

if (data.date && this.hasDateFieldTarget) {
  this.dateFieldTarget.value = data.date
}
Suggested change
if (data.date && data.date !== null && this.hasDateFieldTarget) {
if (data.date && this.hasDateFieldTarget) {

Copilot uses AI. Check for mistakes.
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 = '<span data-voice-recorder-target="indicator">🔴</span> <span>Stop Recording</span>'
this.buttonTarget.disabled = false
}
if (this.hasIndicatorTarget) {
this.indicatorTarget.style.display = 'inline'
}
}

showProcessing() {
if (this.hasButtonTarget) {
this.buttonTarget.innerHTML = '<span>Processing...</span>'
this.buttonTarget.disabled = true
}
}

showIdle() {
if (this.hasButtonTarget) {
this.buttonTarget.innerHTML = '<span>Record Voice Entry</span>'
this.buttonTarget.disabled = false
}
if (this.hasIndicatorTarget) {
this.indicatorTarget.style.display = 'none'
}
}

showError(message) {
if (this.hasStatusTarget) {
this.statusTarget.innerHTML = `
<div style="padding: 1rem; background: #f8d7da; border: 1px solid #f5c6cb; border-radius: 4px; margin-top: 1rem;">
<p style="margin: 0; color: #721c24; font-size: 0.9rem;">${message}</p>
</div>
`
Comment on lines +157 to +161
Copy link

Copilot AI Dec 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential XSS vulnerability: The error message is directly interpolated into HTML without sanitization. If the error message contains user-controlled content or special characters, it could lead to XSS attacks. Use textContent instead or properly escape the message:

const errorDiv = document.createElement('div')
errorDiv.style.cssText = 'padding: 1rem; background: #f8d7da; border: 1px solid #f5c6cb; border-radius: 4px; margin-top: 1rem;'
const errorP = document.createElement('p')
errorP.style.cssText = 'margin: 0; color: #721c24; font-size: 0.9rem;'
errorP.textContent = message
errorDiv.appendChild(errorP)
this.statusTarget.innerHTML = ''
this.statusTarget.appendChild(errorDiv)
Suggested change
this.statusTarget.innerHTML = `
<div style="padding: 1rem; background: #f8d7da; border: 1px solid #f5c6cb; border-radius: 4px; margin-top: 1rem;">
<p style="margin: 0; color: #721c24; font-size: 0.9rem;">${message}</p>
</div>
`
const errorDiv = document.createElement('div');
errorDiv.style.cssText = 'padding: 1rem; background: #f8d7da; border: 1px solid #f5c6cb; border-radius: 4px; margin-top: 1rem;';
const errorP = document.createElement('p');
errorP.style.cssText = 'margin: 0; color: #721c24; font-size: 0.9rem;';
errorP.textContent = message;
errorDiv.appendChild(errorP);
this.statusTarget.innerHTML = '';
this.statusTarget.appendChild(errorDiv);

Copilot uses AI. Check for mistakes.
}
}

clearFeedback() {
if (this.hasStatusTarget) {
this.statusTarget.innerHTML = ''
}
}
}
Loading