├── .browserslistrc ├── .dockerignore ├── .env.example ├── .gitattributes ├── .github └── workflows │ ├── docker.yml │ ├── linting.yml │ ├── security.yml │ └── tests.yml ├── .gitignore ├── .gitpod.Dockerfile ├── .gitpod.yml ├── .rubocop.yml ├── .ruby-version ├── CONTRIBUTING.md ├── Dockerfile.dev ├── Dockerfile.prod ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── Procfile.dev ├── Procfile.docker.dev ├── README.md ├── Rakefile ├── SELFHOSTING.md ├── app ├── assets │ ├── builds │ │ └── .keep │ ├── config │ │ └── manifest.js │ ├── images │ │ ├── .keep │ │ ├── fugu_favicon.png │ │ └── fugu_logo.png │ ├── javascripts │ │ └── libraries │ │ │ └── .keep │ └── stylesheets │ │ ├── application.css │ │ └── application.tailwind.css ├── channels │ └── application_cable │ │ ├── channel.rb │ │ └── connection.rb ├── controllers │ ├── api │ │ ├── api_controller.rb │ │ └── v1 │ │ │ └── events_controller.rb │ ├── application_controller.rb │ ├── concerns │ │ ├── .keep │ │ ├── api_keyable.rb │ │ ├── dateable.rb │ │ ├── event_nameable.rb │ │ └── inactive_alertable.rb │ ├── development │ │ └── embed_mock_controller.rb │ ├── events_controller.rb │ ├── funnels_controller.rb │ ├── projects_controller.rb │ ├── stripe_controller.rb │ └── users │ │ ├── confirmations_controller.rb │ │ ├── omniauth_callbacks_controller.rb │ │ ├── passwords_controller.rb │ │ ├── registrations_controller.rb │ │ ├── sessions_controller.rb │ │ ├── settings_controller.rb │ │ └── unlocks_controller.rb ├── helpers │ ├── application_helper.rb │ └── event_helper.rb ├── javascript │ ├── application.js │ ├── controllers │ │ ├── application.js │ │ ├── chart_controller.js │ │ ├── event_controller.js │ │ ├── funnel_chart_controller.js │ │ ├── index.js │ │ └── selection_controller.js │ └── helpers │ │ └── chart_helpers.js ├── jobs │ └── application_job.rb ├── mailers │ └── application_mailer.rb ├── models │ ├── api_key.rb │ ├── application_record.rb │ ├── concerns │ │ ├── .keep │ │ └── inactivable.rb │ ├── event.rb │ ├── funnel.rb │ ├── funnel_step.rb │ ├── project.rb │ └── user.rb ├── services │ └── fugu_service.rb └── views │ ├── development │ └── embed_mock │ │ └── index.html.erb │ ├── events │ ├── _chart.html.erb │ ├── _selections.html.erb │ ├── index.html.erb │ └── show.html.erb │ ├── funnels │ ├── _chart.html.erb │ ├── _selections.html.erb │ ├── index.html.erb │ ├── new.html.erb │ └── show.html.erb │ ├── layouts │ ├── application.html.erb │ ├── data_view.html.erb │ ├── mailer.html.erb │ └── mailer.text.erb │ ├── partials │ ├── _footer.html.erb │ ├── _header.html.erb │ ├── _mode_toggle.html.erb │ ├── _narrow_box.erb │ └── _project_header.html.erb │ ├── projects │ ├── edit.html.erb │ ├── index.html.erb │ ├── new.html.erb │ └── settings.html.erb │ └── users │ ├── confirmations │ └── new.html.erb │ ├── mailer │ ├── confirmation_instructions.html.erb │ ├── email_changed.html.erb │ ├── password_change.html.erb │ ├── reset_password_instructions.html.erb │ └── unlock_instructions.html.erb │ ├── passwords │ ├── edit.html.erb │ └── new.html.erb │ ├── registrations │ ├── edit.html.erb │ └── new.html.erb │ ├── sessions │ └── new.html.erb │ ├── settings │ ├── _subscription.html.erb │ ├── _support.html.erb │ └── show.html.erb │ ├── shared │ ├── _error_messages.html.erb │ └── _links.html.erb │ └── unlocks │ └── new.html.erb ├── bin ├── bundle ├── dev ├── importmap ├── rails ├── rake ├── setup ├── spring └── yarn ├── config.ru ├── config ├── application.rb ├── boot.rb ├── cable.yml ├── credentials.yml.enc ├── database.yml ├── environment.rb ├── environments │ ├── development.rb │ ├── production.rb │ └── test.rb ├── importmap.rb ├── initializers │ ├── application_controller_renderer.rb │ ├── assets.rb │ ├── backtrace_silencers.rb │ ├── content_security_policy.rb │ ├── cookies_serializer.rb │ ├── devise.rb │ ├── filter_parameter_logging.rb │ ├── inflections.rb │ ├── logstop.rb │ ├── mime_types.rb │ ├── permissions_policy.rb │ ├── sentry.rb │ ├── stripe.rb │ └── wrap_parameters.rb ├── locales │ ├── devise.en.yml │ └── en.yml ├── puma.rb ├── routes.rb ├── spring.rb ├── storage.yml └── tailwind.config.js ├── db ├── migrate │ ├── 20210512145433_create_events.rb │ ├── 20210512164800_create_projects.rb │ ├── 20210617151514_add_project_reference_to_events.rb │ ├── 20210617152330_create_api_keys.rb │ ├── 20210617152542_add_not_null_constraint_to_events.rb │ ├── 20210617154111_add_more_not_null_constraints.rb │ ├── 20210617235020_add_dev_to_events.rb │ ├── 20210618081647_add_unique_index_to_api_key.rb │ ├── 20210729144755_move_staging_to_api_key.rb │ ├── 20210729145810_move_reference_to_api_key.rb │ ├── 20210730092821_devise_create_users.rb │ ├── 20210730101807_add_user_reference_to_project.rb │ ├── 20210922065433_change_event_properties_json_to_json_b.rb │ ├── 20211016133119_add_stripe_and_active_to_user.rb │ ├── 20211227170605_create_funnels.rb │ ├── 20211227171015_create_funnel_steps.rb │ ├── 20220216191234_add_unique_index.rb │ ├── 20220314175006_add_hash_id_to_user.rb │ ├── 20220315181147_add_public_bool_to_projects.rb │ └── 20220320112745_user_hash_id_null_false.rb ├── schema.rb └── seeds.rb ├── docker-compose.dev.yml ├── docker-compose.prod-remote.yml ├── docker-compose.prod.yml ├── docker-entrypoint.sh ├── lib ├── assets │ └── .keep └── tasks │ ├── .keep │ ├── auto_annotate_models.rake │ └── fugu_migration_tasks.rake ├── log └── .keep ├── public ├── 404.html ├── 422.html ├── 500.html ├── apple-touch-icon-precomposed.png ├── apple-touch-icon.png ├── favicon.ico └── robots.txt ├── storage └── .keep ├── test ├── application_system_test_case.rb ├── channels │ └── application_cable │ │ └── connection_test.rb ├── controllers │ ├── .keep │ ├── api │ │ └── v1 │ │ │ └── events_controller_test.rb │ ├── events_controller_test.rb │ ├── funnels_controller_test.rb │ ├── projects_controller_test.rb │ ├── stripe_controller_test.rb │ └── users │ │ ├── registrations_controller_test.rb │ │ └── settings_controller_test.rb ├── factories │ ├── api_keys.rb │ ├── events.rb │ ├── funnel_steps.rb │ ├── funnels.rb │ ├── projects.rb │ └── users.rb ├── helpers │ └── .keep ├── integration │ └── .keep ├── mailers │ └── .keep ├── models │ ├── .keep │ ├── api_key_test.rb │ ├── event_test.rb │ ├── funnel_step_test.rb │ ├── funnel_test.rb │ ├── project_test.rb │ └── user_test.rb ├── support │ └── stripe_test_helper.rb ├── system │ └── .keep └── test_helper.rb ├── tmp └── .keep └── vendor └── .keep /.browserslistrc: -------------------------------------------------------------------------------- 1 | defaults 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .github 3 | .gitignore 4 | .dockerignore 5 | .env 6 | LICENSE.md 7 | README.md 8 | SELFHOSTING.md 9 | CONTRIBUTING.md 10 | .gitpod.Dockerfile 11 | .gitpod.yml 12 | docker-compose.* 13 | node_modules 14 | public/assets 15 | storage 16 | public/packs 17 | public/packs-test 18 | 19 | # Ignore all logfiles and tempfiles. 20 | log 21 | tmp 22 | !log/.keep 23 | !tmp/.keep 24 | 25 | # Ignore pidfiles, but keep the directory. 26 | !tmp/pids 27 | !tmp/pids/.keep -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # REQUIRED if you're self-hosting 2 | ALLOW_REGISTRATION=true # setting this to false disallows new sign ups 3 | DATABASE_NAME= 4 | DATABASE_USER= 5 | DATABASE_PASSWORD= 6 | SECRET_KEY_BASE= # Generate key with `rails secret` or manually (alphanumeric string with 128 chars) 7 | 8 | 9 | # OPTIONAL if you're self-hosting 10 | INIT_USER_EMAIL= # set this if you run with docker-compose and want to create an initial user 11 | INIT_USER_PASSWORD= # set this if you run with docker-compose and want to create an initial user 12 | DATABASE_URL= # required if you're running Fugu without docker-compose in production 13 | SENTRY_DSN= # if you want to use Sentry to track errors 14 | RAILS_LOG_TO_STDOUT=true # if you want to log errors in server logs 15 | DATABASE_NAME_TEST= # only needed if you are running automated tests during development 16 | FORCE_SSL_PROD= # sets config.force_ssl in production.rb - defaults to true. 17 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # See https://git-scm.com/docs/gitattributes for more about git attribute files. 2 | 3 | # Mark the database schema as having been generated. 4 | db/schema.rb linguist-generated 5 | 6 | # Mark the yarn lockfile as having been generated. 7 | yarn.lock linguist-generated 8 | 9 | # Mark any vendored files as having been vendored. 10 | vendor/* linguist-vendored 11 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | docker: 9 | if: github.repository == 'shafy/fugu' 10 | runs-on: ubuntu-latest 11 | steps: 12 | - 13 | name: Set up QEMU 14 | uses: docker/setup-qemu-action@v1 15 | - 16 | name: Set up Docker Buildx 17 | uses: docker/setup-buildx-action@v1 18 | - 19 | name: Login to DockerHub 20 | uses: docker/login-action@v1 21 | with: 22 | username: ${{ secrets.DOCKERHUB_USERNAME }} 23 | password: ${{ secrets.DOCKERHUB_TOKEN }} 24 | - 25 | name: Set Versions 26 | uses: actions/github-script@v4 27 | id: set_version 28 | with: 29 | script: | 30 | const tag = context.ref.substring(10) 31 | const no_v = tag.replace('v', '') 32 | const dash_index = no_v.lastIndexOf('-') 33 | const no_dash = (dash_index > -1) ? no_v.substring(0, dash_index) : no_v 34 | core.setOutput('tag', tag) 35 | core.setOutput('no-v', no_v) 36 | core.setOutput('no-dash', no_dash) 37 | - 38 | name: Build and push 39 | uses: docker/build-push-action@v2 40 | with: 41 | push: true 42 | file: Dockerfile.prod 43 | tags: | 44 | ${{secrets.DOCKERHUB_USERNAME}}/fugu:latest 45 | ${{secrets.DOCKERHUB_USERNAME}}/fugu:${{steps.set_version.outputs.no-dash}} -------------------------------------------------------------------------------- /.github/workflows/linting.yml: -------------------------------------------------------------------------------- 1 | name: Linting 2 | 3 | on: 4 | push: 5 | branches: [ $default-branch ] 6 | pull_request: 7 | 8 | jobs: 9 | linting: 10 | runs-on: ubuntu-latest 11 | 12 | env: 13 | RAILS_ENV: test 14 | NODE_ENV: test 15 | 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v2 19 | 20 | - name: Set up Ruby 21 | uses: ruby/setup-ruby@v1 22 | with: 23 | ruby-version: 3.0.0 24 | bundler-cache: true 25 | 26 | - name: Run linters 27 | run: | 28 | bundle exec rubocop -------------------------------------------------------------------------------- /.github/workflows/security.yml: -------------------------------------------------------------------------------- 1 | name: Security 2 | 3 | on: 4 | push: 5 | branches: [ $default-branch ] 6 | pull_request: 7 | 8 | jobs: 9 | security: 10 | runs-on: ubuntu-latest 11 | 12 | env: 13 | RAILS_ENV: test 14 | NODE_ENV: test 15 | 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v2 19 | 20 | - name: Set up Ruby 21 | uses: ruby/setup-ruby@v1 22 | with: 23 | ruby-version: 3.0.0 24 | bundler-cache: true 25 | 26 | - name: Check code vulnerabilities 27 | run: | 28 | bundle exec brakeman 29 | - name: Check gem vulnerabilities 30 | run: | 31 | bundle exec bundle-audit --update -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [ $default-branch ] 6 | pull_request: 7 | 8 | jobs: 9 | tests: 10 | runs-on: ubuntu-latest 11 | 12 | env: 13 | DATABASE_USER: postgres 14 | DATABASE_PASSWORD: postgres 15 | DATABASE_HOST: localhost 16 | RAILS_ENV: test 17 | 18 | services: 19 | postgres: 20 | image: postgres:14 21 | env: 22 | POSTGRES_PASSWORD: postgres 23 | ports: 24 | - 5432:5432 25 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 26 | 27 | steps: 28 | - name: Checkout code 29 | uses: actions/checkout@v2 30 | 31 | - name: Set up Ruby 32 | uses: ruby/setup-ruby@v1 33 | with: 34 | ruby-version: 3.0.0 35 | bundler-cache: true 36 | 37 | - name: Set up PostgreSQL client 38 | run: | 39 | sudo apt-get -yqq install libpq-dev 40 | 41 | - name: Setup test database 42 | run: | 43 | bin/rails db:setup 44 | 45 | - name: Build Tailwind 46 | run: | 47 | bin/rails tailwindcss:build 48 | 49 | - name: Run specs 50 | run: | 51 | bundle exec rails test 52 | env: 53 | STRIPE_ENDPOINT_SECRET: ${{ secrets.STRIPE_ENDPOINT_SECRET }} 54 | ALLOW_REGISTRATION: true 55 | FUGU_CLOUD: true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.rbc 2 | capybara-*.html 3 | .rspec 4 | /db/*.sqlite3 5 | /db/*.sqlite3-journal 6 | /db/*.sqlite3-[0-9]* 7 | /public/system 8 | /coverage/ 9 | /spec/tmp 10 | *.orig 11 | rerun.txt 12 | pickle-email-*.html 13 | 14 | # Ignore all logfiles and tempfiles. 15 | /log/* 16 | /tmp/* 17 | !/log/.keep 18 | !/tmp/.keep 19 | 20 | config/initializers/secret_token.rb 21 | config/master.key 22 | 23 | .env 24 | 25 | ## Environment normalization: 26 | /.bundle 27 | /vendor/bundle 28 | 29 | # these should all be checked in to normalize the environment: 30 | # Gemfile.lock, .ruby-version, .ruby-gemset 31 | 32 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 33 | .rvmrc 34 | 35 | # if using bower-rails ignore default bower_components path bower.json files 36 | /vendor/assets/bower_components 37 | *.bowerrc 38 | bower.json 39 | 40 | # Ignore pow environment settings 41 | .powenv 42 | 43 | # Ignore Byebug command history file. 44 | .byebug_history 45 | 46 | # Ignore node_modules 47 | node_modules/ 48 | 49 | # Ignore precompiled javascript packs 50 | /public/packs 51 | /public/packs-test 52 | /public/assets 53 | 54 | # Ignore yarn files 55 | /yarn-error.log 56 | yarn-debug.log* 57 | .yarn-integrity 58 | 59 | # Ignore uploaded files in development 60 | /storage/* 61 | !/storage/.keep 62 | /public/uploads 63 | 64 | .DS_Store 65 | /app/assets/builds/* 66 | !/app/assets/builds/.keep 67 | -------------------------------------------------------------------------------- /.gitpod.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM gitpod/workspace-postgres 2 | USER gitpod 3 | 4 | # Install the Ruby version specified in '.ruby-version' 5 | COPY --chown=gitpod:gitpod .ruby-version /tmp 6 | RUN echo "rvm_gems_path=/home/gitpod/.rvm" > ~/.rvmrc 7 | RUN bash -lc "rvm reinstall ruby-$(cat /tmp/.ruby-version) && rvm use ruby-$(cat /tmp/.ruby-version) --default && gem install rails rubocop rubocop-performance rubocop-rails rubocop-rspec" 8 | RUN echo "rvm_gems_path=/workspace/.rvm" > ~/.rvmrc 9 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | image: 2 | file: .gitpod.Dockerfile 3 | 4 | ports: 5 | # Rails server 6 | - port: 3000 7 | onOpen: open-browser 8 | # PostgreSQL server 9 | - port: 5432 10 | onOpen: ignore 11 | 12 | tasks: 13 | - init: > 14 | bundle install && 15 | rails db:setup RAILS_ENV=development && 16 | rails db:reset db:migrate RAILS_ENV=test 17 | 18 | github: 19 | prebuilds: 20 | branches: false 21 | pullRequests: true 22 | 23 | vscode: 24 | extensions: 25 | - rebornix.ruby 26 | - wingrunr21.vscode-ruby -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - rubocop-performance 3 | - rubocop-rails 4 | 5 | AllCops: 6 | Exclude: 7 | - 'db/**/*' 8 | - 'script/**/*' 9 | - 'bin/**/*' 10 | - 'config/**/*' 11 | - 'vendor/**/*' 12 | NewCops: enable 13 | 14 | Layout/LineLength: 15 | Max: 100 16 | 17 | Metrics/ClassLength: 18 | Max: 150 19 | 20 | Metrics/MethodLength: 21 | Max: 20 22 | 23 | Metrics/AbcSize: 24 | Max: 26 25 | 26 | Metrics/BlockLength: 27 | Exclude: 28 | - config/**/* 29 | - test/**/* 30 | 31 | Metrics/ModuleLength: 32 | Max: 120 33 | 34 | Metrics/CyclomaticComplexity: 35 | Max: 10 36 | 37 | Lint/AmbiguousBlockAssociation: 38 | Exclude: 39 | - spec/**/* 40 | 41 | Style/Documentation: 42 | Enabled: false 43 | 44 | Style/StringLiterals: 45 | EnforcedStyle: double_quotes 46 | 47 | Style/StringLiteralsInInterpolation: 48 | EnforcedStyle: double_quotes 49 | 50 | Style/FormatStringToken: 51 | Enabled: false 52 | 53 | Rails/I18nLocaleTexts: 54 | Enabled: false 55 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | ruby-3.0.0 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contribution Guide 2 | 3 | ## Contribution Rules 4 | 5 | This is an open-source project and we welcome contributions that improve the code, UX/UI and add new features. However, this doesn't mean that we will accept all contributions. The best way to contribute is to check the open issues or ask me (canolcer@hey.com) if there's something you can contribute to. 6 | If you want to contribute, fork this repo and create a pull request with your changes that we can review. 7 | 8 | If you have any questions, head over to our [GitHub Discussions](https://github.com/shafy/fugu/discussions) page. 9 | 10 | 11 | ## Development 12 | 13 | ### Setup 14 | 15 | There are a few ways you can get started with Fugu on your local machine. 16 | 17 | ### Gitpod 18 | 19 | We recommend developing with the excellent cloud-based development environment provided by [Gitpod](gitpod.io/). To start your ready-to-code development enviroment just add `gitpod.io/#` in front of the GitHub URL for any branch, or [click here](https://gitpod.io/#https://github.com/shafy/fugu) to get started with the main branch. Don't forget to add the environment variables from `.env.example` to your Gitpod account. 20 | 21 | ### Docker 22 | 23 | You can build the Docker image using the `Dockerfile.dev` configuration. If you don't have an existing PostgreSQL service on your machine, you can use our `docker-compose` setup to also start a PostgreSQL database. Just run `docker-compose -f docker-compose.dev.yml up`. 24 | 25 | Don't forget to create an `.env` file using `.env.example` as a template. 26 | 27 | If you're not using `docker-compose`, make sure to create the database with `docker run --env-file .env your_image rails db:create` before starting the Rails server. 28 | 29 | ### Old school 30 | 31 | Dependencies: 32 | - ruby ~3.0.0 33 | - rails ~7.0.0 34 | - postgres ^13.2 35 | 36 | Install the dependencies on your machine & set up an user in Postgres. Then: 37 | ``` 38 | # Set the .env files and fill in all env vars 39 | cp .env.example .env 40 | 41 | # Create and migrate the database 42 | rails db:create db:migrate 43 | ``` 44 | 45 | ## Starting the server 46 | To start the server, run `./bin/dev` or run `rails s` and `rails tailwindcss:watch` as separate processes (the first command does basically the same with `foreman`). This is necessary because we need the [Tailwind](https://github.com/rails/tailwindcss-rails) watch process during development. 47 | 48 | 49 | ## Fugu Cloud 50 | 51 | There are a few places in the codebase where you will encounter logic that is only needed for Fugu Cloud, the hosted solution by us. These parts of the code are not needed if you are self-hosting, and probably also not relevant for you as a contributor. For the most part this concerns code that relates to payment and subscriptions. 52 | 53 | Currently, we use the environment variable `FUGU_CLOUD` to determine if the instance is being self-hosted or not. Later on, we might improve this structure by factoring out the main code base to a Rails Engine. 54 | -------------------------------------------------------------------------------- /Dockerfile.dev: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Stage 1: Build 3 | FROM ruby:3.0.0 as builder 4 | 5 | # Install base packages 6 | RUN apt-get update -qq && \ 7 | apt-get install -y \ 8 | build-essential \ 9 | vim \ 10 | nano \ 11 | postgresql-client && \ 12 | rm -rf /var/lib/apt/lists 13 | 14 | # Set env variables 15 | ENV BUNDLER_VERSION 2.2.3 16 | ENV BUNDLE_JOBS 8 17 | ENV BUNDLE_RETRY 5 18 | ENV BUNDLE_CACHE_ALL true 19 | ENV RAILS_ROOT /app 20 | ENV RAILS_ENV development 21 | ENV RACK_ENV development 22 | 23 | # Set working directory 24 | WORKDIR $RAILS_ROOT 25 | 26 | # Install gems 27 | COPY Gemfile Gemfile.lock ./ 28 | RUN gem install bundler -v $BUNDLER_VERSION --no-document 29 | RUN bundle config --global frozen 1 && \ 30 | bundle install && \ 31 | rm -rf /usr/local/bundle/cache/*.gem && \ 32 | find /usr/local/bundle/gems/ -name "*.c" -delete && \ 33 | find /usr/local/bundle/gems/ -name "*.o" -delete 34 | 35 | # Copy app files 36 | COPY . . 37 | 38 | # Precompile assets 39 | # SECRET_KEY_BASE=`bin/rake secret` is added here as a workaround for 40 | # https://github.com/rails/rails/issues/32947 41 | RUN SECRET_KEY_BASE=`bin/rake secret` 42 | 43 | ############################################################################### 44 | # Stage 2: Run 45 | FROM ruby:3.0.0 46 | 47 | ENV RAILS_ENV development 48 | ENV RAILS_ROOT /app 49 | 50 | EXPOSE 3000 51 | 52 | RUN mkdir -p $RAILS_ROOT 53 | WORKDIR $RAILS_ROOT 54 | 55 | # Copy necessary data at runtime 56 | COPY --from=builder /usr/lib /usr/lib 57 | 58 | # Copy gems 59 | COPY --from=builder /usr/local/bundle /usr/local/bundle 60 | 61 | # Copy app files 62 | COPY --from=builder $RAILS_ROOT $RAILS_ROOT 63 | 64 | # Default command (overriden if used with this project's docker-compose) 65 | CMD ["rails", "server", "-b", "0.0.0.0"] 66 | -------------------------------------------------------------------------------- /Dockerfile.prod: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Stage 1: Build 3 | FROM ruby:3.0.0 as builder 4 | 5 | # Install base packages 6 | RUN apt-get update -qq && \ 7 | apt-get install -y \ 8 | build-essential \ 9 | vim \ 10 | nano \ 11 | postgresql-client && \ 12 | rm -rf /var/lib/apt/lists 13 | 14 | 15 | # Set env variables 16 | ENV BUNDLER_VERSION 2.2.3 17 | ENV BUNDLE_JOBS 8 18 | ENV BUNDLE_RETRY 5 19 | ENV BUNDLE_WITHOUT development:test 20 | ENV BUNDLE_CACHE_ALL true 21 | ENV RAILS_ROOT /app 22 | ENV RAILS_ENV production 23 | ENV RACK_ENV production 24 | 25 | # Set working directory 26 | WORKDIR $RAILS_ROOT 27 | 28 | # Install gems 29 | COPY Gemfile Gemfile.lock ./ 30 | RUN gem install bundler -v $BUNDLER_VERSION --no-document 31 | RUN bundle config --global frozen 1 && \ 32 | bundle install && \ 33 | rm -rf /usr/local/bundle/cache/*.gem && \ 34 | find /usr/local/bundle/gems/ -name "*.c" -delete && \ 35 | find /usr/local/bundle/gems/ -name "*.o" -delete 36 | 37 | # Copy app files 38 | COPY . . 39 | 40 | # Precompile assets 41 | # SECRET_KEY_BASE=`bin/rake secret` is added here as a workaround for 42 | # https://github.com/rails/rails/issues/32947 43 | RUN SECRET_KEY_BASE=`bin/rake secret` rails assets:precompile --trace && \ 44 | rm -rf tmp/cache vendor/assets test 45 | 46 | ############################################################################### 47 | # Stage 2: Run 48 | FROM ruby:3.0.0 49 | 50 | ENV RAILS_ENV production 51 | ENV RAILS_SERVE_STATIC_FILES true 52 | ENV RAILS_ROOT /app 53 | 54 | EXPOSE 3000 55 | 56 | RUN mkdir -p $RAILS_ROOT 57 | WORKDIR $RAILS_ROOT 58 | 59 | # Copy necessary data at runtime 60 | COPY --from=builder /usr/lib /usr/lib 61 | 62 | # Copy gems 63 | COPY --from=builder /usr/local/bundle /usr/local/bundle 64 | 65 | # Copy app files 66 | COPY --from=builder $RAILS_ROOT $RAILS_ROOT 67 | 68 | # Default entrypoint (overriden if used with this project's docker-compose) 69 | ENTRYPOINT ["sh", "/app/docker-entrypoint.sh"] 70 | 71 | # Default command (overriden if used with this project's docker-compose) 72 | CMD ["rails", "server", "-b", "0.0.0.0"] 73 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 5 | 6 | ruby "3.0.0" 7 | 8 | # Bundle edge Rails instead: gem 'rails', github: 'rails/rails', branch: 'main' 9 | gem "rails", "~> 7.0.0" 10 | 11 | gem "pg" 12 | 13 | # Use Puma as the app server 14 | gem "puma", "~> 5.6.2" 15 | gem "sprockets-rails" 16 | 17 | # Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder 18 | gem "jbuilder", "~> 2.7" 19 | # Use Redis adapter to run Action Cable in production 20 | gem "redis", "~> 4.0" 21 | # Use Active Model has_secure_password 22 | # gem 'bcrypt', '~> 3.1.7' 23 | 24 | # Use Active Storage variant 25 | # gem 'image_processing', '~> 1.2' 26 | 27 | # Reduces boot times through caching; required in config/boot.rb 28 | gem "bootsnap", ">= 1.4.4", require: false 29 | 30 | gem "importmap-rails", "~> 1.0.0" 31 | 32 | gem "stimulus-rails", "~> 1.0.2" 33 | gem "turbo-rails", "~> 1.0.0" 34 | 35 | gem "devise", "~> 4.8.0" 36 | 37 | gem "tailwindcss-rails", "~> 2.0.0" 38 | 39 | gem "sentry-ruby", "~> 4.7" 40 | 41 | gem "sentry-rails", "~> 4.7" 42 | 43 | gem "stripe", "~> 5.39.0" 44 | 45 | gem "logstop" 46 | 47 | gem "faraday", "~> 1.8.0" 48 | 49 | group :development, :test do 50 | # Call 'byebug' anywhere in the code to stop execution and get a debugger console 51 | gem "byebug", platforms: %i[mri mingw x64_mingw] 52 | gem "dotenv-rails" 53 | 54 | # Linting with rubocop 55 | gem "rubocop" 56 | gem "rubocop-performance" 57 | gem "rubocop-rails" 58 | 59 | gem "factory_bot_rails", "~> 6.2.0" 60 | 61 | # security 62 | gem "brakeman", "~> 5.1", require: false 63 | gem "bundler-audit", "~> 0.9", require: false 64 | end 65 | 66 | group :development do 67 | # Access an interactive console on exception pages or by calling 'console' anywhere in the code. 68 | gem "web-console", ">= 4.1.0" 69 | # Display performance information such as SQL time and flame graphs 70 | # for each request in your browser. Can be configured to work on production as well see: 71 | # https://github.com/MiniProfiler/rack-mini-profiler/blob/master/README.md 72 | # gem 'rack-mini-profiler', '~> 2.0' 73 | 74 | gem "listen", "~> 3.3" 75 | # Spring speeds up development by keeping your application running in the background. 76 | # Read more: https://github.com/rails/spring 77 | gem "spring" 78 | 79 | gem "annotate" 80 | 81 | gem "foreman", "~> 0.87.2" 82 | end 83 | 84 | group :test do 85 | # Adds support for Capybara system testing and selenium driver 86 | gem "capybara", ">= 3.26" 87 | gem "selenium-webdriver" 88 | # Easy installation and use of web drivers to run system tests with browsers 89 | gem "webdrivers" 90 | 91 | gem "shoulda-context", "~> 2.0.0" 92 | gem "shoulda-matchers", "~> 5.1.0" 93 | 94 | gem "database_cleaner-active_record", "~> 2.0.1" 95 | 96 | gem "stripe-ruby-mock", "~> 3.1.0.rc3", require: "stripe_mock" 97 | end 98 | 99 | # Windows does not include zoneinfo files, so bundle the tzinfo-data gem 100 | gem "tzinfo-data", platforms: %i[mingw mswin x64_mingw jruby] 101 | -------------------------------------------------------------------------------- /Procfile.dev: -------------------------------------------------------------------------------- 1 | web: bin/rails server -p 3000 2 | css: rails tailwindcss:watch -------------------------------------------------------------------------------- /Procfile.docker.dev: -------------------------------------------------------------------------------- 1 | web: bundle exec rails server -p 3000 -b '0.0.0.0' 2 | css: bundle exec rails tailwindcss:watch 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fugu 🐡 2 | 3 | Fugu is a free, simple, privacy-friendly, open source and self-hostable product analytics. Check out the [Fugu website](https://fugu.lol) to learn more and play with an interactive demo. 4 | 5 | Fugu Screenshot 6 | 7 | ## Getting started 8 | 9 | Fugu is a simple Ruby on Rails app and includes configuration for Docker and Docker Compose to simplify self-hosting. You can run it wherever you want, from Heroku over Hetzner to your own bare metal machine. Read the [Self-Hosting guide](/SELFHOSTING.md) for detailed instructions on how to get running with few steps. 10 | 11 | ## Docs 12 | 13 | Visit the [Fugu Docs](https://docs.fugu.lol). 14 | 15 | 16 | ## Contributing 17 | 18 | We welcome contributions to Fugu. These can be bug fixes, UI improvements, new features, code quality improvements etc. Check out the [contribution guide](/CONTRIBUTING.md) to get started. 19 | 20 | ## License 21 | 22 | This project uses the [GNU Affero General Public License v3.0](https://github.com/mapzy/mapzy/blob/main/LICENSE) license. 23 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Add your own tasks in files placed in lib/tasks ending in .rake, 4 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 5 | 6 | require_relative "config/application" 7 | 8 | Rails.application.load_tasks 9 | -------------------------------------------------------------------------------- /app/assets/builds/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shafy/fugu/610c06390f650d485b389b4d375354b9a943ce57/app/assets/builds/.keep -------------------------------------------------------------------------------- /app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | //= link_tree ../images 2 | //= link_directory ../stylesheets .css 3 | //= link_tree ../../javascript .js 4 | //= link_tree ../builds 5 | -------------------------------------------------------------------------------- /app/assets/images/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shafy/fugu/610c06390f650d485b389b4d375354b9a943ce57/app/assets/images/.keep -------------------------------------------------------------------------------- /app/assets/images/fugu_favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shafy/fugu/610c06390f650d485b389b4d375354b9a943ce57/app/assets/images/fugu_favicon.png -------------------------------------------------------------------------------- /app/assets/images/fugu_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shafy/fugu/610c06390f650d485b389b4d375354b9a943ce57/app/assets/images/fugu_logo.png -------------------------------------------------------------------------------- /app/assets/javascripts/libraries/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shafy/fugu/610c06390f650d485b389b4d375354b9a943ce57/app/assets/javascripts/libraries/.keep -------------------------------------------------------------------------------- /app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* ... 2 | *= require_self 3 | *= require_tree . 4 | */ 5 | 6 | -------------------------------------------------------------------------------- /app/assets/stylesheets/application.tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | 6 | .font-system { 7 | font-family: '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Oxygen-Sans', 'Ubuntu', 'Cantarell', 'Helvetica Neue', sans-serif; 8 | } 9 | 10 | select { 11 | @apply rounded border-2 border-gray-600 bg-amber-50; 12 | } 13 | 14 | select:focus { 15 | box-shadow: none; 16 | border-color: rgb(16, 185, 129); 17 | } 18 | 19 | input { 20 | @apply rounded border-2 border-gray-600; 21 | } 22 | 23 | a { 24 | text-decoration: underline; 25 | font-weight: 500; 26 | } 27 | 28 | a:hover { 29 | color: rgb(16, 185, 129); 30 | } 31 | 32 | h1 { 33 | font-size: 1.25rem; 34 | line-height: 1.75rem; 35 | margin-top: 1.25rem; 36 | margin-bottom: 1rem; 37 | font-weight: 500; 38 | } 39 | 40 | h2 { 41 | font-size: 1.125rem; 42 | line-height: 1.75rem; 43 | margin-top: 0.75rem; 44 | margin-bottom: 0.5rem; 45 | font-weight: 500; 46 | } 47 | 48 | code { 49 | @apply font-mono text-sm bg-gray-900 p-2 rounded text-white 50 | } 51 | 52 | .code--multiline { 53 | @apply p-4 rounded my-4 w-full bg-gray-900 text-white 54 | } 55 | 56 | .code--multiline > code { 57 | @apply bg-transparent p-0 58 | } 59 | 60 | .main-button { 61 | @apply py-2 px-4 bg-green-500 text-white font-semibold rounded-lg shadow-md 62 | focus:outline-none hover:bg-green-300 cursor-pointer w-full border-0; 63 | } 64 | 65 | .w-32rem { 66 | width: 32rem; 67 | } 68 | 69 | .flash-notice, .flash-info { 70 | background-color: rgb(147, 197, 253); 71 | border-color: rgb(96, 165, 250); 72 | } 73 | 74 | .flash-alert, .flash-not_active { 75 | background-color: rgb(252, 211, 77); 76 | border-color: rgb(251, 191, 36); 77 | } 78 | 79 | .flash-error { 80 | background-color: rgb(252, 165, 165); 81 | border-color: rgb(248, 113, 113); 82 | } 83 | 84 | .property-item-unselected--colorbox { 85 | @apply bg-gray-200; 86 | } 87 | 88 | .property-item-unselected--textbox { 89 | @apply text-gray-400; 90 | } 91 | 92 | .property-item-li { 93 | @apply flex flex-row gap-x-1 justify-center items-center cursor-pointer; 94 | } -------------------------------------------------------------------------------- /app/channels/application_cable/channel.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ApplicationCable 4 | class Channel < ActionCable::Channel::Base 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /app/channels/application_cable/connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ApplicationCable 4 | class Connection < ActionCable::Connection::Base 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /app/controllers/api/api_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Api 4 | class ApiController < ApplicationController 5 | skip_before_action :verify_authenticity_token 6 | around_action :handle_exceptions 7 | 8 | private 9 | 10 | def handle_exceptions 11 | begin 12 | yield 13 | rescue ActiveRecord::RecordNotFound => e 14 | @status = 404 15 | @error_type = e.class.to_s 16 | # @message = 'Record not found' 17 | rescue ArgumentError => e 18 | @status = 422 19 | @error_type = e.class.to_s 20 | rescue StandardError => e 21 | @status = 500 22 | @error_type = e.class.to_s 23 | end 24 | 25 | return if e.instance_of?(NilClass) 26 | 27 | render json: { error: { type: @error_type, message: e.message } }, 28 | status: @status 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /app/controllers/api/v1/events_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Api 4 | module V1 5 | class EventsController < Api::ApiController 6 | before_action :validate_param_keys, only: %w[create] 7 | 8 | def create 9 | event = Event.create( 10 | name: params[:name], 11 | api_key: ApiKey.find_by!(key_value: params[:api_key]), 12 | properties: params[:properties] 13 | ) 14 | 15 | raise ArgumentError, event.errors.full_messages.first unless event.errors.empty? 16 | 17 | render json: { success: true, event: event }, status: :ok 18 | end 19 | 20 | def validate_param_keys 21 | raise ArgumentError, "missing 'name' key" unless params.key?(:name) 22 | 23 | raise ArgumentError, "'name' can't be nil" if params[:name].nil? 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationController < ActionController::Base 4 | include InactiveAlertable 5 | 6 | private 7 | 8 | def show_test_alert 9 | return unless params[:test] == "true" 10 | 11 | flash.now[:info] = 12 | "Heads up: You are currently viewing test events. Test events are deleted after 14 days." 13 | end 14 | 15 | def set_project 16 | user = User.find_by(hash_id: params[:user_id]) 17 | @project = Project.find_by(name: params[:project_slug]&.downcase, user: user) 18 | end 19 | 20 | def authorize_project_user 21 | return redirect_to user_projects_path(params[:user_id]) unless @project 22 | 23 | # don't show test data in public projects 24 | if !current_user && @project.public && params[:test] == "true" 25 | return redirect_to user_projects_path(params[:user_id]) 26 | end 27 | 28 | # don't authorize is project if public 29 | return if @project.public 30 | 31 | return redirect_to user_projects_path(params[:user_id]) unless current_user == @project.user 32 | end 33 | 34 | def allow_iframe 35 | return unless params[:embed] == "true" 36 | 37 | response.headers.except! "X-Frame-Options" 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /app/controllers/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shafy/fugu/610c06390f650d485b389b4d375354b9a943ce57/app/controllers/concerns/.keep -------------------------------------------------------------------------------- /app/controllers/concerns/api_keyable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ApiKeyable 4 | extend ActiveSupport::Concern 5 | 6 | private 7 | 8 | def set_api_key 9 | @api_key = params[:test] == "true" ? @project.api_key_test : @project.api_key_live 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/controllers/concerns/dateable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dateable 4 | extend ActiveSupport::Concern 5 | 6 | private 7 | 8 | def set_dates 9 | # defaults to 7days if no date selected or if url param not in the list 10 | @start_date = case params[:date] ? CGI.escapeHTML(params[:date]) : "7d" 11 | when "30d" 12 | 29.days.ago 13 | when "this_m" 14 | Time.zone.now.beginning_of_month 15 | when "6m" 16 | 6.months.ago 17 | when "3m" 18 | 3.months.ago 19 | when "12m" 20 | 12.months.ago 21 | when "1d" 22 | Time.zone.now 23 | else 24 | 6.days.ago 25 | end 26 | @end_date = Time.zone.now 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /app/controllers/concerns/event_nameable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EventNameable 4 | extend ActiveSupport::Concern 5 | 6 | private 7 | 8 | def set_event_names 9 | @event_names = Event.distinct_events_names(@api_key) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/controllers/concerns/inactive_alertable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # These alerts are only used in the Fugu Cloud version (not important if you're self-hosting) 4 | module InactiveAlertable 5 | extend ActiveSupport::Concern 6 | 7 | private 8 | 9 | def show_not_active_flash 10 | return if @project&.public 11 | 12 | return unless ENV["FUGU_CLOUD"] == "true" 13 | 14 | flash.now[:not_active] = user_canceled_flash if current_user.canceled? 15 | flash.now[:not_active] = user_inactive_flash if current_user.inactive? 16 | end 17 | 18 | # rubocop:disable Rails/OutputSafety 19 | def user_canceled_flash 20 | %( 21 | You have canceled your subscription and it will end soon. 22 | Make sure to   renew   it 23 | if you want to keep using Fugu. 24 | ).html_safe 25 | end 26 | 27 | def user_inactive_flash 28 | %( 29 | Hey there 👋 Make sure to subscribe in 30 | order to track events. 31 | You can use your test API key to give Fugu a spin without a subscription. 32 | ).html_safe 33 | end 34 | # rubocop:enable Rails/OutputSafety 35 | end 36 | -------------------------------------------------------------------------------- /app/controllers/development/embed_mock_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Development 4 | class EmbedMockController < ApplicationController 5 | before_action :authenticate_user! 6 | 7 | def index 8 | @project = Project.where(user: current_user).first 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/controllers/funnels_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class FunnelsController < ApplicationController 4 | include ApiKeyable 5 | include Dateable 6 | include EventNameable 7 | 8 | before_action :set_project, only: %i[show index new create] 9 | before_action :authenticate_user!, unless: -> { @project.public } 10 | before_action :authorize_project_user, only: %i[index new] 11 | before_action :show_test_alert, only: %i[show index] 12 | before_action :show_test_funnel_creation_alert, only: %i[new] 13 | before_action :show_not_active_flash, only: %i[index] 14 | before_action :set_api_key, only: %i[index show new create] 15 | before_action :set_dates, only: %i[show] 16 | before_action :set_event_names, only: %i[new] 17 | before_action :set_funnel, only: %i[show] 18 | before_action :set_funnel_names, only: %i[index show] 19 | before_action :set_funnel_event_names, only: %i[show] 20 | before_action :build_funnel, only: %i[create] 21 | 22 | after_action :track_event, only: %i[index] 23 | after_action :allow_iframe, only: %i[index show] 24 | 25 | def index 26 | return render layout: "data_view" unless @funnel_names&.first 27 | 28 | redirect_to user_project_funnel_path( 29 | params[:user_id], 30 | @project.name, 31 | @funnel_names.first.parameterize, 32 | helpers.evergreen_params 33 | ) 34 | end 35 | 36 | def show 37 | unless @funnel 38 | return redirect_to user_project_funnels_path( 39 | params[:user_id], 40 | @project.name, 41 | helpers.evergreen_params 42 | ) 43 | end 44 | 45 | @funnel_data = @funnel_event_names.map do |e| 46 | Event.where(name: e, api_key: @api_key, created_at: @start_date..@end_date).count 47 | end 48 | render layout: "data_view" 49 | end 50 | 51 | def new 52 | @funnel = Funnel.new 53 | 5.times { @funnel.funnel_steps.build } 54 | 55 | show_no_events_alert if @event_names.empty? 56 | end 57 | 58 | def create 59 | if @funnel.save 60 | redirect_to user_project_funnel_path( 61 | current_user.hash_id, 62 | @project.name, 63 | @funnel.name.parameterize, 64 | params: { test: params[:test] } 65 | ) 66 | else 67 | flash[:error] = "We couldn't create your funnel: #{@funnel.errors.full_messages.first}" 68 | redirect_to new_user_project_funnel_path( 69 | current_user.hash_id, 70 | @project.name, 71 | params: { test: params[:test] } 72 | ) 73 | end 74 | end 75 | 76 | private 77 | 78 | def funnel_params 79 | params.require(:funnel).permit(:user_id, :name, funnel_steps_attributes: %w[event_name]) 80 | end 81 | 82 | def track_event 83 | FuguService.track("Viewed Funnels") 84 | end 85 | 86 | def build_funnel 87 | @funnel = Funnel.new(funnel_params.merge(api_key: @api_key)) 88 | end 89 | 90 | def set_funnel 91 | @funnel = Funnel.find_by(name: params[:slug].tr("-", " ").titleize.strip, api_key: @api_key) 92 | end 93 | 94 | def set_funnel_names 95 | @funnel_names = Funnel.where(api_key: @api_key).pluck(:name) 96 | end 97 | 98 | def set_funnel_event_names 99 | @funnel_event_names = @funnel&.funnel_steps&.pluck(:event_name) 100 | end 101 | 102 | def show_test_funnel_creation_alert 103 | return unless params[:test] == "true" 104 | 105 | flash.now[:info] = "You are creating a funnel in test mode. " \ 106 | "This means that you can only select events tracked in test mode. " \ 107 | "Unlike test events, test funnels are not deleted after 14 days." 108 | end 109 | 110 | def show_no_events_alert 111 | flash.now[:alert] = "You haven't tracked any events in the current mode. " \ 112 | "Make sure to track events before creating a funnel." 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /app/controllers/projects_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ProjectsController < ApplicationController 4 | before_action :authenticate_user! 5 | 6 | before_action :set_project, only: %i[settings] 7 | before_action :show_not_active_flash, only: %i[settings] 8 | 9 | def index 10 | @projects = Project.where(user: current_user) 11 | end 12 | 13 | def new 14 | @project = Project.new 15 | end 16 | 17 | def create 18 | @project = Project.new(name: project_params[:name], user: current_user) 19 | if @project.save 20 | @project.create_api_keys 21 | redirect_to user_project_events_path(current_user.hash_id, @project.name) 22 | else 23 | flash.now[:error] = "We couldn't create your project: #{@project.errors.full_messages.first}" 24 | render "projects/new", status: :unprocessable_entity 25 | end 26 | end 27 | 28 | def edit 29 | user = User.find_by(hash_id: params[:user_id]) 30 | @project = Project.find_by(name: params[:slug]&.downcase, user: user) 31 | end 32 | 33 | def update 34 | @project = Project.find(project_params[:project_id]) 35 | 36 | if @project.update(name: project_params[:name], public: project_params[:public]) 37 | redirect_to user_project_settings_path(current_user.hash_id, @project.name.downcase) 38 | else 39 | flash.now[:error] = "We couldn't update your project: #{@project.errors.full_messages.first}" 40 | render "projects/edit", status: :unprocessable_entity 41 | end 42 | end 43 | 44 | def destroy 45 | @project = Project.find_by(name: params[:slug]&.downcase, user: current_user) 46 | if @project.destroy 47 | flash[:notice] = "Your project was successfully deleted" 48 | redirect_to user_projects_path(current_user) 49 | else 50 | flash[:error] = "We couldn't delete the project: #{@project.errors.full_messages.first}" 51 | redirect_to user_project_events_path(current_user.hash_id, @project.name) 52 | end 53 | end 54 | 55 | def settings; end 56 | 57 | private 58 | 59 | def project_params 60 | params.require(:project).permit(:project_id, :user_id, :name, :public) 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /app/controllers/stripe_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class StripeController < ApplicationController 4 | before_action :authenticate_user!, except: %i[webhooks] 5 | skip_before_action :verify_authenticity_token, only: %i[webhooks] 6 | before_action :verify_webhook, only: %i[webhooks] 7 | 8 | def checkout_session 9 | session = Stripe::Checkout::Session.create(checkout_session_args) 10 | redirect_to session.url, allow_other_host: true 11 | rescue StandardError => e 12 | Sentry.capture_exception(e) 13 | flash[:alert] = error_message 14 | redirect_to users_settings_url 15 | end 16 | 17 | def success_callback 18 | session = Stripe::Checkout::Session.retrieve(params[:session_id]) 19 | customer = Stripe::Customer.retrieve(session.customer) 20 | current_user.update(status: "active", stripe_customer_id: customer.id) 21 | 22 | FuguService.track("New Subscription") 23 | 24 | flash[:notice] = success_message 25 | redirect_to users_settings_url 26 | rescue StandardError => e 27 | Sentry.capture_exception(e) 28 | flash[:alert] = error_message 29 | redirect_to users_settings_url 30 | end 31 | 32 | def customer_portal 33 | customer_portal_session = Stripe::BillingPortal::Session.create( 34 | { 35 | customer: current_user.stripe_customer_id, 36 | return_url: users_settings_url 37 | } 38 | ) 39 | redirect_to customer_portal_session.url, allow_other_host: true 40 | rescue StandardError 41 | flash[:alert] = error_message 42 | redirect_to users_settings_url 43 | end 44 | 45 | def webhooks 46 | # cases: 47 | # - customer cancels subscription for the end of the billing cycle 48 | # (customer.subscription.updated) 49 | # - subscription is canceled at the end of billing cycle (customer.subscription.deleted) 50 | # - customer re-activates canceled account before end of billing cycle 51 | # (customer.subscription.updated) 52 | 53 | case params[:type] 54 | when "customer.subscription.updated" 55 | handle_subscription_updated 56 | when "customer.subscription.deleted" 57 | handle_subscription_deleted 58 | else 59 | head :ok 60 | end 61 | end 62 | 63 | def verify_webhook 64 | Stripe::Webhook.construct_event( 65 | request.raw_post, 66 | request.env["HTTP_STRIPE_SIGNATURE"], 67 | ENV.fetch("STRIPE_ENDPOINT_SECRET", nil) 68 | ) 69 | rescue StandardError => e 70 | Sentry.capture_exception(e) 71 | head :not_found 72 | end 73 | 74 | private 75 | 76 | def success_message 77 | "Subscription successful. Enjoy Fugu 🎉🐡" 78 | end 79 | 80 | def error_message 81 | "Something went wrong. Please try again or contact me at canolcer@hey.com" 82 | end 83 | 84 | def sub_cancel_at_period_end 85 | params[:data][:object][:cancel_at_period_end] 86 | end 87 | 88 | def sub_customer_id 89 | params[:data][:object][:customer] 90 | end 91 | 92 | def handle_subscription_updated 93 | user = User.find_by(stripe_customer_id: sub_customer_id) 94 | if sub_cancel_at_period_end && !user.canceled? 95 | user.update!(status: "canceled") 96 | elsif !sub_cancel_at_period_end 97 | user.update!(status: "active") 98 | end 99 | 100 | head :ok 101 | rescue StandardError => e 102 | Sentry.capture_exception(e) 103 | head :internal_server_error 104 | end 105 | 106 | def handle_subscription_deleted 107 | User.find_by(stripe_customer_id: sub_customer_id).update!(status: "inactive") 108 | head :ok 109 | rescue StandardError => e 110 | Sentry.capture_exception(e) 111 | head :internal_server_error 112 | end 113 | 114 | def checkout_session_args 115 | { 116 | payment_method_types: ["card"], 117 | customer_email: current_user.stripe_customer_id ? nil : current_user.email, 118 | customer: current_user.stripe_customer_id, 119 | line_items: [{ 120 | price: ENV.fetch("STRIPE_STANDARD_PRICE_ID", nil), 121 | quantity: 1 122 | }], 123 | mode: "subscription", 124 | success_url: "#{stripe_success_callback_url}?session_id={CHECKOUT_SESSION_ID}", 125 | cancel_url: users_settings_url 126 | } 127 | end 128 | end 129 | -------------------------------------------------------------------------------- /app/controllers/users/confirmations_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Users 4 | class ConfirmationsController < Devise::ConfirmationsController 5 | # GET /resource/confirmation/new 6 | # def new 7 | # super 8 | # end 9 | 10 | # POST /resource/confirmation 11 | # def create 12 | # super 13 | # end 14 | 15 | # GET /resource/confirmation?confirmation_token=abcdef 16 | # def show 17 | # super 18 | # end 19 | 20 | # protected 21 | 22 | # The path used after resending confirmation instructions. 23 | # def after_resending_confirmation_instructions_path_for(resource_name) 24 | # super(resource_name) 25 | # end 26 | 27 | # The path used after confirmation. 28 | # def after_confirmation_path_for(resource_name, resource) 29 | # super(resource_name, resource) 30 | # end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /app/controllers/users/omniauth_callbacks_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Users 4 | class OmniauthCallbacksController < Devise::OmniauthCallbacksController 5 | # You should configure your model like this: 6 | # devise :omniauthable, omniauth_providers: [:twitter] 7 | 8 | # You should also create an action method in this controller like this: 9 | # def twitter 10 | # end 11 | 12 | # More info at: 13 | # https://github.com/heartcombo/devise#omniauth 14 | 15 | # GET|POST /resource/auth/twitter 16 | # def passthru 17 | # super 18 | # end 19 | 20 | # GET|POST /users/auth/twitter/callback 21 | # def failure 22 | # super 23 | # end 24 | 25 | # protected 26 | 27 | # The path used when OmniAuth fails 28 | # def after_omniauth_failure_path_for(scope) 29 | # super(scope) 30 | # end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /app/controllers/users/passwords_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Users 4 | class PasswordsController < Devise::PasswordsController 5 | # GET /resource/password/new 6 | # def new 7 | # super 8 | # end 9 | 10 | # POST /resource/password 11 | # def create 12 | # super 13 | # end 14 | 15 | # GET /resource/password/edit?reset_password_token=abcdef 16 | # def edit 17 | # super 18 | # end 19 | 20 | # PUT /resource/password 21 | # def update 22 | # super 23 | # end 24 | 25 | # protected 26 | 27 | # def after_resetting_password_path_for(resource) 28 | # super(resource) 29 | # end 30 | 31 | # The path used after sending reset password instructions 32 | # def after_sending_reset_password_instructions_path_for(resource_name) 33 | # super(resource_name) 34 | # end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /app/controllers/users/registrations_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Users 4 | class RegistrationsController < Devise::RegistrationsController 5 | # before_action :configure_sign_up_params, only: [:create] 6 | # before_action :configure_account_update_params, only: [:update] 7 | 8 | # GET /resource/sign_up 9 | def new 10 | super 11 | FuguService.track("Viewed Sign Up") 12 | end 13 | 14 | def create 15 | return unless ENV["ALLOW_REGISTRATION"] == "true" 16 | 17 | super do |resource| 18 | FuguService.track("New User") if resource.persisted? 19 | 20 | if resource.errors.present? 21 | flash.now[:alert] = 22 | resource.errors.full_messages.join(", ") 23 | end 24 | end 25 | end 26 | 27 | # GET /resource/edit 28 | # def edit 29 | # super 30 | # end 31 | 32 | # PUT /resource 33 | # def update 34 | # super 35 | # end 36 | 37 | # DELETE /resource 38 | # def destroy 39 | # super 40 | # end 41 | 42 | # GET /resource/cancel 43 | # Forces the session data which is usually expired after sign 44 | # in to be expired now. This is useful if the user wants to 45 | # cancel oauth signing in/up in the middle of the process, 46 | # removing all OAuth session data. 47 | # def cancel 48 | # super 49 | # end 50 | 51 | # protected 52 | 53 | # If you have extra params to permit, append them to the sanitizer. 54 | # def configure_sign_up_params 55 | # devise_parameter_sanitizer.permit(:sign_up, keys: [:attribute]) 56 | # end 57 | 58 | # If you have extra params to permit, append them to the sanitizer. 59 | # def configure_account_update_params 60 | # devise_parameter_sanitizer.permit(:account_update, keys: [:attribute]) 61 | # end 62 | 63 | # The path used after sign up. 64 | # def after_sign_up_path_for(resource) 65 | # super(resource) 66 | # end 67 | 68 | # The path used after sign up for inactive accounts. 69 | # def after_inactive_sign_up_path_for(resource) 70 | # super(resource) 71 | # end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /app/controllers/users/sessions_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Users 4 | class SessionsController < Devise::SessionsController 5 | # before_action :configure_sign_in_params, only: [:create] 6 | 7 | # GET /resource/sign_in 8 | def new 9 | super 10 | FuguService.track("Viewed Sign In") 11 | end 12 | 13 | # POST /resource/sign_in 14 | # def create 15 | # super 16 | # end 17 | 18 | # DELETE /resource/sign_out 19 | # def destroy 20 | # super 21 | # end 22 | 23 | # protected 24 | 25 | # If you have extra params to permit, append them to the sanitizer. 26 | # def configure_sign_in_params 27 | # devise_parameter_sanitizer.permit(:sign_in, keys: [:attribute]) 28 | # end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /app/controllers/users/settings_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Users 4 | class SettingsController < ApplicationController 5 | before_action :authenticate_user! 6 | before_action :show_not_active_flash, only: %i[show] 7 | 8 | def show 9 | return unless ENV["FUGU_CLOUD"] == "true" 10 | 11 | # following code is irrelevant if you're self-hosting 12 | return unless current_user.canceled? && current_user.stripe_customer_id.present? 13 | 14 | customer = retrieve_stripe_customer 15 | 16 | return unless customer.subscriptions.data.first 17 | 18 | @cancel_at = format_cancel_time(customer.subscriptions.data.first.cancel_at) 19 | end 20 | 21 | private 22 | 23 | def retrieve_stripe_customer 24 | Stripe::Customer.retrieve( 25 | id: current_user.stripe_customer_id, 26 | expand: ["subscriptions"] 27 | ) 28 | end 29 | 30 | def format_cancel_time(unix_time) 31 | return unless unix_time 32 | 33 | Time.at(unix_time).utc.to_datetime.strftime("%b %-d, %Y") 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /app/controllers/users/unlocks_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Users 4 | class UnlocksController < Devise::UnlocksController 5 | # GET /resource/unlock/new 6 | # def new 7 | # super 8 | # end 9 | 10 | # POST /resource/unlock 11 | # def create 12 | # super 13 | # end 14 | 15 | # GET /resource/unlock?unlock_token=abcdef 16 | # def show 17 | # super 18 | # end 19 | 20 | # protected 21 | 22 | # The path used after sending unlock password instructions 23 | # def after_sending_unlock_instructions_path_for(resource) 24 | # super(resource) 25 | # end 26 | 27 | # The path used after unlocking the resource 28 | # def after_unlock_path_for(resource) 29 | # super(resource) 30 | # end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ApplicationHelper 4 | def evergreen_params 5 | # params that we often pass along 6 | { test: params[:test], embed: params[:embed] } 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /app/javascript/application.js: -------------------------------------------------------------------------------- 1 | // Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails 2 | import "@hotwired/stimulus-importmap-autoloader" 3 | import "controllers" 4 | import "@hotwired/turbo-rails" 5 | -------------------------------------------------------------------------------- /app/javascript/controllers/application.js: -------------------------------------------------------------------------------- 1 | import { Application } from "@hotwired/stimulus" 2 | 3 | const application = Application.start() 4 | 5 | // Configure Stimulus development experience 6 | application.warnings = true 7 | application.debug = false 8 | window.Stimulus = application 9 | 10 | export { application } 11 | -------------------------------------------------------------------------------- /app/javascript/controllers/chart_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus" 2 | import { Chart, registerables } from "chart.js"; 3 | import { 4 | LS_VISIBLE_PROP_VALUES, 5 | formatDates, 6 | formatLocalStorageName, 7 | htmlDecode, 8 | htmlLegendPlugin, 9 | initLocalStoragePropValues, 10 | localStorageItemJSON 11 | } from "chart_helpers"; 12 | 13 | Chart.register(...registerables); 14 | 15 | export default class extends Controller { 16 | static targets = [ "chart"] 17 | static values = { 18 | dates: Array, 19 | events: Object, 20 | agg: String, 21 | eventName: String, 22 | hasPropValues: Boolean, 23 | propertyName: String, 24 | projectId: String 25 | } 26 | 27 | connect() { 28 | initLocalStoragePropValues(this.eventsValue, this.projectIdValue, this.propertyNameValue); 29 | this.visiblePropValues = localStorageItemJSON( 30 | formatLocalStorageName( 31 | this.projectIdValue, 32 | this.propertyNameValue, 33 | LS_VISIBLE_PROP_VALUES 34 | ) 35 | ); 36 | this.initColorPalette(); 37 | this.showChart(); 38 | } 39 | 40 | showChart() { 41 | console.log(this.eventsValue) 42 | const data = { 43 | labels: formatDates(this.datesValue, this.aggValue), 44 | datasets: Object.keys(this.eventsValue).map((e, i) => this.createDataSet(e, this.eventsValue[e], i)) 45 | }; 46 | 47 | const config = { 48 | type: "line", 49 | data, 50 | options: { 51 | layout: { 52 | padding: 10, 53 | }, 54 | maintainAspectRatio: false, 55 | scales: { 56 | y: { 57 | grace: "10%", 58 | beginAtZero: true, 59 | ticks: { 60 | format: { style: "decimal" }, 61 | precision: 0, 62 | padding: 10 63 | }, 64 | grid: { 65 | color: "rgba(254, 243, 199, 0.7)", 66 | lineWidth: 2, 67 | drawBorder: false 68 | } 69 | }, 70 | x: { 71 | grid: { 72 | display: false 73 | } 74 | } 75 | }, 76 | spanGaps: true, 77 | plugins: { 78 | htmlLegend: { 79 | display: this.hasPropValuesValue, 80 | projectId: this.projectIdValue, 81 | propertyName: this.propertyNameValue 82 | }, 83 | legend: { 84 | display: false 85 | } 86 | }, 87 | }, 88 | plugins: [ htmlLegendPlugin ] 89 | } 90 | Chart.defaults.font.family = "'-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Oxygen-Sans', 'Ubuntu', 'Cantarell', 'Helvetica Neue', sans-serif"; 91 | this.chart = new Chart( 92 | this.chartTarget, 93 | config 94 | ); 95 | } 96 | 97 | createDataSet(labelData, data, index) { 98 | let label = "" 99 | if (labelData !== "") { 100 | label = `${htmlDecode(labelData)} (${data["total_count"]})` 101 | } 102 | 103 | return { 104 | label, 105 | backgroundColor: this.colorPalette[index % this.colorPalette.length], 106 | borderColor: this.colorPalette[index % this.colorPalette.length], 107 | borderJointStyle: "round", 108 | borderCapStyle: "round", 109 | borderWidth: 4.5, 110 | tension: 0.15, 111 | pointRadius: this.datesValue.length == 1 ? 1 : 0, 112 | pointHitRadius: 5, 113 | hoverBorderWidth: 4, 114 | data: data["data"], 115 | //hidden: !data["visible"] 116 | hidden: !this.visiblePropValues.includes(htmlDecode(labelData)) 117 | } 118 | } 119 | 120 | initColorPalette() { 121 | this.colorPalette = [ 122 | "rgb(39, 125, 161)", 123 | "rgb(87, 117, 144)", 124 | "rgb(77, 144, 142)", 125 | "rgb(67, 170, 139)", 126 | "rgb(144, 190, 109)", 127 | "rgb(249, 199, 79)", 128 | "rgb(249, 132, 74)", 129 | "rgb(248, 150, 30)", 130 | "rgb(243, 114, 44)", 131 | "rgb(249, 65, 68)" 132 | ] 133 | } 134 | 135 | selectAllPropValues(e) { 136 | e.preventDefault(); 137 | 138 | this.chart.data.datasets.forEach((dataset, index) => { 139 | this.chart.setDatasetVisibility(index, true); 140 | }); 141 | this.chart.update(); 142 | } 143 | 144 | deselectAllPropValues(e) { 145 | e.preventDefault(); 146 | 147 | this.chart.data.datasets.forEach((dataset, index) => { 148 | this.chart.setDatasetVisibility(index, false); 149 | }); 150 | this.chart.update(); 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /app/javascript/controllers/event_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus" 2 | 3 | export default class extends Controller { 4 | static values = { 5 | aggregation: String 6 | } 7 | 8 | connect() { 9 | this.correctAggValue(); 10 | } 11 | 12 | correctAggValue() { 13 | if(new URLSearchParams(window.location.search).get("agg") != null) { 14 | const url = new URL(window.location.href); 15 | url.searchParams.set("agg", this.aggregationValue); 16 | history.replaceState(null, "", url); 17 | } else { 18 | return 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/javascript/controllers/funnel_chart_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus" 2 | import { Chart, registerables } from "chart.js"; 3 | import ChartDataLabels from "chartjs-plugin-datalabels"; 4 | 5 | Chart.register(...registerables); 6 | 7 | export default class extends Controller { 8 | static targets = ["chart"] 9 | static values = { 10 | funnelEventNames: Array, 11 | funnelData: Array, 12 | } 13 | 14 | connect() { 15 | this.initColorPalette(); 16 | this.showChart(); 17 | } 18 | 19 | showChart() { 20 | const data = { 21 | labels: this.funnelEventNamesValue, 22 | datasets: this.createDataSet(this.funnelDataValue) 23 | }; 24 | 25 | const config = { 26 | type: "bar", 27 | data, 28 | plugins: [ChartDataLabels], 29 | options: { 30 | events: [], 31 | layout: { 32 | padding: 10, 33 | }, 34 | maintainAspectRatio: false, 35 | scales: { 36 | y: { 37 | grace: "10%", 38 | beginAtZero: true, 39 | ticks: { 40 | format: { style: "decimal" }, 41 | precision: 0, 42 | padding: 10 43 | }, 44 | grid: { 45 | color: "rgba(254, 243, 199, 0.7)", 46 | lineWidth: 2, 47 | drawBorder: false 48 | } 49 | }, 50 | x: { 51 | grid: { 52 | display: false 53 | } 54 | } 55 | }, 56 | plugins: { 57 | legend: { 58 | display: this.displayLegend(), 59 | position: "bottom" 60 | }, 61 | datalabels: { 62 | labels: { 63 | title: { 64 | font: { 65 | weight: "bold", 66 | lineHeight: 1.3 67 | }, 68 | textAlign: "center", 69 | align: "top", 70 | anchor: "end", 71 | offset: 5 72 | } 73 | }, 74 | formatter: calcConversionRates 75 | } 76 | } 77 | } 78 | } 79 | Chart.defaults.font.family = "'-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Oxygen-Sans', 'Ubuntu', 'Cantarell', 'Helvetica Neue', sans-serif"; 80 | new Chart( 81 | this.chartTarget, 82 | config 83 | ); 84 | } 85 | 86 | displayLegend() { 87 | return false; 88 | } 89 | 90 | createDataSet(data) { 91 | return [{ 92 | backgroundColor: this.colorPalette[0], 93 | borderColor: this.colorPalette[0], 94 | borderWidth: 0, 95 | borderRadius: 4, 96 | hoverBorderWidth: 4, 97 | hoverBorderColor: "rgb(35, 112, 144)", 98 | maxBarThickness: 100, 99 | data: data, 100 | }] 101 | } 102 | 103 | initColorPalette() { 104 | this.colorPalette = [ 105 | "rgb(39, 125, 161)", 106 | "rgb(87, 117, 144)", 107 | "rgb(77, 144, 142)", 108 | "rgb(67, 170, 139)", 109 | "rgb(144, 190, 109)", 110 | "rgb(249, 199, 79)", 111 | "rgb(249, 132, 74)", 112 | "rgb(248, 150, 30)", 113 | "rgb(243, 114, 44)", 114 | "rgb(249, 65, 68)" 115 | ] 116 | } 117 | 118 | htmlDecode(input) { 119 | var doc = new DOMParser().parseFromString(input, "text/html"); 120 | return doc.documentElement.textContent; 121 | } 122 | } 123 | 124 | const calcConversionRates = (value, context) => { 125 | let percentages = context.chart.data.datasets[0].data.map((element, index, array) => Math.round(element/array[index-1]*100)); 126 | percentages[0] = ""; 127 | if (context.dataIndex == 0) { 128 | return value; 129 | } 130 | return `${value}\n(${percentages[context.dataIndex] || 0}%)`; 131 | } 132 | -------------------------------------------------------------------------------- /app/javascript/controllers/index.js: -------------------------------------------------------------------------------- 1 | // Import and register all your controllers from the importmap under controllers/* 2 | 3 | import { application } from "controllers/application" 4 | import { registerControllersFrom } from "@hotwired/stimulus-importmap-autoloader" 5 | registerControllersFrom("controllers", application) 6 | -------------------------------------------------------------------------------- /app/javascript/controllers/selection_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus" 2 | 3 | export default class extends Controller { 4 | navigateToSelectUrl(event) { 5 | window.location.href = event.currentTarget.selectedOptions[0].dataset.url; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /app/jobs/application_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationJob < ActiveJob::Base 4 | # Automatically retry jobs that encountered a deadlock 5 | # retry_on ActiveRecord::Deadlocked 6 | 7 | # Most jobs are safe to ignore if the underlying records are no longer available 8 | # discard_on ActiveJob::DeserializationError 9 | end 10 | -------------------------------------------------------------------------------- /app/mailers/application_mailer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationMailer < ActionMailer::Base 4 | default from: "from@example.com" 5 | layout "mailer" 6 | end 7 | -------------------------------------------------------------------------------- /app/models/api_key.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # == Schema Information 4 | # 5 | # Table name: api_keys 6 | # 7 | # id :bigint not null, primary key 8 | # key_value :string not null 9 | # test :boolean default(FALSE) 10 | # created_at :datetime not null 11 | # updated_at :datetime not null 12 | # project_id :bigint not null 13 | # 14 | # Indexes 15 | # 16 | # index_api_keys_on_key_value (key_value) UNIQUE 17 | # index_api_keys_on_project_id (project_id) 18 | # 19 | # Foreign Keys 20 | # 21 | # fk_rails_... (project_id => projects.id) 22 | # 23 | 24 | class ApiKey < ApplicationRecord 25 | has_many :events, dependent: :destroy 26 | has_many :funnels, dependent: :destroy 27 | 28 | belongs_to :project, validate: true 29 | 30 | validates :key_value, presence: true, uniqueness: true 31 | 32 | before_validation :generate_key 33 | 34 | def generate_key 35 | self.key_value ||= SecureRandom.hex 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationRecord < ActiveRecord::Base 4 | self.abstract_class = true 5 | end 6 | -------------------------------------------------------------------------------- /app/models/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shafy/fugu/610c06390f650d485b389b4d375354b9a943ce57/app/models/concerns/.keep -------------------------------------------------------------------------------- /app/models/concerns/inactivable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This validation is only run for the Fugu Cloud version (does not apply for self-hosting) 4 | module Inactivable 5 | extend ActiveSupport::Concern 6 | 7 | included do 8 | validate :user_cannot_be_inactive, if: -> { ENV["FUGU_CLOUD"] == "true" } 9 | end 10 | 11 | private 12 | 13 | def user_cannot_be_inactive 14 | return unless api_key 15 | 16 | return unless !api_key.test && api_key.project.user.inactive? 17 | 18 | errors.add(:base, "You need an active subscription to capture events with your live API key") 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /app/models/funnel.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: funnels 4 | # 5 | # id :bigint not null, primary key 6 | # name :string not null 7 | # created_at :datetime not null 8 | # updated_at :datetime not null 9 | # api_key_id :bigint not null 10 | # 11 | # Indexes 12 | # 13 | # index_funnels_on_api_key_id (api_key_id) 14 | # index_funnels_on_name_and_api_key_id (name,api_key_id) UNIQUE 15 | # 16 | # Foreign Keys 17 | # 18 | # fk_rails_... (api_key_id => api_keys.id) 19 | # 20 | 21 | # frozen_string_literal: true 22 | 23 | class Funnel < ApplicationRecord 24 | belongs_to :api_key 25 | 26 | has_many :funnel_steps, dependent: :destroy 27 | accepts_nested_attributes_for :funnel_steps, 28 | reject_if: ->(attr) { attr[:event_name].blank? } 29 | 30 | validates :name, 31 | presence: true, 32 | uniqueness: { 33 | scope: :api_key_id, 34 | message: "You already have a funnel with this name", 35 | case_insensitive: true 36 | }, 37 | format: 38 | { 39 | with: /\A[a-zA-Z0-9\s]*\z/, 40 | message: "can only contain numbers, letters and spaces" 41 | } 42 | 43 | validates :funnel_steps, presence: { message: "- Add at least one funnel step" } 44 | 45 | before_validation :titleize_name 46 | before_validation :strip_name 47 | 48 | FUNNEL_PARAMS = %i[user_id project_slug slug test event prop date embed].freeze 49 | 50 | private 51 | 52 | def titleize_name 53 | self.name = name.downcase.titleize if name 54 | end 55 | 56 | def strip_name 57 | self.name = name.strip if name 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /app/models/funnel_step.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: funnel_steps 4 | # 5 | # id :bigint not null, primary key 6 | # event_name :string not null 7 | # order :integer 8 | # created_at :datetime not null 9 | # updated_at :datetime not null 10 | # funnel_id :bigint not null 11 | # 12 | # Indexes 13 | # 14 | # index_funnel_steps_on_funnel_id (funnel_id) 15 | # 16 | # Foreign Keys 17 | # 18 | # fk_rails_... (funnel_id => funnels.id) 19 | # 20 | 21 | # frozen_string_literal: true 22 | 23 | class FunnelStep < ApplicationRecord 24 | belongs_to :funnel 25 | 26 | validates :event_name, presence: true 27 | end 28 | -------------------------------------------------------------------------------- /app/models/project.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # == Schema Information 4 | # 5 | # Table name: projects 6 | # 7 | # id :bigint not null, primary key 8 | # name :string not null 9 | # public :boolean default(FALSE) 10 | # created_at :datetime not null 11 | # updated_at :datetime not null 12 | # user_id :bigint not null 13 | # 14 | # Indexes 15 | # 16 | # index_projects_on_name_and_user_id (name,user_id) UNIQUE 17 | # index_projects_on_user_id (user_id) 18 | # 19 | # Foreign Keys 20 | # 21 | # fk_rails_... (user_id => users.id) 22 | # 23 | 24 | class Project < ApplicationRecord 25 | has_many :api_keys, dependent: :destroy 26 | belongs_to :user 27 | 28 | validates :name, 29 | presence: true, 30 | length: { maximum: 40 }, 31 | uniqueness: { 32 | scope: :user_id, 33 | message: "You already have a project with this name", 34 | case_insensitive: true 35 | }, 36 | format: 37 | { 38 | with: /\A[a-zA-Z0-9-]*\z/, 39 | message: "can only contain numbers, letters and hyphens" 40 | }, 41 | exclusion: 42 | { 43 | in: %w[project projects], 44 | message: "'%{value}' is a reversed event name by Fugu and can't be used" 45 | } 46 | 47 | before_validation :downcase_name 48 | before_validation :strip_name 49 | 50 | def downcase_name 51 | self.name = name.downcase if name 52 | end 53 | 54 | def strip_name 55 | self.name = name.strip if name 56 | end 57 | 58 | def create_api_keys 59 | ApiKey.create(project: self, test: false) 60 | ApiKey.create(project: self, test: true) 61 | end 62 | 63 | def api_key_live 64 | api_keys.find_by(test: false) 65 | end 66 | 67 | def api_key_test 68 | api_keys.find_by(test: true) 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /app/models/user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # == Schema Information 4 | # 5 | # Table name: users 6 | # 7 | # id :bigint not null, primary key 8 | # confirmation_sent_at :datetime 9 | # confirmation_token :string 10 | # confirmed_at :datetime 11 | # email :string default(""), not null 12 | # encrypted_password :string default(""), not null 13 | # remember_created_at :datetime 14 | # reset_password_sent_at :datetime 15 | # reset_password_token :string 16 | # status :integer default("inactive"), not null 17 | # unconfirmed_email :string 18 | # created_at :datetime not null 19 | # updated_at :datetime not null 20 | # hash_id :string not null 21 | # stripe_customer_id :string 22 | # 23 | # Indexes 24 | # 25 | # index_users_on_confirmation_token (confirmation_token) UNIQUE 26 | # index_users_on_email (email) UNIQUE 27 | # index_users_on_hash_id (hash_id) UNIQUE 28 | # index_users_on_reset_password_token (reset_password_token) UNIQUE 29 | # 30 | 31 | class User < ApplicationRecord 32 | has_many :projects, dependent: :destroy 33 | 34 | validates :hash_id, presence: true, allow_blank: false 35 | 36 | enum status: { 37 | inactive: 0, 38 | active: 1, 39 | canceled: 2 40 | } 41 | 42 | # Include default devise modules. Others available are: 43 | # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable 44 | devise :database_authenticatable, :registerable, 45 | :recoverable, :rememberable, :validatable 46 | 47 | before_validation :add_hash_id, on: :create 48 | 49 | def add_hash_id 50 | # generate random hash_id for the user before being created 51 | return if hash_id.present? 52 | 53 | hash = SecureRandom.alphanumeric(4).downcase 54 | unless User.exists?(hash_id: hash) 55 | self.hash_id = hash 56 | return 57 | end 58 | add_hash_id 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /app/services/fugu_service.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class FuguService 4 | def self.track(name, properties = {}) 5 | return unless ENV.fetch("FUGU_URL", nil) 6 | 7 | return if Rails.env.test? 8 | 9 | body = { 10 | api_key: ENV.fetch("FUGU_API_KEY", nil), 11 | name: name, 12 | properties: properties 13 | } 14 | 15 | begin 16 | Faraday.post(ENV.fetch("FUGU_URL", nil)) do |req| 17 | req.headers["Content-Type"] = "application/json" 18 | req.body = body.to_json 19 | end 20 | rescue StandardError => e 21 | logger = Logger.new($stdout) 22 | logger.error("Error while calling Fugu API: #{e}") 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /app/views/development/embed_mock/index.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :main do %> 2 | 3 |

Embedding testing ground

4 | 14 | <% end %> -------------------------------------------------------------------------------- /app/views/events/_chart.html.erb: -------------------------------------------------------------------------------- 1 |
12 |
13 | Total events 14 |
15 | <%= @total_count %> 16 |
17 |
18 |
19 | 20 |
21 | <% if @property_values.present? %> 22 |
23 |

Property values

24 |
25 |
26 |

Selected

27 | <%= link_to "(deselect all)", 28 | "", 29 | class: "bg-transparent underline text-sm font-light hover:text-green-500 cursor-pointer", 30 | data: { action: "chart#deselectAllPropValues" } 31 | %> 32 |
33 |
    34 |
35 |
36 |
37 |
38 |

Not selected

39 | <%= link_to "(select all)", 40 | "", 41 | class: "bg-transparent underline text-sm font-light hover:text-green-500 cursor-pointer", 42 | data: { action: "chart#selectAllPropValues" } 43 | %> 44 |
45 |
    46 |
47 |
48 | 52 |
53 | <% end %> 54 |
-------------------------------------------------------------------------------- /app/views/events/_selections.html.erb: -------------------------------------------------------------------------------- 1 |
6 |
7 |
8 | <%= label_tag 'events', 'Events', class: "text-sm font-medium mb-2" %> 9 | <%= select_tag "events", 10 | raw(event_select_options(param_data, aggregation, event_names)), 11 | data: { action: "change->selection#navigateToSelectUrl" }, 12 | class: "w-56 text-sm" 13 | %> 14 |
15 |
16 | <%= label_tag 'properties', 'Properties', class: "text-sm font-medium mb-2" %> 17 | <%= select_tag "properties", 18 | raw(property_select_options(param_data, aggregation, properties)), 19 | data: { action: "change->selection#navigateToSelectUrl" }, 20 | class: "w-44 text-sm" 21 | %> 22 |
23 |
24 | <%= label_tag 'aggregation', 'Aggregation', class: "text-sm font-medium mb-2" %> 25 | <%= select_tag "aggregation", 26 | raw(agg_select_options(param_data, aggregation, possible_aggregations)), 27 | data: { action: "change->selection#navigateToSelectUrl" }, 28 | class: "text-sm" 29 | %> 30 |
31 |
32 |
33 |
34 | <%= label_tag 'date_range', 'Date Range', class: "text-sm font-medium mb-2" %> 35 | <%= select_tag "date_range", 36 | raw(date_select_options(param_data, aggregation, "event")), 37 | data: { action: "change->selection#navigateToSelectUrl" }, 38 | class: "text-sm" 39 | %> 40 |
41 |
42 |
-------------------------------------------------------------------------------- /app/views/events/index.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :project_header do %> 2 | <%= render partial: "partials/project_header", locals: { project_name: @project.name } %> 3 | <% end %> 4 | 5 | <% content_for :no_data do %> 6 |

7 | You haven't tracked any events for this project yet. 8 | <% if current_user %> 9 | <%# we don't want to show the rest for public projects %> 10 | To get started, call the Fugu API from your mobile or web app. 11 | Check out the 12 | docs to learn more.

13 | Tip: You'll find your API keys in your 14 | <%= link_to "project settings", user_project_settings_path(current_user.hash_id) %>.

15 | <% if params[:test] == "true" %> 16 | Did you track events with your live API key? 17 | <%= link_to "Toggle live mode!", user_project_events_path(current_user.hash_id, @project.name) %> 18 | <% else %> 19 | Did you track events with your test API key? 20 | <%= link_to "Toggle test mode!", user_project_events_path(current_user.hash_id, @project.name, params: { test: true }) %> 21 | <% end %> 22 | <% end %> 23 |

24 | <% end %> -------------------------------------------------------------------------------- /app/views/events/show.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :project_header do %> 2 | <%= render partial: "partials/project_header", locals: { project_name: @project.name } %> 3 | <% end %> 4 | 5 | <% content_for :data_controls do %> 6 | <%= render partial: "selections", 7 | locals: { 8 | param_data: params, 9 | event_names: @event_names, 10 | properties: @properties, 11 | possible_aggregations: @possible_aggregations, 12 | aggregation: @aggregation 13 | } 14 | %> 15 | <% end%> 16 | 17 | <% content_for :chart_view do %> 18 | <%= render partial: "chart", 19 | locals: { 20 | dates: @dates, 21 | events: @events, 22 | selected_event: @selected_event, 23 | property_values: @property_values, 24 | property_name: @property, 25 | project_id: @project.id 26 | } 27 | %> 28 | <% end %> 29 | 30 | <% content_for :project_footer do %> 31 |
32 | <%= link_to "Project settings", user_project_settings_path(params[:user_id]), class: "font-normal" %> 33 | <%= render partial: "partials/mode_toggle", 34 | locals: { 35 | project_name: @project.name, 36 | test_mode: params[:test] == "true", 37 | aggregation: @aggregation, 38 | chart_type: "event" 39 | } 40 | %> 41 |
42 | <% end %> -------------------------------------------------------------------------------- /app/views/funnels/_chart.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
7 | 8 |
9 |
-------------------------------------------------------------------------------- /app/views/funnels/_selections.html.erb: -------------------------------------------------------------------------------- 1 |
5 |
6 |
7 | <%= label_tag 'funnels', 'Funnels', class: "text-sm font-medium mb-2" %> 8 | <%= select_tag "funnels", 9 | raw(funnel_select_options(param_data, funnel_names)), 10 | data: { action: "change->selection#navigateToSelectUrl" }, 11 | class: "w-56 text-sm" 12 | %> 13 |
14 |
15 |
16 |
17 | <%= label_tag 'date_range', 'Date Range', class: "text-sm font-medium mb-2" %> 18 | <%= select_tag "date_range", 19 | raw(date_select_options(param_data, nil, "funnel")), 20 | data: { action: "change->selection#navigateToSelectUrl" }, 21 | class: "text-sm" 22 | %> 23 |
24 |
25 |
-------------------------------------------------------------------------------- /app/views/funnels/index.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :project_header do %> 2 | <%= render partial: "partials/project_header", locals: { project_name: @project.name } %> 3 | <% end %> 4 | 5 | <% content_for :no_data do %> 6 |

7 | You haven't created any funnels for this project yet. 8 | <% if current_user %> 9 | <%# we don't want to show the rest for public projects %> 10 | To get started, <%= link_to "create a funnel", new_user_project_funnel_path(current_user.hash_id, @project.name, params: { test: params[:test] }) %>.

11 | <% if params[:test] == "true" %> 12 | Did you create funnels in live mode? 13 | <%= link_to "Toggle live mode!", user_project_funnels_path(current_user.hash_id, @project.name) %> 14 | <% else %> 15 | Did you create funnels in test mode? 16 | <%= link_to "Toggle test mode!", user_project_funnels_path(current_user.hash_id, @project.name, params: { test: true }) %> 17 | <% end %> 18 | <% end %> 19 |

20 | <% end %> -------------------------------------------------------------------------------- /app/views/funnels/new.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :main do %> 2 |

Create new funnel

3 | <%= render "partials/narrow_box" do %> 4 | <%= form_with model: @funnel, url: user_project_funnels_path do |funnel_form| %> 5 |

Give your funnel a name:

6 |
7 |
8 | <%= funnel_form.label :name, "Name" %> 9 |
10 | <%= funnel_form.text_field :name, required: true, placeholder: "E.g., Sign up to subscription", class: "w-64 rounded border-2 border-gray-600" %> 11 |
12 |

Select an event for each funnel step:

13 |
14 | <%= funnel_form.fields_for :funnel_steps do |funnel_steps_form| %> 15 |
16 |
17 | <%= funnel_steps_form.label :event_name, "Step #{funnel_steps_form.index + 1}" %> 18 |
19 | <%= 20 | funnel_steps_form.select :event_name, 21 | options_for_select(@event_names, "Select event"), 22 | {include_blank: "Select event"}, 23 | required: false, #funnel_steps_form.index == 0, 24 | class: "w-64" 25 | %> 26 |
27 | <% end %> 28 |
29 | <%= hidden_field_tag "test", params[:test] %> 30 | <%= funnel_form.submit "Create funnel", class: "main-button" %> 31 | <% end %> 32 | <% end %> 33 | <%= link_to "Cancel and go back to #{@project.name}", user_project_events_path(current_user.hash_id, @project.name) %> 34 | <% if params[:test] == "true" %> 35 | <%= link_to "Toggle LIVE mode", new_user_project_funnel_path(current_user.hash_id, @project.name, params: { test: false }), class: "font-normal mt-2" %> 36 | <% else %> 37 | <%= link_to "Toggle TEST mode", new_user_project_funnel_path(current_user.hash_id, @project.name, params: { test: true }), class: "font-normal mt-2" %> 38 | <% end %> 39 | <% end %> -------------------------------------------------------------------------------- /app/views/funnels/show.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :project_header do %> 2 | <%= render partial: "partials/project_header", locals: { project_name: @project.name } %> 3 | <% end %> 4 | 5 | <% content_for :data_controls do %> 6 | <%= render partial: "selections", 7 | locals: { 8 | param_data: params, 9 | funnel_names: @funnel_names, 10 | properties: @properties 11 | } 12 | %> 13 | <% end%> 14 | 15 | <% content_for :chart_view do %> 16 | <%= render partial: "chart", 17 | locals: { 18 | funnel_event_names: @funnel_event_names, 19 | funnel_data: @funnel_data, 20 | } 21 | %> 22 | <% end %> 23 | 24 | <% content_for :project_footer do %> 25 |
26 | <%= link_to "Create new funnel", 27 | new_user_project_funnel_path(params[:user_id], @project.name, params: { test: params[:test] }), 28 | class: "font-normal" 29 | %> 30 | <%= link_to "Project settings", user_project_settings_path(params[:user_id]), class: "font-normal" %> 31 | <%= render partial: "partials/mode_toggle", 32 | locals: { 33 | project_name: @project.name, 34 | test_mode: params[:test] == "true", 35 | aggregation: nil, 36 | chart_type: "funnel" 37 | } if current_user 38 | %> 39 |
40 | <% end %> -------------------------------------------------------------------------------- /app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Fugu 5 | 6 | 7 | <%= csrf_meta_tags %> 8 | <%= csp_meta_tag %> 9 | 10 | <%= favicon_link_tag asset_path('fugu_favicon.png') %> 11 | 12 | <%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %> 13 | <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> 14 | <%= javascript_importmap_tags %> 15 | 16 | <%= yield :head %> 17 | 18 | 19 | 20 | <% unless params[:embed] == "true" %> 21 | <%= render "partials/header" %> 22 | <% flash.each do |key, value| %> 23 | <%= content_tag :div, 24 | value, 25 | class: "flex flex-row text-center justify-center border-2 p-4 shadow-sm flash-#{key}" unless value.blank? %> 26 | <% end %> 27 | <% end %> 28 |
29 | <%= content_for?(:data_view) ? yield(:data_view) : yield(:main) %> 30 |
31 | <%= render "partials/footer" unless params[:embed] == "true" %> 32 | 33 | 34 | -------------------------------------------------------------------------------- /app/views/layouts/data_view.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :head do %> 2 | 3 | <% end %> 4 | 5 | <% content_for :data_view do %> 6 | <%= yield :project_header %> 7 | 8 | <% unless content_for?(:no_data) %> 9 |
10 | <%= yield :data_controls %> 11 |
12 |
13 | <%= yield :chart_view %> 14 |
15 | 16 | <% else %> 17 |
18 | <%= yield :no_data %> 19 |
20 | <% end %> 21 | 22 | <%= yield :project_footer if current_user && params[:embed] != "true" %> 23 | <% end %> 24 | 25 | <%= render template: "layouts/application" %> -------------------------------------------------------------------------------- /app/views/layouts/mailer.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/views/layouts/mailer.text.erb: -------------------------------------------------------------------------------- 1 | <%= yield %> 2 | -------------------------------------------------------------------------------- /app/views/partials/_footer.html.erb: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/views/partials/_header.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 | 6 |
7 | <% if current_user %> 8 | Docs 9 | Projects 10 | Account settings 11 | <% end %> 12 |
13 |
14 |
-------------------------------------------------------------------------------- /app/views/partials/_mode_toggle.html.erb: -------------------------------------------------------------------------------- 1 | <% if test_mode %> 2 | <%= link_to "Toggle LIVE mode", build_test_toggle_url(params, aggregation, false, chart_type), class: "font-normal" %> 3 | <% else %> 4 | <%= link_to "Toggle TEST mode", build_test_toggle_url(params, aggregation, true, chart_type), class: "font-normal" %> 5 | <% end %> -------------------------------------------------------------------------------- /app/views/partials/_narrow_box.erb: -------------------------------------------------------------------------------- 1 |
2 | <%= yield %> 3 |
-------------------------------------------------------------------------------- /app/views/partials/_project_header.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 |
<%= project_name %>
4 | 5 |
6 | <%= link_to "Events", 7 | user_project_events_path(params[:user_id], @project.name, evergreen_params) 8 | %> 9 | <%= link_to "Funnels", 10 | user_project_funnels_path(params[:user_id], @project.name, evergreen_params), 11 | class: "ml-4" 12 | %> 13 |
-------------------------------------------------------------------------------- /app/views/projects/edit.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :main do %> 2 |

Edit project

3 | <%= form_with model: @project, url: user_project_path do |form| %> 4 |
5 |
6 | <%= form.label :name, 'Project name' %> 7 |
8 | <%= form.text_field :name, autofocus: true, required: true, class: "rounded-lg w-full" %> 9 | 10 | Project name can't include any special characters except "-". 11 | 12 |
13 |
14 |
15 | <%= form.label :public, 'Make public' %> 16 | <%= form.check_box :public, class: 'ml-2' %> 17 |
18 | 19 | Public projects can be viewed by anyone who has this URL. 20 | 21 |
22 | <%= form.hidden_field :project_id, value: @project.id %> 23 |
24 | <%= form.submit 'Update', class: 'main-button' %> 25 |
26 | <% end %> 27 | <% end %> -------------------------------------------------------------------------------- /app/views/projects/index.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :main do %> 2 |

Projects

3 | <%= render "partials/narrow_box" do %> 4 | <% if @projects.any? %> 5 | <% @projects.each do |project| %> 6 |
7 | <%= link_to project.name, user_project_events_path(current_user.hash_id, project.name), class: "no-underline text-lg" %> 8 |
9 | <% end %> 10 | <% else %> 11 |

12 | Welcome to Fugu 👋 To get started, add a new project 13 | and track your first event by calling the Fugu API. 14 | Have a look at the 15 | docs for more info. 16 |

17 | <% end %> 18 | <%= link_to 'Add a new project', new_user_project_path(current_user.hash_id), class: "mt-4" %> 19 | <% end %> 20 | <% end %> -------------------------------------------------------------------------------- /app/views/projects/new.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :main do %> 2 |

Add a new project

3 | <%= form_with model: @project, url: user_projects_path do |form| %> 4 |
5 |
6 | <%= form.label :name, 'Project name' %> 7 |
8 | <%= form.text_field :name, autofocus: true, required: true, class: 'rounded-lg w-full' %> 9 | 10 | Project name can't include any special characters except "-". 11 | 12 |
13 |
14 | <%= form.submit 'Add', class: 'main-button' %> 15 |
16 | <% end %> 17 | <% end %> -------------------------------------------------------------------------------- /app/views/projects/settings.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :main do %> 2 |

Project settings

3 | <%= render "partials/narrow_box" do %> 4 |

Project name

5 |

6 | <%= @project.name %> 7 |

8 |

Visibility

9 |

10 | <% unless @project.public %> 11 | This project is private. 12 | <% else %> 13 | This project is public. Share the project URL with your friends to impress them. 14 | <% end %> 15 |

16 |

API keys

17 |
18 |
Test API key
<%= @project.api_key_test.key_value %> 19 |
Test events are deleted after 14 days
20 |
Live API key
<%= @project.api_key_live.key_value %> 21 |
22 |

Embedding

23 | <% if @project.public %> 24 |

25 | Copy and paste the embed code anywhere to display your project. 26 | Your project's visibility needs to be set to public. 27 | Check out the docs 28 | to learn how to customize your embed code. 29 |

30 |
31 | 32 | <%= 33 | %() 34 | %> 35 | 36 |
37 | <% else %> 38 | To display your embed code, edit your project and set it's visibility to public. 39 | <% end %> 40 |
41 | <%= link_to "Edit project", 42 | edit_user_project_path(current_user.hash_id, @project.name), 43 | class: "bg-transparent underline font-medium hover:text-green-500 cursor-pointer" 44 | %> 45 | <%= button_to "Delete project", 46 | user_project_path(current_user.hash_id, @project.name), 47 | method: :delete, 48 | class: "bg-transparent underline font-medium hover:text-green-500 cursor-pointer mt-2", 49 | onclick: "return confirm('This will delete this project and all its events forever. Are you sure?');" 50 | %> 51 |
52 | <% end %> 53 | <%= link_to "Back to #{@project.name}", user_project_events_path(current_user.hash_id, @project.name) %> 54 | <% end %> -------------------------------------------------------------------------------- /app/views/users/confirmations/new.html.erb: -------------------------------------------------------------------------------- 1 |

Resend confirmation instructions

2 | 3 | <%= form_for(resource, as: resource_name, url: confirmation_path(resource_name), html: { method: :post }) do |f| %> 4 | <%= render "devise/shared/error_messages", resource: resource %> 5 | 6 |
7 | <%= f.label :email %>
8 | <%= f.email_field :email, autofocus: true, autocomplete: "email", value: (resource.pending_reconfirmation? ? resource.unconfirmed_email : resource.email) %> 9 |
10 | 11 |
12 | <%= f.submit "Resend confirmation instructions" %> 13 |
14 | <% end %> 15 | 16 | <%= render "devise/shared/links" %> 17 | -------------------------------------------------------------------------------- /app/views/users/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/views/users/mailer/email_changed.html.erb: -------------------------------------------------------------------------------- 1 |

Hello <%= @email %>!

2 | 3 | <% if @resource.try(:unconfirmed_email?) %> 4 |

We're contacting you to notify you that your email is being changed to <%= @resource.unconfirmed_email %>.

5 | <% else %> 6 |

We're contacting you to notify you that your email has been changed to <%= @resource.email %>.

7 | <% end %> 8 | -------------------------------------------------------------------------------- /app/views/users/mailer/password_change.html.erb: -------------------------------------------------------------------------------- 1 |

Hello <%= @resource.email %>!

2 | 3 |

We're contacting you to notify you that your password has been changed.

4 | -------------------------------------------------------------------------------- /app/views/users/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/views/users/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 | -------------------------------------------------------------------------------- /app/views/users/passwords/edit.html.erb: -------------------------------------------------------------------------------- 1 |

Change your password

2 | 3 | <%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :put }) do |f| %> 4 | <%= render "devise/shared/error_messages", resource: resource %> 5 | <%= f.hidden_field :reset_password_token %> 6 | 7 |
8 | <%= f.label :password, "New password" %>
9 | <% if @minimum_password_length %> 10 | (<%= @minimum_password_length %> characters minimum)
11 | <% end %> 12 | <%= f.password_field :password, autofocus: true, autocomplete: "new-password" %> 13 |
14 | 15 |
16 | <%= f.label :password_confirmation, "Confirm new password" %>
17 | <%= f.password_field :password_confirmation, autocomplete: "new-password" %> 18 |
19 | 20 |
21 | <%= f.submit "Change my password" %> 22 |
23 | <% end %> 24 | 25 | <%= render "devise/shared/links" %> 26 | -------------------------------------------------------------------------------- /app/views/users/passwords/new.html.erb: -------------------------------------------------------------------------------- 1 |

Forgot your password?

2 | 3 | <%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :post }) do |f| %> 4 | <%= render "devise/shared/error_messages", resource: resource %> 5 | 6 |
7 | <%= f.label :email %>
8 | <%= f.email_field :email, autofocus: true, autocomplete: "email" %> 9 |
10 | 11 |
12 | <%= f.submit "Send me reset password instructions" %> 13 |
14 | <% end %> 15 | 16 | <%= render "devise/shared/links" %> 17 | -------------------------------------------------------------------------------- /app/views/users/registrations/edit.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :main do %> 2 |

Edit <%= resource_name.to_s.humanize %>

3 | 4 | <%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put }) do |f| %> 5 | <%= render "devise/shared/error_messages", resource: resource %> 6 | 7 |
8 | <%= f.label :email %>
9 | <%= f.email_field :email, autofocus: true, autocomplete: "email" %> 10 |
11 | 12 | <% if devise_mapping.confirmable? && resource.pending_reconfirmation? %> 13 |
Currently waiting confirmation for: <%= resource.unconfirmed_email %>
14 | <% end %> 15 | 16 |
17 | <%= f.label :password %> (leave blank if you don't want to change it)
18 | <%= f.password_field :password, autocomplete: "new-password" %> 19 | <% if @minimum_password_length %> 20 |
21 | <%= @minimum_password_length %> characters minimum 22 | <% end %> 23 |
24 | 25 |
26 | <%= f.label :password_confirmation %>
27 | <%= f.password_field :password_confirmation, autocomplete: "new-password" %> 28 |
29 | 30 |
31 | <%= f.label :current_password %> (we need your current password to confirm your changes)
32 | <%= f.password_field :current_password, autocomplete: "current-password" %> 33 |
34 | 35 |
36 | <%= f.submit "Update" %> 37 |
38 | <% end %> 39 | 40 |

Cancel my account

41 | 42 |

Unhappy? <%= button_to "Cancel my account", registration_path(resource_name), data: { confirm: "Are you sure?" }, method: :delete %>

43 | 44 | <%= link_to "Back", :back %> 45 | <% end %> 46 | -------------------------------------------------------------------------------- /app/views/users/registrations/new.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :main do %> 2 | 3 |

Sign up

4 | 5 |
6 | <% unless ENV["ALLOW_REGISTRATION"] == "true" %> 7 |
8 |

9 | The admin of this instance currently doesn't allow new registrations for Fugu. 10 |

11 |
12 | <% else %> 13 |
14 |

15 | Welcome to Fugu! To get started, create an account. 16 |

17 |

18 | You can start tracking test events for free, but will need to subscribe for live mode. 19 |

20 |
21 | 22 | <%= form_for(resource, as: resource_name, url: registration_path(resource_name), data: { turbo: false }) do |f| %> 23 |
24 | <%= f.label :email %>
25 | <%= f.email_field :email, autocomplete: "email", class: "mt-2 rounded-lg w-full" %> 26 |
27 | 28 |
29 | <%= f.label :password %> 30 | <% if @minimum_password_length %> 31 | (<%= @minimum_password_length %> characters minimum) 32 | <% end %>
33 | <%= f.password_field :password, autocomplete: "new-password", class: "mt-2 rounded-lg w-full" %> 34 |
35 | 36 |
37 | <%= f.label :password_confirmation %>
38 | <%= f.password_field :password_confirmation, autocomplete: "new-password", class: "mt-2 rounded-lg w-full" %> 39 |
40 | 41 |
42 | <%= f.submit "Sign up", class: "main-button border-none" %> 43 |
44 | <% end %> 45 | <% end %> 46 | <%= render "devise/shared/links" %> 47 |
48 | <% end %> 49 | -------------------------------------------------------------------------------- /app/views/users/sessions/new.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :main do %> 2 |

Log in

3 | 4 |
5 |
6 |

7 | Welcome back! Log in to access your account. 8 |

9 |
10 | 11 | <%= form_for(resource, as: resource_name, url: session_path(resource_name), data: { turbo: false }) do |f| %> 12 |
13 | <%= f.label :email %>
14 | <%= f.email_field :email, autofocus: true, autocomplete: "email", class: "mt-2 rounded-lg w-full" %> 15 |
16 | 17 |
18 | <%= f.label :password %>
19 | <%= f.password_field :password, autocomplete: "current-password", class: "mt-2 rounded-lg w-full" %> 20 |
21 | 22 | <% if devise_mapping.rememberable? %> 23 |
24 | <%= f.check_box :remember_me, class: "cursor-pointer" %> 25 | <%= f.label :remember_me, class: "cursor-pointer" %> 26 |
27 | <% end %> 28 | 29 |
30 | <%= f.submit "Log in", class: "main-button border-none" %> 31 |
32 | <% end %> 33 | 34 | <%= render "devise/shared/links" %> 35 |
36 | 37 | <% end %> -------------------------------------------------------------------------------- /app/views/users/settings/_subscription.html.erb: -------------------------------------------------------------------------------- 1 | <%# Only needed by Fugu Cloud %> 2 | 3 |

Subscription

4 |

5 | <% if current_user.active? %> 6 | Your Fugu subscription is active and renews monthly.
7 | <%= link_to "Cancel subscription or update payment method", stripe_customer_portal_url, 8 | data: { turbo: false } 9 | %> 10 | <% elsif current_user.canceled? %> 11 | Your Fugu subscription is canceled, but will remain active until <%= cancel_at %>.
12 | <%= link_to "Renew subscription", stripe_customer_portal_url, 13 | data: { turbo: false } 14 | %> 15 | <% else %> 16 | You don't have an active subscription.
17 | Fugu costs $9/month and includes 1 million events/month.
18 | <%= link_to "Start your subscription now", stripe_checkout_session_url, 19 | data: { turbo: false } 20 | %> 21 | <% if current_user.stripe_customer_id.present? %> 22 |
23 | <%= link_to "View past invoices", stripe_customer_portal_url, 24 | data: { turbo: false } 25 | %> 26 | <% end %> 27 | <% end %> 28 |

-------------------------------------------------------------------------------- /app/views/users/settings/_support.html.erb: -------------------------------------------------------------------------------- 1 | <%# Only needed by Fugu Cloud %> 2 | 3 |

Support

4 |

5 | Thanks for being an early Fugu user ❤️ 6 | Contact me if you have questions or need help: canolcer@hey.com or 7 | @canolcer. 8 |

9 | -------------------------------------------------------------------------------- /app/views/users/settings/show.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :main do %> 2 |

Account settings

3 | <%= render "partials/narrow_box" do %> 4 |

Email

5 |

6 | <%= current_user.email %> 7 |

8 | 9 | <%# only needed by Fugu Cloud (not needed if you're self-hosting) %> 10 | <%= render partial: "subscription", locals: { cancel_at: @cancel_at } if ENV["FUGU_CLOUD"] == "true" %> 11 | <%# only needed by Fugu Cloud (not needed if you're self-hosting) %> 12 | <%= render partial: "support" if ENV["FUGU_CLOUD"] == "true" %> 13 | 14 | <%= button_to "Log out", 15 | destroy_user_session_path, 16 | method: :delete, 17 | class: "bg-transparent underline font-medium hover:text-green-500 cursor-pointer", 18 | data: { turbo: false } 19 | %> 20 | <% end %> 21 | <% end %> -------------------------------------------------------------------------------- /app/views/users/shared/_error_messages.html.erb: -------------------------------------------------------------------------------- 1 | <% if resource.errors.any? %> 2 |
3 |

4 | <%= I18n.t("errors.messages.not_saved", 5 | count: resource.errors.count, 6 | resource: resource.class.model_name.human.downcase) 7 | %> 8 |

9 | 14 |
15 | <% end %> 16 | -------------------------------------------------------------------------------- /app/views/users/shared/_links.html.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 #{OmniAuth::Utils.camelize(provider)}", omniauth_authorize_path(resource_name, provider), method: :post %> 24 | <% end %> 25 | <% end %> 26 | -------------------------------------------------------------------------------- /app/views/users/unlocks/new.html.erb: -------------------------------------------------------------------------------- 1 |

Resend unlock instructions

2 | 3 | <%= form_for(resource, as: resource_name, url: unlock_path(resource_name), html: { method: :post }) do |f| %> 4 | <%= render "devise/shared/error_messages", resource: resource %> 5 | 6 |
7 | <%= f.label :email %>
8 | <%= f.email_field :email, autofocus: true, autocomplete: "email" %> 9 |
10 | 11 |
12 | <%= f.submit "Resend unlock instructions" %> 13 |
14 | <% end %> 15 | 16 | <%= render "devise/shared/links" %> 17 | -------------------------------------------------------------------------------- /bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'bundle' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "rubygems" 12 | 13 | m = Module.new do 14 | module_function 15 | 16 | def invoked_as_script? 17 | File.expand_path($0) == File.expand_path(__FILE__) 18 | end 19 | 20 | def env_var_version 21 | ENV["BUNDLER_VERSION"] 22 | end 23 | 24 | def cli_arg_version 25 | return unless invoked_as_script? # don't want to hijack other binstubs 26 | return unless "update".start_with?(ARGV.first || " ") # must be running `bundle update` 27 | bundler_version = nil 28 | update_index = nil 29 | ARGV.each_with_index do |a, i| 30 | if update_index && update_index.succ == i && a =~ Gem::Version::ANCHORED_VERSION_PATTERN 31 | bundler_version = a 32 | end 33 | next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/ 34 | bundler_version = $1 35 | update_index = i 36 | end 37 | bundler_version 38 | end 39 | 40 | def gemfile 41 | gemfile = ENV["BUNDLE_GEMFILE"] 42 | return gemfile if gemfile && !gemfile.empty? 43 | 44 | File.expand_path("../../Gemfile", __FILE__) 45 | end 46 | 47 | def lockfile 48 | lockfile = 49 | case File.basename(gemfile) 50 | when "gems.rb" then gemfile.sub(/\.rb$/, gemfile) 51 | else "#{gemfile}.lock" 52 | end 53 | File.expand_path(lockfile) 54 | end 55 | 56 | def lockfile_version 57 | return unless File.file?(lockfile) 58 | lockfile_contents = File.read(lockfile) 59 | return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/ 60 | Regexp.last_match(1) 61 | end 62 | 63 | def bundler_version 64 | @bundler_version ||= 65 | env_var_version || cli_arg_version || 66 | lockfile_version 67 | end 68 | 69 | def bundler_requirement 70 | return "#{Gem::Requirement.default}.a" unless bundler_version 71 | 72 | bundler_gem_version = Gem::Version.new(bundler_version) 73 | 74 | requirement = bundler_gem_version.approximate_recommendation 75 | 76 | return requirement unless Gem::Version.new(Gem::VERSION) < Gem::Version.new("2.7.0") 77 | 78 | requirement += ".a" if bundler_gem_version.prerelease? 79 | 80 | requirement 81 | end 82 | 83 | def load_bundler! 84 | ENV["BUNDLE_GEMFILE"] ||= gemfile 85 | 86 | activate_bundler 87 | end 88 | 89 | def activate_bundler 90 | gem_error = activation_error_handling do 91 | gem "bundler", bundler_requirement 92 | end 93 | return if gem_error.nil? 94 | require_error = activation_error_handling do 95 | require "bundler/version" 96 | end 97 | return if require_error.nil? && Gem::Requirement.new(bundler_requirement).satisfied_by?(Gem::Version.new(Bundler::VERSION)) 98 | warn "Activating bundler (#{bundler_requirement}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_requirement}'`" 99 | exit 42 100 | end 101 | 102 | def activation_error_handling 103 | yield 104 | nil 105 | rescue StandardError, LoadError => e 106 | e 107 | end 108 | end 109 | 110 | m.load_bundler! 111 | 112 | if m.invoked_as_script? 113 | load Gem.bin_path("bundler", "bundle") 114 | end 115 | -------------------------------------------------------------------------------- /bin/dev: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if ! command -v foreman &> /dev/null 4 | then 5 | echo "Installing foreman..." 6 | gem install foreman 7 | fi 8 | 9 | foreman start -f Procfile.dev 10 | -------------------------------------------------------------------------------- /bin/importmap: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require_relative "../config/application" 4 | require "importmap/commands" 5 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative "../config/boot" 3 | require "rake" 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "fileutils" 3 | 4 | # path to your application root. 5 | APP_ROOT = File.expand_path("..", __dir__) 6 | 7 | def system!(*args) 8 | system(*args) || abort("\n== Command #{args} failed ==") 9 | end 10 | 11 | FileUtils.chdir APP_ROOT do 12 | # This script is a way to set up or update your development environment automatically. 13 | # This script is idempotent, so that you can run it at any time and get an expectable outcome. 14 | # Add necessary setup steps to this file. 15 | 16 | puts "== Installing dependencies ==" 17 | system! "gem install bundler --conservative" 18 | system("bundle check") || system!("bundle install") 19 | 20 | # puts "\n== Copying sample files ==" 21 | # unless File.exist?("config/database.yml") 22 | # FileUtils.cp "config/database.yml.sample", "config/database.yml" 23 | # end 24 | 25 | puts "\n== Preparing database ==" 26 | system! "bin/rails db:prepare" 27 | 28 | puts "\n== Removing old logs and tempfiles ==" 29 | system! "bin/rails log:clear tmp:clear" 30 | 31 | puts "\n== Restarting application server ==" 32 | system! "bin/rails restart" 33 | end 34 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file is used by Rack-based servers to start the application. 4 | 5 | require_relative "config/environment" 6 | 7 | run Rails.application 8 | Rails.application.load_server 9 | -------------------------------------------------------------------------------- /config/application.rb: -------------------------------------------------------------------------------- 1 | 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 Eventtracker 10 | class Application < Rails::Application 11 | # Initialize configuration defaults for originally generated Rails version. 12 | config.load_defaults 7.0 13 | 14 | # Configuration for the application, engines, and railties goes here. 15 | # 16 | # These settings can be overridden in specific environments using the files 17 | # in config/environments, which are processed later. 18 | # 19 | # config.time_zone = "Central Time (US & Canada)" 20 | # config.eager_load_paths << Rails.root.join("extras") 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /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 | require "bootsnap/setup" # Speed up boot time by caching expensive operations. 5 | -------------------------------------------------------------------------------- /config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: redis 3 | url: redis://localhost:6379/1 4 | 5 | test: 6 | adapter: test 7 | 8 | production: 9 | adapter: redis 10 | url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> 11 | channel_prefix: eventtracker_production 12 | -------------------------------------------------------------------------------- /config/credentials.yml.enc: -------------------------------------------------------------------------------- 1 | C4EOAgibZJ8dZvNg/wLywde3Pkj0sSGCs39X/UnadbDiM25u+sgX4w7n9dM2b7coF/uDE/FTG7aQ/oMMO40tjXEZ26WxuU2hZFnu+Wo4XzqOr0vvARYmHHjYuCllAlvGoaldTQt05f1mlg07FIETmdAyPO1FpT274yxWqrYhRMjhim1HvJP3KGERuFv+XD8Rr+nxg26AmQl3LcixFgTWuRgNf5HhgMJiA4Pozs7dnDsg5U48ZgodMmQqc4eJOpLPLTpqlica0OSeyNxO7ouPYW1MlrPVxR4pjOPmznYpvmCd4D5/ImSwRhEIiEZmUrJWzZ7uVRpGkGbpQJCAQckXlNgg0d5GNo2mHH7cFODDCTv9aA+p0ynxwghwV6KryQQZNJMdV+Ap0d/68p+SK8IFMUcitRMLjIFuy76y--RffyMbSCMWOm3zAX--zfAIgEFygLLXO5St8CYSlA== -------------------------------------------------------------------------------- /config/database.yml: -------------------------------------------------------------------------------- 1 | default: &default 2 | adapter: postgresql 3 | encoding: unicode 4 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> 5 | 6 | 7 | development: 8 | <<: *default 9 | database: <%= ENV.fetch("DATABASE_NAME") { "fugu_dev" } %> 10 | username: <%= ENV["DATABASE_USER"] %> 11 | password: <%= ENV["DATABASE_PASSWORD"] %> 12 | host: <%= ENV.fetch("DATABASE_HOST") { "localhost" } %> 13 | #port: 5432 14 | 15 | # Warning: The database defined as "test" will be erased and 16 | # re-generated from your development database when you run "rake". 17 | # Do not set this db to the same as development or production. 18 | test: 19 | <<: *default 20 | database: <%= ENV.fetch("DATABASE_NAME_TEST") { "fugu_test" } %> 21 | username: <%= ENV["DATABASE_USER"] %> 22 | password: <%= ENV["DATABASE_PASSWORD"] %> 23 | host: <%= ENV.fetch("DATABASE_HOST") { "localhost" } %> 24 | 25 | production: 26 | <<: *default 27 | url: <%= ENV['DATABASE_URL'] %> -------------------------------------------------------------------------------- /config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative "application" 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /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 | # In the development environment your application's code is reloaded any time 7 | # it changes. This slows down response time but is perfect for development 8 | # since you don't have to restart the web server when you make code changes. 9 | config.cache_classes = false 10 | 11 | # Do not eager load code on boot. 12 | config.eager_load = false 13 | 14 | # Show full error reports. 15 | config.consider_all_requests_local = true 16 | 17 | # Enable server timing 18 | config.server_timing = true 19 | 20 | # Allow requests from the Gitpod subdomain 21 | config.hosts.clear 22 | 23 | # Enable/disable caching. By default caching is disabled. 24 | # Run rails dev:cache to toggle caching. 25 | if Rails.root.join("tmp/caching-dev.txt").exist? 26 | config.action_controller.perform_caching = true 27 | config.action_controller.enable_fragment_cache_logging = true 28 | 29 | config.cache_store = :memory_store 30 | config.public_file_server.headers = { 31 | "Cache-Control" => "public, max-age=#{2.days.to_i}" 32 | } 33 | else 34 | config.action_controller.perform_caching = false 35 | 36 | config.cache_store = :null_store 37 | end 38 | 39 | # Store uploaded files on the local file system (see config/storage.yml for options). 40 | config.active_storage.service = :local 41 | 42 | # Don't care if the mailer can't send. 43 | config.action_mailer.raise_delivery_errors = false 44 | 45 | config.action_mailer.perform_caching = false 46 | 47 | config.action_mailer.default_url_options = { host: 'localhost', port: 3000 } 48 | 49 | # Print deprecation notices to the Rails logger. 50 | config.active_support.deprecation = :log 51 | 52 | # Raise exceptions for disallowed deprecations. 53 | config.active_support.disallowed_deprecation = :raise 54 | 55 | # Tell Active Support which deprecation messages to disallow. 56 | config.active_support.disallowed_deprecation_warnings = [] 57 | 58 | # Raise an error on page load if there are pending migrations. 59 | config.active_record.migration_error = :page_load 60 | 61 | # Highlight code that triggered database queries in logs. 62 | config.active_record.verbose_query_logs = true 63 | 64 | # Suppress logger output for asset requests. 65 | config.assets.quiet = true 66 | 67 | # Raises error for missing translations. 68 | # config.i18n.raise_on_missing_translations = true 69 | 70 | # Annotate rendered view with file names. 71 | # config.action_view.annotate_rendered_view_with_filenames = true 72 | 73 | # Uncomment if you wish to allow Action Cable access from any origin. 74 | # config.action_cable.disable_request_forgery_protection = true 75 | end 76 | -------------------------------------------------------------------------------- /config/environments/production.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 | # Code is not reloaded between requests. 7 | config.cache_classes = true 8 | 9 | # Eager load code on boot. This eager loads most of Rails and 10 | # your application in memory, allowingserve_static_files both threaded web servers 11 | # and those relying on copy on write to perform better. 12 | # Rake tasks automatically ignore this option for performance. 13 | config.eager_load = true 14 | 15 | # Full error reports are disabled and caching is turned on. 16 | config.consider_all_requests_local = false 17 | config.action_controller.perform_caching = true 18 | 19 | # Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"] 20 | # or in config/master.key. This key is used to decrypt credentials (and other encrypted files). 21 | # config.require_master_key = true 22 | 23 | # Disable serving static files from the `/public` folder by default since 24 | # Apache or NGINX already handles this. 25 | config.public_file_server.enabled = ENV["RAILS_SERVE_STATIC_FILES"].present? 26 | 27 | # Compress CSS using a preprocessor. 28 | # config.assets.css_compressor = :sass 29 | 30 | # Do not fallback to assets pipeline if a precompiled asset is missed. 31 | config.assets.compile = false 32 | 33 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 34 | # config.asset_host = "http://assets.example.com" 35 | 36 | # Specifies the header that your server uses for sending files. 37 | # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for Apache 38 | # config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX 39 | 40 | # Store uploaded files on the local file system (see config/storage.yml for options). 41 | config.active_storage.service = :local 42 | 43 | # Mount Action Cable outside main process or domain. 44 | # config.action_cable.mount_path = nil 45 | # config.action_cable.url = "wss://example.com/cable" 46 | # config.action_cable.allowed_request_origins = [ "http://example.com", /http:\/\/example.*/ ] 47 | 48 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 49 | config.force_ssl = ENV.fetch("FORCE_SSL_PROD", true) 50 | 51 | config.ssl_options = {hsts: {subdomains: true, preload: true, expires: 1.year}} 52 | 53 | # Include generic and useful information about system operation, but avoid logging too much 54 | # information to avoid inadvertent exposure of personally identifiable information (PII). 55 | config.log_level = :info 56 | 57 | # Prepend all log lines with the following tags. 58 | config.log_tags = [ :request_id ] 59 | 60 | # Use a different cache store in production. 61 | # config.cache_store = :mem_cache_store 62 | 63 | # Use a real queuing backend for Active Job (and separate queues per environment). 64 | # config.active_job.queue_adapter = :resque 65 | # config.active_job.queue_name_prefix = "eventtracker_production" 66 | 67 | config.action_mailer.perform_caching = false 68 | 69 | config.action_mailer.default_url_options = { host: ENV["APP_DOMAIN"] } 70 | 71 | # Ignore bad email addresses and do not raise email delivery errors. 72 | # Set this to true and configure the email server for immediate delivery to raise delivery errors. 73 | # config.action_mailer.raise_delivery_errors = false 74 | 75 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 76 | # the I18n.default_locale when a translation cannot be found). 77 | config.i18n.fallbacks = true 78 | 79 | # Don't log any deprecations. 80 | config.active_support.report_deprecations = false 81 | 82 | # Use default logging formatter so that PID and timestamp are not suppressed. 83 | config.log_formatter = ::Logger::Formatter.new 84 | 85 | # Use a different logger for distributed setups. 86 | # require "syslog/logger" 87 | # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new "app-name") 88 | 89 | if ENV["RAILS_LOG_TO_STDOUT"].present? 90 | logger = ActiveSupport::Logger.new(STDOUT) 91 | logger.formatter = config.log_formatter 92 | config.logger = ActiveSupport::TaggedLogging.new(logger) 93 | end 94 | 95 | # Do not dump schema after migrations. 96 | config.active_record.dump_schema_after_migration = false 97 | end 98 | -------------------------------------------------------------------------------- /config/environments/test.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | # The test environment is used exclusively to run your application's 4 | # test suite. You never need to work with it otherwise. Remember that 5 | # your test database is "scratch space" for the test suite and is wiped 6 | # and recreated between test runs. Don't rely on the data there! 7 | 8 | Rails.application.configure do 9 | # Settings specified here will take precedence over those in config/application.rb. 10 | 11 | # Turn false under Spring and add config.action_view.cache_template_loading = true 12 | config.cache_classes = true 13 | 14 | # Eager loading loads your whole application. When running a single test locally, 15 | # this probably isn't necessary. It's a good idea to do in a continuous integration 16 | # system, or in some way before deploying your code. 17 | config.eager_load = ENV["CI"].present? 18 | 19 | # Configure public file server for tests with Cache-Control for performance. 20 | config.public_file_server.enabled = true 21 | config.public_file_server.headers = { 22 | "Cache-Control" => "public, max-age=#{1.hour.to_i}" 23 | } 24 | 25 | # Show full error reports and disable caching. 26 | config.consider_all_requests_local = true 27 | config.action_controller.perform_caching = false 28 | config.cache_store = :null_store 29 | 30 | # Raise exceptions instead of rendering exception templates. 31 | config.action_dispatch.show_exceptions = false 32 | 33 | # Disable request forgery protection in test environment. 34 | config.action_controller.allow_forgery_protection = false 35 | 36 | # Store uploaded files on the local file system in a temporary directory. 37 | config.active_storage.service = :test 38 | 39 | config.action_mailer.perform_caching = false 40 | config.action_mailer.default_url_options = { host: 'localhost', port: 3000 } 41 | 42 | # Tell Action Mailer not to deliver emails to the real world. 43 | # The :test delivery method accumulates sent emails in the 44 | # ActionMailer::Base.deliveries array. 45 | config.action_mailer.delivery_method = :test 46 | 47 | # Print deprecation notices to the stderr. 48 | config.active_support.deprecation = :stderr 49 | 50 | # Raise exceptions for disallowed deprecations. 51 | config.active_support.disallowed_deprecation = :raise 52 | 53 | # Tell Active Support which deprecation messages to disallow. 54 | config.active_support.disallowed_deprecation_warnings = [] 55 | 56 | # Raises error for missing translations. 57 | # config.i18n.raise_on_missing_translations = true 58 | 59 | # Annotate rendered view with file names. 60 | # config.action_view.annotate_rendered_view_with_filenames = true 61 | end 62 | -------------------------------------------------------------------------------- /config/importmap.rb: -------------------------------------------------------------------------------- 1 | # Use direct uploads for Active Storage (remember to import "@rails/activestorage" in your application.js) 2 | # pin "@rails/activestorage", to: "activestorage.esm.js" 3 | 4 | # Use node modules from a JavaScript CDN by running ./bin/importmap 5 | 6 | pin "application" 7 | 8 | pin "chart.js", to: "https://ga.jspm.io/npm:chart.js@3.7.0/dist/chart.esm.js" 9 | pin "chartjs-plugin-datalabels", to: "https://ga.jspm.io/npm:chartjs-plugin-datalabels@2.0.0/dist/chartjs-plugin-datalabels.esm.js" 10 | pin "chart.js/helpers", to: "https://ga.jspm.io/npm:chart.js@3.7.0/helpers/helpers.esm.js" 11 | pin "@hotwired/stimulus", to: "stimulus.js" 12 | pin "@hotwired/stimulus-importmap-autoloader", to: "stimulus-importmap-autoloader.js" 13 | 14 | pin_all_from "app/javascript/controllers", under: "controllers" 15 | pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true 16 | 17 | pin "chart_helpers", to: "helpers/chart_helpers.js", preload: true 18 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 = "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 the app/assets 11 | # folder are already added. 12 | # Rails.application.config.assets.precompile += %w( admin.js admin.css ) 13 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | # For further information see the following documentation 5 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy 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 and inline scripts 20 | # config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } 21 | # config.content_security_policy_nonce_directives = %w(script-src) 22 | # 23 | # # Report CSP violations to a specified URI. See: 24 | # # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only 25 | # # config.content_security_policy_report_only = true 26 | # end 27 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure sensitive parameters which will be filtered from the log file. 4 | Rails.application.config.filter_parameters += [ 5 | :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, 6 | :api_key, :name, :properties, :email 7 | ] 8 | -------------------------------------------------------------------------------- /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/initializers/logstop.rb: -------------------------------------------------------------------------------- 1 | Logstop.guard(Rails.logger, ip: true) -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /config/initializers/permissions_policy.rb: -------------------------------------------------------------------------------- 1 | # Define an application-wide HTTP permissions policy. For further 2 | # information see https://developers.google.com/web/updates/2018/06/feature-policy 3 | # 4 | # Rails.application.config.permissions_policy do |f| 5 | # f.camera :none 6 | # f.gyroscope :none 7 | # f.microphone :none 8 | # f.usb :none 9 | # f.fullscreen :self 10 | # f.payment :self, "https://secure.example.com" 11 | # end 12 | -------------------------------------------------------------------------------- /config/initializers/sentry.rb: -------------------------------------------------------------------------------- 1 | Sentry.init do |config| 2 | config.dsn = ENV["SENTRY_DSN"] 3 | config.enabled_environments = %w[production] 4 | config.breadcrumbs_logger = [:active_support_logger, :http_logger] 5 | 6 | # Set tracesSampleRate to 1.0 to capture 100% 7 | # of transactions for performance monitoring. 8 | # We recommend adjusting this value in production 9 | config.traces_sample_rate = 0.5 10 | # or 11 | config.traces_sampler = lambda do |context| 12 | true 13 | end 14 | end -------------------------------------------------------------------------------- /config/initializers/stripe.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Stripe.api_key = ENV["STRIPE_SECRET_KEY"] -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /config/locales/devise.en.yml: -------------------------------------------------------------------------------- 1 | # Additional translations at https://github.com/heartcombo/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 %{authentication_keys} 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 %{authentication_keys} 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 | email_changed: 27 | subject: "Email Changed" 28 | password_change: 29 | subject: "Password Changed" 30 | omniauth_callbacks: 31 | failure: "Could not authenticate you from %{kind} because \"%{reason}\"." 32 | success: "Successfully authenticated from %{kind} account." 33 | passwords: 34 | 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." 35 | send_instructions: "You will receive an email with instructions on how to reset your password in a few minutes." 36 | 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." 37 | updated: "Your password has been changed successfully. You are now signed in." 38 | updated_not_active: "Your password has been changed successfully." 39 | registrations: 40 | destroyed: "Bye! Your account has been successfully cancelled. We hope to see you again soon." 41 | signed_up: "Welcome! You have signed up successfully." 42 | signed_up_but_inactive: "You have signed up successfully. However, we could not sign you in because your account is not yet activated." 43 | signed_up_but_locked: "You have signed up successfully. However, we could not sign you in because your account is locked." 44 | 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." 45 | update_needs_confirmation: "You updated your account successfully, but we need to verify your new email address. Please check your email and follow the confirmation link to confirm your new email address." 46 | updated: "Your account has been updated successfully." 47 | updated_but_not_signed_in: "Your account has been updated successfully, but since your password was changed, you need to sign in again." 48 | sessions: 49 | signed_in: "Signed in successfully." 50 | signed_out: "Signed out successfully." 51 | already_signed_out: "Signed out successfully." 52 | unlocks: 53 | send_instructions: "You will receive an email with instructions for how to unlock your account in a few minutes." 54 | send_paranoid_instructions: "If your account exists, you will receive an email with instructions for how to unlock it in a few minutes." 55 | unlocked: "Your account has been unlocked successfully. Please sign in to continue." 56 | errors: 57 | messages: 58 | already_confirmed: "was already confirmed, please try signing in" 59 | confirmation_period_expired: "needs to be confirmed within %{period}, please request a new one" 60 | expired: "has expired, please request a new one" 61 | not_found: "not found" 62 | not_locked: "was not locked" 63 | not_saved: 64 | one: "1 error prohibited this %{resource} from being saved:" 65 | other: "%{count} errors prohibited this %{resource} from being saved:" 66 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # The following keys must be escaped otherwise they will not be retrieved by 20 | # the default I18n backend: 21 | # 22 | # true, false, on, off, yes, no 23 | # 24 | # Instead, surround them with single quotes. 25 | # 26 | # en: 27 | # 'true': 'foo' 28 | # 29 | # To learn more, please read the Rails Internationalization guide 30 | # available at https://guides.rubyonrails.org/i18n.html. 31 | 32 | en: 33 | hello: "Hello world" 34 | -------------------------------------------------------------------------------- /config/puma.rb: -------------------------------------------------------------------------------- 1 | # Puma can serve each request in a thread from an internal thread pool. 2 | # The `threads` method setting takes two numbers: a minimum and maximum. 3 | # Any libraries that use thread pools should be configured to match 4 | # the maximum value specified for Puma. Default is set to 5 threads for minimum 5 | # and maximum; this matches the default thread size of Active Record. 6 | # 7 | max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } 8 | min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count } 9 | threads min_threads_count, max_threads_count 10 | 11 | # Specifies the `worker_timeout` threshold that Puma will use to wait before 12 | # terminating a worker in development environments. 13 | # 14 | worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development" 15 | 16 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 17 | # 18 | port ENV.fetch("PORT") { 3000 } 19 | 20 | # Specifies the `environment` that Puma will run in. 21 | # 22 | environment ENV.fetch("RAILS_ENV") { "development" } 23 | 24 | # Specifies the `pidfile` that Puma will use. 25 | pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" } 26 | 27 | # Specifies the number of `workers` to boot in clustered mode. 28 | # Workers are forked web server processes. If using threads and workers together 29 | # the concurrency of the application would be max `threads` * `workers`. 30 | # Workers do not work on JRuby or Windows (both of which do not support 31 | # processes). 32 | # 33 | # workers ENV.fetch("WEB_CONCURRENCY") { 2 } 34 | 35 | # Use the `preload_app!` method when specifying a `workers` number. 36 | # This directive tells Puma to first boot the application and load code 37 | # before forking the application. This takes advantage of Copy On Write 38 | # process behavior so workers use less memory. 39 | # 40 | # preload_app! 41 | 42 | # Allow puma to be restarted by `rails restart` command. 43 | plugin :tmp_restart 44 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rails.application.routes.draw do 4 | root to: "projects#index" 5 | 6 | scope "/:user_id", as: :user do 7 | resources :projects, only: %i[index new edit update create destroy], param: :slug do 8 | resources :events, only: %i[index show], param: :slug 9 | resources :funnels, param: :slug 10 | get "settings", to: "projects#settings" 11 | end 12 | end 13 | 14 | scope module: "users" do 15 | get "settings/", to: "settings#show", as: :users_settings 16 | end 17 | 18 | scope "stripe/" do 19 | get "/checkout_session/", to: "stripe#checkout_session", as: "stripe_checkout_session" 20 | get "/success_callback/", to: "stripe#success_callback", as: "stripe_success_callback" 21 | get "/customer_portal/", to: "stripe#customer_portal", as: "stripe_customer_portal" 22 | post "/webhooks/", to: "stripe#webhooks", as: "stripe_webhooks" 23 | end 24 | 25 | devise_for :users, controllers: { 26 | sessions: "users/sessions", 27 | registrations: "users/registrations", 28 | passwords: "users/passwords" 29 | } 30 | 31 | namespace :api do 32 | namespace :v1 do 33 | resources :events, only: %i[create] 34 | end 35 | end 36 | 37 | if Rails.env.development? 38 | namespace :development do 39 | resources :embed_mock, only: [:index] 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /config/spring.rb: -------------------------------------------------------------------------------- 1 | Spring.watch( 2 | ".ruby-version", 3 | ".rbenv-vars", 4 | "tmp/restart.txt", 5 | "tmp/caching-dev.txt" 6 | ) 7 | -------------------------------------------------------------------------------- /config/storage.yml: -------------------------------------------------------------------------------- 1 | test: 2 | service: Disk 3 | root: <%= Rails.root.join("tmp/storage") %> 4 | 5 | local: 6 | service: Disk 7 | root: <%= Rails.root.join("storage") %> 8 | 9 | # Use rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) 10 | # amazon: 11 | # service: S3 12 | # access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> 13 | # secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> 14 | # region: us-east-1 15 | # bucket: your_own_bucket 16 | 17 | # Remember not to checkin your GCS keyfile to a repository 18 | # google: 19 | # service: GCS 20 | # project: your_project 21 | # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> 22 | # bucket: your_own_bucket 23 | 24 | # Use rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) 25 | # microsoft: 26 | # service: AzureStorage 27 | # storage_account_name: your_account_name 28 | # storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> 29 | # container: your_container_name 30 | 31 | # mirror: 32 | # service: Mirror 33 | # primary: local 34 | # mirrors: [ amazon, google, microsoft ] 35 | -------------------------------------------------------------------------------- /config/tailwind.config.js: -------------------------------------------------------------------------------- 1 | const defaultTheme = require('tailwindcss/defaultTheme') 2 | 3 | module.exports = { 4 | darkMode: 'media', 5 | content: [ 6 | './app/helpers/**/*.rb', 7 | './app/javascript/**/*.js', 8 | './app/views/**/*' 9 | ], 10 | theme: { 11 | extend: { 12 | fontFamily: { 13 | sans: ['Inter var', ...defaultTheme.fontFamily.sans], 14 | }, 15 | }, 16 | }, 17 | plugins: [ 18 | require('@tailwindcss/forms'), 19 | require('@tailwindcss/aspect-ratio'), 20 | require('@tailwindcss/typography'), 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /db/migrate/20210512145433_create_events.rb: -------------------------------------------------------------------------------- 1 | class CreateEvents < ActiveRecord::Migration[6.1] 2 | def change 3 | create_table :events do |t| 4 | t.string :name 5 | t.json :properties 6 | 7 | t.timestamps 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /db/migrate/20210512164800_create_projects.rb: -------------------------------------------------------------------------------- 1 | class CreateProjects < ActiveRecord::Migration[6.1] 2 | def change 3 | create_table :projects do |t| 4 | t.string :name 5 | 6 | t.timestamps 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20210617151514_add_project_reference_to_events.rb: -------------------------------------------------------------------------------- 1 | class AddProjectReferenceToEvents < ActiveRecord::Migration[6.1] 2 | def change 3 | add_reference :events, :project, index: true, foreign_key: true 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20210617152330_create_api_keys.rb: -------------------------------------------------------------------------------- 1 | class CreateApiKeys < ActiveRecord::Migration[6.1] 2 | def change 3 | create_table :api_keys do |t| 4 | t.string :key_value 5 | t.references :project, null: false, foreign_key: true 6 | 7 | t.timestamps 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /db/migrate/20210617152542_add_not_null_constraint_to_events.rb: -------------------------------------------------------------------------------- 1 | class AddNotNullConstraintToEvents < ActiveRecord::Migration[6.1] 2 | def change 3 | change_column_null :events, :project_id, false 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20210617154111_add_more_not_null_constraints.rb: -------------------------------------------------------------------------------- 1 | class AddMoreNotNullConstraints < ActiveRecord::Migration[6.1] 2 | def change 3 | change_column_null :events, :name, false 4 | change_column_null :api_keys, :key_value, false 5 | change_column_null :projects, :name, false 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20210617235020_add_dev_to_events.rb: -------------------------------------------------------------------------------- 1 | class AddDevToEvents < ActiveRecord::Migration[6.1] 2 | def change 3 | add_column :events, :staging, :boolean, default: false 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20210618081647_add_unique_index_to_api_key.rb: -------------------------------------------------------------------------------- 1 | class AddUniqueIndexToApiKey < ActiveRecord::Migration[6.1] 2 | def change 3 | add_index :api_keys, :key_value, unique: true 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20210729144755_move_staging_to_api_key.rb: -------------------------------------------------------------------------------- 1 | class MoveStagingToApiKey < ActiveRecord::Migration[6.1] 2 | def change 3 | remove_column :events, :staging, :boolean, default: false 4 | 5 | add_column :api_keys, :test, :boolean, default: false 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20210729145810_move_reference_to_api_key.rb: -------------------------------------------------------------------------------- 1 | class MoveReferenceToApiKey < ActiveRecord::Migration[6.1] 2 | def change 3 | remove_reference :events, :project, index: true, foreign_key: true 4 | 5 | add_reference :events, :api_key, index: true, foreign_key: true, null: false 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20210730092821_devise_create_users.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class DeviseCreateUsers < ActiveRecord::Migration[6.1] 4 | def change 5 | create_table :users do |t| 6 | ## Database authenticatable 7 | t.string :email, null: false, default: "" 8 | t.string :encrypted_password, null: false, default: "" 9 | 10 | ## Recoverable 11 | t.string :reset_password_token 12 | t.datetime :reset_password_sent_at 13 | 14 | ## Rememberable 15 | t.datetime :remember_created_at 16 | 17 | ## Trackable 18 | # t.integer :sign_in_count, default: 0, null: false 19 | # t.datetime :current_sign_in_at 20 | # t.datetime :last_sign_in_at 21 | # t.string :current_sign_in_ip 22 | # t.string :last_sign_in_ip 23 | 24 | ## Confirmable 25 | t.string :confirmation_token 26 | t.datetime :confirmed_at 27 | t.datetime :confirmation_sent_at 28 | t.string :unconfirmed_email # Only if using reconfirmable 29 | 30 | ## Lockable 31 | # t.integer :failed_attempts, default: 0, null: false # Only if lock strategy is :failed_attempts 32 | # t.string :unlock_token # Only if unlock strategy is :email or :both 33 | # t.datetime :locked_at 34 | 35 | 36 | t.timestamps null: false 37 | end 38 | 39 | add_index :users, :email, unique: true 40 | add_index :users, :reset_password_token, unique: true 41 | add_index :users, :confirmation_token, unique: true 42 | # add_index :users, :unlock_token, unique: true 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /db/migrate/20210730101807_add_user_reference_to_project.rb: -------------------------------------------------------------------------------- 1 | class AddUserReferenceToProject < ActiveRecord::Migration[6.1] 2 | def change 3 | add_reference :projects, :user, index: true, foreign_key: true, null: false 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20210922065433_change_event_properties_json_to_json_b.rb: -------------------------------------------------------------------------------- 1 | class ChangeEventPropertiesJsonToJsonB < ActiveRecord::Migration[6.1] 2 | def change 3 | change_column :events, :properties, :jsonb 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20211016133119_add_stripe_and_active_to_user.rb: -------------------------------------------------------------------------------- 1 | class AddStripeAndActiveToUser < ActiveRecord::Migration[6.1] 2 | def change 3 | add_column :users, :stripe_customer_id, :string 4 | add_column :users, :status, :integer, default: 0, null: false 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /db/migrate/20211227170605_create_funnels.rb: -------------------------------------------------------------------------------- 1 | class CreateFunnels < ActiveRecord::Migration[7.0] 2 | def change 3 | create_table :funnels do |t| 4 | t.string :name, null: false 5 | t.references :api_key, null: false, foreign_key: true 6 | 7 | t.timestamps 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /db/migrate/20211227171015_create_funnel_steps.rb: -------------------------------------------------------------------------------- 1 | class CreateFunnelSteps < ActiveRecord::Migration[7.0] 2 | def change 3 | create_table :funnel_steps do |t| 4 | t.string :event_name, null: false 5 | t.references :funnel, null: false, foreign_key: true 6 | t.integer :order 7 | 8 | t.timestamps 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20220216191234_add_unique_index.rb: -------------------------------------------------------------------------------- 1 | class AddUniqueIndex < ActiveRecord::Migration[7.0] 2 | def change 3 | add_index :funnels, %i[name api_key_id], unique: true 4 | add_index :projects, %i[name user_id], unique: true 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /db/migrate/20220314175006_add_hash_id_to_user.rb: -------------------------------------------------------------------------------- 1 | class AddHashIdToUser < ActiveRecord::Migration[7.0] 2 | def change 3 | add_column :users, :hash_id, :string 4 | add_index :users, :hash_id, unique: true 5 | 6 | Rake::Task["fugu:migration:generate_hash_ids"].invoke 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /db/migrate/20220315181147_add_public_bool_to_projects.rb: -------------------------------------------------------------------------------- 1 | class AddPublicBoolToProjects < ActiveRecord::Migration[7.0] 2 | def change 3 | add_column :projects, :public, :boolean, default: false 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20220320112745_user_hash_id_null_false.rb: -------------------------------------------------------------------------------- 1 | class UserHashIdNullFalse < ActiveRecord::Migration[7.0] 2 | def change 3 | change_column_null :users, :hash_id, false 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/schema.rb: -------------------------------------------------------------------------------- 1 | # This file is auto-generated from the current state of the database. Instead 2 | # of editing this file, please use the migrations feature of Active Record to 3 | # incrementally modify your database, and then regenerate this schema definition. 4 | # 5 | # This file is the source Rails uses to define your schema when running `bin/rails 6 | # db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to 7 | # be faster and is potentially less error prone than running all of your 8 | # migrations from scratch. Old migrations may fail to apply correctly if those 9 | # migrations use external dependencies or application code. 10 | # 11 | # It's strongly recommended that you check this file into your version control system. 12 | 13 | ActiveRecord::Schema[7.0].define(version: 2022_03_20_112745) do 14 | # These are extensions that must be enabled in order to support this database 15 | enable_extension "plpgsql" 16 | 17 | create_table "api_keys", force: :cascade do |t| 18 | t.string "key_value", null: false 19 | t.bigint "project_id", null: false 20 | t.datetime "created_at", null: false 21 | t.datetime "updated_at", null: false 22 | t.boolean "test", default: false 23 | t.index ["key_value"], name: "index_api_keys_on_key_value", unique: true 24 | t.index ["project_id"], name: "index_api_keys_on_project_id" 25 | end 26 | 27 | create_table "events", force: :cascade do |t| 28 | t.string "name", null: false 29 | t.jsonb "properties" 30 | t.datetime "created_at", null: false 31 | t.datetime "updated_at", null: false 32 | t.bigint "api_key_id", null: false 33 | t.index ["api_key_id"], name: "index_events_on_api_key_id" 34 | end 35 | 36 | create_table "funnel_steps", force: :cascade do |t| 37 | t.string "event_name", null: false 38 | t.bigint "funnel_id", null: false 39 | t.integer "order" 40 | t.datetime "created_at", null: false 41 | t.datetime "updated_at", null: false 42 | t.index ["funnel_id"], name: "index_funnel_steps_on_funnel_id" 43 | end 44 | 45 | create_table "funnels", force: :cascade do |t| 46 | t.string "name", null: false 47 | t.bigint "api_key_id", null: false 48 | t.datetime "created_at", null: false 49 | t.datetime "updated_at", null: false 50 | t.index ["api_key_id"], name: "index_funnels_on_api_key_id" 51 | t.index ["name", "api_key_id"], name: "index_funnels_on_name_and_api_key_id", unique: true 52 | end 53 | 54 | create_table "projects", force: :cascade do |t| 55 | t.string "name", null: false 56 | t.datetime "created_at", null: false 57 | t.datetime "updated_at", null: false 58 | t.bigint "user_id", null: false 59 | t.boolean "public", default: false 60 | t.index ["name", "user_id"], name: "index_projects_on_name_and_user_id", unique: true 61 | t.index ["user_id"], name: "index_projects_on_user_id" 62 | end 63 | 64 | create_table "users", force: :cascade do |t| 65 | t.string "email", default: "", null: false 66 | t.string "encrypted_password", default: "", null: false 67 | t.string "reset_password_token" 68 | t.datetime "reset_password_sent_at", precision: nil 69 | t.datetime "remember_created_at", precision: nil 70 | t.string "confirmation_token" 71 | t.datetime "confirmed_at", precision: nil 72 | t.datetime "confirmation_sent_at", precision: nil 73 | t.string "unconfirmed_email" 74 | t.datetime "created_at", null: false 75 | t.datetime "updated_at", null: false 76 | t.string "stripe_customer_id" 77 | t.integer "status", default: 0, null: false 78 | t.string "hash_id", null: false 79 | t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true 80 | t.index ["email"], name: "index_users_on_email", unique: true 81 | t.index ["hash_id"], name: "index_users_on_hash_id", unique: true 82 | t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true 83 | end 84 | 85 | add_foreign_key "api_keys", "projects" 86 | add_foreign_key "events", "api_keys" 87 | add_foreign_key "funnel_steps", "funnels" 88 | add_foreign_key "funnels", "api_keys" 89 | add_foreign_key "projects", "users" 90 | end 91 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | services: 3 | db: 4 | image: postgres 5 | volumes: 6 | - ./tmp/db:/var/lib/postgresql/data 7 | ports: 8 | - "5432:5432" 9 | restart: unless-stopped 10 | environment: 11 | - POSTGRES_PASSWORD=${DATABASE_PASSWORD} 12 | - POSTGRES_USER=${DATABASE_USER} 13 | - POSTGRES_DB=${DATABASE_NAME} 14 | web: 15 | build: 16 | context: . 17 | dockerfile: Dockerfile.dev 18 | entrypoint: ["sh", "/app/docker-entrypoint.sh"] 19 | command: bash -c "rm -f tmp/pids/server.pid && bundle exec foreman start -f Procfile.docker.dev" 20 | tty: true 21 | working_dir: /app 22 | ports: 23 | - "3000:3000" 24 | depends_on: 25 | - db 26 | environment: 27 | - RAILS_ENV=development 28 | - RACK_ENV=development 29 | - DATABASE_PASSWORD=${DATABASE_PASSWORD} 30 | - DATABASE_USER=${DATABASE_USER} 31 | - DATABASE_HOST=db 32 | - DATABASE_NAME=${DATABASE_NAME} 33 | - DATABASE_NAME_TEST=${DATABASE_NAME} 34 | - SENTRY_DSN=${SENTRY_DSN} 35 | - ALLOW_REGISTRATION=${ALLOW_REGISTRATION} 36 | -------------------------------------------------------------------------------- /docker-compose.prod-remote.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | services: 3 | db: 4 | image: postgres 5 | volumes: 6 | - ./tmp/db:/var/lib/postgresql/data 7 | ports: 8 | - "5432:5432" 9 | restart: unless-stopped 10 | environment: 11 | - POSTGRES_PASSWORD=${DATABASE_PASSWORD} 12 | - POSTGRES_USER=${DATABASE_USER} 13 | - POSTGRES_DB=${DATABASE_NAME} 14 | web: 15 | image: shafyy/fugu:latest 16 | entrypoint: ["sh", "/app/docker-entrypoint.sh"] 17 | command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'" 18 | tty: true 19 | working_dir: /app 20 | ports: 21 | - "3000:3000" 22 | depends_on: 23 | - db 24 | restart: unless-stopped 25 | environment: 26 | - RAILS_ENV=production 27 | - RACK_ENV=production 28 | - RAILS_SERVE_STATIC_FILES=true 29 | - DATABASE_NAME=${DATABASE_NAME} 30 | - DATABASE_URL=postgres://${DATABASE_USER}:${DATABASE_PASSWORD}@db:5432/${DATABASE_NAME} 31 | - SECRET_KEY_BASE=${SECRET_KEY_BASE} 32 | - RAILS_LOG_TO_STDOUT=${RAILS_LOG_TO_STDOUT} 33 | - SENTRY_DSN=${SENTRY_DSN} 34 | - ALLOW_REGISTRATION=${ALLOW_REGISTRATION} 35 | - INIT_USER_EMAIL=${INIT_USER_EMAIL} 36 | - INIT_USER_PASSWORD=${INIT_USER_PASSWORD} 37 | - FORCE_SSL_PROD=${FORCE_SSL_PROD} 38 | -------------------------------------------------------------------------------- /docker-compose.prod.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | services: 3 | db: 4 | image: postgres 5 | volumes: 6 | - ./tmp/db:/var/lib/postgresql/data 7 | ports: 8 | - "5432:5432" 9 | restart: unless-stopped 10 | environment: 11 | - POSTGRES_PASSWORD=${DATABASE_PASSWORD} 12 | - POSTGRES_USER=${DATABASE_USER} 13 | - POSTGRES_DB=${DATABASE_NAME} 14 | web: 15 | build: 16 | context: . 17 | dockerfile: Dockerfile.prod 18 | #image: fugu_image:latest 19 | entrypoint: ["sh", "/app/docker-entrypoint.sh"] 20 | command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'" 21 | tty: true 22 | working_dir: /app 23 | ports: 24 | - "3000:3000" 25 | depends_on: 26 | - db 27 | restart: unless-stopped 28 | environment: 29 | - RAILS_ENV=production 30 | - RACK_ENV=production 31 | - RAILS_SERVE_STATIC_FILES=true 32 | - DATABASE_NAME=${DATABASE_NAME} 33 | - DATABASE_URL=postgres://${DATABASE_USER}:${DATABASE_PASSWORD}@db:5432/${DATABASE_NAME} 34 | - SECRET_KEY_BASE=${SECRET_KEY_BASE} 35 | - RAILS_LOG_TO_STDOUT=${RAILS_LOG_TO_STDOUT} 36 | - SENTRY_DSN=${SENTRY_DSN} 37 | - ALLOW_REGISTRATION=${ALLOW_REGISTRATION} 38 | - INIT_USER_EMAIL=${INIT_USER_EMAIL} 39 | - INIT_USER_PASSWORD=${INIT_USER_PASSWORD} 40 | - FORCE_SSL_PROD=${FORCE_SSL_PROD} 41 | -------------------------------------------------------------------------------- /docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # create database if it doesn't exist 5 | rails db:create 6 | 7 | # run migrations if necessary 8 | rails db:migrate 9 | 10 | # create initial user if env vars are set and user doesn't exist 11 | if [ -n "${INIT_USER_EMAIL}" ] && [ -n "${INIT_USER_PASSWORD}" ] && [ "$(rails runner "puts User.exists?(email: '${INIT_USER_EMAIL}')")" = 'false' ] 12 | then 13 | echo "Creating initial user..." 14 | rails runner "User.create!({email: '${INIT_USER_EMAIL}', password: '${INIT_USER_PASSWORD}', password_confirmation: '${INIT_USER_PASSWORD}', status: 'active' })" 15 | fi 16 | 17 | exec "$@" 18 | -------------------------------------------------------------------------------- /lib/assets/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shafy/fugu/610c06390f650d485b389b4d375354b9a943ce57/lib/assets/.keep -------------------------------------------------------------------------------- /lib/tasks/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shafy/fugu/610c06390f650d485b389b4d375354b9a943ce57/lib/tasks/.keep -------------------------------------------------------------------------------- /lib/tasks/auto_annotate_models.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # NOTE: only doing this in development as some production environments (Heroku) 4 | # NOTE: are sensitive to local FS writes, and besides -- it's just not proper 5 | # NOTE: to have a dev-mode tool do its thing in production. 6 | # rubocop:disable Metrics/BlockLength 7 | if Rails.env.development? 8 | require "annotate" 9 | task set_annotation_options: :environment do 10 | # You can override any of these by setting an environment variable of the 11 | # same name. 12 | Annotate.set_defaults( 13 | "active_admin" => "false", 14 | "additional_file_patterns" => [], 15 | "routes" => "false", 16 | "models" => "true", 17 | "position_in_routes" => "before", 18 | "position_in_class" => "before", 19 | "position_in_test" => "before", 20 | "position_in_fixture" => "before", 21 | "position_in_factory" => "before", 22 | "position_in_serializer" => "before", 23 | "show_foreign_keys" => "true", 24 | "show_complete_foreign_keys" => "false", 25 | "show_indexes" => "true", 26 | "simple_indexes" => "false", 27 | "model_dir" => "app/models", 28 | "root_dir" => "", 29 | "include_version" => "false", 30 | "require" => "", 31 | "exclude_tests" => "false", 32 | "exclude_fixtures" => "false", 33 | "exclude_factories" => "false", 34 | "exclude_serializers" => "false", 35 | "exclude_scaffolds" => "true", 36 | "exclude_controllers" => "true", 37 | "exclude_helpers" => "true", 38 | "exclude_sti_subclasses" => "false", 39 | "ignore_model_sub_dir" => "false", 40 | "ignore_columns" => nil, 41 | "ignore_routes" => nil, 42 | "ignore_unknown_models" => "false", 43 | "hide_limit_column_types" => "integer,bigint,boolean", 44 | "hide_default_column_types" => "json,jsonb,hstore", 45 | "skip_on_db_migrate" => "false", 46 | "format_bare" => "true", 47 | "format_rdoc" => "false", 48 | "format_yard" => "false", 49 | "format_markdown" => "false", 50 | "sort" => "false", 51 | "force" => "false", 52 | "frozen" => "false", 53 | "classified_sort" => "true", 54 | "trace" => "false", 55 | "wrapper_open" => nil, 56 | "wrapper_close" => nil, 57 | "with_comment" => "true" 58 | ) 59 | end 60 | # rubocop:enable Metrics/BlockLength 61 | Annotate.load_tasks 62 | end 63 | -------------------------------------------------------------------------------- /lib/tasks/fugu_migration_tasks.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | namespace :fugu do 4 | namespace :migration do 5 | desc "Generate hash_id for Users who don't have one (< schema version 20220314175006)" 6 | # run automatically by 20220314175006 migration code 7 | task generate_hash_ids: :environment do 8 | User.find_each do |user| 9 | user.add_hash_id if user.hash_id.blank? 10 | user.save 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /log/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shafy/fugu/610c06390f650d485b389b4d375354b9a943ce57/log/.keep -------------------------------------------------------------------------------- /public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The page you were looking for doesn't exist.

62 |

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

63 |
64 |

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

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

The change you wanted was rejected.

62 |

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

63 |
64 |

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

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

We're sorry, but something went wrong.

62 |
63 |

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

64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shafy/fugu/610c06390f650d485b389b4d375354b9a943ce57/public/apple-touch-icon-precomposed.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shafy/fugu/610c06390f650d485b389b4d375354b9a943ce57/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shafy/fugu/610c06390f650d485b389b4d375354b9a943ce57/public/favicon.ico -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / -------------------------------------------------------------------------------- /storage/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shafy/fugu/610c06390f650d485b389b4d375354b9a943ce57/storage/.keep -------------------------------------------------------------------------------- /test/application_system_test_case.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class ApplicationSystemTestCase < ActionDispatch::SystemTestCase 6 | driven_by :selenium, using: :chrome, screen_size: [1400, 1400] 7 | end 8 | -------------------------------------------------------------------------------- /test/channels/application_cable/connection_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | module ApplicationCable 6 | class ConnectionTest < ActionCable::Connection::TestCase 7 | # test "connects with cookies" do 8 | # cookies.signed[:user_id] = 42 9 | # 10 | # connect 11 | # 12 | # assert_equal connection.user_id, "42" 13 | # end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/controllers/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shafy/fugu/610c06390f650d485b389b4d375354b9a943ce57/test/controllers/.keep -------------------------------------------------------------------------------- /test/controllers/api/v1/events_controller_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class EventsControllerTest < ActionDispatch::IntegrationTest 6 | class PostCreate < EventsControllerTest 7 | setup do 8 | @user = FactoryBot.create(:user) 9 | project = FactoryBot.create(:project, user: @user) 10 | @api_key_test = FactoryBot.create(:api_key, project: project, test: true) 11 | @api_key_live = FactoryBot.create(:api_key, project: project, test: false) 12 | 13 | @name = "Test Event" 14 | @properties = %({ 15 | "color": "Blue", 16 | "size": "12" 17 | }) 18 | 19 | @event_params = { 20 | api_key: @api_key_live.key_value, 21 | name: @name, 22 | properties: @properties 23 | } 24 | end 25 | 26 | test "POST create should respond with success" do 27 | post api_v1_events_path, params: @event_params 28 | assert_response :success 29 | end 30 | 31 | test "POST create should add event" do 32 | assert_difference("Event.count") do 33 | post api_v1_events_path, params: @event_params 34 | end 35 | end 36 | 37 | test "POST create should create correct event" do 38 | post api_v1_events_path, params: @event_params 39 | assert_not_empty(Event.where(name: @name)) 40 | assert_not_empty(Event.where("properties->>'color' = 'Blue'")) 41 | end 42 | 43 | test "POST create should respond with ArgumentError for name = nil" do 44 | @event_params[:name] = nil 45 | post api_v1_events_path, params: @event_params 46 | assert_response 422 47 | assert_match("'name' can't be nil", @response.body) 48 | end 49 | 50 | test "POST create should respond with ArgumentError for missing name key" do 51 | @event_params.delete(:name) 52 | post api_v1_events_path, params: @event_params 53 | assert_response 422 54 | assert_match("missing 'name' key", @response.body) 55 | end 56 | 57 | test "POST create should respond with JSON error" do 58 | @event_params[:properties] = "{incorrect_json: hehe}" 59 | post api_v1_events_path, params: @event_params 60 | assert_response 422 61 | assert_match("Properties must be valid JSON", @response.body) 62 | end 63 | 64 | test "POST create should not create event for inactive user" do 65 | @user.update(status: "inactive") 66 | assert_no_difference "Event.count" do 67 | post api_v1_events_path, params: @event_params 68 | end 69 | end 70 | 71 | test "POST create should return correct ArgumentError for inactive user" do 72 | @user.update(status: "inactive") 73 | post api_v1_events_path, params: @event_params 74 | assert_response 422 75 | assert_match("You need an active subscription", @response.body) 76 | end 77 | 78 | test "POST create should be successful for inactive user if self-hosted" do 79 | ENV["FUGU_CLOUD"] = "false" 80 | @user.update(status: "inactive") 81 | assert_difference "Event.count" do 82 | post api_v1_events_path, params: @event_params 83 | end 84 | ENV["FUGU_CLOUD"] = "true" 85 | end 86 | 87 | test "POST create should add event with test api key for inactive user" do 88 | @user.update(status: "inactive") 89 | @event_params[:api_key] = @api_key_test.key_value 90 | assert_difference("Event.count") do 91 | post api_v1_events_path, params: @event_params 92 | end 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /test/controllers/projects_controller_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class ProjectsControllerTest < ActionDispatch::IntegrationTest 6 | def basic_setup 7 | @user = FactoryBot.create(:user) 8 | sign_in @user 9 | end 10 | 11 | def create_project 12 | @project = FactoryBot.create(:project, user: @user) 13 | @api_key_test = FactoryBot.create(:api_key, project: @project, test: true) 14 | @api_key_live = FactoryBot.create(:api_key, project: @project, test: false) 15 | end 16 | 17 | class GetIndex < ProjectsControllerTest 18 | setup do 19 | basic_setup 20 | create_project 21 | get user_projects_path(@user.hash_id) 22 | end 23 | 24 | test "is succesful" do 25 | assert :success 26 | end 27 | 28 | test "contains created project" do 29 | assert_match(@project.name, @response.body) 30 | end 31 | end 32 | 33 | class PostCreate < ProjectsControllerTest 34 | setup do 35 | basic_setup 36 | @project_attributes = { 37 | name: "test-project" 38 | } 39 | end 40 | 41 | test "is succesful" do 42 | post user_projects_path(@user.hash_id), params: { project: @project_attributes } 43 | assert_redirected_to user_project_events_path(@user.hash_id, 44 | @project_attributes[:name].parameterize) 45 | end 46 | 47 | test "contains created project" do 48 | post user_projects_path(@user.hash_id), params: { project: @project_attributes } 49 | assert_not_empty(Project.where(name: @project_attributes[:name])) 50 | end 51 | 52 | test "is not succesful" do 53 | @project_attributes[:name] = "Name With Space" 54 | post user_projects_path(@user.hash_id), params: { project: @project_attributes } 55 | assert :unprocessable_entity 56 | end 57 | end 58 | 59 | class PatchUpdate < ProjectsControllerTest 60 | setup do 61 | basic_setup 62 | create_project 63 | @new_project_attr = { 64 | name: "new-name", 65 | public: true, 66 | project_id: @project.id 67 | } 68 | end 69 | 70 | test "is succesful" do 71 | patch user_project_path(@user.hash_id, @project.name), params: { project: @new_project_attr } 72 | assert_redirected_to user_project_settings_path(@user.hash_id, 73 | @new_project_attr[:name].parameterize) 74 | end 75 | 76 | test "not succesful with invalid name" do 77 | @new_project_attr[:name] = "Nope Nope$" 78 | patch user_project_path(@user.hash_id, @project.name), params: { project: @new_project_attr } 79 | assert :unprocessable_entity 80 | end 81 | 82 | test "contains updated project" do 83 | patch user_project_path(@user.hash_id, @project.name), params: { project: @new_project_attr } 84 | assert_not_empty( 85 | Project.where(name: @new_project_attr[:name], public: @new_project_attr[:public]) 86 | ) 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /test/controllers/stripe_controller_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "stripe_mock" 5 | 6 | class StripeControllerTest < ActionDispatch::IntegrationTest 7 | class PostWebhooksUpdateCancel < StripeControllerTest 8 | # customer cancels subscription for end of billing cycle 9 | setup do 10 | @user = FactoryBot.create(:user, status: "active", stripe_customer_id: "abc1") 11 | event = customer_subscription_updated(true, @user.stripe_customer_id) 12 | post stripe_webhooks_path, params: event, headers: stripe_headers(event), as: :json 13 | end 14 | 15 | test "be successful" do 16 | assert_response :success 17 | end 18 | 19 | test "sets account to canceled" do 20 | assert_equal(User.find(@user.id).status, "canceled") 21 | end 22 | end 23 | 24 | class PostWebhooksUpdateCanceled < StripeControllerTest 25 | # subscription is canceled at the end of billing cycle 26 | setup do 27 | @user = FactoryBot.create(:user, status: "canceled", stripe_customer_id: "abc1") 28 | event = customer_subscription_deleted(@user.stripe_customer_id) 29 | post stripe_webhooks_path, params: event, headers: stripe_headers(event), as: :json 30 | end 31 | 32 | test "be successful" do 33 | assert_response :success 34 | end 35 | 36 | test "sets account to inactive" do 37 | assert_equal(User.find(@user.id).status, "inactive") 38 | end 39 | end 40 | 41 | class PostWebhooksReactivates < StripeControllerTest 42 | # customer reactivates previously canceled subscription before end of billing cycle 43 | setup do 44 | @user = FactoryBot.create(:user, status: "canceled", stripe_customer_id: "abc1") 45 | event = customer_subscription_updated(false, @user.stripe_customer_id) 46 | post stripe_webhooks_path, params: event, headers: stripe_headers(event), as: :json 47 | end 48 | 49 | test "be successful" do 50 | assert_response :success 51 | end 52 | 53 | test "sets account to active" do 54 | assert_equal(User.find(@user.id).status, "active") 55 | end 56 | end 57 | 58 | class GetSuccessCallback < StripeControllerTest 59 | # customer reactivates previously canceled subscription before end of billing cycle 60 | setup do 61 | StripeMock.start 62 | customer = create_stripe_customer 63 | product = create_stripe_product 64 | price = create_stripe_price(product.id) 65 | session = create_stripe_checkout_session(customer.id, price.id) 66 | @user = FactoryBot.create(:user, status: "inactive", stripe_customer_id: customer.id) 67 | 68 | sign_in @user 69 | get stripe_success_callback_path, params: { session_id: session.id } 70 | end 71 | 72 | teardown { StripeMock.stop } 73 | 74 | test "be successful" do 75 | assert_response :redirect 76 | end 77 | 78 | test "sets account to active" do 79 | assert_equal(User.find(@user.id).status, "active") 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /test/controllers/users/registrations_controller_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class RegistrationsControllerTest < ActionDispatch::IntegrationTest 6 | class CreateUser < RegistrationsControllerTest 7 | setup do 8 | @user = FactoryBot.build(:user) 9 | @user_params = { 10 | email: @user.email, 11 | password: @user.password, 12 | password_confirmation: @user.password 13 | } 14 | 15 | post user_registration_path, params: { user: @user_params } 16 | end 17 | 18 | test "to redirect to projects overview" do 19 | assert_redirected_to root_path 20 | end 21 | 22 | test "creates the correct user" do 23 | assert_not_empty(User.where(email: @user.email)) 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/controllers/users/settings_controller_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class SettingsControllerTest < ActionDispatch::IntegrationTest 6 | def setup 7 | FactoryBot.create(:project, user: @user) 8 | sign_in @user 9 | get users_settings_path 10 | end 11 | 12 | class GetShowInactiveUser < SettingsControllerTest 13 | setup do 14 | @user = FactoryBot.create(:user, status: "inactive") 15 | setup 16 | end 17 | 18 | test "be successful" do 19 | assert_response :success 20 | end 21 | 22 | test "display correct email" do 23 | assert_match(@user.email, @response.body) 24 | end 25 | 26 | test "display correct text" do 27 | assert_match("You don't have an active subscription", @response.body) 28 | end 29 | 30 | test "display correct links" do 31 | assert_match("/stripe/checkout_session", @response.body) 32 | assert_match("/stripe/checkout_session", @response.body) 33 | end 34 | end 35 | 36 | class GetShowActiveUser < SettingsControllerTest 37 | setup do 38 | @user = FactoryBot.create(:user, status: "active") 39 | setup 40 | end 41 | 42 | test "be successful" do 43 | assert_response :success 44 | end 45 | 46 | test "display correct text" do 47 | assert_match("Your Fugu subscription is active and renews monthly", @response.body) 48 | end 49 | 50 | test "display correct link" do 51 | assert_match("/stripe/customer_portal", @response.body) 52 | end 53 | end 54 | 55 | class GetShowCanceledUser < SettingsControllerTest 56 | setup do 57 | @user = FactoryBot.create(:user, status: "canceled") 58 | setup 59 | end 60 | 61 | test "be successful" do 62 | assert_response :success 63 | end 64 | 65 | test "display correct text" do 66 | assert_match("Your Fugu subscription is canceled", @response.body) 67 | end 68 | 69 | test "display correct link" do 70 | assert_match("/stripe/customer_portal", @response.body) 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /test/factories/api_keys.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # == Schema Information 4 | # 5 | # Table name: api_keys 6 | # 7 | # id :bigint not null, primary key 8 | # key_value :string not null 9 | # test :boolean default(FALSE) 10 | # created_at :datetime not null 11 | # updated_at :datetime not null 12 | # project_id :bigint not null 13 | # 14 | # Indexes 15 | # 16 | # index_api_keys_on_key_value (key_value) UNIQUE 17 | # index_api_keys_on_project_id (project_id) 18 | # 19 | # Foreign Keys 20 | # 21 | # fk_rails_... (project_id => projects.id) 22 | # 23 | 24 | FactoryBot.define do 25 | factory :api_key do 26 | sequence :key_value do |n| 27 | "12345678kjdhfdsa-#{n}" 28 | end 29 | 30 | project 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/factories/events.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # == Schema Information 4 | # 5 | # Table name: events 6 | # 7 | # id :bigint not null, primary key 8 | # name :string not null 9 | # properties :jsonb 10 | # created_at :datetime not null 11 | # updated_at :datetime not null 12 | # api_key_id :bigint not null 13 | # 14 | # Indexes 15 | # 16 | # index_events_on_api_key_id (api_key_id) 17 | # 18 | # Foreign Keys 19 | # 20 | # fk_rails_... (api_key_id => api_keys.id) 21 | # 22 | 23 | FactoryBot.define do 24 | factory :event do 25 | sequence :name do |n| 26 | "Test Event #{n}" 27 | end 28 | 29 | properties do 30 | %({ 31 | "color": "Blue", 32 | "size": "12" 33 | }) 34 | end 35 | 36 | api_key 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /test/factories/funnel_steps.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # == Schema Information 4 | # 5 | # Table name: funnel_steps 6 | # 7 | # id :bigint not null, primary key 8 | # event_name :string not null 9 | # order :integer 10 | # created_at :datetime not null 11 | # updated_at :datetime not null 12 | # funnel_id :bigint not null 13 | # 14 | # Indexes 15 | # 16 | # index_funnel_steps_on_funnel_id (funnel_id) 17 | # 18 | # Foreign Keys 19 | # 20 | # fk_rails_... (funnel_id => funnels.id) 21 | # 22 | 23 | FactoryBot.define do 24 | factory :funnel_step do 25 | sequence :event_name do |n| 26 | "Test Event #{n}" 27 | end 28 | 29 | sequence :order do |n| 30 | n 31 | end 32 | 33 | funnel 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/factories/funnels.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # == Schema Information 4 | # 5 | # Table name: funnels 6 | # 7 | # id :bigint not null, primary key 8 | # name :string not null 9 | # created_at :datetime not null 10 | # updated_at :datetime not null 11 | # api_key_id :bigint not null 12 | # 13 | # Indexes 14 | # 15 | # index_funnels_on_api_key_id (api_key_id) 16 | # index_funnels_on_name_and_api_key_id (name,api_key_id) UNIQUE 17 | # 18 | # Foreign Keys 19 | # 20 | # fk_rails_... (api_key_id => api_keys.id) 21 | # 22 | 23 | FactoryBot.define do 24 | factory :funnel do 25 | sequence :name do |n| 26 | "My Funnel #{n}" 27 | end 28 | api_key 29 | 30 | before(:create) do |funnel| 31 | funnel.funnel_steps = build_list(:funnel_step, 5, funnel: funnel) 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/factories/projects.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # == Schema Information 4 | # 5 | # Table name: projects 6 | # 7 | # id :bigint not null, primary key 8 | # name :string not null 9 | # public :boolean default(FALSE) 10 | # created_at :datetime not null 11 | # updated_at :datetime not null 12 | # user_id :bigint not null 13 | # 14 | # Indexes 15 | # 16 | # index_projects_on_name_and_user_id (name,user_id) UNIQUE 17 | # index_projects_on_user_id (user_id) 18 | # 19 | # Foreign Keys 20 | # 21 | # fk_rails_... (user_id => users.id) 22 | # 23 | 24 | FactoryBot.define do 25 | factory :project do 26 | sequence :name do |n| 27 | "test-project-#{n}" 28 | end 29 | 30 | public { false } 31 | 32 | user 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/factories/users.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # == Schema Information 4 | # 5 | # Table name: users 6 | # 7 | # id :bigint not null, primary key 8 | # confirmation_sent_at :datetime 9 | # confirmation_token :string 10 | # confirmed_at :datetime 11 | # email :string default(""), not null 12 | # encrypted_password :string default(""), not null 13 | # remember_created_at :datetime 14 | # reset_password_sent_at :datetime 15 | # reset_password_token :string 16 | # status :integer default("inactive"), not null 17 | # unconfirmed_email :string 18 | # created_at :datetime not null 19 | # updated_at :datetime not null 20 | # hash_id :string not null 21 | # stripe_customer_id :string 22 | # 23 | # Indexes 24 | # 25 | # index_users_on_confirmation_token (confirmation_token) UNIQUE 26 | # index_users_on_email (email) UNIQUE 27 | # index_users_on_hash_id (hash_id) UNIQUE 28 | # index_users_on_reset_password_token (reset_password_token) UNIQUE 29 | # 30 | 31 | FactoryBot.define do 32 | factory :user do 33 | sequence :email do |n| 34 | "#{n}@fugu.lol" 35 | end 36 | 37 | sequence :hash_id do |n| 38 | "abc#{n}" 39 | end 40 | 41 | password { "secure_password" } 42 | 43 | status { "active" } 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /test/helpers/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shafy/fugu/610c06390f650d485b389b4d375354b9a943ce57/test/helpers/.keep -------------------------------------------------------------------------------- /test/integration/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shafy/fugu/610c06390f650d485b389b4d375354b9a943ce57/test/integration/.keep -------------------------------------------------------------------------------- /test/mailers/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shafy/fugu/610c06390f650d485b389b4d375354b9a943ce57/test/mailers/.keep -------------------------------------------------------------------------------- /test/models/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shafy/fugu/610c06390f650d485b389b4d375354b9a943ce57/test/models/.keep -------------------------------------------------------------------------------- /test/models/api_key_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # == Schema Information 4 | # 5 | # Table name: api_keys 6 | # 7 | # id :bigint not null, primary key 8 | # key_value :string not null 9 | # test :boolean default(FALSE) 10 | # created_at :datetime not null 11 | # updated_at :datetime not null 12 | # project_id :bigint not null 13 | # 14 | # Indexes 15 | # 16 | # index_api_keys_on_key_value (key_value) UNIQUE 17 | # index_api_keys_on_project_id (project_id) 18 | # 19 | # Foreign Keys 20 | # 21 | # fk_rails_... (project_id => projects.id) 22 | # 23 | 24 | require "test_helper" 25 | 26 | class ApiKeyTest < ActiveSupport::TestCase 27 | test "has a valid factory" do 28 | assert build(:api_key) 29 | end 30 | 31 | context "validations" do 32 | subject { build(:api_key) } 33 | 34 | should belong_to(:project) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /test/models/event_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # == Schema Information 4 | # 5 | # Table name: events 6 | # 7 | # id :bigint not null, primary key 8 | # name :string not null 9 | # properties :jsonb 10 | # created_at :datetime not null 11 | # updated_at :datetime not null 12 | # api_key_id :bigint not null 13 | # 14 | # Indexes 15 | # 16 | # index_events_on_api_key_id (api_key_id) 17 | # 18 | # Foreign Keys 19 | # 20 | # fk_rails_... (api_key_id => api_keys.id) 21 | # 22 | 23 | require "test_helper" 24 | 25 | class EventTest < ActiveSupport::TestCase 26 | test "has a valid factory" do 27 | assert build(:event) 28 | end 29 | 30 | context "validations" do 31 | subject { build(:event) } 32 | 33 | should validate_presence_of(:name) 34 | 35 | should belong_to(:api_key) 36 | 37 | should_not allow_value("a-", "a$", "a_").for(:name) 38 | should validate_length_of(:name).is_at_most(25) 39 | should validate_exclusion_of(:name) 40 | .in_array(%w[all]) 41 | .with_message("'all' is a reserved event name by Fugu and can't be used") 42 | should validate_exclusion_of(:name) 43 | .in_array(%w[All]) 44 | .with_message("'All' is a reserved event name by Fugu and can't be used") 45 | end 46 | 47 | context "property validations general" do 48 | should_not allow_value("Non JSON String").for(:properties) 49 | should_not allow_value("{invalid_json: \"12\"}").for(:properties) 50 | should_not allow_value("{\"all\": \"test\"}") 51 | .for(:properties) 52 | .with_message("You've used a property name that's prohibited by Fugu (such as 'all'). " \ 53 | "Learn more about property constraints in the Fugu docs: https://docs.fugu.lol") 54 | should_not allow_value("{\"All\": \"test\"}") 55 | .for(:properties) 56 | .with_message("You've used a property name that's prohibited by Fugu (such as 'all'). " \ 57 | "Learn more about property constraints in the Fugu docs: https://docs.fugu.lol") 58 | should_not allow_value("{\"email\": \"test\"}") 59 | .for(:properties) 60 | .with_message("You've used a property name that's prohibited by Fugu (such as 'all'). " \ 61 | "Learn more about property constraints in the Fugu docs: https://docs.fugu.lol") 62 | should_not allow_value("{\"Test\": \"192.128.0.12\"}") 63 | .for(:properties) 64 | .with_message("You've used a property value that's prohibited by Fugu " \ 65 | "(such as an email address). " \ 66 | "Learn more about property constraints in the Fugu docs: https://docs.fugu.lol") 67 | should_not allow_value("{\"Test\": \"2345:0425:2CA1:0000:0000:0567:5673:23b5\"}") 68 | .for(:properties) 69 | .with_message("You've used a property value that's prohibited by Fugu " \ 70 | "(such as an email address). " \ 71 | "Learn more about property constraints in the Fugu docs: https://docs.fugu.lol") 72 | should_not allow_value("{\"this_propertyname_is_too_long\": \"test\"}") 73 | .for(:properties) 74 | .with_message("You've used a property name that's too long (> 15 characters). " \ 75 | "Please choose a shorter name.") 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /test/models/funnel_step_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # == Schema Information 4 | # 5 | # Table name: funnel_steps 6 | # 7 | # id :bigint not null, primary key 8 | # event_name :string not null 9 | # order :integer 10 | # created_at :datetime not null 11 | # updated_at :datetime not null 12 | # funnel_id :bigint not null 13 | # 14 | # Indexes 15 | # 16 | # index_funnel_steps_on_funnel_id (funnel_id) 17 | # 18 | # Foreign Keys 19 | # 20 | # fk_rails_... (funnel_id => funnels.id) 21 | # 22 | 23 | require "test_helper" 24 | 25 | class FunnelStepTest < ActiveSupport::TestCase 26 | test "has a valid factory" do 27 | assert build(:funnel_step) 28 | end 29 | 30 | context "validations" do 31 | subject { build(:funnel_step) } 32 | 33 | should validate_presence_of(:event_name) 34 | 35 | should belong_to(:funnel) 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/models/funnel_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # == Schema Information 4 | # 5 | # Table name: funnels 6 | # 7 | # id :bigint not null, primary key 8 | # name :string not null 9 | # created_at :datetime not null 10 | # updated_at :datetime not null 11 | # api_key_id :bigint not null 12 | # 13 | # Indexes 14 | # 15 | # index_funnels_on_api_key_id (api_key_id) 16 | # index_funnels_on_name_and_api_key_id (name,api_key_id) UNIQUE 17 | # 18 | # Foreign Keys 19 | # 20 | # fk_rails_... (api_key_id => api_keys.id) 21 | # 22 | 23 | require "test_helper" 24 | 25 | class FunnelTest < ActiveSupport::TestCase 26 | test "has a valid factory" do 27 | assert build(:funnel) 28 | end 29 | 30 | context "validations" do 31 | subject { build(:funnel) } 32 | 33 | should validate_presence_of(:name) 34 | 35 | should validate_uniqueness_of(:name) 36 | .scoped_to(:api_key_id) 37 | .with_message("You already have a funnel with this name") 38 | .case_insensitive 39 | 40 | should belong_to(:api_key) 41 | 42 | should_not allow_value("My $$$").for(:name) 43 | 44 | should accept_nested_attributes_for(:funnel_steps) 45 | 46 | should have_many(:funnel_steps) 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/models/project_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # == Schema Information 4 | # 5 | # Table name: projects 6 | # 7 | # id :bigint not null, primary key 8 | # name :string not null 9 | # public :boolean default(FALSE) 10 | # created_at :datetime not null 11 | # updated_at :datetime not null 12 | # user_id :bigint not null 13 | # 14 | # Indexes 15 | # 16 | # index_projects_on_name_and_user_id (name,user_id) UNIQUE 17 | # index_projects_on_user_id (user_id) 18 | # 19 | # Foreign Keys 20 | # 21 | # fk_rails_... (user_id => users.id) 22 | # 23 | 24 | require "test_helper" 25 | 26 | class ProjectTest < ActiveSupport::TestCase 27 | test "has a valid factory" do 28 | assert build(:project) 29 | end 30 | 31 | context "validations" do 32 | subject { build(:project) } 33 | 34 | should validate_presence_of(:name) 35 | 36 | should validate_uniqueness_of(:name) 37 | .scoped_to(:user_id) 38 | .with_message("You already have a project with this name") 39 | .case_insensitive 40 | 41 | should validate_length_of(:name).is_at_most(40) 42 | 43 | should belong_to(:user) 44 | 45 | should_not allow_value("Test Project").for(:name) 46 | should_not allow_values("project", "projects", "Project").for(:name) 47 | end 48 | 49 | test ".creat_api_keys should create api keys" do 50 | project = create(:project) 51 | project.create_api_keys 52 | 53 | assert_not_nil(ApiKey.where(project: project).first) 54 | assert_not_nil(ApiKey.where(project: project).second) 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /test/models/user_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # == Schema Information 4 | # 5 | # Table name: users 6 | # 7 | # id :bigint not null, primary key 8 | # confirmation_sent_at :datetime 9 | # confirmation_token :string 10 | # confirmed_at :datetime 11 | # email :string default(""), not null 12 | # encrypted_password :string default(""), not null 13 | # remember_created_at :datetime 14 | # reset_password_sent_at :datetime 15 | # reset_password_token :string 16 | # status :integer default("inactive"), not null 17 | # unconfirmed_email :string 18 | # created_at :datetime not null 19 | # updated_at :datetime not null 20 | # hash_id :string not null 21 | # stripe_customer_id :string 22 | # 23 | # Indexes 24 | # 25 | # index_users_on_confirmation_token (confirmation_token) UNIQUE 26 | # index_users_on_email (email) UNIQUE 27 | # index_users_on_hash_id (hash_id) UNIQUE 28 | # index_users_on_reset_password_token (reset_password_token) UNIQUE 29 | # 30 | 31 | require "test_helper" 32 | 33 | class UserTest < ActiveSupport::TestCase 34 | test "has a valid factory" do 35 | assert build(:user) 36 | end 37 | 38 | should validate_presence_of(:email) 39 | end 40 | -------------------------------------------------------------------------------- /test/support/stripe_test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module StripeTestHelper 4 | def stripe_event_signature(event_json) 5 | secret = ENV.fetch("STRIPE_ENDPOINT_SECRET", nil) 6 | timestamp = Time.zone.now 7 | signature = Stripe::Webhook::Signature.send(:compute_signature, timestamp, event_json, secret) 8 | scheme = Stripe::Webhook::Signature::EXPECTED_SCHEME 9 | "t=#{timestamp.to_i},#{scheme}=#{signature}" 10 | end 11 | 12 | def customer_subscription_updated(cancel_at_period_end, customer) 13 | { 14 | type: "customer.subscription.updated", 15 | data: { 16 | object: { 17 | cancel_at_period_end: cancel_at_period_end, 18 | customer: customer 19 | } 20 | } 21 | } 22 | end 23 | 24 | def customer_subscription_deleted(customer) 25 | { 26 | type: "customer.subscription.deleted", 27 | data: { 28 | object: { 29 | customer: customer 30 | } 31 | } 32 | } 33 | end 34 | 35 | def stripe_headers(event) 36 | { 37 | "Stripe-Signature" => stripe_event_signature(event.to_json) 38 | } 39 | end 40 | 41 | def checkout_session_args(price, customer) 42 | { 43 | payment_method_types: ["card"], 44 | customer: customer, 45 | line_items: [{ 46 | price: price, 47 | quantity: 1 48 | }], 49 | mode: "subscription", 50 | success_url: "/", 51 | cancel_url: "/" 52 | } 53 | end 54 | 55 | def create_stripe_customer 56 | Stripe::Customer.create(source: StripeMock.generate_card_token(last4: "9191", exp_year: 2030)) 57 | end 58 | 59 | def create_stripe_product 60 | Stripe::Product.create({ name: "Fugu" }) 61 | end 62 | 63 | def create_stripe_price(product) 64 | Stripe::Price.create({ 65 | unit_amount: 900, 66 | currency: "usd", 67 | recurring: { interval: "month" }, 68 | product: product 69 | }) 70 | end 71 | 72 | def create_stripe_checkout_session(customer, price) 73 | Stripe::Checkout::Session.create( 74 | payment_method_types: ["card"], 75 | customer: customer, 76 | line_items: [{ 77 | price: price, 78 | quantity: 1 79 | }], 80 | mode: "subscription", 81 | success_url: "/", 82 | cancel_url: "/" 83 | ) 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /test/system/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shafy/fugu/610c06390f650d485b389b4d375354b9a943ce57/test/system/.keep -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ENV["RAILS_ENV"] ||= "test" 4 | require_relative "../config/environment" 5 | require "rails/test_help" 6 | require "database_cleaner/active_record" 7 | 8 | Dir[Rails.root.join("test/support/**/*.rb")].each { |f| require f } 9 | 10 | module ActiveSupport 11 | class TestCase 12 | # Run tests in parallel with specified workers 13 | parallelize(workers: :number_of_processors) 14 | 15 | # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. 16 | # fixtures :all 17 | 18 | # Add more helper methods to be used by all tests here... 19 | include FactoryBot::Syntax::Methods 20 | end 21 | end 22 | 23 | module ActionDispatch 24 | class IntegrationTest 25 | include Devise::Test::IntegrationHelpers 26 | include StripeTestHelper 27 | end 28 | end 29 | 30 | # Shoulda Matchers 31 | Shoulda::Matchers.configure do |config| 32 | config.integrate do |with| 33 | with.test_framework :minitest 34 | with.library :rails 35 | end 36 | end 37 | 38 | # Database Cleaner 39 | DatabaseCleaner.strategy = :transaction 40 | 41 | module AroundEachTest 42 | def before_setup 43 | super 44 | DatabaseCleaner.start 45 | end 46 | 47 | def after_teardown 48 | super 49 | DatabaseCleaner.clean 50 | end 51 | end 52 | 53 | module Minitest 54 | class Test 55 | include AroundEachTest 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /tmp/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shafy/fugu/610c06390f650d485b389b4d375354b9a943ce57/tmp/.keep -------------------------------------------------------------------------------- /vendor/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shafy/fugu/610c06390f650d485b389b4d375354b9a943ce57/vendor/.keep --------------------------------------------------------------------------------