Skip to content

Commit 5089fe6

Browse files
committed
feat: implement ticket/support
1 parent 00ccbf5 commit 5089fe6

6 files changed

Lines changed: 166 additions & 1 deletion

File tree

Gemfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ gem 'meilisearch', '~> 0.33'
8484
# LLM Integration for Support Chatbot
8585
gem 'ruby-openai', '~> 7.0'
8686

87+
# S3-compatible storage for file uploads (Supabase Storage)
88+
gem 'aws-sdk-s3', '~> 1.0'
89+
8790
# Linear algebra for AI draft analysis
8891
gem 'numo-narray', '~> 0.9'
8992

Gemfile.lock

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,25 @@ GEM
7878
activerecord (>= 3.2, < 8.0)
7979
rake (>= 10.4, < 14.0)
8080
ast (2.4.3)
81+
aws-eventstream (1.4.0)
82+
aws-partitions (1.1229.0)
83+
aws-sdk-core (3.244.0)
84+
aws-eventstream (~> 1, >= 1.3.0)
85+
aws-partitions (~> 1, >= 1.992.0)
86+
aws-sigv4 (~> 1.9)
87+
base64
88+
bigdecimal
89+
jmespath (~> 1, >= 1.6.1)
90+
logger
91+
aws-sdk-kms (1.123.0)
92+
aws-sdk-core (~> 3, >= 3.244.0)
93+
aws-sigv4 (~> 1.5)
94+
aws-sdk-s3 (1.217.0)
95+
aws-sdk-core (~> 3, >= 3.244.0)
96+
aws-sdk-kms (~> 1)
97+
aws-sigv4 (~> 1.5)
98+
aws-sigv4 (1.12.1)
99+
aws-eventstream (~> 1, >= 1.0.2)
81100
base64 (0.3.0)
82101
bcrypt (3.1.20)
83102
bcrypt_pbkdf (1.1.2)
@@ -164,6 +183,7 @@ GEM
164183
pp (>= 0.6.0)
165184
rdoc (>= 4.0.0)
166185
reline (>= 0.4.2)
186+
jmespath (1.6.2)
167187
json (2.18.1)
168188
json-schema (5.2.2)
169189
addressable (~> 2.8)
@@ -438,6 +458,7 @@ PLATFORMS
438458

439459
DEPENDENCIES
440460
annotate
461+
aws-sdk-s3 (~> 1.0)
441462
bcrypt (~> 3.1.7)
442463
blueprinter
443464
bootsnap

app/modules/support/controllers/tickets_controller.rb

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ def update_ticket_params
155155
end
156156

157157
def message_params
158-
params.require(:message).permit(:content, :is_internal, attachments: [])
158+
params.require(:message).permit(:content, :is_internal, attachments: %i[key filename content_type size])
159159
end
160160

161161
def serialize_ticket(ticket)
@@ -209,9 +209,34 @@ def serialize_message(message)
209209
id: message.user.id,
210210
name: message.user.full_name
211211
},
212+
attachments: signed_attachments(message.attachments),
212213
created_at: message.created_at.iso8601
213214
}
214215
end
216+
217+
def signed_attachments(attachments)
218+
return [] if attachments.blank?
219+
220+
s3 = s3_service
221+
return [] unless s3
222+
223+
attachments.filter_map do |att|
224+
url = s3.signed_url(att['key'])
225+
next unless url
226+
227+
att.merge('url' => url)
228+
end
229+
rescue StandardError => e
230+
Rails.logger.error("[Uploads] Failed to sign attachments: #{e.message}")
231+
[]
232+
end
233+
234+
def s3_service
235+
@s3_service ||= S3UploadService.new
236+
rescue StandardError => e
237+
Rails.logger.error("[Uploads] Failed to initialize S3 service: #{e.message}")
238+
nil
239+
end
215240
end
216241
end
217242
end
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# frozen_string_literal: true
2+
3+
module Support
4+
module Controllers
5+
# Handles file uploads for support ticket attachments
6+
class UploadsController < Api::V1::BaseController
7+
# POST /api/v1/support/uploads
8+
def create
9+
file = params[:file]
10+
11+
return render_error(message: 'No file provided', status: :unprocessable_entity) unless file
12+
13+
service = S3UploadService.new
14+
attachment = service.upload(file, prefix: "support/#{current_user.id}")
15+
16+
render_success({ attachment: attachment })
17+
rescue ArgumentError => e
18+
render_error(message: e.message, status: :unprocessable_entity)
19+
rescue Aws::S3::Errors::ServiceError => e
20+
Rails.logger.error("[Uploads] S3 error: #{e.message}")
21+
render_error(message: 'Upload failed. Please try again.', status: :internal_server_error)
22+
rescue KeyError => e
23+
Rails.logger.error("[Uploads] Missing env var: #{e.message}")
24+
render_error(message: 'Storage not configured', status: :internal_server_error)
25+
end
26+
end
27+
end
28+
end

app/services/s3_upload_service.rb

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# frozen_string_literal: true
2+
3+
# Handles file uploads to Supabase S3-compatible storage
4+
class S3UploadService
5+
ALLOWED_CONTENT_TYPES = %w[
6+
image/jpeg
7+
image/png
8+
image/gif
9+
image/webp
10+
application/pdf
11+
text/plain
12+
text/csv
13+
].freeze
14+
15+
MAX_SIZE_MB = 10
16+
MAX_SIZE_BYTES = MAX_SIZE_MB * 1024 * 1024
17+
SIGNED_URL_EXPIRY = 3600 # 1 hour
18+
19+
def initialize
20+
@client = Aws::S3::Client.new(
21+
access_key_id: ENV.fetch('SUPABASE_S3_ACCESS_KEY'),
22+
secret_access_key: ENV.fetch('SUPABASE_S3_SECRET_KEY'),
23+
region: ENV.fetch('SUPABASE_S3_REGION', 'sa-east-1'),
24+
endpoint: ENV.fetch('SUPABASE_S3_ENDPOINT'),
25+
force_path_style: true
26+
)
27+
@bucket = ENV.fetch('SUPABASE_S3_BUCKET')
28+
end
29+
30+
# Upload a file and return its metadata (does not include signed URL)
31+
#
32+
# @param file [ActionDispatch::Http::UploadedFile] the uploaded file
33+
# @param prefix [String] S3 key prefix (e.g. "support/user-uuid")
34+
# @return [Hash] { key:, filename:, content_type:, size: }
35+
def upload(file, prefix: 'support')
36+
validate!(file)
37+
38+
key = generate_key(file.original_filename, prefix)
39+
40+
@client.put_object(
41+
bucket: @bucket,
42+
key: key,
43+
body: file.read,
44+
content_type: file.content_type,
45+
content_disposition: "inline; filename=\"#{file.original_filename}\""
46+
)
47+
48+
{
49+
key: key,
50+
filename: file.original_filename,
51+
content_type: file.content_type,
52+
size: file.size
53+
}
54+
end
55+
56+
# Generate a pre-signed GET URL for a stored object
57+
#
58+
# @param key [String] the S3 object key
59+
# @param expires_in [Integer] expiry in seconds
60+
# @return [String] signed URL
61+
def signed_url(key, expires_in: SIGNED_URL_EXPIRY)
62+
signer = Aws::S3::Presigner.new(client: @client)
63+
signer.presigned_url(:get_object, bucket: @bucket, key: key, expires_in: expires_in)
64+
rescue StandardError => e
65+
Rails.logger.error("[S3UploadService] Failed to generate signed URL for #{key}: #{e.message}")
66+
nil
67+
end
68+
69+
private
70+
71+
def validate!(file)
72+
unless ALLOWED_CONTENT_TYPES.include?(file.content_type)
73+
raise ArgumentError, "File type not allowed. Allowed: #{ALLOWED_CONTENT_TYPES.join(', ')}"
74+
end
75+
76+
return unless file.size > MAX_SIZE_BYTES
77+
78+
raise ArgumentError, "File too large. Maximum size is #{MAX_SIZE_MB}MB"
79+
end
80+
81+
def generate_key(filename, prefix)
82+
ext = File.extname(filename).downcase
83+
"#{prefix}/#{SecureRandom.uuid}#{ext}"
84+
end
85+
end

config/routes.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,9 @@
162162
end
163163
end
164164

165+
# File uploads for attachments
166+
post 'uploads', to: '/support/controllers/uploads#create'
167+
165168
# Staff operations
166169
scope '/staff', as: 'staff' do
167170
get 'dashboard', to: '/support/controllers/staff#dashboard'

0 commit comments

Comments
 (0)