├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── epic-mvp.md │ └── feature_request.md └── workflows │ └── CI.yml ├── .gitignore ├── .haml-lint.yml ├── .prettierrc.yaml ├── .rspec ├── .rubocop.yml ├── .ruby-version ├── .yarn └── releases │ └── yarn-berry.cjs ├── .yarnrc.yml ├── Dockerfile ├── Dockerfile.system ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── Procfile ├── README.md ├── Rakefile ├── app ├── assets │ ├── config │ │ └── manifest.js │ ├── images │ │ ├── .keep │ │ ├── happy_users.png │ │ ├── logo.svg │ │ └── social.png │ └── stylesheets │ │ └── application.scss ├── channels │ └── application_cable │ │ ├── channel.rb │ │ └── connection.rb ├── controllers │ ├── application_controller.rb │ ├── concerns │ │ └── .keep │ ├── extension_controller.rb │ ├── home_controller.rb │ ├── omniauth_callbacks_controller.rb │ ├── sessions_controller.rb │ ├── teams │ │ ├── application_controller.rb │ │ ├── subscriptions_controller.rb │ │ ├── topics │ │ │ ├── application_controller.rb │ │ │ ├── comments │ │ │ │ ├── application_controller.rb │ │ │ │ └── votes_controller.rb │ │ │ ├── comments_controller.rb │ │ │ └── votes_controller.rb │ │ ├── topics_controller.rb │ │ └── users_controller.rb │ ├── teams_controller.rb │ ├── users │ │ ├── application_controller.rb │ │ ├── notifications_controller.rb │ │ └── preferences_controller.rb │ └── users_controller.rb ├── helpers │ ├── application_helper.rb │ ├── teams │ │ ├── topics │ │ │ └── comments_helper.rb │ │ └── topics_helper.rb │ ├── teams_helper.rb │ └── users │ │ └── notifications_helper.rb ├── jobs │ └── application_job.rb ├── mailers │ ├── application_mailer.rb │ ├── digest_mailer.rb │ ├── support_mailer.rb │ └── user_mailer.rb ├── models │ ├── application_record.rb │ ├── comment.rb │ ├── concerns │ │ └── .keep │ ├── notification.rb │ ├── subscription.rb │ ├── team.rb │ ├── topic.rb │ ├── user.rb │ ├── user │ │ └── preferences.rb │ └── vote.rb ├── packs │ ├── channels │ │ ├── consumer.js │ │ └── index.js │ ├── controllers │ │ ├── attach_tribute_controller.js │ │ ├── driverjs_controller.js │ │ ├── hotkey_controller.js │ │ ├── index.js │ │ ├── quote_reply_controller.js │ │ ├── reset_form_controller.js │ │ ├── subscription_controller.js │ │ └── toastui_editor_controller.js │ └── entrypoints │ │ └── application.js ├── policies │ ├── application_policy.rb │ ├── comment_policy.rb │ ├── extension_policy.rb │ ├── notification_policy.rb │ ├── subscription_policy.rb │ ├── team_policy.rb │ ├── teams │ │ ├── topics │ │ │ ├── comments │ │ │ │ └── vote_policy.rb │ │ │ └── vote_policy.rb │ │ └── user_policy.rb │ ├── topic_policy.rb │ ├── user_policy.rb │ └── users │ │ └── preferences_policy.rb ├── services │ ├── application_service.rb │ ├── comment_updater.rb │ ├── digest_email_sender.rb │ ├── markdown_parser.rb │ └── topic_updater.rb ├── validators │ └── image_data_validator.rb └── views │ ├── digest_mailer │ ├── digest_email.html.haml │ └── digest_email.text.haml │ ├── home │ └── index.html.haml │ ├── layouts │ ├── application.html.haml │ ├── mailer.html.haml │ └── mailer.text.haml │ ├── shared │ ├── _form_errors.html.haml │ ├── _head.html.haml │ └── _markdown_text_area.html.haml │ ├── support_mailer │ ├── support_email.html.haml │ └── support_email.text.haml │ ├── teams │ ├── edit.html.haml │ ├── new.html.haml │ └── topics │ │ ├── _topic.html.haml │ │ ├── _topic_form.html.haml │ │ ├── _topics.html.haml │ │ ├── _vote_list_group.html.haml │ │ ├── comments │ │ ├── _comment.html.haml │ │ ├── _comment_box.html.haml │ │ ├── _comment_form.html.haml │ │ ├── edit.html.haml │ │ └── new.html.haml │ │ ├── edit.html.haml │ │ ├── index.html.haml │ │ ├── new.html.haml │ │ └── show.html.haml │ ├── user_mailer │ ├── welcome_email.html.haml │ └── welcome_email.text.haml │ └── users │ ├── edit.html.haml │ └── notifications │ └── index.html.haml ├── babel.config.js ├── bin ├── bundle ├── rails ├── rake ├── setup ├── spring ├── webpack ├── webpack-dev-server └── yarn ├── config.ru ├── config ├── application.rb ├── blazer.yml ├── boot.rb ├── cable.yml ├── database.yml ├── environment.rb ├── environments │ ├── development.rb │ ├── production.rb │ ├── staging.rb │ └── test.rb ├── initializers │ ├── acts_as_taggable_on.rb │ ├── application_controller_renderer.rb │ ├── assets.rb │ ├── backtrace_silencers.rb │ ├── content_security_policy.rb │ ├── cookies_serializer.rb │ ├── environment.rb │ ├── filter_parameter_logging.rb │ ├── inflections.rb │ ├── mime_types.rb │ ├── omniauth.rb │ ├── pagy.rb │ ├── permissions_policy.rb │ ├── rack_profiler.rb │ ├── webpacker_patch.rb │ └── wrap_parameters.rb ├── locales │ └── en.yml ├── puma.rb ├── routes.rb ├── spring.rb ├── storage.yml ├── webpack │ ├── base.js │ ├── development.js │ ├── environment.js │ ├── production.js │ └── test.js └── webpacker.yml ├── db ├── migrate │ ├── 20201212231443_create_teams.rb │ ├── 20201212231556_create_users.rb │ ├── 20201212232503_create_topics.rb │ ├── 20201212232508_create_comments.rb │ ├── 20210117195615_create_subscriptions.rb │ ├── 20210123181841_create_notifications.rb │ ├── 20210211101554_create_votes.rb │ ├── 20210215173618_create_user_preferences.rb │ ├── 20210317104058_install_blazer.rb │ ├── 20210326064951_add_message_to_team.rb │ ├── 20210402075111_add_pinned_to_topics.rb │ ├── 20210403173818_acts_as_taggable_on_migration.acts_as_taggable_on_engine.rb │ ├── 20210414151736_add_comment_order_to_user_preferences.rb │ ├── 20210420150827_create_team_subscriptions.rb │ ├── 20210502124505_add_missing_unique_indices.acts_as_taggable_on_engine.rb │ ├── 20210502124506_add_taggings_counter_cache_to_tags.acts_as_taggable_on_engine.rb │ ├── 20210502124507_add_missing_taggable_index.acts_as_taggable_on_engine.rb │ ├── 20210502124508_change_collation_for_tag_names.acts_as_taggable_on_engine.rb │ ├── 20210502124509_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb │ ├── 20210629083734_add_is_archived_to_topic.rb │ ├── 20210629083736_add_is_archived_to_comment.rb │ ├── 20220113140839_add_last_login_to_user.rb │ └── 20220421111001_drop_team_subscriptions.rb ├── schema.rb └── seeds.rb ├── docs ├── images │ ├── asyncgo_topics.png │ ├── atmentions.png │ ├── basicfunctions.png │ ├── duedate.png │ ├── fluid.png │ ├── gravatar.png │ ├── labels.png │ ├── markdown.png │ ├── notifications.png │ ├── participants.png │ ├── pin.png │ ├── teammessage.png │ └── votes.png ├── index.md ├── integrations.md ├── markdown.md ├── teams.md ├── topics.md └── usersettings.md ├── lib ├── assets │ └── .keep ├── asyncgo_tag_parser.rb └── tasks │ ├── .keep │ └── scheduler.rake ├── log └── .keep ├── package.json ├── postcss.config.js ├── public ├── 404.html ├── 422.html ├── 500.html ├── apple-touch-icon-precomposed.png ├── apple-touch-icon.png ├── favicon.ico └── robots.txt ├── renovate.json ├── spec ├── factories │ ├── comments.rb │ ├── notifications.rb │ ├── teams.rb │ ├── topics.rb │ ├── users.rb │ └── votes.rb ├── helpers │ ├── application_helper_spec.rb │ ├── teams │ │ └── topics_helper_spec.rb │ └── users │ │ └── notifications_helper_spec.rb ├── mailers │ ├── digest_mailer_spec.rb │ ├── previews │ │ ├── digest_mailer_preview.rb │ │ ├── support_mailer_preview.rb │ │ └── user_mailer_preview.rb │ ├── support_mailer_spec.rb │ └── user_mailer_spec.rb ├── models │ ├── comment_spec.rb │ ├── notification_spec.rb │ ├── subscription_spec.rb │ ├── team_spec.rb │ ├── topic_spec.rb │ ├── user │ │ └── preferences_spec.rb │ ├── user_spec.rb │ └── vote_spec.rb ├── rails_helper.rb ├── requests │ ├── extension_controller_spec.rb │ ├── home_controller_spec.rb │ ├── omniauth_callbacks_controller_spec.rb │ ├── sessions_controller_spec.rb │ ├── teams │ │ ├── topics │ │ │ ├── comments │ │ │ │ └── votes_controller_spec.rb │ │ │ ├── comments_controller_spec.rb │ │ │ └── votes_controller_spec.rb │ │ ├── topics_controller_spec.rb │ │ └── users_controller_spec.rb │ ├── teams_controller_spec.rb │ ├── users │ │ ├── notifications_controller_spec.rb │ │ └── preferences_controller_spec.rb │ └── users_controller_spec.rb ├── services │ ├── comment_updater_spec.rb │ ├── digest_email_sender_spec.rb │ ├── markdown_parser_spec.rb │ └── topic_updater_spec.rb ├── spec_helper.rb ├── support │ ├── factory_bot.rb │ ├── sign_in_out_request_helpers.rb │ ├── sign_in_out_system_helpers.rb │ ├── unauthenticated_user_examples.rb │ └── unauthorized_user_examples.rb ├── system │ ├── accessibility_spec.rb │ ├── authentication_spec.rb │ ├── comments_spec.rb │ ├── homepage_spec.rb │ ├── notifications_spec.rb │ ├── pagination_spec.rb │ ├── teams_spec.rb │ ├── topics_spec.rb │ └── users_spec.rb └── validators │ └── image_data_validator_spec.rb ├── storage └── .keep ├── tmp ├── .keep └── pids │ └── .keep ├── vendor └── .keep └── yarn.lock /.gitattributes: -------------------------------------------------------------------------------- 1 | # See https://git-scm.com/docs/gitattributes for more about git attribute files. 2 | 3 | # Mark the database schema as having been generated. 4 | db/schema.rb linguist-generated 5 | 6 | # Mark the yarn lockfile as having been generated. 7 | yarn.lock linguist-generated 8 | 9 | # Mark any vendored files as having been vendored. 10 | vendor/* linguist-vendored 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### Summary 11 | 12 | 13 | ### Steps to reproduce 14 | 15 | 16 | ### Link to example 17 | 18 | 19 | ### What is the current *bug* behavior? 20 | 21 | 22 | ### What is the expected *correct* behavior? 23 | 24 | 25 | ### Relevant logs and/or screenshots 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/epic-mvp.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Epic/MVP 3 | about: Collection of related features (and occasionally bugs) 4 | title: '' 5 | labels: epic 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### Problem to solve 11 | 12 | 13 | ### Proposal 14 | 15 | 16 | ### Other details 17 | 18 | 19 | ### Links / references 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### Problem to solve 11 | 12 | 13 | ### Proposal 14 | 15 | 16 | ### Other details 17 | 18 | 19 | ### Permissions and Security 20 | 21 | 22 | ### Documentation 23 | 24 | 25 | ### Links / references 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files for more about ignoring files. 2 | # 3 | # If you find yourself ignoring temporary files generated by your text editor 4 | # or operating system, you probably want to add a global ignore instead: 5 | # git config --global core.excludesfile '~/.gitignore_global' 6 | 7 | # Ignore bundler config. 8 | /.bundle 9 | 10 | # Ignore the default SQLite database. 11 | /db/*.sqlite3 12 | /db/*.sqlite3-* 13 | 14 | # Ignore all logfiles and tempfiles. 15 | /log/* 16 | /tmp/* 17 | !/log/.keep 18 | !/tmp/.keep 19 | 20 | # Ignore pidfiles, but keep the directory. 21 | /tmp/pids/* 22 | !/tmp/pids/ 23 | !/tmp/pids/.keep 24 | 25 | # Ignore uploaded files in development. 26 | /storage/* 27 | !/storage/.keep 28 | 29 | /public/assets 30 | .byebug_history 31 | 32 | # Ignore master key for decrypting credentials and more. 33 | /config/master.key 34 | 35 | /public/packs 36 | /public/packs-test 37 | 38 | # Ignore .env file 39 | .env 40 | 41 | # Ignore yarn files 42 | /node_modules 43 | /yarn-error.log 44 | yarn-debug.log* 45 | .yarn-integrity 46 | .yarn/* 47 | !.yarn/patches 48 | !.yarn/releases 49 | !.yarn/plugins 50 | !.yarn/sdks 51 | !.yarn/versions 52 | .pnp.* 53 | 54 | # Ignore vim files 55 | [._]*.s[a-v][a-z] 56 | [._]*.sw[a-p] 57 | [._]s[a-rt-v][a-z] 58 | [._]ss[a-gi-z] 59 | [._]sw[a-p] 60 | Session.vim 61 | Sessionx.vim 62 | .netrwhist 63 | *~ 64 | tags 65 | tags.lock 66 | tags.temp 67 | [._]*.un~ 68 | -------------------------------------------------------------------------------- /.haml-lint.yml: -------------------------------------------------------------------------------- 1 | exclude: 2 | - 'vendor/**/*' 3 | 4 | linters: 5 | LineLength: 6 | max: 100 7 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | proseWrap: always 2 | printWidth: 80 3 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require rails_helper 2 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - rubocop-performance 3 | - rubocop-rails 4 | - rubocop-rspec 5 | 6 | inherit_mode: 7 | merge: 8 | - Exclude 9 | 10 | AllCops: 11 | NewCops: enable 12 | 13 | Metrics/BlockLength: 14 | Exclude: 15 | - 'config/environments/**/*' 16 | - 'config/routes.rb' 17 | - 'spec/**/*' 18 | 19 | Metrics/MethodLength: 20 | Exclude: 21 | - 'db/migrate/*' 22 | 23 | RSpec/ExampleLength: 24 | Exclude: 25 | - 'spec/system/**/*' 26 | 27 | RSpec/MultipleExpectations: 28 | Exclude: 29 | - 'spec/system/**/*' 30 | 31 | RSpec/NestedGroups: 32 | Max: 5 33 | 34 | Style/Documentation: 35 | Enabled: false 36 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.1.0 2 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | yarnPath: ".yarn/releases/yarn-berry.cjs" 2 | nodeLinker: node-modules 3 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ruby:3.1.0 2 | 3 | RUN curl -fsSL https://deb.nodesource.com/setup_16.x | bash - 4 | RUN apt update && apt install --yes --quiet nodejs 5 | 6 | RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - 7 | RUN echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list 8 | RUN apt update && apt install --yes --quiet yarn=1.22.18-1 9 | -------------------------------------------------------------------------------- /Dockerfile.system: -------------------------------------------------------------------------------- 1 | FROM j4yav/ruby-yarn:3.1.0-1.22.18-1 2 | 3 | RUN apt install --yes --quiet chromium=99.0.4844.51-1~deb11u1 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022 Jason Yavorska, Matija Cupic 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | release: bin/rails db:migrate 2 | web: bin/rails server --port ${PORT:-5000} --environment $RAILS_ENV 3 | worker: bundle exec sidekiq --queue asyncgo_production_default --environment $RAILS_ENV 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # README 2 | 3 | AsyncGo: More mindful collaboration 4 | 5 | AsyncGo helps your team improve how you work, no meetings necessary. 6 | Simply raise a tension, have a discussion, and make a change. 7 | 8 | [User Documentation](./docs/index.md) 9 | 10 | ![Topic](./docs/images/asyncgo_topics.png) 11 | 12 | ## Requirements 13 | 14 | - ruby 3.1.0 (see `.ruby-version`) 15 | - bundler 2.1.4 16 | - yarn 1.22.10 17 | - chrome (for headless rspec tests) 18 | 19 | ## Set up the development database 20 | 21 | 1. Install PostgreSQL 13 22 | 1. Create a passwordless postgresql superuser with a username that matches your 23 | system user (if it doesn't exist already) 24 | 25 | ## Start the development server 26 | 27 | 1. Run `bundle install` 28 | 1. Run `bin/yarn install` 29 | 1. Run `bin/rails db:create` 30 | 1. Run `bin/rails db:migrate` (you can also use `bin/rails db:seed` if you want 31 | sample data loaded.) 32 | 1. Run `bin/rails server` 33 | 34 | ## Configuring auth 35 | 36 | The Client ID and Secret should be used instead of `[REDACTED]`. Please don't 37 | save the Client ID or Client Secret anywhere online or locally other than in the 38 | `.env` file. 39 | 40 | Client IDs and secrets are set up in the developer tooling for each service. 41 | 42 | ### Google 43 | 44 | 1. Go to [Google Cloud Console](https://console.cloud.google.com/) 45 | 2. Go to "APIs & Services" -> "Credentials" 46 | 3. Copy or create the Client ID and Client Secret 47 | 4. Open the `.env` file in the root of your local copy of the project 48 | 5. Enter the Client ID as the `GOOGLE_CLIENT_ID` 49 | 6. Enter the Client Secret as the `GOOGLE_CLIENT_SECRET` 50 | 51 | Here's what a `.env` file looks like 52 | 53 | ```bash 54 | GOOGLE_CLIENT_ID=[REDACTED] 55 | GOOGLE_CLIENT_SECRET=[REDACTED] 56 | ``` 57 | 58 | ### GitHub 59 | 60 | 1. Go to 61 | [GitHub Application](https://github.com/organizations/async-go/settings/applications) 62 | 2. Copy or create a Client ID 63 | 3. Obtain the Client Secret 64 | 4. Open the `.env` file in the root of your local copy of the project 65 | 5. Enter the Client ID as the `GITHUB_CLIENT_ID` 66 | 6. Enter the Client Secret as the `GITHUB_CLIENT_SECRET` 67 | 68 | Here's what a `.env` file looks like 69 | 70 | ```bash 71 | GITHUB_CLIENT_ID=[REDACTED] 72 | GITHUB_CLIENT_SECRET=[REDACTED] 73 | ``` 74 | 75 | ## Rake tasks 76 | 77 | - `rake send_digest_emails` emails a list of unread notifications to every user 78 | 79 | ## Blazer authentication 80 | 81 | Blazer uses hard-coded user authentication. It checks if the user email ends 82 | with `@asyncgo.com`, but you can change this to meet your needs. 83 | 84 | ## Container builds 85 | 86 | Dockerfiles are in the root of this repo. If you update the versions, update the 87 | versions in the container label. 88 | 89 | If you are using an M1 Mac you need to 90 | [build for Linux](https://blog.jaimyn.dev/how-to-build-multi-architecture-docker-images-on-an-m1-mac/) 91 | 92 | ```bash 93 | docker buildx build --platform linux/amd64 --push -t\ 94 | j4yav/ruby-yarn:3.1.0-1.22.18-1 . -f Dockerfile 95 | ``` 96 | 97 | ```bash 98 | docker buildx build --platform linux/amd64 --push -t\ 99 | j4yav/ruby-yarn-chromium:3.1.0-1.22.18-1-99.0.4844.51-1 . -f Dockerfile.system 100 | ``` 101 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Add your own tasks in files placed in lib/tasks ending in .rake, 4 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 5 | 6 | require_relative 'config/application' 7 | 8 | Rails.application.load_tasks 9 | -------------------------------------------------------------------------------- /app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | //= link_tree ../images 2 | //= link_directory ../stylesheets .css 3 | -------------------------------------------------------------------------------- /app/assets/images/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/async-go/asyncgo/b4486310ce3d44755bc6411c8a8ba32457e3eb85/app/assets/images/.keep -------------------------------------------------------------------------------- /app/assets/images/happy_users.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/async-go/asyncgo/b4486310ce3d44755bc6411c8a8ba32457e3eb85/app/assets/images/happy_users.png -------------------------------------------------------------------------------- /app/assets/images/social.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/async-go/asyncgo/b4486310ce3d44755bc6411c8a8ba32457e3eb85/app/assets/images/social.png -------------------------------------------------------------------------------- /app/channels/application_cable/channel.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ApplicationCable 4 | class Channel < ActionCable::Channel::Base 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /app/channels/application_cable/connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ApplicationCable 4 | class Connection < ActionCable::Connection::Base 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationController < ActionController::Base 4 | include Pundit::Authorization 5 | 6 | rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized 7 | 8 | protected 9 | 10 | def user_not_authorized(_exception) 11 | flash[:warning] = I18n.t(:you_are_not_authorized) 12 | 13 | redirect_back fallback_location: root_path 14 | end 15 | 16 | def current_user 17 | @current_user ||= if session[:user_id] 18 | User.find_by(id: session[:user_id]).tap do |user| 19 | user ? gon.push(user_id: user.id, team_id: user.team_id) : session.delete(:user_id) 20 | end 21 | end 22 | end 23 | helper_method :current_user 24 | 25 | def unique_unread_notifications 26 | @unique_unread_notifications ||= Notification.includes(:actor, :user, 27 | :target).where(id: notification_grouping_subquery) 28 | end 29 | helper_method :unique_unread_notifications 30 | 31 | private 32 | 33 | def notification_grouping_subquery 34 | Notification 35 | .where(user: current_user, read_at: nil) 36 | .select('MAX(id)').group(:target_id, :target_type, :actor_id, :user_id, :action) 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /app/controllers/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/async-go/asyncgo/b4486310ce3d44755bc6411c8a8ba32457e3eb85/app/controllers/concerns/.keep -------------------------------------------------------------------------------- /app/controllers/extension_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ExtensionController < ApplicationController 4 | skip_before_action :verify_authenticity_token 5 | 6 | def new_topic 7 | authorize(current_user, policy_class: ExtensionPolicy) 8 | 9 | redirect_to new_team_topic_path(current_user.team, params: new_topic_params) 10 | end 11 | 12 | private 13 | 14 | def new_topic_params 15 | params.permit(:selection, :context) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/controllers/home_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class HomeController < ApplicationController 4 | def index 5 | if current_user&.team 6 | redirect_to team_topics_path(current_user.team) 7 | elsif current_user 8 | redirect_to new_team_path 9 | else 10 | render :index 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/controllers/omniauth_callbacks_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class OmniauthCallbacksController < ApplicationController 4 | def google_oauth2 5 | response = request.env['omniauth.auth'].info 6 | handle_auth(response['email'], response['name']) 7 | end 8 | 9 | def github 10 | response = request.env['omniauth.auth'].info 11 | handle_auth(response['email'], response['name']) 12 | end 13 | 14 | def microsoft_graph 15 | response = request.env['omniauth.auth'].info 16 | handle_auth(response['email'], "#{response['first_name']} #{response['last_name']}".presence) 17 | end 18 | 19 | def slack 20 | response = request.env['omniauth.strategy'].access_token 21 | access_token = OmniAuth::Slack.build_access_token( 22 | slack_client_id, 23 | slack_client_secret, 24 | response.authed_user.token 25 | ) 26 | response = access_token.get('/api/users.identity').parsed['user'] 27 | handle_auth(response['email'], response['name']) 28 | end 29 | 30 | private 31 | 32 | def handle_auth(email, name) 33 | user = User.from_omniauth(email, name) 34 | 35 | if user.persisted? 36 | session[:user_id] = user.id 37 | else 38 | flash[:danger] = I18n.t(:could_not_authenticate_user) 39 | end 40 | 41 | redirect_to root_path 42 | end 43 | 44 | def slack_client_id 45 | @slack_client_id ||= Rails.application.config.x.slack.client_id 46 | end 47 | 48 | def slack_client_secret 49 | @slack_client_secret ||= Rails.application.config.x.slack.client_secret 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /app/controllers/sessions_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class SessionsController < ApplicationController 4 | def destroy 5 | session[:user_id] = nil 6 | 7 | redirect_to root_path, flash: { success: 'You have been signed out.' } 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/controllers/teams/application_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Teams 4 | class ApplicationController < ::ApplicationController 5 | protected 6 | 7 | def team 8 | @team ||= Team.find(params[:team_id] || params[:id]) 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/controllers/teams/subscriptions_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Teams 4 | class SubscriptionsController < Teams::ApplicationController 5 | def edit 6 | authorize(team) 7 | 8 | redirect_to ::FastSpringAccountLinker.new(current_user.email).call, allow_other_host: true 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/controllers/teams/topics/application_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Teams 4 | module Topics 5 | class ApplicationController < ::Teams::ApplicationController 6 | protected 7 | 8 | def topic 9 | @topic ||= team.topics.find(params[:topic_id] || params[:id]) 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/controllers/teams/topics/comments/application_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Teams 4 | module Topics 5 | module Comments 6 | class ApplicationController < ::Teams::Topics::ApplicationController 7 | protected 8 | 9 | def comment 10 | @comment ||= topic.comments.find(params[:comment_id] || params[:id]) 11 | end 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/controllers/teams/topics/comments/votes_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Teams 4 | module Topics 5 | module Comments 6 | class VotesController < Teams::Topics::Comments::ApplicationController 7 | def create # rubocop:disable Metrics/MethodLength 8 | target_comment = comment 9 | authorize(target_comment, policy_class: Teams::Topics::Comments::VotePolicy) 10 | 11 | success = Vote.create(create_params).valid? 12 | 13 | respond_to do |format| 14 | format.turbo_stream do 15 | render turbo_stream: turbo_stream.replace(target_comment, partial: 'teams/topics/comments/comment', 16 | locals: { comment: target_comment }) 17 | end 18 | format.html do 19 | vote_flash = if success 20 | { success: 'Vote was successfully added.' } 21 | else 22 | { danger: 'There was an error while adding the vote.' } 23 | end 24 | 25 | redirect_to topic_path(target_comment), flash: vote_flash 26 | end 27 | end 28 | end 29 | 30 | def destroy # rubocop:disable Metrics/MethodLength 31 | target_comment = comment 32 | vote = target_comment.votes.find(params[:id]) 33 | authorize([:teams, :topics, :comments, vote]) 34 | 35 | vote.destroy 36 | respond_to do |format| 37 | format.turbo_stream do 38 | render turbo_stream: turbo_stream.replace(target_comment, partial: 'teams/topics/comments/comment', 39 | locals: { comment: target_comment }) 40 | end 41 | format.html { redirect_to topic_path(target_comment), flash: { success: 'Vote was successfully removed.' } } 42 | end 43 | end 44 | 45 | private 46 | 47 | def create_params 48 | params.require(:vote).permit(:emoji).merge(user: current_user, votable: comment) 49 | end 50 | 51 | def topic_path(comment) 52 | team_topic_path(comment.topic.team, comment.topic) 53 | end 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /app/controllers/teams/topics/comments_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Teams 4 | module Topics 5 | class CommentsController < Teams::Topics::ApplicationController 6 | include Pagy::Backend 7 | 8 | def new 9 | @comment = topic.comments.build 10 | authorize(@comment) 11 | end 12 | 13 | def edit 14 | @comment = comment 15 | authorize(@comment) 16 | end 17 | 18 | def archive 19 | @comment = comment 20 | authorize(@comment) 21 | @comment.update(is_archived: true) 22 | 23 | redirect_to team_topic_path(topic.team, topic), flash: { success: 'Comment was successfully archived.' } 24 | end 25 | 26 | def create # rubocop:disable Metrics/MethodLength 27 | @comment = topic.comments.build 28 | authorize(@comment) 29 | 30 | if update_comment(@comment, create_params) 31 | respond_to do |format| 32 | format.turbo_stream do 33 | render turbo_stream: turbo_stream.action( 34 | turbo_stream_action, :comments, 35 | partial: 'teams/topics/comments/comment', 36 | locals: { comment: @comment } 37 | ) 38 | end 39 | format.html do 40 | redirect_to topic_path(@comment) 41 | end 42 | end 43 | else 44 | render :new, status: :unprocessable_entity 45 | end 46 | end 47 | 48 | def update # rubocop:disable Metrics/MethodLength 49 | @comment = comment 50 | authorize(@comment) 51 | 52 | if update_comment(@comment, comment_params) 53 | respond_to do |format| 54 | format.turbo_stream do 55 | render turbo_stream: turbo_stream.replace(@comment, partial: 'teams/topics/comments/comment', 56 | locals: { comment: @comment }) 57 | end 58 | format.html do 59 | redirect_to topic_path(@comment), flash: { success: 'Comment was successfully updated.' } 60 | end 61 | end 62 | else 63 | render :edit, status: :unprocessable_entity 64 | end 65 | end 66 | 67 | private 68 | 69 | def comment_params 70 | params.require(:comment).permit(:body) 71 | end 72 | 73 | def create_params 74 | comment_params.merge(user: current_user) 75 | end 76 | 77 | def comment 78 | @comment ||= topic.comments.find(params[:comment_id] || params[:id]) 79 | end 80 | 81 | def turbo_stream_action 82 | if current_user.preferences.inverse_comment_order? 83 | :prepend 84 | else 85 | :append 86 | end 87 | end 88 | 89 | def update_comment(comment, comment_params) 90 | CommentUpdater.new(current_user, comment, comment_params).call 91 | end 92 | 93 | def topic_path(comment) 94 | team_topic_path(comment.topic.team, comment.topic) 95 | end 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /app/controllers/teams/topics/votes_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Teams 4 | module Topics 5 | class VotesController < Teams::Topics::ApplicationController 6 | def create # rubocop:disable Metrics/MethodLength 7 | authorize(topic, policy_class: Teams::Topics::VotePolicy) 8 | 9 | success = Vote.create(create_params).valid? 10 | 11 | respond_to do |format| 12 | format.turbo_stream do 13 | render turbo_stream: turbo_stream.replace(topic, partial: 'teams/topics/topic', locals: { topic: }) 14 | end 15 | format.html do 16 | vote_flash = if success 17 | { success: 'Vote was successfully added.' } 18 | else 19 | { danger: 'There was an error while adding the vote.' } 20 | end 21 | 22 | redirect_to topic_path(topic), flash: vote_flash 23 | end 24 | end 25 | end 26 | 27 | def destroy # rubocop:disable Metrics/MethodLength 28 | target_topic = topic 29 | vote = target_topic.votes.find(params[:id]) 30 | authorize([:teams, :topics, vote]) 31 | 32 | vote.destroy 33 | respond_to do |format| 34 | format.turbo_stream do 35 | render turbo_stream: turbo_stream.replace(target_topic, partial: 'teams/topics/topic', 36 | locals: { topic: target_topic }) 37 | end 38 | format.html { redirect_to topic_path(target_topic), flash: { success: 'Vote was successfully removed.' } } 39 | end 40 | end 41 | 42 | private 43 | 44 | def create_params 45 | params.require(:vote).permit(:emoji).merge(user: current_user, votable: topic) 46 | end 47 | 48 | def topic_path(topic) 49 | team_topic_path(topic.team, topic) 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /app/controllers/teams/users_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Teams 4 | class UsersController < Teams::ApplicationController 5 | def index 6 | authorize(team, policy_class: Teams::UserPolicy) 7 | 8 | respond_to do |format| 9 | format.json do 10 | users = team.users.map { |user| { key: user.printable_name, value: user.email } } 11 | render json: users 12 | end 13 | end 14 | end 15 | 16 | def create 17 | authorize(team, policy_class: Teams::UserPolicy) 18 | 19 | user = User.find_or_initialize_by(create_params).tap do |target_user| 20 | target_user.preferences ||= target_user.build_preferences 21 | end 22 | 23 | user_flash = add_user_flash!(team, user) 24 | redirect_to edit_team_path(team), flash: user_flash 25 | end 26 | 27 | def destroy 28 | user = team.users.find(params[:id]) 29 | authorize([:teams, user]) 30 | 31 | user_flash = if team.users.count > 1 32 | team.users.delete(user) 33 | { success: 'User was successfully removed from the team.' } 34 | else 35 | { danger: 'User could not be removed from the team because he is the last user in it.' } 36 | end 37 | 38 | redirect_to edit_team_path(team), flash: user_flash 39 | end 40 | 41 | private 42 | 43 | def create_params 44 | { email: params[:user][:email].downcase.strip } 45 | end 46 | 47 | def send_welcome_email(user) 48 | UserMailer.with(user:).welcome_email.deliver_later 49 | end 50 | 51 | def add_user_flash!(team, user) 52 | if !user.valid? 53 | { danger: "There was a problem adding the user to the team. #{user.errors.full_messages.join(', ')}." } 54 | elsif user.team 55 | { danger: 'User already belongs to a team.' } 56 | else 57 | add_user_to_team!(team, user) 58 | { success: 'User was successfully added to the team.' } 59 | end 60 | end 61 | 62 | def add_user_to_team!(team, user) 63 | team.users << user 64 | send_welcome_email(user) 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /app/controllers/teams_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class TeamsController < Teams::ApplicationController 4 | include Pagy::Backend 5 | 6 | def edit 7 | @team = team 8 | authorize(@team) 9 | 10 | @pagy, @team_members = pagy( 11 | team.users.order(:created_at) 12 | ) 13 | end 14 | 15 | def new 16 | @team = Team.new 17 | authorize(@team) 18 | end 19 | 20 | def create 21 | @team = Team.new(team_params) 22 | authorize(@team) 23 | 24 | if @team.save 25 | @team.users << current_user 26 | redirect_to edit_team_path(@team), 27 | flash: { success: 'Team was successfully created.' } 28 | else 29 | render :new, status: :unprocessable_entity 30 | end 31 | end 32 | 33 | def support 34 | authorize(team) 35 | 36 | support_flash = if SupportMailer.with(user: current_user, body: params[:body]).support_email.deliver_later 37 | { success: 'Support request was successfully sent.' } 38 | else 39 | { danger: 'Support request was not sent.' } 40 | end 41 | 42 | redirect_to edit_team_path(team), flash: support_flash 43 | end 44 | 45 | def update 46 | @team = team 47 | @pagy, @team_members = pagy(@team.users.order(:created_at)) 48 | 49 | authorize(@team) 50 | 51 | if @team.update(team_params) 52 | redirect_to edit_team_path(team), 53 | flash: { success: 'Team was successfully updated.' } 54 | else 55 | render :edit, status: :unprocessable_entity 56 | end 57 | end 58 | 59 | private 60 | 61 | def team_params 62 | params.require(:team).permit(:name, :message).tap do |params| 63 | params[:message] = params[:message].presence 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /app/controllers/users/application_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Users 4 | class ApplicationController < ::ApplicationController 5 | protected 6 | 7 | def user 8 | @user ||= User.find(params[:user_id] || params[:id]) 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/controllers/users/notifications_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Users 4 | class NotificationsController < ::Users::ApplicationController 5 | include Pagy::Backend 6 | 7 | def index 8 | authorize(user, policy_class: NotificationPolicy) 9 | 10 | @pagy, @notifications = pagy(unique_unread_notifications) 11 | end 12 | 13 | def show 14 | authorize(notification) 15 | 16 | if notification_group.update(read_at: Time.now.utc) 17 | redirect_object = redirect_target(notification) 18 | redirect_path = team_topic_path(redirect_object.team, redirect_object) 19 | redirect_flash = nil 20 | else 21 | redirect_path = root_path 22 | redirect_flash = { danger: 'Notification could not be marked as read.' } 23 | end 24 | 25 | redirect_to redirect_path, flash: redirect_flash 26 | end 27 | 28 | def clear 29 | authorize(user, policy_class: NotificationPolicy) 30 | 31 | user.notifications.update(read_at: Time.now.utc) 32 | 33 | redirect_back fallback_location: root_path 34 | end 35 | 36 | private 37 | 38 | def notification 39 | @notification ||= Notification.find(params[:id]) 40 | end 41 | 42 | def notification_group 43 | Notification.where( 44 | user: notification.user, actor: notification.actor, 45 | target: notification.target, action: notification.action 46 | ) 47 | end 48 | 49 | def redirect_target(notification) 50 | case notification.target_type 51 | when 'Comment' 52 | notification.target.topic 53 | when 'Topic' 54 | notification.target 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /app/controllers/users/preferences_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Users 4 | class PreferencesController < ::Users::ApplicationController 5 | def update 6 | @preferences = user.preferences 7 | authorize(@preferences, policy_class: Users::PreferencesPolicy) 8 | 9 | if @preferences.update(preferences_params) 10 | redirect_to edit_user_path(@preferences.user), 11 | flash: { success: 'User preferences were successfully updated.' } 12 | else 13 | render 'users/edit', status: :unprocessable_entity 14 | end 15 | end 16 | 17 | private 18 | 19 | def preferences_params 20 | params.require(:user_preferences).permit(:digest_enabled, :fluid_layout, :inverse_comment_order) 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /app/controllers/users_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class UsersController < ::Users::ApplicationController 4 | def edit 5 | @user = user 6 | authorize(@user) 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ApplicationHelper 4 | def assistive_icon(source, icon, title, classname: nil) 5 | icon(source, icon, class: classname, title:) + tag.span(title, class: 'visually-hidden') 6 | end 7 | 8 | def emoji_group_text(emoji_name, count) 9 | "#{Emoji.find_by_alias(emoji_name).raw} #{count}" # rubocop:disable Rails/DynamicFindBy 10 | end 11 | 12 | def fastspring_store_url 13 | Rails.application.config.x.fastspring.store_url 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/helpers/teams/topics/comments_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Teams 4 | module Topics 5 | module CommentsHelper 6 | include Pagy::Frontend 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/helpers/teams/topics_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Teams 4 | module TopicsHelper 5 | include ::Pagy::Frontend 6 | 7 | def user_subscribed?(topic) 8 | topic.subscribed_users.include?(current_user) 9 | end 10 | 11 | def topic_due_date_span(topic) 12 | alert_style = topic_overdue?(topic) ? 'text-accent' : nil 13 | 14 | tag.span(class: alert_style) do 15 | topic_due_date_text(topic) 16 | end 17 | end 18 | 19 | def topic_has_notification?(notifications, topic) 20 | # It's cheaper to check this in memory than it is to query the 21 | # database for every single topic. We expect an user to have a 22 | # couple dozen notifications at the most. 23 | notifications.any? do |notification| 24 | notification.target == topic || 25 | (notification.target_type == 'Comment' && notification.target.topic_id == topic.id) 26 | end 27 | end 28 | 29 | def string_checksum(value) 30 | Digest::MD5.hexdigest(value.to_s) 31 | end 32 | 33 | def vote_groups(votable) 34 | thumbsup = votable.votes.select { |vote| vote.emoji == 'thumbsup' } 35 | thumbsdown = votable.votes.select { |vote| vote.emoji == 'thumbsdown' } 36 | 37 | { 'thumbsup' => thumbsup, 'thumbsdown' => thumbsdown } 38 | end 39 | 40 | def votable_path(votable) 41 | case votable 42 | when Topic 43 | [votable.team, votable] 44 | when Comment 45 | [votable.topic.team, votable.topic, votable] 46 | end 47 | end 48 | 49 | private 50 | 51 | def topic_due_date_text(topic) 52 | return 'No due date' unless topic.due_date? 53 | 54 | if topic.active? 55 | active_topic_due_date_text(topic) 56 | else 57 | "Due #{topic.due_date.strftime('%b %-d')}" 58 | end 59 | end 60 | 61 | def active_topic_due_date_text(topic) 62 | due_date_day_diff = (topic.due_date - Time.now.utc.to_date).to_i 63 | if topic_overdue?(topic) 64 | "Due #{pluralize(due_date_day_diff.abs, 'day')} ago" 65 | elsif topic.due_date == Time.zone.today 66 | 'Due today' 67 | else 68 | "Due in #{pluralize(due_date_day_diff, 'day')}" 69 | end 70 | end 71 | 72 | def topic_overdue?(topic) 73 | return false unless topic.due_date? 74 | return false unless topic.active? 75 | 76 | topic.due_date.end_of_day < Time.now.utc 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /app/helpers/teams_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TeamsHelper 4 | include Pagy::Frontend 5 | end 6 | -------------------------------------------------------------------------------- /app/helpers/users/notifications_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Users 4 | module NotificationsHelper 5 | include ::Pagy::Frontend 6 | 7 | def notification_text(notification) 8 | case notification.target 9 | when Comment 10 | comment_notification_text(notification) 11 | when Topic 12 | topic_notification_text(notification) 13 | end 14 | end 15 | 16 | private 17 | 18 | def topic_notification_text(notification) 19 | case notification.action 20 | when 'expiring' 21 | "The topic #{notification.target.title} is due in less than one day." 22 | when 'created', 'updated', 'reopened', 'resolved' 23 | "#{notification.actor.printable_name} #{notification.action} the topic #{notification.target.title}" 24 | when 'mentioned' 25 | "#{notification.actor.printable_name} mentioned you in the topic #{notification.target.title}" 26 | end 27 | end 28 | 29 | def comment_notification_text(notification) 30 | case notification.action 31 | when 'created', 'updated' 32 | <<-NOTIFICATION_TEXT.squish 33 | #{notification.actor.printable_name} 34 | #{notification.action} 35 | a comment in the topic 36 | #{notification.target.topic.title} 37 | NOTIFICATION_TEXT 38 | when 'mentioned' 39 | <<-NOTIFICATION_TEXT.squish 40 | #{notification.actor.printable_name} 41 | mentioned you in a comment in the topic 42 | #{notification.target.topic.title} 43 | NOTIFICATION_TEXT 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /app/jobs/application_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationJob < ActiveJob::Base 4 | # Automatically retry jobs that encountered a deadlock 5 | # retry_on ActiveRecord::Deadlocked 6 | 7 | # Most jobs are safe to ignore if the underlying records are no longer available 8 | # discard_on ActiveJob::DeserializationError 9 | end 10 | -------------------------------------------------------------------------------- /app/mailers/application_mailer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationMailer < ActionMailer::Base 4 | default from: 'from@example.com' 5 | layout 'mailer' 6 | end 7 | -------------------------------------------------------------------------------- /app/mailers/digest_mailer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class DigestMailer < ApplicationMailer 4 | helper Users::NotificationsHelper 5 | default from: 'notifications@asyncgo.com' 6 | 7 | def digest_email 8 | @user = user 9 | @unread_notifications = unread_notifications 10 | @recently_resolved_topics = recently_resolved_topics 11 | @upcoming_due_topics = upcoming_due_topics 12 | 13 | mail(to: @user.email, subject: I18n.t(:your_asyncgo_digest)) 14 | end 15 | 16 | private 17 | 18 | def user 19 | params[:user] 20 | end 21 | 22 | def unread_notifications 23 | user.notifications.where(read_at: nil).uniq do |notification| 24 | notification.values_at(:target_id, :actor_id, :user_id, :action) 25 | end 26 | end 27 | 28 | def recently_resolved_topics 29 | user.team.topics.where( 30 | updated_at: (24.hours.ago)..Time.zone.now, status: :resolved 31 | ) 32 | end 33 | 34 | def upcoming_due_topics 35 | user.team.topics.where( 36 | due_date: Time.zone.now..(24.hours.from_now), status: :active 37 | ) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /app/mailers/support_mailer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class SupportMailer < ApplicationMailer 4 | default to: 'support@asyncgo.com' 5 | 6 | def support_email 7 | @user = params[:user] 8 | @body = params[:body] 9 | mail( 10 | subject: "Support request: #{@user.team.name}", 11 | from: @user.email, 12 | reply_to: @user.email 13 | ) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/mailers/user_mailer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class UserMailer < ApplicationMailer 4 | default from: 'notifications@asyncgo.com' 5 | 6 | def welcome_email 7 | @user = params[:user] 8 | mail(to: @user.email, subject: I18n.t(:welcome_to_asyncgo)) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationRecord < ActiveRecord::Base 4 | self.abstract_class = true 5 | end 6 | -------------------------------------------------------------------------------- /app/models/comment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Comment < ApplicationRecord 4 | validates :body, presence: { allow_blank: false }, image_data: true 5 | validates :body_html, presence: true 6 | 7 | validates :is_archived, inclusion: [true, false] 8 | 9 | belongs_to :user 10 | belongs_to :topic 11 | 12 | has_many :notifications, as: :target, dependent: :destroy 13 | has_many :votes, as: :votable, dependent: :destroy 14 | end 15 | -------------------------------------------------------------------------------- /app/models/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/async-go/asyncgo/b4486310ce3d44755bc6411c8a8ba32457e3eb85/app/models/concerns/.keep -------------------------------------------------------------------------------- /app/models/notification.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Notification < ApplicationRecord 4 | validates :action, presence: true 5 | 6 | belongs_to :user 7 | belongs_to :actor, class_name: '::User' 8 | belongs_to :target, polymorphic: true 9 | 10 | enum action: { created: 0, updated: 1, expiring: 2, mentioned: 3, resolved: 4, reopened: 5 } 11 | end 12 | -------------------------------------------------------------------------------- /app/models/subscription.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Subscription < ApplicationRecord 4 | validates :topic_id, uniqueness: { scope: :user_id } 5 | 6 | belongs_to :user 7 | belongs_to :topic 8 | end 9 | -------------------------------------------------------------------------------- /app/models/team.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Team < ApplicationRecord 4 | validates :name, presence: { allow_blank: false, allow_empty: false }, format: { without: %r{[:\\/]} } 5 | validates :message, presence: { allow_blank: false, allow_empty: false, allow_nil: true } 6 | 7 | has_many :users, dependent: :nullify 8 | has_many :topics, dependent: :destroy 9 | end 10 | -------------------------------------------------------------------------------- /app/models/topic.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Topic < ApplicationRecord 4 | CHECKSUM_ERROR_MESSAGE = <<-ERROR_TEXT.squish.freeze 5 | was changed by somebody else and you can no longer save. Open this same 6 | topic in a new tab and merge your changes manually (do not refresh or 7 | navigate away from this page or your changes will be lost.) 8 | ERROR_TEXT 9 | 10 | validates :title, presence: { allow_blank: false } 11 | validates :description, presence: { allow_blank: false }, image_data: true 12 | validates :description_html, presence: true 13 | validates :outcome, presence: { allow_blank: false, allow_empty: false, allow_nil: true }, image_data: true 14 | validates :outcome_html, presence: { if: :outcome? } 15 | 16 | validates :is_archived, inclusion: [true, false] 17 | 18 | attr_accessor :description_checksum, :outcome_checksum 19 | 20 | validate :validate_description_checksum, on: :update, if: :description_changed? 21 | validate :validate_outcome_checksum, on: :update, if: :outcome_changed? 22 | 23 | belongs_to :user 24 | belongs_to :team 25 | 26 | has_many :comments, dependent: :destroy 27 | 28 | has_many :subscriptions, dependent: :destroy 29 | has_many :subscribed_users, through: :subscriptions, source: :user 30 | 31 | has_many :notifications, as: :target, dependent: :destroy 32 | has_many :votes, as: :votable, dependent: :destroy 33 | 34 | enum status: { active: 0, resolved: 1 } 35 | 36 | acts_as_taggable_on :labels 37 | 38 | scope :by_due_date, lambda { 39 | order(Topic.arel_table[:due_date].eq(nil)) 40 | .order(Topic.arel_table[:due_date].asc) 41 | } 42 | 43 | def last_interacted 44 | if comments.empty? 45 | updated_at 46 | else 47 | updated_at > comments.last.updated_at ? updated_at : comments.last.updated_at 48 | end 49 | end 50 | 51 | private 52 | 53 | def validate_description_checksum 54 | return if Digest::MD5.hexdigest(description_was.to_s) == description_checksum 55 | 56 | errors.add(:description, CHECKSUM_ERROR_MESSAGE) 57 | end 58 | 59 | def validate_outcome_checksum 60 | return if Digest::MD5.hexdigest(outcome_was.to_s) == outcome_checksum 61 | 62 | errors.add(:outcome, CHECKSUM_ERROR_MESSAGE) 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /app/models/user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class User < ApplicationRecord 4 | validates :email, presence: { allow_blank: false }, 5 | uniqueness: { case_sensitive: false }, length: { minimum: 4, maximum: 254 }, 6 | format: { with: /\A(.+)@(.+)\z/ } 7 | validates :name, presence: { allow_blank: false, allow_empty: false, allow_nil: true } 8 | validates :preferences, presence: true 9 | validates :last_login, presence: { allow_blank: false, allow_empty: false, allow_nil: true } 10 | 11 | belongs_to :team, optional: true 12 | 13 | has_many :comments, dependent: :destroy 14 | has_many :topics, dependent: :destroy 15 | 16 | has_many :subscriptions, dependent: :destroy 17 | has_many :subscribed_topics, through: :subscriptions, source: :topic 18 | 19 | has_many :notifications, inverse_of: :user, dependent: :destroy 20 | has_many :votes, dependent: :destroy 21 | 22 | has_one :preferences, class_name: 'User::Preferences', dependent: :destroy 23 | 24 | def self.from_omniauth(email, name) 25 | User.where(email:).first_or_initialize.tap do |user| 26 | user.preferences ||= user.build_preferences 27 | user.name = name 28 | user.last_login = Time.zone.now 29 | user.save! 30 | end 31 | end 32 | 33 | def gravatar_url 34 | "https://www.gravatar.com/avatar/#{email_hash}" 35 | end 36 | 37 | def printable_name 38 | name || email 39 | end 40 | 41 | def topic_notifications(topic) 42 | notifications.where(read_at: nil, target: topic.comments) 43 | .or(notifications.where(read_at: nil, target: topic)) 44 | end 45 | 46 | def clear_topic_notifications(topic) 47 | topic_notifications(topic).update(read_at: Time.now.utc) 48 | end 49 | 50 | private 51 | 52 | def email_hash 53 | Digest::MD5.hexdigest(email.strip.downcase) 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /app/models/user/preferences.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class User 4 | class Preferences < ApplicationRecord 5 | # rubocop:disable Rails/RedundantPresenceValidationOnBelongsTo 6 | validates :user_id, uniqueness: true, presence: true 7 | # rubocop:enable Rails/RedundantPresenceValidationOnBelongsTo 8 | 9 | belongs_to :user 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/models/vote.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Vote < ApplicationRecord 4 | validates :emoji, presence: true 5 | validate :emoji_must_exist 6 | 7 | belongs_to :user 8 | belongs_to :votable, polymorphic: true 9 | 10 | private 11 | 12 | def emoji_must_exist 13 | return if Emoji.find_by_alias(emoji) # rubocop:disable Rails/DynamicFindBy 14 | 15 | errors.add(:emoji, 'must be a valid emoji alias') 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/packs/channels/consumer.js: -------------------------------------------------------------------------------- 1 | // Action Cable provides the framework to deal with WebSockets in Rails. 2 | // You can generate new channels where WebSocket features live using the `bin/rails generate channel` command. 3 | 4 | import { createConsumer } from '@rails/actioncable' 5 | 6 | export default createConsumer() 7 | -------------------------------------------------------------------------------- /app/packs/channels/index.js: -------------------------------------------------------------------------------- 1 | // Load all the channels within this directory and all subdirectories. 2 | // Channel files must be named *_channel.js. 3 | 4 | const channels = require.context('.', true, /_channel\.js$/) 5 | channels.keys().forEach(channels) 6 | -------------------------------------------------------------------------------- /app/packs/controllers/attach_tribute_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from '@hotwired/stimulus' 2 | import Tribute from 'tributejs' 3 | 4 | export default class extends Controller { 5 | static values = { users: Array } 6 | 7 | initialize () { 8 | const controller = this 9 | 10 | const host = window.location.protocol + '//' + window.location.host 11 | const url = `${host}/teams/${window.gon.teamId}/users.json` 12 | window.fetch(url) 13 | .then(response => response.json()) 14 | .then(function (data) { 15 | controller.usersValue = data 16 | }) 17 | } 18 | 19 | attach () { 20 | const tribute = new Tribute({ 21 | values: this.usersValue 22 | }) 23 | tribute.attach(this.element.getElementsByClassName('toastui-editor-contents')) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/packs/controllers/hotkey_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from '@hotwired/stimulus' 2 | import { install, uninstall } from '@github/hotkey' 3 | 4 | export default class extends Controller { 5 | connect () { 6 | if (this.disabled) return 7 | install(this.element) 8 | } 9 | 10 | disconnect () { 11 | uninstall(this.element) 12 | } 13 | 14 | get disabled () { 15 | return document.body.hasAttribute('data-hotkeys-disabled') 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/packs/controllers/index.js: -------------------------------------------------------------------------------- 1 | // Load all the controllers within this directory and all subdirectories. 2 | // Controller files must be named *_controller.js. 3 | 4 | import { Application } from '@hotwired/stimulus' 5 | import { definitionsFromContext } from '@hotwired/stimulus-webpack-helpers' 6 | 7 | const application = Application.start() 8 | const context = require.context('controllers', true, /_controller\.js$/) 9 | application.load(definitionsFromContext(context)) 10 | -------------------------------------------------------------------------------- /app/packs/controllers/quote_reply_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from '@hotwired/stimulus' 2 | 3 | export default class extends Controller { 4 | static targets = ['content', 'authoremail', 'date'] 5 | 6 | quote () { 7 | const content = this.contentTarget.innerText 8 | const date = this.dateTarget.innerText 9 | const authorEmail = this.authoremailTarget.innerText.trim() 10 | 11 | const quotedReply = content.split('\n').map(line => `> ${line}`).join('\n') 12 | 13 | const editor = document.querySelectorAll('[data-target="comment_body"]:last-of-type')[0].editorObj 14 | editor.setMarkdown(`On ${date} @${authorEmail} wrote:\n${quotedReply}\n\n`) 15 | editor.focus() 16 | editor.moveCursorToEnd() 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/packs/controllers/reset_form_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from '@hotwired/stimulus' 2 | 3 | export default class extends Controller { 4 | reset () { 5 | this.element.querySelector('input[type=submit]').disabled = false 6 | this.element.querySelector("div[data-toastui-editor-target='editor']").editorObj.reset() 7 | this.element.reset() 8 | 9 | const formErrors = this.element.querySelector('ul') 10 | if (formErrors) { 11 | this.element.querySelector('ul').innerHTML = '' 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /app/packs/controllers/subscription_controller.js: -------------------------------------------------------------------------------- 1 | /* global fastspring */ 2 | 3 | import { Controller } from '@hotwired/stimulus' 4 | 5 | export default class extends Controller { 6 | buy () { 7 | const fscSession = { 8 | reset: true, 9 | products: [ 10 | { 11 | path: 'asyncgo-team', 12 | quantity: 1 13 | } 14 | ], 15 | tags: { teamId: `${window.gon.teamId}` } 16 | } 17 | fastspring.builder.push(fscSession) 18 | fastspring.builder.checkout() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/packs/controllers/toastui_editor_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from '@hotwired/stimulus' 2 | import Editor from '@toast-ui/editor' 3 | 4 | export default class extends Controller { 5 | static targets = ['editor'] 6 | static values = { users: Array } 7 | 8 | _editor (target) { 9 | return new Editor({ 10 | el: target, 11 | height: 'auto', 12 | initialEditType: 'wysiwyg', 13 | initialValue: target.textContent, 14 | previewStyle: 'tab', 15 | toolbarItems: [ 16 | ['heading', 'bold', 'italic', 'strike'], 17 | ['hr', 'quote'], 18 | ['ul', 'ol', 'task'], 19 | ['table', 'link'], 20 | ['code', 'codeblock'] 21 | ], 22 | autofocus: false 23 | }) 24 | } 25 | 26 | connect () { 27 | this.editorTargets.forEach(editorTarget => { 28 | const editor = this._editor(editorTarget) 29 | editorTarget.editorObj = editor 30 | 31 | editorTarget.closest('form').addEventListener('submit', (event) => { 32 | event.target.elements.namedItem(editorTarget.dataset.target).value = editor.getMarkdown() 33 | }, false) 34 | }) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/packs/entrypoints/application.js: -------------------------------------------------------------------------------- 1 | // This file is automatically compiled by Webpack, along with any other files 2 | // present in this directory. You're encouraged to place your actual application logic in 3 | // a relevant structure within app/javascript and only use these pack files to reference 4 | // that code so it'll be compiled. 5 | 6 | import Rails from '@rails/ujs' 7 | import '@hotwired/turbo-rails' 8 | import * as ActiveStorage from '@rails/activestorage' 9 | import 'channels' 10 | import 'controllers' 11 | 12 | import 'bootstrap' 13 | 14 | Rails.start() 15 | ActiveStorage.start() 16 | -------------------------------------------------------------------------------- /app/policies/application_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationPolicy 4 | attr_reader :user, :record 5 | 6 | def initialize(user, record) 7 | @user = user 8 | @record = record 9 | end 10 | 11 | def index? 12 | false 13 | end 14 | 15 | def show? 16 | false 17 | end 18 | 19 | def create? 20 | false 21 | end 22 | 23 | def new? 24 | create? 25 | end 26 | 27 | def update? 28 | false 29 | end 30 | 31 | def edit? 32 | update? 33 | end 34 | 35 | def destroy? 36 | false 37 | end 38 | 39 | class Scope 40 | attr_reader :user, :scope 41 | 42 | def initialize(user, scope) 43 | @user = user 44 | @scope = scope 45 | end 46 | 47 | def resolve 48 | scope.all 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /app/policies/comment_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CommentPolicy < ApplicationPolicy 4 | def new? 5 | user && 6 | record.topic.team == user.team 7 | end 8 | 9 | def edit? 10 | user && 11 | record.user == user && 12 | record.is_archived == false 13 | end 14 | 15 | def create? 16 | user && 17 | record.topic.team == user.team 18 | end 19 | 20 | def update? 21 | user && 22 | record.user == user && 23 | record.is_archived == false 24 | end 25 | 26 | def archive? 27 | user && 28 | record.user == user && 29 | record.is_archived == false 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /app/policies/extension_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ExtensionPolicy < ApplicationPolicy 4 | def new_topic? 5 | user&.team 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/policies/notification_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class NotificationPolicy < ApplicationPolicy 4 | def index? 5 | user && 6 | record == user 7 | end 8 | 9 | def show? 10 | user && 11 | record.user == user 12 | end 13 | 14 | def clear? 15 | user && 16 | record == user 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /app/policies/subscription_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class SubscriptionPolicy < ApplicationPolicy 4 | def edit? 5 | user && 6 | user.team == record 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /app/policies/team_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class TeamPolicy < ApplicationPolicy 4 | def edit? 5 | user && 6 | record == user.team 7 | end 8 | 9 | def new? 10 | user 11 | end 12 | 13 | def create? 14 | user 15 | end 16 | 17 | def update? 18 | user && 19 | record == user.team 20 | end 21 | 22 | def support? 23 | user && 24 | record == user.team 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /app/policies/teams/topics/comments/vote_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Teams 4 | module Topics 5 | module Comments 6 | class VotePolicy < ApplicationPolicy 7 | def create? 8 | user && 9 | record.topic.team == user.team 10 | end 11 | 12 | def destroy? 13 | user && 14 | record.user == user 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /app/policies/teams/topics/vote_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Teams 4 | module Topics 5 | class VotePolicy < ApplicationPolicy 6 | def create? 7 | user && 8 | record.team == user.team 9 | end 10 | 11 | def destroy? 12 | user && 13 | record.user == user 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/policies/teams/user_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Teams 4 | class UserPolicy < ApplicationPolicy 5 | def index? 6 | user && 7 | record == user.team 8 | end 9 | 10 | def create? 11 | user && 12 | record == user.team 13 | end 14 | 15 | def destroy? 16 | user && 17 | record.team == user.team 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /app/policies/topic_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class TopicPolicy < ApplicationPolicy 4 | def index? 5 | user && 6 | record == user.team 7 | end 8 | 9 | def show? 10 | user && 11 | record.team == user.team && 12 | record.is_archived == false 13 | end 14 | 15 | def new? 16 | user && 17 | record.team == user.team 18 | end 19 | 20 | def edit? 21 | user && 22 | record.team == user.team && 23 | record.is_archived == false 24 | end 25 | 26 | def create? 27 | user && 28 | record.team == user.team 29 | end 30 | 31 | def update? 32 | user && 33 | record.team == user.team && 34 | record.is_archived == false 35 | end 36 | 37 | def toggle? 38 | user && 39 | record.team == user.team && 40 | record.is_archived == false 41 | end 42 | 43 | def subscribe? 44 | user && 45 | record.team == user.team && 46 | record.is_archived == false 47 | end 48 | 49 | def pin? 50 | user && 51 | record.team == user.team && 52 | record.is_archived == false 53 | end 54 | 55 | def archive? 56 | user && 57 | record.team == user.team && 58 | record.is_archived == false 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /app/policies/user_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class UserPolicy < ApplicationPolicy 4 | def edit? 5 | user == record 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/policies/users/preferences_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Users 4 | class PreferencesPolicy < ApplicationPolicy 5 | def update? 6 | user && 7 | record.user == user 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/services/application_service.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationService 4 | def call 5 | raise NotImplementedError 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/services/comment_updater.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CommentUpdater < ApplicationService 4 | def initialize(user, comment, update_params) 5 | super() 6 | 7 | @user = user 8 | @comment = comment 9 | @update_params = update_params 10 | end 11 | 12 | def call 13 | new_comment = @comment.new_record? 14 | @comment.update(processed_params).tap do |result| 15 | next unless result && new_comment 16 | 17 | notify_users! 18 | @comment.topic.subscriptions.create(user: @user) 19 | end 20 | end 21 | 22 | private 23 | 24 | def processed_params 25 | @update_params.tap do |params| 26 | process_body(params) 27 | end 28 | end 29 | 30 | def process_body(original_params) 31 | original_params.tap do |params| 32 | next if params[:body].nil? 33 | 34 | params[:body_html] = MarkdownParser.new(@user, params[:body], @comment).call 35 | end 36 | end 37 | 38 | def notify_users! 39 | subscriber_ids = @comment.topic.subscribed_users.pluck(:id) 40 | subscriber_ids.each do |subscriber_id| 41 | next if subscriber_id == @user.id 42 | 43 | @comment.notifications.create(actor: @user, user_id: subscriber_id, action: :created) 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /app/services/digest_email_sender.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class DigestEmailSender < ApplicationService 4 | def call 5 | Rails.logger.info 'Starting digest creation' 6 | User.includes(:preferences).find_each do |user| 7 | next unless user.preferences.digest_enabled? 8 | 9 | next unless user.last_login 10 | 11 | unread_notifications = unread_notifications_for(user) 12 | recently_resolved_topics = recently_resolved_topics_for(user) 13 | next if unread_notifications.empty? && recently_resolved_topics.empty? 14 | 15 | Rails.logger.info "Sending digest to #{user.email}" 16 | send_digest(user) 17 | end 18 | end 19 | 20 | private 21 | 22 | def unread_notifications_for(user) 23 | user.notifications.where(read_at: nil) 24 | end 25 | 26 | def recently_resolved_topics_for(user) 27 | user.team.topics.where( 28 | updated_at: 24.hours.ago..Time.zone.now, status: :resolved 29 | ) 30 | end 31 | 32 | def send_digest(user) 33 | DigestMailer.with(user:).digest_email.deliver_later 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /app/services/markdown_parser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class MarkdownParser < ApplicationService 4 | MENTION_REGEX = /@\[?([\w.\-_]+)?\w+@[\w\-_]+(\.\w+)+(\]\(mailto:\w+@[\w\-_]+(\.\w+)+\))?/ 5 | 6 | def initialize(user, text, notification_target) 7 | super() 8 | 9 | @user = user 10 | @text = text 11 | @notification_target = notification_target 12 | end 13 | 14 | def call 15 | result = process_mentions(@text) 16 | html_output = process_markdown(result) 17 | process_links(html_output) 18 | end 19 | 20 | private 21 | 22 | def process_mentions(text) 23 | text.gsub(MENTION_REGEX) do |mention| 24 | email = mention.slice(1..-1) 25 | email = email.gsub(/^\[/, '') 26 | email = email.gsub(/\].*/, '') 27 | target_user = User.find_by(email:) 28 | notify_user!(target_user) 29 | 30 | "[#{target_user.printable_name}](mailto:#{email})" 31 | end 32 | end 33 | 34 | def process_markdown(markdown) 35 | CommonMarker.render_html(markdown, :DEFAULT, %i[strikethrough table tasklist tagfilter autolink]) 36 | end 37 | 38 | def process_links(html_output) 39 | Nokogiri::HTML.fragment(html_output).tap do |doc| 40 | doc.css('a').each do |link| 41 | link['target'] = '_blank' 42 | link['rel'] = 'noopener' 43 | end 44 | end.to_s 45 | end 46 | 47 | def notify_user!(target_user) 48 | return if target_user.id == @user.id 49 | 50 | @notification_target.notifications.build(actor: @user, user: target_user, action: :mentioned) 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /app/services/topic_updater.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class TopicUpdater < ApplicationService 4 | def initialize(user, topic, update_params) 5 | super() 6 | 7 | @user = user 8 | @topic = topic 9 | @update_params = update_params 10 | end 11 | 12 | def call 13 | new_topic = @topic.new_record? 14 | @topic.update(processed_params).tap do |result| 15 | next unless result 16 | 17 | notify_users!(notification_users(new_topic), notification_action(new_topic, processed_params)) 18 | next unless new_topic 19 | 20 | @topic.subscriptions.create(user: @user) 21 | end 22 | end 23 | 24 | private 25 | 26 | def processed_params 27 | @update_params.tap do |params| 28 | processed_params = process_description(params) 29 | process_outcome(processed_params) 30 | end 31 | end 32 | 33 | def process_description(original_params) 34 | original_params.tap do |params| 35 | next if params[:description].nil? 36 | 37 | params[:description_html] = MarkdownParser.new(@user, params[:description], @topic).call 38 | end 39 | end 40 | 41 | def process_outcome(original_params) 42 | original_params.tap do |params| 43 | next if params[:outcome].nil? 44 | 45 | if params[:outcome].empty? 46 | params[:outcome] = nil 47 | params[:outcome_html] = nil 48 | elsif params[:outcome].present? 49 | params[:outcome_html] = MarkdownParser.new(@user, params[:outcome], @topic).call 50 | end 51 | end 52 | end 53 | 54 | def notification_update_action(params) 55 | case params[:status] 56 | when 'resolved' 57 | :resolved 58 | when 'active' 59 | :reopened 60 | else 61 | :updated 62 | end 63 | end 64 | 65 | def notification_users(new_topic) 66 | new_topic ? @topic.team.users : @topic.subscribed_users 67 | end 68 | 69 | def notification_action(new_topic, params) 70 | new_topic ? :created : notification_update_action(params) 71 | end 72 | 73 | def notify_users!(users, action) 74 | users.pluck(:id).each do |target_user_id| 75 | next if target_user_id == @user.id 76 | 77 | @topic.notifications.create(actor: @user, user_id: target_user_id, action:) 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /app/validators/image_data_validator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ImageDataValidator < ActiveModel::EachValidator 4 | IMAGEDATA_REGEX = %r{\(data:image/\w+;base64,[^\s)]+\)}i 5 | 6 | def validate_each(record, attribute, value) 7 | record.errors.add(attribute, "can't contain embedded markdown images") if IMAGEDATA_REGEX.match?(value) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/views/digest_mailer/digest_email.html.haml: -------------------------------------------------------------------------------- 1 | %h1 Here's your AsyncGo Digest 2 | %p 3 | This email contains what's happening in your AsyncGo team. 4 | 5 | %h3 Open notifications 6 | %p 7 | - if @unread_notifications.blank? 8 | None 9 | - else 10 | %ul 11 | - @unread_notifications.each do |notification| 12 | %li= link_to notification_text(notification), 13 | user_notification_url(@user, notification) 14 | 15 | %h3 Topics due today 16 | %p 17 | - if @upcoming_due_topics.blank? 18 | None 19 | - else 20 | %ul 21 | - @upcoming_due_topics.each do |topic| 22 | %li= link_to topic.title, team_topic_url(topic.team, topic) 23 | 24 | %h3 Recently resolved (or already resolved but updated) topics 25 | %p 26 | - if @recently_resolved_topics.blank? 27 | None 28 | - else 29 | %ul 30 | - @recently_resolved_topics.each do |topic| 31 | %li= link_to topic.title, team_topic_url(topic.team, topic) 32 | 33 | %hr 34 | 35 | %p 36 | You can change your preference about receiving digests on your user 37 | profile page. 38 | -------------------------------------------------------------------------------- /app/views/digest_mailer/digest_email.text.haml: -------------------------------------------------------------------------------- 1 | Here's your AsyncGo Digest 2 | =============================================== 3 | \ 4 | This email contains what's happening in your AsyncGo team. 5 | \ 6 | Open notifications 7 | \ 8 | - if @unread_notifications.blank? 9 | None 10 | - else 11 | - @unread_notifications.each do |notification| 12 | = notification_text(notification) 13 | = user_notification_url(@user, notification) 14 | \ 15 | Topics due today 16 | \ 17 | - if @upcoming_due_topics.blank? 18 | None 19 | - else 20 | - @upcoming_due_topics.each do |topic| 21 | = topic.title 22 | = team_topic_url(topic.team, topic) 23 | \ 24 | Recently Resolved (or already resolved but updated) Topics 25 | \ 26 | - if @recently_resolved_topics.blank? 27 | None 28 | - else 29 | - @recently_resolved_topics.each do |topic| 30 | = topic.title 31 | = team_topic_url(topic.team, topic) 32 | \ 33 | \--- 34 | \ 35 | You can change your preference about receiving digests on your user 36 | profile page. 37 | -------------------------------------------------------------------------------- /app/views/home/index.html.haml: -------------------------------------------------------------------------------- 1 | = content_for :extra_nav_items do 2 | %button.btn.btn-outline-secondary.me-3.d-none.d-lg-block{ id: 'tour-button', 3 | data: { controller: 'driverjs', action: 'click->driverjs#demoHome' } } 4 | = assistive_icon('fas', 'question-circle', 'Tour this page') 5 | 6 | %h1 Welcome to AsyncGo! 7 | 8 | %p.my-4 9 | Sign in (or sign up) using your external account: 10 | 11 | .row{ 'data-turbo': 'false' } 12 | .col-auto 13 | = button_to '/auth/google_oauth2', 14 | class: 'btn btn-google mb-3' do 15 | = assistive_icon('fab', 'google', 'Google', classname: 'me-2 text-white') 16 | Google 17 | .col-auto 18 | = button_to '/auth/github', 19 | class: 'btn btn-github mb-3' do 20 | = assistive_icon('fab', 'github', 'GitHub', classname: 'me-2 text-white') 21 | GitHub 22 | .col-auto 23 | = button_to '/auth/microsoft_graph', 24 | class: 'btn btn-microsoft mb-3' do 25 | = assistive_icon('fab', 'microsoft', 'Microsoft', classname: 'me-2 text-white') 26 | Microsoft 27 | .col-auto 28 | = button_to '/auth/slack', 29 | class: 'btn btn-slack mb-3' do 30 | = assistive_icon('fab', 'slack', 'Slack', classname: 'me-2 text-white') 31 | Slack 32 | 33 | %p.mt-4 34 | = image_tag('happy_users.png', alt: 'Hello!', class: 'img-fluid') 35 | -------------------------------------------------------------------------------- /app/views/layouts/mailer.html.haml: -------------------------------------------------------------------------------- 1 | !!! 5 2 | %html 3 | %head 4 | %meta{ 'http-equiv' => 'Content-Type', content: 'text/html; charset=utf-8' } 5 | :css 6 | /* Email styles need to be inline */ 7 | 8 | %body 9 | = yield 10 | -------------------------------------------------------------------------------- /app/views/layouts/mailer.text.haml: -------------------------------------------------------------------------------- 1 | = yield 2 | -------------------------------------------------------------------------------- /app/views/shared/_form_errors.html.haml: -------------------------------------------------------------------------------- 1 | - model_errors = model.errors.full_messages 2 | - if model_errors.count.positive? 3 | .my-4 4 | %ul.list-group 5 | - model_errors.each do |message| 6 | %li.list-group-item.list-group-item-danger= message 7 | -------------------------------------------------------------------------------- /app/views/shared/_head.html.haml: -------------------------------------------------------------------------------- 1 | %head 2 | %title AsyncGo 3 | %meta{ content: 'text/html; charset=UTF-8', 'http-equiv': 'Content-Type' } 4 | %meta{ charset: 'utf-8' } 5 | %meta{ name: 'viewport', content: 'width=device-width, initial-scale=1, shrink-to-fit=no' } 6 | %meta{ property: 'og:description', content: Rails.application.config.site_description } 7 | %meta{ property: 'og:title', content: Rails.application.config.site_title } 8 | %meta{ property: 'og:image', content: image_url(Rails.application.config.site_image) } 9 | %meta{ property: 'twitter:description', content: Rails.application.config.site_description } 10 | %meta{ property: 'twitter:title', content: Rails.application.config.site_title } 11 | %meta{ property: 'twitter:image', content: image_url(Rails.application.config.site_image) } 12 | %meta{ property: 'twitter:card', content: 'summary' } 13 | 14 | = csrf_meta_tags 15 | = csp_meta_tag 16 | = Gon::Base.render_data(camel_case: true) 17 | = stylesheet_link_tag 'application', media: 'all', 'data-turbo-track': 'reload' 18 | = javascript_pack_tag 'application', 'data-turbo-track': 'reload', defer: true 19 | = favicon_link_tag image_path('logo.svg') 20 | -------------------------------------------------------------------------------- /app/views/shared/_markdown_text_area.html.haml: -------------------------------------------------------------------------------- 1 | %div{ data: { controller: 'toastui-editor attach-tribute', 2 | 'toastui-editor-target': 'editor', 3 | target: :"#{dom_class(form.object)}_#{name}", 4 | action: 'input->attach-tribute#attach:once' } }= form.object.send(name) 5 | = form.hidden_field name 6 | -------------------------------------------------------------------------------- /app/views/support_mailer/support_email.html.haml: -------------------------------------------------------------------------------- 1 | %h2 Request Info 2 | %ul 3 | %li 4 | %span Team 5 | %ul 6 | %li ID: #{@user.team.id} 7 | %li Name: #{@user.team.name} 8 | %li 9 | %span User 10 | %ul 11 | %li ID: #{@user.id} 12 | %li Email: #{@user.email} 13 | %li Name: #{@user.printable_name} 14 | 15 | %h2 Request Content 16 | %pre= @body 17 | -------------------------------------------------------------------------------- /app/views/support_mailer/support_email.text.haml: -------------------------------------------------------------------------------- 1 | Request Info 2 | ================ 3 | 4 | \- Team: #{@user.team.id} 5 | \ - ID: #{@user.team.id} 6 | \ - Name: #{@user.team.name} 7 | \- User 8 | \ - ID: #{@user.id} 9 | \ - Email: #{@user.email} 10 | \ - Name: #{@user.printable_name} 11 | 12 | Request Content 13 | ================ 14 | 15 | = @body 16 | -------------------------------------------------------------------------------- /app/views/teams/edit.html.haml: -------------------------------------------------------------------------------- 1 | = content_for :extra_nav_items do 2 | %button.btn.btn-outline-secondary.me-3.d-none.d-lg-block{ id: 'tour-button', 3 | data: { controller: 'driverjs', action: 'click->driverjs#demoTeamAdmin' } } 4 | = assistive_icon('fas', 'question-circle', 'Tour this page') 5 | %h1.visually-hidden Configuration for #{@team.name} 6 | .row.mt-3= render partial: 'shared/form_errors', locals: { model: @team } 7 | .row.mt-3 8 | .col-lg-12 9 | %h3#team-name Name 10 | %p This is the name that will be shown for your team. 11 | = form_with(model: @team, class: 'g-3') do |form| 12 | .row 13 | .col-lg-8.mb-3 14 | = form.text_field :name, required: true, class: 'form-control' 15 | .col-lg-2 16 | = form.submit 'Save Name', class: 'btn btn-primary' 17 | %hr 18 | .row.mt-3 19 | .col-lg-12 20 | %h3#team-message Message 21 | %p 22 | Set a message that your team will see on the topics page. This can be used 23 | to share priorities, important updates, or other notes that are important 24 | for the team to see. 25 | = form_with(model: @team) do |form| 26 | .input-group.mb-3 27 | = form.text_area :message, class: 'form-control' 28 | = form.submit 'Save Message', class: 'btn btn-primary' 29 | %hr 30 | .row 31 | .col.lg-12 32 | %h3#invite-users Invite users 33 | %p Invite users to join your team. 34 | = form_with(model: User, url: [@team, :users], class: 'g-3') do |invite_user_form| 35 | .row 36 | .col-lg-10 37 | .input-group 38 | .input-group-text.mb-3 39 | = invite_user_form.label :email 40 | = invite_user_form.text_field :email, required: true, pattern: '\S*', 41 | class: 'form-control mb-3' 42 | .col-lg-2 43 | = invite_user_form.submit 'Invite User', class: 'btn btn-primary mb-3' 44 | %hr 45 | .row 46 | .col-lg-12 47 | %h3#users-in-team Users in Team 48 | %p 49 | Click to remove. If you want to remove yourself, have a team member 50 | do it for you or contact support below. 51 | .row 52 | .list-group.list-group-horizontal.flex-wrap 53 | - @team_members.each do |user| 54 | .col-lg-3 55 | = link_to user.email, team_user_path(@team, user), 56 | method: :delete, class: 'list-group-item list-group-item-action text-center', 57 | data: { confirm: "Are you sure you want to remove #{user.printable_name}?" } 58 | - if @pagy.pages > 1 59 | .row.mt-3 60 | != pagy_bootstrap_nav(@pagy) 61 | %hr 62 | .row 63 | .col-lg-12 64 | %h3#support-form Support/Feedback Form 65 | %p Use this form to contact support and/or provide product feedback. 66 | = form_with(url: team_support_path(@team)) do |support_form| 67 | .input-group.mb-3 68 | = support_form.text_area :body, rows: 4, class: 'form-control' 69 | = support_form.submit 'Send Email', class: 'btn btn-primary' 70 | 71 | -# This will be rendered at the end of the body tag 72 | %script{ id: 'fsc-api', type: 'text/javascript', 73 | src: 'https://d1f8f9xcsvx3ha.cloudfront.net/sbl/0.8.5/fastspring-builder.min.js', 74 | 'data-storefront': fastspring_store_url } 75 | -------------------------------------------------------------------------------- /app/views/teams/new.html.haml: -------------------------------------------------------------------------------- 1 | %h1.visually-hidden New Team 2 | 3 | = render partial: 'shared/form_errors', locals: { model: @team } 4 | 5 | %p.lead 6 | Creating a team is the first step to getting started with AsyncGo 7 | %p 8 | If you were expecting to join a team that already exists, check that the email 9 | associated with the account you logged in with matches your invitation, or 10 | contact your team admin. 11 | 12 | = form_with(model: @team) do |form| 13 | .mb-3 14 | .input-group 15 | .input-group-text 16 | = form.label :name 17 | = form.text_field :name, class: 'form-control' 18 | 19 | = form.submit 'Create Team', class: 'btn btn-primary' 20 | 21 | %p.mt-5 22 | = image_tag('happy_users.png', alt: 'Hello!', class: 'img-fluid') 23 | -------------------------------------------------------------------------------- /app/views/teams/topics/_topic_form.html.haml: -------------------------------------------------------------------------------- 1 | - submit_action = topic.persisted? ? 'Update' : 'Create' 2 | = form_with(model: topic, url: [topic.team, topic]) do |form| 3 | = render partial: 'shared/form_errors', locals: { model: topic } 4 | = form.hidden_field :description_checksum, value: string_checksum(topic.description) 5 | = form.hidden_field :outcome_checksum, value: string_checksum(topic.outcome) 6 | .row.mt-2 7 | .mb-3 8 | .mb-1 9 | = form.label :title, id: 'topic-title' 10 | = form.text_field :title, required: true, autocomplete: 'off', class: 'form-control' 11 | .row 12 | .col-lg-7 13 | .mb-1 14 | = form.label :description, id: 'topic-description' 15 | = render partial: 'shared/markdown_text_area', locals: { form:, name: :description } 16 | .col-lg-5 17 | .mb-1 18 | = form.label :outcome, id: 'topic-outcome' 19 | = render partial: 'shared/markdown_text_area', locals: { form:, name: :outcome } 20 | .row.mt-3 21 | .col-lg-7 22 | .input-group 23 | .input-group-text= form.label :label_list, id: 'topic-labellist' 24 | = form.text_field :label_list, class: 'form-control' 25 | .form-text.text-secondary 26 | Multiple labels can be separated with a space. 27 | .col-lg-4.mb-2 28 | .input-group 29 | .input-group-text= form.label :due_date, id: 'topic-duedate' 30 | = form.date_field :due_date, class: 'form-control' 31 | .col-lg-1 32 | = form.submit submit_action, class: 'btn btn-primary' 33 | -------------------------------------------------------------------------------- /app/views/teams/topics/_topics.html.haml: -------------------------------------------------------------------------------- 1 | %h5.ms-2.d-lg-none #{list_heading.capitalize} Topics 2 | %ul.list-group.list-group-flush 3 | - if topic_collection.present? 4 | %li.list-group-item.border-0.my-0.d-none.d-lg-block.p-2.py-0.px-3 5 | .row.pb-1 6 | .col-lg-5.fw-bold{ id: "#{list_heading}-topics" } 7 | #{list_heading.capitalize} Topics 8 | .col-lg-2.small 9 | - if list_heading == :active 10 | Due 11 | - else 12 | Last Activity 13 | .col-lg-3.small 14 | Labels 15 | .col-lg-2.small 16 | Participants 17 | - topic_collection.each_with_index do |topic, topic_index| 18 | - list_item_style = topic.active? ? 'text-dark' : 'text-secondary' 19 | %li.list-group-item.p-2.mb-1.pb-0.px-3{ class: list_item_style } 20 | .row.my-2 21 | .col-8.col-lg-5.mb-2 22 | - if topic_hotkeys 23 | = link_to topic.title, team_topic_path(current_user.team, topic), 24 | data: { controller: 'hotkey', hotkey: topic_index } 25 | - else 26 | = link_to topic.title, team_topic_path(current_user.team, topic), 27 | class: list_item_style 28 | - if topic.pinned? 29 | = assistive_icon('fas', 'bookmark', 'Is Pinned', classname: 'ms-2 text-secondary') 30 | - if topic_has_notification?(unique_unread_notifications, topic) 31 | = assistive_icon('fas', 'bell', 'Has Notification', classname: 'ms-2 text-warning') 32 | %span.text-secondary.small 33 | = current_user.topic_notifications(topic).count 34 | .col-4.col-lg-2.mb-2.small 35 | %p.mb-0 36 | - if list_heading == :active 37 | = topic_due_date_span(topic) 38 | - else 39 | Updated #{time_ago_in_words(topic.last_interacted)} ago 40 | .col-8.col-lg-3.mb-2 41 | - topic.labels.each do |label| 42 | %span.topic-label.badge.bg-info= label 43 | .col-4.col-lg-2 44 | = link_to image_tag("#{topic.user.gravatar_url}?s=20&d=retro", 45 | class: 'gravatar-img mb-1', 46 | title: "#{topic.user.printable_name} (Creator)", 47 | alt: "#{topic.user.printable_name} (Creator)"), 48 | "mailto:#{topic.user.email}" 49 | - topic.subscribed_users.where.not(id: topic.user.id).each do |participant| 50 | = link_to image_tag("#{participant.gravatar_url}?s=20&d=retro", 51 | title: participant.printable_name, 52 | class: 'gravatar-img mb-1', alt: participant.printable_name), 53 | "mailto:#{participant.email}" 54 | - else 55 | %li.list-group-item 56 | %p= empty_state_text 57 | -------------------------------------------------------------------------------- /app/views/teams/topics/_vote_list_group.html.haml: -------------------------------------------------------------------------------- 1 | .list-group.list-group-horizontal.gap-2.my-0 2 | %ul.list-group.list-group-horizontal.gap-2 3 | - vote_groups(votable).each do |emoji_name, votes| 4 | - content = emoji_group_text(emoji_name, votes.count) 5 | - vote_names = votes.collect { |vote| vote.user.name }.join(', ') 6 | 7 | - if (user_vote = votes.find { |vote| vote.user == current_user }) 8 | = form_with(model: user_vote, url: votable_path(votable) + [user_vote], 9 | method: :delete) do |remove_vote_form| 10 | = remove_vote_form.hidden_field :id 11 | = remove_vote_form.submit content, 12 | class: 'btn btn-sm btn-outline-secondary border-0 active', 13 | 'data-bs-toggle' => 'tooltip', title: vote_names 14 | - else 15 | = form_with(model: Vote, url: votable_path(votable) + [:votes]) do |add_vote_form| 16 | = add_vote_form.hidden_field :emoji, value: emoji_name 17 | = add_vote_form.submit content, class: 'btn btn-sm btn-outline-secondary', 18 | 'data-bs-toggle' => 'tooltip', title: vote_names 19 | -------------------------------------------------------------------------------- /app/views/teams/topics/comments/_comment.html.haml: -------------------------------------------------------------------------------- 1 | = turbo_frame_tag dom_id(comment) do 2 | %li.list-group-item.border-0.pt-0.pb-2{ data: { controller: 'quote-reply' } } 3 | .row.justify-content-start.p-1.border-top.pt-2 4 | .col-auto.pt-1 5 | = image_tag "#{comment.user.gravatar_url}?s=20&d=retro", 6 | class: 'me-1 gravatar-img', alt: '' 7 | = link_to comment.user.printable_name, "mailto:#{comment.user.email}" 8 | .d-none{ data: { 'quote-reply-target' => 'authoremail' } } 9 | = comment.user.email 10 | .col-auto.pt-1.ms-auto.text-secondary 11 | %span{ data: { 'quote-reply-target' => 'date' } }= comment.created_at.strftime('%b %e') 12 | .col-auto 13 | = render partial: 'teams/topics/vote_list_group', locals: { votable: comment } 14 | .col-auto 15 | - if policy(comment).edit? 16 | = link_to 'Edit', 17 | edit_team_topic_comment_path(comment.topic.team, comment.topic, comment), 18 | class: 'btn btn-sm btn-outline-secondary' 19 | .col-auto 20 | %button.btn.btn-sm.btn-outline-secondary{ data: { action: 'click->quote-reply#quote' } } 21 | Quote 22 | .col-auto 23 | - if comment.user == current_user 24 | = link_to 'Archive', 25 | team_topic_comment_archive_path(comment.topic.team, comment.topic, comment), 26 | class: 'btn btn-outline-secondary btn-sm', method: :put, 27 | data: { confirm: 'This will permanently hide it. Are you sure?' } 28 | .row.ps-2 29 | .toastui-editor-contents.px-2!= comment.body_html 30 | .d-none{ data: { 'quote-reply-target' => 'content' } }= comment.body 31 | -------------------------------------------------------------------------------- /app/views/teams/topics/comments/_comment_box.html.haml: -------------------------------------------------------------------------------- 1 | .row.m-0.pt-3.border-top 2 | .col-12 3 | = turbo_frame_tag 'new_comment', src: new_team_topic_comment_path(topic.team, topic) 4 | -------------------------------------------------------------------------------- /app/views/teams/topics/comments/_comment_form.html.haml: -------------------------------------------------------------------------------- 1 | - submit_action = comment.persisted? ? 'Update' : 'Add Comment' 2 | = form_with(model: comment, url: [comment.topic.team, comment.topic, comment], 3 | data: { controller: 'reset-form', action: 'turbo:submit-end->reset-form#reset' }) do |form| 4 | = render partial: 'shared/form_errors', locals: { model: comment } 5 | .mb-1.visually-hidden 6 | = form.label :body 7 | .mb-3 8 | = render partial: 'shared/markdown_text_area', locals: { form:, name: :body } 9 | = form.submit submit_action, class: 'btn btn-sm btn-primary mb-2' 10 | -------------------------------------------------------------------------------- /app/views/teams/topics/comments/edit.html.haml: -------------------------------------------------------------------------------- 1 | .row.my-5 2 | %h1.visually-hidden= @topic.title 3 | 4 | = turbo_frame_tag dom_id(@comment) do 5 | = render partial: 'comment_form', locals: { comment: @comment } 6 | = link_to 'Cancel', team_topic_path(@comment.topic.team, @comment.topic), 7 | class: 'btn btn-secondary my-3' 8 | -------------------------------------------------------------------------------- /app/views/teams/topics/comments/new.html.haml: -------------------------------------------------------------------------------- 1 | .row 2 | %h1.visually-hidden= @topic.title 3 | 4 | = turbo_frame_tag 'new_comment' do 5 | = render partial: 'comment_form', locals: { comment: @comment } 6 | -------------------------------------------------------------------------------- /app/views/teams/topics/edit.html.haml: -------------------------------------------------------------------------------- 1 | .row.my-5 2 | %h1.visually-hidden= @topic.title 3 | 4 | = turbo_frame_tag dom_id(@topic) do 5 | = render partial: 'topic_form', locals: { topic: @topic } 6 | = link_to 'Cancel', team_topic_path(@topic.team, @topic), class: 'btn btn-sm btn-secondary my-2' 7 | -------------------------------------------------------------------------------- /app/views/teams/topics/index.html.haml: -------------------------------------------------------------------------------- 1 | = content_for :extra_nav_items do 2 | %button.btn.btn-outline-secondary.me-3.d-none.d-lg-block{ id: 'tour-button', 3 | data: { controller: 'driverjs', action: 'click->driverjs#demoTopicIndex' } } 4 | = assistive_icon('fas', 'question-circle', 'Tour this page') 5 | 6 | %h1.visually-hidden Topics 7 | 8 | - if @team.message.present? 9 | .row.bg-white.border.shadow-sm.border-info.border-2.rounded.mb-3.p-2 10 | .col-12 11 | .text-break.fs-6= @team.message 12 | 13 | :ruby 14 | empty_state_text = if params[:labels].present? 15 | 'No match found for this label filter.' 16 | else 17 | "You don't have any active topics. Try starting a discussion 18 | by creating one using the orange button above." 19 | end 20 | .row.mt-2.border.bg-white.rounded.shadow-sm.pt-2 21 | = render partial: 'topics', locals: { topic_collection: @active_topics, topic_hotkeys: true, 22 | empty_state_text:, list_heading: :active } 23 | - if @pagy_active_topics.pages > 1 24 | .row.mt-3 25 | != pagy_bootstrap_nav(@pagy_active_topics) 26 | 27 | - if @resolved_topics.present? 28 | .row.mt-4.border.bg-white.rounded.shadow-sm.pt-2 29 | = render partial: 'topics', locals: { topic_collection: @resolved_topics, topic_hotkeys: false, 30 | empty_state_text: '', 31 | list_heading: :resolved } 32 | - if @pagy_resolved_topics.pages > 1 33 | .row.mt-3 34 | != pagy_bootstrap_nav(@pagy_resolved_topics) 35 | -------------------------------------------------------------------------------- /app/views/teams/topics/new.html.haml: -------------------------------------------------------------------------------- 1 | = content_for :extra_nav_items do 2 | %button.btn.btn-outline-secondary.me-3.d-none.d-lg-block{ id: 'tour-button', 3 | data: { controller: 'driverjs', action: 'click->driverjs#demoTopicNew' } } 4 | = assistive_icon('fas', 'question-circle', 'Tour this page') 5 | 6 | .row 7 | %h1.visually-hidden New Topic 8 | 9 | = link_to 'Back to All Topics', team_topics_path(@topic.team), class: 'mb-2' 10 | 11 | .row.shadow-sm.bg-white.border.rounded.p-1 12 | = render partial: 'shared/form_errors', locals: { model: @topic } 13 | = render partial: 'topic_form', locals: { topic: @topic } 14 | -------------------------------------------------------------------------------- /app/views/teams/topics/show.html.haml: -------------------------------------------------------------------------------- 1 | = content_for :extra_nav_items do 2 | %button.btn.btn-outline-secondary.me-3.d-none.d-lg-block{ id: 'tour-button', 3 | data: { controller: 'driverjs', action: 'click->driverjs#demoTopicShow' } } 4 | = assistive_icon('fas', 'question-circle', 'Tour this page') 5 | 6 | .row.shadow-sm.bg-white.border.rounded 7 | = render @topic 8 | 9 | - if current_user.preferences.inverse_comment_order? 10 | = render partial: 'teams/topics/comments/comment_box', locals: { topic: @topic } 11 | 12 | %ul.list-group.list-group.pe-0.mt-3#comments 13 | - @topic_comments.each do |comment| 14 | = render partial: 'teams/topics/comments/comment', locals: { comment: } 15 | 16 | - unless current_user.preferences.inverse_comment_order? 17 | = render partial: 'teams/topics/comments/comment_box', locals: { topic: @topic } 18 | -------------------------------------------------------------------------------- /app/views/user_mailer/welcome_email.html.haml: -------------------------------------------------------------------------------- 1 | %h1 Welcome to AsyncGo! 2 | %p 3 | You have been invited to join team "#{@user.team.name}" on AsyncGo. 4 | %p 5 | To sign in, just follow 6 | = link_to 'this link', root_url 7 | and use an OAuth account connected with #{@user.email}. 8 | Documentation is available on our 9 | = link_to 'docs site', 'https://asyncgo.com/docs/' 10 | \. 11 | %p 12 | Thanks for joining and have a great day! If you have any issues you can always reach us at 13 | = mail_to('support@asyncgo.com') 14 | \. 15 | %p 16 | If you were not expecting this invitation feel free to ignore or contact us at the email 17 | address above. 18 | -------------------------------------------------------------------------------- /app/views/user_mailer/welcome_email.text.haml: -------------------------------------------------------------------------------- 1 | Welcome to AsyncGo! 2 | =============================================== 3 | 4 | You have been invited to join team "#{@user.team.name}" on AsyncGo. 5 | 6 | To sign in go to #{root_url} and use an OAuth account 7 | connected with #{@user.email}. Documentation is available on our 8 | docs site at https://asyncgo.com/docs. 9 | 10 | Thanks for joining and have a great day! If you have any issues 11 | you can always reach us at support@asyncgo.com. 12 | 13 | If you were not expecting this invitation feel free to ignore or 14 | contact us at the email address above. 15 | -------------------------------------------------------------------------------- /app/views/users/edit.html.haml: -------------------------------------------------------------------------------- 1 | = content_for :extra_nav_items do 2 | %button.btn.btn-outline-secondary.me-3.d-none.d-lg-block{ id: 'tour-button', 3 | data: { controller: 'driverjs', action: 'click->driverjs#demoUserProfile' } } 4 | = assistive_icon('fas', 'question-circle', 'Tour this page') 5 | 6 | %h1.visually-hidden= @user.printable_name 7 | 8 | .row 9 | .col-lg-12.mb-3 10 | %h3#digests Digest Notifications 11 | %p 12 | Would you like to receive a daily digest email about open notifications? 13 | - current_toggle_value = current_user.preferences.digest_enabled? 14 | - preference = current_toggle_value ? 'Yes' : 'No' 15 | %p Currently subscribed: #{preference} 16 | = form_with(model: @user.preferences, url: [@user, :preferences]) do |toggle_digests_form| 17 | = toggle_digests_form.hidden_field :digest_enabled, value: !current_toggle_value 18 | .input-group.mb-3 19 | = toggle_digests_form.submit 'Toggle notification status', class: 'btn btn-primary' 20 | .row 21 | .col-lg-12.mb-3 22 | %h3#layout Fluid Layout 23 | %p 24 | Choose between fixed (max. 960px) and fluid (100%) application layout. 25 | - current_toggle_value = current_user.preferences.fluid_layout? 26 | - preference = current_toggle_value ? 'Fluid' : 'Fixed' 27 | %p Current preference: #{preference} 28 | = form_with(model: @user.preferences, url: [@user, :preferences]) do |toggle_fluid_layout_form| 29 | = toggle_fluid_layout_form.hidden_field :fluid_layout, value: !current_toggle_value 30 | .input-group.mb-3 31 | = toggle_fluid_layout_form.submit 'Toggle layout preference', class: 'btn btn-primary' 32 | .row 33 | .col-lg-12.mb-3 34 | %h3#commentorder Comment Order 35 | %p 36 | Do you prefer to see comments oldest to newest or newest to oldest? 37 | - current_toggle_value = current_user.preferences.inverse_comment_order? 38 | - preference = current_toggle_value ? 'Newest to Oldest' : 'Oldest to Newest' 39 | %p Current preference: #{preference} 40 | = form_with(model: @user.preferences, url: [@user, :preferences]) do |toggle_comment_order_form| 41 | = toggle_comment_order_form.hidden_field :inverse_comment_order, value: !current_toggle_value 42 | .input-group.mb-3 43 | = toggle_comment_order_form.submit 'Toggle comment order', class: 'btn btn-primary' 44 | .row 45 | .col-lg-12.mb-3 46 | %h3#about About AsyncGo 47 | %p ©2021 AsyncGo 48 | %p= link_to 'Terms of Use / Privacy Policy', 'https://asyncgo.com/policies.html', 49 | target: :_blank, rel: :noopener 50 | -------------------------------------------------------------------------------- /app/views/users/notifications/index.html.haml: -------------------------------------------------------------------------------- 1 | %h1.visually-hidden Notifications 2 | 3 | .row 4 | .col-lg-6 5 | .list-group 6 | - if @notifications.present? 7 | - @notifications.each do |notification| 8 | = link_to user_notification_path(current_user.id, notification), 9 | class: 'list-group-item list-group-item-action mb-2 shadow-sm' do 10 | = notification_text(notification) 11 | | Created #{notification.created_at.strftime('%b %e')} 12 | - else 13 | No notifications 14 | 15 | - if @pagy.pages > 1 16 | .row.my-3 17 | != pagy_bootstrap_nav(@pagy) 18 | 19 | .row.mt-2 20 | = link_to clear_user_notifications_path(current_user), method: :post do 21 | = icon('fas', 'check-double', 'aria-hidden': 'true') 22 | %span Clear all notifications (all pages) 23 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function (api) { 2 | const validEnv = ['development', 'test', 'production'] 3 | const currentEnv = api.env() 4 | const isDevelopmentEnv = api.env('development') 5 | const isProductionEnv = api.env('production') 6 | const isTestEnv = api.env('test') 7 | 8 | if (!validEnv.includes(currentEnv)) { 9 | throw new Error( 10 | 'Please specify a valid `NODE_ENV` or ' + 11 | '`BABEL_ENV` environment variables. Valid values are "development", ' + 12 | '"test", and "production". Instead, received: ' + 13 | JSON.stringify(currentEnv) + 14 | '.' 15 | ) 16 | } 17 | 18 | return { 19 | presets: [ 20 | isTestEnv && [ 21 | '@babel/preset-env', 22 | { 23 | targets: { 24 | node: 'current' 25 | } 26 | } 27 | ], 28 | (isProductionEnv || isDevelopmentEnv) && [ 29 | '@babel/preset-env', 30 | { 31 | forceAllTransforms: true, 32 | useBuiltIns: 'entry', 33 | corejs: 3, 34 | modules: false, 35 | exclude: ['transform-typeof-symbol'] 36 | } 37 | ] 38 | ].filter(Boolean), 39 | plugins: [ 40 | 'babel-plugin-macros', 41 | '@babel/plugin-syntax-dynamic-import', 42 | isTestEnv && 'babel-plugin-dynamic-import-node', 43 | '@babel/plugin-transform-destructuring', 44 | [ 45 | '@babel/plugin-proposal-class-properties', 46 | { 47 | loose: true 48 | } 49 | ], 50 | [ 51 | '@babel/plugin-proposal-object-rest-spread', 52 | { 53 | useBuiltIns: true 54 | } 55 | ], 56 | [ 57 | '@babel/plugin-transform-runtime', 58 | { 59 | helpers: false 60 | } 61 | ], 62 | [ 63 | '@babel/plugin-transform-regenerator', 64 | { 65 | async: false 66 | } 67 | ] 68 | ].filter(Boolean) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'bundle' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require 'rubygems' 12 | 13 | m = Module.new do 14 | module_function 15 | 16 | def invoked_as_script? 17 | File.expand_path($PROGRAM_NAME) == File.expand_path(__FILE__) 18 | end 19 | 20 | def env_var_version 21 | ENV['BUNDLER_VERSION'] 22 | end 23 | 24 | def cli_arg_version 25 | return unless invoked_as_script? # don't want to hijack other binstubs 26 | return unless 'update'.start_with?(ARGV.first || ' ') # must be running `bundle update` 27 | 28 | bundler_version = nil 29 | update_index = nil 30 | ARGV.each_with_index do |a, i| 31 | bundler_version = a if update_index && update_index.succ == i && a =~ Gem::Version::ANCHORED_VERSION_PATTERN 32 | next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/ 33 | 34 | bundler_version = Regexp.last_match(1) 35 | update_index = i 36 | end 37 | bundler_version 38 | end 39 | 40 | def gemfile 41 | gemfile = ENV['BUNDLE_GEMFILE'] 42 | return gemfile if gemfile && !gemfile.empty? 43 | 44 | File.expand_path('../Gemfile', __dir__) 45 | end 46 | 47 | def lockfile 48 | lockfile = 49 | case File.basename(gemfile) 50 | when 'gems.rb' then gemfile.sub(/\.rb$/, gemfile) 51 | else "#{gemfile}.lock" 52 | end 53 | File.expand_path(lockfile) 54 | end 55 | 56 | def lockfile_version 57 | return unless File.file?(lockfile) 58 | 59 | lockfile_contents = File.read(lockfile) 60 | return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/ 61 | 62 | Regexp.last_match(1) 63 | end 64 | 65 | def bundler_version 66 | @bundler_version ||= 67 | env_var_version || cli_arg_version || 68 | lockfile_version 69 | end 70 | 71 | def bundler_requirement 72 | return "#{Gem::Requirement.default}.a" unless bundler_version 73 | 74 | bundler_gem_version = Gem::Version.new(bundler_version) 75 | 76 | requirement = bundler_gem_version.approximate_recommendation 77 | 78 | return requirement unless Gem::Version.new(Gem::VERSION) < Gem::Version.new('2.7.0') 79 | 80 | requirement += '.a' if bundler_gem_version.prerelease? 81 | 82 | requirement 83 | end 84 | 85 | def load_bundler! 86 | ENV['BUNDLE_GEMFILE'] ||= gemfile 87 | 88 | activate_bundler 89 | end 90 | 91 | def activate_bundler 92 | gem_error = activation_error_handling do 93 | gem 'bundler', bundler_requirement 94 | end 95 | return if gem_error.nil? 96 | 97 | require_error = activation_error_handling do 98 | require 'bundler/version' 99 | end 100 | if require_error.nil? && Gem::Requirement.new(bundler_requirement).satisfied_by?(Gem::Version.new(Bundler::VERSION)) 101 | return 102 | end 103 | 104 | warn "Activating bundler (#{bundler_requirement}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_requirement}'`" 105 | exit 42 106 | end 107 | 108 | def activation_error_handling 109 | yield 110 | nil 111 | rescue StandardError, LoadError => e 112 | e 113 | end 114 | end 115 | 116 | m.load_bundler! 117 | 118 | load Gem.bin_path('bundler', 'bundle') if m.invoked_as_script? 119 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | load File.expand_path('spring', __dir__) 5 | APP_PATH = File.expand_path('../config/application', __dir__) 6 | require_relative '../config/boot' 7 | require 'rails/commands' 8 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | load File.expand_path('spring', __dir__) 5 | require_relative '../config/boot' 6 | require 'rake' 7 | Rake.application.run 8 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'fileutils' 5 | 6 | # path to your application root. 7 | APP_ROOT = File.expand_path('..', __dir__) 8 | 9 | def system!(*args) 10 | system(*args) || abort("\n== Command #{args} failed ==") 11 | end 12 | 13 | FileUtils.chdir APP_ROOT do 14 | # This script is a way to set up or update your development environment automatically. 15 | # This script is idempotent, so that you can run it at any time and get an expectable outcome. 16 | # Add necessary setup steps to this file. 17 | 18 | puts '== Installing dependencies ==' 19 | system! 'gem install bundler --conservative' 20 | system('bundle check') || system!('bundle install') 21 | 22 | # Install JavaScript dependencies 23 | system! 'bin/yarn' 24 | 25 | # puts "\n== Copying sample files ==" 26 | # unless File.exist?('config/database.yml') 27 | # FileUtils.cp 'config/database.yml.sample', 'config/database.yml' 28 | # end 29 | 30 | puts "\n== Preparing database ==" 31 | system! 'bin/rails db:prepare' 32 | 33 | puts "\n== Removing old logs and tempfiles ==" 34 | system! 'bin/rails log:clear tmp:clear' 35 | 36 | puts "\n== Restarting application server ==" 37 | system! 'bin/rails restart' 38 | end 39 | -------------------------------------------------------------------------------- /bin/spring: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | if !defined?(Spring) && [nil, 'development', 'test'].include?(ENV['RAILS_ENV']) 5 | # Load Spring without loading other gems in the Gemfile, for speed. 6 | require 'bundler' 7 | Bundler.locked_gems.specs.find { |spec| spec.name == 'spring' }&.tap do |spring| 8 | Gem.use_paths Gem.dir, Bundler.bundle_path.to_s, *Gem.path 9 | gem 'spring', spring.version 10 | require 'spring/binstub' 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /bin/webpack: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | ENV["RAILS_ENV"] ||= ENV["RACK_ENV"] || "development" 4 | ENV["NODE_ENV"] ||= "development" 5 | 6 | require "pathname" 7 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 8 | Pathname.new(__FILE__).realpath) 9 | 10 | require "bundler/setup" 11 | 12 | require "webpacker" 13 | require "webpacker/webpack_runner" 14 | 15 | APP_ROOT = File.expand_path("..", __dir__) 16 | Dir.chdir(APP_ROOT) do 17 | Webpacker::WebpackRunner.run(ARGV) 18 | end 19 | -------------------------------------------------------------------------------- /bin/webpack-dev-server: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | ENV["RAILS_ENV"] ||= ENV["RACK_ENV"] || "development" 4 | ENV["NODE_ENV"] ||= "development" 5 | 6 | require "pathname" 7 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 8 | Pathname.new(__FILE__).realpath) 9 | 10 | require "bundler/setup" 11 | 12 | require "webpacker" 13 | require "webpacker/dev_server_runner" 14 | 15 | APP_ROOT = File.expand_path("..", __dir__) 16 | Dir.chdir(APP_ROOT) do 17 | Webpacker::DevServerRunner.run(ARGV) 18 | end 19 | -------------------------------------------------------------------------------- /bin/yarn: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'pathname' 5 | 6 | APP_ROOT = File.expand_path('..', __dir__) 7 | Dir.chdir(APP_ROOT) do 8 | executable_path = ENV['PATH'].split(File::PATH_SEPARATOR).find do |path| 9 | normalized_path = File.expand_path(path) 10 | 11 | normalized_path != __dir__ && File.executable?(Pathname.new(normalized_path).join('yarn')) 12 | end 13 | 14 | if executable_path 15 | exec File.expand_path(Pathname.new(executable_path).join('yarn')), *ARGV 16 | else 17 | warn 'Yarn executable was not detected in the system.' 18 | warn 'Download Yarn at https://yarnpkg.com/en/docs/install' 19 | exit 1 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file is used by Rack-based servers to start the application. 4 | 5 | require_relative 'config/environment' 6 | 7 | run Rails.application 8 | Rails.application.load_server 9 | -------------------------------------------------------------------------------- /config/application.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'boot' 4 | 5 | require 'rails/all' 6 | 7 | # Require the gems listed in Gemfile, including any gems 8 | # you've limited to :test, :development, or :production. 9 | Bundler.require(*Rails.groups) 10 | 11 | module Asyncgo 12 | class Application < Rails::Application 13 | # Initialize configuration defaults for originally generated Rails version. 14 | config.load_defaults 7.0 15 | 16 | # Configuration for the application, engines, and railties goes here. 17 | # 18 | # These settings can be overridden in specific environments using the files 19 | # in config/environments, which are processed later. 20 | # 21 | config.time_zone = 'UTC' 22 | # config.eager_load_paths << Rails.root.join("extras") 23 | 24 | # Add bootstrap scss to sass load path 25 | config.sass.load_paths << Rails.root.join('node_modules/bootstrap/scss') 26 | 27 | # Sharing configuration 28 | config.site_description = 'Smarter decisions, more alignment, and outsized 29 | outcomes: AsyncGo helps your team collaborate better with fewer chat and 30 | meeting interruptions.' 31 | config.site_title = 'Sign In to AsyncGo' 32 | config.site_image = 'social.png' 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /config/blazer.yml: -------------------------------------------------------------------------------- 1 | # see https://github.com/ankane/blazer for more info 2 | 3 | data_sources: 4 | main: 5 | url: <%= ENV["BLAZER_DATABASE_URL"] %> 6 | 7 | # statement timeout, in seconds 8 | # none by default 9 | timeout: 15 10 | 11 | # caching settings 12 | # can greatly improve speed 13 | # off by default 14 | # cache: 15 | # mode: slow # or all 16 | # expires_in: 60 # min 17 | # slow_threshold: 15 # sec, only used in slow mode 18 | 19 | # wrap queries in a transaction for safety 20 | # not necessary if you use a read-only user 21 | # true by default 22 | use_transaction: true 23 | 24 | smart_variables: 25 | # zone_id: "SELECT id, name FROM zones ORDER BY name ASC" 26 | # period: ["day", "week", "month"] 27 | # status: {0: "Active", 1: "Archived"} 28 | 29 | linked_columns: 30 | # user_id: "/admin/users/{value}" 31 | 32 | smart_columns: 33 | # user_id: "SELECT id, name FROM users WHERE id IN {value}" 34 | 35 | # create audits 36 | audit: true 37 | 38 | # change the time zone 39 | time_zone: "UTC" 40 | 41 | # class name of the user model 42 | user_class: User 43 | 44 | # method name for the current user 45 | user_method: current_user 46 | 47 | # method name for the display name 48 | user_name: printable_name 49 | 50 | # custom before_action to use for auth 51 | # before_action_method: authorize_blazer! 52 | 53 | # email to send checks from 54 | from_email: blazer@app.asyncgo.com 55 | 56 | # webhook for Slack 57 | # slack_webhook_url: <%= ENV["BLAZER_SLACK_WEBHOOK_URL"] %> 58 | 59 | check_schedules: 60 | - "1 day" 61 | - "1 hour" 62 | - "5 minutes" 63 | 64 | # enable anomaly detection 65 | # note: with trend, time series are sent to https://trendapi.org 66 | # anomaly_checks: trend / r 67 | 68 | # enable forecasting 69 | # note: with trend, time series are sent to https://trendapi.org 70 | # forecasting: trend / prophet 71 | 72 | # enable map 73 | # mapbox_access_token: <%= ENV["MAPBOX_ACCESS_TOKEN"] %> 74 | 75 | # enable uploads 76 | # uploads: 77 | # url: <%= ENV["BLAZER_UPLOADS_URL"] %> 78 | # schema: uploads 79 | # data_source: main 80 | -------------------------------------------------------------------------------- /config/boot.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) 4 | 5 | require 'bundler/setup' # Set up gems listed in the Gemfile. 6 | require 'bootsnap/setup' # Speed up boot time by caching expensive operations. 7 | -------------------------------------------------------------------------------- /config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: redis 3 | url: redis://localhost:6379/1 4 | 5 | test: 6 | adapter: test 7 | 8 | production: 9 | adapter: redis 10 | url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> 11 | channel_prefix: asyncgo_production 12 | -------------------------------------------------------------------------------- /config/database.yml: -------------------------------------------------------------------------------- 1 | # PostgreSQL. Versions 9.3 and up are supported. 2 | # 3 | # Install the pg driver: 4 | # gem install pg 5 | # On macOS with Homebrew: 6 | # gem install pg -- --with-pg-config=/usr/local/bin/pg_config 7 | # On macOS with MacPorts: 8 | # gem install pg -- --with-pg-config=/opt/local/lib/postgresql84/bin/pg_config 9 | # On Windows: 10 | # gem install pg 11 | # Choose the win32 build. 12 | # Install PostgreSQL and put its /bin directory on your path. 13 | # 14 | # Configure Using Gemfile 15 | # gem 'pg' 16 | # 17 | default: &default 18 | adapter: postgresql 19 | encoding: unicode 20 | # For details on connection pooling, see Rails configuration guide 21 | # https://guides.rubyonrails.org/configuring.html#database-pooling 22 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> 23 | 24 | development: 25 | <<: *default 26 | database: asyncgo_development 27 | 28 | # The specified database role being used to connect to postgres. 29 | # To create additional roles in postgres see `$ createuser --help`. 30 | # When left blank, postgres will use the default role. This is 31 | # the same name as the operating system user running Rails. 32 | #username: testapp 33 | 34 | # The password associated with the postgres role (username). 35 | #password: 36 | 37 | # Connect on a TCP socket. Omitted by default since the client uses a 38 | # domain socket that doesn't need configuration. Windows does not have 39 | # domain sockets, so uncomment these lines. 40 | #host: localhost 41 | 42 | # The TCP port the server listens on. Defaults to 5432. 43 | # If your server runs on a different port number, change accordingly. 44 | #port: 5432 45 | 46 | # Schema search path. The server defaults to $user,public 47 | #schema_search_path: myapp,sharedapp,public 48 | 49 | # Minimum log levels, in increasing order: 50 | # debug5, debug4, debug3, debug2, debug1, 51 | # log, notice, warning, error, fatal, and panic 52 | # Defaults to warning. 53 | #min_messages: notice 54 | 55 | # Warning: The database defined as "test" will be erased and 56 | # re-generated from your development database when you run "rake". 57 | # Do not set this db to the same as development or production. 58 | test: 59 | <<: *default 60 | database: asyncgo_test 61 | 62 | # As with config/credentials.yml, you never want to store sensitive information, 63 | # like your database password, in your source code. If your source code is 64 | # ever seen by anyone, they now have access to your database. 65 | # 66 | # Instead, provide the password or a full connection URL as an environment 67 | # variable when you boot the app. For example: 68 | # 69 | # DATABASE_URL="postgres://myuser:mypass@localhost/somedatabase" 70 | # 71 | # If the connection URL is provided in the special DATABASE_URL environment 72 | # variable, Rails will automatically merge its configuration values on top of 73 | # the values provided in this file. Alternatively, you can specify a connection 74 | # URL environment variable explicitly: 75 | # 76 | # production: 77 | # url: <%= ENV['MY_APP_DATABASE_URL'] %> 78 | # 79 | # Read https://guides.rubyonrails.org/configuring.html#configuring-a-database 80 | # for a full overview on how database connection configuration can be specified. 81 | # 82 | production: 83 | <<: *default 84 | database: asyncgo_production 85 | username: asyncgo 86 | password: <%= ENV['ASYNCGO_DATABASE_PASSWORD'] %> 87 | 88 | staging: 89 | <<: *default 90 | database: asyncgo_production 91 | username: asyncgo 92 | password: <%= ENV['ASYNCGO_DATABASE_PASSWORD'] %> 93 | -------------------------------------------------------------------------------- /config/environment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Load the Rails application. 4 | require_relative 'application' 5 | 6 | # Initialize the Rails application. 7 | Rails.application.initialize! 8 | -------------------------------------------------------------------------------- /config/environments/development.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/core_ext/integer/time' 4 | 5 | Rails.application.configure do 6 | # Settings specified here will take precedence over those in config/application.rb. 7 | 8 | # In the development environment your application's code is reloaded any time 9 | # it changes. This slows down response time but is perfect for development 10 | # since you don't have to restart the web server when you make code changes. 11 | config.cache_classes = false 12 | 13 | # Do not eager load code on boot. 14 | config.eager_load = false 15 | 16 | # Show full error reports. 17 | config.consider_all_requests_local = true 18 | 19 | # Enable/disable caching. By default caching is disabled. 20 | # Run rails dev:cache to toggle caching. 21 | if Rails.root.join('tmp/caching-dev.txt').exist? 22 | config.action_controller.perform_caching = true 23 | config.action_controller.enable_fragment_cache_logging = true 24 | 25 | config.cache_store = :memory_store 26 | config.public_file_server.headers = { 27 | 'Cache-Control' => "public, max-age=#{2.days.to_i}" 28 | } 29 | else 30 | config.action_controller.perform_caching = false 31 | 32 | config.cache_store = :null_store 33 | end 34 | 35 | # Store uploaded files on the local file system (see config/storage.yml for options). 36 | config.active_storage.service = :local 37 | 38 | # Don't care if the mailer can't send. 39 | config.action_mailer.raise_delivery_errors = false 40 | 41 | config.action_mailer.perform_caching = false 42 | 43 | config.action_mailer.default_url_options = { host: 'localhost' } 44 | 45 | config.action_mailer.preview_path = Rails.root.join('spec/mailers/previews') 46 | 47 | # Print deprecation notices to the Rails logger. 48 | config.active_support.deprecation = :log 49 | 50 | # Raise exceptions for disallowed deprecations. 51 | config.active_support.disallowed_deprecation = :raise 52 | 53 | # Tell Active Support which deprecation messages to disallow. 54 | config.active_support.disallowed_deprecation_warnings = [] 55 | 56 | # Raise an error on page load if there are pending migrations. 57 | config.active_record.migration_error = :page_load 58 | 59 | # Highlight code that triggered database queries in logs. 60 | config.active_record.verbose_query_logs = true 61 | 62 | # Debug mode disables concatenation and preprocessing of assets. 63 | # This option may cause significant delays in view rendering with a large 64 | # number of complex assets. 65 | config.assets.debug = true 66 | 67 | # Suppress logger output for asset requests. 68 | config.assets.quiet = true 69 | 70 | # Raises error for missing translations. 71 | # config.i18n.raise_on_missing_translations = true 72 | 73 | # Annotate rendered view with file names. 74 | # config.action_view.annotate_rendered_view_with_filenames = true 75 | 76 | # Use an evented file watcher to asynchronously detect changes in source code, 77 | # routes, locales, etc. This feature depends on the listen gem. 78 | config.file_watcher = ActiveSupport::EventedFileUpdateChecker 79 | 80 | # Uncomment if you wish to allow Action Cable access from any origin. 81 | # config.action_cable.disable_request_forgery_protection = true 82 | end 83 | -------------------------------------------------------------------------------- /config/environments/staging.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Use production settings 4 | require File.expand_path('production.rb', __dir__) 5 | 6 | Rails.application.configure do 7 | config.action_mailer.default_url_options = { host: 'staging.asyncgo.com' } 8 | end 9 | -------------------------------------------------------------------------------- /config/environments/test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/core_ext/integer/time' 4 | 5 | # The test environment is used exclusively to run your application's 6 | # test suite. You never need to work with it otherwise. Remember that 7 | # your test database is "scratch space" for the test suite and is wiped 8 | # and recreated between test runs. Don't rely on the data there! 9 | 10 | Rails.application.configure do 11 | # Settings specified here will take precedence over those in config/application.rb. 12 | 13 | config.cache_classes = false 14 | config.action_view.cache_template_loading = true 15 | 16 | # Do not eager load code on boot. This avoids loading your whole application 17 | # just for the purpose of running a single test. If you are using a tool that 18 | # preloads Rails for running tests, you may have to set it to true. 19 | config.eager_load = false 20 | 21 | # Configure public file server for tests with Cache-Control for performance. 22 | config.public_file_server.enabled = true 23 | config.public_file_server.headers = { 24 | 'Cache-Control' => "public, max-age=#{1.hour.to_i}" 25 | } 26 | 27 | # Show full error reports and disable caching. 28 | config.consider_all_requests_local = true 29 | config.action_controller.perform_caching = false 30 | config.cache_store = :null_store 31 | 32 | # Raise exceptions instead of rendering exception templates. 33 | config.action_dispatch.show_exceptions = false 34 | 35 | # Disable request forgery protection in test environment. 36 | config.action_controller.allow_forgery_protection = false 37 | 38 | # Store uploaded files on the local file system in a temporary directory. 39 | config.active_storage.service = :test 40 | 41 | config.action_mailer.perform_caching = false 42 | 43 | config.action_mailer.default_url_options = { host: 'localhost' } 44 | 45 | # Tell Action Mailer not to deliver emails to the real world. 46 | # The :test delivery method accumulates sent emails in the 47 | # ActionMailer::Base.deliveries array. 48 | config.action_mailer.delivery_method = :test 49 | 50 | # Print deprecation notices to the stderr. 51 | config.active_support.deprecation = :stderr 52 | 53 | # Raise exceptions for disallowed deprecations. 54 | config.active_support.disallowed_deprecation = :raise 55 | 56 | # Tell Active Support which deprecation messages to disallow. 57 | config.active_support.disallowed_deprecation_warnings = [] 58 | 59 | # Raises error for missing translations. 60 | # config.i18n.raise_on_missing_translations = true 61 | 62 | # Annotate rendered view with file names. 63 | # config.action_view.annotate_rendered_view_with_filenames = true 64 | 65 | # Put OmniAuth into test mode 66 | OmniAuth.config.test_mode = true 67 | end 68 | -------------------------------------------------------------------------------- /config/initializers/acts_as_taggable_on.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require './lib/asyncgo_tag_parser' 4 | 5 | ActsAsTaggableOn.default_parser = AsyncgoTagParser 6 | -------------------------------------------------------------------------------- /config/initializers/application_controller_renderer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # Be sure to restart your server when you modify this file. 3 | 4 | # ActiveSupport::Reloader.to_prepare do 5 | # ApplicationController.renderer.defaults.merge!( 6 | # http_host: 'example.org', 7 | # https: false 8 | # ) 9 | # end 10 | -------------------------------------------------------------------------------- /config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # Version of your assets, change this if you want to expire all your assets. 6 | Rails.application.config.assets.version = '1.0' 7 | 8 | # Add additional assets to the asset load path. 9 | # Rails.application.config.assets.paths << Emoji.images_path 10 | # Add Yarn node_modules folder to the asset load path. 11 | Rails.application.config.assets.paths << Rails.root.join('node_modules') 12 | 13 | # Precompile additional assets. 14 | # application.js, application.css, and all non-JS/CSS in the app/assets 15 | # folder are already added. 16 | # Rails.application.config.assets.precompile += %w( admin.js admin.css ) 17 | -------------------------------------------------------------------------------- /config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 6 | # Rails.backtrace_cleaner.add_silencer { |line| /my_noisy_library/.match?(line) } 7 | 8 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code 9 | # by setting BACKTRACE=1 before calling your invocation, like "BACKTRACE=1 ./bin/rails runner 'MyClass.perform'". 10 | Rails.backtrace_cleaner.remove_silencers! if ENV['BACKTRACE'] 11 | -------------------------------------------------------------------------------- /config/initializers/content_security_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # Be sure to restart your server when you modify this file. 3 | 4 | # Define an application-wide content security policy 5 | # For further information see the following documentation 6 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy 7 | 8 | # Rails.application.config.content_security_policy do |policy| 9 | # policy.default_src :self, :https 10 | # policy.font_src :self, :https, :data 11 | # policy.img_src :self, :https, :data 12 | # policy.object_src :none 13 | # policy.script_src :self, :https 14 | # policy.style_src :self, :https 15 | # # If you are using webpack-dev-server then specify webpack-dev-server host 16 | # policy.connect_src :self, :https, "http://localhost:3035", "ws://localhost:3035" if Rails.env.development? 17 | 18 | # # Specify URI for violation reports 19 | # # policy.report_uri "/csp-violation-report-endpoint" 20 | # end 21 | 22 | # If you are using UJS then enable automatic nonce generation 23 | # Rails.application.config.content_security_policy_nonce_generator = -> request { SecureRandom.base64(16) } 24 | 25 | # Set the nonce only to specific directives 26 | # Rails.application.config.content_security_policy_nonce_directives = %w(script-src) 27 | 28 | # Report CSP violations to a specified URI 29 | # For further information see the following documentation: 30 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only 31 | # Rails.application.config.content_security_policy_report_only = true 32 | -------------------------------------------------------------------------------- /config/initializers/cookies_serializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # Specify a serializer for the signed and encrypted cookie jars. 6 | # Valid options are :json, :marshal, and :hybrid. 7 | Rails.application.config.action_dispatch.cookies_serializer = :json 8 | -------------------------------------------------------------------------------- /config/initializers/environment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rails.application.config.x.slack.client_id = ENV['SLACK_CLIENT_ID'] 4 | Rails.application.config.x.slack.client_secret = ENV['SLACK_CLIENT_SECRET'] 5 | Rails.application.config.x.fastspring.crypto_key = ENV['FASTSPRING_CRYPTO_KEY'] 6 | Rails.application.config.x.fastspring.store_url = ENV['FASTSPRING_STORE_URL'] 7 | Rails.application.config.x.fastspring.username = ENV['FASTSPRING_USERNAME'] 8 | Rails.application.config.x.fastspring.password = ENV['FASTSPRING_PASSWORD'] 9 | -------------------------------------------------------------------------------- /config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # Configure sensitive parameters which will be filtered from the log file. 6 | Rails.application.config.filter_parameters += %i[ 7 | passw secret token _key crypt salt certificate otp ssn 8 | ] 9 | -------------------------------------------------------------------------------- /config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # Be sure to restart your server when you modify this file. 3 | 4 | # Add new inflection rules using the following format. Inflections 5 | # are locale specific, and you may define rules for as many different 6 | # locales as you wish. All of these examples are active by default: 7 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 8 | # inflect.plural /^(ox)$/i, '\1en' 9 | # inflect.singular /^(ox)en/i, '\1' 10 | # inflect.irregular 'person', 'people' 11 | # inflect.uncountable %w( fish sheep ) 12 | # end 13 | 14 | # These inflection rules are supported but not enabled by default: 15 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 16 | # inflect.acronym 'RESTful' 17 | # end 18 | -------------------------------------------------------------------------------- /config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # Be sure to restart your server when you modify this file. 3 | 4 | # Add new mime types for use in respond_to blocks: 5 | # Mime::Type.register "text/richtext", :rtf 6 | -------------------------------------------------------------------------------- /config/initializers/omniauth.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rails.application.config.middleware.use OmniAuth::Builder do 4 | provider :google_oauth2, ENV['GOOGLE_CLIENT_ID'], ENV['GOOGLE_CLIENT_SECRET'], 5 | { 6 | prompt: 'select_account' 7 | } 8 | provider :github, ENV['GITHUB_CLIENT_ID'], ENV['GITHUB_CLIENT_SECRET'], 9 | { 10 | scope: 'user:email' 11 | } 12 | provider :microsoft_graph, ENV['AZURE_APPLICATION_CLIENT_ID'], 13 | ENV['AZURE_APPLICATION_CLIENT_SECRET'], 14 | { 15 | scope: 'User.Read' 16 | } 17 | provider :slack, ENV['SLACK_CLIENT_ID'], ENV['SLACK_CLIENT_SECRET'], 18 | { 19 | user_scope: 'identity.basic,identity.email' 20 | } 21 | end 22 | -------------------------------------------------------------------------------- /config/initializers/permissions_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # Define an application-wide HTTP permissions policy. For further 3 | # information see https://developers.google.com/web/updates/2018/06/feature-policy 4 | # 5 | # Rails.application.config.permissions_policy do |f| 6 | # f.camera :none 7 | # f.gyroscope :none 8 | # f.microphone :none 9 | # f.usb :none 10 | # f.fullscreen :self 11 | # f.payment :self, "https://secure.example.com" 12 | # end 13 | -------------------------------------------------------------------------------- /config/initializers/rack_profiler.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | if Rails.env.development? 4 | require 'rack-mini-profiler' 5 | 6 | # Move profiler to right to not overlap logo 7 | Rack::MiniProfiler.config.position = 'right' 8 | 9 | # Initialization is skipped so trigger it 10 | Rack::MiniProfilerRails.initialize!(Rails.application) 11 | end 12 | -------------------------------------------------------------------------------- /config/initializers/webpacker_patch.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Revert of https://github.com/rails/webpacker/commit/3760588534c54527d21c684c2cb5ca30cafc0901 4 | module WebpackerChdirPatch 5 | def watched_files_digest 6 | unless watched_paths.empty? 7 | warn 'Webpacker::Compiler.watched_paths has been deprecated. Set additional_paths in webpacker.yml instead.' 8 | end 9 | 10 | files = Dir[*default_watched_paths, *watched_paths].reject { |f| File.directory?(f) } 11 | file_ids = files.sort.map { |f| "#{File.basename(f)}/#{Digest::SHA1.file(f).hexdigest}" } 12 | Digest::SHA1.hexdigest(file_ids.join('/')) 13 | end 14 | end 15 | 16 | Webpacker::Compiler.prepend WebpackerChdirPatch 17 | -------------------------------------------------------------------------------- /config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # This file contains settings for ActionController::ParamsWrapper which 6 | # is enabled by default. 7 | 8 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 9 | ActiveSupport.on_load(:action_controller) do 10 | wrap_parameters format: [:json] 11 | end 12 | 13 | # To enable root element in JSON for ActiveRecord objects. 14 | # ActiveSupport.on_load(:active_record) do 15 | # self.include_root_in_json = true 16 | # end 17 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # The following keys must be escaped otherwise they will not be retrieved by 20 | # the default I18n backend: 21 | # 22 | # true, false, on, off, yes, no 23 | # 24 | # Instead, surround them with single quotes. 25 | # 26 | # en: 27 | # 'true': 'foo' 28 | # 29 | # To learn more, please read the Rails Internationalization guide 30 | # available at https://guides.rubyonrails.org/i18n.html. 31 | 32 | en: 33 | you_are_not_authorized: "You are not authorized." 34 | could_not_authenticate_user: "Could not authenticate user." 35 | your_asyncgo_digest: "Your AsyncGo Digest" 36 | welcome_to_asyncgo: "Welcome to AsyncGo" 37 | -------------------------------------------------------------------------------- /config/puma.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'barnes' 4 | 5 | # Puma can serve each request in a thread from an internal thread pool. 6 | # The `threads` method setting takes two numbers: a minimum and maximum. 7 | # Any libraries that use thread pools should be configured to match 8 | # the maximum value specified for Puma. Default is set to 5 threads for minimum 9 | # and maximum; this matches the default thread size of Active Record. 10 | # 11 | max_threads_count = ENV.fetch('RAILS_MAX_THREADS', 5) 12 | min_threads_count = ENV.fetch('RAILS_MIN_THREADS') { max_threads_count } 13 | threads min_threads_count, max_threads_count 14 | 15 | # Specifies the `worker_timeout` threshold that Puma will use to wait before 16 | # terminating a worker in development environments. 17 | # 18 | worker_timeout 3600 if ENV.fetch('RAILS_ENV', 'development') == 'development' 19 | 20 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 21 | # 22 | port ENV.fetch('PORT', 3000) 23 | 24 | # Specifies the `environment` that Puma will run in. 25 | # 26 | environment ENV.fetch('RAILS_ENV', 'development') 27 | 28 | # Specifies the `pidfile` that Puma will use. 29 | pidfile ENV.fetch('PIDFILE', 'tmp/pids/server.pid') 30 | 31 | # Specifies the number of `workers` to boot in clustered mode. 32 | # Workers are forked web server processes. If using threads and workers together 33 | # the concurrency of the application would be max `threads` * `workers`. 34 | # Workers do not work on JRuby or Windows (both of which do not support 35 | # processes). 36 | # 37 | # workers ENV.fetch("WEB_CONCURRENCY") { 2 } 38 | 39 | # Use the `preload_app!` method when specifying a `workers` number. 40 | # This directive tells Puma to first boot the application and load code 41 | # before forking the application. This takes advantage of Copy On Write 42 | # process behavior so workers use less memory. 43 | # 44 | # preload_app! 45 | 46 | # Allow puma to be restarted by `rails restart` command. 47 | plugin :tmp_restart 48 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'sidekiq/web' 4 | 5 | class AuthConstraint 6 | def self.admin?(request) 7 | user = User.find_by(id: request.session[:user_id]) 8 | user&.email&.ends_with?('@asyncgo.com') 9 | end 10 | end 11 | 12 | Rails.application.routes.draw do 13 | # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html 14 | root controller: :home, action: :index 15 | delete :sign_out, controller: :sessions, action: :destroy 16 | 17 | # OmniAuth routing 18 | scope :auth, controller: :omniauth_callbacks do 19 | get 'github/callback', action: :github 20 | get 'google_oauth2/callback', action: :google_oauth2 21 | get 'microsoft_graph/callback', action: :microsoft_graph 22 | get 'slack/callback', action: :slack 23 | 24 | get :failure, to: redirect('/') 25 | end 26 | 27 | constraints ->(request) { AuthConstraint.admin?(request) } do 28 | mount Sidekiq::Web, at: :sidekiq 29 | mount Blazer::Engine, at: :blazer 30 | end 31 | 32 | scope :extension, controller: :extension do 33 | get :new_topic 34 | end 35 | 36 | resources :users, only: :edit do 37 | scope module: :users do 38 | resource :preferences, only: :update 39 | resources :notifications, only: %i[show index] do 40 | collection do 41 | post :clear 42 | end 43 | end 44 | end 45 | end 46 | 47 | resources :teams, only: %i[edit new create update] do 48 | post :support 49 | 50 | scope module: :teams do 51 | resources :users, only: %i[index create destroy] 52 | resources :topics, only: %i[index show new edit create update] do 53 | patch :toggle 54 | post :subscribe 55 | patch :pin 56 | put :archive 57 | 58 | scope module: :topics do 59 | resources :comments, only: %i[new create edit update] do 60 | put :archive 61 | scope module: :comments do 62 | resources :votes, only: %i[create destroy] 63 | end 64 | end 65 | 66 | resources :votes, only: %i[create destroy] 67 | end 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /config/spring.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Spring.watch( 4 | '.ruby-version', 5 | '.rbenv-vars', 6 | 'tmp/restart.txt', 7 | 'tmp/caching-dev.txt' 8 | ) 9 | -------------------------------------------------------------------------------- /config/storage.yml: -------------------------------------------------------------------------------- 1 | test: 2 | service: Disk 3 | root: <%= Rails.root.join("tmp/storage") %> 4 | 5 | local: 6 | service: Disk 7 | root: <%= Rails.root.join("storage") %> 8 | 9 | # Use rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) 10 | # amazon: 11 | # service: S3 12 | # access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> 13 | # secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> 14 | # region: us-east-1 15 | # bucket: your_own_bucket 16 | 17 | # Remember not to checkin your GCS keyfile to a repository 18 | # google: 19 | # service: GCS 20 | # project: your_project 21 | # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> 22 | # bucket: your_own_bucket 23 | 24 | # Use rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) 25 | # microsoft: 26 | # service: AzureStorage 27 | # storage_account_name: your_account_name 28 | # storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> 29 | # container: your_container_name 30 | 31 | # mirror: 32 | # service: Mirror 33 | # primary: local 34 | # mirrors: [ amazon, google, microsoft ] 35 | -------------------------------------------------------------------------------- /config/webpack/base.js: -------------------------------------------------------------------------------- 1 | const { webpackConfig } = require('@rails/webpacker') 2 | 3 | module.exports = webpackConfig 4 | -------------------------------------------------------------------------------- /config/webpack/development.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = process.env.NODE_ENV || 'development' 2 | 3 | const webpackConfig = require('./base') 4 | 5 | module.exports = webpackConfig 6 | -------------------------------------------------------------------------------- /config/webpack/environment.js: -------------------------------------------------------------------------------- 1 | const { environment } = require('@rails/webpacker') 2 | 3 | module.exports = environment 4 | -------------------------------------------------------------------------------- /config/webpack/production.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = process.env.NODE_ENV || 'production' 2 | 3 | const webpackConfig = require('./base') 4 | 5 | module.exports = webpackConfig 6 | -------------------------------------------------------------------------------- /config/webpack/test.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = process.env.NODE_ENV || 'development' 2 | 3 | const webpackConfig = require('./base') 4 | 5 | module.exports = webpackConfig 6 | -------------------------------------------------------------------------------- /config/webpacker.yml: -------------------------------------------------------------------------------- 1 | # Note: You must restart bin/webpack-dev-server for changes to take effect 2 | 3 | default: &default 4 | source_path: app/packs 5 | source_entry_path: entrypoints 6 | public_root_path: public 7 | public_output_path: packs 8 | cache_path: tmp/cache/webpacker 9 | webpack_compile_output: true 10 | 11 | # Additional paths webpack should lookup modules 12 | # ['app/assets', 'engine/foo/app/assets'] 13 | additional_paths: [] 14 | 15 | # Reload manifest.json on all requests so we reload latest compiled packs 16 | cache_manifest: false 17 | 18 | development: 19 | <<: *default 20 | compile: true 21 | 22 | # Reference: https://webpack.js.org/configuration/dev-server/ 23 | dev_server: 24 | https: false 25 | host: localhost 26 | port: 3035 27 | public: localhost:3035 28 | # Inject browserside javascript that required by both HMR and Live(full) reload 29 | inject_client: true 30 | # Hot Module Replacement updates modules while the application is running without a full reload 31 | hmr: false 32 | # Inline should be set to true if using HMR; it inserts a script to take care of live reloading 33 | inline: true 34 | # Should we show a full-screen overlay in the browser when there are compiler errors or warnings? 35 | overlay: true 36 | # Should we use gzip compression? 37 | compress: true 38 | # Note that apps that do not check the host are vulnerable to DNS rebinding attacks 39 | disable_host_check: true 40 | # This option lets the browser open with your local IP 41 | use_local_ip: false 42 | # When enabled, nothing except the initial startup information will be written to the console. 43 | # This also means that errors or warnings from webpack are not visible. 44 | quiet: false 45 | pretty: false 46 | headers: 47 | 'Access-Control-Allow-Origin': '*' 48 | watch_options: 49 | ignored: '**/node_modules/**' 50 | 51 | test: 52 | <<: *default 53 | compile: true 54 | 55 | # Compile test packs to a separate directory 56 | public_output_path: packs-test 57 | 58 | production: 59 | <<: *default 60 | 61 | # Production depends on precompilation of packs prior to booting for performance. 62 | compile: false 63 | 64 | # Cache manifest.json for performance 65 | cache_manifest: true 66 | -------------------------------------------------------------------------------- /db/migrate/20201212231443_create_teams.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateTeams < ActiveRecord::Migration[6.1] 4 | def change 5 | create_table :teams do |t| 6 | t.string :name, null: false 7 | 8 | t.timestamps 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20201212231556_create_users.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateUsers < ActiveRecord::Migration[6.1] 4 | def change 5 | create_table :users do |t| 6 | t.string :email, null: false, index: { unique: true } 7 | t.string :name, null: true 8 | 9 | t.references :team, foreign_key: true, null: true 10 | 11 | t.timestamps 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /db/migrate/20201212232503_create_topics.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateTopics < ActiveRecord::Migration[6.1] 4 | TOPIC_ACTIVE_STATUS = 0 5 | 6 | def change 7 | create_table :topics do |t| 8 | t.string :title, null: false 9 | t.text :description, null: false 10 | t.text :description_html, null: false 11 | t.text :outcome, null: true 12 | t.text :outcome_html, null: true 13 | t.date :due_date, null: true 14 | t.integer :status, null: false, default: TOPIC_ACTIVE_STATUS 15 | 16 | t.references :user, foreign_key: true, null: false 17 | t.references :team, foreign_key: true, null: false 18 | 19 | t.timestamps 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /db/migrate/20201212232508_create_comments.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateComments < ActiveRecord::Migration[6.1] 4 | def change 5 | create_table :comments do |t| 6 | t.text :body, null: false 7 | t.text :body_html, null: false 8 | 9 | t.references :topic, foreign_key: true, null: false 10 | t.references :user, foreign_key: true, null: false 11 | 12 | t.timestamps 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /db/migrate/20210117195615_create_subscriptions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateSubscriptions < ActiveRecord::Migration[6.1] 4 | def change 5 | create_table :subscriptions do |t| 6 | t.references :user, foreign_key: true, null: false 7 | t.references :topic, foreign_key: true, null: false 8 | 9 | t.index %i[user_id topic_id], unique: true 10 | 11 | t.timestamps 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /db/migrate/20210123181841_create_notifications.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateNotifications < ActiveRecord::Migration[6.1] 4 | def change 5 | create_table :notifications do |t| 6 | t.integer :action, null: false 7 | t.date :read_at, null: true 8 | 9 | t.references :user, foreign_key: true, null: false 10 | t.references :actor, foreign_key: { to_table: :users }, null: false 11 | t.references :target, polymorphic: true, null: false 12 | 13 | t.timestamps 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /db/migrate/20210211101554_create_votes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateVotes < ActiveRecord::Migration[6.1] 4 | def change 5 | create_table :votes do |t| 6 | t.string :emoji, null: false 7 | 8 | t.references :user, foreign_key: true, null: false 9 | t.references :votable, polymorphic: true, null: false 10 | 11 | t.timestamps 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /db/migrate/20210215173618_create_user_preferences.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateUserPreferences < ActiveRecord::Migration[6.1] 4 | def change 5 | create_table :user_preferences do |t| 6 | t.boolean :digest_enabled, default: true, null: false 7 | t.boolean :fluid_layout, default: false, null: false 8 | 9 | t.references :user, foreign_key: true, null: false, index: { unique: true } 10 | 11 | t.timestamps 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /db/migrate/20210317104058_install_blazer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class InstallBlazer < ActiveRecord::Migration[6.1] 4 | def change # rubocop:disable Metrics/AbcSize 5 | create_table :blazer_queries do |t| 6 | t.references :creator 7 | t.string :name 8 | t.text :description 9 | t.text :statement 10 | t.string :data_source 11 | t.string :status 12 | t.timestamps null: false 13 | end 14 | 15 | create_table :blazer_audits do |t| 16 | t.references :user 17 | t.references :query 18 | t.text :statement 19 | t.string :data_source 20 | t.datetime :created_at 21 | end 22 | 23 | create_table :blazer_dashboards do |t| 24 | t.references :creator 25 | t.string :name 26 | t.timestamps null: false 27 | end 28 | 29 | create_table :blazer_dashboard_queries do |t| 30 | t.references :dashboard 31 | t.references :query 32 | t.integer :position 33 | t.timestamps null: false 34 | end 35 | 36 | create_table :blazer_checks do |t| 37 | t.references :creator 38 | t.references :query 39 | t.string :state 40 | t.string :schedule 41 | t.text :emails 42 | t.text :slack_channels 43 | t.string :check_type 44 | t.text :message 45 | t.datetime :last_run_at 46 | t.timestamps null: false 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /db/migrate/20210326064951_add_message_to_team.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddMessageToTeam < ActiveRecord::Migration[6.1] 4 | def change 5 | change_table :teams do |t| 6 | t.string :message 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20210402075111_add_pinned_to_topics.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddPinnedToTopics < ActiveRecord::Migration[6.1] 4 | def change 5 | add_column :topics, :pinned, :boolean, null: false, default: false 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20210403173818_acts_as_taggable_on_migration.acts_as_taggable_on_engine.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ActsAsTaggableOnMigration < ActiveRecord::Migration[6.1] 4 | TAGS_TABLE = ActsAsTaggableOn.tags_table 5 | TAGGINGS_TABLE = ActsAsTaggableOn.taggings_table 6 | 7 | def change 8 | create_table TAGS_TABLE do |t| 9 | t.string :name, index: { unique: true } 10 | t.integer :taggings_count, default: 0 11 | 12 | t.timestamps 13 | end 14 | 15 | create_table TAGGINGS_TABLE do |t| 16 | t.references :tag, foreign_key: { to_table: TAGS_TABLE }, index: true 17 | 18 | # You should make sure that the column created is 19 | # long enough to store the required class names. 20 | t.references :taggable, polymorphic: true 21 | t.references :tagger, polymorphic: true 22 | 23 | # Limit is created to prevent MySQL error on index 24 | # length for MyISAM table type: http://bit.ly/vgW2Ql 25 | t.string :context, limit: 128, index: true 26 | 27 | t.datetime :created_at 28 | 29 | t.index %i[tag_id taggable_id taggable_type context tagger_id tagger_type], unique: true, 30 | name: 'taggings_idx' 31 | t.index %i[taggable_id taggable_type tagger_id context], name: 'taggings_idy' 32 | t.index %i[taggable_id taggable_type context], name: 'taggings_taggable_context_idx' 33 | t.index %i[tagger_id tagger_type] 34 | end 35 | end 36 | 37 | # 20210502124111_add_missing_unique_indices.acts_as_taggable_on_engine.rb from acts_as_taggable_on_engine 38 | # 20210502124112_add_taggings_counter_cache_to_tags.acts_as_taggable_on_engine.rb from acts_as_taggable_on_engine 39 | # 20210502124113_add_missing_taggable_index.acts_as_taggable_on_engine.rb from acts_as_taggable_on_engine 40 | # 20210502124114_change_collation_for_tag_names.acts_as_taggable_on_engine.rb from acts_as_taggable_on_engine 41 | # 20210502124115_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb from acts_as_taggable_on_engine 42 | end 43 | -------------------------------------------------------------------------------- /db/migrate/20210414151736_add_comment_order_to_user_preferences.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddCommentOrderToUserPreferences < ActiveRecord::Migration[6.1] 4 | def change 5 | add_column :user_preferences, :inverse_comment_order, :boolean, default: false, null: false 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20210420150827_create_team_subscriptions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateTeamSubscriptions < ActiveRecord::Migration[6.1] 4 | def change 5 | create_table :team_subscriptions do |t| 6 | t.boolean :active, default: false, null: false 7 | 8 | t.references :team, foreign_key: true, null: false, index: { unique: true } 9 | 10 | t.timestamps 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /db/migrate/20210502124505_add_missing_unique_indices.acts_as_taggable_on_engine.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This migration comes from acts_as_taggable_on_engine (originally 2) 4 | class AddMissingUniqueIndices < ActiveRecord::Migration[6.1] 5 | # This was included in 6 | # 20210403173818_acts_as_taggable_on_migration.acts_as_taggable_on_engine.rb 7 | 8 | # no-op 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20210502124506_add_taggings_counter_cache_to_tags.acts_as_taggable_on_engine.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This migration comes from acts_as_taggable_on_engine (originally 3) 4 | class AddTaggingsCounterCacheToTags < ActiveRecord::Migration[6.1] 5 | # This was included in 6 | # 20210403173818_acts_as_taggable_on_migration.acts_as_taggable_on_engine.rb 7 | 8 | # no-op 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20210502124507_add_missing_taggable_index.acts_as_taggable_on_engine.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This migration comes from acts_as_taggable_on_engine (originally 4) 4 | class AddMissingTaggableIndex < ActiveRecord::Migration[6.1] 5 | # This was included in 6 | # 20210403173818_acts_as_taggable_on_migration.acts_as_taggable_on_engine.rb 7 | 8 | # no-op 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20210502124508_change_collation_for_tag_names.acts_as_taggable_on_engine.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This migration comes from acts_as_taggable_on_engine (originally 5) 4 | # This migration is added to circumvent issue #623 and have special characters 5 | # work properly 6 | class ChangeCollationForTagNames < ActiveRecord::Migration[6.1] 7 | # This was included in 8 | # 20210403173818_acts_as_taggable_on_migration.acts_as_taggable_on_engine.rb 9 | 10 | # no-op 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20210502124509_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This migration comes from acts_as_taggable_on_engine (originally 6) 4 | class AddMissingIndexesOnTaggings < ActiveRecord::Migration[6.1] 5 | # This was included in 6 | # 20210403173818_acts_as_taggable_on_migration.acts_as_taggable_on_engine.rb 7 | 8 | # no-op 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20210629083734_add_is_archived_to_topic.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddIsArchivedToTopic < ActiveRecord::Migration[6.1] 4 | def change 5 | change_table :topics do |t| 6 | t.boolean :is_archived, default: false 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20210629083736_add_is_archived_to_comment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddIsArchivedToComment < ActiveRecord::Migration[6.1] 4 | def change 5 | change_table :comments do |t| 6 | t.boolean :is_archived, default: false 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20220113140839_add_last_login_to_user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddLastLoginToUser < ActiveRecord::Migration[7.0] 4 | def change 5 | add_column :users, :last_login, :datetime, default: nil, null: true 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20220421111001_drop_team_subscriptions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class DropTeamSubscriptions < ActiveRecord::Migration[7.0] 4 | def change 5 | drop_table :team_subscriptions do |t| 6 | t.boolean :active, default: false, null: false 7 | 8 | t.references :team, foreign_key: true, null: false, index: { unique: true } 9 | 10 | t.timestamps 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /docs/images/asyncgo_topics.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/async-go/asyncgo/b4486310ce3d44755bc6411c8a8ba32457e3eb85/docs/images/asyncgo_topics.png -------------------------------------------------------------------------------- /docs/images/atmentions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/async-go/asyncgo/b4486310ce3d44755bc6411c8a8ba32457e3eb85/docs/images/atmentions.png -------------------------------------------------------------------------------- /docs/images/basicfunctions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/async-go/asyncgo/b4486310ce3d44755bc6411c8a8ba32457e3eb85/docs/images/basicfunctions.png -------------------------------------------------------------------------------- /docs/images/duedate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/async-go/asyncgo/b4486310ce3d44755bc6411c8a8ba32457e3eb85/docs/images/duedate.png -------------------------------------------------------------------------------- /docs/images/fluid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/async-go/asyncgo/b4486310ce3d44755bc6411c8a8ba32457e3eb85/docs/images/fluid.png -------------------------------------------------------------------------------- /docs/images/gravatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/async-go/asyncgo/b4486310ce3d44755bc6411c8a8ba32457e3eb85/docs/images/gravatar.png -------------------------------------------------------------------------------- /docs/images/labels.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/async-go/asyncgo/b4486310ce3d44755bc6411c8a8ba32457e3eb85/docs/images/labels.png -------------------------------------------------------------------------------- /docs/images/markdown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/async-go/asyncgo/b4486310ce3d44755bc6411c8a8ba32457e3eb85/docs/images/markdown.png -------------------------------------------------------------------------------- /docs/images/notifications.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/async-go/asyncgo/b4486310ce3d44755bc6411c8a8ba32457e3eb85/docs/images/notifications.png -------------------------------------------------------------------------------- /docs/images/participants.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/async-go/asyncgo/b4486310ce3d44755bc6411c8a8ba32457e3eb85/docs/images/participants.png -------------------------------------------------------------------------------- /docs/images/pin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/async-go/asyncgo/b4486310ce3d44755bc6411c8a8ba32457e3eb85/docs/images/pin.png -------------------------------------------------------------------------------- /docs/images/teammessage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/async-go/asyncgo/b4486310ce3d44755bc6411c8a8ba32457e3eb85/docs/images/teammessage.png -------------------------------------------------------------------------------- /docs/images/votes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/async-go/asyncgo/b4486310ce3d44755bc6411c8a8ba32457e3eb85/docs/images/votes.png -------------------------------------------------------------------------------- /docs/integrations.md: -------------------------------------------------------------------------------- 1 | [Docs Home](index.md) | [Integrations](integrations.md) | [Markdown](markdown.md) | [Teams](teams.md) | [Topics](topics.md) | [User Settings](usersettings.md) 2 | 3 | # Integrations 4 | 5 | AsyncGo also includes integrations with other apps to make it easier to 6 | integrate with your workflow. 7 | 8 | ## Browser Extensions 9 | 10 | We offer browser extensions to make it easy to create topics from anywhere. 11 | Right click on any page to create a topic with a reference back to that page, or 12 | highlight some text and bring that over as well. 13 | 14 | Our extensions are [open source](https://github.com/async-go/extensions). 15 | 16 | ## Authorization Providers 17 | 18 | You can use several different accounts to log in to AsyncGo. We allow both 19 | personal and business accounts to log in, but you'll need to ensure the email 20 | address of the account you log in with matches the one you were invited with. 21 | 22 | - Microsoft 23 | - Google 24 | - GitHub 25 | - Slack 26 | 27 | Note that your administrator can choose to limit authorization to external apps 28 | within your organization. If that's the case you can contact them to allow 29 | AsyncGo. 30 | -------------------------------------------------------------------------------- /docs/markdown.md: -------------------------------------------------------------------------------- 1 | [Docs Home](index.md) | [Integrations](integrations.md) | [Markdown](markdown.md) | [Teams](teams.md) | [Topics](topics.md) | [User Settings](usersettings.md) 2 | 3 | # Markdown 4 | 5 | AsyncGo uses [GFM markdown](https://github.github.com/gfm/) for rich content, 6 | and supports all tags listed in their documentation. There are a few common 7 | keywords used to support discussions which are highlighted here. 8 | 9 | ## Checklists 10 | 11 | It's very helpful to use checklists to define the set of things you're going to 12 | discuss, and then you can mark them off as you go. Checklists are entered as 13 | follows: 14 | 15 | ```markdown 16 | - [ ] An unchecked checkbox 17 | - [x] A checked checkbox 18 | ``` 19 | 20 | ## Bullet Lists 21 | 22 | You can create bulleted lists using the following format: 23 | 24 | ```markdown 25 | - Unordered item 1 26 | - Unordered item 2 27 | 28 | 1. Item 1 29 | 2. Item 2 30 | ``` 31 | 32 | Sub-bullets are also possible, in that case you should use two or four spaces to 33 | indicate the next level of indent. You can also mix and match bullet types in 34 | this way. 35 | 36 | ```markdown 37 | - Unordered item 1 38 | - Unordered item 2 39 | 1. Sub-item 1 40 | 2. Sub-item 2 41 | - Unordered item 3 42 | - Sub-item 1 43 | - Sub-sub item 1 44 | - Sub-sub item 2 45 | - Sub-item 2 46 | ``` 47 | 48 | ## Links 49 | 50 | Links can be added as follows: 51 | 52 | ```markdown 53 | [Link description](https://www.google.com) 54 | ``` 55 | 56 | ## Images 57 | 58 | Images are added similarly: 59 | 60 | ```markdown 61 | ![foo](/url "https://www.google.com/with-my-image.png") 62 | ``` 63 | 64 | ## Videos 65 | 66 | Adding videos is done by adding images with links. Since it's not possible to 67 | embed the video directly, you show the video thumbnail instead as an image and 68 | then link to the video. With YouTube for example you would replace `VIDEO_ID` 69 | below with the actual video ID: 70 | 71 | ```markdown 72 | [![IMAGE ALT TEXT HERE](https://img.youtube.com/vi/VIDEO_ID/0.jpg)](https://www.youtube.com/watch?v=VIDEO_ID) 73 | ``` 74 | -------------------------------------------------------------------------------- /docs/teams.md: -------------------------------------------------------------------------------- 1 | [Docs Home](index.md) | [Integrations](integrations.md) | [Markdown](markdown.md) | [Teams](teams.md) | [Topics](topics.md) | [User Settings](usersettings.md) 2 | 3 | # Teams 4 | 5 | Teams in AsyncGo represent one group of users who collaborate with each other. 6 | 7 | ## Managing Teams 8 | 9 | From the Teams page you are able to add and remove users. A few important notes: 10 | 11 | - Users must be individually invited from the Teams page 12 | - There are no limitations on domain name as to who can be invited to the team 13 | - An individual user, as defined by one email address, may only be a member of 14 | one team 15 | 16 | ## Setting a Team message 17 | 18 | It's possible to set a team message that will be shown from the main topic list 19 | page. The team message is intended to show a daily or weekly update for the 20 | team, such as the priorities for that week, holidays, different people being 21 | unavailable, or other updates that might impact how the team interacts with the 22 | open topics in a general sense. 23 | 24 | ![Team Message](./images/teammessage.png) 25 | -------------------------------------------------------------------------------- /docs/usersettings.md: -------------------------------------------------------------------------------- 1 | [Docs Home](index.md) | [Integrations](integrations.md) | [Markdown](markdown.md) | [Teams](teams.md) | [Topics](topics.md) | [User Settings](usersettings.md) 2 | 3 | # User Settings 4 | 5 | AsyncGo includes several user settings that you can use to configure how the app 6 | looks for you. 7 | 8 | ## Notifications 9 | 10 | If you are watching any topics then notifications will start to appear in your 11 | notifications list whenever someone updates or comments in a topic you are 12 | watching. Click on the notification bell at the top of any page and you can view 13 | all the open notifications you have. From here you can also clear all 14 | notifications. 15 | 16 | You can watch any topic from the view topic page, by clicking on the button to 17 | start watching. 18 | 19 | ![Notifications](./images/notifications.png) 20 | 21 | ## Fluid Mode 22 | 23 | Fluid mode will expand the area for displaying content to fill up your whole 24 | screen. You can use it if you prefer to see as much of the content on screen at 25 | once as possible. 26 | 27 | ![Fluid Mode](./images/fluid.png) 28 | 29 | ## Comment Order 30 | 31 | You can choose between comments within a topic being sorted oldest to newest, or 32 | newest to oldest depending on how you prefer to view them. The box to add a 33 | comment will reposition to the top or bottom of the list, depending on your 34 | preference. 35 | 36 | ## Avatar 37 | 38 | AsyncGo shows your avatar from [Gravatar](https://www.gravatar.com). You can 39 | update your picture there, and it will automatically be reflected here. 40 | 41 | ![Gravatars](./images/gravatar.png) 42 | -------------------------------------------------------------------------------- /lib/assets/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/async-go/asyncgo/b4486310ce3d44755bc6411c8a8ba32457e3eb85/lib/assets/.keep -------------------------------------------------------------------------------- /lib/asyncgo_tag_parser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AsyncgoTagParser < ActsAsTaggableOn::GenericParser 4 | TAG_DELIMITERS = [',', ' '].freeze 5 | 6 | def parse 7 | ActsAsTaggableOn::TagList.new.tap do |tag_list| 8 | tag_list.add @tag_list.split(Regexp.union(TAG_DELIMITERS)) 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/tasks/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/async-go/asyncgo/b4486310ce3d44755bc6411c8a8ba32457e3eb85/lib/tasks/.keep -------------------------------------------------------------------------------- /lib/tasks/scheduler.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | desc 'Sends a digest email containing active notifications to all users' 4 | task send_digest_emails: :environment do 5 | DigestEmailSender.new.call 6 | end 7 | -------------------------------------------------------------------------------- /log/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/async-go/asyncgo/b4486310ce3d44755bc6411c8a8ba32457e3eb85/log/.keep -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "asyncgo", 3 | "private": true, 4 | "dependencies": { 5 | "@github/hotkey": "2.0.0", 6 | "@hotwired/stimulus": "3.0.1", 7 | "@hotwired/stimulus-webpack-helpers": "1.0.1", 8 | "@hotwired/turbo-rails": "7.1.1", 9 | "@popperjs/core": "2.11.4", 10 | "@rails/actioncable": "7.0.2", 11 | "@rails/activestorage": "7.0.2", 12 | "@rails/ujs": "7.0.2", 13 | "@rails/webpacker": "6.0.0-rc.6", 14 | "@toast-ui/editor": "3.1.3", 15 | "babel-plugin-macros": "3.1.0", 16 | "bootstrap": "5.1.3", 17 | "codemirror": "5.65.2", 18 | "driver.js": "0.9.8", 19 | "prettier": "2.6.0", 20 | "tributejs": "5.1.3", 21 | "webpack": "5.65.0", 22 | "webpack-cli": "4.9.1" 23 | }, 24 | "version": "0.1.0", 25 | "devDependencies": { 26 | "@babel/core": "7.16.7", 27 | "@babel/eslint-parser": "7.16.5", 28 | "@webpack-cli/serve": "1.6.1", 29 | "eslint": "8.11.0", 30 | "markdownlint-cli": "0.31.1", 31 | "standard": "16.0.4", 32 | "webpack-dev-server": "4.7.4" 33 | }, 34 | "standard": { 35 | "parser": "@babel/eslint-parser" 36 | }, 37 | "babel": { 38 | "presets": [ 39 | "./node_modules/@rails/webpacker/package/babel/preset.js" 40 | ] 41 | }, 42 | "browserslist": [ 43 | "defaults", 44 | "not IE 11" 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | require('postcss-import'), 4 | require('postcss-flexbugs-fixes'), 5 | require('postcss-preset-env')({ 6 | autoprefixer: { 7 | flexbox: 'no-2009' 8 | }, 9 | stage: 3 10 | }) 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The page you were looking for doesn't exist.

62 |

You may have mistyped the address or the page may have moved.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The change you wanted was rejected.

62 |

Maybe you tried to change something you didn't have access to.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

We're sorry, but something went wrong.

62 |
63 |

If you are the application owner check the logs for more information.

64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/async-go/asyncgo/b4486310ce3d44755bc6411c8a8ba32457e3eb85/public/apple-touch-icon-precomposed.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/async-go/asyncgo/b4486310ce3d44755bc6411c8a8ba32457e3eb85/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/async-go/asyncgo/b4486310ce3d44755bc6411c8a8ba32457e3eb85/public/favicon.ico -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /spec/factories/comments.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :comment do 5 | body { Faker::Lorem.paragraph } 6 | body_html { CommonMarker.render_html(body) } 7 | 8 | association :user, :team 9 | topic { association :topic, team: user.team } 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/factories/notifications.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :notification do 5 | association :user, :team 6 | action { :updated } 7 | 8 | actor { association :actor, team: user.team } 9 | target { association :target, team: user.team } 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/factories/teams.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :team do 5 | name { Faker::Company.name } 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/factories/topics.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :topic, aliases: %i[votable target] do 5 | title { Faker::Marketing.buzzwords } 6 | description { Faker::Lorem.paragraph } 7 | description_html { CommonMarker.render_html(description) } 8 | outcome_html { CommonMarker.render_html(outcome.to_s).presence } 9 | pinned { false } 10 | 11 | team 12 | user { association :user, team: } 13 | 14 | subscribed_users { [user] } 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/factories/users.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :user, aliases: [:actor] do 5 | email { Faker::Internet.email } 6 | 7 | trait :team do 8 | team 9 | end 10 | 11 | trait :name do 12 | name { Faker::Name.name } 13 | end 14 | 15 | preferences { User::Preferences.new(user: instance) } 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/factories/votes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :vote do 5 | emoji { Emoji.all.sample.aliases.sample } 6 | 7 | association :user, :team 8 | votable { association :votable, team: user.team } 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/helpers/application_helper_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe ApplicationHelper, type: :helper do 4 | describe '#emoji_group_text' do 5 | subject(:emoji_group_text) { helper.emoji_group_text('cat', 2) } 6 | 7 | it { is_expected.to eq("#{Emoji.find_by_alias('cat').raw} 2") } # rubocop:disable Rails/DynamicFindBy 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/helpers/users/notifications_helper_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Users::NotificationsHelper, type: :helper do 4 | describe '#notification_text' do 5 | subject(:notification_text) { helper.notification_text(notification) } 6 | 7 | let(:notification) { create(:notification) } 8 | 9 | context 'when target is topic' do 10 | before do 11 | notification.update!(target: create(:topic)) 12 | end 13 | 14 | context 'when action is updated' do 15 | before do 16 | notification.update!(action: :updated) 17 | end 18 | 19 | it { is_expected.to eq("#{notification.actor.printable_name} updated the topic #{notification.target.title}") } 20 | end 21 | 22 | context 'when action is created' do 23 | before do 24 | notification.update!(action: :created) 25 | end 26 | 27 | it { is_expected.to eq("#{notification.actor.printable_name} created the topic #{notification.target.title}") } 28 | end 29 | 30 | context 'when action is expiring' do 31 | before do 32 | notification.update!(action: :expiring) 33 | end 34 | 35 | it { is_expected.to eq("The topic #{notification.target.title} is due in less than one day.") } 36 | end 37 | 38 | context 'when action is mentioned' do 39 | before do 40 | notification.update!(action: :mentioned) 41 | end 42 | 43 | it do 44 | expect(notification_text).to eq( 45 | "#{notification.actor.printable_name} mentioned you in the topic #{notification.target.title}" 46 | ) 47 | end 48 | end 49 | end 50 | 51 | context 'when target is comment' do 52 | before do 53 | notification.update!(target: create(:comment)) 54 | end 55 | 56 | context 'when action is created' do 57 | before do 58 | notification.update!(action: :created) 59 | end 60 | 61 | it do 62 | expect(notification_text).to eq( 63 | "#{notification.actor.printable_name} created a comment in the topic #{notification.target.topic.title}" 64 | ) 65 | end 66 | end 67 | 68 | context 'when action is mentioned' do 69 | before do 70 | notification.update!(action: :mentioned) 71 | end 72 | 73 | it do 74 | expect(notification_text).to eq( 75 | <<-NOTIFICATION_TEXT.squish 76 | #{notification.actor.printable_name} 77 | mentioned you in a comment in the topic 78 | #{notification.target.topic.title} 79 | NOTIFICATION_TEXT 80 | ) 81 | end 82 | end 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /spec/mailers/digest_mailer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe DigestMailer, type: :mailer do 4 | let(:notification) { create(:notification) } 5 | 6 | describe '#digest_email' do 7 | subject(:digest_email) { described_class.with(user: notification.user).digest_email } 8 | 9 | it 'renders the headers' do 10 | expect(digest_email).to have_attributes( 11 | subject: 'Your AsyncGo Digest', 12 | from: ['notifications@asyncgo.com'], 13 | to: [notification.user.email] 14 | ) 15 | end 16 | 17 | it 'renders the body' do 18 | expect(digest_email.body.encoded).to include("Here's your AsyncGo Digest") 19 | end 20 | 21 | it 'contains the notification' do 22 | expect(digest_email.body.encoded).to include(notification.actor.email) 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/mailers/previews/digest_mailer_preview.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'factory_bot' 4 | 5 | # Preview all emails at http://localhost:3000/rails/mailers/digest_mailer 6 | class DigestMailerPreview < ActionMailer::Preview 7 | def digest_email 8 | seed_data 9 | 10 | DigestMailer.with(user: User.find(1001)).digest_email.message.tap do 11 | Team.find(1001).destroy 12 | User.find(1001).destroy 13 | User.find(2001).destroy 14 | end 15 | end 16 | 17 | private 18 | 19 | def seed_data 20 | team = Team.create!(id: 1001, name: 'sample team') 21 | 22 | user = User.new(id: 1001, email: 'digestmailer-user@preview.com', name: 'Bob Test', team:) 23 | user.update!(preferences: User::Preferences.new(user:)) 24 | 25 | actor = User.new(id: 2001, email: 'digestmailer-actor@preview.com', name: 'Actor Sample', team:) 26 | actor.update!(preferences: User::Preferences.new(user: actor)) 27 | 28 | topic = Topic.create!(id: 1001, title: 'Test Topic', team:, user:, status: :resolved, 29 | description: 'Hello', description_html: '

Hello

') 30 | 31 | Notification.create!(id: 1001, user:, actor:, target: topic, action: 'created') 32 | Topic.create!(id: 2001, title: 'Due Topic', team:, user:, status: :active, due_date: Time.zone.today, 33 | description: 'Hello', description_html: '

Hello

') 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/mailers/previews/support_mailer_preview.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class SupportMailerPreview < ActionMailer::Preview 4 | def support_email 5 | team = Team.new(id: 1, name: 'example team') 6 | user = User.new(id: 1, email: 'test@example.com', team:) 7 | body = 'Sample body' 8 | 9 | SupportMailer.with(user:, body:).support_email 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/mailers/previews/user_mailer_preview.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class UserMailerPreview < ActionMailer::Preview 4 | def welcome_email 5 | user = User.new(email: 'test@example.com') 6 | user.team = Team.new(name: 'example team') 7 | 8 | UserMailer.with(user:).welcome_email 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/mailers/support_mailer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe SupportMailer, type: :mailer do 4 | let(:user) { create(:user, :team) } 5 | let(:body) { 'Sample body contents' } 6 | 7 | describe '#support_email' do 8 | subject(:support_email) { described_class.with(user:, body:).support_email } 9 | 10 | it 'renders the headers' do 11 | expect(support_email).to have_attributes( 12 | subject: "Support request: #{user.team.name}", 13 | from: [user.email], 14 | to: ['support@asyncgo.com'] 15 | ) 16 | end 17 | 18 | it 'renders the body' do 19 | expect(support_email.body.encoded).to include(body) 20 | end 21 | 22 | it 'shows the team name' do 23 | expect(support_email.body.encoded).to include("Name: #{user.team.name}") 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/mailers/user_mailer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe UserMailer, type: :mailer do 4 | let(:user) { create(:user, :team) } 5 | 6 | describe '#welcome_email' do 7 | subject(:welcome_email) { described_class.with(user:).welcome_email } 8 | 9 | it 'renders the headers' do 10 | expect(welcome_email).to have_attributes( 11 | subject: 'Welcome to AsyncGo', 12 | from: ['notifications@asyncgo.com'], 13 | to: [user.email] 14 | ) 15 | end 16 | 17 | it 'renders the body' do 18 | expect(welcome_email.body.encoded).to include('You have been invited') 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/models/comment_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Comment, type: :model do 4 | describe 'Validations' do 5 | it { is_expected.to validate_presence_of(:body) } 6 | it { is_expected.to validate_presence_of(:body_html) } 7 | 8 | describe 'body image data' do 9 | subject(:valid?) { comment.valid? } 10 | 11 | let(:comment) { build(:comment, body:) } 12 | 13 | context 'when body does not have image data' do 14 | let(:body) { 'hello world' } 15 | 16 | it { is_expected.to be(true) } 17 | end 18 | 19 | context 'when body has image data' do 20 | let(:body) { '![image.png](data:image/png;base64,abcdefg)' } 21 | 22 | it { is_expected.to be(false) } 23 | 24 | it 'adds an image data error to body' do 25 | comment.valid? 26 | 27 | expect(comment.errors.first).to have_attributes(attribute: :body) 28 | end 29 | end 30 | end 31 | end 32 | 33 | describe 'Relations' do 34 | it { is_expected.to belong_to(:user) } 35 | it { is_expected.to belong_to(:topic) } 36 | it { is_expected.to have_many(:notifications) } 37 | it { is_expected.to have_many(:votes) } 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/models/notification_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Notification, type: :model do 6 | describe 'Validations' do 7 | it { is_expected.to validate_presence_of(:action) } 8 | end 9 | 10 | describe 'Relations' do 11 | it { is_expected.to belong_to(:user) } 12 | it { is_expected.to belong_to(:actor).class_name('::User') } 13 | it { is_expected.to belong_to(:target) } 14 | end 15 | 16 | it { is_expected.to define_enum_for(:action).with_values(%i[created updated expiring mentioned resolved reopened]) } 17 | end 18 | -------------------------------------------------------------------------------- /spec/models/subscription_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Subscription, type: :model do 4 | describe 'Validations' do 5 | before do 6 | user = create(:user, :team) 7 | topic = create(:topic, team: user.team) 8 | described_class.create!(user:, topic:) 9 | end 10 | 11 | it { is_expected.to validate_uniqueness_of(:topic_id).scoped_to(:user_id) } 12 | end 13 | 14 | describe 'Relations' do 15 | it { is_expected.to belong_to(:user) } 16 | it { is_expected.to belong_to(:topic) } 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/models/team_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Team, type: :model do 4 | describe 'Validations' do 5 | it { is_expected.to validate_presence_of(:name) } 6 | 7 | it { is_expected.not_to allow_value('Team name :').for(:name) } 8 | it { is_expected.not_to allow_value('Team name /').for(:name) } 9 | it { is_expected.not_to allow_value('Team name \\').for(:name) } 10 | end 11 | 12 | describe 'Relations' do 13 | it { is_expected.to have_many(:users).dependent(:nullify) } 14 | it { is_expected.to have_many(:topics).dependent(:destroy) } 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/models/user/preferences_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe User::Preferences, type: :model do 4 | describe 'Validations' do 5 | before do 6 | create(:user) 7 | end 8 | 9 | it { is_expected.to validate_presence_of(:user_id) } 10 | it { is_expected.to validate_uniqueness_of(:user_id) } 11 | end 12 | 13 | describe 'Relations' do 14 | it { is_expected.to belong_to(:user) } 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/models/vote_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Vote, type: :model do 6 | describe 'Validations' do 7 | it { is_expected.to validate_presence_of(:emoji) } 8 | end 9 | 10 | describe 'Relations' do 11 | it { is_expected.to belong_to(:user) } 12 | it { is_expected.to belong_to(:votable) } 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/requests/extension_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require './spec/support/unauthorized_user_examples' 4 | 5 | RSpec.describe ExtensionController, type: :request do 6 | describe 'GET new_topic' do 7 | subject(:get_new_topic) do 8 | get '/extension/new_topic', params: { selection: 'Hello', context: 'https://www.google.com' } 9 | end 10 | 11 | let(:user) { create(:user) } 12 | 13 | before do 14 | sign_in(user) 15 | end 16 | 17 | context 'when user is authorized' do 18 | before do 19 | user.update!(team: create(:team)) 20 | end 21 | 22 | it 'persists the selection and context query parameters' do 23 | get_new_topic 24 | 25 | expect(response).to redirect_to( 26 | new_team_topic_path(user.team, params: { selection: 'Hello', context: 'https://www.google.com' }) 27 | ) 28 | end 29 | end 30 | 31 | include_examples 'unauthorized user examples' 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/requests/home_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe HomeController, type: :request do 4 | describe 'GET index' do 5 | subject(:get_index) { get '/' } 6 | 7 | it 'renders the home page' do 8 | get_index 9 | 10 | expect(response.body).to include('Welcome to AsyncGo') 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/requests/sessions_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require './spec/support/sign_in_out_request_helpers' 4 | 5 | RSpec.describe SessionsController, type: :request do 6 | include SignInOutRequestHelpers 7 | 8 | describe 'DELETE destroy' do 9 | subject(:delete_destroy) { delete '/sign_out' } 10 | 11 | let(:user) { create(:user) } 12 | 13 | context 'when user is signed in' do 14 | before do 15 | sign_in(user) 16 | end 17 | 18 | it 'logs the user out' do 19 | expect { delete_destroy }.to change { controller.send(:current_user) }.from(user).to(nil) 20 | end 21 | 22 | it 'sets the flash' do 23 | delete_destroy 24 | 25 | expect(controller.flash[:success]).to eq('You have been signed out.') 26 | end 27 | 28 | it 'redirects to root path' do 29 | expect(delete_destroy).to redirect_to(root_path) 30 | end 31 | end 32 | 33 | context 'when the user is not signed in' do 34 | it 'sets the flash' do 35 | delete_destroy 36 | 37 | expect(controller.flash[:success]).to eq('You have been signed out.') 38 | end 39 | 40 | it 'redirects to root path' do 41 | expect(delete_destroy).to redirect_to(root_path) 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/requests/teams/topics/comments/votes_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require './spec/support/unauthorized_user_examples' 4 | 5 | RSpec.describe Teams::Topics::Comments::VotesController, type: :request do 6 | let(:comment) { create(:comment) } 7 | 8 | describe 'POST create' do 9 | subject(:post_create) do 10 | post "/teams/#{comment.topic.team.id}/topics/#{comment.topic.id}/comments/#{comment.id}/votes", 11 | params: { vote: { emoji: } } 12 | end 13 | 14 | context 'when user is authorized' do 15 | before do 16 | sign_in(create(:user, team: comment.topic.team)) 17 | end 18 | 19 | context 'when vote is valid' do 20 | let(:emoji) { 'cat' } 21 | 22 | it 'creates the vote' do 23 | expect { post_create }.to change(Vote, :count).from(0).to(1) 24 | end 25 | 26 | it 'adds the vote' do 27 | post_create 28 | 29 | expect(Vote.last).to have_attributes(votable_id: comment.id, emoji:) 30 | end 31 | 32 | it 'sets the flash' do 33 | post_create 34 | 35 | expect(controller.flash[:success]).to eq('Vote was successfully added.') 36 | end 37 | 38 | it 'redirects to topic' do 39 | expect(post_create).to redirect_to(team_topic_path(comment.topic.team, comment.topic)) 40 | end 41 | end 42 | 43 | context 'when vote is not valid' do 44 | let(:emoji) { 'notanemoji' } 45 | 46 | it 'does not create the vote' do 47 | expect { post_create }.not_to change(Vote, :count).from(0) 48 | end 49 | 50 | it 'sets the flash' do 51 | post_create 52 | 53 | expect(controller.flash[:danger]).to eq('There was an error while adding the vote.') 54 | end 55 | 56 | it 'redirects to topic' do 57 | expect(post_create).to redirect_to(team_topic_path(comment.topic.team, comment.topic)) 58 | end 59 | end 60 | end 61 | 62 | include_examples 'unauthorized user examples' do 63 | let(:emoji) { 'cat' } 64 | end 65 | end 66 | 67 | describe 'DELETE destroy' do 68 | subject(:delete_destroy) do 69 | delete "/teams/#{comment.topic.team.id}/topics/#{comment.topic.id}/comments/#{comment.id}/votes/#{vote.id}" 70 | end 71 | 72 | let(:vote) { create(:vote, votable: comment) } 73 | 74 | context 'when user is authorized' do 75 | before do 76 | sign_in(vote.user) 77 | end 78 | 79 | it 'removes the vote' do 80 | expect { delete_destroy }.to change { Vote.find_by(id: vote.id) }.from(vote).to(nil) 81 | end 82 | 83 | it 'sets the flash' do 84 | delete_destroy 85 | 86 | expect(controller.flash[:success]).to eq('Vote was successfully removed.') 87 | end 88 | 89 | it 'redirects to topic' do 90 | expect(delete_destroy).to redirect_to(team_topic_path(comment.topic.team, comment.topic)) 91 | end 92 | end 93 | 94 | include_examples 'unauthorized user examples' 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /spec/requests/teams/topics/votes_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require './spec/support/unauthorized_user_examples' 4 | 5 | RSpec.describe Teams::Topics::VotesController, type: :request do 6 | let(:topic) { create(:topic) } 7 | 8 | describe 'POST create' do 9 | subject(:post_create) do 10 | post "/teams/#{topic.team.id}/topics/#{topic.id}/votes", params: { vote: { emoji: } } 11 | end 12 | 13 | context 'when user is authorized' do 14 | before do 15 | sign_in(create(:user, team: topic.team)) 16 | end 17 | 18 | context 'when vote is valid' do 19 | let(:emoji) { 'cat' } 20 | 21 | it 'creates the vote' do 22 | expect { post_create }.to change(Vote, :count).from(0).to(1) 23 | end 24 | 25 | it 'adds the vote' do 26 | post_create 27 | 28 | expect(Vote.last).to have_attributes(votable_id: topic.id, emoji:) 29 | end 30 | 31 | it 'sets the flash' do 32 | post_create 33 | 34 | expect(controller.flash[:success]).to eq('Vote was successfully added.') 35 | end 36 | 37 | it 'redirects to topic page' do 38 | expect(post_create).to redirect_to(team_topic_path(topic.team, topic)) 39 | end 40 | end 41 | 42 | context 'when vote is not valid' do 43 | let(:emoji) { 'notanemoji' } 44 | 45 | it 'does not create the vote' do 46 | expect { post_create }.not_to change(Vote, :count).from(0) 47 | end 48 | 49 | it 'sets the flash' do 50 | post_create 51 | 52 | expect(controller.flash[:danger]).to eq('There was an error while adding the vote.') 53 | end 54 | 55 | it 'redirects to topic page' do 56 | expect(post_create).to redirect_to(team_topic_path(topic.team, topic)) 57 | end 58 | end 59 | end 60 | 61 | include_examples 'unauthorized user examples' do 62 | let(:emoji) { 'cat' } 63 | end 64 | end 65 | 66 | describe 'DELETE destroy' do 67 | subject(:delete_destroy) { delete "/teams/#{topic.team.id}/topics/#{topic.id}/votes/#{vote.id}" } 68 | 69 | let(:vote) { create(:vote, votable: topic) } 70 | 71 | context 'when user is authorized' do 72 | before do 73 | sign_in(vote.user) 74 | end 75 | 76 | it 'removes the vote' do 77 | expect { delete_destroy }.to change { Vote.find_by(id: vote.id) }.from(vote).to(nil) 78 | end 79 | 80 | it 'sets the flash' do 81 | delete_destroy 82 | 83 | expect(controller.flash[:success]).to eq('Vote was successfully removed.') 84 | end 85 | 86 | it 'redirects to topic page' do 87 | expect(delete_destroy).to redirect_to(team_topic_path(topic.team, topic)) 88 | end 89 | end 90 | 91 | include_examples 'unauthorized user examples' 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /spec/requests/users/preferences_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require './spec/support/unauthorized_user_examples' 4 | 5 | RSpec.describe Users::PreferencesController, type: :request do 6 | describe 'PATCH update' do 7 | subject(:patch_update) do 8 | patch "/users/#{user.id}/preferences", params: { user_preferences: preferences_params } 9 | end 10 | 11 | let(:user) { create(:user) } 12 | 13 | context 'when user is authorized' do 14 | before do 15 | sign_in(user) 16 | end 17 | 18 | context 'when updating digest preferences' do 19 | let(:preferences_params) { { digest_enabled: 'false' } } 20 | 21 | it 'updates the digest preferences' do 22 | expect { patch_update }.to change { user.preferences.reload.digest_enabled }.from(true).to(false) 23 | end 24 | 25 | it 'redirects to edit user' do 26 | patch_update 27 | 28 | expect(response).to redirect_to(edit_user_path(user)) 29 | end 30 | end 31 | 32 | context 'when updating layout preferences' do 33 | let(:preferences_params) { { fluid_layout: 'true' } } 34 | 35 | it 'updates the layout preferences' do 36 | expect { patch_update }.to change { user.preferences.reload.fluid_layout }.from(false).to(true) 37 | end 38 | 39 | it 'redirects to edit user' do 40 | patch_update 41 | 42 | expect(response).to redirect_to(edit_user_path(user)) 43 | end 44 | end 45 | end 46 | 47 | include_examples 'unauthorized user examples' do 48 | let(:preferences_params) { { digest_enabled: 'false' } } 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /spec/requests/users_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require './spec/support/unauthorized_user_examples' 4 | 5 | RSpec.describe UsersController, type: :request do 6 | describe 'GET edit' do 7 | subject(:get_edit) { get "/users/#{user.id}/edit" } 8 | 9 | let(:user) { create(:user) } 10 | 11 | context 'when user is authorized' do 12 | before do 13 | sign_in(user) 14 | end 15 | 16 | it 'renders the edit page' do 17 | get_edit 18 | 19 | expect(response.body).to include(CGI.escapeHTML(user.printable_name)) 20 | end 21 | end 22 | 23 | include_examples 'unauthorized user examples' 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/services/digest_email_sender_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe DigestEmailSender, type: :service do 4 | let!(:user) { create(:user, :team) } 5 | 6 | let(:recently_resolved_topic) do 7 | create(:topic, status: :resolved, updated_at: 4.hours.ago, user:, team: user.team) 8 | end 9 | let(:unread_notification) do 10 | create(:notification, user:, actor: user, target: recently_resolved_topic) 11 | end 12 | 13 | before do 14 | ActiveJob::Base.queue_adapter = :test 15 | end 16 | 17 | describe '#call' do 18 | subject(:call) { described_class.new.call } 19 | 20 | describe 'when users have never logged in before' do 21 | it 'never creates digests' do 22 | unread_notification 23 | 24 | expect { call }.not_to have_enqueued_mail(DigestMailer, :digest_email) 25 | end 26 | end 27 | 28 | describe 'when users have logged in before' do 29 | before do 30 | user.update!(last_login: Time.zone.now) 31 | end 32 | 33 | it 'does not create digests for users that disabled it' do 34 | unread_notification 35 | user.preferences.update!(digest_enabled: false) 36 | 37 | expect { call }.not_to have_enqueued_mail(DigestMailer, :digest_email) 38 | end 39 | 40 | it 'does not create digest when there are no notifications or topics' do 41 | expect { call }.not_to have_enqueued_mail(DigestMailer, :digest_email) 42 | end 43 | 44 | it 'creates digest when there are recently resolved topics' do 45 | recently_resolved_topic 46 | 47 | expect { call }.to have_enqueued_mail(DigestMailer, :digest_email).with( 48 | a_hash_including(params: { user: }) 49 | ) 50 | end 51 | 52 | it 'creates digest when there are unread notifications' do 53 | unread_notification 54 | 55 | expect { call }.to have_enqueued_mail(DigestMailer, :digest_email).with( 56 | a_hash_including(params: { user: }) 57 | ) 58 | end 59 | 60 | it 'creates digest when there are notifications and topics' do 61 | recently_resolved_topic 62 | unread_notification 63 | 64 | expect { call }.to have_enqueued_mail(DigestMailer, :digest_email).with( 65 | a_hash_including(params: { user: }) 66 | ) 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /spec/support/factory_bot.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.configure do |config| 4 | config.include FactoryBot::Syntax::Methods 5 | end 6 | -------------------------------------------------------------------------------- /spec/support/sign_in_out_request_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SignInOutRequestHelpers 4 | def sign_in(user) 5 | OmniAuth.config.add_mock(:google_oauth2, 6 | info: { email: user.email, 7 | name: user.name }) 8 | post '/auth/google_oauth2' 9 | # This is a redirect to the callback controller 10 | follow_redirect! 11 | # This is a redirect back to the referrer path 12 | follow_redirect! 13 | end 14 | 15 | def sign_out 16 | delete '/sign_out' 17 | follow_redirect! 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/support/sign_in_out_system_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SignInOutSystemHelpers 4 | def sign_in_user(user = nil) 5 | (user || FactoryBot.create(:user)).tap do |active_user| 6 | OmniAuth.config.add_mock(:google_oauth2, 7 | info: { email: active_user.email, 8 | name: active_user.name }) 9 | click_button('Google') 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/support/unauthenticated_user_examples.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require './spec/support/sign_in_out_request_helpers' 4 | 5 | RSpec.shared_examples 'unauthenticated user examples' do 6 | include SignInOutRequestHelpers 7 | 8 | context 'when user is not authenticated' do 9 | it 'sets the alert flash' do 10 | subject 11 | follow_redirect! 12 | 13 | expect(controller.flash[:warning]).to eq('You are not authorized.') 14 | end 15 | 16 | it 'redirects the user back (to root)' do 17 | expect(subject).to redirect_to(root_path) 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/support/unauthorized_user_examples.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require './spec/support/unauthenticated_user_examples' 4 | require './spec/support/sign_in_out_request_helpers' 5 | 6 | RSpec.shared_examples 'unauthorized user examples' do 7 | include SignInOutRequestHelpers 8 | 9 | include_examples 'unauthenticated user examples' 10 | 11 | context 'when user is authenticated but not authorized' do 12 | before do 13 | sign_in(create(:user)) 14 | end 15 | 16 | it 'sets the alert flash' do 17 | subject 18 | follow_redirect! 19 | 20 | expect(controller.flash[:warning]).to eq('You are not authorized.') 21 | end 22 | 23 | it 'redirects the user back (to root)' do 24 | expect(subject).to redirect_to(root_path) 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/system/accessibility_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require './spec/support/sign_in_out_system_helpers' 4 | 5 | RSpec.describe 'Accessibility', type: :system do 6 | include SignInOutSystemHelpers 7 | 8 | it 'makes sure homepage is accessible' do 9 | visit '/' 10 | 11 | expect(page).to be_axe_clean.skipping('color-contrast') 12 | end 13 | 14 | it 'makes sure new topic is accessible' do 15 | team = create(:team) 16 | user = create(:user, team:) 17 | 18 | visit '/' 19 | sign_in_user(user) 20 | click_link 'Topics' 21 | click_link 'New Topic' 22 | 23 | expect(page).to have_button 'Create' 24 | expect(page).to be_axe_clean.skipping('button-name') 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/system/authentication_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require './spec/support/sign_in_out_system_helpers' 4 | 5 | RSpec.describe 'Authentication', type: :system do 6 | include SignInOutSystemHelpers 7 | 8 | it 'allows user creation' do 9 | visit '/' 10 | 11 | OmniAuth.config.add_mock(:google_oauth2, 12 | info: { email: 'test@example.com', 13 | name: 'Test Person' }) 14 | click_button('Google') 15 | expect(page).to have_link('Sign out') 16 | end 17 | 18 | it 'allows signing out' do 19 | visit '/' 20 | 21 | sign_in_user 22 | 23 | expect(page).to have_link('Sign out') 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/system/homepage_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Homepage', type: :system do 4 | it 'shows the homepage' do 5 | visit '/' 6 | 7 | expect(page).to have_text('Welcome to AsyncGo') 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/system/pagination_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require './spec/support/sign_in_out_system_helpers' 4 | 5 | RSpec.describe 'Pagination', type: :system do 6 | include SignInOutSystemHelpers 7 | 8 | it 'paginates active topics' do 9 | user = create(:user, :team) 10 | create_list( 11 | :topic, 20, user:, team: user.team, status: :active, 12 | due_date: Date.new(2020, 1, 1) 13 | ) 14 | create( 15 | :topic, user:, team: user.team, status: :active, 16 | due_date: Date.new(2021, 1, 1), title: 'thisisthelasttopic' 17 | ) 18 | 19 | visit '/' 20 | sign_in_user(user) 21 | 22 | click_link 'Topics' 23 | 24 | expect(page).not_to have_text('thisisthelasttopic') 25 | click_link '2' 26 | expect(page).to have_text('thisisthelasttopic') 27 | end 28 | 29 | it 'paginates resolved topics' do 30 | user = create(:user, :team) 31 | create_list( 32 | :topic, 20, user:, team: user.team, status: :resolved, 33 | updated_at: Date.new(2021, 1, 1) 34 | ) 35 | create( 36 | :topic, user:, team: user.team, status: :resolved, 37 | updated_at: Date.new(2020, 1, 1), title: 'thisisthelasttopic' 38 | ) 39 | 40 | visit '/' 41 | sign_in_user(user) 42 | 43 | click_link 'Topics' 44 | 45 | expect(page).not_to have_text('thisisthelasttopic') 46 | click_link '2' 47 | expect(page).to have_text('thisisthelasttopic') 48 | end 49 | 50 | it 'paginates notifications' do 51 | user = create(:user, :team) 52 | create_list(:notification, 20, user:, action: :updated) 53 | notification = create(:notification, user:, action: :created) 54 | 55 | visit '/' 56 | sign_in_user(user) 57 | 58 | click_link '21' 59 | 60 | expect(page).not_to have_text(notification.action) 61 | click_link 'Next' 62 | expect(page).to have_text(notification.action) 63 | end 64 | 65 | it 'paginates user members' do 66 | user = create(:user, :team) 67 | create_list( 68 | :user, 20, team: user.team, created_at: Date.new(2020, 1, 1) 69 | ) 70 | create( 71 | :user, team: user.team, email: 'thisisthelastuser@example.com', 72 | created_at: Date.new(2021, 1, 1) 73 | ) 74 | 75 | visit '/' 76 | sign_in_user(user) 77 | 78 | click_link 'Admin' 79 | 80 | expect(page).not_to have_text('thisisthelastuser@example.com') 81 | click_link 'Next' 82 | expect(page).to have_text('thisisthelastuser@example.com') 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /spec/system/users_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require './spec/support/sign_in_out_system_helpers' 4 | 5 | RSpec.describe 'Users', type: :system do 6 | include SignInOutSystemHelpers 7 | 8 | it 'allows the user to change digest preferences' do 9 | visit '/' 10 | sign_in_user 11 | 12 | click_link 'Profile' 13 | 14 | expect(page).to have_text('Currently subscribed: Yes') 15 | click_button 'Toggle notification status' 16 | expect(page).to have_text('Currently subscribed: No') 17 | click_button 'Toggle notification status' 18 | expect(page).to have_text('Currently subscribed: Yes') 19 | end 20 | 21 | it 'allows the user to change layout preferences' do 22 | visit '/' 23 | sign_in_user 24 | 25 | click_link 'Profile' 26 | 27 | expect(page).to have_text('Current preference: Fixed') 28 | click_button 'Toggle layout preference' 29 | expect(page).to have_text('Current preference: Fluid') 30 | click_button 'Toggle layout preference' 31 | expect(page).to have_text('Current preference: Fixed') 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/validators/image_data_validator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | ImageDataValidatable = Struct.new(:body) do 6 | include ActiveModel::Validations 7 | 8 | validates :body, image_data: true 9 | end 10 | 11 | RSpec.describe ImageDataValidator, type: :validator do 12 | it 'is valid without image data' do 13 | expect(ImageDataValidatable.new('My amazing day!')).to be_valid 14 | end 15 | 16 | it 'is invalid with image data' do 17 | expect(ImageDataValidatable.new('![image.png](data:image/png;base64,abcdefg)')).to be_invalid 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /storage/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/async-go/asyncgo/b4486310ce3d44755bc6411c8a8ba32457e3eb85/storage/.keep -------------------------------------------------------------------------------- /tmp/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/async-go/asyncgo/b4486310ce3d44755bc6411c8a8ba32457e3eb85/tmp/.keep -------------------------------------------------------------------------------- /tmp/pids/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/async-go/asyncgo/b4486310ce3d44755bc6411c8a8ba32457e3eb85/tmp/pids/.keep -------------------------------------------------------------------------------- /vendor/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/async-go/asyncgo/b4486310ce3d44755bc6411c8a8ba32457e3eb85/vendor/.keep --------------------------------------------------------------------------------