├── app ├── mailers │ ├── .keep │ ├── welcome_mailer.rb │ └── prompt_mailer.rb ├── models │ ├── .keep │ ├── concerns │ │ └── .keep │ ├── cancellation.rb │ ├── application_record.rb │ ├── import.rb │ ├── prompt_task.rb │ ├── search.rb │ ├── prompt_entry.rb │ ├── export.rb │ ├── subscription.rb │ ├── reply_token.rb │ ├── entry.rb │ ├── prompt_delivery_hour.rb │ ├── email_processor.rb │ ├── admin_dashboard.rb │ ├── user.rb │ └── ohlife_importer.rb ├── assets │ ├── images │ │ ├── .keep │ │ ├── logo.png │ │ ├── team.jpg │ │ ├── favicon.ico │ │ ├── logo-stripe.png │ │ ├── apple-touch-icon-57.png │ │ ├── apple-touch-icon-72.png │ │ ├── apple-touch-icon-114.png │ │ └── apple-touch-icon-144.png │ ├── stylesheets │ │ ├── application.sass │ │ ├── base.sass │ │ ├── base │ │ │ ├── _form.scss │ │ │ ├── _variables.scss │ │ │ ├── _footer.scss │ │ │ ├── _header.scss │ │ │ └── _body.scss │ │ └── icons.scss │ ├── javascripts │ │ ├── tooltips.js │ │ ├── application.js │ │ └── placeholders.min.js │ ├── fonts │ │ ├── glyphicons-halflings-regular.eot │ │ ├── glyphicons-halflings-regular.ttf │ │ └── glyphicons-halflings-regular.woff │ └── config │ │ └── manifest.js ├── views │ ├── pages │ │ ├── .keep │ │ ├── github.html.erb │ │ ├── privacy.html.erb │ │ ├── ohlife-alternative.html.erb │ │ ├── faq.html.erb │ │ └── keeping-a-development-journal.html.erb │ ├── subscriptions │ │ ├── create.html.erb │ │ ├── _pricing.html.erb │ │ ├── _sign_up_link_javascript.html.erb │ │ ├── _payment_form.html.erb │ │ ├── _stripe_javascript.html.erb │ │ └── _close_account.html.erb │ ├── entries │ │ ├── index.html.erb │ │ ├── _date.html.erb │ │ ├── _entry.html.erb │ │ ├── edit.html.erb │ │ └── _welcome.html.erb │ ├── kaminari │ │ ├── _paginator.html.erb │ │ ├── _first_page.html.erb │ │ ├── _next_page.html.erb │ │ └── _prev_page.html.erb │ ├── layouts │ │ ├── _footer.html.erb │ │ ├── _default_content.html.erb │ │ ├── _apple_touch_icons.html.erb │ │ ├── skinny.html.erb │ │ └── application.html.erb │ ├── devise │ │ ├── mailer │ │ │ ├── confirmation_instructions.html.erb │ │ │ ├── unlock_instructions.html.erb │ │ │ └── reset_password_instructions.html.erb │ │ ├── unlocks │ │ │ └── new.html.erb │ │ ├── confirmations │ │ │ └── new.html.erb │ │ ├── passwords │ │ │ ├── edit.html.erb │ │ │ └── new.html.erb │ │ ├── sessions │ │ │ └── new.html.erb │ │ └── shared │ │ │ └── _links.erb │ ├── searches │ │ ├── _entry.html.erb │ │ └── show.html.erb │ ├── application │ │ ├── _flashes.html.erb │ │ ├── _javascript.html.erb │ │ ├── _analytics.html.erb │ │ └── _navbar.html.erb │ ├── landing │ │ ├── _header.html.erb │ │ └── show.html.erb │ ├── welcome_mailer │ │ └── welcome.html.erb │ ├── prompt_mailer │ │ └── prompt.html.erb │ ├── imports │ │ └── new.html.erb │ ├── settings │ │ └── edit.html.erb │ ├── admin_dashboard │ │ └── show.html.erb │ └── credit_cards │ │ ├── edit.html.erb │ │ └── _stripe_javascript.html.erb ├── controllers │ ├── concerns │ │ └── .keep │ ├── landing_controller.rb │ ├── exports_controller.rb │ ├── application_controller.rb │ ├── searches_controller.rb │ ├── admin_dashboard_controller.rb │ ├── settings_controller.rb │ ├── credit_cards_controller.rb │ ├── entries_controller.rb │ ├── cancellations_controller.rb │ ├── imports_controller.rb │ └── subscriptions_controller.rb ├── helpers │ └── application_helper.rb ├── uploaders │ ├── ohlife_export_uploader.rb │ └── photo_uploader.rb ├── workers │ ├── welcome_mailer_worker.rb │ ├── prompt_worker.rb │ └── ohlife_import_worker.rb └── transitions │ ├── fix_future_dated_entries_transition.rb │ └── backfill_subscriptions_transition.rb ├── lib ├── assets │ └── .keep ├── tasks │ ├── .keep │ ├── trailmix.rake │ └── development_seeds.rake └── templates │ └── erb │ └── scaffold │ └── _form.html.erb ├── public ├── favicon.ico ├── robots.txt └── 404.html ├── spec ├── helpers │ └── .keep ├── lib │ └── .keep ├── controllers │ ├── .keep │ ├── credit_cards_controller_spec.rb │ ├── exports_controller_spec.rb │ ├── searches_controller_spec.rb │ ├── settings_controller_spec.rb │ ├── landing_controller_spec.rb │ ├── imports_controller_spec.rb │ ├── admin_dashboard_controller_spec.rb │ ├── cancellations_controller_spec.rb │ └── subscriptions_controller_spec.rb ├── features │ ├── .keep │ ├── user_signs_out_spec.rb │ ├── user_exports_entries_spec.rb │ ├── user_searches_entries_spec.rb │ ├── server_sends_daily_email_prompt_spec.rb │ ├── user_imports_ohlife_entries_spec.rb │ ├── user_cancels_account_spec.rb │ ├── user_edits_entry_spec.rb │ ├── user_updates_credit_card_spec.rb │ ├── user_changes_settings_spec.rb │ ├── user_views_entries_spec.rb │ └── user_responds_to_prompt_spec.rb ├── support │ ├── features │ │ ├── .keep │ │ └── warden_helpers.rb │ ├── matchers │ │ └── .keep │ ├── mixins │ │ └── .keep │ ├── shared_examples │ │ └── .keep │ ├── factory_bot.rb │ ├── action_mailer.rb │ ├── carrierwave.rb │ ├── sidekiq.rb │ └── database_cleaner.rb ├── fixtures │ └── files │ │ ├── photo.jpg │ │ └── ohlife_export.txt ├── models │ ├── prompt_entry_spec.rb │ ├── prompt_task_spec.rb │ ├── reply_token_spec.rb │ ├── export_spec.rb │ ├── ohlife_importer_spec.rb │ ├── search_spec.rb │ ├── entry_spec.rb │ ├── prompt_delivery_hour_spec.rb │ ├── user_spec.rb │ ├── admin_dashboard_spec.rb │ └── email_processor_spec.rb ├── mailers │ ├── welcome_mailer_spec.rb │ └── prompt_mailer_spec.rb ├── views │ └── searches │ │ ├── _entry.html.erb_spec.rb │ │ └── show.html.erb_spec.rb ├── tasks │ └── trailmix_spec.rb ├── factories.rb ├── spec_helper.rb └── transitions │ ├── fix_future_dated_entries_transition_spec.rb │ └── backfill_subscriptions_transition_spec.rb ├── .ruby-version ├── vendor └── assets │ ├── javascripts │ └── .keep │ └── stylesheets │ └── .keep ├── .rspec ├── circle.yml ├── bin ├── dev ├── rake ├── bundle ├── rails ├── rspec ├── delayed_job ├── spring ├── yarn ├── update └── setup ├── Procfile ├── config ├── initializers │ ├── kaminari_config.rb │ ├── session_store.rb │ ├── mime_types.rb │ ├── redis.rb │ ├── chartkick.rb │ ├── yaml.rb │ ├── stripe.rb │ ├── application_controller_renderer.rb │ ├── cookies_serializer.rb │ ├── griddler.rb │ ├── filter_parameter_logging.rb │ ├── permissions_policy.rb │ ├── assets.rb │ ├── wrap_parameters.rb │ ├── backtrace_silencers.rb │ ├── inflections.rb │ ├── errors.rb │ ├── carrierwave.rb │ ├── sidekiq.rb │ ├── cve_patches.rb │ ├── content_security_policy.rb │ └── airbrake.rb ├── spring.rb ├── boot.rb ├── environment.rb ├── cable.yml ├── i18n-tasks.yml ├── secrets.yml ├── database.yml ├── smtp.rb ├── environments │ ├── staging.rb │ ├── test.rb │ ├── development.rb │ └── production.rb ├── locales │ ├── en.yml │ ├── simple_form.en.yml │ └── devise.en.yml ├── puma.rb ├── routes.rb ├── storage.yml └── application.rb ├── db ├── migrate │ ├── 20140923002749_drop_delayed_jobs.rb │ ├── 20141002193215_add_date_to_entry.rb │ ├── 20141010010843_add_stripe_id_to_user.rb │ ├── 20160125013209_add_photos_to_entries.rb │ ├── 20140925183528_add_import_id_to_entry.rb │ ├── 20141004031950_add_reply_token_to_user.rb │ ├── 20140924213544_remove_stripe_customer_id_from_user.rb │ ├── 20140926025305_add_raw_file_to_import.rb │ ├── 20140924172056_add_stripe_customer_id_to_user.rb │ ├── 20141010201317_remove_stripe_id_from_user.rb │ ├── 20140922202440_create_entries.rb │ ├── 20141004062429_add_index_and_not_null_to_user_reply_token.rb │ ├── 20141004203140_add_not_null_to_entry_date.rb │ ├── 20140927193748_remove_default_ohlife_export.rb │ ├── 20140925173215_create_imports.rb │ ├── 20140928015646_add_time_zone_to_user.rb │ ├── 20140928181424_add_prompt_delivery_hour_to_users.rb │ ├── 20141011191307_create_cancellation.rb │ ├── 20140926024949_remove_paperclip_from_import.rb │ ├── 20140924180559_add_not_null_to_user_timestamps.rb │ ├── 20140924180920_add_not_null_to_entry_timestamps.rb │ ├── 20141010200536_create_subscription.rb │ ├── 20140921195949_create_delayed_jobs.rb │ └── 20140921203919_devise_create_users.rb ├── seeds.rb └── schema.rb ├── .gitignore ├── config.ru ├── Rakefile ├── .sample.env ├── .travis.yml ├── .github └── workflows │ └── rubyonrails.yml ├── LICENSE ├── Gemfile └── README.md /app/mailers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/models/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/assets/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/tasks/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/helpers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/lib/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.4.2 2 | -------------------------------------------------------------------------------- /app/assets/images/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/views/pages/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/controllers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/features/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/models/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/support/features/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/support/matchers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/support/mixins/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/controllers/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vendor/assets/javascripts/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vendor/assets/stylesheets/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/support/shared_examples/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /app/views/subscriptions/create.html.erb: -------------------------------------------------------------------------------- 1 |

Success!

2 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | checkout: 2 | post: 3 | - cp .sample.env .env 4 | -------------------------------------------------------------------------------- /app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /bin/dev: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | exec "./bin/rails", "server", *ARGV 3 | -------------------------------------------------------------------------------- /app/models/cancellation.rb: -------------------------------------------------------------------------------- 1 | class Cancellation < ApplicationRecord 2 | end 3 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: bundle exec puma -C config/puma.rb 2 | worker: bundle exec sidekiq 3 | -------------------------------------------------------------------------------- /app/assets/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codecation/trailmix/HEAD/app/assets/images/logo.png -------------------------------------------------------------------------------- /app/assets/images/team.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codecation/trailmix/HEAD/app/assets/images/team.jpg -------------------------------------------------------------------------------- /app/assets/stylesheets/application.sass: -------------------------------------------------------------------------------- 1 | @import "bootstrap-theme" 2 | @import "base" 3 | @import "icons" 4 | -------------------------------------------------------------------------------- /app/assets/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codecation/trailmix/HEAD/app/assets/images/favicon.ico -------------------------------------------------------------------------------- /spec/fixtures/files/photo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codecation/trailmix/HEAD/spec/fixtures/files/photo.jpg -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative "../config/boot" 3 | require "rake" 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /config/initializers/kaminari_config.rb: -------------------------------------------------------------------------------- 1 | Kaminari.configure do |config| 2 | config.default_per_page = 1 3 | end 4 | -------------------------------------------------------------------------------- /spec/support/factory_bot.rb: -------------------------------------------------------------------------------- 1 | RSpec.configure do |config| 2 | config.include FactoryBot::Syntax::Methods 3 | end 4 | -------------------------------------------------------------------------------- /app/assets/images/logo-stripe.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codecation/trailmix/HEAD/app/assets/images/logo-stripe.png -------------------------------------------------------------------------------- /app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | self.abstract_class = true 3 | end 4 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | -------------------------------------------------------------------------------- /app/assets/images/apple-touch-icon-57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codecation/trailmix/HEAD/app/assets/images/apple-touch-icon-57.png -------------------------------------------------------------------------------- /app/assets/images/apple-touch-icon-72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codecation/trailmix/HEAD/app/assets/images/apple-touch-icon-72.png -------------------------------------------------------------------------------- /app/assets/javascripts/tooltips.js: -------------------------------------------------------------------------------- 1 | // Enable any tooltips on the page 2 | $(function () { $("[data-toggle='tooltip']").tooltip(); }); 3 | -------------------------------------------------------------------------------- /config/spring.rb: -------------------------------------------------------------------------------- 1 | Spring.watch( 2 | ".ruby-version", 3 | ".rbenv-vars", 4 | "tmp/restart.txt", 5 | "tmp/caching-dev.txt" 6 | ) 7 | -------------------------------------------------------------------------------- /app/assets/images/apple-touch-icon-114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codecation/trailmix/HEAD/app/assets/images/apple-touch-icon-114.png -------------------------------------------------------------------------------- /app/assets/images/apple-touch-icon-144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codecation/trailmix/HEAD/app/assets/images/apple-touch-icon-144.png -------------------------------------------------------------------------------- /bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) 3 | load Gem.bin_path('bundler', 'bundle') 4 | -------------------------------------------------------------------------------- /app/assets/fonts/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codecation/trailmix/HEAD/app/assets/fonts/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /app/assets/fonts/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codecation/trailmix/HEAD/app/assets/fonts/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /spec/support/action_mailer.rb: -------------------------------------------------------------------------------- 1 | RSpec.configure do |config| 2 | config.before(:each) do 3 | ActionMailer::Base.deliveries.clear 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/assets/fonts/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codecation/trailmix/HEAD/app/assets/fonts/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /config/boot.rb: -------------------------------------------------------------------------------- 1 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 2 | 3 | require "bundler/setup" # Set up gems listed in the Gemfile. 4 | -------------------------------------------------------------------------------- /app/assets/stylesheets/base.sass: -------------------------------------------------------------------------------- 1 | @import "base/_variables" 2 | @import "base/_body" 3 | @import "base/_form" 4 | @import "base/_header" 5 | @import "base/_footer" 6 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path("../config/application", __dir__) 3 | require_relative "../config/boot" 4 | require "rails/commands" 5 | -------------------------------------------------------------------------------- /db/migrate/20140923002749_drop_delayed_jobs.rb: -------------------------------------------------------------------------------- 1 | class DropDelayedJobs < ActiveRecord::Migration 2 | def change 3 | drop_table :delayed_jobs 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative "application" 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /app/models/import.rb: -------------------------------------------------------------------------------- 1 | class Import < ApplicationRecord 2 | has_many :entries 3 | belongs_to :user 4 | 5 | mount_uploader :ohlife_export, OhlifeExportUploader 6 | end 7 | -------------------------------------------------------------------------------- /db/migrate/20141002193215_add_date_to_entry.rb: -------------------------------------------------------------------------------- 1 | class AddDateToEntry < ActiveRecord::Migration 2 | def change 3 | add_column :entries, :date, :date 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/views/entries/index.html.erb: -------------------------------------------------------------------------------- 1 | <% if @entries.empty? %> 2 | <%= render "welcome" %> 3 | <% else %> 4 | <%= render partial: "entry", collection: @entries %> 5 | <% end %> 6 | -------------------------------------------------------------------------------- /config/initializers/session_store.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | Rails.application.config.session_store :cookie_store, key: '_trailmix_session' 4 | -------------------------------------------------------------------------------- /db/migrate/20141010010843_add_stripe_id_to_user.rb: -------------------------------------------------------------------------------- 1 | class AddStripeIdToUser < ActiveRecord::Migration 2 | def change 3 | add_column :users, :stripe_id, :string 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20160125013209_add_photos_to_entries.rb: -------------------------------------------------------------------------------- 1 | class AddPhotosToEntries < ActiveRecord::Migration 2 | def change 3 | add_column :entries, :photo, :string 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | !.keep 2 | *.DS_Store 3 | *.swo 4 | *.swp 5 | /.bundle 6 | /.env 7 | /.foreman 8 | /coverage/* 9 | /db/*.sqlite3 10 | /log/* 11 | /public/system 12 | /tags 13 | /tmp/* 14 | -------------------------------------------------------------------------------- /app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | //= link favicon.ico 2 | //= link chartkick.js 3 | //= link_directory ../javascripts .js 4 | //= link_directory ../stylesheets .css 5 | //= link_tree ../images 6 | -------------------------------------------------------------------------------- /app/controllers/landing_controller.rb: -------------------------------------------------------------------------------- 1 | class LandingController < ApplicationController 2 | def show 3 | if signed_in? 4 | redirect_to entries_path 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/uploaders/ohlife_export_uploader.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | class OhlifeExportUploader < CarrierWave::Uploader::Base 4 | def extension_white_list 5 | %w(txt) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative "config/environment" 4 | 5 | run Rails.application 6 | Rails.application.load_server 7 | -------------------------------------------------------------------------------- /db/migrate/20140925183528_add_import_id_to_entry.rb: -------------------------------------------------------------------------------- 1 | class AddImportIdToEntry < ActiveRecord::Migration 2 | def change 3 | add_column :entries, :import_id, :integer 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20141004031950_add_reply_token_to_user.rb: -------------------------------------------------------------------------------- 1 | class AddReplyTokenToUser < ActiveRecord::Migration 2 | def change 3 | add_column :users, :reply_token, :string 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /bin/rspec: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | begin 3 | load File.expand_path("../spring", __FILE__) 4 | rescue LoadError 5 | end 6 | require 'bundler/setup' 7 | load Gem.bin_path('rspec-core', 'rspec') 8 | -------------------------------------------------------------------------------- /config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new mime types for use in respond_to blocks: 4 | # Mime::Type.register "text/richtext", :rtf 5 | -------------------------------------------------------------------------------- /bin/delayed_job: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require File.expand_path(File.join(File.dirname(__FILE__), '..', 'config', 'environment')) 4 | require 'delayed/command' 5 | Delayed::Command.new(ARGV).daemonize 6 | -------------------------------------------------------------------------------- /app/views/kaminari/_paginator.html.erb: -------------------------------------------------------------------------------- 1 | <%= paginator.render do -%> 2 | 7 | <% end %> 8 | -------------------------------------------------------------------------------- /config/initializers/redis.rb: -------------------------------------------------------------------------------- 1 | # https://devcenter.heroku.com/articles/connecting-heroku-redis#connecting-in-ruby 2 | $redis = Redis.new(url: ENV["REDIS_URL"], ssl_params: { verify_mode: OpenSSL::SSL::VERIFY_NONE }) 3 | -------------------------------------------------------------------------------- /db/migrate/20140924213544_remove_stripe_customer_id_from_user.rb: -------------------------------------------------------------------------------- 1 | class RemoveStripeCustomerIdFromUser < ActiveRecord::Migration 2 | def change 3 | remove_column :users, :stripe_customer_id 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/fixtures/files/ohlife_export.txt: -------------------------------------------------------------------------------- 1 | 2014-01-28 2 | 3 | Older entry line one. 4 | 5 | Older entry line two. 6 | 7 | 2014-01-29 8 | 9 | List of items: 10 | 11 | * Item 1 12 | * Item 2 13 | -------------------------------------------------------------------------------- /app/assets/stylesheets/base/_form.scss: -------------------------------------------------------------------------------- 1 | input[type]:focus { 2 | border-color: $brown; 3 | box-shadow: none; 4 | outline: 0 none; 5 | } 6 | 7 | #sign-up-button-tooltip { 8 | display: inline-block; 9 | } 10 | -------------------------------------------------------------------------------- /app/views/kaminari/_first_page.html.erb: -------------------------------------------------------------------------------- 1 |
  • 2 | <%= link_to "Latest".html_safe, 3 | current_page.first? ? "#" : url, 4 | :remote => remote 5 | %> 6 |
  • 7 | -------------------------------------------------------------------------------- /app/views/subscriptions/_pricing.html.erb: -------------------------------------------------------------------------------- 1 | Trailmix costs $3.99/month, but it's free for 14 days. You can 2 | cancel at any time. If you're ever unhappy with the service, we'll refund your 3 | money. 4 | -------------------------------------------------------------------------------- /db/migrate/20140926025305_add_raw_file_to_import.rb: -------------------------------------------------------------------------------- 1 | class AddRawFileToImport < ActiveRecord::Migration 2 | def change 3 | add_column :imports, :ohlife_export, :string, default: "", null: false 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/support/features/warden_helpers.rb: -------------------------------------------------------------------------------- 1 | include Warden::Test::Helpers 2 | 3 | Warden.test_mode! 4 | 5 | RSpec.configure do |config| 6 | config.after(:each) do 7 | Warden.test_reset! 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/views/kaminari/_next_page.html.erb: -------------------------------------------------------------------------------- 1 |
  • 2 | <%= link_to "← Older".html_safe, 3 | current_page.last? ? "#" : url, 4 | :remote => remote 5 | %> 6 |
  • 7 | -------------------------------------------------------------------------------- /app/views/kaminari/_prev_page.html.erb: -------------------------------------------------------------------------------- 1 |
  • 2 | <%= link_to "Newer →".html_safe, 3 | current_page.first? ? "#" : url, 4 | :remote => remote 5 | %> 6 |
  • 7 | -------------------------------------------------------------------------------- /db/migrate/20140924172056_add_stripe_customer_id_to_user.rb: -------------------------------------------------------------------------------- 1 | class AddStripeCustomerIdToUser < ActiveRecord::Migration 2 | def change 3 | add_column :users, :stripe_customer_id, :string, null: false 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/controllers/credit_cards_controller_spec.rb: -------------------------------------------------------------------------------- 1 | describe CreditCardsController do 2 | it "requires authentication" do 3 | get :edit 4 | 5 | expect(response).to redirect_to(new_user_session_path) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/assets/stylesheets/base/_variables.scss: -------------------------------------------------------------------------------- 1 | // colors 2 | $brown: #883d1c; 3 | $light-brown: #f2e6da; 4 | $lighter-brown: #f7ebdf; 5 | $transparent-brown: rgba(136, 61, 28, 0.5); 6 | $light-grey: #eeeeee; 7 | $lighter-grey: #fcfcfc; 8 | -------------------------------------------------------------------------------- /app/assets/stylesheets/base/_footer.scss: -------------------------------------------------------------------------------- 1 | footer { 2 | position: fixed; 3 | bottom: 0; 4 | width: 100%; 5 | background: white; 6 | border-top: 1px solid $light-grey; 7 | padding: 10px 0; 8 | text-align: center; 9 | } 10 | -------------------------------------------------------------------------------- /app/views/layouts/_footer.html.erb: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /app/workers/welcome_mailer_worker.rb: -------------------------------------------------------------------------------- 1 | class WelcomeMailerWorker 2 | include Sidekiq::Worker 3 | 4 | def perform(user_id) 5 | user = User.find(user_id) 6 | 7 | WelcomeMailer.welcome(user).deliver_now 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /config/initializers/chartkick.rb: -------------------------------------------------------------------------------- 1 | Chartkick.options = { 2 | library: { 3 | colors: ["#883D1C"], 4 | backgroundColor: "#FCFCFC", 5 | chartArea: { width: "85%", height: "80%" }, 6 | hAxis: { format: "MMM d" } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /lib/tasks/trailmix.rake: -------------------------------------------------------------------------------- 1 | namespace :trailmix do 2 | desc "Delivers prompt emails for the current hour" 3 | task schedule_all_prompts: :environment do 4 | PromptTask.new(User.promptable.pluck(:id), PromptWorker).run 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /app/views/entries/_date.html.erb: -------------------------------------------------------------------------------- 1 |

    2 | <% if today %> 3 | Today 4 | <% else %> 5 | <%= l(date, format: :day_of_week) %> 6 | <% end %> 7 |

    8 |

    9 | <%= l(date, format: :month_day_year) %> 10 |

    11 | -------------------------------------------------------------------------------- /app/views/layouts/_default_content.html.erb: -------------------------------------------------------------------------------- 1 |
    2 |
    3 | <%= yield :header %> 4 | <%= render "flashes" -%> 5 | <%= yield %> 6 |
    7 |
    8 | -------------------------------------------------------------------------------- /app/views/subscriptions/_sign_up_link_javascript.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :javascript do %> 2 | <%= javascript_tag do %> 3 | $(".sign-up-link").on("click", function(e) { 4 | $("#email").focus(); 5 | }); 6 | <% end %> 7 | <% end %> 8 | -------------------------------------------------------------------------------- /config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: async 3 | 4 | test: 5 | adapter: test 6 | 7 | production: 8 | adapter: redis 9 | url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> 10 | channel_prefix: trailmix_production 11 | -------------------------------------------------------------------------------- /config/i18n-tasks.yml: -------------------------------------------------------------------------------- 1 | search: 2 | paths: 3 | - "app/controllers" 4 | - "app/helpers" 5 | - "app/presenters" 6 | - "app/views" 7 | 8 | ignore_unused: 9 | - activerecord.* 10 | - date.* 11 | - simple_form.* 12 | - time.* 13 | -------------------------------------------------------------------------------- /config/secrets.yml: -------------------------------------------------------------------------------- 1 | default: &default 2 | secret_key_base: <%= ENV["SECRET_KEY_BASE"] %> 3 | 4 | development: 5 | <<: *default 6 | 7 | test: 8 | <<: *default 9 | 10 | staging: 11 | <<: *default 12 | 13 | production: 14 | <<: *default 15 | -------------------------------------------------------------------------------- /app/workers/prompt_worker.rb: -------------------------------------------------------------------------------- 1 | class PromptWorker 2 | include Sidekiq::Worker 3 | 4 | def perform(user_id) 5 | user = User.find(user_id) 6 | entry = user.prompt_entry 7 | 8 | PromptMailer.prompt(user, entry).deliver_now 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /config/initializers/yaml.rb: -------------------------------------------------------------------------------- 1 | # Patch to address incompatability between Ruby 3.1 and Psych 4.x 2 | # https://stackoverflow.com/a/71192990 3 | 4 | module YAML 5 | class << self 6 | alias_method :load, :unsafe_load if YAML.respond_to? :unsafe_load 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /db/migrate/20141010201317_remove_stripe_id_from_user.rb: -------------------------------------------------------------------------------- 1 | class RemoveStripeIdFromUser < ActiveRecord::Migration 2 | def up 3 | remove_column :users, :stripe_id 4 | end 5 | 6 | def down 7 | add_column :users, :stripe_id, :string 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/views/devise/mailer/confirmation_instructions.html.erb: -------------------------------------------------------------------------------- 1 |

    Welcome <%= @email %>!

    2 | 3 |

    You can confirm your account email through the link below:

    4 | 5 |

    <%= link_to 'Confirm my account', confirmation_url(@resource, confirmation_token: @token) %>

    6 | -------------------------------------------------------------------------------- /app/controllers/exports_controller.rb: -------------------------------------------------------------------------------- 1 | class ExportsController < ApplicationController 2 | before_action :authenticate_user! 3 | 4 | def new 5 | export = Export.new(current_user) 6 | 7 | send_data export.to_json, filename: export.filename 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/views/searches/_entry.html.erb: -------------------------------------------------------------------------------- 1 |

    2 | <%= l(entry.date, format: :month_day_year) %> 3 | 4 | <%= l(entry.date, format: :day_of_week) %> 5 | 6 |

    7 | 8 | <%= highlight(simple_format(entry.body), @search.term) %> 9 | 10 |
    11 | -------------------------------------------------------------------------------- /app/models/prompt_task.rb: -------------------------------------------------------------------------------- 1 | class PromptTask 2 | def initialize(user_ids, worker) 3 | @user_ids = user_ids 4 | @worker = worker 5 | end 6 | 7 | def run 8 | @user_ids.each do |user_id| 9 | @worker.perform_async(user_id) 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/support/carrierwave.rb: -------------------------------------------------------------------------------- 1 | CarrierWave.configure do |config| 2 | config.storage = :file 3 | config.enable_processing = false 4 | end 5 | 6 | RSpec.configure do |config| 7 | config.after(:all) do 8 | FileUtils.rm_r(Dir.glob("public/uploads")) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /config/database.yml: -------------------------------------------------------------------------------- 1 | development: &default 2 | adapter: postgresql 3 | database: trailmix_development 4 | encoding: utf8 5 | host: localhost 6 | min_messages: warning 7 | pool: 2 8 | timeout: 5000 9 | 10 | test: 11 | <<: *default 12 | database: trailmix_test 13 | -------------------------------------------------------------------------------- /config/initializers/stripe.rb: -------------------------------------------------------------------------------- 1 | Rails.configuration.stripe = { 2 | publishable_key: ENV.fetch("STRIPE_PUBLISHABLE_KEY"), 3 | secret_key: ENV.fetch("STRIPE_SECRET_KEY"), 4 | plan_name: ENV.fetch("STRIPE_PLAN_NAME") 5 | } 6 | 7 | Stripe.api_key = Rails.configuration.stripe[:secret_key] 8 | -------------------------------------------------------------------------------- /db/migrate/20140922202440_create_entries.rb: -------------------------------------------------------------------------------- 1 | class CreateEntries < ActiveRecord::Migration 2 | def change 3 | create_table :entries do |t| 4 | t.integer :user_id, null: false 5 | t.text :body, null: false 6 | 7 | t.timestamps 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /config/initializers/application_controller_renderer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # ActiveSupport::Reloader.to_prepare do 4 | # ApplicationController.renderer.defaults.merge!( 5 | # http_host: 'example.org', 6 | # https: false 7 | # ) 8 | # end 9 | -------------------------------------------------------------------------------- /db/migrate/20141004062429_add_index_and_not_null_to_user_reply_token.rb: -------------------------------------------------------------------------------- 1 | class AddIndexAndNotNullToUserReplyToken < ActiveRecord::Migration 2 | def change 3 | change_column :users, :reply_token, :string, null: false 4 | 5 | add_index :users, :reply_token, unique: true 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20141004203140_add_not_null_to_entry_date.rb: -------------------------------------------------------------------------------- 1 | class AddNotNullToEntryDate < ActiveRecord::Migration 2 | def up 3 | change_column :entries, :date, :date, null: false 4 | end 5 | 6 | def down 7 | change_column :entries, :date, :date, null: true 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/models/search.rb: -------------------------------------------------------------------------------- 1 | class Search 2 | include ActiveModel::Model 3 | 4 | attr_accessor :term, :user 5 | 6 | def entries 7 | if term.blank? 8 | [] 9 | else 10 | user.entries.by_date.where("LOWER(body) LIKE ?", "%#{term.downcase}%") 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/views/application/_flashes.html.erb: -------------------------------------------------------------------------------- 1 | <% if flash.any? %> 2 |
    3 | <% flash.each do |key, value| -%> 4 |
    5 | <%= value %> 6 |
    7 | <% end -%> 8 |
    9 | <% end %> 10 | -------------------------------------------------------------------------------- /config/initializers/cookies_serializer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Specify a serializer for the signed and encrypted cookie jars. 4 | # Valid options are :json, :marshal, and :hybrid. 5 | Rails.application.config.action_dispatch.cookies_serializer = :json 6 | -------------------------------------------------------------------------------- /db/migrate/20140927193748_remove_default_ohlife_export.rb: -------------------------------------------------------------------------------- 1 | class RemoveDefaultOhlifeExport < ActiveRecord::Migration 2 | def up 3 | change_column_default :imports, :ohlife_export, nil 4 | end 5 | 6 | def down 7 | change_column_default :imports, :ohlife_export, "" 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/models/prompt_entry.rb: -------------------------------------------------------------------------------- 1 | class PromptEntry 2 | def initialize(entries) 3 | @entries = entries 4 | end 5 | 6 | def self.best(entries) 7 | new(entries).best 8 | end 9 | 10 | def best 11 | entries.random 12 | end 13 | 14 | private 15 | 16 | attr_reader :entries 17 | end 18 | -------------------------------------------------------------------------------- /app/views/layouts/_apple_touch_icons.html.erb: -------------------------------------------------------------------------------- 1 | <% [57, 72, 114, 144].each do |size| %> 2 | <%= favicon_link_tag "apple-touch-icon-#{size}.png", 3 | rel: "apple-touch-icon", 4 | sizes: "#{size}x#{size}", 5 | type: "image/png" %> 6 | <% end %> 7 | -------------------------------------------------------------------------------- /db/migrate/20140925173215_create_imports.rb: -------------------------------------------------------------------------------- 1 | class CreateImports < ActiveRecord::Migration 2 | def change 3 | create_table :imports do |t| 4 | t.integer :user_id, null: false 5 | t.attachment :raw_file, null: false 6 | 7 | t.timestamps null: false 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/models/export.rb: -------------------------------------------------------------------------------- 1 | class Export 2 | def initialize(user) 3 | @user = user 4 | end 5 | 6 | def filename 7 | "trailmix-#{Date.current}.json" 8 | end 9 | 10 | def to_json 11 | user.entries.to_json(only: [:body, :date]) 12 | end 13 | 14 | private 15 | 16 | attr_reader :user 17 | end 18 | -------------------------------------------------------------------------------- /app/views/landing/_header.html.erb: -------------------------------------------------------------------------------- 1 |
    2 |

    Trailmix

    3 |

    A private place to write.

    4 |

    5 | <%= link_to "Get started", 6 | "#sign-up-form", 7 | class: "btn btn-default btn-lg sign-up-link" %> 8 |

    9 |
    10 | -------------------------------------------------------------------------------- /app/workers/ohlife_import_worker.rb: -------------------------------------------------------------------------------- 1 | class OhlifeImportWorker 2 | include Sidekiq::Worker 3 | sidekiq_options retry: false 4 | 5 | def perform(user_id, import_id) 6 | user = User.find(user_id) 7 | import = Import.find(import_id) 8 | 9 | OhlifeImporter.new(user, import).run 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20140928015646_add_time_zone_to_user.rb: -------------------------------------------------------------------------------- 1 | class AddTimeZoneToUser < ActiveRecord::Migration 2 | def change 3 | add_column :users, 4 | :time_zone, 5 | :string, 6 | null: false, 7 | default: "Central Time (US & Canada)" 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/controllers/exports_controller_spec.rb: -------------------------------------------------------------------------------- 1 | describe ExportsController do 2 | describe "#new" do 3 | context "when signed out" do 4 | it "redirects to sign in" do 5 | get :new 6 | 7 | expect(response).to redirect_to(new_user_session_path) 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require File.expand_path('../config/application', __FILE__) 5 | 6 | Rails.application.load_tasks 7 | task(:default).clear 8 | task default: [:spec] 9 | -------------------------------------------------------------------------------- /app/views/application/_javascript.html.erb: -------------------------------------------------------------------------------- 1 | <%= javascript_include_tag :application %> 2 | 3 | <%= yield :javascript %> 4 | 5 | <%= render "analytics" %> 6 | 7 | <% if Rails.env.test? %> 8 | <%= javascript_tag do %> 9 | $.fx.off = true; 10 | $.ajaxSetup({ async: false }); 11 | <% end %> 12 | <% end %> 13 | -------------------------------------------------------------------------------- /spec/controllers/searches_controller_spec.rb: -------------------------------------------------------------------------------- 1 | describe SearchesController do 2 | describe "#show" do 3 | context "when signed out" do 4 | it "redirects to sign in" do 5 | get :show 6 | 7 | expect(response).to redirect_to(new_user_session_path) 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/controllers/settings_controller_spec.rb: -------------------------------------------------------------------------------- 1 | describe SettingsController do 2 | describe "#edit" do 3 | context "when signed out" do 4 | it "redirects to sign in" do 5 | get :edit 6 | 7 | expect(response).to redirect_to(new_user_session_path) 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20140928181424_add_prompt_delivery_hour_to_users.rb: -------------------------------------------------------------------------------- 1 | class AddPromptDeliveryHourToUsers < ActiveRecord::Migration 2 | def change 3 | add_column :users, 4 | :prompt_delivery_hour, 5 | :integer, 6 | default: 2, # 2AM UTC 7 | null: false 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /config/smtp.rb: -------------------------------------------------------------------------------- 1 | SMTP_SETTINGS = { 2 | address: ENV.fetch("SMTP_ADDRESS"), # example: "smtp.sendgrid.net" 3 | authentication: :plain, 4 | domain: ENV.fetch("SMTP_DOMAIN"), # example: "this-app.com" 5 | enable_starttls_auto: true, 6 | password: ENV.fetch("SENDGRID_API_KEY"), 7 | port: "587", 8 | user_name: "apikey" 9 | } 10 | -------------------------------------------------------------------------------- /app/mailers/welcome_mailer.rb: -------------------------------------------------------------------------------- 1 | class WelcomeMailer < ActionMailer::Base 2 | START_OF_MESSAGE = "Welcome to Trailmix, a private place to write." 3 | 4 | def welcome(user) 5 | mail( 6 | from: "Trailmix <#{user.reply_email}>", 7 | to: user.email, 8 | subject: "Write your first entry" 9 | ) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/views/devise/mailer/unlock_instructions.html.erb: -------------------------------------------------------------------------------- 1 |

    Hello <%= @resource.email %>!

    2 | 3 |

    Your account has been locked due to an excessive number of unsuccessful sign in attempts.

    4 | 5 |

    Click the link below to unlock your account:

    6 | 7 |

    <%= link_to 'Unlock my account', unlock_url(@resource, unlock_token: @token) %>

    8 | -------------------------------------------------------------------------------- /db/migrate/20141011191307_create_cancellation.rb: -------------------------------------------------------------------------------- 1 | class CreateCancellation < ActiveRecord::Migration 2 | def change 3 | create_table :cancellations do |t| 4 | t.string :email, null: false 5 | t.string :stripe_customer_id, null: false 6 | t.text :reason 7 | 8 | t.timestamps null: false 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20140926024949_remove_paperclip_from_import.rb: -------------------------------------------------------------------------------- 1 | class RemovePaperclipFromImport < ActiveRecord::Migration 2 | def change 3 | remove_column :imports, :raw_file_file_name 4 | remove_column :imports, :raw_file_content_type 5 | remove_column :imports, :raw_file_file_size 6 | remove_column :imports, :raw_file_updated_at 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /app/views/layouts/skinny.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :content do %> 2 |
    3 |
    4 | <%= yield :header %> 5 | <%= render "flashes" -%> 6 | <%= yield %> 7 |
    8 |
    9 | <% end %> 10 | 11 | <%= render template: "layouts/application" %> 12 | -------------------------------------------------------------------------------- /config/environments/staging.rb: -------------------------------------------------------------------------------- 1 | require_relative "production" 2 | 3 | Mail.register_interceptor( 4 | RecipientInterceptor.new(ENV.fetch("EMAIL_RECIPIENTS")) 5 | ) 6 | 7 | Rails.application.configure do 8 | config.middleware.delete(Rack::SslEnforcer) 9 | 10 | config.action_mailer.default_url_options = { host: 'trailmix-staging.herokuapp.com' } 11 | end 12 | -------------------------------------------------------------------------------- /lib/tasks/development_seeds.rake: -------------------------------------------------------------------------------- 1 | if Rails.env.development? 2 | require "factory_bot" 3 | 4 | namespace :dev do 5 | desc "Seed data for development environment" 6 | task prime: "db:setup" do 7 | include FactoryBot::Syntax::Methods 8 | 9 | # create(:user, email: "user@example.com", password: "password") 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /db/seeds.rb: -------------------------------------------------------------------------------- 1 | # This file should contain all the record creation needed to seed the database with its default values. 2 | # The data can then be loaded with the rake db:seed (or created alongside the db with db:setup). 3 | # 4 | # Examples: 5 | # 6 | # cities = City.create([{ name: 'Chicago' }, { name: 'Copenhagen' }]) 7 | # Mayor.create(name: 'Emanuel', city: cities.first) 8 | -------------------------------------------------------------------------------- /app/models/subscription.rb: -------------------------------------------------------------------------------- 1 | class Subscription < ApplicationRecord 2 | belongs_to :user 3 | before_destroy :delete_stripe_subscription 4 | 5 | def stripe_customer 6 | @stripe_customer ||= Stripe::Customer.retrieve(stripe_customer_id) 7 | end 8 | 9 | private 10 | 11 | def delete_stripe_subscription 12 | stripe_customer.subscriptions.map(&:delete) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/features/user_signs_out_spec.rb: -------------------------------------------------------------------------------- 1 | feature "User signs out" do 2 | scenario "and we miss them" do 3 | user = create(:user) 4 | visit new_user_session_path 5 | fill_in "user_email", with: user.email 6 | fill_in "user_password", with: user.password 7 | click_button "Sign in" 8 | click_button "Sign out" 9 | 10 | expect(current_path).to eq root_path 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/models/prompt_entry_spec.rb: -------------------------------------------------------------------------------- 1 | describe PromptEntry do 2 | describe "#best" do 3 | it "returns a random entry" do 4 | user = create(:user) 5 | random_entry = double(:entry) 6 | allow(user.entries).to(receive(:random).and_return(random_entry)) 7 | 8 | best = PromptEntry.new(user.entries).best 9 | 10 | expect(best).to eq(random_entry) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | # Prevent CSRF attacks by raising an exception. 3 | # For APIs, you may want to use :null_session instead. 4 | protect_from_forgery with: :exception 5 | 6 | layout :layout_by_resource 7 | 8 | protected 9 | 10 | def layout_by_resource 11 | devise_controller? ? "skinny" : "application" 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /db/migrate/20140924180559_add_not_null_to_user_timestamps.rb: -------------------------------------------------------------------------------- 1 | class AddNotNullToUserTimestamps < ActiveRecord::Migration 2 | def up 3 | change_column :users, :created_at, :datetime, null: false 4 | change_column :users, :updated_at, :datetime, null: false 5 | end 6 | 7 | def down 8 | change_column :users, :created_at, :datetime, null: true 9 | change_column :users, :updated_at, :datetime, null: true 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/transitions/fix_future_dated_entries_transition.rb: -------------------------------------------------------------------------------- 1 | class FixFutureDatedEntriesTransition 2 | def self.perform 3 | new.perform 4 | end 5 | 6 | def perform 7 | entries_with_dates_in_the_future.each do |entry| 8 | entry.update_attribute(:date, entry.date - 1.year) 9 | end 10 | end 11 | 12 | private 13 | 14 | def entries_with_dates_in_the_future 15 | Entry.where("date > ?", Date.today) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /config/initializers/griddler.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../app/models/email_processor" 2 | require_relative "../../app/mailers/prompt_mailer" 3 | require_relative "../../app/mailers/welcome_mailer" 4 | 5 | Griddler.configure do |config| 6 | config.processor_class = EmailProcessor 7 | config.reply_delimiter = [ 8 | PromptMailer::PROMPT_TEXT, 9 | WelcomeMailer::START_OF_MESSAGE 10 | ] 11 | config.email_service = :sendgrid 12 | end 13 | -------------------------------------------------------------------------------- /lib/templates/erb/scaffold/_form.html.erb: -------------------------------------------------------------------------------- 1 | <%%= simple_form_for(@<%= singular_table_name %>) do |f| %> 2 | <%%= f.error_notification %> 3 | 4 |
    5 | <%- attributes.each do |attribute| -%> 6 | <%%= f.<%= attribute.reference? ? :association : :input %> :<%= attribute.name %> %> 7 | <%- end -%> 8 |
    9 | 10 |
    11 | <%%= f.button :submit %> 12 |
    13 | <%% end %> 14 | -------------------------------------------------------------------------------- /app/assets/stylesheets/icons.scss: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Glyphicons Halflings'; 3 | src: asset-url('glyphicons-halflings-regular.eot'); 4 | src: asset-url('glyphicons-halflings-regular.eot?#iefix') format('embedded-opentype'), asset-url('glyphicons-halflings-regular.woff') format('woff'), asset-url('glyphicons-halflings-regular.ttf') format('truetype'), asset-url('glyphicons-halflings-regular.svg#glyphicons_halflingsregular') format('svg'); 5 | } 6 | -------------------------------------------------------------------------------- /app/controllers/searches_controller.rb: -------------------------------------------------------------------------------- 1 | class SearchesController < ApplicationController 2 | before_action :authenticate_user! 3 | 4 | def show 5 | @search = Search.new(search_params) 6 | end 7 | 8 | private 9 | 10 | def search_params 11 | permitted_params.fetch(:search, {}).permit(:term).merge(user: current_user) 12 | end 13 | 14 | def permitted_params 15 | params.permit(:commit, :utf8, search: :term) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /db/migrate/20140924180920_add_not_null_to_entry_timestamps.rb: -------------------------------------------------------------------------------- 1 | class AddNotNullToEntryTimestamps < ActiveRecord::Migration 2 | def up 3 | change_column :entries, :created_at, :datetime, null: false 4 | change_column :entries, :updated_at, :datetime, null: false 5 | end 6 | 7 | def down 8 | change_column :entries, :created_at, :datetime, null: true 9 | change_column :entries, :updated_at, :datetime, null: true 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20141010200536_create_subscription.rb: -------------------------------------------------------------------------------- 1 | class CreateSubscription < ActiveRecord::Migration 2 | def change 3 | create_table :subscriptions do |t| 4 | t.integer :user_id, null: false 5 | t.string :stripe_customer_id, null: false 6 | 7 | t.timestamps null: false 8 | end 9 | 10 | add_index :subscriptions, :user_id, unique: true 11 | add_index :subscriptions, :stripe_customer_id, unique: true 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/views/entries/_entry.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :header do %> 2 | <%= render partial: "date", 3 | object: entry.date, 4 | locals: { today: entry.for_today? } 5 | %> 6 | <% end %> 7 | 8 |

    9 | <%= link_to "Edit this entry", edit_entry_path(entry) %> 10 |

    11 | 12 | <%= paginate @entries %> 13 | 14 | <%= simple_format(entry.body) %> 15 | 16 | <% if entry.photo.url %> 17 | <%= image_tag entry.photo.url, width: 400 %> 18 | <% end %> 19 | -------------------------------------------------------------------------------- /app/views/devise/mailer/reset_password_instructions.html.erb: -------------------------------------------------------------------------------- 1 |

    Hello <%= @resource.email %>!

    2 | 3 |

    Someone has requested a link to change your password. You can do this through the link below.

    4 | 5 |

    <%= link_to 'Change my password', edit_password_url(@resource, reset_password_token: @token) %>

    6 | 7 |

    If you didn't request this, please ignore this email.

    8 |

    Your password won't change until you access the link above and create a new one.

    9 | -------------------------------------------------------------------------------- /app/models/reply_token.rb: -------------------------------------------------------------------------------- 1 | class ReplyToken 2 | def initialize(email) 3 | @email = email 4 | end 5 | 6 | def self.generate(email) 7 | new(email).generate 8 | end 9 | 10 | def generate 11 | "#{username}.#{random_suffix}".downcase 12 | end 13 | 14 | private 15 | 16 | attr_reader :email 17 | 18 | def random_suffix 19 | SecureRandom.urlsafe_base64(8) 20 | end 21 | 22 | def username 23 | email.split("@").first 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/mailers/welcome_mailer_spec.rb: -------------------------------------------------------------------------------- 1 | describe WelcomeMailer do 2 | describe ".welcome" do 3 | it "welcomes the user" do 4 | user = create(:user) 5 | 6 | mail = WelcomeMailer.welcome(user) 7 | 8 | expect(mail.body.encoded).to include("Welcome to Trailmix") 9 | expect(mail.subject).to include("Write your first entry") 10 | expect(mail.from).to eq([user.reply_email]) 11 | expect(mail.to).to eq([user.email]) 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/models/entry.rb: -------------------------------------------------------------------------------- 1 | class Entry < ApplicationRecord 2 | belongs_to :import, optional: true 3 | belongs_to :user 4 | 5 | mount_uploader :photo, PhotoUploader 6 | 7 | def self.by_date 8 | order("date DESC") 9 | end 10 | 11 | def self.newest 12 | by_date.first 13 | end 14 | 15 | def self.random 16 | order(Arel.sql("RANDOM()")).first 17 | end 18 | 19 | def for_today? 20 | date == Time.zone.now.in_time_zone(user.time_zone).to_date 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /app/controllers/admin_dashboard_controller.rb: -------------------------------------------------------------------------------- 1 | class AdminDashboardController < ApplicationController 2 | before_action :authenticate_user!, :restrict_to_admins 3 | 4 | def show 5 | @dashboard = AdminDashboard.new 6 | end 7 | 8 | private 9 | 10 | def restrict_to_admins 11 | unless admin_emails.include?(current_user.email) 12 | redirect_to entries_path 13 | end 14 | end 15 | 16 | def admin_emails 17 | ENV.fetch("ADMIN_EMAILS").split(",") 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /app/views/welcome_mailer/welcome.html.erb: -------------------------------------------------------------------------------- 1 |

    <%= WelcomeMailer::START_OF_MESSAGE %>

    2 | 3 |

    To create your first entry, simply reply to this email. That's it.

    4 | 5 |

    If you like, you can even attach a photo!

    6 | 7 |

    We'll email you once a day to remind you to keep up the habit. We'll include a previous entry each time (they're super fun to read).

    8 | 9 |

    Don't worry if you need to skip a day. You can just ignore or delete our email and nothing bad will happen.

    10 | -------------------------------------------------------------------------------- /spec/support/sidekiq.rb: -------------------------------------------------------------------------------- 1 | require 'sidekiq/testing' 2 | 3 | RSpec.configure do |config| 4 | config.before(:each) do |example| 5 | Sidekiq::Worker.clear_all 6 | 7 | if example.metadata[:sidekiq] == :fake 8 | Sidekiq::Testing.fake! 9 | elsif example.metadata[:sidekiq] == :inline 10 | Sidekiq::Testing.inline! 11 | elsif example.metadata[:type] == :acceptance 12 | Sidekiq::Testing.inline! 13 | else 14 | Sidekiq::Testing.fake! 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/controllers/settings_controller.rb: -------------------------------------------------------------------------------- 1 | class SettingsController < ApplicationController 2 | layout "skinny" 3 | 4 | before_action :authenticate_user! 5 | 6 | def edit 7 | end 8 | 9 | def update 10 | current_user.update!(user_params) 11 | 12 | flash[:notice] = "Your settings have been saved." 13 | 14 | redirect_to entries_path 15 | end 16 | 17 | private 18 | 19 | def user_params 20 | params.require(:user).permit(:time_zone, :prompt_delivery_hour) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/views/searches/_entry.html.erb_spec.rb: -------------------------------------------------------------------------------- 1 | describe "searches/_entry" do 2 | it "highlights the searched-for term" do 3 | user = build_stubbed(:user) 4 | allow(view).to receive(:current_user).and_return(user) 5 | entry = build_stubbed(:entry, body: "I want to get a cat") 6 | search = double("search", term: "cat") 7 | assign(:search, search) 8 | 9 | render "searches/entry", entry: entry 10 | 11 | expect(rendered).to have_css("mark", text: search.term) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/controllers/credit_cards_controller.rb: -------------------------------------------------------------------------------- 1 | class CreditCardsController < ApplicationController 2 | before_action :authenticate_user! 3 | 4 | layout "skinny" 5 | 6 | def edit 7 | end 8 | 9 | def update 10 | stripe_customer = 11 | Stripe::Customer.retrieve(current_user.stripe_customer_id) 12 | stripe_customer.card = params[:stripeToken] 13 | stripe_customer.save 14 | 15 | flash.notice = "Credit card updated successfully" 16 | redirect_to action: :edit 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/models/prompt_task_spec.rb: -------------------------------------------------------------------------------- 1 | describe PromptTask do 2 | describe "#run" do 3 | it "enqueues a job for each user" do 4 | user = double("user", id: 1) 5 | other_user = double("other_user", id: 2) 6 | worker = double("worker", perform_async: nil) 7 | 8 | PromptTask.new([1, 2], worker).run 9 | 10 | expect(worker).to have_received(:perform_async).with(user.id) 11 | expect(worker).to have_received(:perform_async).with(other_user.id) 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/support/database_cleaner.rb: -------------------------------------------------------------------------------- 1 | RSpec.configure do |config| 2 | config.before(:suite) do 3 | DatabaseCleaner.clean_with(:deletion) 4 | end 5 | 6 | config.before(:each) do 7 | DatabaseCleaner.strategy = :transaction 8 | end 9 | 10 | config.before(:each, :js => true) do 11 | DatabaseCleaner.strategy = :deletion 12 | end 13 | 14 | config.before(:each) do 15 | DatabaseCleaner.start 16 | end 17 | 18 | config.after(:each) do 19 | DatabaseCleaner.clean 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /app/views/entries/edit.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :header do %> 2 | <%= render partial: "date", 3 | object: @entry.date, 4 | locals: { today: @entry.for_today? } 5 | %> 6 | <% end %> 7 | 8 |

    9 | <%= form_for @entry do |f| %> 10 |

    11 | <%= f.text_area :body, 12 | class: "form-control input-lg", 13 | autofocus: true 14 | %> 15 |
    16 | 17 | <%= f.submit "Save Entry", class: "btn btn-default btn-lg" %> 18 | <% end %> 19 |

    20 | -------------------------------------------------------------------------------- /config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file. 4 | # Use this to limit dissemination of sensitive information. 5 | # See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. 6 | Rails.application.config.filter_parameters += [ 7 | :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc 8 | ] 9 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | date: 3 | formats: 4 | default: 5 | "%m/%d/%Y" 6 | day_of_week: 7 | "%A" 8 | prompt_subject_line: 9 | "%A, %b %-d" # Thursday, Sept 17 10 | month_day_year: 11 | "%B %-d, %Y" 12 | month_day: 13 | "%b %-d" 14 | with_weekday: 15 | "%a %m/%d/%y" 16 | 17 | time: 18 | formats: 19 | default: 20 | "%a, %b %-d, %Y at %r" 21 | date: 22 | "%b %-d, %Y" 23 | short: 24 | "%B %d" 25 | -------------------------------------------------------------------------------- /spec/features/user_exports_entries_spec.rb: -------------------------------------------------------------------------------- 1 | feature "User exports their entries" do 2 | scenario "from the FAQ" do 3 | user = create(:user) 4 | entries = [ 5 | create(:entry, user: user, body: "first entry"), 6 | create(:entry, user: user, body: "second entry"), 7 | create(:entry, user: user, body: "third entry") 8 | ] 9 | 10 | login_as(user) 11 | visit entries_path 12 | click_link "FAQ" 13 | click_link "export" 14 | 15 | expect(page.body).to include(*entries.map(&:body)) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/uploaders/photo_uploader.rb: -------------------------------------------------------------------------------- 1 | class PhotoUploader < CarrierWave::Uploader::Base 2 | include CarrierWave::MiniMagick 3 | 4 | process :auto_orient 5 | process resize_to_fit: [800, 800], quality: 80 6 | 7 | def extension_white_list 8 | %w(jpg jpeg gif png) 9 | end 10 | 11 | def filename 12 | "#{hash}.jpg" 13 | end 14 | 15 | def fog_public 16 | true 17 | end 18 | 19 | private 20 | 21 | def hash 22 | Digest::SHA1.hexdigest file_contents 23 | end 24 | 25 | def file_contents 26 | read 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /bin/spring: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | if !defined?(Spring) && [nil, "development", "test"].include?(ENV["RAILS_ENV"]) 3 | gem "bundler" 4 | require "bundler" 5 | 6 | # Load Spring without loading other gems in the Gemfile, for speed. 7 | Bundler.locked_gems&.specs&.find { |spec| spec.name == "spring" }&.tap do |spring| 8 | Gem.use_paths Gem.dir, Bundler.bundle_path.to_s, *Gem.path 9 | gem "spring", spring.version 10 | require "spring/binstub" 11 | rescue Gem::LoadError 12 | # Ignore when Spring is not installed. 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/views/devise/unlocks/new.html.erb: -------------------------------------------------------------------------------- 1 |

    Resend unlock instructions

    2 | 3 | <%= simple_form_for(resource, as: resource_name, url: unlock_path(resource_name), html: { method: :post }) do |f| %> 4 | <%= f.error_notification %> 5 | <%= f.full_error :unlock_token %> 6 | 7 |
    8 | <%= f.input :email, required: true, autofocus: true %> 9 |
    10 | 11 |
    12 | <%= f.button :submit, "Resend unlock instructions" %> 13 |
    14 | <% end %> 15 | 16 | <%= render "devise/shared/links" %> 17 | -------------------------------------------------------------------------------- /config/initializers/permissions_policy.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Define an application-wide HTTP permissions policy. For further 4 | # information see: https://developers.google.com/web/updates/2018/06/feature-policy 5 | 6 | # Rails.application.config.permissions_policy do |policy| 7 | # policy.camera :none 8 | # policy.gyroscope :none 9 | # policy.microphone :none 10 | # policy.usb :none 11 | # policy.fullscreen :self 12 | # policy.payment :self, "https://secure.example.com" 13 | # end 14 | -------------------------------------------------------------------------------- /config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Version of your assets, change this if you want to expire all your assets. 4 | Rails.application.config.assets.version = (ENV["ASSETS_VERSION"] || "1.0") 5 | 6 | # Add additional assets to the asset load path 7 | # Rails.application.config.assets.paths << Emoji.images_path 8 | 9 | # Precompile additional assets. 10 | # application.js, application.css, and all non-JS/CSS in app/assets folder are already added. 11 | Rails.application.config.assets.precompile = ["manifest.js"] 12 | -------------------------------------------------------------------------------- /config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # This file contains settings for ActionController::ParamsWrapper which 4 | # is enabled by default. 5 | 6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 7 | ActiveSupport.on_load(:action_controller) do 8 | wrap_parameters format: [:json] 9 | end 10 | 11 | # To enable root element in JSON for ActiveRecord objects. 12 | # ActiveSupport.on_load(:active_record) do 13 | # self.include_root_in_json = true 14 | # end 15 | -------------------------------------------------------------------------------- /spec/features/user_searches_entries_spec.rb: -------------------------------------------------------------------------------- 1 | feature "User searches entries" do 2 | scenario "and sees results" do 3 | user = create(:user) 4 | create(:entry, user: user, body: "I like small kittens") 5 | create(:entry, user: user, body: "I like large dogs") 6 | 7 | login_as(user) 8 | visit entries_path 9 | click_link "Search" 10 | fill_in :search_term, with: "kittens" 11 | click_button "Search" 12 | 13 | expect(page).to have_content("I like small kittens") 14 | expect(page).not_to have_content("I like large dogs") 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /.sample.env: -------------------------------------------------------------------------------- 1 | # http://ddollar.github.com/foreman/ 2 | ADMIN_EMAILS=ben@trailmix.life,chris@trailmix.life 3 | AWS_ACCESS_KEY_ID=abc123 4 | AWS_SECRET_ACCESS_KEY=def456 5 | DEVISE_SECRET_KEY=foo 6 | EMAIL_RECIPIENTS=trailmixtheapp@gmail.com 7 | RACK_ENV=development 8 | SECRET_KEY_BASE=abc123 9 | SECRET_KEY_BASE=development_secret 10 | SIDEKIQ_WEB_PASSWORD=password 11 | SIDEKIQ_WEB_USER=admin 12 | SKYLIGHT_APPLICATION=abc123 13 | SKYLIGHT_AUTHENTICATION=def456 14 | SMTP_DOMAIN=localhost 15 | STRIPE_PLAN_NAME=boulder 16 | STRIPE_PUBLISHABLE_KEY=pk_test_abc123 17 | STRIPE_SECRET_KEY=sk_test_abc123 18 | -------------------------------------------------------------------------------- /app/controllers/entries_controller.rb: -------------------------------------------------------------------------------- 1 | class EntriesController < ApplicationController 2 | before_action :authenticate_user! 3 | 4 | def index 5 | @entries = current_user.entries.by_date.page(params[:page]) 6 | end 7 | 8 | def edit 9 | @entry = entry 10 | end 11 | 12 | def update 13 | entry.update!(entry_params) 14 | 15 | redirect_to entries_path 16 | end 17 | 18 | private 19 | 20 | def entry 21 | current_user.entries.find_by!(id: params[:id]) 22 | end 23 | 24 | def entry_params 25 | params.require(:entry).permit(:body) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /app/views/devise/confirmations/new.html.erb: -------------------------------------------------------------------------------- 1 |

    Resend confirmation instructions

    2 | 3 | <%= simple_form_for(resource, as: resource_name, url: confirmation_path(resource_name), html: { method: :post }) do |f| %> 4 | <%= f.error_notification %> 5 | <%= f.full_error :confirmation_token %> 6 | 7 |
    8 | <%= f.input :email, required: true, autofocus: true %> 9 |
    10 | 11 |
    12 | <%= f.button :submit, "Resend confirmation instructions" %> 13 |
    14 | <% end %> 15 | 16 | <%= render "devise/shared/links" %> 17 | -------------------------------------------------------------------------------- /config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 4 | # Rails.backtrace_cleaner.add_silencer { |line| /my_noisy_library/.match?(line) } 5 | 6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code 7 | # by setting BACKTRACE=1 before calling your invocation, like "BACKTRACE=1 ./bin/rails runner 'MyClass.perform'". 8 | Rails.backtrace_cleaner.remove_silencers! if ENV["BACKTRACE"] 9 | -------------------------------------------------------------------------------- /bin/yarn: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_ROOT = File.expand_path('..', __dir__) 3 | Dir.chdir(APP_ROOT) do 4 | yarn = ENV["PATH"].split(File::PATH_SEPARATOR). 5 | select { |dir| File.expand_path(dir) != __dir__ }. 6 | product(["yarn", "yarn.cmd", "yarn.ps1"]). 7 | map { |dir, file| File.expand_path(file, dir) }. 8 | find { |file| File.executable?(file) } 9 | 10 | if yarn 11 | exec yarn, *ARGV 12 | else 13 | $stderr.puts "Yarn executable was not detected in the system." 14 | $stderr.puts "Download Yarn at https://yarnpkg.com/en/docs/install" 15 | exit 1 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/features/server_sends_daily_email_prompt_spec.rb: -------------------------------------------------------------------------------- 1 | feature "The server executes the prompt task", sidekiq: :inline do 2 | scenario "and all users are emailed" do 3 | first_user = create(:user) 4 | second_user = create(:user) 5 | third_user = create(:user) 6 | users = [first_user, second_user, third_user] 7 | 8 | PromptTask.new(User.pluck(:id), PromptWorker).run 9 | 10 | users.each do |user| 11 | expect(emailed_addresses).to include(user.email) 12 | end 13 | end 14 | 15 | def emailed_addresses 16 | ActionMailer::Base.deliveries.map(&:to).flatten 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/controllers/landing_controller_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe LandingController do 4 | describe "#show" do 5 | context "when the user is signed out" do 6 | it "renders the landing page" do 7 | get :show 8 | 9 | expect(response).to render_template :show 10 | end 11 | end 12 | 13 | context "when the user is signed in" do 14 | it "redirects to the entries index" do 15 | user = create(:user) 16 | sign_in(user) 17 | 18 | get :show 19 | 20 | expect(response).to redirect_to entries_path 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/models/reply_token_spec.rb: -------------------------------------------------------------------------------- 1 | describe ReplyToken do 2 | describe "#generate" do 3 | it "generates a unique token" do 4 | reply_token = ReplyToken.new("username@example.com") 5 | 6 | first_token = reply_token.generate 7 | second_token = reply_token.generate 8 | 9 | expect(first_token).to_not eq second_token 10 | end 11 | 12 | it "includes the email username at the beginning of the token" do 13 | reply_token = ReplyToken.new("username@example.com") 14 | 15 | token = reply_token.generate 16 | 17 | expect(token).to start_with "username" 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /app/views/prompt_mailer/prompt.html.erb: -------------------------------------------------------------------------------- 1 |

    <%= PromptMailer::PROMPT_TEXT %>

    2 | 3 |

    Simply reply to this email with your entry.

    4 | 5 | <% if @announcement.present? %> 6 | <%= @announcement.html_safe %> 7 | <% end %> 8 | 9 | <% if @entry %> 10 |

    Remember this? On <%= I18n.l(@entry.date, format: :month_day_year) %> (<%= (distance_of_time_in_words(@date, @entry.date)) %> ago), you wrote the following:

    11 | 12 |

    ----------------------

    13 | 14 | <%= simple_format @entry.body %> 15 | 16 | <% if @entry.photo.url %> 17 | <%= image_tag @entry.photo.url, width: 400 %> 18 | <% end %> 19 | <% end %> 20 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | before_install: 2 | - "echo '--colour' > ~/.rspec" 3 | - "echo 'gem: --no-document' > ~/.gemrc" 4 | - "gem install bundler --version '1.17.3'" 5 | - export DISPLAY=:99.0 6 | before_script: 7 | - cp .sample.env .env 8 | - psql -c 'create database "trailmix_test";' -U postgres 9 | - bundle exec rake db:setup 10 | addons: 11 | postgresql: 9.6 12 | services: 13 | - xvfb 14 | - postgresql 15 | dist: trusty 16 | branches: 17 | only: 18 | - master 19 | cache: 20 | - bundler 21 | language: 22 | - ruby 23 | notifications: 24 | email: 25 | - false 26 | rvm: 27 | - 2.5.7 28 | addons: 29 | postgresql: "9.3" 30 | -------------------------------------------------------------------------------- /app/controllers/cancellations_controller.rb: -------------------------------------------------------------------------------- 1 | class CancellationsController < ApplicationController 2 | before_action :authenticate_user! 3 | 4 | def create 5 | create_cancellation 6 | current_user.destroy! 7 | flash[:notice] = "Your account has been removed and " + 8 | "your subscription has been canceled." 9 | 10 | redirect_to new_registration_path 11 | end 12 | 13 | private 14 | 15 | def create_cancellation 16 | Cancellation.create!(email: current_user.email, 17 | stripe_customer_id: current_user.stripe_customer_id, 18 | reason: params[:reason]) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /app/transitions/backfill_subscriptions_transition.rb: -------------------------------------------------------------------------------- 1 | class BackfillSubscriptionsTransition 2 | def self.perform 3 | new.perform 4 | end 5 | 6 | def perform 7 | stripe_customers.each do |stripe_customer| 8 | create_subscription_for(stripe_customer) 9 | end 10 | end 11 | 12 | private 13 | 14 | def create_subscription_for(stripe_customer) 15 | user = User.find_by(email: stripe_customer.email) 16 | 17 | if user && user.subscription.blank? 18 | user.create_subscription!(stripe_customer_id: stripe_customer.id) 19 | end 20 | end 21 | 22 | def stripe_customers 23 | Stripe::Customer.all(limit: 100) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /app/views/devise/passwords/edit.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :header do %> 2 |

    3 |

    Change your password

    4 |

    5 | <% end %> 6 | 7 | <%= simple_form_for(resource, 8 | as: resource_name, 9 | url: password_path(resource_name), 10 | html: { method: :put } 11 | ) do |f| %> 12 | 13 | <%= f.error_notification %> 14 | 15 | <%= f.input :reset_password_token, as: :hidden %> 16 | 17 |
    18 | <%= f.input :password, 19 | label: false, 20 | placeholder: "New password", 21 | required: true, 22 | autofocus: true %> 23 |
    24 | 25 | <%= f.button :submit, "Change password" %> 26 | <% end %> 27 | -------------------------------------------------------------------------------- /app/models/prompt_delivery_hour.rb: -------------------------------------------------------------------------------- 1 | class PromptDeliveryHour 2 | def initialize(hour, time_zone) 3 | @hour = hour.to_i 4 | @time_zone = time_zone 5 | end 6 | 7 | def in_time_zone 8 | in_24_hours(hour + time_zone_offset) 9 | end 10 | 11 | def in_utc 12 | in_24_hours(hour - time_zone_offset) 13 | end 14 | 15 | private 16 | 17 | attr_reader :hour, :time_zone 18 | 19 | def in_24_hours(hour) 20 | if hour < 0 21 | hour + 24 22 | elsif hour > 23 23 | hour - 24 24 | else 25 | hour 26 | end 27 | end 28 | 29 | def time_zone_offset 30 | Time.zone.now.in_time_zone(time_zone).utc_offset / 1.hour 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/controllers/imports_controller_spec.rb: -------------------------------------------------------------------------------- 1 | describe ImportsController do 2 | describe "#create" do 3 | context "when the import fails to save" do 4 | it "apologizes to the user" do 5 | stub_current_user 6 | import = double("import", save: false) 7 | allow(Import).to(receive(:new).and_return(import)) 8 | 9 | post :create, params: { import: { ohlife_export: double("export") } } 10 | 11 | expect(flash[:error]).to include "Sorry" 12 | end 13 | end 14 | 15 | end 16 | 17 | def stub_current_user 18 | allow(controller).to(receive(:authenticate_user!)) 19 | allow(controller).to(receive(:current_user)) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /app/views/devise/passwords/new.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :header do %> 2 |

    Forgot your password?

    3 |

    We'll email you reset instructions.

    4 | <% end %> 5 | 6 | <%= simple_form_for( 7 | resource, 8 | as: resource_name, 9 | url: password_path(resource_name), 10 | html: { method: :post } 11 | ) do |f| %> 12 | 13 | <%= f.error_notification %> 14 | 15 |
    16 | <%= f.input :email, 17 | label: false, 18 | required: true, 19 | autofocus: true, 20 | placeholder: "Email address" %> 21 |
    22 | 23 | <%= f.button :submit, "Email me" %> 24 | or <%= link_to "cancel", new_session_path(:user) %>. 25 | <% end %> 26 | -------------------------------------------------------------------------------- /app/assets/stylesheets/base/_header.scss: -------------------------------------------------------------------------------- 1 | .navbar { 2 | .navbar-toggle { 3 | color: $brown; 4 | 5 | &:hover { 6 | background: $lighter-brown; 7 | border-color: $lighter-brown; 8 | } 9 | 10 | &:focus { 11 | background: $light-brown; 12 | border-color: $light-brown; 13 | } 14 | } 15 | 16 | img { 17 | height: 60px; 18 | margin-top: -5px; 19 | } 20 | 21 | .navbar-right { 22 | text-align: center; 23 | 24 | li a, li button[type=submit] { 25 | background: none; 26 | border: none; 27 | color: $brown; 28 | line-height: 18px; 29 | padding: 10px 15px; 30 | 31 | &:hover { 32 | color: $brown; 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, "\\1en" 8 | # inflect.singular /^(ox)en/i, "\\1" 9 | # inflect.irregular "person", "people" 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym "RESTful" 16 | # end 17 | -------------------------------------------------------------------------------- /config/puma.rb: -------------------------------------------------------------------------------- 1 | # https://devcenter.heroku.com/articles/deploying-rails-applications-with-the-puma-web-server 2 | 3 | workers Integer(ENV['WEB_CONCURRENCY'] || 2) 4 | threads_count = Integer(ENV['RAILS_MAX_THREADS'] || 5) 5 | threads threads_count, threads_count 6 | 7 | preload_app! 8 | 9 | # Support IPv6 by binding to host `::` instead of `0.0.0.0` 10 | port(ENV['PORT'] || 3000, "::") 11 | 12 | # Turn off keepalive support for better long tails response time with Router 2.0 13 | # Remove this line when https://github.com/puma/puma/issues/3487 is closed, and the fix is released 14 | enable_keep_alives(false) if respond_to?(:enable_keep_alives) 15 | 16 | environment ENV['RACK_ENV'] || 'development' 17 | 18 | raise_exception_on_sigterm false 19 | -------------------------------------------------------------------------------- /app/views/pages/github.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :header do %> 2 |
    3 |

    Trailmix

    4 |

    A private place to write.

    5 |
    6 | <% end %> 7 | 8 |

    9 | Hey there person referred from GitHub! 10 |

    11 | 12 |

    13 | This is Trailmix, a service that helps you build a journaling habit that will 14 | actually stick. 15 |

    16 | 17 |

    18 | As a developer or designer, you might enjoy <%= link_to "4 Reasons to Keep a 19 | Development Journal", page_path("keeping-a-development-journal") %>. 20 |

    21 | 22 |

    23 | Or, if you prefer, you can simply 24 | <%= link_to "head to our standard landing page", new_registration_path %>. 25 |

    26 | -------------------------------------------------------------------------------- /config/initializers/errors.rb: -------------------------------------------------------------------------------- 1 | require "net/http" 2 | require "net/smtp" 3 | 4 | # Example: 5 | # begin 6 | # some http call 7 | # rescue *HTTP_ERRORS => error 8 | # notify_hoptoad error 9 | # end 10 | 11 | HTTP_ERRORS = [ 12 | EOFError, 13 | Errno::ECONNRESET, 14 | Errno::EINVAL, 15 | Net::HTTPBadResponse, 16 | Net::HTTPHeaderSyntaxError, 17 | Net::ProtocolError, 18 | Timeout::Error 19 | ] 20 | 21 | SMTP_SERVER_ERRORS = [ 22 | IOError, 23 | Net::SMTPAuthenticationError, 24 | Net::SMTPServerBusy, 25 | Net::SMTPUnknownError, 26 | Timeout::Error 27 | ] 28 | 29 | SMTP_CLIENT_ERRORS = [ 30 | Net::SMTPFatalError, 31 | Net::SMTPSyntaxError 32 | ] 33 | 34 | SMTP_ERRORS = SMTP_SERVER_ERRORS + SMTP_CLIENT_ERRORS 35 | -------------------------------------------------------------------------------- /spec/views/searches/show.html.erb_spec.rb: -------------------------------------------------------------------------------- 1 | describe "searches/show" do 2 | context "when no entries are found" do 3 | it "informs the user" do 4 | search = mock_model("Search", term: "cat", entries: []) 5 | assign(:search, search) 6 | 7 | render template: "searches/show" 8 | 9 | expect(rendered).to(include("Sorry, we couldn't find any entries")) 10 | end 11 | end 12 | 13 | context "when no search term is provided" do 14 | it "does not inform the user" do 15 | search = mock_model("Search", term: "", entries: []) 16 | assign(:search, search) 17 | 18 | render template: "searches/show" 19 | 20 | expect(rendered).to_not include("we couldn't find any entries") 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /app/assets/javascripts/application.js: -------------------------------------------------------------------------------- 1 | // This is a manifest file that'll be compiled into application.js, which will include all the files 2 | // listed below. 3 | // 4 | // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts, 5 | // or vendor/assets/javascripts of plugins, if any, can be referenced here using a relative path. 6 | // 7 | // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the 8 | // compiled file. 9 | // 10 | // Read Sprockets README (https://github.com/sstephenson/sprockets#sprockets-directives) for details 11 | // about supported directives. 12 | // 13 | //= require jquery 14 | //= require jquery_ujs 15 | //= require bootstrap.min 16 | //= require_tree . 17 | -------------------------------------------------------------------------------- /config/initializers/carrierwave.rb: -------------------------------------------------------------------------------- 1 | CarrierWave.configure do |config| 2 | config.storage = :fog 3 | 4 | config.fog_credentials = { 5 | provider: "AWS", 6 | aws_access_key_id: ENV.fetch("AWS_ACCESS_KEY_ID"), 7 | aws_secret_access_key: ENV.fetch("AWS_SECRET_ACCESS_KEY") 8 | } 9 | 10 | config.fog_directory = "trailmix" 11 | config.fog_public = false 12 | end 13 | 14 | module CarrierWave 15 | module MiniMagick 16 | def quality(percentage) 17 | manipulate! do |image| 18 | image.quality(percentage) 19 | image 20 | end 21 | end 22 | 23 | def auto_orient 24 | manipulate! do |image| 25 | image.auto_orient 26 | image 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /config/initializers/sidekiq.rb: -------------------------------------------------------------------------------- 1 | require "sidekiq" 2 | require "sidekiq/web" 3 | 4 | Sidekiq::Web.use(Rack::Auth::Basic) do |user, password| 5 | [user, password] == [ 6 | ENV.fetch("SIDEKIQ_WEB_USER"), 7 | ENV.fetch("SIDEKIQ_WEB_PASSWORD") 8 | ] 9 | end 10 | 11 | # https://devcenter.heroku.com/articles/connecting-heroku-redis#connecting-in-ruby 12 | Sidekiq.configure_server do |config| 13 | config.redis = { 14 | url: ENV["REDIS_URL"], 15 | ssl_params: { verify_mode: OpenSSL::SSL::VERIFY_NONE } 16 | } 17 | end 18 | 19 | Sidekiq.configure_client do |config| 20 | config.redis = { 21 | url: ENV["REDIS_URL"], 22 | ssl_params: { verify_mode: OpenSSL::SSL::VERIFY_NONE } 23 | } 24 | config.logger = Rails.logger if Rails.env.test? 25 | end 26 | -------------------------------------------------------------------------------- /app/mailers/prompt_mailer.rb: -------------------------------------------------------------------------------- 1 | class PromptMailer < ActionMailer::Base 2 | PROMPT_TEXT = "How was your day?" 3 | 4 | def prompt(user, entry, date = nil) 5 | @entry = entry 6 | @date = date || Time.current.in_time_zone(user.time_zone).to_date 7 | @announcement = ENV["ANNOUNCEMENT"] 8 | 9 | mail( 10 | from: "Trailmix <#{user.reply_email}>", 11 | to: user.email, 12 | subject: Subject.new(user, @date) 13 | ) 14 | end 15 | 16 | class Subject 17 | def initialize(user, date) 18 | @user = user 19 | @date = date 20 | end 21 | 22 | def to_s 23 | "It's #{date}. How was your day?" 24 | end 25 | 26 | private 27 | 28 | def date 29 | I18n.l(@date, format: :prompt_subject_line) 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/features/user_imports_ohlife_entries_spec.rb: -------------------------------------------------------------------------------- 1 | feature "User imports their OhLife entries" do 2 | scenario "immediately after creating their account", sidekiq: :inline do 3 | user = create(:user) 4 | 5 | login_as(user) 6 | visit entries_path 7 | click_link "import your OhLife entries" 8 | file_path = Rails.root + "spec/fixtures/files/ohlife_export.txt" 9 | attach_file('import_ohlife_export', file_path) 10 | click_button "Start Import" 11 | 12 | expect(page).to have_content("We're importing your entries") 13 | expect(Import.count).to eq(1) 14 | expect(user.entries.count).to eq(2) 15 | end 16 | 17 | scenario "when signed out" do 18 | visit new_import_path 19 | 20 | expect(current_path).to eq(new_user_session_path) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /app/views/devise/sessions/new.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :header do %> 2 |

    Sign in

    3 |

    4 | <%= link_to "Forget your password?", new_password_path(:user) %> 5 |

    6 | <% end %> 7 | 8 | <%= simple_form_for( 9 | resource, 10 | as: resource_name, 11 | url: session_path(resource_name) 12 | ) do |f| %> 13 | 14 |
    15 | <%= f.input :email, 16 | label: false, 17 | required: true, 18 | autofocus: true, 19 | placeholder: "Email address" %> 20 |
    21 | 22 |
    23 | <%= f.input :password, 24 | label: false, 25 | required: true, 26 | placeholder: "Password" %> 27 |
    28 | 29 | <%= f.button :submit, "Sign in" %> 30 | or <%= link_to "Sign up", new_registration_path %>. 31 | <% end %> 32 | -------------------------------------------------------------------------------- /app/views/pages/privacy.html.erb: -------------------------------------------------------------------------------- 1 |

    2 |

    Trailmix Privacy Policy

    3 |

    4 | 5 |

    Your entries are private.

    6 | 7 |

    8 | Your entries are not publicly-accessible. They can only be viewed by logging 9 | into your account or when sent to you via email. 10 |

    11 | 12 |

    13 | We will never view your entries unless absolutely necessary for technical 14 | reasons, such as fixing a bug (unlikely). 15 |

    16 | 17 |

    18 | The entries are not searchable by Google. 19 |

    20 | 21 |

    Your entries are deletable.

    22 | 23 |

    24 | If you no longer wish to use Trailmix, you can delete your account and all 25 | your entries with a click. 26 |

    27 | 28 |

    No third parties.

    29 | 30 |

    31 | We will never sell or share your information with third parties. 32 |

    33 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | devise_for :users 3 | 4 | mount_griddler 5 | 6 | require "sidekiq/web" 7 | mount Sidekiq::Web => "/jobs" 8 | 9 | get "/admin_dashboard", to: "admin_dashboard#show", as: :admin_dashboard 10 | get "/landing", to: "landing#show", as: :new_registration 11 | get "/search", to: "searches#show" 12 | 13 | get "/pages/ohlife_refugees", to: redirect("/pages/ohlife-alternative") 14 | 15 | resources :cancellations, only: [:create] 16 | resource :credit_card, only: [:edit, :update] 17 | resources :entries, only: [:index, :edit, :update] 18 | resource :export, only: [:new] 19 | resources :imports, only: [:new, :create] 20 | resource :settings, only: [:edit, :update] 21 | resources :subscriptions, only: [:create] 22 | 23 | root to: "landing#show" 24 | end 25 | -------------------------------------------------------------------------------- /spec/features/user_cancels_account_spec.rb: -------------------------------------------------------------------------------- 1 | feature "User cancels account" do 2 | let(:stripe_helper) { StripeMock.create_test_helper } 3 | before { StripeMock.start } 4 | after { StripeMock.stop } 5 | 6 | scenario "and their information is removed" do 7 | stripe_customer = Stripe::Customer.create 8 | subscription = create(:subscription, stripe_customer_id: stripe_customer.id) 9 | user = subscription.user 10 | entry = create(:entry, user: user) 11 | 12 | login_as(user) 13 | visit edit_settings_path 14 | click_link "Close my account" 15 | fill_in :reason, with: "I decided I hate journaling." 16 | click_button "Close my account" 17 | 18 | expect(page).to have_content("account has been removed") 19 | expect(page).to have_content("subscription has been canceled") 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/features/user_edits_entry_spec.rb: -------------------------------------------------------------------------------- 1 | feature "User edits entry" do 2 | scenario "and the entry body is updated" do 3 | user = create(:user) 4 | create(:entry, user: user, body: "Original body") 5 | 6 | login_as(user) 7 | visit entries_path 8 | click_link "Edit this entry" 9 | fill_in :entry_body, with: "New body" 10 | click_button "Save Entry" 11 | 12 | expect(page).to_not have_content("Original body") 13 | expect(page).to have_content("New body") 14 | end 15 | 16 | scenario "and the entry belongs to another user" do 17 | user = create(:user) 18 | another_user = create(:user) 19 | another_users_entry = create(:entry, user: another_user) 20 | 21 | login_as(user) 22 | 23 | visit edit_entry_path(another_users_entry) 24 | expect(page.status_code).to eq(404) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /config/locales/simple_form.en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | simple_form: 3 | "yes": 'Yes' 4 | "no": 'No' 5 | required: 6 | text: 'required' 7 | mark: '*' 8 | # You can uncomment the line below if you need to overwrite the whole required html. 9 | # When using html, text and mark won't be used. 10 | # html: '*' 11 | error_notification: 12 | default_message: "Please review the problems below:" 13 | # Labels and hints examples 14 | # labels: 15 | # defaults: 16 | # password: 'Password' 17 | # user: 18 | # new: 19 | # email: 'E-mail to sign in.' 20 | # edit: 21 | # email: 'E-mail.' 22 | # hints: 23 | # defaults: 24 | # username: 'User name to sign in.' 25 | # password: 'No special characters, please.' 26 | 27 | -------------------------------------------------------------------------------- /app/controllers/imports_controller.rb: -------------------------------------------------------------------------------- 1 | class ImportsController < ApplicationController 2 | before_action :authenticate_user! 3 | 4 | def new 5 | @import = Import.new 6 | end 7 | 8 | def create 9 | import = Import.new(import_params.merge(user: current_user)) 10 | 11 | if import.save 12 | OhlifeImportWorker.perform_async(current_user.id, import.id) 13 | 14 | flash[:notice] = "We're importing your entries. Try refreshing the page "\ 15 | "in a few seconds." 16 | redirect_to entries_path 17 | else 18 | flash[:error] = "Sorry, we had trouble importing that. :( Need help? "\ 19 | "Contact us at team@trailmix.life" 20 | redirect_to new_import_path 21 | end 22 | end 23 | 24 | private 25 | 26 | def import_params 27 | params.require(:import).permit(:ohlife_export) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/features/user_updates_credit_card_spec.rb: -------------------------------------------------------------------------------- 1 | feature "User updates their credit card" do 2 | let(:stripe_helper) { StripeMock.create_test_helper } 3 | before { StripeMock.start } 4 | after { StripeMock.stop } 5 | 6 | scenario "successfully" do 7 | stripe_customer = Stripe::Customer.create 8 | user = create(:user) 9 | create(:subscription, user: user, stripe_customer_id: stripe_customer.id) 10 | 11 | login_as(user) 12 | update_credit_card 13 | 14 | expect(page).to have_content("Credit card updated successfully") 15 | end 16 | 17 | def update_credit_card 18 | visit edit_settings_path 19 | click_link "Update credit card" 20 | fill_in "number", with: "4242424242424242" 21 | fill_in "exp_month", with: "04" 22 | fill_in "exp_year", with: "2016" 23 | fill_in "cvc", with: "216" 24 | click_button "Update credit card" 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /app/views/entries/_welcome.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :header do %> 2 |

    Welcome to Trailmix.

    3 | <% end %> 4 | 5 |

    6 | You're now well on your way to a journaling habit that will stick. You don't 7 | have any entries yet, but you'll be creating one soon. 8 |

    9 | 10 |

    Entries are created using email.

    11 | 12 |

    13 | We email you to ask how your day was and you just hit reply and start typing. 14 | Feel free to attach a photo as well! Your entries will be saved here 15 | automatically. 16 |

    17 | 18 |

    19 | Your first email is probably already in your inbox. Try writing just a 20 | sentence or two at first. It's fine to write more, but even short entries 21 | will be interesting when you read them later. 22 |

    23 | 24 |

    Miss OhLife?

    25 | 26 |

    27 | You can <%= link_to "import your OhLife entries here", new_import_path %>. 28 |

    29 | -------------------------------------------------------------------------------- /app/models/email_processor.rb: -------------------------------------------------------------------------------- 1 | class EmailProcessor 2 | def initialize(email) 3 | @email = email 4 | end 5 | 6 | def process 7 | entry = Entry.find_or_initialize_by(user: user, date: date) 8 | entry.update!(body: body, photo: attachment) 9 | end 10 | 11 | private 12 | 13 | attr_reader :email 14 | 15 | def body 16 | EmailReplyParser.parse_reply(email.body) 17 | end 18 | 19 | def user 20 | @user ||= User.find_by!(reply_token: reply_token) 21 | end 22 | 23 | def attachment 24 | if email.attachments 25 | email.attachments.first 26 | end 27 | end 28 | 29 | def reply_token 30 | email.to.first[:token].downcase 31 | end 32 | 33 | def date 34 | date = Date.parse(email.subject) 35 | date.future? ? (date - 1.year) : date 36 | rescue 37 | today 38 | end 39 | 40 | def today 41 | Time.zone.now.in_time_zone(user.time_zone) 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /app/views/subscriptions/_payment_form.html.erb: -------------------------------------------------------------------------------- 1 | <%= form_tag subscriptions_path, id: "sign-up-form" do %> 2 |
    3 | 8 |
    9 | 10 |
    11 | 16 |
    17 | 18 | 19 | 20 |
    21 | 27 |
    28 | <% end %> 29 | 30 | <%= render "subscriptions/stripe_javascript" %> 31 | -------------------------------------------------------------------------------- /spec/tasks/trailmix_spec.rb: -------------------------------------------------------------------------------- 1 | require "rake" 2 | 3 | describe "trailmix:schedule_all_prompts", sidekiq: :inline do 4 | before do 5 | load "tasks/trailmix.rake" 6 | Rake::Task.define_task(:environment) 7 | end 8 | 9 | it "sends prompts to all users that would like a prompt" do 10 | Timecop.freeze(Time.utc(2014, 1, 1, 8)) do # 8AM UTC 11 | create(:user, time_zone: "UTC", prompt_delivery_hour: 7) 12 | utc_8am = create(:user, time_zone: "UTC", prompt_delivery_hour: 8) 13 | arz_1am = create(:user, time_zone: "Arizona", prompt_delivery_hour: 1) 14 | create(:user, time_zone: "UTC", prompt_delivery_hour: 9) 15 | 16 | Rake::Task["trailmix:schedule_all_prompts"].invoke 17 | 18 | expect(emailed_addresses).to contain_exactly(utc_8am.email, arz_1am.email) 19 | end 20 | end 21 | 22 | def emailed_addresses 23 | ActionMailer::Base.deliveries.map(&:to).flatten 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /app/views/imports/new.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :header do %> 2 |

    3 |

    How to import your OhLife entries

    4 |

    5 | <% end %> 6 | 7 |

    8 |

      9 |
    1. 10 | If you haven't exported your OhLife entries yet, do so now. (And hurry, 11 | exports won't be available after October 11th!) Export your entries by 12 | visiting the OhLife 13 | <%= link_to "export page", "https://ohlife.com/export" %>. 14 |

    2. 15 | 16 |
    3. 17 | Click "Choose File" below, and select the export you just downloaded. 18 |

    4. 19 | 20 |
    5. 21 | Click "Start Import" 22 |

    6. 23 |
    24 |

    25 | 26 | <%= form_for @import, url: imports_path, html: { multipart: true } do |form| %> 27 |
    28 | <%= form.file_field :ohlife_export %> 29 |
    30 | 31 | <%= form.submit "Start Import", class: "btn btn-default btn-lg" %> 32 | <% end %> 33 | -------------------------------------------------------------------------------- /spec/models/export_spec.rb: -------------------------------------------------------------------------------- 1 | describe Export do 2 | describe "#filename" do 3 | it "includes today's date" do 4 | Timecop.freeze(Time.utc(2014, 1, 2)) do 5 | export = Export.new(double(:user)) 6 | 7 | filename = export.filename 8 | 9 | expect(filename).to eq("trailmix-2014-01-02.json") 10 | end 11 | end 12 | end 13 | 14 | describe "#to_json" do 15 | it "returns the user's entries as json" do 16 | today = Time.utc(2014, 2, 3) 17 | user = build_stubbed(:user, entries: [ 18 | build_stubbed(:entry, body: "first entry", date: today), 19 | build_stubbed(:entry, body: "second entry", date: today + 1.day) 20 | ]) 21 | 22 | export = Export.new(user) 23 | 24 | expect(export.to_json).to eq('[' + 25 | '{"body":"first entry","date":"2014-02-03"},' + 26 | '{"body":"second entry","date":"2014-02-04"}' + 27 | ']') 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /bin/update: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'fileutils' 3 | include FileUtils 4 | 5 | # path to your application root. 6 | APP_ROOT = File.expand_path('..', __dir__) 7 | 8 | def system!(*args) 9 | system(*args) || abort("\n== Command #{args} failed ==") 10 | end 11 | 12 | chdir APP_ROOT do 13 | # This script is a way to update your development environment automatically. 14 | # Add necessary update steps to this file. 15 | 16 | puts '== Installing dependencies ==' 17 | system! 'gem install bundler --conservative' 18 | system('bundle check') || system!('bundle install') 19 | 20 | # Install JavaScript dependencies if using Yarn 21 | # system('bin/yarn') 22 | 23 | puts "\n== Updating database ==" 24 | system! 'bin/rails db:migrate' 25 | 26 | puts "\n== Removing old logs and tempfiles ==" 27 | system! 'bin/rails log:clear tmp:clear' 28 | 29 | puts "\n== Restarting application server ==" 30 | system! 'bin/rails restart' 31 | end 32 | -------------------------------------------------------------------------------- /config/initializers/cve_patches.rb: -------------------------------------------------------------------------------- 1 | # CVE patches that have been corrected in the latest version of Rails. When 2 | # rails has been updated to the latest version, all of these pathces are safe 3 | # to remove. 4 | 5 | # Fixes CVE-2020-5267: Possible XSS vulnerability in ActionView 6 | # https://github.com/advisories/GHSA-65cv-r6x7-79hv 7 | ActionView::Helpers::JavaScriptHelper::JS_ESCAPE_MAP.merge!( 8 | { 9 | "`" => "\\`", 10 | "$" => "\\$" 11 | } 12 | ) 13 | 14 | module ActionView::Helpers::JavaScriptHelper 15 | alias :old_ej :escape_javascript 16 | alias :old_j :j 17 | 18 | def escape_javascript(javascript) 19 | javascript = javascript.to_s 20 | if javascript.empty? 21 | result = "" 22 | else 23 | result = javascript.gsub(/(\\|<\/|\r\n|\342\200\250|\342\200\251|[\n\r"']|[`]|[$])/u, JS_ESCAPE_MAP) 24 | end 25 | javascript.html_safe? ? result.html_safe : result 26 | end 27 | 28 | alias :j :escape_javascript 29 | end 30 | -------------------------------------------------------------------------------- /app/views/searches/show.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :header do %> 2 |

    3 | <% end %> 4 | 5 | <%= form_for @search, 6 | url: search_path, 7 | method: :get, 8 | html: { class: "form-horizontal" } do |f| 9 | %> 10 |
    11 |
    12 | <%= f.text_field :term, 13 | class: "form-control input-lg", 14 | autofocus: true, 15 | placeholder: "What are you looking for?" 16 | %> 17 |
    18 | 19 |
    20 | <%= f.submit "Search", class: "btn btn-default btn-lg" %> 21 |
    22 |
    23 | <% end %> 24 | 25 | <% if @search.entries.present? %> 26 |

    We found <%= @search.entries.count %> entries.

    27 | <%= render partial: "entry", collection: @search.entries %> 28 | <% elsif @search.term.present? %> 29 |

    30 | Sorry, we couldn't find any entries that include 31 | <%= @search.term %>. 32 |

    33 | <% end %> 34 | -------------------------------------------------------------------------------- /app/views/subscriptions/_stripe_javascript.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :javascript do %> 2 | <%= javascript_include_tag "https://checkout.stripe.com/checkout.js" %> 3 | <%= javascript_tag do %> 4 | var handler = StripeCheckout.configure({ 5 | key: "<%= Rails.configuration.stripe[:publishable_key] %>", 6 | image: "<%= image_path 'logo-stripe.png' %>", 7 | name: "Trailmix", 8 | description: "Monthly subscription with trial", 9 | amount: 399, 10 | panelLabel: "{{amount}} after 14-day trial", 11 | allowRememberMe: false, 12 | token: function(token) { 13 | $("#sign-up-button").button("loading"); 14 | $("#stripe_card_id").val(token.id); 15 | $("#sign-up-form").submit(); 16 | } 17 | }); 18 | 19 | $("#sign-up-button").on("click", function(e) { 20 | e.preventDefault(); 21 | 22 | handler.open({ 23 | email: $("#email").val() 24 | }); 25 | }); 26 | <% end %> 27 | <% end %> 28 | -------------------------------------------------------------------------------- /app/views/settings/edit.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :header do %> 2 |

    Settings

    3 |

    When would you like your daily email?

    4 | <% end %> 5 | 6 |

    7 | <%= form_tag settings_path, method: :put do %> 8 |

    9 | <%= time_zone_select :user, 10 | :time_zone, 11 | ActiveSupport::TimeZone.us_zones, 12 | { default: current_user.time_zone }, 13 | { class: "form-control input-lg" } 14 | %> 15 |
    16 | 17 |
    18 | <%= select_hour current_user.prompt_delivery_hour, 19 | { ampm: true, prefix: :user, field_name: :prompt_delivery_hour }, 20 | { class: "form-control input-lg" } 21 | %> 22 |
    23 | 24 | 25 | <% end %> 26 |

    27 | 28 |
    29 | 30 |

    31 | <%= link_to "Update credit card", edit_credit_card_path %> | 32 | <%= render "subscriptions/close_account" %> 33 |

    34 | -------------------------------------------------------------------------------- /app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Trailmix. <% if content_for?(:page_title) %> 8 | <%= yield :page_title %> 9 | <% else %> 10 | A private place to write. 11 | <% end %> 12 | 13 | 14 | <%= favicon_link_tag "favicon.ico" %> 15 | <%= render "layouts/apple_touch_icons" %> 16 | 17 | <%= stylesheet_link_tag :application, media: "all" %> 18 | <%= csrf_meta_tags %> 19 | 20 | 21 |
    22 | <%= render "navbar" -%> 23 | 24 |
    25 | <% if content_for?(:content) %> 26 | <%= yield :content %> 27 | <% else %> 28 | <%= render "layouts/default_content" %> 29 | <% end %> 30 |
    31 |
    32 | 33 | <%= render "layouts/footer" %> 34 | <%= render "javascript" %> 35 | 36 | 37 | -------------------------------------------------------------------------------- /app/models/admin_dashboard.rb: -------------------------------------------------------------------------------- 1 | class AdminDashboard 2 | def users_by_day_since(date) 3 | users_created_since(date).group_by_day(:created_at).count 4 | end 5 | 6 | def entries_by_day_since(date) 7 | Entry.where("date >= ?", date).group_by_day(:date).count 8 | end 9 | 10 | def users_created_since(date) 11 | User.where("created_at >= ?", date) 12 | end 13 | 14 | def entries_per_day_for(user) 15 | (entry_count_for(user) / account_age_for(user)).to_f.round(1) 16 | rescue ZeroDivisionError 17 | 0 18 | end 19 | 20 | def trial_status_for(user) 21 | entries_per_day = entries_per_day_for(user) 22 | 23 | if entries_per_day <= 0.3 24 | "danger" 25 | elsif entries_per_day <= 0.5 26 | "warning" 27 | else 28 | "great" 29 | end 30 | end 31 | 32 | private 33 | 34 | def entry_count_for(user) 35 | user.entries.where("created_at >= ?", user.created_at.to_date).count 36 | end 37 | 38 | def account_age_for(user) 39 | Time.zone.now.to_date - user.created_at.to_date 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/features/user_changes_settings_spec.rb: -------------------------------------------------------------------------------- 1 | feature "User changes settings" do 2 | scenario "time zone from Pacific Time to Melbourne" do 3 | user = create(:user, time_zone: "Pacific Time (US & Canada)") 4 | create(:entry, user: user, date: Time.utc(2014, 1, 1)) 5 | login_as(user) 6 | visit entries_path 7 | 8 | click_link "Settings" 9 | select "Melbourne", from: :user_time_zone 10 | click_button "Save" 11 | 12 | expect(page).to have_content("settings have been saved") 13 | expect(current_path).to eq entries_path 14 | end 15 | 16 | scenario "email delivery time from 9PM to 6AM" do 17 | user = create( 18 | :user, 19 | time_zone: "Melbourne", 20 | prompt_delivery_hour: 21 21 | ) 22 | login_as(user) 23 | 24 | visit edit_settings_path 25 | select "06 AM", from: :user_prompt_delivery_hour 26 | click_button "Save" 27 | 28 | click_link "Settings" 29 | expect(page).to have_select( 30 | :user_prompt_delivery_hour, 31 | selected: "06 AM" 32 | ) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/factories.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | sequence(:email) { |n| "user-#{n}@example.com" } 3 | sequence(:stripe_customer_id) { |n| "cus_#{n}" } 4 | 5 | factory :user do 6 | email 7 | password { 'abc123' } 8 | end 9 | 10 | factory :entry do 11 | user 12 | date { Time.zone.now } 13 | body { 'Entry body' } 14 | 15 | trait :with_photo do 16 | photo do 17 | Rack::Test::UploadedFile.new( 18 | Rails.root.join("spec", "fixtures", "files", "photo.jpg") 19 | ) 20 | end 21 | end 22 | end 23 | 24 | factory :subscription do 25 | user 26 | stripe_customer_id 27 | end 28 | 29 | factory :import do 30 | user 31 | end 32 | 33 | factory :griddler_email, class: OpenStruct do 34 | to { [{ 35 | full: "to_user@example.com", 36 | email: "to_user@example.com", 37 | token: "to_user", 38 | host: "example.com", 39 | name: nil 40 | }] } 41 | from { ({ email: "from_user@example.com" }) } 42 | subject { "Hello Trailmix" } 43 | body { "Today was great" } 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /app/models/user.rb: -------------------------------------------------------------------------------- 1 | class User < ApplicationRecord 2 | devise :database_authenticatable, 3 | :recoverable, 4 | :rememberable, 5 | :trackable, 6 | :validatable 7 | 8 | has_many :entries, dependent: :destroy 9 | has_many :imports, dependent: :destroy 10 | has_one :subscription, dependent: :destroy 11 | 12 | before_create :generate_reply_token 13 | 14 | delegate :stripe_customer_id, to: :subscription 15 | 16 | def self.promptable(time = Time.zone.now.utc) 17 | where(prompt_delivery_hour: time.hour) 18 | end 19 | 20 | def reply_email 21 | "#{reply_token}@#{ENV.fetch('SMTP_DOMAIN')}" 22 | end 23 | 24 | def generate_reply_token 25 | self.reply_token = ReplyToken.generate(email) 26 | end 27 | 28 | def newest_entry 29 | entries.newest 30 | end 31 | 32 | def prompt_entry 33 | PromptEntry.best(entries) 34 | end 35 | 36 | def prompt_delivery_hour 37 | PromptDeliveryHour.new(super, time_zone).in_time_zone 38 | end 39 | 40 | def prompt_delivery_hour=(hour) 41 | super PromptDeliveryHour.new(hour, time_zone).in_utc 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /.github/workflows/rubyonrails.yml: -------------------------------------------------------------------------------- 1 | name: "Ruby on Rails CI" 2 | on: 3 | push: 4 | branches: ["master"] 5 | pull_request: 6 | branches: ["master"] 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | services: 11 | postgres: 12 | image: postgres:14-alpine 13 | ports: 14 | - "5432:5432" 15 | env: 16 | POSTGRES_DB: rails_test 17 | POSTGRES_USER: rails 18 | POSTGRES_PASSWORD: password 19 | env: 20 | RAILS_ENV: test 21 | DATABASE_URL: "postgres://rails:password@localhost:5432/rails_test" 22 | steps: 23 | - name: Checkout code 24 | uses: actions/checkout@v4 25 | - name: Setup environment variables 26 | run: cp .sample.env .env 27 | - name: Install libcurl 28 | run: sudo apt-get install build-essential libcurl4-openssl-dev 29 | - name: Install Ruby and gems 30 | uses: ruby/setup-ruby@v1 31 | with: 32 | bundler-cache: true 33 | - name: Set up database schema 34 | run: bin/rails db:schema:load 35 | - name: Run tests 36 | run: bin/rake 37 | -------------------------------------------------------------------------------- /app/views/application/_analytics.html.erb: -------------------------------------------------------------------------------- 1 | <% if ENV["SEGMENT_IO_KEY"] %> 2 | 7 | <% end %> 8 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | ENV["RAILS_ENV"] = "test" 2 | 3 | require File.expand_path("../../config/environment", __FILE__) 4 | 5 | require "rspec/rails" 6 | require "webmock/rspec" 7 | require "stripe_mock" 8 | 9 | Dir[Rails.root.join("spec/support/**/*.rb")].each { |file| require file } 10 | 11 | module Features 12 | # Extend this module in spec/support/features/*.rb 13 | include Formulaic::Dsl 14 | end 15 | 16 | RSpec.configure do |config| 17 | config.expect_with :rspec do |c| 18 | c.syntax = :expect 19 | end 20 | 21 | config.include Features, type: :feature 22 | config.include Devise::Test::ControllerHelpers, type: :controller 23 | config.include Devise::Test::ControllerHelpers, type: :view 24 | config.infer_base_class_for_anonymous_controllers = false 25 | config.infer_spec_type_from_file_location! 26 | config.order = "random" 27 | config.use_transactional_fixtures = false 28 | config.fixture_paths = ["#{::Rails.root}/spec/fixtures"] 29 | end 30 | 31 | Capybara.javascript_driver = :webkit 32 | 33 | ActiveRecord::Migration.maintain_test_schema! 34 | WebMock.disable_net_connect!(allow_localhost: true) 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Trailmix 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # Set up Rails app. Run this script immediately after cloning the codebase. 4 | # https://github.com/thoughtbot/guides/tree/master/protocol 5 | 6 | # Exit if any subcommand fails 7 | set -e 8 | 9 | # Set up Ruby dependencies via Bundler 10 | bundle install 11 | 12 | # Set up configurable environment variables 13 | if [ ! -f .env ]; then 14 | cp .sample.env .env 15 | fi 16 | 17 | # Set up database and add any development seed data 18 | bundle exec rake dev:prime 19 | 20 | # Add binstubs to PATH via export PATH=".git/safe/../../bin:$PATH" in ~/.zshenv 21 | mkdir -p .git/safe 22 | 23 | # Pick a port for Foreman 24 | echo "port: 5000" > .foreman 25 | 26 | # Print warning if Foreman is not installed 27 | if ! command -v foreman &>/dev/null; then 28 | echo "foreman is not installed." 29 | echo "See https://github.com/ddollar/foreman for install instructions." 30 | fi 31 | 32 | # Set up production app. 33 | if heroku join --app trailmix-production &> /dev/null; then 34 | heroku git:remote -a trailmix-production -r production || true 35 | echo 'You are a collaborator on the "trailmix-production" Heroku app' 36 | fi 37 | -------------------------------------------------------------------------------- /config/initializers/content_security_policy.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Define an application-wide content security policy. 4 | # See the Securing Rails Applications Guide for more information: 5 | # https://guides.rubyonrails.org/security.html#content-security-policy-header 6 | 7 | # Rails.application.configure do 8 | # config.content_security_policy do |policy| 9 | # policy.default_src :self, :https 10 | # policy.font_src :self, :https, :data 11 | # policy.img_src :self, :https, :data 12 | # policy.object_src :none 13 | # policy.script_src :self, :https 14 | # policy.style_src :self, :https 15 | # # Specify URI for violation reports 16 | # # policy.report_uri "/csp-violation-report-endpoint" 17 | # end 18 | # 19 | # # Generate session nonces for permitted importmap, inline scripts, and inline styles. 20 | # config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } 21 | # config.content_security_policy_nonce_directives = %w(script-src style-src) 22 | # 23 | # # Report violations without enforcing the policy. 24 | # # config.content_security_policy_report_only = true 25 | # end 26 | -------------------------------------------------------------------------------- /spec/controllers/admin_dashboard_controller_spec.rb: -------------------------------------------------------------------------------- 1 | describe AdminDashboardController do 2 | describe "#show" do 3 | context "when a logged out user requests it" do 4 | it "redirects" do 5 | get :show 6 | 7 | expect(response).to redirect_to(new_user_session_path) 8 | end 9 | end 10 | 11 | context "when a non-admin user requests it" do 12 | it "redirects" do 13 | non_admin = mock_model("User", email: "foo@bar.com") 14 | stub_current_user_with(non_admin) 15 | 16 | get :show 17 | 18 | expect(response).to redirect_to(entries_path) 19 | end 20 | end 21 | 22 | context "when an admin requests it" do 23 | it "renders successfully" do 24 | email = ENV.fetch("ADMIN_EMAILS").split(",").first 25 | admin = mock_model("User", email: email) 26 | stub_current_user_with(admin) 27 | 28 | get :show 29 | 30 | expect(response).to have_http_status(:ok) 31 | end 32 | end 33 | end 34 | 35 | def stub_current_user_with(user) 36 | allow(controller).to(receive(:authenticate_user!)) 37 | allow(controller).to(receive(:current_user).and_return(user)) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /app/views/admin_dashboard/show.html.erb: -------------------------------------------------------------------------------- 1 | <%= javascript_include_tag "//www.google.com/jsapi", "chartkick" %> 2 | 3 |

    Sign ups

    4 |

    Are we still growing?

    5 | <%= line_chart @dashboard.users_by_day_since(30.days.ago) %>
    6 | 7 |

    New entries

    8 |

    Are customers using the service?

    9 | <%= line_chart @dashboard.entries_by_day_since(30.days.ago) %>
    10 | 11 |

    Active trials

    12 |

    Does anyone need help?

    13 |

    14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | <% @dashboard.users_created_since(14.days.ago).order(:created_at).each do |user| %> 22 | 23 | 24 | 25 | 26 | 27 | 28 | <% end %> 29 |
    EmailSigned upEntriesPer day
    <%= link_to user.email, "mailto:#{user.email}" %><%= l(user.created_at.to_date, format: :month_day) %><%= user.entries.count %><%= @dashboard.entries_per_day_for(user) %>

    30 | -------------------------------------------------------------------------------- /app/controllers/subscriptions_controller.rb: -------------------------------------------------------------------------------- 1 | class SubscriptionsController < ApplicationController 2 | def create 3 | user = build_user 4 | 5 | if user.valid? 6 | create_subscription(user) 7 | send_welcome_email(user) 8 | sign_in(user) 9 | redirect_to entries_path 10 | else 11 | flash[:error] = user.errors.full_messages.to_sentence 12 | redirect_to new_registration_path 13 | end 14 | 15 | rescue Stripe::CardError => e 16 | flash[:error] = e.message 17 | redirect_to new_registration_path 18 | end 19 | 20 | private 21 | 22 | def build_user 23 | User.new(email: params[:email], 24 | password: params[:password]) 25 | end 26 | 27 | def create_subscription(user) 28 | stripe_customer_id = create_stripe_customer.id 29 | user.save! 30 | user.create_subscription!(stripe_customer_id: stripe_customer_id) 31 | end 32 | 33 | def create_stripe_customer 34 | Stripe::Customer.create( 35 | email: params[:email], 36 | card: params[:stripe_card_id], 37 | plan: Rails.configuration.stripe[:plan_name] 38 | ) 39 | end 40 | 41 | def send_welcome_email(user) 42 | WelcomeMailerWorker.perform_async(user.id) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /config/storage.yml: -------------------------------------------------------------------------------- 1 | test: 2 | service: Disk 3 | root: <%= Rails.root.join("tmp/storage") %> 4 | 5 | local: 6 | service: Disk 7 | root: <%= Rails.root.join("storage") %> 8 | 9 | # Use rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) 10 | # amazon: 11 | # service: S3 12 | # access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> 13 | # secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> 14 | # region: us-east-1 15 | # bucket: your_own_bucket 16 | 17 | # Remember not to checkin your GCS keyfile to a repository 18 | # google: 19 | # service: GCS 20 | # project: your_project 21 | # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> 22 | # bucket: your_own_bucket 23 | 24 | # Use rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) 25 | # microsoft: 26 | # service: AzureStorage 27 | # storage_account_name: your_account_name 28 | # storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> 29 | # container: your_container_name 30 | 31 | # mirror: 32 | # service: Mirror 33 | # primary: local 34 | # mirrors: [ amazon, google, microsoft ] 35 | -------------------------------------------------------------------------------- /app/views/devise/shared/_links.erb: -------------------------------------------------------------------------------- 1 | <%- if controller_name != 'sessions' %> 2 | <%= link_to "Log in", new_session_path(resource_name) %>
    3 | <% end -%> 4 | 5 | <%- if devise_mapping.registerable? && controller_name != 'registrations' %> 6 | <%= link_to "Sign up", new_registration_path(resource_name) %>
    7 | <% end -%> 8 | 9 | <%- if devise_mapping.recoverable? && controller_name != 'passwords' && controller_name != 'registrations' %> 10 | <%= link_to "Forgot your password?", new_password_path(resource_name) %>
    11 | <% end -%> 12 | 13 | <%- if devise_mapping.confirmable? && controller_name != 'confirmations' %> 14 | <%= link_to "Didn't receive confirmation instructions?", new_confirmation_path(resource_name) %>
    15 | <% end -%> 16 | 17 | <%- if devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email) && controller_name != 'unlocks' %> 18 | <%= link_to "Didn't receive unlock instructions?", new_unlock_path(resource_name) %>
    19 | <% end -%> 20 | 21 | <%- if devise_mapping.omniauthable? %> 22 | <%- resource_class.omniauth_providers.each do |provider| %> 23 | <%= link_to "Sign in with #{provider.to_s.titleize}", omniauth_authorize_path(resource_name, provider) %>
    24 | <% end -%> 25 | <% end -%> 26 | -------------------------------------------------------------------------------- /app/views/pages/ohlife-alternative.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :page_title, "An OhLife alternative." %> 2 | 3 | <% content_for :header do %> 4 |

    Are you an OhLife refugee?

    5 |

    We've got a home for you.

    6 | <% end %> 7 | 8 |

    9 | We loved OhLife and are sad to see it go, so we built a 10 | replacement with a single additional feature: paid plans. We call 11 | it Trailmix. 12 |

    13 | 14 |

    15 | It works just like OhLife used to: a daily email asking how 16 | your day went, plus an old entry chosen at random. Just as simple and 17 | beautiful as OhLife, but with a business model. 18 |

    19 | 20 |

    21 | And, of course, you can import all your existing OhLife 22 | entries. 23 |

    24 | 25 |

    26 | Your monthly payment ensures we'll never have ads and can justify the work to 27 | keep things running. No surprise shutdown with minimal notice. 28 |

    29 | 30 |

    31 | <%= render "subscriptions/pricing" %> 32 |

    33 | 34 |

    35 | You can sign up now and restart your journaling habit like 36 | nothing ever happened. Trailmix is your new private place to write. 37 |

    38 | 39 | <%= render 'subscriptions/payment_form' %> 40 | -------------------------------------------------------------------------------- /config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative "boot" 2 | 3 | require "rails/all" 4 | 5 | # Require the gems listed in Gemfile, including any gems 6 | # you've limited to :test, :development, or :production. 7 | Bundler.require(*Rails.groups) 8 | 9 | module Trailmix 10 | class Application < Rails::Application 11 | # Initialize configuration defaults for originally generated Rails version. 12 | config.load_defaults 8.0 13 | 14 | config.i18n.enforce_available_locales = true 15 | 16 | config.active_record.default_timezone = :utc 17 | 18 | config.generators do |generate| 19 | generate.helper false 20 | generate.javascript_engine false 21 | generate.request_specs false 22 | generate.routing_specs false 23 | generate.stylesheets false 24 | generate.test_framework :rspec 25 | generate.view_specs false 26 | end 27 | 28 | config.eager_load_paths += ["#{config.root}/app/workers"] 29 | 30 | ActionMailer::Base.default( 31 | from: "Team Trailmix " 32 | ) 33 | 34 | # Settings in config/environments/* take precedence over those specified here. 35 | # Application configuration should go into files in config/initializers 36 | # -- all .rb files in that directory are automatically loaded. 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/features/user_views_entries_spec.rb: -------------------------------------------------------------------------------- 1 | feature "User views entries" do 2 | scenario "when signed in with entries" do 3 | user = create(:user) 4 | create(:entry, user: user, body: "My first entry", date: 2.days.ago) 5 | create(:entry, user: user, body: "My latest entry", date: 1.day.ago) 6 | 7 | login_as(user) 8 | visit entries_path 9 | 10 | expect(page).to have_content("My latest entry") 11 | expect(page).to_not have_content("My first entry") 12 | 13 | click_link 'Older' 14 | 15 | expect(page).to have_content("My first entry") 16 | expect(page).to_not have_content("My latest entry") 17 | end 18 | 19 | scenario "when an entry includes a photo" do 20 | user = create(:user) 21 | entry = create(:entry, :with_photo, user: user) 22 | 23 | login_as(user) 24 | visit entries_path 25 | 26 | expect(page).to have_css("img[src='#{entry.photo.url}']") 27 | end 28 | 29 | scenario "when signed in without entries" do 30 | user = create(:user) 31 | 32 | login_as(user) 33 | visit entries_path 34 | 35 | expect(current_path).to eq entries_path 36 | expect(page).to have_content("Welcome to Trailmix") 37 | end 38 | 39 | scenario "when signed out" do 40 | visit entries_path 41 | 42 | expect(current_path).to eq new_user_session_path 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/features/user_responds_to_prompt_spec.rb: -------------------------------------------------------------------------------- 1 | feature "User responds to a prompt" do 2 | include Rack::Test::Methods 3 | include ActionDispatch::TestProcess 4 | 5 | scenario "and it does not include the original prompt email text" do 6 | user = create(:user, entries: []) 7 | text = "User response.\n\n#{PromptMailer::PROMPT_TEXT}\n\nOld entry." 8 | 9 | simulate_email_from(user, text: text) 10 | 11 | last_entry = user.reload.entries.last 12 | expect(last_entry.body).to eq("User response.") 13 | expect(File.exist?(last_entry.photo.path)).to be_truthy 14 | end 15 | 16 | def simulate_email_from(user, options = {}) 17 | post("/email_processor", email_params(user).merge(options)) 18 | end 19 | 20 | def email_params(user) 21 | { 22 | headers: "Received: by 127.0.0.1 with SMTP...", 23 | to: user.reply_email, 24 | cc: "CC ", 25 | from: "whocares@example.com", 26 | subject: "hello there", 27 | text: "this is an email message", 28 | html: "

    this is an email message

    ", 29 | SPF: "pass", 30 | attachments: 1, 31 | attachment1: photo_attachment 32 | } 33 | end 34 | 35 | def photo_attachment 36 | fixture_file_upload("photo.jpg", "image/jpg") 37 | end 38 | 39 | def app 40 | Rails.application 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /app/models/ohlife_importer.rb: -------------------------------------------------------------------------------- 1 | class OhlifeImporter 2 | def initialize(user, import) 3 | @user = user 4 | @import = import 5 | end 6 | 7 | def run 8 | export.each_line(separator) do |line| 9 | if is_start_of_entry?(line) 10 | build_new_entry(line) 11 | else 12 | append_to_existing_entry(line) 13 | end 14 | end 15 | 16 | save_entry 17 | end 18 | 19 | private 20 | 21 | attr_reader :user, :import 22 | 23 | def is_start_of_entry?(line) 24 | line =~ /\d\d\d\d-\d\d-\d\d/ 25 | end 26 | 27 | def build_new_entry(line) 28 | if @entry 29 | save_entry 30 | end 31 | 32 | @entry = Entry.new(body: "", user: user, import: import) 33 | @entry.date = line 34 | end 35 | 36 | def save_entry 37 | @entry.body.strip! 38 | @entry.save! 39 | end 40 | 41 | def append_to_existing_entry(line) 42 | line = convert_to_unix_line_endings(line) 43 | @entry.body.concat(line) 44 | end 45 | 46 | def convert_to_unix_line_endings(line) 47 | line.strip.concat("\n") 48 | end 49 | 50 | def export 51 | ohlife_export = import.ohlife_export 52 | 53 | if ohlife_export.file.is_a?(CarrierWave::SanitizedFile) 54 | ohlife_export.read 55 | else 56 | open(ohlife_export.url) 57 | end 58 | end 59 | 60 | def separator 61 | "\r\n" 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/transitions/fix_future_dated_entries_transition_spec.rb: -------------------------------------------------------------------------------- 1 | describe FixFutureDatedEntriesTransition do 2 | describe "#perform" do 3 | context "for entries dated in the past" do 4 | it "does nothing" do 5 | last_year = 1.year.ago.to_date 6 | yesterday = 1.day.ago.to_date 7 | entry_from_last_year = create(:entry, date: last_year) 8 | entry_from_yesterday = create(:entry, date: yesterday) 9 | 10 | FixFutureDatedEntriesTransition.perform 11 | 12 | entry_from_last_year.reload 13 | entry_from_yesterday.reload 14 | expect(entry_from_last_year.date).to eq(last_year) 15 | expect(entry_from_yesterday.date).to eq(yesterday) 16 | end 17 | end 18 | 19 | context "for entries dated in the future" do 20 | it "fixes the date by moving them one year back" do 21 | tomorrow = 1.day.from_now.to_date 22 | next_week = 1.week.from_now.to_date 23 | entry_from_tomorrow = create(:entry, date: tomorrow) 24 | entry_from_next_week = create(:entry, date: next_week) 25 | 26 | FixFutureDatedEntriesTransition.perform 27 | 28 | entry_from_tomorrow.reload 29 | entry_from_next_week.reload 30 | expect(entry_from_tomorrow.date).to eq(tomorrow - 1.year) 31 | expect(entry_from_next_week.date).to eq(next_week - 1.year) 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /app/views/credit_cards/edit.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :javascript do %> 2 | <%= render "stripe_javascript" %> 3 | <% end %> 4 | 5 | <% content_for :header do %> 6 |

    Update credit card

    7 | <% end %> 8 | 9 |

    10 | <%= form_tag credit_card_path, method: :put, id: "credit-card-update" do %> 11 |

    12 | 13 |
    14 | <%= text_field_tag :number, nil, 15 | class: "form-control input-lg", 16 | placeholder: "Number (1234-1234-1234-1234)", 17 | data: { stripe: "number" } 18 | %> 19 |
    20 | 21 |
    22 | <%= text_field_tag :exp_month, nil, 23 | class: "form-control input-lg", 24 | placeholder: "Month (MM)", 25 | data: { stripe: "exp-month" } 26 | %> 27 |
    28 | 29 |
    30 | <%= text_field_tag :exp_year, nil, 31 | class: "form-control input-lg", 32 | placeholder: "Year (YY)", 33 | data: { stripe: "exp-year" } 34 | %> 35 |
    36 | 37 |
    38 | <%= text_field_tag :cvc, nil, 39 | class: "form-control input-lg", 40 | placeholder: "CVC (1234)", 41 | data: { stripe: "cvc" } 42 | %> 43 |
    44 | 45 | <%= button_tag "Update credit card", 46 | class: "btn btn-default btn-lg", 47 | data: { loading_text: "Updating..." } 48 | %> 49 | <% end %> 50 |

    51 | -------------------------------------------------------------------------------- /db/migrate/20140921195949_create_delayed_jobs.rb: -------------------------------------------------------------------------------- 1 | class CreateDelayedJobs < ActiveRecord::Migration 2 | def self.up 3 | create_table :delayed_jobs, :force => true do |table| 4 | table.integer :priority, :default => 0, :null => false # Allows some jobs to jump to the front of the queue 5 | table.integer :attempts, :default => 0, :null => false # Provides for retries, but still fail eventually. 6 | table.text :handler, :null => false # YAML-encoded string of the object that will do work 7 | table.text :last_error # reason for last failure (See Note below) 8 | table.datetime :run_at # When to run. Could be Time.zone.now for immediately, or sometime in the future. 9 | table.datetime :locked_at # Set when a client is working on this object 10 | table.datetime :failed_at # Set when all retries have failed (actually, by default, the record is deleted instead) 11 | table.string :locked_by # Who is working on this object (if locked) 12 | table.string :queue # The name of the queue this job is in 13 | table.timestamps 14 | end 15 | 16 | add_index :delayed_jobs, [:priority, :run_at], :name => 'delayed_jobs_priority' 17 | end 18 | 19 | def self.down 20 | drop_table :delayed_jobs 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /app/assets/stylesheets/base/_body.scss: -------------------------------------------------------------------------------- 1 | body { 2 | font-size: 16px; 3 | padding: 70px 0; 4 | } 5 | 6 | a { 7 | color: $brown; 8 | 9 | &:hover { 10 | color: $brown; 11 | } 12 | } 13 | 14 | p, h3, h2 { 15 | margin-bottom: 25px; 16 | } 17 | 18 | mark { 19 | color: $brown; 20 | background: $lighter-brown; 21 | } 22 | 23 | .btn-default, 24 | .pagination > li > a, 25 | .pagination > li > span { 26 | color: $brown; 27 | background: $lighter-brown; 28 | border-color: $light-brown; 29 | 30 | &:hover, &:focus, &:disabled { 31 | color: $brown; 32 | background: $light-brown; 33 | border-color: $light-brown; 34 | } 35 | } 36 | 37 | .pagination > .disabled > span, 38 | .pagination > .disabled > span:hover, 39 | .pagination > .disabled > span:focus, 40 | .pagination > .disabled > a, 41 | .pagination > .disabled > a:hover, 42 | .pagination > .disabled > a:focus { 43 | color: $transparent-brown; 44 | background: $light-brown; 45 | border-color: $light-brown; 46 | } 47 | 48 | .btn-primary { 49 | color: white; 50 | background: $brown; 51 | border-color: $brown; 52 | 53 | &:hover, &:focus, &:disabled { 54 | color: white; 55 | background: $brown; 56 | border-color: $brown; 57 | } 58 | } 59 | 60 | .alert-danger, .alert-success { 61 | color: $brown; 62 | background: $lighter-brown; 63 | border-color: $light-brown; 64 | } 65 | 66 | .jumbotron { 67 | background: $lighter-grey; 68 | padding: 0 0 20px 0; 69 | text-align: center; 70 | } 71 | -------------------------------------------------------------------------------- /db/migrate/20140921203919_devise_create_users.rb: -------------------------------------------------------------------------------- 1 | class DeviseCreateUsers < ActiveRecord::Migration 2 | def change 3 | create_table(:users) do |t| 4 | ## Database authenticatable 5 | t.string :email, null: false, default: "" 6 | t.string :encrypted_password, null: false, default: "" 7 | 8 | ## Recoverable 9 | t.string :reset_password_token 10 | t.datetime :reset_password_sent_at 11 | 12 | ## Rememberable 13 | t.datetime :remember_created_at 14 | 15 | ## Trackable 16 | t.integer :sign_in_count, default: 0, null: false 17 | t.datetime :current_sign_in_at 18 | t.datetime :last_sign_in_at 19 | t.inet :current_sign_in_ip 20 | t.inet :last_sign_in_ip 21 | 22 | ## Confirmable 23 | # t.string :confirmation_token 24 | # t.datetime :confirmed_at 25 | # t.datetime :confirmation_sent_at 26 | # t.string :unconfirmed_email # Only if using reconfirmable 27 | 28 | ## Lockable 29 | # t.integer :failed_attempts, default: 0, null: false # Only if lock strategy is :failed_attempts 30 | # t.string :unlock_token # Only if unlock strategy is :email or :both 31 | # t.datetime :locked_at 32 | 33 | 34 | t.timestamps 35 | end 36 | 37 | add_index :users, :email, unique: true 38 | add_index :users, :reset_password_token, unique: true 39 | # add_index :users, :confirmation_token, unique: true 40 | # add_index :users, :unlock_token, unique: true 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /spec/models/ohlife_importer_spec.rb: -------------------------------------------------------------------------------- 1 | describe OhlifeImporter do 2 | describe "#run" do 3 | it "creates entries for each entry in the file" do 4 | import = create_import 5 | user = import.user 6 | 7 | OhlifeImporter.new(user, import).run 8 | 9 | first_entry = user.entries.first 10 | expect(first_entry.body).to( 11 | eq("Older entry line one.\n\nOlder entry line two.") 12 | ) 13 | expect(first_entry.date).to eq(Date.new(2014, 1, 28)) 14 | 15 | second_entry = user.entries.second 16 | expect(second_entry.body).to( 17 | eq("List of items:\n\n* Item 1\n* Item 2") 18 | ) 19 | expect(second_entry.date).to eq(Date.new(2014, 1, 29)) 20 | end 21 | 22 | it "handles a single newline between lines" do 23 | import = create_import 24 | user = import.user 25 | 26 | OhlifeImporter.new(user, import).run 27 | 28 | first_entry = user.entries.first 29 | expect(first_entry.body).to( 30 | eq("Older entry line one.\n\nOlder entry line two.") 31 | ) 32 | end 33 | 34 | it "associates the created entries with an import" do 35 | import = create_import 36 | user = import.user 37 | 38 | OhlifeImporter.new(user, import).run 39 | 40 | expect(import.reload.entries.count).to eq(2) 41 | end 42 | end 43 | 44 | def create_import 45 | create(:import, ohlife_export: File.new(ohlife_export)) 46 | end 47 | 48 | def ohlife_export 49 | Rails.root + "spec/fixtures/files/ohlife_export.txt" 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /app/views/credit_cards/_stripe_javascript.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 43 | -------------------------------------------------------------------------------- /spec/models/search_spec.rb: -------------------------------------------------------------------------------- 1 | describe Search do 2 | describe "#entries" do 3 | it "returns entries whose bodies match the search term" do 4 | user = create(:user) 5 | matched_entry = create(:entry, user: user, body: "I like cats") 6 | create(:entry, user: user, body: "I like dogs") 7 | create(:entry, body: "This guy likes cats too") 8 | search = Search.new(term: "cats", user: user) 9 | 10 | result = search.entries.map(&:body) 11 | 12 | expect(result).to eq([matched_entry.body]) 13 | end 14 | 15 | it "is not case sensitive" do 16 | user = create(:user) 17 | create(:entry, user: user, body: "i like pizza") 18 | create(:entry, user: user, body: "I REALLY LIKE PIZZA") 19 | all_user_entries = user.entries.map(&:body) 20 | search = Search.new(term: "piZZa", user: user) 21 | 22 | result = search.entries.map(&:body) 23 | 24 | expect(result).to contain_exactly(*all_user_entries) 25 | end 26 | 27 | it "sorts entries by date" do 28 | user = create(:user) 29 | create(:entry, user: user, body: "I like cats.", date: 2.days.ago) 30 | create(:entry, user: user, body: "I still like cats.", date: 1.day.ago) 31 | entries_sorted_by_date = user.entries.by_date.map(&:body) 32 | search = Search.new(term: "cats", user: user) 33 | 34 | result = search.entries.map(&:body) 35 | 36 | expect(result).to eq(entries_sorted_by_date) 37 | end 38 | 39 | it "returns nothing when there's no search term" do 40 | entry = create(:entry, body: "I like cats") 41 | search = Search.new(user: entry.user) 42 | 43 | result = search.entries 44 | 45 | expect(result).to be_empty 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | ruby file: ".ruby-version" 4 | 5 | gem "airbrake" 6 | gem "autoprefixer-rails" 7 | gem "benchmark" 8 | gem "bigdecimal", "~> 3.1.9" 9 | gem "binding_of_caller" 10 | gem "carrierwave" 11 | gem "chartkick" 12 | gem "devise", "~> 4.9.4" 13 | gem "drb" 14 | gem "email_reply_parser" 15 | gem "email_validator" 16 | gem "flutie" 17 | gem "fog" 18 | gem "griddler" 19 | gem "griddler-sendgrid" 20 | gem "groupdate" 21 | gem "high_voltage" 22 | gem "jquery-rails" 23 | gem "kaminari" 24 | gem "mini_magick" 25 | gem "mutex_m" 26 | gem "normalize-rails", "~> 3.0.0" 27 | gem "ostruct" 28 | gem "pg", "~> 1.0" 29 | gem "puma" 30 | gem "rack-timeout" 31 | gem "rails", "~> 8.0.0" 32 | gem "recipient_interceptor" 33 | gem "redis" 34 | gem "sass-rails", "~> 6.0.0" 35 | gem "sidekiq", "< 8" 36 | gem "sprockets", ">= 2.12.5" 37 | gem "simple_form", "~> 5.0.3" 38 | gem "sinatra", "~> 4.1.0", require: false 39 | gem "stripe", "~> 1.57.1" 40 | gem "title" 41 | gem "xmlrpc" 42 | 43 | group :development do 44 | gem "better_errors" 45 | gem "spring" 46 | gem "spring-commands-rspec" 47 | gem "web-console", "~> 2.0" 48 | end 49 | 50 | group :development, :test do 51 | gem "awesome_print" 52 | gem "dotenv-rails" 53 | gem "factory_bot_rails" 54 | gem "pry-rails" 55 | gem "rspec-activemodel-mocks" 56 | gem "rspec-rails", "~> 6.1.0" 57 | end 58 | 59 | group :test do 60 | gem "selenium-webdriver" 61 | gem "database_cleaner" 62 | gem "formulaic" 63 | gem "stripe-ruby-mock", "~> 2.4.0" 64 | gem "launchy" 65 | gem "timecop" 66 | gem "webmock" 67 | gem "rails-controller-testing" 68 | end 69 | 70 | group :staging, :production do 71 | gem "rails_12factor", "~> 0.0.3" 72 | gem "rails_serve_static_assets", "~> 0.0.4" 73 | end 74 | -------------------------------------------------------------------------------- /app/views/application/_navbar.html.erb: -------------------------------------------------------------------------------- 1 |

    50 | -------------------------------------------------------------------------------- /spec/models/entry_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Entry, :type => :model do 2 | describe ".by_date" do 3 | it "sorts entries, by date, with the newest first" do 4 | user = create(:user) 5 | 6 | entries = [ 7 | create(:entry, user: user, date: Date.current, created_at: 1.day.ago), 8 | create(:entry, user: user, date: 1.days.ago, created_at: 3.days.ago), 9 | create(:entry, user: user, date: 2.days.ago, created_at: Time.current), 10 | create(:entry, user: user, date: 3.day.ago, created_at: 2.days.ago), 11 | ] 12 | 13 | entries_by_date = user.entries.by_date 14 | 15 | expect(entries_by_date).to( 16 | eq(entries), 17 | "expected #{entries.map(&:id)}, got #{entries_by_date.map(&:id)}") 18 | end 19 | end 20 | 21 | describe ".newest" do 22 | it "returns the newest dated entry" do 23 | user = create(:user) 24 | newest_entry = create(:entry, user: user, date: Date.current) 25 | create(:entry, user: user, date: 1.day.ago) 26 | 27 | entry = user.entries.newest 28 | 29 | expect(entry).to eq(newest_entry) 30 | end 31 | end 32 | 33 | describe ".random" do 34 | it "returns a random entry" do 35 | entry = create(:entry) 36 | 37 | expect(Entry.random).to eq(entry) 38 | end 39 | end 40 | 41 | describe "#for_today?" do 42 | context "when the entry is dated for today" do 43 | it "returns true" do 44 | Timecop.freeze(Time.utc(2014, 1, 1, 20)) do 45 | user = create(:user, time_zone: "UTC") 46 | entry = create(:entry, user: user, date: Time.zone.now) 47 | 48 | expect(entry).to be_for_today 49 | 50 | user.update_attribute(:time_zone, "Guam") # UTC+10 51 | 52 | expect(entry).to_not be_for_today 53 | end 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /spec/transitions/backfill_subscriptions_transition_spec.rb: -------------------------------------------------------------------------------- 1 | describe BackfillSubscriptionsTransition do 2 | describe "#perform" do 3 | context "for users missing a Subscription" do 4 | it "creates one with the correct Stripe customer id" do 5 | first_user = create(:user, subscription: nil) 6 | second_user = create(:user, subscription: nil) 7 | stub_stripe_customers([ 8 | { email: first_user.email, id: "first id" }, 9 | { email: second_user.email, id: "second id" } 10 | ]) 11 | 12 | BackfillSubscriptionsTransition.perform 13 | 14 | first_subscription = first_user.reload.subscription 15 | second_subscription = second_user.reload.subscription 16 | 17 | expect(first_subscription.stripe_customer_id).to eq("first id") 18 | expect(second_subscription.stripe_customer_id).to eq("second id") 19 | end 20 | end 21 | 22 | context "for users that already have a Subscription" do 23 | it "does nothing" do 24 | original_subscription = create(:subscription) 25 | user = create(:user, subscription: original_subscription) 26 | stub_stripe_customers([ 27 | { email: user.email, id: "new id" } 28 | ]) 29 | 30 | BackfillSubscriptionsTransition.perform 31 | 32 | expect(user.reload.subscription).to eq(original_subscription) 33 | end 34 | end 35 | end 36 | 37 | def stub_stripe_customers(customers) 38 | customer_list = double("Stripe::ListObject") 39 | iterator = allow(customer_list).to(receive(:each)) 40 | 41 | customers.inject(iterator) do |iterator, customer| 42 | iterator.and_yield( 43 | double("Stripe::Customer", email: customer[:email], id: customer[:id])) 44 | end 45 | 46 | allow(Stripe::Customer).to(receive(:all).and_return(customer_list)) 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /app/views/subscriptions/_close_account.html.erb: -------------------------------------------------------------------------------- 1 | 2 | <%= link_to "Close my account", 3 | "#", 4 | data: { toggle: "modal", target: "#closeAccount" } 5 | %> 6 | 7 | 8 | 66 | -------------------------------------------------------------------------------- /app/views/pages/faq.html.erb: -------------------------------------------------------------------------------- 1 |

    2 |

    Frequently Asked Questions

    3 |

    4 | 5 |

    How much does Trailmix cost?

    6 | 7 |

    8 | Trailmix costs $3.99 per month. 9 |

    10 | 11 |

    Is there a free trial?

    12 | 13 |

    14 | Yes! Your first 14 days of Trailmix are free. You'll be asked for a credit card 15 | when you sign up, but you will not be charged until your trial is 16 | over. 17 |

    18 | 19 |

    What if I forget to cancel before I'm charged?

    20 | 21 |

    22 | No problem! We'll happily issue you a refund. 23 |

    24 | 25 |

    What if I want a refund for some other reason?

    26 | 27 |

    28 | That's fine. We will refund your latest Trailmix payment for any reason at all. 29 | Just try us! 30 |

    31 | 32 |

    Why does Trailmix cost money?

    33 | 34 |

    35 | It costs money to run servers, send emails (really!), and to pay ourselves a 36 | little bit to work on the service. We want Trailmix to be around for a long 37 | time, so it's important that it be self-sustaining. 38 |

    39 | 40 |

    Can I import my OhLife entries?

    41 | 42 |

    43 | Yep! It takes two clicks and ten seconds. Visit the 44 | <%= link_to "import page", new_import_path %> to get things started. 45 |

    46 | 47 |

    Can I export my Trailmix entries?

    48 | 49 |

    50 | Yeah! Visit the <%= link_to "export page", new_export_path %> to download 51 | them all. 52 |

    53 | 54 |

    Can I change what time the daily email is sent?

    55 | 56 |

    57 | Yep! Just visit the <%= link_to "settings page", edit_settings_path %>. 58 |

    59 | 60 |

    Can I include a photo in my entries?

    61 | 62 |

    63 | Yes indeed! Just attach an image to your email and we'll make it part of your 64 | entry. 65 |

    66 | 67 |

    Do you have a privacy policy?

    68 | 69 |

    70 | Absolutely. Read our <%= link_to "privacy policy", page_path(:privacy) %>. 71 |

    72 | 73 |

    Who built Trailmix?

    74 | 75 |

    76 | Trailmix was built by Ben Orenstein and 77 | Chris Hunt in the Fall of 2014. 78 |

    79 | 80 |

    81 | <%= image_tag "team.jpg", class: "img-responsive img-rounded" %> 82 |

    83 | -------------------------------------------------------------------------------- /spec/models/prompt_delivery_hour_spec.rb: -------------------------------------------------------------------------------- 1 | describe PromptDeliveryHour do 2 | after { Timecop.return } 3 | 4 | context "when not in daylight savings" do 5 | before { Timecop.freeze Time.utc(2014, 10, 1) } 6 | 7 | describe "#in_time_zone" do 8 | it "returns the hour in the specified time zone" do 9 | hour = 15 10 | time_zone = "Eastern Time (US & Canada)" # UTC-4 11 | 12 | delivery_hour = PromptDeliveryHour.new(hour, time_zone).in_time_zone 13 | 14 | expect(delivery_hour).to eq(11) 15 | end 16 | 17 | it "always returns an hour in the 24 hour range" do 18 | hour = 1 19 | time_zone = "Eastern Time (US & Canada)" # UTC-4 20 | 21 | delivery_hour = PromptDeliveryHour.new(hour, time_zone).in_time_zone 22 | 23 | expect(delivery_hour).to eq(21) 24 | end 25 | end 26 | 27 | describe "#in_utc" do 28 | it "returns the hour in the utc time zone" do 29 | hour = 15 30 | time_zone = "Eastern Time (US & Canada)" # UTC-4 31 | 32 | delivery_hour = PromptDeliveryHour.new(hour, time_zone).in_utc 33 | 34 | expect(delivery_hour).to eq(19) 35 | end 36 | 37 | it "always returns an hour in the 24 hour range" do 38 | hour = 20 39 | time_zone = "Eastern Time (US & Canada)" # UTC-4 40 | 41 | delivery_hour = PromptDeliveryHour.new(hour, time_zone).in_utc 42 | 43 | expect(delivery_hour).to eq(0) 44 | end 45 | end 46 | end 47 | 48 | context "when in daylight savings" do 49 | before { Timecop.freeze Time.utc(2014, 1, 1) } 50 | 51 | describe "#in_time_zone" do 52 | it "returns the hour in the specified time zone accounting for DST" do 53 | hour = 15 54 | time_zone = "Eastern Time (US & Canada)" # UTC-5 55 | 56 | delivery_hour = PromptDeliveryHour.new(hour, time_zone).in_time_zone 57 | 58 | expect(delivery_hour).to eq(10) 59 | end 60 | end 61 | 62 | describe "#in_utc" do 63 | it "returns the hour in the utc time zone accounting for DST" do 64 | hour = 15 65 | time_zone = "Eastern Time (US & Canada)" # UTC-5 66 | 67 | delivery_hour = PromptDeliveryHour.new(hour, time_zone).in_utc 68 | 69 | expect(delivery_hour).to eq(20) 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /spec/controllers/cancellations_controller_spec.rb: -------------------------------------------------------------------------------- 1 | 2 | describe CancellationsController do 3 | let(:stripe_helper) { StripeMock.create_test_helper } 4 | before { StripeMock.start } 5 | after { StripeMock.stop } 6 | 7 | describe "#create" do 8 | context "when signed in" do 9 | it "destroys the current user" do 10 | stripe_customer = Stripe::Customer.create 11 | subscription = create(:subscription, stripe_customer_id: stripe_customer.id) 12 | user = subscription.user 13 | 14 | sign_in(user) 15 | post :create, params: { id: user.id } 16 | 17 | expect(User.count).to be_zero 18 | end 19 | 20 | it "creates a Cancellation" do 21 | stripe_customer = Stripe::Customer.create 22 | subscription = create(:subscription, stripe_customer_id: stripe_customer.id) 23 | user = subscription.user 24 | 25 | sign_in(user) 26 | post :create, params: { id: user.id, reason: "I'm done journaling" } 27 | cancellation = Cancellation.last 28 | 29 | expect(cancellation.reason).to eq("I'm done journaling") 30 | expect(cancellation.stripe_customer_id).to( 31 | eq(subscription.stripe_customer_id) 32 | ) 33 | end 34 | 35 | it "cancels the Stripe subscription" do 36 | subscription = create(:subscription) 37 | user = subscription.user 38 | stripe_subscription = 39 | stub_stripe(subscription.stripe_customer_id) 40 | sign_in(user) 41 | 42 | post :create, params: { id: user.id } 43 | 44 | expect(stripe_subscription).to(have_received(:delete)) 45 | end 46 | end 47 | 48 | context "when signed out" do 49 | it "redirects to sign in" do 50 | post :create, params: { id: "not an id" } 51 | 52 | expect(response).to redirect_to(new_user_session_path) 53 | end 54 | end 55 | 56 | def stub_stripe(customer_id) 57 | double("Stripe::Subscription", delete: true).tap do |subscription| 58 | customer = double( 59 | "Stripe::Customer", 60 | id: customer_id, 61 | subscriptions: [subscription] 62 | ) 63 | 64 | allow(Stripe::Customer).to( 65 | receive(:retrieve).with(customer_id).and_return(customer) 66 | ) 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /config/environments/test.rb: -------------------------------------------------------------------------------- 1 | # The test environment is used exclusively to run your application's 2 | # test suite. You never need to work with it otherwise. Remember that 3 | # your test database is "scratch space" for the test suite and is wiped 4 | # and recreated between test runs. Don't rely on the data there! 5 | 6 | Rails.application.configure do 7 | # Settings specified here will take precedence over those in config/application.rb. 8 | 9 | # While tests run files are not watched, reloading is not necessary. 10 | config.enable_reloading = false 11 | 12 | # Eager loading loads your entire application. When running a single test locally, 13 | # this is usually not necessary, and can slow down your test suite. However, it's 14 | # recommended that you enable it in continuous integration systems to ensure eager 15 | # loading is working properly before deploying your code. 16 | config.eager_load = ENV["CI"].present? 17 | 18 | # Configure public file server for tests with cache-control for performance. 19 | config.public_file_server.headers = { "cache-control" => "public, max-age=3600" } 20 | 21 | # Show full error reports. 22 | config.consider_all_requests_local = true 23 | config.cache_store = :null_store 24 | 25 | # Render exception templates for rescuable exceptions and raise for other exceptions. 26 | config.action_dispatch.show_exceptions = :rescuable 27 | 28 | # Disable request forgery protection in test environment. 29 | config.action_controller.allow_forgery_protection = false 30 | 31 | # Store uploaded files on the local file system in a temporary directory. 32 | config.active_storage.service = :test 33 | 34 | # Tell Action Mailer not to deliver emails to the real world. 35 | # The :test delivery method accumulates sent emails in the 36 | # ActionMailer::Base.deliveries array. 37 | config.action_mailer.delivery_method = :test 38 | 39 | # Set host to be used by links generated in mailer templates. 40 | config.action_mailer.default_url_options = { host: "trailmix.life" } 41 | 42 | # Print deprecation notices to the stderr. 43 | config.active_support.deprecation = :stderr 44 | 45 | # Raises error for missing translations. 46 | # config.i18n.raise_on_missing_translations = true 47 | 48 | # Annotate rendered view with file names. 49 | # config.action_view.annotate_rendered_view_with_filenames = true 50 | 51 | # Raise error when a before_action's only/except options reference missing actions. 52 | config.action_controller.raise_on_missing_callback_actions = true 53 | end 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Trailmix 2 | ======== 3 | 4 | What is this? 5 | ------------- 6 | This is source code behind [Trailmix], a private place to write. 7 | 8 | Every day, Trailmix sends you an email asking how your day went. Simply reply 9 | to that email, and a journal entry is created. 10 | 11 | To encourage you to write, and to provide a delightful reminiscence, each 12 | prompt email contains a previous entry chosen at random. 13 | 14 | ![homepage](https://cloud.githubusercontent.com/assets/65323/4512764/26484d88-4b44-11e4-9a79-7ee06d6942e7.png) 15 | 16 | [Trailmix]: https://www.trailmix.life/pages/github 17 | 18 | Caveat emptor 19 | ------------- 20 | 21 | This source code is very much provided as-is. 22 | 23 | We hope you find it useful to see the source code behind a production 24 | application that real people are paying for. 25 | 26 | You are welcome to do what you like with this code, including running your own 27 | instance of it. However, please know that our development time is limited, so 28 | we're unable to spend time helping you make it work. 29 | 30 | Who wrote this? 31 | --------------- 32 | 33 | [Chris] and [Ben] did. 34 | 35 | [Chris]: http://twitter.com/chrishunt 36 | [Ben]: http://twitter.com/r00k 37 | 38 | Contributions 39 | ------------- 40 | 41 | Our goal is to keep the functionality of this app very focused. Please ask 42 | before opening a pull to add features. 43 | 44 | That said, if you find a bug, please do open an issue! 45 | 46 | Getting Started 47 | --------------- 48 | 49 | After you have cloned this repo, run this setup script to set up your machine 50 | with the necessary dependencies to run and test this app: 51 | 52 | % ./bin/setup 53 | 54 | The script also assumes you have a machine equipped with Ruby, Postgres, etc. 55 | If not, set up your machine with [this script]. 56 | 57 | [this script]: https://github.com/thoughtbot/laptop 58 | 59 | After setting up, you can run the application using [foreman]: 60 | 61 | % foreman start 62 | 63 | If you don't have `foreman`, see [Foreman's install instructions][foreman]. It 64 | is [purposefully excluded from the project's `Gemfile`][exclude]. 65 | 66 | [foreman]: https://github.com/ddollar/foreman 67 | [exclude]: https://github.com/ddollar/foreman/pull/437#issuecomment-41110407 68 | 69 | Guidelines 70 | ---------- 71 | 72 | Use the following guides for getting things done, programming well, and 73 | programming in style. 74 | 75 | * [Protocol](http://github.com/thoughtbot/guides/blob/master/protocol) 76 | * [Best Practices](http://github.com/thoughtbot/guides/blob/master/best-practices) 77 | * [Style](http://github.com/thoughtbot/guides/blob/master/style) 78 | -------------------------------------------------------------------------------- /app/views/landing/show.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :header do %> 2 | <%= render 'header' %> 3 | <% end %> 4 | 5 |

    6 | Hey there, 7 |

    8 | 9 |

    10 | Want to know the hardest part about keeping a journal? 11 | Actually keeping one. 12 |

    13 | 14 |

    15 | Like most habits, it's easy for the first few days, but soon a lack of 16 | motivation or simple forgetfulness sets in. 17 |

    18 | 19 |

    That's why we built Trailmix.

    20 | 21 |

    22 | Every day, Trailmix sends you an email asking how your day 23 | went. All you do is hit reply and start typing. You can also 24 | include a photo that will remind you of your day. When you're done, hit send, 25 | and your entry will be saved instantly. 26 |

    27 | 28 |

    29 | With every reminder email, we'll include one of your previous entries 30 | chosen at random. You might get one from yesterday, last week, or 31 | last year. 32 |

    33 | 34 |

    35 | This makes opening our emails fun! It's delightful to be reminded of your 36 | former thoughts and feelings. Even yesterday's entry can surprise you. 37 |

    38 | 39 |

    40 | This enjoyable feeling will encourage you to write more, so that future-you 41 | can enjoy reading today's entry. 42 |

    43 | 44 |

    45 | Before you know it, you're a daily journaler. 46 |

    47 | 48 |

    49 | Once you've built this habit, you get a tremendous payoff: the ability to 50 | look back over your life experiences. You'll notice patterns like "I always 51 | seem stressed on Wednesdays!" Or "Wow, I was so worried about that talk with 52 | my boss, but it turned out completely fine." 53 |

    54 | 55 |

    56 | Insights like these can have a profound effect on your happiness and 57 | behavior. We know, because we've experienced it ourselves. 58 |

    59 | 60 |

    If you'd like to experience this too, you can sign up below.

    61 | 62 |

    63 | <%= render "subscriptions/pricing" %> 64 |

    65 | 66 |

    67 | We charge for Trailmix because it costs money to run servers and fix bugs, 68 | but we're not in this to get rich. We're in it because we believe 69 | daily journalers are happier people and we want you to become one. 70 |

    71 | 72 |

    73 | — <%= link_to "Ben and Chris", page_path(:faq) + "#us" %> 74 |

    75 | 76 |

    77 | 78 | P.S. If you're a former OhLife user, you can import all your old entries 79 | with two clicks. 80 | 81 |

    82 | 83 | <%= render "subscriptions/payment_form" %> 84 | <%= render "subscriptions/sign_up_link_javascript" %> 85 | -------------------------------------------------------------------------------- /config/environments/development.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # Make code changes take effect immediately without server restart. 7 | config.enable_reloading = true 8 | 9 | # Do not eager load code on boot. 10 | config.eager_load = false 11 | 12 | # Show full error reports. 13 | config.consider_all_requests_local = true 14 | 15 | # Enable server timing. 16 | config.server_timing = true 17 | 18 | # Enable/disable Action Controller caching. By default Action Controller caching is disabled. 19 | # Run rails dev:cache to toggle Action Controller caching. 20 | if Rails.root.join("tmp/caching-dev.txt").exist? 21 | config.action_controller.perform_caching = true 22 | config.action_controller.enable_fragment_cache_logging = true 23 | config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" } 24 | else 25 | config.action_controller.perform_caching = false 26 | end 27 | 28 | # Change to :null_store to avoid any caching. 29 | config.cache_store = :memory_store 30 | 31 | # Store uploaded files on the local file system (see config/storage.yml for options). 32 | config.active_storage.service = :local 33 | 34 | # Don't care if the mailer can't send. 35 | config.action_mailer.raise_delivery_errors = false 36 | 37 | # Make template changes take effect immediately. 38 | config.action_mailer.perform_caching = false 39 | 40 | # Set localhost to be used by links generated in mailer templates. 41 | config.action_mailer.default_url_options = { host: "localhost", port: 3000 } 42 | 43 | # Print deprecation notices to the Rails logger. 44 | config.active_support.deprecation = :log 45 | 46 | # Raise an error on page load if there are pending migrations. 47 | config.active_record.migration_error = :page_load 48 | 49 | # Highlight code that triggered database queries in logs. 50 | config.active_record.verbose_query_logs = true 51 | 52 | # Append comments with runtime information tags to SQL queries in logs. 53 | config.active_record.query_log_tags_enabled = true 54 | 55 | # Highlight code that enqueued background job in logs. 56 | config.active_job.verbose_enqueue_logs = true 57 | 58 | # Raises error for missing translations. 59 | config.i18n.raise_on_missing_translations = true 60 | 61 | # Annotate rendered view with file names. 62 | config.action_view.annotate_rendered_view_with_filenames = true 63 | 64 | # Uncomment if you wish to allow Action Cable access from any origin. 65 | # config.action_cable.disable_request_forgery_protection = true 66 | 67 | # Raise error when a before_action's only/except options reference missing actions. 68 | config.action_controller.raise_on_missing_callback_actions = true 69 | end 70 | -------------------------------------------------------------------------------- /spec/controllers/subscriptions_controller_spec.rb: -------------------------------------------------------------------------------- 1 | describe SubscriptionsController, sidekiq: :inline do 2 | describe "#create" do 3 | before do 4 | stub_sign_in 5 | stub_stripe_customer_create 6 | end 7 | 8 | context "with valid params" do 9 | it "creates a user, subscription, stripe customer, and email" do 10 | post :create, params: default_params 11 | 12 | expect(User.count).to eq(1) 13 | expect(Subscription.count).to eq(1) 14 | expect(Stripe::Customer).to( 15 | have_received(:create).with( 16 | email: default_params[:email], 17 | card: default_params[:stripe_card_id], 18 | plan: Rails.configuration.stripe[:plan_name])) 19 | expect(ActionMailer::Base.deliveries).not_to be_empty 20 | end 21 | 22 | it "saves the Stripe customer id on the subscription" do 23 | stripe_customer_id = "cus_123" 24 | stub_stripe_customer_create(stripe_customer_id) 25 | 26 | post :create, params: default_params 27 | subscription = Subscription.last 28 | 29 | expect(subscription.stripe_customer_id).to eq(stripe_customer_id) 30 | end 31 | end 32 | 33 | context "when the user is invalid" do 34 | it "does not create a new user, subscription, or stripe customer" do 35 | stub_invalid_user 36 | 37 | post :create, params: default_params 38 | 39 | expect(User.count).to eq(0) 40 | expect(Subscription.count).to eq(0) 41 | expect(ActionMailer::Base.deliveries).to be_empty 42 | end 43 | end 44 | 45 | context "when the credit card fails to charge" do 46 | it "does not create a user, subscription, or stripe customer" do 47 | stub_stripe_charge_failure 48 | 49 | post :create, params: default_params 50 | 51 | expect(User.count).to eq(0) 52 | expect(Subscription.count).to eq(0) 53 | expect(ActionMailer::Base.deliveries).to be_empty 54 | end 55 | end 56 | 57 | def default_params 58 | { 59 | email: 'foo@bar.com', 60 | password: 'password', 61 | stripe_card_id: 'abc123' 62 | } 63 | end 64 | 65 | def stub_stripe_charge_failure 66 | error = Stripe::CardError.new(double, double, double) 67 | allow(error).to(receive(:message).and_return("Failed to charge card")) 68 | 69 | allow(Stripe::Customer).to(receive(:create).and_raise(error)) 70 | end 71 | 72 | def stub_stripe_customer_create(stripe_customer_id = "cus_123") 73 | allow(Stripe::Customer).to( 74 | receive(:create).and_return( 75 | double("Stripe::Customer", id: stripe_customer_id))) 76 | end 77 | 78 | def stub_sign_in 79 | allow(controller).to receive(:sign_in) 80 | end 81 | 82 | def stub_invalid_user 83 | user = double("user", 84 | valid?: false, 85 | errors: double('errors').as_null_object) 86 | allow(User).to(receive(:new).and_return(user)) 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /spec/models/user_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe User, :type => :model do 2 | describe ".promptable" do 3 | it "returns promptable users for the current hour" do 4 | Timecop.freeze(Time.utc(2014, 1, 1, 11)) do # 11AM UTC 5 | create(:user, time_zone: "UTC", prompt_delivery_hour: 10) 6 | create(:user, time_zone: "UTC", prompt_delivery_hour: 12) 7 | utc_11am = create(:user, time_zone: "UTC", prompt_delivery_hour: 11) 8 | 9 | expect(User.promptable).to eq [utc_11am] 10 | end 11 | end 12 | end 13 | 14 | describe "#reply_token" do 15 | it "is generated automatically before saving" do 16 | user = build(:user, reply_token: nil) 17 | 18 | user.save 19 | 20 | expect(user.reply_token).to_not be_nil 21 | end 22 | end 23 | 24 | describe "#reply_email" do 25 | it "starts with the reply token" do 26 | user = create(:user) 27 | 28 | expect(user.reply_email).to start_with(user.reply_token) 29 | end 30 | 31 | it "ends with the email domain" do 32 | user = create(:user) 33 | email_domain = ENV.fetch("SMTP_DOMAIN") 34 | 35 | expect(user.reply_email).to end_with("@#{email_domain}") 36 | end 37 | end 38 | 39 | describe "#newest_entry" do 40 | it "returns the newest entry by date" do 41 | user = create(:user) 42 | newest_entry = create(:entry, user: user, date: 1.day.ago) 43 | oldest_entry = create(:entry, user: user, date: 2.days.ago) 44 | 45 | expect(user.newest_entry).to eq(newest_entry) 46 | end 47 | end 48 | 49 | describe "#prompt_entry" do 50 | it "delegates to PromptEntry" do 51 | user = create(:user) 52 | allow(PromptEntry).to( 53 | receive(:best).with(user.entries). 54 | and_return("best entry")) 55 | 56 | entry = user.prompt_entry 57 | 58 | expect(entry).to eq("best entry") 59 | end 60 | end 61 | 62 | describe "#prompt_delivery_hour" do 63 | it "returns the prompt delivery hour in the user's time zone" do 64 | Timecop.freeze(Time.utc(2014, 10, 1)) do 65 | user = create(:user, time_zone: "Melbourne") # Melbourne is UTC+10 66 | user.update_column :prompt_delivery_hour, 5 67 | 68 | prompt_delivery_hour = user.prompt_delivery_hour 69 | 70 | expect(prompt_delivery_hour).to eq(15) 71 | end 72 | end 73 | end 74 | 75 | describe "#prompt_delivery_hour=" do 76 | it "writes the prompt delivery hour in utc" do 77 | Timecop.freeze(Time.utc(2014, 10, 1)) do 78 | user = create(:user, time_zone: "Melbourne") # Melbourne is UTC+10 79 | 80 | user.prompt_delivery_hour = 5 81 | prompt_delivery_hour = user.read_attribute(:prompt_delivery_hour) 82 | 83 | expect(prompt_delivery_hour).to eq(19) 84 | end 85 | end 86 | end 87 | 88 | describe "#stripe_customer_id" do 89 | it "delegates to the subscription" do 90 | subscription = create(:subscription) 91 | user = subscription.user 92 | 93 | expect(user.stripe_customer_id).to eq(subscription.stripe_customer_id) 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /app/views/pages/keeping-a-development-journal.html.erb: -------------------------------------------------------------------------------- 1 |

    2 |

    4 Reasons To Keep A Development Journal

    3 |

    4 | 5 |

    6 | tl;dr: Taking 60 seconds to jot some notes about your day can yield impressive 7 | professional benefits. <%= link_to 'Trailmix', new_registration_path %> makes 8 | it particularly easy to build this habit. 9 |

    10 | 11 |

    12 | You'll never forget how you fixed that particular error 13 |

    14 | 15 |

    16 | Have you ever gotten an error and said "I know I've seen this 17 | before, but how did I solve it?". With a searchable journal, that annoying 18 | experience goes away. 19 |

    20 | 21 |

    22 | You'll learn which design techniques continued to work well as the code 23 | changed 24 |

    25 | 26 |

    27 | The true test of a design isn't when you first write it, it's when you need 28 | to change it later. 29 |

    30 | 31 |

    32 | If you make a note when your design choices work out particularly well or 33 | poorly, you'll soon build a catalog of techniques to embrace or avoid. After 34 | your third "Observer pattern on app_foo continues to confuse new developers 35 | coming on to the project", you'll learn to shy away in the future. 36 |

    37 | 38 |

    39 | A list of your accomplishments is very useful at review time 40 |

    41 | 42 |

    43 | Emailing your boss a week before your review reminding him or her of your 44 | major accomplishments in the past N months is a very smart move. 45 |

    46 | 47 |

    48 | You'll know when you need a new job 49 |

    50 | 51 |

    52 | As humans, we tend to have some good days and some bad ones. Because of this, 53 | it can be hard to detect trends in your mood. Consistent writing can help you 54 | detect that the bad days are consistently outnumbering the good ones. Maybe 55 | it's time to start looking. 56 |

    57 | 58 |

    How To Successfully Build The Habit

    59 | 60 |

    61 | Starting a development journal isn't hard. Maintaining one 62 | is. 63 |

    64 | 65 |

    66 | The key to building a habit isn't initial excitement, it's consistency. The 67 | biggest enemy of consistency is forgetfulness. 68 |

    69 | 70 |

    71 | If you'd like to try building this habit, you'll want to set a daily reminder 72 | for at least the first few weeks. When the alarm goes off, fire up your 73 | editor and record an entry. 74 |

    75 | 76 |

    77 | Another option is an app we wrote called 78 | <%= link_to "Trailmix", new_registration_path %>. 79 |

    80 | 81 |

    82 | Trailmix keeps you consistent by sending you an email each day asking what 83 | you were working on. You reply right in your email client and your entries 84 | are parsed into a searchable daily journal. 85 |

    86 | 87 |

    88 | What's more, Trailmix includes an old entry chosen at random in your daily 89 | reminder. This keeps your previous entries fresh in your mind. You might 90 | never forget how you fixed an error again. 91 |

    92 | 93 |

    Additional Resources

    94 | 95 | 105 | -------------------------------------------------------------------------------- /spec/models/admin_dashboard_spec.rb: -------------------------------------------------------------------------------- 1 | describe AdminDashboard do 2 | describe ".users_by_day_since" do 3 | it "returns the count of users created, grouped by day" do 4 | Timecop.freeze(2014, 01, 30) do 5 | cutoff = 15.days.ago 6 | outside_range = create(:user, created_at: (cutoff - 1.day)) 7 | within_range = create(:user, created_at: Date.yesterday) 8 | 9 | result = AdminDashboard.new.users_by_day_since(cutoff) 10 | 11 | expected = { Date.parse("2014-01-29") => 1 } 12 | expect(result).to eq(expected) 13 | end 14 | end 15 | end 16 | 17 | describe ".entries_by_day_since" do 18 | it "returns the count of entries created, grouped by day" do 19 | Timecop.freeze(2014, 01, 30) do 20 | cutoff = 15.days.ago 21 | outside_range = create(:entry, date: (cutoff - 1.day)) 22 | within_range = create(:entry, date: Date.yesterday) 23 | 24 | result = AdminDashboard.new.entries_by_day_since(cutoff) 25 | 26 | expected = { Date.parse("2014-01-29") => 1 } 27 | expect(result).to eq(expected) 28 | end 29 | end 30 | end 31 | 32 | describe ".users_created_since" do 33 | it "returns users created after a given date" do 34 | Timecop.freeze(2014, 01, 30) do 35 | cutoff = 15.days.ago 36 | outside_range = create(:user, created_at: (cutoff - 1.day)) 37 | within_range = create(:user, created_at: Date.yesterday) 38 | 39 | result = AdminDashboard.new.users_created_since(cutoff) 40 | 41 | expect(result).to eq([within_range]) 42 | end 43 | end 44 | end 45 | 46 | describe "#trial_status_for" do 47 | context "when the user writes every day" do 48 | it "returns 'great'" do 49 | daily_writer = build_stubbed(:user, created_at: 3.days.ago) 50 | 3.times { create(:entry, user: daily_writer) } 51 | 52 | result = AdminDashboard.new.trial_status_for(daily_writer) 53 | 54 | expect(result).to eq "great" 55 | end 56 | end 57 | 58 | context "when the user writes half the time" do 59 | it "returns 'warning'" do 60 | frequent_writer = build_stubbed(:user, created_at: 2.days.ago) 61 | create(:entry, user: frequent_writer) 62 | 63 | result = AdminDashboard.new.trial_status_for(frequent_writer) 64 | 65 | expect(result).to eq "warning" 66 | end 67 | end 68 | 69 | context "when the user writes a third of the time" do 70 | it "returns 'danger'" do 71 | occasional_writer = build_stubbed(:user, created_at: 3.days.ago) 72 | create(:entry, user: occasional_writer) 73 | 74 | result = AdminDashboard.new.trial_status_for(occasional_writer) 75 | 76 | expect(result).to eq "danger" 77 | end 78 | end 79 | end 80 | 81 | describe "#entries_per_day_for" do 82 | context "when the user writes every day" do 83 | it "returns 1" do 84 | daily_writer = build_stubbed(:user, created_at: 3.days.ago) 85 | 3.times { create(:entry, user: daily_writer) } 86 | 87 | result = AdminDashboard.new.entries_per_day_for(daily_writer) 88 | 89 | expect(result).to eq 1 90 | end 91 | end 92 | 93 | context "when the user writes a third of the time" do 94 | it "returns 0.3" do 95 | occasional_writer = build_stubbed(:user, created_at: 3.days.ago) 96 | create(:entry, user: occasional_writer) 97 | 98 | result = AdminDashboard.new.entries_per_day_for(occasional_writer) 99 | 100 | expect(result).to eq 0.3 101 | end 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /config/environments/production.rb: -------------------------------------------------------------------------------- 1 | require Rails.root.join("config/smtp") 2 | require "active_support/core_ext/integer/time" 3 | 4 | Rails.application.configure do 5 | # Settings specified here will take precedence over those in config/application.rb. 6 | 7 | # Code is not reloaded between requests. 8 | config.enable_reloading = false 9 | 10 | # Eager load code on boot for better performance and memory savings (ignored by Rake tasks). 11 | config.eager_load = true 12 | 13 | # Full error reports are disabled. 14 | config.consider_all_requests_local = false 15 | 16 | # Turn on fragment caching in view templates. 17 | config.action_controller.perform_caching = true 18 | 19 | # Cache assets for far-future expiry since they are all digest stamped. 20 | config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" } 21 | 22 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 23 | # config.asset_host = "http://assets.example.com" 24 | 25 | # https://devcenter.heroku.com/articles/rails-4-asset-pipeline#caching 26 | config.assets.compile = false 27 | 28 | # Store uploaded files on the local file system (see config/storage.yml for options). 29 | config.active_storage.service = :local 30 | 31 | # Assume all access to the app is happening through a SSL-terminating reverse proxy. 32 | config.assume_ssl = true 33 | 34 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 35 | config.force_ssl = true 36 | 37 | # Skip http-to-https redirect for the default health check endpoint. 38 | # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } 39 | 40 | # Log to STDOUT with the current request id as a default log tag. 41 | config.log_tags = [ :request_id ] 42 | config.logger = ActiveSupport::TaggedLogging.logger(STDOUT) 43 | 44 | # Change to "debug" to log everything (including potentially personally-identifiable information!) 45 | config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") 46 | 47 | # Prevent health checks from clogging up the logs. 48 | config.silence_healthcheck_path = "/up" 49 | 50 | # Don't log any deprecations. 51 | config.active_support.report_deprecations = false 52 | 53 | # Replace the default in-process memory cache store with a durable alternative. 54 | # config.cache_store = :mem_cache_store 55 | 56 | # Replace the default in-process and non-durable queuing backend for Active Job. 57 | # config.active_job.queue_adapter = :resque 58 | 59 | # Ignore bad email addresses and do not raise email delivery errors. 60 | # Set this to true and configure the email server for immediate delivery to raise delivery errors. 61 | # config.action_mailer.raise_delivery_errors = false 62 | config.action_mailer.delivery_method = :smtp 63 | config.action_mailer.smtp_settings = SMTP_SETTINGS 64 | 65 | 66 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 67 | # the I18n.default_locale when a translation cannot be found). 68 | config.i18n.fallbacks = true 69 | 70 | # Do not dump schema after migrations. 71 | config.active_record.dump_schema_after_migration = false 72 | 73 | config.action_mailer.default_url_options = { 74 | protocol: "https", 75 | host: "www.trailmix.life" 76 | } 77 | 78 | # Only use :id for inspections in production. 79 | config.active_record.attributes_for_inspect = [ :id ] 80 | 81 | # Enable DNS rebinding protection and other `Host` header attacks. 82 | # config.hosts = [ 83 | # "example.com", # Allow requests from example.com 84 | # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` 85 | # ] 86 | # 87 | # Skip DNS rebinding protection for the default health check endpoint. 88 | # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } 89 | end 90 | -------------------------------------------------------------------------------- /db/schema.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | # This file is auto-generated from the current state of the database. Instead 3 | # of editing this file, please use the migrations feature of Active Record to 4 | # incrementally modify your database, and then regenerate this schema definition. 5 | # 6 | # Note that this schema.rb definition is the authoritative source for your 7 | # database schema. If you need to create the application database on another 8 | # system, you should be using db:schema:load, not running all the migrations 9 | # from scratch. The latter is a flawed and unsustainable approach (the more migrations 10 | # you'll amass, the slower it'll run and the greater likelihood for issues). 11 | # 12 | # It's strongly recommended that you check this file into your version control system. 13 | 14 | ActiveRecord::Schema[6.1].define(version: 20160125013209) do 15 | 16 | # These are extensions that must be enabled in order to support this database 17 | enable_extension "plpgsql" 18 | 19 | create_table "cancellations", force: :cascade do |t| 20 | t.string "email", null: false 21 | t.string "stripe_customer_id", null: false 22 | t.text "reason" 23 | t.datetime "created_at", null: false 24 | t.datetime "updated_at", null: false 25 | end 26 | 27 | create_table "entries", force: :cascade do |t| 28 | t.integer "user_id", null: false 29 | t.text "body", null: false 30 | t.datetime "created_at", null: false 31 | t.datetime "updated_at", null: false 32 | t.integer "import_id" 33 | t.date "date", null: false 34 | t.string "photo" 35 | end 36 | 37 | create_table "imports", force: :cascade do |t| 38 | t.integer "user_id", null: false 39 | t.datetime "created_at", null: false 40 | t.datetime "updated_at", null: false 41 | t.string "ohlife_export", null: false 42 | end 43 | 44 | create_table "subscriptions", force: :cascade do |t| 45 | t.integer "user_id", null: false 46 | t.string "stripe_customer_id", null: false 47 | t.datetime "created_at", null: false 48 | t.datetime "updated_at", null: false 49 | end 50 | 51 | add_index "subscriptions", ["stripe_customer_id"], name: "index_subscriptions_on_stripe_customer_id", unique: true, using: :btree 52 | add_index "subscriptions", ["user_id"], name: "index_subscriptions_on_user_id", unique: true, using: :btree 53 | 54 | create_table "users", force: :cascade do |t| 55 | t.string "email", default: "", null: false 56 | t.string "encrypted_password", default: "", null: false 57 | t.string "reset_password_token" 58 | t.datetime "reset_password_sent_at" 59 | t.datetime "remember_created_at" 60 | t.integer "sign_in_count", default: 0, null: false 61 | t.datetime "current_sign_in_at" 62 | t.datetime "last_sign_in_at" 63 | t.inet "current_sign_in_ip" 64 | t.inet "last_sign_in_ip" 65 | t.datetime "created_at", null: false 66 | t.datetime "updated_at", null: false 67 | t.string "time_zone", default: "Central Time (US & Canada)", null: false 68 | t.integer "prompt_delivery_hour", default: 2, null: false 69 | t.string "reply_token", null: false 70 | end 71 | 72 | add_index "users", ["email"], name: "index_users_on_email", unique: true, using: :btree 73 | add_index "users", ["reply_token"], name: "index_users_on_reply_token", unique: true, using: :btree 74 | add_index "users", ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true, using: :btree 75 | 76 | end 77 | -------------------------------------------------------------------------------- /spec/models/email_processor_spec.rb: -------------------------------------------------------------------------------- 1 | describe EmailProcessor do 2 | describe "#process" do 3 | it "creates an entry based on the email token" do 4 | user = create(:user) 5 | email = create( 6 | :griddler_email, 7 | to: [{ token: user.reply_token }], 8 | body: "I am great" 9 | ) 10 | 11 | EmailProcessor.new(email).process 12 | 13 | expect(user.newest_entry.body).to eq("I am great") 14 | end 15 | 16 | it "creates an entry even if the email token is uppercase" do 17 | user = create(:user) 18 | email = create( 19 | :griddler_email, 20 | to: [{ token: user.reply_token.upcase }], 21 | body: "I am great" 22 | ) 23 | 24 | EmailProcessor.new(email).process 25 | 26 | expect(user.newest_entry.body).to eq("I am great") 27 | end 28 | 29 | it "parses the email body with an email reply parser" do 30 | user = create(:user) 31 | email = create(:griddler_email, to: [{ token: user.reply_token }]) 32 | 33 | expect(EmailReplyParser).to( 34 | receive(:parse_reply).with(email.body).and_return("")) 35 | 36 | EmailProcessor.new(email).process 37 | end 38 | 39 | context "when a user can't be found" do 40 | it "raises an exception" do 41 | user = create(:user) 42 | email = create(:griddler_email, to: [{ token: "not-a-token" }]) 43 | 44 | expect do 45 | EmailProcessor.new(email).process 46 | end.to raise_error(ActiveRecord::RecordNotFound) 47 | end 48 | end 49 | 50 | context "when the entry can't be created" do 51 | it "raises an exception" do 52 | user = create(:user) 53 | email = create(:griddler_email) 54 | 55 | allow(user.entries).to receive(:create!).and_raise 56 | 57 | expect do 58 | EmailProcessor.new(email).process 59 | end.to raise_error(ActiveRecord::ActiveRecordError) 60 | end 61 | end 62 | 63 | it "sets the entry date to today's date in the user's time zone" do 64 | Timecop.freeze(2014, 1, 1, 20) do # 8 PM UTC 65 | user = create(:user, time_zone: "Guam") # UTC+10 66 | email = create(:griddler_email, to: [{ token: user.reply_token }]) 67 | 68 | EmailProcessor.new(email).process 69 | 70 | expect(user.newest_entry.date).to eq(Date.new(2014, 1, 2)) 71 | end 72 | end 73 | 74 | context "when the entry is a response to a past day's email" do 75 | it "sets the entry date to the email's date" do 76 | past_day = Date.new(last_year, 1, 2) 77 | Timecop.freeze(last_year, 5, 10) do 78 | user = create(:user) 79 | email = create( 80 | :griddler_email, 81 | to: [{ token: user.reply_token }], 82 | subject: "Re: #{PromptMailer::Subject.new(user, past_day)}" 83 | ) 84 | 85 | EmailProcessor.new(email).process 86 | 87 | expect(user.newest_entry.date).to eq(past_day) 88 | end 89 | end 90 | end 91 | 92 | context "when the entry is a response to an email from last year" do 93 | it "sets the entry date to last year" do 94 | end_of_last_year = Date.new(last_year, 12, 31) 95 | Timecop.freeze(current_year, 1, 1) do 96 | user = create(:user) 97 | email = create( 98 | :griddler_email, 99 | to: [{ token: user.reply_token }], 100 | subject: "Re: #{PromptMailer::Subject.new(user, end_of_last_year)}" 101 | ) 102 | 103 | EmailProcessor.new(email).process 104 | 105 | expect(user.newest_entry.date).to eq(end_of_last_year) 106 | end 107 | end 108 | end 109 | end 110 | 111 | def current_year 112 | Date.today.year 113 | end 114 | 115 | def last_year 116 | current_year - 1 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /config/initializers/airbrake.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Airbrake is an online tool that provides robust exception tracking in your 4 | # Rails applications. In doing so, it allows you to easily review errors, tie an 5 | # error to an individual piece of code, and trace the cause back to recent 6 | # changes. Airbrake enables for easy categorization, searching, and 7 | # prioritization of exceptions so that when errors occur, your team can quickly 8 | # determine the root cause. 9 | # 10 | # Configuration details: 11 | # https://github.com/airbrake/airbrake-ruby#configuration 12 | if (project_id = ENV['AIRBRAKE_PROJECT_ID']) && 13 | project_key = (ENV['AIRBRAKE_PROJECT_KEY'] || ENV['AIRBRAKE_API_KEY']) 14 | Airbrake.configure do |c| 15 | # You must set both project_id & project_key. To find your project_id and 16 | # project_key navigate to your project's General Settings and copy the 17 | # values from the right sidebar. 18 | # https://github.com/airbrake/airbrake-ruby#project_id--project_key 19 | c.project_id = project_id 20 | c.project_key = project_key 21 | 22 | # Configures the root directory of your project. Expects a String or a 23 | # Pathname, which represents the path to your project. Providing this option 24 | # helps us to filter out repetitive data from backtrace frames and link to 25 | # GitHub files from our dashboard. 26 | # https://github.com/airbrake/airbrake-ruby#root_directory 27 | c.root_directory = Rails.root 28 | 29 | # By default, Airbrake Ruby outputs to STDOUT. In Rails apps it makes sense 30 | # to use the Rails' logger. 31 | # https://github.com/airbrake/airbrake-ruby#logger 32 | c.logger = Airbrake::Rails.logger 33 | 34 | # Configures the environment the application is running in. Helps the 35 | # Airbrake dashboard to distinguish between exceptions occurring in 36 | # different environments. 37 | # NOTE: This option must be set in order to make the 'ignore_environments' 38 | # option work. 39 | # https://github.com/airbrake/airbrake-ruby#environment 40 | c.environment = Rails.env 41 | 42 | # Setting this option allows Airbrake to filter exceptions occurring in 43 | # unwanted environments such as :test. NOTE: This option *does not* work if 44 | # you don't set the 'environment' option. 45 | # https://github.com/airbrake/airbrake-ruby#ignore_environments 46 | c.ignore_environments = %w[test] 47 | 48 | # A list of parameters that should be filtered out of what is sent to 49 | # Airbrake. By default, all "password" attributes will have their contents 50 | # replaced. 51 | # https://github.com/airbrake/airbrake-ruby#blocklist_keys 52 | c.blocklist_keys = [/password/i, /authorization/i] 53 | 54 | # Alternatively, you can integrate with Rails' filter_parameters. 55 | # Read more: https://goo.gl/gqQ1xS 56 | # c.blocklist_keys = Rails.application.config.filter_parameters 57 | end 58 | 59 | # A filter that collects request body information. Enable it if you are sure you 60 | # don't send sensitive information to Airbrake in your body (such as passwords). 61 | # https://github.com/airbrake/airbrake#requestbodyfilter 62 | # Airbrake.add_filter(Airbrake::Rack::RequestBodyFilter.new) 63 | 64 | # Attaches thread & fiber local variables along with general thread information. 65 | # Airbrake.add_filter(Airbrake::Filters::ThreadFilter.new) 66 | 67 | # Attaches loaded dependencies to the notice object 68 | # (under context/versions/dependencies). 69 | # Airbrake.add_filter(Airbrake::Filters::DependencyFilter.new) 70 | 71 | # If you want to convert your log messages to Airbrake errors, we offer an 72 | # integration with the Logger class from stdlib. 73 | # https://github.com/airbrake/airbrake#logger 74 | # Rails.logger = Airbrake::AirbrakeLogger.new(Rails.logger) 75 | else 76 | Rails.logger.warn( 77 | "#{__FILE__}: Airbrake project id or project key is not set. " \ 78 | "Skipping Airbrake configuration" 79 | ) 80 | end 81 | -------------------------------------------------------------------------------- /config/locales/devise.en.yml: -------------------------------------------------------------------------------- 1 | # Additional translations at https://github.com/plataformatec/devise/wiki/I18n 2 | 3 | en: 4 | devise: 5 | confirmations: 6 | confirmed: "Your email address has been successfully confirmed." 7 | send_instructions: "You will receive an email with instructions for how to confirm your email address in a few minutes." 8 | send_paranoid_instructions: "If your email address exists in our database, you will receive an email with instructions for how to confirm your email address in a few minutes." 9 | failure: 10 | already_authenticated: "You are already signed in." 11 | inactive: "Your account is not activated yet." 12 | invalid: "Invalid email or password." 13 | locked: "Your account is locked." 14 | last_attempt: "You have one more attempt before your account is locked." 15 | not_found_in_database: "Invalid email address or password." 16 | timeout: "Your session expired. Please sign in again to continue." 17 | unauthenticated: "You need to sign in or sign up before continuing." 18 | unconfirmed: "You have to confirm your email address before continuing." 19 | mailer: 20 | confirmation_instructions: 21 | subject: "Confirmation instructions" 22 | reset_password_instructions: 23 | subject: "Reset password instructions" 24 | unlock_instructions: 25 | subject: "Unlock instructions" 26 | omniauth_callbacks: 27 | failure: "Could not authenticate you from %{kind} because \"%{reason}\"." 28 | success: "Successfully authenticated from %{kind} account." 29 | passwords: 30 | no_token: "You can't access this page without coming from a password reset email. If you do come from a password reset email, please make sure you used the full URL provided." 31 | send_instructions: "You will receive an email with instructions on how to reset your password in a few minutes." 32 | send_paranoid_instructions: "If your email address exists in our database, you will receive a password recovery link at your email address in a few minutes." 33 | updated: "Your password has been changed successfully. You are now signed in." 34 | updated_not_active: "Your password has been changed successfully." 35 | registrations: 36 | destroyed: "Bye! Your account has been successfully cancelled. We hope to see you again soon." 37 | signed_up: "Welcome! You have signed up successfully." 38 | signed_up_but_inactive: "You have signed up successfully. However, we could not sign you in because your account is not yet activated." 39 | signed_up_but_locked: "You have signed up successfully. However, we could not sign you in because your account is locked." 40 | signed_up_but_unconfirmed: "A message with a confirmation link has been sent to your email address. Please follow the link to activate your account." 41 | update_needs_confirmation: "You updated your account successfully, but we need to verify your new email address. Please check your email and follow the confirm link to confirm your new email address." 42 | updated: "Your account has been updated successfully." 43 | sessions: 44 | signed_in: "" 45 | signed_out: "" 46 | already_signed_out: "Signed out successfully." 47 | unlocks: 48 | send_instructions: "You will receive an email with instructions for how to unlock your account in a few minutes." 49 | send_paranoid_instructions: "If your account exists, you will receive an email with instructions for how to unlock it in a few minutes." 50 | unlocked: "Your account has been unlocked successfully. Please sign in to continue." 51 | errors: 52 | messages: 53 | already_confirmed: "was already confirmed, please try signing in" 54 | confirmation_period_expired: "needs to be confirmed within %{period}, please request a new one" 55 | expired: "has expired, please request a new one" 56 | not_found: "not found" 57 | not_locked: "was not locked" 58 | not_saved: 59 | one: "1 error prohibited this %{resource} from being saved:" 60 | other: "%{count} errors prohibited this %{resource} from being saved:" 61 | -------------------------------------------------------------------------------- /app/assets/javascripts/placeholders.min.js: -------------------------------------------------------------------------------- 1 | /* Placeholders.js v3.0.2 */ 2 | (function(t){"use strict";function e(t,e,r){return t.addEventListener?t.addEventListener(e,r,!1):t.attachEvent?t.attachEvent("on"+e,r):void 0}function r(t,e){var r,n;for(r=0,n=t.length;n>r;r++)if(t[r]===e)return!0;return!1}function n(t,e){var r;t.createTextRange?(r=t.createTextRange(),r.move("character",e),r.select()):t.selectionStart&&(t.focus(),t.setSelectionRange(e,e))}function a(t,e){try{return t.type=e,!0}catch(r){return!1}}t.Placeholders={Utils:{addEventListener:e,inArray:r,moveCaret:n,changeType:a}}})(this),function(t){"use strict";function e(){}function r(){try{return document.activeElement}catch(t){}}function n(t,e){var r,n,a=!!e&&t.value!==e,u=t.value===t.getAttribute(V);return(a||u)&&"true"===t.getAttribute(D)?(t.removeAttribute(D),t.value=t.value.replace(t.getAttribute(V),""),t.className=t.className.replace(R,""),n=t.getAttribute(F),parseInt(n,10)>=0&&(t.setAttribute("maxLength",n),t.removeAttribute(F)),r=t.getAttribute(P),r&&(t.type=r),!0):!1}function a(t){var e,r,n=t.getAttribute(V);return""===t.value&&n?(t.setAttribute(D,"true"),t.value=n,t.className+=" "+I,r=t.getAttribute(F),r||(t.setAttribute(F,t.maxLength),t.removeAttribute("maxLength")),e=t.getAttribute(P),e?t.type="text":"password"===t.type&&M.changeType(t,"text")&&t.setAttribute(P,"password"),!0):!1}function u(t,e){var r,n,a,u,i,l,o;if(t&&t.getAttribute(V))e(t);else for(a=t?t.getElementsByTagName("input"):b,u=t?t.getElementsByTagName("textarea"):f,r=a?a.length:0,n=u?u.length:0,o=0,l=r+n;l>o;o++)i=r>o?a[o]:u[o-r],e(i)}function i(t){u(t,n)}function l(t){u(t,a)}function o(t){return function(){m&&t.value===t.getAttribute(V)&&"true"===t.getAttribute(D)?M.moveCaret(t,0):n(t)}}function c(t){return function(){a(t)}}function s(t){return function(e){return A=t.value,"true"===t.getAttribute(D)&&A===t.getAttribute(V)&&M.inArray(C,e.keyCode)?(e.preventDefault&&e.preventDefault(),!1):void 0}}function d(t){return function(){n(t,A),""===t.value&&(t.blur(),M.moveCaret(t,0))}}function g(t){return function(){t===r()&&t.value===t.getAttribute(V)&&"true"===t.getAttribute(D)&&M.moveCaret(t,0)}}function v(t){return function(){i(t)}}function p(t){t.form&&(T=t.form,"string"==typeof T&&(T=document.getElementById(T)),T.getAttribute(U)||(M.addEventListener(T,"submit",v(T)),T.setAttribute(U,"true"))),M.addEventListener(t,"focus",o(t)),M.addEventListener(t,"blur",c(t)),m&&(M.addEventListener(t,"keydown",s(t)),M.addEventListener(t,"keyup",d(t)),M.addEventListener(t,"click",g(t))),t.setAttribute(j,"true"),t.setAttribute(V,x),(m||t!==r())&&a(t)}var b,f,m,h,A,y,E,x,L,T,N,S,w,B=["text","search","url","tel","email","password","number","textarea"],C=[27,33,34,35,36,37,38,39,40,8,46],k="#ccc",I="placeholdersjs",R=RegExp("(?:^|\\s)"+I+"(?!\\S)"),V="data-placeholder-value",D="data-placeholder-active",P="data-placeholder-type",U="data-placeholder-submit",j="data-placeholder-bound",q="data-placeholder-focus",z="data-placeholder-live",F="data-placeholder-maxlength",G=document.createElement("input"),H=document.getElementsByTagName("head")[0],J=document.documentElement,K=t.Placeholders,M=K.Utils;if(K.nativeSupport=void 0!==G.placeholder,!K.nativeSupport){for(b=document.getElementsByTagName("input"),f=document.getElementsByTagName("textarea"),m="false"===J.getAttribute(q),h="false"!==J.getAttribute(z),y=document.createElement("style"),y.type="text/css",E=document.createTextNode("."+I+" { color:"+k+"; }"),y.styleSheet?y.styleSheet.cssText=E.nodeValue:y.appendChild(E),H.insertBefore(y,H.firstChild),w=0,S=b.length+f.length;S>w;w++)N=b.length>w?b[w]:f[w-b.length],x=N.attributes.placeholder,x&&(x=x.nodeValue,x&&M.inArray(B,N.type)&&p(N));L=setInterval(function(){for(w=0,S=b.length+f.length;S>w;w++)N=b.length>w?b[w]:f[w-b.length],x=N.attributes.placeholder,x?(x=x.nodeValue,x&&M.inArray(B,N.type)&&(N.getAttribute(j)||p(N),(x!==N.getAttribute(V)||"password"===N.type&&!N.getAttribute(P))&&("password"===N.type&&!N.getAttribute(P)&&M.changeType(N,"text")&&N.setAttribute(P,"password"),N.value===N.getAttribute(V)&&(N.value=x),N.setAttribute(V,x)))):N.getAttribute(D)&&(n(N),N.removeAttribute(V));h||clearInterval(L)},100)}M.addEventListener(t,"beforeunload",function(){K.disable()}),K.disable=K.nativeSupport?e:i,K.enable=K.nativeSupport?e:l}(this); -------------------------------------------------------------------------------- /spec/mailers/prompt_mailer_spec.rb: -------------------------------------------------------------------------------- 1 | describe PromptMailer do 2 | describe "#prompt" do 3 | it "has the correct headers" do 4 | Timecop.freeze(Time.utc(2014, 1, 1)) do 5 | entry = build_stubbed(:entry) 6 | user = build_stubbed( 7 | :user, 8 | time_zone: "UTC", 9 | reply_token: "abc123" 10 | ) 11 | 12 | mail = PromptMailer.prompt(user, entry) 13 | 14 | expect(mail.to).to eq([user.email]) 15 | expect(mail.from).to eq([user.reply_email]) 16 | expect(mail.subject).to eq("It's Wednesday, Jan 1. How was your day?") 17 | end 18 | end 19 | 20 | context "when the user has set their timezone" do 21 | it "shows today's date in their timezone" do 22 | Timecop.freeze(Time.utc(2014, 1, 1, 20)) do 23 | user = create(:user, time_zone: "Guam") # UTC+10 24 | entry = build_stubbed(:entry) 25 | 26 | mail = PromptMailer.prompt(user, entry) 27 | 28 | expect(mail.subject).to include("Jan 2") 29 | end 30 | end 31 | end 32 | 33 | context "when the user has a previous entry" do 34 | it "includes a past entry" do 35 | user = create(:user) 36 | entry = create(:entry, user: user) 37 | 38 | mail = PromptMailer.prompt(user, entry) 39 | 40 | expect(mail.body.encoded).to include(entry.body) 41 | end 42 | 43 | it "says how long ago the past entry was in the user's time zone" do 44 | Timecop.freeze(Time.utc(2014, 1, 3, 14)) do 45 | user = create(:user, time_zone: "Guam") # UTC+10 46 | entry = create(:entry, user: user, date: Date.new(2014, 1, 1)) 47 | 48 | mail = PromptMailer.prompt(user, entry) 49 | 50 | expect(mail.body.encoded).to include("3 days ago") 51 | end 52 | end 53 | 54 | it "describes when the user created their entry" do 55 | Timecop.freeze(Date.new(2014, 1, 10)) do 56 | user = create(:user) 57 | entry = create(:entry, user: user, date: Date.new(2014, 1, 1)) 58 | 59 | mail = PromptMailer.prompt(user, entry) 60 | 61 | expect(mail.body.encoded).to( 62 | include("On January 1, 2014 (8 days ago)") 63 | ) 64 | end 65 | end 66 | 67 | it "formats the previous entry with html" do 68 | user = create(:user) 69 | entry = create(:entry, user: user, body: "Line 1\n\nLine 2") 70 | 71 | mail = PromptMailer.prompt(user, entry) 72 | 73 | expect(mail.body.encoded).to( 74 | include("

    Line 1

    \r\n\r\n

    Line 2

    ") 75 | ) 76 | end 77 | 78 | context "and it has a photo" do 79 | it "includes it" do 80 | user = create(:user) 81 | entry = create(:entry, :with_photo, user: user) 82 | 83 | mail = PromptMailer.prompt(user, entry) 84 | 85 | expect(mail.body.encoded).to( 86 | include(entry.photo.url) 87 | ) 88 | end 89 | end 90 | end 91 | end 92 | 93 | context "when the user has no previous entries" do 94 | it "does not try to display the entry" do 95 | user = create(:user, entries: []) 96 | 97 | mail = PromptMailer.prompt(user, nil) 98 | 99 | expect(mail.body.encoded).to_not include("Remember this?") 100 | end 101 | end 102 | 103 | context "when there is an announcement" do 104 | it "shows the announcement in the prompt" do 105 | user = create(:user) 106 | announcement = "

    check out this new feature

    " 107 | 108 | with_announcement(announcement) do 109 | mail = PromptMailer.prompt(user, nil) 110 | 111 | expect(mail.body.encoded).to include(announcement) 112 | end 113 | end 114 | end 115 | 116 | context "when a date is provided" do 117 | it "sends a prompt for the provided date" do 118 | user = create(:user) 119 | date = Date.parse("2017-10-31") 120 | 121 | mail = PromptMailer.prompt(user, nil, date) 122 | 123 | expect(mail.subject).to eq("It's Tuesday, Oct 31. How was your day?") 124 | end 125 | end 126 | 127 | def with_announcement(announcement) 128 | ENV["ANNOUNCEMENT"] = announcement 129 | yield 130 | ENV["ANNOUNCEMENT"] = nil 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | The page you were looking for doesn’t exist (404 Not found) 8 | 9 | 10 | 11 | 12 | 13 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 |
    104 |
    105 | 106 |
    107 |
    108 |

    The page you were looking for doesn’t exist. You may have mistyped the address or the page may have moved. If you’re the application owner check the logs for more information.

    109 |
    110 |
    111 | 112 | 113 | 114 | 115 | --------------------------------------------------------------------------------