diff --git a/.annotaterb.yml b/.annotaterb.yml new file mode 100644 index 0000000..5c007f0 --- /dev/null +++ b/.annotaterb.yml @@ -0,0 +1,58 @@ +--- +:position: before +:position_in_additional_file_patterns: before +:position_in_class: before +:position_in_factory: before +:position_in_fixture: before +:position_in_routes: before +:position_in_serializer: before +:position_in_test: before +:classified_sort: true +:exclude_controllers: true +:exclude_factories: true +:exclude_fixtures: false +:exclude_helpers: true +:exclude_scaffolds: true +:exclude_serializers: false +:exclude_sti_subclasses: false +:exclude_tests: true +:force: false +:format_markdown: false +:format_rdoc: false +:format_yard: false +:frozen: false +:ignore_model_sub_dir: false +:ignore_unknown_models: false +:include_version: false +:show_check_constraints: false +:show_complete_foreign_keys: false +:show_foreign_keys: true +:show_indexes: true +:simple_indexes: false +:sort: false +:timestamp: false +:trace: false +:with_comment: true +:with_column_comments: true +:with_table_comments: true +:active_admin: false +:command: +:debug: false +:hide_default_column_types: '' +:hide_limit_column_types: '' +:ignore_columns: +:ignore_routes: +:models: true +:routes: false +:skip_on_db_migrate: false +:target_action: :do_annotations +:wrapper: +:wrapper_close: +:wrapper_open: +:classes_default_to_s: [] +:additional_file_patterns: [] +:model_dir: +- app/models +:require: [] +:root_dir: +- '' diff --git a/.dockerignore b/.dockerignore index 9612375..9be59ed 100644 --- a/.dockerignore +++ b/.dockerignore @@ -35,3 +35,4 @@ /app/assets/builds/* !/app/assets/builds/.keep /public/assets +.docker-compose.yml \ No newline at end of file diff --git a/.gitea/workflows/tests.yml b/.gitea/workflows/tests.yml new file mode 100644 index 0000000..9c53589 --- /dev/null +++ b/.gitea/workflows/tests.yml @@ -0,0 +1,159 @@ +name: Run unit tests +on: + push: + branches: + - main + pull_request: +concurrency: + group: ${{ github.ref }} + cancel-in-progress: true +jobs: + unit_tests: + runs-on: ubuntu-latest + services: &services + postgres: + image: postgres + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - 5432 + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + ref: ${{ github.head_ref }} # Checkout the actual branch, not the result if merged into the base + - uses: ruby/setup-ruby@v1.207.0 + - run: bundle install + - &postgres_wait + name: Wait until Postgres is ready to accept connections + run: | + apt-get update && apt-get install -f -y postgresql-client + until pg_isready -h postgres -U postgres -d postgres + do + sleep 1 + echo "Trying again" + done + - name: Load schema and run unit tests + run: | + bundle exec rake db:schema:load + bundle exec rspec + env: + RAILS_ENV: test + DATABASE_URL: postgres://postgres:postgres@postgres:5432/postgres + - name: Get all migrations added + id: changed-migration-files + uses: tj-actions/changed-files@v45 + with: + files: | + db/migrate/**.rb + - name: Redo all migrations and check there are no schema changes + if: steps.changed-migration-files.outputs.any_changed == 'true' + env: + ALL_CHANGED_FILES: ${{ steps.changed-migration-files.outputs.all_changed_files }} + RAILS_ENV: test + DATABASE_URL: postgres://postgres:postgres@postgres:5432/postgres + run: | + echo ${#ALL_CHANGED_FILES[@]} migrations changed: + for file in ${ALL_CHANGED_FILES}; do + echo "$file" + done + + bundle exec rake db:migrate:redo STEP=${#ALL_CHANGED_FILES[@]} + git diff --exit-code db/schema.rb + - name: Clean up containers generated by this flow + if: failure() + run: docker ps --filter network=$JOB_CONTAINER_NAME-$GITHUB_JOB-network --filter name=$JOB_CONTAINER_NAME-* --format "{{.ID}}" | xargs docker rm -f + rubocop: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + - uses: ruby/setup-ruby@v1.207.0 + - run: bundle install + - run: bundle exec rubocop --force-exclusion --parallel + check-licenses: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + - uses: ruby/setup-ruby@v1.207.0 + - name: Install project dependencies + run: bundle install --jobs `getconf _NPROCESSORS_ONLN` + - name: Run license finder + run: license_finder + copyright_notice: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.ACTIONS_TOKEN }} + ref: ${{ github.head_ref }} + - uses: VinnyBabuManjaly/copyright-action@v1.0.0 + with: + CopyrightString: '# Copyright (C) 2024-2025 LibreWeddingPlanner contributors\n\n' + FileType: '.rb' + Path: 'app/, config/, db/, spec/' + IgnorePath: 'db' + - uses: VinnyBabuManjaly/copyright-action@v1.0.0 + with: + CopyrightString: '<%# Copyright (C) 2024-2025 LibreWeddingPlanner contributors %>\n\n' + FileType: '.erb' + Path: 'app/' + - name: Commit changes + run: | + git config --local user.email "bustikiller@bustikiller.com" + git config --local user.name "Manuel Bustillo" + git add . + + if [ -n "$(git status --porcelain)" ]; then + echo "there are changes"; + git commit -m "Add copyright notice" + git push + else + echo "no changes"; + fi + + build-static-assets: + runs-on: ubuntu-latest + timeout-minutes: 30 + needs: + - unit_tests + - rubocop + - check-licenses + - copyright_notice + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to the private Docker registry + uses: docker/login-action@v3 + with: + registry: ${{ secrets.PRIVATE_REGISTRY_HOST }} + username: ${{ secrets.PRIVATE_REGISTRY_USERNAME }} + password: ${{ secrets.PRIVATE_REGISTRY_TOKEN }} + + - name: Build and push intermediate stages (build) + uses: docker/build-push-action@v6 + with: + context: . + target: build + push: ${{ github.ref == 'refs/heads/main' }} + tags: ${{ secrets.PRIVATE_REGISTRY_HOST }}/${{ env.GITHUB_REPOSITORY }}:build + cache-from: type=registry,ref=${{ secrets.PRIVATE_REGISTRY_HOST }}/${{ env.GITHUB_REPOSITORY }}:build + cache-to: type=inline + + - name: Build and push (final) + uses: docker/build-push-action@v6 + with: + context: . + push: ${{ github.ref == 'refs/heads/main' }} + tags: ${{ secrets.PRIVATE_REGISTRY_HOST }}/${{ env.GITHUB_REPOSITORY }}:latest + cache-from: type=registry,ref=${{ secrets.PRIVATE_REGISTRY_HOST }}/${{ env.GITHUB_REPOSITORY }}:latest + cache-to: type=inline \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index f7a5815..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Build docker image -on: - push: - branches: - - main - pull_request: -jobs: - build-static-assets: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Build and push - uses: docker/build-push-action@v5 - with: - context: . - push: ${{ github.event_name != 'pull_request' }} - tags: bustikiller/wedding-planner:latest - cache-from: type=registry,ref=user/app:latest - cache-to: type=inline \ No newline at end of file diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml deleted file mode 100644 index e4d4fec..0000000 --- a/.github/workflows/tests.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Run unit tests -on: - push: - branches: - - main - pull_request: -jobs: - unit_tests: - runs-on: ubuntu-latest - services: - postgres: - image: postgres - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - ports: - - 5432 - steps: - - uses: actions/checkout@v4 - with: - token: ${{ secrets.GITHUB_TOKEN }} - - uses: ruby/setup-ruby@v1 - - run: bundle install - - name: Wait until Postgres is ready to accept connections - run: | - apt-get update && apt-get install -f -y postgresql-client - until pg_isready -h postgres -U postgres -d postgres - do - sleep 1 - echo "Trying again" - done - - run: | - bundle exec rake db:schema:load - bundle exec rspec - env: - RAILS_ENV: test - DATABASE_URL: postgres://postgres:postgres@postgres:5432/postgres - - name: Clean up containers generated by this flow - if: failure() - run: docker ps --filter network=$JOB_CONTAINER_NAME-$GITHUB_JOB-network --filter name=$JOB_CONTAINER_NAME-* --format "{{.ID}}" | xargs docker rm -f diff --git a/.gitignore b/.gitignore index 5fb66c9..13e3051 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,6 @@ # Ignore master key for decrypting credentials and more. /config/master.key + +# Ignore swagger generated documentation +swagger/v1/swagger.yaml diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..ca1ed81 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,29 @@ +require: + - rubocop-rails + - rubocop-factory_bot + - rubocop-rspec + - rubocop-rspec_rails +AllCops: + NewCops: enable + Exclude: + - 'db/**/*' + - 'config/**/*' + - 'script/**/*' + - 'bin/*' + - '*.yml' +Layout/LineLength: + Max: 120 +RSpec/ExampleLength: + Enabled: false +Metrics/ModuleLength: + Enabled: false +RSpec/MultipleMemoizedHelpers: + Enabled: false +Style/Documentation: + Enabled: false +Metrics/MethodLength: + Max: 20 +Rails/SkipsModelValidations: + Enabled: false +Metrics/AbcSize: + Enabled: false \ No newline at end of file diff --git a/.ruby-version b/.ruby-version index 6d5369b..408069a 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -ruby-3.3.4 +ruby-3.4.1 diff --git a/COPYING.md b/COPYING.md new file mode 100644 index 0000000..c6f01c6 --- /dev/null +++ b/COPYING.md @@ -0,0 +1,660 @@ +# GNU AFFERO GENERAL PUBLIC LICENSE + +Version 3, 19 November 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. + + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + +## Preamble + +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains +free software for all its users. + +When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + +Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + +A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + +The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + +An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing +under this license. + +The precise terms and conditions for copying, distribution and +modification follow. + +## TERMS AND CONDITIONS + +### 0. Definitions. + +"This License" refers to version 3 of the GNU Affero General Public +License. + +"Copyright" also means copyright-like laws that apply to other kinds +of works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of +an exact copy. The resulting work is called a "modified version" of +the earlier work or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based +on the Program. + +To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + +To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user +through a computer network, with no transfer of a copy, is not +conveying. + +An interactive user interface displays "Appropriate Legal Notices" to +the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + +### 1. Source Code. + +The "source code" for a work means the preferred form of the work for +making modifications to it. "Object code" means any non-source form of +a work. + +A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + +The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can +regenerate automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same +work. + +### 2. Basic Permissions. + +All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not convey, +without conditions so long as your license otherwise remains in force. +You may convey covered works to others for the sole purpose of having +them make modifications exclusively for you, or provide you with +facilities for running those works, provided that you comply with the +terms of this License in conveying all material for which you do not +control copyright. Those thus making or running the covered works for +you must do so exclusively on your behalf, under your direction and +control, on terms that prohibit them from making any copies of your +copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under the +conditions stated below. Sublicensing is not allowed; section 10 makes +it unnecessary. + +### 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + +No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such +circumvention is effected by exercising rights under this License with +respect to the covered work, and you disclaim any intention to limit +operation or modification of the work as a means of enforcing, against +the work's users, your or third parties' legal rights to forbid +circumvention of technological measures. + +### 4. Conveying Verbatim Copies. + +You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + +### 5. Conveying Modified Source Versions. + +You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these +conditions: + +- a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. +- b) The work must carry prominent notices stating that it is + released under this License and any conditions added under + section 7. This requirement modifies the requirement in section 4 + to "keep intact all notices". +- c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. +- d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + +A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + +### 6. Conveying Non-Source Forms. + +You may convey a covered work in object code form under the terms of +sections 4 and 5, provided that you also convey the machine-readable +Corresponding Source under the terms of this License, in one of these +ways: + +- a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. +- b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the Corresponding + Source from a network server at no charge. +- c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. +- d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. +- e) Convey the object code using peer-to-peer transmission, + provided you inform other peers where the object code and + Corresponding Source of the work are being offered to the general + public at no charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, +family, or household purposes, or (2) anything designed or sold for +incorporation into a dwelling. In determining whether a product is a +consumer product, doubtful cases shall be resolved in favor of +coverage. For a particular product received by a particular user, +"normally used" refers to a typical or common use of that class of +product, regardless of the status of the particular user or of the way +in which the particular user actually uses, or expects or is expected +to use, the product. A product is a consumer product regardless of +whether the product has substantial commercial, industrial or +non-consumer uses, unless such uses represent the only significant +mode of use of the product. + +"Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to +install and execute modified versions of a covered work in that User +Product from a modified version of its Corresponding Source. The +information must suffice to ensure that the continued functioning of +the modified object code is in no case prevented or interfered with +solely because modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or +updates for a work that has been modified or installed by the +recipient, or for the User Product in which it has been modified or +installed. Access to a network may be denied when the modification +itself materially and adversely affects the operation of the network +or violates the rules and protocols for communication across the +network. + +Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + +### 7. Additional Terms. + +"Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders +of that material) supplement the terms of this License with terms: + +- a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or +- b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or +- c) Prohibiting misrepresentation of the origin of that material, + or requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or +- d) Limiting the use for publicity purposes of names of licensors + or authors of the material; or +- e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or +- f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions + of it) with contractual assumptions of liability to the recipient, + for any liability that these contractual assumptions directly + impose on those licensors and authors. + +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; the +above requirements apply either way. + +### 8. Termination. + +You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + +However, if you cease all violation of this License, then your license +from a particular copyright holder is reinstated (a) provisionally, +unless and until the copyright holder explicitly and finally +terminates your license, and (b) permanently, if the copyright holder +fails to notify you of the violation by some reasonable means prior to +60 days after the cessation. + +Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + +Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + +### 9. Acceptance Not Required for Having Copies. + +You are not required to accept this License in order to receive or run +a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + +### 10. Automatic Licensing of Downstream Recipients. + +Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + +### 11. Patents. + +A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned +or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + +If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + +A patent license is "discriminatory" if it does not include within the +scope of its coverage, prohibits the exercise of, or is conditioned on +the non-exercise of one or more of the rights that are specifically +granted under this License. You may not convey a covered work if you +are a party to an arrangement with a third party that is in the +business of distributing software, under which you make payment to the +third party based on the extent of your activity of conveying the +work, and under which the third party grants, to any of the parties +who would receive the covered work from you, a discriminatory patent +license (a) in connection with copies of the covered work conveyed by +you (or copies made from those copies), or (b) primarily for and in +connection with specific products or compilations that contain the +covered work, unless you entered into that arrangement, or that patent +license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + +### 12. No Surrender of Others' Freedom. + +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under +this License and any other pertinent obligations, then as a +consequence you may not convey it at all. For example, if you agree to +terms that obligate you to collect a royalty for further conveying +from those to whom you convey the Program, the only way you could +satisfy both those terms and this License would be to refrain entirely +from conveying the Program. + +### 13. Remote Network Interaction; Use with the GNU General Public License. + +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your +version supports such interaction) an opportunity to receive the +Corresponding Source of your version by providing access to the +Corresponding Source from a network server at no charge, through some +standard or customary means of facilitating copying of software. This +Corresponding Source shall include the Corresponding Source for any +work covered by version 3 of the GNU General Public License that is +incorporated pursuant to the following paragraph. + +Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + +### 14. Revised Versions of this License. + +The Free Software Foundation may publish revised and/or new versions +of the GNU Affero General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever +published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions +of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + +Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + +### 15. Disclaimer of Warranty. + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT +WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND +PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE +DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR +CORRECTION. + +### 16. Limitation of Liability. + +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR +CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT +NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR +LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM +TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER +PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +### 17. Interpretation of Sections 15 and 16. + +If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + +END OF TERMS AND CONDITIONS + +## How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these +terms. + +To do so, attach the following notices to the program. It is safest to +attach them to the start of each source file to most effectively state +the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation, either version 3 of the + License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper +mail. + +If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for +the specific requirements. + +You should also get your employer (if you work as a programmer) or +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. For more information on this, and how to apply and follow +the GNU AGPL, see . diff --git a/Dockerfile b/Dockerfile index 80ee039..290b412 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ # syntax = docker/dockerfile:1 # Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile -ARG RUBY_VERSION=3.3.4 -FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim as base +ARG RUBY_VERSION=3.4.1 +FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim AS base # Rails app lives here WORKDIR /rails @@ -16,11 +16,11 @@ ENV RAILS_ENV="production" \ RUN apt-get update && apt-get install -y nodejs # Throw-away build stage to reduce size of final image -FROM base as build +FROM base AS build # Install packages needed to build gems RUN apt-get update -qq && \ - apt-get install --no-install-recommends -y build-essential git libpq-dev libvips pkg-config + apt-get install --no-install-recommends -y build-essential git libpq-dev libvips pkg-config libyaml-dev # Install application gems COPY Gemfile Gemfile.lock ./ diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..54e6717 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,42 @@ +# syntax = docker/dockerfile:1 + +# Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile +ARG RUBY_VERSION=3.4.1 +FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim as base + +# Rails app lives here +WORKDIR /rails + +RUN apt-get update && apt-get install -y nodejs + +FROM base as build + +# Install packages needed to build gems +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y build-essential git libpq-dev libvips pkg-config libyaml-dev + +# Install application gems +COPY Gemfile Gemfile.lock ./ +RUN bundle install + +# Copy application code +COPY . . + +# Final stage for app image +FROM base + +# Install packages needed for deployment +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y curl libvips postgresql-client && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Copy built artifacts: gems, application +COPY --from=build /usr/local/bundle /usr/local/bundle +COPY --from=build /rails /rails + +# Entrypoint prepares the database. +ENTRYPOINT ["/rails/bin/docker-entrypoint"] + +# Start the server by default, this can be overwritten at runtime +EXPOSE 3000 +CMD ["./bin/rails", "server", "--binding=0.0.0.0"] diff --git a/Gemfile b/Gemfile index 6f53d2e..adaab29 100644 --- a/Gemfile +++ b/Gemfile @@ -1,7 +1,8 @@ +# frozen_string_literal: true + source 'https://rubygems.org' -ruby '3.3.4' -gem 'acts-as-taggable-on' +ruby '3.4.1' gem 'bootsnap', require: false gem 'csv' gem 'importmap-rails' @@ -9,27 +10,44 @@ gem 'jbuilder' gem 'money' gem 'pg', '~> 1.1' gem 'puma', '>= 5.0' -gem 'rails', '~> 7.1.3', '>= 7.1.3.2' +gem 'rails', '~> 8.0.0', '>= 8.0.0' gem 'redis', '>= 4.0.1' gem 'sprockets-rails' gem 'stimulus-rails' gem 'turbo-rails' gem 'tzinfo-data', platforms: %i[windows jruby] +gem 'acts_as_tenant' +gem 'faker' +gem 'httparty' gem 'jsonapi-rails' +gem 'pluck_to_hash' gem 'rack-cors' gem 'react-rails' +gem 'rswag' gem 'rubytree' group :development, :test do + gem 'annotaterb' gem 'debug', platforms: %i[mri windows] gem 'factory_bot_rails' - gem 'faker' + gem 'license_finder' gem 'pry' - gem 'rspec-rails', '~> 6.1.0' + gem 'rspec-rails', '~> 7.1.0' + gem 'shoulda-matchers', '~> 6.0' end group :development do + gem 'letter_opener_web' gem 'rubocop' + gem 'rubocop-factory_bot', require: false + gem 'rubocop-rails', require: false + gem 'rubocop-rspec', require: false + gem 'rubocop-rspec_rails', require: false gem 'web-console' end + +gem 'chroma' +gem 'solid_queue', '~> 1.0' + +gem 'devise', '~> 4.9' diff --git a/Gemfile.lock b/Gemfile.lock index 8b9b054..7c365c5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,129 +1,151 @@ GEM remote: https://rubygems.org/ specs: - actioncable (7.1.3.4) - actionpack (= 7.1.3.4) - activesupport (= 7.1.3.4) + actioncable (8.0.1) + actionpack (= 8.0.1) + activesupport (= 8.0.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (7.1.3.4) - actionpack (= 7.1.3.4) - activejob (= 7.1.3.4) - activerecord (= 7.1.3.4) - activestorage (= 7.1.3.4) - activesupport (= 7.1.3.4) - mail (>= 2.7.1) - net-imap - net-pop - net-smtp - actionmailer (7.1.3.4) - actionpack (= 7.1.3.4) - actionview (= 7.1.3.4) - activejob (= 7.1.3.4) - activesupport (= 7.1.3.4) - mail (~> 2.5, >= 2.5.4) - net-imap - net-pop - net-smtp + actionmailbox (8.0.1) + actionpack (= 8.0.1) + activejob (= 8.0.1) + activerecord (= 8.0.1) + activestorage (= 8.0.1) + activesupport (= 8.0.1) + mail (>= 2.8.0) + actionmailer (8.0.1) + actionpack (= 8.0.1) + actionview (= 8.0.1) + activejob (= 8.0.1) + activesupport (= 8.0.1) + mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (7.1.3.4) - actionview (= 7.1.3.4) - activesupport (= 7.1.3.4) + actionpack (8.0.1) + actionview (= 8.0.1) + activesupport (= 8.0.1) nokogiri (>= 1.8.5) - racc rack (>= 2.2.4) rack-session (>= 1.0.1) rack-test (>= 0.6.3) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - actiontext (7.1.3.4) - actionpack (= 7.1.3.4) - activerecord (= 7.1.3.4) - activestorage (= 7.1.3.4) - activesupport (= 7.1.3.4) + useragent (~> 0.16) + actiontext (8.0.1) + actionpack (= 8.0.1) + activerecord (= 8.0.1) + activestorage (= 8.0.1) + activesupport (= 8.0.1) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.1.3.4) - activesupport (= 7.1.3.4) + actionview (8.0.1) + activesupport (= 8.0.1) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (7.1.3.4) - activesupport (= 7.1.3.4) + activejob (8.0.1) + activesupport (= 8.0.1) globalid (>= 0.3.6) - activemodel (7.1.3.4) - activesupport (= 7.1.3.4) - activerecord (7.1.3.4) - activemodel (= 7.1.3.4) - activesupport (= 7.1.3.4) + activemodel (8.0.1) + activesupport (= 8.0.1) + activerecord (8.0.1) + activemodel (= 8.0.1) + activesupport (= 8.0.1) timeout (>= 0.4.0) - activestorage (7.1.3.4) - actionpack (= 7.1.3.4) - activejob (= 7.1.3.4) - activerecord (= 7.1.3.4) - activesupport (= 7.1.3.4) + activestorage (8.0.1) + actionpack (= 8.0.1) + activejob (= 8.0.1) + activerecord (= 8.0.1) + activesupport (= 8.0.1) marcel (~> 1.0) - activesupport (7.1.3.4) + activesupport (8.0.1) base64 + benchmark (>= 0.3) bigdecimal - concurrent-ruby (~> 1.0, >= 1.0.2) + concurrent-ruby (~> 1.0, >= 1.3.1) connection_pool (>= 2.2.5) drb i18n (>= 1.6, < 2) + logger (>= 1.4.2) minitest (>= 5.1) - mutex_m - tzinfo (~> 2.0) - acts-as-taggable-on (10.0.0) - activerecord (>= 6.1, < 7.2) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) + acts_as_tenant (1.0.1) + rails (>= 6.0) + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) + annotaterb (4.13.0) ast (2.4.2) babel-source (5.8.35) babel-transpiler (0.7.0) babel-source (>= 4.0, < 6) execjs (~> 2.0) base64 (0.2.0) - bigdecimal (3.1.8) + bcrypt (3.1.20) + benchmark (0.4.0) + bigdecimal (3.1.9) bindex (0.8.1) bootsnap (1.18.4) msgpack (~> 1.2) builder (3.3.0) + childprocess (5.1.0) + logger (~> 1.5) + chroma (0.2.0) coderay (1.1.3) - concurrent-ruby (1.3.4) + concurrent-ruby (1.3.5) connection_pool (2.4.1) crass (1.0.6) - csv (3.3.0) - date (3.3.4) - debug (1.9.2) + csv (3.3.2) + date (3.4.1) + debug (1.10.0) irb (~> 1.10) reline (>= 0.3.8) + devise (4.9.4) + bcrypt (~> 3.0) + orm_adapter (~> 0.1) + railties (>= 4.1.0) + responders + warden (~> 1.2.3) diff-lcs (1.5.1) drb (2.2.1) - erubi (1.13.0) + erubi (1.13.1) + et-orbi (1.2.11) + tzinfo execjs (2.9.1) factory_bot (6.4.6) activesupport (>= 5.0.0) factory_bot_rails (6.4.3) factory_bot (~> 6.4) railties (>= 5.0.0) - faker (3.4.2) + faker (3.5.1) i18n (>= 1.8.11, < 2) + fugit (1.11.1) + et-orbi (~> 1, >= 1.2.11) + raabro (~> 1.4) globalid (1.2.1) activesupport (>= 6.1) - i18n (1.14.5) + httparty (0.22.0) + csv + mini_mime (>= 1.0.0) + multi_xml (>= 0.5.2) + i18n (1.14.7) concurrent-ruby (~> 1.0) - importmap-rails (2.0.1) + importmap-rails (2.1.0) actionpack (>= 6.0.0) activesupport (>= 6.0.0) railties (>= 6.0.0) - io-console (0.7.2) - irb (1.14.0) + io-console (0.8.0) + irb (1.14.3) rdoc (>= 4.0.0) reline (>= 0.4.2) - jbuilder (2.12.0) + jbuilder (2.13.0) actionview (>= 5.0.0) activesupport (>= 5.0.0) - json (2.7.2) + json (2.9.1) + json-schema (5.0.1) + addressable (~> 2.8) jsonapi-deserializable (0.2.0) jsonapi-parser (0.1.1) jsonapi-rails (0.4.1) @@ -136,7 +158,26 @@ GEM jsonapi-serializable (0.3.1) jsonapi-renderer (~> 0.2.0) language_server-protocol (3.17.0.3) - loofah (2.22.0) + launchy (3.0.1) + addressable (~> 2.8) + childprocess (~> 5.0) + letter_opener (1.10.0) + launchy (>= 2.2, < 4) + letter_opener_web (3.0.0) + actionmailer (>= 6.1) + letter_opener (~> 1.9) + railties (>= 6.1) + rexml + license_finder (7.2.1) + bundler + csv (~> 3.2) + rubyzip (>= 1, < 3) + thor (~> 1.2) + tomlrb (>= 1.3, < 2.1) + with_env (= 1.1.0) + xml-simple (~> 1.1.9) + logger (1.6.5) + loofah (2.23.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.8.1) @@ -145,14 +186,16 @@ GEM net-pop net-smtp marcel (1.0.4) - method_source (1.0.0) + method_source (1.1.0) mini_mime (1.1.5) - minitest (5.24.1) + mini_portile2 (2.8.8) + minitest (5.25.4) money (6.19.0) i18n (>= 0.6.4, <= 2) msgpack (1.7.2) - mutex_m (0.2.0) - net-imap (0.4.14) + multi_xml (0.7.1) + bigdecimal (~> 3.1) + net-imap (0.5.2) date net-protocol net-pop (0.1.2) @@ -161,74 +204,81 @@ GEM timeout net-smtp (0.5.0) net-protocol - nio4r (2.7.3) - nokogiri (1.16.7-aarch64-linux) + nio4r (2.7.4) + nokogiri (1.18.1) + mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.16.7-arm-linux) + nokogiri (1.18.1-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.16.7-arm64-darwin) + nokogiri (1.18.1-arm-linux-gnu) racc (~> 1.4) - nokogiri (1.16.7-x86-linux) + nokogiri (1.18.1-arm64-darwin) racc (~> 1.4) - nokogiri (1.16.7-x86_64-darwin) + nokogiri (1.18.1-x86_64-darwin) racc (~> 1.4) - nokogiri (1.16.7-x86_64-linux) + nokogiri (1.18.1-x86_64-linux-gnu) racc (~> 1.4) - parallel (1.26.2) - parser (3.3.4.2) + orm_adapter (0.5.0) + parallel (1.26.3) + parser (3.3.7.0) ast (~> 2.4.1) racc - pg (1.5.7) - pry (0.14.2) + pg (1.5.9) + pluck_to_hash (1.0.2) + activerecord (>= 4.0.2) + activesupport (>= 4.0.2) + pry (0.15.2) coderay (~> 1.1) method_source (~> 1.0) - psych (5.1.2) + psych (5.2.2) + date stringio - puma (6.4.2) + public_suffix (6.0.1) + puma (6.5.0) nio4r (~> 2.0) + raabro (1.4.0) racc (1.8.1) - rack (3.1.7) + rack (3.1.8) rack-cors (2.0.2) rack (>= 2.0.0) rack-session (2.0.0) rack (>= 3.0.0) rack-test (2.1.0) rack (>= 1.3) - rackup (2.1.0) + rackup (2.2.1) rack (>= 3) - webrick (~> 1.8) - rails (7.1.3.4) - actioncable (= 7.1.3.4) - actionmailbox (= 7.1.3.4) - actionmailer (= 7.1.3.4) - actionpack (= 7.1.3.4) - actiontext (= 7.1.3.4) - actionview (= 7.1.3.4) - activejob (= 7.1.3.4) - activemodel (= 7.1.3.4) - activerecord (= 7.1.3.4) - activestorage (= 7.1.3.4) - activesupport (= 7.1.3.4) + rails (8.0.1) + actioncable (= 8.0.1) + actionmailbox (= 8.0.1) + actionmailer (= 8.0.1) + actionpack (= 8.0.1) + actiontext (= 8.0.1) + actionview (= 8.0.1) + activejob (= 8.0.1) + activemodel (= 8.0.1) + activerecord (= 8.0.1) + activestorage (= 8.0.1) + activesupport (= 8.0.1) bundler (>= 1.15.0) - railties (= 7.1.3.4) + railties (= 8.0.1) rails-dom-testing (2.2.0) activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.6.0) + rails-html-sanitizer (1.6.2) loofah (~> 2.21) - nokogiri (~> 1.14) - railties (7.1.3.4) - actionpack (= 7.1.3.4) - activesupport (= 7.1.3.4) - irb + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + railties (8.0.1) + actionpack (= 8.0.1) + activesupport (= 8.0.1) + irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) thor (~> 1.0, >= 1.2.2) zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.2.1) - rdoc (6.7.0) + rdoc (6.10.0) psych (>= 4.0.0) react-rails (3.2.1) babel-transpiler (>= 0.7.0) @@ -236,47 +286,87 @@ GEM execjs railties (>= 3.2) tilt - redis (5.2.0) + redis (5.3.0) redis-client (>= 0.22.0) redis-client (0.22.2) connection_pool - regexp_parser (2.9.2) - reline (0.5.9) + regexp_parser (2.10.0) + reline (0.6.0) io-console (~> 0.5) - rexml (3.2.8) - rspec-core (3.12.3) - rspec-support (~> 3.12.0) - rspec-expectations (3.12.4) + responders (3.1.1) + actionpack (>= 5.2) + railties (>= 5.2) + rexml (3.3.9) + rspec-core (3.13.2) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.3) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.12.0) - rspec-mocks (3.12.7) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.2) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.12.0) - rspec-rails (6.1.1) - actionpack (>= 6.1) - activesupport (>= 6.1) - railties (>= 6.1) - rspec-core (~> 3.12) - rspec-expectations (~> 3.12) - rspec-mocks (~> 3.12) - rspec-support (~> 3.12) - rspec-support (3.12.2) - rubocop (1.65.1) + rspec-support (~> 3.13.0) + rspec-rails (7.1.0) + actionpack (>= 7.0) + activesupport (>= 7.0) + railties (>= 7.0) + rspec-core (~> 3.13) + rspec-expectations (~> 3.13) + rspec-mocks (~> 3.13) + rspec-support (~> 3.13) + rspec-support (3.13.1) + rswag (2.16.0) + rswag-api (= 2.16.0) + rswag-specs (= 2.16.0) + rswag-ui (= 2.16.0) + rswag-api (2.16.0) + activesupport (>= 5.2, < 8.1) + railties (>= 5.2, < 8.1) + rswag-specs (2.16.0) + activesupport (>= 5.2, < 8.1) + json-schema (>= 2.2, < 6.0) + railties (>= 5.2, < 8.1) + rspec-core (>= 2.14) + rswag-ui (2.16.0) + actionpack (>= 5.2, < 8.1) + railties (>= 5.2, < 8.1) + rubocop (1.71.0) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 2.4, < 3.0) - rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.31.1, < 2.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.36.2, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.32.0) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.37.0) parser (>= 3.3.1.0) + rubocop-factory_bot (2.26.1) + rubocop (~> 1.61) + rubocop-rails (2.29.1) + activesupport (>= 4.2.0) + rack (>= 1.1) + rubocop (>= 1.52.0, < 2.0) + rubocop-ast (>= 1.31.1, < 2.0) + rubocop-rspec (3.4.0) + rubocop (~> 1.61) + rubocop-rspec_rails (2.30.0) + rubocop (~> 1.61) + rubocop-rspec (~> 3, >= 3.0.1) ruby-progressbar (1.13.0) - rubytree (2.0.3) - json (~> 2.0, > 2.3.1) + rubytree (2.1.1) + json (~> 2.0, > 2.9) + rubyzip (2.3.2) + securerandom (0.4.1) + shoulda-matchers (6.4.0) + activesupport (>= 5.2.0) + solid_queue (1.1.2) + activejob (>= 7.1) + activerecord (>= 7.1) + concurrent-ruby (>= 1.3.1) + fugit (~> 1.11.0) + railties (>= 7.1) + thor (~> 1.3.1) sprockets (4.2.1) concurrent-ruby (~> 1.0) rack (>= 2.2.4, < 4) @@ -284,29 +374,35 @@ GEM actionpack (>= 6.1) activesupport (>= 6.1) sprockets (>= 3.0.0) - stimulus-rails (1.3.3) + stimulus-rails (1.3.4) railties (>= 6.0.0) - stringio (3.1.1) - thor (1.3.1) + stringio (3.1.2) + thor (1.3.2) tilt (2.4.0) - timeout (0.4.1) - turbo-rails (2.0.6) + timeout (0.4.3) + tomlrb (2.0.3) + turbo-rails (2.0.11) actionpack (>= 6.0.0) - activejob (>= 6.0.0) railties (>= 6.0.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - unicode-display_width (2.5.0) + unicode-display_width (2.6.0) + uri (1.0.2) + useragent (0.16.11) + warden (1.2.9) + rack (>= 2.0.9) web-console (4.2.1) actionview (>= 6.0.0) activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) - webrick (1.8.1) websocket-driver (0.7.6) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) - zeitwerk (2.6.17) + with_env (1.1.0) + xml-simple (1.1.9) + rexml + zeitwerk (2.7.1) PLATFORMS aarch64-linux @@ -317,34 +413,206 @@ PLATFORMS x86_64-linux DEPENDENCIES - acts-as-taggable-on + acts_as_tenant + annotaterb bootsnap + chroma csv debug + devise (~> 4.9) factory_bot_rails faker + httparty importmap-rails jbuilder jsonapi-rails + letter_opener_web + license_finder money pg (~> 1.1) + pluck_to_hash pry puma (>= 5.0) rack-cors - rails (~> 7.1.3, >= 7.1.3.2) + rails (~> 8.0.0, >= 8.0.0) react-rails redis (>= 4.0.1) - rspec-rails (~> 6.1.0) + rspec-rails (~> 7.1.0) + rswag rubocop + rubocop-factory_bot + rubocop-rails + rubocop-rspec + rubocop-rspec_rails rubytree + shoulda-matchers (~> 6.0) + solid_queue (~> 1.0) sprockets-rails stimulus-rails turbo-rails tzinfo-data web-console +CHECKSUMS + actioncable (8.0.1) sha256=808bff2a4e3aba36f66f0cd65d7a1579ad52fb65e99304442c46051a79689d9b + actionmailbox (8.0.1) sha256=bbc7db779be857fb6eb5b53f313d3881cd8cda38a150c3aa25f89f2f9977b08c + actionmailer (8.0.1) sha256=7b074e9590e4ec5cebd2fc91d1f9ba4c61bbd4bbd4376f731527da187cd39952 + actionpack (8.0.1) sha256=c764e4bfc0ad9d3505c09ef9b6fbf9eca4292793550c6b7e2ea93167181bfcba + actiontext (8.0.1) sha256=f232d303e854db2098f34d7331fe493a72dc2e53dfce80fbd517c7b93d4b05b2 + actionview (8.0.1) sha256=3005e3de5ca49ea789bf1ad46002d63fe5aa543c61c341239d3c533757e64f8a + activejob (8.0.1) sha256=95acd9a32d498d3a458efbb317f6191fb678758cde0ebb6c68f0b25e0fe3477f + activemodel (8.0.1) sha256=f46292fd6dcc128e18d588854298a933fd9eb22544c412b414ec02821062dc78 + activerecord (8.0.1) sha256=34a7f0610660bb704f0363025d4b8d35ffe8ddc8f5b8147e0809171f724b5306 + activestorage (8.0.1) sha256=91a8f156638568fac971ff25962a617d9c58fdc0e44eb6bd0edff36aff7df205 + activesupport (8.0.1) sha256=fd5bc74641c24ac3541055c2879789198ff42adee3e39c2933289ba008912e37 + acts_as_tenant (1.0.1) sha256=6944e4d64533337938a8817a6b4ff9b11189c9dcc0b1333bb89f3821a4c14c53 + addressable (2.8.7) sha256=462986537cf3735ab5f3c0f557f14155d778f4b43ea4f485a9deb9c8f7c58232 + annotaterb (4.13.0) sha256=6f472912002fefa735665b4132de47d0134ebf1efb76a7ef05f579cc4a6b2ff1 + ast (2.4.2) sha256=1e280232e6a33754cde542bc5ef85520b74db2aac73ec14acef453784447cc12 + babel-source (5.8.35) sha256=79ef222a9dcb867ac2efa3b0da35b4bcb15a4bfa67b6b2dcbf1e9a29104498d9 + babel-transpiler (0.7.0) sha256=4c06f4ad9e8e1cabe94f99e11df2f140bb72aca9ba067dbb49dc14d9b98d1570 + base64 (0.2.0) sha256=0f25e9b21a02a0cc0cea8ef92b2041035d39350946e8789c562b2d1a3da01507 + bcrypt (3.1.20) sha256=8410f8c7b3ed54a3c00cd2456bf13917d695117f033218e2483b2e40b0784099 + benchmark (0.4.0) sha256=0f12f8c495545e3710c3e4f0480f63f06b4c842cc94cec7f33a956f5180e874a + bigdecimal (3.1.9) sha256=2ffc742031521ad69c2dfc815a98e426a230a3d22aeac1995826a75dabfad8cc + bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e + bootsnap (1.18.4) sha256=ac4c42af397f7ee15521820198daeff545e4c360d2772c601fbdc2c07d92af55 + builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f + childprocess (5.1.0) sha256=9a8d484be2fd4096a0e90a0cd3e449a05bc3aa33f8ac9e4d6dcef6ac1455b6ec + chroma (0.2.0) sha256=64bdcd36a4765fbcd45adc64960cc153101300b4918f90ffdd89f4e2eb954b54 + coderay (1.1.3) sha256=dc530018a4684512f8f38143cd2a096c9f02a1fc2459edcfe534787a7fc77d4b + concurrent-ruby (1.3.5) sha256=813b3e37aca6df2a21a3b9f1d497f8cbab24a2b94cab325bffe65ee0f6cbebc6 + connection_pool (2.4.1) sha256=0f40cf997091f1f04ff66da67eabd61a9fe0d4928b9a3645228532512fab62f4 + crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d + csv (3.3.2) sha256=6ff0c135e65e485d1864dde6c1703b60d34cc9e19bed8452834a0b28a519bd4e + date (3.4.1) sha256=bf268e14ef7158009bfeaec40b5fa3c7271906e88b196d958a89d4b408abe64f + debug (1.10.0) sha256=11e28ca74875979e612444104f3972bd5ffb9e79179907d7ad46dba44bd2e7a4 + devise (4.9.4) sha256=920042fe5e704c548aa4eb65ebdd65980b83ffae67feb32c697206bfd975a7f8 + diff-lcs (1.5.1) sha256=273223dfb40685548436d32b4733aa67351769c7dea621da7d9dd4813e63ddfe + drb (2.2.1) sha256=e9d472bf785f558b96b25358bae115646da0dbfd45107ad858b0bc0d935cb340 + erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 + et-orbi (1.2.11) sha256=d26e868cc21db88280a9ec1a50aa3da5d267eb9b2037ba7b831d6c2731f5df64 + execjs (2.9.1) sha256=e8fd066f6df60c8e8fbebc32c6fb356b5212c77374e8416a9019ca4bb154dcfb + factory_bot (6.4.6) sha256=1a9486ce98d318d740d8f5804b885a8265a28f326ecf2bcd4ce9fb27a71a6e04 + factory_bot_rails (6.4.3) sha256=ea73ceac1c0ff3dc11fff390bf2ea8a2604066525ed8ecd3b3bc2c267226dcc8 + faker (3.5.1) sha256=1ad1fbea279d882f486059c23fe3ddb816ccd1d7052c05a45014b4450d859bfc + fugit (1.11.1) sha256=e89485e7be22226d8e9c6da411664d0660284b4b1c08cacb540f505907869868 + globalid (1.2.1) sha256=70bf76711871f843dbba72beb8613229a49429d1866828476f9c9d6ccc327ce9 + httparty (0.22.0) sha256=78652a5c9471cf0093d3b2083c2295c9c8f12b44c65112f1846af2b71430fa6c + i18n (1.14.7) sha256=ceba573f8138ff2c0915427f1fc5bdf4aa3ab8ae88c8ce255eb3ecf0a11a5d0f + importmap-rails (2.1.0) sha256=9f10c67d60651a547579f448100d033df311c5d5db578301374aeb774faae741 + io-console (0.8.0) sha256=cd6a9facbc69871d69b2cb8b926fc6ea7ef06f06e505e81a64f14a470fddefa2 + irb (1.14.3) sha256=c457f1f2f1438ae9ce5c5be3981ae2138dec7fb894c7d73777eeeb0a6c0d0752 + jbuilder (2.13.0) sha256=7200a38a1c0081aa81b7a9757e7a299db75bc58cf1fd45ca7919a91627d227d6 + json (2.9.1) sha256=d2bdef4644052fad91c1785d48263756fe32fcac08b96a20bb15840e96550d11 + json-schema (5.0.1) sha256=bef71a82c600a42594911553522e143f7634affc198ed507ef3ded2f920a74a9 + jsonapi-deserializable (0.2.0) sha256=5f0ca2d3f8404cce1584a314e8a3753be32a56054c942adfe997b87e92bce147 + jsonapi-parser (0.1.1) sha256=9ee0dc031e88fc7548d56fab66f9716d1e1c06f972b529b8c4617bc42a097020 + jsonapi-rails (0.4.1) sha256=fa68b927b58f194e8b81f578c0bf18e61575638f45a390f66c832de2e6d179ba + jsonapi-rb (0.5.0) sha256=7922a164278f506c43d56277f6bd0800a0b603cc985f7f63fe7241b2628bd105 + jsonapi-renderer (0.2.2) sha256=b5c44b033d61b4abdb6500fa4ab84807ca0b36ea0e59e47a2c3ca7095a6e447b + jsonapi-serializable (0.3.1) sha256=221e657677659d798e268a33ec97a83ec5ea0e4233f931358db84e88056552e9 + language_server-protocol (3.17.0.3) sha256=3d5c58c02f44a20d972957a9febe386d7e7468ab3900ce6bd2b563dd910c6b3f + launchy (3.0.1) sha256=b7fa60bda0197cf57614e271a250a8ca1f6a34ab889a3c73f67ec5d57c8a7f2c + letter_opener (1.10.0) sha256=2ff33f2e3b5c3c26d1959be54b395c086ca6d44826e8bf41a14ff96fdf1bdbb2 + letter_opener_web (3.0.0) sha256=3f391efe0e8b9b24becfab5537dfb17a5cf5eb532038f947daab58cb4b749860 + license_finder (7.2.1) sha256=179ead19b64b170638b72fd16024233813673ac9d20d5ba75ae0b4444887ef14 + logger (1.6.5) sha256=c3cfe56d01656490ddd103d38b8993d73d86296adebc5f58cefc9ec03741e56b + loofah (2.23.1) sha256=d0a07422cb3b69272e124afa914ef6d517e30d5496b7f1c1fc5b95481f13f75e + mail (2.8.1) sha256=ec3b9fadcf2b3755c78785cb17bc9a0ca9ee9857108a64b6f5cfc9c0b5bfc9ad + marcel (1.0.4) sha256=0d5649feb64b8f19f3d3468b96c680bae9746335d02194270287868a661516a4 + method_source (1.1.0) sha256=181301c9c45b731b4769bc81e8860e72f9161ad7d66dd99103c9ab84f560f5c5 + mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef + mini_portile2 (2.8.8) sha256=8e47136cdac04ce81750bb6c09733b37895bf06962554e4b4056d78168d70a75 + minitest (5.25.4) sha256=9cf2cae25ac4dfc90c988ebc3b917f53c054978b673273da1bd20bcb0778f947 + money (6.19.0) sha256=ec936fa1e42f2783719241ed9fd52725d0efa628f928feea1eb5c37d5de7daf3 + msgpack (1.7.2) sha256=59ab62fd8a4d0dfbde45009f87eb6f158ab2628a7c48886b0256f175166baaa8 + multi_xml (0.7.1) sha256=4fce100c68af588ff91b8ba90a0bb3f0466f06c909f21a32f4962059140ba61b + net-imap (0.5.2) sha256=e955b55e539712518bdb4eb747c6514f9c8d56ec4eb8eb573a82a6885a9effea + net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 + net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8 + net-smtp (0.5.0) sha256=5fc0415e6ea1cc0b3dfea7270438ec22b278ca8d524986a3ae4e5ae8d087b42a + nio4r (2.7.4) sha256=d95dee68e0bb251b8ff90ac3423a511e3b784124e5db7ff5f4813a220ae73ca9 + nokogiri (1.18.1) sha256=df18be7e96c34736b6abfdeda80c6e845134fb9afe2fe5d4fbc1cf1f89c68475 + nokogiri (1.18.1-aarch64-linux-gnu) sha256=35837013800e34342fcbaca305f8c49231f6bd4f779bfa23fe7b4686ae82d5b8 + nokogiri (1.18.1-arm-linux-gnu) sha256=3b873fd6b0cd1ad7c77e87af701075bdfd14c9a6b2f2965c5e00ed29a5627a37 + nokogiri (1.18.1-arm64-darwin) sha256=d75193f284c899d225943a8944479faedd995a7573ddd5c8308ffbdf2ec55204 + nokogiri (1.18.1-x86_64-darwin) sha256=d94e3aa6483577495fc8969d6b4b5c075840ce6b1ab09636a6d4177ad171051d + nokogiri (1.18.1-x86_64-linux-gnu) sha256=e516cf16ccde67ed4cc595a2621ca5ddd42562ecb24928914b0045a20a41620e + orm_adapter (0.5.0) sha256=aa5d0be5d540cbb46d3a93e88061f4ece6a25f6e97d6a47122beb84fe595e9b9 + parallel (1.26.3) sha256=d86babb7a2b814be9f4b81587bf0b6ce2da7d45969fab24d8ae4bf2bb4d4c7ef + parser (3.3.7.0) sha256=7449011771e3e7881297859b849de26a6f4fccd515bece9520a87e7d2116119b + pg (1.5.9) sha256=761efbdf73b66516f0c26fcbe6515dc7500c3f0aa1a1b853feae245433c64fdc + pluck_to_hash (1.0.2) sha256=1599906239716f98262a41493dd7d4cb72e8d83ad3d76d666deacfc5de50a47e + pry (0.15.2) sha256=12d54b8640d3fa29c9211dd4ffb08f3fd8bf7a4fd9b5a73ce5b59c8709385b6b + psych (5.2.2) sha256=a4a9477c85d3e858086c38cf64e7096abe40d1b1eed248b01020dec0ff9906ab + public_suffix (6.0.1) sha256=61d44e1cab5cbbbe5b31068481cf16976dd0dc1b6b07bd95617ef8c5e3e00c6f + puma (6.5.0) sha256=94d1b75cab7f356d52e4f1b17b9040a090889b341dbeee6ee3703f441dc189f2 + raabro (1.4.0) sha256=d4fa9ff5172391edb92b242eed8be802d1934b1464061ae5e70d80962c5da882 + racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f + rack (3.1.8) sha256=d3fbcbca43dc2b43c9c6d7dfbac01667ae58643c42cea10013d0da970218a1b1 + rack-cors (2.0.2) sha256=415d4e1599891760c5dc9ef0349c7fecdf94f7c6a03e75b2e7c2b54b82adda1b + rack-session (2.0.0) sha256=db04b2063e180369192a9046b4559af311990af38c6a93d4c600cee4eb6d4e81 + rack-test (2.1.0) sha256=0c61fc61904049d691922ea4bb99e28004ed3f43aa5cfd495024cc345f125dfb + rackup (2.2.1) sha256=f737191fd5c5b348b7f0a4412a3b86383f88c43e13b8217b63d4c8d90b9e798d + rails (8.0.1) sha256=c86f4cd7834a67c1e5d04a77d35c88a5f56a20e2022ec416fa52c1af2cdc9491 + rails-dom-testing (2.2.0) sha256=e515712e48df1f687a1d7c380fd7b07b8558faa26464474da64183a7426fa93b + rails-html-sanitizer (1.6.2) sha256=35fce2ca8242da8775c83b6ba9c1bcaad6751d9eb73c1abaa8403475ab89a560 + railties (8.0.1) sha256=8f653c6b1b0721b553045bd0deda1f22074b9ddc2209526e6f7285fcf607ac51 + rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a + rake (13.2.1) sha256=46cb38dae65d7d74b6020a4ac9d48afed8eb8149c040eccf0523bec91907059d + rdoc (6.10.0) sha256=db665021883ac9df3ba29cdf71aece960749888db1bf9615b4a584cfa3fa3eda + react-rails (3.2.1) sha256=2235db0b240517596b1cb3e26177ab5bc64d3a56579b0415ee242b1691f81f64 + redis (5.3.0) sha256=6bf810c5ae889187f0c45f77db503310980310afa57cf1640d57f419ccda72b1 + redis-client (0.22.2) sha256=31fee4b7cf04109b227327fabeaaf1fc5b652cf48a186a03bc607e40767bacc0 + regexp_parser (2.10.0) sha256=cb6f0ddde88772cd64bff1dbbf68df66d376043fe2e66a9ef77fcb1b0c548c61 + reline (0.6.0) sha256=57620375dcbe56ec09bac7192bfb7460c716bbf0054dc94345ecaa5438e539d2 + responders (3.1.1) sha256=92f2a87e09028347368639cfb468f5fefa745cb0dc2377ef060db1cdd79a341a + rexml (3.3.9) sha256=d71875b85299f341edf47d44df0212e7658cbdf35aeb69cefdb63f57af3137c9 + rspec-core (3.13.2) sha256=94fbda6e4738e478f1c7532b7cc241272fcdc8b9eac03a97338b1122e4573300 + rspec-expectations (3.13.3) sha256=0e6b5af59b900147698ea0ff80456c4f2e69cac4394fbd392fbd1ca561f66c58 + rspec-mocks (3.13.2) sha256=2327335def0e1665325a9b617e3af9ae20272741d80ac550336309a7c59abdef + rspec-rails (7.1.0) sha256=94585b69c4086ca79afae5cc8d2c5e314f6ad32a88c927f9c065b99596e3ee47 + rspec-support (3.13.1) sha256=48877d4f15b772b7538f3693c22225f2eda490ba65a0515c4e7cf6f2f17de70f + rswag (2.16.0) sha256=f07ce41548b9bb51464c38bc7b95af22fee84b90f2d1197a515a623906353086 + rswag-api (2.16.0) sha256=b653f7bd92e98be18b01ab4525d88950d7b0960e293a99f856b9efcee3ae6074 + rswag-specs (2.16.0) sha256=8ba26085c408b0bd2ed21dc8015c80f417c7d34c63720ab7133c2549b5bd2a91 + rswag-ui (2.16.0) sha256=a1f49e927dceda92e6e6e7c1000f1e217ee66c565f69e28131dc98b33cd3a04f + rubocop (1.71.0) sha256=e19679efd447346ac476122313d3788ae23c38214790bcf660e984c747608bf0 + rubocop-ast (1.37.0) sha256=9513ac88aaf113d04b52912533ffe46475de1362d4aa41141b51b2455827c080 + rubocop-factory_bot (2.26.1) sha256=8de13cd4edcee5ca800f255188167ecef8dbfc3d1fae9f15734e9d2e755392aa + rubocop-rails (2.29.1) sha256=41c2fcf48d5d62f4a5f574d5f1c97bbaf4cba88ee367936c98b3422d047b17aa + rubocop-rspec (3.4.0) sha256=8721c13b6a8c9530a7ac481cea9423022f946fcf72428bda8289f8b57e4d4885 + rubocop-rspec_rails (2.30.0) sha256=888112e83f9d7ef7ad2397e9d69a0b9614a4bae24f072c399804a180f80c4c46 + ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 + rubytree (2.1.1) sha256=4925016356a81730e982f1f8c3b5f8da461f18906c77d238bad4c4ba896abd41 + rubyzip (2.3.2) sha256=3f57e3935dc2255c414484fbf8d673b4909d8a6a57007ed754dde39342d2373f + securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 + shoulda-matchers (6.4.0) sha256=9055bb7f4bb342125fb860809798855c630e05ef5e75837b3168b8e6ee1608b0 + solid_queue (1.1.2) sha256=178c9396d1cf0dac595c7508da90ddb397d25848ca007b5c5ed48e6ac6fc360c + sprockets (4.2.1) sha256=951b13dd2f2fcae840a7184722689a803e0ff9d2702d902bd844b196da773f97 + sprockets-rails (3.5.2) sha256=a9e88e6ce9f8c912d349aa5401509165ec42326baf9e942a85de4b76dbc4119e + stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06 + stringio (3.1.2) sha256=204f1828f85cdb39d57cac4abc6dc44b04505a223f131587f2e20ae3729ba131 + thor (1.3.2) sha256=eef0293b9e24158ccad7ab383ae83534b7ad4ed99c09f96f1a6b036550abbeda + tilt (2.4.0) sha256=df74f29a451daed26591a85e8e0cebb198892cb75b6573394303acda273fba4d + timeout (0.4.3) sha256=9509f079b2b55fe4236d79633bd75e34c1c1e7e3fb4b56cb5fda61f80a0fe30e + tomlrb (2.0.3) sha256=c2736acf24919f793334023a4ff396c0647d93fce702a73c9d348deaa815d4f7 + turbo-rails (2.0.11) sha256=fc47674736372780abd2a4dc0d84bef242f5ca156a457cd7fa6308291e397fcf + tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b + unicode-display_width (2.6.0) sha256=12279874bba6d5e4d2728cef814b19197dbb10d7a7837a869bab65da943b7f5a + uri (1.0.2) sha256=b303504ceb7e5905771fa7fa14b649652fa949df18b5880d69cfb12494791e27 + useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844 + warden (1.2.9) sha256=46684f885d35a69dbb883deabf85a222c8e427a957804719e143005df7a1efd0 + web-console (4.2.1) sha256=e7bcf37a10ea2b4ec4281649d1cee461b32232d0a447e82c786e6841fd22fe20 + websocket-driver (0.7.6) sha256=f69400be7bc197879726ad8e6f5869a61823147372fd8928836a53c2c741d0db + websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241 + with_env (1.1.0) sha256=50b3e4f0a6cda8f90d8a6bd87a6261f6c381429abafb161c4c69ad4a0cd0b6e4 + xml-simple (1.1.9) sha256=d21131e519c86f1a5bc2b6d2d57d46e6998e47f18ed249b25cad86433dbd695d + zeitwerk (2.7.1) sha256=0945986050e4907140895378e74df1fe882a2271ed087cc6c6d6b00d415a2756 + RUBY VERSION - ruby 3.3.4p94 + ruby 3.4.1p0 BUNDLED WITH - 2.5.17 + 2.6.1 diff --git a/README.md b/README.md index 7db80e4..e1cc204 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,96 @@ -# README +# Libre Wedding Planner -This README would normally document whatever steps are necessary to get the -application up and running. +Libre Wedding Planner is Free, Open Source Software that helps organize several aspects of a wedding. -Things you may want to cover: +The project is not production-ready yet. -* Ruby version +## Features -* System dependencies +The follwing features are either developed or under active development: -* Configuration +- Guests management +- Expense management +- Seating chart -* Database creation -* Database initialization +## Next steps -* How to run the test suite +Some ideas we would like to implement next: -* Services (job queues, cache servers, search engines, etc.) +- Authentication (required to make an instance public) +- Website with wedding information +- Attendance confirmation forms +- Multitenancy -* Deployment instructions +# Development setup + +Libre Wedding Planner is made of two main pieces: + +- The backend (this repo), built with Ruby (on Rails) +- The frontend (repo [here](https://gitea.bustikiller.com/bustikiller/wedding-planner-frontend/)), built with NextJS and React. You will need both to have the service fully working. + +Both repositories are expected to live have a common parent directory: + +``` +projects + |-> wedding-planner + |-> wedding-planner-frontend +``` + +## Docker compose + +Docker compose is the recommended way to run Libre Wedding Planner for development purposes. After downloading both repositories, `cd` to the root of `wedding-planner` and run: + +```bash +docker compose up --build +``` + +Several containers will be started: + +- backend: starts a Rails server that will act as an API. +- workers: starts a runner of [solid queue](https://github.com/rails/solid_queue/) that takes .care of async tasks. +- frontend: starts a NextJS application in charge of the frontend. +- nginx: A reverse proxy that the backend and frontend under the same domain, and routes all requests to the upstream services. +- db: A Postgres instance used by the backend service. + +The backend service will seed the database with fake data. It's worth noting that the Postgres container does not have a volume, so the application will be seeded every time the container is created. + +The backend, frontend and workers have hot-reloading enabled, so changes made to the codebase should be reflected in the application on the next request. + +Once all containers have started, visit http://libre-wedding-planner.app.localhost/default/dashboard to load the application. + +## Multitenancy + +LibreWeddingPlanner is designed to manage multiple weddings in a single host. All URLs (in the API and the frontend) are scoped under a slug that is unique per wedding. The slug is made of lowercase letters, numbers, and dashes (-). + +The development environment is seeded with a wedding whose slug is `default`. +## Email delivery + +In the development environment, real emails will not be sent. You can visit http://libre-wedding-planner.app.localhost/letter_opener/ to get a list of emails generated by the application. + +## Testing + +Unit tests can be executed with + +``` +bundle exec rspec +``` + +## API documentation + +Generate the OpenAPI documentation with the command: + +``` +rake rswag:specs:swaggerize +``` + +The documentation is available in Swagger UI in http://libre-wedding-planner.app.localhost/api/api-docs/index.html. If testing the API through the UI, you will need to select the second server (which includes the `/api` path), intended for development. + +## Contributing + +Contributions of all kinds (code, UX/UI, testing, translations, etc.) are welcome. The procedures to contribute are still being defined, but don't hesitate to reach out in case you want to participate. + +# License + +This project is licensed under the GNU Affero General Public License (AGPL). Check [COPYING.md](./COPYING.md) for additional information. -* ... diff --git a/Rakefile b/Rakefile index 9a5ea73..488c551 100644 --- a/Rakefile +++ b/Rakefile @@ -1,6 +1,8 @@ +# frozen_string_literal: true + # Add your own tasks in files placed in lib/tasks ending in .rake, # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. -require_relative "config/application" +require_relative 'config/application' Rails.application.load_tasks diff --git a/app/channels/application_cable/channel.rb b/app/channels/application_cable/channel.rb index d672697..88942fe 100644 --- a/app/channels/application_cable/channel.rb +++ b/app/channels/application_cable/channel.rb @@ -1,3 +1,7 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + module ApplicationCable class Channel < ActionCable::Channel::Base end diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb index 0ff5442..ab575d6 100644 --- a/app/channels/application_cable/connection.rb +++ b/app/channels/application_cable/connection.rb @@ -1,3 +1,7 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + module ApplicationCable class Connection < ActionCable::Connection::Base end diff --git a/app/controllers/affinities_controller.rb b/app/controllers/affinities_controller.rb new file mode 100644 index 0000000..63849b2 --- /dev/null +++ b/app/controllers/affinities_controller.rb @@ -0,0 +1,72 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +class AffinitiesController < ApplicationController + before_action :set_group, except: :reset + + def index + overridden = @group.affinities.each_with_object({}) do |affinity, acc| + acc[affinity.another_group(@group).id] = affinity.discomfort + end + + for_each_group do |group_id| + overridden[group_id] || GroupAffinity::NEUTRAL + end + end + + def bulk_update + affinities = params.expect(affinities: [%i[group_id affinity]]).map(&:to_h).map do |affinity| + { + group_a_id: @group.id, + group_b_id: affinity[:group_id], + discomfort: GroupAffinity::MAX_DISCOMFORT - affinity[:affinity] + } + end + + GroupAffinity.upsert_all(affinities, unique_by: :uindex_group_pair) + + render json: {}, status: :ok + rescue ActiveRecord::InvalidForeignKey + render json: { error: 'At least one of the group IDs provided does not exist.' }, status: :bad_request + rescue ActiveRecord::StatementInvalid + render json: { error: 'Invalid group ID or discomfort provided.' }, status: :bad_request + end + + def default + hierarchy = AffinityGroupsHierarchy.new + + for_each_group do |group_id| + hierarchy.default_discomfort(@group.id, group_id).to_f + end + end + + def reset + hierarchy = AffinityGroupsHierarchy.new + + affinities = Group.pluck(:id).combination(2).map do |(group_a_id, group_b_id)| + { + group_a_id:, + group_b_id:, + discomfort: hierarchy.default_discomfort(group_a_id, group_b_id).to_f + } + end + + GroupAffinity.upsert_all(affinities, unique_by: :uindex_group_pair) + + render json: {}, status: :ok + end + + private + + def for_each_group + Group.where.not(id: @group.id) + .pluck(:id) + .index_with { |group_id| GroupAffinity::MAX_DISCOMFORT - yield(group_id) } + .then { |affinities| render json: affinities } + end + + def set_group + @group = Group.find(params[:group_id]) + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 09705d1..025dc85 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,2 +1,68 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + class ApplicationController < ActionController::Base + set_current_tenant_through_filter + before_action :set_tenant + before_action :authenticate_user! + after_action :set_csrf_cookie + + skip_before_action :verify_authenticity_token, if: :development_swagger? + + rescue_from ActiveRecord::RecordInvalid do |exception| + render json: { + message: 'Record invalid', + errors: exception.record.errors.full_messages + }, status: :unprocessable_entity + end + + rescue_from ActionController::ParameterMissing do |exception| + render json: { + message: 'Parameter missing', + errors: [exception.message] + }, status: :bad_request + end + + rescue_from ActiveRecord::RecordNotFound do |exception| + render json: { + message: 'Record not found', + errors: [exception.message] + }, status: :not_found + end + + private + + def validate_captcha! + Rails.logger.info("Captcha params: #{captcha_params}") + + return if LibreCaptcha.new.valid?(id: captcha_params[:id], answer: captcha_params[:answer]) + + render json: { error: 'Incorrect CAPTCHA solution' }, status: :unprocessable_entity + end + + def captcha_params + params.expect(captcha: %i[id answer]) + end + + def default_url_options(options = {}) + options.merge(path_params: { slug: ActsAsTenant.current_tenant&.slug }) + end + + def set_tenant + set_current_tenant(Wedding.find_by!(slug: params[:slug])) + end + + def development_swagger? + Rails.env.test? || + (Rails.env.development? && request.headers['referer']&.include?('/api-docs/index.html')) + end + + def set_csrf_cookie + cookies['csrf-token'] = { + value: form_authenticity_token, + secure: false, + same_site: :strict + } + end end diff --git a/app/controllers/captcha_controller.rb b/app/controllers/captcha_controller.rb new file mode 100644 index 0000000..6c3f3ca --- /dev/null +++ b/app/controllers/captcha_controller.rb @@ -0,0 +1,15 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +class CaptchaController < ApplicationController + skip_before_action :authenticate_user! + skip_before_action :set_tenant + def create + id = LibreCaptcha.new.id + render json: { + id:, + media_url: media_captcha_index_url(id:) + }, status: :created + end +end diff --git a/app/controllers/expenses_controller.rb b/app/controllers/expenses_controller.rb index 2692f74..3195a8b 100644 --- a/app/controllers/expenses_controller.rb +++ b/app/controllers/expenses_controller.rb @@ -1,70 +1,34 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + class ExpensesController < ApplicationController - before_action :set_expense, only: %i[ show edit update destroy ] + def summary + render json: Expenses::TotalQuery.new.call + end - # GET /expenses or /expenses.json def index - @expenses = Expense.all + render json: Expense.order(pricing_type: :asc, amount: :desc).as_json(only: %i[id name amount pricing_type]) end - # GET /expenses/1 or /expenses/1.json - def show - end - - # GET /expenses/new - def new - @expense = Expense.new - end - - # GET /expenses/1/edit - def edit - end - - # POST /expenses or /expenses.json def create - @expense = Expense.new(expense_params) - - respond_to do |format| - if @expense.save - format.html { redirect_to expense_url(@expense), notice: "Expense was successfully created." } - format.json { render :show, status: :created, location: @expense } - else - format.html { render :new, status: :unprocessable_entity } - format.json { render json: @expense.errors, status: :unprocessable_entity } - end - end + Expense.create!(expense_params) + render json: {}, status: :created end - # PATCH/PUT /expenses/1 or /expenses/1.json def update - respond_to do |format| - if @expense.update(expense_params) - format.html { redirect_to expense_url(@expense), notice: "Expense was successfully updated." } - format.json { render :show, status: :ok, location: @expense } - else - format.html { render :edit, status: :unprocessable_entity } - format.json { render json: @expense.errors, status: :unprocessable_entity } - end - end + Expense.find(params[:id]).update!(expense_params) + render json: {}, status: :ok end - # DELETE /expenses/1 or /expenses/1.json def destroy - @expense.destroy! - - respond_to do |format| - format.html { redirect_to expenses_url, notice: "Expense was successfully destroyed." } - format.json { head :no_content } - end + Expense.find(params[:id]).destroy! + render json: {}, status: :ok end private - # Use callbacks to share common setup or constraints between actions. - def set_expense - @expense = Expense.find(params[:id]) - end - # Only allow a list of trusted parameters through. - def expense_params - params.require(:expense).permit(:name, :amount, :pricing_type) - end + def expense_params + params.expect(expense: %i[name amount pricing_type]) + end end diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 0f489fb..08334aa 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -1,6 +1,46 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + class GroupsController < ApplicationController def index - roots = Group.where(parent_id: nil) - render jsonapi: roots, include: [children: [children: [:children]]] + query_result = Groups::SummaryQuery.new.call.as_json.map(&:deep_symbolize_keys).map do |group| + { + id: group[:id], + name: group[:name], + icon: group[:icon], + color: group[:color], + parent_id: group[:parent_id], + attendance: group.slice(:total, :considered, :invited, :confirmed, :declined, :tentative) + } + end + + render json: query_result + end + + def create + group = Group.create!(**group_params, parent:) + render json: group.as_json(only: %i[id name icon color parent_id]), status: :created + end + + def update + group = Group.find(params[:id]) + group.update!(**group_params, parent:) + render json: group.as_json(only: %i[id name icon color parent_id]), status: :ok + end + + def destroy + Group.find(params[:id]).destroy! + render json: {}, status: :ok + end + + private + + def parent + params[:group][:parent_id].present? ? Group.find(params[:group][:parent_id]) : nil + end + + def group_params + params.expect(group: %i[name icon color]) end end diff --git a/app/controllers/guests_controller.rb b/app/controllers/guests_controller.rb index 925770f..1d872fa 100644 --- a/app/controllers/guests_controller.rb +++ b/app/controllers/guests_controller.rb @@ -1,89 +1,35 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + require 'csv' class GuestsController < ApplicationController - before_action :set_guest, only: %i[show edit update destroy] - - # GET /guests or /guests.json def index - @guests = Guest.all - .joins(:group) - .order('groups.name' => :asc) - - render jsonapi: @guests + render json: Guest.includes(:group) + .left_joins(:group) + .order('groups.name' => :asc, name: :asc) + .as_json(only: %i[id name status], include: { group: { only: %i[id name] } }) end - # GET /guests/1 or /guests/1.json - def show; end - - # GET /guests/new - def new - @guest = Guest.new - end - - # GET /guests/1/edit - def edit; end - - # POST /guests or /guests.json def create - @guest = Guest.new(guest_params) - - respond_to do |format| - if @guest.save - format.html { redirect_to guest_url(@guest), notice: 'Guest was successfully created.' } - format.json { render :show, status: :created, location: @guest } - else - format.html { render :new, status: :unprocessable_entity } - format.json { render json: @guest.errors, status: :unprocessable_entity } - end - end + Guest.create!(guest_params) + render json: {}, status: :created end - # PATCH/PUT /guests/1 or /guests/1.json def update - respond_to do |format| - if @guest.update(guest_params) - format.html { redirect_to guest_url(@guest), notice: 'Guest was successfully updated.' } - format.json { render :show, status: :ok, location: @guest } - else - format.html { render :edit, status: :unprocessable_entity } - format.json { render json: @guest.errors, status: :unprocessable_entity } - end - end + Guest.find(params[:id]).update!(guest_params) + render json: {}, status: :ok end - # DELETE /guests/1 or /guests/1.json def destroy - @guest.destroy! - - respond_to do |format| - format.html { redirect_to guests_url, notice: 'Guest was successfully destroyed.' } - format.json { head :no_content } - end - end - - def import - csv = CSV.parse(params[:file].read, headers: true) - ActiveRecord::Base.transaction do - csv.each do |row| - guest = Guest.create!(first_name: row['name']) - - guest.affinity_group_list.add(row['affinity_group']) - guest.save! - end - end - - redirect_to guests_url + Guest.find(params[:id]).destroy! + render json: {}, status: :ok end private - # Use callbacks to share common setup or constraints between actions. - def set_guest - @guest = Guest.find(params[:id]) - end - - # Only allow a list of trusted parameters through. def guest_params - params.require(:guest).permit(:first_name, :last_name, :email, :phone) + params.expect(guest: %i[name group_id status]) end end diff --git a/app/controllers/summary_controller.rb b/app/controllers/summary_controller.rb new file mode 100644 index 0000000..0d3f655 --- /dev/null +++ b/app/controllers/summary_controller.rb @@ -0,0 +1,44 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +class SummaryController < ApplicationController + def index + render json: { + expenses:, + guests: + } + end + + private + + def guests + guest_summary = Guest.group(:status).count + + { + total: guest_summary.except('considered').values.sum, + confirmed: guest_summary['confirmed'].to_i, + declined: guest_summary['declined'].to_i, + tentative: guest_summary['tentative'].to_i, + invited: guest_summary['invited'].to_i + } + end + + def expenses + expense_summary = Expenses::TotalQuery.new(wedding: ActsAsTenant.current_tenant).call + + { + projected: { + total: expense_summary['total_projected'], + guests: expense_summary['projected_guests'] + }, + confirmed: { + total: expense_summary['total_confirmed'], + guests: expense_summary['confirmed_guests'] + }, + status: { + paid: 0 + } + } + end +end diff --git a/app/controllers/tables_arrangements_controller.rb b/app/controllers/tables_arrangements_controller.rb index 7d3c371..45cf633 100644 --- a/app/controllers/tables_arrangements_controller.rb +++ b/app/controllers/tables_arrangements_controller.rb @@ -1,12 +1,54 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + class TablesArrangementsController < ApplicationController def index - @tables_arrangements = TablesArrangement.all.order(discomfort: :asc).limit(10) + current_digest = Tables::Distribution.digest(current_tenant) + + render json: TablesArrangement + .order(valid: :desc) + .order(discomfort: :asc) + .select(:id, :name, :discomfort) + .select("digest = '#{current_digest}'::uuid as valid") + .limit(20) + .as_json(only: %i[id name discomfort valid]) end def show - @tables_arrangement = TablesArrangement.find(params[:id]) - @seats = @tables_arrangement.seats - .includes(guest: %i[affinity_groups unbreakable_bonds]) - .group_by(&:table_number) + Guest.joins(:seats, :group) + .where(seats: { tables_arrangement_id: params[:id] }) + .select('guests.*', 'groups.color', 'seats.table_number') + .group_by(&:table_number) + .map { |number, guests| format(number:, guests:) } + .then { |result| render json: { id: params[:id], tables: result } } + end + + def create + TableSimulatorJob.perform_later(current_tenant.id) + + render json: {}, status: :created + end + + private + + def format(number:, guests:) + { + number: number, + discomfort: discomfort(guests: guests), + guests: guests.as_json(only: %i[id name color]) + } + end + + def discomfort(guests:) + table = Tables::Table.new(guests) + + table.min_per_table = TableSimulatorJob::MIN_PER_TABLE + table.max_per_table = TableSimulatorJob::MAX_PER_TABLE + calculator = Tables::DiscomfortCalculator.new(table:) + { + discomfort: calculator.calculate, + breakdown: calculator.breakdown + } end end diff --git a/app/controllers/tokens_controller.rb b/app/controllers/tokens_controller.rb new file mode 100644 index 0000000..a0ee546 --- /dev/null +++ b/app/controllers/tokens_controller.rb @@ -0,0 +1,12 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +class TokensController < ApplicationController + skip_before_action :authenticate_user! + skip_before_action :set_tenant + + def show + head :ok + end +end diff --git a/app/controllers/users/confirmations_controller.rb b/app/controllers/users/confirmations_controller.rb new file mode 100644 index 0000000..01eb91e --- /dev/null +++ b/app/controllers/users/confirmations_controller.rb @@ -0,0 +1,27 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +module Users + class ConfirmationsController < Devise::ConfirmationsController + clear_respond_to + respond_to :json + + def show + super do |resource| + if resource.errors.empty? + respond_to do |format| + format.json { render json: resource, status: :ok } + format.any { redirect_to root_path } + end + else + render json: { + message: 'Record invalid', + errors: resource.errors.full_messages + }, status: :unprocessable_entity + end + return + end + end + end +end diff --git a/app/controllers/users/registrations_controller.rb b/app/controllers/users/registrations_controller.rb new file mode 100644 index 0000000..f261e90 --- /dev/null +++ b/app/controllers/users/registrations_controller.rb @@ -0,0 +1,32 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +module Users + class RegistrationsController < Devise::RegistrationsController + clear_respond_to + respond_to :json + + before_action :validate_captcha!, only: :create + + def create + wedding = Wedding.create(slug: params[:slug]) + unless wedding.persisted? + render json: { errors: wedding.errors.full_messages }, status: :unprocessable_entity + return + end + + ActsAsTenant.with_tenant(wedding) do + super do |user| + wedding.destroy unless user.persisted? + end + end + end + + private + + def set_tenant + set_current_tenant(nil) + end + end +end diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb new file mode 100644 index 0000000..4d7b6a8 --- /dev/null +++ b/app/controllers/users/sessions_controller.rb @@ -0,0 +1,10 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +module Users + class SessionsController < Devise::SessionsController + clear_respond_to + respond_to :json + end +end diff --git a/app/extensions/tree_node_extension.rb b/app/extensions/tree_node_extension.rb index fe4f720..6894dd7 100644 --- a/app/extensions/tree_node_extension.rb +++ b/app/extensions/tree_node_extension.rb @@ -1,3 +1,7 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + module TreeNodeExtension def distance_to_common_ancestor(another_node) return 0 if self == another_node diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index de6be79..9dbd98c 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,2 +1,6 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + module ApplicationHelper end diff --git a/app/helpers/expenses_helper.rb b/app/helpers/expenses_helper.rb index 1d50062..0b8c324 100644 --- a/app/helpers/expenses_helper.rb +++ b/app/helpers/expenses_helper.rb @@ -1,2 +1,6 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + module ExpensesHelper end diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index c091b2f..feda408 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -1,2 +1,6 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + module GroupsHelper end diff --git a/app/helpers/guests_helper.rb b/app/helpers/guests_helper.rb index 00317bf..369b8ed 100644 --- a/app/helpers/guests_helper.rb +++ b/app/helpers/guests_helper.rb @@ -1,2 +1,6 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + module GuestsHelper end diff --git a/app/helpers/tables_arrangements_helper.rb b/app/helpers/tables_arrangements_helper.rb index c066a2e..6353e46 100644 --- a/app/helpers/tables_arrangements_helper.rb +++ b/app/helpers/tables_arrangements_helper.rb @@ -1,2 +1,6 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + module TablesArrangementsHelper end diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb index d394c3d..561fb56 100644 --- a/app/jobs/application_job.rb +++ b/app/jobs/application_job.rb @@ -1,3 +1,7 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + class ApplicationJob < ActiveJob::Base # Automatically retry jobs that encountered a deadlock # retry_on ActiveRecord::Deadlocked diff --git a/app/jobs/table_simulator_job.rb b/app/jobs/table_simulator_job.rb new file mode 100644 index 0000000..51b7256 --- /dev/null +++ b/app/jobs/table_simulator_job.rb @@ -0,0 +1,30 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +class TableSimulatorJob < ApplicationJob + queue_as :default + + MIN_PER_TABLE = 8 + MAX_PER_TABLE = 10 + + def perform(wedding_id) + ActsAsTenant.with_tenant(Wedding.find(wedding_id)) do + engine = VNS::Engine.new + + engine.add_perturbation(Tables::Swap) + engine.add_perturbation(Tables::Shift) + + initial_solution = Tables::Distribution.new(min_per_table: MIN_PER_TABLE, max_per_table: MAX_PER_TABLE) + initial_solution.random_distribution(Guest.potential.shuffle) + + engine.initial_solution = initial_solution + + engine.target_function(&:discomfort) + + best_solution = engine.run + + best_solution.save! + end + end +end diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index 3c34c81..b28db31 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -1,4 +1,8 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + class ApplicationMailer < ActionMailer::Base - default from: "from@example.com" - layout "mailer" + default from: 'from@example.com' + layout 'mailer' end diff --git a/app/models/application_record.rb b/app/models/application_record.rb index b63caeb..61b9d31 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -1,3 +1,7 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + class ApplicationRecord < ActiveRecord::Base primary_abstract_class end diff --git a/app/models/expense.rb b/app/models/expense.rb index 15a9560..66b32f3 100644 --- a/app/models/expense.rb +++ b/app/models/expense.rb @@ -1,2 +1,34 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +# == Schema Information +# +# Table name: expenses +# +# id :uuid not null, primary key +# amount :decimal(, ) +# name :string +# pricing_type :enum default("fixed"), not null +# created_at :datetime not null +# updated_at :datetime not null +# wedding_id :uuid not null +# +# Indexes +# +# index_expenses_on_wedding_id (wedding_id) +# +# Foreign Keys +# +# fk_rails_... (wedding_id => weddings.id) +# class Expense < ApplicationRecord + acts_as_tenant :wedding + enum :pricing_type, + fixed: 'fixed', + per_person: 'per_person' + + validates :name, presence: true + validates :amount, presence: true, numericality: { greater_than: 0 } + validates :pricing_type, presence: true end diff --git a/app/models/group.rb b/app/models/group.rb index afcadaa..2db28ed 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -1,9 +1,77 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +# == Schema Information +# +# Table name: groups +# +# id :uuid not null, primary key +# color :string +# icon :string +# name :string not null +# order :integer default(1), not null +# created_at :datetime not null +# updated_at :datetime not null +# parent_id :uuid +# wedding_id :uuid not null +# +# Indexes +# +# index_groups_on_name (name) UNIQUE +# index_groups_on_parent_id (parent_id) +# index_groups_on_wedding_id (wedding_id) +# +# Foreign Keys +# +# fk_rails_... (parent_id => groups.id) +# fk_rails_... (wedding_id => weddings.id) +# class Group < ApplicationRecord + acts_as_tenant :wedding + validates :name, uniqueness: true validates :name, :order, presence: true - has_many :children, class_name: 'Group', foreign_key: 'parent_id' + has_many :children, class_name: 'Group', foreign_key: 'parent_id', dependent: :nullify, inverse_of: :parent belongs_to :parent, class_name: 'Group', optional: true - has_many :guests + before_create :set_color + + scope :roots, -> { where(parent_id: nil) } + + has_many :guests, dependent: :nullify + + def colorize_children(generation = 1) + children.zip(palette(generation)) do |child, raw_color| + final_color = raw_color.paint + final_color.brighten(60) if final_color.dark? + + child.update!(color: final_color) + + child.colorize_children(generation + 1) + end + end + + def affinities + GroupAffinity.where(group_a_id: id).or(GroupAffinity.where(group_b_id: id)) + end + + private + + def palette(generation) + if generation == 1 + color.paint.palette.analogous(size: children.count) + else + color.paint.palette.decreasing_saturation + end + end + + def set_color + return if color.present? + + new_color = "##{SecureRandom.hex(3)}".paint + new_color = new_color.lighten(30) if new_color.dark? + self.color = new_color + end end diff --git a/app/models/group_affinity.rb b/app/models/group_affinity.rb new file mode 100644 index 0000000..8e70d59 --- /dev/null +++ b/app/models/group_affinity.rb @@ -0,0 +1,43 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +# == Schema Information +# +# Table name: group_affinities +# +# id :bigint not null, primary key +# discomfort :float not null +# created_at :datetime not null +# updated_at :datetime not null +# group_a_id :uuid not null +# group_b_id :uuid not null +# +# Indexes +# +# index_group_affinities_on_group_a_id (group_a_id) +# index_group_affinities_on_group_b_id (group_b_id) +# uindex_group_pair (LEAST(group_a_id, group_b_id), GREATEST(group_a_id, group_b_id)) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (group_a_id => groups.id) +# fk_rails_... (group_b_id => groups.id) +# +class GroupAffinity < ApplicationRecord + NEUTRAL = 1 + MIN_DISCOMFORT = 0 + MAX_DISCOMFORT = 2 + + belongs_to :group_a, class_name: 'Group' + belongs_to :group_b, class_name: 'Group' + + validates :discomfort, + numericality: { greater_than_or_equal_to: MIN_DISCOMFORT, less_than_or_equal_to: MAX_DISCOMFORT } + + def another_group(group) + return nil if group != group_a && group != group_b + + group == group_a ? group_b : group_a + end +end diff --git a/app/models/guest.rb b/app/models/guest.rb index cc4f615..d080679 100644 --- a/app/models/guest.rb +++ b/app/models/guest.rb @@ -1,15 +1,45 @@ -class Guest < ApplicationRecord - acts_as_taggable_on :affinity_groups, :unbreakable_bonds - belongs_to :group +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors - enum status: { +# frozen_string_literal: true + +# == Schema Information +# +# Table name: guests +# +# id :uuid not null, primary key +# name :string +# phone :string +# status :integer default("considered") +# created_at :datetime not null +# updated_at :datetime not null +# group_id :uuid +# wedding_id :uuid not null +# +# Indexes +# +# index_guests_on_group_id (group_id) +# index_guests_on_wedding_id (wedding_id) +# +# Foreign Keys +# +# fk_rails_... (group_id => groups.id) +# fk_rails_... (wedding_id => weddings.id) +# +class Guest < ApplicationRecord + acts_as_tenant :wedding + belongs_to :group, optional: true + + enum :status, { considered: 0, invited: 10, confirmed: 20, - declined: 30 - } + declined: 30, + tentative: 40 + }, validate: true - def full_name - "#{first_name} #{last_name}" - end + validates :name, presence: true + + scope :potential, -> { where.not(status: %i[declined considered]) } + + has_many :seats, dependent: :delete_all end diff --git a/app/models/seat.rb b/app/models/seat.rb index e909587..9c31c42 100644 --- a/app/models/seat.rb +++ b/app/models/seat.rb @@ -1,4 +1,33 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +# == Schema Information +# +# Table name: seats +# +# id :uuid not null, primary key +# table_number :integer +# created_at :datetime not null +# updated_at :datetime not null +# guest_id :uuid not null +# tables_arrangement_id :uuid not null +# wedding_id :uuid not null +# +# Indexes +# +# index_seats_on_guest_id (guest_id) +# index_seats_on_tables_arrangement_id (tables_arrangement_id) +# index_seats_on_wedding_id (wedding_id) +# +# Foreign Keys +# +# fk_rails_... (guest_id => guests.id) +# fk_rails_... (tables_arrangement_id => tables_arrangements.id) ON DELETE => cascade +# fk_rails_... (wedding_id => weddings.id) +# class Seat < ApplicationRecord + acts_as_tenant :wedding belongs_to :guest belongs_to :table_arrangement end diff --git a/app/models/tables_arrangement.rb b/app/models/tables_arrangement.rb index 1be3ec3..a461555 100644 --- a/app/models/tables_arrangement.rb +++ b/app/models/tables_arrangement.rb @@ -1,3 +1,37 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +# == Schema Information +# +# Table name: tables_arrangements +# +# id :uuid not null, primary key +# digest :uuid not null +# discomfort :integer +# name :string not null +# created_at :datetime not null +# updated_at :datetime not null +# wedding_id :uuid not null +# +# Indexes +# +# index_tables_arrangements_on_wedding_id (wedding_id) +# +# Foreign Keys +# +# fk_rails_... (wedding_id => weddings.id) +# class TablesArrangement < ApplicationRecord - has_many :seats + acts_as_tenant :wedding + has_many :seats, dependent: :delete_all + has_many :guests, through: :seats + + before_create :assign_name + + private + + def assign_name + self.name = "#{Faker::Adjective.positive} #{Faker::Creature::Animal.name}".capitalize + end end diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 0000000..ff46fff --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,42 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +# == Schema Information +# +# Table name: users +# +# id :uuid not null, primary key +# confirmation_sent_at :datetime +# confirmation_token :string +# confirmed_at :datetime +# email :string default(""), not null +# encrypted_password :string default(""), not null +# failed_attempts :integer default(0), not null +# locked_at :datetime +# reset_password_sent_at :datetime +# reset_password_token :string +# unconfirmed_email :string +# unlock_token :string +# created_at :datetime not null +# updated_at :datetime not null +# wedding_id :uuid not null +# +# Indexes +# +# index_users_on_confirmation_token (confirmation_token) UNIQUE +# index_users_on_email (email) UNIQUE +# index_users_on_reset_password_token (reset_password_token) UNIQUE +# index_users_on_unlock_token (unlock_token) UNIQUE +# index_users_on_wedding_id (wedding_id) +# +# Foreign Keys +# +# fk_rails_... (wedding_id => weddings.id) +# +class User < ApplicationRecord + acts_as_tenant :wedding + + devise :database_authenticatable, :registerable, + :recoverable, :validatable, :confirmable, :lockable +end diff --git a/app/models/wedding.rb b/app/models/wedding.rb new file mode 100644 index 0000000..5f0dabf --- /dev/null +++ b/app/models/wedding.rb @@ -0,0 +1,24 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +# == Schema Information +# +# Table name: weddings +# +# id :uuid not null, primary key +# slug :string not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_weddings_on_slug (slug) UNIQUE +# +class Wedding < ApplicationRecord + SLUG_REGEX = /[a-z\d-]+/ + + validates :slug, presence: true, uniqueness: true, format: { with: /\A#{SLUG_REGEX}\z/ } + + has_many :guests, dependent: :delete_all +end diff --git a/app/queries/expenses/total_query.rb b/app/queries/expenses/total_query.rb new file mode 100644 index 0000000..ae4fe65 --- /dev/null +++ b/app/queries/expenses/total_query.rb @@ -0,0 +1,50 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +module Expenses + class TotalQuery + private attr_reader :wedding + def initialize(wedding:) + @wedding = wedding + end + + def call + ActiveRecord::Base.connection.execute( + ActiveRecord::Base.sanitize_sql_array([query, { wedding_id: wedding.id }]) + ).first + end + + private + + def query + <<~SQL.squish + WITH guest_count AS (#{guest_count_per_status}), + expense_summary AS (#{expense_summary}) + SELECT guest_count.confirmed as confirmed_guests, + guest_count.projected as projected_guests, + expense_summary.fixed + expense_summary.variable * guest_count.confirmed as total_confirmed, + expense_summary.fixed + expense_summary.variable * guest_count.projected as total_projected + FROM guest_count, expense_summary; + SQL + end + + def expense_summary + <<~SQL.squish + SELECT coalesce(sum(amount) filter (where pricing_type = 'fixed'), 0) as fixed, + coalesce(sum(amount) filter (where pricing_type = 'per_person'), 0) as variable + FROM expenses + WHERE wedding_id = :wedding_id + SQL + end + + def guest_count_per_status + <<~SQL.squish + SELECT COALESCE(count(*) filter(where status = #{Guest.statuses['confirmed']}), 0) as confirmed, + COALESCE(count(*) filter(where status IN (#{Guest.statuses.values_at('confirmed', 'invited', 'tentative').join(',')})), 0) as projected + FROM guests + WHERE wedding_id = :wedding_id + SQL + end + end +end diff --git a/app/queries/groups/summary_query.rb b/app/queries/groups/summary_query.rb new file mode 100644 index 0000000..81c91b4 --- /dev/null +++ b/app/queries/groups/summary_query.rb @@ -0,0 +1,31 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +module Groups + class SummaryQuery + def call + Group.left_joins(:guests).group(:id).pluck_to_hash( + :id, + :name, + :icon, + :parent_id, + :color, + *count_expressions + ) + end + + private + + def count_expressions + [ + Arel.sql('count(*) filter (where status IS NOT NULL) as total'), + Arel.sql('count(*) filter (where status = 0) as considered'), + Arel.sql('count(*) filter (where status = 10) as invited'), + Arel.sql('count(*) filter (where status = 20) as confirmed'), + Arel.sql('count(*) filter (where status = 30) as declined'), + Arel.sql('count(*) filter (where status = 40) as tentative') + ] + end + end +end diff --git a/app/serializers/serializable_group.rb b/app/serializers/serializable_group.rb index f832475..b3e2258 100644 --- a/app/serializers/serializable_group.rb +++ b/app/serializers/serializable_group.rb @@ -1,3 +1,7 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + class SerializableGroup < JSONAPI::Serializable::Resource type 'group' diff --git a/app/serializers/serializable_guest.rb b/app/serializers/serializable_guest.rb index 647c106..ee4bac1 100644 --- a/app/serializers/serializable_guest.rb +++ b/app/serializers/serializable_guest.rb @@ -1,10 +1,14 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + class SerializableGuest < JSONAPI::Serializable::Resource type 'guest' - attributes :id, :email, :group_id, :status + attributes :id, :group_id, :status attribute :name do - "#{@object.first_name} #{@object.last_name}" + @object.name end attribute :group_name do diff --git a/app/services/affinity_groups_hierarchy.rb b/app/services/affinity_groups_hierarchy.rb index af6c62a..cd448de 100644 --- a/app/services/affinity_groups_hierarchy.rb +++ b/app/services/affinity_groups_hierarchy.rb @@ -1,27 +1,77 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + class AffinityGroupsHierarchy < Array - include Singleton + DEFAULT_DISCOMFORT = 1 def initialize super @references = {} + + Group.roots.each do |group| + self << group.id + + hydrate(group) + end + + discomforts + freeze end - def find(name) - @references[name] + def find(id) + @references[id] end - def <<(name) - new_node = Tree::TreeNode.new(name) - super(new_node).tap { @references[name] = new_node } + def <<(id) + new_node = Tree::TreeNode.new(id) + super(new_node).tap { @references[id] = new_node } end - def register_child(parent_name, child_name) - @references[parent_name] << Tree::TreeNode.new(child_name).tap { |child_node| @references[child_name] = child_node } + def register_child(parent_id, child_id) + @references[parent_id] << Tree::TreeNode.new(child_id).tap { |child_node| @references[child_id] = child_node } end - def distance(name_a, name_b) - return nil if @references[name_a].nil? || @references[name_b].nil? + def distance(id_a, id_b) + return nil if @references[id_a].nil? || @references[id_b].nil? - @references[name_a].distance_to_common_ancestor(@references[name_b]) + @references[id_a].distance_to_common_ancestor(@references[id_b]) + end + + def discomfort(id_a, id_b) + return 0 if id_a == id_b + + @discomforts[uuid_to_int(id_a) + uuid_to_int(id_b)] || DEFAULT_DISCOMFORT + end + + def default_discomfort(id_a, id_b) + return 0 if id_a == id_b + + dist = distance(id_a, id_b) + + return DEFAULT_DISCOMFORT if dist.nil? + + Rational(dist, dist + 1) + end + + private + + def discomforts + @discomforts ||= GroupAffinity.pluck(:group_a_id, :group_b_id, + :discomfort).each_with_object({}) do |(id_a, id_b, discomfort), acc| + acc[uuid_to_int(id_a) + uuid_to_int(id_b)] = discomfort + end + end + + def uuid_to_int(uuid) + uuid.gsub('-', '').hex + end + + def hydrate(group) + group.children.each do |child| + register_child(group.id, child.id) + + hydrate(child) + end end end diff --git a/app/services/libre_captcha.rb b/app/services/libre_captcha.rb new file mode 100644 index 0000000..a3843da --- /dev/null +++ b/app/services/libre_captcha.rb @@ -0,0 +1,20 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +class LibreCaptcha + def id + HTTParty.post('http://libre-captcha:8888/v2/captcha', + body: { + input_type: 'text', + level: :hard, + media: 'image/png', + size: '350x100' + }.to_json).then { |raw| JSON.parse(raw)['id'] } + end + + def valid?(id:, answer:) + HTTParty.post('http://libre-captcha:8888/v2/answer', + body: { id:, answer: }.to_json).then { |raw| JSON.parse(raw)['result'] == 'True' } + end +end diff --git a/app/services/tables/discomfort_calculator.rb b/app/services/tables/discomfort_calculator.rb index 69214bb..6a7ebcf 100644 --- a/app/services/tables/discomfort_calculator.rb +++ b/app/services/tables/discomfort_calculator.rb @@ -1,24 +1,54 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + module Tables class DiscomfortCalculator - private attr_reader :table - def initialize(table) + private attr_reader :table, :hierarchy + def initialize(table:, hierarchy: AffinityGroupsHierarchy.new) @table = table + @hierarchy = hierarchy end def calculate - cohesion_penalty + breakdown.values.sum + end + + def breakdown + @breakdown ||= { table_size_penalty:, cohesion_penalty: } end private + # + # Calculates the penalty associated with violating the table size constraints. The penalty is + # zero when the limits are honored, and it increases linearly as the number of guests deviates + # from the limits. Overcapacity is penalized more severely than undercapacity. + # + # @return [Number] The penalty associated with violating the table size constraints. + # + def table_size_penalty + case table.size + when 0...table.min_per_table then 5 * (table.min_per_table - table.size) + when table.min_per_table..table.max_per_table then 0 + else 5 * (table.size - table.max_per_table) + end + end + def cohesion_penalty - table.map { |guest| guest.affinity_group_list.first }.tally.to_a.combination(2).sum do |(a, count_a), (b, count_b)| - distance = AffinityGroupsHierarchy.instance.distance(a, b) + 10 * (cohesion_discomfort * 1.0 / table.size) + end - next count_a * count_b if distance.nil? - next 0 if distance.zero? - - count_a * count_b * Rational(distance, distance + 1) + # + # Calculates the discomfort of the table based on the cohesion of the guests. The total discomfort + # is calculated as the sum of the discomfort of each pair of guests. The discomfort of a pair of + # guests is a rational number between 1 (unrelated groups) and 0 (same group). + # + # @return [Number] Total discomfort of the table. + # + def cohesion_discomfort + table.map(&:group_id).tally.to_a.combination(2).sum do |(a, count_a), (b, count_b)| + count_a * count_b * hierarchy.discomfort(a, b) end end end diff --git a/app/services/tables/distribution.rb b/app/services/tables/distribution.rb index 33cb130..cc51308 100644 --- a/app/services/tables/distribution.rb +++ b/app/services/tables/distribution.rb @@ -1,19 +1,33 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + require_relative '../../extensions/tree_node_extension' module Tables class Distribution - attr_accessor :tables + class << self + def digest(wedding) + Digest::UUID.uuid_v5(wedding.id, wedding.guests.potential.order(:id).pluck(:id).join) + end + end + + attr_accessor :tables, :min_per_table, :max_per_table, :hierarchy def initialize(min_per_table:, max_per_table:) @min_per_table = min_per_table @max_per_table = max_per_table + @hierarchy = AffinityGroupsHierarchy.new @tables = [] end def random_distribution(people) - @tables = [] - - @tables << Table.new(people.slice!(0..rand(@min_per_table..@max_per_table))) while people.any? + min_tables = (people.count * 1.0 / @max_per_table).ceil + max_tables = (people.count * 1.0 / @min_per_table).ceil + @tables = people.in_groups(rand(min_tables..max_tables), false) + .map { |group| Table.new(group) } + .each { |table| table.min_per_table = @min_per_table } + .each { |table| table.max_per_table = @max_per_table } end def discomfort @@ -26,12 +40,6 @@ module Tables "#{@tables.count} tables, discomfort: #{discomfort}" end - def pretty_print - @tables.map.with_index do |table, i| - "Table #{i + 1} (#{table.count} ppl): (#{local_discomfort(table)}) #{table.map(&:full_name).join(', ')}" - end.join("\n") - end - def deep_dup self.class.new(min_per_table: @min_per_table, max_per_table: @max_per_table).tap do |new_distribution| new_distribution.tables = @tables.map(&:dup) @@ -52,14 +60,17 @@ module Tables Seat.insert_all!(records_to_store) - arrangement.update!(discomfort:) + arrangement.update!( + discomfort:, + digest: self.class.digest(tables.first.first.wedding) + ) end end private def local_discomfort(table) - table.discomfort ||= DiscomfortCalculator.new(table).calculate + table.discomfort ||= DiscomfortCalculator.new(table:, hierarchy:).calculate end end end diff --git a/app/services/tables/shift.rb b/app/services/tables/shift.rb new file mode 100644 index 0000000..d5592d9 --- /dev/null +++ b/app/services/tables/shift.rb @@ -0,0 +1,32 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + +module Tables + class Shift + private attr_reader :initial_solution + def initialize(initial_solution) + @initial_solution = initial_solution + end + + def each + @initial_solution.tables.permutation(2) do |table_a, table_b| + table_a.dup.each do |person| + original_discomfort_a = table_a.reset + original_discomfort_b = table_b.reset + + table_a.delete(person) + table_b << person + + yield(@initial_solution) + ensure + table_b.delete(person) + table_a << person + + table_a.discomfort = original_discomfort_a + table_b.discomfort = original_discomfort_b + end + end + end + end +end diff --git a/app/services/tables/swap.rb b/app/services/tables/swap.rb index 631b49b..04abc18 100644 --- a/app/services/tables/swap.rb +++ b/app/services/tables/swap.rb @@ -1,3 +1,7 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + module Tables class Swap private attr_reader :initial_solution @@ -7,7 +11,7 @@ module Tables def each @initial_solution.tables.combination(2) do |table_a, table_b| - table_a.product(table_b).each do |(person_a, person_b)| + table_a.to_a.product(table_b.to_a).each do |(person_a, person_b)| original_discomfort_a = table_a.reset original_discomfort_b = table_b.reset diff --git a/app/services/tables/table.rb b/app/services/tables/table.rb index 1dde7f6..cc05ea0 100644 --- a/app/services/tables/table.rb +++ b/app/services/tables/table.rb @@ -1,6 +1,11 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + module Tables - class Table < Array - attr_accessor :discomfort + class Table < Set + attr_accessor :discomfort, :min_per_table, :max_per_table + def initialize(*args) super reset @@ -12,4 +17,4 @@ module Tables original_discomfort end end -end \ No newline at end of file +end diff --git a/app/services/vns/engine.rb b/app/services/vns/engine.rb index 5c260aa..d66c80b 100644 --- a/app/services/vns/engine.rb +++ b/app/services/vns/engine.rb @@ -1,5 +1,16 @@ +# Copyright (C) 2024-2025 LibreWeddingPlanner contributors + +# frozen_string_literal: true + module VNS class Engine + class << self + def sequence(elements) + elements = elements.to_a + (elements + elements.reverse).chunk(&:itself).map(&:first) + end + end + def target_function(&function) @target_function = function end @@ -19,29 +30,31 @@ module VNS @best_solution = @initial_solution @best_score = @target_function.call(@best_solution) - puts "Initial score: #{@best_score.to_f}" - - @perturbations.each do |perturbation| - puts "Running perturbation: #{perturbation.name}" - optimize(perturbation.new(@best_solution)) + self.class.sequence(@perturbations).each do |perturbation| + optimize(perturbation) end - @best_solution + @best_solution end private - def optimize(perturbation) - perturbation.each do |alternative_solution| - score = @target_function.call(alternative_solution) - next if score >= @best_score + def optimize(perturbation_klass) + loop do + optimized = false - @best_solution = alternative_solution.deep_dup - @best_score = score + perturbation_klass.new(@best_solution).each do |alternative_solution| + score = @target_function.call(alternative_solution) + next if score >= @best_score - puts "New lowest score: #{@best_score.to_f}" + @best_solution = alternative_solution.deep_dup + @best_score = score + optimized = true - return optimize(perturbation.class.new(@best_solution)) + break + end + + return unless optimized end end end diff --git a/app/views/expenses/_expense.html.erb b/app/views/expenses/_expense.html.erb deleted file mode 100644 index 6581cb7..0000000 --- a/app/views/expenses/_expense.html.erb +++ /dev/null @@ -1,17 +0,0 @@ -
-

- Name: - <%= expense.name %> -

- -

- Amount: - <%= expense.amount %> -

- -

- Pricing type: - <%= expense.pricing_type %> -

- -
diff --git a/app/views/expenses/_form.html.erb b/app/views/expenses/_form.html.erb deleted file mode 100644 index 543a3ef..0000000 --- a/app/views/expenses/_form.html.erb +++ /dev/null @@ -1,32 +0,0 @@ -<%= form_with(model: expense) do |form| %> - <% if expense.errors.any? %> -
-

<%= pluralize(expense.errors.count, "error") %> prohibited this expense from being saved:

- -
    - <% expense.errors.each do |error| %> -
  • <%= error.full_message %>
  • - <% end %> -
-
- <% end %> - -
- <%= form.label :name, style: "display: block" %> - <%= form.text_field :name %> -
- -
- <%= form.label :amount, style: "display: block" %> - <%= form.text_field :amount %> -
- -
- <%= form.label :pricing_type, style: "display: block" %> - <%= form.text_field :pricing_type %> -
- -
- <%= form.submit %> -
-<% end %> diff --git a/app/views/expenses/edit.html.erb b/app/views/expenses/edit.html.erb deleted file mode 100644 index 75bab6f..0000000 --- a/app/views/expenses/edit.html.erb +++ /dev/null @@ -1,10 +0,0 @@ -

Editing expense

- -<%= render "form", expense: @expense %> - -
- -
- <%= link_to "Show this expense", @expense %> | - <%= link_to "Back to expenses", expenses_path %> -
diff --git a/app/views/expenses/index.html.erb b/app/views/expenses/index.html.erb deleted file mode 100644 index e63f662..0000000 --- a/app/views/expenses/index.html.erb +++ /dev/null @@ -1,28 +0,0 @@ -

<%= notice %>

- -

Expenses

- -
- - - - - - - <% @expenses.each do |expense| %> - - - - - - - <% end %> - - - - - -
NameAmount
<%= expense.name %><%= expense.amount.to_currency %><%= link_to "Show", expense %><%= link_to "Edit", edit_expense_path(expense) %>
Total<%= @expenses.sum(&:amount).to_currency %>
-
- -<%= link_to "New expense", new_expense_path %> diff --git a/app/views/expenses/new.html.erb b/app/views/expenses/new.html.erb deleted file mode 100644 index 17d6299..0000000 --- a/app/views/expenses/new.html.erb +++ /dev/null @@ -1,9 +0,0 @@ -

New expense

- -<%= render "form", expense: @expense %> - -
- -
- <%= link_to "Back to expenses", expenses_path %> -
diff --git a/app/views/expenses/show.html.erb b/app/views/expenses/show.html.erb deleted file mode 100644 index 2262a0e..0000000 --- a/app/views/expenses/show.html.erb +++ /dev/null @@ -1,10 +0,0 @@ -

<%= notice %>

- -<%= render @expense %> - -
- <%= link_to "Edit this expense", edit_expense_path(@expense) %> | - <%= link_to "Back to expenses", expenses_path %> - - <%= button_to "Destroy this expense", @expense, method: :delete %> -
diff --git a/app/views/guests/_form.html.erb b/app/views/guests/_form.html.erb deleted file mode 100644 index eaea454..0000000 --- a/app/views/guests/_form.html.erb +++ /dev/null @@ -1,37 +0,0 @@ -<%= form_with(model: guest) do |form| %> - <% if guest.errors.any? %> -
-

<%= pluralize(guest.errors.count, "error") %> prohibited this guest from being saved:

- -
    - <% guest.errors.each do |error| %> -
  • <%= error.full_message %>
  • - <% end %> -
-
- <% end %> - -
- <%= form.label :first_name, style: "display: block" %> - <%= form.text_field :first_name %> -
- -
- <%= form.label :last_name, style: "display: block" %> - <%= form.text_field :last_name %> -
- -
- <%= form.label :email, style: "display: block" %> - <%= form.text_field :email %> -
- -
- <%= form.label :phone, style: "display: block" %> - <%= form.text_field :phone %> -
- -
- <%= form.submit %> -
-<% end %> diff --git a/app/views/guests/_guest.html.erb b/app/views/guests/_guest.html.erb deleted file mode 100644 index dfd51e9..0000000 --- a/app/views/guests/_guest.html.erb +++ /dev/null @@ -1,22 +0,0 @@ -
-

- First name: - <%= guest.first_name %> -

- -

- Last name: - <%= guest.last_name %> -

- -

- Email: - <%= guest.email %> -

- -

- Phone: - <%= guest.phone %> -

- -
diff --git a/app/views/guests/edit.html.erb b/app/views/guests/edit.html.erb deleted file mode 100644 index a024444..0000000 --- a/app/views/guests/edit.html.erb +++ /dev/null @@ -1,10 +0,0 @@ -

Editing guest

- -<%= render "form", guest: @guest %> - -
- -
- <%= link_to "Show this guest", @guest %> | - <%= link_to "Back to guests", guests_path %> -
diff --git a/app/views/guests/index.html.erb b/app/views/guests/index.html.erb deleted file mode 100644 index d71cfa8..0000000 --- a/app/views/guests/index.html.erb +++ /dev/null @@ -1,37 +0,0 @@ -

<%= notice %>

- -

Guests

- -
- - - - - - - - - - - <% @guests.each_with_index do |guest, i| %> - - - - - - - - - - - <% end %> -
Row #NameEmailPhoneAffinity groupsUnbreakable 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) %>
-
- -<%= link_to "New guest", new_guest_path %> - -<%= form_with url: import_guests_path, method: :post do |form| %> - <%= form.label :file %> - <%= form.file_field :file %> - <%= form.submit "Import" %> -<% end %> diff --git a/app/views/guests/new.html.erb b/app/views/guests/new.html.erb deleted file mode 100644 index d05ae72..0000000 --- a/app/views/guests/new.html.erb +++ /dev/null @@ -1,9 +0,0 @@ -

New guest

- -<%= render "form", guest: @guest %> - -
- -
- <%= link_to "Back to guests", guests_path %> -
diff --git a/app/views/guests/show.html.erb b/app/views/guests/show.html.erb deleted file mode 100644 index bcfad6e..0000000 --- a/app/views/guests/show.html.erb +++ /dev/null @@ -1,10 +0,0 @@ -

<%= notice %>

- -<%= render @guest %> - -
- <%= link_to "Edit this guest", edit_guest_path(@guest) %> | - <%= link_to "Back to guests", guests_path %> - - <%= button_to "Destroy this guest", @guest, method: :delete %> -
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb deleted file mode 100644 index d68b671..0000000 --- a/app/views/layouts/application.html.erb +++ /dev/null @@ -1,16 +0,0 @@ - - - - WeddingPlanner - - <%= csrf_meta_tags %> - <%= csp_meta_tag %> - - <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> - <%= javascript_importmap_tags %> - - - - <%= yield %> - - diff --git a/app/views/layouts/mailer.html.erb b/app/views/layouts/mailer.html.erb index 3aac900..32b0c16 100644 --- a/app/views/layouts/mailer.html.erb +++ b/app/views/layouts/mailer.html.erb @@ -1,3 +1,5 @@ +<%# Copyright (C) 2024-2025 LibreWeddingPlanner contributors %> + diff --git a/app/views/layouts/mailer.text.erb b/app/views/layouts/mailer.text.erb index 37f0bdd..9bdce1a 100644 --- a/app/views/layouts/mailer.text.erb +++ b/app/views/layouts/mailer.text.erb @@ -1 +1,3 @@ +<%# Copyright (C) 2024-2025 LibreWeddingPlanner contributors %> + <%= yield %> diff --git a/app/views/tables_arrangements/index.html.erb b/app/views/tables_arrangements/index.html.erb deleted file mode 100644 index 260e144..0000000 --- a/app/views/tables_arrangements/index.html.erb +++ /dev/null @@ -1,9 +0,0 @@ -

Tables arrangements

- -
    - <% @tables_arrangements.each_with_index do |tables_arrangement, i| %> -
  1. -

    <%= link_to "Arrangement ##{i+1}", tables_arrangement_path(tables_arrangement) %> Discomfort: <%= tables_arrangement.discomfort %>

    -
  2. - <% end %> -
\ No newline at end of file diff --git a/app/views/tables_arrangements/show.html.erb b/app/views/tables_arrangements/show.html.erb deleted file mode 100644 index a2b9aae..0000000 --- a/app/views/tables_arrangements/show.html.erb +++ /dev/null @@ -1,16 +0,0 @@ -

ID: <%= @tables_arrangement.id %>

- -

Discomfort: <%= @tables_arrangement.discomfort %>

- -

Seats

- -<% @seats.each do |table_number, seats| %> - -

Table <%= table_number %>

- -
    - <% seats.each do |seat| %> -
  • <%= seat.guest.full_name %> (<%= seat.guest.affinity_groups.pluck(:name).join(", ") %>)
  • - <% end %> -
-<% end %> \ No newline at end of file diff --git a/app/views/users/mailer/confirmation_instructions.html.erb b/app/views/users/mailer/confirmation_instructions.html.erb new file mode 100644 index 0000000..cd3b2f1 --- /dev/null +++ b/app/views/users/mailer/confirmation_instructions.html.erb @@ -0,0 +1,7 @@ +<%# Copyright (C) 2024-2025 LibreWeddingPlanner contributors %> + +

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