diff --git a/.circleci/config.yml b/.circleci/config.yml
index cf0f5918eca..874e2b3bd93 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -10,6 +10,10 @@ jobs:
- image: cimg/ruby:3.4.3-node
steps:
- checkout
+ - run:
+ name: Configure Bundler
+ command: |
+ bundle lock --add-platform ruby
- ruby/install-deps
- node/install:
install-yarn: true
@@ -21,6 +25,10 @@ jobs:
- image: 'cimg/ruby:3.4.3-node'
steps:
- checkout
+ - run:
+ name: Configure Bundler
+ command: |
+ bundle lock --add-platform ruby
- ruby/install-deps
- ruby/rubocop-check:
format: progress
@@ -75,7 +83,6 @@ jobs:
- run:
name: Configure Bundler
command: |
- bundle config set --local force_ruby_platform true
bundle lock --add-platform ruby
- ruby/install-deps:
clean-bundle: true
diff --git a/.cloudbuild/cloudbuild-staging.yaml b/.cloudbuild/cloudbuild-staging.yaml
index 2e3cbbaab78..c71dba70491 100644
--- a/.cloudbuild/cloudbuild-staging.yaml
+++ b/.cloudbuild/cloudbuild-staging.yaml
@@ -250,6 +250,7 @@ steps:
- '--set-env-vars=DISCORD_AUTHENTICATION_URL=$_DISCORD_AUTHENTICATION_URL'
- '--set-env-vars=PUBSUB_AUDIENCE=$_PUBSUB_AUDIENCE'
- '--set-env-vars=PUBSUB_SERVICE_ACCOUNT_EMAIL=$_PUBSUB_SERVICE_ACCOUNT_EMAIL'
+ - '--set-env-vars=PUBSUB_TOPIC=$_PUBSUB_TOPIC'
- '--set-env-vars=$_ENVS'
- >-
--labels=managed-by=gcp-cloud-build-deploy-cloud-run,commit-sha=$COMMIT_SHA,gcb-build-id=$BUILD_ID,gcb-trigger-id=$_TRIGGER_ID,$_LABELS
diff --git a/.cloudbuild/cloudbuild.yaml b/.cloudbuild/cloudbuild.yaml
index 775959054b9..fbda48d0844 100644
--- a/.cloudbuild/cloudbuild.yaml
+++ b/.cloudbuild/cloudbuild.yaml
@@ -121,6 +121,7 @@ steps:
- '--set-env-vars=DISCORD_AUTHENTICATION_URL=$_DISCORD_AUTHENTICATION_URL'
- '--set-env-vars=PUBSUB_AUDIENCE=$_PUBSUB_AUDIENCE'
- '--set-env-vars=PUBSUB_SERVICE_ACCOUNT_EMAIL=$_PUBSUB_SERVICE_ACCOUNT_EMAIL'
+ - '--set-env-vars=PUBSUB_TOPIC=$_PUBSUB_TOPIC'
- '--set-env-vars=$_ENVS'
- >-
--labels=managed-by=gcp-cloud-build-deploy-cloud-run,commit-sha=$COMMIT_SHA,gcb-build-id=$BUILD_ID,gcb-trigger-id=$_TRIGGER_ID,$_LABELS
diff --git a/Gemfile b/Gemfile
index 78cb53f5b67..0f47fbe45bb 100644
--- a/Gemfile
+++ b/Gemfile
@@ -36,6 +36,8 @@ gem 'discordrb', '~> 3.5', require: false
gem 'doorkeeper'
gem 'good_job', '~> 4.5'
gem 'google-cloud-storage', '~> 1.25', require: false
+gem 'google-cloud-video-transcoder'
+gem 'google-id-token'
gem 'holiday_jp'
gem 'icalendar', '~> 2.8'
gem 'interactor', '~> 3.0'
@@ -58,6 +60,7 @@ gem 'omniauth-discord'
gem 'omniauth-github', '~> 2.0.1'
gem 'omniauth-rails_csrf_protection'
gem 'opengraph_parser'
+gem 'openssl'
gem 'parser', '3.2.2.4'
gem 'pg', '~> 1.4.6'
gem 'postmark-rails'
diff --git a/Gemfile.lock b/Gemfile.lock
index ef9f130267c..a63d06956c3 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -199,6 +199,9 @@ GEM
multipart-post (~> 2.0)
faraday-net_http (3.4.2)
net-http (~> 0.5)
+ faraday-retry (2.3.2)
+ faraday (~> 2.0)
+ ffi (1.17.1)
ffi (1.17.1-aarch64-linux-gnu)
ffi (1.17.1-aarch64-linux-musl)
ffi (1.17.1-arm-linux-gnu)
@@ -212,6 +215,16 @@ GEM
fugit (1.12.1)
et-orbi (~> 1.4)
raabro (~> 1.4)
+ gapic-common (1.1.0)
+ faraday (>= 1.9, < 3.a)
+ faraday-retry (>= 1.0, < 3.a)
+ google-cloud-env (~> 2.2)
+ google-logging-utils (~> 0.1)
+ google-protobuf (>= 3.25, < 5.a)
+ googleapis-common-protos (~> 1.6)
+ googleapis-common-protos-types (~> 1.15)
+ googleauth (~> 1.12)
+ grpc (~> 1.66)
globalid (1.3.0)
activesupport (>= 6.1)
good_job (4.12.1)
@@ -249,7 +262,30 @@ GEM
google-cloud-core (~> 1.6)
googleauth (~> 1.9)
mini_mime (~> 1.0)
+ google-cloud-video-transcoder (2.0.1)
+ google-cloud-core (~> 1.6)
+ google-cloud-video-transcoder-v1 (~> 2.0)
+ google-cloud-video-transcoder-v1 (2.2.0)
+ gapic-common (~> 1.0)
+ google-cloud-errors (~> 1.0)
+ google-id-token (1.4.2)
+ jwt (>= 1)
google-logging-utils (0.2.0)
+ google-protobuf (4.32.0)
+ bigdecimal
+ rake (>= 13)
+ google-protobuf (4.32.0-arm64-darwin)
+ bigdecimal
+ rake (>= 13)
+ google-protobuf (4.32.0-x86_64-linux-gnu)
+ bigdecimal
+ rake (>= 13)
+ googleapis-common-protos (1.8.0)
+ google-protobuf (>= 3.18, < 5.a)
+ googleapis-common-protos-types (~> 1.20)
+ grpc (~> 1.41)
+ googleapis-common-protos-types (1.20.0)
+ google-protobuf (>= 3.18, < 5.a)
googleauth (1.15.1)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.2)
@@ -258,6 +294,15 @@ GEM
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
+ grpc (1.74.1)
+ google-protobuf (>= 3.25, < 5.0)
+ googleapis-common-protos-types (~> 1.0)
+ grpc (1.74.1-arm64-darwin)
+ google-protobuf (>= 3.25, < 5.0)
+ googleapis-common-protos-types (~> 1.0)
+ grpc (1.74.1-x86_64-linux-gnu)
+ google-protobuf (>= 3.25, < 5.0)
+ googleapis-common-protos-types (~> 1.0)
hashdiff (1.2.1)
hashie (5.0.0)
holiday_jp (0.8.1)
@@ -426,6 +471,7 @@ GEM
opengraph_parser (0.2.5)
addressable
nokogiri
+ openssl (4.0.0)
opus-ruby (1.0.1)
ffi
os (1.1.4)
@@ -743,6 +789,8 @@ DEPENDENCIES
foreman
good_job (~> 4.5)
google-cloud-storage (~> 1.25)
+ google-cloud-video-transcoder
+ google-id-token
holiday_jp
icalendar (~> 2.8)
image_processing (~> 1.12)
@@ -772,6 +820,7 @@ DEPENDENCIES
omniauth-github (~> 2.0.1)
omniauth-rails_csrf_protection
opengraph_parser
+ openssl
parser (= 3.2.2.4)
pg (~> 1.4.6)
postmark-rails
diff --git a/app/components/users/micro_reports/micro_report_component.html.slim b/app/components/users/micro_reports/micro_report_component.html.slim
index 8301953e7b5..4efc533628f 100644
--- a/app/components/users/micro_reports/micro_report_component.html.slim
+++ b/app/components/users/micro_reports/micro_report_component.html.slim
@@ -1,20 +1,20 @@
.micro-report(id="micro_report_#{@micro_report.id}" data-micro_report_id="#{@micro_report.id}" data-micro_report_content="#{@micro_report.content}")
.micro-report__start
- = render 'users/icon', user: @user, link_class: 'micro-report__user-link', image_class: 'micro-report_user-icon'
+ = render 'users/icon', user: comment_user, link_class: 'micro-report__user-link', image_class: 'micro-report_user-icon'
.micro-report__end
.is-micro-report.micro-report-display
header.micro-report__header
h2.micro-report__title
- = link_to user_path(@user), class: 'micro-report__title-link a-text-link' do
- = @user.login_name
+ = link_to user_path(comment_user), class: 'micro-report__title-link a-text-link' do
+ = comment_user.login_name
time.micro-report__created-at
= posted_datetime
.micro-report__body
.a-short-text.is-sm.js-markdown-view
= @micro_report.content
.micro-report__footer
- - if @micro_report.user == @current_user || admin_login?
- .micro-report-actions
+ .micro-report-actions
+ - if @micro_report.comment_user == @current_user || @current_user&.admin?
ul.micro-report-actions__items
li.micro-report-actions__item
button.micro-report-actions__action.is-edit.js-editor-button
diff --git a/app/components/users/micro_reports/micro_report_component.rb b/app/components/users/micro_reports/micro_report_component.rb
index 7e5eb756397..ca59b33b6a6 100644
--- a/app/components/users/micro_reports/micro_report_component.rb
+++ b/app/components/users/micro_reports/micro_report_component.rb
@@ -7,6 +7,10 @@ def initialize(user:, current_user:, micro_report:)
@micro_report = micro_report
end
+ def comment_user
+ @micro_report.comment_user
+ end
+
def posted_datetime
time = @micro_report.created_at
if time.to_date == Time.zone.today
@@ -18,5 +22,7 @@ def posted_datetime
end
end
- delegate :admin_login?, to: :helpers
+ def owner_post?
+ comment_user == @user
+ end
end
diff --git a/app/controllers/api/bookmarks_controller.rb b/app/controllers/api/bookmarks_controller.rb
index ff6830baf51..1767fd7c066 100644
--- a/app/controllers/api/bookmarks_controller.rb
+++ b/app/controllers/api/bookmarks_controller.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class API::BookmarksController < API::BaseController
- PAGER_NUMBER = 25
+ PAGER_NUMBER = 20
def index
per = params[:per] || PAGER_NUMBER
diff --git a/app/controllers/api/micro_reports_controller.rb b/app/controllers/api/micro_reports_controller.rb
index d300a114aa8..39657b7ab7d 100644
--- a/app/controllers/api/micro_reports_controller.rb
+++ b/app/controllers/api/micro_reports_controller.rb
@@ -14,7 +14,12 @@ def update
private
def set_micro_report
- @micro_report = current_user.admin? ? MicroReport.find(params[:id]) : current_user.micro_reports.find(params[:id])
+ @micro_report =
+ if current_user.admin?
+ MicroReport.find(params[:id])
+ else
+ current_user.authored_micro_reports.find(params[:id])
+ end
end
def micro_report_params
diff --git a/app/controllers/api/pub_sub_controller.rb b/app/controllers/api/pub_sub_controller.rb
new file mode 100644
index 00000000000..3d01cc55ac9
--- /dev/null
+++ b/app/controllers/api/pub_sub_controller.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+class API::PubSubController < API::BaseController
+ skip_before_action :verify_authenticity_token
+ skip_before_action :require_login_for_api
+ skip_before_action :basic_auth
+ before_action :authenticate_pubsub_token
+
+ def create
+ result = ProcessTranscodingNotification.call(body: request.body.read)
+
+ if result.success?
+ head :ok
+ else
+ Rails.logger.error("Failed to process transcoding notification: #{result.error}")
+
+ if result.retryable == false
+ head :ok # 200系のステータスコードを返すとPub/Subは再送しない
+ else
+ head :internal_server_error
+ end
+ end
+ end
+
+ private
+
+ def authenticate_pubsub_token
+ authz = request.headers['Authorization'].to_s
+ token = authz[/\ABearer\s+(.+)\z/i, 1]
+ return if token && valid_pubsub_token?(token)
+
+ Rails.logger.warn('Unauthorized Pub/Sub request')
+ head :unauthorized
+ end
+
+ def valid_pubsub_token?(token)
+ validator = GoogleIDToken::Validator.new
+ expected_audience = "#{request.base_url}#{request.path}"
+ payload = validator.check(token, expected_audience)
+
+ expected_sa_email = ENV['PUBSUB_SERVICE_ACCOUNT_EMAIL']
+ sa_email_claim = payload['email']
+ sa_email_claim == expected_sa_email
+ rescue GoogleIDToken::ValidationError => e
+ Rails.logger.warn("Invalid JWT: #{e.message}")
+ false
+ end
+end
diff --git a/app/controllers/billing_portal_controller.rb b/app/controllers/billing_portal_controller.rb
index ed9738aec21..57f914de916 100644
--- a/app/controllers/billing_portal_controller.rb
+++ b/app/controllers/billing_portal_controller.rb
@@ -3,6 +3,6 @@
class BillingPortalController < ApplicationController
def create
session = Stripe::BillingPortal::Session.create(customer: current_user.customer_id)
- redirect_to session.url
+ redirect_to session.url, allow_other_host: true
end
end
diff --git a/app/controllers/current_user/bookmarks_controller.rb b/app/controllers/current_user/bookmarks_controller.rb
index 6e89ba1a89c..d24d54732e6 100644
--- a/app/controllers/current_user/bookmarks_controller.rb
+++ b/app/controllers/current_user/bookmarks_controller.rb
@@ -1,7 +1,20 @@
# frozen_string_literal: true
class CurrentUser::BookmarksController < ApplicationController
- def index
- @user = current_user
+ PAGER_NUMBER = 20
+
+ before_action :set_bookmarks, only: [:index]
+
+ def index; end
+
+ def destroy
+ current_user.bookmarks.find(params[:id]).destroy
+ head :no_content
+ end
+
+ private
+
+ def set_bookmarks
+ @bookmarks = current_user.bookmarks.preload(bookmarkable: :user).order(created_at: :desc, id: :desc).page(params[:page]).per(PAGER_NUMBER)
end
end
diff --git a/app/controllers/notifications_controller.rb b/app/controllers/notifications_controller.rb
index 823b08fde2b..3a3de6a5d12 100644
--- a/app/controllers/notifications_controller.rb
+++ b/app/controllers/notifications_controller.rb
@@ -5,6 +5,11 @@ class NotificationsController < ApplicationController
def index
@target = params[:target]
+ @notifications = UserNotificationsQuery.new(
+ user: current_user,
+ target: params[:target],
+ status: params[:status]
+ ).call.page(params[:page])
end
def show
diff --git a/app/controllers/training_completion_controller.rb b/app/controllers/training_completion_controller.rb
index faa924c2e8e..076ba33bfe1 100644
--- a/app/controllers/training_completion_controller.rb
+++ b/app/controllers/training_completion_controller.rb
@@ -15,7 +15,7 @@ def create
user = current_user
current_user.cancel_participation_from_regular_events
current_user.delete_and_assign_new_organizer
- Newspaper.publish(:training_completion_create, { user: })
+ ActiveSupport::Notifications.instrument('training_completion.create', user:)
user.clear_github_data
notify_to_user(user)
notify_to_admins(user)
diff --git a/app/controllers/users/micro_reports_controller.rb b/app/controllers/users/micro_reports_controller.rb
index 15cb80d9b37..85fd6c62644 100644
--- a/app/controllers/users/micro_reports_controller.rb
+++ b/app/controllers/users/micro_reports_controller.rb
@@ -13,22 +13,28 @@ def index
def create
@micro_report = @user.micro_reports.build(micro_report_params)
+ @micro_report.comment_user = current_user
- if current_user == @user && @micro_report.save
+ if @micro_report.save
flash[:notice] = '分報を投稿しました。'
else
flash[:alert] = '分報の投稿に失敗しました。'
end
- redirect_to user_micro_reports_path(@user, page: @user.latest_micro_report_page)
+ redirect_to user_micro_reports_path(@user, page: @user.latest_micro_report_page(per_page: PAGER_NUMBER))
end
def destroy
+ if !current_user.admin? && @micro_report.comment_user != current_user
+ redirect_to user_micro_reports_path(@user), alert: '権限がありません。'
+ return
+ end
+
@micro_report.destroy!
referer_path = request.referer
if page_out_of_range?(referer_path)
- redirect_to user_micro_reports_path(@user, page: @user.latest_micro_report_page)
+ redirect_to user_micro_reports_path(@user, page: @user.latest_micro_report_page(per_page: PAGER_NUMBER))
else
redirect_to referer_path
end
@@ -42,7 +48,12 @@ def set_user
end
def set_micro_report
- @micro_report = current_user.admin? ? MicroReport.find(params[:id]) : current_user.micro_reports.find(params[:id])
+ @micro_report =
+ if current_user.admin?
+ MicroReport.find(params[:id])
+ else
+ current_user.authored_micro_reports.find(params[:id])
+ end
end
def micro_report_params
@@ -53,6 +64,6 @@ def page_out_of_range?(referer_path)
matched_page_number = referer_path.match(/page=(\d+)/)
page_number = matched_page_number ? matched_page_number[1] : FIRST_PAGE
- MicroReport.page(page_number).out_of_range?
+ @user.micro_reports.page(page_number).out_of_range?
end
end
diff --git a/app/interactors/process_transcoding_notification.rb b/app/interactors/process_transcoding_notification.rb
new file mode 100644
index 00000000000..79350ccf139
--- /dev/null
+++ b/app/interactors/process_transcoding_notification.rb
@@ -0,0 +1,94 @@
+# frozen_string_literal: true
+
+class ProcessTranscodingNotification
+ include Interactor
+
+ def call
+ message = parse_pubsub_message(context.body)
+
+ job_name = message[:job_name]
+ job_state = message[:job_state]
+ job_error = message[:job_error]
+
+ movie = find_movie(job_name)
+ context.fail!(retryable: false, error: "Movie not found for job_name: #{message[:job_name]}") unless movie
+
+ handle_job_state(movie, job_name, job_state, job_error)
+ rescue Interactor::Failure
+ raise
+ rescue StandardError => e
+ Rails.logger.error("Unhandled error in ProcessTranscodingNotification: #{e.class}: #{e.message}")
+ context.fail!(retryable: true, error: e.message)
+ end
+
+ private
+
+ def parse_pubsub_message(body)
+ message = JSON.parse(body)
+ encoded_data = message.dig('message', 'data')
+ context.fail!(retryable: false, error: "Missing 'data' field in Pub/Sub message") unless encoded_data
+
+ data = JSON.parse(Base64.decode64(encoded_data))
+ job_name, job_state, job_error = data.fetch('job', {}).values_at('name', 'state', 'error')
+
+ if job_name.blank? || job_state.blank?
+ context.fail!(retryable: false,
+ error: "Pub/Sub message missing required job fields: name=#{job_name}, state=#{job_state}")
+ end
+ { job_name:, job_state:, job_error: }
+ rescue JSON::ParserError, ArgumentError => e
+ context.fail!(retryable: false, error: "Invalid JSON/base64: #{e.message}")
+ end
+
+ def find_movie(job_name)
+ movie_id = Transcoder::Client.new.get_movie_id(job_name)
+ Movie.find_by(id: movie_id)
+ rescue Google::Cloud::Error => e
+ Rails.logger.error("Failed to get movie_id for job #{job_name}: #{e.message}")
+ nil
+ end
+
+ def handle_job_state(movie, job_name, job_state, job_error)
+ case job_state
+ when 'SUCCEEDED'
+ attach_transcoded_file(movie)
+ when 'FAILED', 'CANCELLED'
+ if audio_missing_error?(job_error)
+ Rails.logger.warn("Audio missing error detected for Movie #{movie.id}. Retrying without audio.")
+ TranscodeJob.perform_later(movie, force_video_only: true)
+ else
+ Rails.logger.error("Transcoding job #{job_name} for Movie #{movie.id} failed or cancelled.")
+ end
+ else
+ Rails.logger.warn("Unknown job state: #{job_state} for Movie #{movie.id}")
+ end
+ end
+
+ def attach_transcoded_file(movie)
+ transcoded_movie = Transcoder::Movie.new(movie)
+ movie.movie_data.attach(io: transcoded_movie.data, filename: "#{movie.id}.mp4")
+ movie.save!
+ Rails.logger.info("Successfully attached transcoded movie for Movie #{movie.id}")
+ rescue StandardError => e
+ Rails.logger.error("Failed to attach transcoded movie for Movie #{movie.id}: #{e.message}")
+ raise
+ ensure
+ transcoded_movie.cleanup if transcoded_movie.respond_to?(:cleanup)
+ end
+
+ def audio_missing_error?(error)
+ return false if error.blank?
+
+ details = error['details'] || []
+ details.any? do |detail|
+ field_violations = detail['fieldViolations'] || []
+ field_violations.any? do |fv|
+ description = fv['description'].to_s
+ description.match?(/AudioMissing|audio.*not.*found|no.*audio.*stream/i)
+ end
+ end
+ rescue StandardError => e
+ Rails.logger.error("Failed to check audio missing error: #{e.message}")
+ false
+ end
+end
diff --git a/app/javascript/bookmarks-delete-button-visibility.js b/app/javascript/bookmarks-delete-button-visibility.js
new file mode 100644
index 00000000000..47ad0e9f8a5
--- /dev/null
+++ b/app/javascript/bookmarks-delete-button-visibility.js
@@ -0,0 +1,6 @@
+export function toggleDeleteButtonVisibility(editToggle, deleteButtons) {
+ const displayStyle = editToggle.checked ? 'block' : 'none'
+ for (const button of deleteButtons) {
+ button.style.display = displayStyle
+ }
+}
diff --git a/app/javascript/bookmarks-edit-button.js b/app/javascript/bookmarks-edit-button.js
deleted file mode 100644
index 76407116d92..00000000000
--- a/app/javascript/bookmarks-edit-button.js
+++ /dev/null
@@ -1,23 +0,0 @@
-document.addEventListener('DOMContentLoaded', () => {
- const bookMarksEditButton = document.getElementById('bookmark_edit')
- const bookmarkDeleteButton = document.getElementsByClassName(
- 'js-bookmark-delete-button'
- )
- if (bookMarksEditButton && bookmarkDeleteButton) {
- for (let i = 0; i < bookmarkDeleteButton.length; i++) {
- bookmarkDeleteButton[i].style.display = 'none'
- }
-
- bookMarksEditButton.addEventListener('click', () => {
- if (bookMarksEditButton.checked) {
- for (let i = 0; i < bookmarkDeleteButton.length; i++) {
- bookmarkDeleteButton[i].style.display = 'block'
- }
- } else {
- for (let i = 0; i < bookmarkDeleteButton.length; i++) {
- bookmarkDeleteButton[i].style.display = 'none'
- }
- }
- })
- }
-})
diff --git a/app/javascript/bookmarks.js b/app/javascript/bookmarks.js
new file mode 100644
index 00000000000..7033ef8c2f9
--- /dev/null
+++ b/app/javascript/bookmarks.js
@@ -0,0 +1,83 @@
+import { get, destroy } from '@rails/request.js'
+import { toggleDeleteButtonVisibility } from './bookmarks-delete-button-visibility'
+
+const EDIT_MODE_KEY = 'bookmark_edit_mode'
+const DELETE_BUTTON_CLASS = 'js-bookmark-delete-button'
+
+document.addEventListener('DOMContentLoaded', () => {
+ const editButton = document.getElementById('bookmark_edit')
+ const pageBody = document.querySelector('.page-body')
+ if (!pageBody) return
+
+ const savedValue = sessionStorage.getItem(EDIT_MODE_KEY)
+ const savedMode = savedValue === 'true'
+ initialize(savedMode)
+
+ if (editButton) {
+ editButton.addEventListener('change', () => {
+ const deleteButtons = document.getElementsByClassName(DELETE_BUTTON_CLASS)
+ toggleDeleteButtonVisibility(editButton, deleteButtons)
+ sessionStorage.setItem(EDIT_MODE_KEY, editButton.checked)
+ })
+ }
+
+ document.addEventListener('click', async (event) => {
+ const deleteButton = event.target.closest('.bookmark-delete-button')
+ if (!deleteButton) return
+
+ deleteButton.disabled = true
+
+ try {
+ const url = deleteButton.dataset.url
+ const response = await destroy(url)
+
+ if (!response.ok) {
+ throw new Error(`削除に失敗しました。(ステータス: ${response.status})`)
+ }
+
+ const params = new URLSearchParams(location.search)
+ const currentPage = parseInt(params.get('page') || '1', 10)
+ const newPageMain = await fetchPageMain(currentPage)
+
+ // 空ページの場合は1ページ前にフォールバック
+ let pageToShow = newPageMain
+ if (currentPage > 1 && newPageMain.querySelector('.o-empty-message')) {
+ pageToShow = await fetchPageMain(currentPage - 1)
+ }
+ document.querySelector('.page-body').replaceWith(pageToShow)
+
+ const savedModeAfterDelete =
+ sessionStorage.getItem(EDIT_MODE_KEY) === 'true'
+ initialize(savedModeAfterDelete)
+ } catch (error) {
+ console.warn(error)
+ deleteButton.disabled = false
+ }
+ })
+})
+
+window.addEventListener('beforeunload', () => {
+ const isBookmarkPage = location.pathname.includes('/current_user/bookmarks')
+ if (!isBookmarkPage) {
+ sessionStorage.removeItem(EDIT_MODE_KEY)
+ }
+})
+
+const fetchPageMain = async (page) => {
+ const bookmarkUrl = `/current_user/bookmarks?page=${page}`
+ const response = await get(bookmarkUrl, { responseKind: 'html' })
+ const html = await response.text
+ const parser = new DOMParser()
+ const parsedDocument = parser.parseFromString(html, 'text/html')
+ return parsedDocument.querySelector('.page-body')
+}
+
+const initialize = (deleteMode = false) => {
+ const editButton = document.getElementById('bookmark_edit')
+ const deleteButtons = document.getElementsByClassName(DELETE_BUTTON_CLASS)
+
+ if (editButton && deleteButtons.length > 0) {
+ editButton.checked = deleteMode
+ toggleDeleteButtonVisibility(editButton, deleteButtons)
+ }
+}
diff --git a/app/javascript/components/Bookmarks.jsx b/app/javascript/components/Bookmarks.jsx
deleted file mode 100644
index db737cd07f3..00000000000
--- a/app/javascript/components/Bookmarks.jsx
+++ /dev/null
@@ -1,240 +0,0 @@
-import React, { useState, useEffect, useRef } from 'react'
-import useSWR, { useSWRConfig } from 'swr'
-import fetcher from '../fetcher'
-import { destroy } from '@rails/request.js'
-import userIcon from '../user-icon.js'
-import Pagination from './Pagination'
-import usePage from './hooks/usePage'
-import { formatDateToJapanese } from '../dateFormatter'
-
-export default function Bookmarks() {
- const [editable, setEditable] = useState(false)
- const per = 20
- const { page, setPage } = usePage()
- const bookmarksUrl = `/api/bookmarks.json?page=${page}&per=${per}`
-
- const { data, error } = useSWR(bookmarksUrl, fetcher)
- if (error) return <>エラーが発生しました。>
- if (!data) return <>ロード中…>
-
- if (data.totalPages === 0) {
- return
- } else {
- return (
-
-
-
- {/* .page-main-header */}
-
-
-
- {data.totalPages > 1 && (
-
- )}
-
-
- {data.bookmarks.map((bookmark) => {
- return (
-
- )
- })}
-
-
- {data.totalPages > 1 && (
-
- )}
-
- {/* .container */}
-
- {/* .page-body */}
-
- {/* .page-main */}
-
- )
- }
-}
-
-const NoBookmarks = () => {
- return (
-
-
- {/* .page-main-header */}
-
-
-
-
-
-
- ブックマークはまだありません。
-
-
-
-
-
- )
-}
-
-const EditButton = ({ editable, setEditable }) => {
- return (
- <>
-
-
- >
- )
-}
-
-const Bookmark = ({ bookmark, editable, bookmarksUrl }) => {
- // userIconの非React化により、useRef,useEffectを導入している。
- const userIconRef = useRef(null)
- useEffect(() => {
- const linkClass = 'card-list-item__user-link'
- const imgClasses = ['card-list-item__user-icon', 'a-user-icon']
-
- const userIconElement = userIcon({
- user: bookmark.user,
- linkClass,
- imgClasses
- })
-
- if (userIconRef.current) {
- userIconRef.current.innerHTML = ''
- userIconRef.current.appendChild(userIconElement)
- }
- }, [bookmark.user])
-
- const date = bookmark.reported_on || bookmark.created_at
- const createdAt = formatDateToJapanese(date)
- const { mutate } = useSWRConfig()
- const afterDelete = async (id) => {
- try {
- const response = await destroy(`/api/bookmarks/${id}.json`)
- if (response.ok) {
- mutate(bookmarksUrl)
- } else {
- console.warn('削除に失敗しました。')
- }
- } catch (error) {
- console.warn(error)
- }
- }
-
- return (
-
-
- {bookmark.modelName === 'Talk' ? (
-
- ) : (
-
{bookmark.modelNameI18n}
- )}
-
-
- {bookmark.modelName !== 'Talk' && (
-
-
-
-
-
-
-
-
-
-
-
- )}
-
- {editable && (
-
- )}
-
-
- )
-}
-
-const DeleteButton = ({ id, afterDelete }) => {
- return (
-
-
afterDelete(id)}>
- 削除
-
-
- )
-}
diff --git a/app/javascript/components/Notification.jsx b/app/javascript/components/Notification.jsx
deleted file mode 100644
index d2c53592722..00000000000
--- a/app/javascript/components/Notification.jsx
+++ /dev/null
@@ -1,74 +0,0 @@
-import React, { useEffect, useRef } from 'react'
-import userIcon from '../user-icon.js'
-import dayjs from 'dayjs'
-import ja from 'dayjs/locale/ja'
-dayjs.locale(ja)
-
-export default function Notification({ notification }) {
- // userIconの非React化により、useRef,useEffectを導入している。
- const userIconRef = useRef(null)
- useEffect(() => {
- const linkClass = 'card-list-item__user-link'
- const imgClasses = ['card-list-item__user-icon', 'a-user-icon']
-
- const userIconElement = userIcon({
- user: notification.sender,
- linkClass,
- imgClasses
- })
-
- if (userIconRef.current) {
- userIconRef.current.innerHTML = ''
- userIconRef.current.appendChild(userIconElement)
- }
- }, [notification.sender])
-
- const createdAt = dayjs(notification.created_at).format(
- 'YYYY年MM月DD日(ddd) HH:mm'
- )
-
- return (
-
-
-
-
-
-
-
- {notification.read === false && (
-
- 未読
-
- )}
-
-
-
-
-
-
-
-
- )
-}
diff --git a/app/javascript/components/Notifications.jsx b/app/javascript/components/Notifications.jsx
deleted file mode 100644
index 946172e817a..00000000000
--- a/app/javascript/components/Notifications.jsx
+++ /dev/null
@@ -1,151 +0,0 @@
-import React from 'react'
-import Notification from './Notification'
-import LoadingListPlaceholder from './LoadingListPlaceholder'
-import Pagination from './Pagination'
-import UnconfirmedLink from './UnconfirmedLink'
-import useSWR from 'swr'
-import fetcher from '../fetcher'
-import usePage from './hooks/usePage'
-
-export default function Notifications({ isMentor }) {
- const per = 20
- const isUnreadPage = () => {
- const params = new URLSearchParams(location.search)
- return params.get('status') === 'unread'
- }
- const apiUrl = () => {
- const searchParams = new URLSearchParams()
- const params = new URLSearchParams(location.search)
- const target = params.get('target')
- if (target) {
- searchParams.set('target', target)
- }
-
- const status = params.get('status')
- if (status) {
- searchParams.set('status', status)
- }
-
- const page = params.get('page') ?? 1
- searchParams.set('page', page)
-
- const url = new URL('api/notifications.json', location.origin)
- url.search = searchParams
-
- return url.toString()
- }
-
- const { page, setPage } = usePage()
- const { data, error } = useSWR(apiUrl, fetcher)
-
- if (error) {
- console.warn(error)
- return failed to load
- } else if (!data) {
- return (
-
-
-
- )
- } else if (data.notifications.length === 0) {
- return (
- <>
-
-
-
-
-
-
- {isUnreadPage() ? '未読の通知はありません' : '通知はありません'}
-
-
- >
- )
- }
-
- return (
- <>
-
-
- {data.total_pages > 1 && (
-
- )}
-
- {data.notifications.map((notification) => {
- return (
-
- )
- })}
-
- {isMentor && isUnreadPage() && (
-
- )}
- {data.total_pages > 1 && (
-
- )}
-
- >
- )
-}
-
-const FilterButton = () => {
- const filterButtonUrl = (status) => {
- const searchParams = new URLSearchParams()
- const params = new URLSearchParams(location.search)
- const target = params.get('target')
- if (target) {
- searchParams.set('target', target)
- }
-
- if (status) {
- searchParams.set('status', status)
- }
-
- const url = new URL('/notifications', location.origin)
- url.search = searchParams
-
- return url.toString()
- }
-
- const params = new URLSearchParams(location.search)
- return (
-
- )
-}
diff --git a/app/javascript/notifications_remove_after_open.js b/app/javascript/notifications_remove_after_open.js
new file mode 100644
index 00000000000..5e92437f21a
--- /dev/null
+++ b/app/javascript/notifications_remove_after_open.js
@@ -0,0 +1,27 @@
+document.addEventListener('DOMContentLoaded', () => {
+ const notificationPage = document.querySelector('#notifications')
+ if (!notificationPage) return
+
+ const allOpenButton = document.querySelector(
+ '#js-shortcut-unconfirmed-links-open'
+ )
+ if (!allOpenButton) return
+
+ allOpenButton.addEventListener('click', () => {
+ document.querySelector('.card-list')?.remove()
+ allOpenButton.closest('.card-footer')?.remove()
+ document.querySelector('.a-border-tint')?.remove()
+
+ const container = document.querySelector('.page-content')
+ if (container) {
+ container.innerHTML = `
+
+ `
+ }
+ })
+})
diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js
index 42c89b21ff5..a751411e65f 100644
--- a/app/javascript/packs/application.js
+++ b/app/javascript/packs/application.js
@@ -44,7 +44,7 @@ import '../learning-completion-message.js'
import '../choices-ui.js'
import '../training-info-toggler.js'
import '../welcome_message_for_adviser.js'
-import '../bookmarks-edit-button.js'
+import '../bookmarks.js'
import '../hibernation_agreements.js'
import '../current-date-time-setter.js'
import '../modal-switcher.js'
@@ -85,6 +85,7 @@ import '../toast.js'
import '../tag.js'
import '../tag_edit.js'
import '../bookmark-button.js'
+import '../notifications_remove_after_open.js'
import '../stylesheets/application.sass'
diff --git a/app/jobs/transcode_job.rb b/app/jobs/transcode_job.rb
new file mode 100644
index 00000000000..cb57cb5e8fe
--- /dev/null
+++ b/app/jobs/transcode_job.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+class TranscodeJob < ApplicationJob
+ MAX_RETRIES = 5
+ RETRY_WAIT = 1.minute
+
+ queue_as :default
+
+ def perform(movie, force_video_only: false)
+ return unless Rails.application.config.transcoder['enabled']
+
+ Transcoder::Client.new(movie, force_video_only:).transcode
+ rescue Google::Cloud::Error => e
+ handle_transcode_error(e, movie)
+ end
+
+ private
+
+ def handle_transcode_error(error, movie)
+ if retryable?(error) && executions < MAX_RETRIES
+ log_retry(movie, error)
+ retry_job(wait: RETRY_WAIT)
+ return
+ end
+
+ log_failure(movie, error)
+ notify_failure(movie, error)
+ end
+
+ def retryable?(error)
+ code_str = error.respond_to?(:code) ? error.code.to_s.downcase : ''
+ %w[429 503 8 14 resource_exhausted unavailable].include?(code_str)
+ end
+
+ def log_retry(movie, error)
+ Rails.logger.warn(
+ "Retrying Transcoding for Movie #{movie.id} " \
+ "(code=#{error.code}, attempt=#{executions + 1})"
+ )
+ end
+
+ def log_failure(movie, error)
+ code = error.respond_to?(:code) ? error.code : 'n/a'
+ Rails.logger.error(
+ "Transcoding failed for Movie #{movie.id}: #{error.message} (code=#{code})"
+ )
+ end
+
+ def notify_failure(movie, error)
+ # 捕まえた例外は Rollbar に自動送信されないため、明示的に通知する。
+ # movieの作成は既に完了しているため、ジョブ失敗でユーザー体験を損なわないよう例外は再送出しない
+ Rollbar.error(error, movie_id: movie.id) if defined?(Rollbar)
+ end
+end
diff --git a/app/models/micro_report.rb b/app/models/micro_report.rb
index 3e36ce3bf97..8c8581a498f 100644
--- a/app/models/micro_report.rb
+++ b/app/models/micro_report.rb
@@ -4,5 +4,14 @@ class MicroReport < ApplicationRecord
include Reactionable
belongs_to :user
+ belongs_to :comment_user, class_name: 'User'
validates :content, presence: true
+
+ before_validation :set_default_comment_user, on: :create
+
+ private
+
+ def set_default_comment_user
+ self.comment_user ||= user
+ end
end
diff --git a/app/models/movie.rb b/app/models/movie.rb
index 0d2e94808be..485b63c45e0 100644
--- a/app/models/movie.rb
+++ b/app/models/movie.rb
@@ -22,6 +22,8 @@ class Movie < ApplicationRecord
scope :wip, -> { where(wip: true) }
scope :by_tag, ->(tag) { tag.present? ? tagged_with(tag) : all }
+ after_create_commit :start_transcode_job, on: :create
+
def self.ransackable_attributes(_auth_object = nil)
%w[title description wip created_at updated_at user_id]
end
@@ -29,4 +31,13 @@ def self.ransackable_attributes(_auth_object = nil)
def self.ransackable_associations(_auth_object = nil)
%w[user practices comments reactions watches bookmarks]
end
+
+ private
+
+ def start_transcode_job
+ TranscodeJob.perform_later(self)
+ rescue StandardError => e
+ Rails.logger.error("Failed to enqueue TranscodeJob for Movie #{id}: #{e.message}")
+ raise
+ end
end
diff --git a/app/models/question_auto_closer.rb b/app/models/question_auto_closer.rb
index b7c57771d7e..bbf5f22bb17 100644
--- a/app/models/question_auto_closer.rb
+++ b/app/models/question_auto_closer.rb
@@ -13,7 +13,7 @@ def post_warning
system_user = User.find_by(login_name: SYSTEM_USER_LOGIN)
return unless system_user
- Question.not_solved.find_each do |question|
+ Question.not_wip.not_solved.find_each do |question|
next unless should_post_warning?(question, system_user)
create_warning_message(question, system_user)
@@ -24,7 +24,7 @@ def close_and_select_best_answer
system_user = User.find_by(login_name: SYSTEM_USER_LOGIN)
return unless system_user
- Question.not_solved.find_each do |question|
+ Question.not_wip.not_solved.find_each do |question|
next unless should_close?(question, system_user)
close_with_best_answer(question, system_user)
@@ -34,7 +34,8 @@ def close_and_select_best_answer
private
def should_post_warning?(question, system_user)
- last_activity_at = question.answers.order(created_at: :desc).first&.created_at || question.created_at
+ last_updated_answer = question.answers.order(updated_at: :desc, id: :desc).first
+ last_activity_at = [last_updated_answer&.updated_at, question.updated_at].compact.max
return false unless last_activity_at <= 1.month.ago
!system_message?(question, system_user, ANY_CLOSE_MESSAGE_PATTERN) &&
@@ -42,10 +43,11 @@ def should_post_warning?(question, system_user)
end
def create_warning_message(question, system_user)
- question.answers.create!(
+ answer = question.answers.create!(
user: system_user,
description: AUTO_CLOSE_WARNING_MESSAGE
)
+ ActiveSupport::Notifications.instrument('answer.create', answer:)
end
def should_close?(question, system_user)
diff --git a/app/models/retirement.rb b/app/models/retirement.rb
index cdf794db87a..32aab615db7 100644
--- a/app/models/retirement.rb
+++ b/app/models/retirement.rb
@@ -33,7 +33,7 @@ def execute
remove_as_event_organizer
clear_github_info
destroy_cards
- publish_to_newspaper
+ publish
notify
true
rescue ActiveRecord::RecordInvalid => e
@@ -82,8 +82,8 @@ def remove_as_event_organizer
@user.delete_and_assign_new_organizer
end
- def publish_to_newspaper
- Newspaper.publish(:retirement_create, { user: @user })
+ def publish
+ ActiveSupport::Notifications.instrument('retirement.create', user: @user)
end
def notify
diff --git a/app/models/times_channel_destroyer.rb b/app/models/times_channel_destroyer.rb
index 4a295bd9dde..d00cb5c5d40 100644
--- a/app/models/times_channel_destroyer.rb
+++ b/app/models/times_channel_destroyer.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class TimesChannelDestroyer
- def call(payload)
+ def call(_name, _started, _finished, _id, payload)
user = payload[:user]
return unless user.discord_profile.times_id
diff --git a/app/models/transcoder/client.rb b/app/models/transcoder/client.rb
new file mode 100644
index 00000000000..8825a540564
--- /dev/null
+++ b/app/models/transcoder/client.rb
@@ -0,0 +1,128 @@
+# frozen_string_literal: true
+
+module Transcoder
+ class Client
+ def initialize(movie = nil, config: nil, bucket_name: nil, project_id: nil, force_video_only: false)
+ @movie = movie
+ @config = config || default_config
+ @bucket_name = bucket_name || default_storage_config['bucket']
+ @project_id = project_id || default_storage_config['project']
+ @force_video_only = force_video_only
+
+ validate_configuration
+ end
+
+ def transcode
+ return if existing_job
+
+ # 処理完了後にAPI::PubSubControllerへPub/Subで通知を送る
+
+ transcoder_service.create_job(
+ parent: parent_path,
+ job: {
+ input_uri:,
+ output_uri:,
+ config: {
+ elementary_streams:,
+ mux_streams:,
+ pubsub_destination: { topic: pubsub_topic_path }
+ },
+ labels: { movie_id: @movie.id.to_s }
+ }
+ )
+ rescue Google::Cloud::Error => e
+ Rails.logger.error("Failed to create transcoding job for Movie #{@movie.id}: #{e.message}")
+ raise
+ end
+
+ def get_movie_id(job_name)
+ return nil if job_name.blank?
+
+ job = transcoder_service.get_job(name: job_name)
+ job&.labels&.[]('movie_id')
+ rescue Google::Cloud::Error => e
+ Rails.logger.error("Failed to get job #{job_name}: #{e.message}")
+ nil
+ end
+
+ private
+
+ def existing_job
+ transcoder_service.list_jobs(parent: parent_path).find do |job|
+ job.labels&.[]('movie_id') == @movie.id.to_s &&
+ %w[PENDING RUNNING].include?(job.state.to_s)
+ end
+ rescue Google::Cloud::Error => e
+ Rails.logger.error("Failed to check existing jobs for Movie #{@movie.id}: #{e.message}")
+ nil
+ end
+
+ def validate_configuration
+ raise ArgumentError, 'bucket_name is required' if @bucket_name.blank?
+ raise ArgumentError, 'project_id is required' if @project_id.blank?
+
+ %w[location pubsub_topic].each do |key|
+ raise ArgumentError, "#{key} is required" if @config[key].blank?
+ end
+ end
+
+ def elementary_streams
+ [video_stream_config, (!@force_video_only ? audio_stream_config : nil)].compact
+ end
+
+ def video_stream_config
+ { key: 'video-stream',
+ video_stream: { h264: { height_pixels: @config['video_height'], width_pixels: @config['video_width'], bitrate_bps: @config['video_bitrate'],
+ frame_rate: @config['video_frame_rate'] } } }
+ end
+
+ def audio_stream_config
+ { key: 'audio-stream', audio_stream: { codec: @config['audio_codec'], bitrate_bps: @config['audio_bitrate'] } }
+ end
+
+ def mux_streams
+ [{ key: 'muxed-stream', container: @config['container'], elementary_streams: ['video-stream', ('audio-stream' unless @force_video_only)].compact }]
+ end
+
+ def transcoder_service
+ Google::Cloud::Video::Transcoder.transcoder_service
+ end
+
+ def location
+ @config['location']
+ end
+
+ def service_name
+ ActiveStorage::Blob.service.name.to_s
+ end
+
+ def default_storage_config
+ Rails.application.config.active_storage.service_configurations[ActiveStorage::Blob.service.name.to_s] || raise('ActiveStorage service not found')
+ end
+
+ def input_uri
+ key = @movie&.movie_data&.blob&.key
+ raise ArgumentError, 'Movie and movie_data blob.key are required' if key.blank?
+
+ "gs://#{@bucket_name}/#{key}"
+ end
+
+ def output_uri
+ raise ArgumentError, 'Movie ID is required' unless @movie&.id
+
+ "gs://#{@bucket_name}/#{@movie.id}/"
+ end
+
+ def parent_path
+ "projects/#{@project_id}/locations/#{location}"
+ end
+
+ def pubsub_topic_path
+ "projects/#{@project_id}/topics/#{@config['pubsub_topic']}"
+ end
+
+ def default_config
+ Rails.application.config.transcoder
+ end
+ end
+end
diff --git a/app/models/transcoder/movie.rb b/app/models/transcoder/movie.rb
new file mode 100644
index 00000000000..96477601582
--- /dev/null
+++ b/app/models/transcoder/movie.rb
@@ -0,0 +1,85 @@
+# frozen_string_literal: true
+
+module Transcoder
+ class Movie
+ def initialize(movie, bucket_name: nil, path: nil)
+ @movie = movie
+ @bucket_name = bucket_name || default_bucket_name
+ @path = path || default_path
+ @tempfile = nil
+ end
+
+ def data
+ raise 'Transcoded file not found' unless file&.exists?
+
+ @tempfile = Tempfile.new([@movie.id.to_s, '.mp4'], binmode: true)
+
+ file.download @tempfile.path
+
+ @tempfile.rewind
+ @tempfile
+ rescue Google::Cloud::Storage::FileVerificationError => e
+ Rails.logger.error "File verification failed: #{e.message}"
+ raise
+ rescue Google::Cloud::Error => e
+ Rails.logger.error "Failed to download transcoded file: #{e.message}"
+ raise
+ end
+
+ def cleanup
+ cleanup_gcs
+ cleanup_tempfile
+ end
+
+ private
+
+ def cleanup_gcs
+ return unless file&.exists?
+
+ file.delete
+ rescue Google::Cloud::Storage::FileVerificationError, Google::Cloud::Error => e
+ # クリーンアップ失敗は処理結果に影響させない
+ Rails.logger.warn "Cleanup skipped (GCS): #{e.class}: #{e.message}"
+ end
+
+ def cleanup_tempfile
+ return unless @tempfile
+
+ @tempfile.close!
+ @tempfile = nil
+ rescue StandardError => e
+ # クリーンアップ失敗は処理結果に影響させない
+ Rails.logger.warn "Cleanup skipped (Tempfile): #{e.class}: #{e.message}"
+ end
+
+ def file
+ @file ||= bucket.file(@path)
+ end
+
+ def bucket
+ @bucket ||= begin
+ bucket_obj = storage.bucket(@bucket_name)
+ raise "Bucket not found or inaccessible: #{@bucket_name}" unless bucket_obj
+
+ bucket_obj
+ end
+ end
+
+ def storage
+ Google::Cloud::Storage.new
+ end
+
+ def default_bucket_name
+ service_name = ActiveStorage::Blob.service.name.to_s
+ config = Rails.application.config.active_storage.service_configurations[service_name]
+ raise "ActiveStorage service configuration not found: #{service_name}" unless config
+ raise "Bucket not configured for service: #{service_name}" unless config['bucket']
+
+ config['bucket']
+ end
+
+ def default_path
+ "#{@movie.id}/muxed-stream.mp4"
+ end
+ end
+end
diff --git a/app/models/unfinished_data_destroyer.rb b/app/models/unfinished_data_destroyer.rb
index 6af72f7484e..38ae6d0240d 100644
--- a/app/models/unfinished_data_destroyer.rb
+++ b/app/models/unfinished_data_destroyer.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class UnfinishedDataDestroyer
- def call(payload)
+ def call(_name, _started, _finished, _id, payload)
user = payload[:user]
Product.where(user:).unchecked.destroy_all
Report.where(user:).wip.destroy_all
diff --git a/app/models/user.rb b/app/models/user.rb
index 7cf7eb62a57..22c8fc29d6d 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -130,6 +130,7 @@ class User < ApplicationRecord # rubocop:todo Metrics/ClassLength
has_many :request_retirements, dependent: :destroy
has_one :targeted_request_retirement, class_name: 'RequestRetirement', foreign_key: 'target_user_id', dependent: :destroy, inverse_of: :target_user
has_many :micro_reports, dependent: :destroy
+ has_many :authored_micro_reports, class_name: 'MicroReport', foreign_key: 'comment_user_id', dependent: :destroy, inverse_of: :comment_user
has_many :learning_time_frames_users, dependent: :destroy
has_many :participate_events,
@@ -921,8 +922,8 @@ def grant_course?
course_id.present? && course&.grant?
end
- def latest_micro_report_page
- [micro_reports.page.total_pages, 1].max
+ def latest_micro_report_page(per_page: 25)
+ [micro_reports.page.per(per_page).total_pages, 1].max
end
def mark_mail_as_sent_before_auto_retire
diff --git a/app/queries/user_notifications_query.rb b/app/queries/user_notifications_query.rb
index 6405236deb4..b1afcff5503 100644
--- a/app/queries/user_notifications_query.rb
+++ b/app/queries/user_notifications_query.rb
@@ -5,7 +5,7 @@ class UserNotificationsQuery < Patterns::Query
private
- def initialize(relation = Notification.all, user:, target: nil, status: nil)
+ def initialize(relation = Notification.all, user:, target:, status:)
super(relation)
@user = user
@target = target
@@ -14,7 +14,7 @@ def initialize(relation = Notification.all, user:, target: nil, status: nil)
def query
latest_notifications = @user.notifications
- .by_target(@target)
+ .by_target(validated_target)
.by_read_status(@status)
.latest_of_each_link
@@ -22,4 +22,9 @@ def query
.from(latest_notifications, :notifications)
.order(created_at: :desc)
end
+
+ def validated_target
+ target = @target&.to_sym
+ Notification::TARGETS_TO_KINDS.key?(target) ? target : nil
+ end
end
diff --git a/app/views/current_user/bookmarks/_empty.html.slim b/app/views/current_user/bookmarks/_empty.html.slim
new file mode 100644
index 00000000000..2cf4dcbc87b
--- /dev/null
+++ b/app/views/current_user/bookmarks/_empty.html.slim
@@ -0,0 +1,11 @@
+.page-main-header
+ .container
+ .page-main-header__inner
+ .page-main-header__start
+ h1.page-main-header__title ブックマーク
+hr.a-border
+.page-body
+ .o-empty-message
+ .o-empty-message__icon
+ i.fa-regular.fa-face-sad-tear
+ p.o-empty-message__text ブックマークはまだありません。
diff --git a/app/views/current_user/bookmarks/_list.html.slim b/app/views/current_user/bookmarks/_list.html.slim
new file mode 100644
index 00000000000..6d6d8a817c8
--- /dev/null
+++ b/app/views/current_user/bookmarks/_list.html.slim
@@ -0,0 +1,59 @@
+.page-main-header
+ .container
+ .page-main-header__inner
+ .page-main-header__start
+ h1.page-main-header__title ブックマーク
+ .page-main-header__end
+ .page-header-actions
+ .page-header-actions__items
+ .page-header-actions__item.js-bookmark-edit-toggle
+ .form-item.is-inline
+ label.a-form-label for="card-list-tools__action"
+ | 編集
+ label.a-on-off-checkbox.is-sm
+ input#bookmark_edit[name="card-list-tools__action" type="checkbox"]
+ span#spec-edit-mode
+hr.a-border
+.page-body
+ .container.is-md
+ - if bookmarks.total_pages > 1
+ = paginate bookmarks
+
+ .card-list.a-card
+ .card-list__items
+ - bookmarks.each do |bookmark|
+ .card-list-item class="is-#{bookmark.bookmarkable_type.downcase}" id="bookmark-#{bookmark.id}"
+ .card-list-item__inner
+ - if bookmark.bookmarkable_type == 'Talk'
+ = image_tag bookmark.bookmarkable.user.avatar_url, class: 'card-list-item__user-icon a-user-icon'
+ - else
+ .card-list-item__label = bookmark.bookmarkable.model_name.human
+
+ .card-list-item__rows
+ .card-list-item__row
+ .card-list-item-title
+ .card-list-item-title__title
+ = link_to polymorphic_path(bookmark.bookmarkable), class: 'card-list-item-title__link a-text-link' do
+ = bookmark.bookmarkable_type == 'Talk' ? "#{bookmark.bookmarkable.user.long_name} さんの相談部屋" : bookmark.bookmarkable.title
+
+ - if bookmark.bookmarkable_type != 'Talk'
+ .card-list-item__row
+ .card-list-item__summary
+ p = truncate(search_summary(bookmark.bookmarkable, ''), length: 100)
+
+ .card-list-item__row
+ .card-list-item-meta
+ .card-list-item-meta__item
+ = link_to bookmark.bookmarkable.user.url, class: 'a-user-name' do
+ = "#{bookmark.bookmarkable.user.login_name}(#{bookmark.bookmarkable.user.name_kana})"
+ .card-list-item-meta__item
+ - date = bookmark.bookmarkable_type == 'Report' ? bookmark.bookmarkable.reported_on.to_time : bookmark.bookmarkable.created_at
+ time.a-meta datetime=date
+ = l(date, format: :default)
+
+ .card-list-item__option
+ .js-bookmark-delete-button
+ button.bookmark-delete-button.a-bookmark-button.a-button.is-sm.is-block.is-main[type="button" data-url=current_user_bookmark_path(bookmark)] 削除
+
+ - if bookmarks.total_pages > 1
+ = paginate bookmarks
diff --git a/app/views/current_user/bookmarks/index.html.slim b/app/views/current_user/bookmarks/index.html.slim
index e054472fc3c..94670f816d6 100644
--- a/app/views/current_user/bookmarks/index.html.slim
+++ b/app/views/current_user/bookmarks/index.html.slim
@@ -4,4 +4,8 @@
= render 'home/page_header'
= dashboard_page_tabs(active_tab: 'ブックマーク')
-= react_component 'Bookmarks'
+.page-main
+ - if @bookmarks.empty?
+ = render 'current_user/bookmarks/empty'
+ - else
+ = render 'current_user/bookmarks/list', bookmarks: @bookmarks
diff --git a/app/views/notifications/_filter_button.html.slim b/app/views/notifications/_filter_button.html.slim
new file mode 100644
index 00000000000..bdee6f86148
--- /dev/null
+++ b/app/views/notifications/_filter_button.html.slim
@@ -0,0 +1,12 @@
+nav.pill-nav
+ .container
+ ul.pill-nav__items
+ li.pill-nav__item
+ = link_to '未読',
+ notifications_path(status: 'unread', target: params[:target]),
+ class: "pill-nav__item-link #{params[:status] == 'unread' ? 'is-active' : ''}"
+
+ li.pill-nav__item
+ = link_to '全て',
+ notifications_path(target: params[:target]),
+ class: "pill-nav__item-link #{params[:status] == 'unread' ? '' : 'is-active'}"
diff --git a/app/views/notifications/_notification.html.slim b/app/views/notifications/_notification.html.slim
new file mode 100644
index 00000000000..35b2062e900
--- /dev/null
+++ b/app/views/notifications/_notification.html.slim
@@ -0,0 +1,30 @@
+- read_class = notification.read? ? 'is-read' : 'is-unread'
+
+.card-list-item class=read_class
+ .card-list-item__inner
+ .card-list-item__user
+ = link_to user_path(notification.sender), class: 'card-list-item__user-link' do
+ = image_tag notification.sender.avatar_url,
+ class: 'card-list-item__user-icon a-user-icon',
+ alt: notification.sender.login_name
+
+ .card-list-item__rows
+ .card-list-item__row
+ .card-list-item-title
+ .card-list-item-title__start
+ - if !notification.read?
+ .a-list-item-badge.is-unread
+ span 未読
+
+ h2.card-list-item-title__title[itemProp="name"]
+ = link_to notification.path,
+ class: 'card-list-item-title__link a-text-link js-unconfirmed-link',
+ itemProp: 'url' do
+ span.card-list-item-title__link-label = notification.message
+
+ .card-list-item__row
+ .card-list-item-meta
+ .card-list-item-meta__items
+ .card-list-item-meta__item
+ time.a-meta dateTime=notification.created_at
+ = l(notification.created_at, format: :long)
diff --git a/app/views/notifications/index.html.slim b/app/views/notifications/index.html.slim
index ff7fb73fdd2..bc83de0d866 100644
--- a/app/views/notifications/index.html.slim
+++ b/app/views/notifications/index.html.slim
@@ -44,4 +44,25 @@ main.page-main
hr.a-border
.page-body
.container.is-md
- = react_component('Notifications', isMentor: mentor_login?)
+ = render 'filter_button'
+ - if @notifications.empty?
+ .o-empty-message
+ .o-empty-message__icon
+ .i.fa-regular.fa-smile
+ p.o-empty-message__text
+ = params[:status] == 'unread' ? '未読の通知はありません' : '通知はありません'
+ - else
+ .page-content#notifications
+ - if @notifications.total_pages > 1
+ nav.pagination
+ = paginate @notifications
+ .card-list.a-card
+ - @notifications.each do |notification|
+ = render 'notification', notification: notification
+
+ - if current_user.mentor? && params[:status] == 'unread'
+ = render 'application/unconfirmed_links_open', label: '未読の通知を一括で開く'
+
+ - if @notifications.total_pages > 1
+ nav.pagination
+ = paginate @notifications
diff --git a/app/views/users/micro_reports/index.html.slim b/app/views/users/micro_reports/index.html.slim
index f3fd2a59934..497f171956a 100644
--- a/app/views/users/micro_reports/index.html.slim
+++ b/app/views/users/micro_reports/index.html.slim
@@ -8,13 +8,9 @@
.page-body.pb-0
.page-content
- - if current_user == @user
- .micro-reports__end
- = render(Users::MicroReports::FormComponent.new(user: @user))
- - micro_reports_class = 'micro-reports-with-form'
- - else
- - micro_reports_class = 'micro-reports-without-form'
- .micro-reports#js-micro-reports class=micro_reports_class
+ .micro-reports__end
+ = render(Users::MicroReports::FormComponent.new(user: @user))
+ .micro-reports#js-micro-reports.micro-reports-with-form
.micro-reports__start
.container.is-md
.micro-reports__items
diff --git a/config/application.rb b/config/application.rb
index c375bf6a840..2c38dcf2524 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -34,6 +34,14 @@ class Application < Rails::Application
config.active_storage.variant_processor = :vips
+ # Allow reading legacy Active Storage URLs that were signed with Marshal serializer
+ # Rails 7.2 defaults to :json, which cannot read old URLs
+ config.active_support.message_serializer = :json_allow_marshal
+
+ # Use SHA1 for key generator to support legacy Active Storage URLs
+ # Old URLs were signed with SHA1-based keys
+ config.active_support.key_generator_hash_digest_class = OpenSSL::Digest::SHA1
+
# Disable foreign key validation for fixtures
# Cloud SQL restricts access to pg_constraint system table
config.active_record.verify_foreign_keys_for_fixtures = false
@@ -43,5 +51,7 @@ class Application < Rails::Application
config.to_prepare do
Doorkeeper::AuthorizationsController.layout "authorization"
end
+
+ config.transcoder = config_for(:transcoder)
end
end
diff --git a/config/initializers/active_support_notifications.rb b/config/initializers/active_support_notifications.rb
index cb8d2eea85a..58924ff9cc8 100644
--- a/config/initializers/active_support_notifications.rb
+++ b/config/initializers/active_support_notifications.rb
@@ -60,4 +60,12 @@
question_notifier = QuestionNotifier.new
ActiveSupport::Notifications.subscribe('question.create', question_notifier)
ActiveSupport::Notifications.subscribe('question.update', question_notifier)
+
+ unfinished_data_destroyer = UnfinishedDataDestroyer.new
+ ActiveSupport::Notifications.subscribe('retirement.create', unfinished_data_destroyer)
+ ActiveSupport::Notifications.subscribe('training_completion.create', unfinished_data_destroyer)
+
+ times_channel_destroyer = TimesChannelDestroyer.new
+ ActiveSupport::Notifications.subscribe('retirement.create', times_channel_destroyer)
+ ActiveSupport::Notifications.subscribe('training_completion.create', times_channel_destroyer)
end
diff --git a/config/initializers/newspaper.rb b/config/initializers/newspaper.rb
index 08993429d2e..83906530ec1 100644
--- a/config/initializers/newspaper.rb
+++ b/config/initializers/newspaper.rb
@@ -1,13 +1,5 @@
# frozen_string_literal: true
Rails.configuration.after_initialize do
- unfinished_data_destroyer = UnfinishedDataDestroyer.new
- Newspaper.subscribe(:retirement_create, unfinished_data_destroyer)
- Newspaper.subscribe(:training_completion_create, unfinished_data_destroyer)
-
- times_channel_destroyer = TimesChannelDestroyer.new
- Newspaper.subscribe(:retirement_create, times_channel_destroyer)
- Newspaper.subscribe(:training_completion_create, times_channel_destroyer)
-
Newspaper.subscribe(:came_comment_in_talk, CommentNotifierForAdmin.new)
end
diff --git a/config/routes/api.rb b/config/routes/api.rb
index 4b5b259a107..b9545448c1b 100644
--- a/config/routes/api.rb
+++ b/config/routes/api.rb
@@ -9,6 +9,7 @@
namespace 'mentor' do
resources :practices, only: %i(index)
end
+ resource :pubsub, controller:"pub_sub", only: %i(create), defaults: { format: :json }
resource :session, controller: "session", only: %i(create)
resource :image, controller: "image", only: %i(create)
resources :courses, only: %i() do
diff --git a/config/routes/current_user.rb b/config/routes/current_user.rb
index dda7d75dc03..ac7e86d9f7e 100644
--- a/config/routes/current_user.rb
+++ b/config/routes/current_user.rb
@@ -5,6 +5,6 @@
resources :reports, only: %i(index)
resources :products, only: %i(index)
resources :watches, only: %i(index)
- resources :bookmarks, only: %i(index)
+ resources :bookmarks, only: %i(index destroy)
end
end
diff --git a/config/transcoder.yml b/config/transcoder.yml
new file mode 100644
index 00000000000..816a2e5b405
--- /dev/null
+++ b/config/transcoder.yml
@@ -0,0 +1,21 @@
+default: &default
+ enabled: false
+ video_height: 1080
+ video_width: 1920
+ video_bitrate: 12000000
+ video_frame_rate: 120
+ audio_codec: 'aac'
+ audio_bitrate: 128000
+ container: 'mp4'
+ location: 'asia-northeast1'
+ pubsub_topic: <%= ENV['PUBSUB_TOPIC'] %>
+
+production:
+ <<: *default
+ enabled: true
+
+development:
+ <<: *default
+
+test:
+ <<: *default
diff --git a/db/fixtures/micro_reports.yml b/db/fixtures/micro_reports.yml
index 52074ba2065..e4c05bc2ef0 100644
--- a/db/fixtures/micro_reports.yml
+++ b/db/fixtures/micro_reports.yml
@@ -1,21 +1,25 @@
micro_report1:
user: hajime
+ comment_user: hajime
content: 初めての分報です。
created_at: '2022-01-01 00:00:00'
micro_report2:
user: hajime
+ comment_user: hajime
content: 2個目の分報です。
created_at: '2022-01-02 00:00:00'
micro_report3:
user: hajime
+ comment_user: hajime
content: 3個目の分報です。
created_at: '2022-01-03 00:00:00'
<% (4..28).each do |id| %>
micro_report<%= id %>:
user: hajime
+ comment_user: hajime
content: <%= id %>個目の分報です。
created_at: '2022-01-04 00:00:00'
<% end %>
diff --git a/db/fixtures/questions.yml b/db/fixtures/questions.yml
index 94b40b0b40d..71becf988fa 100644
--- a/db/fixtures/questions.yml
+++ b/db/fixtures/questions.yml
@@ -113,6 +113,7 @@ question15:
user: kimura
practice: practice1
created_at: "2022-01-14"
+ updated_at: "2022-01-14"
wip: true
question16:
@@ -139,4 +140,14 @@ question51:
user: kimura
practice: practice1
created_at: <%= Time.current - 1.month - 1.week %>
+ updated_at: <%= Time.current - 1.month - 1.week %>
published_at: <%= Time.current - 1.month - 1.week %>
+
+question52:
+ title: 自動クローズ前に警告される質問
+ description: 自動クローズ前に警告される質問です。
+ user: kimura
+ practice: practice1
+ created_at: <%= Time.current - 1.month %>
+ updated_at: <%= Time.current - 1.month %>
+ published_at: <%= Time.current - 1.month %>
diff --git a/db/migrate/20251002152627_add_comment_users_to_micro_reports.rb b/db/migrate/20251002152627_add_comment_users_to_micro_reports.rb
new file mode 100644
index 00000000000..db325c11ad1
--- /dev/null
+++ b/db/migrate/20251002152627_add_comment_users_to_micro_reports.rb
@@ -0,0 +1,6 @@
+class AddCommentUsersToMicroReports < ActiveRecord::Migration[6.1]
+ def change
+ add_column :micro_reports, :comment_user_id, :bigint
+ add_foreign_key :micro_reports, :users, column: :comment_user_id
+ end
+end
diff --git a/db/migrate/20251103051313_backfill_comment_user_id_on_micro_reports.rb b/db/migrate/20251103051313_backfill_comment_user_id_on_micro_reports.rb
new file mode 100644
index 00000000000..a6b55488039
--- /dev/null
+++ b/db/migrate/20251103051313_backfill_comment_user_id_on_micro_reports.rb
@@ -0,0 +1,11 @@
+class BackfillCommentUserIdOnMicroReports < ActiveRecord::Migration[6.1]
+ def up
+ MicroReport.where(comment_user_id: nil).find_each do |report|
+ report.update!(comment_user_id: report.user_id)
+ end
+ end
+
+ def down
+ MicroReport.update_all(comment_user_id: nil)
+ end
+end
diff --git a/db/migrate/20251103053813_backfill_comment_user_id_on_micro_reports_v2.rb b/db/migrate/20251103053813_backfill_comment_user_id_on_micro_reports_v2.rb
new file mode 100644
index 00000000000..961c89ddffe
--- /dev/null
+++ b/db/migrate/20251103053813_backfill_comment_user_id_on_micro_reports_v2.rb
@@ -0,0 +1,12 @@
+class BackfillCommentUserIdOnMicroReportsV2 < ActiveRecord::Migration[6.1]
+ def up
+ MicroReport.where(comment_user_id: nil).find_each do |report|
+ next if report.user_id.blank?
+ report.update_column(:comment_user_id, report.user_id)
+ end
+ end
+
+ def down
+ MicroReport.update_all(comment_user_id: nil)
+ end
+end
diff --git a/db/migrate/20251220000001_add_missing_columns_to_good_jobs.rb b/db/migrate/20251220000001_add_missing_columns_to_good_jobs.rb
new file mode 100644
index 00000000000..79caa668843
--- /dev/null
+++ b/db/migrate/20251220000001_add_missing_columns_to_good_jobs.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+class AddMissingColumnsToGoodJobs < ActiveRecord::Migration[7.2]
+ def change
+ # Add missing columns to good_jobs table if they don't exist
+ add_column :good_jobs, :error_event, :integer, limit: 2 unless column_exists?(:good_jobs, :error_event)
+ add_column :good_jobs, :labels, :text, array: true unless column_exists?(:good_jobs, :labels)
+ add_column :good_jobs, :locked_by_id, :uuid unless column_exists?(:good_jobs, :locked_by_id)
+ add_column :good_jobs, :locked_at, :datetime unless column_exists?(:good_jobs, :locked_at)
+ add_column :good_jobs, :is_discrete, :boolean unless column_exists?(:good_jobs, :is_discrete)
+ add_column :good_jobs, :executions_count, :integer unless column_exists?(:good_jobs, :executions_count)
+ add_column :good_jobs, :job_class, :text unless column_exists?(:good_jobs, :job_class)
+ add_column :good_jobs, :batch_id, :uuid unless column_exists?(:good_jobs, :batch_id)
+ add_column :good_jobs, :batch_callback_id, :uuid unless column_exists?(:good_jobs, :batch_callback_id)
+
+ # Add missing indexes
+ unless index_exists?(:good_jobs, :labels, name: 'index_good_jobs_on_labels')
+ add_index :good_jobs, :labels, using: :gin, where: "(labels IS NOT NULL)", name: :index_good_jobs_on_labels
+ end
+
+ unless index_exists?(:good_jobs, :locked_by_id, name: 'index_good_jobs_on_locked_by_id')
+ add_index :good_jobs, :locked_by_id, where: "locked_by_id IS NOT NULL", name: "index_good_jobs_on_locked_by_id"
+ end
+
+ unless index_exists?(:good_jobs, :batch_id, name: 'index_good_jobs_on_batch_id')
+ add_index :good_jobs, [:batch_id], where: "batch_id IS NOT NULL", name: 'index_good_jobs_on_batch_id'
+ end
+
+ unless index_exists?(:good_jobs, :batch_callback_id, name: 'index_good_jobs_on_batch_callback_id')
+ add_index :good_jobs, [:batch_callback_id], where: "batch_callback_id IS NOT NULL", name: 'index_good_jobs_on_batch_callback_id'
+ end
+
+ unless index_exists?(:good_jobs, :job_class, name: 'index_good_jobs_on_job_class')
+ add_index :good_jobs, :job_class, name: :index_good_jobs_on_job_class
+ end
+
+ unless index_exists?(:good_jobs, [:priority, :scheduled_at], name: 'index_good_jobs_on_priority_scheduled_at_unfinished_unlocked')
+ add_index :good_jobs, [:priority, :scheduled_at], order: { priority: "ASC NULLS LAST", scheduled_at: :asc },
+ where: "finished_at IS NULL AND locked_by_id IS NULL", name: :index_good_jobs_on_priority_scheduled_at_unfinished_unlocked
+ end
+ end
+end
diff --git a/db/migrate/20251223000001_add_missing_good_job_tables.rb b/db/migrate/20251223000001_add_missing_good_job_tables.rb
new file mode 100644
index 00000000000..08a47d3517d
--- /dev/null
+++ b/db/migrate/20251223000001_add_missing_good_job_tables.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+class AddMissingGoodJobTables < ActiveRecord::Migration[7.2]
+ def change
+ # Create good_job_batches table if it doesn't exist
+ unless table_exists?(:good_job_batches)
+ create_table :good_job_batches, id: :uuid do |t|
+ t.timestamps
+ t.text :description
+ t.jsonb :serialized_properties
+ t.text :on_finish
+ t.text :on_success
+ t.text :on_discard
+ t.text :callback_queue_name
+ t.integer :callback_priority
+ t.datetime :enqueued_at
+ t.datetime :discarded_at
+ t.datetime :finished_at
+ t.datetime :jobs_finished_at
+ end
+ end
+
+ # Create good_job_executions table if it doesn't exist
+ unless table_exists?(:good_job_executions)
+ create_table :good_job_executions, id: :uuid do |t|
+ t.timestamps
+
+ t.uuid :active_job_id, null: false
+ t.text :job_class
+ t.text :queue_name
+ t.jsonb :serialized_params
+ t.datetime :scheduled_at
+ t.datetime :finished_at
+ t.text :error
+ t.integer :error_event, limit: 2
+ t.text :error_backtrace, array: true
+ t.uuid :process_id
+ t.interval :duration
+ end
+
+ add_index :good_job_executions, [:active_job_id, :created_at], name: :index_good_job_executions_on_active_job_id_and_created_at
+ add_index :good_job_executions, [:process_id, :created_at], name: :index_good_job_executions_on_process_id_and_created_at
+ end
+
+ # Create good_job_processes table if it doesn't exist
+ unless table_exists?(:good_job_processes)
+ create_table :good_job_processes, id: :uuid do |t|
+ t.timestamps
+ t.jsonb :state
+ t.integer :lock_type, limit: 2
+ end
+ end
+
+ # Create good_job_settings table if it doesn't exist
+ unless table_exists?(:good_job_settings)
+ create_table :good_job_settings, id: :uuid do |t|
+ t.timestamps
+ t.text :key
+ t.jsonb :value
+ end
+ add_index :good_job_settings, :key, unique: true
+ end
+ end
+end
diff --git a/db/migrate/20251223000002_add_lock_type_to_good_job_processes.rb b/db/migrate/20251223000002_add_lock_type_to_good_job_processes.rb
new file mode 100644
index 00000000000..e6a2df65ec6
--- /dev/null
+++ b/db/migrate/20251223000002_add_lock_type_to_good_job_processes.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class AddLockTypeToGoodJobProcesses < ActiveRecord::Migration[7.2]
+ def change
+ return unless table_exists?(:good_job_processes)
+ return if column_exists?(:good_job_processes, :lock_type)
+
+ add_column :good_job_processes, :lock_type, :integer, limit: 2
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 111bee2bf4c..94aff4b203f 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.2].define(version: 2025_09_11_162031) do
+ActiveRecord::Schema[7.2].define(version: 2025_12_23_000002) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -430,6 +430,7 @@
t.index ["cron_key", "created_at"], name: "index_good_jobs_on_cron_key_and_created_at"
t.index ["cron_key", "cron_at"], name: "index_good_jobs_on_cron_key_and_cron_at", unique: true
t.index ["finished_at"], name: "index_good_jobs_jobs_on_finished_at", where: "((retried_good_job_id IS NULL) AND (finished_at IS NOT NULL))"
+ t.index ["job_class"], name: "index_good_jobs_on_job_class"
t.index ["labels"], name: "index_good_jobs_on_labels", where: "(labels IS NOT NULL)", using: :gin
t.index ["locked_by_id"], name: "index_good_jobs_on_locked_by_id", where: "(locked_by_id IS NOT NULL)"
t.index ["priority", "created_at"], name: "index_good_jobs_jobs_on_priority_created_at_when_unfinished", order: { priority: "DESC NULLS LAST" }, where: "(finished_at IS NULL)"
@@ -542,8 +543,9 @@
create_table "micro_reports", force: :cascade do |t|
t.bigint "user_id", null: false
t.text "content", null: false
- t.datetime "created_at", null: false
- t.datetime "updated_at", null: false
+ t.datetime "created_at", precision: 6, null: false
+ t.datetime "updated_at", precision: 6, null: false
+ t.bigint "comment_user_id"
t.index ["user_id"], name: "index_micro_reports_on_user_id"
end
@@ -1068,6 +1070,7 @@
add_foreign_key "learning_times", "reports"
add_foreign_key "linear_scales", "survey_questions"
add_foreign_key "micro_reports", "users"
+ add_foreign_key "micro_reports", "users", column: "comment_user_id"
add_foreign_key "movies", "users"
add_foreign_key "notifications", "users"
add_foreign_key "notifications", "users", column: "sender_id"
diff --git a/test/components/users/micro_reports/micro_report_component_test.rb b/test/components/users/micro_reports/micro_report_component_test.rb
index b9279aee218..403d3c28754 100644
--- a/test/components/users/micro_reports/micro_report_component_test.rb
+++ b/test/components/users/micro_reports/micro_report_component_test.rb
@@ -5,13 +5,15 @@
class Users::MicroReports::MicroReportComponentTest < ViewComponent::TestCase
def setup
@user = users(:hatsuno)
+ @other_user = users(:kimura)
end
def test_default
micro_report = micro_reports(:hajime_first_micro_report)
+ micro_report.comment_user ||= micro_report.user
render_component(micro_report)
- assert_selector "img.micro-report_user-icon[title='#{micro_report.user.icon_title}']"
+ assert_selector "img.micro-report_user-icon[title='#{micro_report.comment_user.icon_title}']"
assert_selector '.micro-report__body', text: micro_report.content
assert_selector 'time.micro-report__created-at', text: I18n.l(micro_report.created_at, format: :date_and_time)
assert_text '👍1'
@@ -19,32 +21,57 @@ def test_default
end
def test_posted_datetime_today
- micro_report = @user.micro_reports.create(content: '今日の分報', created_at: Time.zone.now)
+ micro_report = MicroReport.create!(user: @user, comment_user: @user, content: '今日の分報', created_at: Time.zone.now)
render_component(micro_report)
assert_includes page.text, "今日 #{I18n.l(Time.zone.now, format: :time_only)}"
end
def test_posted_datetime_yesterday
- micro_report = @user.micro_reports.create(content: '昨日の分報', created_at: 1.day.ago)
+ micro_report = MicroReport.create!(user: @user, comment_user: @user, content: '昨日の分報', created_at: 1.day.ago)
render_component(micro_report)
assert_includes page.text, "昨日 #{I18n.l(1.day.ago, format: :time_only)}"
end
def test_posted_datetime_older_than_two_days
- micro_report = @user.micro_reports.create(content: '2日前の分報', created_at: 2.days.ago)
+ micro_report = MicroReport.create!(user: @user, comment_user: @user, content: '2日前の分報', created_at: 2.days.ago)
render_component(micro_report)
assert_includes page.text, I18n.l(2.days.ago, format: :date_and_time)
end
+ def test_comment_user_is_not_post_owner
+ micro_report = MicroReport.create!(user: @user, comment_user: @other_user, content: '他人のコメント')
+ render_component(micro_report)
+
+ assert_selector "img.micro-report_user-icon[title='#{@other_user.icon_title}']"
+ assert_selector '.micro-report__body', text: '他人のコメント'
+ end
+
+ def test_edit_delete_buttons_visible_only_to_comment_user_or_admin
+ micro_report = MicroReport.create!(user: @user, comment_user: @other_user, content: 'テスト')
+
+ render_component(micro_report, current_user: @user)
+ assert_no_selector '.micro-report__footer .micro-report-actions .is-edit'
+ assert_no_selector '.micro-report__footer .micro-report-actions .is-delete'
+
+ render_component(micro_report, current_user: @other_user)
+ assert_selector '.micro-report__footer .micro-report-actions .is-edit'
+ assert_selector '.micro-report__footer .micro-report-actions .is-delete'
+
+ admin_user = users(:komagata)
+ render_component(micro_report, current_user: admin_user)
+ assert_selector '.micro-report__footer .micro-report-actions .is-edit'
+ assert_selector '.micro-report__footer .micro-report-actions .is-delete'
+ end
+
private
- def render_component(micro_report)
+ def render_component(micro_report, current_user: @user)
component = Users::MicroReports::MicroReportComponent.new(
user: micro_report.user,
- current_user: @user,
+ current_user:,
micro_report:
)
render_inline(component)
diff --git a/test/fixtures/micro_reports.yml b/test/fixtures/micro_reports.yml
index 6c1750fab9d..0eb89a297ef 100644
--- a/test/fixtures/micro_reports.yml
+++ b/test/fixtures/micro_reports.yml
@@ -1,13 +1,16 @@
hajime_first_micro_report:
user: hajime
+ comment_user: hajime
content: 最初の分報
created_at: <%= 2.day.ago %>
hajime_second_micro_report:
user: hajime
+ comment_user: hajime
content: 2つ目の分報
created_at: <%= 1.day.ago %>
hajime_third_micro_report:
user: hajime
+ comment_user: hajime
content: 最新の分報
diff --git a/test/integration/api/pub_sub_test.rb b/test/integration/api/pub_sub_test.rb
new file mode 100644
index 00000000000..c6ed51ff8e1
--- /dev/null
+++ b/test/integration/api/pub_sub_test.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+require 'test_helper'
+require 'ostruct'
+
+class API::PubSubControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ @payload = { message: { data: Base64.encode64('dummy data') } }
+
+ API::PubSubController.prepend(Module.new do
+ private
+
+ def valid_pubsub_token?(_token)
+ true
+ end
+ end)
+ end
+
+ test 'returns 200 when ProcessTranscodingNotification succeeds' do
+ ProcessTranscodingNotification.stub :call, OpenStruct.new(success?: true) do
+ post api_pubsub_path(format: :json),
+ params: @payload.to_json,
+ headers: { 'CONTENT_TYPE' => 'application/json', 'Authorization' => 'Bearer dummy' }
+
+ assert_response :ok
+ end
+ end
+
+ test 'returns 500 when ProcessTranscodingNotification fails and retryable' do
+ ProcessTranscodingNotification.stub :call, OpenStruct.new(success?: false, retryable: true, error: 'fail') do
+ post api_pubsub_path(format: :json),
+ params: @payload.to_json,
+ headers: { 'CONTENT_TYPE' => 'application/json', 'Authorization' => 'Bearer dummy' }
+
+ assert_response :internal_server_error
+ end
+ end
+
+ test 'returns 200 when ProcessTranscodingNotification fails but not retryable' do
+ ProcessTranscodingNotification.stub :call, OpenStruct.new(success?: false, retryable: false, error: 'fail') do
+ post api_pubsub_path(format: :json),
+ params: @payload.to_json,
+ headers: { 'CONTENT_TYPE' => 'application/json', 'Authorization' => 'Bearer dummy' }
+
+ assert_response :ok
+ end
+ end
+end
diff --git a/test/interactors/process_transcoding_notification_test.rb b/test/interactors/process_transcoding_notification_test.rb
new file mode 100644
index 00000000000..fe73b2f1fb5
--- /dev/null
+++ b/test/interactors/process_transcoding_notification_test.rb
@@ -0,0 +1,124 @@
+# frozen_string_literal: true
+
+require 'test_helper'
+
+class ProcessTranscodingNotificationTest < ActiveSupport::TestCase
+ def build_message(data)
+ {
+ 'message' => {
+ 'data' => Base64.encode64(data.to_json)
+ }
+ }.to_json
+ end
+
+ setup do
+ @movie = movies(:movie1)
+ end
+
+ test 'SUCCEEDED job calls attach_transcoded_file' do
+ body = build_message({ 'job' => { 'name' => 'job-1', 'state' => 'SUCCEEDED' } })
+ interactor = ProcessTranscodingNotification.new(body:)
+
+ attach_called = false
+ interactor.stub(:find_movie, @movie) do
+ interactor.stub(:attach_transcoded_file, ->(*) { attach_called = true }) do
+ interactor.call
+ end
+ end
+
+ assert attach_called, 'attach_transcoded_file should be called for SUCCEEDED jobs'
+ end
+
+ test 'job with missing name fails with retryable=false' do
+ body = build_message({ 'job' => { 'state' => 'SUCCEEDED' } })
+ interactor = ProcessTranscodingNotification.new(body:)
+
+ error = assert_raises(Interactor::Failure) { interactor.call }
+ failure_context = error.context
+
+ assert_not(failure_context.retryable)
+ assert_equal 'Pub/Sub message missing required job fields: name=, state=SUCCEEDED', failure_context.error
+ end
+
+ test 'job with unknown movie fails with retryable=false' do
+ body = build_message({ 'job' => { 'name' => 'unknown', 'state' => 'SUCCEEDED' } })
+ interactor = ProcessTranscodingNotification.new(body:)
+
+ error = assert_raises(Interactor::Failure) do
+ interactor.stub(:find_movie, nil) { interactor.call }
+ end
+ failure_context = error.context
+
+ assert failure_context.failure?
+ assert_not failure_context.retryable
+ end
+
+ test 'FAILED job with unexpected error is retryable' do
+ body = build_message({
+ 'job' => {
+ 'name' => 'job-1',
+ 'state' => 'FAILED',
+ 'error' => {
+ 'details' => [{ 'fieldViolations' => [{ 'description' => 'Unexpected error' }] }]
+ }
+ }
+ })
+ interactor = ProcessTranscodingNotification.new(body:)
+
+ error = assert_raises(Interactor::Failure) { interactor.call }
+ failure_context = error.context
+
+ assert failure_context.failure?
+ assert failure_context.retryable, 'Unexpected errors should be retryable'
+ end
+
+ test 'invalid JSON fails with retryable=false' do
+ body = '{ invalid json'
+ interactor = ProcessTranscodingNotification.new(body:)
+
+ error = assert_raises(Interactor::Failure) { interactor.call }
+ failure_context = error.context
+
+ assert failure_context.failure?
+ assert_not failure_context.retryable
+ end
+
+ test 'get_movie_id raises Google::Cloud::Error is retryable' do
+ body = build_message({ 'job' => { 'name' => 'job-1', 'state' => 'SUCCEEDED' } })
+ interactor = ProcessTranscodingNotification.new(body:)
+
+ error = assert_raises(Interactor::Failure) do
+ interactor.stub(:find_movie, ->(*) { raise Google::Cloud::Error, 'boom' }) do
+ interactor.call
+ end
+ end
+ failure_context = error.context
+
+ assert failure_context.failure?
+ assert failure_context.retryable
+ end
+
+ test 'FAILED job with AudioMissing error triggers TranscodeJob.perform_later' do
+ body = build_message({
+ 'job' => {
+ 'name' => 'job-1',
+ 'state' => 'FAILED',
+ 'error' => {
+ 'details' => [{ 'fieldViolations' => [{ 'description' => 'AudioMissing' }] }]
+ }
+ }
+ })
+ interactor = ProcessTranscodingNotification.new(body:)
+
+ perform_later_called = false
+ interactor.stub(:find_movie, @movie) do
+ TranscodeJob.stub(:perform_later, ->(*) { perform_later_called = true }) do
+ interactor.call
+ rescue StandardError
+ nil
+ end
+ end
+
+ assert perform_later_called, 'AudioMissing error should trigger TranscodeJob.perform_later'
+ end
+end
diff --git a/test/jobs/transcode_job_test.rb b/test/jobs/transcode_job_test.rb
new file mode 100644
index 00000000000..74f3b7251b9
--- /dev/null
+++ b/test/jobs/transcode_job_test.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+require 'test_helper'
+require 'ostruct'
+require 'google/cloud/errors'
+
+class TranscodeJobTest < ActiveJob::TestCase
+ setup do
+ @movie = movies(:movie1)
+ Rails.application.config.transcoder['enabled'] = true
+ end
+
+ test 'calls transcode on Transcoder::Client' do
+ Transcoder::Client.stub :new, OpenStruct.new(transcode: nil) do
+ TranscodeJob.perform_now(@movie, force_video_only: true)
+ end
+ end
+
+ test 'does not perform when transcoder is disabled' do
+ Rails.application.config.transcoder['enabled'] = false
+ begin
+ Transcoder::Client.stub :new, ->(*) { flunk 'should not be called' } do
+ TranscodeJob.perform_now(@movie)
+ end
+ ensure
+ Rails.application.config.transcoder['enabled'] = true
+ end
+ end
+
+ test 'retries on retryable Google::Cloud::Error' do
+ error = Google::Cloud::Error.new('temporary failure')
+ def code = 429
+
+ job = TranscodeJob.new(@movie)
+ job.stub :executions, 0 do
+ job.stub :retryable?, true do
+ job.stub :retry_job, ->(wait:) { @retry_called = wait } do
+ job.send(:handle_transcode_error, error, @movie)
+ end
+ end
+ end
+
+ assert @retry_called.present?, 'retry_job should be called'
+ end
+
+ test 'logs on non-retryable error' do
+ error = StandardError.new('permanent failure')
+
+ logged = nil
+ Rails.logger.stub :error, ->(msg) { logged = msg } do
+ TranscodeJob.new(@movie).send(:handle_transcode_error, error, @movie)
+ end
+
+ assert_includes logged, 'permanent failure'
+ end
+end
diff --git a/test/models/micro_report_test.rb b/test/models/micro_report_test.rb
new file mode 100644
index 00000000000..b935d61a220
--- /dev/null
+++ b/test/models/micro_report_test.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+require 'test_helper'
+
+class MicroReportTest < ActiveSupport::TestCase
+ def setup
+ @user = users(:hatsuno)
+ end
+
+ test 'comment_user is automatically set to user when not specified' do
+ user = users(:hatsuno)
+ micro_report = MicroReport.new(user:, content: 'テスト')
+ micro_report.save
+ assert_equal user, micro_report.comment_user
+ end
+
+ test 'comment_user is not overwritten when already specified' do
+ user = users(:hatsuno)
+ other_user = users(:kimura)
+ micro_report = MicroReport.new(user:, comment_user: other_user, content: 'テスト')
+ micro_report.save
+ assert_equal other_user, micro_report.comment_user
+ end
+end
diff --git a/test/models/question_auto_closer_test.rb b/test/models/question_auto_closer_test.rb
new file mode 100644
index 00000000000..05a3058a964
--- /dev/null
+++ b/test/models/question_auto_closer_test.rb
@@ -0,0 +1,135 @@
+# frozen_string_literal: true
+
+require 'test_helper'
+
+class QuestionAutoCloserTest < ActiveSupport::TestCase
+ AUTO_CLOSE_WARNING_MESSAGE = 'このQ&Aは1ヶ月間コメントがありませんでした。1週間後に自動的にクローズされます。'
+ AUTO_CLOSE_MESSAGE = '自動的にクローズしました。'
+
+ test '.post_warning' do
+ created_at = Time.zone.local(2025, 10, 1, 0, 0, 0)
+ question = Question.create!(
+ title: '自動クローズテスト',
+ description: 'テスト',
+ user: users(:kimura),
+ created_at:,
+ updated_at: created_at
+ )
+
+ travel_to created_at.advance(months: 1, days: -1) do
+ assert_no_difference -> { question.answers.count } do
+ QuestionAutoCloser.post_warning
+ end
+ end
+
+ travel_to created_at.advance(months: 1) do
+ assert_difference -> { question.answers.count }, 1 do
+ QuestionAutoCloser.post_warning
+ end
+ answer = question.answers.last
+ assert_equal users(:pjord), answer.user
+ assert_equal AUTO_CLOSE_WARNING_MESSAGE, answer.description
+ end
+ end
+
+ test '.close_and_select_best_answer' do
+ created_at = Time.zone.local(2025, 10, 1, 0, 0, 0)
+ question = Question.create!(
+ title: '自動クローズテスト',
+ description: 'テスト',
+ user: users(:kimura),
+ created_at:,
+ updated_at: created_at
+ )
+
+ warned_at = created_at.advance(months: 1)
+ system_user = users(:pjord)
+ question.answers.create!(
+ user: system_user,
+ description: AUTO_CLOSE_WARNING_MESSAGE,
+ created_at: warned_at,
+ updated_at: warned_at
+ )
+
+ travel_to warned_at.advance(weeks: 1, days: -1) do
+ assert_no_difference -> { question.answers.count } do
+ QuestionAutoCloser.close_and_select_best_answer
+ end
+ end
+
+ travel_to warned_at.advance(weeks: 1) do
+ assert_difference -> { question.answers.count }, 1 do
+ QuestionAutoCloser.close_and_select_best_answer
+ end
+ answer = question.answers.last
+ assert_equal system_user, answer.user
+ assert_equal AUTO_CLOSE_MESSAGE, answer.description
+ assert answer.is_a?(CorrectAnswer)
+ end
+ end
+
+ test 'does not post warning for WIP questions' do
+ created_at = Time.zone.local(2025, 10, 1, 0, 0, 0)
+ question = Question.create!(
+ title: 'WIPの質問',
+ description: 'テスト',
+ user: users(:kimura),
+ wip: true,
+ created_at:,
+ updated_at: created_at
+ )
+
+ travel_to created_at.advance(months: 1) do
+ assert_no_difference -> { question.answers.count } do
+ QuestionAutoCloser.post_warning
+ end
+ end
+ end
+
+ test 'does not close WIP questions' do
+ created_at = Time.zone.local(2025, 10, 1, 0, 0, 0)
+ question = Question.create!(
+ title: '公開から1ヶ月後WIPにする質問',
+ description: 'テスト',
+ user: users(:kimura),
+ created_at:,
+ updated_at: created_at
+ )
+
+ warned_at = created_at.advance(months: 1)
+ question.answers.create!(
+ user: users(:pjord),
+ description: AUTO_CLOSE_WARNING_MESSAGE,
+ created_at: warned_at,
+ updated_at: warned_at
+ )
+ question.update!(wip: true, updated_at: warned_at)
+
+ travel_to warned_at.advance(weeks: 1) do
+ assert_no_difference -> { question.answers.count } do
+ QuestionAutoCloser.close_and_select_best_answer
+ end
+ end
+ end
+
+ test 'resets warning countdown when the question is updated' do
+ created_at = Time.zone.local(2025, 10, 1, 0, 0, 0)
+ question = Question.create!(
+ title: '後日公開する質問',
+ description: 'テスト',
+ user: users(:kimura),
+ wip: true,
+ created_at:,
+ updated_at: created_at
+ )
+
+ updated_at = created_at.advance(months: 1)
+ question.update!(wip: false, updated_at:)
+
+ travel_to updated_at do
+ assert_no_difference -> { question.answers.count } do
+ QuestionAutoCloser.post_warning
+ end
+ end
+ end
+end
diff --git a/test/models/question_test.rb b/test/models/question_test.rb
index 91941cdf960..0e3018aa387 100644
--- a/test/models/question_test.rb
+++ b/test/models/question_test.rb
@@ -135,56 +135,4 @@ class QuestionTest < ActiveSupport::TestCase
assert_equal '質問を作成しました。', published_question.generate_notice_message(:create)
assert_equal '質問を更新しました。', published_question.generate_notice_message(:update)
end
-
- test '.post_warning' do
- question_create_date = 2.months.ago.floor
-
- question = Question.create!(
- title: '自動クローズテスト',
- description: 'テスト',
- user: users(:kimura),
- created_at: question_create_date,
- updated_at: question_create_date
- )
- travel_to 1.month.ago + 1.day do
- assert_difference -> { question.answers.count }, 1 do
- QuestionAutoCloser.post_warning
- end
- answer = question.answers.last
- assert_equal users(:pjord), answer.user
- assert_includes answer.description, '1週間後に自動的にクローズされます'
- end
- end
-
- test '.close_and_select_best_answer' do
- question = Question.create!(
- title: '自動クローズテスト2',
- description: 'テスト',
- user: users(:kimura),
- created_at: 2.months.ago,
- updated_at: 2.months.ago
- )
-
- question.answers.create!(
- user: users(:kimura),
- description: 'これは通常のユーザーによる回答です',
- created_at: 6.weeks.ago
- )
-
- system_user = users(:pjord)
- question.answers.create!(
- user: system_user,
- description: 'このQ&Aは1ヶ月間コメントがありませんでした。1週間後に自動的にクローズされます。',
- created_at: 8.days.ago
- )
-
- QuestionAutoCloser.close_and_select_best_answer
-
- question.reload
-
- correct_answer = CorrectAnswer.find_by(question_id: question.id)
- assert_not_nil correct_answer
- assert_equal system_user, correct_answer.user
- assert_includes correct_answer.description, '自動的にクローズしました'
- end
end
diff --git a/test/models/retirement_test.rb b/test/models/retirement_test.rb
index fe7794c250e..de83cecac95 100644
--- a/test/models/retirement_test.rb
+++ b/test/models/retirement_test.rb
@@ -20,7 +20,7 @@ class RetirementTest < ActiveSupport::TestCase
remove_as_event_organizer
clear_github_info
destroy_cards
- publish_to_newspaper
+ publish
notify
]
diff --git a/test/models/times_channel_destroyer_test.rb b/test/models/times_channel_destroyer_test.rb
index f22c25da96c..66d9df58ae0 100644
--- a/test/models/times_channel_destroyer_test.rb
+++ b/test/models/times_channel_destroyer_test.rb
@@ -12,7 +12,7 @@ class TimesChannelDestroyerTest < ActiveSupport::TestCase
Rails.logger.stub(:warn, ->(message) { logs << message }) do
Discord::Server.stub(:delete_text_channel, true) do
- TimesChannelDestroyer.new.call({ user: })
+ TimesChannelDestroyer.new.call(nil, nil, nil, nil, { user: })
end
assert_nil user.discord_profile.times_id
assert_nil user.discord_profile.times_url
@@ -26,7 +26,7 @@ class TimesChannelDestroyerTest < ActiveSupport::TestCase
user.discord_profile.update!(times_id: '987654321987654321')
Rails.logger.stub(:warn, ->(message) { logs << message }) do
Discord::Server.stub(:delete_text_channel, nil) do
- TimesChannelDestroyer.new.call({ user: })
+ TimesChannelDestroyer.new.call(nil, nil, nil, nil, { user: })
end
assert_equal '987654321987654321', user.discord_profile.times_id
assert_equal "[Discord API] #{user.login_name}の分報チャンネルが削除できませんでした。", logs.last
diff --git a/test/models/transcoder/client_test.rb b/test/models/transcoder/client_test.rb
new file mode 100644
index 00000000000..b0866a8ca26
--- /dev/null
+++ b/test/models/transcoder/client_test.rb
@@ -0,0 +1,118 @@
+# frozen_string_literal: true
+
+require 'test_helper'
+
+module Transcoder
+ class ClientTest < ActiveSupport::TestCase
+ setup do
+ @valid_movie = OpenStruct.new(id: 1, movie_data: OpenStruct.new(blob: OpenStruct.new(key: 'dummy.mp4')))
+ @valid_config = {
+ 'location' => 'us-central1',
+ 'pubsub_topic' => 'topic',
+ 'video_height' => 720,
+ 'video_width' => 1280,
+ 'video_bitrate' => 1_000_000,
+ 'video_frame_rate' => 30,
+ 'audio_codec' => 'aac',
+ 'audio_bitrate' => 128_000,
+ 'container' => 'mp4'
+ }
+ end
+
+ private
+
+ def client(force_video_only: false)
+ Transcoder::Client.new(@valid_movie, config: @valid_config, bucket_name: 'bucket', project_id: 'proj', force_video_only:)
+ end
+
+ def with_transcoder_service(client, list_jobs: [], create_job_proc: nil, get_job_proc: nil)
+ client.define_singleton_method(:transcoder_service) do
+ Object.new.tap do |service|
+ service.define_singleton_method(:list_jobs) { |*| list_jobs }
+ service.define_singleton_method(:create_job) { |**args| create_job_proc&.call(args) }
+ service.define_singleton_method(:get_job) { |**args| get_job_proc&.call(args) }
+ end
+ end
+ end
+
+ test 'bucket_name required' do
+ error = assert_raises(ArgumentError) { Transcoder::Client.new(@valid_movie, config: @valid_config, bucket_name: nil, project_id: 'proj') }
+ assert_equal 'bucket_name is required', error.message
+ end
+
+ test 'project_id required' do
+ error = assert_raises(ArgumentError) { Transcoder::Client.new(@valid_movie, config: @valid_config, bucket_name: 'bucket', project_id: nil) }
+ assert_equal 'project_id is required', error.message
+ end
+
+ test 'location required in config' do
+ config = @valid_config.dup
+ config['location'] = nil
+ error = assert_raises(ArgumentError) { Transcoder::Client.new(@valid_movie, config:, bucket_name: 'bucket', project_id: 'proj') }
+ assert_equal 'location is required', error.message
+ end
+
+ test 'pubsub_topic required in config' do
+ config = @valid_config.dup
+ config['pubsub_topic'] = nil
+ error = assert_raises(ArgumentError) { Transcoder::Client.new(@valid_movie, config:, bucket_name: 'bucket', project_id: 'proj') }
+ assert_equal 'pubsub_topic is required', error.message
+ end
+
+ test '#transcode creates job' do
+ c = client
+ job_created = false
+ with_transcoder_service(c, list_jobs: [], create_job_proc: ->(_args) { job_created = true })
+ c.transcode
+ assert job_created, 'A new job should be created when no existing job'
+ end
+
+ test '#transcode skips if job exists' do
+ c = client
+ job_created = false
+ with_transcoder_service(c, list_jobs: [OpenStruct.new(labels: { 'movie_id' => @valid_movie.id.to_s }, state: 'RUNNING')],
+ create_job_proc: ->(_args) { job_created = true })
+ c.transcode
+ assert_not job_created
+ end
+
+ test '#transcode raises on failure' do
+ c = client
+ with_transcoder_service(c, list_jobs: [], create_job_proc: ->(_args) { raise Google::Cloud::Error, 'API failed' })
+ error = assert_raises(Google::Cloud::Error) { c.transcode }
+ assert_equal 'API failed', error.message
+ end
+
+ test '#transcode force_video_only' do
+ c = client(force_video_only: true)
+ created_job = nil
+ with_transcoder_service(c, list_jobs: [], create_job_proc: ->(args) { created_job = args[:job] })
+ c.transcode
+
+ elementary_keys = created_job[:config][:elementary_streams].map { |s| s[:key] }
+ assert_equal ['video-stream'], elementary_keys
+
+ mux_keys = created_job[:config][:mux_streams].flat_map { |m| m[:elementary_streams] }
+ assert_not_includes mux_keys, 'audio-stream'
+ end
+
+ test '#get_movie_id returns id' do
+ c = client
+ with_transcoder_service(c, get_job_proc: ->(_args) { OpenStruct.new(labels: { 'movie_id' => '123' }) })
+ movie_id = c.get_movie_id('job_name_1')
+ assert_equal '123', movie_id
+ end
+
+ test '#get_movie_id nil if blank' do
+ c = client
+ assert_nil c.get_movie_id(nil)
+ assert_nil c.get_movie_id('')
+ end
+
+ test '#get_movie_id nil on error' do
+ c = client
+ with_transcoder_service(c, get_job_proc: ->(_args) { raise Google::Cloud::Error, 'boom' })
+ assert_nil c.get_movie_id('job_name_1')
+ end
+ end
+end
diff --git a/test/models/transcoder/movie_test.rb b/test/models/transcoder/movie_test.rb
new file mode 100644
index 00000000000..e10d5b6cb06
--- /dev/null
+++ b/test/models/transcoder/movie_test.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+require 'test_helper'
+require 'ostruct'
+require 'google/cloud/storage'
+
+module Transcoder
+ class MovieTest < ActiveSupport::TestCase
+ def build_movie(file_exists: true, fail_gcs_delete: false, fail_gcs_download: false)
+ movie = movies(:movie1)
+ movie_obj = Transcoder::Movie.new(movie, bucket_name: 'dummy', path: 'dummy/path')
+
+ movie_obj.define_singleton_method(:file) do
+ obj = Object.new
+ obj.define_singleton_method(:exists?) { file_exists }
+ obj.define_singleton_method(:delete) { raise Google::Cloud::Error, 'fail delete' if fail_gcs_delete }
+ obj.define_singleton_method(:download) do |path|
+ raise Google::Cloud::Error, 'fail download' if fail_gcs_download
+
+ File.write(path, 'dummy data')
+ end
+ obj
+ end
+
+ movie_obj
+ end
+
+ test 'data returns tempfile' do
+ movie_obj = build_movie(file_exists: true)
+ tf = movie_obj.data
+
+ assert tf.is_a?(Tempfile)
+ assert_equal 'dummy data', tf.read
+ end
+
+ test 'data raises when file missing' do
+ movie_obj = build_movie(file_exists: false)
+ error = assert_raises(RuntimeError) { movie_obj.data }
+ assert_equal 'Transcoded file not found', error.message
+ end
+
+ test 'data raises on GCS download error' do
+ movie_obj = build_movie(file_exists: true, fail_gcs_download: true)
+ error = assert_raises(Google::Cloud::Error) { movie_obj.data }
+ assert_equal 'fail download', error.message
+ end
+
+ test 'cleanup deletes GCS file and tempfile' do
+ movie_obj = build_movie(file_exists: true)
+ temp = Tempfile.new('test')
+ path = temp.path
+ movie_obj.instance_variable_set(:@tempfile, temp)
+
+ assert_silent { movie_obj.cleanup }
+ assert_not File.exist?(path)
+ end
+
+ test 'cleanup ignores GCS delete errors' do
+ movie_obj = build_movie(file_exists: true, fail_gcs_delete: true)
+ movie_obj.instance_variable_set(:@tempfile, Tempfile.new('test'))
+
+ assert_silent { movie_obj.cleanup }
+ end
+
+ test 'cleanup ignores tempfile close errors' do
+ movie_obj = build_movie(file_exists: true)
+ temp = Tempfile.new('test')
+ movie_obj.instance_variable_set(:@tempfile, temp)
+ temp.define_singleton_method(:close!) { raise StandardError, 'fail' }
+
+ assert_silent { movie_obj.cleanup }
+ end
+ end
+end
diff --git a/test/models/unfinished_data_destroyer_test.rb b/test/models/unfinished_data_destroyer_test.rb
new file mode 100644
index 00000000000..36d8d5acd3c
--- /dev/null
+++ b/test/models/unfinished_data_destroyer_test.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+require 'test_helper'
+
+class UnfinishedDataDestroyerTest < ActiveSupport::TestCase
+ setup do
+ @payload = { user: users(:kimura) }
+ end
+
+ test '#call deletes user wip reports and unchecked products and resets career_path' do
+ user = @payload[:user]
+ user.update!(career_path: 1)
+
+ 3.times do |i|
+ Report.create!(
+ user:,
+ title: "wipの日報#{i}",
+ description: 'テスト日報',
+ wip: true,
+ reported_on: Date.current - i
+ )
+ end
+
+ practices = Practice.where.not(id: user.products.pluck(:practice_id)).take(3)
+ practices.each do |practice|
+ Product.create!(
+ user:,
+ practice:,
+ body: '提出物',
+ wip: false
+ )
+ end
+
+ UnfinishedDataDestroyer.new.call(nil, nil, nil, nil, @payload)
+
+ assert_equal 0, Product.unchecked.where(user:).count
+ assert_equal 0, Report.wip.where(user:).count
+ assert_equal 'unset', user.reload.career_path
+ end
+
+ test 'does not delete other users wip report and unchecked product' do
+ other_user_unchecked_product = products(:product1)
+ other_user_wip_report = reports(:report9)
+
+ UnfinishedDataDestroyer.new.call(nil, nil, nil, nil, @payload)
+
+ assert Product.exists?(other_user_unchecked_product.id)
+ assert Report.exists?(other_user_wip_report.id)
+ end
+
+ test 'does not delete checked product or non-wip report' do
+ checked_product = products(:product2)
+ non_wip_report = reports(:report24)
+
+ UnfinishedDataDestroyer.new.call(nil, nil, nil, nil, @payload)
+
+ assert Product.exists?(checked_product.id)
+ assert Report.exists?(non_wip_report.id)
+ end
+end
diff --git a/test/queries/user_notifications_query_test.rb b/test/queries/user_notifications_query_test.rb
new file mode 100644
index 00000000000..c0501c57541
--- /dev/null
+++ b/test/queries/user_notifications_query_test.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require 'test_helper'
+
+class UserNotificationsQueryTest < ActiveSupport::TestCase
+ test 'should return notifications for the given user' do
+ user = users(:komagata)
+ other_user = users(:machida)
+
+ Notification.create!(user:, sender: other_user, kind: :came_comment, link: '/comments/1')
+ Notification.create!(user: other_user, sender: user, kind: :came_comment, link: '/comments/2')
+
+ result = UserNotificationsQuery.new(user:, target: nil, status: nil).call
+
+ assert_includes result.map(&:user), user
+ assert_not_includes result.map(&:user), other_user
+ end
+
+ test 'should return unread notifications when status is unread' do
+ user = users(:komagata)
+ sender = users(:machida)
+
+ read_notification = Notification.create!(user:, sender:, kind: :checked, read: true, link: '/checks/1')
+ unread_notification = Notification.create!(user:, sender:, kind: :checked, read: false, link: '/checks/2')
+
+ result = UserNotificationsQuery.new(user:, target: 'check', status: 'unread').call
+
+ assert_includes result, unread_notification
+ assert_not_includes result, read_notification
+ end
+
+ test 'should return notifications for given target' do
+ user = users(:komagata)
+ sender = users(:machida)
+
+ comment_notification = Notification.create!(user:, sender:, kind: :came_comment, link: '/comments/1')
+ check_notification = Notification.create!(user:, sender:, kind: :checked, link: '/checks/1')
+
+ result = UserNotificationsQuery.new(user:, target: 'check', status: 'unread').call
+
+ assert_includes result, check_notification
+ assert_not_includes result, comment_notification
+ end
+end
diff --git a/test/system/bookmark/talks_test.rb b/test/system/bookmark/talks_test.rb
index 43aa50aee15..69de2e18c2f 100644
--- a/test/system/bookmark/talks_test.rb
+++ b/test/system/bookmark/talks_test.rb
@@ -11,6 +11,8 @@ class Bookmark::TalkTest < ApplicationSystemTestCase
test 'show talk bookmark on lists' do
visit_with_auth '/current_user/bookmarks', 'komagata'
+ find_all('a.pagination__item-link', text: '2').first.click if !page.has_text?("#{@decorated_user.long_name} さんの相談部屋")
+
assert_text "#{@decorated_user.long_name} さんの相談部屋"
end
diff --git a/test/system/current_user/bookmarks_test.rb b/test/system/current_user/bookmarks_test.rb
index e8b56d01d97..b702c462b1d 100644
--- a/test/system/current_user/bookmarks_test.rb
+++ b/test/system/current_user/bookmarks_test.rb
@@ -26,7 +26,7 @@ class CurrentUser::BookmarksTest < ApplicationSystemTestCase
visit_with_auth '/current_user/bookmarks', 'kimura'
assert_selector 'label', text: '編集'
- assert_selector 'input#card-list-tools__action', visible: false
+ assert_selector 'input#bookmark_edit', visible: false
assert_text '作業週1日目'
assert_selector '.card-list-item__label', text: '日報'
@@ -104,8 +104,7 @@ class CurrentUser::BookmarksTest < ApplicationSystemTestCase
user_with_some_bookmarks.bookmarks.create!(bookmarkable_id: reports("report#{n}".to_sym).id, bookmarkable_type: 'Report')
end
visit_with_auth '/current_user/bookmarks', user_with_some_bookmarks.login_name
- # ページ遷移直後なのでreactコンポーネントが表示されるまで待つ
- within "[data-testid='bookmarks']" do
+ within '.page-main' do
assert_no_selector 'nav.pagination'
end
end
@@ -127,8 +126,7 @@ class CurrentUser::BookmarksTest < ApplicationSystemTestCase
user_with_many_bookmarks.bookmarks.create!(bookmarkable_id: reports("report#{n}".to_sym).id, bookmarkable_type: 'Report')
end
visit_with_auth '/current_user/bookmarks', user_with_many_bookmarks.login_name
- # ページ遷移直後なのでreactコンポーネントが表示されるまで待つ
- within "[data-testid='bookmarks']" do
+ within '.page-main' do
assert_selector 'nav.pagination', count: 2
end
diff --git a/test/system/notifications/pagination_test.rb b/test/system/notifications/pagination_test.rb
index 075f4a6b667..97cb7aa90a0 100644
--- a/test/system/notifications/pagination_test.rb
+++ b/test/system/notifications/pagination_test.rb
@@ -24,7 +24,7 @@ class Notifications::PaginationTest < ApplicationSystemTestCase
login_user 'mentormentaro', 'testtest'
visit '/notifications'
within first('nav.pagination') do
- find('button', text: '2').click
+ click_link_or_button '2'
end
assert_text '1番古い通知'
assert_no_text '1番新しい通知'
@@ -55,14 +55,14 @@ class Notifications::PaginationTest < ApplicationSystemTestCase
login_user 'mentormentaro', 'testtest'
visit '/notifications?status=unread'
within first('nav.pagination') do
- find('button', text: '2').click
+ click_link_or_button '2'
end
assert_text '1番古い通知'
assert_no_text '1番新しい通知'
all('.pagination .is-active').each do |active_button|
assert active_button.has_text? '2'
end
- assert_current_path('/notifications?status=unread&page=2')
+ assert_match %r{/notifications\?(status=unread&page=2|page=2&status=unread)}, current_url
end
test 'click on the pager button with multiple query string' do
@@ -72,14 +72,14 @@ class Notifications::PaginationTest < ApplicationSystemTestCase
login_user 'mentormentaro', 'testtest'
visit '/notifications?status=unread&target=mention'
within first('nav.pagination') do
- find('button', text: '2').click
+ click_link_or_button '2'
end
assert_text '1番古い通知'
assert_no_text '1番新しい通知'
all('.pagination .is-active').each do |active_button|
assert active_button.has_text? '2'
end
- assert_current_path('/notifications?status=unread&target=mention&page=2')
+ assert_match %r{/notifications\?(status=unread&page=2|page=2&status=unread)}, current_url
end
test 'specify the page number in the URL' do
@@ -102,7 +102,7 @@ class Notifications::PaginationTest < ApplicationSystemTestCase
login_user 'mentormentaro', 'testtest'
visit '/notifications?page=2'
within first('nav.pagination') do
- find('button', text: '1').click
+ click_link_or_button '1'
end
assert_text '1番新しい通知'
page.go_back
diff --git a/test/system/user/micro_reports_test.rb b/test/system/user/micro_reports_test.rb
index 6f9fd503ef2..54170acd366 100644
--- a/test/system/user/micro_reports_test.rb
+++ b/test/system/user/micro_reports_test.rb
@@ -29,12 +29,6 @@ class MicroReportsTest < ApplicationSystemTestCase
end
end
- test 'form not found in other user microo reports page' do
- visit_with_auth user_micro_reports_path(users(:hajime)), 'hatsuno'
- assert has_no_field?(id: 'js-micro-report-textarea')
- assert_no_button '投稿'
- end
-
test 'form found in current user micro reports page' do
visit_with_auth user_micro_reports_path(users(:hatsuno)), 'hatsuno'
assert has_field?(id: 'js-micro-report-textarea')
@@ -151,6 +145,24 @@ class MicroReportsTest < ApplicationSystemTestCase
within(".micro-report#micro_report_#{micro_report.id}") { assert_no_selector 'a', text: '削除する' }
end
+ test 'comment_user can delete their own comment on another user micro_report' do
+ user = users(:hatsuno)
+ comment_user = users(:kimura)
+
+ micro_report = user.micro_reports.create!(content: '他人の分報にコメント', comment_user:)
+
+ visit_with_auth user_micro_reports_path(user), 'kimura'
+
+ within(".micro-report#micro_report_#{micro_report.id}") do
+ assert_selector 'a', text: '削除する'
+ click_link_or_button '削除する'
+ page.accept_alert
+ end
+
+ assert_text '分報を削除しました。'
+ assert_no_text '他人の分報にコメント'
+ end
+
test 'update micro_report through comment tab form' do
micro_report = micro_reports(:hajime_first_micro_report)
visit_with_auth user_micro_reports_path(users(:hajime)), 'hajime'