├── .env.test ├── .github └── workflows │ └── hanami.yml ├── .gitignore ├── .rspec ├── .standard.yml ├── Gemfile ├── Gemfile.lock ├── Guardfile ├── Procfile.dev ├── README.md ├── Rakefile ├── app ├── assets │ ├── css │ │ ├── app.css │ │ └── bulma.scss │ └── js │ │ └── app.js └── db │ ├── relation.rb │ ├── relations │ ├── accounts.rb │ ├── categories.rb │ ├── messages.rb │ ├── profiles.rb │ └── threads.rb │ └── repo.rb ├── bin └── dev ├── config.ru ├── config ├── app.rb ├── assets.js ├── db │ ├── migrate │ │ ├── 001_create_categories.rb │ │ ├── 002_create_threads.rb │ │ ├── 003_create_messages.rb │ │ ├── 004_create_profiles.rb │ │ ├── 005_create_accounts.rb │ │ ├── 006_add_avatar.rb │ │ └── 007_add_thread_pinning_and_locking.rb │ └── seeds.rb ├── providers │ └── uploader.rb ├── puma.rb ├── routes.rb └── settings.rb ├── lib ├── hanami_extensions.rb ├── palaver │ ├── action.rb │ ├── avatar_uploader.rb │ ├── entity.rb │ ├── repository.rb │ ├── types.rb │ └── view.rb ├── tasks │ └── .keep ├── ui.rb └── ui │ ├── components │ ├── columns.rb │ ├── form.rb │ └── form │ │ ├── field.rb │ │ ├── horizontal_field.rb │ │ └── horizontal_submit.rb │ ├── form.rb │ ├── layout.rb │ └── typography.rb ├── package-lock.json ├── package.json ├── public └── .keep ├── slices ├── account │ ├── action.rb │ ├── actions │ │ ├── .keep │ │ ├── registration │ │ │ ├── confirm.rb │ │ │ ├── create.rb │ │ │ └── new.rb │ │ ├── settings │ │ │ ├── save.rb │ │ │ └── show.rb │ │ └── sign_in │ │ │ ├── create.rb │ │ │ ├── destroy.rb │ │ │ └── new.rb │ ├── config │ │ └── routes.rb │ ├── entities │ │ ├── anonymous_user.rb │ │ ├── current_user.rb │ │ └── settings.rb │ ├── operation.rb │ ├── operations │ │ ├── confirm_user.rb │ │ ├── register.rb │ │ ├── sign_in.rb │ │ └── update_account.rb │ ├── relations │ │ ├── accounts.rb │ │ └── profiles.rb │ ├── repositories │ │ ├── account.rb │ │ └── profile.rb │ ├── utils │ │ └── hasher.rb │ └── views │ │ ├── registration │ │ ├── after_create.rb │ │ └── new.rb │ │ ├── settings │ │ └── show.rb │ │ └── sign_in │ │ └── new.rb ├── discussion │ ├── access_control.rb │ ├── action.rb │ ├── actions │ │ ├── .keep │ │ ├── category │ │ │ └── show.rb │ │ ├── home │ │ │ ├── index.rb │ │ │ ├── new_threads.rb │ │ │ └── recent.rb │ │ ├── profile │ │ │ └── show.rb │ │ └── thread │ │ │ ├── reply.rb │ │ │ └── show.rb │ ├── config │ │ └── routes.rb │ ├── domain │ │ └── threads.rb │ ├── entities │ │ ├── author.rb │ │ ├── category.rb │ │ ├── current_user.rb │ │ ├── message.rb │ │ ├── profile.rb │ │ └── thread.rb │ ├── events │ │ ├── reply_added_to_thread.rb │ │ └── thread_created.rb │ ├── queries │ │ ├── homepage_categories.rb │ │ ├── homepage_new_threads.rb │ │ ├── homepage_recent.rb │ │ ├── thread_messages_page.rb │ │ └── threads_in_category.rb │ ├── relations │ │ ├── accounts.rb │ │ ├── categories.rb │ │ ├── messages.rb │ │ ├── profiles.rb │ │ └── threads.rb │ ├── repositories │ │ ├── category.rb │ │ ├── profile.rb │ │ └── thread.rb │ ├── utils │ │ ├── slug_provider.rb │ │ └── slugger.rb │ └── views │ │ ├── category │ │ └── show.rb │ │ ├── home │ │ ├── components │ │ │ └── tabs.rb │ │ ├── index.rb │ │ ├── new_threads.rb │ │ ├── partials │ │ │ └── category.rb │ │ └── recent.rb │ │ ├── profile │ │ └── show.rb │ │ ├── shared │ │ ├── components │ │ │ └── no_profile_warning.rb │ │ └── partials │ │ │ └── thread_row.rb │ │ └── thread │ │ └── show.rb └── moderation │ ├── action.rb │ ├── actions │ ├── .keep │ └── thread │ │ └── pin.rb │ ├── config │ └── routes.rb │ ├── domain │ └── threads.rb │ ├── entities │ └── thread.rb │ ├── events │ ├── thread_locked.rb │ ├── thread_pinned.rb │ ├── thread_unlocked.rb │ └── thread_unpinned.rb │ ├── relations │ ├── categories.rb │ └── threads.rb │ └── repositories │ └── thread.rb └── spec ├── palaver └── entity_spec.rb ├── requests ├── account │ ├── registration │ │ ├── create_spec.rb │ │ └── new_spec.rb │ ├── settings │ │ ├── save_spec.rb │ │ └── show_spec.rb │ └── sign_in │ │ ├── create_spec.rb │ │ ├── destroy_spec.rb │ │ └── new_spec.rb ├── discussion │ ├── categories │ │ └── show_spec.rb │ ├── home │ │ ├── new_threads_spec.rb │ │ └── recent_spec.rb │ ├── home_spec.rb │ ├── profile │ │ └── show_spec.rb │ └── threads │ │ ├── reply_spec.rb │ │ └── show_spec.rb └── root_spec.rb ├── slices ├── account │ ├── actions │ │ ├── .keep │ │ └── registration │ │ │ └── create_spec.rb │ ├── entities │ │ └── current_user_spec.rb │ └── operations │ │ ├── confirm_user_spec.rb │ │ └── register_spec.rb ├── discussion │ ├── access_control_spec.rb │ ├── action_spec.rb │ ├── actions │ │ └── category │ │ │ └── show_spec.rb │ ├── domain │ │ └── threads_spec.rb │ ├── entities │ │ └── current_user_spec.rb │ ├── repositories │ │ └── thread_spec.rb │ ├── utils │ │ └── slugger_spec.rb │ └── views │ │ ├── home │ │ └── partials │ │ │ └── category_spec.rb │ │ └── shared │ │ ├── components │ │ └── no_profile_warning_spec.rb │ │ └── partials │ │ └── thread_row_spec.rb └── moderation │ ├── actions │ └── .keep │ ├── domain │ └── threads_spec.rb │ └── repositories │ └── thread_spec.rb ├── spec_helper.rb └── support ├── component_testing.rb ├── database_cleaner.rb ├── files └── cat_small.jpg ├── fixtures ├── account.rb ├── discussion.rb └── moderation.rb ├── helpers.rb ├── requests.rb ├── rspec.rb └── uploads.rb /.env.test: -------------------------------------------------------------------------------- 1 | DATABASE_URL=postgres://postgres@localhost/palaver_test 2 | SECRET=test 3 | HASHIDS_SALT=this_is_the_salt 4 | -------------------------------------------------------------------------------- /.github/workflows/hanami.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | # This workflow will download a prebuilt Ruby version, install dependencies and run tests with Rake 6 | # For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby 7 | 8 | name: Hanami 9 | 10 | on: 11 | push: 12 | branches: [ "main" ] 13 | pull_request: 14 | branches: [ "main" ] 15 | 16 | permissions: 17 | contents: read 18 | 19 | jobs: 20 | test: 21 | env: 22 | HANAMI_ENV: test 23 | DATABASE_URL: postgres://palaver:password@localhost/palaver_test 24 | 25 | runs-on: ubuntu-latest 26 | strategy: 27 | matrix: 28 | ruby-version: ['3.2', '3.3', '3.4'] 29 | 30 | services: 31 | postgres: 32 | image: postgres:12.7 33 | env: 34 | POSTGRES_USER: palaver 35 | POSTGRES_DB: palaver_test 36 | POSTGRES_PASSWORD: password 37 | ports: ["5432:5432"] 38 | 39 | steps: 40 | - uses: actions/checkout@v3 41 | - name: Set up Ruby 42 | # To automatically get bug fixes and new Ruby versions for ruby/setup-ruby, 43 | # change this to (see https://github.com/ruby/setup-ruby#versioning): 44 | # uses: ruby/setup-ruby@v1 45 | uses: ruby/setup-ruby@v1 46 | with: 47 | ruby-version: ${{ matrix.ruby-version }} 48 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 49 | - name: Setup test database 50 | run: bundle exec hanami db migrate --no-dump 51 | - name: npm install 52 | run: npm install 53 | - name: Compile assets 54 | run: bundle exec hanami assets compile 55 | - name: Run tests 56 | run: bundle exec rspec 57 | - name: Upload coverage reports to Codecov 58 | uses: codecov/codecov-action@v3 59 | env: 60 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 61 | - name: Check style 62 | run: bundle exec standardrb 63 | - name: Run seeds 64 | run: bundle exec rake db:seeds 65 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .bundle 2 | .env 3 | log 4 | db/*.sqlite 5 | coverage 6 | .dir-locals.el 7 | public 8 | /node_modules 9 | config/db/structure.sql 10 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | -------------------------------------------------------------------------------- /.standard.yml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/katafrakt/palaver/a69196dcb7e1f44583ca3fb262b28aa33e7041c5/.standard.yml -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rake" 6 | 7 | HANAMI_VERSION = "2.2.0" 8 | gem "hanami", HANAMI_VERSION 9 | gem "hanami-controller", HANAMI_VERSION 10 | gem "hanami-router", HANAMI_VERSION 11 | gem "hanami-utils", HANAMI_VERSION 12 | gem "hanami-validations", HANAMI_VERSION 13 | gem "hanami-assets", HANAMI_VERSION 14 | gem "hanami-db", "2.2.1" 15 | 16 | gem "argon2", "~> 2.1" 17 | gem "dry-monads" 18 | gem "dry-types", "~> 1.6" 19 | gem "pg" 20 | gem "phlex", "~> 2.3.0" 21 | gem "puma" 22 | gem "shrine", "~> 3.6" 23 | gem "marcel", "~> 0.3" 24 | gem "shrine-rom" 25 | gem "verifica" 26 | gem "hashids" 27 | gem "base64" 28 | gem "stringex" 29 | gem "dry-operation", "~> 1.0" 30 | gem "strict_ivars", require: false 31 | 32 | group :cli, :development do 33 | gem "hanami-reloader" 34 | end 35 | 36 | group :cli, :development, :test do 37 | gem "dotenv", "~> 2.7" 38 | gem "faker" 39 | gem "hanami-rspec", HANAMI_VERSION 40 | gem "standard" 41 | end 42 | 43 | group :development do 44 | gem "guard-puma" 45 | end 46 | 47 | group :test do 48 | gem "database_cleaner-sequel" 49 | gem "nokolexbor" 50 | gem "rack-test" 51 | gem "rom-factory" 52 | gem "simplecov", require: false 53 | gem "simplecov-cobertura" 54 | end 55 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | addressable (2.8.6) 5 | public_suffix (>= 2.0.2, < 6.0) 6 | argon2 (2.3.0) 7 | ffi (~> 1.15) 8 | ffi-compiler (~> 1.0) 9 | ast (2.4.2) 10 | base64 (0.2.0) 11 | bigdecimal (3.1.9) 12 | coderay (1.1.3) 13 | concurrent-ruby (1.3.5) 14 | content_disposition (1.0.0) 15 | database_cleaner-core (2.0.1) 16 | database_cleaner-sequel (2.0.2) 17 | database_cleaner-core (~> 2.0.0) 18 | sequel 19 | diff-lcs (1.5.1) 20 | docile (1.4.0) 21 | dotenv (2.8.1) 22 | down (5.4.2) 23 | addressable (~> 2.8) 24 | dry-auto_inject (1.0.1) 25 | dry-core (~> 1.0) 26 | zeitwerk (~> 2.6) 27 | dry-cli (1.1.0) 28 | dry-configurable (1.3.0) 29 | dry-core (~> 1.1) 30 | zeitwerk (~> 2.6) 31 | dry-core (1.1.0) 32 | concurrent-ruby (~> 1.0) 33 | logger 34 | zeitwerk (~> 2.6) 35 | dry-events (1.0.1) 36 | concurrent-ruby (~> 1.0) 37 | dry-core (~> 1.0, < 2) 38 | dry-files (1.0.2) 39 | dry-inflector (1.2.0) 40 | dry-initializer (3.2.0) 41 | dry-logger (1.0.4) 42 | dry-logic (1.6.0) 43 | bigdecimal 44 | concurrent-ruby (~> 1.0) 45 | dry-core (~> 1.1) 46 | zeitwerk (~> 2.6) 47 | dry-monads (1.6.0) 48 | concurrent-ruby (~> 1.0) 49 | dry-core (~> 1.0, < 2) 50 | zeitwerk (~> 2.6) 51 | dry-monitor (1.0.1) 52 | dry-configurable (~> 1.0, < 2) 53 | dry-core (~> 1.0, < 2) 54 | dry-events (~> 1.0, < 2) 55 | dry-operation (1.0.0) 56 | dry-monads (~> 1.6) 57 | zeitwerk (~> 2.6) 58 | dry-schema (1.13.4) 59 | concurrent-ruby (~> 1.0) 60 | dry-configurable (~> 1.0, >= 1.0.1) 61 | dry-core (~> 1.0, < 2) 62 | dry-initializer (~> 3.0) 63 | dry-logic (>= 1.4, < 2) 64 | dry-types (>= 1.7, < 2) 65 | zeitwerk (~> 2.6) 66 | dry-struct (1.7.1) 67 | dry-core (~> 1.1) 68 | dry-types (~> 1.8, >= 1.8.2) 69 | ice_nine (~> 0.11) 70 | zeitwerk (~> 2.6) 71 | dry-system (1.1.1) 72 | dry-auto_inject (~> 1.0) 73 | dry-configurable (~> 1.0) 74 | dry-core (~> 1.0) 75 | dry-inflector (~> 1.0) 76 | dry-transformer (1.0.1) 77 | zeitwerk (~> 2.6) 78 | dry-types (1.8.2) 79 | bigdecimal (~> 3.0) 80 | concurrent-ruby (~> 1.0) 81 | dry-core (~> 1.0) 82 | dry-inflector (~> 1.0) 83 | dry-logic (~> 1.4) 84 | zeitwerk (~> 2.6) 85 | dry-validation (1.10.0) 86 | concurrent-ruby (~> 1.0) 87 | dry-core (~> 1.0, < 2) 88 | dry-initializer (~> 3.0) 89 | dry-schema (>= 1.12, < 2) 90 | zeitwerk (~> 2.6) 91 | faker (2.23.0) 92 | i18n (>= 1.8.11, < 2) 93 | ffi (1.16.3) 94 | ffi-compiler (1.0.1) 95 | ffi (>= 1.0.0) 96 | rake 97 | formatador (1.1.0) 98 | guard (2.18.1) 99 | formatador (>= 0.2.4) 100 | listen (>= 2.7, < 4.0) 101 | lumberjack (>= 1.0.12, < 2.0) 102 | nenv (~> 0.1) 103 | notiffany (~> 0.0) 104 | pry (>= 0.13.0) 105 | shellany (~> 0.0) 106 | thor (>= 0.18.1) 107 | guard-compat (1.2.1) 108 | guard-puma (0.8.1) 109 | guard (~> 2.14) 110 | guard-compat (~> 1.2) 111 | puma (>= 4.0, < 7) 112 | hanami (2.2.0) 113 | bundler (>= 1.16, < 3) 114 | dry-configurable (~> 1.0, >= 1.2.0, < 2) 115 | dry-core (~> 1.0, < 2) 116 | dry-inflector (~> 1.0, >= 1.1.0, < 2) 117 | dry-logger (~> 1.0, < 2) 118 | dry-monitor (~> 1.0, >= 1.0.1, < 2) 119 | dry-system (~> 1.1) 120 | hanami-cli (~> 2.2) 121 | hanami-utils (~> 2.2) 122 | json (>= 2.7.2) 123 | zeitwerk (~> 2.6) 124 | hanami-assets (2.2.0) 125 | zeitwerk (~> 2.6) 126 | hanami-cli (2.2.1) 127 | bundler (~> 2.1) 128 | dry-cli (~> 1.0, >= 1.1.0) 129 | dry-files (~> 1.0, >= 1.0.2, < 2) 130 | dry-inflector (~> 1.0, < 2) 131 | rake (~> 13.0) 132 | zeitwerk (~> 2.6) 133 | hanami-controller (2.2.0) 134 | dry-configurable (~> 1.0, < 2) 135 | dry-core (~> 1.0) 136 | hanami-utils (~> 2.2) 137 | rack (~> 2.0) 138 | zeitwerk (~> 2.6) 139 | hanami-db (2.2.1) 140 | rom (~> 5.4, >= 5.4.1) 141 | rom-sql (~> 3.7) 142 | zeitwerk (~> 2.6) 143 | hanami-reloader (2.1.0) 144 | hanami-cli (~> 2.1) 145 | zeitwerk (~> 2.6) 146 | hanami-router (2.2.0) 147 | mustermann (~> 3.0) 148 | mustermann-contrib (~> 3.0) 149 | rack (~> 2.0) 150 | hanami-rspec (2.2.0) 151 | hanami-cli (~> 2.2) 152 | rake (~> 13.0) 153 | rspec (~> 3.12) 154 | zeitwerk (~> 2.6) 155 | hanami-utils (2.2.0) 156 | concurrent-ruby (~> 1.0) 157 | dry-core (~> 1.0, < 2) 158 | dry-transformer (~> 1.0, < 2) 159 | hanami-validations (2.2.0) 160 | dry-validation (>= 1.10, < 2) 161 | hansi (0.2.1) 162 | hashids (1.0.6) 163 | i18n (1.14.1) 164 | concurrent-ruby (~> 1.0) 165 | ice_nine (0.11.2) 166 | json (2.10.1) 167 | language_server-protocol (3.17.0.4) 168 | lint_roller (1.1.0) 169 | listen (3.8.0) 170 | rb-fsevent (~> 0.10, >= 0.10.3) 171 | rb-inotify (~> 0.9, >= 0.9.10) 172 | logger (1.6.6) 173 | lumberjack (1.2.9) 174 | marcel (0.3.3) 175 | mimemagic (~> 0.3.2) 176 | method_source (1.0.0) 177 | mimemagic (0.3.10) 178 | nokogiri (~> 1) 179 | rake 180 | mustermann (3.0.3) 181 | ruby2_keywords (~> 0.0.1) 182 | mustermann-contrib (3.0.3) 183 | hansi (~> 0.2.0) 184 | mustermann (= 3.0.3) 185 | nenv (0.3.0) 186 | nio4r (2.5.9) 187 | nokogiri (1.18.8-x86_64-linux-gnu) 188 | racc (~> 1.4) 189 | nokolexbor (0.6.0-x86_64-linux) 190 | notiffany (0.1.3) 191 | nenv (~> 0.1) 192 | shellany (~> 0.0) 193 | parallel (1.26.3) 194 | parser (3.3.7.1) 195 | ast (~> 2.4.1) 196 | racc 197 | pg (1.5.4) 198 | phlex (2.3.0) 199 | zeitwerk (~> 2.7) 200 | prism (1.4.0) 201 | pry (0.14.2) 202 | coderay (~> 1.1) 203 | method_source (~> 1.0) 204 | public_suffix (5.0.5) 205 | puma (6.4.0) 206 | nio4r (~> 2.0) 207 | racc (1.8.1) 208 | rack (2.2.14) 209 | rack-test (2.1.0) 210 | rack (>= 1.3) 211 | rainbow (3.1.1) 212 | rake (13.0.6) 213 | rb-fsevent (0.11.2) 214 | rb-inotify (0.10.1) 215 | ffi (~> 1.0) 216 | regexp_parser (2.10.0) 217 | require-hooks (0.2.2) 218 | rexml (3.4.1) 219 | rom (5.4.2) 220 | rom-changeset (~> 5.4) 221 | rom-core (~> 5.4) 222 | rom-repository (~> 5.4, >= 5.4.2) 223 | rom-changeset (5.4.0) 224 | dry-core (~> 1.0) 225 | rom-core (~> 5.4) 226 | transproc (~> 1.1) 227 | rom-core (5.4.0) 228 | concurrent-ruby (~> 1.1) 229 | dry-configurable (~> 1.0) 230 | dry-core (~> 1.0) 231 | dry-inflector (~> 1.0) 232 | dry-initializer (~> 3.2) 233 | dry-struct (~> 1.0) 234 | dry-types (~> 1.6) 235 | transproc (~> 1.1) 236 | rom-factory (0.12.0) 237 | dry-configurable (~> 1.0) 238 | dry-core (~> 1.0) 239 | dry-struct (~> 1.6) 240 | faker (>= 2.0, < 4) 241 | rom-core (~> 5.3) 242 | rom-repository (5.4.2) 243 | dry-core (~> 1.0) 244 | dry-initializer (~> 3.2) 245 | rom-core (~> 5.4) 246 | rom-sql (3.7.0) 247 | dry-core (~> 1.1) 248 | dry-types (~> 1.8) 249 | rom (~> 5.4) 250 | sequel (>= 4.49) 251 | rspec (3.13.0) 252 | rspec-core (~> 3.13.0) 253 | rspec-expectations (~> 3.13.0) 254 | rspec-mocks (~> 3.13.0) 255 | rspec-core (3.13.2) 256 | rspec-support (~> 3.13.0) 257 | rspec-expectations (3.13.3) 258 | diff-lcs (>= 1.2.0, < 2.0) 259 | rspec-support (~> 3.13.0) 260 | rspec-mocks (3.13.2) 261 | diff-lcs (>= 1.2.0, < 2.0) 262 | rspec-support (~> 3.13.0) 263 | rspec-support (3.13.2) 264 | rubocop (1.71.2) 265 | json (~> 2.3) 266 | language_server-protocol (>= 3.17.0) 267 | parallel (~> 1.10) 268 | parser (>= 3.3.0.2) 269 | rainbow (>= 2.2.2, < 4.0) 270 | regexp_parser (>= 2.9.3, < 3.0) 271 | rubocop-ast (>= 1.38.0, < 2.0) 272 | ruby-progressbar (~> 1.7) 273 | unicode-display_width (>= 2.4.0, < 4.0) 274 | rubocop-ast (1.38.0) 275 | parser (>= 3.3.1.0) 276 | rubocop-performance (1.23.1) 277 | rubocop (>= 1.48.1, < 2.0) 278 | rubocop-ast (>= 1.31.1, < 2.0) 279 | ruby-progressbar (1.13.0) 280 | ruby2_keywords (0.0.5) 281 | sequel (5.89.0) 282 | bigdecimal 283 | shellany (0.0.1) 284 | shrine (3.6.0) 285 | content_disposition (~> 1.0) 286 | down (~> 5.1) 287 | shrine-rom (0.1.0) 288 | rom (~> 5.0) 289 | shrine (~> 3.0) 290 | simplecov (0.22.0) 291 | docile (~> 1.1) 292 | simplecov-html (~> 0.11) 293 | simplecov_json_formatter (~> 0.1) 294 | simplecov-cobertura (2.1.0) 295 | rexml 296 | simplecov (~> 0.19) 297 | simplecov-html (0.12.3) 298 | simplecov_json_formatter (0.1.4) 299 | standard (1.45.0) 300 | language_server-protocol (~> 3.17.0.2) 301 | lint_roller (~> 1.0) 302 | rubocop (~> 1.71.0) 303 | standard-custom (~> 1.0.0) 304 | standard-performance (~> 1.6) 305 | standard-custom (1.0.2) 306 | lint_roller (~> 1.0) 307 | rubocop (~> 1.50) 308 | standard-performance (1.6.0) 309 | lint_roller (~> 1.1) 310 | rubocop-performance (~> 1.23.0) 311 | strict_ivars (0.6.0) 312 | prism 313 | require-hooks (~> 0.2) 314 | stringex (2.8.6) 315 | thor (1.2.2) 316 | transproc (1.1.1) 317 | unicode-display_width (3.1.4) 318 | unicode-emoji (~> 4.0, >= 4.0.4) 319 | unicode-emoji (4.0.4) 320 | verifica (1.0.1) 321 | zeitwerk (2.7.1) 322 | 323 | PLATFORMS 324 | x86_64-linux 325 | 326 | DEPENDENCIES 327 | argon2 (~> 2.1) 328 | base64 329 | database_cleaner-sequel 330 | dotenv (~> 2.7) 331 | dry-monads 332 | dry-operation (~> 1.0) 333 | dry-types (~> 1.6) 334 | faker 335 | guard-puma 336 | hanami (= 2.2.0) 337 | hanami-assets (= 2.2.0) 338 | hanami-controller (= 2.2.0) 339 | hanami-db (= 2.2.1) 340 | hanami-reloader 341 | hanami-router (= 2.2.0) 342 | hanami-rspec (= 2.2.0) 343 | hanami-utils (= 2.2.0) 344 | hanami-validations (= 2.2.0) 345 | hashids 346 | marcel (~> 0.3) 347 | nokolexbor 348 | pg 349 | phlex (~> 2.3.0) 350 | puma 351 | rack-test 352 | rake 353 | rom-factory 354 | shrine (~> 3.6) 355 | shrine-rom 356 | simplecov 357 | simplecov-cobertura 358 | standard 359 | strict_ivars 360 | stringex 361 | verifica 362 | 363 | BUNDLED WITH 364 | 2.5.9 365 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | group :server do 4 | guard "puma", port: ENV["HANAMI_PORT"] || 2300 do 5 | watch(%r{config/*}) 6 | watch(%r{lib/*}) 7 | watch(%r{app/*}) 8 | watch(%r{slices/*}) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /Procfile.dev: -------------------------------------------------------------------------------- 1 | web: bundle exec hanami server 2 | assets: bundle exec hanami assets watch 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Palaver 2 | 3 | This is my test application in Hanami 2. It attempts to replicate an experience of old-times discussion boards (think phpBB), while at the same time being a testing ground for standard features of a web app. 4 | 5 | ## Technical features 6 | 7 | * Persistence – [ROM](https://rom-rb.org/) 8 | * View layer – [Phlex](https://phlex.fun) 9 | * User registration and authentication – custom-made, password hashing with Argon2, using native Hanami sessions 10 | * Authorization – with [Verifica](https://github.com/maximgurin/verifica) 11 | * Pagination – with [ROM pagination plugin](https://github.com/rom-rb/rom-sql/blob/main/lib/rom/sql/plugin/pagination.rb) 12 | * File upload (for avatars) – [Shrine](https://shrinerb.com/) + [shrine-rom](https://github.com/shrinerb/shrine-rom) 13 | 14 | ## Contributing etc. 15 | 16 | This is a personal testing projects. Currently I'm not accepting PRs, so please do not create them. 17 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "hanami/rake_tasks" 4 | 5 | namespace :db do 6 | task :seeds do 7 | migrate = Hanami::CLI::Commands::App::DB::Migrate.new 8 | migrate.call(target: 0) 9 | migrate.call 10 | Hanami::CLI::Commands::App::DB::Seed.new.call 11 | puts "Seeded" 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/assets/css/app.css: -------------------------------------------------------------------------------- 1 | .navbar-brand a { font-weight: bolder; } 2 | -------------------------------------------------------------------------------- /app/assets/css/bulma.scss: -------------------------------------------------------------------------------- 1 | @charset "utf-8"; 2 | $primary: #8A4D76; 3 | @import "../../../node_modules/bulma/bulma.scss"; 4 | -------------------------------------------------------------------------------- /app/assets/js/app.js: -------------------------------------------------------------------------------- 1 | import '../css/bulma.scss' 2 | import '../css/app.css' 3 | -------------------------------------------------------------------------------- /app/db/relation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Palaver 4 | module DB 5 | class Relation < Hanami::DB::Relation 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /app/db/relations/accounts.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Palaver 4 | module DB 5 | class Accounts < Palaver::DB::Relation 6 | schema(:accounts, infer: true) do 7 | associations do 8 | has_one :profiles, as: :profile 9 | end 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/db/relations/categories.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Palaver 4 | module DB 5 | class Categories < Palaver::DB::Relation 6 | schema(:categories, infer: true) do 7 | associations do 8 | has_many :threads 9 | belongs_to :threads, as: :latest_thread 10 | end 11 | end 12 | 13 | def with_counts 14 | left_join(:threads) 15 | .left_join(:messages, thread_id: :id) 16 | .group { `categories.id` } 17 | .select_append { 18 | [ 19 | function(:count, `messages.id`).as(:message_count), 20 | function(:count, `threads.title`).distinct.as(:thread_count) 21 | ] 22 | } 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /app/db/relations/messages.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Palaver 4 | module DB 5 | class Messages < Palaver::DB::Relation 6 | schema(:messages, infer: true) do 7 | associations do 8 | belongs_to :threads, as: :thread 9 | belongs_to :profiles, as: :author 10 | end 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/db/relations/profiles.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Palaver 4 | module DB 5 | class Profiles < Palaver::DB::Relation 6 | schema(:profiles, infer: true) do 7 | associations do 8 | has_many :messages, as: :author 9 | belongs_to :account 10 | end 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/db/relations/threads.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Palaver 4 | module DB 5 | class Threads < Palaver::DB::Relation 6 | schema(:threads, infer: true) do 7 | associations do 8 | belongs_to :categories, as: :category 9 | has_many :messages 10 | has_one :categories, as: :latest_thread 11 | belongs_to :messages, as: :last_message 12 | end 13 | end 14 | 15 | def with_messages 16 | left_join(messages) 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /app/db/repo.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Palaver 4 | module DB 5 | class Repo < Hanami::DB::Repo 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /bin/dev: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | if ! gem list foreman -i --silent; then 4 | echo "Installing foreman..." 5 | gem install foreman 6 | fi 7 | 8 | exec foreman start -f Procfile.dev "$@" 9 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "hanami/boot" 4 | use Rack::Static, urls: ["/uploads"], root: "public" 5 | run Hanami.app 6 | -------------------------------------------------------------------------------- /config/app.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "hanami" 4 | require "strict_ivars" 5 | 6 | module Palaver 7 | # By default Hanami tries to find a matching class for current action, 8 | # infer its name and instantiate it. However, this does not work with Phlex 9 | # views. Hanami tries to create new instance with no arguments, but Phlex views 10 | # use arguments in initialization. Therefore we need to disable this feature by 11 | # creating a fake inferrer that always return empty array. 12 | class FakeViewNameInferrer 13 | def self.call(action_class_name:, slice:) = [] 14 | end 15 | 16 | class App < Hanami::App 17 | config.actions.content_security_policy[:default_src] = "*" 18 | config.actions.content_security_policy[:style_src] = "*" 19 | config.actions.content_security_policy[:script_src] = "*" 20 | config.actions.sessions = :cookie, {secret: ENV["SECRET"]} 21 | config.middleware.use :body_parser, :form 22 | 23 | # disable default view inferrer 24 | config.actions.view_name_inferrer = FakeViewNameInferrer 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /config/assets.js: -------------------------------------------------------------------------------- 1 | import * as assets from "hanami-assets"; 2 | import {sassPlugin} from 'esbuild-sass-plugin' 3 | 4 | await assets.run({esbuildOptionsFn: (args, esbuildOptions) => { 5 | esbuildOptions.plugins.push(sassPlugin({ 6 | filter: /\.scss$/ 7 | })); 8 | 9 | return esbuildOptions; 10 | }}); 11 | -------------------------------------------------------------------------------- /config/db/migrate/001_create_categories.rb: -------------------------------------------------------------------------------- 1 | ROM::SQL.migration do 2 | change do 3 | create_table(:categories) do 4 | primary_key :id 5 | column :name, String 6 | column :description, String 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /config/db/migrate/002_create_threads.rb: -------------------------------------------------------------------------------- 1 | ROM::SQL.migration do 2 | change do 3 | create_table(:threads) do 4 | primary_key :id 5 | foreign_key :category_id, :categories 6 | column :title, String 7 | end 8 | 9 | alter_table(:categories) do 10 | add_foreign_key(:latest_thread_id, :threads) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /config/db/migrate/003_create_messages.rb: -------------------------------------------------------------------------------- 1 | ROM::SQL.migration do 2 | change do 3 | create_table(:messages) do 4 | primary_key :id 5 | foreign_key :thread_id, :threads 6 | column :text, String 7 | column :posted_at, DateTime, null: false 8 | end 9 | 10 | alter_table(:threads) do 11 | add_foreign_key(:first_message_id, :messages) 12 | add_foreign_key(:last_message_id, :messages) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /config/db/migrate/004_create_profiles.rb: -------------------------------------------------------------------------------- 1 | ROM::SQL.migration do 2 | change do 3 | create_table(:profiles) do 4 | primary_key :id 5 | column :nickname, String 6 | column :message_count, Integer 7 | end 8 | 9 | alter_table(:messages) do 10 | add_foreign_key(:author_id, :profiles) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /config/db/migrate/005_create_accounts.rb: -------------------------------------------------------------------------------- 1 | ROM::SQL.migration do 2 | change do 3 | create_table(:accounts) do 4 | primary_key(:id) 5 | column :email, String, null: false, unique: true 6 | column :password_hash, String 7 | column :confirmation_token, String 8 | column :confirmed_at, DateTime 9 | column :registered_at, DateTime 10 | end 11 | 12 | alter_table(:profiles) do 13 | add_column(:account_id, Integer) 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /config/db/migrate/006_add_avatar.rb: -------------------------------------------------------------------------------- 1 | ROM::SQL.migration do 2 | change do 3 | add_column :profiles, :avatar_data, :jsonb 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /config/db/migrate/007_add_thread_pinning_and_locking.rb: -------------------------------------------------------------------------------- 1 | ROM::SQL.migration do 2 | change do 3 | alter_table(:threads) do 4 | add_column(:pinned, TrueClass, null: false, default: false) 5 | add_column(:locked, TrueClass, null: false, default: false) 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /config/db/seeds.rb: -------------------------------------------------------------------------------- 1 | require "faker" 2 | 3 | class Seeds 4 | def run 5 | test_cat = create_category("Test") 6 | create_category("Empty category") 7 | 8 | john = create_profile("johnny") 9 | martha = create_profile("Martha Ann van Smith") 10 | 11 | th1 = create_thread(test_cat, title: "Topic 1", content: "test", creator: john) 12 | reply_in_thread(th1, author: martha, content: "this is a longer reply to a previous message") 13 | reply_in_thread(th1, author: john, content: Faker::Lorem.paragraphs(number: 5).join(" ")) 14 | 15 | create_thread(test_cat, title: "Topic 2", creator: john, content: "test") 16 | 17 | pinned = create_thread(test_cat, title: "Important announcement", content: "This is a pinned thread", 18 | creator: john) 19 | pin_thread(pinned) 20 | 21 | th3 = create_thread(test_cat, title: "A long one", content: "Let's discus...", creator: martha) 22 | 40.times do 23 | author = [john, martha].sample 24 | content = Faker::Lorem.sentence(word_count: rand(5..14)) 25 | reply_in_thread(th3, author:, content:) 26 | end 27 | end 28 | 29 | private 30 | 31 | def create_category(name) 32 | repo = Discussion::Container["repositories.category"] 33 | record = repo.create(name:) 34 | Discussion::Entities::Category.from_rom(record) 35 | end 36 | 37 | def create_profile(name) 38 | repo = Discussion::Container["repositories.thread"] 39 | repo.create_profile(name) 40 | end 41 | 42 | def create_thread(category, args) 43 | repo = Discussion::Container["repositories.thread"] 44 | threads = Discussion::Container["domain.threads"] 45 | event = threads.start_thread(**args.merge(category:)) 46 | record = repo.handle(event) 47 | repo.get(record.id) 48 | end 49 | 50 | def reply_in_thread(thread, args) 51 | repo = Discussion::Container["repositories.thread"] 52 | threads = Discussion::Container["domain.threads"] 53 | threads.add_reply(thread, **args).bind do |event| 54 | repo.handle(event) 55 | end 56 | end 57 | 58 | def pin_thread(thread) 59 | repo = Moderation::Container["repositories.thread"] 60 | thread = repo.get(thread.id) 61 | threads = Moderation::Container["domain.threads"] 62 | threads.pin(thread).bind { |event| repo.handle(event) } 63 | end 64 | end 65 | 66 | Seeds.new.run 67 | -------------------------------------------------------------------------------- /config/providers/uploader.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Hanami.app.register_provider :uploader do 4 | prepare do 5 | require "shrine" 6 | require "shrine/storage/file_system" 7 | end 8 | 9 | start do 10 | root_dir = (Hanami.env == :test) ? Hanami.app.root.join("spec", "tmp") : "public" 11 | Shrine.storages = { 12 | cache: Shrine::Storage::FileSystem.new(root_dir, prefix: "uploads/cache"), # temporary 13 | store: Shrine::Storage::FileSystem.new(root_dir, prefix: "uploads") # permanent 14 | } 15 | 16 | Shrine.plugin :rom 17 | Shrine.plugin :rack_file 18 | Shrine.plugin :cached_attachment_data 19 | Shrine.plugin :restore_cached_data 20 | Shrine.plugin :form_assign 21 | Shrine.plugin :validation_helpers 22 | Shrine.plugin :determine_mime_type, analyzer: :marcel 23 | 24 | register :uploader, Shrine 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /config/puma.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | max_threads_count = ENV.fetch("HANAMI_MAX_THREADS", 5) 4 | min_threads_count = ENV.fetch("HANAMI_MIN_THREADS") { max_threads_count } 5 | threads min_threads_count, max_threads_count 6 | 7 | port ENV.fetch("HANAMI_PORT", 2300) 8 | environment ENV.fetch("HANAMI_ENV", "development") 9 | workers ENV.fetch("HANAMI_WEB_CONCURRENCY", 2) 10 | 11 | on_worker_boot do 12 | Hanami.shutdown 13 | end 14 | 15 | preload_app! 16 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "hanami/routes" 4 | 5 | module Palaver 6 | class Routes < Hanami::Routes 7 | slice :discussion, at: "/" 8 | slice :account, at: "/account" 9 | slice :moderation, at: "/moderation" 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /config/settings.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "palaver/types" 4 | require "hanami/settings" 5 | 6 | module Palaver 7 | class Settings < Hanami::Settings 8 | setting :database_url, constructor: Types::String 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/hanami_extensions.rb: -------------------------------------------------------------------------------- 1 | module HanamiExtensions 2 | module ResponseExtension 3 | def render(view, **args) 4 | context = Palaver::View::Context.new(self) 5 | layout = Ui::Layout.new(view, context, args) 6 | self.body = layout.call 7 | end 8 | end 9 | end 10 | 11 | Hanami::Action::Response.prepend(HanamiExtensions::ResponseExtension) 12 | -------------------------------------------------------------------------------- /lib/palaver/action.rb: -------------------------------------------------------------------------------- 1 | # auto_register: false 2 | # frozen_string_literal: true 3 | 4 | require "hanami/action" 5 | require "hanami/action/session" 6 | require "phlex" 7 | require "dry/monads" 8 | require "ui/layout" 9 | require "hanami_extensions" 10 | 11 | module Palaver 12 | class Action < Hanami::Action 13 | include Hanami::Action::Session 14 | include Dry::Monads[:result] 15 | include Account::Deps[account_repo: "repositories.account"] 16 | 17 | before :fetch_current_user_id 18 | 19 | private 20 | 21 | def validate_params(req) 22 | req.params.valid? ? Success() : Failure(:invalid_params) 23 | end 24 | 25 | def fetch_current_user_id(req, res) 26 | session_id = req.session[:usi] 27 | user = account_repo.by_session_id(session_id) 28 | res[:_current_user_id] = user.signed_in? ? user.id : nil 29 | end 30 | 31 | def render_on_invalid_params(res, template) 32 | req = res.request 33 | if !req.params.valid? 34 | body = res.render(template, values: req.params.to_h, errors: req.params.errors) 35 | halt(422, body) 36 | end 37 | end 38 | 39 | public 40 | 41 | # Redirects to root page if the user is not signed in 42 | def self.require_signed_in_user! 43 | before do |_, res| 44 | unless res[:_current_user_id] 45 | res.flash[:error] = "You need to be signed in to access this page" 46 | res.redirect_to "/" 47 | end 48 | end 49 | end 50 | 51 | def self.require_signed_out_user! 52 | before do |_, res| 53 | if res[:_current_user_id] 54 | res.flash[:error] = "You are already signed in" 55 | res.redirect_to "/" 56 | end 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/palaver/avatar_uploader.rb: -------------------------------------------------------------------------------- 1 | class Palaver::AvatarUploader < Hanami.app["uploader"] 2 | end 3 | -------------------------------------------------------------------------------- /lib/palaver/entity.rb: -------------------------------------------------------------------------------- 1 | class Palaver::Entity 2 | attr_reader :attributes 3 | 4 | def initialize(attributes = {}) 5 | @attributes = default_attrs.merge(attributes) 6 | end 7 | 8 | def method_missing(name) 9 | attributes.key?(name) ? attributes[name] : super 10 | end 11 | 12 | def respond_to_missing?(name, _) 13 | attributes.key?(name) 14 | end 15 | 16 | private 17 | 18 | def default_attrs = {} 19 | end 20 | -------------------------------------------------------------------------------- /lib/palaver/repository.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rom-repository" 4 | 5 | module Palaver 6 | class Repository < ROM::Repository::Root 7 | include Deps[container: "db.rom"] 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/palaver/types.rb: -------------------------------------------------------------------------------- 1 | # auto_register: false 2 | # frozen_string_literal: true 3 | 4 | require "dry/types" 5 | 6 | module Palaver 7 | module Types 8 | include Dry.Types 9 | 10 | ID = Integer 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/palaver/view.rb: -------------------------------------------------------------------------------- 1 | require "ui" 2 | 3 | class Palaver::View < Phlex::HTML 4 | attr_reader :context, :args 5 | 6 | class Context 7 | def initialize(response) 8 | @response = response 9 | end 10 | 11 | def flash = @response.flash 12 | 13 | def csrf_token = @response.session[:_csrf_token] 14 | 15 | def current_user = @response[:current_user] 16 | 17 | def asset_url(asset) = Hanami.app["assets"][asset].url 18 | end 19 | 20 | def initialize(context, args = {}) 21 | @context = context 22 | @args = args 23 | define_args_vars 24 | end 25 | 26 | def flash = context.flash 27 | 28 | def csrf_token = context.csrf_token 29 | 30 | def current_user = context.current_user 31 | 32 | # Standard check must be disabled, because we still support Ruby 3.1 33 | def asset_url(*args) = context.asset_url(*args) # standard:disable Style/ArgumentsForwarding 34 | 35 | private 36 | 37 | def define_args_vars 38 | # TODO: forbid some names 39 | @args.each do |name, value| 40 | instance_variable_set(:"@#{name}", value) 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/tasks/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/katafrakt/palaver/a69196dcb7e1f44583ca3fb262b28aa33e7041c5/lib/tasks/.keep -------------------------------------------------------------------------------- /lib/ui.rb: -------------------------------------------------------------------------------- 1 | require "phlex" 2 | 3 | module Ui 4 | def self.setup_loader 5 | Zeitwerk::Loader.new.tap do |loader| 6 | loader.push_dir(Hanami.app.root.join("lib", "ui"), namespace: Ui) 7 | end.setup 8 | end 9 | end 10 | 11 | Ui.setup_loader 12 | -------------------------------------------------------------------------------- /lib/ui/components/columns.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ui 4 | module Components 5 | class Columns < Phlex::HTML 6 | def view_template(&) 7 | div(class: "columns", &) 8 | end 9 | 10 | def column(&) 11 | div(class: "column", &) 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/ui/components/form.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Ui 4 | module Components 5 | class Form < Phlex::HTML 6 | include Ui::Form 7 | attr_reader :url 8 | 9 | def initialize(url:, multipart: false) 10 | @url = url 11 | @multipart = multipart 12 | end 13 | 14 | def view_template(&content) 15 | params = {method: "POST", action: url} 16 | params[:enctype] = "multipart/form-data" if @multipart 17 | form(**params, &content) 18 | end 19 | 20 | def section_title(title, pad_top: false) 21 | extra_classes = pad_top ? " pt-5" : "" 22 | h4(class: "is-size-4 pb-2 mb-2#{extra_classes}") { title } 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/ui/components/form/field.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Ui::Components::Form::Field < Phlex::HTML 4 | attr_reader :type, :name, :placeholder, :value 5 | 6 | # NOTE: We are not creating a getter fot label, because it conflicts with label method 7 | # from Phlex 8 | def initialize(label:, name:, type: :text, placeholder: nil, value: nil, error: nil, disabled: false) 9 | @type = type 10 | @label = label 11 | @name = name 12 | @placeholder = placeholder 13 | @value = value 14 | @error = error 15 | @disabled = disabled 16 | end 17 | 18 | def view_template 19 | div(class: "field") do 20 | label(class: "label") { @label } 21 | render_input 22 | end 23 | end 24 | 25 | private 26 | 27 | def render_input(disabled: false) 28 | classes = ["input", @error.nil? ? nil : "is-danger"].compact.join(" ") 29 | case type 30 | when :textarea 31 | classes = ["textarea", @error.nil? ? nil : "is-danger"].compact.join(" ") 32 | textarea(class: classes, name: name, rows: 5) { value || placeholder } 33 | when :file 34 | label(class: "file-label") do 35 | input(class: "file-input", type: "file", name: name) 36 | span(class: "file-cta") do 37 | span(class: "file-icon") do 38 | i(class: "fas fa-upload") 39 | end 40 | span(class: "file-label") { "Choose a file" } 41 | end 42 | end 43 | else 44 | input(class: classes, name: name, type: type.to_s, placeholder: placeholder, value: value, disabled: disabled) 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/ui/components/form/horizontal_field.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Ui::Components::Form::HorizontalField < Ui::Components::Form::Field 4 | def view_template 5 | div(class: "field is-horizontal") do 6 | div(class: "field-label is-normal") do 7 | label(class: "label") { @label } 8 | end 9 | div(class: "field-body") do 10 | div(class: "field") do 11 | p(class: "control is-expanded") do 12 | render_input(disabled: @disabled) 13 | end 14 | if @error 15 | p(class: "help is-danger") { @error.join(", ") } 16 | end 17 | end 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/ui/components/form/horizontal_submit.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Ui::Components::Form::HorizontalSubmit < Phlex::HTML 4 | def initialize(label: "Submit") 5 | @label = label 6 | end 7 | 8 | def view_template 9 | div(class: "field is-horizontal") do 10 | div(class: "field-label") do 11 | end 12 | div(class: "field-body") do 13 | div(class: "field") do 14 | p(class: "control is-expanded") do 15 | button(class: "button is-primary") { @label } 16 | end 17 | end 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/ui/form.rb: -------------------------------------------------------------------------------- 1 | module Ui 2 | module Form 3 | def hidden_field(name, value) 4 | input(name: name, value: value, type: "hidden") 5 | end 6 | 7 | def horizontal_field(label:, name:, placeholder: "", type: "text", value: nil, error: nil, disabled: false) 8 | render Ui::Components::Form::HorizontalField.new(label:, name:, placeholder:, type:, value:, error:, disabled:) 9 | end 10 | 11 | def submit(label) 12 | render Ui::Components::Form::HorizontalSubmit.new(label:) 13 | end 14 | 15 | def csrf(token) 16 | hidden_field("_csrf_token", token) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/ui/layout.rb: -------------------------------------------------------------------------------- 1 | module Ui 2 | class Layout < Palaver::View 3 | def initialize(view, context, args) 4 | super(context, args) 5 | @view = view 6 | end 7 | 8 | def view_template 9 | html do 10 | head do 11 | title { "Palaver" } 12 | link rel: "stylesheet", href: asset_url("app.css") 13 | meta(name: "viewport", content: "width=device-width, initial-scale=1") 14 | end 15 | 16 | body do 17 | nav(class: "navbar", role: "navigation") do 18 | container do 19 | div(class: "navbar-brand") do 20 | div(class: "navbar-item") do 21 | a(href: "/") { "Home" } 22 | end 23 | end 24 | 25 | div(class: "navbar-menu") do 26 | div(class: "navbar-end") do 27 | if current_user&.signed_in? 28 | div(class: "navbar-item has-dropdown is-hoverable") do 29 | a(class: "navbar-link") { current_user.email } 30 | div(class: "navbar-dropdown") do 31 | a(class: "navbar-item", href: "/account/settings") { "Account settings" } 32 | a(class: "navbar-item", href: "/profile") { "My Profile" } 33 | a(class: "navbar-item", href: "/account/sign_out") { "Sign out" } 34 | end 35 | end 36 | else 37 | a(href: "/account/sign_in", class: "navbar-item") { "Sign in" } 38 | end 39 | end 40 | end 41 | end 42 | end 43 | 44 | container do 45 | if flash && flash[:error] 46 | article(class: "message is-danger") do 47 | div(class: "message-body") { flash[:error] } 48 | end 49 | end 50 | 51 | if flash && flash[:success] 52 | article(class: "message is-success") do 53 | div(class: "message-body") { flash[:success] } 54 | end 55 | end 56 | 57 | section(class: "section") do 58 | render @view.new(context, **args) 59 | end 60 | end 61 | end 62 | end 63 | end 64 | 65 | def container(&) 66 | div(class: "container", &) 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/ui/typography.rb: -------------------------------------------------------------------------------- 1 | module Ui 2 | module Typography 3 | def heading1(text, opts = {}) 4 | h1(class: "is-size-1") { text } 5 | end 6 | 7 | def heading2(text) 8 | h2(class: "is-size-2 pb-3 mb-3") { text } 9 | end 10 | 11 | def heading3(text) 12 | h3(class: "is-size-3 pb-2 mb-2") { text } 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "type": "module", 4 | "scripts": { 5 | "assets": "node config/assets.mjs" 6 | }, 7 | "dependencies": { 8 | "esbuild-sass-plugin": "^3.3.1", 9 | "hanami-assets": "^2.1.1" 10 | }, 11 | "devDependencies": { 12 | "bulma": "^1.0.3", 13 | "node-sass": "^9.0.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /public/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/katafrakt/palaver/a69196dcb7e1f44583ca3fb262b28aa33e7041c5/public/.keep -------------------------------------------------------------------------------- /slices/account/action.rb: -------------------------------------------------------------------------------- 1 | # auto_register: false 2 | # frozen_string_literal: true 3 | 4 | module Account 5 | class Action < Palaver::Action 6 | include Deps[account_repo: "repositories.account"] 7 | 8 | before :fetch_current_user 9 | 10 | private 11 | 12 | def fetch_current_user(req, res) 13 | id = res[:_current_user_id] 14 | 15 | res[:current_user] = if id 16 | account_repo.by_session_id(id) 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /slices/account/actions/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/katafrakt/palaver/a69196dcb7e1f44583ca3fb262b28aa33e7041c5/slices/account/actions/.keep -------------------------------------------------------------------------------- /slices/account/actions/registration/confirm.rb: -------------------------------------------------------------------------------- 1 | class Account::Actions::Registration::Confirm < Account::Action 2 | include Account::Deps["operations.confirm_user"] 3 | 4 | params do 5 | required(:id).filled(:integer) 6 | required(:token).filled(:str?) 7 | end 8 | 9 | def handle(req, res) 10 | result = validate_params(req).bind do 11 | confirm_user.call(id: req.params[:id], token: req.params[:token]) 12 | end 13 | 14 | case result 15 | when Failure(:invalid_params) 16 | handle_failure("Incorrect confirmation link") 17 | when Failure(:user_not_found) 18 | handle_failure("Incorrect confirmation link", status: 404) 19 | when Failure(:already_confirmed) 20 | handle_failure(res, "User is already confirmed") 21 | when Success() 22 | res.flash[:success] = "User confirmed. You can now sign in." 23 | res.redirect_to "/account/sign_in" 24 | end 25 | end 26 | 27 | private 28 | 29 | def handle_failure(res, message, status: 422) 30 | res.flash[:error] = message 31 | res.status = status 32 | res.redirect_to "/" 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /slices/account/actions/registration/create.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Account::Actions::Registration::Create < Account::Action 4 | include Account::Deps["operations.register"] 5 | 6 | require_signed_out_user! 7 | 8 | class Schema < Dry::Validation::Contract 9 | params do 10 | required(:email).filled(:str?, format?: URI::MailTo::EMAIL_REGEXP) 11 | required(:password).filled(:str?, min_size?: 8) 12 | required(:password_confirmation).filled(:str?, min_size?: 8) 13 | end 14 | 15 | rule(:password_confirmation, :password) do 16 | key.failure("passwords do not match") if values[:password] != values[:password_confirmation] 17 | end 18 | end 19 | contract Schema 20 | 21 | def handle(req, res) 22 | render_on_invalid_params(res, Account::Views::Registration::New) 23 | 24 | case register.call(email: req.params[:email], password: req.params[:password]) 25 | in Success(account) 26 | res.render(Account::Views::Registration::AfterCreate, account:) 27 | else 28 | email_error = "must be unique" 29 | res.status = 422 30 | res.render(Account::Views::Registration::New, values: req.params.to_h, errors: {email: [email_error]}) 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /slices/account/actions/registration/new.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Account::Actions::Registration::New < Account::Action 4 | require_signed_out_user! 5 | 6 | def handle(req, res) 7 | res.render(Account::Views::Registration::New, values: {}, errors: {}) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /slices/account/actions/settings/save.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "argon2" 4 | require "dry/monads" 5 | require "dry/monads/do" 6 | require "hanami/utils/blank" 7 | 8 | class Account::Actions::Settings::Save < Account::Action 9 | include Account::Deps["operations.update_account", repo: "repositories.account"] 10 | 11 | require_signed_in_user! 12 | 13 | contract do 14 | params do 15 | optional(:current_password).maybe(:string) 16 | optional(:new_password).maybe(:string, min_size?: 8) 17 | optional(:new_password_confirmation).maybe(:string, min_size?: 8) 18 | optional(:avatar) 19 | optional(:nickname).maybe(:string) 20 | end 21 | 22 | rule(:new_password_confirmation, :new_password) do 23 | key.failure("passwords do not match") if values[:new_password] != values[:new_password_confirmation] 24 | end 25 | end 26 | 27 | def handle(req, res) 28 | current_user = res[:current_user] 29 | return render_show(req, res) unless validate_params(req).success? 30 | 31 | case update_account.call(current_user, req.params.to_h) 32 | in Failure(:current_password_invalid) 33 | req.params.errors[:current_password] = ["incorrect password"] 34 | render_show(req, res) 35 | in Success(_) 36 | res.flash[:success] = "Settings saved" 37 | res.redirect_to "/account/settings" 38 | end 39 | end 40 | 41 | private 42 | 43 | def render_show(req, res) 44 | current_user = res[:current_user] 45 | settings = repo.settings_by_user_id(current_user.id) 46 | res.render(Account::Views::Settings::Show, settings:, errors: req.params.errors, values: req.params.to_h) 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /slices/account/actions/settings/show.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Account::Actions::Settings::Show < Account::Action 4 | include Account::Deps[repo: "repositories.account"] 5 | 6 | require_signed_in_user! 7 | 8 | def handle(req, res) 9 | settings = repo.settings_by_user_id(res[:current_user].id) 10 | res.render(Account::Views::Settings::Show, settings:, errors: {}, values: {}) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /slices/account/actions/sign_in/create.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Account::Actions::SignIn::Create < Account::Action 4 | include Account::Deps["operations.sign_in"] 5 | 6 | require_signed_out_user! 7 | 8 | params do 9 | required(:email).filled(:str?, format?: URI::MailTo::EMAIL_REGEXP) 10 | required(:password).filled(:str?) 11 | end 12 | 13 | def handle(req, res) 14 | render_on_invalid_params(res, Account::Views::SignIn::New) 15 | 16 | case sign_in.call(email: req.params[:email], password: req.params[:password]) 17 | in Success(user) 18 | res.session[:usi] = user.id 19 | res.flash[:success] = "Successfully signed in" 20 | # TODO: remember where to redirect back 21 | res.redirect_to "/" 22 | else 23 | res.flash[:error] = "Incorrect email or password" 24 | res.redirect_to "/account/sign_in" 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /slices/account/actions/sign_in/destroy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Account::Actions::SignIn::Destroy < Account::Action 4 | def handle(req, res) 5 | res.session[:usi] = nil 6 | res.flash[:success] = "You have been signed out" 7 | res.redirect_to "/" 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /slices/account/actions/sign_in/new.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Account::Actions::SignIn::New < Account::Action 4 | require_signed_out_user! 5 | 6 | def handle(req, res) 7 | res.render(Account::Views::SignIn::New, values: {}, errors: {}) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /slices/account/config/routes.rb: -------------------------------------------------------------------------------- 1 | module Account 2 | class Routes < Hanami::Routes 3 | get "/register", to: "registration.new" 4 | post "/register", to: "registration.create" 5 | get "/confirm", to: "registration.confirm" 6 | 7 | get "/sign_in", to: "sign_in.new" 8 | post "/sign_in", to: "sign_in.create" 9 | get "/sign_out", to: "sign_in.destroy" 10 | 11 | get "/settings", to: "settings.show" 12 | post "/settings", to: "settings.save" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /slices/account/entities/anonymous_user.rb: -------------------------------------------------------------------------------- 1 | module Account 2 | module Entities 3 | class AnonymousUser 4 | def signed_in? = false 5 | 6 | def subject_id = "user:anonymous" 7 | 8 | def subject_type = :user 9 | 10 | def subject_sids = [:anonymous] 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /slices/account/entities/current_user.rb: -------------------------------------------------------------------------------- 1 | class Account::Entities::CurrentUser < Palaver::Entity 2 | def signed_in? = !id.nil? 3 | 4 | # access control 5 | def subject_id = "user:#{id}" 6 | 7 | def subject_type = :user 8 | 9 | def subject_sids 10 | [].tap do |sids| 11 | sids << :authenticated if signed_in? 12 | end 13 | end 14 | 15 | private 16 | 17 | def default_attrs = {id: nil} 18 | end 19 | -------------------------------------------------------------------------------- /slices/account/entities/settings.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Account::Entities::Settings < ROM::Struct 4 | include Palaver::Types 5 | include Palaver::AvatarUploader::Attachment(:avatar) 6 | 7 | attribute :user_id, Integer 8 | attribute :email, String 9 | attribute :nickname, String.optional 10 | attribute :avatar_data, String.optional 11 | 12 | def self.from_rom(user) 13 | new( 14 | user_id: user.id, 15 | email: user.email, 16 | nickname: user.profile&.nickname, 17 | avatar_data: user.profile&.avatar_data 18 | ) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /slices/account/operation.rb: -------------------------------------------------------------------------------- 1 | # auto_register: false 2 | # frozen_string_literal: true 3 | 4 | require "dry/operation" 5 | 6 | module Account 7 | class Operation < Dry::Operation 8 | # TODO check if needed 9 | include Dry::Monads[:result] 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /slices/account/operations/confirm_user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Account 4 | module Operations 5 | class ConfirmUser < Account::Operation 6 | include Account::Deps[repo: "repositories.account"] 7 | def call(id:, token:) 8 | account = step fetch_account(id, token) 9 | step confirm_account(account) 10 | end 11 | 12 | private 13 | 14 | def fetch_account(id, token) 15 | account = repo.by_id_and_token(id, token) 16 | return Failure(:user_not_found) unless account 17 | return Failure(:already_confirmed) if account.confirmed_at 18 | Success(account) 19 | end 20 | 21 | def confirm_account(account) 22 | repo.confirm_user(account) 23 | Success(account) 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /slices/account/operations/register.rb: -------------------------------------------------------------------------------- 1 | module Account 2 | module Operations 3 | class Register < Account::Operation 4 | include Account::Deps[repo: "repositories.account", hasher: "utils.hasher"] 5 | 6 | def call(email:, password:) 7 | confirmation_token = step generate_confirmation_token 8 | step create_account(email:, password:, confirmation_token:) 9 | end 10 | 11 | private 12 | 13 | def generate_confirmation_token 14 | Success(SecureRandom.uuid) 15 | end 16 | 17 | def create_account(email:, password:, confirmation_token:) 18 | password_hash = hasher.create(password) 19 | account = repo.create( 20 | email: email, 21 | password_hash: password_hash, 22 | confirmation_token: confirmation_token, 23 | registered_at: Time.now.utc 24 | ) 25 | Success(account) 26 | rescue ROM::SQL::UniqueConstraintError 27 | Failure(:email_not_unique) 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /slices/account/operations/sign_in.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "argon2" 4 | 5 | module Account 6 | module Operations 7 | class SignIn < Account::Operation 8 | include Account::Deps[repo: "repositories.account"] 9 | 10 | def call(email:, password:) 11 | account = step fetch_account(email) 12 | step verify_password(account, password) 13 | end 14 | 15 | private 16 | 17 | def fetch_account(email) 18 | account = repo.get_by_email(email) 19 | 20 | if account.nil? 21 | Failure(:not_found) 22 | elsif account.confirmed_at.nil? 23 | Failure(:not_confirmed) 24 | else 25 | Success(account) 26 | end 27 | end 28 | 29 | def verify_password(account, password) 30 | if Argon2::Password.verify_password(password, account.password_hash) 31 | Success(account) 32 | else 33 | Failure(:incorrect_password) 34 | end 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /slices/account/operations/update_account.rb: -------------------------------------------------------------------------------- 1 | module Account 2 | module Operations 3 | class UpdateAccount < Account::Operation 4 | include Account::Deps[repo: "repositories.account", profile_repo: "repositories.profile", hasher: "utils.hasher"] 5 | 6 | def call(user, attrs) 7 | profile = step ensure_profile_exists(user) 8 | step update_profile_attributes(profile, attrs) 9 | step change_password(user, attrs) 10 | step set_avatar(profile, attrs[:avatar]) 11 | step reload_user(user.id) 12 | end 13 | 14 | private 15 | 16 | # Right after the user registered, they might only have an Account 17 | # record present, with Profile record to be created later. This is 18 | # the moment when we need to check it. 19 | def ensure_profile_exists(user) 20 | profile = profile_repo.by_account_id(user.id) 21 | 22 | if profile.nil? 23 | Success(profile_repo.create(account_id: user.id)) 24 | else 25 | Success(profile) 26 | end 27 | end 28 | 29 | # Update the "regular" attributes 30 | def update_profile_attributes(profile, attrs) 31 | allowed_keys = [:nickname] 32 | filtered_attrs = attrs.slice(*allowed_keys) 33 | profile_repo.update(profile.id, filtered_attrs) unless filtered_attrs.empty? 34 | Success() 35 | end 36 | 37 | # Changes the password of the user 38 | # 39 | # Only happens if all the conditions are met: 40 | # * New password is provided 41 | # * Current passwor dis provided and correct 42 | def change_password(user, attrs) 43 | return Success() if Hanami::Utils::Blank.blank?(attrs[:new_password]) 44 | return Failure(:current_password_invalid) unless Argon2::Password.verify_password(attrs[:current_password], user.password_hash) 45 | 46 | new_hash = hasher.create(attrs[:new_password]) 47 | user = repo.update(user.id, password_hash: new_hash) 48 | Success(user) 49 | end 50 | 51 | def set_avatar(profile, avatar) 52 | return Success() if Hanami::Utils::Blank.blank?(avatar) 53 | 54 | attacher = Account::Entities::Settings.avatar_attacher 55 | attacher.assign(avatar) 56 | attacher.finalize 57 | 58 | params = {avatar_data: attacher.column_data} 59 | profile = profile_repo.update(profile.id, params) 60 | Success(profile) 61 | end 62 | 63 | def reload_user(id) 64 | user = repo.settings_by_user_id(id) 65 | Success(user) 66 | end 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /slices/account/relations/accounts.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Account 4 | module Relations 5 | class Accounts < Palaver::DB::Relation 6 | schema(:accounts, infer: true) do 7 | associations do 8 | has_one :profiles, as: :profile 9 | end 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /slices/account/relations/profiles.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Account 4 | module Relations 5 | class Profiles < Palaver::DB::Relation 6 | schema(:profiles, infer: true) do 7 | associations do 8 | belongs_to :account 9 | end 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /slices/account/repositories/account.rb: -------------------------------------------------------------------------------- 1 | class Account::Repositories::Account < Palaver::DB::Repo[:accounts] 2 | struct_namespace Account::Entities 3 | auto_struct true 4 | commands :create, update: :by_pk 5 | 6 | def by_id(id) 7 | accounts.where(id:).one! 8 | end 9 | 10 | def by_id_and_token(id, token) 11 | accounts.where(id: id, confirmation_token: token).one 12 | end 13 | 14 | def confirm_user(account) 15 | accounts.by_pk(account.id).changeset(:update, confirmed_at: Time.now.utc).commit 16 | end 17 | 18 | def get_by_email(email) 19 | accounts.where(email: email).one 20 | end 21 | 22 | def by_session_id(id) 23 | accounts.by_pk(id).map_to(Account::Entities::CurrentUser).one || Account::Entities::AnonymousUser.new 24 | end 25 | 26 | def settings_by_user_id(id) 27 | accounts.by_pk(id).combine(:profile).one 28 | .then { Account::Entities::Settings.from_rom(_1) } 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /slices/account/repositories/profile.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Account::Repositories::Profile < Palaver::DB::Repo[:profiles] 4 | commands :create, update: :by_pk 5 | 6 | def by_account_id(id) 7 | profiles.where(account_id: id).one 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /slices/account/utils/hasher.rb: -------------------------------------------------------------------------------- 1 | class Account::Utils::Hasher < Argon2::Password 2 | end 3 | -------------------------------------------------------------------------------- /slices/account/views/registration/after_create.rb: -------------------------------------------------------------------------------- 1 | class Account::Views::Registration::AfterCreate < Palaver::View 2 | include Ui::Typography 3 | 4 | def view_template 5 | article(class: "message is-warning") do 6 | div(class: "message-body") do 7 | "This should be an email, but we are just testing, so let's pretend it is." 8 | end 9 | end 10 | 11 | div do 12 | heading2("Thank you for registering on Palaver") 13 | 14 | p do 15 | plain "In order to confirm your account, please click on the following link: " 16 | a(href: "/account/confirm?id=#{@account.id}&token=#{@account.confirmation_token}") { "confirm account" } 17 | plain "." 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /slices/account/views/registration/new.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Account::Views::Registration::New < Palaver::View 4 | include Ui::Typography 5 | include Ui::Form 6 | 7 | def view_template 8 | div do 9 | heading2("Register new account") 10 | render Ui::Components::Form.new(url: "/account/register") do 11 | hidden_field("_csrf_token", csrf_token) 12 | horizontal_field(label: "Email", name: "email", placeholder: "your-email@example.com", value: @values[:email], error: @errors[:email]) 13 | horizontal_field(label: "Password", name: "password", type: :password, value: @values[:password], error: @errors[:password]) 14 | horizontal_field(label: "Password confirmation", name: "password_confirmation", type: :password, value: @values[:password_confirmation], error: @errors[:password_confirmation]) 15 | render Ui::Components::Form::HorizontalSubmit.new(label: "Register") 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /slices/account/views/settings/show.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Account::Views::Settings::Show < Palaver::View 4 | include Ui::Typography 5 | 6 | def view_template 7 | heading2("Account settings") 8 | render Ui::Components::Form.new(url: "/account/settings", multipart: true) do |form| 9 | form.csrf(csrf_token) 10 | render Ui::Components::Columns.new do |columns| 11 | columns.column do 12 | form.horizontal_field(label: "Email", name: "email", value: @settings.email, disabled: true) 13 | form.horizontal_field(label: "Name", name: "nickname", value: @settings.nickname, disabled: !@settings.nickname.nil?) 14 | 15 | form.section_title("Password reset", pad_top: true) 16 | form.horizontal_field(label: "Current password", name: "current_password", type: :password, 17 | error: @errors[:current_password], value: @values[:current_password]) 18 | form.horizontal_field(label: "New password", name: "new_password", type: :password, 19 | error: @errors[:new_password], value: @values[:new_password]) 20 | form.horizontal_field(label: "Confirm new password", name: "new_password_confirmation", type: :password, 21 | error: @errors[:new_password_confirmation], value: @values[:new_password_confirmation]) 22 | end 23 | 24 | columns.column do 25 | form.section_title("Avatar") 26 | 27 | if @settings.avatar_data 28 | p(class: "image is-128x128 mb-3 mt-3") do 29 | img(src: @settings.avatar.url) 30 | end 31 | end 32 | 33 | form.input(type: "hidden", value: nil, name: :avatar) 34 | form.horizontal_field(label: "Pick new", name: :avatar, type: :file) 35 | end 36 | end 37 | 38 | div(class: "is-flex is-justify-content-center pt-4") do 39 | form.submit("Save changes") 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /slices/account/views/sign_in/new.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ui/typography" 4 | 5 | class Account::Views::SignIn::New < Palaver::View 6 | include ::Ui::Typography 7 | include ::Ui::Form 8 | 9 | def view_template 10 | div do 11 | heading2("Sign in") 12 | div(class: "columns") do 13 | div(class: "column is-two-thirds") do 14 | div do 15 | render Ui::Components::Form.new(url: "/account/sign_in") do 16 | hidden_field("_csrf_token", csrf_token) 17 | horizontal_field(label: "Email", name: "email", placeholder: "your-email@domain.com") 18 | horizontal_field(label: "Password", name: "password", type: :password, 19 | value: @values[:password], error: @errors[:password]) 20 | render Ui::Components::Form::HorizontalSubmit.new(label: "Sign in") 21 | end 22 | end 23 | end 24 | 25 | div(class: "column is-one-third") do 26 | p do 27 | plain "Don't have an account yet?" 28 | whitespace 29 | a(href: "/account/register") { "Register here" } 30 | plain "." 31 | end 32 | end 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /slices/discussion/access_control.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "verifica" 4 | 5 | module Discussion 6 | class AccessControl 7 | class Thread 8 | def call(thread, **) 9 | Verifica::Acl.build do |acl| 10 | acl.allow :authenticated, [:reply] 11 | acl.deny :no_profile, [:reply] 12 | end 13 | end 14 | end 15 | 16 | def authorizer 17 | Verifica.authorizer do |config| 18 | config.register_resource :thread, [:reply], Thread.new 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /slices/discussion/action.rb: -------------------------------------------------------------------------------- 1 | # auto_register: false 2 | # frozen_string_literal: true 3 | 4 | class Discussion::Action < Palaver::Action 5 | include ::Discussion::Deps[profile_repo: "repositories.profile"] 6 | 7 | before :fetch_current_user 8 | 9 | private 10 | 11 | def fetch_current_user(req, res) 12 | id = res[:_current_user_id] 13 | 14 | res[:current_user] = if id 15 | profile_repo.current_user(id) 16 | else 17 | Discussion::Entities::CurrentUser.build_anonymous unless id 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /slices/discussion/actions/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/katafrakt/palaver/a69196dcb7e1f44583ca3fb262b28aa33e7041c5/slices/discussion/actions/.keep -------------------------------------------------------------------------------- /slices/discussion/actions/category/show.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Discussion::Actions::Category::Show < Discussion::Action 4 | include Discussion::Deps[ 5 | repo: "repositories.category", 6 | slugger: "utils.slugger", 7 | query: "queries.threads_in_category" 8 | ] 9 | 10 | def handle(req, res) 11 | id = slugger.decode_id(req.params[:id]) 12 | category = repo.get(id) 13 | threads = query.call(category.id) 14 | res.render(Discussion::Views::Category::Show, category: category, threads: threads) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /slices/discussion/actions/home/index.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Discussion::Actions::Home::Index < Discussion::Action 4 | include Discussion::Deps[query: "queries.homepage_categories"] 5 | 6 | def handle(_req, res) 7 | res.render(Discussion::Views::Home::Index, categories: query.call) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /slices/discussion/actions/home/new_threads.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Discussion::Actions::Home::NewThreads < Discussion::Action 4 | include Discussion::Deps[query: "queries.homepage_new_threads"] 5 | 6 | def handle(_req, res) 7 | res.render(Discussion::Views::Home::NewThreads, threads: query.call) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /slices/discussion/actions/home/recent.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Discussion::Actions::Home::Recent < Discussion::Action 4 | include Discussion::Deps[query: "queries.homepage_recent"] 5 | 6 | def handle(_req, res) 7 | res.render(Discussion::Views::Home::Recent, threads: query.call) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /slices/discussion/actions/profile/show.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Discussion::Actions::Profile::Show < Discussion::Action 4 | include Discussion::Deps[ 5 | repo: "repositories.profile" 6 | ] 7 | 8 | require_signed_in_user! 9 | 10 | def handle(req, res) 11 | profile = res[:current_user] 12 | 13 | if profile.profile_set_up? 14 | res.render(Discussion::Views::Profile::Show, profile:) 15 | else 16 | res.redirect "/account/settings" 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /slices/discussion/actions/thread/reply.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Discussion::Actions::Thread::Reply < Discussion::Action 4 | include Discussion::Deps[ 5 | repo: "repositories.thread", 6 | profile_repo: "repositories.profile", 7 | slugger: "utils.slugger", 8 | threads: "domain.threads" 9 | ] 10 | 11 | def handle(req, res) 12 | thread_id = slugger.decode_id(req.params[:id]) 13 | thread = repo.get(thread_id) 14 | author = res[:current_user].to_author 15 | case threads.add_reply(thread, author:, content: req.params[:reply]) 16 | in Success(event) 17 | repo.handle(event) 18 | slug = slugger.to_slug(Discussion::Entities::Thread::HASHIDS_NUM, thread.title, thread.id) 19 | 20 | # TODO: redirect always to last page and add anchor 21 | res.redirect_to "/th/#{slug}" 22 | in Failure(:thread_locked) 23 | res.flash[:error] = "This thread is locked. You cannot reply to it." 24 | slug = slugger.to_slug(Discussion::Entities::Thread::HASHIDS_NUM, thread.title, thread.id) 25 | res.redirect_to "/th/#{slug}" 26 | else 27 | # TODO: figure out 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /slices/discussion/actions/thread/show.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Discussion::Actions::Thread::Show < Discussion::Action 4 | include Discussion::Deps[ 5 | repo: "repositories.thread", 6 | slugger: "utils.slugger", 7 | query: "queries.thread_messages_page" 8 | ] 9 | 10 | def handle(req, res) 11 | id = slugger.decode_id(req.params[:id]) 12 | page = req.params[:page] || 1 13 | result = query.call(id, page) 14 | 15 | res.render(Discussion::Views::Thread::Show, thread: result[:thread], pager: result[:pager]) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /slices/discussion/config/routes.rb: -------------------------------------------------------------------------------- 1 | module Discussion 2 | class Routes < Hanami::Routes 3 | get "/cat/:id", to: "category.show" 4 | get "/th/:id", to: "thread.show" 5 | post "/th/:id/reply", to: "thread.reply" 6 | get "/profile", to: "profile.show" 7 | 8 | get "/recent", to: "home.recent" 9 | get "/new_threads", to: "home.new_threads" 10 | root to: "home.index" 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /slices/discussion/domain/threads.rb: -------------------------------------------------------------------------------- 1 | module Discussion 2 | module Domain 3 | class Threads 4 | include Dry::Monads[:result] 5 | include Discussion::Events 6 | 7 | def start_thread(category:, title:, content:, creator:) 8 | ThreadCreated.new(title:, content:, creator:, category_id: category.id) 9 | end 10 | 11 | def add_reply(thread, author:, content:) 12 | return Failure(:thread_locked) if thread.locked? 13 | 14 | event = ReplyAddedToThread.new(thread_id: thread.id, author:, content:) 15 | Success(event) 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /slices/discussion/entities/author.rb: -------------------------------------------------------------------------------- 1 | # Author entity represents a user profile in context of a message, which 2 | # they authored. 3 | class Discussion::Entities::Author < Discussion::Entities::Profile 4 | end 5 | -------------------------------------------------------------------------------- /slices/discussion/entities/category.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "stringex/unidecoder" 4 | require "hashids" 5 | 6 | class Discussion::Entities::Category < Dry::Struct 7 | include Palaver::Types 8 | 9 | HASHIDS_NUM = 1 10 | 11 | attribute :id, ID 12 | attribute :name, String 13 | attribute :thread_count, Integer 14 | attribute :message_count, Integer 15 | attribute? :latest_thread, Discussion::Entities::Thread.optional 16 | 17 | def self.from_rom(struct) 18 | attrs = { 19 | id: struct.id, 20 | name: struct.name, 21 | thread_count: struct.attributes.fetch(:thread_count, 0), 22 | message_count: struct.attributes.fetch(:message_count, 0) 23 | } 24 | 25 | if struct.attributes[:latest_thread] 26 | attrs[:latest_thread] = Discussion::Entities::Thread.from_rom(struct.latest_thread) if struct.latest_thread 27 | end 28 | 29 | new(attrs) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /slices/discussion/entities/current_user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # The CurrentUser entity represents an actor interacting with the forum in the 4 | # context of the discussion. It is amalgamation of data from account and a profile, 5 | # as well as some data related to access limitations. 6 | module Discussion 7 | module Entities 8 | class CurrentUser < Dry::Struct 9 | ANONYMOUS = "Anonymous" 10 | 11 | include Palaver::Types 12 | include Palaver::AvatarUploader::Attachment(:avatar) 13 | 14 | attribute :account_id, ID.optional 15 | attribute :profile_id, ID.optional 16 | attribute? :email, String 17 | attribute :nickname, String 18 | attribute :message_count, Integer 19 | 20 | def self.build_anonymous 21 | new( 22 | account_id: nil, 23 | profile_id: nil, 24 | nickname: ANONYMOUS, 25 | message_count: 0 26 | ) 27 | end 28 | 29 | # Builds a representation of a user that does not have a profile set up yet 30 | def self.build_profileless(account_id) 31 | new( 32 | account_id:, 33 | profile_id: nil, 34 | nickname: ANONYMOUS, 35 | message_count: 0 36 | ) 37 | end 38 | 39 | def to_author 40 | Discussion::Entities::Author.new( 41 | id: profile_id, 42 | nickname:, 43 | account_id:, 44 | message_count: 45 | ) 46 | end 47 | 48 | def profile_set_up? = !profile_id.nil? 49 | 50 | def signed_in? = !account_id.nil? 51 | 52 | # Access control 53 | 54 | def subject_id = "user:#{profile_id}" 55 | 56 | def subject_type = :user 57 | 58 | def subject_sids 59 | [].tap do |sids| 60 | sids << :authenticated if signed_in? 61 | sids << :no_profile unless profile_set_up? 62 | end 63 | end 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /slices/discussion/entities/message.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Discussion::Entities::Message < Dry::Struct 4 | class Author < Dry::Struct 5 | include Palaver::Types 6 | include Palaver::AvatarUploader::Attachment(:avatar) 7 | 8 | attribute :nickname, String 9 | attribute :id, Integer 10 | attribute :message_count, Integer.optional 11 | attribute :avatar_data, String.optional 12 | end 13 | 14 | include Palaver::Types 15 | 16 | attribute :id, Integer 17 | attribute :text, String 18 | attribute :posted_at, Time 19 | attribute :author, Author 20 | 21 | def self.from_rom(record) 22 | author = Author.new(record.author.to_h) 23 | new(record.to_h.merge(author: author)) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /slices/discussion/entities/profile.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Discussion 4 | module Entities 5 | class Profile < Dry::Struct 6 | include Palaver::Types 7 | include Palaver::AvatarUploader::Attachment(:avatar) 8 | 9 | attribute :id, ID 10 | attribute :nickname, String 11 | attribute :account_id, Integer.optional 12 | attribute? :message_count, Integer.optional 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /slices/discussion/entities/thread.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dry/monads" 4 | 5 | module Discussion 6 | module Entities 7 | class Thread < ROM::Struct 8 | include Palaver::Types 9 | include Dry::Monads[:result] 10 | 11 | HASHIDS_NUM = 2 12 | 13 | attribute :title, String 14 | attribute :id, Integer 15 | attribute? :pinned, Bool.default(false) 16 | attribute? :locked, Bool.default(false) 17 | attribute? :message_count, Integer.default(0) 18 | attribute? :creator, Discussion::Entities::Author 19 | 20 | def self.from_rom(struct, message_count: 0) 21 | new( 22 | id: struct.id, 23 | title: struct.title, 24 | pinned: struct.pinned, 25 | message_count: message_count 26 | ) 27 | end 28 | 29 | def resource_id = "thread:#{id}" 30 | 31 | def resource_type = :thread 32 | 33 | def locked? = locked 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /slices/discussion/events/reply_added_to_thread.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Discussion 4 | module Events 5 | class ReplyAddedToThread < Dry::Struct 6 | include Palaver::Types 7 | 8 | attribute :thread_id, Integer 9 | attribute :author, Discussion::Entities::Author 10 | attribute :content, String 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /slices/discussion/events/thread_created.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Discussion 4 | module Events 5 | class ThreadCreated < Dry::Struct 6 | include Palaver::Types 7 | 8 | attribute :category_id, Integer 9 | attribute :creator, Discussion::Entities::Author 10 | attribute :title, String 11 | attribute :content, String 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /slices/discussion/queries/homepage_categories.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Fetches the category list for the homepage 4 | class Discussion::Queries::HomepageCategories 5 | include Discussion::Deps[repo: "repositories.category"] 6 | 7 | def call 8 | repo.all_with_last_thread.map do |category| 9 | Discussion::Entities::Category.from_rom(category) 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /slices/discussion/queries/homepage_new_threads.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Discussion::Queries::HomepageNewThreads 4 | include Discussion::Deps[repo: "repositories.thread"] 5 | 6 | def call 7 | threads = repo.by_first_message.to_a 8 | counts = repo.message_counts(threads.map(&:id)) 9 | threads.map do |thread| 10 | message_count = counts.detect { |c| c.thread_id == thread.id }&.count 11 | Discussion::Entities::Thread.from_rom(thread, message_count:) 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /slices/discussion/queries/homepage_recent.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Discussion::Queries::HomepageRecent 4 | include Discussion::Deps[repo: "repositories.thread"] 5 | 6 | def call 7 | threads = repo.by_last_message.to_a 8 | counts = repo.message_counts(threads.map(&:id)) 9 | threads.map do |thread| 10 | message_count = counts.detect { |c| c.thread_id == thread.id }&.count 11 | Discussion::Entities::Thread.from_rom(thread, message_count:) 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /slices/discussion/queries/thread_messages_page.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Discussion::Queries::ThreadMessagesPage 4 | include Discussion::Deps[repo: "repositories.thread"] 5 | 6 | Pager = Struct.new(:entries, :total_entries, :total_pages, :current_page) 7 | 8 | def call(thread_id, page_no) 9 | thread = repo.get(thread_id) 10 | thread = Discussion::Entities::Thread.new(thread.to_h) 11 | all_thread_messages_count = repo.message_counts([thread_id]).first.count 12 | entries = repo.paged_messages(thread_id, page_no.to_i) 13 | entries.map! { |e| Discussion::Entities::Message.from_rom(e) } 14 | pager = Pager.new(entries, all_thread_messages_count, (all_thread_messages_count / 15.0).ceil, page_no.to_i) 15 | 16 | {thread:, pager:} 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /slices/discussion/queries/threads_in_category.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Discussion 4 | module Queries 5 | class ThreadsInCategory 6 | include Discussion::Deps[repo: "repositories.thread"] 7 | 8 | def call(category_id) 9 | threads = repo.by_category(category_id) 10 | counts = repo.message_counts(threads.map(&:id)) 11 | threads.map do |thread| 12 | message_count = counts.detect { |c| c.thread_id == thread.id }&.count || 0 13 | Discussion::Entities::Thread.from_rom(thread, message_count:) 14 | end 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /slices/discussion/relations/accounts.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Discussion 4 | module Relations 5 | class Accounts < Palaver::DB::Relation 6 | schema(:accounts, infer: true) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /slices/discussion/relations/categories.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Discussion 4 | module Relations 5 | class Categories < Palaver::DB::Relation 6 | schema(:categories, infer: true) do 7 | associations do 8 | has_many :threads 9 | belongs_to :threads, as: :latest_thread 10 | end 11 | end 12 | 13 | def with_counts 14 | left_join(:threads) 15 | .left_join(:messages, thread_id: :id) 16 | .group { `categories.id` } 17 | .select_append { 18 | [ 19 | function(:count, `messages.id`).as(:message_count), 20 | function(:count, `threads.title`).distinct.as(:thread_count) 21 | ] 22 | } 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /slices/discussion/relations/messages.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Discussion 4 | module Relations 5 | class Messages < Palaver::DB::Relation 6 | schema(:messages, infer: true) do 7 | associations do 8 | belongs_to :threads, as: :thread 9 | belongs_to :profiles, as: :author 10 | end 11 | end 12 | 13 | use :pagination 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /slices/discussion/relations/profiles.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Discussion 4 | module Relations 5 | class Profiles < Palaver::DB::Relation 6 | schema(:profiles, infer: true) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /slices/discussion/relations/threads.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Discussion 4 | module Relations 5 | class Threads < Palaver::DB::Relation 6 | schema(:threads, infer: true) do 7 | associations do 8 | belongs_to :categories, as: :category 9 | has_many :messages 10 | has_one :categories, as: :latest_thread 11 | belongs_to :messages, as: :last_message 12 | end 13 | end 14 | 15 | use :pagination 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /slices/discussion/repositories/category.rb: -------------------------------------------------------------------------------- 1 | module Discussion 2 | module Repositories 3 | class Category < Palaver::DB::Repo[:categories] 4 | commands :create 5 | 6 | def all_with_last_thread 7 | categories 8 | .with_counts 9 | .combine(latest_thread: {last_message: :author}) 10 | .to_a 11 | end 12 | 13 | def get(id) 14 | categories.by_pk(id).one! 15 | end 16 | 17 | def set_last_thread(category_id:, thread:) 18 | categories.by_pk(category_id).changeset(:update, last_thread_id: thread.id).commit 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /slices/discussion/repositories/profile.rb: -------------------------------------------------------------------------------- 1 | class Discussion::Repositories::Profile < Palaver::DB::Repo[:profiles] 2 | def current_user(id) 3 | account = accounts.where(id: id).one 4 | return Discussion::Entities::CurrentUser.build_anonymous unless account 5 | 6 | profile = profiles.where(account_id: id).one 7 | return Discussion::Entities::CurrentUser.build_profileless(id) unless profile 8 | 9 | Discussion::Entities::CurrentUser.new( 10 | account_id: id, 11 | profile_id: profile.id, 12 | email: account.email, 13 | nickname: profile.nickname, 14 | message_count: profile.message_count || 0 15 | ) 16 | end 17 | 18 | def create(params) 19 | params[:message_count] = 0 20 | profiles.command(:create).call(params) 21 | end 22 | 23 | def update(id, params) 24 | command = profiles.by_pk(id).command(:update) 25 | command.call(params) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /slices/discussion/repositories/thread.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Discussion 4 | module Repositories 5 | class Thread < Palaver::DB::Repo[:threads] 6 | commands :create 7 | 8 | def by_category(category_id) 9 | threads.where(category_id:) 10 | .combine(:last_message) 11 | .order([Sequel.case({pinned: 0}, 1)]) 12 | .to_a 13 | end 14 | 15 | def message_counts(thread_ids) 16 | messages 17 | .where(thread_id: thread_ids) 18 | .group(:thread_id) 19 | .select { [thread_id, integer.count(id).as(:count)] } 20 | .order(:thread_id) 21 | .to_a 22 | end 23 | 24 | # temp 25 | def create_profile(name) 26 | profiles.command(:create).call(nickname: name, message_count: 0) 27 | end 28 | 29 | def get(id) 30 | threads.by_pk(id).one!.then { |struct| to_entity(struct) } 31 | end 32 | 33 | def paged_messages(thread_id, page = 1) 34 | messages 35 | .where(thread_id:) 36 | .combine(:author) 37 | .order(:posted_at) 38 | .per_page(15) 39 | .page(page) 40 | .to_a 41 | end 42 | 43 | def sync_message_count(author) 44 | count = messages.where(author_id: author.id).count 45 | profiles.by_pk(author.id).changeset(:update, message_count: count).commit 46 | end 47 | 48 | def by_first_message 49 | threads 50 | .left_join(:messages, id: :first_message_id) 51 | .combine(:messages).order(messages[:posted_at].desc) 52 | end 53 | 54 | def by_last_message 55 | threads 56 | .combine(:messages) 57 | .left_join(:messages, id: :last_message_id) 58 | .order(messages[:posted_at].desc) 59 | end 60 | 61 | def handle(event) 62 | case event 63 | when Discussion::Events::ReplyAddedToThread 64 | message = messages 65 | .changeset( 66 | :create, 67 | text: event.content, 68 | posted_at: DateTime.now, 69 | author_id: event.author.id, 70 | thread_id: event.thread_id 71 | ).commit 72 | 73 | threads.by_pk(message.thread_id).changeset( 74 | :update, 75 | last_message_id: message.id 76 | ).commit 77 | 78 | sync_message_count(event.author) 79 | message 80 | when Discussion::Events::ThreadCreated 81 | transaction do 82 | thread = threads.changeset( 83 | :create, 84 | title: event.title, 85 | category_id: event.category_id 86 | ).commit 87 | 88 | message = messages.changeset( 89 | :create, 90 | text: event.content, 91 | posted_at: DateTime.now, 92 | author_id: event.creator.id, 93 | thread_id: thread.id 94 | ).commit 95 | 96 | sync_message_count(event.creator) 97 | threads.by_pk(thread.id).changeset( 98 | :update, 99 | first_message_id: message.id, 100 | last_message_id: message.id 101 | ).commit 102 | end 103 | else 104 | raise NotImplementedError 105 | end 106 | end 107 | 108 | private 109 | 110 | def to_entity(struct) 111 | Discussion::Entities::Thread.new( 112 | id: struct.id, 113 | title: struct.title, 114 | pinned: struct.pinned, 115 | locked: struct.locked 116 | ) 117 | end 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /slices/discussion/utils/slug_provider.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "stringex" 4 | 5 | module Discussion 6 | module Utils 7 | # This is a code that actually creates a slugged value from a string. 8 | # It uses Stringex, which is great, but it's also slow. That's why it's being put into 9 | # a separate class, so we may replace in with something simpler yet efficient in tests. 10 | class SlugProvider 11 | def call(string) 12 | string.to_url 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /slices/discussion/utils/slugger.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "hashids" 4 | 5 | module Discussion 6 | module Utils 7 | class Slugger 8 | include Deps["utils.slug_provider"] 9 | 10 | def to_slug(num, string, id) 11 | hashids = Hashids.new(ENV["HASHIDS_SALT"]) 12 | hash = hashids.encode(num, id) 13 | "#{slug_provider.call(string)}-#{hash}" 14 | end 15 | 16 | def decode_id(slug) 17 | hashids = Hashids.new(ENV["HASHIDS_SALT"]) 18 | hashid = slug.split("-").last 19 | _num, id = hashids.decode(hashid) 20 | id 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /slices/discussion/views/category/show.rb: -------------------------------------------------------------------------------- 1 | class Discussion::Views::Category::Show < Palaver::View 2 | def view_template 3 | div do 4 | h2(class: "is-size-2") { @category.name } 5 | 6 | if @threads.empty? 7 | p { "No threads" } 8 | else 9 | @threads.each do |thread| 10 | render Discussion::Views::Shared::Partials::ThreadRow.new(thread) 11 | end 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /slices/discussion/views/home/components/tabs.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Discussion::Views::Home::Components::Tabs < Phlex::HTML 4 | attr_reader :selected 5 | 6 | def initialize(selected) 7 | @selected = selected 8 | end 9 | 10 | def view_template 11 | div(class: "tabs pl-5 pr-5") do 12 | ul do 13 | tab("/", "Categories", :categories) 14 | tab("/recent", "Recently updated", :recent) 15 | tab("/new_threads", "New threads", :new) 16 | end 17 | end 18 | end 19 | 20 | def tab(url, label, type) 21 | class_string = (type == @selected) ? "is-active" : "" 22 | 23 | li(class: class_string) do 24 | a(href: url) { label } 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /slices/discussion/views/home/index.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Discussion::Views::Home::Index < Palaver::View 4 | def view_template 5 | div do 6 | render Discussion::Views::Shared::Components::NoProfileWarning.new(current_user) 7 | render Discussion::Views::Home::Components::Tabs.new(:categories) 8 | 9 | div(class: "section") do 10 | @categories.each do |category| 11 | render Discussion::Views::Home::Partials::Category.new(category: category) 12 | end 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /slices/discussion/views/home/new_threads.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Discussion::Views::Home::NewThreads < Palaver::View 4 | def view_template 5 | div do 6 | render Discussion::Views::Shared::Components::NoProfileWarning.new(current_user) 7 | render Discussion::Views::Home::Components::Tabs.new(:new) 8 | 9 | div(class: "section") do 10 | @threads.each do |thread| 11 | render Discussion::Views::Shared::Partials::ThreadRow.new(thread) 12 | end 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /slices/discussion/views/home/partials/category.rb: -------------------------------------------------------------------------------- 1 | class Discussion::Views::Home::Partials::Category < Phlex::HTML 2 | include Discussion::Deps["utils.slugger"] 3 | 4 | class Detail < Phlex::HTML 5 | def initialize(label, value) 6 | @label = label 7 | @value = value 8 | end 9 | 10 | def view_template 11 | span do 12 | strong { @label } 13 | plain ": #{@value}" 14 | end 15 | end 16 | end 17 | 18 | attr_reader :category 19 | 20 | def initialize(category:, slugger:) 21 | @category = category 22 | @slugger = slugger 23 | end 24 | 25 | def view_template 26 | slug = @slugger.to_slug(Discussion::Entities::Category::HASHIDS_NUM, category.name, category.id) 27 | 28 | article(class: "category mb-5") do 29 | h4(class: "is-size-4") do 30 | a(href: "/cat/#{slug}") { category.name } 31 | end 32 | 33 | div do 34 | render Detail.new("Threads", category.thread_count) 35 | raw safe(" · ") 36 | render Detail.new("Messages", category.message_count) 37 | if most_recent_thread 38 | raw safe(" · ") 39 | span do 40 | plain "Last message by " 41 | a(href: "/") { "Test" } 42 | whitespace 43 | plain "in" 44 | whitespace 45 | a(href: "/th/") { most_recent_thread.title } 46 | end 47 | end 48 | end 49 | end 50 | end 51 | 52 | private 53 | 54 | def most_recent_thread 55 | category.latest_thread 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /slices/discussion/views/home/recent.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Discussion::Views::Home::Recent < Palaver::View 4 | def view_template 5 | div do 6 | render Discussion::Views::Shared::Components::NoProfileWarning.new(current_user) 7 | render Discussion::Views::Home::Components::Tabs.new(:recent) 8 | 9 | div(class: "section") do 10 | @threads.each do |thread| 11 | render Discussion::Views::Shared::Partials::ThreadRow.new(thread) 12 | end 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /slices/discussion/views/profile/show.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Discussion::Views::Profile::Show < Palaver::View 4 | include Ui::Typography 5 | 6 | def view_template 7 | div do 8 | heading2(@profile.nickname) 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /slices/discussion/views/shared/components/no_profile_warning.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Discussion::Views::Shared::Components::NoProfileWarning < Phlex::HTML 4 | def initialize(current_user) 5 | @current_user = current_user 6 | end 7 | 8 | def view_template 9 | if @current_user.signed_in? && !@current_user.profile_set_up? 10 | article(class: "message is-warning") do 11 | div(class: "message-body") do 12 | plain "You need to set up your profile to start posting. You can do that " 13 | a(href: "/profile") { "here" } 14 | plain "." 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /slices/discussion/views/shared/partials/thread_row.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Discussion::Views::Shared::Partials::ThreadRow < Phlex::HTML 4 | include Discussion::Deps["utils.slugger"] 5 | 6 | def initialize(thread, slugger:) 7 | @thread = thread 8 | @slugger = slugger 9 | end 10 | 11 | def view_template 12 | slug = @slugger.to_slug(Discussion::Entities::Thread::HASHIDS_NUM, @thread.title, @thread.id) 13 | 14 | article(class: "mt-5 mb-5 media thread-row") do 15 | figure(class: "media-left") do 16 | p(class: "image is-64x64") do 17 | img(src: "https://bulma.io/assets/images/placeholders/128x128.png") 18 | end 19 | end 20 | div(class: "media-content") do 21 | div(class: "content") do 22 | h4(class: "is-size-4") do 23 | a(href: "/th/#{slug}") { @thread.title } 24 | span(class: "ml-2 tag is-info is-light") { "Pinned" } if @thread.pinned 25 | end 26 | end 27 | 28 | nav(class: "level is-mobile") do 29 | span do 30 | strong { "Replies: " } 31 | plain @thread.message_count - 1 32 | end 33 | end 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /slices/discussion/views/thread/show.rb: -------------------------------------------------------------------------------- 1 | class Discussion::Views::Thread::Show < Palaver::View 2 | include Ui::Typography 3 | include Ui::Form 4 | include Discussion::Deps[ 5 | "access_control", 6 | slugger: "utils.slugger" 7 | ] 8 | 9 | def view_template 10 | div do 11 | heading2(@thread.title) 12 | 13 | @pager.entries.each do |message| 14 | message_row(message) 15 | end 16 | 17 | if access_control.authorizer.authorized?(current_user, @thread, :reply) 18 | reply_form 19 | else 20 | render Discussion::Views::Shared::Components::NoProfileWarning.new(current_user) 21 | end 22 | 23 | pagination 24 | end 25 | end 26 | 27 | private 28 | 29 | def message_row(message) 30 | article(class: "mt-5 mb-5 box columns") do 31 | div(class: "column is-one-quarter") do 32 | p(class: "is-size-4") do 33 | strong { message.author.nickname } 34 | end 35 | p(class: "image is-96x96 is-hidden-mobile mb-3 mt-3") do 36 | img(src: avatar_url(message)) 37 | end 38 | p(class: "is-hidden-mobile is-size-7") do 39 | plain "Posts: " 40 | plain message.author.message_count 41 | end 42 | end 43 | 44 | div(class: "column") do 45 | div(class: "pb-2 mb-3", style: "border-bottom: solid 1px gray") do 46 | small { post_date(message) } 47 | end 48 | 49 | div(class: "content") { message.text } 50 | end 51 | end 52 | end 53 | 54 | def avatar_url(message) 55 | if message.author.avatar 56 | message.author.avatar_url 57 | else 58 | "https://bulma.io/assets/images/placeholders/128x128.png" 59 | end 60 | end 61 | 62 | def post_date(message) 63 | message.posted_at.strftime("%Y-%m-%d %H:%M") 64 | end 65 | 66 | def thread_slug 67 | slugger.to_slug(Discussion::Entities::Thread::HASHIDS_NUM, @thread.title, @thread.id) 68 | end 69 | 70 | def reply_form 71 | render Ui::Components::Form.new(url: "/th/#{thread_slug}/reply") do 72 | hidden_field("_csrf_token", csrf_token) 73 | horizontal_field(label: "Write your reply", name: :reply, type: :textarea) 74 | render Ui::Components::Form::HorizontalSubmit.new(label: "Reply") 75 | end 76 | end 77 | 78 | def pagination 79 | nav(class: "pagination is-centered", role: "pagination", aria_label: "pagination") do 80 | ul(class: "pagination-list") do 81 | @pager.total_pages.times do |pg| 82 | page = pg + 1 83 | li do 84 | a(class: "pagination-link #{(page == @pager.current_page) ? "is-current" : nil}", aria_label: "Go to page #{page}", href: "/th/#{thread_slug}?page=#{page}") { plain page } 85 | end 86 | end 87 | end 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /slices/moderation/action.rb: -------------------------------------------------------------------------------- 1 | # auto_register: false 2 | # frozen_string_literal: true 3 | 4 | module Moderation 5 | class Action < Palaver::Action 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /slices/moderation/actions/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/katafrakt/palaver/a69196dcb7e1f44583ca3fb262b28aa33e7041c5/slices/moderation/actions/.keep -------------------------------------------------------------------------------- /slices/moderation/actions/thread/pin.rb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/katafrakt/palaver/a69196dcb7e1f44583ca3fb262b28aa33e7041c5/slices/moderation/actions/thread/pin.rb -------------------------------------------------------------------------------- /slices/moderation/config/routes.rb: -------------------------------------------------------------------------------- 1 | module Moderation 2 | class Routes < Hanami::Routes 3 | get "/thread/:id/pin", to: "thread.pin" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /slices/moderation/domain/threads.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dry/monads" 4 | 5 | module Moderation 6 | module Domain 7 | class Threads 8 | include Moderation::Events 9 | include Dry::Monads[:result] 10 | 11 | def pin(thread) 12 | return Failure(:thread_already_pinned) if thread.pinned? 13 | 14 | event = ThreadPinned.new(thread_id: thread.id) 15 | Success(event) 16 | end 17 | 18 | def unpin(thread) 19 | return Failure(:thread_not_pinned) unless thread.pinned? 20 | 21 | event = ThreadUnpinned.new(thread_id: thread.id) 22 | Success(event) 23 | end 24 | 25 | def lock(thread) 26 | return Failure(:thread_already_locked) if thread.locked? 27 | event = ThreadLocked.new(thread_id: thread.id) 28 | Success(event) 29 | end 30 | 31 | def unlock(thread) 32 | return Failure(:thread_not_locked) unless thread.locked? 33 | event = ThreadUnlocked.new(thread_id: thread.id) 34 | Success(event) 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /slices/moderation/entities/thread.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Moderation 4 | module Entities 5 | class Thread < Dry::Struct 6 | include Palaver::Types 7 | 8 | attribute :id, Integer 9 | attribute :pinned, Bool 10 | attribute :locked, Bool 11 | 12 | def pinned? = pinned 13 | 14 | def locked? = locked 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /slices/moderation/events/thread_locked.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Moderation 4 | module Events 5 | class ThreadLocked < Dry::Struct 6 | include Palaver::Types 7 | 8 | attribute :thread_id, Integer 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /slices/moderation/events/thread_pinned.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Moderation 4 | module Events 5 | class ThreadPinned < Dry::Struct 6 | include Palaver::Types 7 | 8 | attribute :thread_id, Integer 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /slices/moderation/events/thread_unlocked.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Moderation 4 | module Events 5 | class ThreadUnlocked < Dry::Struct 6 | include Palaver::Types 7 | 8 | attribute :thread_id, Integer 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /slices/moderation/events/thread_unpinned.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Moderation 4 | module Events 5 | class ThreadUnpinned < Dry::Struct 6 | include Palaver::Types 7 | 8 | attribute :thread_id, Integer 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /slices/moderation/relations/categories.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Moderation 4 | module Relations 5 | class Categories < Palaver::DB::Relation 6 | schema(:categories, infer: true) do 7 | associations do 8 | has_many :threads 9 | end 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /slices/moderation/relations/threads.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Moderation 4 | module Relations 5 | class Threads < Palaver::DB::Relation 6 | schema(:threads, infer: true) do 7 | associations do 8 | belongs_to :category 9 | end 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /slices/moderation/repositories/thread.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Moderation::Repositories::Thread < Palaver::DB::Repo[:threads] 4 | def get(id) 5 | threads.by_pk(id).one!.then do |record| 6 | Moderation::Entities::Thread.new( 7 | id: record.id, 8 | pinned: record.pinned, 9 | locked: record.locked 10 | ) 11 | end 12 | end 13 | 14 | def handle(event) 15 | case event 16 | when Moderation::Events::ThreadPinned 17 | threads.by_pk(event.thread_id).changeset(:update, pinned: true).commit 18 | get(event.thread_id) 19 | when Moderation::Events::ThreadUnpinned 20 | threads.by_pk(event.thread_id).changeset(:update, pinned: false).commit 21 | get(event.thread_id) 22 | when Moderation::Events::ThreadLocked 23 | threads.by_pk(event.thread_id).changeset(:update, locked: true).commit 24 | get(event.thread_id) 25 | when Moderation::Events::ThreadUnlocked 26 | threads.by_pk(event.thread_id).changeset(:update, locked: false).commit 27 | get(event.thread_id) 28 | else 29 | raise NotImplementedError 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/palaver/entity_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Palaver::Entity do 2 | it "returns attribute" do 3 | entity = Palaver::Entity.new(test: "test") 4 | expect(entity.test).to eq("test") 5 | end 6 | 7 | it "raises NoMethodError when asking for a non-attribute" do 8 | entity = Palaver::Entity.new(test: "test") 9 | expect { entity.name }.to raise_error(NoMethodError) 10 | end 11 | 12 | it "supports respond_to?" do 13 | entity = Palaver::Entity.new(test: "test") 14 | expect(entity.respond_to?(:test)).to eq(true) 15 | expect(entity.respond_to?(:restart)).to eq(false) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/requests/account/registration/create_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe "POST /account/register", type: :request do 2 | let(:params) do 3 | { 4 | email: "test@example.net", 5 | password: "12345678", 6 | password_confirmation: "12345678" 7 | } 8 | end 9 | let(:url) { "/account/register" } 10 | 11 | context "errors" do 12 | specify "with missing email" do 13 | params[:email] = nil 14 | post url, params 15 | 16 | expect(last_response).not_to be_successful 17 | expect(last_response.body).to include("must be filled") 18 | end 19 | 20 | specify "with incorrect email" do 21 | params[:email] = "abc@" 22 | post url, params 23 | 24 | expect(last_response).not_to be_successful 25 | expect(last_response.body).to include("is in invalid format") 26 | end 27 | 28 | specify "with missing password" do 29 | params[:password] = nil 30 | post url, params 31 | 32 | expect(last_response).not_to be_successful 33 | expect(last_response.body).to include("must be filled") 34 | end 35 | 36 | specify "with missing password confirmation" do 37 | params[:password_confirmation] = nil 38 | post url, params 39 | 40 | expect(last_response).not_to be_successful 41 | expect(last_response.body).to include("must be filled") 42 | end 43 | 44 | specify "with password too short" do 45 | params[:password] = "123" 46 | post url, params 47 | 48 | expect(last_response).not_to be_successful 49 | expect(last_response.body).to include("size cannot be less than 8") 50 | end 51 | 52 | specify "with password and confirmation not matching" do 53 | params[:password] = "12344321" 54 | post url, params 55 | 56 | expect(last_response).not_to be_successful 57 | expect(last_response.body).to include("passwords do not match") 58 | end 59 | 60 | specify "with email already taken", db: true do 61 | Account::Repositories::Account.new.create(email: "test@test.com") 62 | params[:email] = "test@test.com" 63 | post url, params 64 | 65 | expect(last_response).not_to be_successful 66 | expect(last_response.body).to include("must be unique") 67 | end 68 | end 69 | 70 | context "success" do 71 | it "renders a message" do 72 | post url, params 73 | expect(last_response).to be_successful 74 | expect(last_response.body).to include("Thank you for registering") 75 | end 76 | end 77 | 78 | context "with signed in user" do 79 | before do 80 | user = Fixtures::Account.user 81 | env "rack.session", {usi: user.id} 82 | end 83 | 84 | it "redirects" do 85 | get url 86 | expect(last_response.status).to eq(302) 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /spec/requests/account/registration/new_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | url = "/account/register" 4 | RSpec.describe "GET #{url}", type: :request do 5 | specify do 6 | get url 7 | expect(last_response).to be_successful 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/requests/account/settings/save_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe "POST /account/settings", type: :request do 2 | let(:url) { "/account/settings" } 3 | 4 | def perform_request(params = {}) 5 | default_params = {avatar: "", current_password: "", new_password: "", new_password_confirmation: ""} 6 | post url, default_params.merge(params) 7 | end 8 | 9 | context "as anonymous user" do 10 | specify "redirect to home" do 11 | perform_request 12 | expect(last_response.status).to eq(302) 13 | end 14 | end 15 | 16 | context "as signed in user" do 17 | let(:password) { "12345678" } 18 | let(:user) { Fixtures::Account.user(password:) } 19 | 20 | before do 21 | Fixtures::Account.profile(user.id) 22 | env "rack.session", {usi: user.id} 23 | end 24 | 25 | it "shows error when new passwords do not match" do 26 | perform_request new_password: "123123123", new_password_confirmation: "124124124" 27 | expect(last_response.body).to include("passwords do not match") 28 | end 29 | 30 | it "shows error when new password is too short" do 31 | perform_request new_password: "123", new_password_confirmation: "123" 32 | expect(last_response.body).to include("size cannot be less than 8") 33 | end 34 | 35 | it "shows error when current password does not match" do 36 | perform_request current_password: SecureRandom.hex, new_password: "123123123", new_password_confirmation: "123123123" 37 | expect(last_response.body).to include("incorrect password") 38 | end 39 | 40 | it "shows error when you provide new password but not current password (when new is valid)" do 41 | perform_request current_password: "", new_password: "123123123", new_password_confirmation: "123123123" 42 | expect(last_response.body).to include("incorrect password") 43 | end 44 | 45 | it "does not update password when current password is incorrect" do 46 | perform_request current_password: "abcdefgh", new_password: "123123123", new_password_confirmation: "123123123" 47 | reloaded_user = Account::Repositories::Account.new.by_id(user.id) 48 | expect(Argon2::Password.verify_password("123123123", reloaded_user.password_hash)).to eq(false) 49 | end 50 | 51 | it "updates the password when correct params are passed" do 52 | perform_request current_password: password, new_password: "123123123", new_password_confirmation: "123123123" 53 | reloaded_user = Account::Repositories::Account.new.by_id(user.id) 54 | expect(Argon2::Password.verify_password("123123123", reloaded_user.password_hash)).to eq(true) 55 | end 56 | 57 | it "updates the avatar in the profile" do 58 | file_path = File.join(Hanami.app.root, "spec", "support", "files", "cat_small.jpg") 59 | perform_request avatar: Rack::Test::UploadedFile.new(file_path, "image/jpeg") 60 | profile = Account::Repositories::Profile.new.by_account_id(user.id) 61 | expect(profile.avatar_data).not_to be_nil 62 | data = JSON.parse(profile.avatar_data) 63 | expect(data["metadata"]["filename"]).to eq("cat_small.jpg") 64 | end 65 | end 66 | 67 | context "as a signed in user without a profile yet" do 68 | let(:password) { "12345678" } 69 | let(:user) { Fixtures::Account.user(password:) } 70 | 71 | before do 72 | env "rack.session", {usi: user.id} 73 | end 74 | 75 | it "updates name and avatar in the profile" do 76 | file_path = File.join(Hanami.app.root, "spec", "support", "files", "cat_small.jpg") 77 | perform_request avatar: Rack::Test::UploadedFile.new(file_path, "image/jpeg"), nickname: "John" 78 | profile = Account::Repositories::Profile.new.by_account_id(user.id) 79 | expect(profile.avatar_data).not_to be_nil 80 | data = JSON.parse(profile.avatar_data) 81 | expect(data["metadata"]["filename"]).to eq("cat_small.jpg") 82 | expect(profile.nickname).to eq("John") 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /spec/requests/account/settings/show_spec.rb: -------------------------------------------------------------------------------- 1 | require "nokolexbor" 2 | 3 | RSpec.describe "GET /account/settings", type: :request do 4 | let(:user) { Account::Container["repositories.account"].create(email: "test@test.com") } 5 | let(:profile) { Fixtures::Discussion.profile(account_id: user.id) } 6 | 7 | before do 8 | env "rack.session", {usi: user_id} 9 | get "/account/settings" 10 | @doc = Nokolexbor::HTML(last_response.body) 11 | end 12 | 13 | describe "as a user without profile set up" do 14 | let(:user_id) { user.id } 15 | specify "I can input my nickname" do 16 | nickname_input = @doc.xpath("//input[@name='nickname']").first 17 | expect(nickname_input.attributes["disabled"]).to be_nil 18 | end 19 | 20 | specify "I cannot change my email" do 21 | nickname_input = @doc.xpath("//input[@name='email']").first 22 | expect(nickname_input.attributes["disabled"]).not_to be_nil 23 | end 24 | end 25 | 26 | describe "as a user with a profile set up" do 27 | let(:user_id) { profile.account_id } 28 | 29 | specify "I see a page with my nickname included" do 30 | get "/account/settings" 31 | expect(last_response.body).to include(profile.nickname) 32 | end 33 | 34 | specify "I cannot input my nickname" do 35 | nickname_input = @doc.xpath("//input[@name='nickname']").first 36 | expect(nickname_input.attributes["disabled"]).not_to be_nil 37 | end 38 | 39 | specify "I cannot change my email" do 40 | nickname_input = @doc.xpath("//input[@name='email']").first 41 | expect(nickname_input.attributes["disabled"]).not_to be_nil 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/requests/account/sign_in/create_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | url = "/account/sign_in" 4 | RSpec.describe "GET #{url}", type: :request do 5 | let(:register) { Account::Container["operations.register"] } 6 | let(:confirm_user) { Account::Container["operations.confirm_user"] } 7 | 8 | it "redirects when user not found" do 9 | post url, {email: "test@test.com", password: "12345678"} 10 | expect(last_response.status).to eq(302) 11 | expect(last_response.headers["Location"]).to eq("/account/sign_in") 12 | end 13 | 14 | it "renders error message and login form" do 15 | post url, {email: "test@test.com", password: "12345678"} 16 | follow_redirect! 17 | expect(last_response).to be_successful 18 | expect(last_response.body).to include("Incorrect email or password") 19 | end 20 | 21 | context "with successful login" do 22 | before do 23 | account = register.call(email: "test@test.com", password: "12345678").value! 24 | confirm_user.call(id: account.id, token: account.confirmation_token) 25 | end 26 | 27 | it "redirects when user is correct" do 28 | post url, {email: "test@test.com", password: "12345678"} 29 | expect(last_response.status).to eq(302) 30 | expect(last_response.headers["Location"]).to eq("/") 31 | end 32 | 33 | it "renders success message on successful sign in" do 34 | post url, {email: "test@test.com", password: "12345678"} 35 | follow_redirect! 36 | expect(last_response).to be_successful 37 | expect(last_response.body).to include("Successfully signed in") 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /spec/requests/account/sign_in/destroy_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe "GET /sign_out", type: :request do 4 | let(:user) { Account::Container["repositories.account"].create(email: "test@test.com") } 5 | 6 | specify "removes user id from session" do 7 | env "rack.session", {usi: user.id} 8 | get "/account/sign_out" 9 | expect(last_response.headers["Location"]).to eq("/") 10 | expect(last_request.session[:usi]).to be_nil 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/requests/account/sign_in/new_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | url = "/account/sign_in" 4 | RSpec.describe "GET #{url}", type: :request do 5 | specify "return success" do 6 | get url 7 | expect(last_response).to be_successful 8 | end 9 | 10 | specify "redirect when user already signed in" do 11 | user = Fixtures::Account.user 12 | env "rack.session", {usi: user.id} 13 | get url 14 | expect(last_response.status).to eq(302) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/requests/discussion/categories/show_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe "GET /cat/:id", type: :request do 2 | let(:author) { Fixtures::Discussion.profile } 3 | let(:repo) { Discussion::Container["repositories.category"] } 4 | let(:thread_repo) { Discussion::Container["repositories.thread"] } 5 | 6 | specify "with no threads" do 7 | category = repo.create(name: "abcd") 8 | slug = category_slug(category) 9 | 10 | get "/cat/#{slug}" 11 | 12 | expect(last_response).to be_successful 13 | expect(last_response.body).to include("No threads") 14 | end 15 | 16 | specify "with one thread" do 17 | category = repo.create(name: "abcd") 18 | Fixtures::Discussion.thread( 19 | title: "A test thread", content: "Testiiiing", 20 | category_id: category.id, author: author 21 | ) 22 | slug = category_slug(category) 23 | 24 | get "/cat/#{slug}" 25 | 26 | expect(last_response).to be_successful 27 | expect(last_response.body).not_to include("No threads") 28 | expect(last_response.body).to include("A test thread") 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/requests/discussion/home/new_threads_spec.rb: -------------------------------------------------------------------------------- 1 | require "nokolexbor" 2 | 3 | RSpec.describe "GET /new_threads", type: :request do 4 | let(:profile) { Fixtures::Discussion.profile } 5 | let(:create_thread) { Discussion::Container["commands.create_thread"] } 6 | let(:repo) { Discussion::Container["repositories.category"] } 7 | let(:category_id) { repo.create(name: "abcd").id } 8 | 9 | specify "return threads descending by last message date" do 10 | th1 = Fixtures::Discussion.thread(title: "test 1", category_id:, author: profile) 11 | th2 = Fixtures::Discussion.thread(title: "test 2", category_id:, author: profile) 12 | th3 = Fixtures::Discussion.thread(title: "test 3", category_id:, author: profile) 13 | Fixtures::Discussion.message(content: "test", author: profile, thread_id: th3.id) 14 | Fixtures::Discussion.message(content: "test", author: profile, thread_id: th2.id) 15 | 16 | get "/new_threads" 17 | 18 | expect(last_response).to be_successful 19 | doc = Nokolexbor::HTML(last_response.body) 20 | titles = doc.css(".thread-row").map do |row| 21 | row.at_css("h4 a").text 22 | end 23 | expect(titles).to eq([th3.title, th2.title, th1.title]) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/requests/discussion/home/recent_spec.rb: -------------------------------------------------------------------------------- 1 | require "nokolexbor" 2 | 3 | RSpec.describe "GET /recent", type: :request do 4 | let(:profile) { Fixtures::Discussion.profile } 5 | let(:create_thread) { Discussion::Container["commands.create_thread"] } 6 | let(:repo) { Discussion::Container["repositories.category"] } 7 | let(:category_id) { repo.create(name: "abcd").id } 8 | 9 | specify "return threads descending by last message date" do 10 | th1 = Fixtures::Discussion.thread(title: "test 1", category_id:, author: profile) 11 | th2 = Fixtures::Discussion.thread(title: "test 2", category_id:, author: profile) 12 | th3 = Fixtures::Discussion.thread(title: "test 3", category_id:, author: profile) 13 | Fixtures::Discussion.message(content: "test", author: profile, thread_id: th3.id) 14 | Fixtures::Discussion.message(content: "test", author: profile, thread_id: th2.id) 15 | 16 | get "/recent" 17 | 18 | expect(last_response).to be_successful 19 | doc = Nokolexbor::HTML(last_response.body) 20 | titles = doc.css(".thread-row").map do |row| 21 | row.at_css("h4 a").text 22 | end 23 | expect(titles).to eq([th2.title, th3.title, th1.title]) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/requests/discussion/home_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe "GET /", type: :request do 2 | let(:user) { Account::Container["repositories.account"].create(email: "test@test.com") } 3 | let(:no_profile_msg) { "You need to set up your profile to start posting" } 4 | 5 | describe "no-profile warning" do 6 | specify "anonymous user does not see it" do 7 | get "/" 8 | expect(last_response.body).not_to include(no_profile_msg) 9 | end 10 | 11 | specify "signd in user without profile sees it" do 12 | env "rack.session", {usi: user.id} 13 | get "/" 14 | expect(last_response.body).to include(no_profile_msg) 15 | end 16 | 17 | specify "signed in user with profile does not see it" do 18 | env "rack.session", {usi: user.id} 19 | Discussion::Container["repositories.profile"].create(account_id: user.id, nickname: "janice") 20 | get "/" 21 | expect(last_response.body).not_to include(no_profile_msg) 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/requests/discussion/profile/show_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe "GET /profile", type: :request do 2 | let(:user) { Account::Container["repositories.account"].create(email: "test@test.com") } 3 | let(:profile) { Fixtures::Discussion.profile(account_id: user.id) } 4 | 5 | describe "as a user without profile set up" do 6 | before { env "rack.session", {usi: user.id} } 7 | 8 | specify "I'm redirected to account settings page" do 9 | get "/profile" 10 | expect(last_response.status).to eq(302) 11 | expect(last_response.header["Location"]).to eq("/account/settings") 12 | end 13 | end 14 | 15 | describe "as a user with a profile set up" do 16 | before { env "rack.session", {usi: profile.account_id} } 17 | 18 | specify "I see a page with my nickname included" do 19 | get "/profile" 20 | expect(last_response.body).to include(profile.nickname) 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/requests/discussion/threads/reply_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe "POST /th/:id/reply", type: :request do 2 | let(:user) { Account::Container["repositories.account"].create(email: "test@test.com") } 3 | let(:author) { Fixtures::Discussion.profile(account_id: user.id) } 4 | 5 | let(:thread) do 6 | category = Fixtures::Discussion.category 7 | Fixtures::Discussion.thread(category_id: category.id, author:) 8 | end 9 | 10 | describe "as a signed in user" do 11 | before do 12 | env "rack.session", {usi: user.id} 13 | end 14 | 15 | specify "redirects to the thread" do 16 | post "/th/#{thread_slug(thread)}/reply", reply: "This is a reply" 17 | 18 | expect(last_response.status).to eq(302) 19 | expect(last_response.headers["Location"]).to eq("/th/#{thread_slug(thread)}") 20 | end 21 | 22 | context "for locked thread" do 23 | before do 24 | Fixtures::Moderation.lock_thread(thread.id) 25 | end 26 | 27 | specify "renders flash message" do 28 | post "/th/#{thread_slug(thread)}/reply", reply: "This is a reply" 29 | follow_redirect! 30 | 31 | expect(last_response.body).not_to include("This is a reply") 32 | expect(last_response.body).to include("This thread is locked") 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/requests/discussion/threads/show_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe "GET /th/:id", type: :request do 2 | let(:author) { Fixtures::Discussion.profile } 3 | let(:user) { Account::Container["repositories.account"].create(email: "test@test.com") } 4 | let(:thread) do 5 | category = Discussion::Repositories::Category.new.create(name: "abcd") 6 | Fixtures::Discussion.thread(category_id: category.id, author:, title: "A test thread", content: "Testing") 7 | end 8 | 9 | describe "as anonymous user" do 10 | specify "shows title and first message" do 11 | get "/th/#{thread_slug(thread)}" 12 | expect(last_response.body).to include("A test thread") 13 | expect(last_response.body).to include("Testing") 14 | end 15 | 16 | specify "does not display reply form" do 17 | get "/th/#{thread_slug(thread)}" 18 | expect(last_response.body).not_to include("Write your reply") 19 | end 20 | end 21 | 22 | describe "as a signed in user without profile set up" do 23 | specify "does not display reply form" do 24 | env "rack.session", {usi: user.id} 25 | get "/th/#{thread_slug(thread)}" 26 | 27 | expect(last_response.body).not_to include("Write your reply") 28 | expect(last_response.body).to include("You need to set up your profile to start posting") 29 | end 30 | end 31 | 32 | describe "as a signed in user with profile set up" do 33 | specify "displays reply form" do 34 | Discussion::Container["repositories.profile"].create(nickname: "Joshua", account_id: user.id) 35 | env "rack.session", {usi: user.id} 36 | get "/th/#{thread_slug(thread)}" 37 | 38 | expect(last_response.body).to include("Write your reply") 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/requests/root_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe "Root", type: :request do 4 | let(:category) { Discussion::Entities::Category.new(id: 123, name: "Test", thread_count: 0, message_count: 0) } 5 | 6 | let(:repo) { double("fake repo") } 7 | stub(Discussion::Container, "repositories.category") { repo } 8 | 9 | it "is succesful" do 10 | expect(repo).to receive(:all_with_last_thread) { [category] } 11 | 12 | get "/" 13 | 14 | expect(last_response).to be_successful 15 | expect(last_response.body).to include("Categories") 16 | expect(last_response.body).to include(category.name) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/slices/account/actions/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/katafrakt/palaver/a69196dcb7e1f44583ca3fb262b28aa33e7041c5/spec/slices/account/actions/.keep -------------------------------------------------------------------------------- /spec/slices/account/actions/registration/create_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Account::Actions::Registration::Create do 2 | let(:params) do 3 | { 4 | email: "test@example.net", 5 | password: "12345678", 6 | password_confirmation: "12345678" 7 | } 8 | end 9 | 10 | describe "errors" do 11 | it "errors with missing password" do 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/slices/account/entities/current_user_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Account::Entities::CurrentUser do 2 | subject(:entity) { described_class } 3 | 4 | context "signed_in?" do 5 | it "returns true for user with id" do 6 | expect(entity.new(id: 123).signed_in?).to eq(true) 7 | end 8 | 9 | it "returns false for user without id" do 10 | expect(entity.new.signed_in?).to eq(false) 11 | end 12 | end 13 | 14 | context "access control" do 15 | it "returns :authenticated sid when user signed in" do 16 | expect(entity.new(id: 12).subject_sids).to include(:authenticated) 17 | end 18 | 19 | it "does not return :authenticated sid when user is not signed in" do 20 | expect(entity.new.subject_sids).not_to include(:authenticated) 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/slices/account/operations/confirm_user_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Account::Operations::ConfirmUser do 2 | include Dry::Monads[:result] 3 | subject(:operation) { described_class.new } 4 | let(:repo) { double(:repo) } 5 | stub(Account::Container, "repositories.account") { repo } 6 | 7 | it "returns failure when there is no user" do 8 | allow(repo).to receive(:by_id_and_token).and_return(nil) 9 | expect(operation.call(id: 123, token: "abc")).to eq(Failure(:user_not_found)) 10 | end 11 | 12 | it "returns failure when user is already confirmed" do 13 | account = Fixtures::Account.current_user_entity(confirmed_at: Time.now) 14 | allow(repo).to receive(:by_id_and_token).and_return(account) 15 | expect(operation.call(id: 123, token: "abc")).to eq(Failure(:already_confirmed)) 16 | end 17 | 18 | it "confirms user if conditions met" do 19 | account = Fixtures::Account.current_user_entity(confirmed_at: nil) 20 | allow(repo).to receive(:by_id_and_token).and_return(account) 21 | expect(repo).to receive(:confirm_user) 22 | expect(operation.call(id: 123, token: "abc")).to be_success 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/slices/account/operations/register_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Account::Operations::Register do 2 | let(:repo) { double(:repo) } 3 | subject(:operation) { described_class.new } 4 | let(:non_unique_exception) do 5 | ROM::SQL::UniqueConstraintError.new(StandardError.new) 6 | end 7 | 8 | stub(Account::Container, "repositories.account") { repo } 9 | 10 | it "calls the repository's create" do 11 | account = double(:account) 12 | expect(repo).to receive(:create) { account } 13 | 14 | result = operation.call(email: "test@test.com", password: "123456") 15 | expect(result).to be_success 16 | end 17 | 18 | it "handles non-unique email" do 19 | allow(repo).to receive(:create).and_raise(non_unique_exception) 20 | 21 | result = operation.call(email: "test@test.com", password: "123456") 22 | expect(result).to be_failure 23 | expect(result.failure).to eq(:email_not_unique) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/slices/discussion/access_control_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Discussion::AccessControl do 2 | subject(:acl) { Discussion::Container["access_control"].authorizer } 3 | let(:thread_model) { Discussion::Entities::Thread } 4 | 5 | it "disallows anonymous user to reply" do 6 | current_user = Account::Entities::AnonymousUser.new 7 | thread = thread_model.new(id: 15, category_id: 15, title: "test thread", first_message_id: 1, last_message_id: 1, 8 | pinned: false, locked: false) 9 | expect(acl.authorized?(current_user, thread, :reply)).to eq(false) 10 | end 11 | 12 | it "allows authorized user to reply" do 13 | current_user = Account::Entities::CurrentUser.new(id: 1, email: "test@test.com") 14 | thread = thread_model.new(id: 15, category_id: 15, title: "test thread", first_message_id: 1, last_message_id: 1, 15 | pinned: false, locked: false) 16 | expect(acl.authorized?(current_user, thread, :reply)).to eq(true) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/slices/discussion/action_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Discussion::Action do 2 | describe "require_signed_in_user!" do 3 | let(:action) do 4 | Class.new(described_class) do 5 | require_signed_in_user! 6 | end 7 | end 8 | 9 | it "redirects when no signed in user" do 10 | res = action.new.call({}) 11 | expect(res.status).to eq(302) 12 | expect(res.flash.next[:error]).not_to be_nil 13 | expect(res.headers["Location"]).to eq("/") 14 | end 15 | 16 | it "does not redirect then there is a user" do 17 | account_repo = double("user repo") 18 | expect(account_repo).to receive(:by_session_id).with(101) { Account::Entities::CurrentUser.new(id: 101, email: "") } 19 | 20 | Account::Container.stub("repositories.account", account_repo) do 21 | res = action.new.call("rack.session" => {"usi" => 101}) 22 | expect(res.status).to eq(200) 23 | expect(res.headers["Location"]).to be_nil 24 | expect(res.flash.next[:error]).to be_nil 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/slices/discussion/actions/category/show_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Discussion::Actions::Category::Show do 2 | subject(:action) { described_class.new } 3 | let(:repo) { Discussion::Container["repositories.category"] } 4 | 5 | let(:category) do 6 | id = repo.categories.insert(name: "test") 7 | repo.categories.by_pk(id).one! 8 | end 9 | 10 | describe "with no threads" do 11 | it "renders message about no threads" do 12 | response = action.call({id: category_slug(category)}) 13 | expect(response.body.first).to include("No threads") 14 | end 15 | end 16 | 17 | describe "with threads" do 18 | before do 19 | repo.threads.insert(category_id: category.id, title: "test thread") 20 | end 21 | 22 | it "renders message about no threads" do 23 | response = action.call({id: category_slug(category)}) 24 | expect(response.body.first).to include("test thread") 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/slices/discussion/domain/threads_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Discussion::Domain::Threads do 2 | # TODO: acocunt_id should not be required 3 | let(:creator) { Discussion::Entities::Author.new(nickname: "leah", id: rand(100), account_id: 1) } 4 | let(:thread) { Discussion::Entities::Thread.new(id: rand(100), creator:, title: "My test thread") } 5 | let(:threads) { Discussion::Container["domain.threads"] } 6 | 7 | context "add_reply" do 8 | it "returns the event" do 9 | result = threads.add_reply(thread, author: creator, content: "Let's do this!") 10 | expect(result).to be_success 11 | event = result.value! 12 | 13 | expect(event).to be_kind_of(Discussion::Events::ReplyAddedToThread) 14 | expect(event.thread_id).to eq(thread.id) 15 | expect(event.content).to eq("Let's do this!") 16 | end 17 | end 18 | 19 | context "start_thread" do 20 | let(:category) { Discussion::Entities::Category.new(id: rand(100), name: "Test category", thread_count: 1, message_count: 1) } 21 | 22 | it "returns the event" do 23 | event = threads.start_thread(category:, creator:, title: "Good thread", content: "This is good") 24 | 25 | expect(event).to be_kind_of(Discussion::Events::ThreadCreated) 26 | expect(event.category_id).to eq(category.id) 27 | expect(event.title).to eq("Good thread") 28 | expect(event.content).to eq("This is good") 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/slices/discussion/entities/current_user_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Discussion::Entities::CurrentUser do 2 | let(:anonymous) { described_class.build_anonymous } 3 | let(:without_profile) { described_class.build_profileless(1) } 4 | let(:user) do 5 | described_class.new( 6 | account_id: 1, 7 | profile_id: 2, 8 | nickname: "Johann", 9 | email: "johann@example.no", 10 | message_count: 15 11 | ) 12 | end 13 | 14 | context "signed_in?" do 15 | it "returns true for user with id" do 16 | expect(user.signed_in?).to eq(true) 17 | end 18 | 19 | it "returns true for user without profile" do 20 | expect(without_profile.signed_in?).to eq(true) 21 | end 22 | 23 | it "returns false for anonymous user" do 24 | expect(anonymous.signed_in?).to eq(false) 25 | end 26 | end 27 | 28 | context "profile_set_up?" do 29 | it "returns true when profile_id present" do 30 | expect(user.profile_set_up?).to eq(true) 31 | end 32 | 33 | it "returns false for user without profile" do 34 | expect(without_profile.profile_set_up?).to eq(false) 35 | end 36 | 37 | it "returns false for anonymous user" do 38 | expect(anonymous.profile_set_up?).to eq(false) 39 | end 40 | end 41 | 42 | context "access control" do 43 | it "returns :authenticated sid when user signed in" do 44 | expect(user.subject_sids).to include(:authenticated) 45 | end 46 | 47 | it "returns :authenticated sid when user signed in but without profile" do 48 | expect(without_profile.subject_sids).to include(:authenticated) 49 | end 50 | 51 | it "does not return :authenticated sid when user is not signed in" do 52 | expect(anonymous.subject_sids).not_to include(:authenticated) 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /spec/slices/discussion/repositories/thread_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Discussion::Repositories::Thread do 2 | let(:repo) { described_class.new } 3 | let(:category_id) { repo.categories.insert(name: "test") } 4 | let(:thread) do 5 | id = repo.threads.insert(category_id:) 6 | repo.threads.by_pk(id).one! 7 | end 8 | 9 | let(:profile) do 10 | id = repo.profiles.insert(nickname: "hans", message_count: 17) 11 | repo.profiles.by_pk(id).one! 12 | end 13 | 14 | describe "handle" do 15 | context "ReplyAddedToThread" do 16 | it "creates new message in thread" do 17 | event = Discussion::Events::ReplyAddedToThread.new(thread_id: thread.id, author: profile, content: "test") 18 | repo.handle(event) 19 | messages = repo.messages.where(thread_id: thread.id).to_a 20 | 21 | expect(messages.length).to eq(1) 22 | expect(messages.first.text).to eq("test") 23 | end 24 | 25 | it "updates last_message_id of the thread" do 26 | event = Discussion::Events::ReplyAddedToThread.new(thread_id: thread.id, author: profile, content: "test") 27 | repo.handle(event) 28 | message = repo.messages.where(thread_id: thread.id).one! 29 | record = repo.threads.by_pk(thread.id).one! 30 | 31 | expect(record.last_message_id).to eq(message.id) 32 | end 33 | 34 | it "recalculates message counter" do 35 | event = Discussion::Events::ReplyAddedToThread.new(thread_id: thread.id, author: profile, content: "test") 36 | repo.handle(event) 37 | reloaded_profile = repo.profiles.by_pk(profile.id).one! 38 | expect(reloaded_profile.message_count).to eq(1) 39 | end 40 | end 41 | 42 | context "ThreadCreated" do 43 | it "creates a new thread" do 44 | event = Discussion::Events::ThreadCreated.new(title: "test", category_id:, content: "content", creator: profile) 45 | thread = repo.handle(event) 46 | expect(thread.title).to eq("test") 47 | end 48 | 49 | it "adds the first message" do 50 | event = Discussion::Events::ThreadCreated.new(title: "test", category_id:, content: "content", creator: profile) 51 | thread = repo.handle(event) 52 | message = repo.messages.where(thread_id: thread.id).one! 53 | expect(message.text).to eq("content") 54 | end 55 | 56 | it "recalculates message counter" do 57 | event = Discussion::Events::ThreadCreated.new(title: "test", category_id:, content: "content", creator: profile) 58 | repo.handle(event) 59 | reloaded_profile = repo.profiles.by_pk(profile.id).one! 60 | expect(reloaded_profile.message_count).to eq(1) 61 | end 62 | end 63 | 64 | it "raises on unknown event" do 65 | expect { repo.handle(Class.new) }.to raise_error(NotImplementedError) 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /spec/slices/discussion/utils/slugger_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Discussion::Utils::Slugger do 2 | subject(:slugger) { described_class.new } 3 | 4 | it "decodes the id in slug to the same id" do 5 | id = rand(15_000) 6 | slug = slugger.to_slug(5, "abc", id) 7 | decoded_id = slugger.decode_id(slug) 8 | expect(decoded_id).to eq(id) 9 | end 10 | 11 | it "contains downcased name in the slug" do 12 | slug = slugger.to_slug(5, "The Big THING", 42) 13 | expect(slug).to include("the-big-thing") 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/slices/discussion/views/home/partials/category_spec.rb: -------------------------------------------------------------------------------- 1 | require "nokolexbor" 2 | 3 | RSpec.describe Discussion::Views::Home::Partials::Category do 4 | include ComponentTestingHelper 5 | 6 | let(:category) { Discussion::Entities::Category.new(name: "Announcements", id: 24, thread_count: 25, message_count: 67) } 7 | 8 | it "contains a link to a category" do 9 | row = described_class.new(category:) 10 | doc = Nokolexbor::HTML(render(row)) 11 | category_link = doc.xpath("//a").detect { |link| link.attributes["href"].to_s.include?(category_slug(category)) } 12 | expect(category_link.attributes["href"].to_s).to eq("/cat/announcements-0Vh4") 13 | end 14 | 15 | it "displays informatio about last message and its author" do 16 | author = Discussion::Entities::Author.new(nickname: "John", id: 123, account_id: 17) 17 | thread = Discussion::Entities::Thread.new(id: 1, title: "Welcome thread", author:) 18 | row = described_class.new(category: Discussion::Entities::Category.new(category.attributes.merge(latest_thread: thread))) 19 | doc = Nokolexbor::HTML(render(row)) 20 | profile_link = doc.xpath("//a").detect { |link| link.attributes["href"].to_s.include?("th/") } 21 | expect(profile_link).to_not be_nil 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/slices/discussion/views/shared/components/no_profile_warning_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Discussion::Views::Shared::Components::NoProfileWarning do 2 | include ComponentTestingHelper 3 | 4 | let(:anonymous) { Discussion::Entities::CurrentUser.build_anonymous } 5 | let(:without_profile) { Discussion::Entities::CurrentUser.build_profileless(1) } 6 | let(:user) do 7 | Discussion::Entities::CurrentUser.new( 8 | account_id: 1, 9 | profile_id: 2, 10 | nickname: "Johann", 11 | email: "johann@example.no", 12 | message_count: 15 13 | ) 14 | end 15 | 16 | it "renders nothing when user is not signed in" do 17 | warning = described_class.new(anonymous) 18 | expect(render(warning)).to eq("") 19 | end 20 | 21 | it "renders nothing when user is signed in but without a profile" do 22 | warning = described_class.new(without_profile) 23 | expect(render(warning)).to match("You need to set up") 24 | end 25 | 26 | it "renders warning when user is signed in and has profile" do 27 | warning = described_class.new(user) 28 | expect(render(warning)).to eq("") 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/slices/discussion/views/shared/partials/thread_row_spec.rb: -------------------------------------------------------------------------------- 1 | require "nokolexbor" 2 | 3 | RSpec.describe Discussion::Views::Shared::Partials::ThreadRow do 4 | include ComponentTestingHelper 5 | 6 | let(:thread) { Discussion::Entities::Thread.new(title: "Registrations are now closed", id: 22, message_count: 1, pinned: false, messages: []) } 7 | 8 | it "contains a link to a category" do 9 | row = described_class.new(thread) 10 | doc = Nokolexbor::HTML(render(row)) 11 | thread_link = doc.xpath("//a").detect { |link| link.attributes["href"].to_s.include?(thread_slug(thread)) } 12 | expect(thread_link.attributes["href"].to_s).to eq("/th/registrations-are-now-closed-BNtg") 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/slices/moderation/actions/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/katafrakt/palaver/a69196dcb7e1f44583ca3fb262b28aa33e7041c5/spec/slices/moderation/actions/.keep -------------------------------------------------------------------------------- /spec/slices/moderation/domain/threads_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Moderation::Domain::Threads do 2 | let(:threads) { Moderation::Container["domain.threads"] } 3 | 4 | def build_thread(args = {}) 5 | params = {id: rand(100), title: "My test thread", pinned: false, locked: false}.merge(args) 6 | Moderation::Entities::Thread.new(params) 7 | end 8 | 9 | describe "pin" do 10 | it "returns success for thread" do 11 | expect(threads.pin(build_thread)).to be_success 12 | end 13 | 14 | it "returns correct event on success" do 15 | thread = build_thread 16 | event = threads.pin(thread).value! 17 | expect(event.thread_id).to eq(thread.id) 18 | end 19 | 20 | it "returns failure when thread already pinned" do 21 | result = threads.pin(build_thread(pinned: true)) 22 | expect(result).to be_failure 23 | expect(result.failure).to eq(:thread_already_pinned) 24 | end 25 | end 26 | 27 | describe "unpin" do 28 | it "returns success for pinned thread" do 29 | thread = build_thread(pinned: true) 30 | expect(threads.unpin(thread)).to be_success 31 | end 32 | 33 | it "returns correct event on success" do 34 | thread = build_thread(pinned: true) 35 | event = threads.unpin(thread).value! 36 | expect(event.thread_id).to eq(thread.id) 37 | end 38 | 39 | it "returns failure for not pinned thread" do 40 | result = threads.unpin(build_thread) 41 | expect(result).to be_failure 42 | expect(result.failure).to eq(:thread_not_pinned) 43 | end 44 | end 45 | 46 | describe "lock" do 47 | it "returns success for thread" do 48 | thread = build_thread 49 | expect(threads.lock(thread)).to be_success 50 | end 51 | 52 | it "returns correct event on success" do 53 | thread = build_thread 54 | event = threads.lock(thread).value! 55 | expect(event.thread_id).to eq(thread.id) 56 | end 57 | 58 | it "returns failure if thread if already locked" do 59 | thread = build_thread(locked: true) 60 | result = threads.lock(thread) 61 | expect(result).to be_failure 62 | expect(result.failure).to eq(:thread_already_locked) 63 | end 64 | end 65 | 66 | describe "unlock" do 67 | it "returns success for locked thread" do 68 | thread = build_thread(locked: true) 69 | expect(threads.unlock(thread)).to be_success 70 | end 71 | 72 | it "returns correct event on success" do 73 | thread = build_thread(locked: true) 74 | event = threads.unlock(thread).value! 75 | expect(event.thread_id).to eq(thread.id) 76 | end 77 | 78 | it "returns failure when thread is unlcoked" do 79 | thread = build_thread(locked: false) 80 | result = threads.unlock(thread) 81 | expect(result).to be_failure 82 | expect(result.failure).to eq(:thread_not_locked) 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /spec/slices/moderation/repositories/thread_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Moderation::Repositories::Thread do 2 | let(:repo) { described_class.new } 3 | let(:category_id) { repo.categories.insert(name: "test") } 4 | let(:thread) do 5 | id = repo.threads.insert(category_id:) 6 | repo.threads.by_pk(id).one! 7 | end 8 | 9 | describe "handle" do 10 | context "ThreadPinned" do 11 | it "changes the value of pinned column" do 12 | event = Moderation::Events::ThreadPinned.new(thread_id: thread.id, pinned: false) 13 | reloaded = repo.handle(event) 14 | expect(reloaded.pinned).to eq(true) 15 | end 16 | end 17 | 18 | context "ThreadUnpinned" do 19 | it "changes the value of pinned column" do 20 | event = Moderation::Events::ThreadUnpinned.new(thread_id: thread.id, pinned: true) 21 | reloaded = repo.handle(event) 22 | expect(reloaded.pinned).to eq(false) 23 | end 24 | end 25 | 26 | context "ThreadLocked" do 27 | it "changes the value of locked column" do 28 | event = Moderation::Events::ThreadLocked.new(thread_id: thread.id, locked: false) 29 | reloaded = repo.handle(event) 30 | expect(reloaded.locked).to eq(true) 31 | end 32 | end 33 | 34 | context "ThreadUnlocked" do 35 | it "changes the value of locked column" do 36 | event = Moderation::Events::ThreadUnlocked.new(thread_id: thread.id, locked: true) 37 | reloaded = repo.handle(event) 38 | expect(reloaded.locked).to eq(false) 39 | end 40 | end 41 | end 42 | 43 | it "raises on unknown event" do 44 | expect { repo.handle(Class.new) }.to raise_error(NotImplementedError) 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "simplecov" 4 | SimpleCov.start do 5 | enable_coverage :branch 6 | primary_coverage :branch 7 | add_filter "/spec/" 8 | add_filter "/config/" 9 | add_group "Account", "slices/account" 10 | add_group "Discussion", "slices/discussion" 11 | add_group "UI", "lib/ui" 12 | end 13 | 14 | if ENV["GITHUB_ACTIONS"] 15 | require "simplecov-cobertura" 16 | SimpleCov.formatter = SimpleCov::Formatter::CoberturaFormatter 17 | end 18 | 19 | require "pathname" 20 | SPEC_ROOT = Pathname(__dir__).realpath.freeze 21 | 22 | ENV["HANAMI_ENV"] ||= "test" 23 | require "hanami/prepare" 24 | 25 | require_relative "support/rspec" 26 | require_relative "support/requests" 27 | require_relative "support/database_cleaner" 28 | require_relative "support/uploads" 29 | require_relative "support/component_testing" 30 | 31 | require "phlex" 32 | 33 | require "dry/system/stubs" 34 | Discussion::Container.enable_stubs! 35 | Account::Container.enable_stubs! 36 | 37 | require "rom/core" 38 | require "rom/factory" 39 | Factory = ROM::Factory.configure do |config| 40 | config.rom = Hanami.app["db.rom"] 41 | end 42 | 43 | Dir[File.dirname(__FILE__) + "/support/factories/*.rb"].each { |file| require file } 44 | -------------------------------------------------------------------------------- /spec/support/component_testing.rb: -------------------------------------------------------------------------------- 1 | module ComponentTestingHelper 2 | def render(component) 3 | component.call 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/support/database_cleaner.rb: -------------------------------------------------------------------------------- 1 | require "database_cleaner/sequel" 2 | 3 | DatabaseCleaner[:sequel].strategy = :transaction 4 | 5 | RSpec.configure do |config| 6 | config.before :suite do 7 | DatabaseCleaner[:sequel].clean_with :truncation, except: ["schema_migrations"] 8 | end 9 | 10 | config.before :each do 11 | DatabaseCleaner[:sequel].strategy = :transaction 12 | DatabaseCleaner[:sequel].start 13 | end 14 | 15 | config.after :each do 16 | DatabaseCleaner[:sequel].clean 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/support/files/cat_small.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/katafrakt/palaver/a69196dcb7e1f44583ca3fb262b28aa33e7041c5/spec/support/files/cat_small.jpg -------------------------------------------------------------------------------- /spec/support/fixtures/account.rb: -------------------------------------------------------------------------------- 1 | module Fixtures 2 | module Account 3 | extend self 4 | 5 | def user(password: SecureRandom.hex(10)) 6 | ::Account::Container["operations.register"] 7 | .call(email: "#{SecureRandom.hex(16)}@test.com", password:) 8 | .value! 9 | end 10 | 11 | def profile(account_id = nil) 12 | account_id ||= user.id 13 | repo = ::Account::Container["repositories.profile"] 14 | repo.create(nickname: "test", account_id:) 15 | end 16 | 17 | def current_user_entity(args) 18 | ::Account::Entities::CurrentUser.new(args) 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/support/fixtures/discussion.rb: -------------------------------------------------------------------------------- 1 | module Fixtures 2 | module Discussion 3 | extend self 4 | 5 | def category 6 | ::Discussion::Container["repositories.category"] 7 | .create(name: "test category") 8 | end 9 | 10 | def thread(category_id:, author:, title: "test thread", content: "this is the thread", locked: false) 11 | event = ::Discussion::Events::ThreadCreated.new( 12 | title:, 13 | content:, 14 | category_id:, 15 | creator: author 16 | ) 17 | 18 | ::Discussion::Container["repositories.thread"].handle(event) 19 | end 20 | 21 | def message(thread_id:, author:, content:) 22 | event = ::Discussion::Events::ReplyAddedToThread.new( 23 | content:, thread_id:, author: 24 | ) 25 | 26 | ::Discussion::Container["repositories.thread"].handle(event) 27 | end 28 | 29 | def profile(account_id: 1) 30 | # there's a cross-slice dependency here, but I guess it is okay in test code 31 | # Discussion slice does not have the rights to write profiles, but they are 32 | # needed to read. 33 | repo = ::Account::Container["repositories.profile"] 34 | record = repo.create(nickname: "test", account_id:) 35 | ::Discussion::Entities::Profile.new(record.to_h.merge(message_count: 1)) 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/support/fixtures/moderation.rb: -------------------------------------------------------------------------------- 1 | module Fixtures 2 | module Moderation 3 | extend self 4 | 5 | def lock_thread(thread_id) 6 | event = ::Moderation::Events::ThreadLocked.new(thread_id:) 7 | ::Moderation::Container["repositories.thread"].handle(event) 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/support/helpers.rb: -------------------------------------------------------------------------------- 1 | module RSpecHelpers 2 | module ClassMethods 3 | def stub(container, key, &block) 4 | around :each do |example| 5 | container.stub(key, instance_exec(&block)) { example.run } 6 | end 7 | end 8 | end 9 | 10 | module InstanceMethods 11 | def category_slug(category) 12 | Discussion::Utils::Slugger.new.to_slug(Discussion::Entities::Category::HASHIDS_NUM, category.name, category.id) 13 | end 14 | 15 | def thread_slug(thread) 16 | Discussion::Utils::Slugger.new.to_slug(Discussion::Entities::Thread::HASHIDS_NUM, thread.title, thread.id) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/support/requests.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rack/test" 4 | 5 | RSpec.shared_context "Hanami app" do 6 | let(:app) { Hanami.app } 7 | end 8 | 9 | RSpec.configure do |config| 10 | config.include Rack::Test::Methods, type: :request 11 | config.include_context "Hanami app", type: :request 12 | end 13 | -------------------------------------------------------------------------------- /spec/support/rspec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "helpers" 4 | require_relative "fixtures/account" 5 | require_relative "fixtures/discussion" 6 | require_relative "fixtures/moderation" 7 | 8 | RSpec.configure do |config| 9 | config.extend RSpecHelpers::ClassMethods 10 | config.include RSpecHelpers::InstanceMethods 11 | 12 | config.expect_with :rspec do |expectations| 13 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 14 | end 15 | 16 | config.mock_with :rspec do |mocks| 17 | mocks.verify_partial_doubles = true 18 | end 19 | 20 | config.shared_context_metadata_behavior = :apply_to_host_groups 21 | 22 | config.filter_run_when_matching :focus 23 | 24 | config.disable_monkey_patching! 25 | config.warnings = true 26 | 27 | if config.files_to_run.one? 28 | config.default_formatter = "doc" 29 | end 30 | 31 | config.profile_examples = 10 32 | 33 | config.order = :random 34 | Kernel.srand config.seed 35 | 36 | config.before(:all) do 37 | require "argon2" 38 | Account::Container.stub("utils.hasher", Argon2::Password.new(t_cost: 1, m_cost: 4, p_cost: 1)) 39 | Discussion::Container.stub("utils.slug_provider", ->(string) { string.downcase.tr(" ", "-") }) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/support/uploads.rb: -------------------------------------------------------------------------------- 1 | require "fileutils" 2 | 3 | RSpec.configure do |config| 4 | uploads_dir = Hanami.app.root.join("spec", "tmp", "uploads") 5 | 6 | config.before :suite do 7 | FileUtils.mkdir_p(uploads_dir) 8 | end 9 | 10 | config.after :suite do 11 | FileUtils.rm_rf(uploads_dir) 12 | end 13 | end 14 | --------------------------------------------------------------------------------