- Name: - <%= expense.name %> -
- -- Amount: - <%= expense.amount %> -
- -- Pricing type: - <%= expense.pricing_type %> -
- -<%= notice %>
- -Name | -Amount | -- | |
---|---|---|---|
<%= expense.name %> | -<%= expense.amount.to_currency %> | -<%= link_to "Show", expense %> | -<%= link_to "Edit", edit_expense_path(expense) %> | -
Total | -<%= @expenses.sum(&:amount).to_currency %> | -- |
<%= notice %>
- -<%= render @expense %> - -- First name: - <%= guest.first_name %> -
- -- Last name: - <%= guest.last_name %> -
- -- Email: - <%= guest.email %> -
- -- Phone: - <%= guest.phone %> -
- -<%= notice %>
- -Row # | -Name | -Phone | -Affinity groups | -Unbreakable bonds | -- | ||
---|---|---|---|---|---|---|---|
<%= i + 1 %> | -<%= guest.full_name %> | -<%= guest.email %> | -<%= guest.phone %> | -<%= guest.affinity_groups.pluck(:name).join(", ") %> | -<%= guest.unbreakable_bonds.pluck(:name).join(", ") %> | -<%= link_to "Show", guest %> | -<%= link_to "Edit", edit_guest_path(guest) %> | -
<%= notice %>
- -<%= render @guest %> - -<%= link_to "Arrangement ##{i+1}", tables_arrangement_path(tables_arrangement) %> Discomfort: <%= tables_arrangement.discomfort %>
-Discomfort: <%= @tables_arrangement.discomfort %>
- -Welcome <%= @email %>!
+ +You can confirm your account email through the link below:
+ +<%= link_to 'Confirm my account', confirmation_url(slug: ActsAsTenant.current_tenant&.slug, confirmation_token: @token) %>
diff --git a/app/views/users/mailer/email_changed.html.erb b/app/views/users/mailer/email_changed.html.erb new file mode 100644 index 0000000..f5f2998 --- /dev/null +++ b/app/views/users/mailer/email_changed.html.erb @@ -0,0 +1,9 @@ +<%# Copyright (C) 2024-2025 LibreWeddingPlanner contributors %> + +Hello <%= @email %>!
+ +<% if @resource.try(:unconfirmed_email?) %> +We're contacting you to notify you that your email is being changed to <%= @resource.unconfirmed_email %>.
+<% else %> +We're contacting you to notify you that your email has been changed to <%= @resource.email %>.
+<% end %> diff --git a/app/views/users/mailer/password_change.html.erb b/app/views/users/mailer/password_change.html.erb new file mode 100644 index 0000000..1181727 --- /dev/null +++ b/app/views/users/mailer/password_change.html.erb @@ -0,0 +1,5 @@ +<%# Copyright (C) 2024-2025 LibreWeddingPlanner contributors %> + +Hello <%= @resource.email %>!
+ +We're contacting you to notify you that your password has been changed.
diff --git a/app/views/users/mailer/reset_password_instructions.html.erb b/app/views/users/mailer/reset_password_instructions.html.erb new file mode 100644 index 0000000..95bc588 --- /dev/null +++ b/app/views/users/mailer/reset_password_instructions.html.erb @@ -0,0 +1,10 @@ +<%# Copyright (C) 2024-2025 LibreWeddingPlanner contributors %> + +Hello <%= @resource.email %>!
+ +Someone has requested a link to change your password. You can do this through the link below.
+ +<%= link_to 'Change my password', edit_password_url(slug: ActsAsTenant.current_tenant&.slug, reset_password_token: @token) %>
+ +If you didn't request this, please ignore this email.
+Your password won't change until you access the link above and create a new one.
diff --git a/app/views/users/mailer/unlock_instructions.html.erb b/app/views/users/mailer/unlock_instructions.html.erb new file mode 100644 index 0000000..3efb341 --- /dev/null +++ b/app/views/users/mailer/unlock_instructions.html.erb @@ -0,0 +1,9 @@ +<%# Copyright (C) 2024-2025 LibreWeddingPlanner contributors %> + +Hello <%= @resource.email %>!
+ +Your account has been locked due to an excessive number of unsuccessful sign in attempts.
+ +Click the link below to unlock your account:
+ +<%= link_to 'Unlock my account', unlock_url(slug: ActsAsTenant.current_tenant&.slug, unlock_token: @token) %>
diff --git a/bin/jobs b/bin/jobs new file mode 100755 index 0000000..dcf59f3 --- /dev/null +++ b/bin/jobs @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby + +require_relative "../config/environment" +require "solid_queue/cli" + +SolidQueue::Cli.start(ARGV) diff --git a/config.ru b/config.ru index 4a3c09a..6dc8321 100644 --- a/config.ru +++ b/config.ru @@ -1,6 +1,8 @@ +# frozen_string_literal: true + # This file is used by Rack-based servers to start the application. -require_relative "config/environment" +require_relative 'config/environment' run Rails.application Rails.application.load_server diff --git a/config/application.rb b/config/application.rb index e21736e..1795a6f 100644 --- a/config/application.rb +++ b/config/application.rb @@ -1,3 +1,5 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + require_relative 'boot' require 'rails' @@ -28,6 +30,9 @@ module WeddingPlanner # Common ones are `templates`, `generators`, or `middleware`, for example. config.autoload_lib(ignore: %w[assets tasks]) + # Use a real queuing backend for Active Job (and separate queues per environment). + config.active_job.queue_adapter = :solid_queue + # Configuration for the application, engines, and railties goes here. # # These settings can be overridden in specific environments using the files diff --git a/config/boot.rb b/config/boot.rb index 988a5dd..4bebfef 100644 --- a/config/boot.rb +++ b/config/boot.rb @@ -1,3 +1,5 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) require "bundler/setup" # Set up gems listed in the Gemfile. diff --git a/config/database.yml b/config/database.yml index 3d30d61..611e16b 100644 --- a/config/database.yml +++ b/config/database.yml @@ -83,6 +83,7 @@ test: # production: <<: *default + host: db database: wedding_planner_production username: wedding_planner password: <%= ENV["WEDDING_PLANNER_DATABASE_PASSWORD"] %> diff --git a/config/environment.rb b/config/environment.rb index cac5315..49c0fb1 100644 --- a/config/environment.rb +++ b/config/environment.rb @@ -1,3 +1,5 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + # Load the Rails application. require_relative "application" diff --git a/config/environments/development.rb b/config/environments/development.rb index 794f390..232e88e 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -1,3 +1,5 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + require "active_support/core_ext/integer/time" Rails.application.configure do @@ -38,8 +40,10 @@ Rails.application.configure do # Don't care if the mailer can't send. config.action_mailer.raise_delivery_errors = false - config.action_mailer.perform_caching = false + config.action_mailer.default_url_options = { host: 'libre-wedding-planner.app.localhost/api' } + config.action_mailer.delivery_method = :letter_opener_web + config.action_mailer.perform_deliveries = true # Print deprecation notices to the Rails logger. config.active_support.deprecation = :log @@ -73,4 +77,6 @@ Rails.application.configure do # Raise error when a before_action's only/except options reference missing actions config.action_controller.raise_on_missing_callback_actions = true + + config.hosts << "libre-wedding-planner.app.localhost" end diff --git a/config/environments/production.rb b/config/environments/production.rb index c39bd29..4db7dca 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -1,3 +1,5 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + require "active_support/core_ext/integer/time" Rails.application.configure do @@ -67,8 +69,6 @@ Rails.application.configure do # Use a different cache store in production. # config.cache_store = :mem_cache_store - # Use a real queuing backend for Active Job (and separate queues per environment). - # config.active_job.queue_adapter = :resque # config.active_job.queue_name_prefix = "wedding_planner_production" config.action_mailer.perform_caching = false @@ -92,6 +92,9 @@ Rails.application.configure do # "example.com", # Allow requests from example.com # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` # ] + + config.hosts << "app.libreweddingplanner.org" + # Skip DNS rebinding protection for the default health check endpoint. - # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } + config.host_authorization = { exclude: ->(request) { request.path == "/up" } } end diff --git a/config/environments/test.rb b/config/environments/test.rb index 3ada93b..0982e19 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -1,3 +1,5 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + require "active_support/core_ext/integer/time" # The test environment is used exclusively to run your application's diff --git a/config/importmap.rb b/config/importmap.rb index 909dfc5..7ec75cd 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -1,3 +1,5 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + # Pin npm packages by running ./bin/importmap pin "application" diff --git a/config/initializers/acts_as_tenant.rb b/config/initializers/acts_as_tenant.rb new file mode 100644 index 0000000..fd8890d --- /dev/null +++ b/config/initializers/acts_as_tenant.rb @@ -0,0 +1,5 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +ActsAsTenant.configure do |config| + config.require_tenant = !Rails.env.test? +end \ No newline at end of file diff --git a/config/initializers/affinity_groups.rb b/config/initializers/affinity_groups.rb deleted file mode 100644 index f52c1d4..0000000 --- a/config/initializers/affinity_groups.rb +++ /dev/null @@ -1,39 +0,0 @@ -require_relative '../../app/services/affinity_groups_hierarchy' - -hierarchy = AffinityGroupsHierarchy.instance - -hierarchy << 'guests_a' -hierarchy << 'guests_b' -hierarchy << 'common_guests' - -hierarchy.register_child('guests_a', 'family_a') -hierarchy.register_child('family_a', 'close_family_a') -hierarchy.register_child('family_a', 'cousins_a') -hierarchy.register_child('family_a', 'relatives_a') - -hierarchy.register_child('guests_a', 'work_a') -hierarchy.register_child('work_a', 'besties_work_a') - -hierarchy.register_child('guests_a', 'friends_a') -hierarchy.register_child('friends_a', 'college_friends_a') -hierarchy.register_child('friends_a', 'high_school_friends_a') -hierarchy.register_child('friends_a', 'childhood_friends_a') - -hierarchy.register_child('guests_a', 'sports_a') -hierarchy.register_child('sports_a', 'basket_team_a') -hierarchy.register_child('sports_a', 'football_team_a') - -hierarchy.register_child('guests_b', 'family_b') -hierarchy.register_child('family_b', 'close_family_b') -hierarchy.register_child('family_b', 'cousins_b') -hierarchy.register_child('family_b', 'relatives_b') - -hierarchy.register_child('guests_b', 'work_b') -hierarchy.register_child('work_b', 'besties_work_b') - -hierarchy.register_child('guests_b', 'friends_b') -hierarchy.register_child('friends_b', 'college_friends_b') -hierarchy.register_child('friends_b', 'high_school_friends_b') -hierarchy.register_child('friends_b', 'childhood_friends_b') - -hierarchy.register_child('common_guests', 'dance_club') \ No newline at end of file diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb index 2eeef96..4772d1a 100644 --- a/config/initializers/assets.rb +++ b/config/initializers/assets.rb @@ -1,3 +1,5 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + # Be sure to restart your server when you modify this file. # Version of your assets, change this if you want to expire all your assets. diff --git a/config/initializers/colors.rb b/config/initializers/colors.rb new file mode 100644 index 0000000..ea065fa --- /dev/null +++ b/config/initializers/colors.rb @@ -0,0 +1,8 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +Chroma.define_palette :decreasing_saturation do + spin(20).desaturate(40) + spin(-20).desaturate(40) + spin(40).desaturate(40) + spin(-40).desaturate(40) +end diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index b3076b3..8798db8 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -1,3 +1,5 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + # Be sure to restart your server when you modify this file. # Define an application-wide content security policy. diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb index a908920..ce186ed 100644 --- a/config/initializers/cors.rb +++ b/config/initializers/cors.rb @@ -1,3 +1,5 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + # config/initializers/cors.rb Rails.application.config.middleware.insert_before 0, Rack::Cors do diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb new file mode 100644 index 0000000..3977aa9 --- /dev/null +++ b/config/initializers/devise.rb @@ -0,0 +1,315 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +# Assuming you have not yet modified this file, each configuration option below +# is set to its default value. Note that some are commented out while others +# are not: uncommented lines are intended to protect your configuration from +# breaking changes in upgrades (i.e., in the event that future versions of +# Devise change the default values for those options). +# +# Use this hook to configure devise mailer, warden hooks and so forth. +# Many of these configuration options can be set straight in your model. +Devise.setup do |config| + # The secret key used by Devise. Devise uses this key to generate + # random tokens. Changing this key will render invalid all existing + # confirmation, reset password and unlock tokens in the database. + # Devise will use the `secret_key_base` as its `secret_key` + # by default. You can change it below and use your own secret key. + # config.secret_key = '11353ae8c2bf66dd638d9edff9ec82856aecf74bba6c598273559a8750c902d3439da1b301e40c47578577a971f1058dbf37211c107fba5107c29baa654e9888' + + # ==> Controller configuration + # Configure the parent class to the devise controllers. + # config.parent_controller = 'DeviseController' + + # ==> Mailer Configuration + # Configure the e-mail address which will be shown in Devise::Mailer, + # note that it will be overwritten if you use your own mailer class + # with default "from" parameter. + config.mailer_sender = 'noreply@libreweddingplanner.org' + + # Configure the class responsible to send e-mails. + # config.mailer = 'Devise::Mailer' + + # Configure the parent class responsible to send e-mails. + # config.parent_mailer = 'ActionMailer::Base' + + # ==> ORM configuration + # Load and configure the ORM. Supports :active_record (default) and + # :mongoid (bson_ext recommended) by default. Other ORMs may be + # available as additional gems. + require 'devise/orm/active_record' + + # ==> Configuration for any authentication mechanism + # Configure which keys are used when authenticating a user. The default is + # just :email. You can configure it to use [:username, :subdomain], so for + # authenticating a user, both parameters are required. Remember that those + # parameters are used only when authenticating and not when retrieving from + # session. If you need permissions, you should implement that in a before filter. + # You can also supply a hash where the value is a boolean determining whether + # or not authentication should be aborted when the value is not present. + # config.authentication_keys = [:email] + + # Configure parameters from the request object used for authentication. Each entry + # given should be a request method and it will automatically be passed to the + # find_for_authentication method and considered in your model lookup. For instance, + # if you set :request_keys to [:subdomain], :subdomain will be used on authentication. + # The same considerations mentioned for authentication_keys also apply to request_keys. + # config.request_keys = [] + + # Configure which authentication keys should be case-insensitive. + # These keys will be downcased upon creating or modifying a user and when used + # to authenticate or find a user. Default is :email. + config.case_insensitive_keys = [:email] + + # Configure which authentication keys should have whitespace stripped. + # These keys will have whitespace before and after removed upon creating or + # modifying a user and when used to authenticate or find a user. Default is :email. + config.strip_whitespace_keys = [:email] + + # Tell if authentication through request.params is enabled. True by default. + # It can be set to an array that will enable params authentication only for the + # given strategies, for example, `config.params_authenticatable = [:database]` will + # enable it only for database (email + password) authentication. + # config.params_authenticatable = true + + # Tell if authentication through HTTP Auth is enabled. False by default. + # It can be set to an array that will enable http authentication only for the + # given strategies, for example, `config.http_authenticatable = [:database]` will + # enable it only for database authentication. + # For API-only applications to support authentication "out-of-the-box", you will likely want to + # enable this with :database unless you are using a custom strategy. + # The supported strategies are: + # :database = Support basic authentication with authentication key + password + # config.http_authenticatable = false + + # If 401 status code should be returned for AJAX requests. True by default. + # config.http_authenticatable_on_xhr = true + + # The realm used in Http Basic Authentication. 'Application' by default. + # config.http_authentication_realm = 'Application' + + # It will change confirmation, password recovery and other workflows + # to behave the same regardless if the e-mail provided was right or wrong. + # Does not affect registerable. + config.paranoid = true + + # By default Devise will store the user in session. You can skip storage for + # particular strategies by setting this option. + # Notice that if you are skipping storage for all authentication paths, you + # may want to disable generating routes to Devise's sessions controller by + # passing skip: :sessions to `devise_for` in your config/routes.rb + config.skip_session_storage = [:http_auth] + + # By default, Devise cleans up the CSRF token on authentication to + # avoid CSRF token fixation attacks. This means that, when using AJAX + # requests for sign in and sign up, you need to get a new CSRF token + # from the server. You can disable this option at your own risk. + # config.clean_up_csrf_token_on_authentication = true + + # When false, Devise will not attempt to reload routes on eager load. + # This can reduce the time taken to boot the app but if your application + # requires the Devise mappings to be loaded during boot time the application + # won't boot properly. + # config.reload_routes = true + + # ==> Configuration for :database_authenticatable + # For bcrypt, this is the cost for hashing the password and defaults to 12. If + # using other algorithms, it sets how many times you want the password to be hashed. + # The number of stretches used for generating the hashed password are stored + # with the hashed password. This allows you to change the stretches without + # invalidating existing passwords. + # + # Limiting the stretches to just one in testing will increase the performance of + # your test suite dramatically. However, it is STRONGLY RECOMMENDED to not use + # a value less than 10 in other environments. Note that, for bcrypt (the default + # algorithm), the cost increases exponentially with the number of stretches (e.g. + # a value of 20 is already extremely slow: approx. 60 seconds for 1 calculation). + config.stretches = Rails.env.test? ? 1 : 12 + + # Set up a pepper to generate the hashed password. + # config.pepper = '6f86425fd587f80f4a338a785a6abbbccf8de7322f70fcccf356118d982942c9421819445f9d236a296fa3c431ef5e509be20e6db03f90ec2b42aa78f3a7e526' + + # Send a notification to the original email when the user's email is changed. + config.send_email_changed_notification = false + + # Send a notification email when the user's password is changed. + config.send_password_change_notification = false + + # ==> Configuration for :confirmable + # A period that the user is allowed to access the website even without + # confirming their account. For instance, if set to 2.days, the user will be + # able to access the website for two days without confirming their account, + # access will be blocked just in the third day. + # You can also set it to nil, which will allow the user to access the website + # without confirming their account. + # Default is 0.days, meaning the user cannot access the website without + # confirming their account. + # config.allow_unconfirmed_access_for = 2.days + + # A period that the user is allowed to confirm their account before their + # token becomes invalid. For example, if set to 3.days, the user can confirm + # their account within 3 days after the mail was sent, but on the fourth day + # their account can't be confirmed with the token any more. + # Default is nil, meaning there is no restriction on how long a user can take + # before confirming their account. + config.confirm_within = 3.days + + # If true, requires any email changes to be confirmed (exactly the same way as + # initial account confirmation) to be applied. Requires additional unconfirmed_email + # db field (see migrations). Until confirmed, new email is stored in + # unconfirmed_email column, and copied to email column on successful confirmation. + config.reconfirmable = true + + # Defines which key will be used when confirming an account + # config.confirmation_keys = [:email] + + # ==> Configuration for :rememberable + # The time the user will be remembered without asking for credentials again. + # config.remember_for = 2.weeks + + # Invalidates all the remember me tokens when the user signs out. + config.expire_all_remember_me_on_sign_out = true + + # If true, extends the user's remember period when remembered via cookie. + # config.extend_remember_period = false + + # Options to be passed to the created cookie. For instance, you can set + # secure: true in order to force SSL only cookies. + # config.rememberable_options = {} + + # ==> Configuration for :validatable + # Range for password length. + config.password_length = 15..128 + + # Email regex used to validate email formats. It simply asserts that + # one (and only one) @ exists in the given string. This is mainly + # to give user feedback and not to assert the e-mail validity. + config.email_regexp = /\A[^@\s]+@[^@\s]+\z/ + + # ==> Configuration for :timeoutable + # The time you want to timeout the user session without activity. After this + # time the user will be asked for credentials again. Default is 30 minutes. + # config.timeout_in = 30.minutes + + # ==> Configuration for :lockable + # Defines which strategy will be used to lock an account. + # :failed_attempts = Locks an account after a number of failed attempts to sign in. + # :none = No lock strategy. You should handle locking by yourself. + config.lock_strategy = :failed_attempts + + # Defines which key will be used when locking and unlocking an account + # config.unlock_keys = [:email] + + # Defines which strategy will be used to unlock an account. + # :email = Sends an unlock link to the user email + # :time = Re-enables login after a certain amount of time (see :unlock_in below) + # :both = Enables both strategies + # :none = No unlock strategy. You should handle unlocking by yourself. + config.unlock_strategy = :both + + # Number of authentication tries before locking an account if lock_strategy + # is failed attempts. + config.maximum_attempts = 10 + + # Time interval to unlock the account if :time is enabled as unlock_strategy. + config.unlock_in = 1.hour + + # Warn on the last attempt before the account is locked. + # config.last_attempt_warning = true + + # ==> Configuration for :recoverable + # + # Defines which key will be used when recovering the password for an account + # config.reset_password_keys = [:email] + + # Time interval you can reset your password with a reset password key. + # Don't put a too small interval or your users won't have the time to + # change their passwords. + config.reset_password_within = 6.hours + + # When set to false, does not sign a user in automatically after their password is + # reset. Defaults to true, so a user is signed in automatically after a reset. + config.sign_in_after_reset_password = true + + # ==> Configuration for :encryptable + # Allow you to use another hashing or encryption algorithm besides bcrypt (default). + # You can use :sha1, :sha512 or algorithms from others authentication tools as + # :clearance_sha1, :authlogic_sha512 (then you should set stretches above to 20 + # for default behavior) and :restful_authentication_sha1 (then you should set + # stretches to 10, and copy REST_AUTH_SITE_KEY to pepper). + # + # Require the `devise-encryptable` gem when using anything other than bcrypt + # config.encryptor = :sha512 + + # ==> Scopes configuration + # Turn scoped views on. Before rendering "sessions/new", it will first check for + # "users/sessions/new". It's turned off by default because it's slower if you + # are using only default views. + config.scoped_views = true + + # Configure the default scope given to Warden. By default it's the first + # devise role declared in your routes (usually :user). + # config.default_scope = :user + + # Set this configuration to false if you want /users/sign_out to sign out + # only the current scope. By default, Devise signs out all scopes. + # config.sign_out_all_scopes = true + + # ==> Navigation configuration + # Lists the formats that should be treated as navigational. Formats like + # :html should redirect to the sign in page when the user does not have + # access, but formats like :xml or :json, should return 401. + # + # If you have any extra navigational formats, like :iphone or :mobile, you + # should add them to the navigational formats lists. + # + # The "*/*" below is required to match Internet Explorer requests. + # config.navigational_formats = ['*/*', :html, :turbo_stream] + + # The default HTTP method used to sign out a resource. Default is :delete. + config.sign_out_via = :delete + + # ==> OmniAuth + # Add a new OmniAuth provider. Check the wiki for more information on setting + # up on your models and hooks. + # config.omniauth :github, 'APP_ID', 'APP_SECRET', scope: 'user,public_repo' + + # ==> Warden configuration + # If you want to use other strategies, that are not supported by Devise, or + # change the failure app, you can configure them inside the config.warden block. + # + # config.warden do |manager| + # manager.intercept_401 = false + # manager.default_strategies(scope: :user).unshift :some_external_strategy + # end + + # ==> Mountable engine configurations + # When using Devise inside an engine, let's call it `MyEngine`, and this engine + # is mountable, there are some extra configurations to be taken into account. + # The following options are available, assuming the engine is mounted as: + # + # mount MyEngine, at: '/my_engine' + # + # The router that invoked `devise_for`, in the example above, would be: + # config.router_name = :my_engine + # + # When using OmniAuth, Devise cannot automatically set OmniAuth path, + # so you need to do it manually. For the users scope, it would be: + # config.omniauth_path_prefix = '/my_engine/users/auth' + + # ==> Hotwire/Turbo configuration + # When using Devise with Hotwire/Turbo, the http status for error responses + # and some redirects must match the following. The default in Devise for existing + # apps is `200 OK` and `302 Found` respectively, but new apps are generated with + # these new defaults that match Hotwire/Turbo behavior. + # Note: These might become the new default in future versions of Devise. + config.responder.error_status = :unprocessable_entity + config.responder.redirect_status = :see_other + + # ==> Configuration for :registerable + + # When set to false, does not sign a user in automatically after their password is + # changed. Defaults to true, so a user is signed in automatically after changing a password. + # config.sign_in_after_change_password = true +end diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb index c2d89e2..1be5be3 100644 --- a/config/initializers/filter_parameter_logging.rb +++ b/config/initializers/filter_parameter_logging.rb @@ -1,3 +1,5 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + # Be sure to restart your server when you modify this file. # Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file. diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index 157a851..2a6b3c4 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -1,3 +1,5 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + # Be sure to restart your server when you modify this file. # Add new inflection rules using the following format. Inflections diff --git a/config/initializers/permissions_policy.rb b/config/initializers/permissions_policy.rb index 7db3b95..f281e9b 100644 --- a/config/initializers/permissions_policy.rb +++ b/config/initializers/permissions_policy.rb @@ -1,3 +1,5 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + # Be sure to restart your server when you modify this file. # Define an application-wide HTTP permissions policy. For further diff --git a/config/initializers/rswag_api.rb b/config/initializers/rswag_api.rb new file mode 100644 index 0000000..42deb17 --- /dev/null +++ b/config/initializers/rswag_api.rb @@ -0,0 +1,16 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +Rswag::Api.configure do |c| + + # Specify a root folder where Swagger JSON files are located + # This is used by the Swagger middleware to serve requests for API descriptions + # NOTE: If you're using rswag-specs to generate Swagger, you'll need to ensure + # that it's configured to generate files in the same folder + c.openapi_root = Rails.root.to_s + '/swagger' + + # Inject a lambda function to alter the returned Swagger prior to serialization + # The function will have access to the rack env for the current request + # For example, you could leverage this to dynamically assign the "host" property + # + #c.swagger_filter = lambda { |swagger, env| swagger['host'] = env['HTTP_HOST'] } +end diff --git a/config/initializers/rswag_ui.rb b/config/initializers/rswag_ui.rb new file mode 100644 index 0000000..a50e133 --- /dev/null +++ b/config/initializers/rswag_ui.rb @@ -0,0 +1,18 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +Rswag::Ui.configure do |c| + + # List the Swagger endpoints that you want to be documented through the + # swagger-ui. The first parameter is the path (absolute or relative to the UI + # host) to the corresponding endpoint and the second is a title that will be + # displayed in the document selector. + # NOTE: If you're using rspec-api to expose Swagger files + # (under openapi_root) as JSON or YAML endpoints, then the list below should + # correspond to the relative paths for those endpoints. + + c.swagger_endpoint '/api/api-docs/v1/swagger.yaml', 'API V1 Docs' + + # Add Basic Auth in case your API is private + # c.basic_auth_enabled = true + # c.basic_auth_credentials 'username', 'password' +end diff --git a/config/initializers/ruby_extensions.rb b/config/initializers/ruby_extensions.rb index f25093d..47091de 100644 --- a/config/initializers/ruby_extensions.rb +++ b/config/initializers/ruby_extensions.rb @@ -1,10 +1,12 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + class Numeric def to_currency Money.from_amount(self, "EUR").format end end -class Array +class Set def to_table Tables::Table.new(self) end diff --git a/config/locales/devise.en.yml b/config/locales/devise.en.yml new file mode 100644 index 0000000..260e1c4 --- /dev/null +++ b/config/locales/devise.en.yml @@ -0,0 +1,65 @@ +# Additional translations at https://github.com/heartcombo/devise/wiki/I18n + +en: + devise: + confirmations: + confirmed: "Your email address has been successfully confirmed." + send_instructions: "You will receive an email with instructions for how to confirm your email address in a few minutes." + send_paranoid_instructions: "If your email address exists in our database, you will receive an email with instructions for how to confirm your email address in a few minutes." + failure: + already_authenticated: "You are already signed in." + inactive: "Your account is not activated yet." + invalid: "Invalid %{authentication_keys} or password." + locked: "Your account is locked." + last_attempt: "You have one more attempt before your account is locked." + not_found_in_database: "Invalid %{authentication_keys} or password." + timeout: "Your session expired. Please sign in again to continue." + unauthenticated: "You need to sign in or sign up before continuing." + unconfirmed: "You have to confirm your email address before continuing." + mailer: + confirmation_instructions: + subject: "Confirmation instructions" + reset_password_instructions: + subject: "Reset password instructions" + unlock_instructions: + subject: "Unlock instructions" + email_changed: + subject: "Email Changed" + password_change: + subject: "Password Changed" + omniauth_callbacks: + failure: "Could not authenticate you from %{kind} because \"%{reason}\"." + success: "Successfully authenticated from %{kind} account." + passwords: + no_token: "You can't access this page without coming from a password reset email. If you do come from a password reset email, please make sure you used the full URL provided." + send_instructions: "You will receive an email with instructions on how to reset your password in a few minutes." + send_paranoid_instructions: "If your email address exists in our database, you will receive a password recovery link at your email address in a few minutes." + updated: "Your password has been changed successfully. You are now signed in." + updated_not_active: "Your password has been changed successfully." + registrations: + destroyed: "Bye! Your account has been successfully cancelled. We hope to see you again soon." + signed_up: "Welcome! You have signed up successfully." + signed_up_but_inactive: "You have signed up successfully. However, we could not sign you in because your account is not yet activated." + signed_up_but_locked: "You have signed up successfully. However, we could not sign you in because your account is locked." + signed_up_but_unconfirmed: "A message with a confirmation link has been sent to your email address. Please follow the link to activate your account." + update_needs_confirmation: "You updated your account successfully, but we need to verify your new email address. Please check your email and follow the confirmation link to confirm your new email address." + updated: "Your account has been updated successfully." + updated_but_not_signed_in: "Your account has been updated successfully, but since your password was changed, you need to sign in again." + sessions: + signed_in: "Signed in successfully." + signed_out: "Signed out successfully." + already_signed_out: "Signed out successfully." + unlocks: + send_instructions: "You will receive an email with instructions for how to unlock your account in a few minutes." + send_paranoid_instructions: "If your account exists, you will receive an email with instructions for how to unlock it in a few minutes." + unlocked: "Your account has been unlocked successfully. Please sign in to continue." + errors: + messages: + already_confirmed: "was already confirmed, please try signing in" + confirmation_period_expired: "needs to be confirmed within %{period}, please request a new one" + expired: "has expired, please request a new one" + not_found: "not found" + not_locked: "was not locked" + not_saved: + one: "1 error prohibited this %{resource} from being saved:" + other: "%{count} errors prohibited this %{resource} from being saved:" diff --git a/config/puma.rb b/config/puma.rb index afa809b..128df0b 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -1,3 +1,5 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + # This configuration file will be evaluated by Puma. The top-level methods that # are invoked here are part of Puma's configuration DSL. For more information # about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html. diff --git a/config/queue.yml b/config/queue.yml new file mode 100644 index 0000000..9eace59 --- /dev/null +++ b/config/queue.yml @@ -0,0 +1,18 @@ +default: &default + dispatchers: + - polling_interval: 1 + batch_size: 500 + workers: + - queues: "*" + threads: 3 + processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %> + polling_interval: 0.1 + +development: + <<: *default + +test: + <<: *default + +production: + <<: *default diff --git a/config/recurring.yml b/config/recurring.yml new file mode 100644 index 0000000..d045b19 --- /dev/null +++ b/config/recurring.yml @@ -0,0 +1,10 @@ +# production: +# periodic_cleanup: +# class: CleanSoftDeletedRecordsJob +# queue: background +# args: [ 1000, { batch_size: 500 } ] +# schedule: every hour +# periodic_command: +# command: "SoftDeletedRecord.due.delete_all" +# priority: 2 +# schedule: at 5am every day diff --git a/config/routes.rb b/config/routes.rb index 6e4d17b..39d0460 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,10 +1,45 @@ -Rails.application.routes.draw do - resources :groups, only: :index - resources :guests do - post :import, on: :collection - end - resources :expenses - resources :tables_arrangements, only: [:index, :show] +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors +Rails.application.routes.draw do + mount LetterOpenerWeb::Engine, at: "/letter_opener" if Rails.env.development? + get 'token' => 'tokens#show', as: :token get 'up' => 'rails/health#show', as: :rails_health_check + + resources :captcha, only: :create do + get 'v2/media', to: 'captcha#media', on: :collection, as: :media + end + + mount Rswag::Ui::Engine => '/api-docs' + mount Rswag::Api::Engine => '/api-docs' + + scope ":slug", constraints: { slug: Wedding::SLUG_REGEX } do + devise_for :users, skip: [:registration, :session, :confirmation] + devise_scope :user do + post 'users', to: 'users/registrations#create' + + post '/users/sign_in', to: 'users/sessions#create' + delete '/users/sign_out', to: 'users/sessions#destroy' + + get '/users/confirmation', to: 'users/confirmations#show', as: :confirmation + end + + resources :groups, only: %i[index create update destroy] do + post 'affinities/reset', to: 'affinities#reset', on: :collection + resources :affinities, only: %i[index] do + put :bulk_update, on: :collection + get :default, on: :collection + end + end + + resources :guests, only: %i[index create update destroy] do + post :bulk_update, on: :collection + end + resources :expenses, only: %i[index create update destroy] do + get :summary, on: :collection + end + resources :tables_arrangements, only: %i[index show create] + resources :summary, only: :index + + root to: redirect("/%{slug}") + end end diff --git a/db/migrate/20240711175425_create_expenses.rb b/db/migrate/20240711175425_create_expenses.rb index 63d0f2b..52fa269 100644 --- a/db/migrate/20240711175425_create_expenses.rb +++ b/db/migrate/20240711175425_create_expenses.rb @@ -1,3 +1,5 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + class CreateExpenses < ActiveRecord::Migration[7.1] def change create_enum :pricing_types, ["fixed", "per_person"] diff --git a/db/migrate/20240711180753_create_guests.rb b/db/migrate/20240711180753_create_guests.rb index 1280006..d2f876a 100644 --- a/db/migrate/20240711180753_create_guests.rb +++ b/db/migrate/20240711180753_create_guests.rb @@ -1,3 +1,5 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + class CreateGuests < ActiveRecord::Migration[7.1] def change create_table :guests, id: :uuid do |t| diff --git a/db/migrate/20240711181626_acts_as_taggable_on_migration.acts_as_taggable_on_engine.rb b/db/migrate/20240711181626_acts_as_taggable_on_migration.acts_as_taggable_on_engine.rb index 8b97ba6..f1709ca 100644 --- a/db/migrate/20240711181626_acts_as_taggable_on_migration.acts_as_taggable_on_engine.rb +++ b/db/migrate/20240711181626_acts_as_taggable_on_migration.acts_as_taggable_on_engine.rb @@ -1,3 +1,5 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + # frozen_string_literal: true # This migration comes from acts_as_taggable_on_engine (originally 1) diff --git a/db/migrate/20240711181627_add_missing_unique_indices.acts_as_taggable_on_engine.rb b/db/migrate/20240711181627_add_missing_unique_indices.acts_as_taggable_on_engine.rb index ebd46fd..a1f7d1c 100644 --- a/db/migrate/20240711181627_add_missing_unique_indices.acts_as_taggable_on_engine.rb +++ b/db/migrate/20240711181627_add_missing_unique_indices.acts_as_taggable_on_engine.rb @@ -1,3 +1,5 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + # frozen_string_literal: true # This migration comes from acts_as_taggable_on_engine (originally 2) diff --git a/db/migrate/20240711181628_add_taggings_counter_cache_to_tags.acts_as_taggable_on_engine.rb b/db/migrate/20240711181628_add_taggings_counter_cache_to_tags.acts_as_taggable_on_engine.rb index d17afe8..078b0c0 100644 --- a/db/migrate/20240711181628_add_taggings_counter_cache_to_tags.acts_as_taggable_on_engine.rb +++ b/db/migrate/20240711181628_add_taggings_counter_cache_to_tags.acts_as_taggable_on_engine.rb @@ -1,3 +1,5 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + # frozen_string_literal: true # This migration comes from acts_as_taggable_on_engine (originally 3) diff --git a/db/migrate/20240711181629_add_missing_taggable_index.acts_as_taggable_on_engine.rb b/db/migrate/20240711181629_add_missing_taggable_index.acts_as_taggable_on_engine.rb index 52f696b..126aa38 100644 --- a/db/migrate/20240711181629_add_missing_taggable_index.acts_as_taggable_on_engine.rb +++ b/db/migrate/20240711181629_add_missing_taggable_index.acts_as_taggable_on_engine.rb @@ -1,3 +1,5 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + # frozen_string_literal: true # This migration comes from acts_as_taggable_on_engine (originally 4) diff --git a/db/migrate/20240711181630_change_collation_for_tag_names.acts_as_taggable_on_engine.rb b/db/migrate/20240711181630_change_collation_for_tag_names.acts_as_taggable_on_engine.rb index 47fd928..fec8036 100644 --- a/db/migrate/20240711181630_change_collation_for_tag_names.acts_as_taggable_on_engine.rb +++ b/db/migrate/20240711181630_change_collation_for_tag_names.acts_as_taggable_on_engine.rb @@ -1,3 +1,5 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + # frozen_string_literal: true # This migration comes from acts_as_taggable_on_engine (originally 5) diff --git a/db/migrate/20240711181631_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb b/db/migrate/20240711181631_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb index f5aaaf9..5f98782 100644 --- a/db/migrate/20240711181631_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb +++ b/db/migrate/20240711181631_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb @@ -1,3 +1,5 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + # frozen_string_literal: true # This migration comes from acts_as_taggable_on_engine (originally 6) diff --git a/db/migrate/20240711181632_add_tenant_to_taggings.acts_as_taggable_on_engine.rb b/db/migrate/20240711181632_add_tenant_to_taggings.acts_as_taggable_on_engine.rb index b62b660..16fc678 100644 --- a/db/migrate/20240711181632_add_tenant_to_taggings.acts_as_taggable_on_engine.rb +++ b/db/migrate/20240711181632_add_tenant_to_taggings.acts_as_taggable_on_engine.rb @@ -1,3 +1,5 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + # frozen_string_literal: true # This migration comes from acts_as_taggable_on_engine (originally 7) diff --git a/db/migrate/20240724181756_create_tables_arrangements.rb b/db/migrate/20240724181756_create_tables_arrangements.rb index c05f6dc..81defbc 100644 --- a/db/migrate/20240724181756_create_tables_arrangements.rb +++ b/db/migrate/20240724181756_create_tables_arrangements.rb @@ -1,3 +1,5 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + class CreateTablesArrangements < ActiveRecord::Migration[7.1] def change create_table :tables_arrangements, id: :uuid do |t| diff --git a/db/migrate/20240724181853_create_seats.rb b/db/migrate/20240724181853_create_seats.rb index 74f5b7b..a4fa0ae 100644 --- a/db/migrate/20240724181853_create_seats.rb +++ b/db/migrate/20240724181853_create_seats.rb @@ -1,3 +1,5 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + class CreateSeats < ActiveRecord::Migration[7.1] def change create_table :seats, id: :uuid do |t| diff --git a/db/migrate/20240811142121_create_groups.rb b/db/migrate/20240811142121_create_groups.rb index 0246978..cad993a 100644 --- a/db/migrate/20240811142121_create_groups.rb +++ b/db/migrate/20240811142121_create_groups.rb @@ -1,3 +1,5 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + class CreateGroups < ActiveRecord::Migration[7.1] def change create_table :groups, id: :uuid do |t| diff --git a/db/migrate/20240811143801_add_parent_to_group.rb b/db/migrate/20240811143801_add_parent_to_group.rb index 575af38..868ea79 100644 --- a/db/migrate/20240811143801_add_parent_to_group.rb +++ b/db/migrate/20240811143801_add_parent_to_group.rb @@ -1,3 +1,5 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + class AddParentToGroup < ActiveRecord::Migration[7.1] def change add_reference :groups, :parent, type: :uuid, index: true, foreign_key: { to_table: :groups } diff --git a/db/migrate/20240811154115_add_group_to_guest.rb b/db/migrate/20240811154115_add_group_to_guest.rb index d4e2bd4..b7df5aa 100644 --- a/db/migrate/20240811154115_add_group_to_guest.rb +++ b/db/migrate/20240811154115_add_group_to_guest.rb @@ -1,3 +1,5 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + class AddGroupToGuest < ActiveRecord::Migration[7.1] def change add_reference :guests, :group, null: false, foreign_key: true, type: :uuid diff --git a/db/migrate/20240811170021_add_status_to_guest.rb b/db/migrate/20240811170021_add_status_to_guest.rb index cd7be5b..ec92086 100644 --- a/db/migrate/20240811170021_add_status_to_guest.rb +++ b/db/migrate/20240811170021_add_status_to_guest.rb @@ -1,3 +1,5 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + class AddStatusToGuest < ActiveRecord::Migration[7.1] def change add_column :guests, :status, :integer, default: 0 diff --git a/db/migrate/20241101181052_drop_taggable_tables.rb b/db/migrate/20241101181052_drop_taggable_tables.rb new file mode 100644 index 0000000..1ece831 --- /dev/null +++ b/db/migrate/20241101181052_drop_taggable_tables.rb @@ -0,0 +1,37 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +class DropTaggableTables < ActiveRecord::Migration[7.2] + def change + drop_table 'taggings', force: :cascade do |t| + t.bigint 'tag_id' + t.string 'taggable_type' + t.uuid 'taggable_id' + t.string 'tagger_type' + t.bigint 'tagger_id' + t.string 'context', limit: 128 + t.datetime 'created_at', precision: nil + t.string 'tenant', limit: 128 + t.index ['context'], name: 'index_taggings_on_context' + t.index %w[tag_id taggable_id taggable_type context tagger_id tagger_type], name: 'taggings_idx', + unique: true + t.index ['tag_id'], name: 'index_taggings_on_tag_id' + t.index %w[taggable_id taggable_type context], name: 'taggings_taggable_context_idx' + t.index %w[taggable_id taggable_type tagger_id context], name: 'taggings_idy' + t.index ['taggable_id'], name: 'index_taggings_on_taggable_id' + t.index %w[taggable_type taggable_id], name: 'index_taggings_on_taggable_type_and_taggable_id' + t.index ['taggable_type'], name: 'index_taggings_on_taggable_type' + t.index %w[tagger_id tagger_type], name: 'index_taggings_on_tagger_id_and_tagger_type' + t.index ['tagger_id'], name: 'index_taggings_on_tagger_id' + t.index %w[tagger_type tagger_id], name: 'index_taggings_on_tagger_type_and_tagger_id' + t.index ['tenant'], name: 'index_taggings_on_tenant' + end + + drop_table "tags", force: :cascade do |t| + t.string "name" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.integer "taggings_count", default: 0 + t.index ["name"], name: "index_tags_on_name", unique: true + end + end +end diff --git a/db/migrate/20241103072808_add_name_to_tables_arrangements.rb b/db/migrate/20241103072808_add_name_to_tables_arrangements.rb new file mode 100644 index 0000000..1893200 --- /dev/null +++ b/db/migrate/20241103072808_add_name_to_tables_arrangements.rb @@ -0,0 +1,7 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +class AddNameToTablesArrangements < ActiveRecord::Migration[7.2] + def change + add_column :tables_arrangements, :name, :string, null: false + end +end diff --git a/db/migrate/20241103075705_solid_queue_install.rb b/db/migrate/20241103075705_solid_queue_install.rb new file mode 100644 index 0000000..fcd7627 --- /dev/null +++ b/db/migrate/20241103075705_solid_queue_install.rb @@ -0,0 +1,134 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +class SolidQueueInstall < ActiveRecord::Migration[7.2] + def change + create_table 'solid_queue_blocked_executions', force: :cascade do |t| + t.bigint 'job_id', null: false + t.string 'queue_name', null: false + t.integer 'priority', default: 0, null: false + t.string 'concurrency_key', null: false + t.datetime 'expires_at', null: false + t.datetime 'created_at', null: false + t.index %w[concurrency_key priority job_id], name: 'index_solid_queue_blocked_executions_for_release' + t.index %w[expires_at concurrency_key], name: 'index_solid_queue_blocked_executions_for_maintenance' + t.index ['job_id'], name: 'index_solid_queue_blocked_executions_on_job_id', unique: true + end + + create_table 'solid_queue_claimed_executions', force: :cascade do |t| + t.bigint 'job_id', null: false + t.bigint 'process_id' + t.datetime 'created_at', null: false + t.index ['job_id'], name: 'index_solid_queue_claimed_executions_on_job_id', unique: true + t.index %w[process_id job_id], name: 'index_solid_queue_claimed_executions_on_process_id_and_job_id' + end + + create_table 'solid_queue_failed_executions', force: :cascade do |t| + t.bigint 'job_id', null: false + t.text 'error' + t.datetime 'created_at', null: false + t.index ['job_id'], name: 'index_solid_queue_failed_executions_on_job_id', unique: true + end + + create_table 'solid_queue_jobs', force: :cascade do |t| + t.string 'queue_name', null: false + t.string 'class_name', null: false + t.text 'arguments' + t.integer 'priority', default: 0, null: false + t.string 'active_job_id' + t.datetime 'scheduled_at' + t.datetime 'finished_at' + t.string 'concurrency_key' + t.datetime 'created_at', null: false + t.datetime 'updated_at', null: false + t.index ['active_job_id'], name: 'index_solid_queue_jobs_on_active_job_id' + t.index ['class_name'], name: 'index_solid_queue_jobs_on_class_name' + t.index ['finished_at'], name: 'index_solid_queue_jobs_on_finished_at' + t.index %w[queue_name finished_at], name: 'index_solid_queue_jobs_for_filtering' + t.index %w[scheduled_at finished_at], name: 'index_solid_queue_jobs_for_alerting' + end + + create_table 'solid_queue_pauses', force: :cascade do |t| + t.string 'queue_name', null: false + t.datetime 'created_at', null: false + t.index ['queue_name'], name: 'index_solid_queue_pauses_on_queue_name', unique: true + end + + create_table 'solid_queue_processes', force: :cascade do |t| + t.string 'kind', null: false + t.datetime 'last_heartbeat_at', null: false + t.bigint 'supervisor_id' + t.integer 'pid', null: false + t.string 'hostname' + t.text 'metadata' + t.datetime 'created_at', null: false + t.string 'name', null: false + t.index ['last_heartbeat_at'], name: 'index_solid_queue_processes_on_last_heartbeat_at' + t.index %w[name supervisor_id], name: 'index_solid_queue_processes_on_name_and_supervisor_id', unique: true + t.index ['supervisor_id'], name: 'index_solid_queue_processes_on_supervisor_id' + end + + create_table 'solid_queue_ready_executions', force: :cascade do |t| + t.bigint 'job_id', null: false + t.string 'queue_name', null: false + t.integer 'priority', default: 0, null: false + t.datetime 'created_at', null: false + t.index ['job_id'], name: 'index_solid_queue_ready_executions_on_job_id', unique: true + t.index %w[priority job_id], name: 'index_solid_queue_poll_all' + t.index %w[queue_name priority job_id], name: 'index_solid_queue_poll_by_queue' + end + + create_table 'solid_queue_recurring_executions', force: :cascade do |t| + t.bigint 'job_id', null: false + t.string 'task_key', null: false + t.datetime 'run_at', null: false + t.datetime 'created_at', null: false + t.index ['job_id'], name: 'index_solid_queue_recurring_executions_on_job_id', unique: true + t.index %w[task_key run_at], name: 'index_solid_queue_recurring_executions_on_task_key_and_run_at', + unique: true + end + + create_table 'solid_queue_recurring_tasks', force: :cascade do |t| + t.string 'key', null: false + t.string 'schedule', null: false + t.string 'command', limit: 2048 + t.string 'class_name' + t.text 'arguments' + t.string 'queue_name' + t.integer 'priority', default: 0 + t.boolean 'static', default: true, null: false + t.text 'description' + t.datetime 'created_at', null: false + t.datetime 'updated_at', null: false + t.index ['key'], name: 'index_solid_queue_recurring_tasks_on_key', unique: true + t.index ['static'], name: 'index_solid_queue_recurring_tasks_on_static' + end + + create_table 'solid_queue_scheduled_executions', force: :cascade do |t| + t.bigint 'job_id', null: false + t.string 'queue_name', null: false + t.integer 'priority', default: 0, null: false + t.datetime 'scheduled_at', null: false + t.datetime 'created_at', null: false + t.index ['job_id'], name: 'index_solid_queue_scheduled_executions_on_job_id', unique: true + t.index %w[scheduled_at priority job_id], name: 'index_solid_queue_dispatch_all' + end + + create_table 'solid_queue_semaphores', force: :cascade do |t| + t.string 'key', null: false + t.integer 'value', default: 1, null: false + t.datetime 'expires_at', null: false + t.datetime 'created_at', null: false + t.datetime 'updated_at', null: false + t.index ['expires_at'], name: 'index_solid_queue_semaphores_on_expires_at' + t.index %w[key value], name: 'index_solid_queue_semaphores_on_key_and_value' + t.index ['key'], name: 'index_solid_queue_semaphores_on_key', unique: true + end + + add_foreign_key 'solid_queue_blocked_executions', 'solid_queue_jobs', column: 'job_id', on_delete: :cascade + add_foreign_key 'solid_queue_claimed_executions', 'solid_queue_jobs', column: 'job_id', on_delete: :cascade + add_foreign_key 'solid_queue_failed_executions', 'solid_queue_jobs', column: 'job_id', on_delete: :cascade + add_foreign_key 'solid_queue_ready_executions', 'solid_queue_jobs', column: 'job_id', on_delete: :cascade + add_foreign_key 'solid_queue_recurring_executions', 'solid_queue_jobs', column: 'job_id', on_delete: :cascade + add_foreign_key 'solid_queue_scheduled_executions', 'solid_queue_jobs', column: 'job_id', on_delete: :cascade + end +end diff --git a/db/migrate/20241103093955_remove_email_from_guests.rb b/db/migrate/20241103093955_remove_email_from_guests.rb new file mode 100644 index 0000000..cde2f00 --- /dev/null +++ b/db/migrate/20241103093955_remove_email_from_guests.rb @@ -0,0 +1,7 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +class RemoveEmailFromGuests < ActiveRecord::Migration[7.2] + def change + remove_column :guests, :email, :string + end +end diff --git a/db/migrate/20241103133122_add_color_to_group.rb b/db/migrate/20241103133122_add_color_to_group.rb new file mode 100644 index 0000000..a031638 --- /dev/null +++ b/db/migrate/20241103133122_add_color_to_group.rb @@ -0,0 +1,7 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +class AddColorToGroup < ActiveRecord::Migration[7.2] + def change + add_column :groups, :color, :string + end +end diff --git a/db/migrate/20241111063741_merge_guest_names.rb b/db/migrate/20241111063741_merge_guest_names.rb new file mode 100644 index 0000000..3b2690c --- /dev/null +++ b/db/migrate/20241111063741_merge_guest_names.rb @@ -0,0 +1,19 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +class MergeGuestNames < ActiveRecord::Migration[8.0] + def change + add_column :guests, :name, :string + + reversible do |dir| + dir.up do + execute <<~SQL + UPDATE guests + SET name = CONCAT(first_name, ' ', last_name) + SQL + end + end + + remove_column :guests, :first_name, :string + remove_column :guests, :last_name, :string + end +end diff --git a/db/migrate/20241130095753_devise_create_users.rb b/db/migrate/20241130095753_devise_create_users.rb new file mode 100644 index 0000000..efdddef --- /dev/null +++ b/db/migrate/20241130095753_devise_create_users.rb @@ -0,0 +1,46 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +class DeviseCreateUsers < ActiveRecord::Migration[8.0] + def change + create_table :users, id: :uuid do |t| + ## Database authenticatable + t.string :email, null: false, default: "" + t.string :encrypted_password, null: false, default: "" + + ## Recoverable + t.string :reset_password_token + t.datetime :reset_password_sent_at + + ## Rememberable + # t.datetime :remember_created_at + + ## Trackable + # t.integer :sign_in_count, default: 0, null: false + # t.datetime :current_sign_in_at + # t.datetime :last_sign_in_at + # t.string :current_sign_in_ip + # t.string :last_sign_in_ip + + ## Confirmable + t.string :confirmation_token + t.datetime :confirmed_at + t.datetime :confirmation_sent_at + t.string :unconfirmed_email # Only if using reconfirmable + + ## Lockable + t.integer :failed_attempts, default: 0, null: false # Only if lock strategy is :failed_attempts + t.string :unlock_token # Only if unlock strategy is :email or :both + t.datetime :locked_at + + + t.timestamps null: false + end + + add_index :users, :email, unique: true + add_index :users, :reset_password_token, unique: true + add_index :users, :confirmation_token, unique: true + add_index :users, :unlock_token, unique: true + end +end diff --git a/db/migrate/20241130182228_create_weddings.rb b/db/migrate/20241130182228_create_weddings.rb new file mode 100644 index 0000000..857b63e --- /dev/null +++ b/db/migrate/20241130182228_create_weddings.rb @@ -0,0 +1,12 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +class CreateWeddings < ActiveRecord::Migration[8.0] + def change + create_table :weddings, id: :uuid do |t| + t.string :slug, null: false, index: { unique: true } + t.date :date, null: false + + t.timestamps + end + end +end diff --git a/db/migrate/20241130185731_add_wedding_id_to_models.rb b/db/migrate/20241130185731_add_wedding_id_to_models.rb new file mode 100644 index 0000000..85fbabf --- /dev/null +++ b/db/migrate/20241130185731_add_wedding_id_to_models.rb @@ -0,0 +1,9 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +class AddWeddingIdToModels < ActiveRecord::Migration[8.0] + def change + [:expenses, :guests, :seats, :tables_arrangements, :groups, :users].each do |table| + add_reference table, :wedding, type: :uuid, null: false, foreign_key: true + end + end +end diff --git a/db/migrate/20241207112305_remove_wedding_date.rb b/db/migrate/20241207112305_remove_wedding_date.rb new file mode 100644 index 0000000..5b2e987 --- /dev/null +++ b/db/migrate/20241207112305_remove_wedding_date.rb @@ -0,0 +1,7 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +class RemoveWeddingDate < ActiveRecord::Migration[8.0] + def change + remove_column :weddings, :date, :date, null: false + end +end diff --git a/db/migrate/20241208102932_allow_ungrouped_guests.rb b/db/migrate/20241208102932_allow_ungrouped_guests.rb new file mode 100644 index 0000000..572a871 --- /dev/null +++ b/db/migrate/20241208102932_allow_ungrouped_guests.rb @@ -0,0 +1,7 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +class AllowUngroupedGuests < ActiveRecord::Migration[8.0] + def change + change_column_null :guests, :group_id, true + end +end diff --git a/db/migrate/20241216231415_create_group_affinities.rb b/db/migrate/20241216231415_create_group_affinities.rb new file mode 100644 index 0000000..22f86c5 --- /dev/null +++ b/db/migrate/20241216231415_create_group_affinities.rb @@ -0,0 +1,29 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +class CreateGroupAffinities < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + create_table :group_affinities, if_not_exists: true do |t| + t.references :group_a, type: :uuid, null: false, foreign_key: { to_table: :groups } + t.references :group_b, type: :uuid, null: false, foreign_key: { to_table: :groups } + t.float :discomfort, null: false + t.timestamps + end + + add_check_constraint :group_affinities, 'group_a_id != group_b_id', name: :check_distinct_groups, if_not_exists: true + add_check_constraint :group_affinities, 'discomfort >= 0 AND discomfort <= 2', if_not_exists: true + + reversible do |dir| + dir.up do + execute <<~SQL + CREATE UNIQUE INDEX CONCURRENTLY uindex_group_pair ON group_affinities (least(group_a_id, group_b_id), greatest(group_a_id, group_b_id)); + SQL + end + + dir.down do + remove_index :group_affinities, name: :uindex_group_pair, if_exists: true + end + end + end +end diff --git a/db/migrate/20250126091823_add_guests_digest_column_to_tables_arrangements.rb b/db/migrate/20250126091823_add_guests_digest_column_to_tables_arrangements.rb new file mode 100644 index 0000000..34a500b --- /dev/null +++ b/db/migrate/20250126091823_add_guests_digest_column_to_tables_arrangements.rb @@ -0,0 +1,5 @@ +class AddGuestsDigestColumnToTablesArrangements < ActiveRecord::Migration[8.0] + def change + add_column :tables_arrangements, :digest, :uuid, null: false, default: 'gen_random_uuid()' + end +end diff --git a/db/queue_schema.rb b/db/queue_schema.rb new file mode 100644 index 0000000..41a41b0 --- /dev/null +++ b/db/queue_schema.rb @@ -0,0 +1,4 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +ActiveRecord::Schema[7.1].define(version: 1) do + end diff --git a/db/schema.rb b/db/schema.rb index c4145db..5b61f3f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,9 +10,9 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2024_08_11_170021) do +ActiveRecord::Schema[8.0].define(version: 2025_01_26_091823) do # These are extensions that must be enabled in order to support this database - enable_extension "plpgsql" + enable_extension "pg_catalog.plpgsql" # Custom types defined in this database. # Note that some types may not work with other database engines. Be careful if changing database. @@ -24,6 +24,21 @@ ActiveRecord::Schema[7.1].define(version: 2024_08_11_170021) do t.enum "pricing_type", default: "fixed", null: false, enum_type: "pricing_types" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.uuid "wedding_id", null: false + t.index ["wedding_id"], name: "index_expenses_on_wedding_id" + end + + create_table "group_affinities", force: :cascade do |t| + t.uuid "group_a_id", null: false + t.uuid "group_b_id", null: false + t.float "discomfort", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index "LEAST(group_a_id, group_b_id), GREATEST(group_a_id, group_b_id)", name: "uindex_group_pair", unique: true + t.index ["group_a_id"], name: "index_group_affinities_on_group_a_id" + t.index ["group_b_id"], name: "index_group_affinities_on_group_b_id" + t.check_constraint "discomfort >= 0::double precision AND discomfort <= 2::double precision", name: "check_valid_discomfort" + t.check_constraint "group_a_id <> group_b_id", name: "check_distinct_groups" end create_table "groups", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -33,20 +48,23 @@ ActiveRecord::Schema[7.1].define(version: 2024_08_11_170021) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.uuid "parent_id" + t.string "color" + t.uuid "wedding_id", null: false t.index ["name"], name: "index_groups_on_name", unique: true t.index ["parent_id"], name: "index_groups_on_parent_id" + t.index ["wedding_id"], name: "index_groups_on_wedding_id" end create_table "guests", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "first_name" - t.string "last_name" - t.string "email" t.string "phone" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.uuid "group_id", null: false + t.uuid "group_id" t.integer "status", default: 0 + t.string "name" + t.uuid "wedding_id", null: false t.index ["group_id"], name: "index_guests_on_group_id" + t.index ["wedding_id"], name: "index_guests_on_wedding_id" end create_table "seats", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -55,50 +73,188 @@ ActiveRecord::Schema[7.1].define(version: 2024_08_11_170021) do t.integer "table_number" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.uuid "wedding_id", null: false t.index ["guest_id"], name: "index_seats_on_guest_id" t.index ["tables_arrangement_id"], name: "index_seats_on_tables_arrangement_id" + t.index ["wedding_id"], name: "index_seats_on_wedding_id" + end + + create_table "solid_queue_blocked_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.string "concurrency_key", null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.index ["concurrency_key", "priority", "job_id"], name: "index_solid_queue_blocked_executions_for_release" + t.index ["expires_at", "concurrency_key"], name: "index_solid_queue_blocked_executions_for_maintenance" + t.index ["job_id"], name: "index_solid_queue_blocked_executions_on_job_id", unique: true + end + + create_table "solid_queue_claimed_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.bigint "process_id" + t.datetime "created_at", null: false + t.index ["job_id"], name: "index_solid_queue_claimed_executions_on_job_id", unique: true + t.index ["process_id", "job_id"], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id" + end + + create_table "solid_queue_failed_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.text "error" + t.datetime "created_at", null: false + t.index ["job_id"], name: "index_solid_queue_failed_executions_on_job_id", unique: true + end + + create_table "solid_queue_jobs", force: :cascade do |t| + t.string "queue_name", null: false + t.string "class_name", null: false + t.text "arguments" + t.integer "priority", default: 0, null: false + t.string "active_job_id" + t.datetime "scheduled_at" + t.datetime "finished_at" + t.string "concurrency_key" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["active_job_id"], name: "index_solid_queue_jobs_on_active_job_id" + t.index ["class_name"], name: "index_solid_queue_jobs_on_class_name" + t.index ["finished_at"], name: "index_solid_queue_jobs_on_finished_at" + t.index ["queue_name", "finished_at"], name: "index_solid_queue_jobs_for_filtering" + t.index ["scheduled_at", "finished_at"], name: "index_solid_queue_jobs_for_alerting" + end + + create_table "solid_queue_pauses", force: :cascade do |t| + t.string "queue_name", null: false + t.datetime "created_at", null: false + t.index ["queue_name"], name: "index_solid_queue_pauses_on_queue_name", unique: true + end + + create_table "solid_queue_processes", force: :cascade do |t| + t.string "kind", null: false + t.datetime "last_heartbeat_at", null: false + t.bigint "supervisor_id" + t.integer "pid", null: false + t.string "hostname" + t.text "metadata" + t.datetime "created_at", null: false + t.string "name", null: false + t.index ["last_heartbeat_at"], name: "index_solid_queue_processes_on_last_heartbeat_at" + t.index ["name", "supervisor_id"], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true + t.index ["supervisor_id"], name: "index_solid_queue_processes_on_supervisor_id" + end + + create_table "solid_queue_ready_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.datetime "created_at", null: false + t.index ["job_id"], name: "index_solid_queue_ready_executions_on_job_id", unique: true + t.index ["priority", "job_id"], name: "index_solid_queue_poll_all" + t.index ["queue_name", "priority", "job_id"], name: "index_solid_queue_poll_by_queue" + end + + create_table "solid_queue_recurring_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "task_key", null: false + t.datetime "run_at", null: false + t.datetime "created_at", null: false + t.index ["job_id"], name: "index_solid_queue_recurring_executions_on_job_id", unique: true + t.index ["task_key", "run_at"], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true + end + + create_table "solid_queue_recurring_tasks", force: :cascade do |t| + t.string "key", null: false + t.string "schedule", null: false + t.string "command", limit: 2048 + t.string "class_name" + t.text "arguments" + t.string "queue_name" + t.integer "priority", default: 0 + t.boolean "static", default: true, null: false + t.text "description" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["key"], name: "index_solid_queue_recurring_tasks_on_key", unique: true + t.index ["static"], name: "index_solid_queue_recurring_tasks_on_static" + end + + create_table "solid_queue_scheduled_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.datetime "scheduled_at", null: false + t.datetime "created_at", null: false + t.index ["job_id"], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true + t.index ["scheduled_at", "priority", "job_id"], name: "index_solid_queue_dispatch_all" + end + + create_table "solid_queue_semaphores", force: :cascade do |t| + t.string "key", null: false + t.integer "value", default: 1, null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["expires_at"], name: "index_solid_queue_semaphores_on_expires_at" + t.index ["key", "value"], name: "index_solid_queue_semaphores_on_key_and_value" + t.index ["key"], name: "index_solid_queue_semaphores_on_key", unique: true end create_table "tables_arrangements", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.integer "discomfort" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "name", null: false + t.uuid "wedding_id", null: false + t.uuid "digest", default: -> { "gen_random_uuid()" }, null: false + t.index ["wedding_id"], name: "index_tables_arrangements_on_wedding_id" end - create_table "taggings", force: :cascade do |t| - t.bigint "tag_id" - t.string "taggable_type" - t.uuid "taggable_id" - t.string "tagger_type" - t.bigint "tagger_id" - t.string "context", limit: 128 - t.datetime "created_at", precision: nil - t.string "tenant", limit: 128 - t.index ["context"], name: "index_taggings_on_context" - t.index ["tag_id", "taggable_id", "taggable_type", "context", "tagger_id", "tagger_type"], name: "taggings_idx", unique: true - t.index ["tag_id"], name: "index_taggings_on_tag_id" - t.index ["taggable_id", "taggable_type", "context"], name: "taggings_taggable_context_idx" - t.index ["taggable_id", "taggable_type", "tagger_id", "context"], name: "taggings_idy" - t.index ["taggable_id"], name: "index_taggings_on_taggable_id" - t.index ["taggable_type", "taggable_id"], name: "index_taggings_on_taggable_type_and_taggable_id" - t.index ["taggable_type"], name: "index_taggings_on_taggable_type" - t.index ["tagger_id", "tagger_type"], name: "index_taggings_on_tagger_id_and_tagger_type" - t.index ["tagger_id"], name: "index_taggings_on_tagger_id" - t.index ["tagger_type", "tagger_id"], name: "index_taggings_on_tagger_type_and_tagger_id" - t.index ["tenant"], name: "index_taggings_on_tenant" - end - - create_table "tags", force: :cascade do |t| - t.string "name" + create_table "users", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "email", default: "", null: false + t.string "encrypted_password", default: "", null: false + t.string "reset_password_token" + t.datetime "reset_password_sent_at" + t.string "confirmation_token" + t.datetime "confirmed_at" + t.datetime "confirmation_sent_at" + t.string "unconfirmed_email" + t.integer "failed_attempts", default: 0, null: false + t.string "unlock_token" + t.datetime "locked_at" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.integer "taggings_count", default: 0 - t.index ["name"], name: "index_tags_on_name", unique: true + t.uuid "wedding_id", null: false + t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true + t.index ["email"], name: "index_users_on_email", unique: true + t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true + t.index ["unlock_token"], name: "index_users_on_unlock_token", unique: true + t.index ["wedding_id"], name: "index_users_on_wedding_id" end + create_table "weddings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "slug", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["slug"], name: "index_weddings_on_slug", unique: true + end + + add_foreign_key "expenses", "weddings" + add_foreign_key "group_affinities", "groups", column: "group_a_id" + add_foreign_key "group_affinities", "groups", column: "group_b_id" add_foreign_key "groups", "groups", column: "parent_id" + add_foreign_key "groups", "weddings" add_foreign_key "guests", "groups" + add_foreign_key "guests", "weddings" add_foreign_key "seats", "guests" add_foreign_key "seats", "tables_arrangements", on_delete: :cascade - add_foreign_key "taggings", "tags" + add_foreign_key "seats", "weddings" + add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "tables_arrangements", "weddings" + add_foreign_key "users", "weddings" end diff --git a/db/seeds.rb b/db/seeds.rb index 391d6e1..fcf5ec3 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,66 +1,85 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + NUMBER_OF_GUESTS = 50 -TablesArrangement.delete_all -Expense.delete_all -Guest.delete_all -ActsAsTaggableOn::Tagging.delete_all -ActsAsTaggableOn::Tag.delete_all -Group.delete_all - -Expense.create!(name: 'Photographer', amount: 3000, pricing_type: 'fixed') -Expense.create!(name: 'Country house', amount: 6000, pricing_type: 'fixed') -Expense.create!(name: 'Catering', amount: 200, pricing_type: 'per_person') -Expense.create!(name: 'Flowers', amount: 500, pricing_type: 'fixed') -Expense.create!(name: 'Band', amount: 1000, pricing_type: 'fixed') -Expense.create!(name: 'Wedding planner', amount: 2000, pricing_type: 'fixed') -Expense.create!(name: 'Dress', amount: 1000, pricing_type: 'fixed') -Expense.create!(name: 'Suit', amount: 500, pricing_type: 'fixed') -Expense.create!(name: 'Rings', amount: 1000, pricing_type: 'fixed') -Expense.create!(name: 'Makeup', amount: 200, pricing_type: 'fixed') -Expense.create!(name: 'Hair', amount: 200, pricing_type: 'fixed') -Expense.create!(name: 'Transportation', amount: 3000, pricing_type: 'fixed') -Expense.create!(name: 'Invitations', amount: 200, pricing_type: 'fixed') -Expense.create!(name: 'Cake', amount: 500, pricing_type: 'fixed') - -Group.create!(name: "Jim's guests", icon: 'pi pi-heart').tap do |parent| - parent.children.create!(name: "Jim's family", icon: 'pi pi-users').tap do |family| - family.children.create!(name: "Jim's close family", icon: 'pi pi-home') - family.children.create!(name: "Jim's cousins", icon: 'pi pi-home') - family.children.create!(name: "Jim's relatives", icon: 'pi pi-home') - end - parent.children.create!(name: "Jim's friends", icon: 'pi pi-bullseye') - parent.children.create!(name: "Jim's work", icon: 'pi pi-desktop').tap do |work| - work.children.create!(name: "Jim's besties at work", icon: 'pi pi-briefcase') - end +ActsAsTenant.without_tenant do + TablesArrangement.delete_all + Expense.delete_all + Guest.delete_all + Group.delete_all + + Wedding.delete_all end -Group.create!(name: "Pam's guests", icon: 'pi pi-heart-fill').tap do |parent| - parent.children.create!(name: "Pam's family", icon: 'pi pi-users').tap do |family| - family.children.create!(name: "Pam's close family", icon: 'pi pi-home') - family.children.create!(name: "Pam's cousins", icon: 'pi pi-home') - family.children.create!(name: "Pam's relatives", icon: 'pi pi-home') +wedding = Wedding.create!(slug: :default) + +ActsAsTenant.with_tenant(wedding) do + Expense.create!(name: 'Photographer', amount: 3000, pricing_type: 'fixed') + Expense.create!(name: 'Country house', amount: 6000, pricing_type: 'fixed') + Expense.create!(name: 'Catering', amount: 200, pricing_type: 'per_person') + Expense.create!(name: 'Flowers', amount: 500, pricing_type: 'fixed') + Expense.create!(name: 'Band', amount: 1000, pricing_type: 'fixed') + Expense.create!(name: 'Wedding planner', amount: 2000, pricing_type: 'fixed') + Expense.create!(name: 'Dress', amount: 1000, pricing_type: 'fixed') + Expense.create!(name: 'Suit', amount: 500, pricing_type: 'fixed') + Expense.create!(name: 'Rings', amount: 1000, pricing_type: 'fixed') + Expense.create!(name: 'Makeup', amount: 200, pricing_type: 'fixed') + Expense.create!(name: 'Hair', amount: 200, pricing_type: 'fixed') + Expense.create!(name: 'Transportation', amount: 3000, pricing_type: 'fixed') + Expense.create!(name: 'Invitations', amount: 200, pricing_type: 'fixed') + Expense.create!(name: 'Cake', amount: 500, pricing_type: 'fixed') + + Group.create!(name: "Jim's guests", icon: 'pi pi-heart').tap do |parent| + parent.children.create!(name: "Jim's family", icon: 'pi pi-users').tap do |family| + family.children.create!(name: "Jim's close family", icon: 'pi pi-home') + family.children.create!(name: "Jim's cousins", icon: 'pi pi-home') + family.children.create!(name: "Jim's relatives", icon: 'pi pi-home') + end + parent.children.create!(name: "Jim's friends", icon: 'pi pi-bullseye') + parent.children.create!(name: "Jim's work", icon: 'pi pi-desktop').tap do |work| + work.children.create!(name: "Jim's besties at work", icon: 'pi pi-briefcase') + end end - parent.children.create!(name: "Pam's friends", icon: 'pi pi-bullseye') - parent.children.create!(name: "Pam's work", icon: 'pi pi-desktop').tap do |work| - work.children.create!(name: "Pam's besties at work", icon: 'pi pi-briefcase') + + Group.create!(name: "Pam's guests", icon: 'pi pi-heart-fill').tap do |parent| + parent.children.create!(name: "Pam's family", icon: 'pi pi-users').tap do |family| + family.children.create!(name: "Pam's close family", icon: 'pi pi-home') + family.children.create!(name: "Pam's cousins", icon: 'pi pi-home') + family.children.create!(name: "Pam's relatives", icon: 'pi pi-home') + end + parent.children.create!(name: "Pam's friends", icon: 'pi pi-bullseye') + parent.children.create!(name: "Pam's work", icon: 'pi pi-desktop').tap do |work| + work.children.create!(name: "Pam's besties at work", icon: 'pi pi-briefcase') + end end -end -Group.create!(name: 'Common guests', icon: 'pi pi-users').tap do |parent| - parent.children.create!(name: 'College friends', icon: 'pi pi-calculator') - parent.children.create!(name: 'High school friends', icon: 'pi pi-crown') - parent.children.create!(name: 'Childhood friends', icon: 'pi pi-envelope') -end + Group.create!(name: 'Common guests', icon: 'pi pi-users').tap do |parent| + parent.children.create!(name: 'College friends', icon: 'pi pi-calculator') + parent.children.create!(name: 'High school friends', icon: 'pi pi-crown') + parent.children.create!(name: 'Childhood friends', icon: 'pi pi-envelope') + end -groups = Group.all + groups = Group.all -NUMBER_OF_GUESTS.times do - Guest.create!( - first_name: Faker::Name.first_name, - last_name: Faker::Name.last_name, - email: Faker::Internet.email, - phone: Faker::PhoneNumber.cell_phone, - group: groups.sample, - status: Guest.statuses.keys.sample + NUMBER_OF_GUESTS.times do + Guest.create!( + name: Faker::Name.name, + phone: Faker::PhoneNumber.cell_phone, + group: groups.sample, + status: Guest.statuses.keys.sample + ) + end + + ActiveJob.perform_all_later(3.times.map { TableSimulatorJob.new(wedding.id) }) + + 'red'.paint.palette.triad(as: :hex).zip(Group.roots).each { |(color, group)| group.update!(color: color.paint.desaturate(40)) } + + Group.roots.each(&:colorize_children) + + User.create!( + email: 'development@example.com', + confirmed_at: Time.zone.now, + password: 'supersecretpassword', + password_confirmation: 'supersecretpassword', ) end diff --git a/doc/dependency_decisions.yml b/doc/dependency_decisions.yml new file mode 100644 index 0000000..59ca514 --- /dev/null +++ b/doc/dependency_decisions.yml @@ -0,0 +1,91 @@ +--- +- - :permit + - MIT + - :who: + :why: + :versions: [] + :when: 2024-10-25 17:45:36.831184284 Z +- - :permit + - ISC + - :who: + :why: + :versions: [] + :when: 2024-10-25 17:48:14.527140943 Z +- - :permit + - Apache 2.0 + - :who: + :why: + :versions: [] + :when: 2024-10-25 17:48:23.863998708 Z +- - :permit + - Simplified BSD + - :who: + :why: + :versions: [] + :when: 2024-10-25 17:49:01.330574375 Z +- - :permit + - New BSD + - :who: + :why: + :versions: [] + :when: 2024-10-25 17:49:53.995999923 Z +- - :permit + - LGPL-3.0-or-later + - :who: + :why: + :versions: [] + :when: 2024-10-25 17:51:16.274818102 Z +- - :permit + - Python-2.0 + - :who: + :why: + :versions: [] + :when: 2024-10-25 17:51:32.610018037 Z +- - :permit + - BlueOak-1.0.0 + - :who: + :why: + :versions: [] + :when: 2024-10-25 17:52:28.568966565 Z +- - :permit + - BSD + - :who: + :why: + :versions: [] + :when: 2024-10-25 17:52:37.235297087 Z +- - :permit + - The Unlicense + - :who: + :why: + :versions: [] + :when: 2024-10-25 17:52:49.646463302 Z +- - :permit + - CC-BY-4.0 + - :who: + :why: + :versions: [] + :when: 2024-10-25 17:54:29.363007852 Z +- - :permit + - "(MIT AND Zlib)" + - :who: + :why: + :versions: [] + :when: 2024-10-25 17:54:49.936741134 Z +- - :permit + - BSD Zero Clause License + - :who: + :why: + :versions: [] + :when: 2024-10-25 17:55:31.968339009 Z +- - :permit + - Artistic-2.0 + - :who: + :why: + :versions: [] + :when: 2024-10-25 17:55:52.371898047 Z +- - :permit + - ruby + - :who: + :why: + :versions: [] + :when: 2024-11-03 10:58:35.358938407 Z diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..93cff59 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,83 @@ +services: + backend: + build: + context: . + dockerfile: Dockerfile.dev + ports: + - 3000 + depends_on: + db: + condition: service_healthy + environment: + DATABASE_URL: postgres://postgres:postgres@db:5432/postgres + RAILS_ENV: development + tty: true + stdin_open: true + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/up"] + interval: 10s + timeout: 5s + retries: 5 + volumes: + - .:/rails + workers: + build: + context: . + dockerfile: Dockerfile.dev + entrypoint: bin/jobs + depends_on: + db: + condition: service_healthy + environment: + DATABASE_URL: postgres://postgres:postgres@db:5432/postgres + RAILS_ENV: development + volumes: + - .:/rails + frontend: + build: + context: ../wedding-planner-frontend + dockerfile: Dockerfile.dev + ports: + - 3000 + healthcheck: + test: wget -qO - http://localhost:3000/api/health || exit 1 + interval: 10s + timeout: 5s + retries: 5 + depends_on: + - backend + volumes: + - ../wedding-planner-frontend/:/app + libre-captcha: + image: librecaptcha/lc-core:latest + volumes: + - "./tmp/libre-captcha-data:/lc-core/data" + - "./libre-captcha-config.json:/lc-core/data/config.json" + ports: + - 8888 + nginx: + image: nginx:latest + ports: + - 80:80 + volumes: + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + frontend: + condition: service_healthy + backend: + condition: service_healthy + db: + image: postgres:17 + ports: + - 5432 + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U postgres'] + interval: 10s + timeout: 5s + retries: 5 + + \ No newline at end of file diff --git a/lib/tasks/annotate_rb.rake b/lib/tasks/annotate_rb.rake new file mode 100644 index 0000000..e8368b2 --- /dev/null +++ b/lib/tasks/annotate_rb.rake @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# This rake task was added by annotate_rb gem. + +# Can set `ANNOTATERB_SKIP_ON_DB_TASKS` to be anything to skip this +if Rails.env.development? && ENV['ANNOTATERB_SKIP_ON_DB_TASKS'].nil? + require 'annotate_rb' + + AnnotateRb::Core.load_rake_tasks +end diff --git a/lib/tasks/vns.rake b/lib/tasks/vns.rake deleted file mode 100644 index c8e6b50..0000000 --- a/lib/tasks/vns.rake +++ /dev/null @@ -1,18 +0,0 @@ -namespace :vns do - task distribute_tables: :environment do - engine = VNS::Engine.new - - engine.add_perturbation(Tables::Swap) - - initial_solution = Tables::Distribution.new(min_per_table: 8, max_per_table: 10) - initial_solution.random_distribution(Guest.all.shuffle) - - engine.initial_solution = initial_solution - - engine.target_function(&:discomfort) - - best_solution = engine.run - - best_solution.save! - end -end diff --git a/libre-captcha-config.json b/libre-captcha-config.json new file mode 100644 index 0000000..c9b5d67 --- /dev/null +++ b/libre-captcha-config.json @@ -0,0 +1,29 @@ +{ + "randomSeed": -1534087241, + "port": 8888, + "address": "0.0.0.0", + "captchaExpiryTimeLimit": 5, + "bufferCount": 1000, + "threadDelay": 2, + "playgroundEnabled": false, + "corsHeader": "", + "maxAttemptsRatio": 0.009999999776482582, + "captchas": [ + { + "name": "FilterChallenge", + "allowedLevels": [ + "hard" + ], + "allowedMedia": [ + "image/png" + ], + "allowedInputType": [ + "text" + ], + "allowedSizes": [ + "350x100" + ], + "config": {} + } + ] +} \ No newline at end of file diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..58c2bb9 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,25 @@ +server { + listen 80; + server_name libre-wedding-planner.app.localhost; + + location /api/ { + proxy_pass http://backend:3000/; + proxy_set_header Host $http_host; + } + + location /letter_opener/ { + proxy_pass http://backend:3000/letter_opener/; + proxy_set_header Host $http_host; + } + + location /captcha/v2/media/ { + proxy_pass http://libre-captcha:8888/v2/media/; + proxy_set_header Host $http_host; + } + + location / { + proxy_pass http://frontend:3000; + proxy_set_header Host $http_host; + } +} + diff --git a/spec/extensions/tree_spec.rb b/spec/extensions/tree_spec.rb index cbefba4..4b45e26 100644 --- a/spec/extensions/tree_spec.rb +++ b/spec/extensions/tree_spec.rb @@ -1,68 +1,72 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + require 'rails_helper' module Tree RSpec.describe TreeNode do describe '#distance_to_common_ancestor' do - def assert_distance(node_1, node_2, distance) + def assert_distance(node1, node2, distance) aggregate_failures do - expect(node_1.distance_to_common_ancestor(node_2)).to eq(distance) - expect(node_2.distance_to_common_ancestor(node_1)).to eq(distance) + expect(node1.distance_to_common_ancestor(node2)).to eq(distance) + expect(node2.distance_to_common_ancestor(node1)).to eq(distance) end end context 'when the two nodes are the same' do it 'returns 0 when comparing the root itself' do - root = Tree::TreeNode.new('root') + root = described_class.new('root') assert_distance(root, root, 0) end it 'returns 0 when comparing a child to itself' do - root = Tree::TreeNode.new('root') - child = root << Tree::TreeNode.new('child') + root = described_class.new('root') + child = root << described_class.new('child') assert_distance(child, child, 0) end end context 'when the two nodes are siblings' do it 'returns 1 when comparing siblings' do - root = Tree::TreeNode.new('root') - child1 = root << Tree::TreeNode.new('child1') - child2 = root << Tree::TreeNode.new('child2') + root = described_class.new('root') + child1 = root << described_class.new('child1') + child2 = root << described_class.new('child2') assert_distance(child1, child2, 1) end end context 'when one node is parent of the other' do it 'returns 1 when comparing parent to child' do - root = Tree::TreeNode.new('root') - child = root << Tree::TreeNode.new('child') + root = described_class.new('root') + child = root << described_class.new('child') assert_distance(root, child, 1) end end context 'when one node is grandparent of the other' do it 'returns 2 when comparing grandparent to grandchild' do - root = Tree::TreeNode.new('root') - child = root << Tree::TreeNode.new('child') - grandchild = child << Tree::TreeNode.new('grandchild') + root = described_class.new('root') + child = root << described_class.new('child') + grandchild = child << described_class.new('grandchild') assert_distance(root, grandchild, 2) end end context 'when the two nodes are cousins' do it 'returns 2 when comparing cousins' do - root = Tree::TreeNode.new('root') - child1 = root << Tree::TreeNode.new('child1') - child2 = root << Tree::TreeNode.new('child2') - grandchild1 = child1 << Tree::TreeNode.new('grandchild1') - grandchild2 = child2 << Tree::TreeNode.new('grandchild2') + root = described_class.new('root') + child1 = root << described_class.new('child1') + child2 = root << described_class.new('child2') + grandchild1 = child1 << described_class.new('grandchild1') + grandchild2 = child2 << described_class.new('grandchild2') assert_distance(grandchild1, grandchild2, 2) end end context 'when the two nodes are not related' do it 'returns nil' do - root = Tree::TreeNode.new('root') - another_root = Tree::TreeNode.new('another_root') + root = described_class.new('root') + another_root = described_class.new('another_root') assert_distance(root, another_root, nil) end end diff --git a/spec/factories/expense.rb b/spec/factories/expense.rb new file mode 100644 index 0000000..cec5134 --- /dev/null +++ b/spec/factories/expense.rb @@ -0,0 +1,20 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +FactoryBot.define do + factory :expense do + wedding + sequence(:name) { |i| "Expense #{i}" } + pricing_type { 'fixed' } + amount { 100 } + end + + trait :fixed do + pricing_type { 'fixed' } + end + + trait :per_person do + pricing_type { 'per_person' } + end +end diff --git a/spec/factories/group_affinities.rb b/spec/factories/group_affinities.rb new file mode 100644 index 0000000..be308bd --- /dev/null +++ b/spec/factories/group_affinities.rb @@ -0,0 +1,11 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +FactoryBot.define do + factory :group_affinity do + group_a factory: %i[group] + group_b factory: %i[group] + discomfort { GroupAffinity::NEUTRAL } + end +end diff --git a/spec/factories/groups.rb b/spec/factories/groups.rb index 9bfade0..0cd9c48 100644 --- a/spec/factories/groups.rb +++ b/spec/factories/groups.rb @@ -1,5 +1,10 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + FactoryBot.define do factory :group do + wedding sequence(:name) { |i| "Group #{i}" } order { 1 } end diff --git a/spec/factories/guest.rb b/spec/factories/guest.rb index 7ff9066..9efecdb 100644 --- a/spec/factories/guest.rb +++ b/spec/factories/guest.rb @@ -1,10 +1,13 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + FactoryBot.define do factory :guest do - association :group + group + wedding - first_name { Faker::Name.first_name } - last_name { Faker::Name.last_name } - email { Faker::Internet.email } + name { Faker::Name.name } phone { Faker::PhoneNumber.cell_phone } end end diff --git a/spec/factories/table_arrangement.rb b/spec/factories/table_arrangement.rb new file mode 100644 index 0000000..e0c9cbb --- /dev/null +++ b/spec/factories/table_arrangement.rb @@ -0,0 +1,9 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +FactoryBot.define do + factory :tables_arrangement do + wedding + end +end diff --git a/spec/factories/users.rb b/spec/factories/users.rb new file mode 100644 index 0000000..edae51d --- /dev/null +++ b/spec/factories/users.rb @@ -0,0 +1,9 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +FactoryBot.define do + factory :user do + wedding + end +end diff --git a/spec/factories/weddings.rb b/spec/factories/weddings.rb new file mode 100644 index 0000000..8fa5a9c --- /dev/null +++ b/spec/factories/weddings.rb @@ -0,0 +1,9 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +FactoryBot.define do + factory :wedding do + sequence(:slug) { |i| "wedding-#{i}" } + end +end diff --git a/spec/models/expense_spec.rb b/spec/models/expense_spec.rb index 5ad50a3..9a9a025 100644 --- a/spec/models/expense_spec.rb +++ b/spec/models/expense_spec.rb @@ -1,5 +1,14 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe Expense, type: :model do - pending "add some examples to (or delete) #{__FILE__}" +RSpec.describe Expense do + describe 'validations' do + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_presence_of(:amount) } + it { is_expected.to validate_numericality_of(:amount).is_greater_than(0) } + it { is_expected.to validate_presence_of(:pricing_type) } + end end diff --git a/spec/models/group_affinity_spec.rb b/spec/models/group_affinity_spec.rb new file mode 100644 index 0000000..8afd2c6 --- /dev/null +++ b/spec/models/group_affinity_spec.rb @@ -0,0 +1,46 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe GroupAffinity do + subject(:affinity) { build(:group_affinity, group_a:, group_b:) } + + let(:wedding) { create(:wedding) } + let(:group_a) { create(:group, wedding:) } + let(:group_b) { create(:group, wedding:) } + let(:group_c) { create(:group, wedding:) } + + describe 'validations' do + it do + expect(affinity).to validate_numericality_of(:discomfort) + .is_greater_than_or_equal_to(0) + .is_less_than_or_equal_to(2) + end + end + + describe '.create' do + before do + create(:group_affinity, group_a: group_a, group_b: group_b) + end + + it 'disallows the creation of a group affinity with the same group on both sides' do + expect do + create(:group_affinity, group_a: group_c, group_b: group_c) + end.to raise_error(ActiveRecord::StatementInvalid) + end + + it 'disallows the creation of a group affinity that already exists' do + expect do + create(:group_affinity, group_a: group_a, group_b: group_b) + end.to raise_error(ActiveRecord::StatementInvalid) + end + + it 'disallows the creation of a group affinity with the same groups in reverse order' do + expect do + create(:group_affinity, group_a: group_b, group_b: group_a) + end.to raise_error(ActiveRecord::StatementInvalid) + end + end +end diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index be100cf..c55b081 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -1,5 +1,13 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe Group, type: :model do - pending "add some examples to (or delete) #{__FILE__}" +RSpec.describe Group do + describe 'callbacks' do + it 'sets color before create' do + expect(create(:group).color).to be_present + end + end end diff --git a/spec/models/guest_spec.rb b/spec/models/guest_spec.rb index 1e6abc7..e1f7ad6 100644 --- a/spec/models/guest_spec.rb +++ b/spec/models/guest_spec.rb @@ -1,5 +1,39 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe Guest, type: :model do - pending "add some examples to (or delete) #{__FILE__}" +RSpec.describe Guest do + describe 'validations' do + subject(:guest) { build(:guest) } + + it { is_expected.to validate_presence_of(:name) } + + it do + expect(guest).to define_enum_for(:status).with_values( + considered: 0, + invited: 10, + confirmed: 20, + declined: 30, + tentative: 40 + ) + end + end + + it { is_expected.to belong_to(:group).optional } + + describe 'scopes' do + describe '.potential' do + it 'returns guests that are not declined or considered' do + _declined_guest = create(:guest, status: :declined) + _considered_guest = create(:guest, status: :considered) + invited_guest = create(:guest, status: :invited) + confirmed_guest = create(:guest, status: :confirmed) + tentative_guest = create(:guest, status: :tentative) + + expect(described_class.potential).to contain_exactly(invited_guest, confirmed_guest, tentative_guest) + end + end + end end diff --git a/spec/models/seat_spec.rb b/spec/models/seat_spec.rb index bdcd95d..5648c2e 100644 --- a/spec/models/seat_spec.rb +++ b/spec/models/seat_spec.rb @@ -1,5 +1,9 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe Seat, type: :model do +RSpec.describe Seat do pending "add some examples to (or delete) #{__FILE__}" end diff --git a/spec/models/tables_arrangement_spec.rb b/spec/models/tables_arrangement_spec.rb index 71a09f5..96ff744 100644 --- a/spec/models/tables_arrangement_spec.rb +++ b/spec/models/tables_arrangement_spec.rb @@ -1,5 +1,13 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe TablesArrangement, type: :model do - pending "add some examples to (or delete) #{__FILE__}" +RSpec.describe TablesArrangement do + describe 'callbacks' do + it 'assigns a name before creation' do + expect(create(:tables_arrangement).name).to be_present + end + end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb new file mode 100644 index 0000000..f066e79 --- /dev/null +++ b/spec/models/user_spec.rb @@ -0,0 +1,9 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe User do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/models/wedding_spec.rb b/spec/models/wedding_spec.rb new file mode 100644 index 0000000..77fae82 --- /dev/null +++ b/spec/models/wedding_spec.rb @@ -0,0 +1,25 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Wedding do + describe 'validations' do + subject { build(:wedding) } + + describe 'slug' do + it { is_expected.to allow_value('foo').for(:slug) } + it { is_expected.to allow_value('foo-bar').for(:slug) } + it { is_expected.to allow_value('foo-123').for(:slug) } + it { is_expected.to allow_value('foo-123-').for(:slug) } + it { is_expected.to allow_value('foo--123').for(:slug) } + + it { is_expected.not_to allow_value('Foo').for(:slug) } + it { is_expected.not_to allow_value('/foo').for(:slug) } + it { is_expected.not_to allow_value('foo/123').for(:slug) } + it { is_expected.not_to allow_value('foo_123').for(:slug) } + it { is_expected.not_to allow_value('foo/').for(:slug) } + end + end +end diff --git a/spec/queries/expenses/total_query_spec.rb b/spec/queries/expenses/total_query_spec.rb new file mode 100644 index 0000000..f1c2c4f --- /dev/null +++ b/spec/queries/expenses/total_query_spec.rb @@ -0,0 +1,74 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +require 'rails_helper' + +module Expenses + RSpec.describe TotalQuery do + describe '#call' do + let(:wedding) { create(:wedding) } + let(:response) { described_class.new(wedding:).call } + + before do + create_list(:guest, 2, wedding:, status: :confirmed) + create_list(:guest, 3, wedding:, status: :considered) + create_list(:guest, 4, wedding:, status: :invited) + create_list(:guest, 5, wedding:, status: :tentative) + create_list(:guest, 6, wedding:, status: :declined) + end + + context 'when there is no expense' do + it 'returns zero in all values', :aggregate_failures do + expect(response['total_confirmed']).to be_zero + expect(response['total_projected']).to be_zero + expect(response['confirmed_guests']).to eq(2) + expect(response['projected_guests']).to eq(2 + 4 + 5) + end + end + + context 'when there are only fixed expenses' do + before do + create(:expense, :fixed, wedding:, amount: 100) + create(:expense, :fixed, wedding:, amount: 200) + end + + it 'returns the sum of fixed expenses', :aggregate_failures do + expect(response['total_confirmed']).to eq(300) + expect(response['total_projected']).to eq(300) + expect(response['confirmed_guests']).to eq(2) + expect(response['projected_guests']).to eq(2 + 4 + 5) + end + end + + context 'when there are only variable expenses' do + before do + create(:expense, :per_person, wedding:, amount: 100) + create(:expense, :per_person, wedding:, amount: 200) + end + + it 'returns zero in the values and nonzero in the count', :aggregate_failures do + expect(response['total_confirmed']).to eq(2 * 300) + expect(response['total_projected']).to eq(11 * 300) + expect(response['confirmed_guests']).to eq(2) + expect(response['projected_guests']).to eq(2 + 4 + 5) + end + end + + context 'when there are both fixed and variable expenses' do + before do + create(:expense, :fixed, wedding:, amount: 100) + create(:expense, :fixed, wedding:, amount: 200) + create(:expense, :per_person, wedding:, amount: 50) + end + + it 'returns the sum of fixed and variable expenses', :aggregate_failures do + expect(response['total_confirmed']).to eq(100 + 200 + (50 * 2)) + expect(response['total_projected']).to eq(100 + 200 + (11 * 50)) + expect(response['confirmed_guests']).to eq(2) + expect(response['projected_guests']).to eq(2 + 4 + 5) + end + end + end + end +end diff --git a/spec/queries/groups/summary_query_spec.rb b/spec/queries/groups/summary_query_spec.rb new file mode 100644 index 0000000..6300724 --- /dev/null +++ b/spec/queries/groups/summary_query_spec.rb @@ -0,0 +1,100 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +require 'rails_helper' + +module Groups + RSpec.describe SummaryQuery do + describe '#call' do + subject(:result) { described_class.new.call } + + context 'when there are no groups' do + it { is_expected.to eq([]) } + end + + context 'when groups are defined' do + let!(:parent) { create(:group, name: 'Friends', icon: 'icon-1', color: '#FF0000') } + let!(:child) { create(:group, name: 'Family', icon: 'icon-2', color: '#00FF00', parent:) } + + context 'when there are no guests' do + it 'returns the summary of groups' do + expect(result).to contain_exactly( + { 'id' => parent.id, + 'name' => 'Friends', + 'icon' => 'icon-1', + 'parent_id' => nil, + 'color' => '#FF0000', + 'total' => 0, + 'considered' => 0, + 'invited' => 0, + 'confirmed' => 0, + 'declined' => 0, + 'tentative' => 0 }, + { 'id' => child.id, + 'name' => 'Family', + 'icon' => 'icon-2', + 'parent_id' => parent.id, + 'color' => '#00FF00', + 'total' => 0, + 'considered' => 0, + 'invited' => 0, + 'confirmed' => 0, + 'declined' => 0, + 'tentative' => 0 } + ) + end + end + + context 'when there are guests' do + before do + # Parent group + create_list(:guest, 2, group: parent, status: :considered) + create_list(:guest, 3, group: parent, status: :invited) + create_list(:guest, 4, group: parent, status: :confirmed) + create_list(:guest, 5, group: parent, status: :declined) + create_list(:guest, 6, group: parent, status: :tentative) + + # Child group + create_list(:guest, 7, group: child, status: :considered) + create_list(:guest, 8, group: child, status: :invited) + create_list(:guest, 9, group: child, status: :confirmed) + create_list(:guest, 10, group: child, status: :declined) + create_list(:guest, 11, group: child, status: :tentative) # rubocop:disable FactoryBot/ExcessiveCreateList + end + + it 'returns the summary of groups' do + expect(result).to contain_exactly( + { + 'id' => parent.id, + 'name' => 'Friends', + 'icon' => 'icon-1', + 'parent_id' => nil, + 'color' => '#FF0000', + 'total' => 20, + 'considered' => 2, + 'invited' => 3, + 'confirmed' => 4, + 'declined' => 5, + 'tentative' => 6 + }, + { + 'id' => child.id, + 'name' => 'Family', + 'icon' => 'icon-2', + 'parent_id' => parent.id, + 'color' => '#00FF00', + 'total' => 45, + 'considered' => 7, + 'invited' => 8, + 'confirmed' => 9, + 'declined' => 10, + 'tentative' => 11 + } + ) + end + end + end + end + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index c10abff..faf5740 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -1,9 +1,13 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + # This file is copied to spec/ when you run 'rails generate rspec:install' require 'spec_helper' ENV['RAILS_ENV'] ||= 'test' require_relative '../config/environment' # Prevent database truncation if the environment is production -abort("The Rails environment is running in production mode!") if Rails.env.production? +abort('The Rails environment is running in production mode!') if Rails.env.production? require 'rspec/rails' # Add additional requires below this line. Rails is not loaded until this point! @@ -64,3 +68,10 @@ RSpec.configure do |config| # config.filter_gems_from_backtrace("gem name") config.include FactoryBot::Syntax::Methods end + +Shoulda::Matchers.configure do |config| + config.integrate do |with| + with.test_framework :rspec + with.library :rails + end +end diff --git a/spec/requests/affinities_spec.rb b/spec/requests/affinities_spec.rb new file mode 100644 index 0000000..9f85d33 --- /dev/null +++ b/spec/requests/affinities_spec.rb @@ -0,0 +1,78 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'affinities' do + path '/{slug}/groups/affinities/reset' do + parameter Swagger::Schema::SLUG + + post('reset affinities') do + tags 'Affinities' + description 'Reset all affinities to default values based on the distance between groups in the hierarchy.' + + response_empty200 + end + end + + path '/{slug}/groups/{group_id}/affinities' do + parameter Swagger::Schema::SLUG + parameter name: 'group_id', in: :path, type: :string, format: :uuid, description: 'group_id' + + get('list affinities') do + tags 'Affinities' + produces 'application/json' + + response(200, 'successful') do + schema type: :object, additionalProperties: { type: :integer, minimum: 0, maximum: 2 } + xit + end + end + end + + path '/{slug}/groups/{group_id}/affinities/default' do + parameter Swagger::Schema::SLUG + parameter name: 'group_id', in: :path, type: :string, format: :uuid, description: 'group_id' + + get('calculate default affinity') do + tags 'Affinities' + produces 'application/json' + + response(200, 'successful') do + schema type: :object, additionalProperties: { type: :integer, minimum: 0, maximum: 2 } + xit + end + end + end + + path '/{slug}/groups/{group_id}/affinities/bulk_update' do + parameter Swagger::Schema::SLUG + parameter name: 'group_id', in: :path, type: :string, format: :uuid, description: 'group_id' + + put('bulk update affinities') do + tags 'Affinities' + produces 'application/json' + consumes 'application/json' + parameter name: :body, in: :body, schema: { + type: :object, + required: [:affinities], + properties: { + affinities: { + type: :array, + items: { + type: :object, + required: %i[group_id affinity], + properties: { + group_id: { type: :string, format: :uuid, description: 'ID of the associated group' }, + affinity: { type: :integer, minimum: 0, maximum: 2 } + } + } + } + } + } + + response_empty200 + end + end +end diff --git a/spec/requests/captcha_spec.rb b/spec/requests/captcha_spec.rb new file mode 100644 index 0000000..e3f49cf --- /dev/null +++ b/spec/requests/captcha_spec.rb @@ -0,0 +1,25 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'captcha' do + path '/captcha' do + post('create a CAPTCHA challenge') do + tags 'CAPTCHA' + consumes 'application/json' + produces 'application/json' + + response(201, 'created') do + schema type: :object, + required: %i[id], + properties: { + id: { type: :string, format: :uuid }, + media_url: { type: :string, format: :uri } + } + xit + end + end + end +end diff --git a/spec/requests/expenses_spec.rb b/spec/requests/expenses_spec.rb new file mode 100644 index 0000000..1f064d6 --- /dev/null +++ b/spec/requests/expenses_spec.rb @@ -0,0 +1,81 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'expenses' do + path '/{slug}/expenses' do + get('list expenses') do + tags 'Expenses' + produces 'application/json' + parameter Swagger::Schema::SLUG + + response(200, 'successful') do + schema type: :array, + items: { + type: :object, + required: %i[id name amount pricing_type], + properties: { + id: { type: :string, format: :uuid }, + **Swagger::Schema::EXPENSE + } + } + + xit + end + regular_api_responses + end + + post 'create expense' do + tags 'Expenses' + consumes 'application/json' + produces 'application/json' + parameter Swagger::Schema::SLUG + parameter name: :body, in: :body, schema: { + type: :object, + required: %i[expense], + properties: { + expense: { + type: :object, + required: %i[name amount pricing_type], + properties: Swagger::Schema::EXPENSE + } + } + } + + response_empty201 + response422 + regular_api_responses + end + end + + path '/{slug}/expenses/{id}' do + patch('update expense') do + tags 'Expenses' + consumes 'application/json' + produces 'application/json' + parameter Swagger::Schema::SLUG + parameter name: 'id', in: :path, type: :string, format: :uuid, description: 'id' + parameter name: :body, in: :body, schema: { + type: :object, + properties: Swagger::Schema::EXPENSE + } + + response_empty200 + response422 + response404 + regular_api_responses + end + + delete('delete expense') do + tags 'Expenses' + produces 'application/json' + parameter Swagger::Schema::SLUG + parameter Swagger::Schema::ID + response_empty200 + response404 + regular_api_responses + end + end +end diff --git a/spec/requests/groups_spec.rb b/spec/requests/groups_spec.rb new file mode 100644 index 0000000..2e9d5ac --- /dev/null +++ b/spec/requests/groups_spec.rb @@ -0,0 +1,110 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'groups' do + path '/{slug}/groups' do + get('list groups') do + tags 'Groups' + produces 'application/json' + parameter Swagger::Schema::SLUG + response(200, 'successful') do + schema type: :array, + items: { + type: :object, + required: %i[id name icon parent_id color attendance], + properties: { + id: { type: :string, format: :uuid, required: true }, + name: { type: :string }, + icon: { type: :string, example: 'pi pi-crown', description: 'The CSS classes used by the icon' }, + parent_id: { type: :string, format: :uuid }, + color: { type: :string, pattern: '^#(?:[0-9a-fA-F]{3}){1,2}$' }, + attendance: { + type: :object, + required: %i[total considered invited confirmed declined tentative], + properties: { + total: { type: :integer, minimum: 0, description: 'Total number of guests in any status' }, + considered: { type: :integer, minimum: 0 }, + invited: { type: :integer, minimum: 0 }, + confirmed: { type: :integer, minimum: 0 }, + declined: { type: :integer, minimum: 0 }, + tentative: { type: :integer, minimum: 0 } + } + } + } + } + xit + end + regular_api_responses + end + + post('create group') do + tags 'Groups' + consumes 'application/json' + produces 'application/json' + parameter Swagger::Schema::SLUG + parameter name: :body, in: :body, schema: { + type: :object, + required: %i[group], + properties: { + group: { + type: :object, + required: %i[name], + properties: Swagger::Schema::GROUP + } + } + } + response(201, 'created') do + schema type: :object, properties: { + id: { type: :string, format: :uuid, required: true }, + **Swagger::Schema::GROUP + } + + xit + end + regular_api_responses + end + + path '/{slug}/groups/{id}' do + put('update group') do + tags 'Groups' + consumes 'application/json' + produces 'application/json' + parameter Swagger::Schema::SLUG + parameter name: :id, in: :path, type: :string, format: :uuid + parameter name: :body, in: :body, schema: { + type: :object, + required: %i[group], + properties: { + group: { + type: :object, + required: %i[name], + properties: Swagger::Schema::GROUP + } + } + } + response(200, 'updated') do + schema type: :object, properties: { + id: { type: :string, format: :uuid, required: true }, + **Swagger::Schema::GROUP + } + + xit + end + regular_api_responses + end + + delete('delete group') do + tags 'Groups' + produces 'application/json' + parameter Swagger::Schema::SLUG + parameter name: :id, in: :path, type: :string, format: :uuid + + response_empty200 + regular_api_responses + end + end + end +end diff --git a/spec/requests/guests_spec.rb b/spec/requests/guests_spec.rb new file mode 100644 index 0000000..0ecd72f --- /dev/null +++ b/spec/requests/guests_spec.rb @@ -0,0 +1,102 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'guests' do + path '/{slug}/guests' do + get('list guests') do + tags 'Guests' + produces 'application/json' + parameter Swagger::Schema::SLUG + response(200, 'successful') do + schema type: :array, + items: { + type: :object, + required: %i[id name status group], + properties: { + id: { type: :string, format: :uuid }, + name: { type: :string }, + status: { type: :string, enum: Guest.statuses.keys }, + group: { type: :object, + required: %i[id name], + properties: { + id: { type: :string, format: :uuid }, + name: { type: :string } + } } + } + } + xit + end + regular_api_responses + end + + post('create guest') do + tags 'Guests' + consumes 'application/json' + produces 'application/json' + parameter Swagger::Schema::SLUG + parameter name: :body, in: :body, schema: { + type: :object, + required: %i[guest], + properties: { + guest: { + type: :object, + required: %i[name status], + properties: { + name: { type: :string }, + group_id: { type: :string, format: :uuid }, + status: { type: :string, enum: Guest.statuses.keys } + } + } + } + } + + response_empty201 + response422 + regular_api_responses + end + end + + path '/{slug}/guests/{id}' do + patch('update guest') do + tags 'Guests' + consumes 'application/json' + produces 'application/json' + parameter Swagger::Schema::SLUG + parameter name: 'id', in: :path, type: :string, format: :uuid + parameter name: :body, in: :body, schema: { + type: :object, + required: %i[guest], + properties: { + guest: { + type: :object, + required: %i[name status], + properties: { + name: { type: :string }, + group_id: { type: :string, format: :uuid }, + status: { type: :string, enum: Guest.statuses.keys } + } + } + } + } + + response_empty200 + response422 + response404 + regular_api_responses + end + + delete('delete guest') do + tags 'Guests' + produces 'application/json' + parameter Swagger::Schema::SLUG + parameter name: 'id', in: :path, type: :string, format: :uuid + + response_empty200 + response404 + regular_api_responses + end + end +end diff --git a/spec/requests/schemas.rb b/spec/requests/schemas.rb new file mode 100644 index 0000000..549834e --- /dev/null +++ b/spec/requests/schemas.rb @@ -0,0 +1,54 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +module Swagger + module Schema + USER = { + id: { type: :string, format: :uuid }, + email: { type: :string, format: :email }, + created_at: SwaggerResponseHelper::TIMESTAMP, + updated_at: SwaggerResponseHelper::TIMESTAMP + }.freeze + + ID = { # rubocop:disable Style/MutableConstant -- rswag modifies in: :path parameters + name: 'id', + in: :path, + type: :string, + format: :uuid + } + + GROUP = { + name: { type: :string }, + icon: { type: :string, example: 'pi pi-crown', description: 'The CSS classes used by the icon' }, + parent_id: { type: :string, format: :uuid }, + color: { type: :string, pattern: '^#(?:[0-9a-fA-F]{3}){1,2}$' } + }.freeze + + EXPENSE = { + name: { type: :string }, + amount: { type: :number, minimum: 0 }, + pricing_type: { type: :string, enum: Expense.pricing_types.keys } + }.freeze + + SLUG = { # rubocop:disable Style/MutableConstant -- rswag modifies in: :path parameters + name: 'slug', + in: :path, + type: :string, + pattern: Wedding::SLUG_REGEX, + example: :default, + description: 'Wedding slug' + } + + CAPTCHA = { + captcha: { + type: :object, + required: %i[id answer], + properties: { + id: { type: :string, format: :uuid }, + answer: { type: :string } + } + } + }.freeze + end +end diff --git a/spec/requests/summary_spec.rb b/spec/requests/summary_spec.rb new file mode 100644 index 0000000..d6a1f85 --- /dev/null +++ b/spec/requests/summary_spec.rb @@ -0,0 +1,63 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'summary' do + path '/{slug}/summary' do + get('list summaries') do + tags 'Summary' + produces 'application/json' + consumes 'application/json' + parameter Swagger::Schema::SLUG + response(200, 'successful') do + schema type: :object, + required: %i[expenses guests], + properties: { + expenses: { + type: :object, + required: %i[projected confirmed status], + properties: { + projected: { + type: :object, + required: %i[total guests], + properties: { + total: { type: :number }, + guests: { type: :number } + } + }, + confirmed: { + type: :object, + required: %i[total guests], + properties: { + total: { type: :number }, + guests: { type: :number } + } + }, + status: { + type: :object, + required: [:paid], + properties: { + paid: { type: :number } + } + } + } + }, + guests: { + type: :object, + required: %i[total confirmed declined tentative invited], + properties: { + total: { type: :number }, + confirmed: { type: :number }, + declined: { type: :number }, + tentative: { type: :number }, + invited: { type: :number } + } + } + } + xit + end + end + end +end diff --git a/spec/requests/tables_arrangements_spec.rb b/spec/requests/tables_arrangements_spec.rb new file mode 100644 index 0000000..3cc11c1 --- /dev/null +++ b/spec/requests/tables_arrangements_spec.rb @@ -0,0 +1,99 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'tables_arrangements' do + path '/{slug}/tables_arrangements' do + get('list tables arrangements') do + tags 'Tables Arrangements' + produces 'application/json' + parameter Swagger::Schema::SLUG + response(200, 'successful') do + schema type: :array, + items: { + type: :object, + required: %i[id name discomfort], + properties: { + id: { type: :string, format: :uuid }, + name: { type: :string }, + discomfort: { type: :integer }, + valid: { type: :boolean } + } + } + xit + end + regular_api_responses + end + + post('create tables arrangement') do + tags 'Tables Arrangements' + produces 'application/json' + parameter Swagger::Schema::SLUG + response(201, 'successful') do + schema type: :object, + required: [], + properties: {} + xit + end + regular_api_responses + end + end + + path '/{slug}/tables_arrangements/{id}' do + get('show tables arrangement') do + tags 'Tables Arrangements' + produces 'application/json' + parameter Swagger::Schema::SLUG + parameter Swagger::Schema::ID + response(200, 'successful') do + schema type: :object, + required: %i[id tables], + properties: { + id: { type: :string, format: :uuid }, + tables: { + + type: :array, + items: { + type: :object, + required: %i[number guests discomfort], + properties: { + number: { type: :integer }, + guests: { + type: :array, + items: { + type: :object, + required: %i[id name color], + properties: { + id: { type: :string, format: :uuid }, + name: { type: :string }, + color: { type: :string } + } + } + }, + discomfort: { + type: :object, + required: %i[discomfort breakdown], + properties: { + discomfort: { type: :number }, + breakdown: { + type: :object, + required: %i[table_size_penalty cohesion_penalty], + properties: { + table_size_penalty: { type: :number }, + cohesion_penalty: { type: :number } + } + } + } + } + } + } + } + } + xit + end + regular_api_responses + end + end +end diff --git a/spec/requests/tokens_spec.rb b/spec/requests/tokens_spec.rb new file mode 100644 index 0000000..a6aac2e --- /dev/null +++ b/spec/requests/tokens_spec.rb @@ -0,0 +1,5 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +require 'swagger_helper' diff --git a/spec/requests/users/confirmations_spec.rb b/spec/requests/users/confirmations_spec.rb new file mode 100644 index 0000000..492f6f3 --- /dev/null +++ b/spec/requests/users/confirmations_spec.rb @@ -0,0 +1,24 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'users/confirmations' do + path '/{slug}/users/confirmation' do + get('confirm user email') do + tags 'Users' + produces 'application/json' + + parameter Swagger::Schema::SLUG + parameter name: :confirmation_token, in: :query, type: :string, required: true + + response(200, 'confirmed') do + schema Swagger::Schema::USER + xit + end + + response422 + end + end +end diff --git a/spec/requests/users/registrations_spec.rb b/spec/requests/users/registrations_spec.rb new file mode 100644 index 0000000..e55d4c8 --- /dev/null +++ b/spec/requests/users/registrations_spec.rb @@ -0,0 +1,38 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'users/registrations' do + path '/{slug}/users' do + post('create registration') do + tags 'Users Registrations' + consumes 'application/json' + produces 'application/json' + + parameter Swagger::Schema::SLUG + parameter name: :body, in: :body, schema: { + type: :object, + required: %i[user wedding], + properties: { + user: { + type: :object, + required: %i[email password password_confirmation], + properties: { + email: { type: :string, format: :email }, + password: SwaggerResponseHelper::PASSWORD, + password_confirmation: SwaggerResponseHelper::PASSWORD + } + }, + **Swagger::Schema::CAPTCHA + } + } + + response(201, 'created') do + schema type: :object, properties: Swagger::Schema::USER + xit + end + end + end +end diff --git a/spec/requests/users/sessions_spec.rb b/spec/requests/users/sessions_spec.rb new file mode 100644 index 0000000..60b49e1 --- /dev/null +++ b/spec/requests/users/sessions_spec.rb @@ -0,0 +1,50 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'users/sessions' do + path '/{slug}/users/sign_in' do + post('create session') do + tags 'Users Sessions' + consumes 'application/json' + produces 'application/json' + + parameter Swagger::Schema::SLUG + parameter name: :body, in: :body, schema: { + type: :object, + required: %i[user], + properties: { + user: { + type: :object, + required: %i[email password], + properties: { + email: { type: :string, format: :email }, + password: SwaggerResponseHelper::PASSWORD + } + } + } + } + + response(201, 'created') do + schema type: :object, properties: Swagger::Schema::USER + xit + end + + response401(message: 'Invalid Email or password.') + end + end + + path '/{slug}/users/sign_out' do + parameter Swagger::Schema::SLUG + delete('delete session') do + tags 'Users Sessions' + consumes 'application/json' + produces 'application/json' + response(204, 'Session destroyed') do + xit + end + end + end +end diff --git a/spec/services/tables/discomfort_calculator_spec.rb b/spec/services/tables/discomfort_calculator_spec.rb index 3764e7e..4524a1c 100644 --- a/spec/services/tables/discomfort_calculator_spec.rb +++ b/spec/services/tables/discomfort_calculator_spec.rb @@ -1,157 +1,76 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + require 'rails_helper' module Tables RSpec.describe DiscomfortCalculator do - let(:calculator) { described_class.new(table) } + let(:calculator) { described_class.new(table:) } - describe '#cohesion_penalty' do + let(:family) { create(:group, name: 'family') } + let(:friends) { create(:group, name: 'friends') } + let(:work) { create(:group, name: 'work') } + let(:school) { create(:group, name: 'school') } + + describe '#calculate' do before do - # Overridden in each test except trivial cases - allow(AffinityGroupsHierarchy.instance).to receive(:distance).and_call_original - - %w[family friends work school].each do |group| - allow(AffinityGroupsHierarchy.instance).to receive(:distance).with(group, group).and_return(0) - end - - allow(AffinityGroupsHierarchy.instance).to receive(:distance).with('family', 'friends').and_return(nil) - allow(AffinityGroupsHierarchy.instance).to receive(:distance).with('friends', 'work').and_return(1) - allow(AffinityGroupsHierarchy.instance).to receive(:distance).with('family', 'work').and_return(2) - allow(AffinityGroupsHierarchy.instance).to receive(:distance).with('family', 'school').and_return(3) - allow(AffinityGroupsHierarchy.instance).to receive(:distance).with('friends', 'school').and_return(4) - allow(AffinityGroupsHierarchy.instance).to receive(:distance).with('work', 'school').and_return(5) - end - context 'when the table contains just two guests' do - context 'when they belong to the same group' do - let(:table) { create_list(:guest, 2, affinity_group_list: ['family']) } - - it { expect(calculator.send(:cohesion_penalty)).to eq(0) } - end - - context 'when they belong to completely unrelated groups' do - let(:table) do - [ - create(:guest, affinity_group_list: ['family']), - create(:guest, affinity_group_list: ['friends']) - ] - end - it { expect(calculator.send(:cohesion_penalty)).to eq(1) } - end - - context 'when they belong to groups at a distance of 1' do - let(:table) do - [ - create(:guest, affinity_group_list: ['friends']), - create(:guest, affinity_group_list: ['work']) - ] - end - - it { expect(calculator.send(:cohesion_penalty)).to eq(0.5) } - end - - context 'when they belong to groups at a distance of 2' do - let(:table) do - [ - create(:guest, affinity_group_list: ['family']), - create(:guest, affinity_group_list: ['work']) - ] - end - - it { expect(calculator.send(:cohesion_penalty)).to eq(Rational(2, 3)) } - end - - context 'when they belong to groups at a distance of 3' do - let(:table) do - [ - create(:guest, affinity_group_list: ['family']), - create(:guest, affinity_group_list: ['school']) - ] - end - - it { expect(calculator.send(:cohesion_penalty)).to eq(Rational(3, 4)) } - end + allow(calculator).to receive_messages(table_size_penalty: 2, cohesion_discomfort: 3) end - context 'when the table contains three guests' do - let(:table) do - [ - create(:guest, affinity_group_list: ['family']), - create(:guest, affinity_group_list: ['friends']), - create(:guest, affinity_group_list: ['work']) - ] - end + let(:table) { Table.new(create_list(:guest, 6)) } - it 'returns the sum of the penalties for each pair of guests' do - expect(calculator.send(:cohesion_penalty)).to eq(1 + Rational(1, 2) + Rational(2, 3)) - end + it 'returns the sum of the table size penalty and the average cohesion penalty', :aggregate_failures do + expect(calculator.calculate).to eq(7) + expect(calculator.breakdown).to eq(table_size_penalty: 2, cohesion_penalty: 5) + end + end + + describe '#table_size_penalty' do + before do + table.min_per_table = 5 + table.max_per_table = 7 end - context 'when the table contains four guests of different groups' do - let(:table) do - [ - create(:guest, affinity_group_list: ['family']), - create(:guest, affinity_group_list: ['friends']), - create(:guest, affinity_group_list: ['work']), - create(:guest, affinity_group_list: ['school']) - ] - end + context 'when the number of guests is in the lower bound' do + let(:table) { Table.new(create_list(:guest, 5)) } - it 'returns the sum of the penalties for each pair of guests' do - expect(calculator.send(:cohesion_penalty)) - .to eq(1 + Rational(1, 2) + Rational(2, 3) + Rational(3, 4) + Rational(4, 5) + Rational(5, 6)) - end + it { expect(calculator.send(:table_size_penalty)).to eq(0) } end - context 'when the table contains four guests of two evenly split groups' do - let(:table) do - [ - create_list(:guest, 2, affinity_group_list: ['family']), - create_list(:guest, 2, affinity_group_list: ['friends']) - ].flatten - end + context 'when the number of guests is within the table size limits' do + let(:table) { Table.new(create_list(:guest, 6)) } - it 'returns the sum of the penalties for each pair of guests' do - expect(calculator.send(:cohesion_penalty)).to eq(4) - end + it { expect(calculator.send(:table_size_penalty)).to eq(0) } end - context 'when the table contains six guests of two unevenly split groups' do - let(:table) do - [ - create_list(:guest, 2, affinity_group_list: ['family']), - create_list(:guest, 4, affinity_group_list: ['friends']) - ].flatten - end + context 'when the number of guests is in the upper bound' do + let(:table) { Table.new(create_list(:guest, 7)) } - it 'returns the sum of the penalties for each pair of guests' do - expect(calculator.send(:cohesion_penalty)).to eq(8) - end + it { expect(calculator.send(:table_size_penalty)).to eq(0) } end - context 'when the table contains six guests of three evenly split groups' do - let(:table) do - [ - create_list(:guest, 2, affinity_group_list: ['family']), - create_list(:guest, 2, affinity_group_list: ['friends']), - create_list(:guest, 2, affinity_group_list: ['work']) - ].flatten - end + context 'when the number of guests is one unit below the lower bound' do + let(:table) { Table.new(create_list(:guest, 4)) } - it 'returns the sum of the penalties for each pair of guests' do - expect(calculator.send(:cohesion_penalty)).to eq(4 * 1 + 4 * Rational(1, 2) + 4 * Rational(2, 3)) - end + it { expect(calculator.send(:table_size_penalty)).to eq(5) } end - context 'when the table contains six guests of three unevenly split groups' do - let(:table) do - [ - create_list(:guest, 3, affinity_group_list: ['family']), - create_list(:guest, 2, affinity_group_list: ['friends']), - create_list(:guest, 1, affinity_group_list: ['work']) - ].flatten - end + context 'when the number of guests is two units below the lower bound' do + let(:table) { Table.new(create_list(:guest, 3)) } - it 'returns the sum of the penalties for each pair of guests' do - expect(calculator.send(:cohesion_penalty)).to eq(6 * 1 + 2 * Rational(1, 2) + 3 * Rational(2, 3)) - end + it { expect(calculator.send(:table_size_penalty)).to eq(10) } + end + + context 'when the number of guests is one unit above the upper bound' do + let(:table) { Table.new(create_list(:guest, 8)) } + + it { expect(calculator.send(:table_size_penalty)).to eq(5) } + end + + context 'when the number of guests is two units above the upper bound' do + let(:table) { Table.new(create_list(:guest, 9)) } + + it { expect(calculator.send(:table_size_penalty)).to eq(10) } end end end diff --git a/spec/services/tables/distribution_spec.rb b/spec/services/tables/distribution_spec.rb new file mode 100644 index 0000000..e598a52 --- /dev/null +++ b/spec/services/tables/distribution_spec.rb @@ -0,0 +1,27 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +require 'rails_helper' + +module Tables + RSpec.describe Distribution do + describe '#random_distribution' do + subject(:distribution) { described_class.new(min_per_table: 5, max_per_table: 10) } + + context 'when there are fewer people than the minimum per table' do + it 'creates one table' do + distribution.random_distribution([1, 2, 3, 4]) + expect(distribution.tables.count).to eq(1) + end + end + + context 'when there are more people than the maximum per table' do + it 'creates multiple tables' do + distribution.random_distribution([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]) + expect(distribution.tables.count).to be > 1 + end + end + end + end +end diff --git a/spec/services/tables/shift_spec.rb b/spec/services/tables/shift_spec.rb new file mode 100644 index 0000000..39e07f8 --- /dev/null +++ b/spec/services/tables/shift_spec.rb @@ -0,0 +1,57 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +require 'rails_helper' + +module Tables + RSpec.describe Shift do + describe '#each' do + let(:shifts) do + acc = [] + described_class.new(initial_solution).each do |solution| # rubocop:disable Style/MapIntoArray -- #map is not implemented + acc << solution.tables.map(&:dup) + end + acc + end + + context 'when there are two tables with two people each' do + let(:initial_solution) do + Distribution.new(min_per_table: 2, max_per_table: 2).tap do |distribution| + distribution.tables << Set[:a, :b].to_table + distribution.tables << Set[:c, :d].to_table + end + end + + it 'yields all possible shifts between the tables' do + expect(shifts).to contain_exactly( + [Set[:b], Set[:c, :d, :a]], + [Set[:a], Set[:c, :d, :b]], + [Set[:b, :a, :d], Set[:c]], + [Set[:b, :a, :c], Set[:d]] + ) + end + end + + context 'when there are two tables with three people each' do + let(:initial_solution) do + Distribution.new(min_per_table: 3, max_per_table: 3).tap do |distribution| + distribution.tables << Set[:a, :b, :c].to_table + distribution.tables << Set[:d, :e, :f].to_table + end + end + + it 'yields all possible shifts between the tables' do + expect(shifts).to contain_exactly( + [Set[:b, :c], Set[:d, :e, :f, :a]], + [Set[:c, :a], Set[:d, :e, :f, :b]], + [Set[:a, :b], Set[:d, :e, :f, :c]], + [Set[:a, :b, :c, :d], Set[:e, :f]], + [Set[:a, :b, :c, :e], Set[:d, :f]], + [Set[:a, :b, :c, :f], Set[:d, :e]] + ) + end + end + end + end +end diff --git a/spec/services/tables/swap_spec.rb b/spec/services/tables/swap_spec.rb index fff181f..d8db4a7 100644 --- a/spec/services/tables/swap_spec.rb +++ b/spec/services/tables/swap_spec.rb @@ -1,3 +1,7 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + require 'rails_helper' module Tables @@ -5,7 +9,7 @@ module Tables describe '#each' do let(:swaps) do acc = [] - described_class.new(initial_solution).each do |solution| + described_class.new(initial_solution).each do |solution| # rubocop:disable Style/MapIntoArray -- #map is not implemented acc << solution.tables.map(&:dup) end acc @@ -14,17 +18,17 @@ module Tables context 'when there are two tables with two people each' do let(:initial_solution) do Distribution.new(min_per_table: 2, max_per_table: 2).tap do |distribution| - distribution.tables << %i[a b].to_table - distribution.tables << %i[c d].to_table + distribution.tables << Set[:a, :b].to_table + distribution.tables << Set[:c, :d].to_table end end it 'yields all possible swaps between the tables' do expect(swaps).to contain_exactly( - [%i[a d], %i[c b]], - [%i[b c], %i[d a]], - [%i[a c], %i[d b]], - [%i[b d], %i[c a]] + [Set[:a, :d], Set[:c, :b]], + [Set[:b, :c], Set[:d, :a]], + [Set[:a, :c], Set[:d, :b]], + [Set[:b, :d], Set[:c, :a]] ) end end @@ -32,22 +36,22 @@ module Tables context 'when there are two tables with three people each' do let(:initial_solution) do Distribution.new(min_per_table: 3, max_per_table: 3).tap do |distribution| - distribution.tables << %i[a b c].to_table - distribution.tables << %i[d e f].to_table + distribution.tables << Set[:a, :b, :c].to_table + distribution.tables << Set[:d, :e, :f].to_table end end it 'yields all possible swaps between the tables' do expect(swaps).to contain_exactly( - [%i[b c d], %i[e f a]], - [%i[b c e], %i[f d a]], - [%i[b c f], %i[d e a]], - [%i[c a d], %i[e f b]], - [%i[c a e], %i[f d b]], - [%i[c a f], %i[d e b]], - [%i[a b d], %i[e f c]], - [%i[a b e], %i[f d c]], - [%i[a b f], %i[d e c]] + [Set[:b, :c, :d], Set[:e, :f, :a]], + [Set[:b, :c, :e], Set[:f, :d, :a]], + [Set[:b, :c, :f], Set[:d, :e, :a]], + [Set[:c, :a, :d], Set[:e, :f, :b]], + [Set[:c, :a, :e], Set[:f, :d, :b]], + [Set[:c, :a, :f], Set[:d, :e, :b]], + [Set[:a, :b, :d], Set[:e, :f, :c]], + [Set[:a, :b, :e], Set[:f, :d, :c]], + [Set[:a, :b, :f], Set[:d, :e, :c]] ) end end @@ -55,26 +59,26 @@ module Tables context 'when there are three tables with two people each' do let(:initial_solution) do Distribution.new(min_per_table: 2, max_per_table: 2).tap do |distribution| - distribution.tables << %i[a b].to_table - distribution.tables << %i[c d].to_table - distribution.tables << %i[e f].to_table + distribution.tables << Set[:a, :b].to_table + distribution.tables << Set[:c, :d].to_table + distribution.tables << Set[:e, :f].to_table end end it 'yields all possible swaps between the tables' do expect(swaps).to contain_exactly( - [%i[b c], %i[d a], %i[e f]], - [%i[b d], %i[c a], %i[e f]], - [%i[a c], %i[d b], %i[e f]], - [%i[a d], %i[c b], %i[e f]], - [%i[b e], %i[c d], %i[f a]], - [%i[b f], %i[c d], %i[e a]], - [%i[a e], %i[c d], %i[f b]], - [%i[a f], %i[c d], %i[e b]], - [%i[a b], %i[d e], %i[f c]], - [%i[a b], %i[d f], %i[e c]], - [%i[a b], %i[c e], %i[f d]], - [%i[a b], %i[c f], %i[e d]] + [Set[:b, :c], Set[:d, :a], Set[:e, :f]], + [Set[:b, :d], Set[:c, :a], Set[:e, :f]], + [Set[:a, :c], Set[:d, :b], Set[:e, :f]], + [Set[:a, :d], Set[:c, :b], Set[:e, :f]], + [Set[:b, :e], Set[:c, :d], Set[:f, :a]], + [Set[:b, :f], Set[:c, :d], Set[:e, :a]], + [Set[:a, :e], Set[:c, :d], Set[:f, :b]], + [Set[:a, :f], Set[:c, :d], Set[:e, :b]], + [Set[:a, :b], Set[:d, :e], Set[:f, :c]], + [Set[:a, :b], Set[:d, :f], Set[:e, :c]], + [Set[:a, :b], Set[:c, :e], Set[:f, :d]], + [Set[:a, :b], Set[:c, :f], Set[:e, :d]] ) end end diff --git a/spec/services/vns/engine_spec.rb b/spec/services/vns/engine_spec.rb new file mode 100644 index 0000000..73d7b82 --- /dev/null +++ b/spec/services/vns/engine_spec.rb @@ -0,0 +1,17 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +require 'rails_helper' + +module VNS + RSpec.describe Engine do + describe '.sequence' do + it { expect(described_class.sequence([])).to eq([]) } + it { expect(described_class.sequence([1])).to eq([1]) } + it { expect(described_class.sequence([1, 2])).to eq([1, 2, 1]) } + it { expect(described_class.sequence([1, 2, 3])).to eq([1, 2, 3, 2, 1]) } + it { expect(described_class.sequence([1, 2, 3, 4])).to eq([1, 2, 3, 4, 3, 2, 1]) } + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 327b58e..d405628 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,3 +1,7 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + # This file was generated by the `rails generate rspec:install` command. Conventionally, all # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. # The generated `.rspec` file contains `--require spec_helper` which will cause @@ -44,51 +48,49 @@ RSpec.configure do |config| # triggering implicit auto-inclusion in groups with matching metadata. config.shared_context_metadata_behavior = :apply_to_host_groups -# The settings below are suggested to provide a good initial experience -# with RSpec, but feel free to customize to your heart's content. -=begin - # This allows you to limit a spec run to individual examples or groups - # you care about by tagging them with `:focus` metadata. When nothing - # is tagged with `:focus`, all examples get run. RSpec also provides - # aliases for `it`, `describe`, and `context` that include `:focus` - # metadata: `fit`, `fdescribe` and `fcontext`, respectively. - config.filter_run_when_matching :focus - - # Allows RSpec to persist some state between runs in order to support - # the `--only-failures` and `--next-failure` CLI options. We recommend - # you configure your source control system to ignore this file. - config.example_status_persistence_file_path = "spec/examples.txt" - - # Limits the available syntax to the non-monkey patched syntax that is - # recommended. For more details, see: - # https://rspec.info/features/3-12/rspec-core/configuration/zero-monkey-patching-mode/ - config.disable_monkey_patching! - - # Many RSpec users commonly either run the entire suite or an individual - # file, and it's useful to allow more verbose output when running an - # individual spec file. - if config.files_to_run.one? - # Use the documentation formatter for detailed output, - # unless a formatter has already been configured - # (e.g. via a command-line flag). - config.default_formatter = "doc" - end - - # Print the 10 slowest examples and example groups at the - # end of the spec run, to help surface which specs are running - # particularly slow. - config.profile_examples = 10 - - # Run specs in random order to surface order dependencies. If you find an - # order dependency and want to debug it, you can fix the order by providing - # the seed, which is printed after each run. - # --seed 1234 - config.order = :random - - # Seed global randomization in this process using the `--seed` CLI option. - # Setting this allows you to use `--seed` to deterministically reproduce - # test failures related to randomization by passing the same `--seed` value - # as the one that triggered the failure. - Kernel.srand config.seed -=end + # The settings below are suggested to provide a good initial experience + # with RSpec, but feel free to customize to your heart's content. + # # This allows you to limit a spec run to individual examples or groups + # # you care about by tagging them with `:focus` metadata. When nothing + # # is tagged with `:focus`, all examples get run. RSpec also provides + # # aliases for `it`, `describe`, and `context` that include `:focus` + # # metadata: `fit`, `fdescribe` and `fcontext`, respectively. + # config.filter_run_when_matching :focus + # + # # Allows RSpec to persist some state between runs in order to support + # # the `--only-failures` and `--next-failure` CLI options. We recommend + # # you configure your source control system to ignore this file. + # config.example_status_persistence_file_path = "spec/examples.txt" + # + # # Limits the available syntax to the non-monkey patched syntax that is + # # recommended. For more details, see: + # # https://rspec.info/features/3-12/rspec-core/configuration/zero-monkey-patching-mode/ + # config.disable_monkey_patching! + # + # # Many RSpec users commonly either run the entire suite or an individual + # # file, and it's useful to allow more verbose output when running an + # # individual spec file. + # if config.files_to_run.one? + # # Use the documentation formatter for detailed output, + # # unless a formatter has already been configured + # # (e.g. via a command-line flag). + # config.default_formatter = "doc" + # end + # + # # Print the 10 slowest examples and example groups at the + # # end of the spec run, to help surface which specs are running + # # particularly slow. + # config.profile_examples = 10 + # + # # Run specs in random order to surface order dependencies. If you find an + # # order dependency and want to debug it, you can fix the order by providing + # # the seed, which is printed after each run. + # # --seed 1234 + # config.order = :random + # + # # Seed global randomization in this process using the `--seed` CLI option. + # # Setting this allows you to use `--seed` to deterministically reproduce + # # test failures related to randomization by passing the same `--seed` value + # # as the one that triggered the failure. + # Kernel.srand config.seed end diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb new file mode 100644 index 0000000..5ba4cce --- /dev/null +++ b/spec/swagger_helper.rb @@ -0,0 +1,45 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +require 'rails_helper' +require_relative 'swagger_response_helper' +require_relative 'requests/schemas' + +include SwaggerResponseHelper # rubocop:disable Style/MixinUsage + +RSpec.configure do |config| + # Specify a root folder where Swagger JSON files are generated + # NOTE: If you're using the rswag-api to serve API descriptions, you'll need + # to ensure that it's configured to serve Swagger from the same folder + config.openapi_root = Rails.root.join('swagger').to_s + + # Define one or more Swagger documents and provide global metadata for each one + # When you run the 'rswag:specs:swaggerize' rake task, the complete Swagger will + # be generated at the provided relative path under openapi_root + # By default, the operations defined in spec files are added to the first + # document below. You can override this behavior by adding a openapi_spec tag to the + # the root example_group in your specs, e.g. describe '...', openapi_spec: 'v2/swagger.json' + config.openapi_specs = { + 'v1/swagger.yaml' => { + openapi: '3.0.1', + info: { + title: 'API V1', + version: 'v1' + }, + paths: {}, + servers: [ + { + url: 'http://libre-wedding-planner.app.localhost/api', + description: 'Suitable for development' + } + ] + } + } + + # Specify the format of the output Swagger file when running 'rswag:specs:swaggerize'. + # The openapi_specs configuration option has the filename including format in + # the key, this may want to be changed to avoid putting yaml in json files. + # Defaults to json. Accepts ':json' and ':yaml'. + config.openapi_format = :yaml +end diff --git a/spec/swagger_response_helper.rb b/spec/swagger_response_helper.rb new file mode 100644 index 0000000..09df9a1 --- /dev/null +++ b/spec/swagger_response_helper.rb @@ -0,0 +1,70 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +module SwaggerResponseHelper + TIMESTAMP_FORMAT = '\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z' + TIMESTAMP_EXAMPLE = Time.zone.now.iso8601(3) + + TIMESTAMP = { type: :string, pattern: TIMESTAMP_FORMAT, example: TIMESTAMP_EXAMPLE }.freeze + PASSWORD = { type: :string, minLength: User.password_length.begin, maxLength: User.password_length.end }.freeze + + def regular_api_responses + response401 + end + + def response422 + response(422, 'Validation errors in input parameters') do + produces 'application/json' + error_schema + xit + end + end + + def response_empty200 + response(200, 'Success') do + produces 'application/json' + schema type: :object + xit + end + end + + def response_empty201 + response(201, 'Created') do + produces 'application/json' + schema type: :object + xit + end + end + + def response404 + response(404, 'Record not found') do + produces 'application/json' + error_schema + xit + end + end + + def response401(message: nil) + response(401, 'Unauthorized') do + produces 'application/json' + schema type: :object, + required: %i[error], + properties: { + error: { type: :string, example: message || 'You need to sign in or sign up before continuing.' } + } + xit + end + end + + private + + def error_schema + schema type: :object, + required: %i[message errors], + properties: { + message: { type: :string }, + errors: { type: :array, items: { type: :string } } + } + end +end