├── .circleci └── config.yml ├── .formatter.exs ├── .github └── dependabot.yml ├── .gitignore ├── .hound.yml ├── .iex.exs ├── .sample.env ├── .tool-versions ├── CONTRIBUTING.md ├── LICENSE ├── Procfile ├── README.md ├── app.json ├── assets ├── .babelrc ├── .eslintrc.json ├── .stylelintignore ├── .stylelintrc.json ├── css │ ├── _shims.scss │ ├── app.scss │ ├── components │ │ ├── _announcement-create.scss │ │ ├── _announcement-form.scss │ │ ├── _announcement.scss │ │ ├── _announcements.scss │ │ ├── _app-header.scss │ │ ├── _app-main.scss │ │ ├── _at-mention.scss │ │ ├── _box.scss │ │ ├── _checkbox-switch.scss │ │ ├── _comments.scss │ │ ├── _flash.scss │ │ ├── _flex-button-container.scss │ │ ├── _help-block.scss │ │ ├── _interest.scss │ │ ├── _page-header.scss │ │ ├── _pagination.scss │ │ ├── _tabs.scss │ │ └── _uploadable-input.scss │ ├── elements │ │ ├── _layout.scss │ │ ├── _lists.scss │ │ ├── _media.scss │ │ ├── _tables.scss │ │ └── _typography.scss │ ├── generic │ │ └── _fonts.scss │ ├── objects │ │ └── _container.scss │ ├── scopes │ │ └── _links-no-underline.scss │ ├── settings │ │ └── _variables.scss │ ├── utilities │ │ └── _font-size.scss │ └── vendor │ │ └── _selectize.scss ├── js │ ├── app.js │ ├── checkbox-switch.js │ ├── components │ │ ├── announcement-form-mobile.js │ │ ├── announcement-form.js │ │ ├── comment-form.js │ │ ├── recipients-preview.js │ │ └── user-autocomplete.js │ ├── lib │ │ ├── remote-append.js │ │ ├── syntax-highlighting.js │ │ └── textarea-image-uploader.js │ └── socket.js ├── package-lock.json ├── package.json ├── static │ ├── apple-touch-icon-114x114.png │ ├── apple-touch-icon-120x120.png │ ├── apple-touch-icon-144x144.png │ ├── apple-touch-icon-152x152.png │ ├── apple-touch-icon-180x180.png │ ├── apple-touch-icon-57x57.png │ ├── apple-touch-icon-72x72.png │ ├── apple-touch-icon-76x76.png │ ├── apple-touch-icon.png │ ├── favicon.ico │ ├── fonts │ │ ├── Inter-italic.var.woff2 │ │ └── Inter-roman.var.woff2 │ ├── images │ │ ├── icon-search.svg │ │ ├── icons.svg │ │ ├── ralph.png │ │ └── upload-progress-bar-background.png │ ├── robots.txt │ └── vendor │ │ └── textcomplete.css └── webpack.config.js ├── bin ├── clone_prod_db_to_dev ├── console ├── deploy └── setup ├── config ├── config.exs ├── dev.exs ├── locales │ └── en.exs ├── prod.exs └── test.exs ├── elixir_buildpack.config ├── lib ├── constable.ex ├── constable │ ├── announcement.ex │ ├── announcement_interest.ex │ ├── application.ex │ ├── comment.ex │ ├── env.ex │ ├── factory.ex │ ├── interest.ex │ ├── mailer.ex │ ├── markdown.ex │ ├── pact.ex │ ├── plugs │ │ ├── deslugifier.ex │ │ ├── fetch_current_user.ex │ │ ├── require_api_login.ex │ │ ├── require_web_login.ex │ │ └── set_user_id_from_params.ex │ ├── profiles.ex │ ├── pub_sub.ex │ ├── repo.ex │ ├── serializers │ │ ├── announcement.ex │ │ ├── comment.ex │ │ ├── interest.ex │ │ ├── serializers.ex │ │ ├── subscription.ex │ │ ├── user.ex │ │ └── user_interest.ex │ ├── services │ │ ├── announcement_creator.ex │ │ ├── announcement_interest_associator.ex │ │ ├── announcement_updater.ex │ │ ├── comment_creator.ex │ │ ├── customer_scrubber.ex │ │ ├── daily_digest.ex │ │ ├── email_reply_parser.ex │ │ ├── fake_google_strategy.ex │ │ ├── fake_profile_provider.ex │ │ ├── google_strategy.ex │ │ ├── hub_profile_provider.ex │ │ ├── mention_finder.ex │ │ ├── oauth_redirect_strategy.ex │ │ ├── profile_provider.ex │ │ └── slack_hook.ex │ ├── slug.ex │ ├── subscription.ex │ ├── task_supervisor.ex │ ├── time.ex │ ├── user.ex │ ├── user_identifier.ex │ └── user_interest.ex ├── constable_web.ex ├── constable_web │ ├── channels │ │ └── user_socket.ex │ ├── controller_helper.ex │ ├── controllers │ │ ├── announcement_controller.ex │ │ ├── api │ │ │ ├── announcement_controller.ex │ │ │ ├── comment_controller.ex │ │ │ ├── interest_controller.ex │ │ │ ├── search_controller.ex │ │ │ ├── subscription_controller.ex │ │ │ ├── user_controller.ex │ │ │ └── user_interest_controller.ex │ │ ├── auth_controller.ex │ │ ├── comment_controller.ex │ │ ├── email_forward_controller.ex │ │ ├── email_preview_controller.ex │ │ ├── email_reply_controller.ex │ │ ├── home_controller.ex │ │ ├── interest_controller.ex │ │ ├── recipients_preview_controller.ex │ │ ├── search_controller.ex │ │ ├── session_controller.ex │ │ ├── settings_controller.ex │ │ ├── slack_channel_controller.ex │ │ ├── subscriptions_controller.ex │ │ ├── unsubscribe_controller.ex │ │ ├── user_activation_controller.ex │ │ └── user_interest_controller.ex │ ├── emails.ex │ ├── endpoint.ex │ ├── gettext.ex │ ├── live │ │ ├── announcement_live │ │ │ └── show.ex │ │ └── interest_live.ex │ ├── router.ex │ ├── templates │ │ ├── announcement │ │ │ ├── _comment.html.eex │ │ │ ├── _form.html.eex │ │ │ ├── _unsubscribe_button.html.eex │ │ │ ├── edit.html.eex │ │ │ ├── index.html.eex │ │ │ ├── new.html.eex │ │ │ └── show.html.leex │ │ ├── announcement_list │ │ │ └── index.html.eex │ │ ├── comment │ │ │ ├── _form.html.eex │ │ │ └── edit.html.eex │ │ ├── email │ │ │ ├── author_footer.html.eex │ │ │ ├── daily_digest.html.eex │ │ │ ├── daily_digest.text.eex │ │ │ ├── new_announcement.html.eex │ │ │ ├── new_announcement.text.eex │ │ │ ├── new_comment.html.eex │ │ │ ├── new_comment.text.eex │ │ │ ├── unsubscribe_footer.html.eex │ │ │ └── unsubscribe_footer.text.eex │ │ ├── interest │ │ │ ├── index.html.leex │ │ │ ├── show.html.eex │ │ │ └── subscription.html.eex │ │ ├── layout │ │ │ ├── _flashes.html.eex │ │ │ ├── _header.html.eex │ │ │ ├── _styles.html.eex │ │ │ ├── app.html.eex │ │ │ ├── email.html.eex │ │ │ ├── live.html.leex │ │ │ ├── login.html.eex │ │ │ └── root.html.leex │ │ ├── recipients_preview │ │ │ └── recipients_preview.html.eex │ │ ├── search │ │ │ └── new.html.eex │ │ ├── session │ │ │ └── new.html.eex │ │ ├── settings │ │ │ └── show.html.eex │ │ ├── slack_channel │ │ │ └── edit.html.eex │ │ └── user_activation │ │ │ └── index.html.eex │ └── views │ │ ├── announcement_list_view.ex │ │ ├── announcement_view.ex │ │ ├── api │ │ ├── announcement_view.ex │ │ ├── comment_view.ex │ │ ├── interest_view.ex │ │ ├── subscription_view.ex │ │ ├── user_interest_view.ex │ │ └── user_view.ex │ │ ├── auth_view.ex │ │ ├── changeset_view.ex │ │ ├── comment_view.ex │ │ ├── email_view.ex │ │ ├── error_helpers.ex │ │ ├── error_view.ex │ │ ├── interest_view.ex │ │ ├── layout_view.ex │ │ ├── recipients_preview_view.ex │ │ ├── search_view.ex │ │ ├── session_view.ex │ │ ├── settings_view.ex │ │ ├── shared_view.ex │ │ ├── slack_channel_view.ex │ │ ├── subscription_view.ex │ │ └── user_activation_view.ex ├── enum_helper.ex └── mix │ └── tasks │ ├── constable.update_profile_image_urls.ex │ ├── refresh_last_discussed_at.ex │ └── send_daily_digest.ex ├── mix.exs ├── mix.lock ├── phoenix_static_buildpack.config ├── priv └── repo │ ├── migrations │ ├── .formatter.exs │ ├── 20150204170307_create_announcements.exs │ ├── 20150206153434_create_users.exs │ ├── 20150209232905_create_comments.exs │ ├── 20150212191157_add_user_to_announcements.exs │ ├── 20150212203030_add_user_to_comments.exs │ ├── 20150213213319_add_name_to_users.exs │ ├── 20150226150429_add_subscriptions_table.exs │ ├── 20150226184100_create_interests.exs │ ├── 20150227200438_create_announcements_interests.exs │ ├── 20150227210639_create_users_interests.exs │ ├── 20150825202821_add_username_to_users.exs │ ├── 20150826133822_add_null_constraint_to_username.exs │ ├── 20150909175723_add_notification_settings_to_users.exs │ ├── 20150918165253_set_autosubscribe_default_to_true.exs │ ├── 20150921193646_add_token_to_subscription.exs │ ├── 20150921194253_generate_subscription_tokens.exs │ ├── 20151218210106_add_slack_channel_to_interest.exs │ ├── 20160422175132_add_last_discussed_to_announcements.exs │ ├── 20160610142434_add_active_to_user.exs │ ├── 20160624194052_add_not_null_constraint_to_required_foreign_keys.exs │ ├── 20180413164621_add_slug_to_announcements.exs │ ├── 20180511201013_make_announcement_slugs_non_nullable.exs │ └── 20200527201059_add_profile_fields_to_users.exs │ └── seeds.exs ├── script └── heroku-compile-assets └── test ├── constable ├── announcement_test.exs ├── comment_test.exs ├── emails_test.exs ├── interest_test.exs ├── mailers │ ├── announcement_mailer_test.exs │ └── comment_mailer_test.exs ├── markdown_test.exs ├── profiles_test.exs ├── pub_sub_test.exs ├── repo_test.exs ├── services │ ├── announcement_creator_test.exs │ ├── announcement_updater_test.exs │ ├── comment_creator_test.exs │ ├── daily_digest_test.exs │ ├── email_reply_parser_test.exs │ ├── google_strategy_test.exs │ ├── hub_profile_test.exs │ └── mention_finder_test.exs ├── slug_test.exs ├── user_interest_test.exs └── user_test.exs ├── constable_web ├── acceptance │ ├── user_announcement_test.exs │ ├── user_controls_settings_test.exs │ ├── user_manages_interests_test.exs │ ├── user_searches_announcements_test.exs │ ├── user_sets_slack_channel_for_interest_test.exs │ └── user_unsubscribes_test.exs ├── controllers │ ├── announcement_controller_test.exs │ ├── api │ │ ├── announcement_controller_test.exs │ │ ├── comment_controller_test.exs │ │ ├── interest_controller_test.exs │ │ ├── search_controller_test.exs │ │ ├── subscription_controller_test.exs │ │ ├── user_controller_test.exs │ │ └── user_interest_controller_test.exs │ ├── auth_controller_test.exs │ ├── comment_controller_test.exs │ ├── email_forward_controller_test.exs │ ├── email_reply_controller_test.exs │ ├── home_controller_test.exs │ ├── interest_controller_test.exs │ ├── recipients_preview_controller_test.exs │ ├── search_controller_test.exs │ ├── session_controller_test.exs │ ├── settings_controller_test.exs │ ├── subscription_controller_test.exs │ ├── unsubscribe_controller_test.exs │ └── user_activation_controller_test.exs ├── live │ └── announcement_live │ │ └── show_test.exs ├── plugs │ ├── api_auth_test.exs │ ├── deslugifier_test.exs │ ├── fetch_current_user_test.exs │ └── require_web_login_test.exs └── views │ ├── api │ ├── announcement_view_test.exs │ ├── comment_view_test.exs │ ├── interest_view_test.exs │ ├── subscription_view_test.exs │ ├── user_interest_view_test.exs │ └── user_view_test.exs │ ├── auth_view_test.exs │ ├── email_view_test.exs │ └── shared_view_test.exs ├── mix └── send_daily_digest_test.exs ├── support ├── acceptance_case.ex ├── channel_case.ex ├── conn_case.ex ├── conn_case_helper.ex ├── data_case.ex ├── test_with_ecto.ex ├── view_case.ex ├── view_case_helper.ex └── wallaby_helper.ex └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto, :phoenix], 3 | inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"], 4 | subdirectories: ["priv/*/migrations"] 5 | ] 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: mix 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "08:00" 8 | timezone: America/New_York 9 | open-pull-requests-limit: 10 10 | ignore: 11 | - dependency-name: slugger 12 | versions: 13 | - ">= 0.3.a, < 0.4" 14 | - dependency-name: floki 15 | versions: 16 | - 0.30.0 17 | - dependency-name: earmark 18 | versions: 19 | - 1.4.13 20 | - 1.4.14 21 | - dependency-name: phoenix_live_reload 22 | versions: 23 | - 1.3.0 24 | - dependency-name: postgrex 25 | versions: 26 | - 0.15.8 27 | - dependency-name: phoenix_live_view 28 | versions: 29 | - 0.15.4 30 | - dependency-name: ecto_sql 31 | versions: 32 | - 3.5.4 33 | - dependency-name: phoenix_html 34 | versions: 35 | - 2.14.3 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build/ 2 | /cover/ 3 | /deps/ 4 | /doc/ 5 | /.fetch 6 | erl_crash.dump 7 | *.ez 8 | 9 | assets/node_modules/ 10 | priv/static 11 | 12 | sample_app-*.tar 13 | 14 | npm-debug.log 15 | 16 | .env 17 | *.tmlanguage.cache 18 | *.tmPreferences.cache 19 | *.stTheme.cache 20 | *.sublime-workspace 21 | *.sublime-project 22 | .dialyzer.plt 23 | .dialyzer.plt.hash 24 | 25 | screenshots 26 | /config/*.secret.exs 27 | -------------------------------------------------------------------------------- /.hound.yml: -------------------------------------------------------------------------------- 1 | eslint: 2 | enabled: true 3 | config_file: assets/.eslintrc.json 4 | version: 5.16.0 5 | scss: 6 | enabled: false 7 | stylelint: 8 | config_file: assets/.stylelintrc.json 9 | enabled: true 10 | -------------------------------------------------------------------------------- /.iex.exs: -------------------------------------------------------------------------------- 1 | use QuickAlias, Constable 2 | 3 | import Ecto.Query 4 | -------------------------------------------------------------------------------- /.sample.env: -------------------------------------------------------------------------------- 1 | CLIENT_ID=get-value-from-staging 2 | CLIENT_SECRET=get-value-from-staging 3 | INBOUND_EMAIL_DOMAIN=inbound.staging.constable.io 4 | MANDRILL_KEY=GetFromMandrillSettings 5 | OUTBOUND_EMAIL_DOMAIN=staging.constable.io 6 | PERMITTED_EMAIL_DOMAIN=thoughtbot.com 7 | SHUBOX_KEY=get-from-shubox-dashboard 8 | SLACK_WEBHOOK_URL=https://fakeslack/webhook 9 | HUB_URL=https://staging-hub.thoughtbot.com 10 | HUB_API_TOKEN=get-API_TOKEN-value-from-thoughtbot-hub-staging 11 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | erlang 24.0 2 | elixir 1.12.1-otp-24 3 | nodejs 10.10.0 4 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: mix phx.server 2 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "constable", 3 | "description": "Constable Heroku review app config", 4 | "scripts": { 5 | "postdeploy": "mix ecto.migrate && mix run priv/repo/seeds.exs" 6 | }, 7 | "env": { 8 | "ADMIN_EMAILS": { 9 | "required": true 10 | }, 11 | "BUILDPACK_URL": { 12 | "required": true 13 | }, 14 | "CLIENT_ID": { 15 | "required": true 16 | }, 17 | "CLIENT_SECRET": { 18 | "required": true 19 | }, 20 | "HEROKU_APP_NAME": { 21 | "required": true 22 | }, 23 | "HONEYBADGER_API_KEY": { 24 | "required": true 25 | }, 26 | "HONEYBADGER_ENV": "review", 27 | "INBOUND_EMAIL_DOMAIN": { 28 | "required": true 29 | }, 30 | "MANDRILL_KEY": { 31 | "required": true 32 | }, 33 | "MIX_ENV": { 34 | "required": true 35 | }, 36 | "OAUTH_REDIRECT_OVERRIDE": "https://constable-oauth-redirector.herokuapp.com/auth", 37 | "OUTBOUND_EMAIL_DOMAIN": { 38 | "required": true 39 | }, 40 | "PERMITTED_EMAIL_DOMAIN": { 41 | "required": true 42 | }, 43 | "SECRET_KEY_BASE": { 44 | "generator": "secret" 45 | }, 46 | "SHUBOX_KEY": { 47 | "required": true 48 | }, 49 | "SLACK_WEBHOOK_URL": { 50 | "required": true 51 | }, 52 | "URL_PORT": { 53 | "required": true 54 | } 55 | }, 56 | "formation": { 57 | "web": { 58 | "quantity": 1 59 | } 60 | }, 61 | "addons": [ 62 | "heroku-postgresql", 63 | "scheduler" 64 | ], 65 | "buildpacks": [ 66 | { 67 | "url": "https://github.com/HashNuke/heroku-buildpack-elixir" 68 | }, 69 | { 70 | "url": "https://github.com/gjaldon/phoenix-static-buildpack" 71 | } 72 | ] 73 | } 74 | -------------------------------------------------------------------------------- /assets/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /assets/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@thoughtbot/eslint-config", 3 | "env": { 4 | "browser": true, 5 | "es6": true, 6 | "jquery": true 7 | }, 8 | "parserOptions": { 9 | "sourceType": "module", 10 | "ecmaFeatures": { 11 | "impliedStrict": true, 12 | "modules": true 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /assets/.stylelintignore: -------------------------------------------------------------------------------- 1 | css/vendor 2 | -------------------------------------------------------------------------------- /assets/.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@thoughtbot/stylelint-config" 3 | } 4 | -------------------------------------------------------------------------------- /assets/css/_shims.scss: -------------------------------------------------------------------------------- 1 | .tbds-button { 2 | font-family: $font-family-body; 3 | font-weight: $font-weight-medium; 4 | 5 | &:hover { 6 | text-decoration: none; 7 | } 8 | } 9 | 10 | .button-secondary { 11 | background-color: $white; 12 | border: $base-border; 13 | color: $base-font-color; 14 | transition: border-color $base-duration $base-timing; 15 | 16 | &:hover { 17 | background-color: $white; 18 | border-color: shade($base-border-color, 25%); 19 | color: $base-font-color; 20 | } 21 | } 22 | 23 | .app-header__announce-button { 24 | .tbds-button__icon--start { 25 | @media (max-width: $breakpoint-2) { 26 | margin-inline-end: 0; 27 | } 28 | } 29 | } 30 | 31 | .tbds-app-frame__header { 32 | flex-shrink: 0; 33 | } 34 | 35 | .tbds-badge { 36 | font-size: inherit; 37 | font-weight: inherit; 38 | } 39 | -------------------------------------------------------------------------------- /assets/css/app.scss: -------------------------------------------------------------------------------- 1 | @import "~bourbon/core/bourbon"; 2 | 3 | @import "~normalize.css/normalize"; 4 | 5 | @import "../node_modules/nprogress/nprogress.css"; 6 | 7 | @import "~@thoughtbot/design-system/src/index"; 8 | 9 | @import "settings/variables"; 10 | 11 | @import "generic/fonts"; 12 | 13 | @import "elements/layout"; 14 | @import "elements/lists"; 15 | @import "elements/tables"; 16 | @import "elements/typography"; 17 | 18 | @import "objects/container"; 19 | 20 | @import "~highlight.js/styles/github.css"; 21 | @import "vendor/selectize"; 22 | 23 | @import "components/announcement"; 24 | @import "components/announcement-create"; 25 | @import "components/announcement-form"; 26 | @import "components/announcements"; 27 | @import "components/app-header"; 28 | @import "components/app-main"; 29 | @import "components/at-mention"; 30 | @import "components/box"; 31 | @import "components/checkbox-switch"; 32 | @import "components/comments"; 33 | @import "components/flex-button-container"; 34 | @import "components/flash"; 35 | @import "components/help-block"; 36 | @import "components/interest"; 37 | @import "components/pagination"; 38 | @import "components/page-header"; 39 | @import "components/tabs"; 40 | @import "components/uploadable-input"; 41 | 42 | @import "scopes/links-no-underline"; 43 | 44 | @import "utilities/font-size"; 45 | 46 | @import "shims"; 47 | -------------------------------------------------------------------------------- /assets/css/components/_announcement-create.scss: -------------------------------------------------------------------------------- 1 | .flex-container { 2 | display: flex; 3 | justify-content: flex-start; 4 | 5 | @media (min-width: $breakpoint-1) { 6 | justify-content: center; 7 | } 8 | } 9 | 10 | .announcement-create, 11 | .announcement-preview { 12 | display: none; 13 | flex: 1; 14 | min-height: calc(100vh - 91px); 15 | min-width: calc(100vw / 2); 16 | padding: $large-spacing $base-spacing; 17 | 18 | @media (min-width: $breakpoint-1) { 19 | display: block; 20 | } 21 | 22 | &.active { 23 | display: block; 24 | } 25 | } 26 | 27 | .announcement-preview { 28 | border-left: 1px solid darken($lightest-gray, 4%); 29 | 30 | pre { 31 | overflow-x: auto; 32 | } 33 | } 34 | 35 | .header-tags { 36 | display: flex; 37 | justify-content: center; 38 | position: absolute; 39 | width: 100%; 40 | z-index: $mezzanine-z-index; 41 | } 42 | 43 | .header-tag { 44 | @include padding($tiny-spacing null $tiny-spacing $base-spacing); 45 | background-color: $light-gray; 46 | font-weight: $font-weight-bold; 47 | text-transform: uppercase; 48 | transition: background-color 0.1s ease-out; 49 | width: 100%; 50 | 51 | h2 { 52 | font-size: $tiny-font-size; 53 | } 54 | 55 | a { 56 | color: inherit; 57 | display: block; 58 | 59 | @media (min-width: $breakpoint-2) { 60 | cursor: default; 61 | } 62 | } 63 | 64 | &.active { 65 | background-color: $dark-blue; 66 | color: #fff; 67 | 68 | @media (min-width: $breakpoint-2) { 69 | background-color: $light-gray; 70 | color: $base-font-color; 71 | } 72 | } 73 | } 74 | 75 | .button-container { 76 | @include position(fixed, null 0 0 0); 77 | @include padding($base-spacing null); 78 | background-color: $white; 79 | border-top: $base-border; 80 | display: flex; 81 | justify-content: center; 82 | } 83 | -------------------------------------------------------------------------------- /assets/css/components/_announcement-form.scss: -------------------------------------------------------------------------------- 1 | .col-markdown { 2 | textarea { 3 | min-height: 400px; 4 | } 5 | } 6 | 7 | .announcement-form__markdown { 8 | h1 { 9 | margin-top: 0; 10 | } 11 | } 12 | 13 | .recipients-preview { 14 | position: relative; 15 | 16 | .interested-user-names { 17 | @include position(absolute, 24px null null null); 18 | background-color: $white; 19 | border: $base-border; 20 | border-radius: $base-border-radius; 21 | box-shadow: 0 0 16px rgba($light-gray, 0.6); 22 | display: none; 23 | font-size: $small-font-size; 24 | padding: $small-spacing; 25 | width: 600px; 26 | z-index: $ceiling-z-index; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /assets/css/components/_announcements.scss: -------------------------------------------------------------------------------- 1 | .search-results-header { 2 | margin-top: $base-spacing; 3 | } 4 | 5 | .announcement-list-item { 6 | .announcement-metadata { 7 | margin-bottom: 0; 8 | } 9 | 10 | span { 11 | color: $medium-gray; 12 | } 13 | 14 | &:last-child { 15 | border-bottom: none; 16 | } 17 | } 18 | 19 | .announcement-list-item-heading { 20 | font-size: 1.75rem; 21 | font-weight: $font-weight-bold; 22 | line-height: $heading-line-height; 23 | margin-bottom: $tiniest-spacing; 24 | margin-top: 0; 25 | } 26 | 27 | .announcement-list-item-metadata { 28 | color: $secondary-font-color; 29 | font-size: $small-font-size; 30 | } 31 | 32 | .announcement-metadata-container { 33 | color: $secondary-font-color; 34 | display: flex; 35 | flex-wrap: wrap; 36 | font-size: $small-font-size; 37 | 38 | .announcement-metadata-item { 39 | flex: 1 0 auto; 40 | } 41 | 42 | .author { 43 | color: $base-font-color; 44 | font-weight: $font-weight-bold; 45 | } 46 | 47 | .subscription { 48 | @include margin($small-spacing 0); 49 | margin-left: 1rem; 50 | } 51 | } 52 | 53 | .commenters { 54 | margin-top: $small-spacing; 55 | } 56 | 57 | .commenters__avatar { 58 | @include size(32px); 59 | border: medium solid $white; 60 | border-radius: 50%; 61 | margin-right: -0.75rem; 62 | vertical-align: middle; 63 | 64 | &:first-of-type { 65 | @include size(40px); 66 | margin-right: 0; 67 | position: relative; 68 | z-index: $mezzanine-z-index; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /assets/css/components/_app-header.scss: -------------------------------------------------------------------------------- 1 | .app-header { 2 | align-items: center; 3 | background-color: $lightest-gray; 4 | border-bottom: $base-border; 5 | display: flex; 6 | flex-wrap: wrap; 7 | justify-content: space-between; 8 | padding: $small-spacing 9 | calc(#{$base-spacing} + env(safe-area-inset-right)) 10 | $small-spacing 11 | calc(#{$base-spacing} + env(safe-area-inset-left)); 12 | 13 | @media (min-width: $breakpoint-1) { 14 | padding: $small-spacing $base-spacing; 15 | } 16 | } 17 | 18 | .app-header__logo { 19 | color: $dark-blue; 20 | font-size: $medium-font-size; 21 | font-weight: $font-weight-medium; 22 | order: 2; 23 | text-align: center; 24 | 25 | @media (min-width: $breakpoint-2) { 26 | margin-bottom: 0; 27 | } 28 | } 29 | 30 | .app-header__user-avatar { 31 | margin-right: $small-spacing; 32 | order: 1; 33 | 34 | @media (min-width: $breakpoint-2) { 35 | order: 3; 36 | } 37 | } 38 | 39 | .app-header__search-form { 40 | flex: 1 0 100%; 41 | margin-top: $small-spacing; 42 | order: 4; 43 | position: relative; 44 | 45 | @media (min-width: $breakpoint-2) { 46 | @include margin(0 auto null); 47 | flex: 0 0 18.75rem; 48 | order: 2; 49 | } 50 | 51 | &::before { 52 | @include position(absolute, 28% null null $tiny-spacing); 53 | @include size(1.125rem); 54 | background: url("/images/icon-search.svg") center / cover no-repeat; 55 | content: ""; 56 | opacity: 0.75; 57 | z-index: $mezzanine-z-index; 58 | } 59 | } 60 | 61 | .app-header__search-input { 62 | padding-left: $base-spacing; 63 | } 64 | 65 | .app-header__announce-button { 66 | order: 3; 67 | } 68 | 69 | .app-header__announce-button-text { 70 | display: none; 71 | 72 | @media (min-width: $breakpoint-2) { 73 | display: block; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /assets/css/components/_app-main.scss: -------------------------------------------------------------------------------- 1 | .app-main { 2 | padding: 0 3 | calc(#{$tiniest-spacing} + env(safe-area-inset-right)) 4 | calc(#{$tiniest-spacing} + env(safe-area-inset-bottom)) 5 | calc(#{$tiniest-spacing} + env(safe-area-inset-left)); 6 | } 7 | -------------------------------------------------------------------------------- /assets/css/components/_at-mention.scss: -------------------------------------------------------------------------------- 1 | @import "../static/vendor/textcomplete.css"; 2 | 3 | .dropdown-menu { 4 | background-color: $white; 5 | border: none; 6 | border-radius: $base-border-radius; 7 | box-shadow: $base-box-shadow; 8 | 9 | li { 10 | border-top: $base-border; 11 | cursor: pointer; 12 | padding: $tiny-spacing $small-spacing; 13 | 14 | &.active, 15 | &:hover { 16 | background-color: $dark-gray; 17 | border-color: transparent; 18 | 19 | a { 20 | color: $white; 21 | text-decoration: none; 22 | } 23 | } 24 | } 25 | } 26 | 27 | .textcomplete-header, 28 | .textcomplete-footer { 29 | display: none; 30 | } 31 | 32 | .textcomplete-item { 33 | transition: background-color $base-transition-time; 34 | 35 | &.active { 36 | &:first-of-type { 37 | @include border-top-radius($base-border-radius); 38 | } 39 | 40 | &:last-of-type { 41 | @include border-bottom-radius($base-border-radius); 42 | } 43 | } 44 | 45 | a { 46 | align-items: center; 47 | color: $base-font-color; 48 | display: flex; 49 | font-size: $small-font-size; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /assets/css/components/_box.scss: -------------------------------------------------------------------------------- 1 | .box { 2 | background-color: $white; 3 | border: $base-border; 4 | border-radius: $base-border-radius; 5 | padding: $tbds-space-4; 6 | } 7 | -------------------------------------------------------------------------------- /assets/css/components/_flash.scss: -------------------------------------------------------------------------------- 1 | .flash { 2 | color: $white; 3 | padding: $small-spacing; 4 | text-align: center; 5 | } 6 | 7 | .flash-error { 8 | background-color: $action-color; 9 | } 10 | 11 | .flash-info { 12 | background-color: $seafoam; 13 | } 14 | -------------------------------------------------------------------------------- /assets/css/components/_flex-button-container.scss: -------------------------------------------------------------------------------- 1 | .flex-button-container { 2 | display: flex; 3 | flex-direction: row; 4 | flex-wrap: wrap; 5 | justify-content: space-between; 6 | 7 | > .flex-button-container-item { 8 | flex-grow: 1; 9 | margin: 0.4rem 1rem; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /assets/css/components/_help-block.scss: -------------------------------------------------------------------------------- 1 | .help-block { 2 | color: $error-color; 3 | font-style: italic; 4 | } 5 | -------------------------------------------------------------------------------- /assets/css/components/_interest.scss: -------------------------------------------------------------------------------- 1 | .current-channel { 2 | color: $base-font-color; 3 | padding-top: $small-spacing; 4 | position: relative; 5 | 6 | a { 7 | color: $base-font-color; 8 | } 9 | } 10 | 11 | .interest-list-item { 12 | h3 { 13 | margin: 0; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /assets/css/components/_page-header.scss: -------------------------------------------------------------------------------- 1 | .page-header { 2 | margin-top: $large-spacing; 3 | } 4 | -------------------------------------------------------------------------------- /assets/css/components/_pagination.scss: -------------------------------------------------------------------------------- 1 | .pagination { 2 | @include margin($base-spacing null); 3 | align-items: center; 4 | display: flex; 5 | justify-content: center; 6 | } 7 | 8 | .pagination-button { 9 | @include margin(null $tiny-spacing); 10 | } 11 | -------------------------------------------------------------------------------- /assets/css/components/_tabs.scss: -------------------------------------------------------------------------------- 1 | .tabs { 2 | box-shadow: inset 0 -1px $light-gray; 3 | display: flex; 4 | font-size: $small-font-size; 5 | overflow-x: auto; 6 | width: 100%; 7 | 8 | @media (min-width: $breakpoint-1) { 9 | @include padding(null ($base-spacing - $small-spacing)); 10 | } 11 | 12 | a { 13 | display: inline-block; 14 | padding: $small-spacing; 15 | white-space: nowrap; 16 | 17 | &.selected { 18 | box-shadow: inset 0 -2px $dark-gray; 19 | font-weight: $font-weight-medium; 20 | } 21 | } 22 | } 23 | 24 | .tabs__link--push { 25 | margin-left: auto; 26 | } 27 | -------------------------------------------------------------------------------- /assets/css/components/_uploadable-input.scss: -------------------------------------------------------------------------------- 1 | $progress-bar-height: 5px; 2 | 3 | .uploadable-input { 4 | background-image: url("/images/upload-progress-bar-background.png"); 5 | background-position: top left; 6 | background-repeat: no-repeat; 7 | background-size: 0 $progress-bar-height; 8 | display: block; 9 | 10 | @for $i from 1 through 99 { 11 | $percentage: $i; 12 | 13 | &[data-shubox-progress="#{$percentage}"] { 14 | background-size: ($percentage * 1%) $progress-bar-height; 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /assets/css/elements/_layout.scss: -------------------------------------------------------------------------------- 1 | html { 2 | box-sizing: border-box; 3 | } 4 | 5 | *, 6 | *::before, 7 | *::after { 8 | box-sizing: inherit; 9 | } 10 | -------------------------------------------------------------------------------- /assets/css/elements/_lists.scss: -------------------------------------------------------------------------------- 1 | ul, 2 | ol { 3 | list-style-type: none; 4 | margin: 0; 5 | padding: 0; 6 | } 7 | 8 | dl { 9 | margin-bottom: $small-spacing; 10 | 11 | dt { 12 | font-weight: $font-weight-bold; 13 | margin-top: $small-spacing; 14 | } 15 | 16 | dd { 17 | margin: 0; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /assets/css/elements/_media.scss: -------------------------------------------------------------------------------- 1 | img { 2 | height: auto; 3 | max-width: 100%; 4 | 5 | @media (inverted-colors: inverted) { 6 | filter: invert(100%); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /assets/css/elements/_tables.scss: -------------------------------------------------------------------------------- 1 | table { 2 | border-collapse: collapse; 3 | font-variant-numeric: tabular-nums; 4 | margin: $small-spacing 0; 5 | table-layout: fixed; 6 | width: 100%; 7 | } 8 | 9 | th { 10 | border-bottom: 1px solid shade($base-border-color, 25%); 11 | font-weight: $font-weight-bold; 12 | padding: $small-spacing 0; 13 | text-align: left; 14 | } 15 | 16 | td { 17 | border-bottom: $base-border; 18 | padding: $small-spacing 0; 19 | } 20 | 21 | tr, 22 | td, 23 | th { 24 | vertical-align: middle; 25 | } 26 | -------------------------------------------------------------------------------- /assets/css/elements/_typography.scss: -------------------------------------------------------------------------------- 1 | body { 2 | color: $base-font-color; 3 | font-family: $font-family-body; 4 | font-size: $base-font-size; 5 | font-variant-ligatures: contextual common-ligatures; 6 | line-height: $base-line-height; 7 | } 8 | 9 | h1, 10 | h2, 11 | h3, 12 | h4, 13 | h5, 14 | h6 { 15 | font-family: $font-family-heading; 16 | font-size: $base-font-size; 17 | font-weight: $font-weight-bold; 18 | line-height: $heading-line-height; 19 | margin: 0 0 $small-spacing; 20 | } 21 | 22 | h1 { 23 | font-size: $large-font-size; 24 | } 25 | 26 | h2 { 27 | font-size: 20px; 28 | margin-bottom: 0; 29 | } 30 | 31 | h4 { 32 | font-size: $base-font-size; 33 | margin-bottom: 0; 34 | } 35 | 36 | b, 37 | strong { 38 | font-weight: $font-weight-bold; 39 | } 40 | 41 | blockquote, 42 | p { 43 | @include margin(0 null ($base-line-height * 0.75) null); 44 | } 45 | 46 | a { 47 | transition-duration: $base-duration; 48 | transition-property: color; 49 | transition-timing-function: $base-timing; 50 | word-wrap: break-word; 51 | } 52 | 53 | hr { 54 | border-bottom: $base-border; 55 | border-left: 0; 56 | border-right: 0; 57 | border-top: 0; 58 | margin: $base-spacing 0; 59 | } 60 | -------------------------------------------------------------------------------- /assets/css/generic/_fonts.scss: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-display: swap; 3 | font-family: "Inter"; 4 | font-style: normal; 5 | font-weight: 100 900; 6 | src: url("/fonts/Inter-roman.var.woff2") format("woff2"); 7 | } 8 | 9 | @font-face { 10 | font-display: swap; 11 | font-family: "Inter"; 12 | font-style: italic; 13 | font-weight: 100 900; 14 | src: url("/fonts/Inter-italic.var.woff2") format("woff2"); 15 | } 16 | -------------------------------------------------------------------------------- /assets/css/objects/_container.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | @include margin(null auto); 3 | @include padding(null $small-spacing); 4 | max-width: 40rem; 5 | } 6 | 7 | .container-x-small { 8 | max-width: 400px; 9 | } 10 | 11 | .container-pad-top { 12 | padding-top: $base-spacing; 13 | } 14 | -------------------------------------------------------------------------------- /assets/css/scopes/_links-no-underline.scss: -------------------------------------------------------------------------------- 1 | .links-no-underline { 2 | a { 3 | text-decoration: none; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /assets/css/utilities/_font-size.scss: -------------------------------------------------------------------------------- 1 | .u-font-size-large { 2 | font-size: $large-font-size; 3 | } 4 | -------------------------------------------------------------------------------- /assets/js/checkbox-switch.js: -------------------------------------------------------------------------------- 1 | const A11YswitchCheck = function() { 2 | /** 3 | * Author: Scott O'Hara 4 | * Version: 0.1.0 5 | * License: https://github.com/scottaohara/a11y_styled_form_controls/blob/master/LICENSE 6 | */ 7 | let el; 8 | 9 | /** 10 | * Initialize the instance, run all setup functions 11 | * and attach the necessary events. 12 | */ 13 | this.init = function(elm) { 14 | el = elm; 15 | setRole(el); 16 | attachEvents(el); 17 | }; 18 | 19 | /** 20 | * Check default state of element: 21 | * A toggle button is not particularly useful without JavaScript, 22 | * so ideally such a button would be set to hidden or disabled, if JS wasn't 23 | * around to make it function. 24 | */ 25 | const setRole = function(el) { 26 | if (el.getAttribute('type') === 'checkbox') { 27 | el.setAttribute('role', 'switch'); 28 | } else { 29 | console.error(el.id + ' is not a checkbox...'); 30 | } 31 | }; 32 | 33 | /** 34 | * Attach keyEvents to toggle buttons 35 | */ 36 | let keyEvents = function(e) { 37 | let keyCode = e.keyCode || e.which; 38 | 39 | switch (keyCode) { 40 | case 13: 41 | e.preventDefault(); 42 | e.target.click(); 43 | break; 44 | } 45 | }; 46 | 47 | /** 48 | * Events for toggle buttons 49 | */ 50 | const attachEvents = function(el) { 51 | el.addEventListener('keypress', keyEvents, false); 52 | }; 53 | 54 | return this; 55 | }; 56 | 57 | $(document).ready(function() { 58 | $('.checkbox-switch > input[type=checkbox]').each(function(_index) { 59 | let a11ySwitch = new A11YswitchCheck(); 60 | a11ySwitch.init(this); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /assets/js/components/announcement-form-mobile.js: -------------------------------------------------------------------------------- 1 | export const setupForm = () => { 2 | const markdownHeader = $('.header-tag-markdown'); 3 | const previewHeader = $('.header-tag-preview'); 4 | const markdownContainer = $('.announcement-create'); 5 | const previewContainer = $('.announcement-preview'); 6 | 7 | markdownHeader.on('click', () => { 8 | markdownHeader.addClass('active'); 9 | previewHeader.removeClass('active'); 10 | 11 | markdownContainer.addClass('active'); 12 | previewContainer.removeClass('active'); 13 | }); 14 | 15 | previewHeader.on('click', () => { 16 | previewHeader.addClass('active'); 17 | markdownHeader.removeClass('active'); 18 | 19 | previewContainer.addClass('active'); 20 | markdownContainer.removeClass('active'); 21 | }); 22 | }; 23 | -------------------------------------------------------------------------------- /assets/js/components/recipients-preview.js: -------------------------------------------------------------------------------- 1 | export const updateRecipientsPreview = (interests) => { 2 | $.getJSON('/recipients_preview', { interests }) 3 | .done((data) => $('.recipients-preview').html(data.recipients_preview_html)); 4 | }; 5 | -------------------------------------------------------------------------------- /assets/js/components/user-autocomplete.js: -------------------------------------------------------------------------------- 1 | import { Textcomplete, Textarea } from 'textcomplete'; 2 | 3 | const AT_REGEX = /(^|\s)@(\w*)$/; 4 | 5 | export const autocompleteUsers = (selector, users) => { 6 | const usernameStrategy = { 7 | id: 'username', 8 | match: AT_REGEX, 9 | search: (term, callback) => { 10 | term = term.toLowerCase(); 11 | 12 | if (term === '') { 13 | callback(users); 14 | } else { 15 | const matches = users.filter((user) => { 16 | const name = user.name; 17 | const username = user.username; 18 | const matchesName = name.toLowerCase().startsWith(term); 19 | const matchesUsername = username.toLowerCase().startsWith(term); 20 | 21 | return matchesName || matchesUsername; 22 | }); 23 | callback(matches); 24 | } 25 | }, 26 | 27 | template(user, _term) { 28 | return `${user.name} ${user.username}`; 29 | }, 30 | 31 | replace(user) { 32 | return `$1@${user.username} `; 33 | }, 34 | }; 35 | 36 | const editor = new Textarea(document.querySelector(selector)); 37 | const textcomplete = new Textcomplete(editor); 38 | textcomplete.register([ usernameStrategy ]); 39 | }; 40 | -------------------------------------------------------------------------------- /assets/js/lib/remote-append.js: -------------------------------------------------------------------------------- 1 | $(document).on('click', '.remote-append', (event) => { 2 | event.preventDefault(); 3 | 4 | $.ajax({ 5 | url: event.currentTarget.href, 6 | method: 'GET', 7 | }).done((data) => $('body').append(data)); 8 | }); 9 | -------------------------------------------------------------------------------- /assets/js/lib/syntax-highlighting.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery'; 2 | import marked from 'marked'; 3 | import hljs from 'highlight.js'; 4 | 5 | marked.setOptions({ 6 | highlight: (code) => { 7 | return hljs.highlightAuto(code).value; 8 | }, 9 | }); 10 | 11 | const observer = new MutationObserver(mutations => { 12 | mutations.forEach((_mutation) => { 13 | highlightCodeBlocks(); 14 | }); 15 | }); 16 | 17 | const initializeSyntaxHighlighting = (_container) => { 18 | highlightCodeBlocks(); 19 | }; 20 | 21 | const highlightCodeBlocks = () => { 22 | $('pre code').each((_index, block) => { 23 | hljs.highlightBlock(block); 24 | }); 25 | }; 26 | 27 | export const markedWithSyntax = (value) => { 28 | return marked(value); 29 | }; 30 | 31 | export const highlightSyntax = (container) => { 32 | observer.observe(document.querySelector(container), { childList: true }); 33 | initializeSyntaxHighlighting(container); 34 | }; 35 | -------------------------------------------------------------------------------- /assets/js/lib/textarea-image-uploader.js: -------------------------------------------------------------------------------- 1 | import Shubox from 'shubox'; 2 | 3 | const refreshMarkdownPreview = (inputSelector) => { 4 | $(inputSelector).trigger('input'); 5 | }; 6 | 7 | export const setupImageUploader = (selector) => { 8 | const shuboxOptions = { 9 | key: window.shuboxKey, 10 | textBehavior: 'append', 11 | clickable: false, 12 | uploadingTemplate: '![Uploading {{name}}...]()', 13 | successTemplate: '![{{name}}]({{s3url}})', 14 | success() { 15 | refreshMarkdownPreview(selector); 16 | }, 17 | }; 18 | 19 | if (typeof(Shubox) !== 'undefined') { 20 | new Shubox(selector, shuboxOptions); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /assets/js/socket.js: -------------------------------------------------------------------------------- 1 | import { Socket } from 'phoenix'; 2 | 3 | let socket = new Socket('/socket', { 4 | params: { token: window.userToken }, 5 | }); 6 | 7 | socket.connect(); 8 | 9 | export default socket; 10 | -------------------------------------------------------------------------------- /assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "repository": {}, 3 | "scripts": { 4 | "deploy": "webpack --mode production", 5 | "stylelint": "npx stylelint 'css/**/*.scss'", 6 | "watch": "webpack --mode development --watch-stdin", 7 | "eslint": "npx eslint js/**/*.js" 8 | }, 9 | "dependencies": { 10 | "@thoughtbot/design-system": "0.7.0", 11 | "bourbon": "^6.0.0", 12 | "highlight.js": "^10.4.1", 13 | "jquery": "^3.5.0", 14 | "local-time": "^2.1.0", 15 | "marked": "^0.7.0", 16 | "mousetrap": "^1.6.3", 17 | "normalize.css": "^8.0.1", 18 | "nprogress": "^0.2.0", 19 | "phoenix": "file:../deps/phoenix", 20 | "phoenix_html": "file:../deps/phoenix_html", 21 | "phoenix_live_view": "file:../deps/phoenix_live_view", 22 | "sass": "^1.23.6", 23 | "selectize": "^0.12.6", 24 | "shubox": "^0.5.0", 25 | "textcomplete": "^0.18.0", 26 | "turbolinks": "^5.2.0" 27 | }, 28 | "devDependencies": { 29 | "@babel/core": "^7.7.2", 30 | "@babel/preset-env": "^7.7.1", 31 | "@thoughtbot/eslint-config": "^0.1.0", 32 | "@thoughtbot/stylelint-config": "^1.1.0", 33 | "autoprefixer": "^9.7.2", 34 | "babel-loader": "^8.0.6", 35 | "copy-webpack-plugin": "^5.0.5", 36 | "css-loader": "^3.2.0", 37 | "eslint": "5.16.0", 38 | "mini-css-extract-plugin": "^0.8.0", 39 | "optimize-css-assets-webpack-plugin": "^5.0.3", 40 | "postcss-loader": "^3.0.0", 41 | "sass-loader": "^7.3.1", 42 | "stylelint": "^10.1.0", 43 | "terser-webpack-plugin": "^2.3.5", 44 | "webpack": "^4.41.2", 45 | "webpack-cli": "^3.3.10" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /assets/static/apple-touch-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughtbot/constable/d2d427497ba5fa225378d1c094e54848cf5e753b/assets/static/apple-touch-icon-114x114.png -------------------------------------------------------------------------------- /assets/static/apple-touch-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughtbot/constable/d2d427497ba5fa225378d1c094e54848cf5e753b/assets/static/apple-touch-icon-120x120.png -------------------------------------------------------------------------------- /assets/static/apple-touch-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughtbot/constable/d2d427497ba5fa225378d1c094e54848cf5e753b/assets/static/apple-touch-icon-144x144.png -------------------------------------------------------------------------------- /assets/static/apple-touch-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughtbot/constable/d2d427497ba5fa225378d1c094e54848cf5e753b/assets/static/apple-touch-icon-152x152.png -------------------------------------------------------------------------------- /assets/static/apple-touch-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughtbot/constable/d2d427497ba5fa225378d1c094e54848cf5e753b/assets/static/apple-touch-icon-180x180.png -------------------------------------------------------------------------------- /assets/static/apple-touch-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughtbot/constable/d2d427497ba5fa225378d1c094e54848cf5e753b/assets/static/apple-touch-icon-57x57.png -------------------------------------------------------------------------------- /assets/static/apple-touch-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughtbot/constable/d2d427497ba5fa225378d1c094e54848cf5e753b/assets/static/apple-touch-icon-72x72.png -------------------------------------------------------------------------------- /assets/static/apple-touch-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughtbot/constable/d2d427497ba5fa225378d1c094e54848cf5e753b/assets/static/apple-touch-icon-76x76.png -------------------------------------------------------------------------------- /assets/static/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughtbot/constable/d2d427497ba5fa225378d1c094e54848cf5e753b/assets/static/apple-touch-icon.png -------------------------------------------------------------------------------- /assets/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughtbot/constable/d2d427497ba5fa225378d1c094e54848cf5e753b/assets/static/favicon.ico -------------------------------------------------------------------------------- /assets/static/fonts/Inter-italic.var.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughtbot/constable/d2d427497ba5fa225378d1c094e54848cf5e753b/assets/static/fonts/Inter-italic.var.woff2 -------------------------------------------------------------------------------- /assets/static/fonts/Inter-roman.var.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughtbot/constable/d2d427497ba5fa225378d1c094e54848cf5e753b/assets/static/fonts/Inter-roman.var.woff2 -------------------------------------------------------------------------------- /assets/static/images/icon-search.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /assets/static/images/ralph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughtbot/constable/d2d427497ba5fa225378d1c094e54848cf5e753b/assets/static/images/ralph.png -------------------------------------------------------------------------------- /assets/static/images/upload-progress-bar-background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thoughtbot/constable/d2d427497ba5fa225378d1c094e54848cf5e753b/assets/static/images/upload-progress-bar-background.png -------------------------------------------------------------------------------- /assets/static/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / 3 | -------------------------------------------------------------------------------- /assets/static/vendor/textcomplete.css: -------------------------------------------------------------------------------- 1 | /* Sample */ 2 | 3 | .dropdown-menu { 4 | border: 1px solid #ddd; 5 | background-color: white; 6 | } 7 | 8 | .dropdown-menu li { 9 | border-top: 1px solid #ddd; 10 | padding: 2px 5px; 11 | } 12 | 13 | .dropdown-menu li:first-child { 14 | border-top: none; 15 | } 16 | 17 | .dropdown-menu li:hover, 18 | .dropdown-menu .active { 19 | background-color: rgb(110, 183, 219); 20 | } 21 | 22 | 23 | /* SHOULD not modify */ 24 | 25 | .dropdown-menu { 26 | list-style: none; 27 | padding: 0; 28 | margin: 0; 29 | } 30 | 31 | .dropdown-menu a:hover { 32 | cursor: pointer; 33 | } 34 | -------------------------------------------------------------------------------- /assets/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const glob = require('glob'); 3 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 4 | const TerserPlugin = require('terser-webpack-plugin'); 5 | const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin'); 6 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 7 | const autoprefix = require('autoprefixer'); 8 | const webpack = require('webpack'); 9 | 10 | module.exports = (env, options) => ({ 11 | optimization: { 12 | minimizer: [ 13 | new TerserPlugin(), 14 | new OptimizeCSSAssetsPlugin({}), 15 | ], 16 | }, 17 | entry: { 18 | './js/app.js': [ './js/app.js' ].concat(glob.sync('./vendor/**/*.js')), 19 | }, 20 | output: { 21 | filename: 'app.js', 22 | path: path.resolve(__dirname, '../priv/static/js'), 23 | }, 24 | module: { 25 | rules: [ 26 | { 27 | test: /\.js$/, 28 | exclude: /node_modules/, 29 | use: { 30 | loader: 'babel-loader', 31 | }, 32 | }, 33 | { 34 | test: /\.(sa|sc|c)ss$/, 35 | use: [ 36 | { loader: MiniCssExtractPlugin.loader }, 37 | { loader: 'css-loader' }, 38 | { 39 | loader: 'postcss-loader', 40 | options: { 41 | plugins: [ autoprefix('last 2 versions') ], 42 | }, 43 | }, 44 | { loader: 'sass-loader' }, 45 | ], 46 | }, 47 | ], 48 | }, 49 | plugins: [ 50 | new webpack.ProvidePlugin({ 51 | $: 'jquery', 52 | jQuery: 'jquery', 53 | }), 54 | new MiniCssExtractPlugin({ filename: '../css/app.css' }), 55 | new CopyWebpackPlugin([ { from: 'static/', to: '../' } ]), 56 | ], 57 | }); 58 | -------------------------------------------------------------------------------- /bin/clone_prod_db_to_dev: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # Exit if any subcommand fails 4 | set -e 5 | 6 | echo "Dropping existing db" 7 | mix ecto.drop 8 | 9 | echo "Importing production data into local development database" 10 | heroku pg:pull DATABASE_URL constable_api_development --remote production 11 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | set -e 4 | 5 | if [ -z "$1" ]; then 6 | iex -S mix 7 | else 8 | heroku run iex -S mix --app constable-api-$1 9 | fi 10 | -------------------------------------------------------------------------------- /bin/deploy: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | set -e 4 | 5 | if [ -z "$1" ]; then 6 | echo "Specify an environment (staging|production)" 7 | else 8 | heroku config:set BUILDPACK_URL="https://github.com/HashNuke/heroku-buildpack-elixir.git" --app constable-api-$1 9 | git push $1 main; heroku run mix ecto.migrate --app constable-api-$1 10 | fi 11 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # Exit if any subcommand fails 4 | set -e 5 | 6 | # set up environment variables if not set up yet 7 | if [ ! -f .env ]; then 8 | echo "Copying .env file" 9 | cp .sample.env .env 10 | fi 11 | 12 | # check if chromedriver is installed 13 | if ! command -v chromedriver >/dev/null; then 14 | echo "You must install chromedriver before continuing." 15 | exit 1 16 | fi 17 | 18 | echo "Removing previous build artifacts" 19 | rm -rf deps _build 20 | 21 | # Set up Elixir and Phoenix 22 | if ! command -v mix >/dev/null; then 23 | echo "It looks like you don't have Elixir installed." 24 | echo "See http://elixir-lang.org/install.html for instructions." 25 | exit 1 26 | fi 27 | 28 | echo "Installing dependencies and compiling" 29 | mix local.hex --force 30 | mix deps.get 31 | mix deps.compile 32 | mix compile 33 | 34 | # Set up database 35 | echo "Setting up the database" 36 | mix ecto.create 37 | mix ecto.migrate 38 | mix run priv/repo/seeds.exs 39 | 40 | # Grab JS dependencies from NPM 41 | echo "Installing npm dependencies" 42 | cd assets && npm install && cd ../ 43 | 44 | # Only if this isn't CI 45 | if [ -z "$CI" ]; then 46 | # Set up Heroku 47 | echo "Setting up Heroku and git remotes" 48 | heroku join --app constable-api-staging || true 49 | heroku git:remote -r staging -a constable-api-staging || true 50 | 51 | heroku join --app constable-api-production || true 52 | heroku git:remote -r production -a constable-api-production || true 53 | 54 | if ! grep -E -q 'CLIENT_ID |CLIENT_SECRET' .env; then 55 | heroku config --shell --remote staging | grep -E 'CLIENT_ID|CLIENT_SECRET' >> .env 56 | fi 57 | fi 58 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | 7 | # General application configuration 8 | use Mix.Config 9 | 10 | config :constable, 11 | ecto_repos: [Constable.Repo] 12 | 13 | # Configures the endpoint 14 | config :constable, ConstableWeb.Endpoint, 15 | url: [host: "localhost"], 16 | secret_key_base: "tJ+MdrPlKWMpmz7JyJgSu/11xvwnNZo7Sz8IAacy9MM6di3GqackE9iNjhkHI9p8", 17 | render_errors: [view: ConstableWeb.ErrorView, accepts: ~w(html json)], 18 | pubsub_server: Constable.PubSub, 19 | live_view: [signing_salt: "9CpMX+WbuB/UCRZ3VqJ6Pt0AUKvOhj/T"] 20 | 21 | # Configures Elixir's Logger 22 | config :logger, :console, 23 | format: "$time $metadata[$level] $message\n", 24 | metadata: [:request_id] 25 | 26 | # Use Jason for JSON parsing in Phoenix 27 | config :phoenix, :json_library, Jason 28 | 29 | # To sign in, users must have an email in this domain 30 | config :constable, :permitted_email_domain, System.get_env("PERMITTED_EMAIL_DOMAIN") 31 | 32 | # The shubox key is unique to each app/env 33 | config :constable, :shubox_key, System.get_env("SHUBOX_KEY") 34 | 35 | # Hub 36 | config :constable, :hub_url, System.get_env("HUB_URL") 37 | config :constable, :hub_api_token, System.get_env("HUB_API_TOKEN") 38 | 39 | config :oauth2, serializers: %{"application/json" => Poison} 40 | 41 | # Import environment specific config. This must remain at the bottom 42 | # of this file so it overrides the configuration defined above. 43 | import_config "#{Mix.env()}.exs" 44 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :constable, ConstableWeb.Endpoint, 4 | http: [port: System.get_env("PORT") || 4000], 5 | url: [host: "localhost"], 6 | debug_errors: true, 7 | code_reloader: true, 8 | check_origin: false, 9 | watchers: [ 10 | node: [ 11 | "node_modules/webpack/bin/webpack.js", 12 | "--mode", 13 | "development", 14 | "--watch-stdin", 15 | cd: Path.expand("../assets", __DIR__) 16 | ] 17 | ] 18 | 19 | # Watch static and templates for browser reloading. 20 | config :constable, ConstableWeb.Endpoint, 21 | live_reload: [ 22 | patterns: [ 23 | ~r{priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$}, 24 | ~r{priv/gettext/.*(po)$}, 25 | ~r{lib/constable_web/views/.*(ex)$}, 26 | ~r{lib/constable_web/templates/.*(eex)$} 27 | ] 28 | ] 29 | 30 | # Do not include metadata nor timestamps in development logs 31 | config :logger, :console, format: "[$level] $message\n" 32 | 33 | # Set a higher stacktrace during development. Avoid configuring such 34 | # in production as building large stacktraces may be expensive. 35 | config :phoenix, :stacktrace_depth, 20 36 | 37 | # Initialize plugs at runtime for faster development compilation 38 | config :phoenix, :plug_init_mode, :runtime 39 | 40 | # Configure your database 41 | config :constable, Constable.Repo, 42 | adapter: Ecto.Adapters.Postgres, 43 | database: "constable_api_development", 44 | hostname: "localhost" 45 | 46 | config :constable, Constable.Mailer, adapter: Bamboo.LocalAdapter 47 | 48 | config :honeybadger, :environment_name, :dev 49 | -------------------------------------------------------------------------------- /config/locales/en.exs: -------------------------------------------------------------------------------- 1 | [ 2 | hello: "Hello" 3 | ] 4 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :constable, ConstableWeb.Endpoint, 4 | http: [port: System.get_env("PORT") || 4002], 5 | server: true 6 | 7 | # Print only warnings and errors during test 8 | config :logger, level: :warn 9 | 10 | # Configure your database 11 | config :constable, Constable.Repo, 12 | adapter: Ecto.Adapters.Postgres, 13 | database: "constable_api_test", 14 | hostname: "localhost", 15 | username: System.get_env("POSTGRES_USER") || "postgres", 16 | pool: Ecto.Adapters.SQL.Sandbox 17 | 18 | config :constable, :sql_sandbox, true 19 | 20 | config :constable, :shubox_key, "111" 21 | 22 | config :constable, Constable.Mailer, adapter: Bamboo.TestAdapter 23 | 24 | config :honeybadger, :environment_name, :test 25 | 26 | # The tests all use thoughtbot.com user emails 27 | config :constable, :permitted_email_domain, "thoughtbot.com" 28 | 29 | config :wallaby, 30 | max_wait_time: 250, 31 | js_logger: false, 32 | driver: Wallaby.Chrome 33 | 34 | # Set a higher stacktrace during test. 35 | config :phoenix, :stacktrace_depth, 35 36 | -------------------------------------------------------------------------------- /elixir_buildpack.config: -------------------------------------------------------------------------------- 1 | erlang_version=24.0 2 | elixir_version=1.12.1 3 | always_rebuild=true 4 | config_vars_to_export=(DATABASE_URL MANDRILL_KEY URL_PORT SHUBOX_KEY PERMITTED_EMAIL_DOMAIN) 5 | -------------------------------------------------------------------------------- /lib/constable.ex: -------------------------------------------------------------------------------- 1 | defmodule Constable do 2 | @moduledoc """ 3 | Constable keeps the contexts that define your domain 4 | and business logic. 5 | Contexts are also responsible for managing your data, regardless 6 | if it comes from the database, an external API or others. 7 | """ 8 | end 9 | -------------------------------------------------------------------------------- /lib/constable/announcement_interest.ex: -------------------------------------------------------------------------------- 1 | defmodule Constable.AnnouncementInterest do 2 | use Ecto.Schema 3 | alias Constable.Announcement 4 | alias Constable.Interest 5 | 6 | schema "announcements_interests" do 7 | timestamps() 8 | 9 | belongs_to :announcement, Announcement 10 | belongs_to :interest, Interest 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/constable/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Constable.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | 5 | @moduledoc false 6 | 7 | use Application 8 | 9 | def start(_type, _args) do 10 | unless Mix.env() == "production" do 11 | Envy.auto_load() 12 | Envy.reload_config() 13 | end 14 | 15 | Neuron.Config.set(url: "#{Application.fetch_env!(:constable, :hub_url)}/graphql") 16 | 17 | Neuron.Config.set( 18 | headers: [authorization: "Bearer #{Application.fetch_env!(:constable, :hub_api_token)}"] 19 | ) 20 | 21 | setup_dependencies() 22 | 23 | children = [ 24 | Constable.Repo, 25 | {Phoenix.PubSub, name: Constable.PubSub}, 26 | ConstableWeb.Endpoint, 27 | {Task.Supervisor, name: Constable.TaskSupervisor} 28 | ] 29 | 30 | opts = [strategy: :one_for_one, name: Constable.Supervisor] 31 | Supervisor.start_link(children, opts) 32 | end 33 | 34 | defp setup_dependencies do 35 | Constable.Pact.start_link() 36 | end 37 | 38 | # Tell Phoenix to update the endpoint configuration 39 | # whenever the application is updated. 40 | def config_change(changed, _new, removed) do 41 | ConstableWeb.Endpoint.config_change(changed, removed) 42 | :ok 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/constable/comment.ex: -------------------------------------------------------------------------------- 1 | defmodule Constable.Comment do 2 | use ConstableWeb, :schema 3 | alias Constable.Announcement 4 | alias Constable.User 5 | 6 | schema "comments" do 7 | field :body 8 | 9 | belongs_to :user, User 10 | belongs_to :announcement, Announcement 11 | timestamps() 12 | end 13 | 14 | def create_changeset(model \\ %__MODULE__{}, params) do 15 | create_changeset(model, params, DateTime.utc_now()) 16 | end 17 | 18 | def create_changeset(model, params, last_discussed_at) do 19 | model 20 | |> cast(params, ~w(announcement_id user_id body)a) 21 | |> validate_required(:body) 22 | |> set_last_discussed_at(last_discussed_at) 23 | end 24 | 25 | def update_changeset(model, params) do 26 | model 27 | |> cast(params, ~w(body)a) 28 | |> validate_required(:body) 29 | end 30 | 31 | defp set_last_discussed_at(changeset, last_discussed_at) do 32 | prepare_changes(changeset, fn changeset -> 33 | Announcement 34 | |> where(id: ^get_field(changeset, :announcement_id)) 35 | |> changeset.repo.update_all(set: [last_discussed_at: last_discussed_at]) 36 | 37 | changeset 38 | end) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/constable/env.ex: -------------------------------------------------------------------------------- 1 | defmodule Constable.Env do 2 | @doc """ 3 | Gets a variable from the environment and throws an error if it does not exist 4 | """ 5 | def get(key) do 6 | System.get_env() |> Map.fetch!(key) 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/constable/interest.ex: -------------------------------------------------------------------------------- 1 | defmodule Constable.Interest do 2 | defimpl Phoenix.Param do 3 | def to_param(%{name: name}) do 4 | "#{name}" 5 | end 6 | end 7 | 8 | use ConstableWeb, :schema 9 | alias Constable.{Announcement, User} 10 | alias Constable.{AnnouncementInterest, UserInterest} 11 | 12 | schema "interests" do 13 | field :name 14 | field :slack_channel 15 | timestamps() 16 | 17 | many_to_many :announcements, Announcement, join_through: AnnouncementInterest 18 | many_to_many :users, User, join_through: UserInterest 19 | end 20 | 21 | def changeset(interest \\ %__MODULE__{}, params) do 22 | interest 23 | |> cast(params, ~w(name slack_channel)a) 24 | |> validate_required(:name) 25 | |> update_change(:name, &String.trim(&1)) 26 | |> update_change(:name, &String.replace(&1, "#", "")) 27 | |> update_change(:name, &String.replace(&1, " ", "-")) 28 | |> update_change(:name, &String.downcase/1) 29 | |> unique_constraint(:name) 30 | end 31 | 32 | def update_channel_changeset(interest, channel_name) do 33 | interest 34 | |> cast(%{slack_channel: channel_name}, ~w(slack_channel)a) 35 | |> update_change(:slack_channel, &Regex.replace(~r/^#*/, &1, "#")) 36 | end 37 | 38 | def ordered_by_name(query \\ __MODULE__) do 39 | from i in query, order_by: [asc: i.name] 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/constable/mailer.ex: -------------------------------------------------------------------------------- 1 | defmodule Constable.Mailer do 2 | use Bamboo.Mailer, otp_app: :constable 3 | end 4 | -------------------------------------------------------------------------------- /lib/constable/markdown.ex: -------------------------------------------------------------------------------- 1 | defmodule Constable.Markdown do 2 | alias Earmark.Options 3 | 4 | def to_html(markdown) do 5 | markdown 6 | |> Earmark.as_html!(%Options{smartypants: false}) 7 | |> HtmlSanitizeEx.Scrubber.scrub(Constable.CustomScrubber) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/constable/pact.ex: -------------------------------------------------------------------------------- 1 | defmodule Constable.Pact do 2 | use Pact 3 | alias Constable.Services.HubProfileProvider 4 | 5 | register(:daily_digest, Constable.DailyDigest) 6 | register(:google_strategy, GoogleStrategy) 7 | register(:profile_provider, HubProfileProvider) 8 | register(:oauth_redirect_strategy, OAuthRedirectStrategy) 9 | register(:request_with_access_token, OAuth2.Client) 10 | register(:token_retriever, OAuth2.Strategy.AuthCode) 11 | end 12 | -------------------------------------------------------------------------------- /lib/constable/plugs/deslugifier.ex: -------------------------------------------------------------------------------- 1 | defmodule Constable.Plugs.Deslugifier do 2 | alias Constable.Slug 3 | 4 | def init(opts) do 5 | case Keyword.get(opts, :slugified_key) do 6 | nil -> raise "Must provide a :slugified_key to #{inspect(__MODULE__)}" 7 | key -> key 8 | end 9 | end 10 | 11 | def call(conn, key) do 12 | with {:ok, slugified_id} <- Map.fetch(conn.params, key), 13 | {:ok, id} <- Slug.deslugify(slugified_id) do 14 | put_in(conn, [Access.key!(:params), key], id) 15 | else 16 | _ -> conn 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/constable/plugs/fetch_current_user.ex: -------------------------------------------------------------------------------- 1 | defmodule Constable.Plugs.FetchCurrentUser do 2 | import Plug.Conn 3 | 4 | alias Constable.{Repo, User, UserIdentifier} 5 | 6 | def init(opts), do: opts 7 | 8 | def call(conn, _) do 9 | case find_user(conn) do 10 | :not_signed_in -> 11 | conn 12 | 13 | nil -> 14 | conn 15 | 16 | user -> 17 | conn 18 | |> assign(:current_user, user) 19 | |> put_session(:current_user_id, user.id) 20 | end 21 | end 22 | 23 | def find_user(conn) do 24 | case UserIdentifier.verify_signed_user_id(conn) do 25 | {:ok, user_id} -> Repo.get(User.active(), user_id) 26 | {:error, _} -> :not_signed_in 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/constable/plugs/require_api_login.ex: -------------------------------------------------------------------------------- 1 | defmodule Constable.Plugs.RequireApiLogin do 2 | import Plug.Conn 3 | 4 | alias Constable.Repo 5 | alias Constable.User 6 | 7 | def init(default), do: default 8 | 9 | def call(conn, _) do 10 | case find_user(conn) do 11 | nil -> 12 | unauthorized(conn) 13 | 14 | user -> 15 | conn |> assign(:current_user, user) 16 | end 17 | end 18 | 19 | def find_user(conn) do 20 | fetch_token(conn) 21 | |> find_user_from_token 22 | end 23 | 24 | def fetch_token(conn) do 25 | get_req_header(conn, "authorization") |> List.first() 26 | end 27 | 28 | def find_user_from_token(nil), do: nil 29 | 30 | def find_user_from_token(token) do 31 | Repo.get_by(User.active(), token: token) 32 | end 33 | 34 | def unauthorized(conn) do 35 | conn |> send_resp(401, "") |> halt 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/constable/plugs/require_web_login.ex: -------------------------------------------------------------------------------- 1 | defmodule Constable.Plugs.RequireWebLogin do 2 | import Plug.Conn 3 | 4 | def init(opts), do: opts 5 | 6 | def call(conn, _) do 7 | if conn.assigns[:current_user] do 8 | conn 9 | else 10 | conn |> redirect_to_login 11 | end 12 | end 13 | 14 | defp redirect_to_login(conn) do 15 | conn 16 | |> maybe_store_request_path 17 | |> Phoenix.Controller.redirect(to: "/session/new") 18 | |> halt 19 | end 20 | 21 | defp maybe_store_request_path(conn) do 22 | if conn.method == "GET" do 23 | conn |> put_session(:original_request_path, conn.request_path) 24 | else 25 | conn 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/constable/plugs/set_user_id_from_params.ex: -------------------------------------------------------------------------------- 1 | defmodule Constable.Plugs.SetUserIdFromParams do 2 | alias Constable.UserIdentifier 3 | 4 | def init(opts), do: opts 5 | 6 | def call(conn, _opts) do 7 | if user_id = conn.params["as"] do 8 | set_user_id_cookie(conn, user_id) 9 | else 10 | conn 11 | end 12 | end 13 | 14 | defp set_user_id_cookie(conn, user_id) do 15 | signed_user_id = UserIdentifier.sign_user_id(conn, user_id) 16 | conn |> Plug.Conn.put_resp_cookie("user_id", signed_user_id) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/constable/profiles.ex: -------------------------------------------------------------------------------- 1 | defmodule Constable.Profiles do 2 | alias Constable.{User, Repo} 3 | 4 | def update_profile_info(user) do 5 | provider = Constable.Pact.get(:profile_provider) 6 | profile_url = provider.fetch_profile_url(user) 7 | image_url = provider.fetch_image_url(user) 8 | 9 | user 10 | |> User.profile_changeset(%{profile_url: profile_url, profile_image_url: image_url}) 11 | |> Repo.update() 12 | end 13 | 14 | def update_profile_image_urls(users) do 15 | provider = Constable.Pact.get(:profile_provider) 16 | 17 | Enum.map(users, fn user -> 18 | image_url = provider.fetch_image_url(user) 19 | 20 | user 21 | |> User.profile_changeset(%{profile_image_url: image_url}) 22 | |> Repo.update() 23 | end) 24 | 25 | :ok 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/constable/pub_sub.ex: -------------------------------------------------------------------------------- 1 | defmodule Constable.PubSub do 2 | def subscribe_to_announcement(announcement_id) do 3 | Phoenix.PubSub.subscribe(__MODULE__, announcement_topic(announcement_id)) 4 | end 5 | 6 | def broadcast_new_comment(comment) do 7 | Phoenix.PubSub.broadcast( 8 | __MODULE__, 9 | announcement_topic(comment.announcement_id), 10 | {:new_comment, comment} 11 | ) 12 | end 13 | 14 | defp announcement_topic(announcement_id) do 15 | "announcement:#{announcement_id}" 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/constable/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Constable.Repo do 2 | use Ecto.Repo, 3 | otp_app: :constable, 4 | adapter: Ecto.Adapters.Postgres 5 | 6 | use Scrivener, page_size: 30 7 | import Ecto.Query 8 | 9 | def count(query) do 10 | one!(from record in query, select: count(record.id)) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/constable/serializers/announcement.ex: -------------------------------------------------------------------------------- 1 | defimpl Poison.Encoder, for: Constable.Announcement do 2 | alias Constable.Repo 3 | @attributes ~W( 4 | id 5 | title 6 | body 7 | user 8 | comments 9 | interests 10 | inserted_at 11 | updated_at 12 | )a 13 | 14 | def encode(announcement, _options) do 15 | announcement 16 | |> Repo.preload([:comments, :user, :interests]) 17 | |> Map.take(@attributes) 18 | |> set_interests 19 | |> Poison.encode!() 20 | end 21 | 22 | def set_interests(announcement) do 23 | interest_names = Enum.map(announcement.interests, fn interest -> interest.name end) 24 | Map.put(announcement, "interests", interest_names) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/constable/serializers/comment.ex: -------------------------------------------------------------------------------- 1 | defimpl Poison.Encoder, for: Constable.Comment do 2 | @attributes ~W(id body user announcement_id inserted_at)a 3 | 4 | def encode(comment, _options) do 5 | comment |> Map.take(@attributes) |> Poison.encode!() 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/constable/serializers/interest.ex: -------------------------------------------------------------------------------- 1 | defimpl Poison.Encoder, for: Constable.Interest do 2 | def encode(interest, _options) do 3 | %{ 4 | id: interest.id, 5 | name: interest.name 6 | } 7 | |> Poison.encode!() 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/constable/serializers/serializers.ex: -------------------------------------------------------------------------------- 1 | defmodule Constable.Serializers do 2 | def ids_as_keys(objects) do 3 | Enum.reduce(objects, %{}, fn object, objects -> 4 | Map.put(objects, to_string(object.id), object) 5 | end) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/constable/serializers/subscription.ex: -------------------------------------------------------------------------------- 1 | defimpl Poison.Encoder, for: Constable.Subscription do 2 | def encode(subscription, _options) do 3 | %{ 4 | id: subscription.id, 5 | user_id: subscription.user_id, 6 | announcement_id: subscription.announcement_id 7 | } 8 | |> Poison.encode!() 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/constable/serializers/user.ex: -------------------------------------------------------------------------------- 1 | defimpl Poison.Encoder, for: Constable.User do 2 | use ConstableWeb, :serializer 3 | alias Constable.Repo 4 | 5 | def encode(user, _options) do 6 | user = user |> Repo.preload([:subscriptions, :user_interests]) 7 | 8 | %{ 9 | id: user.id, 10 | email: user.email, 11 | name: user.name, 12 | profile_image_url: user.profile_image_url, 13 | user_interests: user.user_interests, 14 | subscriptions: user.subscriptions 15 | } 16 | |> Poison.encode!([]) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/constable/serializers/user_interest.ex: -------------------------------------------------------------------------------- 1 | defimpl Poison.Encoder, for: Constable.UserInterest do 2 | def encode(user_interest, _options) do 3 | %{ 4 | id: user_interest.id, 5 | user_id: user_interest.user_id, 6 | interest_id: user_interest.interest_id 7 | } 8 | |> Poison.encode!() 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/constable/services/announcement_interest_associator.ex: -------------------------------------------------------------------------------- 1 | defmodule Constable.Services.AnnouncementInterestAssociator do 2 | alias Constable.Repo 3 | alias Constable.Interest 4 | alias Constable.AnnouncementInterest 5 | 6 | alias ConstableWeb.Api.InterestView 7 | 8 | def add_interests(announcement, names) do 9 | get_or_create_interests(names) 10 | |> associate_interests_with_announcement(announcement) 11 | end 12 | 13 | defp get_or_create_interests(names) do 14 | List.wrap(names) 15 | |> Enum.reject(&blank_interest?/1) 16 | |> Enum.map(fn name -> 17 | interest = Interest.changeset(%{name: name}) 18 | 19 | case Repo.get_by(Interest, interest.changes) do 20 | nil -> create_and_broadcast(interest) 21 | interest -> interest 22 | end 23 | end) 24 | |> Enum.uniq() 25 | end 26 | 27 | defp associate_interests_with_announcement(interests, announcement) do 28 | Enum.each(interests, fn interest -> 29 | %AnnouncementInterest{ 30 | interest_id: interest.id, 31 | announcement_id: announcement.id 32 | } 33 | |> Repo.insert!() 34 | end) 35 | 36 | announcement 37 | end 38 | 39 | defp blank_interest?(" " <> rest), do: blank_interest?(rest) 40 | defp blank_interest?(""), do: true 41 | defp blank_interest?(_), do: false 42 | 43 | defp create_and_broadcast(changeset) do 44 | interest = changeset |> Repo.insert!() 45 | 46 | ConstableWeb.Endpoint.broadcast!( 47 | "update", 48 | "interest:add", 49 | InterestView.render("show.json", %{interest: interest}) 50 | ) 51 | 52 | interest 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/constable/services/announcement_updater.ex: -------------------------------------------------------------------------------- 1 | defmodule Constable.Services.AnnouncementUpdater do 2 | import Ecto.Query 3 | 4 | alias Constable.Announcement 5 | alias Constable.AnnouncementInterest 6 | alias Constable.Services.AnnouncementInterestAssociator 7 | alias Constable.Repo 8 | 9 | def update(announcement, params, interest_names) do 10 | changeset = Announcement.update_changeset(announcement, params) 11 | 12 | case Repo.update(changeset) do 13 | {:ok, announcement} -> 14 | announcement 15 | |> clear_interests 16 | |> update_interests(interest_names) 17 | 18 | {:ok, announcement} 19 | 20 | error -> 21 | error 22 | end 23 | end 24 | 25 | defp clear_interests(announcement) do 26 | Repo.delete_all( 27 | from ai in AnnouncementInterest, 28 | where: ai.announcement_id == ^announcement.id 29 | ) 30 | 31 | announcement 32 | end 33 | 34 | defp update_interests(announcement, names) do 35 | AnnouncementInterestAssociator.add_interests(announcement, names) 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/constable/services/comment_creator.ex: -------------------------------------------------------------------------------- 1 | defmodule Constable.Services.CommentCreator do 2 | alias Constable.Comment 3 | alias Constable.Repo 4 | alias Constable.Services.MentionFinder 5 | alias Constable.Subscription 6 | alias Constable.Emails 7 | alias Constable.Mailer 8 | 9 | def create(params) do 10 | changeset = Comment.create_changeset(params) 11 | 12 | case Repo.insert(changeset) do 13 | {:ok, comment} -> 14 | comment = comment |> Repo.preload([:user, announcement: :user]) 15 | broadcast(comment) 16 | mentioned_users = email_mentioned_users(comment) 17 | email_subscribers(comment, mentioned_users) 18 | subscribe_comment_author(comment) 19 | {:ok, comment} 20 | 21 | {:error, changeset} -> 22 | {:error, changeset} 23 | end 24 | end 25 | 26 | defp subscribe_comment_author(comment) do 27 | comment 28 | |> Map.take([:announcement_id, :user_id]) 29 | |> Subscription.changeset() 30 | |> Repo.insert!(on_conflict: :nothing) 31 | end 32 | 33 | defp email_subscribers(comment, mentioned_users) do 34 | users = 35 | (find_subscribed_users(comment.announcement_id) -- mentioned_users) 36 | |> Enum.reject(fn user -> user.id == comment.user_id end) 37 | 38 | Emails.new_comment(comment, users) |> Mailer.deliver_later!() 39 | end 40 | 41 | defp email_mentioned_users(comment) do 42 | users = MentionFinder.find_users(comment.body) 43 | 44 | Emails.new_comment_mention(comment, users) |> Mailer.deliver_later!() 45 | users 46 | end 47 | 48 | defp find_subscribed_users(announcement_id) do 49 | Repo.all(Subscription.for_announcement(announcement_id)) 50 | |> Enum.map(fn subscription -> subscription.user end) 51 | end 52 | 53 | defp broadcast(comment) do 54 | Constable.PubSub.broadcast_new_comment(comment) 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/constable/services/daily_digest.ex: -------------------------------------------------------------------------------- 1 | defmodule Constable.DailyDigest do 2 | import Ecto.Query 3 | require Logger 4 | alias Constable.Repo 5 | alias Constable.Announcement 6 | alias Constable.Comment 7 | alias Constable.Interest 8 | alias Constable.Mailer 9 | alias Constable.Emails 10 | 11 | def send_email(users, time) do 12 | if new_items_since?(time) do 13 | Logger.info("Sending daily digest") 14 | daily_digest_email(time, users) |> Mailer.deliver_now!() 15 | else 16 | Logger.info("No new items since: #{inspect(time)}") 17 | end 18 | end 19 | 20 | defp daily_digest_email(time, users) do 21 | Emails.daily_digest( 22 | interests_since(time), 23 | announcements_since(time), 24 | comments_since(time), 25 | users 26 | ) 27 | end 28 | 29 | defp new_items_since?(time) do 30 | !Enum.empty?(announcements_since(time)) || 31 | !Enum.empty?(interests_since(time)) || 32 | !Enum.empty?(comments_since(time)) 33 | end 34 | 35 | defp interests_since(time) do 36 | Repo.all(from i in Interest, where: i.inserted_at > ^time) 37 | end 38 | 39 | defp announcements_since(time) do 40 | Repo.all(from i in Announcement, where: i.inserted_at > ^time) 41 | |> Repo.preload([:user, :interests]) 42 | end 43 | 44 | defp comments_since(time) do 45 | Repo.all(from i in Comment, where: i.inserted_at > ^time) 46 | |> Repo.preload([:user, announcement: :user]) 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/constable/services/email_reply_parser.ex: -------------------------------------------------------------------------------- 1 | defmodule Constable.EmailReplyParser do 2 | def remove_original_email(email) do 3 | email 4 | |> remove_quoted_email 5 | |> remove_trailing_newlines 6 | end 7 | 8 | defp remove_quoted_email(body) do 9 | Enum.reduce(reply_header_formats(), body, fn regex, email_body -> 10 | match = Regex.split(regex, email_body) 11 | List.first(match) 12 | end) 13 | end 14 | 15 | defp reply_header_formats do 16 | [ 17 | ~r/\n\>?[[:space:]]*On.*?.*\n?wrote:\n?/ 18 | ] 19 | end 20 | 21 | defp remove_trailing_newlines(body) do 22 | Regex.replace(~r/\n+$/, body, "") 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/constable/services/fake_google_strategy.ex: -------------------------------------------------------------------------------- 1 | defmodule FakeGoogleStrategy do 2 | def get_token!(_redirect_uri, _params), do: "fake_token" 3 | end 4 | -------------------------------------------------------------------------------- /lib/constable/services/fake_profile_provider.ex: -------------------------------------------------------------------------------- 1 | defmodule FakeProfileProvider do 2 | alias ConstableWeb.Endpoint 3 | alias Constable.Services.ProfileProvider 4 | @behaviour ProfileProvider 5 | 6 | @impl ProfileProvider 7 | def fetch_profile_url(_user) do 8 | "http://example.com/" 9 | end 10 | 11 | @impl ProfileProvider 12 | def fetch_image_url(_user) do 13 | "#{Endpoint.url()}/images/ralph.png" 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/constable/services/hub_profile_provider.ex: -------------------------------------------------------------------------------- 1 | defmodule Constable.Services.HubProfileProvider do 2 | alias Constable.Services.ProfileProvider 3 | @behaviour ProfileProvider 4 | 5 | @impl ProfileProvider 6 | def fetch_profile_url(user) do 7 | "#{Application.fetch_env!(:constable, :hub_url)}/people/#{fetch_slug(user)}" 8 | end 9 | 10 | @impl ProfileProvider 11 | def fetch_image_url(user) do 12 | case Neuron.query(person_image_url_by_email_query(user.email)) do 13 | {:ok, %Neuron.Response{body: %{"data" => %{"person" => %{"image_url" => image_url}}}}} -> 14 | image_url 15 | 16 | _ -> 17 | nil 18 | end 19 | end 20 | 21 | defp fetch_slug(user) do 22 | case Neuron.query(person_slug_by_email_query(user.email)) do 23 | {:ok, %Neuron.Response{body: %{"data" => %{"person" => %{"slug" => slug}}}}} -> 24 | slug 25 | 26 | _ -> 27 | nil 28 | end 29 | end 30 | 31 | defp person_slug_by_email_query(email) do 32 | """ 33 | { 34 | person(email: "#{email}") { 35 | slug 36 | } 37 | } 38 | """ 39 | end 40 | 41 | defp person_image_url_by_email_query(email) do 42 | """ 43 | { 44 | person(email: "#{email}") { 45 | image_url 46 | } 47 | } 48 | """ 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/constable/services/mention_finder.ex: -------------------------------------------------------------------------------- 1 | defmodule Constable.Services.MentionFinder do 2 | @mention_regex ~r/@([\w\.]+)/u 3 | 4 | alias Constable.Repo 5 | alias Constable.User 6 | 7 | def find_users(text) do 8 | Regex.scan(@mention_regex, text) 9 | |> Enum.map(fn [_, username] -> username end) 10 | |> Enum.map(&Repo.get_by(User.active(), username: &1)) 11 | |> Enum.reject(&is_nil/1) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/constable/services/oauth_redirect_strategy.ex: -------------------------------------------------------------------------------- 1 | defmodule OAuthRedirectStrategy do 2 | def redirect_uri(original_redirect_uri) do 3 | oauth_redirect_override() || original_redirect_uri 4 | end 5 | 6 | def state_param(original_redirect_uri) do 7 | if oauth_redirect_override() do 8 | original_redirect_uri 9 | else 10 | nil 11 | end 12 | end 13 | 14 | defp oauth_redirect_override do 15 | System.get_env("OAUTH_REDIRECT_OVERRIDE") 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/constable/services/profile_provider.ex: -------------------------------------------------------------------------------- 1 | defmodule Constable.Services.ProfileProvider do 2 | alias Constable.User 3 | 4 | @callback fetch_profile_url(User.t()) :: String.t() 5 | @callback fetch_image_url(User.t()) :: String.t() 6 | end 7 | -------------------------------------------------------------------------------- /lib/constable/services/slack_hook.ex: -------------------------------------------------------------------------------- 1 | defmodule Constable.Services.SlackHook do 2 | import ConstableWeb.Router.Helpers 3 | 4 | alias Constable.Repo 5 | 6 | def new_announcement(announcement) do 7 | announcement = announcement |> Repo.preload([:interests, :user]) 8 | 9 | Enum.each(announcement.interests, fn interest -> 10 | if interest.slack_channel do 11 | payload = %{ 12 | text: 13 | "#{announcement.user.name} posted <#{announcement_url(ConstableWeb.Endpoint, :show, announcement)}|#{announcement.title}>", 14 | channel: interest.slack_channel 15 | } 16 | 17 | post(payload) 18 | end 19 | end) 20 | end 21 | 22 | defp post(payload) do 23 | if slack_webhook_url() do 24 | spawn(fn -> 25 | HTTPoison.post(slack_webhook_url(), Poison.encode!(payload)) 26 | end) 27 | end 28 | end 29 | 30 | defp slack_webhook_url do 31 | System.get_env("SLACK_WEBHOOK_URL") 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/constable/slug.ex: -------------------------------------------------------------------------------- 1 | defmodule Constable.Slug do 2 | def deslugify!(slugified_id) do 3 | case deslugify(slugified_id) do 4 | {:ok, int} -> int 5 | :error -> raise "Invalid slug" 6 | end 7 | end 8 | 9 | def deslugify(slugified_id) do 10 | case Integer.parse(slugified_id) do 11 | {int, _} when int > 0 -> {:ok, int} 12 | _ -> :error 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/constable/subscription.ex: -------------------------------------------------------------------------------- 1 | defmodule Constable.Subscription do 2 | use ConstableWeb, :schema 3 | alias Constable.Announcement 4 | alias Constable.User 5 | 6 | schema "subscriptions" do 7 | field :token 8 | belongs_to :user, User 9 | belongs_to :announcement, Announcement 10 | timestamps() 11 | end 12 | 13 | def changeset(subscription \\ %__MODULE__{}, params) do 14 | subscription 15 | |> cast(params, ~w(user_id announcement_id)a) 16 | |> generate_token 17 | end 18 | 19 | def for_announcement(announcement_id) do 20 | from s in __MODULE__, 21 | join: u in assoc(s, :user), 22 | where: u.active, 23 | where: s.announcement_id == ^announcement_id, 24 | preload: [:user] 25 | end 26 | 27 | defp generate_token(changeset) do 28 | token = SecureRandom.urlsafe_base64(32) 29 | put_change(changeset, :token, token) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/constable/task_supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Constable.TaskSupervisor do 2 | def one_off_task(function_to_perform) do 3 | Task.Supervisor.start_child(__MODULE__, function_to_perform) 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/constable/time.ex: -------------------------------------------------------------------------------- 1 | defmodule Constable.Time do 2 | def now do 3 | DateTime.utc_now() 4 | end 5 | 6 | def yesterday do 7 | days_ago(1) 8 | end 9 | 10 | def days_ago(count) do 11 | GoodTimes.days_ago(count) |> cast! 12 | end 13 | 14 | def cast!(time) do 15 | time |> NaiveDateTime.from_erl!() |> DateTime.from_naive!("Etc/UTC") 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/constable/user_identifier.ex: -------------------------------------------------------------------------------- 1 | defmodule Constable.UserIdentifier do 2 | @one_week 86400 * 7 3 | @salt "user_id" 4 | 5 | def sign_user_id(conn, user_id) do 6 | Phoenix.Token.sign(conn, @salt, user_id) 7 | end 8 | 9 | def verify_signed_user_id(conn) do 10 | Phoenix.Token.verify(conn, @salt, conn.cookies["user_id"], max_age: @one_week) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/constable/user_interest.ex: -------------------------------------------------------------------------------- 1 | defmodule Constable.UserInterest do 2 | use ConstableWeb, :schema 3 | alias Constable.User 4 | alias Constable.Interest 5 | 6 | schema "users_interests" do 7 | timestamps() 8 | 9 | belongs_to :user, User 10 | belongs_to :interest, Interest 11 | end 12 | 13 | def changeset(user_interest \\ %__MODULE__{}, params) do 14 | user_interest 15 | |> cast(params, ~w(user_id interest_id)a) 16 | |> unique_constraint(:user_id, name: :users_interests_interest_id_user_id_index) 17 | |> unique_constraint(:interest_id, name: :users_interests_interest_id_user_id_index) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/constable_web/channels/user_socket.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.UserSocket do 2 | use Phoenix.Socket 3 | require Logger 4 | 5 | alias Constable.Repo 6 | alias Constable.User 7 | 8 | def connect(%{"token" => token}, socket, _connect_info) do 9 | if user = user_with_token(token) do 10 | socket = assign(socket, :current_user, user) 11 | {:ok, socket} 12 | else 13 | {:error, "Unauthorized"} 14 | end 15 | end 16 | 17 | def connect(params, _socket, _connect_info) do 18 | Logger.debug("Expected socket params to have a 'token', got: #{inspect(params)}") 19 | end 20 | 21 | def id(_socket), do: nil 22 | 23 | defp user_with_token(token) do 24 | Repo.get_by!(User, token: token) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/constable_web/controller_helper.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.ControllerHelper do 2 | def unauthorized(conn) do 3 | Plug.Conn.send_resp(conn, 401, "") 4 | end 5 | 6 | def current_user(conn) do 7 | conn.assigns[:current_user] 8 | end 9 | 10 | def page_title(conn, title) do 11 | Plug.Conn.assign(conn, :page_title, title) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/constable_web/controllers/api/comment_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.Api.CommentController do 2 | use ConstableWeb, :controller 3 | 4 | alias Constable.Services.CommentCreator 5 | 6 | plug Constable.Plugs.Deslugifier, slugified_key: "announcement_id" 7 | 8 | def create(conn, %{"comment" => params}) do 9 | current_user = current_user(conn) 10 | params = Map.put(params, "user_id", current_user.id) 11 | 12 | case CommentCreator.create(params) do 13 | {:ok, comment} -> 14 | conn |> put_status(201) |> render("show.json", comment: comment) 15 | 16 | {:error, changeset} -> 17 | conn 18 | |> put_status(422) 19 | |> render(ConstableWeb.ChangesetView, "error.json", changeset: changeset) 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/constable_web/controllers/api/interest_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.Api.InterestController do 2 | use ConstableWeb, :controller 3 | 4 | alias Constable.Interest 5 | alias ConstableWeb.Api.InterestView 6 | 7 | def index(conn, _params) do 8 | interests = Repo.all(Interest) 9 | 10 | render(conn, "index.json", interests: interests) 11 | end 12 | 13 | def show(conn, %{"id" => id}) do 14 | interest = Repo.get!(Interest, id) 15 | render(conn, "show.json", interest: interest) 16 | end 17 | 18 | def update(conn, %{"id" => id, "channel" => channel}) do 19 | interest = Repo.get!(Interest, id) 20 | 21 | case Repo.update(Interest.update_channel_changeset(interest, channel)) do 22 | {:ok, interest} -> 23 | ConstableWeb.Endpoint.broadcast!( 24 | "update", 25 | "interest:update", 26 | InterestView.render("show.json", %{interest: interest}) 27 | ) 28 | 29 | render(conn, "show.json", interest: interest) 30 | 31 | {:error, changeset} -> 32 | conn 33 | |> put_status(:unprocessable_entity) 34 | |> render(ConstableWeb.ChangesetView, "error.json", changeset: changeset) 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/constable_web/controllers/api/search_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.Api.SearchController do 2 | use ConstableWeb, :controller 3 | 4 | plug :put_view, ConstableWeb.Api.AnnouncementView 5 | 6 | alias Constable.Announcement 7 | 8 | def create(conn, params = %{"query" => search_terms}) do 9 | excludes = params["exclude_interests"] || [] 10 | 11 | announcements = 12 | search_terms 13 | |> Announcement.search(exclude_interests: excludes) 14 | |> Announcement.last_discussed_first() 15 | |> Repo.all() 16 | 17 | render(conn, "index.json", announcements: announcements) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/constable_web/controllers/api/subscription_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.Api.SubscriptionController do 2 | use ConstableWeb, :controller 3 | 4 | alias Constable.Subscription 5 | 6 | plug Constable.Plugs.Deslugifier, slugified_key: "announcement_id" 7 | 8 | def index(conn, _params) do 9 | current_user = current_user(conn) 10 | subscriptions = subscriptions_for(current_user) 11 | 12 | render(conn, "index.json", subscriptions: subscriptions) 13 | end 14 | 15 | def create(conn, %{"subscription" => params}) do 16 | current_user = current_user(conn) 17 | params = params |> Map.put("user_id", current_user.id) 18 | 19 | changeset = Subscription.changeset(params) 20 | 21 | case Repo.insert(changeset) do 22 | {:ok, subscription} -> 23 | conn |> put_status(201) |> render("show.json", subscription: subscription) 24 | 25 | {:error, changeset} -> 26 | conn 27 | |> put_status(422) 28 | |> render(ConstableWeb.ChangesetView, "error.json", changeset: changeset) 29 | end 30 | end 31 | 32 | def delete(conn, %{"id" => id}) do 33 | current_user = current_user(conn) 34 | subscription = Repo.get!(Subscription, id) 35 | 36 | if current_user.id == subscription.user_id do 37 | Repo.delete!(subscription) 38 | send_resp(conn, 204, "") 39 | else 40 | unauthorized(conn) 41 | end 42 | end 43 | 44 | defp subscriptions_for(user) do 45 | Repo.all(Ecto.assoc(user, :subscriptions)) 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/constable_web/controllers/api/user_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.Api.UserController do 2 | use ConstableWeb, :controller 3 | 4 | alias Constable.{User, Profiles} 5 | 6 | def create(conn, %{"user" => user_params}) do 7 | changeset = User.create_changeset(%User{}, user_params) 8 | 9 | case Repo.insert(changeset) do 10 | {:ok, user} -> 11 | update_profile_info(user) 12 | render(conn, "show.json", user: user) 13 | 14 | {:error, changeset} -> 15 | conn 16 | |> put_status(:unprocessable_entity) 17 | |> put_view(ConstableWeb.ChangesetView) 18 | |> render("error.json", changeset: changeset) 19 | end 20 | end 21 | 22 | defp update_profile_info(user) do 23 | Constable.TaskSupervisor.one_off_task(fn -> Profiles.update_profile_info(user) end) 24 | end 25 | 26 | def index(conn, _params) do 27 | users = all_users_ordered_by_name() 28 | 29 | render(conn, "index.json", users: users) 30 | end 31 | 32 | def show(conn, %{"id" => "me"}) do 33 | current_user = current_user(conn) 34 | render(conn, "show.json", user: current_user) 35 | end 36 | 37 | def show(conn, %{"id" => id}) do 38 | user = Repo.get!(User, id) 39 | render(conn, "show.json", user: user) 40 | end 41 | 42 | def update(conn, %{"user" => params}) do 43 | current_user = current_user(conn) 44 | changeset = User.settings_changeset(current_user, params) 45 | 46 | case Repo.update(changeset) do 47 | {:ok, user} -> 48 | render(conn, "show.json", user: user) 49 | 50 | {:error, changeset} -> 51 | conn 52 | |> put_status(:unprocessable_entity) 53 | |> render(ConstableWeb.ChangesetView, "error.json", changeset: changeset) 54 | end 55 | end 56 | 57 | defp all_users_ordered_by_name do 58 | Repo.all(User.ordered_by_name()) 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/constable_web/controllers/api/user_interest_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.Api.UserInterestController do 2 | use ConstableWeb, :controller 3 | 4 | alias Constable.UserInterest 5 | 6 | def index(conn, _params) do 7 | current_user = current_user(conn) 8 | user_interests = user_interests_for(current_user) 9 | 10 | render(conn, "index.json", user_interests: user_interests) 11 | end 12 | 13 | def show(conn, %{"id" => id}) do 14 | user_interest = Repo.get!(UserInterest, id) 15 | render(conn, "show.json", user_interest: user_interest) 16 | end 17 | 18 | def create(conn, %{"user_interest" => params}) do 19 | current_user = current_user(conn) 20 | params = Map.put(params, "user_id", current_user.id) 21 | 22 | changeset = UserInterest.changeset(params) 23 | 24 | case Repo.insert(changeset) do 25 | {:ok, user_interest} -> 26 | conn |> put_status(201) |> render("show.json", user_interest: user_interest) 27 | 28 | {:error, changeset} -> 29 | conn 30 | |> put_status(422) 31 | |> render(ConstableWeb.ChangesetView, "error.json", changeset: changeset) 32 | end 33 | end 34 | 35 | def delete(conn, %{"id" => id}) do 36 | current_user = current_user(conn) 37 | user_interest = Repo.get!(UserInterest, id) 38 | 39 | if current_user.id == user_interest.user_id do 40 | Repo.delete!(user_interest) 41 | send_resp(conn, 204, "") 42 | else 43 | unauthorized(conn) 44 | end 45 | end 46 | 47 | defp user_interests_for(user) do 48 | Repo.all(Ecto.assoc(user, :user_interests)) 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/constable_web/controllers/email_forward_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.EmailForwardController do 2 | use ConstableWeb, :controller 3 | 4 | def create(conn, %{"mandrill_events" => messages}) do 5 | messages 6 | |> Poison.decode!() 7 | |> forward_emails_to_admins 8 | 9 | text(conn, nil) 10 | end 11 | 12 | defp forward_emails_to_admins(forwarded_emails) do 13 | for %{"msg" => message} <- forwarded_emails do 14 | message 15 | |> Constable.Emails.forwarded_email() 16 | |> Constable.Mailer.deliver_now!() 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/constable_web/controllers/email_reply_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.EmailReplyController do 2 | use ConstableWeb, :controller 3 | alias Constable.Services.CommentCreator 4 | alias Constable.User 5 | alias Constable.EmailReplyParser 6 | 7 | def create(conn, %{"mandrill_events" => messages}) do 8 | messages 9 | |> Poison.decode!() 10 | |> create_comments 11 | 12 | text(conn, nil) 13 | end 14 | 15 | defp create_comments(messages) do 16 | for message <- messages do 17 | message |> comment_params |> CommentCreator.create() 18 | end 19 | end 20 | 21 | defp comment_params(%{"msg" => %{"text" => email_body, "email" => to, "from_email" => from}}) do 22 | %{ 23 | user_id: user_from_email(from).id, 24 | announcement_id: announcement_id_from_email(to), 25 | body: EmailReplyParser.remove_original_email(email_body) 26 | } 27 | end 28 | 29 | defp user_from_email(email_address) do 30 | User.with_email(email_address) |> Repo.one() 31 | end 32 | 33 | defp announcement_id_from_email("announcement-" <> key_and_domain) do 34 | key_and_domain |> String.split("@") |> List.first() 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/constable_web/controllers/home_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.HomeController do 2 | use ConstableWeb, :controller 3 | 4 | def index(conn, _params) do 5 | conn |> redirect_to_original_path_or_announcement_index 6 | end 7 | 8 | defp redirect_to_original_path_or_announcement_index(conn) do 9 | original_request_path = get_session(conn, :original_request_path) 10 | 11 | if original_request_path do 12 | conn 13 | |> delete_session(:original_request_path) 14 | |> redirect(to: original_request_path) 15 | else 16 | conn |> redirect(to: Routes.announcement_path(conn, :index)) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/constable_web/controllers/interest_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.InterestController do 2 | use ConstableWeb, :controller 3 | 4 | alias Constable.{Announcement, Interest} 5 | 6 | def index(conn, _params) do 7 | conn 8 | |> assign(:current_user, preload_interests(conn.assigns.current_user)) 9 | |> assign(:interests, all_interests()) 10 | |> page_title("Interests") 11 | |> render("index.html") 12 | end 13 | 14 | def show(conn, params) do 15 | interest = get_interest_by_id_or_name(params["id_or_name"]) 16 | interest_page = sorted_announcements(interest) |> Repo.paginate(params) 17 | 18 | conn 19 | |> assign(:current_user, preload_interests(conn.assigns.current_user)) 20 | |> assign(:announcements, interest_page.entries) 21 | |> assign(:interest_page, interest_page) 22 | |> assign(:interest, interest) 23 | |> page_title("#" <> interest.name) 24 | |> render("show.html") 25 | end 26 | 27 | defp get_interest_by_id_or_name(param) do 28 | case Integer.parse(param) do 29 | :error -> Repo.get_by!(Interest, name: param) 30 | {id, _} -> Repo.get!(Interest, id) 31 | end 32 | end 33 | 34 | defp preload_interests(user) do 35 | Repo.preload(user, :interests) 36 | end 37 | 38 | defp all_interests do 39 | Repo.all(Interest.ordered_by_name()) 40 | end 41 | 42 | defp sorted_announcements(interest) do 43 | Ecto.assoc(interest, :announcements) 44 | |> Announcement.last_discussed_first() 45 | |> Announcement.with_announcement_list_assocs() 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/constable_web/controllers/recipients_preview_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.RecipientsPreviewController do 2 | use ConstableWeb, :controller 3 | 4 | alias Constable.User 5 | 6 | def show(conn, params) do 7 | interest_names = params["interests"] |> String.split(",", trim: true) 8 | 9 | conn 10 | |> put_status(200) 11 | |> render("show.json", 12 | interest_names: interest_names, 13 | interested_user_names: interested_user_names(interest_names) 14 | ) 15 | end 16 | 17 | defp interested_user_names(interest_names) do 18 | query = 19 | from u in User, 20 | distinct: true, 21 | join: i in assoc(u, :interests), 22 | order_by: u.name, 23 | select: u.name, 24 | where: u.active == true, 25 | where: i.name in ^interest_names 26 | 27 | Repo.all(query) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/constable_web/controllers/search_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.SearchController do 2 | use ConstableWeb, :controller 3 | 4 | alias Constable.Announcement 5 | 6 | def show(conn, params) do 7 | announcements = matching_announcements(params) |> Repo.paginate(params) 8 | 9 | conn 10 | |> assign(:announcements, announcements.entries) 11 | |> assign(:search_page, announcements) 12 | |> assign(:query, params["query"]) 13 | |> page_title("Results for '#{params["query"]}'") 14 | |> render("new.html") 15 | end 16 | 17 | def new(conn, _params) do 18 | render(conn, "new.html", announcements: []) 19 | end 20 | 21 | defp matching_announcements(params = %{"query" => search_terms}) do 22 | excluded_interests = params["exclude_interests"] || [] 23 | 24 | Announcement.search(search_terms, exclude_interests: excluded_interests) 25 | |> Announcement.last_discussed_first() 26 | |> Announcement.with_announcement_list_assocs() 27 | end 28 | 29 | defp matching_announcements(_params = %{}) do 30 | Announcement.search("", exclude_interests: []) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/constable_web/controllers/session_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.SessionController do 2 | use ConstableWeb, :controller 3 | 4 | def new(conn, _params) do 5 | if current_user(conn) do 6 | conn |> redirect(to: Routes.home_path(conn, :index)) 7 | else 8 | conn 9 | |> put_layout("login.html") 10 | |> render("new.html") 11 | end 12 | end 13 | 14 | def delete(conn, _params) do 15 | conn 16 | |> logout_user 17 | |> redirect(to: Routes.home_path(conn, :index)) 18 | end 19 | 20 | defp logout_user(conn) do 21 | delete_resp_cookie(conn, "user_id") 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/constable_web/controllers/settings_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.SettingsController do 2 | use ConstableWeb, :controller 3 | 4 | alias Constable.User 5 | 6 | def show(conn, _params) do 7 | changeset = User.settings_changeset(conn.assigns.current_user) 8 | render(conn, "show.html", changeset: changeset) 9 | end 10 | 11 | def update(conn, %{"user" => user_params}) do 12 | changeset = User.settings_changeset(conn.assigns.current_user, user_params) 13 | 14 | case Repo.update(changeset) do 15 | {:ok, _user} -> 16 | conn 17 | |> put_flash(:success, "YES!") 18 | |> render("show.html", changeset: changeset) 19 | 20 | {:error, changeset} -> 21 | conn 22 | |> put_status(:unprocessable_entity) 23 | |> put_flash(:error, gettext("Something went wrong!")) 24 | |> render("show.html", changeset: changeset) 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/constable_web/controllers/slack_channel_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.SlackChannelController do 2 | use ConstableWeb, :controller 3 | 4 | alias Constable.Interest 5 | 6 | def edit(conn, %{"interest_id_or_name" => name}) do 7 | interest = find_interest(name) 8 | changeset = Interest.update_channel_changeset(interest, interest.slack_channel) 9 | render(conn, "edit.html", interest: interest, changeset: changeset) 10 | end 11 | 12 | def update(conn, %{"interest_id_or_name" => name, "interest" => %{"slack_channel" => channel}}) do 13 | interest = find_interest(name) 14 | 15 | case Repo.update(Interest.update_channel_changeset(interest, channel)) do 16 | {:ok, interest} -> 17 | redirect(conn, to: Routes.interest_path(conn, :show, interest)) 18 | 19 | {:error, changeset} -> 20 | conn 21 | |> put_status(:unprocessable_entity) 22 | |> render(ConstableWeb.ChangesetView, "edit.html", changeset: changeset) 23 | end 24 | end 25 | 26 | def delete(conn, %{"interest_id_or_name" => name}) do 27 | interest = find_interest(name) 28 | Repo.update!(Interest.changeset(interest, %{slack_channel: nil})) 29 | redirect(conn, to: Routes.interest_path(conn, :show, interest)) 30 | end 31 | 32 | defp find_interest(interest_name) do 33 | Repo.get_by!(Interest, name: interest_name) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/constable_web/controllers/subscriptions_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.SubscriptionController do 2 | use ConstableWeb, :controller 3 | 4 | alias Constable.Subscription 5 | 6 | plug Constable.Plugs.Deslugifier, slugified_key: "announcement_id" 7 | 8 | def create(conn, %{"announcement_id" => announcement_id}) do 9 | changeset = 10 | Subscription.changeset(%{ 11 | announcement_id: announcement_id, 12 | user_id: conn.assigns.current_user.id 13 | }) 14 | 15 | Repo.insert!(changeset) 16 | 17 | redirect(conn, to: Routes.announcement_path(conn, :show, announcement_id)) 18 | end 19 | 20 | def delete(conn, %{"announcement_id" => announcement_id}) do 21 | subscription = 22 | Repo.get_by(Subscription, 23 | announcement_id: announcement_id, 24 | user_id: conn.assigns.current_user.id 25 | ) 26 | 27 | Repo.delete!(subscription) 28 | redirect(conn, to: Routes.announcement_path(conn, :show, announcement_id)) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/constable_web/controllers/unsubscribe_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.UnsubscribeController do 2 | use ConstableWeb, :controller 3 | 4 | alias Constable.Subscription 5 | 6 | def show(conn, %{"id" => token}) do 7 | subscription = Repo.get_by(subscription_with_announcement(), token: token) 8 | 9 | if subscription do 10 | Repo.delete(subscription) 11 | 12 | conn 13 | |> put_flash(:info, gettext("You've been unsubscribed from this announcement.")) 14 | |> redirect(to: Routes.announcement_path(conn, :show, subscription.announcement)) 15 | else 16 | conn 17 | |> put_flash(:error, gettext("We could not unsubscribe you from the announcement.")) 18 | |> redirect(to: Routes.announcement_path(conn, :index)) 19 | end 20 | end 21 | 22 | defp subscription_with_announcement do 23 | Subscription |> preload(:announcement) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/constable_web/controllers/user_activation_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.UserActivationController do 2 | use ConstableWeb, :controller 3 | 4 | alias Constable.{Repo, User, Profiles} 5 | 6 | def index(conn, _params) do 7 | users = Repo.all(User |> order_by(desc: :active)) 8 | render(conn, "index.html", users: users) 9 | end 10 | 11 | def update(conn, %{"id" => id}) do 12 | user = Repo.get!(User, id) 13 | new_activation_status = !user.active 14 | 15 | user 16 | |> Ecto.Changeset.change(%{active: new_activation_status}) 17 | |> Repo.update!() 18 | |> update_profile_info() 19 | 20 | redirect(conn, to: Routes.user_activation_path(conn, :index)) 21 | end 22 | 23 | defp update_profile_info(user) do 24 | Constable.TaskSupervisor.one_off_task(fn -> Profiles.update_profile_info(user) end) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/constable_web/controllers/user_interest_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.UserInterestController do 2 | use ConstableWeb, :controller 3 | 4 | alias Constable.{Interest, UserInterest} 5 | 6 | def create(conn, %{"interest_id_or_name" => interest_name}) do 7 | interest = find_interest(interest_name) 8 | current_user = current_user(conn) 9 | 10 | UserInterest.changeset(%{user_id: current_user.id, interest_id: interest.id}) 11 | |> Repo.insert!() 12 | 13 | redirect(conn, to: Routes.interest_path(conn, :index)) 14 | end 15 | 16 | def delete(conn, %{"interest_id_or_name" => interest_name}) do 17 | interest = find_interest(interest_name) 18 | 19 | UserInterest 20 | |> Repo.get_by!(interest_id: interest.id, user_id: current_user(conn).id) 21 | |> Repo.delete!() 22 | 23 | redirect(conn, to: Routes.interest_path(conn, :index)) 24 | end 25 | 26 | defp find_interest(interest_name) do 27 | Interest |> Repo.get_by!(name: interest_name) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/constable_web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.Gettext do 2 | @moduledoc """ 3 | A module providing Internationalization with a gettext-based API. 4 | 5 | By using [Gettext](http://hexdocs.pm/gettext), 6 | your module gains a set of macros for translations, for example: 7 | 8 | import Async.Gettext 9 | 10 | # Simple translation 11 | gettext("Here is the string to translate") 12 | 13 | # Plural translation 14 | ngettext("Here is the string to translate", 15 | "Here are the strings to translate", 16 | 3) 17 | 18 | # Domain-based translation 19 | dgettext("errors", "Here is the error message to translate") 20 | 21 | See the [Gettext Docs](http://hexdocs.pm/gettext) for detailed usage. 22 | """ 23 | use Gettext, otp_app: :constable 24 | end 25 | -------------------------------------------------------------------------------- /lib/constable_web/live/interest_live.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.InterestLive do 2 | use ConstableWeb, :live_view 3 | 4 | alias Constable.{Interest, User, UserInterest} 5 | 6 | def render(assigns) do 7 | render(ConstableWeb.InterestView, "index.html", assigns) 8 | end 9 | 10 | def mount(_, session, socket) do 11 | socket = 12 | socket 13 | |> assign(:current_user, fetch_user(session["current_user_id"])) 14 | |> assign(:interests, fetch_interests()) 15 | 16 | {:ok, socket} 17 | end 18 | 19 | def handle_event("subscribe", %{"id" => id}, socket) do 20 | current_user = socket.assigns.current_user 21 | interest = Interest |> Repo.get!(id) 22 | 23 | UserInterest.changeset(%{user_id: current_user.id, interest_id: interest.id}) 24 | |> Repo.insert!() 25 | 26 | user = fetch_user(current_user.id) 27 | 28 | {:noreply, assign(socket, current_user: user)} 29 | end 30 | 31 | def handle_event("unsubscribe", %{"id" => id}, socket) do 32 | current_user = socket.assigns.current_user 33 | interest = Interest |> Repo.get!(id) 34 | 35 | UserInterest 36 | |> Repo.get_by!(interest_id: interest.id, user_id: current_user.id) 37 | |> Repo.delete!() 38 | 39 | user = fetch_user(current_user.id) 40 | 41 | {:noreply, assign(socket, current_user: user)} 42 | end 43 | 44 | defp fetch_user(user_id) do 45 | User.active() 46 | |> Repo.get(user_id) 47 | |> Repo.preload(:interests) 48 | end 49 | 50 | defp fetch_interests do 51 | Repo.all(Interest.ordered_by_name()) 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/constable_web/templates/announcement/_comment.html.eex: -------------------------------------------------------------------------------- 1 |
  • 2 |
    3 | <%= @comment.user.name %> 10 |
    11 | 12 |
    13 |
    14 |

    <%= @comment.user.name %>

    15 | 16 | <%= relative_timestamp(@comment.inserted_at) %> 17 | 18 | 19 | <%= if @comment.user_id == @current_user.id do %> 20 | <%= link to: Routes.announcement_comment_path(ConstableWeb.Endpoint, :edit, @comment.announcement_id, @comment), class: "edit-comment", data: [role: "edit-comment"] do %> 21 | <%= gettext "(edit)" %> 22 | <% end %> 23 | <% end %> 24 |
    25 | 26 |
    27 | <%= raw markdown_with_users(@comment.body) %> 28 |
    29 |
    30 |
  • 31 | -------------------------------------------------------------------------------- /lib/constable_web/templates/announcement/_unsubscribe_button.html.eex: -------------------------------------------------------------------------------- 1 | <%= link to: Routes.announcement_subscription_path(ConstableWeb.Endpoint, :delete, @announcement_id), 2 | method: :delete, 3 | class: "tbds-button button-secondary" do %> 4 | 5 | 6 | 7 | <%= gettext("Subscribed to thread") %> 8 | <% end %> 9 | -------------------------------------------------------------------------------- /lib/constable_web/templates/announcement/edit.html.eex: -------------------------------------------------------------------------------- 1 | <%= render "_form.html", 2 | path: Routes.announcement_path(@conn, :update, @changeset.data.id), 3 | conn: @conn, 4 | changeset: @changeset, 5 | interests: @interests, 6 | users: @users 7 | %> 8 | -------------------------------------------------------------------------------- /lib/constable_web/templates/announcement/new.html.eex: -------------------------------------------------------------------------------- 1 |

    Create an Announcement

    2 | 3 | <%= render "_form.html", 4 | path: Routes.announcement_path(@conn, :create), 5 | conn: @conn, 6 | changeset: @changeset, 7 | interests: @interests, 8 | users: @users 9 | %> 10 | -------------------------------------------------------------------------------- /lib/constable_web/templates/announcement_list/index.html.eex: -------------------------------------------------------------------------------- 1 | 39 | -------------------------------------------------------------------------------- /lib/constable_web/templates/comment/_form.html.eex: -------------------------------------------------------------------------------- 1 | <%= form_for @comment_changeset, @path, [class: "tbds-form tbds-block-stack tbds-block-stack--gap-4 comment-form"], fn(f) -> %> 2 |
    3 |
    4 | 5 | 6 |
    7 |
    8 | <%= textarea( 9 | f, 10 | :body, 11 | class: "tbds-form__textarea comment-textarea mousetrap uploadable-input", 12 | required: true, 13 | placeholder: "Comment on this announcement" 14 | ) %> 15 |
    16 | <%= submit @submit_text, id: "submit-comment", class: "tbds-button" %> 17 |
    18 |
    19 | <% end %> 20 | -------------------------------------------------------------------------------- /lib/constable_web/templates/comment/edit.html.eex: -------------------------------------------------------------------------------- 1 |
    2 |
    3 | <%= render "_form.html", 4 | comment_changeset: @changeset, 5 | path: Routes.announcement_comment_path(@conn, :update, @announcement, @comment), 6 | submit_text: gettext("Update Comment") %> 7 |
    8 |
    9 | 10 | 16 | -------------------------------------------------------------------------------- /lib/constable_web/templates/email/author_footer.html.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 11 | 12 |
    6 | <%= @author.name %> 7 | 9 | <%= @author.name %>
    10 |
    13 | 14 | 15 | <%= link to: announcement_url_for_footer(@announcement, @comment), 16 | style: "color: #{red()}; font-size: 14px;" do %> 17 | <%= gettext("View on Constable") %> 18 | <% end %> 19 | 20 | 21 | -------------------------------------------------------------------------------- /lib/constable_web/templates/email/daily_digest.text.eex: -------------------------------------------------------------------------------- 1 | <%= gettext("Daily digest of new Interests and Announcements\n") %> 2 | ================= 3 | <%= unless Enum.empty?(@interests) do %> 4 | 5 | <%= gettext("Recently added interests\n") %> 6 | ----------------- 7 | 8 | <%= for interest <- @interests do %> 9 | - #<%= interest.name %> (<%= Routes.interest_url(ConstableWeb.Endpoint, :show, interest) %>) 10 | <% end %> 11 | <% end %> 12 | <%= unless Enum.empty?(@announcements) do %> 13 | <%= gettext("Recently added announcements") %> 14 | ----------------- 15 | <%= for announcement <- @announcements do %> 16 | 17 | - <%= announcement.title %> 18 | <%= announcement.body %> 19 | <%= gettext("posted by") %> <%= announcement.user.name %> <%= gettext("in") %> <%= interest_names(announcement) %> 20 | <%= " " %><%= Routes.announcement_url(ConstableWeb.Endpoint, :show, announcement) %> 21 | <% end %> 22 | <% end %> 23 | <%= unless Enum.empty?(@comments) do %> 24 | ----------------- 25 | <%= for announcement <- discussed_announcements(@comments) do %> 26 | 27 | - <%= announcement.title %> 28 | Posted by <%= announcement.user.name %> <%= time_ago_in_words announcement.inserted_at %> 29 | <%= for comment <- new_comments(@comments, announcement) do %> 30 | Comment by <%= comment.user.name %> <%= time_ago_in_words comment.inserted_at %>: 31 | <%= comment.body %> 32 | <% end %> 33 | <%= " " %><%= Routes.announcement_url(ConstableWeb.Endpoint, :show, announcement) %> 34 | <% end %> 35 | <% end %> 36 | --- 37 | <%= gettext("View Interests and Announcements on Constable\n") %> 38 | <%= Routes.announcement_url(ConstableWeb.Endpoint, :index) %> 39 | -------------------------------------------------------------------------------- /lib/constable_web/templates/email/new_announcement.html.eex: -------------------------------------------------------------------------------- 1 |
    2 | 3 | 4 | 5 | 6 | 7 | 11 | 17 | 18 | 19 | 20 | 21 | 22 | 25 | 26 | 27 | 28 | 29 |
    8 | Announced to <%= raw interest_links(@announcement) %> 9 | <%= time_ago_in_words @announcement.inserted_at %>: 10 | 12 | <%= link to: announcement_url_for_footer(@announcement, nil), 13 | style: "color: #{light_gray()}; font-size: 14px;" do %> 14 | <%= gettext("View on Constable") %> 15 | <% end %> 16 |
    23 | <%= raw Constable.Markdown.to_html(@announcement.body) %> 24 |
    30 | 31 | <%= render "author_footer.html", author: @author, announcement: @announcement, comment: nil %> 32 | <%= render "unsubscribe_footer.html" %> 33 | 34 | 35 | 36 |
    37 |
    38 | -------------------------------------------------------------------------------- /lib/constable_web/templates/email/new_announcement.text.eex: -------------------------------------------------------------------------------- 1 | Announced to <%= interest_names(@announcement) %> 2 | 3 | <%= @announcement.body %> 4 | 5 | <%= announcement_url_for_footer(@announcement, nil) %> 6 | 7 | <%= render "unsubscribe_footer.text" %> 8 | 9 | -------------------------------------------------------------------------------- /lib/constable_web/templates/email/new_comment.html.eex: -------------------------------------------------------------------------------- 1 |
    2 | 3 | 4 | 5 | 6 | 7 | 15 | 21 | 22 | 23 | 24 | 25 |
    8 | <%= if @mentioner do %> 9 |

    10 | <%= gettext("You were mentioned by") %> <%= @mentioner.name %>: 11 |

    12 | <% end %> 13 | <%= raw Constable.Markdown.to_html(@comment.body) %> 14 |
    16 | <%= link to: announcement_url_for_footer(@announcement, @comment), 17 | style: "color: #{light_gray()}; font-size: 14px;" do %> 18 | <%= gettext("View on Constable") %> 19 | <% end %> 20 |
    26 | 27 | <%= render "author_footer.html", author: @author, announcement: @announcement, comment: @comment %> 28 | <%= render "unsubscribe_footer.html" %> 29 | 30 | 31 | 32 |
    33 |
    34 | -------------------------------------------------------------------------------- /lib/constable_web/templates/email/new_comment.text.eex: -------------------------------------------------------------------------------- 1 | <%= if @mentioner do %> 2 | <%= gettext("You were mentioned by") %> <%= @mentioner.name %>: 3 | 4 | <% end %> 5 | <%= @comment.body %> 6 | 7 | <%= announcement_url_for_footer(@announcement, @comment) %> 8 | 9 | <%= render "unsubscribe_footer.text" %> 10 | -------------------------------------------------------------------------------- /lib/constable_web/templates/email/unsubscribe_footer.html.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <%= link to: unsubscribe_link(), style: "text-decoration: none; color: #{light_gray()}; font-size: 13px;" do %> 8 | <%= gettext("Unsubscribe from this thread") %> 9 | <% end %> 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | <%= link to: notification_settings_link(), style: "text-decoration: none; color: #{light_gray()}; font-size: 13px" do %> 20 | <%= gettext("Manage your notification settings") %> 21 | <% end %> 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /lib/constable_web/templates/email/unsubscribe_footer.text.eex: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | <%= gettext("Unsubscribe from this thread:") %> <%= unsubscribe_link() %> 4 | 5 | <%= gettext("Manage your notification settings:") %> <%= notification_settings_link() %> 6 | -------------------------------------------------------------------------------- /lib/constable_web/templates/interest/subscription.html.eex: -------------------------------------------------------------------------------- 1 | <%= if interested_in?(@current_user, @interest) do %> 2 | <%= link to: Routes.interest_user_interest_path(@conn, :delete, @interest), 3 | method: :delete, 4 | class: "tbds-button", 5 | data: [role: "unsubscribe-from-interest"] do %> 6 | 7 | 8 | 9 | <%= gettext("Subscribed") %> 10 | <% end %> 11 | <% else %> 12 | <%= link to: Routes.interest_user_interest_path(@conn, :create, @interest), 13 | method: :post, 14 | class: "tbds-button button-secondary", 15 | data: [role: "subscribe-to-interest"] do %> 16 | 17 | 18 | 19 | <%= gettext("Subscribe") %> 20 | <% end %> 21 | <% end %> 22 | -------------------------------------------------------------------------------- /lib/constable_web/templates/layout/_flashes.html.eex: -------------------------------------------------------------------------------- 1 |
    2 | <%= if get_flash(@conn, :info) do %> 3 |
    4 | <%= get_flash(@conn, :info) %> 5 |
    6 | <% end %> 7 | 8 | <%= if get_flash(@conn, :error) do %> 9 |
    10 | <%= get_flash(@conn, :error) %> 11 |
    12 | <% end %> 13 |
    14 | -------------------------------------------------------------------------------- /lib/constable_web/templates/layout/_header.html.eex: -------------------------------------------------------------------------------- 1 | Skip to main content 2 | 37 | -------------------------------------------------------------------------------- /lib/constable_web/templates/layout/_styles.html.eex: -------------------------------------------------------------------------------- 1 | 94 | -------------------------------------------------------------------------------- /lib/constable_web/templates/layout/app.html.eex: -------------------------------------------------------------------------------- 1 |
    2 | <%= render "_flashes.html", conn: @conn %> 3 | 4 | <%= @inner_content %> 5 |
    6 | -------------------------------------------------------------------------------- /lib/constable_web/templates/layout/email.html.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Constable email 7 | <%= raw render("_styles.html") %> 8 | 9 | 10 | 11 | <%= @inner_content %> 12 | 13 | 14 | -------------------------------------------------------------------------------- /lib/constable_web/templates/layout/live.html.leex: -------------------------------------------------------------------------------- 1 |
    2 |
    3 | <%= if live_flash(@flash, :info) do %> 4 |
    5 | <%= live_flash(@flash, :info) %> 6 |
    7 | <% end %> 8 | 9 | <%= if live_flash(@flash, :error) do %> 10 |
    11 | <%= live_flash(@flash, :error) %> 12 |
    13 | <% end %> 14 |
    15 | 16 | <%= @inner_content %> 17 |
    18 | -------------------------------------------------------------------------------- /lib/constable_web/templates/layout/login.html.eex: -------------------------------------------------------------------------------- 1 |
    2 | <%= render "_flashes.html", conn: @conn %> 3 | 4 | <%= @inner_content %> 5 |
    6 | -------------------------------------------------------------------------------- /lib/constable_web/templates/layout/root.html.leex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <%= title(assigns) %> 8 | "> 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | <%= if current_user(@conn) do %> 20 | 21 | 22 | <%= csrf_meta_tag() %> 23 | 24 | <% end %> 25 | 26 | 27 | <%= if current_user(@conn) do %> 28 | <%= render "_header.html", conn: @conn %> 29 | <% end %> 30 | 31 | <%= @inner_content %> 32 | 33 | 34 | -------------------------------------------------------------------------------- /lib/constable_web/templates/recipients_preview/recipients_preview.html.eex: -------------------------------------------------------------------------------- 1 | <%= if length(@interested_user_names) > 0 do %> 2 | 3 | <%= ngettext("1 person is", "%{count} people are", length(@interested_user_names)) %> 4 | subscribed 5 | 6 | to these interests. 7 | <% else %> 8 | <%= if length(@interest_names) > 0 do %> 9 | No one is subscribed to these interests. 10 | <% else %> 11 | Select interests 12 | <% end %> 13 | <% end %> 14 | 15 |
    16 | <%= Enum.join(@interested_user_names, ", ") %> 17 |
    18 | 19 | 31 | -------------------------------------------------------------------------------- /lib/constable_web/templates/search/new.html.eex: -------------------------------------------------------------------------------- 1 |

    2 | Search results for <%= @conn.params["query"] %> 3 |

    4 | 5 | <%= if length(@announcements) > 0 do %> 6 | <%= render ConstableWeb.AnnouncementListView, 7 | "index.html", 8 | conn: @conn, 9 | announcements: @announcements 10 | %> 11 | 12 | 21 | <% end %> 22 | 23 | -------------------------------------------------------------------------------- /lib/constable_web/templates/session/new.html.eex: -------------------------------------------------------------------------------- 1 |
    2 |
    3 | 4 | 5 | 6 | 7 |

    8 | Constable is for posting announcements and 9 | having discussions. 10 |

    11 | 12 | <%= link to: Routes.auth_path(@conn, :index, browser: true), class: "tbds-button tbds-button--full-width sign-in-link" do %> 13 | 16 | Sign in with Google 17 | <% end %> 18 |
    19 |
    20 | -------------------------------------------------------------------------------- /lib/constable_web/templates/user_activation/index.html.eex: -------------------------------------------------------------------------------- 1 |
    2 |

    3 | <%= gettext "Users" %> 4 |

    5 | 6 | 38 |
    39 | -------------------------------------------------------------------------------- /lib/constable_web/views/announcement_list_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.AnnouncementListView do 2 | use ConstableWeb, :view 3 | 4 | def commenters(announcement) do 5 | comment_users = Enum.map(announcement.comments, & &1.user) 6 | 7 | Enum.uniq([announcement.user | comment_users]) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/constable_web/views/announcement_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.AnnouncementView do 2 | use ConstableWeb, :view 3 | 4 | def json_interests(interests) do 5 | interests 6 | |> Enum.map(&%{name: &1.name}) 7 | |> Poison.encode!() 8 | end 9 | 10 | def comma_separated_interest_names(interests) when is_list(interests) do 11 | interests 12 | |> Enum.map(& &1.name) 13 | |> Enum.join(",") 14 | end 15 | 16 | def comma_separated_interest_names(_), do: "" 17 | 18 | def class_for("all", %{params: %{"all" => "true"}}), do: "selected" 19 | 20 | def class_for("your announcements", conn = %{params: %{"user_id" => id}}) do 21 | if current_user_same_as_announcements_user?(conn, id) do 22 | "selected" 23 | end 24 | end 25 | 26 | def class_for( 27 | "your comments", 28 | conn = %{params: %{"comment_user_id" => id}} 29 | ) do 30 | if current_user_same_as_announcements_user?(conn, id) do 31 | "selected" 32 | end 33 | end 34 | 35 | def class_for("your interests", %{params: %{"all" => "true"}}), do: nil 36 | def class_for("your interests", %{params: %{"comment_user_id" => _}}), do: nil 37 | def class_for("your interests", %{params: %{"user_id" => _}}), do: nil 38 | def class_for("your interests", _), do: "selected" 39 | def class_for(_, _), do: nil 40 | 41 | defp current_user_same_as_announcements_user?(conn, announcements_user_id) do 42 | "#{current_user(conn).id}" == announcements_user_id 43 | end 44 | 45 | def interest_count_for(user) do 46 | length(user.interests) 47 | end 48 | 49 | def user_autocomplete_json(users) do 50 | users 51 | |> Enum.map(&format_user_json/1) 52 | |> Poison.encode!() 53 | end 54 | 55 | defp format_user_json(user) do 56 | %{name: user.name, username: user.username, profile_image_url: profile_image_url(user)} 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/constable_web/views/api/announcement_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.Api.AnnouncementView do 2 | use ConstableWeb, :view 3 | 4 | alias ConstableWeb.Api.CommentView 5 | 6 | def render("index.json", %{announcements: announcements}) do 7 | announcements = announcements |> Repo.preload([:comments, :interests]) 8 | 9 | %{ 10 | announcements: render_many(announcements, __MODULE__, "announcement.json") 11 | } 12 | end 13 | 14 | def render("show.json", %{announcement: announcement}) do 15 | announcement = announcement |> Repo.preload([:comments, :interests]) 16 | %{announcement: render_one(announcement, __MODULE__, "announcement.json")} 17 | end 18 | 19 | def render("announcement.json", %{announcement: announcement}) do 20 | %{ 21 | id: announcement.id, 22 | title: announcement.title, 23 | body: announcement.body, 24 | inserted_at: announcement.inserted_at, 25 | updated_at: announcement.updated_at, 26 | user_id: announcement.user_id, 27 | comments: render_many(announcement.comments, CommentView, "comment.json"), 28 | interest_ids: pluck(announcement.interests, :id), 29 | url: 30 | ConstableWeb.Router.Helpers.announcement_url(ConstableWeb.Endpoint, :show, announcement) 31 | } 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/constable_web/views/api/comment_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.Api.CommentView do 2 | use ConstableWeb, :view 3 | 4 | def render("index.json", %{comments: comments}) do 5 | %{comments: render_many(comments, ConstableWeb.Api.CommentView, "comment.json")} 6 | end 7 | 8 | def render("show.json", %{comment: comment}) do 9 | %{comment: render_one(comment, ConstableWeb.Api.CommentView, "comment.json")} 10 | end 11 | 12 | def render("comment.json", %{comment: comment}) do 13 | %{ 14 | id: comment.id, 15 | body: comment.body, 16 | announcement_id: comment.announcement_id, 17 | user_id: comment.user_id, 18 | inserted_at: comment.inserted_at 19 | } 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/constable_web/views/api/interest_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.Api.InterestView do 2 | use ConstableWeb, :view 3 | 4 | def render("index.json", %{interests: interests}) do 5 | %{interests: render_many(interests, ConstableWeb.Api.InterestView, "interest.json")} 6 | end 7 | 8 | def render("show.json", %{interest: interest}) do 9 | %{interest: render_one(interest, ConstableWeb.Api.InterestView, "interest.json")} 10 | end 11 | 12 | def render("interest.json", %{interest: interest}) do 13 | %{ 14 | id: interest.id, 15 | name: interest.name, 16 | slack_channel: interest.slack_channel 17 | } 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/constable_web/views/api/subscription_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.Api.SubscriptionView do 2 | use ConstableWeb, :view 3 | 4 | def render("index.json", %{subscriptions: subscriptions}) do 5 | %{ 6 | subscriptions: 7 | render_many(subscriptions, ConstableWeb.Api.SubscriptionView, "subscription.json") 8 | } 9 | end 10 | 11 | def render("show.json", %{subscription: subscription}) do 12 | %{ 13 | subscription: 14 | render_one(subscription, ConstableWeb.Api.SubscriptionView, "subscription.json") 15 | } 16 | end 17 | 18 | def render("subscription.json", %{subscription: subscription}) do 19 | %{ 20 | id: subscription.id, 21 | user_id: subscription.user_id, 22 | announcement_id: subscription.announcement_id 23 | } 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/constable_web/views/api/user_interest_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.Api.UserInterestView do 2 | use ConstableWeb, :view 3 | 4 | def render("index.json", %{user_interests: user_interests}) do 5 | %{ 6 | user_interests: 7 | render_many(user_interests, ConstableWeb.Api.UserInterestView, "user_interest.json") 8 | } 9 | end 10 | 11 | def render("show.json", %{user_interest: user_interest}) do 12 | %{ 13 | user_interest: 14 | render_one(user_interest, ConstableWeb.Api.UserInterestView, "user_interest.json") 15 | } 16 | end 17 | 18 | def render("user_interest.json", %{user_interest: user_interest}) do 19 | %{ 20 | id: user_interest.id, 21 | user_id: user_interest.user_id, 22 | interest_id: user_interest.interest_id 23 | } 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/constable_web/views/api/user_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.Api.UserView do 2 | use ConstableWeb, :view 3 | 4 | def render("index.json", %{users: users}) do 5 | users = users |> Repo.preload([:user_interests, :subscriptions]) 6 | %{users: render_many(users, ConstableWeb.Api.UserView, "user.json")} 7 | end 8 | 9 | def render("show.json", %{user: user}) do 10 | user = user |> Repo.preload([:user_interests, :subscriptions]) 11 | %{user: render_one(user, ConstableWeb.Api.UserView, "user.json")} 12 | end 13 | 14 | def render("user.json", %{user: user}) do 15 | %{ 16 | id: user.id, 17 | name: user.name, 18 | auto_subscribe: user.auto_subscribe, 19 | daily_digest: user.daily_digest, 20 | profile_image_url: profile_image_url(user), 21 | username: user.username, 22 | user_interests: pluck(user.user_interests, :id), 23 | subscriptions: pluck(user.subscriptions, :id) 24 | } 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/constable_web/views/auth_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.AuthView do 2 | use ConstableWeb, :view 3 | 4 | alias ConstableWeb.Api.UserView 5 | 6 | def render("show.json", %{user: user}) do 7 | rendered_user = render_one(user, UserView, "show.json") 8 | rendered_user |> put_in([:user, :token], user.token) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/constable_web/views/changeset_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.ChangesetView do 2 | use ConstableWeb, :view 3 | 4 | def translate_errors(changeset) do 5 | Ecto.Changeset.traverse_errors(changeset, &translate_error/1) 6 | end 7 | 8 | def render("error.json", %{changeset: changeset}) do 9 | # When encoded, the changeset returns its errors 10 | # as a JSON object. So we just pass it forward. 11 | %{errors: translate_errors(changeset)} 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/constable_web/views/comment_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.CommentView do 2 | use ConstableWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/constable_web/views/error_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.ErrorHelpers do 2 | @moduledoc """ 3 | Conveniences for translating and building error messages. 4 | """ 5 | 6 | use Phoenix.HTML 7 | 8 | @doc """ 9 | Generates tag for inlined form input errors. 10 | """ 11 | def error_tag(form, field) do 12 | Enum.map(Keyword.get_values(form.errors, field), fn error -> 13 | content_tag(:span, translate_error(error), 14 | id: "#{field}_error", 15 | class: "help-block", 16 | role: "alert" 17 | ) 18 | end) 19 | end 20 | 21 | @doc """ 22 | Translates an error message using gettext. 23 | """ 24 | def translate_error({msg, opts}) do 25 | # When using gettext, we typically pass the strings we want 26 | # to translate as a static argument: 27 | # 28 | # # Translate "is invalid" in the "errors" domain 29 | # dgettext "errors", "is invalid" 30 | # 31 | # # Translate the number of files with plural rules 32 | # dngettext "errors", "1 file", "%{count} files", count 33 | # 34 | # Because the error messages we show in our forms and APIs 35 | # are defined inside Ecto, we need to translate them dynamically. 36 | # This requires us to call the Gettext module passing our gettext 37 | # backend as first argument. 38 | # 39 | # Note we use the "errors" domain, which means translations 40 | # should be written to the errors.po file. The :count option is 41 | # set by Ecto and indicates we should also apply plural rules. 42 | if count = opts[:count] do 43 | Gettext.dngettext(ConstableWeb.Gettext, "errors", msg, msg, count, opts) 44 | else 45 | Gettext.dgettext(ConstableWeb.Gettext, "errors", msg, opts) 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/constable_web/views/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.ErrorView do 2 | use ConstableWeb, :view 3 | 4 | # If you want to customize a particular status code 5 | # for a certain format, you may uncomment below. 6 | # def render("500.html", _assigns) do 7 | # "Internal Server Error" 8 | # end 9 | 10 | # By default, Phoenix returns the status message from 11 | # the template name. For example, "404.html" becomes 12 | # "Not Found". 13 | def template_not_found(template, _assigns) do 14 | Phoenix.Controller.status_message_from_template(template) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/constable_web/views/interest_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.InterestView do 2 | use ConstableWeb, :view 3 | 4 | import Constable.User, only: [interested_in?: 2] 5 | 6 | def interest_count_for(user) do 7 | length(user.interests) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/constable_web/views/layout_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.LayoutView do 2 | use ConstableWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/constable_web/views/recipients_preview_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.RecipientsPreviewView do 2 | use ConstableWeb, :view 3 | 4 | def render("show.json", assigns) do 5 | %{recipients_preview_html: render_recipients_preview_html(assigns)} 6 | end 7 | 8 | defp render_recipients_preview_html(assigns) do 9 | Phoenix.View.render_to_string( 10 | ConstableWeb.RecipientsPreviewView, 11 | "recipients_preview.html", 12 | assigns 13 | ) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/constable_web/views/search_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.SearchView do 2 | use ConstableWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/constable_web/views/session_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.SessionView do 2 | use ConstableWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/constable_web/views/settings_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.SettingsView do 2 | use ConstableWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/constable_web/views/slack_channel_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.SlackChannelView do 2 | use ConstableWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/constable_web/views/subscription_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.SubscriptionView do 2 | use ConstableWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/constable_web/views/user_activation_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.UserActivationView do 2 | use ConstableWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/enum_helper.ex: -------------------------------------------------------------------------------- 1 | defmodule Constable.EnumHelper do 2 | def pluck(enumerable, property) do 3 | Enum.map(enumerable, fn object -> 4 | Map.get(object, property) 5 | end) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/mix/tasks/constable.update_profile_image_urls.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Constable.UpdateProfileImageUrls do 2 | use Mix.Task 3 | 4 | import Ecto.Query, only: [where: 3] 5 | 6 | require Logger 7 | 8 | alias Constable.{User, Repo, Profiles} 9 | 10 | @moduledoc """ 11 | Fetches all user's image urls from hub and updates the user's profile_image_url. 12 | 13 | ## Sample use 14 | 15 | mix constable.update_profile_image_urls 16 | """ 17 | 18 | @doc "Fetches and updates all users' image urls (from hub)" 19 | def run(_) do 20 | Mix.Task.run("app.start") 21 | 22 | users = 23 | User 24 | |> where([u], u.active == true) 25 | |> where([u], is_nil(u.profile_image_url)) 26 | |> Repo.all() 27 | 28 | Logger.info(""" 29 | Active users without a valid profile_image_url 30 | 31 | #{inspect(Enum.map(users, & &1.email))} 32 | """) 33 | 34 | Profiles.update_profile_image_urls(users) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/mix/tasks/refresh_last_discussed_at.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Constable.RefreshLastDiscussedAt do 2 | use Mix.Task 3 | alias Constable.Repo 4 | import Ecto.Query 5 | 6 | def run(_opts) do 7 | Mix.Task.run("app.start") 8 | 9 | for announcement <- Repo.all(Constable.Announcement) do 10 | latest_comment_inserted_at = 11 | announcement 12 | |> Ecto.assoc(:comments) 13 | |> limit(1) 14 | |> order_by(desc: :inserted_at) 15 | |> select([a], a.inserted_at) 16 | |> Repo.one() 17 | 18 | last_discussed_at_params = %{ 19 | last_discussed_at: latest_comment_inserted_at || announcement.inserted_at 20 | } 21 | 22 | announcement 23 | |> Ecto.Changeset.cast(last_discussed_at_params, [:last_discussed_at], []) 24 | |> Repo.update!() 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/mix/tasks/send_daily_digest.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Constable.SendDailyDigest do 2 | use Mix.Task 3 | import Ecto.Query 4 | require Logger 5 | 6 | alias Constable.Repo 7 | alias Constable.User 8 | 9 | def run(_) do 10 | Mix.Task.run("app.start") 11 | users = Repo.all(from u in User.active(), where: u.daily_digest == true) 12 | 13 | user_emails = 14 | for user <- users do 15 | user.email 16 | end 17 | 18 | Logger.info(""" 19 | Users with Daily Digest enabled: 20 | 21 | #{inspect(user_emails)} 22 | """) 23 | 24 | since = Constable.Time.yesterday() 25 | Constable.Pact.get(:daily_digest).send_email(users, since) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /phoenix_static_buildpack.config: -------------------------------------------------------------------------------- 1 | clean_cache=false 2 | compile="script/heroku-compile-assets" 3 | node_version=10.10.0 4 | phoenix_relative_path=. 5 | remove_node=false 6 | assets_path=assets 7 | phoenix_ex=phx 8 | -------------------------------------------------------------------------------- /priv/repo/migrations/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto_sql], 3 | inputs: ["*.exs"] 4 | ] 5 | -------------------------------------------------------------------------------- /priv/repo/migrations/20150204170307_create_announcements.exs: -------------------------------------------------------------------------------- 1 | defmodule Constable.Repo.Migrations.CreateAnnouncements do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:announcement) do 6 | add :title, :string 7 | add :body, :text 8 | 9 | timestamps() 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /priv/repo/migrations/20150206153434_create_users.exs: -------------------------------------------------------------------------------- 1 | defmodule Constable.Repo.Migrations.CreateUsers do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:users) do 6 | add :email, :string 7 | add :token, :string 8 | 9 | timestamps() 10 | end 11 | 12 | create index(:users, [:email], unique: true) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /priv/repo/migrations/20150209232905_create_comments.exs: -------------------------------------------------------------------------------- 1 | defmodule Constable.Repo.Migrations.CreateComments do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:comments) do 6 | add :body, :text, null: false 7 | add :announcement_id, references(:announcement) 8 | 9 | timestamps() 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /priv/repo/migrations/20150212191157_add_user_to_announcements.exs: -------------------------------------------------------------------------------- 1 | defmodule Constable.Repo.Migrations.AddUserToAnnouncements do 2 | use Ecto.Migration 3 | 4 | def up do 5 | alter table(:announcement) do 6 | add :user_id, references(:users), null: false 7 | modify :body, :text 8 | end 9 | end 10 | 11 | def down do 12 | alter table(:announcement) do 13 | remove :user_id 14 | modify :body, :string 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /priv/repo/migrations/20150212203030_add_user_to_comments.exs: -------------------------------------------------------------------------------- 1 | defmodule Constable.Repo.Migrations.AddUserToComments do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:comments) do 6 | add :user_id, references(:users), null: false 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20150213213319_add_name_to_users.exs: -------------------------------------------------------------------------------- 1 | defmodule Constable.Repo.Migrations.AddNameToUsers do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:users) do 6 | add :name, :string, null: false 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20150226150429_add_subscriptions_table.exs: -------------------------------------------------------------------------------- 1 | defmodule Constable.Repo.Migrations.AddSubscriptionsTable do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:subscriptions) do 6 | add :user_id, references(:users), null: false 7 | add :announcement_id, references(:announcement), null: false 8 | 9 | timestamps() 10 | end 11 | 12 | create index(:subscriptions, [:user_id, :announcement_id], unique: true) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /priv/repo/migrations/20150226184100_create_interests.exs: -------------------------------------------------------------------------------- 1 | defmodule Constable.Repo.Migrations.CreateInterests do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:interests) do 6 | add :name, :string, null: false 7 | 8 | timestamps() 9 | end 10 | 11 | create index(:interests, [:name], unique: true) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /priv/repo/migrations/20150227200438_create_announcements_interests.exs: -------------------------------------------------------------------------------- 1 | defmodule Constable.Repo.Migrations.CreateAnnouncementsInterests do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:announcements_interests) do 6 | add :announcement_id, references(:announcement) 7 | add :interest_id, references(:interests) 8 | 9 | timestamps() 10 | end 11 | 12 | create index( 13 | :announcements_interests, 14 | [:announcement_id, :interest_id], 15 | unique: true 16 | ) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /priv/repo/migrations/20150227210639_create_users_interests.exs: -------------------------------------------------------------------------------- 1 | defmodule Constable.Repo.Migrations.CreateUsersInterests do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:users_interests) do 6 | add :interest_id, references(:interests) 7 | add :user_id, references(:users) 8 | 9 | timestamps() 10 | end 11 | 12 | create index( 13 | :users_interests, 14 | [:interest_id, :user_id], 15 | unique: true 16 | ) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /priv/repo/migrations/20150825202821_add_username_to_users.exs: -------------------------------------------------------------------------------- 1 | defmodule Constable.Repo.Migrations.AddUsernameToUsers do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:users) do 6 | add :username, :string 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20150826133822_add_null_constraint_to_username.exs: -------------------------------------------------------------------------------- 1 | defmodule Constable.Repo.Migrations.AddNullConstrainToUsername do 2 | use Ecto.Migration 3 | alias Constable.Repo 4 | 5 | def up do 6 | {:ok, %{rows: users}} = Ecto.Adapters.SQL.query(Repo, "SELECT id,email FROM users", []) 7 | 8 | Enum.each(users, fn [id, email] -> 9 | [name, _] = String.split(email, "@") 10 | 11 | query = "UPDATE users SET username = $1 WHERE id = $2" 12 | Ecto.Adapters.SQL.query(Repo, query, [name, id]) 13 | end) 14 | 15 | alter table(:users) do 16 | modify :username, :string, null: false 17 | end 18 | end 19 | 20 | def down do 21 | alter table(:users) do 22 | modify :username, :string, null: true 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /priv/repo/migrations/20150909175723_add_notification_settings_to_users.exs: -------------------------------------------------------------------------------- 1 | defmodule Constable.Repo.Migrations.AddNotificationSettingsToUsers do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:users) do 6 | add :auto_subscribe, :boolean, default: false, null: false 7 | add :daily_digest, :boolean, default: true, null: false 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /priv/repo/migrations/20150918165253_set_autosubscribe_default_to_true.exs: -------------------------------------------------------------------------------- 1 | defmodule Constable.Repo.Migrations.SetAutosubscribeDefaultToTrue do 2 | use Ecto.Migration 3 | 4 | def up do 5 | alter table(:users) do 6 | modify :auto_subscribe, :boolean, default: true 7 | end 8 | end 9 | 10 | def down do 11 | alter table(:users) do 12 | modify :auto_subscribe, :boolean, default: false 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /priv/repo/migrations/20150921193646_add_token_to_subscription.exs: -------------------------------------------------------------------------------- 1 | defmodule Constable.Repo.Migrations.AddTokenToSubscription do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:subscriptions) do 6 | add :token, :string 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20150921194253_generate_subscription_tokens.exs: -------------------------------------------------------------------------------- 1 | defmodule Constable.Repo.Migrations.GenerateSubscriptionTokens do 2 | use Ecto.Migration 3 | 4 | alias Constable.Repo 5 | 6 | defmodule Subscription do 7 | use Ecto.Schema 8 | 9 | schema "subscriptions" do 10 | field(:token) 11 | end 12 | end 13 | 14 | def up do 15 | subscriptions = Repo.all(Subscription) 16 | 17 | Enum.each(subscriptions, fn subscription -> 18 | %{subscription | token: SecureRandom.urlsafe_base64(32)} 19 | |> Repo.update() 20 | end) 21 | 22 | alter table(:subscriptions) do 23 | modify :token, :string, null: false 24 | end 25 | 26 | create index(:subscriptions, [:token], unique: true) 27 | end 28 | 29 | def down do 30 | alter table(:subscriptions) do 31 | modify :token, :string, null: true 32 | end 33 | 34 | drop index(:subscriptions, [:token], unique: true) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /priv/repo/migrations/20151218210106_add_slack_channel_to_interest.exs: -------------------------------------------------------------------------------- 1 | defmodule Constable.Repo.Migrations.AddSlackWebhookToInterest do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:interests) do 6 | add :slack_channel, :string 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20160422175132_add_last_discussed_to_announcements.exs: -------------------------------------------------------------------------------- 1 | defmodule Constable.Repo.Migrations.AddLastDiscussedToAnnouncements do 2 | use Ecto.Migration 3 | 4 | def up do 5 | rename table(:announcement), to: table(:announcements) 6 | 7 | alter table(:announcements) do 8 | add :last_discussed_at, :utc_datetime 9 | end 10 | 11 | execute """ 12 | UPDATE announcements 13 | SET last_discussed_at = inserted_at 14 | WHERE last_discussed_at IS NULL 15 | """ 16 | 17 | alter table(:announcements) do 18 | modify :last_discussed_at, :utc_datetime, null: false 19 | end 20 | end 21 | 22 | def down do 23 | rename table(:announcements), to: table(:announcement) 24 | 25 | alter table(:announcement) do 26 | remove :last_discussed_at 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /priv/repo/migrations/20160610142434_add_active_to_user.exs: -------------------------------------------------------------------------------- 1 | defmodule Elixir.Constable.Repo.Migrations.AddActiveToUser do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:users) do 6 | add :active, :boolean, default: true, null: false 7 | end 8 | 9 | create index(:users, [:id, :active]) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /priv/repo/migrations/20160624194052_add_not_null_constraint_to_required_foreign_keys.exs: -------------------------------------------------------------------------------- 1 | defmodule Constable.Repo.Migrations.AddNotNullConstraintToRequiredForeignKeys do 2 | use Ecto.Migration 3 | 4 | def up do 5 | alter table(:announcements_interests) do 6 | modify :interest_id, :integer, null: false 7 | modify :announcement_id, :integer, null: false 8 | end 9 | 10 | alter table(:comments) do 11 | modify :announcement_id, :integer, null: false 12 | end 13 | 14 | alter table(:users) do 15 | modify :email, :string, null: false 16 | end 17 | 18 | alter table(:users_interests) do 19 | modify :interest_id, :integer, null: false 20 | modify :user_id, :integer, null: false 21 | end 22 | end 23 | 24 | def down do 25 | alter table(:announcements_interests) do 26 | modify :interest_id, :integer, null: true 27 | modify :announcement_id, :integer, null: true 28 | end 29 | 30 | alter table(:comments) do 31 | modify :announcement_id, :integer, null: true 32 | end 33 | 34 | alter table(:users) do 35 | modify :email, :string, null: true 36 | end 37 | 38 | alter table(:users_interests) do 39 | modify :interest_id, :integer, null: true 40 | modify :user_id, :integer, null: true 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /priv/repo/migrations/20180413164621_add_slug_to_announcements.exs: -------------------------------------------------------------------------------- 1 | defmodule Constable.Repo.Migrations.AddSlugToAnnouncements do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:announcements) do 6 | add :slug, :string 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20180511201013_make_announcement_slugs_non_nullable.exs: -------------------------------------------------------------------------------- 1 | defmodule Constable.Repo.Migrations.MakeAnnouncementSlugsNonNullable do 2 | use Ecto.Migration 3 | 4 | alias Constable.{Announcement, Repo} 5 | import Ecto.Query, only: [from: 2] 6 | 7 | def change do 8 | Announcement 9 | |> Repo.all() 10 | |> Enum.each(fn announcement -> 11 | slug = Slugger.slugify_downcase(announcement.title) 12 | 13 | query = 14 | from( 15 | a in Announcement, 16 | where: a.id == ^announcement.id, 17 | update: [set: [slug: ^slug]] 18 | ) 19 | 20 | Repo.update_all(query, []) 21 | end) 22 | 23 | alter table(:announcements) do 24 | modify(:slug, :string, null: false) 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200527201059_add_profile_fields_to_users.exs: -------------------------------------------------------------------------------- 1 | defmodule Constable.Repo.Migrations.AddProfileFieldsToUsers do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:users) do 6 | add :profile_url, :string 7 | add :profile_image_url, :string 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /priv/repo/seeds.exs: -------------------------------------------------------------------------------- 1 | # Run with mix run priv/repo/seeds.exs 2 | 3 | alias Constable.Repo 4 | import Constable.Factory 5 | 6 | Repo.get_by(Constable.Interest, name: "everyone") || insert(:interest, name: "everyone") 7 | -------------------------------------------------------------------------------- /script/heroku-compile-assets: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # script/heroku-compile-assets: The asset compilation script called by the Heroku Phoenix Static buildpack. 4 | 5 | npm run deploy 6 | cd $phoenix_dir 7 | mix "${phoenix_ex}.digest" 8 | 9 | if mix help "${phoenix_ex}.digest.clean" 1>/dev/null 2>&1; then 10 | mix "${phoenix_ex}.digest.clean" 11 | fi 12 | -------------------------------------------------------------------------------- /test/constable/comment_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Constable.CommentTest do 2 | use Constable.DataCase, async: true 3 | alias Constable.Comment 4 | import GoodTimes 5 | 6 | test "when a comment is inserted, it updates the announcement last_discussed_at" do 7 | announcement = create_announcement_last_discussed(a_week_ago()) 8 | now = DateTime.utc_now() |> DateTime.truncate(:second) 9 | 10 | comment = insert_comment_on_announcement(announcement, now) 11 | updated_last_discussed_at = DateTime.truncate(comment.announcement.last_discussed_at, :second) 12 | 13 | assert updated_last_discussed_at == now 14 | end 15 | 16 | defp create_announcement_last_discussed(time_ago) do 17 | insert(:announcement, last_discussed_at: Constable.Time.cast!(time_ago)) 18 | end 19 | 20 | defp insert_comment_on_announcement(announcement, last_discussed_at) do 21 | comment_params = %{ 22 | announcement_id: announcement.id, 23 | body: "Anything", 24 | user_id: insert(:user).id 25 | } 26 | 27 | Comment.create_changeset(%Comment{}, comment_params, last_discussed_at) 28 | |> Repo.insert!() 29 | |> Repo.preload(:announcement) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/constable/interest_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Constable.InterestTest do 2 | use Constable.DataCase, async: true 3 | alias Constable.Interest 4 | 5 | test "strips extra hashes from the interest name" do 6 | changeset = Interest.changeset(%{name: "##everyone"}) 7 | assert changeset.valid? 8 | assert changeset.changes.name == "everyone" 9 | end 10 | 11 | test "replaces spaces in the interest name with dashes" do 12 | changeset = Interest.changeset(%{name: "this is an interest"}) 13 | assert changeset.valid? 14 | assert changeset.changes.name == "this-is-an-interest" 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/constable/markdown_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Constable.MarkdownTest do 2 | use ExUnit.Case, async: true 3 | 4 | describe "to_html/1" do 5 | test "filters out JavaScript" do 6 | html = 7 | """ 8 | 9 | 10 | Click please! 11 | 12 | """ 13 | |> Constable.Markdown.to_html() 14 | 15 | assert html == ~s[\nalert("Gonna steal your data!");\nClick please!\n] 16 | end 17 | 18 | test "converts markdown" do 19 | html = 20 | """ 21 | This is a paragraph 22 | """ 23 | |> Constable.Markdown.to_html() 24 | 25 | assert html == "

    This is a paragraph

    \n" 26 | end 27 | 28 | test "allows for including iframes" do 29 | html = 30 | """ 31 | 32 | """ 33 | |> Constable.Markdown.to_html() 34 | 35 | assert html == "

    \n" 36 | end 37 | 38 | test "allows for alt tags on images" do 39 | html = 40 | """ 41 | ![alt text](image.jpg) 42 | """ 43 | |> Constable.Markdown.to_html() 44 | 45 | assert html == "

    \"alt

    \n" 46 | end 47 | 48 | test "allows for inline css on images" do 49 | html = 50 | """ 51 | 52 | """ 53 | |> Constable.Markdown.to_html() 54 | 55 | assert html == 56 | "" 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /test/constable/profiles_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Constable.ProfilesTest do 2 | use Constable.DataCase, async: true 3 | 4 | alias Constable.{User, Profiles} 5 | 6 | describe "update_profile_info/1" do 7 | test "fetches and updates a user's profile info" do 8 | user = insert(:user, profile_url: nil, profile_image_url: nil) 9 | 10 | Profiles.update_profile_info(user) 11 | 12 | updated_user = User |> Repo.get!(user.id) 13 | assert updated_user.profile_url == "http://example.com/" 14 | assert String.ends_with?(updated_user.profile_image_url, "/images/ralph.png") 15 | end 16 | end 17 | 18 | describe "update_profile_image_urls/0" do 19 | test "updates all image urls of users" do 20 | users = insert_pair(:user, profile_image_url: nil) 21 | 22 | :ok = Profiles.update_profile_image_urls(users) 23 | 24 | [user1, user2] = User |> Repo.all() 25 | assert String.ends_with?(user1.profile_image_url, "/images/ralph.png") 26 | assert String.ends_with?(user2.profile_image_url, "/images/ralph.png") 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/constable/pub_sub_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Constable.PubSubTest do 2 | use Constable.DataCase, async: true 3 | 4 | import Constable.Factory 5 | 6 | alias Constable.PubSub 7 | 8 | describe "broadcast_new_comment/1" do 9 | test "broadcasts a comment on the announcement topic" do 10 | announcement = insert(:announcement) 11 | comment = insert(:comment, announcement: announcement) 12 | PubSub.subscribe_to_announcement(announcement.id) 13 | 14 | :ok = PubSub.broadcast_new_comment(comment) 15 | 16 | assert_receive {:new_comment, ^comment} 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/constable/repo_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Constable.RepoTest do 2 | use Constable.DataCase, async: true 3 | alias Constable.Announcement 4 | 5 | test "Repo.count/1 return the count of all models" do 6 | insert_pair(:announcement) 7 | 8 | assert Repo.count(Announcement) == 2 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/constable/services/announcement_updater_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Constable.Services.AnnouncementUpdaterTest do 2 | use Constable.TestWithEcto, async: true 3 | 4 | alias Constable.Repo 5 | alias Constable.Announcement 6 | alias Constable.Services.AnnouncementUpdater 7 | 8 | test "updates an announcement and its interests" do 9 | user = insert(:user) 10 | announcement = insert(:announcement, user: user) 11 | interest = insert(:interest, name: "old") 12 | 13 | insert(:announcement_interest, 14 | announcement: announcement, 15 | interest: interest 16 | ) 17 | 18 | params = %{title: "New!", body: "# Bar!"} 19 | {:ok, _} = AnnouncementUpdater.update(announcement, params, ["new", "newer"]) 20 | 21 | announcement = Repo.get!(Announcement, announcement.id) |> Repo.preload(:interests) 22 | 23 | assert announcement.title == "New!" 24 | assert announcement.body == "# Bar!" 25 | assert announcement_has_interest_named?(announcement, "new") 26 | assert announcement_has_interest_named?(announcement, "newer") 27 | refute announcement_has_interest_named?(announcement, "old") 28 | end 29 | 30 | defp announcement_has_interest_named?(announcement, interest_name) do 31 | announcement.interests |> Enum.any?(&(&1.name == interest_name)) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /test/constable/services/email_reply_parser_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Constable.EmailReplyParserTest do 2 | use ExUnit.Case 3 | 4 | @possible_email_body_formats [ 5 | """ 6 | Sweet. Yay London and Calle! 7 | 8 | On Fri, Apr 29, 2016 at 5:51 AM, Nick Charlton (Constable) wrote: 9 | > Old email contents 10 | """, 11 | """ 12 | Sweet. Yay London and Calle! 13 | 14 | On Fri, Apr 29, 2016 at 5:51 AM, Nick Charlton (Constable) < 15 | email@example.com> wrote: 16 | > Old email contents 17 | """, 18 | """ 19 | Sweet. Yay London and Calle! 20 | 21 | On 05/02, Nick Charlton (Constable) wrote: 22 | > Old email contents 23 | """ 24 | ] 25 | 26 | for phrase <- @possible_email_body_formats do 27 | test ".parse_body parses possible inputs correctly '#{phrase}'" do 28 | assert Constable.EmailReplyParser.remove_original_email(unquote(phrase)) == 29 | "Sweet. Yay London and Calle!" 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/constable/services/google_strategy_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Constable.GoogleStrategyTest do 2 | use Constable.TestWithEcto, async: true 3 | 4 | test ".tokeninfo_url adds correct headers" do 5 | client = GoogleStrategy.client("/") 6 | 7 | {client, _} = GoogleStrategy.tokeninfo_url(client, "1234") 8 | 9 | {"content-type", content_type} = List.first(client.headers) 10 | assert content_type == "application/x-www-form-urlencoded" 11 | end 12 | 13 | test ".tokeninfo_url adds correct params" do 14 | client = GoogleStrategy.client("/") 15 | 16 | {client, _} = GoogleStrategy.tokeninfo_url(client, "1234") 17 | 18 | assert Map.get(client.params, "id_token") == "1234" 19 | end 20 | 21 | test ".tokeninfo_url adds id_token to the URL" do 22 | client = GoogleStrategy.client("/") 23 | 24 | {_, url} = GoogleStrategy.tokeninfo_url(client, "1234") 25 | 26 | assert String.ends_with?(url, "id_token=1234") 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/constable/services/mention_finder_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Constable.Services.MentionFinderTest do 2 | use Constable.TestWithEcto, async: true 3 | 4 | alias Constable.Repo 5 | alias Constable.User 6 | alias Constable.Services.MentionFinder 7 | 8 | test "extracts users from text" do 9 | insert(:user, username: "machoman") 10 | body = "Hello @machoman and @hulkamania" 11 | 12 | user = Repo.one(User) 13 | assert MentionFinder.find_users(body) == [user] 14 | end 15 | 16 | test "extracts possessive-ized users from text" do 17 | insert(:user, username: "machoman") 18 | body = "Hello @machoman's lunch and and @hulkamania fans" 19 | 20 | user = Repo.one(User) 21 | assert MentionFinder.find_users(body) == [user] 22 | end 23 | 24 | test "operates on text with smart quotes" do 25 | insert(:user, username: "machoman") 26 | body = "The @machoman’s peanut butter sandwich at @hulkamania" 27 | 28 | user = Repo.one(User) 29 | assert MentionFinder.find_users(body) == [user] 30 | end 31 | 32 | test "extracts users with periods in username" do 33 | insert(:user, username: "machoman.savage") 34 | body = "The @machoman.savage’s peanut butter sandwich at @hulkamania" 35 | 36 | user = Repo.one(User) 37 | assert MentionFinder.find_users(body) == [user] 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /test/constable/slug_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Constable.SlugTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Constable.Slug 5 | 6 | describe "deslugify/1" do 7 | test "separates the id from the title slug" do 8 | title = "23-slugified-title" 9 | 10 | {:ok, id} = Slug.deslugify(title) 11 | 12 | assert id == 23 13 | end 14 | 15 | test "returns :error if no id is present" do 16 | title = "slugified-title" 17 | 18 | assert :error = Slug.deslugify(title) 19 | end 20 | 21 | test "returns :error if id is 0 (an invalid id)" do 22 | title = "0-slugified-title" 23 | 24 | assert :error = Slug.deslugify(title) 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/constable/user_interest_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Constable.UserInterestTest do 2 | use Constable.TestWithEcto, async: true 3 | 4 | alias Constable.UserInterest 5 | 6 | test "validates uniqueness scoped user_id" do 7 | first_user = insert(:user) 8 | second_user = insert(:user) 9 | interest = insert(:interest) 10 | 11 | assert Repo.insert!(%UserInterest{user_id: first_user.id, interest_id: interest.id}) 12 | assert Repo.insert!(%UserInterest{user_id: second_user.id, interest_id: interest.id}) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/constable_web/acceptance/user_controls_settings_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.UserControlsSettingsTest do 2 | use ConstableWeb.AcceptanceCase 3 | 4 | test "user views their settings", %{session: session} do 5 | user = insert(:user) 6 | 7 | session 8 | |> visit(Routes.settings_path(Endpoint, :show, as: user.id)) 9 | |> has_text?("Profile and Settings") 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/constable_web/acceptance/user_manages_interests_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.UserManagesInterestsTest do 2 | use ConstableWeb.AcceptanceCase 3 | 4 | @unsubscribe_link_css css("[data-role=unsubscribe-from-interest]") 5 | @subscribe_link_css css("[data-role=subscribe-to-interest]") 6 | @view_all_interests_css css("[data-role=view-all-interests]") 7 | 8 | test "user manages interests", %{session: session} do 9 | insert(:interest) 10 | 11 | session 12 | |> view_interests() 13 | |> not_subscribed_to_interest?() 14 | 15 | session 16 | |> subscribe_to_interest() 17 | |> subscribed_to_interest?() 18 | 19 | session 20 | |> unsubscribe_from_interest() 21 | |> not_subscribed_to_interest?() 22 | end 23 | 24 | test "user subscribes to an interest from interest page", %{session: session} do 25 | user = insert(:user) 26 | interest = insert(:interest) 27 | session |> visit(Routes.interest_path(Endpoint, :show, interest, as: user.id)) 28 | 29 | assert not_subscribed_to_interest?(session) 30 | session |> subscribe_to_interest 31 | 32 | assert subscribed_to_interest?(session) 33 | end 34 | 35 | defp view_interests(session) do 36 | user = insert(:user) 37 | 38 | session 39 | |> visit(Routes.announcement_path(Endpoint, :index, as: user.id)) 40 | |> click(@view_all_interests_css) 41 | end 42 | 43 | defp subscribed_to_interest?(session) do 44 | session |> assert_has(@unsubscribe_link_css) 45 | end 46 | 47 | defp not_subscribed_to_interest?(session) do 48 | session |> assert_has(@subscribe_link_css) 49 | end 50 | 51 | defp subscribe_to_interest(session) do 52 | session |> click(@subscribe_link_css) 53 | end 54 | 55 | defp unsubscribe_from_interest(session) do 56 | session |> click(@unsubscribe_link_css) 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /test/constable_web/acceptance/user_searches_announcements_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.UserSearchesAnnouncementsTest do 2 | use ConstableWeb.AcceptanceCase 3 | 4 | test "user performs search", %{session: session} do 5 | matching_announcement = insert(:announcement, title: "foobar1") 6 | non_matching_announcement = insert(:announcement, title: "foobar2") 7 | user = insert(:user) 8 | 9 | session 10 | |> visit(Routes.announcement_path(Endpoint, :new, as: user.id)) 11 | |> fill_in(text_field("query"), with: matching_announcement.title) 12 | |> submit_search 13 | 14 | assert has_announcement_text?(session, matching_announcement.title) 15 | refute has_announcement_text?(session, non_matching_announcement.title) 16 | end 17 | 18 | defp submit_search(session) do 19 | session 20 | |> execute_script("$('.app-header__search-input').parent().trigger('submit')") 21 | 22 | session 23 | end 24 | 25 | defp has_announcement_text?(session, announcment_title) do 26 | session 27 | |> find(css("[data-role=title]")) 28 | |> has_text?(announcment_title) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/constable_web/acceptance/user_unsubscribes_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.UserUnsubscribesTest do 2 | use ConstableWeb.AcceptanceCase 3 | 4 | test "shows unsubscribe message when logged in", %{session: session} do 5 | announcement = insert(:announcement) 6 | subscription = insert(:subscription, announcement: announcement) 7 | user = insert(:user) 8 | 9 | session 10 | |> visit(Routes.unsubscribe_path(Endpoint, :show, subscription.token, as: user)) 11 | 12 | assert has_unsubscribed_flash_message?(session) 13 | assert has_announcement_title?(session, announcement.title) 14 | end 15 | 16 | test "shows unsubscribed message when logged out", %{session: session} do 17 | subscription = insert(:subscription) 18 | 19 | session 20 | |> visit(Routes.unsubscribe_path(Endpoint, :show, subscription.token)) 21 | 22 | assert has_unsubscribed_flash_message?(session) 23 | assert has_login_button?(session) 24 | end 25 | 26 | defp has_unsubscribed_flash_message?(session) do 27 | session 28 | |> find(css(".flash")) 29 | |> has_text?("unsubscribed") 30 | end 31 | 32 | defp has_announcement_title?(session, text) do 33 | session 34 | |> find(css("h1[data-role=title]")) 35 | |> has_text?(text) 36 | end 37 | 38 | defp has_login_button?(session) do 39 | session 40 | |> find(css("a.sign-in-link")) 41 | |> has_text?("Sign in") 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/constable_web/controllers/api/comment_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.Api.CommentControllerTest do 2 | import Ecto.Query 3 | use ConstableWeb.ConnCase, async: true 4 | use Bamboo.Test 5 | alias Constable.Emails 6 | alias Constable.Comment 7 | 8 | setup do 9 | {:ok, api_authenticate()} 10 | end 11 | 12 | test "#create creates a comment for user and announcement", %{conn: conn, user: user} do 13 | announcement = insert(:announcement) 14 | subscribed_user = insert(:user) |> with_subscription(announcement) 15 | 16 | conn = 17 | post conn, Routes.api_comment_path(conn, :create), 18 | comment: %{ 19 | body: "Foo", 20 | announcement_id: announcement.id 21 | } 22 | 23 | assert json_response(conn, 201) 24 | comment = Repo.one(Comment) |> Repo.preload([:user, announcement: :user]) 25 | assert comment.body == "Foo" 26 | assert comment.user_id == user.id 27 | assert comment.announcement_id == announcement.id 28 | 29 | assert_delivered_email(Emails.new_comment(comment, [subscribed_user])) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/constable_web/controllers/api/interest_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.Api.InterestControllerTest do 2 | use ConstableWeb.ConnCase, async: true 3 | 4 | @view ConstableWeb.Api.InterestView 5 | 6 | setup do 7 | {:ok, api_authenticate()} 8 | end 9 | 10 | test "#index displays all interests", %{conn: conn} do 11 | interests = insert_pair(:interest) 12 | 13 | conn = get(conn, Routes.api_interest_path(conn, :index)) 14 | 15 | assert json_response(conn, 200) == render_json("index.json", interests: interests) 16 | end 17 | 18 | test "#show displays a single interest", %{conn: conn} do 19 | interest = insert(:interest) 20 | 21 | conn = get(conn, Routes.api_interest_path(conn, :show, interest.id)) 22 | 23 | assert json_response(conn, 200)["interest"]["id"] == interest.id 24 | end 25 | 26 | test "#update changes the slack channel and adds leading #", %{conn: conn} do 27 | interest = insert(:interest) 28 | 29 | conn = put conn, Routes.api_interest_path(conn, :update, interest.id), channel: "boston" 30 | 31 | assert json_response(conn, 200)["interest"]["slack_channel"] == "#boston" 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /test/constable_web/controllers/api/subscription_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.Api.SubscriptionControllerTest do 2 | import Ecto.Query 3 | use ConstableWeb.ConnCase, async: true 4 | 5 | alias Constable.Subscription 6 | 7 | setup do 8 | {:ok, api_authenticate()} 9 | end 10 | 11 | test "#index shows all current users subscriptions", %{conn: conn, user: user} do 12 | other_user = insert(:user) 13 | subscription_1 = insert(:subscription, user: user) 14 | subscription_2 = insert(:subscription, user: user) 15 | insert(:subscription, user: other_user) 16 | 17 | conn = get(conn, Routes.api_subscription_path(conn, :index)) 18 | 19 | ids = fetch_json_ids("subscriptions", conn) 20 | assert ids == [subscription_1.id, subscription_2.id] 21 | end 22 | 23 | test "#create subscribes the current user to an announcement", %{conn: conn, user: user} do 24 | announcement = insert(:announcement) 25 | 26 | post conn, Routes.api_subscription_path(conn, :create), 27 | subscription: %{ 28 | announcement_id: announcement.id 29 | } 30 | 31 | subscription = Repo.one!(Subscription) 32 | assert subscription.user_id == user.id 33 | assert subscription.announcement_id == announcement.id 34 | end 35 | 36 | test "#delete destroys subscription", %{conn: conn, user: user} do 37 | subscription = insert(:subscription, user: user) 38 | 39 | conn = delete(conn, Routes.api_subscription_path(conn, :delete, subscription.id)) 40 | 41 | assert response(conn, 204) 42 | end 43 | 44 | test "#delete can only destroys current users subscriptions", %{conn: conn} do 45 | other_user = insert(:user) 46 | subscription = insert(:subscription, user: other_user) 47 | 48 | conn = delete(conn, Routes.api_subscription_path(conn, :delete, subscription.id)) 49 | 50 | assert response(conn, 401) 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /test/constable_web/controllers/api/user_interest_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.Api.UserInterestControllerTest do 2 | use ConstableWeb.ConnCase, async: true 3 | 4 | @view ConstableWeb.Api.UserInterestView 5 | 6 | setup do 7 | {:ok, api_authenticate()} 8 | end 9 | 10 | test "#index returns current users user_interests", %{conn: conn, user: user} do 11 | user_interests = insert_pair(:user_interest, user: user) 12 | 13 | conn = get(conn, Routes.api_user_interest_path(conn, :index)) 14 | 15 | assert json_response(conn, 200) == render_json("index.json", user_interests: user_interests) 16 | end 17 | 18 | test "#show returns invidual interest", %{conn: conn} do 19 | user_interest = insert(:user_interest) 20 | 21 | conn = get(conn, Routes.api_user_interest_path(conn, :show, user_interest.id)) 22 | 23 | assert json_response(conn, 200) == render_json("show.json", user_interest: user_interest) 24 | end 25 | 26 | test "#destroy destroys a user's interest", %{conn: conn, user: user} do 27 | user_interest = insert(:user_interest, user: user) 28 | 29 | conn = delete(conn, Routes.api_user_interest_path(conn, :delete, user_interest.id)) 30 | 31 | assert response(conn, 204) 32 | end 33 | 34 | test "#destroy only allows current user to destroy user interest", %{conn: conn} do 35 | other_user = insert(:user) 36 | user_interest = insert(:user_interest, user: other_user) 37 | 38 | conn = delete(conn, Routes.api_user_interest_path(conn, :delete, user_interest.id)) 39 | 40 | assert response(conn, 401) 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /test/constable_web/controllers/email_forward_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.EmailForwardControllerTest do 2 | use ConstableWeb.ConnCase, async: true 3 | use Bamboo.Test 4 | alias ConstableWeb.Endpoint 5 | alias Constable.Emails 6 | 7 | test "forwards email to admins" do 8 | System.put_env("ADMIN_EMAILS", "1@foo.com, 2@foo.com") 9 | 10 | forwarded_emails = 11 | create_email_forward_webhook( 12 | from_email: "someone@gmail.com", 13 | text: "YO DAWG", 14 | email: "admin@constable.io" 15 | ) 16 | 17 | conn = build_conn() 18 | 19 | conn = post(conn, Routes.email_forward_path(Endpoint, :create), forwarded_emails) 20 | 21 | assert conn.status == 200 22 | 23 | message = 24 | forwarded_emails.mandrill_events |> Poison.decode!() |> List.first() |> Map.fetch!("msg") 25 | 26 | assert Emails.forwarded_email(message).to == ~w(1@foo.com 2@foo.com) 27 | assert_delivered_email(Emails.forwarded_email(message)) 28 | end 29 | 30 | defp create_email_forward_webhook(message_attributes) do 31 | email_reply_message = build(:email_reply_message, message_attributes) 32 | 33 | reply_events = 34 | build(:email_reply_event, msg: email_reply_message) 35 | |> List.wrap() 36 | |> Poison.encode!() 37 | 38 | build(:email_reply_webhook, mandrill_events: reply_events) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/constable_web/controllers/home_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.HomeControllerTest do 2 | use ConstableWeb.ConnCase, async: true 3 | 4 | setup do 5 | {:ok, browser_authenticate()} 6 | end 7 | 8 | test "when authenticated redirect to announcements", %{conn: conn} do 9 | conn = get(conn, Routes.home_path(conn, :index)) 10 | 11 | assert redirected_to(conn) == Routes.announcement_path(conn, :index) 12 | end 13 | 14 | test "redirects to the original request path and removes it from the session" do 15 | conn = 16 | build_conn(:get, "/") 17 | |> assign(:current_user, build(:user)) 18 | |> with_session(original_request_path: Routes.search_path(build_conn(), :new)) 19 | |> get("/") 20 | 21 | assert redirected_to(conn) == Routes.search_path(conn, :new) 22 | refute get_session(conn, :original_request_path) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /test/constable_web/controllers/interest_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.InterestControllerTest do 2 | use ConstableWeb.ConnCase, async: true 3 | 4 | setup do 5 | {:ok, browser_authenticate()} 6 | end 7 | 8 | test "#show all announcements that are associated with this interest", %{conn: conn} do 9 | interest = insert(:interest) 10 | insert(:announcement, title: "Awesome") |> tag_with_interest(interest) 11 | insert(:announcement, title: "Nope") |> tag_with_interest(insert(:interest)) 12 | 13 | conn = get(conn, Routes.interest_path(conn, :show, interest)) 14 | 15 | assert html_response(conn, :ok) =~ "Awesome" 16 | refute html_response(conn, :ok) =~ "Nope" 17 | end 18 | 19 | test "works with legacy ids for the param", %{conn: conn} do 20 | interest = insert(:interest) 21 | insert(:announcement, title: "Awesome") |> tag_with_interest(interest) 22 | 23 | conn = get(conn, Routes.interest_path(conn, :show, interest.id)) 24 | 25 | assert html_response(conn, :ok) =~ "Awesome" 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/constable_web/controllers/recipients_preview_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ContableWeb.RecipientsPreviewControllerTest do 2 | use ConstableWeb.ConnCase, async: true 3 | 4 | setup do 5 | {:ok, browser_authenticate()} 6 | end 7 | 8 | describe ".show/2" do 9 | test "returns the interested users' names", %{conn: conn} do 10 | interest = insert(:interest) 11 | insert(:announcement) |> tag_with_interest(interest) 12 | interested_user = insert(:user) |> with_interest(interest) 13 | 14 | response = 15 | conn 16 | |> get(Routes.recipients_preview_path(conn, :show, %{"interests" => interest.name})) 17 | |> json_response(200) 18 | 19 | recipients_preview_html = response["recipients_preview_html"] 20 | 21 | assert recipients_preview_html =~ interested_user.name 22 | end 23 | 24 | test "does not return inactive users", %{conn: conn} do 25 | interest = insert(:interest) 26 | insert(:announcement) |> tag_with_interest(interest) 27 | interested_user = insert(:user, active: false) |> with_interest(interest) 28 | 29 | response = 30 | conn 31 | |> get(Routes.recipients_preview_path(conn, :show, %{"interests" => interest.name})) 32 | |> json_response(200) 33 | 34 | recipients_preview_html = response["recipients_preview_html"] 35 | 36 | refute recipients_preview_html =~ interested_user.name 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /test/constable_web/controllers/session_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.SessionControllerTest do 2 | use ConstableWeb.ConnCase, async: true 3 | 4 | setup do 5 | {:ok, browser_authenticate()} 6 | end 7 | 8 | test "when authenticated redirect to home", %{conn: conn} do 9 | conn = get(conn, Routes.session_path(conn, :new)) 10 | 11 | assert redirected_to(conn) == Routes.home_path(conn, :index) 12 | end 13 | 14 | test "when not authenticated render login" do 15 | conn = build_conn() 16 | 17 | conn = get(conn, Routes.session_path(conn, :new)) 18 | 19 | assert html_response(conn, :ok) =~ "Sign in" 20 | end 21 | 22 | test "delete logs user out and redirects to home", %{conn: conn} do 23 | conn = delete(conn, Routes.session_path(conn, :delete)) 24 | 25 | assert redirected_to(conn) == Routes.home_path(conn, :index) 26 | refute conn.cookies["user_id"] 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/constable_web/controllers/settings_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.SettingsControllerTest do 2 | use ConstableWeb.ConnCase, async: true 3 | 4 | alias Constable.User 5 | 6 | setup do 7 | {:ok, browser_authenticate()} 8 | end 9 | 10 | test "show renders the settings form", %{conn: conn} do 11 | conn = get(conn, Routes.settings_path(conn, :show)) 12 | 13 | assert html_response(conn, :ok) 14 | end 15 | 16 | test "#update updates user attributes" do 17 | user = insert(:user, name: "Joe Dirt", auto_subscribe: true, daily_digest: true) 18 | %{conn: conn, user: user} = browser_authenticate(user) 19 | 20 | put conn, Routes.settings_path(conn, :update), 21 | user: %{ 22 | auto_subscribe: false, 23 | daily_digest: false, 24 | name: "Roger Murdoch" 25 | } 26 | 27 | user = Repo.get(User, user.id) 28 | assert user.name == "Roger Murdoch" 29 | refute user.auto_subscribe 30 | refute user.daily_digest 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/constable_web/controllers/subscription_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.SubscriptionControllerTest do 2 | use ConstableWeb.ConnCase, async: true 3 | use Bamboo.Test 4 | 5 | alias Constable.Subscription 6 | 7 | setup do 8 | {:ok, browser_authenticate()} 9 | end 10 | 11 | test "#create creates the subscription", %{conn: conn, user: user} do 12 | announcement = insert(:announcement) 13 | 14 | post(conn, Routes.announcement_subscription_path(conn, :create, announcement.id)) 15 | 16 | subscription = Repo.one(Subscription) |> Repo.preload([:user, :announcement]) 17 | assert subscription.user.id == user.id 18 | assert subscription.announcement.id == announcement.id 19 | end 20 | 21 | test "#delete deletes the subscription", %{conn: conn, user: user} do 22 | announcement = insert(:announcement) 23 | subscription = insert(:subscription, user: user, announcement: announcement) 24 | 25 | delete(conn, Routes.announcement_subscription_path(conn, :delete, announcement.id)) 26 | 27 | refute Repo.get(Subscription, subscription.id) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/constable_web/controllers/unsubscribe_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.UsubscribeControllerTest do 2 | use ConstableWeb.ConnCase, async: true 3 | 4 | alias Constable.Repo 5 | alias Constable.Subscription 6 | 7 | test "#show deletes subscription and shows a page" do 8 | announcement = insert(:announcement) 9 | subscription = insert(:subscription, announcement: announcement) 10 | conn = build_conn() 11 | 12 | conn = get(conn, Routes.unsubscribe_path(conn, :show, subscription.token)) 13 | 14 | assert Repo.one(Subscription) == nil 15 | assert redirected_to(conn) == Routes.announcement_path(conn, :show, announcement) 16 | end 17 | 18 | test "#show shows the page if no subscription exists" do 19 | non_existent_token = "foo" 20 | conn = build_conn() 21 | 22 | conn = get(conn, Routes.unsubscribe_path(conn, :show, non_existent_token)) 23 | 24 | assert redirected_to(conn) == Routes.announcement_path(conn, :index) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/constable_web/controllers/user_activation_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.UserActivationControllerTest do 2 | use ConstableWeb.ConnCase, async: true 3 | 4 | alias Constable.{Repo, User} 5 | 6 | setup do 7 | {:ok, browser_authenticate()} 8 | end 9 | 10 | test "GET index renders deactivate button when user is active", %{conn: conn} do 11 | insert(:user, active: true) 12 | resp = get(conn, Routes.user_activation_path(conn, :index)) 13 | 14 | assert html_response(resp, 200) =~ "Deactivate" 15 | end 16 | 17 | test "GET index renders activate button when user is inactive", %{conn: conn} do 18 | insert(:user, active: false) 19 | resp = get(conn, Routes.user_activation_path(conn, :index)) 20 | 21 | assert html_response(resp, 200) =~ "Activate" 22 | end 23 | 24 | test "PUT update toggles active flag on user", %{conn: conn} do 25 | user = insert(:user, active: false) 26 | 27 | put(conn, Routes.user_activation_path(conn, :update, user)) 28 | user = Repo.get!(User, user.id) 29 | assert user.active 30 | 31 | put(conn, Routes.user_activation_path(conn, :update, user)) 32 | user = Repo.get!(User, user.id) 33 | refute user.active 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/constable_web/plugs/api_auth_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Constable.Plugs.RequireApiLoginTest do 2 | use ConstableWeb.ConnCase 3 | 4 | test "active user is assigned to current_user assigns on conn" do 5 | user = insert(:user, active: true) 6 | 7 | conn = 8 | build_conn() 9 | |> bypass_through 10 | |> put_req_header("authorization", user.token) 11 | |> run_plug 12 | 13 | assert conn.assigns[:current_user] 14 | end 15 | 16 | test "inactive user is not assigned to current_user assigns on conn" do 17 | user = insert(:user, active: false) 18 | 19 | conn = 20 | build_conn() 21 | |> bypass_through 22 | |> put_req_header("authorization", user.token) 23 | |> run_plug 24 | 25 | refute conn.assigns[:current_user] 26 | end 27 | 28 | defp run_plug(conn) do 29 | conn |> Constable.Plugs.RequireApiLogin.call(%{}) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/constable_web/plugs/deslugifier_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Constable.Plugs.DeslugifierTest do 2 | use ConstableWeb.ConnCase, async: true 3 | 4 | alias Constable.Plugs.Deslugifier 5 | 6 | test "takes a slugified_key upon initialization" do 7 | assert Deslugifier.init(slugified_key: "id") == "id" 8 | end 9 | 10 | test "raises an error if no slugified_key is provided" do 11 | error_msg = "Must provide a :slugified_key to Constable.Plugs.Deslugifier" 12 | 13 | assert_raise RuntimeError, error_msg, fn -> 14 | Deslugifier.init([]) 15 | end 16 | end 17 | 18 | test "turns an id-slugified-title into an id" do 19 | conn = build_conn(:get, "foo", id: "23-slugified-title") 20 | 21 | conn = Deslugifier.call(conn, "id") 22 | 23 | assert conn.params["id"] == 23 24 | end 25 | 26 | test "returns unmodified conn if key is not in params" do 27 | conn = build_conn(:get, "foo") 28 | 29 | unmodified_conn = Deslugifier.call(conn, "id") 30 | 31 | assert unmodified_conn == conn 32 | end 33 | 34 | test "returns unmodified conn if it fails to deslugify key" do 35 | conn = build_conn(:get, "foo", id: "slugified-title-no-id") 36 | 37 | unmodified_conn = Deslugifier.call(conn, "id") 38 | 39 | assert unmodified_conn == conn 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/constable_web/plugs/fetch_current_user_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Constable.Plugs.FetchCurrentUserTest do 2 | use ConstableWeb.ConnCase 3 | 4 | test "active user is assigned to current_user assigns on conn" do 5 | user = insert(:user, active: true) 6 | token = Constable.UserIdentifier.sign_user_id(ConstableWeb.Endpoint, user.id) 7 | 8 | conn = 9 | build_conn() 10 | |> bypass_through 11 | |> Phoenix.ConnTest.put_req_cookie("user_id", token) 12 | |> with_session 13 | |> get("/") 14 | |> run_plug 15 | 16 | assert conn.assigns[:current_user] 17 | end 18 | 19 | test "inactive user is not assigned to current_user assigns on conn" do 20 | user = insert(:user, active: false) 21 | token = Constable.UserIdentifier.sign_user_id(ConstableWeb.Endpoint, user.id) 22 | 23 | conn = 24 | build_conn() 25 | |> bypass_through 26 | |> Phoenix.ConnTest.put_req_cookie("user_id", token) 27 | |> with_session 28 | |> get("/") 29 | |> run_plug 30 | 31 | refute conn.assigns[:current_user] 32 | end 33 | 34 | defp run_plug(conn) do 35 | conn |> Constable.Plugs.FetchCurrentUser.call(%{}) 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/constable_web/plugs/require_web_login_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Constable.Plugs.RequireWebLoginTest do 2 | use ConstableWeb.ConnCase, async: true 3 | 4 | test "user is redirected when current_user is not set" do 5 | conn = build_conn() |> with_session |> run_plug 6 | 7 | assert redirected_to(conn) == "/session/new" 8 | end 9 | 10 | test "the original request path is stored on the session" do 11 | conn = build_conn(:get, "/foo") |> with_session |> run_plug 12 | 13 | assert get_session(conn, :original_request_path) == "/foo" 14 | end 15 | 16 | test "user passes through when current_user is set" do 17 | conn = build_conn() |> authenticate |> run_plug 18 | 19 | assert not_redirected?(conn) 20 | end 21 | 22 | defp not_redirected?(conn) do 23 | conn.status != 302 24 | end 25 | 26 | defp authenticate(conn) do 27 | conn |> assign(:current_user, %Constable.User{}) 28 | end 29 | 30 | defp run_plug(conn) do 31 | conn |> Constable.Plugs.RequireWebLogin.call(%{}) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /test/constable_web/views/api/announcement_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.Api.AnnouncementViewTest do 2 | use ConstableWeb.ViewCase, async: true 3 | 4 | alias ConstableWeb.Api.AnnouncementView 5 | alias ConstableWeb.Api.CommentView 6 | alias ConstableWeb.Router.Helpers, as: Routes 7 | 8 | test "show.json returns correct fields" do 9 | interest = insert(:interest) 10 | announcement = insert(:announcement) |> tag_with_interest(interest) 11 | comment = insert(:comment, announcement: announcement) 12 | announcement_url = Routes.announcement_url(ConstableWeb.Endpoint, :show, announcement) 13 | 14 | rendered_announcement = render_one(announcement, AnnouncementView, "show.json") 15 | 16 | assert rendered_announcement == %{ 17 | announcement: %{ 18 | id: announcement.id, 19 | title: announcement.title, 20 | body: announcement.body, 21 | inserted_at: announcement.inserted_at, 22 | updated_at: announcement.updated_at, 23 | user_id: announcement.user_id, 24 | comments: render_many([comment], CommentView, "comment.json"), 25 | interest_ids: [interest.id], 26 | url: announcement_url 27 | } 28 | } 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/constable_web/views/api/comment_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.Api.CommentViewTest do 2 | use ConstableWeb.ViewCase, async: true 3 | alias ConstableWeb.Api.CommentView 4 | 5 | test "show.json returns correct fields" do 6 | comment = insert(:comment) 7 | 8 | rendered_comment = render_one(comment, CommentView, "show.json") 9 | 10 | assert rendered_comment == %{ 11 | comment: %{ 12 | id: comment.id, 13 | body: comment.body, 14 | announcement_id: comment.announcement_id, 15 | user_id: comment.user_id, 16 | inserted_at: comment.inserted_at 17 | } 18 | } 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/constable_web/views/api/interest_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.Api.InterestViewTest do 2 | use ConstableWeb.ViewCase, async: true 3 | alias ConstableWeb.Api.InterestView 4 | 5 | test "show.json returns correct fields" do 6 | interest = build(:interest, id: 1) 7 | 8 | rendered_interest = render_one(interest, InterestView, "show.json") 9 | 10 | assert rendered_interest == %{ 11 | interest: %{ 12 | id: interest.id, 13 | name: interest.name, 14 | slack_channel: interest.slack_channel 15 | } 16 | } 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/constable_web/views/api/subscription_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.Api.SubscriptionViewTest do 2 | use ConstableWeb.ViewCase, async: true 3 | alias ConstableWeb.Api.SubscriptionView 4 | 5 | test "show.json returns correct fields" do 6 | subscription = insert(:subscription) 7 | 8 | rendered_subscription = render_one(subscription, SubscriptionView, "show.json") 9 | 10 | assert rendered_subscription == %{ 11 | subscription: %{ 12 | id: subscription.id, 13 | announcement_id: subscription.announcement_id, 14 | user_id: subscription.user_id 15 | } 16 | } 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/constable_web/views/api/user_interest_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.Api.UserInterestViewTest do 2 | use ConstableWeb.ViewCase, async: true 3 | alias ConstableWeb.Api.UserInterestView 4 | 5 | test "show.json returns correct fields" do 6 | user_interest = insert(:user_interest) 7 | 8 | rendered_user_interest = render_one(user_interest, UserInterestView, "show.json") 9 | 10 | assert rendered_user_interest == %{ 11 | user_interest: %{ 12 | id: user_interest.id, 13 | interest_id: user_interest.interest_id, 14 | user_id: user_interest.user_id 15 | } 16 | } 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/constable_web/views/api/user_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.Api.UserViewTest do 2 | use ConstableWeb.ViewCase, async: true 3 | alias ConstableWeb.Api.UserView 4 | 5 | test "show.json returns correct fields" do 6 | user = 7 | insert(:user) 8 | |> with_interest 9 | |> with_subscription 10 | |> Repo.preload([:user_interests, :subscriptions]) 11 | 12 | rendered_user = render_one(user, UserView, "show.json") 13 | 14 | assert rendered_user == %{ 15 | user: %{ 16 | id: user.id, 17 | name: user.name, 18 | profile_image_url: user.profile_image_url, 19 | daily_digest: user.daily_digest, 20 | auto_subscribe: user.auto_subscribe, 21 | username: user.username, 22 | user_interests: ids_from(user.user_interests), 23 | subscriptions: ids_from(user.subscriptions) 24 | } 25 | } 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/constable_web/views/auth_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.AuthViewTest do 2 | use ConstableWeb.ViewCase, async: true 3 | alias ConstableWeb.AuthView 4 | alias ConstableWeb.Api.UserView 5 | 6 | test "show.json returns correct fields" do 7 | user = insert(:user) 8 | 9 | rendered_user = render_one(user, AuthView, "show.json", as: :user) 10 | user_view_json = render_one(user, UserView, "show.json") 11 | user_with_token = user_view_json |> put_in([:user, :token], user.token) 12 | 13 | assert rendered_user == user_with_token 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/constable_web/views/email_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.EmailViewTest do 2 | use ConstableWeb.ViewCase, async: true 3 | alias ConstableWeb.EmailView 4 | 5 | test "unsubscribe_link shows a mandrill merge field" do 6 | assert EmailView.unsubscribe_link() == 7 | "#{ConstableWeb.Endpoint.url()}/unsubscribe/{{subscription_id}}" 8 | end 9 | 10 | test "notification_settings_link shows a link to the settings page" do 11 | assert EmailView.notification_settings_link() == "#{ConstableWeb.Endpoint.url()}/settings" 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/constable_web/views/shared_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.SharedViewTest do 2 | use ConstableWeb.ConnCase 3 | alias ConstableWeb.SharedView 4 | 5 | test "markdown_with_users/1 bolds existing users" do 6 | insert(:user, username: "joedirt") 7 | markdown = "Hello @joedirt not @forestgump!" 8 | 9 | rendered = SharedView.markdown_with_users(markdown) 10 | 11 | assert rendered =~ "@joedirt" 12 | refute rendered =~ "@forestgump" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/mix/send_daily_digest_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Constable.SendDailyDigestTest do 2 | use Constable.TestWithEcto, async: true 3 | use Bamboo.Test 4 | 5 | test "sends daily digest to users that want a daily digest" do 6 | daily_digest_user = insert(:user, daily_digest: true) 7 | announcement = insert(:announcement, user: daily_digest_user) |> Repo.preload(:interests) 8 | insert(:user, daily_digest: false) 9 | 10 | Mix.Tasks.Constable.SendDailyDigest.run(nil) 11 | 12 | assert_delivered_email( 13 | Constable.Emails.daily_digest( 14 | [], 15 | [announcement], 16 | [], 17 | [daily_digest_user] 18 | ) 19 | ) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/support/acceptance_case.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.AcceptanceCase do 2 | use ExUnit.CaseTemplate 3 | 4 | using do 5 | quote do 6 | use Wallaby.DSL 7 | 8 | alias ConstableWeb.Endpoint 9 | import Ecto.Schema 10 | import Ecto.Query, only: [from: 2] 11 | alias ConstableWeb.Router.Helpers, as: Routes 12 | import Constable.Factory 13 | import ConstableWeb.WallabyHelper 14 | import Wallaby.Query, only: [link: 1, button: 1, css: 1, text_field: 1] 15 | 16 | Application.put_env(:wallaby, :base_url, ConstableWeb.Endpoint.url()) 17 | end 18 | end 19 | 20 | setup tags do 21 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Constable.Repo) 22 | metadata = Phoenix.Ecto.SQL.Sandbox.metadata_for(Constable.Repo, self()) 23 | {:ok, session} = Wallaby.start_session(metadata: metadata) 24 | 25 | unless tags[:async] do 26 | Ecto.Adapters.SQL.Sandbox.mode(Constable.Repo, {:shared, self()}) 27 | end 28 | 29 | {:ok, session: session} 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/support/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.ChannelCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | channel tests. 5 | Such tests rely on `Phoenix.ChannelTest` and also 6 | import other functionality to make it easier 7 | to build common data structures and query the data layer. 8 | Finally, if the test case interacts with the database, 9 | it cannot be async. For this reason, every test runs 10 | inside a transaction which is reset at the beginning 11 | of the test unless the test case is marked as async. 12 | """ 13 | 14 | use ExUnit.CaseTemplate 15 | 16 | using do 17 | quote do 18 | # Import conveniences for testing with channels 19 | import Phoenix.ChannelTest 20 | use ConstableWeb, :view 21 | # Alias the data repository and import query/model functions 22 | alias Constable.Repo 23 | import Ecto.Schema 24 | import Ecto.Query, only: [from: 2] 25 | import Constable.Factory 26 | 27 | # The default endpoint for testing 28 | @endpoint ConstableWeb.Endpoint 29 | end 30 | end 31 | 32 | setup tags do 33 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Constable.Repo) 34 | 35 | unless tags[:async] do 36 | Ecto.Adapters.SQL.Sandbox.mode(Constable.Repo, {:shared, self()}) 37 | end 38 | 39 | :ok 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.ConnCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | tests that require setting up a connection. 5 | 6 | Such tests rely on `Phoenix.ConnTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | it cannot be async. For this reason, every test runs 12 | inside a transaction which is reset at the beginning 13 | of the test unless the test case is marked as async. 14 | """ 15 | 16 | use ExUnit.CaseTemplate 17 | 18 | using do 19 | quote do 20 | # Import conveniences for testing with connections 21 | import Plug.Conn 22 | import Phoenix.ConnTest 23 | alias ConstableWeb.Router.Helpers, as: Routes 24 | 25 | import ConstableWeb.ConnCaseHelper 26 | 27 | # Alias the data repository and import query/model functions 28 | alias Constable.Repo 29 | import Ecto.Query, only: [from: 2] 30 | import Constable.Factory 31 | 32 | # The default endpoint for testing 33 | @endpoint ConstableWeb.Endpoint 34 | end 35 | end 36 | 37 | setup tags do 38 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Constable.Repo) 39 | 40 | unless tags[:async] do 41 | Ecto.Adapters.SQL.Sandbox.mode(Constable.Repo, {:shared, self()}) 42 | end 43 | 44 | {:ok, conn: Phoenix.ConnTest.build_conn()} 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /test/support/data_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Constable.DataCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | model tests. 5 | 6 | Finally, if the test case interacts with the database, 7 | it cannot be async. For this reason, every test runs 8 | inside a transaction which is reset at the beginning 9 | of the test unless the test case is marked as async. 10 | """ 11 | 12 | use ExUnit.CaseTemplate 13 | 14 | using do 15 | quote do 16 | # Alias the data repository and import query/model functions 17 | alias Constable.Repo 18 | import Ecto.Query, only: [from: 2] 19 | import Constable.Factory 20 | end 21 | end 22 | 23 | setup tags do 24 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Constable.Repo) 25 | 26 | unless tags[:async] do 27 | Ecto.Adapters.SQL.Sandbox.mode(Constable.Repo, {:shared, self()}) 28 | end 29 | 30 | :ok 31 | end 32 | 33 | @doc """ 34 | Helper for returning list of errors in model when passed certain data. 35 | ## Examples 36 | Given a User model that has validation for the presence of a value for the 37 | `:name` field and validation that `:password` is "safe": 38 | iex> errors_on(%User{}, password: "password") 39 | [{:password, "is unsafe"}, {:name, "is blank"}] 40 | You would then write your assertion like: 41 | assert {:password, "is unsafe"} in errors_on(%User{}, password: "password") 42 | """ 43 | def errors_on(model, data) do 44 | model.__struct__.changeset(model, data).errors 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /test/support/test_with_ecto.ex: -------------------------------------------------------------------------------- 1 | defmodule Constable.TestWithEcto do 2 | use ExUnit.CaseTemplate 3 | 4 | using do 5 | quote do 6 | import Constable.Factory 7 | alias Constable.Repo 8 | end 9 | end 10 | 11 | setup tags do 12 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Constable.Repo) 13 | 14 | unless tags[:async] do 15 | Ecto.Adapters.SQL.Sandbox.mode(Constable.Repo, {:shared, self()}) 16 | end 17 | 18 | :ok 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/support/view_case.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.ViewCase do 2 | use ExUnit.CaseTemplate 3 | 4 | using do 5 | quote do 6 | use ConstableWeb, :view 7 | 8 | import Constable.Factory 9 | alias ConstableWeb.UserView 10 | alias ConstableWeb.InterestsView 11 | alias ConstableWeb.SubscriptionView 12 | alias ConstableWeb.UserInterestView 13 | alias ConstableWeb.CommentView 14 | import ConstableWeb.ViewCaseHelper 15 | end 16 | end 17 | 18 | setup tags do 19 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Constable.Repo) 20 | 21 | unless tags[:async] do 22 | Ecto.Adapters.SQL.Sandbox.mode(Constable.Repo, {:shared, self()}) 23 | end 24 | 25 | :ok 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/support/view_case_helper.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.ViewCaseHelper do 2 | def ids_from(enumerable) do 3 | Enum.map(enumerable, fn object -> 4 | Map.get(object, :id) 5 | end) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/support/wallaby_helper.ex: -------------------------------------------------------------------------------- 1 | defmodule ConstableWeb.WallabyHelper do 2 | use Wallaby.DSL 3 | 4 | def accept_all_confirm_dialogs(session) do 5 | execute_script(session, "window.confirm = function(m) { return true; };") 6 | session 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.configure(exclude: [pending: true], max_cases: 10) 2 | {:ok, _} = Application.ensure_all_started(:bamboo) 3 | {:ok, _} = Application.ensure_all_started(:wallaby) 4 | ExUnit.start() 5 | Constable.Pact.register(:google_strategy, FakeGoogleStrategy) 6 | Constable.Pact.register(:profile_provider, FakeProfileProvider) 7 | Ecto.Adapters.SQL.Sandbox.mode(Constable.Repo, :manual) 8 | --------------------------------------------------------------------------------