diff --git a/.gitignore b/.gitignore index c8b748e8..f8557cf1 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,5 @@ public/sitemaps /config/credentials/production.key /config/credentials/development.key + +.env diff --git a/Gemfile b/Gemfile index 525929f9..c6d4344f 100644 --- a/Gemfile +++ b/Gemfile @@ -53,6 +53,7 @@ gem 'tzinfo-data' # Exception tracking gem 'sentry-raven', group: [:production] +gem 'countries', '= 6.0.1' group :development, :test do gem 'pry-rails' diff --git a/Gemfile.lock b/Gemfile.lock index b1cdbee0..a554e3f5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -88,6 +88,8 @@ GEM coderay (1.1.3) concurrent-ruby (1.1.10) connection_pool (2.3.0) + countries (6.0.1) + unaccent (~> 0.3) crass (1.0.6) dead_end (3.1.1) derailed_benchmarks (2.1.1) @@ -370,6 +372,7 @@ GEM concurrent-ruby (~> 1.0) tzinfo-data (1.2022.1) tzinfo (>= 1.0.0) + unaccent (0.4.0) unicode-display_width (2.1.0) uniform_notifier (1.16.0) webrick (1.7.0) @@ -389,7 +392,7 @@ DEPENDENCIES benchmark-ips bullet byebug - cld3 (= 3.4.3) + countries (= 6.0.1) derailed_benchmarks elastic-transport elasticsearch-model diff --git a/app/controllers/api/qdc/chapter_metadata_controller.rb b/app/controllers/api/qdc/chapter_metadata_controller.rb new file mode 100644 index 00000000..61b01b94 --- /dev/null +++ b/app/controllers/api/qdc/chapter_metadata_controller.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Api::Qdc + class ChapterMetadataController < ApiController + before_action :init_presenter + + def metadata + render + end + + protected + + def init_presenter + @presenter = Qdc::ChapterMetadataPresenter.new(params) + end + end +end diff --git a/app/controllers/api/qdc/quran_controller.rb b/app/controllers/api/qdc/quran_controller.rb index 16b3997a..f57d7015 100644 --- a/app/controllers/api/qdc/quran_controller.rb +++ b/app/controllers/api/qdc/quran_controller.rb @@ -2,5 +2,18 @@ module Api::Qdc class QuranController < Api::V4::QuranController + def tafsir + @presenter = Qdc::TafsirsPresenter.new(params) + filters = resource_filters(@resource) + @filter_names = humanize_filter_names(filters) + + @tafsirs = if (@resource = fetch_tafsir_resource) + Tafsir.order('verse_id ASC').where(filters) + else + [] + end + + render + end end end diff --git a/app/controllers/api/qdc/recitations_controller.rb b/app/controllers/api/qdc/recitations_controller.rb index 739ee756..4ce32813 100644 --- a/app/controllers/api/qdc/recitations_controller.rb +++ b/app/controllers/api/qdc/recitations_controller.rb @@ -2,5 +2,9 @@ module Api::Qdc class RecitationsController < Api::V4::RecitationsController + protected + def init_presenter + @presenter = Qdc::RecitationsPresenter.new(params) + end end end diff --git a/app/controllers/api/qdc/resources_controller.rb b/app/controllers/api/qdc/resources_controller.rb index a1bf05fc..913aba08 100644 --- a/app/controllers/api/qdc/resources_controller.rb +++ b/app/controllers/api/qdc/resources_controller.rb @@ -116,6 +116,113 @@ def languages render end + def country_language_preference + user_device_language = request.query_parameters[:user_device_language].presence + country = request.query_parameters[:country].presence&.upcase + + # Require a valid user_device_language always + if user_device_language.blank? + return render_bad_request('user_device_language is required') + end + + unless Language.exists?(iso_code: user_device_language) + return render_bad_request('Invalid user_device_language') + end + + # Validate country only if provided + if country.present? + valid_countries = ISO3166::Country.all.map(&:alpha2) + unless valid_countries.include?(country) + return render_bad_request('Invalid country code') + end + end + + if country.present? + # First try to find country-specific preference + preferences = CountryLanguagePreference.with_includes + .where(user_device_language: user_device_language, country: country) + @preference = preferences.first + + # If no country-specific preference found, try global preference + unless @preference + @preference = CountryLanguagePreference.with_includes + .find_by(user_device_language: user_device_language, country: nil) + end + else + # No country provided: search by user_device_language only + # Prefer global (country: nil), then fall back to any match for that language + @preference = CountryLanguagePreference.with_includes + .find_by(user_device_language: user_device_language, country: nil) + + unless @preference + @preference = CountryLanguagePreference.with_includes + .where(user_device_language: user_device_language) + .first + end + end + + if @preference + # Filter out unapproved resources when building the response + @data = build_preference_data(@preference) + render + else + render_404("No matching country language preference found") + end + end + + private + + def build_preference_data(preference) + # Sanitize CSV IDs for default translations + ids = if preference.default_translation_ids.present? + preference.default_translation_ids + .split(',') + .map(&:strip) + .reject(&:blank?) + .map(&:to_i) + else + [] + end + + # QR specific default translations ids + qr_ids = if preference.qr_default_translations_ids.present? + preference.qr_default_translations_ids + .split(',') + .map(&:strip) + .reject(&:blank?) + .map(&:to_i) + else + [] + end + + # QR default arabic fonts + qr_font_ids = if preference.qr_default_arabic_fonts.present? + preference.qr_default_arabic_fonts + .split(',') + .map(&:strip) + .reject(&:blank?) + .map(&:to_i) + else + [] + end + + { + preference: preference, + default_mushaf: preference.mushaf&.enabled ? preference.mushaf : nil, + default_translations: ids.any? ? + ResourceContent.where(id: ids).approved.includes(:translated_name) : [], + qr_default_translations: qr_ids.any? ? + ResourceContent.where(id: qr_ids).approved.includes(:translated_name) : [], + default_tafsir: preference.tafsir&.approved? ? preference.tafsir : nil, + default_wbw_language: preference.wbw_language, + default_reciter: preference.reciter, + ayah_reflections_languages: Language.where(iso_code: preference.ayah_reflections_languages&.split(',') || []), + qr_reflection_languages: Language.where(iso_code: preference.qr_reflection_languages&.split(',') || []), + learning_plan_languages: Language.where(iso_code: preference.learning_plan_languages&.split(',') || []), + qr_default_arabic_fonts: qr_font_ids + } + end + protected def load_translations diff --git a/app/controllers/api/qdc/tafsirs_controller.rb b/app/controllers/api/qdc/tafsirs_controller.rb index 7647e310..363aae83 100644 --- a/app/controllers/api/qdc/tafsirs_controller.rb +++ b/app/controllers/api/qdc/tafsirs_controller.rb @@ -2,5 +2,9 @@ module Api::Qdc class TafsirsController < Api::V4::TafsirsController + protected + def init_presenter + @presenter = Qdc::TafsirsPresenter.new(params) + end end end diff --git a/app/controllers/api/qdc/translations_controller.rb b/app/controllers/api/qdc/translations_controller.rb index 9cd500dc..88a0acf9 100644 --- a/app/controllers/api/qdc/translations_controller.rb +++ b/app/controllers/api/qdc/translations_controller.rb @@ -7,7 +7,7 @@ class TranslationsController < Api::V4::TranslationsController protected def init_presenter - @presenter = TranslationsPresenter.new(params) + @presenter = Qdc::TranslationsPresenter.new(params) end def load_translations diff --git a/app/controllers/api/v3/options_controller.rb b/app/controllers/api/v3/options_controller.rb index 4e18f638..01db76de 100644 --- a/app/controllers/api/v3/options_controller.rb +++ b/app/controllers/api/v3/options_controller.rb @@ -22,6 +22,7 @@ def translations .one_verse .translations .approved + .allowed_to_share .order('priority ASC') @translations = eager_load_translated_name(list) @@ -47,6 +48,7 @@ def chapter_info .chapter_info .one_chapter .approved + .allowed_to_share @chapter_infos = eager_load_translated_name(list) render @@ -58,6 +60,7 @@ def tafsirs .eager_load(:translated_name) .tafsirs .approved + .allowed_to_share .order('priority ASC') @tafsirs = eager_load_translated_name(list) diff --git a/app/controllers/api/v3/search_controller.rb b/app/controllers/api/v3/search_controller.rb index 137d24b9..55e99c46 100644 --- a/app/controllers/api/v3/search_controller.rb +++ b/app/controllers/api/v3/search_controller.rb @@ -27,6 +27,7 @@ def translations approved_translations = ResourceContent .approved + .allowed_to_share .translations .one_verse diff --git a/app/controllers/api/v3/tafsirs_controller.rb b/app/controllers/api/v3/tafsirs_controller.rb index 743e5919..8ce85654 100644 --- a/app/controllers/api/v3/tafsirs_controller.rb +++ b/app/controllers/api/v3/tafsirs_controller.rb @@ -20,7 +20,10 @@ def verse end def tafisr_id - approved_tafsir = ResourceContent.tafsirs.approved + approved_tafsir = ResourceContent + .tafsirs + .approved + .allowed_to_share tafsir = approved_tafsir.where(id: params[:tafsir]) .or(approved_tafsir.where(slug: params[:tafsir])) diff --git a/app/controllers/api/v4/api_controller.rb b/app/controllers/api/v4/api_controller.rb index e87ddcc1..2a6cc5f7 100644 --- a/app/controllers/api/v4/api_controller.rb +++ b/app/controllers/api/v4/api_controller.rb @@ -10,6 +10,7 @@ def fetch_translation_resource .translations .one_verse .approved + .allowed_to_share find_resource(approved, params[:translation_id], true) end @@ -20,6 +21,7 @@ def fetch_tafsir_resource .tafsirs .one_verse .approved + .allowed_to_share find_resource(approved, params[:tafsir_id], true) end diff --git a/app/controllers/api/v4/resources_controller.rb b/app/controllers/api/v4/resources_controller.rb index d480cb2a..62fd23fc 100644 --- a/app/controllers/api/v4/resources_controller.rb +++ b/app/controllers/api/v4/resources_controller.rb @@ -13,6 +13,7 @@ def translations .eager_load(:translated_name) .one_verse .translations + .allowed_to_share .approved .order('priority ASC') @@ -31,7 +32,7 @@ def translation_info end def word_by_word_translations - list = ResourceContent.eager_load(:translated_name).approved.one_word.translations_only.order('priority ASC') + list = ResourceContent.eager_load(:translated_name).approved.allowed_to_share.one_word.translations_only.order('priority ASC') @word_by_word_translations = eager_load_translated_name(list) @@ -43,6 +44,7 @@ def tafsirs .eager_load(:translated_name) .tafsirs .approved + .allowed_to_share .order('priority ASC') @presenter = ResourcePresenter.new(params) @@ -62,6 +64,8 @@ def tafsir_info def recitations list = Recitation .eager_load(reciter: :translated_name) + .joins(:resource_content) + .where.not(resource_contents: { permission_to_share: :rejected }) .approved .order('translated_names.language_priority desc') @@ -93,6 +97,7 @@ def chapter_infos .chapter_info .one_chapter .approved + .allowed_to_share @chapter_infos = eager_load_translated_name(list) @@ -103,8 +108,9 @@ def verse_media @media = ResourceContent .includes(:language) .media - .one_verse.approved - + .one_verse + .approved + .allowed_to_share render end @@ -121,6 +127,7 @@ def changes .change_log(after: time) .filter_subtype(params[:type]) .approved + .allowed_to_share render else diff --git a/app/controllers/api/v4/search_controller.rb b/app/controllers/api/v4/search_controller.rb index 2de69b53..09d78c2e 100644 --- a/app/controllers/api/v4/search_controller.rb +++ b/app/controllers/api/v4/search_controller.rb @@ -29,6 +29,7 @@ def translations .approved .translations .one_verse + .allowed_to_share params[:translations] = approved_translations .where(id: translation) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 292b70b1..5ea686a6 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -39,12 +39,14 @@ def fetch_locale end def set_cache_headers - if action_name != 'random' - expires_in 7.day, public: true - # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security - headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains; preload' - end + headers['Cache-Control'] = 'no-store, no-cache, max-age=0, private, must-revalidate' + headers['Pragma'] = 'no-cache' + headers['Expires'] = '0' + + # Keep HSTS header + headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains; preload' + # Keep CORS header headers['Access-Control-Allow-Origin'] = '*' end diff --git a/app/controllers/health_controller.rb b/app/controllers/health_controller.rb new file mode 100644 index 00000000..9d84714e --- /dev/null +++ b/app/controllers/health_controller.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class HealthController < ApplicationController + # Lightweight liveness endpoint: confirms app process & middleware stack respond. + def show + render json: { status: 'ok' } + end +end diff --git a/app/controllers/mobile/translations_controller.rb b/app/controllers/mobile/translations_controller.rb index 546011ff..5598c3da 100644 --- a/app/controllers/mobile/translations_controller.rb +++ b/app/controllers/mobile/translations_controller.rb @@ -3,7 +3,7 @@ module Mobile class TranslationsController < ApplicationController def index - resources = ResourceContent.includes(:language).translations.one_verse.approved + resources = ResourceContent.includes(:language).translations.one_verse.approved.allowed_to_share render json: resources, root: :data, each_serializer: Mobile::TranslationSerializer end diff --git a/app/controllers/readiness_controller.rb b/app/controllers/readiness_controller.rb new file mode 100644 index 00000000..3564cc23 --- /dev/null +++ b/app/controllers/readiness_controller.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class ReadinessController < ApplicationController + # Readiness: returns 200 if all dependencies up, 503 if any down. + def show + checks = {} + + checks[:database] = dep_check('database') do + ActiveRecord::Base.connection.execute('SELECT 1') + 'up' + end + + checks[:redis] = dep_check('redis') do + k = Kredis.string("readiness:#{Process.pid}") + k.value = '1' + k.value == '1' ? 'up' : 'down' + end + + checks[:elasticsearch] = dep_check('elasticsearch') do + client = Elasticsearch::Model.client + resp = client.cluster.health(timeout: '1s') + resp && resp['status'].present? ? 'up' : 'down' + end + + overall_ok = checks.values.all? { |v| v == 'up' } + status_code = overall_ok ? :ok : :service_unavailable + render json: { status: overall_ok ? 'ok' : 'degraded', checks: checks }, status: status_code + end + + private + def dep_check(label) + yield + rescue => e + Rails.logger.warn("readiness #{label} error: #{e.class}: #{e.message}") + 'down' + end +end diff --git a/app/finders/chapter_finder.rb b/app/finders/chapter_finder.rb index 1332fa16..17add017 100644 --- a/app/finders/chapter_finder.rb +++ b/app/finders/chapter_finder.rb @@ -28,6 +28,18 @@ def all_with_eager_load(locale: 'en', include_slugs: false) .or(with_default_names) end + # Eager load transliteration for chapter names with fallback to English (mirror translated_names pattern) + with_default_trans = chapters.where(transliterations: { language_id: Language.default.id }) + + chapters = if language.nil? || language.default? + with_default_trans + else + chapters + .where(transliterations: { language_id: language.id }) + .or(with_default_trans) + .order(Arel.sql("CASE WHEN transliterations.language_id = #{language.id} THEN 0 ELSE 1 END")) + end + if !include_slugs # Fix slugs order and language chapters = load_language_slug(chapters, locale: locale) @@ -52,7 +64,7 @@ def load_language_slug(relation, locale: 'en') protected def chapter_eager_loads(include_slugs) - eager_load = [:translated_name] + eager_load = [:translated_name, :transliteration] if include_slugs eager_load.push :slugs @@ -62,4 +74,5 @@ def chapter_eager_loads(include_slugs) eager_load end + end diff --git a/app/finders/qdc/chapter_metadata_finder.rb b/app/finders/qdc/chapter_metadata_finder.rb new file mode 100644 index 00000000..36110e25 --- /dev/null +++ b/app/finders/qdc/chapter_metadata_finder.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Qdc + class ChapterMetadataFinder < Finder + + def chapter + strong_memoize :chapter do + find_chapter(params[:id]) + end + end + + def suggestions + strong_memoize :suggestions do + fetch_metadata_with_language( + ChapterMetadata.active.suggestions.for_chapter(chapter.id) + ) + end + end + + def next_chapter + strong_memoize :next_chapter do + Chapter.find_by(id: chapter.id + 1) unless chapter.id == 114 + end + end + + def previous_chapter + strong_memoize :previous_chapter do + Chapter.find_by(id: chapter.id - 1) unless chapter.id == 1 + end + end + + def next_chapter_summaries + strong_memoize :next_chapter_summaries do + next_chapter ? fetch_summaries_for(next_chapter.id) : [] + end + end + + def previous_chapter_summaries + strong_memoize :previous_chapter_summaries do + previous_chapter ? fetch_summaries_for(previous_chapter.id) : [] + end + end + + private + + def fetch_summaries_for(chapter_id) + fetch_metadata_with_language( + ChapterMetadata.active.summaries.for_chapter(chapter_id) + ) + end + + def fetch_metadata_with_language(scope) + language_id = params[:language_id].presence || Language.default.id + + scope.includes(:language) + .where(language_id: language_id) + .order(created_at: :asc) + end + end +end diff --git a/app/finders/v4/verse_finder.rb b/app/finders/v4/verse_finder.rb index d86b01c0..4585dd88 100644 --- a/app/finders/v4/verse_finder.rb +++ b/app/finders/v4/verse_finder.rb @@ -185,7 +185,7 @@ def verse_pagination_end(start, total_verses) def load_words(word_translation_lang) language = Language.find_with_id_or_iso_code(word_translation_lang) - approved_word_by_word_translations = ResourceContent.approved.one_word.translations_only + approved_word_by_word_translations = ResourceContent.approved.allowed_to_share.one_word.translations_only words_with_default_translation = @results.where(word_translations: { language_id: Language.default.id }) if language diff --git a/app/finders/verse_finder.rb b/app/finders/verse_finder.rb index e51eca9f..14415309 100644 --- a/app/finders/verse_finder.rb +++ b/app/finders/verse_finder.rb @@ -52,7 +52,7 @@ def fetch_verses_range def load_words(word_translation_lang) language = Language.find_with_id_or_iso_code(word_translation_lang) - approved_word_by_word_translations = ResourceContent.approved.one_word.translations_only + approved_word_by_word_translations = ResourceContent.approved.one_word.translations_only.allowed_to_share words_with_default_translation = results.where(word_translations: { language_id: Language.default.id, resource_content_id: approved_word_by_word_translations }) if language diff --git a/app/models/chapter.rb b/app/models/chapter.rb index 4be82136..7858e5f4 100644 --- a/app/models/chapter.rb +++ b/app/models/chapter.rb @@ -29,9 +29,11 @@ class Chapter < ApplicationRecord include Slugable include QuranNavigationSearchable include NameTranslateable + include NameTransliterateable has_many :verses has_many :chapter_infos + has_many :chapter_metadata serialize :pages diff --git a/app/models/chapter_metadata.rb b/app/models/chapter_metadata.rb new file mode 100644 index 00000000..dfff2243 --- /dev/null +++ b/app/models/chapter_metadata.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: chapter_metadata +# +# id :bigint not null, primary key +# chapter_id :integer not null +# metadata_type :string not null +# content :text not null +# language_id :integer not null +# resource_content_id :integer +# is_active :boolean default(TRUE) +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_chapter_metadata_on_query_pattern (chapter_id,language_id,metadata_type,is_active) +# index_chapter_metadata_on_resource_content_id (resource_content_id) +# +# + +class ChapterMetadata < ApplicationRecord + include LanguageFilterable + + belongs_to :chapter + belongs_to :resource_content, optional: true + + enum metadata_type: { summary: 'summary', suggestion: 'suggestion' } + + validates :content, presence: true + + scope :for_chapter, ->(chapter_id) { where(chapter_id: chapter_id) } + scope :active, -> { where(is_active: true) } + scope :suggestions, -> { suggestion } + scope :summaries, -> { summary } +end diff --git a/app/models/concerns/name_transliterateable.rb b/app/models/concerns/name_transliterateable.rb new file mode 100644 index 00000000..03a634b3 --- /dev/null +++ b/app/models/concerns/name_transliterateable.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module NameTransliterateable + extend ActiveSupport::Concern + + included do + has_many :transliterations, as: :resource + has_one :transliteration, as: :resource + end + + def localised_transliteration + transliteration&.text + end +end diff --git a/app/models/concerns/resourceable.rb b/app/models/concerns/resourceable.rb index 2e50f41c..53de6aba 100644 --- a/app/models/concerns/resourceable.rb +++ b/app/models/concerns/resourceable.rb @@ -13,7 +13,7 @@ def resource_id def get_resource_content if respond_to? :resource_content_id - ResourceContent.find(resource_content_id) + ResourceContent.allowed_to_share.find(resource_content_id) else resource_content end diff --git a/app/models/country_language_preference.rb b/app/models/country_language_preference.rb new file mode 100644 index 00000000..20acd58d --- /dev/null +++ b/app/models/country_language_preference.rb @@ -0,0 +1,43 @@ +# == Schema Information +# Schema version: 20250909000000 +# +# Table name: country_language_preferences +# +# id :bigint not null, primary key +# ayah_reflections_languages :string +# country :string +# default_reciter :integer +# default_translation_ids :string +# default_wbw_language :string +# learning_plan_languages :string +# qr_default_arabic_fonts :string +# qr_default_translations_ids :string +# qr_reflection_languages :string +# user_device_language :string not null +# created_at :datetime not null +# updated_at :datetime not null +# default_mushaf_id :integer +# default_tafsir_id :integer +# +# Foreign Keys +# +# fk_rails_1069e91c22 (default_reciter => audio_recitations.id) ON DELETE => cascade +# fk_rails_508ee899a1 (user_device_language => languages.iso_code) ON DELETE => cascade +# fk_rails_90bfd196ab (default_tafsir_id => resource_contents.id) ON DELETE => cascade +# fk_rails_9b4f468673 (default_wbw_language => languages.iso_code) ON DELETE => cascade +# fk_rails_fbdc70f32a (default_mushaf_id => mushafs.id) ON DELETE => cascade +# + +class CountryLanguagePreference < ApplicationRecord + belongs_to :audio_recitation, class_name: 'Audio::Recitation', foreign_key: :default_reciter, optional: true + belongs_to :language, foreign_key: :user_device_language, primary_key: :iso_code, optional: true + belongs_to :wbw_language, class_name: 'Language', foreign_key: :default_wbw_language, primary_key: :iso_code, optional: true + belongs_to :mushaf, foreign_key: :default_mushaf_id, optional: true + belongs_to :tafsir, class_name: 'ResourceContent', foreign_key: :default_tafsir_id, optional: true + + validates :user_device_language, presence: true + + scope :with_includes, -> { + includes(:audio_recitation, :language, :wbw_language, :mushaf, :tafsir) + } +end diff --git a/app/models/language.rb b/app/models/language.rb index e02263c1..9be3c43c 100644 --- a/app/models/language.rb +++ b/app/models/language.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # == Schema Information -# Schema version: 20230313013539 +# Schema version: 20240918072240 # # Table name: languages # @@ -16,7 +16,7 @@ # # Indexes # -# index_languages_on_iso_code (iso_code) +# index_languages_on_iso_code (iso_code) UNIQUE # class Language < ApplicationRecord diff --git a/app/models/resource_content.rb b/app/models/resource_content.rb index 7ec82fe5..50479104 100644 --- a/app/models/resource_content.rb +++ b/app/models/resource_content.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true # == Schema Information -# Schema version: 20230313013539 +# Schema version: 20241228151537 # # Table name: resource_contents # @@ -10,8 +10,13 @@ # cardinality_type :string # description :text # language_name :string +# meta_data :jsonb # name :string +# permission_to_host :integer default("unknown") +# permission_to_share :integer default("unknown") +# priority :integer # records_count :integer default(0) +# resource_info :text # resource_type :string # resource_type_name :string # slug :string @@ -24,6 +29,7 @@ # data_source_id :integer # language_id :integer # mobile_translation_id :integer +# resource_id :string # # Indexes # @@ -32,7 +38,10 @@ # index_resource_contents_on_cardinality_type (cardinality_type) # index_resource_contents_on_data_source_id (data_source_id) # index_resource_contents_on_language_id (language_id) +# index_resource_contents_on_meta_data (meta_data) USING gin # index_resource_contents_on_mobile_translation_id (mobile_translation_id) +# index_resource_contents_on_priority (priority) +# index_resource_contents_on_resource_id (resource_id) # index_resource_contents_on_resource_type_name (resource_type_name) # index_resource_contents_on_slug (slug) # index_resource_contents_on_sub_type (sub_type) @@ -42,6 +51,20 @@ class ResourceContent < ApplicationRecord include LanguageFilterable include NameTranslateable + enum permission_to_host: { + unknown: 0, + requested: 1, + granted: 2, + rejected: 3 + }, _prefix: :host_permission_is + + enum permission_to_share: { + unknown: 0, + requested: 1, + granted: 2, + rejected: 3 + }, _prefix: :share_permission_is + scope :translations, -> { where sub_type: [SubType::Translation, SubType::Transliteration] } scope :media, -> { where sub_type: SubType::Video } scope :translations_only, -> { where sub_type: SubType::Translation } @@ -52,6 +75,7 @@ class ResourceContent < ApplicationRecord scope :one_word, -> { where cardinality_type: CardinalityType::OneWord } scope :approved, -> { where approved: true } scope :recitations, -> { where sub_type: SubType::Audio } + scope :allowed_to_share, -> { where.not(permission_to_share: :rejected) } module CardinalityType OneVerse = '1_ayah' @@ -82,6 +106,7 @@ module SubType belongs_to :author belongs_to :data_source has_one :resource_content_stat + has_one :resource_permission def self.filter_by(ids: nil, name: nil) if name.present? diff --git a/app/presenters/chapter_presenter.rb b/app/presenters/chapter_presenter.rb index 52c864ec..ae1c026b 100644 --- a/app/presenters/chapter_presenter.rb +++ b/app/presenters/chapter_presenter.rb @@ -26,6 +26,10 @@ def include_translated_names? include_in_response? params[:translated_names] end + def include_transliterated_name? + include_in_response? params[:transliterated_name] + end + def include_chapter_info? include_in_response? params[:info] end diff --git a/app/presenters/qdc/chapter_metadata_presenter.rb b/app/presenters/qdc/chapter_metadata_presenter.rb new file mode 100644 index 00000000..846b5683 --- /dev/null +++ b/app/presenters/qdc/chapter_metadata_presenter.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Qdc + class ChapterMetadataPresenter < BasePresenter + delegate :chapter, + :suggestions, + :next_chapter, + :previous_chapter, + :next_chapter_summaries, + :previous_chapter_summaries, + to: :finder + + attr_reader :finder + + def initialize(params) + super(params) + lang_id = (language&.id || Language.default.id) + @finder = Qdc::ChapterMetadataFinder.new(params.merge(language_id: lang_id)) + end + end +end diff --git a/app/presenters/qdc/recitations_presenter.rb b/app/presenters/qdc/recitations_presenter.rb new file mode 100644 index 00000000..0ac50d08 --- /dev/null +++ b/app/presenters/qdc/recitations_presenter.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Qdc + class RecitationsPresenter < ::RecitationsPresenter + protected + def recitation_id + strong_memoize :approved_recitation do + if params[:recitation_id] + recitation = params[:recitation_id].to_s.strip + approved = Recitation.approved + params[:recitation_id] = approved.where(id: recitation).first&.id + end + end + end + end +end diff --git a/app/presenters/qdc/tafsirs_presenter.rb b/app/presenters/qdc/tafsirs_presenter.rb new file mode 100644 index 00000000..625fa2ab --- /dev/null +++ b/app/presenters/qdc/tafsirs_presenter.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true +module Qdc + class TafsirsPresenter < ::TafsirsPresenter + protected + + def resource + strong_memoize :approved_tafsir do + if params[:resource_id] + id_or_slug = params[:resource_id].to_s + + approved_Tafsirs = ResourceContent + .approved + .tafsirs + + approved_tafsir = approved_Tafsirs + .where(id: id_or_slug) + .or(approved_Tafsirs.where(slug: id_or_slug)) + .first + + raise_404("Tafsir not found") unless approved_tafsir + + params[:resource_id] = approved_tafsir.id + approved_tafsir + else + raise_404("Tafsir not found") + end + end + end + end +end diff --git a/app/presenters/qdc/translations_presenter.rb b/app/presenters/qdc/translations_presenter.rb new file mode 100644 index 00000000..80f38e5b --- /dev/null +++ b/app/presenters/qdc/translations_presenter.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true +module Qdc + class TranslationsPresenter < ::TranslationsPresenter + protected + + def resource_id + strong_memoize :approved_translation do + if params[:resource_id] + translations = params[:resource_id].to_s + + approved_translations = ResourceContent + .approved + .translations + .one_verse + + params[:resource_id] = approved_translations + .where(id: translations) + .or(approved_translations.where(slug: translations)) + .pick(:id) + params[:resource_id] + else + [] + end + end + end + end +end diff --git a/app/presenters/qdc/verses_presenter.rb b/app/presenters/qdc/verses_presenter.rb index 38e1d63f..e4a05354 100644 --- a/app/presenters/qdc/verses_presenter.rb +++ b/app/presenters/qdc/verses_presenter.rb @@ -123,6 +123,43 @@ def render_segments? end end + def fetch_tafsirs + strong_memoize :approved_tafsirs do + if params[:tafsirs] + tafsirs = params[:tafsirs].to_s.split(',') + approved_tafsirs = ResourceContent + .approved + .tafsirs + .one_verse + + params[:tafsirs] = approved_tafsirs + .where(id: tafsirs) + .pluck(:id) + + params[:tafsirs] + end + end + end + + def fetch_translations + strong_memoize :approved_translations do + if params[:translations] + translations = params[:translations].to_s.split(',') + + approved_translations = ResourceContent + .approved + .translations + .one_verse + + params[:translations] = approved_translations + .where(id: translations) + .or(approved_translations.where(slug: translations)) + .pluck(:id) + params[:translations] + end + end + end + protected def chapter_ids @@ -137,4 +174,4 @@ def fetch_reciter end end end -end \ No newline at end of file +end diff --git a/app/presenters/recitations_presenter.rb b/app/presenters/recitations_presenter.rb index 25aaa828..72dfac1f 100644 --- a/app/presenters/recitations_presenter.rb +++ b/app/presenters/recitations_presenter.rb @@ -51,7 +51,9 @@ def recitation_id recitation = params[:recitation_id].to_s.strip approved = Recitation - .approved + .joins(:resource_content) + .where.not(resource_contents: { permission_to_share: :rejected }) + .approved params[:recitation_id] = approved.where(id: recitation).first&.id end diff --git a/app/presenters/tafsirs_presenter.rb b/app/presenters/tafsirs_presenter.rb index 96aa0599..9feeed3f 100644 --- a/app/presenters/tafsirs_presenter.rb +++ b/app/presenters/tafsirs_presenter.rb @@ -74,6 +74,7 @@ def resource approved_Tafsirs = ResourceContent .approved .tafsirs + .allowed_to_share approved_tafsir = approved_Tafsirs .where(id: id_or_slug) diff --git a/app/presenters/translations_presenter.rb b/app/presenters/translations_presenter.rb index e1e7ce93..fd4c2527 100644 --- a/app/presenters/translations_presenter.rb +++ b/app/presenters/translations_presenter.rb @@ -53,7 +53,7 @@ def resource_id .approved .translations .one_verse - + .allowed_to_share params[:resource_id] = approved_translations .where(id: translations) .or(approved_translations.where(slug: translations)) diff --git a/app/presenters/verses_presenter.rb b/app/presenters/verses_presenter.rb index ff1ec600..c712903c 100644 --- a/app/presenters/verses_presenter.rb +++ b/app/presenters/verses_presenter.rb @@ -262,6 +262,7 @@ def fetch_tafsirs .approved .tafsirs .one_verse + .allowed_to_share params[:tafsirs] = approved_tafsirs .where(id: tafsirs) @@ -281,6 +282,7 @@ def fetch_translations .approved .translations .one_verse + .allowed_to_share params[:translations] = approved_translations .where(id: translations) diff --git a/app/views/api/qdc/chapter_metadata/_metadata_item.json.streamer b/app/views/api/qdc/chapter_metadata/_metadata_item.json.streamer new file mode 100644 index 00000000..a6cc8ff1 --- /dev/null +++ b/app/views/api/qdc/chapter_metadata/_metadata_item.json.streamer @@ -0,0 +1,5 @@ +json.object! do + json.id item.id + json.language_name item.language.name + json.text item.content +end diff --git a/app/views/api/qdc/chapter_metadata/metadata.json.streamer b/app/views/api/qdc/chapter_metadata/metadata.json.streamer new file mode 100644 index 00000000..79a6116f --- /dev/null +++ b/app/views/api/qdc/chapter_metadata/metadata.json.streamer @@ -0,0 +1,41 @@ +json.object! do + json.chapter_metadata do + json.object! do + json.chapter_id @presenter.chapter.id + + json.suggestions do + json.array! @presenter.suggestions do |item| + json.partial! 'metadata_item', item: item + end + end + + if @presenter.next_chapter.present? + json.next_chapter do + json.object! do + json.summaries do + json.array! @presenter.next_chapter_summaries do |item| + json.partial! 'metadata_item', item: item + end + end + end + end + else + json.next_chapter nil + end + + if @presenter.previous_chapter.present? + json.previous_chapter do + json.object! do + json.summaries do + json.array! @presenter.previous_chapter_summaries do |item| + json.partial! 'metadata_item', item: item + end + end + end + end + else + json.previous_chapter nil + end + end + end +end diff --git a/app/views/api/qdc/chapters/_chapter.json.streamer b/app/views/api/qdc/chapters/_chapter.json.streamer index 8bf22593..f82461d4 100644 --- a/app/views/api/qdc/chapters/_chapter.json.streamer +++ b/app/views/api/qdc/chapters/_chapter.json.streamer @@ -44,4 +44,12 @@ json.object! do json.extract! chapter.translated_name, :language_name, :name end end -end \ No newline at end of file + + if render_transliterated_name && chapter.transliteration + json.transliterated_name do + json.object! do + json.extract! chapter.transliteration, :language_name, :text + end + end + end +end diff --git a/app/views/api/qdc/chapters/index.json.streamer b/app/views/api/qdc/chapters/index.json.streamer index fd0b98e2..8121fafa 100644 --- a/app/views/api/qdc/chapters/index.json.streamer +++ b/app/views/api/qdc/chapters/index.json.streamer @@ -4,6 +4,7 @@ json.object! do json.partial! 'chapter', chapter: chapter, render_slugs: @presenter.include_slugs?, render_translated_names: @presenter.include_translated_names?, + render_transliterated_name: @presenter.include_transliterated_name?, render_info: false end end diff --git a/app/views/api/qdc/chapters/show.json.streamer b/app/views/api/qdc/chapters/show.json.streamer index 63e9ac38..6a26d846 100644 --- a/app/views/api/qdc/chapters/show.json.streamer +++ b/app/views/api/qdc/chapters/show.json.streamer @@ -3,6 +3,7 @@ json.object! do json.partial! 'chapter', chapter: @presenter.chapter, render_slugs: @presenter.include_slugs?, render_translated_names: @presenter.include_translated_names?, + render_transliterated_name: @presenter.include_transliterated_name?, render_info: @presenter.include_chapter_info? end -end \ No newline at end of file +end diff --git a/app/views/api/qdc/recitations/_audio_files.json.streamer b/app/views/api/qdc/recitations/_audio_files.json.streamer index 3c470dde..9e105d30 100644 --- a/app/views/api/qdc/recitations/_audio_files.json.streamer +++ b/app/views/api/qdc/recitations/_audio_files.json.streamer @@ -1,8 +1,9 @@ fields = @presenter.audio_fields +audio_files = @presenter.audio_files(action_name) json.object! do json.audio_files do - json.array! @audio_files do |audio| + json.array! audio_files do |audio| json.object! do json.extract! audio, :verse_key, @@ -21,4 +22,4 @@ json.object! do json.total_records @presenter.total_records end end -end \ No newline at end of file +end diff --git a/app/views/api/qdc/resources/country_language_preference.json.streamer b/app/views/api/qdc/resources/country_language_preference.json.streamer new file mode 100644 index 00000000..ffa4a778 --- /dev/null +++ b/app/views/api/qdc/resources/country_language_preference.json.streamer @@ -0,0 +1,101 @@ +json.object! do + json.extract! @data[:preference], :id, :country, :user_device_language + + if @data[:default_mushaf] + json.default_mushaf do + json.object! do + json.extract! @data[:default_mushaf], :id, :name + end + end + else + json.default_mushaf nil + end + + json.default_translations do + json.array! @data[:default_translations] do |translation| + json.object! do + json.extract! translation, :id, :name, :author_name, :slug, :language_name + json.translated_name do + translated_name = translation.translated_name + json.object! do + json.extract! translated_name, :name, :language_name + end + end + end + end + end + + json.qr_default_translations do + json.array! @data[:qr_default_translations] do |translation| + json.object! do + json.extract! translation, :id, :name, :author_name, :slug, :language_name + json.translated_name do + translated_name = translation.translated_name + json.object! do + json.extract! translated_name, :name, :language_name + end + end + end + end + end + + if @data[:default_tafsir] + json.default_tafsir do + json.object! do + json.extract! @data[:default_tafsir], :id, :name, :author_name + end + end + else + json.default_tafsir nil + end + + if @data[:default_wbw_language] + json.default_wbw_language do + json.object! do + json.extract! @data[:default_wbw_language], :id, :name, :iso_code + end + end + else + json.default_wbw_language nil + end + + if @data[:default_reciter] + json.default_reciter do + json.object! do + json.extract! @data[:default_reciter], :id, :name + end + end + else + json.default_reciter nil + end + + json.ayah_reflections_languages do + json.array! @data[:ayah_reflections_languages] do |language| + json.object! do + json.extract! language, :id, :name, :iso_code + end + end + end + + json.qr_reflection_languages do + json.array! @data[:qr_reflection_languages] do |language| + json.object! do + json.extract! language, :id, :name, :iso_code + end + end + end + + json.learning_plan_languages do + json.array! @data[:learning_plan_languages] do |language| + json.object! do + json.extract! language, :id, :name, :iso_code + end + end + end + + json.qr_default_arabic_fonts do + json.array! @data[:qr_default_arabic_fonts] do |font_id| + json.value! font_id + end + end +end diff --git a/app/views/api/v4/resources/chapter_reciters.json.streamer b/app/views/api/v4/resources/chapter_reciters.json.streamer index cc7aa85d..fc6bd987 100644 --- a/app/views/api/v4/resources/chapter_reciters.json.streamer +++ b/app/views/api/v4/resources/chapter_reciters.json.streamer @@ -20,13 +20,17 @@ json.object! do end end - json.translated_name do - translated_name = recitation.translated_name - json.object! do - json.extract! translated_name, :name, :language_name + translated_name = recitation.translated_name + if translated_name + json.translated_name do + json.object! do + json.extract! translated_name, :name, :language_name + end end + else + json.translated_name nil end end end end -end \ No newline at end of file +end diff --git a/config/environments/test.rb b/config/environments/test.rb index eb2f1716..4182ee5f 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -47,4 +47,7 @@ # Annotate rendered view with file names. # config.action_view.annotate_rendered_view_with_filenames = true + + # Allow any host in test to prevent HostAuthorization 403s in request specs + config.hosts.clear end diff --git a/config/initializers/hosts.rb b/config/initializers/hosts.rb index 9c29c973..6da6555c 100644 --- a/config/initializers/hosts.rb +++ b/config/initializers/hosts.rb @@ -5,9 +5,20 @@ '.qurancdn.com', '.quran.foundation', '.staging.quran.foundation', - '.ondigitalocean.app' + '.testing.quran.foundation', + '.pre-live.quran.foundation', + '.ondigitalocean.app', + '.quranreflect.com', + '.quranreflect.org', + '.test.quranreflect.org', + '.staging.quranreflect.org', + '.prelive.quranreflect.org', ] if Rails.env.development? Rails.application.config.hosts +=['.loca.lt', /.ngrok.io/, 'localhost'] end + +if Rails.env.test? + Rails.application.config.hosts += ['www.example.com', 'example.org', 'example.com'] +end diff --git a/config/initializers/raven.rb b/config/initializers/raven.rb index 78ede8a7..6a1e2da2 100644 --- a/config/initializers/raven.rb +++ b/config/initializers/raven.rb @@ -1,8 +1,7 @@ # frozen_string_literal: true -if Rails.env.production? && ENV['SENTRY_DSN'].present? +if ENV['SENTRY_DSN'].present? Raven.configure do |config| config.dsn = ENV['SENTRY_DSN'] - config.environments = ['production'] end end diff --git a/config/routes.rb b/config/routes.rb index f0891519..1c0c40b9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -12,6 +12,11 @@ def draw_routes(routes_name) post '/graphql', to: 'graphql#execute' post "/internal/sync_api_client", to: "api_clients#sync" + # Simple health check endpoint (non-cached) + get '/health', to: 'health#show' + # Readiness endpoint (returns 503 if dependencies down) + get '/ready', to: 'readiness#show' + namespace :kalimat do get '/search', to: 'search#search' get '/suggest', to: 'search#suggest' diff --git a/config/routes/api/qdc.rb b/config/routes/api/qdc.rb index ea0ff2c0..38c35e53 100644 --- a/config/routes/api/qdc.rb +++ b/config/routes/api/qdc.rb @@ -28,6 +28,8 @@ get :chapter_infos get :verse_media get :word_by_word_translations + get :country_language_preference + end get :chapters, to: 'chapters#index' @@ -36,6 +38,9 @@ # Chapter info get 'chapters/:id/info', to: 'chapter_infos#show' + # Chapter metadata + get 'chapters/:id/metadata', to: 'chapter_metadata#metadata' + # Juz get 'juzs', to: 'juzs#index' get 'juzs/:id', to: 'juzs#show' diff --git a/db/migrate/20240918072140_language_iso_code_unique.rb b/db/migrate/20240918072140_language_iso_code_unique.rb new file mode 100644 index 00000000..5a1a379e --- /dev/null +++ b/db/migrate/20240918072140_language_iso_code_unique.rb @@ -0,0 +1,6 @@ +class LanguageIsoCodeUnique < ActiveRecord::Migration[7.0] + def change + remove_index :languages, :iso_code if index_exists?(:languages, :iso_code) + add_index :languages, :iso_code, unique: true + end +end diff --git a/db/migrate/20240918072240_create_country_language_preferences.rb b/db/migrate/20240918072240_create_country_language_preferences.rb new file mode 100644 index 00000000..2121e8eb --- /dev/null +++ b/db/migrate/20240918072240_create_country_language_preferences.rb @@ -0,0 +1,23 @@ +class CreateCountryLanguagePreferences < ActiveRecord::Migration[7.0] + def change + create_table :country_language_preferences do |t| + t.string :country, null: true + t.string :user_device_language, null: false + t.integer :default_mushaf_id + t.string :default_translation_ids + t.integer :default_tafsir_id + t.string :default_wbw_language + t.integer :default_reciter + t.string :ayah_reflections_languages + t.string :learning_plan_languages + + t.timestamps + end + + add_foreign_key :country_language_preferences, :languages, column: :user_device_language, primary_key: :iso_code, on_delete: :cascade + add_foreign_key :country_language_preferences, :mushafs, column: :default_mushaf_id, on_delete: :cascade + add_foreign_key :country_language_preferences, :resource_contents, column: :default_tafsir_id, on_delete: :cascade + add_foreign_key :country_language_preferences, :languages, column: :default_wbw_language, primary_key: :iso_code, on_delete: :cascade + add_foreign_key :country_language_preferences, :reciters, column: :default_reciter, on_delete: :cascade + end +end diff --git a/db/migrate/20241228151537_add_resource_permissions.rb b/db/migrate/20241228151537_add_resource_permissions.rb new file mode 100644 index 00000000..313740e7 --- /dev/null +++ b/db/migrate/20241228151537_add_resource_permissions.rb @@ -0,0 +1,6 @@ +class AddResourcePermissions < ActiveRecord::Migration[7.0] + def change + add_column :resource_contents, :permission_to_host, :integer, default: 0 + add_column :resource_contents, :permission_to_share, :integer, default: 0 + end +end diff --git a/db/migrate/20250909000000_add_qr_fields_to_country_language_preferences.rb b/db/migrate/20250909000000_add_qr_fields_to_country_language_preferences.rb new file mode 100644 index 00000000..d11a19d6 --- /dev/null +++ b/db/migrate/20250909000000_add_qr_fields_to_country_language_preferences.rb @@ -0,0 +1,6 @@ +class AddQrFieldsToCountryLanguagePreferences < ActiveRecord::Migration[7.0] + def change + add_column :country_language_preferences, :qr_default_translations_ids, :string + add_column :country_language_preferences, :qr_reflection_languages, :string + end +end diff --git a/db/migrate/20251003133718_create_chapter_metadata.rb b/db/migrate/20251003133718_create_chapter_metadata.rb new file mode 100644 index 00000000..ca9b4d06 --- /dev/null +++ b/db/migrate/20251003133718_create_chapter_metadata.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class CreateChapterMetadata < ActiveRecord::Migration[7.0] + def change + create_table :chapter_metadata do |t| + t.integer :chapter_id, null: false + t.string :metadata_type, null: false + t.text :content, null: false + t.integer :language_id, null: false + t.integer :resource_content_id + t.boolean :is_active, default: true, null: false + + t.timestamps + end + + add_index :chapter_metadata, [:chapter_id, :language_id, :metadata_type, :is_active], name: 'index_chapter_metadata_on_query_pattern' + add_index :chapter_metadata, :resource_content_id + + add_foreign_key :chapter_metadata, :chapters, column: :chapter_id + add_foreign_key :chapter_metadata, :languages, column: :language_id + add_foreign_key :chapter_metadata, :resource_contents, column: :resource_content_id + + add_check_constraint :chapter_metadata, "metadata_type IN ('summary', 'suggestion')", name: 'check_metadata_type' + end +end diff --git a/db/migrate/20251013110636_add_qr_default_arabic_fonts_to_country_language_preferences.rb b/db/migrate/20251013110636_add_qr_default_arabic_fonts_to_country_language_preferences.rb new file mode 100644 index 00000000..800cb72e --- /dev/null +++ b/db/migrate/20251013110636_add_qr_default_arabic_fonts_to_country_language_preferences.rb @@ -0,0 +1,5 @@ +class AddQrDefaultArabicFontsToCountryLanguagePreferences < ActiveRecord::Migration[7.0] + def change + add_column :country_language_preferences, :qr_default_arabic_fonts, :string + end +end diff --git a/db/migrate/20251014122032_update_default_reciter_foreign_key_to_audio_recitation.rb b/db/migrate/20251014122032_update_default_reciter_foreign_key_to_audio_recitation.rb new file mode 100644 index 00000000..435c4994 --- /dev/null +++ b/db/migrate/20251014122032_update_default_reciter_foreign_key_to_audio_recitation.rb @@ -0,0 +1,9 @@ +class UpdateDefaultReciterForeignKeyToAudioRecitation < ActiveRecord::Migration[7.0] + def change + # Remove the existing foreign key constraint to reciters table + remove_foreign_key :country_language_preferences, :reciters, column: :default_reciter + + # Add the new foreign key constraint to audio_recitations table + add_foreign_key :country_language_preferences, :audio_recitations, column: :default_reciter, on_delete: :cascade + end +end diff --git a/db/schema.rb b/db/schema.rb index 7982eb8c..74d99371 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.0].define(version: 2023_03_13_013539) do +ActiveRecord::Schema[7.0].define(version: 2025_10_14_122032) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -40,6 +40,23 @@ t.index ["api_key"], name: "index_api_clients_on_api_key" end + create_table "arabic_transliterations", id: :serial, force: :cascade do |t| + t.integer "word_id" + t.integer "verse_id" + t.string "text" + t.string "indopak_text" + t.integer "page_number" + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false + t.integer "position_x" + t.integer "position_y" + t.float "zoom" + t.string "ur_translation" + t.boolean "continuous" + t.index ["verse_id"], name: "index_arabic_transliterations_on_verse_id" + t.index ["word_id"], name: "index_arabic_transliterations_on_word_id" + end + create_table "audio_change_logs", force: :cascade do |t| t.integer "audio_recitation_id" t.datetime "date", precision: nil @@ -64,17 +81,18 @@ t.jsonb "metadata", default: {} t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.integer "resource_content_id" t.integer "duration_ms" t.string "audio_url" t.string "timing_percentiles", array: true t.index ["audio_recitation_id"], name: "index_audio_chapter_audio_files_on_audio_recitation_id" t.index ["chapter_id"], name: "index_audio_chapter_audio_files_on_chapter_id" t.index ["format"], name: "index_audio_chapter_audio_files_on_format" + t.index ["resource_content_id"], name: "index_audio_chapter_audio_files_on_resource_content_id" end create_table "audio_files", id: :serial, force: :cascade do |t| - t.string "resource_type" - t.integer "resource_id" + t.integer "verse_id" t.text "url" t.integer "duration" t.text "segments" @@ -102,9 +120,9 @@ t.index ["manzil_number"], name: "index_audio_files_on_manzil_number" t.index ["page_number"], name: "index_audio_files_on_page_number" t.index ["recitation_id"], name: "index_audio_files_on_recitation_id" - t.index ["resource_type", "resource_id"], name: "index_audio_files_on_resource" t.index ["rub_el_hizb_number"], name: "index_audio_files_on_rub_el_hizb_number" t.index ["ruku_number"], name: "index_audio_files_on_ruku_number" + t.index ["verse_id"], name: "index_audio_files_on_verse_id" t.index ["verse_key"], name: "index_audio_files_on_verse_key" end @@ -116,22 +134,21 @@ t.integer "section_id" t.text "description" t.integer "files_count" - t.integer "priority" t.integer "resource_content_id" t.integer "recitation_style_id" t.integer "reciter_id" - t.boolean "approved", default: false - t.boolean "lock_segments", default: false - t.integer "segment_locked" - t.float "files_size" + t.boolean "approved" t.integer "home" - t.integer "qirat_type_id" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.integer "priority" + t.integer "segments_count" + t.float "files_size" + t.integer "qirat_type_id" + t.boolean "segment_locked", default: false t.index ["approved"], name: "index_audio_recitations_on_approved" t.index ["name"], name: "index_audio_recitations_on_name" t.index ["priority"], name: "index_audio_recitations_on_priority" - t.index ["qirat_type_id"], name: "index_audio_recitations_on_qirat_type_id" t.index ["recitation_style_id"], name: "index_audio_recitations_on_recitation_style_id" t.index ["reciter_id"], name: "index_audio_recitations_on_reciter_id" t.index ["relative_path"], name: "index_audio_recitations_on_relative_path" @@ -164,13 +181,14 @@ t.integer "timestamp_to" t.integer "timestamp_median" t.jsonb "segments", default: [] - t.jsonb "relative_segments", default: [] t.integer "duration" - t.integer "silent_duration" - t.integer "relative_silent_duration" + t.integer "duration_ms" t.float "percentile" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.integer "silent_duration" + t.jsonb "relative_segments", default: [] + t.integer "relative_silent_duration" t.index ["audio_file_id", "verse_number"], name: "index_audio_segments_on_audio_file_id_and_verse_number", unique: true t.index ["audio_file_id"], name: "index_audio_segments_on_audio_file_id" t.index ["audio_recitation_id", "chapter_id", "verse_id", "timestamp_median"], name: "index_on_audio_segments_median_time" @@ -180,11 +198,33 @@ t.index ["verse_number"], name: "index_audio_segments_on_verse_number" end + create_table "author", primary_key: "author_id", id: :integer, default: -> { "nextval('_author_author_id_seq'::regclass)" }, force: :cascade do |t| + t.text "url", array: true + t.text "name", null: false + end + create_table "authors", id: :serial, force: :cascade do |t| t.string "name" t.string "url" t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false + t.integer "resource_contents_count", default: 0 + end + + create_table "ayah", primary_key: "ayah_key", id: :text, force: :cascade do |t| + t.integer "ayah_index", null: false + t.integer "surah_id" + t.integer "ayah_num" + t.integer "page_num" + t.integer "juz_num" + t.integer "hizb_num" + t.integer "rub_num" + t.text "text" + t.text "sajdah" + t.index ["ayah_index"], name: "ayah_index_key", unique: true + t.index ["ayah_key"], name: "index_quran.ayah_on_ayah_key" + t.index ["surah_id", "ayah_num"], name: "surah_ayah_key", unique: true + t.index ["surah_id"], name: "ayah_surah_id_idx" end create_table "ayah_themes", force: :cascade do |t| @@ -198,10 +238,9 @@ t.integer "verses_count" t.string "theme" t.jsonb "keywords" - t.integer "book_id" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["book_id"], name: "index_ayah_themes_on_book_id" + t.integer "book_id" t.index ["chapter_id"], name: "index_ayah_themes_on_chapter_id" t.index ["verse_id_from"], name: "index_ayah_themes_on_verse_id_from" t.index ["verse_id_to"], name: "index_ayah_themes_on_verse_id_to" @@ -231,6 +270,23 @@ t.index ["resource_content_id"], name: "index_chapter_infos_on_resource_content_id" end + create_table "chapter_metadata", force: :cascade do |t| + t.integer "chapter_id", null: false + t.string "metadata_type", null: false + t.text "content", null: false + t.integer "language_id", null: false + t.string "language_name" + t.boolean "is_active", default: true + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["chapter_id", "is_active"], name: "index_chapter_metadata_on_chapter_id_and_is_active" + t.index ["chapter_id", "metadata_type", "is_active"], name: "index_chapter_metadata_on_chapter_type_active" + t.index ["chapter_id", "metadata_type"], name: "index_chapter_metadata_on_chapter_id_and_metadata_type" + t.index ["chapter_id"], name: "index_chapter_metadata_on_chapter_id" + t.index ["language_id", "is_active"], name: "index_chapter_metadata_on_language_active" + t.index ["language_id"], name: "index_chapter_metadata_on_language_id" + end + create_table "chapters", id: :serial, force: :cascade do |t| t.boolean "bismillah_pre" t.integer "revelation_order" @@ -249,6 +305,13 @@ t.index ["chapter_number"], name: "index_chapters_on_chapter_number" end + create_table "char_type", primary_key: "char_type_id", id: :serial, force: :cascade do |t| + t.text "name", null: false + t.text "description" + t.integer "parent_id" + t.index ["name", "parent_id"], name: "char_type_name_parent_id_key", unique: true + end + create_table "char_types", id: :serial, force: :cascade do |t| t.string "name" t.integer "parent_id" @@ -279,6 +342,23 @@ t.index ["word_id"], name: "index_corpus_word_grammars_on_word_id" end + create_table "country_language_preferences", force: :cascade do |t| + t.string "country" + t.string "user_device_language", null: false + t.integer "default_mushaf_id" + t.string "default_translation_ids" + t.integer "default_tafsir_id" + t.string "default_wbw_language" + t.integer "default_reciter" + t.string "ayah_reflections_languages" + t.string "learning_plan_languages" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "qr_default_translations_ids" + t.string "qr_reflection_languages" + t.string "qr_default_arabic_fonts" + end + create_table "data_sources", id: :serial, force: :cascade do |t| t.string "name" t.string "url" @@ -305,10 +385,10 @@ t.integer "segment_first_word_timestamp" t.integer "segment_last_word_timestamp" t.integer "word_id" - t.integer "verse_id" t.bigint "word_root_id" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.integer "verse_id" t.index ["verse_id"], name: "index_dictionary_root_examples_on_verse_id" t.index ["word_id"], name: "index_dictionary_root_examples_on_word_id" t.index ["word_root_id"], name: "index_on_dict_word_example_id" @@ -330,9 +410,31 @@ t.index ["root_number"], name: "index_dictionary_word_roots_on_root_number" end + create_table "feedbacks", force: :cascade do |t| + t.string "name" + t.string "email" + t.string "url" + t.text "message" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + create_table "file", primary_key: "file_id", id: :integer, default: -> { "nextval('_file_file_id_seq'::regclass)" }, force: :cascade do |t| + t.integer "recitation_id", null: false + t.text "ayah_key", null: false + t.text "format", null: false + t.float "duration" + t.text "mime_type", null: false + t.boolean "is_enabled", default: true, null: false + t.text "url" + t.json "segments_stats" + t.text "segments", array: true + t.index ["ayah_key"], name: "index_audio.file_on_ayah_key" + t.index ["recitation_id", "ayah_key", "format"], name: "_file_recitation_id_ayah_key_format_key", unique: true + end + create_table "foot_notes", id: :serial, force: :cascade do |t| - t.string "resource_type" - t.integer "resource_id" + t.integer "translation_id" t.text "text" t.integer "language_id" t.string "language_name" @@ -341,7 +443,7 @@ t.datetime "updated_at", precision: nil, null: false t.index ["language_id"], name: "index_foot_notes_on_language_id" t.index ["resource_content_id"], name: "index_foot_notes_on_resource_content_id" - t.index ["resource_type", "resource_id"], name: "index_foot_notes_on_resource" + t.index ["translation_id"], name: "index_foot_notes_on_translation_id" end create_table "hizbs", force: :cascade do |t| @@ -356,6 +458,26 @@ t.index ["hizb_number"], name: "index_hizbs_on_hizb_number" end + create_table "image", primary_key: ["resource_id", "ayah_key"], force: :cascade do |t| + t.integer "resource_id", null: false + t.text "ayah_key", null: false + t.text "url", null: false + t.text "alt", null: false + t.integer "width", null: false + end + + create_table "images", id: :serial, force: :cascade do |t| + t.integer "verse_id" + t.integer "resource_content_id" + t.integer "width" + t.string "url" + t.text "alt" + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false + t.index ["resource_content_id"], name: "index_images_on_resource_content_id" + t.index ["verse_id"], name: "index_images_on_verse_id" + end + create_table "juzs", id: :serial, force: :cascade do |t| t.integer "juz_number" t.datetime "created_at", precision: nil, null: false @@ -369,6 +491,15 @@ t.index ["last_verse_id"], name: "index_juzs_on_last_verse_id" end + create_table "language", primary_key: "language_code", id: :text, force: :cascade do |t| + t.text "unicode" + t.text "english", null: false + t.text "direction", default: "ltr", null: false + t.integer "priority", default: 999, null: false + t.boolean "beta", default: true, null: false + t.string "es_analyzer_default" + end + create_table "languages", id: :serial, force: :cascade do |t| t.string "name" t.string "iso_code" @@ -377,7 +508,16 @@ t.string "es_analyzer_default" t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false - t.index ["iso_code"], name: "index_languages_on_iso_code" + t.string "es_indexes" + t.integer "translations_count" + t.index ["iso_code"], name: "index_languages_on_iso_code", unique: true + t.index ["translations_count"], name: "index_languages_on_translations_count" + end + + create_table "lemma", primary_key: "lemma_id", id: :serial, force: :cascade do |t| + t.string "value", limit: 50, null: false + t.string "clean", limit: 50, null: false + t.index ["value"], name: "lemma_value_key", unique: true end create_table "lemmas", id: :serial, force: :cascade do |t| @@ -416,7 +556,7 @@ t.datetime "updated_at", precision: nil, null: false t.index ["language_id"], name: "index_media_contents_on_language_id" t.index ["resource_content_id"], name: "index_media_contents_on_resource_content_id" - t.index ["resource_type", "resource_id"], name: "index_media_contents_on_resource" + t.index ["resource_type", "resource_id"], name: "index_media_contents_on_resource_type_and_resource_id" end create_table "morphology_derived_words", force: :cascade do |t| @@ -498,9 +638,9 @@ t.string "root_name" t.string "lemma_name" t.string "verb_form" - t.boolean "hidden", default: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.boolean "hidden" t.index ["grammar_concept_id"], name: "index_morphology_word_segments_on_grammar_concept_id" t.index ["grammar_role_id"], name: "index_morphology_word_segments_on_grammar_role_id" t.index ["grammar_sub_role_id"], name: "index_morphology_word_segments_on_grammar_sub_role_id" @@ -566,13 +706,13 @@ create_table "mushaf_pages", force: :cascade do |t| t.integer "page_number" - t.integer "mushaf_id" + t.json "verse_mapping" t.integer "first_verse_id" t.integer "last_verse_id" t.integer "verses_count" - t.json "verse_mapping" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.integer "mushaf_id" t.integer "first_word_id" t.integer "last_word_id" t.index ["mushaf_id"], name: "index_mushaf_pages_on_mushaf_id" @@ -583,7 +723,7 @@ t.integer "mushaf_id" t.integer "word_id" t.integer "verse_id" - t.string "text" + t.text "text" t.integer "char_type_id" t.string "char_type_name" t.integer "line_number" @@ -594,25 +734,34 @@ t.string "css_style" t.string "css_class" t.index ["mushaf_id", "verse_id", "position_in_page"], name: "index_on_mushaf_word_position" + t.index ["mushaf_id", "verse_id", "position_in_verse"], name: "index_on_mushad_word_position" t.index ["mushaf_id", "word_id"], name: "index_mushaf_words_on_mushaf_id_and_word_id" end create_table "mushafs", force: :cascade do |t| - t.string "name" + t.string "name", null: false t.text "description" t.integer "lines_per_page" t.boolean "is_default", default: false t.string "default_font_name" - t.bigint "resource_content_id" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.integer "pages_count" t.integer "qirat_type_id" t.boolean "enabled" + t.integer "resource_content_id" t.index ["enabled"], name: "index_mushafs_on_enabled" t.index ["is_default"], name: "index_mushafs_on_is_default" t.index ["qirat_type_id"], name: "index_mushafs_on_qirat_type_id" - t.index ["resource_content_id"], name: "index_mushafs_on_resource_content_id" + end + + create_table "mushas_pages", force: :cascade do |t| + t.integer "page_number" + t.json "verse_mapping" + t.integer "first_verse_id" + t.integer "last_verse_id" + t.integer "verses_count" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["page_number"], name: "index_mushas_pages_on_page_number" end create_table "navigation_search_records", force: :cascade do |t| @@ -632,9 +781,9 @@ create_table "qirat_types", force: :cascade do |t| t.string "name" t.text "description" - t.integer "audio_recitations_count" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.integer "recitations_count", default: 0 end create_table "qr_authors", force: :cascade do |t| @@ -644,12 +793,12 @@ t.string "avatar_url" t.text "bio" t.integer "user_type" - t.integer "followers_count", default: 0 - t.integer "followings_count", default: 0 - t.integer "posts_count", default: 0 - t.integer "comments_count", default: 0 + t.integer "followers_count" + t.integer "followings_count" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.integer "posts_count", default: 0 + t.integer "comments_count", default: 0 t.index ["user_type"], name: "index_qr_authors_on_user_type" t.index ["username"], name: "index_qr_authors_on_username" t.index ["verified"], name: "index_qr_authors_on_verified" @@ -658,12 +807,12 @@ create_table "qr_comments", force: :cascade do |t| t.text "body" t.text "html_body" - t.integer "replies_count", default: 0 t.integer "post_id" t.integer "parent_id" t.integer "author_id" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.integer "replies_count", default: 0 t.index ["author_id"], name: "index_qr_comments_on_author_id" t.index ["parent_id"], name: "index_qr_comments_on_parent_id" t.index ["post_id"], name: "index_qr_comments_on_post_id" @@ -707,20 +856,20 @@ create_table "qr_posts", force: :cascade do |t| t.integer "post_type" t.integer "author_id" - t.boolean "verified" t.integer "likes_count", default: 0 t.integer "comments_count", default: 0 t.integer "views_count", default: 0 t.integer "language_id" - t.integer "ranking_weight" t.string "language_name" - t.string "url" - t.json "referenced_ayahs" t.text "body" t.text "html_body" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.boolean "verified" + t.json "referenced_ayahs" + t.integer "ranking_weight" t.integer "room_id" + t.string "url" t.integer "room_post_status" t.index ["author_id"], name: "index_qr_posts_on_author_id" t.index ["language_id"], name: "index_qr_posts_on_language_id" @@ -751,10 +900,10 @@ create_table "qr_tags", force: :cascade do |t| t.string "name" t.boolean "approved", default: true - t.integer "posts_count" - t.integer "comments_count" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.integer "comments_count" + t.integer "posts_count" t.index ["approved"], name: "index_qr_tags_on_approved" t.index ["name"], name: "index_qr_tags_on_name" t.index ["posts_count"], name: "index_qr_tags_on_posts_count" @@ -775,23 +924,30 @@ t.string "profile_picture" t.text "description" t.integer "audio_recitation_id" - t.integer "parent_id" t.integer "priority" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.integer "parent_id" t.index ["audio_recitation_id"], name: "index_radio_stations_on_audio_recitation_id" t.index ["parent_id"], name: "index_radio_stations_on_parent_id" t.index ["priority"], name: "index_radio_stations_on_priority" end + create_table "recitation", primary_key: "recitation_id", id: :serial, force: :cascade do |t| + t.integer "reciter_id", null: false + t.integer "style_id" + t.boolean "is_enabled", default: true, null: false + t.index ["reciter_id", "style_id"], name: "recitation_reciter_id_style_id_key", unique: true + end + create_table "recitation_styles", id: :serial, force: :cascade do |t| t.string "name" - t.text "description" + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.string "arabic" t.string "slug" + t.text "description" t.integer "recitations_count", default: 0 - t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false t.index ["slug"], name: "index_recitation_styles_on_slug" end @@ -810,15 +966,26 @@ t.index ["resource_content_id"], name: "index_recitations_on_resource_content_id" end + create_table "reciter", primary_key: "reciter_id", id: :integer, default: -> { "nextval('_reciter_reciter_id_seq'::regclass)" }, force: :cascade do |t| + t.text "path" + t.text "slug" + t.text "english", null: false + t.text "arabic", null: false + t.text "description" + t.index ["arabic"], name: "_reciter_arabic_key", unique: true + t.index ["english"], name: "_reciter_english_key", unique: true + t.index ["path"], name: "_reciter_path_key", unique: true + t.index ["slug"], name: "_reciter_slug_key", unique: true + end + create_table "reciters", id: :serial, force: :cascade do |t| t.string "name" - t.text "description" - t.integer "recitations_count", default: 0 t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false + t.integer "recitations_count", default: 0 t.string "profile_picture" - t.string "cover_image" t.text "bio" + t.string "cover_image" end create_table "related_topics", force: :cascade do |t| @@ -829,6 +996,33 @@ t.index ["topic_id"], name: "index_related_topics_on_topic_id" end + create_table "resource", primary_key: "resource_id", id: :serial, force: :cascade do |t| + t.text "type", null: false + t.text "sub_type", null: false + t.text "cardinality_type", default: "1_ayah", null: false + t.text "language_code", null: false + t.text "slug", null: false + t.boolean "is_available", default: true, null: false + t.text "description" + t.integer "author_id" + t.integer "source_id" + t.text "name", null: false + t.index ["type", "sub_type", "language_code", "slug"], name: "resource_type_sub_type_language_code_slug_key", unique: true + end + + create_table "resource_api_version", primary_key: "resource_id", id: :integer, default: nil, force: :cascade do |t| + t.boolean "v1_is_enabled", default: false, null: false + t.boolean "v1_is_default" + t.boolean "v1_separator" + t.boolean "v1_label" + t.integer "v1_order" + t.integer "v1_id" + t.text "v1_name" + t.boolean "v2_is_enabled", default: false, null: false + t.boolean "v2_is_default" + t.float "v2_weighted", default: 0.618, null: false + end + create_table "resource_content_stats", id: :serial, force: :cascade do |t| t.integer "resource_content_id" t.integer "download_count" @@ -847,41 +1041,65 @@ t.string "resource_type_name" t.string "sub_type" t.string "name" - t.integer "mobile_translation_id" t.text "description" t.string "cardinality_type" t.integer "language_id" t.string "language_name" - t.integer "records_count", default: 0 t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false t.string "slug" + t.integer "mobile_translation_id" + t.integer "priority" + t.text "resource_info" + t.string "resource_id" + t.jsonb "meta_data", default: {} t.string "resource_type" t.string "sqlite_db" t.datetime "sqlite_db_generated_at", precision: nil + t.integer "records_count", default: 0 + t.integer "permission_to_host", default: 0 + t.integer "permission_to_share", default: 0 t.index ["approved"], name: "index_resource_contents_on_approved" t.index ["author_id"], name: "index_resource_contents_on_author_id" t.index ["cardinality_type"], name: "index_resource_contents_on_cardinality_type" t.index ["data_source_id"], name: "index_resource_contents_on_data_source_id" t.index ["language_id"], name: "index_resource_contents_on_language_id" + t.index ["meta_data"], name: "index_resource_contents_on_meta_data", using: :gin t.index ["mobile_translation_id"], name: "index_resource_contents_on_mobile_translation_id" + t.index ["priority"], name: "index_resource_contents_on_priority" + t.index ["resource_id"], name: "index_resource_contents_on_resource_id" t.index ["resource_type_name"], name: "index_resource_contents_on_resource_type_name" t.index ["slug"], name: "index_resource_contents_on_slug" t.index ["sub_type"], name: "index_resource_contents_on_sub_type" end + create_table "resource_tags", force: :cascade do |t| + t.integer "tag_id" + t.integer "resource_id" + t.string "resource_type" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["tag_id", "resource_id", "resource_type"], name: "index_on_resource_tag" + end + + create_table "root", primary_key: "root_id", id: :serial, force: :cascade do |t| + t.string "value", limit: 50, null: false + t.index ["value"], name: "root_value_key", unique: true + end + create_table "roots", id: :serial, force: :cascade do |t| + t.string "value" + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false + t.integer "words_count" + t.integer "uniq_words_count" t.string "text_clean" t.string "text_uthmani" t.string "english_trilateral" t.string "arabic_trilateral" + t.jsonb "en_translations" + t.jsonb "ur_translations" t.string "dictionary_image_path" - t.json "en_translations" - t.json "ur_translations" - t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false - t.integer "words_count" - t.integer "uniq_words_count" t.index ["arabic_trilateral"], name: "index_roots_on_arabic_trilateral" t.index ["english_trilateral"], name: "index_roots_on_english_trilateral" t.index ["text_clean"], name: "index_roots_on_text_clean" @@ -919,15 +1137,28 @@ t.string "locale" t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false + t.boolean "is_default", default: false t.string "name" t.integer "language_priority" t.integer "language_id" t.index ["chapter_id", "slug"], name: "index_slugs_on_chapter_id_and_slug" t.index ["chapter_id"], name: "index_slugs_on_chapter_id" + t.index ["is_default"], name: "index_slugs_on_is_default" t.index ["language_id"], name: "index_slugs_on_language_id" t.index ["language_priority"], name: "index_slugs_on_language_priority" end + create_table "source", primary_key: "source_id", id: :integer, default: nil, force: :cascade do |t| + t.text "name", null: false + t.text "url" + end + + create_table "stem", primary_key: "stem_id", id: :serial, force: :cascade do |t| + t.string "value", limit: 50, null: false + t.string "clean", limit: 50, null: false + t.index ["value"], name: "stem_value_key", unique: true + end + create_table "stems", id: :serial, force: :cascade do |t| t.string "text_madani" t.string "text_clean" @@ -937,6 +1168,49 @@ t.integer "uniq_words_count" end + create_table "style", primary_key: "style_id", id: :integer, default: -> { "nextval('_style_style_id_seq'::regclass)" }, force: :cascade do |t| + t.text "path", null: false + t.text "slug", null: false + t.text "english", null: false + t.text "arabic", null: false + t.index ["arabic"], name: "_style_arabic_key", unique: true + t.index ["english"], name: "_style_english_key", unique: true + t.index ["path"], name: "_style_path_key", unique: true + t.index ["slug"], name: "_style_slug_key", unique: true + end + + create_table "surah", primary_key: "surah_id", id: :integer, default: nil, force: :cascade do |t| + t.integer "ayat", null: false + t.boolean "bismillah_pre", null: false + t.integer "revelation_order", null: false + t.text "revelation_place", null: false + t.integer "page", null: false, array: true + t.text "name_complex", null: false + t.text "name_simple", null: false + t.text "name_english", null: false + t.text "name_arabic", null: false + end + + create_table "surah_infos", id: :serial, force: :cascade do |t| + t.string "language_code" + t.text "description" + t.integer "surah_id" + t.text "content_source" + t.text "short_description" + t.index ["surah_id"], name: "index_content.surah_infos_on_surah_id" + end + + create_table "tafsir", primary_key: "tafsir_id", id: :integer, default: -> { "nextval('_tafsir_tafsir_id_seq'::regclass)" }, force: :cascade do |t| + t.integer "resource_id", null: false + t.text "text", null: false + t.index "resource_id, md5(text)", name: "tafsir_resource_id_md5_idx", unique: true + end + + create_table "tafsir_ayah", primary_key: ["tafsir_id", "ayah_key"], force: :cascade do |t| + t.integer "tafsir_id", null: false + t.text "ayah_key", null: false + end + create_table "tafsirs", id: :serial, force: :cascade do |t| t.integer "verse_id" t.integer "language_id" @@ -978,12 +1252,34 @@ t.index ["verse_key"], name: "index_tafsirs_on_verse_key" end + create_table "tags", force: :cascade do |t| + t.string "name" + t.string "description" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["name"], name: "index_tags_on_name" + end + + create_table "text", primary_key: ["resource_id", "ayah_key"], force: :cascade do |t| + t.integer "resource_id", null: false + t.text "ayah_key", null: false + t.text "text", null: false + end + + create_table "token", primary_key: "token_id", id: :serial, force: :cascade do |t| + t.string "value", limit: 50, null: false + t.string "clean", limit: 50, null: false + t.index ["value"], name: "token_value_key", unique: true + end + create_table "tokens", id: :serial, force: :cascade do |t| - t.string "text_madani" - t.string "text_clean" + t.string "text_uthmani" + t.string "text_imlaei_simple" t.string "text_indopak" t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false + t.string "text_imlaei" + t.string "text_uthmani_tajweed" t.text "text" t.integer "resource_content_id" t.integer "record_id" @@ -1031,19 +1327,26 @@ t.integer "language_priority" t.index ["language_id"], name: "index_translated_names_on_language_id" t.index ["language_priority"], name: "index_translated_names_on_language_priority" - t.index ["resource_type", "resource_id"], name: "index_translated_names_on_resource" + t.index ["resource_type", "resource_id"], name: "index_translated_names_on_resource_type_and_resource_id" + end + + create_table "translation", primary_key: ["resource_id", "ayah_key"], force: :cascade do |t| + t.integer "resource_id", null: false + t.text "ayah_key", null: false + t.text "text", null: false + t.index ["ayah_key"], name: "index_content.translation_on_ayah_key" end create_table "translations", id: :serial, force: :cascade do |t| t.integer "language_id" - t.integer "verse_id" t.text "text" t.integer "resource_content_id" + t.integer "verse_id" t.string "language_name" - t.integer "priority" t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false t.string "resource_name" + t.integer "priority" t.string "verse_key" t.integer "chapter_id" t.integer "verse_number" @@ -1069,6 +1372,12 @@ t.index ["verse_key"], name: "index_translations_on_verse_key" end + create_table "transliteration", primary_key: ["resource_id", "ayah_key"], force: :cascade do |t| + t.integer "resource_id", null: false + t.text "ayah_key", null: false + t.text "text", null: false + end + create_table "transliterations", id: :serial, force: :cascade do |t| t.string "resource_type" t.integer "resource_id" @@ -1080,7 +1389,7 @@ t.datetime "updated_at", precision: nil, null: false t.index ["language_id"], name: "index_transliterations_on_language_id" t.index ["resource_content_id"], name: "index_transliterations_on_resource_content_id" - t.index ["resource_type", "resource_id"], name: "index_transliterations_on_resource" + t.index ["resource_type", "resource_id"], name: "index_transliterations_on_resource_type_and_resource_id" end create_table "verse_lemmas", id: :serial, force: :cascade do |t| @@ -1116,10 +1425,10 @@ t.integer "topic_id" t.integer "verse_id" t.jsonb "topic_words", default: [] - t.boolean "ontology" - t.boolean "thematic" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.boolean "ontology", default: false + t.boolean "thematic", default: false t.index ["topic_id"], name: "index_verse_topics_on_topic_id" t.index ["verse_id"], name: "index_verse_topics_on_verse_id" end @@ -1129,16 +1438,13 @@ t.integer "verse_number" t.integer "verse_index" t.string "verse_key" - t.text "text_madani" - t.text "text_uthmani_simple" - t.text "text_uthmani_tajweed" - t.text "text_indopak" - t.text "text_simple" - t.text "text_imlaei" + t.string "text_uthmani" + t.string "text_indopak" + t.string "text_imlaei_simple" t.integer "juz_number" t.integer "hizb_number" t.integer "rub_el_hizb_number" - t.string "sajdah" + t.string "sajdah_type" t.integer "sajdah_number" t.integer "page_number" t.datetime "created_at", precision: nil, null: false @@ -1148,18 +1454,22 @@ t.integer "verse_root_id" t.integer "verse_lemma_id" t.integer "verse_stem_id" + t.string "text_imlaei" + t.string "text_uthmani_simple" + t.text "text_uthmani_tajweed" t.string "code_v1" t.string "code_v2" t.integer "v2_page" - t.string "text_indopak_nastaleeq" - t.string "text_qpc_nastaleeq" - t.string "text_qpc_nastaleeq_hafs" + t.string "text_qpc_hafs" t.integer "words_count" + t.string "text_indopak_nastaleeq" t.integer "pause_words_count", default: 0 t.jsonb "mushaf_pages_mapping", default: {} + t.string "text_qpc_nastaleeq" t.integer "ruku_number" t.integer "surah_ruku_number" t.integer "manzil_number" + t.string "text_qpc_nastaleeq_hafs" t.jsonb "mushaf_juzs_mapping", default: {} t.index ["chapter_id"], name: "index_verses_on_chapter_id" t.index ["hizb_number"], name: "index_verses_on_hizb_number" @@ -1176,17 +1486,59 @@ t.index ["words_count"], name: "index_verses_on_words_count" end + create_table "view", primary_key: "view_id", id: :serial, force: :cascade do |t| + end + + create_table "word", primary_key: "word_id", id: :serial, force: :cascade do |t| + t.text "ayah_key", null: false + t.integer "position", null: false + t.integer "token_id", null: false + t.string "translation" + t.string "transliteration" + t.index ["ayah_key", "position"], name: "word_ayah_key_position_key", unique: true + end + + create_table "word_corpus", primary_key: "corpus_id", id: :serial, force: :cascade do |t| + t.integer "word_id" + t.string "location" + t.string "description" + t.string "transliteration" + t.string "image_src" + t.json "segment" + t.index ["word_id"], name: "index_quran.word_corpus_on_word_id" + end + create_table "word_corpuses", id: :serial, force: :cascade do |t| t.integer "word_id" t.string "location" t.text "description" t.string "image_src" - t.jsonb "segments_data", default: {} + t.json "segments" t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false + t.jsonb "segments_data" t.index ["word_id"], name: "index_word_corpuses_on_word_id" end + create_table "word_font", primary_key: ["resource_id", "ayah_key", "position"], force: :cascade do |t| + t.integer "resource_id", null: false + t.text "ayah_key", null: false + t.integer "position", null: false + t.integer "word_id" + t.integer "page_num", null: false + t.integer "line_num", null: false + t.integer "code_dec", null: false + t.text "code_hex", null: false + t.integer "char_type_id", null: false + t.index ["ayah_key"], name: "index_quran.word_font_on_ayah_key" + end + + create_table "word_lemma", primary_key: ["word_id", "lemma_id", "position"], force: :cascade do |t| + t.integer "word_id", null: false + t.integer "lemma_id", null: false + t.integer "position", default: 1, null: false + end + create_table "word_lemmas", id: :serial, force: :cascade do |t| t.integer "word_id" t.integer "lemma_id" @@ -1196,6 +1548,12 @@ t.index ["word_id"], name: "index_word_lemmas_on_word_id" end + create_table "word_root", primary_key: ["word_id", "root_id", "position"], force: :cascade do |t| + t.integer "word_id", null: false + t.integer "root_id", null: false + t.integer "position", default: 1, null: false + end + create_table "word_roots", id: :serial, force: :cascade do |t| t.integer "word_id" t.integer "root_id" @@ -1205,6 +1563,12 @@ t.index ["word_id"], name: "index_word_roots_on_word_id" end + create_table "word_stem", primary_key: ["word_id", "stem_id", "position"], force: :cascade do |t| + t.integer "word_id", null: false + t.integer "stem_id", null: false + t.integer "position", default: 1, null: false + end + create_table "word_stems", id: :serial, force: :cascade do |t| t.integer "word_id" t.integer "stem_id" @@ -1214,6 +1578,13 @@ t.index ["word_id"], name: "index_word_stems_on_word_id" end + create_table "word_translation", primary_key: "translation_id", id: :integer, default: -> { "nextval('translation_translation_id_seq1'::regclass)" }, force: :cascade do |t| + t.integer "word_id", null: false + t.text "language_code", null: false + t.text "value", null: false + t.index ["word_id", "language_code"], name: "translation_word_id_language_code_key", unique: true + end + create_table "word_translations", force: :cascade do |t| t.integer "word_id" t.string "text" @@ -1227,14 +1598,20 @@ t.index ["word_id", "language_id"], name: "index_word_translations_on_word_id_and_language_id" end + create_table "word_transliteration", primary_key: "transliteration_id", id: :serial, force: :cascade do |t| + t.integer "word_id" + t.string "language_code" + t.string "value" + t.index ["word_id"], name: "index_quran.word_transliteration_on_word_id" + end + create_table "words", id: :serial, force: :cascade do |t| t.integer "verse_id" t.integer "chapter_id" t.integer "position" - t.string "text_madani" + t.string "text_uthmani" t.string "text_indopak" - t.string "text_simple" - t.string "text_imlaei" + t.string "text_imlaei_simple" t.string "verse_key" t.integer "page_number" t.string "class_name" @@ -1244,17 +1621,25 @@ t.string "code_hex_v3" t.integer "code_dec_v3" t.integer "char_type_id" - t.string "location" - t.string "audio_url" t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false t.string "pause_name" + t.string "audio_url" + t.text "image_blob" + t.string "image_url" t.integer "token_id" t.integer "topic_id" + t.string "location" t.string "char_type_name" + t.string "text_imlaei" + t.string "text_uthmani_simple" + t.string "text_uthmani_tajweed" + t.string "en_transliteration" t.string "code_v1" t.string "code_v2" t.integer "v2_page" + t.integer "line_v2" + t.string "text_qpc_hafs" t.string "text_indopak_nastaleeq" t.string "text_qpc_nastaleeq" t.string "text_qpc_nastaleeq_hafs" @@ -1268,6 +1653,19 @@ t.index ["verse_key"], name: "index_words_on_verse_key" end + add_foreign_key "ayah", "surah", primary_key: "surah_id", name: "ayah_surah_id_fkey" + add_foreign_key "chapter_metadata", "chapters" + add_foreign_key "chapter_metadata", "languages" + add_foreign_key "char_type", "char_type", column: "parent_id", primary_key: "char_type_id", name: "char_type_parent_id_fkey", on_update: :cascade, on_delete: :nullify + add_foreign_key "country_language_preferences", "audio_recitations", column: "default_reciter", on_delete: :cascade + add_foreign_key "country_language_preferences", "languages", column: "default_wbw_language", primary_key: "iso_code", on_delete: :cascade + add_foreign_key "country_language_preferences", "languages", column: "user_device_language", primary_key: "iso_code", on_delete: :cascade + add_foreign_key "country_language_preferences", "mushafs", column: "default_mushaf_id", on_delete: :cascade + add_foreign_key "country_language_preferences", "resource_contents", column: "default_tafsir_id", on_delete: :cascade + add_foreign_key "file", "ayah", column: "ayah_key", primary_key: "ayah_key", name: "_file_ayah_key_fkey", on_update: :cascade, on_delete: :cascade + add_foreign_key "file", "recitation", primary_key: "recitation_id", name: "_file_recitation_id_fkey", on_update: :cascade, on_delete: :cascade + add_foreign_key "image", "ayah", column: "ayah_key", primary_key: "ayah_key", name: "image_ayah_key_fkey", on_update: :cascade, on_delete: :cascade + add_foreign_key "image", "resource", primary_key: "resource_id", name: "image_resource_id_fkey", on_update: :cascade, on_delete: :cascade add_foreign_key "morphology_derived_words", "verses" add_foreign_key "morphology_derived_words", "words" add_foreign_key "morphology_word_grammar_concepts", "words" @@ -1278,8 +1676,37 @@ add_foreign_key "morphology_word_verb_forms", "words" add_foreign_key "morphology_words", "verses" add_foreign_key "morphology_words", "words" + add_foreign_key "recitation", "reciter", primary_key: "reciter_id", name: "recitation_reciter_id_fkey", on_update: :cascade, on_delete: :cascade + add_foreign_key "recitation", "style", primary_key: "style_id", name: "recitation_style_id_fkey", on_update: :cascade, on_delete: :cascade + add_foreign_key "resource", "author", primary_key: "author_id", name: "resource_author_id_fkey", on_update: :cascade, on_delete: :cascade + add_foreign_key "resource", "language", column: "language_code", primary_key: "language_code", name: "resource_language_code_fkey", on_update: :cascade, on_delete: :cascade + add_foreign_key "resource", "source", primary_key: "source_id", name: "resource_source_id_fkey", on_update: :cascade, on_delete: :cascade + add_foreign_key "resource_api_version", "resource", primary_key: "resource_id", name: "resource_api_version_resource_id_fkey", on_update: :cascade, on_delete: :cascade + add_foreign_key "tafsir", "resource", primary_key: "resource_id", name: "tafsir_resource_id_fkey", on_update: :cascade, on_delete: :cascade + add_foreign_key "tafsir_ayah", "ayah", column: "ayah_key", primary_key: "ayah_key", name: "_tafsir_ayah_ayah_key_fkey", on_update: :cascade, on_delete: :cascade + add_foreign_key "tafsir_ayah", "tafsir", primary_key: "tafsir_id", name: "_tafsir_ayah_tafsir_id_fkey", on_update: :cascade, on_delete: :cascade + add_foreign_key "text", "ayah", column: "ayah_key", primary_key: "ayah_key", name: "text_ayah_key_fkey", on_update: :cascade, on_delete: :cascade + add_foreign_key "text", "resource", primary_key: "resource_id", name: "text_resource_id_fkey", on_update: :cascade, on_delete: :cascade + add_foreign_key "translation", "ayah", column: "ayah_key", primary_key: "ayah_key", name: "_translation_ayah_key_fkey", on_update: :cascade, on_delete: :cascade + add_foreign_key "translation", "resource", primary_key: "resource_id", name: "translation_resource_id_fkey", on_update: :cascade, on_delete: :cascade + add_foreign_key "transliteration", "ayah", column: "ayah_key", primary_key: "ayah_key", name: "_transliteration_ayah_key_fkey", on_update: :cascade, on_delete: :cascade + add_foreign_key "transliteration", "resource", primary_key: "resource_id", name: "transliteration_resource_id_fkey", on_update: :cascade, on_delete: :cascade + add_foreign_key "word", "ayah", column: "ayah_key", primary_key: "ayah_key", name: "word_ayah_key_fkey", on_update: :cascade, on_delete: :cascade + add_foreign_key "word", "token", primary_key: "token_id", name: "word_token_id_fkey", on_update: :cascade, on_delete: :cascade + add_foreign_key "word_font", "ayah", column: "ayah_key", primary_key: "ayah_key", name: "word_font_ayah_key_fkey", on_update: :cascade, on_delete: :cascade + add_foreign_key "word_font", "char_type", primary_key: "char_type_id", name: "word_font_char_type_id_fkey", on_update: :cascade, on_delete: :cascade + add_foreign_key "word_font", "resource", primary_key: "resource_id", name: "word_font_resource_id_fkey", on_update: :cascade, on_delete: :cascade + add_foreign_key "word_font", "word", primary_key: "word_id", name: "word_font_word_id_fkey", on_update: :cascade, on_delete: :nullify + add_foreign_key "word_lemma", "lemma", primary_key: "lemma_id", name: "word_lemma_lemma_id_fkey", on_update: :cascade, on_delete: :cascade + add_foreign_key "word_lemma", "word", primary_key: "word_id", name: "word_lemma_word_id_fkey", on_update: :cascade, on_delete: :cascade add_foreign_key "word_lemmas", "lemmas" add_foreign_key "word_lemmas", "words" + add_foreign_key "word_root", "root", primary_key: "root_id", name: "word_root_root_id_fkey", on_update: :cascade, on_delete: :cascade + add_foreign_key "word_root", "word", primary_key: "word_id", name: "word_root_word_id_fkey", on_update: :cascade, on_delete: :cascade + add_foreign_key "word_stem", "stem", primary_key: "stem_id", name: "word_stem_stem_id_fkey", on_update: :cascade, on_delete: :cascade + add_foreign_key "word_stem", "word", primary_key: "word_id", name: "word_stem_word_id_fkey", on_update: :cascade, on_delete: :cascade add_foreign_key "word_stems", "stems" add_foreign_key "word_stems", "words" + add_foreign_key "word_translation", "language", column: "language_code", primary_key: "language_code", name: "translation_language_code_fkey", on_update: :cascade, on_delete: :cascade + add_foreign_key "word_translation", "word", primary_key: "word_id", name: "translation_word_id_fkey", on_update: :cascade, on_delete: :cascade end diff --git a/spec/requests/api/qdc/chapter_metadata_spec.rb b/spec/requests/api/qdc/chapter_metadata_spec.rb new file mode 100644 index 00000000..d0e9c22f --- /dev/null +++ b/spec/requests/api/qdc/chapter_metadata_spec.rb @@ -0,0 +1,442 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'QDC Chapter Metadata API', type: :request do + let(:english) { Language.find_by(iso_code: 'en') } + let(:arabic) { Language.find_by(iso_code: 'ar') } + + before do + ChapterMetadata.delete_all + Language.find_or_create_by!(iso_code: 'en', name: 'English') + Language.find_or_create_by!(iso_code: 'ar', name: 'Arabic') + end + + describe 'GET /api/qdc/chapters/:id/metadata' do + context 'valid chapter ID' do + context 'with suggestions and summaries' do + let!(:suggestion_en) do + ChapterMetadata.create!( + chapter_id: 1, + metadata_type: 'suggestion', + content: 'Read this chapter when seeking guidance.', + language_id: english.id, + is_active: true + ) + end + + let!(:suggestion_ar) do + ChapterMetadata.create!( + chapter_id: 1, + metadata_type: 'suggestion', + content: 'اقرأ هذه السورة عند طلب الهداية.', + language_id: arabic.id, + is_active: true + ) + end + + let!(:next_chapter_summary_en) do + ChapterMetadata.create!( + chapter_id: 2, + metadata_type: 'summary', + content: 'This chapter emphasizes faith.', + language_id: english.id, + is_active: true + ) + end + + let!(:next_chapter_summary_ar) do + ChapterMetadata.create!( + chapter_id: 2, + metadata_type: 'summary', + content: 'تركز هذه السورة على الإيمان.', + language_id: arabic.id, + is_active: true + ) + end + + it 'returns English metadata when language=en' do + get '/api/qdc/chapters/1/metadata', params: { language: 'en' } + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + + expect(json['chapter_metadata']['chapter_id']).to eq(1) + expect(json['chapter_metadata']['suggestions'].size).to eq(1) + expect(json['chapter_metadata']['suggestions'][0]['id']).to eq(suggestion_en.id) + expect(json['chapter_metadata']['suggestions'][0]['language_name']).to eq('English') + expect(json['chapter_metadata']['suggestions'][0]['text']).to eq('Read this chapter when seeking guidance.') + end + + it 'returns Arabic metadata when language=ar' do + get '/api/qdc/chapters/1/metadata', params: { language: 'ar' } + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + + expect(json['chapter_metadata']['suggestions'].size).to eq(1) + expect(json['chapter_metadata']['suggestions'][0]['id']).to eq(suggestion_ar.id) + expect(json['chapter_metadata']['suggestions'][0]['language_name']).to eq('Arabic') + expect(json['chapter_metadata']['suggestions'][0]['text']).to eq('اقرأ هذه السورة عند طلب الهداية.') + end + + it 'returns next chapter summaries in requested language' do + get '/api/qdc/chapters/1/metadata', params: { language: 'ar' } + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + + expect(json['chapter_metadata']['next_chapter']).not_to be_nil + expect(json['chapter_metadata']['next_chapter']['summaries'].size).to eq(1) + expect(json['chapter_metadata']['next_chapter']['summaries'][0]['language_name']).to eq('Arabic') + expect(json['chapter_metadata']['next_chapter']['summaries'][0]['text']).to eq('تركز هذه السورة على الإيمان.') + end + + it 'returns previous_chapter as null for chapter 1' do + get '/api/qdc/chapters/1/metadata', params: { language: 'en' } + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + + expect(json['chapter_metadata']['previous_chapter']).to be_nil + end + + it 'returns correct JSON structure with field order' do + get '/api/qdc/chapters/1/metadata', params: { language: 'en' } + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + + suggestion = json['chapter_metadata']['suggestions'][0] + expect(suggestion.keys).to eq(['id', 'language_name', 'text']) + end + end + + context 'language fallback' do + let!(:suggestion_en_only) do + ChapterMetadata.create!( + chapter_id: 50, + metadata_type: 'suggestion', + content: 'English only suggestion.', + language_id: english.id, + is_active: true + ) + end + + it 'falls back to English when requested language has no data' do + get '/api/qdc/chapters/50/metadata', params: { language: 'fr' } + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + + expect(json['chapter_metadata']['suggestions'].size).to eq(1) + expect(json['chapter_metadata']['suggestions'][0]['language_name']).to eq('English') + expect(json['chapter_metadata']['suggestions'][0]['text']).to eq('English only suggestion.') + end + + it 'returns empty arrays when no data exists in any language' do + get '/api/qdc/chapters/100/metadata', params: { language: 'ar' } + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + + expect(json['chapter_metadata']['suggestions']).to eq([]) + end + end + + context 'chapter 114 (last chapter)' do + let!(:suggestion_114) do + ChapterMetadata.create!( + chapter_id: 114, + metadata_type: 'suggestion', + content: 'Final chapter suggestion.', + language_id: english.id, + is_active: true + ) + end + + let!(:summary_113) do + ChapterMetadata.create!( + chapter_id: 113, + metadata_type: 'summary', + content: 'Previous chapter summary.', + language_id: english.id, + is_active: true + ) + end + + it 'returns next_chapter as null' do + get '/api/qdc/chapters/114/metadata', params: { language: 'en' } + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + + expect(json['chapter_metadata']['chapter_id']).to eq(114) + expect(json['chapter_metadata']['next_chapter']).to be_nil + end + + it 'returns previous chapter summaries' do + get '/api/qdc/chapters/114/metadata', params: { language: 'en' } + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + + expect(json['chapter_metadata']['previous_chapter']).not_to be_nil + expect(json['chapter_metadata']['previous_chapter']['summaries'].size).to eq(1) + expect(json['chapter_metadata']['previous_chapter']['summaries'][0]['text']).to eq('Previous chapter summary.') + end + end + + context 'inactive metadata' do + let!(:active_suggestion) do + ChapterMetadata.create!( + chapter_id: 5, + metadata_type: 'suggestion', + content: 'Active suggestion.', + language_id: english.id, + is_active: true + ) + end + + let!(:inactive_suggestion) do + ChapterMetadata.create!( + chapter_id: 5, + metadata_type: 'suggestion', + content: 'Inactive suggestion.', + language_id: english.id, + is_active: false + ) + end + + it 'returns only active metadata' do + get '/api/qdc/chapters/5/metadata', params: { language: 'en' } + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + + expect(json['chapter_metadata']['suggestions'].size).to eq(1) + expect(json['chapter_metadata']['suggestions'][0]['text']).to eq('Active suggestion.') + end + end + + context 'multiple metadata items ordering' do + let!(:older_suggestion) do + ChapterMetadata.create!( + chapter_id: 10, + metadata_type: 'suggestion', + content: 'First suggestion (older).', + language_id: english.id, + is_active: true, + created_at: 2.days.ago + ) + end + + let!(:newer_suggestion) do + ChapterMetadata.create!( + chapter_id: 10, + metadata_type: 'suggestion', + content: 'Second suggestion (newer).', + language_id: english.id, + is_active: true, + created_at: 1.day.ago + ) + end + + it 'returns metadata ordered by created_at ASC' do + get '/api/qdc/chapters/10/metadata', params: { language: 'en' } + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + + expect(json['chapter_metadata']['suggestions'].size).to eq(2) + expect(json['chapter_metadata']['suggestions'][0]['text']).to eq('First suggestion (older).') + expect(json['chapter_metadata']['suggestions'][1]['text']).to eq('Second suggestion (newer).') + end + end + + context 'locale parameter alias' do + let!(:suggestion) do + ChapterMetadata.create!( + chapter_id: 20, + metadata_type: 'suggestion', + content: 'Test suggestion.', + language_id: arabic.id, + is_active: true + ) + end + + it 'accepts locale parameter as alias for language' do + get '/api/qdc/chapters/20/metadata', params: { locale: 'ar' } + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + + expect(json['chapter_metadata']['suggestions'][0]['language_name']).to eq('Arabic') + end + end + end + + context 'invalid chapter ID' do + it 'returns 404 for chapter 0' do + get '/api/qdc/chapters/0/metadata' + + expect(response).to have_http_status(:not_found) + json = JSON.parse(response.body) + + expect(json['status']).to eq(404) + expect(json['error']).to be_a(String) + expect(json['error']).not_to be_empty + end + + it 'returns 404 for chapter 115' do + get '/api/qdc/chapters/115/metadata' + + expect(response).to have_http_status(:not_found) + json = JSON.parse(response.body) + + expect(json['status']).to eq(404) + expect(json['error']).to be_a(String) + expect(json['error']).not_to be_empty + end + + it 'returns 404 for chapter 999' do + get '/api/qdc/chapters/999/metadata' + + expect(response).to have_http_status(:not_found) + json = JSON.parse(response.body) + expect(json['status']).to eq(404) + + expect(json['error']).to be_a(String) + expect(json['error']).not_to be_empty + end + + it 'returns 404 for negative chapter ID' do + get '/api/qdc/chapters/-1/metadata' + + expect(response).to have_http_status(:not_found) + json = JSON.parse(response.body) + expect(json['status']).to eq(404) + expect(json['error']).to be_a(String) + expect(json['error']).not_to be_empty + end + end + + context 'edge cases' do + it 'defaults to English when no language parameter provided' do + ChapterMetadata.create!( + chapter_id: 30, + metadata_type: 'suggestion', + content: 'Default English suggestion.', + language_id: english.id, + is_active: true + ) + + get '/api/qdc/chapters/30/metadata' + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + + expect(json['chapter_metadata']['suggestions'][0]['language_name']).to eq('English') + end + + it 'handles invalid language code gracefully' do + ChapterMetadata.create!( + chapter_id: 40, + metadata_type: 'suggestion', + content: 'Fallback to English.', + language_id: english.id, + is_active: true + ) + + get '/api/qdc/chapters/40/metadata', params: { language: 'xyz' } + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + + expect(json['chapter_metadata']['suggestions'][0]['language_name']).to eq('English') + end + + it 'handles chapter with only summaries (no suggestions)' do + ChapterMetadata.create!( + chapter_id: 60, + metadata_type: 'summary', + content: 'Summary only.', + language_id: english.id, + is_active: true + ) + + get '/api/qdc/chapters/60/metadata', params: { language: 'en' } + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + + expect(json['chapter_metadata']['suggestions']).to eq([]) + end + + it 'returns valid JSON for chapter with no metadata at all' do + get '/api/qdc/chapters/70/metadata' + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + + expect(json['chapter_metadata']['chapter_id']).to eq(70) + expect(json['chapter_metadata']['suggestions']).to eq([]) + expect(json['chapter_metadata']['next_chapter']).not_to be_nil + expect(json['chapter_metadata']['previous_chapter']).not_to be_nil + end + end + + context 'response structure validation' do + let!(:test_metadata) do + ChapterMetadata.create!( + chapter_id: 80, + metadata_type: 'suggestion', + content: 'Test content.', + language_id: english.id, + is_active: true + ) + end + + it 'returns correct root structure' do + get '/api/qdc/chapters/80/metadata' + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + + expect(json.keys).to eq(['chapter_metadata']) + expect(json['chapter_metadata'].keys).to contain_exactly('chapter_id', 'suggestions', 'next_chapter', 'previous_chapter') + end + + it 'returns suggestions with correct structure' do + get '/api/qdc/chapters/80/metadata' + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + + suggestion = json['chapter_metadata']['suggestions'][0] + expect(suggestion.keys).to eq(['id', 'language_name', 'text']) + expect(suggestion['id']).to be_a(Integer) + expect(suggestion['language_name']).to be_a(String) + expect(suggestion['text']).to be_a(String) + end + + it 'returns next_chapter with summaries structure' do + ChapterMetadata.create!( + chapter_id: 81, + metadata_type: 'summary', + content: 'Next summary.', + language_id: english.id, + is_active: true + ) + + get '/api/qdc/chapters/80/metadata' + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + + expect(json['chapter_metadata']['next_chapter']).to have_key('summaries') + expect(json['chapter_metadata']['next_chapter']['summaries']).to be_an(Array) + end + end + end +end diff --git a/spec/requests/api/qdc/resources_country_language_preference_spec.rb b/spec/requests/api/qdc/resources_country_language_preference_spec.rb new file mode 100644 index 00000000..08336d40 --- /dev/null +++ b/spec/requests/api/qdc/resources_country_language_preference_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'QDC Resources country_language_preference', type: :request do + describe 'GET /api/qdc/resources/country_language_preference' do + let!(:test_lang) { Language.find_or_create_by!(iso_code: 'xx') { |l| l.name = 'Test Lang' } } + let!(:other_lang) { Language.find_or_create_by!(iso_code: 'zz') { |l| l.name = 'Other Lang' } } + + let!(:global_test_pref) do + CountryLanguagePreference.create!(user_device_language: 'xx', country: nil) + end + + let!(:us_test_pref) do + CountryLanguagePreference.create!(user_device_language: 'xx', country: 'US') + end + + it 'returns global preference when country is omitted' do + get '/api/qdc/resources/country_language_preference', params: { user_device_language: 'xx' } + + unless response.status == 200 + warn "DEBUG status=#{response.status} headers=#{response.headers.inspect} body=#{response.body}" + end + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json['id']).to eq(global_test_pref.id) + end + + it 'returns country-specific preference first when country provided' do + get '/api/qdc/resources/country_language_preference', params: { user_device_language: 'xx', country: 'US' } + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json['id']).to eq(us_test_pref.id) + end + + it 'falls back to global when country-specific not found' do + get '/api/qdc/resources/country_language_preference', params: { user_device_language: 'xx', country: 'CA' } + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json['id']).to eq(global_test_pref.id) + end + + it 'returns 404 when nothing matches' do + get '/api/qdc/resources/country_language_preference', params: { user_device_language: 'zz' } + + expect(response).to have_http_status(:not_found) + end + + it 'validates user_device_language presence' do + get '/api/qdc/resources/country_language_preference' + + expect(response).to have_http_status(:bad_request) + end + + it 'validates country code when provided' do + get '/api/qdc/resources/country_language_preference', params: { user_device_language: 'xx', country: 'ZZ' } + + expect(response).to have_http_status(:bad_request) + end + + it 'returns qr_default_arabic_fonts as an array of numbers' do + pref_with_fonts = CountryLanguagePreference.create!( + user_device_language: 'xx', + country: 'GB', + qr_default_arabic_fonts: '1,2,3' + ) + + get '/api/qdc/resources/country_language_preference', params: { user_device_language: 'xx', country: 'GB' } + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json['qr_default_arabic_fonts']).to eq([1, 2, 3]) + end + end +end diff --git a/spec/requests/health_spec.rb b/spec/requests/health_spec.rb new file mode 100644 index 00000000..72555203 --- /dev/null +++ b/spec/requests/health_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Health endpoint', type: :request do + describe 'GET /health' do + it 'returns simple ok with no caching headers' do + get '/health' + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json).to eq({ 'status' => 'ok' }) + expect(response.headers['Cache-Control']).to match(/no-store/) + expect(response.headers['Pragma']).to eq('no-cache') + expect(response.headers['Expires']).to eq('0') + end + end +end diff --git a/spec/requests/readiness_spec.rb b/spec/requests/readiness_spec.rb new file mode 100644 index 00000000..dd1628c3 --- /dev/null +++ b/spec/requests/readiness_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Readiness endpoint', type: :request do + describe 'GET /ready' do + it 'returns checks and ok when all up (or degraded if something down)' do + get '/ready' + expect([200, 503]).to include(response.status) + json = JSON.parse(response.body) + expect(json).to include('status', 'checks') + expect(json['checks']).to be_a(Hash) + %w[database redis elasticsearch].each do |k| + expect(json['checks']).to have_key(k) + end + expect(%w[ok degraded]).to include(json['status']) + if response.status == 200 + expect(json['status']).to eq('ok') + else + expect(json['status']).to eq('degraded') + end + expect(response.headers['Cache-Control']).to match(/no-store/) + end + end +end