commit 14e163a1a585ef13e2e04a75a75252308475c9ea Author: Mike Sutton Date: Sat Nov 12 02:27:46 2022 +0100 init push - laying out the project diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..a628c44 --- /dev/null +++ b/.drone.yml @@ -0,0 +1,103 @@ +--- +kind: pipeline +type: docker +name: build-test-deploy +image_pull_secrets: + - registryLogins + +platform: + os: linux + arch: amd64 + +volumes: + - name: dockersock + host: + path: /var/run/docker.sock + +steps: + - name: notify_start + image: plugins/slack + settings: + webhook: https://team.wizewerx.tech/hooks/5eq841fropn3prt4kzgoaoprgr + channel: builds + template: > + {{uppercase repo.name}}:{{uppercase build.branch}}:{{build.number}} CI build started + - name: consumer + image: wizewerx/wizewerx-docker-compose + volumes: + - name: dockersock + path: /var/run/docker.sock + environment: + DOCKER_AUTH_CONFIG: + from_secret: registryLogins + commands: + - mkdir -p "/root/.docker" && echo $DOCKER_AUTH_CONFIG > "/root/.docker/config.json" + - export BM_COMPONENT=consumer + - export BUILD_TARGET=wizewerx/bettermail_${BM_COMPONENT}:${DRONE_COMMIT_BRANCH} + - docker pull $BUILD_TARGET + - docker build --cache-from $BUILD_TARGET --build-arg GEM_CACHE=$BUILD_TARGET -f ./consumer/docker/Dockerfile -t $BUILD_TARGET . + - docker push $BUILD_TARGET + - name: portal + image: wizewerx/wizewerx-docker-compose + volumes: + - name: dockersock + path: /var/run/docker.sock + environment: + DOCKER_AUTH_CONFIG: + from_secret: registryLogins + commands: + - mkdir -p "/root/.docker" && echo $DOCKER_AUTH_CONFIG > "/root/.docker/config.json" + - export BM_COMPONENT=portal + - export BUILD_TARGET=wizewerx/bettermail_${BM_COMPONENT}:${DRONE_COMMIT_BRANCH} + - docker pull $BUILD_TARGET + - docker build --cache-from $BUILD_TARGET --build-arg GEM_CACHE=$BUILD_TARGET -f ./consumer/docker/Dockerfile -t $BUILD_TARGET . + - docker push $BUILD_TARGET + - name: proxy + image: wizewerx/wizewerx-docker-compose + volumes: + - name: dockersock + path: /var/run/docker.sock + environment: + DOCKER_AUTH_CONFIG: + from_secret: registryLogins + commands: + - mkdir -p "/root/.docker" && echo $DOCKER_AUTH_CONFIG > "/root/.docker/config.json" + - export BM_COMPONENT=proxy + - export BUILD_TARGET=wizewerx/bettermail_${BM_COMPONENT}:${DRONE_COMMIT_BRANCH} + - docker pull $BUILD_TARGET + - docker build --cache-from $BUILD_TARGET --build-arg GEM_CACHE=$BUILD_TARGET -f ./consumer/docker/Dockerfile -t $BUILD_TARGET . + - docker push $BUILD_TARGET + - name: notifications + image: wizewerx/wizewerx-docker-compose + volumes: + - name: dockersock + path: /var/run/docker.sock + environment: + DOCKER_AUTH_CONFIG: + from_secret: registryLogins + commands: + - mkdir -p "/root/.docker" && echo $DOCKER_AUTH_CONFIG > "/root/.docker/config.json" + - export BM_COMPONENT=notifications + - export BUILD_TARGET=wizewerx/bettermail_${BM_COMPONENT}:${DRONE_COMMIT_BRANCH} + - docker pull $BUILD_TARGET + - docker build --cache-from $BUILD_TARGET --build-arg GEM_CACHE=$BUILD_TARGET -f ./consumer/docker/Dockerfile -t $BUILD_TARGET . + - docker push $BUILD_TARGET + - name: notify_end + image: plugins/slack + settings: + webhook: https://team.wizewerx.tech/hooks/5eq841fropn3prt4kzgoaoprgr + channel: builds + username: mike + template: > + {{uppercase repo.name}}:{{uppercase build.branch}}:{{build.number}}{{#success build.status}} succeeded. Good job. {{else}} failed. FIX THE BUILD!!!.{{/success}} + Build took {{since build.started}} + when: + status: [ success, failure ] + branch: + - master + - production + depends_on: + - consumer + - portal + - proxy + - notifications diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..76a87c6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# See https://help.github.com/articles/ignoring-files for more about ignoring files. +# +# If you find yourself ignoring temporary files generated by your text editor +# or operating system, you probably want to add a global ignore instead: +# git config --global core.excludesfile '~/.gitignore_global' + +# Ignore bundler config. +/.bundle +/vendor + +# Ignore all logfiles and tempfiles. +*.xcf +log/* +tmp/* +!/log/.keep +!/tmp/.keep +.idea/* +*/.idea/* +/public/uploads/* +/node_modules +app/assets/images/avatars/* +# Ignore Byebug command history file. +.byebug_history +/.vscode/* +# Ignore master key for decrypting credentials and more. +config/master.key +.env +# allow database.yml and secrets for gitlab ci pipeline build +#/config/database.yml +#/config/secrets.yml + +#ignore avatars +public/assets/ + +public/packs + +dev.env +.env*.* +storage +*.dmp +package-lock.json +out/ +/spec/ +/samples/ diff --git a/consumer/Gemfile b/consumer/Gemfile new file mode 100644 index 0000000..db05c52 --- /dev/null +++ b/consumer/Gemfile @@ -0,0 +1,15 @@ +source 'https://rubygems.org' +ruby '2.6.5' +gem 'dotenv-rails' + +# Reduces boot times through caching; required in config/boot.rb +gem 'bootsnap', '>= 1.4.2', require: false + +group :development do + gem 'web-console', '>= 3.3.0' + # Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring + gem 'spring' + gem 'spring-watcher-listen', '~> 2.0.0' +end + +gem 'bunny' diff --git a/notifications/Gemfile b/notifications/Gemfile new file mode 100644 index 0000000..db05c52 --- /dev/null +++ b/notifications/Gemfile @@ -0,0 +1,15 @@ +source 'https://rubygems.org' +ruby '2.6.5' +gem 'dotenv-rails' + +# Reduces boot times through caching; required in config/boot.rb +gem 'bootsnap', '>= 1.4.2', require: false + +group :development do + gem 'web-console', '>= 3.3.0' + # Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring + gem 'spring' + gem 'spring-watcher-listen', '~> 2.0.0' +end + +gem 'bunny' diff --git a/portal/.dockerignore b/portal/.dockerignore new file mode 100644 index 0000000..2d4c264 --- /dev/null +++ b/portal/.dockerignore @@ -0,0 +1,2 @@ +.env.* +.env*.* diff --git a/portal/.eslintrc.json b/portal/.eslintrc.json new file mode 100644 index 0000000..7c6222d --- /dev/null +++ b/portal/.eslintrc.json @@ -0,0 +1,26 @@ +{ + "env": { + "browser": true, + "es6": true + }, + "parser": "babel-eslint", + "extends": "wesbos", + "globals": { + "Atomics": "readonly", + "SharedArrayBuffer": "readonly" + }, + "parserOptions": { + "ecmaFeatures": { + "jsx": true + }, + "ecmaVersion": 2018, + "sourceType": "module" + }, + "plugins": ["react"], + "rules": { + "jsx-a11y/href-no-hash": "off", + "jsx-a11y/img-has-alt": "off", + "import/prefer-default-export": "off", + "react-hooks/exhaustive-deps": "off" + } +} diff --git a/portal/.gitignore b/portal/.gitignore new file mode 100644 index 0000000..b6c0754 --- /dev/null +++ b/portal/.gitignore @@ -0,0 +1,45 @@ +# See https://help.github.com/articles/ignoring-files for more about ignoring files. +# +# If you find yourself ignoring temporary files generated by your text editor +# or operating system, you probably want to add a global ignore instead: +# git config --global core.excludesfile '~/.gitignore_global' + +# Ignore bundler config. +/.bundle +/vendor + +# Ignore all logfiles and tempfiles. +*.xcf +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep +/.idea/* +/public/uploads/* +/node_modules +app/assets/images/avatars/* +# Ignore Byebug command history file. +.byebug_history +/.vscode/* +# Ignore master key for decrypting credentials and more. +/config/master.key +/.env +# allow database.yml and secrets for gitlab ci pipeline build +#/config/database.yml +#/config/secrets.yml + +#ignore avatars +/public/assets/ + +/public/packs + +/dev.env +/.env*.* +/storage +*.dmp +/brakeman.html +package-lock.json +/out/ +/ssl/ +/spec/ +/samples/ diff --git a/portal/.rspec b/portal/.rspec new file mode 100644 index 0000000..c99d2e7 --- /dev/null +++ b/portal/.rspec @@ -0,0 +1 @@ +--require spec_helper diff --git a/portal/.rubocop.yml b/portal/.rubocop.yml new file mode 100644 index 0000000..ecb56a5 --- /dev/null +++ b/portal/.rubocop.yml @@ -0,0 +1,150 @@ +inherit_from: .rubocop_todo.yml +require: + - rubocop-performance + +AllCops: + NewCops: enable + Exclude: + - 'tmp/**/*' + - 'vendor/**/*' + - config/**/* + - db/**/* + - bin/**/* + - node_modules/**/* + - yarn-cache/**/* + - Rakefile + - app/javascript + - 'client/**/*' +Style/OptionalBooleanParameter: + Enabled: false +Style/Documentation: + Enabled: false + +Lint/SuppressedException: + Enabled: false + +Layout/EmptyLinesAroundBlockBody: + Enabled: false + +Layout/EmptyLinesAroundModuleBody: + Enabled: false + +Layout/EmptyLinesAroundClassBody: + Enabled: false + +Layout/EmptyLinesAroundMethodBody: + Enabled: false + +Metrics/AbcSize: + Enabled: false + Max: 20 +Metrics/ModuleLength: + Enabled: false +Metrics/BlockLength: + Enabled: false +Metrics/CyclomaticComplexity: + Enabled: false +Metrics/PerceivedComplexity: + Enabled: false +Metrics/ClassLength: + Enabled: false +Style/StructInheritance: + Enabled: false +Metrics/ParameterLists: + Enabled: false +Naming/MethodParameterName: + Enabled: false +Lint/RequireParentheses: + Enabled: false +Metrics/MethodLength: + Enabled: false + Max: 30 +Lint/ShadowedException: + Enabled: false +Layout/LineLength: + Max: 200 + IgnoredPatterns: [ + '# index_*' + ] + +Style/RaiseArgs: + Enabled: +Style/ClassAndModuleChildren: + Enabled: false +Style/EmptyMethod: + Enabled: false + +Style/FrozenStringLiteralComment: + Enabled: false + +Style/BlockDelimiters: + Exclude: + - 'spec/**/*' + +Style/IfUnlessModifier: + Enabled: false + +Style/RescueStandardError: + Enabled: false +Layout/SpaceAroundMethodCallOperator: + Enabled: false +Lint/RaiseException: + Enabled: false +Lint/StructNewOverride: + Enabled: false +Style/ExponentialNotation: + Enabled: false +Style/HashEachMethods: + Enabled: false +Style/HashTransformKeys: + Enabled: false +Style/HashTransformValues: + Enabled: false +Performance/StringInclude: + Enabled: false +Layout/EmptyLinesAroundAttributeAccessor: + Enabled: false +Lint/DeprecatedOpenSSLConstant: + Enabled: false +Lint/DuplicateElsifCondition: + Enabled: false +Lint/MixedRegexpCaptureTypes: + Enabled: false +Style/AccessorGrouping: + Enabled: false +Style/ArrayCoercion: + Enabled: false +Style/BisectedAttrAccessor: + Enabled: false +Style/CaseLikeIf: + Enabled: false +Style/HashAsLastArrayItem: + Enabled: false +Style/HashLikeCase: + Enabled: false +Style/RedundantAssignment: + Enabled: true +Style/RedundantFetchBlock: + Enabled: true +Style/RedundantFileExtensionInRequire: + Enabled: false +Style/RedundantRegexpCharacterClass: + Enabled: false +Style/RedundantRegexpEscape: + Enabled: false +Style/SlicingWithRange: + Enabled: true +Performance/AncestorsInclude: + Enabled: false +Performance/BigDecimalWithNumericArgument: + Enabled: false +Performance/RedundantSortBlock: + Enabled: false +Performance/RedundantStringChars: + Enabled: true +Performance/ReverseFirst: + Enabled: false +Performance/SortReverse: + Enabled: false +Performance/Squeeze: + Enabled: false \ No newline at end of file diff --git a/portal/.rubocop_todo.yml b/portal/.rubocop_todo.yml new file mode 100644 index 0000000..e69de29 diff --git a/portal/.ruby-gemset b/portal/.ruby-gemset new file mode 100644 index 0000000..22696ba --- /dev/null +++ b/portal/.ruby-gemset @@ -0,0 +1 @@ +betbeast diff --git a/portal/.ruby-version b/portal/.ruby-version new file mode 100644 index 0000000..d7edb56 --- /dev/null +++ b/portal/.ruby-version @@ -0,0 +1 @@ +ruby-2.6.5 diff --git a/portal/Gemfile b/portal/Gemfile new file mode 100644 index 0000000..3475c0b --- /dev/null +++ b/portal/Gemfile @@ -0,0 +1,80 @@ +source 'https://rubygems.org' +git_source(:techgit) { |repo| "https://git.wizewerx.tech/#{repo}.git" } +# gem 'wizewerx_utils', techgit: 'wizewerx/wizewerx_utils', branch: 'master' + +ruby '2.6.5' +gem 'dotenv-rails' + +# Bundle edge Rails instead: gem 'rails', github: 'rails/rails' +gem 'rails', '~> 6.0.3', '>= 6.0.3.4' +# Use postgresql as the database for Active Record +gem 'pg', '>= 0.18', '< 2.0' +# Use Puma as the app server +gem 'puma', '~> 4.1' +# Use SCSS for stylesheets +gem 'sass-rails', '>= 6' +# Transpile app-like JavaScript. Read more: https://github.com/rails/webpacker +gem 'webpacker', '~> 4.0' +# Turbolinks makes navigating your web application faster. Read more: https://github.com/turbolinks/turbolinks +gem 'turbolinks', '~> 5' +# Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder +gem 'jbuilder', '~> 2.7' +# Use Redis adapter to run Action Cable in production +# gem 'redis', '~> 4.0' +# Use Active Model has_secure_password +# gem 'bcrypt', '~> 3.1.7' +gem 'devise', techgit: 'foss/devise', tag: 'v4.8.1' +# Use Active Storage variant +# gem 'image_processing', '~> 1.2' +# Reduces boot times through caching; required in config/boot.rb +gem 'bootsnap', '>= 1.4.2', require: false +# this is used for asset precompile for docker image build to avoid database failure, wrap in production group +# --------------------------------------------------- +gem 'activerecord-nulldb-adapter' +# --------------------------------------------------- +# +# +gem 'public_activity' + +group :development, :test do + # Call 'byebug' anywhere in the code to stop execution and get a debugger console + gem 'byebug', platforms: %i[mri mingw x64_mingw] + gem 'rubocop', require: false + gem 'rubocop-performance', require: false + gem 'rubocop-rails' + gem 'rubocop-rspec' + # testing support + gem 'rspec-rails', '~> 4.0.1' + gem 'factory_bot_rails' + gem 'capybara' + gem 'database_cleaner' + gem 'faker' +end + +group :development do + gem 'annotate' + # Access an interactive console on exception pages or by calling 'console' anywhere in the code. + gem 'listen', '~> 3.2' + gem 'web-console', '>= 3.3.0' + # Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring + gem 'spring' + gem 'spring-watcher-listen', '~> 2.0.0' +end + +# Windows does not include zoneinfo files, so bundle the tzinfo-data gem +gem 'pry' +gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby] +gem 'stripe' +gem 'feature' +gem 'omniauth', '~> 1.9.1' +gem 'omniauth-keycloak', '~> 1.2.1' +gem 'keycloak', '3.2.1' +gem 'mini_magick' +gem "aws-sdk-s3", require: false +gem 'sidekiq' +gem "sidekiq-cron", "1.8.0" +gem 'ledermann-rails-settings' +gem 'appsignal' +gem 'httparty' +gem 'pagy', '~> 3.5' +gem 'activerecord-import' diff --git a/portal/Gemfile.lock b/portal/Gemfile.lock new file mode 100644 index 0000000..15cd5cb --- /dev/null +++ b/portal/Gemfile.lock @@ -0,0 +1,446 @@ +GIT + remote: https://git.wizewerx.tech/foss/devise.git + revision: ffecd4caaca700883a042561538f84507ac1edfc + tag: v4.8.1 + specs: + devise (4.8.1) + bcrypt (~> 3.0) + orm_adapter (~> 0.1) + railties (>= 4.1.0) + responders + warden (~> 1.2.3) + +GEM + remote: https://rubygems.org/ + specs: + actioncable (6.0.6) + actionpack (= 6.0.6) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + actionmailbox (6.0.6) + actionpack (= 6.0.6) + activejob (= 6.0.6) + activerecord (= 6.0.6) + activestorage (= 6.0.6) + activesupport (= 6.0.6) + mail (>= 2.7.1) + actionmailer (6.0.6) + actionpack (= 6.0.6) + actionview (= 6.0.6) + activejob (= 6.0.6) + mail (~> 2.5, >= 2.5.4) + rails-dom-testing (~> 2.0) + actionpack (6.0.6) + actionview (= 6.0.6) + activesupport (= 6.0.6) + rack (~> 2.0, >= 2.0.8) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.0) + rails-html-sanitizer (~> 1.0, >= 1.2.0) + actiontext (6.0.6) + actionpack (= 6.0.6) + activerecord (= 6.0.6) + activestorage (= 6.0.6) + activesupport (= 6.0.6) + nokogiri (>= 1.8.5) + actionview (6.0.6) + activesupport (= 6.0.6) + builder (~> 3.1) + erubi (~> 1.4) + rails-dom-testing (~> 2.0) + rails-html-sanitizer (~> 1.1, >= 1.2.0) + activejob (6.0.6) + activesupport (= 6.0.6) + globalid (>= 0.3.6) + activemodel (6.0.6) + activesupport (= 6.0.6) + activerecord (6.0.6) + activemodel (= 6.0.6) + activesupport (= 6.0.6) + activerecord-import (1.0.8) + activerecord (>= 3.2) + activerecord-nulldb-adapter (0.8.0) + activerecord (>= 5.2.0, < 7.1) + activestorage (6.0.6) + actionpack (= 6.0.6) + activejob (= 6.0.6) + activerecord (= 6.0.6) + marcel (~> 1.0) + activesupport (6.0.6) + concurrent-ruby (~> 1.0, >= 1.0.2) + i18n (>= 0.7, < 2) + minitest (~> 5.1) + tzinfo (~> 1.1) + zeitwerk (~> 2.2, >= 2.2.2) + addressable (2.8.1) + public_suffix (>= 2.0.2, < 6.0) + aes_key_wrap (1.1.0) + annotate (3.2.0) + activerecord (>= 3.2, < 8.0) + rake (>= 10.4, < 14.0) + appsignal (3.1.5) + rack + ast (2.4.2) + aws-eventstream (1.2.0) + aws-partitions (1.650.0) + aws-sdk-core (3.164.0) + aws-eventstream (~> 1, >= 1.0.2) + aws-partitions (~> 1, >= 1.525.0) + aws-sigv4 (~> 1.1) + jmespath (~> 1, >= 1.6.1) + aws-sdk-kms (1.58.0) + aws-sdk-core (~> 3, >= 3.127.0) + aws-sigv4 (~> 1.1) + aws-sdk-s3 (1.116.0) + aws-sdk-core (~> 3, >= 3.127.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.4) + aws-sigv4 (1.5.2) + aws-eventstream (~> 1, >= 1.0.2) + bcrypt (3.1.18) + bindata (2.4.13) + bindex (0.8.1) + bootsnap (1.13.0) + msgpack (~> 1.2) + builder (3.2.4) + byebug (11.1.3) + capybara (3.36.0) + addressable + matrix + mini_mime (>= 0.1.3) + nokogiri (~> 1.8) + rack (>= 1.6.0) + rack-test (>= 0.6.3) + regexp_parser (>= 1.5, < 3.0) + xpath (~> 3.2) + coderay (1.1.3) + concurrent-ruby (1.1.10) + connection_pool (2.3.0) + crass (1.0.6) + database_cleaner (2.0.1) + database_cleaner-active_record (~> 2.0.0) + database_cleaner-active_record (2.0.1) + activerecord (>= 5.a) + database_cleaner-core (~> 2.0.0) + database_cleaner-core (2.0.1) + diff-lcs (1.5.0) + domain_name (0.5.20190701) + unf (>= 0.0.5, < 1.0.0) + dotenv (2.8.1) + dotenv-rails (2.8.1) + dotenv (= 2.8.1) + railties (>= 3.2) + erubi (1.11.0) + et-orbi (1.2.7) + tzinfo + factory_bot (6.2.1) + activesupport (>= 5.0.0) + factory_bot_rails (6.2.0) + factory_bot (~> 6.2.0) + railties (>= 5.0.0) + faker (2.22.0) + i18n (>= 1.8.11, < 2) + faraday (2.6.0) + faraday-net_http (>= 2.0, < 3.1) + ruby2_keywords (>= 0.0.4) + faraday-follow_redirects (0.3.0) + faraday (>= 1, < 3) + faraday-net_http (3.0.1) + feature (1.4.0) + ffi (1.15.5) + fugit (1.7.1) + et-orbi (~> 1, >= 1.2.7) + raabro (~> 1.4) + globalid (1.0.0) + activesupport (>= 5.0) + hashie (5.0.0) + http-accept (1.7.0) + http-cookie (1.0.5) + domain_name (~> 0.5) + httparty (0.20.0) + mime-types (~> 3.0) + multi_xml (>= 0.5.2) + i18n (1.12.0) + concurrent-ruby (~> 1.0) + jbuilder (2.11.5) + actionview (>= 5.0.0) + activesupport (>= 5.0.0) + jmespath (1.6.1) + json (2.3.0) + json-jwt (1.16.1) + activesupport (>= 4.2) + aes_key_wrap + bindata + faraday (~> 2.0) + faraday-follow_redirects + jwt (2.2.1) + keycloak (3.2.1) + json (= 2.3.0) + jwt (= 2.2.1) + rest-client (= 2.1.0) + ledermann-rails-settings (2.5.0) + activerecord (>= 4.2) + listen (3.7.1) + rb-fsevent (~> 0.10, >= 0.10.3) + rb-inotify (~> 0.9, >= 0.9.10) + loofah (2.19.0) + crass (~> 1.0.2) + nokogiri (>= 1.5.9) + mail (2.7.1) + mini_mime (>= 0.1.1) + marcel (1.0.2) + matrix (0.4.2) + method_source (1.0.0) + mime-types (3.4.1) + mime-types-data (~> 3.2015) + mime-types-data (3.2022.0105) + mini_magick (4.11.0) + mini_mime (1.1.2) + mini_portile2 (2.8.0) + minitest (5.16.3) + msgpack (1.6.0) + multi_json (1.15.0) + multi_xml (0.6.0) + netrc (0.11.0) + nio4r (2.5.8) + nokogiri (1.13.9) + mini_portile2 (~> 2.8.0) + racc (~> 1.4) + nokogiri (1.13.9-x86_64-linux) + racc (~> 1.4) + oauth2 (1.4.11) + faraday (>= 0.17.3, < 3.0) + jwt (>= 1.0, < 3.0) + multi_json (~> 1.3) + multi_xml (~> 0.5) + rack (>= 1.2, < 4) + omniauth (1.9.2) + hashie (>= 3.4.6) + rack (>= 1.6.2, < 3) + omniauth-keycloak (1.2.1) + json-jwt (~> 1.12) + omniauth (~> 1.9.0) + omniauth-oauth2 (~> 1.6.0) + omniauth-oauth2 (1.6.0) + oauth2 (~> 1.1) + omniauth (~> 1.9) + orm_adapter (0.5.0) + pagy (3.14.0) + parallel (1.22.1) + parser (3.1.2.1) + ast (~> 2.4.1) + pg (1.4.4) + pry (0.14.1) + coderay (~> 1.1) + method_source (~> 1.0) + public_activity (2.0.2) + actionpack (>= 5.0.0) + activerecord (>= 5.0) + i18n (>= 0.5.0) + railties (>= 5.0.0) + public_suffix (5.0.0) + puma (4.3.12) + nio4r (~> 2.0) + raabro (1.4.0) + racc (1.6.0) + rack (2.2.4) + rack-proxy (0.7.4) + rack + rack-test (2.0.2) + rack (>= 1.3) + rails (6.0.6) + actioncable (= 6.0.6) + actionmailbox (= 6.0.6) + actionmailer (= 6.0.6) + actionpack (= 6.0.6) + actiontext (= 6.0.6) + actionview (= 6.0.6) + activejob (= 6.0.6) + activemodel (= 6.0.6) + activerecord (= 6.0.6) + activestorage (= 6.0.6) + activesupport (= 6.0.6) + bundler (>= 1.3.0) + railties (= 6.0.6) + sprockets-rails (>= 2.0.0) + rails-dom-testing (2.0.3) + activesupport (>= 4.2.0) + nokogiri (>= 1.6) + rails-html-sanitizer (1.4.3) + loofah (~> 2.3) + railties (6.0.6) + actionpack (= 6.0.6) + activesupport (= 6.0.6) + method_source + rake (>= 0.8.7) + thor (>= 0.20.3, < 2.0) + rainbow (3.1.1) + rake (13.0.6) + rb-fsevent (0.11.2) + rb-inotify (0.10.1) + ffi (~> 1.0) + redis (4.8.0) + regexp_parser (2.6.0) + responders (3.0.1) + actionpack (>= 5.0) + railties (>= 5.0) + rest-client (2.1.0) + http-accept (>= 1.7.0, < 2.0) + http-cookie (>= 1.0.2, < 2.0) + mime-types (>= 1.16, < 4.0) + netrc (~> 0.8) + rexml (3.2.5) + rspec-core (3.11.0) + rspec-support (~> 3.11.0) + rspec-expectations (3.11.1) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.11.0) + rspec-mocks (3.11.2) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.11.0) + rspec-rails (4.0.2) + actionpack (>= 4.2) + activesupport (>= 4.2) + railties (>= 4.2) + rspec-core (~> 3.10) + rspec-expectations (~> 3.10) + rspec-mocks (~> 3.10) + rspec-support (~> 3.10) + rspec-support (3.11.1) + rubocop (1.37.1) + json (~> 2.3) + parallel (~> 1.10) + parser (>= 3.1.2.1) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 1.8, < 3.0) + rexml (>= 3.2.5, < 4.0) + rubocop-ast (>= 1.23.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 1.4.0, < 3.0) + rubocop-ast (1.23.0) + parser (>= 3.1.1.0) + rubocop-performance (1.15.0) + rubocop (>= 1.7.0, < 2.0) + rubocop-ast (>= 0.4.0) + rubocop-rails (2.17.1) + activesupport (>= 4.2.0) + rack (>= 1.1) + rubocop (>= 1.33.0, < 2.0) + rubocop-rspec (2.14.1) + rubocop (~> 1.33) + ruby-progressbar (1.11.0) + ruby2_keywords (0.0.5) + sass-rails (6.0.0) + sassc-rails (~> 2.1, >= 2.1.1) + sassc (2.4.0) + ffi (~> 1.9) + sassc-rails (2.1.2) + railties (>= 4.0.0) + sassc (>= 2.0) + sprockets (> 3.0) + sprockets-rails + tilt + sidekiq (6.5.7) + connection_pool (>= 2.2.5) + rack (~> 2.0) + redis (>= 4.5.0, < 5) + sidekiq-cron (1.8.0) + fugit (~> 1) + sidekiq (>= 4.2.1) + spring (2.1.1) + spring-watcher-listen (2.0.1) + listen (>= 2.7, < 4.0) + spring (>= 1.2, < 3.0) + sprockets (4.1.1) + concurrent-ruby (~> 1.0) + rack (> 1, < 3) + sprockets-rails (3.4.2) + actionpack (>= 5.2) + activesupport (>= 5.2) + sprockets (>= 3.0.0) + stripe (7.1.0) + thor (1.2.1) + thread_safe (0.3.6) + tilt (2.0.11) + turbolinks (5.2.1) + turbolinks-source (~> 5.2) + turbolinks-source (5.2.0) + tzinfo (1.2.10) + thread_safe (~> 0.1) + unf (0.1.4) + unf_ext + unf_ext (0.0.8.2) + unicode-display_width (2.3.0) + warden (1.2.9) + rack (>= 2.0.9) + web-console (4.2.0) + actionview (>= 6.0.0) + activemodel (>= 6.0.0) + bindex (>= 0.4.0) + railties (>= 6.0.0) + webpacker (4.3.0) + activesupport (>= 4.2) + rack-proxy (>= 0.6.1) + railties (>= 4.2) + websocket-driver (0.7.5) + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + xpath (3.2.0) + nokogiri (~> 1.8) + zeitwerk (2.6.1) + +PLATFORMS + -darwin-21 + ruby + x86_64-linux + +DEPENDENCIES + activerecord-import + activerecord-nulldb-adapter + annotate + appsignal + aws-sdk-s3 + bootsnap (>= 1.4.2) + byebug + capybara + database_cleaner + devise! + dotenv-rails + factory_bot_rails + faker + feature + httparty + jbuilder (~> 2.7) + keycloak (= 3.2.1) + ledermann-rails-settings + listen (~> 3.2) + mini_magick + omniauth (~> 1.9.1) + omniauth-keycloak (~> 1.2.1) + pagy (~> 3.5) + pg (>= 0.18, < 2.0) + pry + public_activity + puma (~> 4.1) + rails (~> 6.0.3, >= 6.0.3.4) + rspec-rails (~> 4.0.1) + rubocop + rubocop-performance + rubocop-rails + rubocop-rspec + sass-rails (>= 6) + sidekiq + sidekiq-cron (= 1.8.0) + spring + spring-watcher-listen (~> 2.0.0) + stripe + turbolinks (~> 5) + tzinfo-data + web-console (>= 3.3.0) + webpacker (~> 4.0) + +RUBY VERSION + ruby 2.6.5p114 + +BUNDLED WITH + 2.3.10 diff --git a/portal/README.md b/portal/README.md new file mode 100644 index 0000000..72ab306 --- /dev/null +++ b/portal/README.md @@ -0,0 +1,9 @@ +# Betbeast + +Betbeast is a private bets arbitration service. + +## Setting up development + +`$ bundle exec rails db:setup` + +`$ bundle exec rails s` diff --git a/portal/Rakefile b/portal/Rakefile new file mode 100644 index 0000000..e85f913 --- /dev/null +++ b/portal/Rakefile @@ -0,0 +1,6 @@ +# Add your own tasks in files placed in lib/tasks ending in .rake, +# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. + +require_relative 'config/application' + +Rails.application.load_tasks diff --git a/portal/app/assets/config/manifest.js b/portal/app/assets/config/manifest.js new file mode 100644 index 0000000..5918193 --- /dev/null +++ b/portal/app/assets/config/manifest.js @@ -0,0 +1,2 @@ +//= link_tree ../images +//= link_directory ../stylesheets .css diff --git a/portal/app/assets/images/.keep b/portal/app/assets/images/.keep new file mode 100644 index 0000000..e69de29 diff --git a/portal/app/assets/images/favicon.png b/portal/app/assets/images/favicon.png new file mode 100644 index 0000000..b034e25 Binary files /dev/null and b/portal/app/assets/images/favicon.png differ diff --git a/portal/app/assets/images/logo-white.png b/portal/app/assets/images/logo-white.png new file mode 100644 index 0000000..6d4ee76 Binary files /dev/null and b/portal/app/assets/images/logo-white.png differ diff --git a/portal/app/assets/images/logo.png b/portal/app/assets/images/logo.png new file mode 100644 index 0000000..39a79a3 Binary files /dev/null and b/portal/app/assets/images/logo.png differ diff --git a/portal/app/assets/stylesheets/application.css b/portal/app/assets/stylesheets/application.css new file mode 100644 index 0000000..d05ea0f --- /dev/null +++ b/portal/app/assets/stylesheets/application.css @@ -0,0 +1,15 @@ +/* + * This is a manifest file that'll be compiled into application.css, which will include all the files + * listed below. + * + * Any CSS and SCSS file within this directory, lib/assets/stylesheets, or any plugin's + * vendor/assets/stylesheets directory can be referenced here using a relative path. + * + * You're free to add application-wide styles to this file and they'll appear at the bottom of the + * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS + * files in this directory. Styles in this file should be added after the last require_* statement. + * It is generally better to create a new file per style scope. + * + *= require_tree . + *= require_self + */ diff --git a/portal/app/channels/application_cable/channel.rb b/portal/app/channels/application_cable/channel.rb new file mode 100644 index 0000000..d672697 --- /dev/null +++ b/portal/app/channels/application_cable/channel.rb @@ -0,0 +1,4 @@ +module ApplicationCable + class Channel < ActionCable::Channel::Base + end +end diff --git a/portal/app/channels/application_cable/connection.rb b/portal/app/channels/application_cable/connection.rb new file mode 100644 index 0000000..0ff5442 --- /dev/null +++ b/portal/app/channels/application_cable/connection.rb @@ -0,0 +1,4 @@ +module ApplicationCable + class Connection < ActionCable::Connection::Base + end +end diff --git a/portal/app/channels/exchange_account_channel.rb b/portal/app/channels/exchange_account_channel.rb new file mode 100644 index 0000000..2411498 --- /dev/null +++ b/portal/app/channels/exchange_account_channel.rb @@ -0,0 +1,9 @@ +class ExchangeAccountChannel < ApplicationCable::Channel + def subscribed + stream_from "exchange_account:#{params[:id]}" + end + + def unsubscribed + # Any cleanup needed when channel is unsubscribed + end +end diff --git a/portal/app/controllers/api/authenticated_api_controller.rb b/portal/app/controllers/api/authenticated_api_controller.rb new file mode 100644 index 0000000..be13139 --- /dev/null +++ b/portal/app/controllers/api/authenticated_api_controller.rb @@ -0,0 +1,3 @@ +class Api::AuthenticatedApiController < ActionController::Base + before_action :authenticate_user! +end diff --git a/portal/app/controllers/api/v1/account_controller.rb b/portal/app/controllers/api/v1/account_controller.rb new file mode 100644 index 0000000..73d86ee --- /dev/null +++ b/portal/app/controllers/api/v1/account_controller.rb @@ -0,0 +1,11 @@ +class Api::V1::AccountController < Api::AuthenticatedApiController + def create + render json: { success: true} and return + end + + # def is_domain_valid + # valid = !Helpbuild::Ecosystem.reserved_account_domains.include?(params[:domain]) + # valid &= /[A-Za-z0-9-]{1,63}/.match(params[:domain]).present? + # render json: { success: valid} and return + # end +end diff --git a/portal/app/controllers/api/v1/app_controller.rb b/portal/app/controllers/api/v1/app_controller.rb new file mode 100644 index 0000000..5131ba9 --- /dev/null +++ b/portal/app/controllers/api/v1/app_controller.rb @@ -0,0 +1,7 @@ +class Api::V1::AppController < Api::AuthenticatedApiController + skip_before_action :authenticate_user!, only: [:configuration] + def configuration + rtn = {exchange_accounts: ExchangeAccount.pluck(:id).sort} + render json: { success: true, config: rtn} and return + end +end diff --git a/portal/app/controllers/api/v1/bets_controller.rb b/portal/app/controllers/api/v1/bets_controller.rb new file mode 100644 index 0000000..c954152 --- /dev/null +++ b/portal/app/controllers/api/v1/bets_controller.rb @@ -0,0 +1,104 @@ +class Api::V1::BetsController < Api::AuthenticatedApiController + include ApplicationHelper + include Pagy::Backend + skip_before_action :authenticate_user!, only: %i[add_placed_bet tips] + skip_before_action :verify_authenticity_token, only: %i[add_placed_bet tips] + + def index + exchange_account = params[:exchange_account_id].blank? ? ExchangeAccount.mounted_account : ExchangeAccount.find(params[:exchange_account_id]) + + bets = filter_by_date_range(exchange_account.my_bets, 'created_at') + + bets = bets.where(outcome: params[:outcome]) unless params[:outcome].blank? + bets = bets.where("exchange_event_name ILIKE '%%#{params[:event_name]}%%'") unless params[:event_name].blank? + + ev_range = params[:expected_value] + if ev_range.present? + v_symbolize = JSON.parse(ev_range).symbolize_keys + bets = bets.where("expected_value >= #{v_symbolize[:min]} AND expected_value <= #{v_symbolize[:max]}") + end + # add additional filters later + # + summary = summary_dashboard(exchange_account, bets) + + pagy, bets = pagy(bets) + + pagy_json = pagy_metadata(pagy) + + + render json: { summary: summary, pagy: pagy_json, bets: bets.order(created_at: :desc).map(&:json_payload) } + end + + def add_placed_bet + # push a placed bet from an authenticated client + wpb = 'WEB_PLACED_BETS' + + ex = ExchangeAccount.find_by(id: wpb) + stake = (params['stake'] || 0).to_f + executed_odds = (params['odds_accepted'] || 0).to_f + record = { + tip_provider_id: 'betburger', + tip_provider_bet_id: params['betburger_bet_id'], + tip_provider_percent: (params['value_bet_ev_percent'] || 0).to_f, + tip_provider_odds: (params['original_odds'] || 0).to_f, + team1: "Not Set", + team2: "Not Set", + exchange_id: params['bookmaker'], + exchange_event_name: params['match'], + exchange_market_details: {market: params['market'], selection: params['selection']}, + stake: stake, + executed_odds: executed_odds, + expected_value: Bet.calculate_expected_value(stake, executed_odds), + original_json: params.to_json, + outcome: {'Bet placed': 'open', 'Odds changed': 'expired'}[params['placer_result']] || 'skipped', + log: [params['placer_result']], + } + ex.my_bets.create(record) + render json: { success: true } + end + + def tips + id = params[:id] + resp = { success: false } + ts = id.nil? ? nil : TipSource.find_by(id: id) + if ts + resp[:success] = true + latest_source_data = ts.tip_source_data.latest + resp[:data] = latest_source_data.blank? ? {} : latest_source_data.data + else + resp[:message] = 'Unknown tip source requested' + end + render json: resp + end + + def summary_dashboard(exchange_account, bets) + { + exchange_account: exchange_account.json_payload, + total_win_amount: exchange_account.total_won(bets), + total_lost_amount: exchange_account.total_lost(bets), + total_risked_amount: exchange_account.total_open(bets), + total_tips: bets.count, + total_tips_skipped: bets.skipped.count, + total_tips_processing: bets.processing.count, + total_tips_expired: bets.expired.count, + total_tips_ignored: bets.ignored.count, + total_tips_voided: bets.voided.count, + total_placed_bets: bets.placed_bets.count, + total_placed_bets_won: bets.won.count, + total_placed_bets_lost: bets.lost.count, + total_placed_bets_open: bets.open.count, + average_odds_won: odd_averages(bets.won), + average_odds_lost: odd_averages(bets.lost), + average_odds: odd_averages(bets), + running_since: exchange_account.my_bets.minimum(:created_at) || exchange_account.created_at, + } + end + + def odd_averages(bets) + return 0 if bets.count.zero? + + total_odds = 0 + bets.map { |b| total_odds += b.tip_provider_odds.to_f } + (total_odds / bets.count).round(2) + end +end diff --git a/portal/app/controllers/api/v1/exchange_accounts_controller.rb b/portal/app/controllers/api/v1/exchange_accounts_controller.rb new file mode 100644 index 0000000..c13bb9e --- /dev/null +++ b/portal/app/controllers/api/v1/exchange_accounts_controller.rb @@ -0,0 +1,14 @@ +class Api::V1::ExchangeAccountsController < Api::AuthenticatedApiController + def update + exchange_account = ExchangeAccount.find(params[:exchange_account_id]) + render json: { success: false} and return unless exchange_account + + payload = {} + payload[:betting_enabled] = params['canBet'] unless params['canBet'].nil? + payload[:status] = params['isActive'] == true ? 'active' : 'inactive' unless params['isActive'].nil? + + exchange_account.update(payload) unless payload.empty? + render json: { success: true} and return + end + +end diff --git a/portal/app/controllers/api/v1/users_controller.rb b/portal/app/controllers/api/v1/users_controller.rb new file mode 100644 index 0000000..6be5dbe --- /dev/null +++ b/portal/app/controllers/api/v1/users_controller.rb @@ -0,0 +1,13 @@ +class Api::V1::UsersController < Api::AuthenticatedApiController + skip_before_action :authenticate_user!, only: [ :auth] + + def auth + unless user_signed_in? + render json: { success: false } + return + end + + render json: { success: true, user: current_user.json_payload} + end + +end diff --git a/portal/app/controllers/application_controller.rb b/portal/app/controllers/application_controller.rb new file mode 100644 index 0000000..09705d1 --- /dev/null +++ b/portal/app/controllers/application_controller.rb @@ -0,0 +1,2 @@ +class ApplicationController < ActionController::Base +end diff --git a/portal/app/controllers/pages_controller.rb b/portal/app/controllers/pages_controller.rb new file mode 100644 index 0000000..39848c6 --- /dev/null +++ b/portal/app/controllers/pages_controller.rb @@ -0,0 +1,4 @@ +class PagesController < ApplicationController + def index + end +end diff --git a/portal/app/controllers/users/omniauth_callbacks_controller.rb b/portal/app/controllers/users/omniauth_callbacks_controller.rb new file mode 100644 index 0000000..bb67faf --- /dev/null +++ b/portal/app/controllers/users/omniauth_callbacks_controller.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Users + class OmniauthCallbacksController < Devise::OmniauthCallbacksController + def keycloakopenid + Rails.logger.debug(request.env['omniauth.auth']) + @user = User.find_or_create_from_auth_hash(request.env['omniauth.auth']) + sign_in_and_redirect @user, event: :authentication + end + + def failure + redirect_to root_path + end + end +end diff --git a/portal/app/helpers/application_helper.rb b/portal/app/helpers/application_helper.rb new file mode 100644 index 0000000..ca1a4fd --- /dev/null +++ b/portal/app/helpers/application_helper.rb @@ -0,0 +1,25 @@ +module ApplicationHelper + def render_activities(_activities) + 'Activities' + end + + def filter_by_date_range(records, field_string) + return records if field_string.blank? + + field = params[field_string.to_sym] + + return records unless field + + ea_symbolize = JSON.parse(field).symbolize_keys + + parsed_date_from = nil + parsed_date_to = nil + + parsed_date_from = DateTime.parse(ea_symbolize[:from]) unless ea_symbolize[:from].nil? + parsed_date_to = DateTime.parse(ea_symbolize[:to]) unless ea_symbolize[:to].nil? + + records = records.where("#{field_string} >= ? AND #{field_string} <= ?", parsed_date_from, parsed_date_to + 1.day) unless parsed_date_from.nil? + + records + end +end diff --git a/portal/app/helpers/mailer_style_helper.rb b/portal/app/helpers/mailer_style_helper.rb new file mode 100644 index 0000000..db4a210 --- /dev/null +++ b/portal/app/helpers/mailer_style_helper.rb @@ -0,0 +1,11 @@ +module MailerStyleHelper + def mail_logo_style + "background: #222C36; + width: 100%; + display: inline-block; + padding: 15px; + box-sizing: border-box; + border-radius: 10px 10px 0 0" + end +end +# reload!; ProjectMailer.notify_helper_for_join_request(ProjectHelper.first).deliver_now diff --git a/portal/app/jobs/account_sync_and_reconciliation_job.rb b/portal/app/jobs/account_sync_and_reconciliation_job.rb new file mode 100644 index 0000000..d483e77 --- /dev/null +++ b/portal/app/jobs/account_sync_and_reconciliation_job.rb @@ -0,0 +1,14 @@ +class AccountSyncAndReconciliationJob < ApplicationJob + queue_as :high + + def perform(args = {}) + ExchangeAccount.active.each do | ea| + am = Integrations::Betfair::AccountManager.new(ea) + bm = Integrations::Betfair::BetManager.new(ea) + puts "Refreshing account balance on '#{ea.id}'" + am.refresh_account_balance + puts "Reconcile open bets on '#{ea.id}'" + bm.check_qualified_bet_outcome(ea.my_bets.open) + end + end +end diff --git a/portal/app/jobs/application_job.rb b/portal/app/jobs/application_job.rb new file mode 100644 index 0000000..f332cde --- /dev/null +++ b/portal/app/jobs/application_job.rb @@ -0,0 +1,8 @@ +class ApplicationJob < ActiveJob::Base + sidekiq_options retry: false + # Automatically retry jobs that encountered a deadlock + # retry_on ActiveRecord::Deadlocked + + # Most jobs are safe to ignore if the underlying records are no longer available + # discard_on ActiveJob::DeserializationError +end diff --git a/portal/app/jobs/bet_placement_service.rb b/portal/app/jobs/bet_placement_service.rb new file mode 100644 index 0000000..2e1dd6a --- /dev/null +++ b/portal/app/jobs/bet_placement_service.rb @@ -0,0 +1,63 @@ +class BetPlacementService < ApplicationJob + queue_as :high + + def perform(args = {}) + # do not proceed if we have already procssed a bet for this tip + bet = args[:bet] + # get a valid betfair instance for the account we are using, halt if not valid. + exchange = Integrations::Betfair::BetManager.new(bet.exchange_account) + return unless exchange + + last_step = 'Checking event id' + begin + # check this bet with the exchange and populate the event id if it is missing + + unless bet.exchange_event_id + bet.exchange_event_id = exchange.bet_event(bet) + end + bet.placement_attempts += 1 + + # check the odds are still good at the exchange for that event + last_step = 'Checking odds' + prices_and_stakes = exchange.bet_odds(bet) + prices = prices_and_stakes[:prices] + + bet.exchange_odds = prices_and_stakes + bet.outcome = 'expired' + tip_odds = bet.tip_provider_odds.to_f + + # use tip odds or get closest match to tip odds offered by the exchange + last_step = 'Determining if odds optimal' + margin = tip_odds * bet.exchange_account.current_stake_strategy[:odds_margin] + max_tip_odds = tip_odds + margin + odds_to_execute_at = prices.min_by { |num| (max_tip_odds - num).abs } + raise 'Cannot determine optimal odds' unless odds_to_execute_at + if odds_to_execute_at.between?(tip_odds, tip_odds + margin) + last_step = 'Determining stake and placing bet' + odds_stake = prices_and_stakes[:stakes][odds_to_execute_at.to_s] + + bet.executed_odds = odds_to_execute_at.to_s + # stake will always fill the max available or the limits of our stake management - whichever is the lower. + stake = bet.exchange_account.optimal_stake(bet, exchange.minimum_stake, odds_stake) + + raise 'Optimal stake is zero. Not taking bet' if stake.zero? + + bet.outcome = 'open' + bet.stake = stake + bet.expected_value = Bet.calculate_expected_value(stake, odds_to_execute_at) + if bet.exchange_account.can_bet? + bet.exchange_bet_id = exchange.place_bet(bet, stake) + end + else + bet.log << '[Bet placement] Tip odds not found in latest prices, nudge too far out' + end + rescue Exception => e + x = "Placement #{bet.placement_attempts}--->Last step: #{last_step}. Error: #{e.message}. Skipping" + bet.log ||= [] + bet.log << x + bet.outcome = e.message.include?('PERMISSION_DENIED') ? 'errored' : 'skipped' + ensure + bet.save! + end + end +end diff --git a/portal/app/jobs/clear_old_pulls_job.rb b/portal/app/jobs/clear_old_pulls_job.rb new file mode 100644 index 0000000..49b56e7 --- /dev/null +++ b/portal/app/jobs/clear_old_pulls_job.rb @@ -0,0 +1,8 @@ +class ClearOldPullsJob < ApplicationJob + queue_as :high + + def perform(args = {}) + max_hours_to_keep = ENV['MAX_HISTORY_HOURS']&.to_i || 2 + TipSourceData.where("created_at < ?", max_hours_to_keep.hours.ago).delete_all + end +end diff --git a/portal/app/jobs/process_subscription_job.rb b/portal/app/jobs/process_subscription_job.rb new file mode 100644 index 0000000..a9e99b4 --- /dev/null +++ b/portal/app/jobs/process_subscription_job.rb @@ -0,0 +1,10 @@ +class ProcessSubscriptionJob < ApplicationJob + queue_as :high + + def perform(args = {}) + subscription = args[:subscription] + tsd = args[:tsd] + bb = Integrations::Betburger.new() + bb.process_subscription_tips(subscription: subscription, data: tsd) + end +end diff --git a/portal/app/jobs/pull_event_markets_job.rb b/portal/app/jobs/pull_event_markets_job.rb new file mode 100644 index 0000000..f22366b --- /dev/null +++ b/portal/app/jobs/pull_event_markets_job.rb @@ -0,0 +1,11 @@ +class PullEventMarketsJob < ApplicationJob + queue_as :medium + + def perform(args = {}) + ea = ExchangeAccount.find_by(id: ENV['BETFAIR_HUNTER_ACCOUNT']) + raise 'No Betfair hunter account' unless ea + + hunter = Integrations::Betfair::OpportunityHunter.new(ea) + hunter.event_markets_and_selections #refresh the markets to include + end +end diff --git a/portal/app/jobs/pull_latest_odds_prices_job.rb b/portal/app/jobs/pull_latest_odds_prices_job.rb new file mode 100644 index 0000000..0b67386 --- /dev/null +++ b/portal/app/jobs/pull_latest_odds_prices_job.rb @@ -0,0 +1,13 @@ +class PullLatestOddsPricesJob < ApplicationJob + queue_as :medium + + def perform(args = {}) + ea = ExchangeAccount.find_by(id: ENV['BETFAIR_HUNTER_ACCOUNT']) + raise 'No Betfair hunter account' unless ea + + hunter = Integrations::Betfair::OpportunityHunter.new(ea) + BetfairEventRunner.runners_for_open_events.each do | runner | + PullRunnerOddsJob.perform_later(hunter: hunter, runner: runner) + end + end +end diff --git a/portal/app/jobs/pull_runner_odds_job.rb b/portal/app/jobs/pull_runner_odds_job.rb new file mode 100644 index 0000000..1c4a182 --- /dev/null +++ b/portal/app/jobs/pull_runner_odds_job.rb @@ -0,0 +1,9 @@ +class PullRunnerOddsJob < ApplicationJob + queue_as :medium + + def perform(args = {}) + hunter = params[:oh] + runner = params[:runner] + hunter.runner_odds(runner) + end +end diff --git a/portal/app/jobs/pull_tips_job.rb b/portal/app/jobs/pull_tips_job.rb new file mode 100644 index 0000000..075d66d --- /dev/null +++ b/portal/app/jobs/pull_tips_job.rb @@ -0,0 +1,16 @@ +class PullTipsJob < ApplicationJob + queue_as :high + + def perform(args = {}) + ta = TipsterAccount.find_by(id: ENV['TIPSTER_ACCOUNT']) + bb = Integrations::Betburger.new(ta) + ta.tip_sources.active_sources.each do |ts| + tsd = bb.pull_and_save_tips(ts) + ts.source_subscriptions.each do |ss| + next unless ss.exchange_account.active? + + ProcessSubscriptionJob.perform_later(subscription: ss, tsd: tsd) + end + end + end +end diff --git a/portal/app/jobs/pull_upcoming_events_job.rb b/portal/app/jobs/pull_upcoming_events_job.rb new file mode 100644 index 0000000..a374439 --- /dev/null +++ b/portal/app/jobs/pull_upcoming_events_job.rb @@ -0,0 +1,13 @@ +class PullUpcomingEventsJob < ApplicationJob + queue_as :medium + + def perform(args = {}) + ea = ExchangeAccount.find_by(id: ENV['BETFAIR_HUNTER_ACCOUNT']) + raise 'No Betfair hunter account' unless ea + + hunter = Integrations::Betfair::OpportunityHunter.new(ea) + top_of_hour = Time.now.beginning_of_hour + hunter.events_in_timeframe(from: top_of_hour, to: top_of_hour + 1.hour) #pull events + hunter.event_markets_and_selections #refresh the markets to include + end +end diff --git a/portal/app/lib/general_helper.rb b/portal/app/lib/general_helper.rb new file mode 100644 index 0000000..652d2b6 --- /dev/null +++ b/portal/app/lib/general_helper.rb @@ -0,0 +1,6 @@ +class GeneralHelper + + def self.broadcast_to_account_channel(account_id, data) + ExchangeAccountChannel.broadcast_to(account_id, data) + end +end diff --git a/portal/app/lib/integrations/betburger.rb b/portal/app/lib/integrations/betburger.rb new file mode 100644 index 0000000..e74a926 --- /dev/null +++ b/portal/app/lib/integrations/betburger.rb @@ -0,0 +1,940 @@ +module Integrations + class Betburger + include HTTParty + attr_reader :access_token + + URIS = { + live: 'https://rest-api-lv.betburger.com/api/v1/valuebets/bot_pro_search', + prematch: 'https://rest-api-pr.betburger.com/api/v1/valuebets/bot_pro_search', + }.freeze.with_indifferent_access + + def initialize(tipster_account=nil) + super() + tipster_account ||= TipsterAccount.find_by(id: ENV['TIPSTER_ACCOUNT']) + @access_token = tipster_account.apikey + end + + def pull_and_save_tips(source) + url = URIS[source.source_type] + return unless url + + response = self.class.post(url, { body: { per_page: 32, search_filter: source.filters, access_token: @access_token } }) + source.tip_source_data.create(data: response) + end + + def process_subscription_tips(subscription:, data:,place_bets: true) + + return unless subscription + + tsd = data || subscription.tip_source_data.last + + # tips already processed + return if subscription.subscription_runs.where(tip_source_data_id: tsd.id).exists? + + json = tsd.data + return unless json['total_by_filter'].to_i.positive? + + percentages = {} + vb_refs = json['source']['value_bets'] + vb_refs.each do |vb_ref| + percentages[vb_ref['bet_id']] = { percent: vb_ref['percent'] } + end + bet_refs = json['bets'] + stats = { non_valuebet: 0, unsupported_exchange: 0, duplicate: 0, processed: 0 } + bet_refs.each do |bf| + place_this_bet = place_bets + unless bf['is_value_bet'] == true + stats[:non_valuebet] += 1 + next + end + + exchange = exchanges[(bf['bookmaker_id']).to_s] + unless exchange + stats[:unsupported_exchange] += 1 + next + end + + tipster_event_id = bf['event_id'] + + log = [] + bet_id = bf['id'] + + vb = populate_bet_hash(subscription, bet_id, bf, exchange, log, percentages, tipster_event_id) + # on live betting, strictly adhere to max_odds rule. Otherwise bet on all odds to generate learning data. + odds_within_range = vb[:tip_provider_odds].to_f.between?( subscription.exchange_account.current_stake_strategy[:min_odds_to_bet], subscription.exchange_account.current_stake_strategy[:max_odds_to_bet]) + ev_within_range = vb[:tip_provider_percent].to_f.between?( subscription.exchange_account.current_stake_strategy[:min_ev], subscription.exchange_account.current_stake_strategy[:max_ev]) + unless odds_within_range && ev_within_range + log << "[Parsing] Tip provider odds not within #{subscription.exchange_account.current_stake_strategy[:min_odds_to_bet]} and #{subscription.exchange_account.current_stake_strategy[:max_odds_to_bet]}" unless odds_within_range + log << "[Parsing] Tip provider EV not within #{subscription.exchange_account.current_stake_strategy[:min_ev]} and #{subscription.exchange_account.current_stake_strategy[:max_ev]}" unless ev_within_range + vb[:outcome] = 'ignored' + place_this_bet = false + end + + vb[:log] = log + skip_duplicate_event_check = false + duplicates = subscription.exchange_account.my_bets.where(tip_provider_bet_id: bet_id) + if duplicates.exists? + stats[:duplicate] += 1 + if duplicates.retryable_bets.count.positive? + bet = duplicates.retryable_bets.last #only supporting 1 duplicate! + bet.log = [] + bet.update(vb) + skip_duplicate_event_check = true + else + next + end + end + + unless skip_duplicate_event_check + next if subscription.exchange_account.my_bets.where(tip_provider_event_id: tipster_event_id).exists? && !subscription.exchange_account.allow_multiple_bets_per_event + end + + begin + bet ||= Bet.unscoped.create(vb) + + if bet && place_this_bet && vb[:exchange_market_details] + BetPlacementService.perform_later(bet: bet) + end + rescue Exception => e + puts "Something weird happened: #{e.message}" + end + stats[:processed] += 1 + end + subscription.subscription_runs.create(tip_source_data_id: tsd.id, log: ["Payload: #{bet_refs.size}. #{stats}"]) + end + + def populate_bet_hash(subscription, bet_id, bf, exchange, log, percentages, tipster_event_id) + vb = { tip_provider_id: 'betburger', tip_provider_bet_id: bet_id } + vb[:exchange_id] = exchange + vb[:exchange_account_id] = subscription.exchange_account.id + vb[:tip_provider_event_id] = tipster_event_id + vb[:tip_provider_odds] = bf['koef'] # what the tip provider says the odds are from the exchange + vb[:exchange_event_id] = bf['bookmaker_event_direct_link'] # may be blank + vb[:team1] = bf['team1_name'] + vb[:team2] = bf['team2_name'] + vb[:period] = periods[bf['period_id'].to_s.to_sym] unless bf['period_id'].blank? + vb[:exchange_event_name] = vb[:team1] + vb[:exchange_event_name] += " v #{vb[:team2]}" unless vb[:team2].blank? + vb[:exchange_event_id] = bf['bookmaker_event_direct_link'] # may be blank + vb[:tip_provider_percent] = percentages[bet_id][:percent] # dunno what this is used for + tip_market_details = {} + tip_market_details[:market] = variations[bf['market_and_bet_type']&.to_s] + tip_market_details[:params] = bf['market_and_bet_type_param']&.to_s + vb[:tip_provider_market_details] = tip_market_details + exchange_market_details = map_exchange_market_details(exchange, tip_market_details, vb[:team1], vb[:team2]) + vb[:original_json] = bf + if exchange_market_details + vb[:exchange_market_details] = exchange_market_details + else + vb[:outcome] = 'ignored' + log << '[Parsing] Unsupported market/selection' + end + vb + end + + def exchanges + @exchanges ||= { + '11' => 'betfair' + } + end + + def map_exchange_market_details(exchange, tip_market_details, team1, team2) + market_mapping = mapped_markets(exchange) + return unless market_mapping + + rtn = market_mapping[tip_market_details[:market]]&.clone + return unless rtn + + swaps = { 'Team1' => team1, 'Team2' => team2, '%s' => tip_market_details[:params]} + if tip_market_details[:params] + rounded = tip_market_details[:params].to_f.round + swaps['%rounded_s'] = "#{rounded.positive? ? '+':''}#{rounded}" if tip_market_details[:params] + swaps['%rounded_positive_s'] = "#{rounded.abs}" + swaps['%nearest_half_over_s'] = determine_nearest_half(tip_market_details[:params], 'over') if tip_market_details[:params] + swaps['%nearest_half_under_s'] = determine_nearest_half(tip_market_details[:params], 'under') if tip_market_details[:params] + end + + swaps.keys.each do |k| + rtn[:market] = rtn[:market].sub(k, swaps[k]) + rtn[:selection] = rtn[:selection].sub(k, swaps[k]) + end + if rtn.key?(:handicap) + rtn[:handicap] = tip_market_details[:params].to_f + end + rtn + end + + def determine_nearest_half(line, direction ) + line_f = line.to_f - line.to_i + return line if line_f == 0.5 + + line_f = line.to_i + 0.5 + line_f -= 1 if direction == 'over' + line_f += 1 if direction == 'under' + + line_f.to_s + end + + + def mapped_markets(key) + # this is ready for other exchanges + @mapped_markets ||= { 'betfair' => betfair_markets } + @mapped_markets[key] + end + + def betfair_markets + { + 'Team1 Win' => { market: 'Match Odds', selection: 'Team1' }, + 'Team2 Win' => { market: 'Match Odds', selection: 'Team2' }, + 'X' => { market: 'Match Odds', selection: 'The Draw' }, + '1' => { market: 'Match Odds', selection: 'Team1' }, + '2' => { market: 'Match Odds', selection: 'Team2' }, + '1X' => { market: 'Double Chance', selection: 'Home or Draw' }, + '12' => { market: 'Double Chance', selection: 'Home or Away' }, + 'X2' => { market: 'Double Chance', selection: 'Draw or Away' }, + 'Total Over(%s)' => { market: 'Over/Under %nearest_half_over_s Goals', selection: 'Over %nearest_half_over_s Goals' }, + 'Total Over(%s) for Team1' => { market: 'Team1 Over/Under %nearest_half_over_s Goals', selection: 'Over %nearest_half_over_s Goals' }, + 'Total Over(%s) for Team2' => { market: 'Team2 Over/Under %nearest_half_over_s Goals', selection: 'Over %nearest_half_over_s Goals' }, + 'Total Under(%s)' => { market: 'Over/Under %nearest_half_under_s Goals', selection: 'Under %nearest_half_under_s Goals' }, + 'Total Under(%s) for Team1' => { market: 'Team1 Over/Under %nearest_half_under_s Goals', selection: 'Under %nearest_half_under_s Goals' }, + 'Total Under(%s) for Team2' => { market: 'Team2 Over/Under %nearest_half_under_s Goals', selection: 'Under %nearest_half_under_s Goals' }, + 'Asian Handicap1(%s)' => { market: 'Asian Handicap', selection: 'Team1' }, + 'Asian Handicap1(0.0)/Draw No Bet' => { market: 'Draw no Bet', selection: 'Team1' }, + 'Asian Handicap2(0.0)/Draw No Bet' => { market: 'Draw no Bet', selection: 'Team2' }, + 'Asian Handicap2(%s)' => { market: 'Asian Handicap', selection: 'Team2' }, + 'European Handicap1(%s)' => { market: 'Team1 +%rounded_positive_s', selection: 'Team1 %rounded_s' }, + 'European Handicap2(%s)' => { market: 'Team1 +%rounded_positive_s', selection: 'Team2 %rounded_s' }, + 'European HandicapX(%s)' => { market: 'Team1 +%rounded_positive_s', selection: 'Draw' }, + 'Both to score' => { market: 'Both teams to score?', selection: 'Yes' }, + 'One scoreless' => { market: 'Both teams to score?', selection: 'No' }, + 'Only one to score' => { market: 'Both teams to score?', selection: 'No' }, + 'Odd' => { market: 'Total Goals Odd/Even', selection: 'Odd' }, + 'Even' => { market: 'Total Goals Odd/Even', selection: 'Even' }, + 'Mat' => { market: 'Total Goals Odd/Even', selection: 'Even' }, + } + end + + def variations + @variations ||= + { '1' => 'Team1 Win', + '2' => 'Team2 Win', + '3' => 'Asian Handicap1(0.0)/Draw No Bet', + '4' => 'Asian Handicap2(0.0)/Draw No Bet', + '5' => 'European Handicap1(%s)', + '6' => 'European HandicapX(%s)', + '7' => 'European Handicap2(%s)', + '8' => 'Both to score', + '9' => 'One scoreless', + '10' => 'Only one to score', + '11' => '1', + '12' => 'X', + '13' => '2', + '14' => '1X', + '15' => 'X2', + '16' => '12', + '17' => 'Asian Handicap1(%s)', + '18' => 'Asian Handicap2(%s)', + '19' => 'Total Over(%s)', + '20' => 'Total Under(%s)', + '21' => 'Total Over(%s) for Team1', + '22' => 'Total Under(%s) for Team1', + '23' => 'Total Over(%s) for Team2', + '24' => 'Total Under(%s) for Team2', + '25' => 'Odd', + '26' => 'Even', + '27' => '1 - Yellow Cards', + '28' => 'X - Yellow Cards', + '29' => '2 - Yellow Cards', + '30' => '1X - Yellow Cards', + '31' => '12 - Yellow Cards', + '32' => 'X2 - Yellow Cards', + '33' => 'Asian Handicap1(%s) - Yellow Cards', + '34' => 'Asian Handicap2(%s) - Yellow Cards', + '35' => 'Total Over(%s) - Yellow Cards', + '36' => 'Total Under(%s) - Yellow Cards', + '37' => 'Total Under(%s) for Team1 - Yellow Cards', + '38' => 'Total Over(%s) for Team1 - Yellow Cards', + '39' => 'Total Under(%s) for Team2 - Yellow Cards', + '40' => 'Total Over(%s) for Team2 - Yellow Cards', + '41' => 'Even - Yellow Cards', + '42' => 'Odd - Yellow Cards', + '43' => '1 - Corners', + '44' => 'X - Corners', + '45' => '2 - Corners', + '46' => '1X - Corners', + '47' => '12 - Corners', + '48' => 'X2 - Corners', + '49' => 'Asian Handicap1(%s) - Corners', + '50' => 'Asian Handicap2(%s) - Corners', + '51' => 'Total Over(%s) - Corners', + '52' => 'Total Under(%s) - Corners', + '53' => 'Total Under(%s) for Team1 - Corners', + '54' => 'Total Over(%s) for Team1 - Corners', + '55' => 'Total Under(%s) for Team2 - Corners', + '56' => 'Total Over(%s) for Team2 - Corners', + '57' => 'Odd - Corners', + '58' => 'Even - Corners', + '63' => 'Red card - yes', + '64' => 'No red card', + '65' => 'Penalty - yes', + '66' => 'No penalty', + '67' => 'Score (%s)', + '68' => 'Total Over(%s) - Tie Break', + '69' => 'Total Under(%s) - Tie Break', + '70' => 'Score (%s) - not', + '71' => 'Any substitute to score a goal - yes', + '72' => 'Any substitute to score a goal - no', + '73' => 'Team1 to win by exactly 1 goal - yes', + '74' => 'Team1 to win by exactly 1 goal - no', + '75' => 'Team2 to win by exactly 1 goal - yes', + '76' => 'Team2 to win by exactly 1 goal - no', + '77' => 'Team1 to win by exactly 2 goals - yes', + '78' => 'Team1 to win by exactly 2 goals - no', + '79' => 'Team1 to win by exactly 3 goals - yes', + '80' => 'Team1 to win by exactly 3 goals - no', + '81' => 'Team2 to win by exactly 2 goals - yes', + '82' => 'Team2 to win by exactly 2 goals - no', + '83' => 'Team2 to win by exactly 3 goals - yes', + '84' => 'Team2 to win by exactly 3 goals - no', + '85' => 'Team1 to win to Nil - yes', + '86' => 'Team1 to win to Nil - no', + '87' => 'Team2 to win to Nil - yes', + '88' => 'Team2 to win to Nil - no', + '89' => 'Team1 to win either halves - yes', + '90' => 'Team1 to win either halves - no', + '91' => 'Team2 to win either halves - yes', + '92' => 'Team2 to win either halves - no', + '93' => 'Draw in either half - yes', + '94' => 'Draw in either half - no', + '95' => 'Team1 to win in both halves - yes', + '96' => 'Team1 to win in both halves - no', + '97' => 'Team2 to win in both halves - yes', + '98' => 'Team2 to win in both halves - no', + '99' => 'Team1 to win and Total Over 2.5 - yes', + '100' => 'Team1 to win and Total Over 2.5 - no', + '101' => 'Team1 to win and Total Under 2.5 - yes', + '102' => 'Team1 to win and Total Under 2.5 - no', + '103' => 'Team2 to win and Total Over 2.5 - yes', + '104' => 'Team2 to win and Total Over 2.5 - no', + '105' => 'Team2 to win and Total Under 2.5 - yes', + '106' => 'Team2 to win and Total Under 2.5 - no', + '107' => 'Draw in both half - yes', + '108' => 'Draw in both half - no', + '109' => 'Draw and Total Over 2.5 - yes', + '110' => 'Draw and Total Over 2.5 - no', + '111' => 'Draw and Total Under 2.5 - yes', + '112' => 'Draw and Total Under 2.5 - no', + '113' => 'Goals in both halves - yes', + '114' => 'Goals in both halves - no', + '115' => 'Team1 to score in both halves - yes', + '116' => 'Team1 to score in both halves - no', + '117' => 'Team2 to score in both halves - yes', + '118' => 'Team2 to score in both halves - no', + '119' => 'Double - yes', + '120' => 'Double - no', + '121' => 'Hattrick - yes', + '122' => 'Hattrick - no', + '123' => 'Own goal - yes', + '124' => 'Own goal - no', + '125' => 'Both halves > 1.5 goals - yes', + '126' => 'Both halves > 1.5 goals - no', + '127' => 'Both halves < 1.5 goals - yes', + '128' => 'Both halves < 1.5 goals - no', + '129' => 'Sets (%s)', + '130' => 'Sets (%s) - not', + '131' => 'Asian Handicap1(%s) - Sets', + '132' => 'Asian Handicap2(%s) - Sets', + '133' => 'Total Over(%s) - Sets', + '134' => 'Total Under(%s) - Sets', + '135' => 'Team1/Team1', + '136' => 'Team1/Team1 - no', + '137' => 'Team1/Draw', + '138' => 'Team1/Draw - no', + '139' => 'Team1/Team2', + '140' => 'Team1/Team2 - no', + '141' => 'Draw/Team1', + '142' => 'Draw/Team1 - no', + '143' => 'Draw/Draw', + '144' => 'Draw/Draw - no', + '145' => 'Draw/Team2', + '146' => 'Draw/Team2 - no', + '147' => 'Team2/Team1', + '148' => 'Team2/Team1 - no', + '149' => 'Team2/Draw', + '150' => 'Team2/Draw - no', + '151' => 'Team2/Team2', + '152' => 'Team2/Team2 - no', + '153' => 'Exact (%s)', + '154' => 'Exact (%s) - no', + '155' => 'Exact (%s) for Team1', + '156' => 'Exact (%s) for Team1 - no', + '157' => 'Exact (%s) for Team2', + '158' => 'Exact (%s) for Team2 - no', + '159' => 'More goals in the 1st half', + '160' => 'Equal goals in halves', + '161' => 'More goals in the 2nd half', + '162' => '1 half most goals (draw no bet)', + '163' => '2 half most goals (draw no bet)', + '164' => 'Team1 - 1st goal', + '165' => 'No goal', + '166' => 'Team2 - 1st goal', + '167' => 'Team1 - 1st goal (draw no bet)', + '168' => 'Team2 - 1st goal (draw no bet)', + '169' => 'Team1 - Last goal', + '170' => 'No goal', + '171' => 'Team2 - Last goal', + '172' => 'Team1 - Last goal (draw no bet)', + '173' => 'Team2 - Last goal (draw no bet)', + '174' => 'Total Over(%s) - Aces', + '175' => 'Total Under(%s) - Aces', + '176' => 'Total Over(%s) for Team1 - Aces', + '177' => 'Total Under(%s) for Team1 - Aces', + '178' => 'Total Over(%s) for Team2 - Aces', + '179' => 'Total Under(%s) for Team2 - Aces', + '180' => 'Total Over(%s) - Double Faults', + '181' => 'Total Under(%s) - Double Faults', + '182' => 'Total Over(%s) for Team1 - Double Faults', + '183' => 'Total Under(%s) for Team1 - Double Faults', + '184' => 'Total Over(%s) for Team2 - Double Faults', + '185' => 'Total Under(%s) for Team2 - Double Faults', + '186' => 'Total Over(%s) for Team1 - 1st Serve', + '187' => 'Total Under(%s) for Team1 - 1st Serve', + '188' => 'Total Over(%s) for Team2 - 1st Serve', + '189' => 'Total Under(%s) for Team2 - 1st Serve', + '190' => 'Asian Handicap1(%s) - Aces', + '191' => 'Asian Handicap2(%s) - Aces', + '192' => 'Asian Handicap1(%s) - Double Faults', + '193' => 'Asian Handicap2(%s) - Double Faults', + '194' => 'Asian Handicap1(%s) - 1st Serve', + '195' => 'Asian Handicap2(%s) - 1st Serve', + '196' => 'Player1 - 1st Ace', + '197' => 'Player2 - 1st Ace', + '198' => 'Player1 - 1st Double Fault', + '199' => 'Player2 - 1st Double Fault', + '200' => 'Player1 - 1st Break', + '201' => 'Player2 - 1st Break', + '202' => 'Player1 - 1st Break', + '203' => 'No break - 1st Break', + '204' => 'Player2 - 1st Break', + '205' => '6-0 Set - yes', + '206' => '6-0 Set - no', + '207' => 'Win From Behind - yes', + '208' => 'Win From Behind - no', + '209' => 'Exact (%s) - Sets', + '210' => 'Exact (%s) - Sets - no', + '211' => 'Team1 - 1st corner', + '212' => 'No corners', + '213' => 'Team2 - 1st corner', + '214' => 'Team1 - Last corner', + '215' => 'No corners', + '216' => 'Team2 - Last corner', + '217' => 'Team1 - 1st Yellow Card', + '218' => 'No Yellow Card', + '219' => 'Team2 - 1st Yellow Card', + '220' => 'Team1 - Last Yellow Card', + '221' => 'No Yellow Card', + '222' => 'Team2 - Last Yellow Card', + '223' => 'Team1 - 1st offside', + '224' => 'No offsides', + '225' => 'Team2 - 1st offside', + '226' => 'Team1 - Last offside', + '227' => 'No offsides', + '228' => 'Team2 - Last offside', + '229' => '1st substitution - 1 half', + '230' => '1st substitution - intermission', + '231' => '1st substitution - 2 half', + '232' => '1st goal - 1 half', + '233' => 'No goal', + '234' => '1st goal - 2 half', + '235' => 'Team1 - 1st subs', + '236' => 'Team2 - 1st subs', + '237' => 'Team1 - Last subs', + '238' => 'Team2 - Last subs', + '239' => '1 - Shots on goal', + '240' => 'X - Shots on goal', + '241' => '2 - Shots on goal', + '242' => '1X - Shots on goal', + '243' => '12 - Shots on goal', + '244' => 'X2 - Shots on goal', + '245' => 'Asian Handicap1(%s) - Shots on goal', + '246' => 'Asian Handicap2(%s) - Shots on goal', + '247' => 'Total Over(%s) - Shots on goal', + '248' => 'Total Under(%s) - Shots on goal', + '249' => 'Total Over(%s) for Team1 - Shots on goal', + '250' => 'Total Under(%s) for Team1 - Shots on goal', + '251' => 'Total Over(%s) for Team2 - Shots on goal', + '252' => 'Total Under(%s) for Team2 - Shots on goal', + '253' => 'Odd - Shots on goal', + '254' => 'Even - Shots on goal', + '255' => '1 - Fouls', + '256' => 'X - Fouls', + '257' => '2 - Fouls', + '258' => '1X - Fouls', + '259' => '12 - Fouls', + '260' => 'X2 - Fouls', + '261' => 'Asian Handicap1(%s) - Fouls', + '262' => 'Asian Handicap2(%s) - Fouls', + '263' => 'Total Over(%s) - Fouls', + '264' => 'Total Under(%s) - Fouls', + '265' => 'Total Over(%s) for Team1 - Fouls', + '266' => 'Total Under(%s) for Team1 - Fouls', + '267' => 'Total Over(%s) for Team2 - Fouls', + '268' => 'Total Under(%s) for Team2 - Fouls', + '269' => 'Odd - Fouls', + '270' => 'Even - Fouls', + '271' => '1 - Offsides', + '272' => 'X - Offsides', + '273' => '2 - Offsides', + '274' => '1X - Offsides', + '275' => '12 - Offsides', + '276' => 'X2 - Offsides', + '277' => 'Asian Handicap1(%s) - Offsides', + '278' => 'Asian Handicap2(%s) - Offsides', + '279' => 'Total Over(%s) - Offsides', + '280' => 'Total Under(%s) - Offsides', + '281' => 'Total Over(%s) for Team1 - Offsides', + '282' => 'Total Under(%s) for Team1 - Offsides', + '283' => 'Total Over(%s) for Team2 - Offsides', + '284' => 'Total Under(%s) for Team2 - Offsides', + '285' => 'Odd - Offsides', + '286' => 'Even - Offsides', + '287' => 'Team1 to Win From Behind - yes', + '288' => 'Team1 to Win From Behind - no', + '289' => 'Team2 to Win From Behind - yes', + '290' => 'Team2 to Win From Behind - no', + '291' => 'Both To Score and W1 - yes', + '292' => 'Both To Score and W1 - no', + '293' => 'Both To Score and W2 - yes', + '294' => 'Both To Score and W2 - no', + '295' => 'Both To Score and Draw - yes', + '296' => 'Both To Score and Draw - no', + '297' => 'Exact (%s) - Added time', + '298' => 'Exact (%s) - Added time - no', + '299' => 'Total Over(%s) - Added time', + '300' => 'Total Under(%s) - Added time', + '301' => 'Home No Bet - W2', + '302' => 'Home No Bet - Draw', + '303' => 'Home No Bet - No Draw', + '304' => 'Away No Bet - W1', + '305' => 'Away No Bet - Draw', + '306' => 'Away No Bet - No Draw', + '307' => 'Total Over(%s) - Subs', + '308' => 'Total Under(%s) - Subs', + '309' => 'Total Over(%s) for Team1 - Subs', + '310' => 'Total Under(%s) for Team1 - Subs', + '311' => 'Total Over(%s) for Team2 - Subs', + '312' => 'Total Under(%s) for Team1 - Subs', + '313' => '1 - Ball possession', + '314' => 'X - Ball possession', + '315' => '2 - Ball possession', + '316' => '1X - Ball possession', + '317' => '12 - Ball possession', + '318' => 'X2 - Ball possession', + '319' => 'Asian Handicap1(%s) - Ball possession', + '320' => 'Asian Handicap2(%s) - Ball possession', + '321' => 'Total Over(%s) for Team1 - Ball possession', + '322' => 'Total Under(%s) for Team1 - Ball possession', + '323' => 'Total Over(%s) for Team2 - Ball possession', + '324' => 'Total Under(%s) for Team2 - Ball possession', + '325' => 'Team1 - 1st corner', + '326' => 'Team2 - 1st corner', + '327' => 'Team1 - Last corner', + '328' => 'Team2 - Last corner', + '329' => 'Team1 - 1st Yellow Card', + '330' => 'Team2 - 1st Yellow Card', + '331' => 'Team1 - Last Yellow Card', + '332' => 'Team2 - Last Yellow Card', + '333' => 'Team1 - 1st offside', + '334' => 'Team2 - 1st offside', + '335' => 'Team1 - Last offside', + '336' => 'Team2 - Last offside', + '337' => 'Home No Bet - No W2', + '338' => 'Away No Bet - No W1', + '339' => '1X and Total Over 2.5 - yes', + '340' => '1X and Total Over 2.5 - no', + '341' => '1X and Total Under 2.5 - yes', + '342' => '1X and Total Under 2.5 - no', + '343' => 'X2 and Total Over 2.5 - yes', + '344' => 'X2 and Total Over 2.5 - no', + '345' => 'X2 and Total Under 2.5 - yes', + '346' => 'X2 and Total Under 2.5 - no', + '348' => 'Overtime - yes', + '351' => 'Overtime - no', + '354' => 'Score Draw - yes', + '357' => 'Score Draw - no', + '360' => 'Race to 2 - Team1', + '363' => 'Race to 2 - Team2', + '366' => 'Race to 2 - Team1', + '369' => 'Race to 2 - neither', + '372' => 'Race to 2 - Team2', + '375' => 'Race to 3 - Team1', + '378' => 'Race to 3 - Team2', + '381' => 'Race to 3 - Team1', + '384' => 'Race to 3 - neither', + '387' => 'Race to 3 - Team2', + '390' => 'Race to 4 - Team1', + '393' => 'Race to 4 - Team2', + '396' => 'Race to 4 - Team1', + '399' => 'Race to 4 - neither', + '402' => 'Race to 4 - Team2', + '405' => 'Race to 5 - Team1', + '408' => 'Race to 5 - Team2', + '411' => 'Race to 5 - Team1', + '414' => 'Race to 5 - neither', + '417' => 'Race to 5 - Team2', + '420' => 'Race to 10 - Team1', + '423' => 'Race to 10 - Team2', + '426' => 'Race to 10 - Team1', + '429' => 'Race to 10 - neither', + '432' => 'Race to 10 - Team2', + '435' => 'Race to 15 - Team1', + '438' => 'Race to 15 - Team2', + '441' => 'Race to 15 - Team1', + '444' => 'Race to 15 - neither', + '447' => 'Race to 15 - Team2', + '450' => 'Race to 20 - Team1', + '453' => 'Race to 20 - Team2', + '456' => 'Race to 20 - Team1', + '459' => 'Race to 20 - neither', + '462' => 'Race to 20 - Team2', + '467' => 'Team1 - Next goal (draw no bet)', + '470' => 'Team2 - Next goal (draw no bet)', + '473' => 'Team1 - Next goal', + '476' => 'No next goal', + '479' => 'Team2 - Next goal', + '481' => '1st - yes', + '484' => '1st - no', + '487' => '1st-3rd - yes', + '490' => '1st-3rd - no', + '493' => 'W1', + '496' => 'W2', + '499' => 'TO(%s) - Hits', + '502' => 'TU(%s) - Hits', + '505' => 'TO(%s) for Team1 - Hits', + '508' => 'TU(%s) for Team1 - Hits', + '511' => 'TO(%s) for Team2 - Hits', + '514' => 'TU(%s) for Team2 - Hits', + '517' => 'TO(%s) - Errors', + '520' => 'TU(%s) - Errors', + '523' => 'TO(%s) - Hits+Errors+Runs', + '526' => 'TU(%s) - Hits+Errors+Runs', + '529' => 'AH1(%s) - Hits', + '532' => 'AH2(%s) - Hits', + '535' => '1 - Hits', + '538' => 'X - Hits', + '541' => '2 - Hits', + '546' => 'Team1 Win - Kills', + '549' => 'Team2 Win - Kills', + '552' => 'Asian Handicap1(%s) - Kills', + '555' => 'Asian Handicap2(%s) - Kills', + '558' => 'Total Over(%s) - Kills', + '561' => 'Total Under(%s) - Kills', + '564' => 'Total Over(%s) for Team1 - Kills', + '567' => 'Total Under(%s) for Team1 - Kills', + '570' => 'Total Over(%s) for Team2 - Kills', + '573' => 'Total Under(%s) for Team2 - Kills', + '574' => 'W1 - 1st blood', + '575' => 'W2 - 1st blood', + '576' => 'W1 - 1st tower', + '577' => 'W2 - 1st tower', + '578' => 'W1 - 1st dragon', + '579' => 'W2 - 1st dragon', + '580' => 'W1 - 1st baron', + '581' => 'W2 - 1st baron', + '582' => 'W1 - 1st inhibitor', + '583' => 'W2 - 1st inhibitor', + '584' => 'W1 - 1st roshan', + '585' => 'W2 - 1st roshan', + '586' => 'Win pistol rounds - Yes', + '587' => 'Win pistol rounds - No', + '588' => 'TO(%s) - Duration', + '589' => 'TU(%s) - Duration', + '590' => 'TO(%s) - Barons', + '591' => 'TU(%s) - Barons', + '592' => 'TO(%s) - Inhibitors', + '593' => 'TU(%s) - Inhibitors', + '594' => 'TO(%s) - Towers', + '595' => 'TU(%s) - Towers', + '596' => 'TO(%s) - Dragons', + '597' => 'TU(%s) - Dragons', + '598' => 'TO(%s) - Roshans', + '599' => 'TU(%s) - Roshans', + '600' => 'TO(%s) for Team1 - Sets', + '601' => 'TO(%s) for Team1 - Sets', + '602' => 'TO(%s) for Team2 - Sets', + '603' => 'TO(%s) for Team2 - Sets', + '604' => 'W1 - Longest TD', + '605' => 'W2 - Longest TD', + '606' => 'W1 - Longest FG', + '607' => 'W2 - Longest FG', + '608' => 'Touchdown - Yes', + '609' => 'Touchdown - No', + '610' => 'Safety - Yes', + '611' => 'Safety - No', + '612' => 'First score TD - Yes', + '613' => 'First score TD - No', + '614' => 'Both teams 10 pts - Yes', + '615' => 'Both teams 10 pts - No', + '616' => 'Both teams 15 pts - Yes', + '617' => 'Both teams 15 pts - No', + '618' => 'Both teams 20 pts - Yes', + '619' => 'Both teams 20 pts - No', + '620' => 'Both teams 25 pts - Yes', + '621' => 'Both teams 25 pts - No', + '622' => 'Both teams 30 pts - Yes', + '623' => 'Both teams 30 pts - No', + '624' => 'Both teams 35 pts - Yes', + '625' => 'Both teams 35 pts - No', + '626' => 'Both teams 40 pts - Yes', + '627' => 'Both teams 40 pts - No', + '628' => 'Both teams 45 pts - Yes', + '629' => 'Both teams 45 pts - No', + '630' => 'Both teams 50 pts - Yes', + '631' => 'Both teams 50 pts - No', + '632' => 'Highest Scoring Quarter - 1st', + '633' => 'Highest Scoring Quarter - 2nd', + '634' => 'Highest Scoring Quarter - 3rd', + '635' => 'Highest Scoring Quarter - 4th', + '636' => 'Highest Scoring Quarter - Tie', + '637' => 'TO(%s) - Field Goals', + '638' => 'TU(%s) - Field Goals', + '639' => 'TO(%s) - Touchdowns', + '640' => 'TU(%s) - Touchdowns', + '641' => 'TO(%s) - Longest TD, distance', + '642' => 'TU(%s) - Longest TD, distance', + '643' => 'TO(%s) - Longest FG, distance', + '644' => 'TU(%s) - Longest FG, distance', + '645' => 'TO(%s) for Team1 - Field Goals', + '646' => 'TU(%s) for Team1 - Field Goals', + '647' => 'TO(%s) for Team2 - Field Goals', + '648' => 'TU(%s) for Team2 - Field Goals', + '649' => 'TO(%s) for Team1 - Touchdowns', + '650' => 'TU(%s) for Team1 - Touchdowns', + '651' => 'TO(%s) for Team2 - Touchdowns', + '652' => 'TU(%s) for Team2 - Touchdowns', + '653' => 'AH1(%s) - Maps', + '654' => 'AH2(%s) - Maps', + '655' => 'TO(%s) - Maps', + '656' => 'TU(%s) - Maps', + '657' => 'TO(%s) for Team1 - Maps', + '658' => 'TU(%s) for Team1 - Maps', + '659' => 'TO(%s) for Team2 - Maps', + '660' => 'TU(%s) for Team2 - Maps', + '661' => 'Maps (%s)', + '662' => 'Maps (%s) - not', + '663' => 'Exact (%s) - Maps', + '664' => 'Exact (%s) - Maps - no', + '665' => 'Win both pistol rounds - Yes', + '666' => 'Win both pistol rounds - No', + '667' => 'Team1 to win both pistol rounds - Yes', + '668' => 'Team1 to win both pistol rounds - No', + '669' => 'Team2 to win both pistol rounds - Yes', + '670' => 'Team1 to win both pistol rounds - No', + '671' => 'Team1 to win at least 1 set - Yes', + '672' => 'Team1 to win at least 1 set - No', + '673' => 'Team2 to win at least 1 set - Yes', + '674' => 'Team1 to win at least 1 set - No', + '675' => 'Team1 to win at least 1 map - Yes', + '676' => 'Team1 to win at least 1 map - No', + '677' => 'Team2 to win at least 1 map - Yes', + '678' => 'Team1 to win at least 1 map - No', + '679' => 'Both teams kill a dragon - Yes', + '680' => 'Both teams kill a dragon - No', + '681' => 'Both teams kill a baron - Yes', + '682' => 'Both teams kill a baron - No', + '683' => 'W1 - 1st Barracks', + '684' => 'W2 - 1st Barracks', + '685' => 'W1 - 1st Double Kill', + '686' => 'W2 - 1st Double Kill', + '687' => 'TO(%s) - Barracks', + '688' => 'TU(%s) - Barracks', + '689' => 'TO(%s) - Double Kills', + '690' => 'TU(%s) - Double Kills', + '691' => 'AH1(%s) - Barons', + '692' => 'AH2(%s) - Barons', + '693' => 'AH1(%s) - Dragons', + '694' => 'AH2(%s) - Dragons', + '695' => 'AH1(%s) - Towers/Turrets', + '696' => 'AH2(%s) - Towers/Turrets', + '697' => '1 - 3-points', + '698' => 'X - 3-points', + '699' => '2 - 3-points', + '700' => '1 - Rebounds', + '701' => 'X - Rebounds', + '702' => '2 - Rebounds', + '703' => '1 - Assists', + '704' => 'X - Assists', + '705' => '2 - Assists', + '706' => '1X - 3-points', + '707' => 'X2 - 3-points', + '708' => '12 - 3-points', + '709' => '1X - Rebounds', + '710' => 'X2 - Rebounds', + '711' => '12 - Rebounds', + '712' => '1X - Assists', + '713' => 'X2 - Assists', + '714' => '12 - Assists', + '715' => 'AH1(%s) - 3-points', + '716' => 'AH2(%s) - 3-points', + '717' => 'AH1(%s) - Rebounds', + '718' => 'AH2(%s) - Rebounds', + '719' => 'AH1(%s) - Assists', + '720' => 'AH2(%s) - Assists', + '721' => 'TO(%s) - 3-points', + '722' => 'TU(%s) - 3-points', + '723' => 'TO(%s) - Rebounds', + '724' => 'TU(%s) - Rebounds', + '725' => 'TO(%s) - Assists', + '726' => 'TU(%s) - Assists', + '727' => 'TO(%s) for Team1 - 3-points', + '728' => 'TU(%s) for Team1 - 3-points', + '729' => 'TO(%s) for Team1 - Rebounds', + '730' => 'TU(%s) for Team1 - Rebounds', + '731' => 'TO(%s) for Team1 - Assists', + '732' => 'TU(%s) for Team1 - Assists', + '733' => 'TO(%s) for Team2 - 3-points', + '734' => 'TU(%s) for Team2 - 3-points', + '735' => 'TO(%s) for Team2 - Rebounds', + '736' => 'TU(%s) for Team2 - Rebounds', + '737' => 'TO(%s) for Team2 - Assists', + '738' => 'TU(%s) for Team2 - Assists', + '739' => 'W1 - 180s', + '740' => 'W2 - 180s', + '741' => 'AH1(%s) - 180s', + '742' => 'AH2(%s) - 180s', + '743' => 'TO(%s) - 180s', + '744' => 'TU(%s) - 180s', + '745' => '1 - Cards', + '746' => 'X - Cards', + '747' => '2 - Cards', + '748' => '1 - Booking points', + '749' => 'X - Booking points', + '750' => '2 - Booking points', + '751' => '1X - Cards', + '752' => 'X2 - Cards', + '753' => '12 - Cards', + '754' => '1X - Booking points', + '755' => 'X2 - Booking points', + '756' => '12 - Booking points', + '757' => 'AH1(%s) - Cards', + '758' => 'AH2(%s) - Cards', + '759' => 'AH1(%s) - Booking points', + '760' => 'AH2(%s) - Booking points', + '761' => 'TO(%s) - Cards', + '762' => 'TU(%s) - Cards', + '763' => 'TO(%s) - Booking points', + '764' => 'TU(%s) - Booking points', + '765' => 'TO(%s) for Team1 - Cards', + '766' => 'TU(%s) for Team1 - Cards', + '767' => 'TO(%s) for Team1 - Booking points', + '768' => 'TU(%s) for Team1 - Booking points', + '769' => 'TO(%s) for Team2 - Cards', + '770' => 'TU(%s) for Team2 - Cards', + '771' => 'TO(%s) for Team2 - Booking points', + '772' => 'TU(%s) for Team2 - Booking points', + '773' => 'Odd - Cards', + '774' => 'Even - Cards', + '775' => 'Odd - Booking points', + '776' => 'Even - Booking points' } + + end + + def periods + @periods ||= + { + '1': 'match (pairs)', + '2': 'with OT and SO', + '3': 'with OT', + '4': 'regular time', #match odds + '5': '1st', + '6': '2nd', + '7': '3rd', + '8': '4th', + '9': '5th', + '10': '1st half', #first + '13': '2nd half', + '14': '1 set, 01 game', + '15': '1 set, 02 game', + '16': '1 set, 03 game', + '17': '1 set, 04 game', + '18': '1 set, 05 game', + '19': '1 set, 06 game', + '20': '1 set, 07 game', + '21': '1 set, 08 game', + '22': '1 set, 09 game', + '23': '1 set, 10 game', + '24': '1 set, 11 game', + '25': '1 set, 12 game', + '26': '1 set, 13 game', + '44': '2 set, 01 game', + '45': '2 set, 02 game', + '46': '2 set, 03 game', + '47': '2 set, 04 game', + '48': '2 set, 05 game', + '49': '2 set, 06 game', + '50': '2 set, 07 game', + '51': '2 set, 08 game', + '52': '2 set, 09 game', + '53': '2 set, 10 game', + '54': '2 set, 11 game', + '55': '2 set, 12 game', + '56': '2 set, 13 game', + '57': '3 set, 01 game', + '58': '3 set, 02 game', + '59': '3 set, 03 game', + '60': '3 set, 04 game', + '61': '3 set, 05 game', + '62': '3 set, 06 game', + '63': '3 set, 07 game', + '64': '3 set, 08 game', + '65': '3 set, 09 game', + '66': '3 set, 10 game', + '67': '3 set, 11 game', + '68': '3 set, 12 game', + '71': '3 set, 13 game', + '76': '6th', + '78': '7th', + '86': 'regular time', + '92': 'regular time', + '93': '1st half', + '95': 'to qualify', + '96': '8th', + '97': '9th', + '113': '4 set, 01 game', + '114': '4 set, 02 game', + '115': '4 set, 03 game', + '116': '4 set, 04 game', + '117': '4 set, 05 game', + '118': '4 set, 06 game', + '119': '4 set, 07 game', + '120': '4 set, 08 game', + '121': '4 set, 09 game', + '122': '4 set, 10 game', + '123': '4 set, 11 game', + '124': '4 set, 12 game', + '125': '5 set, 01 game', + '126': '5 set, 02 game', + '127': '5 set, 03 game', + '128': '5 set, 04 game', + '129': '5 set, 05 game', + '130': '5 set, 06 game', + '131': '5 set, 07 game', + '132': '5 set, 08 game', + '133': '5 set, 09 game', + '134': '5 set, 10 game', + '156': '4 set, 13 game', + '159': '5 set, 11 game', + '161': '5 set, 12 game', + '169': '5 set, 13 game', + '223': 'regular time', + '243': '1st half', + '245': '2nd half', + '246': '2nd half', + '317': '1st half', + '318': '2nd half', + '4091': 'regular time', #match odds + '4094': '1st half' + } + end + end +end diff --git a/portal/app/lib/integrations/betfair/account_manager.rb b/portal/app/lib/integrations/betfair/account_manager.rb new file mode 100644 index 0000000..4923af2 --- /dev/null +++ b/portal/app/lib/integrations/betfair/account_manager.rb @@ -0,0 +1,16 @@ +module Integrations + module Betfair + class AccountManager < Base + def refresh_account_balance + # go check the account balance and update the ExchangeAccount for this account + res = self.class.post("#{API_ACCOUNT_ENDPOINT}/getAccountFunds/", { headers: @connection.api_headers, data: {} }) + if res['availableToBetBalance'] + acc_bal = res['availableToBetBalance'] + @account.update(account_balance: acc_bal, account_balance_last_checked: Time.now) + end + @account.account_balance + end + end + end +end + diff --git a/portal/app/lib/integrations/betfair/base.rb b/portal/app/lib/integrations/betfair/base.rb new file mode 100644 index 0000000..7a1cc5e --- /dev/null +++ b/portal/app/lib/integrations/betfair/base.rb @@ -0,0 +1,40 @@ +module Integrations + module Betfair + class Base + include HTTParty + + API_BETTING_ENDPOINT = 'https://api.betfair.com/exchange/betting/rest/v1.0'.freeze + API_ACCOUNT_ENDPOINT = 'https://api.betfair.com/exchange/account/rest/v1.0'.freeze + + def initialize(account_friendly_id) + @account = ExchangeAccount.find_by_id(account_friendly_id) + unless @account + puts "No exchange account for '#{account_friendly_id}'. Stopping" + return + end + self.class.pem @account.ssl_pem + @connection = Integrations::Betfair::Connection.new(@account) + end + + def minimum_stake + 1 + end + + def list_events(filter) + body = { filter: filter } + self.class.post("#{API_BETTING_ENDPOINT}/listEvents/", { headers: @connection.api_headers, body: body.to_json }) + end + + def debug_list_market_types + body = { filter: {} } + marketTypes = self.class.post("#{API_BETTING_ENDPOINT}/listMarketTypes/", { headers: @connection.api_headers, body: body.to_json }) + results = [] + marketTypes.each do |market| + results << market['marketType'] + end + File.write(Rails.root.join('samples/bf_market_types.json'), results.join("\n")) + end + end + end +end + diff --git a/portal/app/lib/integrations/betfair/bet_manager.rb b/portal/app/lib/integrations/betfair/bet_manager.rb new file mode 100644 index 0000000..8656190 --- /dev/null +++ b/portal/app/lib/integrations/betfair/bet_manager.rb @@ -0,0 +1,173 @@ +module Integrations + module Betfair + class BetManager < Base + def check_qualified_bet_outcome(bets) + bets_lookup = {} + market_ids = [] + bets_by_bet_ids = [] + reconciliation_limit = 20 + bet_count = 0 + bets.each do |bet| + bet_count += 1 + next unless bet.exchange_market_details['market_id'] && bet.exchange_market_details['selection_id'] + + market_ids << bet.exchange_market_details['market_id'] + bets_lookup[bet.exchange_market_details['market_id'].to_s] = { selection_id: bet.exchange_market_details['selection_id'], bet_id: bet.id } + bets_by_bet_ids << bet unless bet.exchange_bet_id.blank? + next unless (reconciliation_limit == market_ids.size) || (bet_count == bets.count) + + reconcile_bets_by_markets(market_ids, bets_lookup) unless market_ids.empty? + market_ids = [] + bets_lookup = {} + end + reconcile_bets_by_bet_ids(bets_by_bet_ids) unless bets_by_bet_ids.empty? + end + + def reconcile_bets_by_markets(market_ids, bets_lookup) + outcomes = { LOSER: [], WINNER: [] } + body = { marketIds: market_ids } + markets = self.class.post("#{API_BETTING_ENDPOINT}/listMarketBook/", { headers: @connection.api_headers, body: body.to_json }) + markets.each do |market| + next unless market['status'] == 'CLOSED' + + bet = bets_lookup[market['marketId']] + next unless bet + + runners = market['runners'] + runners.each do |runner| + next unless runner['selectionId'] == bet[:selection_id] + + x_status = runner['status'].to_sym + if outcomes[x_status] + outcomes[x_status] << bet[:bet_id] + end + break + end + end + ActiveRecord::Base.connection.execute("update bets set outcome = 'lost', outcome_value=stake where #{ActiveRecord::Base.sanitize_sql(['id in (?)', outcomes[:LOSER]])}") if outcomes[:LOSER].size.positive? + ActiveRecord::Base.connection.execute("update bets set outcome = 'won', outcome_value=expected_value where #{ActiveRecord::Base.sanitize_sql(['id in (?)', outcomes[:WINNER]])}") if outcomes[:WINNER].size.positive? + end + + def reconcile_bets_by_bet_ids(bets) + return unless bets.count.positive? + + results = {} + open_bet_ids = bets.pluck(:exchange_bet_id) + %w[SETTLED VOIDED CANCELLED].each do |status| + body = { betStatus: status, betIds: open_bet_ids } + r = self.class.post("#{API_BETTING_ENDPOINT}/listClearedOrders/", { headers: @connection.api_headers, body: body.to_json }) + orders = r['clearedOrders'] + if status == 'SETTLED' + # the status updates are by betOutcome + orders.each do |o| + results[o['betOutcome']] ||= [] + results[o['betOutcome']] << o['betId'] + end + else + results[status] ||= [] + bet_ids = orders.map { |o| o['betId'] } + results[status] = bet_ids + end + end + results.keys.each do |k| + next if results[k].blank? + + Bet.unscoped.open.where(exchange_bet_id: results[k]).update(outcome: k.downcase) + end + end + + def place_bet(bet, stake) + body = { marketId: bet.exchange_market_details['market_id'], customerRef: bet.tip_provider_bet_id } + body[:instructions] = + [{ orderType: 'LIMIT', side: 'BACK', selectionId: bet.exchange_market_details['selection_id'].to_s, + limitOrder: { timeInForce: 'FILL_OR_KILL', size: stake.to_s, price: bet.tip_provider_odds.to_s, persistenceType: 'LAPSE' } }] + r = self.class.post("#{API_BETTING_ENDPOINT}/placeOrders/", { headers: @connection.api_headers, body: body.to_json }) + + success = r['status'] == 'SUCCESS' + if success + bet_id = r['instructionReports'][0]['betId'] + return bet_id + end + error_code = r['errorCode'] + raise "[Place bet] Placing bet failed: #{error_code}" + end + + def bet_event(bet, update_bet = false) + # return the event for this bet. + # easy if we have the event_id, else we have to search. + event_id = [] + event_id << bet.exchange_event_id unless bet.exchange_event_id.blank? + events = list_events({ eventIds: event_id, textQuery: bet.exchange_event_name }) + e_id = events[0]['event']['id'] if events.length.positive? + if e_id + bet.update(exchange_event_id: e_id) if update_bet + return e_id + end + raise '[bet_event] Error getting event id' + end + + def bet_odds(bet) + event_market_selection_hash = event_market_selection(bet) + raise '[bet odds] - market not available' unless event_market_selection_hash['market_id'] + raise '[bet odds] - selection not available' unless event_market_selection_hash['selection_id'] + + body = { marketId: event_market_selection_hash['market_id'], selectionId: event_market_selection_hash['selection_id'] } + body[:priceProjection] = { priceData: ['EX_ALL_OFFERS'], virtualise: true } + r = self.class.post("#{API_BETTING_ENDPOINT}/listRunnerBook/", { headers: @connection.api_headers, body: body.to_json }) + runners = r[0]['runners'] + raise '[Bet odds] - cannot identify prices' unless runners + + rs = runners.first + raise '[Bet odds] - cannot identify prices' unless rs && rs['ex'] && rs['ex']['availableToBack'] + + prices = [] + stakes = {} + rs['ex']['availableToBack'].each do |ex| + prices << ex['price'] + stakes[(ex['price']).to_s] = ex['size'] + end + { prices: prices, stakes: stakes, liquidity: r[0]["totalAvailable"] } + end + + def event_market_selection(bet) + if bet.exchange_event_id.blank? + bet_event(bet, true) + raise '[No event id]' if bet.exchange_event_id.blank? + end + body = { maxResults: 500, filter: { eventIds: [bet.exchange_event_id] }, marketProjection: %w[MARKET_DESCRIPTION RUNNER_DESCRIPTION RUNNER_METADATA] } + markets = self.class.post("#{API_BETTING_ENDPOINT}/listMarketCatalogue/", { headers: @connection.api_headers, body: body.to_json }) + m_details = bet.exchange_market_details + returned_markets = [] + returned_selections = [] + markets.each do |market| + returned_markets << market['marketName'] + next unless m_details['market'].casecmp(market['marketName']).zero? + + m_details['market_id'] = market['marketId'] + fuzzy_match_runners = market['marketName'] == 'Match Odds' + runners = market['runners'] + m_selection = m_details['selection'] + runners.each do |runner| + returned_selections << runner['runnerName'] + runner_name_matched = runner['runnerName'].casecmp(m_selection).zero? + if !runner_name_matched && fuzzy_match_runners + runner_name_matched = (runner['runnerName'].downcase.split & m_selection.downcase.split).length.positive? + end + next unless runner_name_matched + + m_details['selection_id'] = runner['selectionId'] + m_details['selection'] = runner['runnerName'] + break + end + end + + m_details['returned_markets'] = returned_markets unless m_details.key?('market_id') + m_details['returned_selections'] = returned_selections unless m_details.key?('selection_id') + + bet.update(exchange_market_details: m_details) + m_details + end + end + end +end + diff --git a/portal/app/lib/integrations/betfair/connection.rb b/portal/app/lib/integrations/betfair/connection.rb new file mode 100644 index 0000000..dee3698 --- /dev/null +++ b/portal/app/lib/integrations/betfair/connection.rb @@ -0,0 +1,39 @@ +module Integrations + module Betfair + class Connection + + include HTTParty + def initialize(account) + @account = account + self.class.pem @account.ssl_pem + end + + def api_headers + { 'X-Application' => @account.apikey, 'X-Authentication' => session_token, 'content-type' => 'application/json', 'accept' => 'application/json' } + end + + private + + def session_token + # if + puts 'Checking if session still fresh' + if @account.last_session_token_saved_at && @account.last_session_token_saved_at > 10.hours.ago + puts 'Returning cached session token' + return @account.last_session_token + end + + puts 'Cache is stale or non-existent - getting fresh session key' + url = 'https://identitysso-cert.betfair.com/api/certlogin' + r = self.class.post(url, headers: { 'X-Application' => @account.apikey }, body: { username: @account.login_uid, password: @account.login_pass }) + resp = JSON.parse(r) + if resp['loginStatus'] == 'SUCCESS' + @account.update(last_session_token: resp['sessionToken'], last_session_token_saved_at: Time.now) + return resp['sessionToken'] + end + + raise '[Betfair Session token] Cannot get session to Betfair' + end + end + end +end + diff --git a/portal/app/lib/integrations/betfair/opportunity_hunter.rb b/portal/app/lib/integrations/betfair/opportunity_hunter.rb new file mode 100644 index 0000000..27012f7 --- /dev/null +++ b/portal/app/lib/integrations/betfair/opportunity_hunter.rb @@ -0,0 +1,77 @@ +module Integrations + module Betfair + class OpportunityHunter < Base + def events_in_timeframe(from:, to:) + raise "Timeframe not set " unless from.present? && to.present? + + timeframe = { from: from.iso8601, to: to.iso8601 } + filter = { marketStartTime: timeframe } + body = { filter: filter } + + r = self.class.post("#{API_BETTING_ENDPOINT}/listEvents/", { headers: @connection.api_headers, body: body.to_json }) + events = [] + r.each do |e| + ev = e['event'] + events << BetfairEvent.new(event_id: ev['id'], event_name: ev['name'], event_start: DateTime.parse(ev['openDate'])) + end + import_result = BetfairEvent.import(events, on_duplicate_key_ignore: true) + puts "#{import_result.ids.size} events added" + end + + def event_markets_and_selections + batches = [] + batch = 0 + limit = 10 + BetfairEvent.open.order(created_at: :desc).pluck(:event_id).each do |eid| + batches[batch] ||= [] + if batches[batch].size < limit + batches[batch] << eid + else + batch += 1 + end + end + batches.each { |b| batch_event_runners b } + end + + def runner_odds(runner) + body = { marketId: runner.market_id, selectionId: runner.selection_id } + body[:priceProjection] = { priceData: ['EX_BEST_OFFERS'], virtualise: true } + r = self.class.post("#{API_BETTING_ENDPOINT}/listRunnerBook/", { headers: @connection.api_headers, body: body.to_json }) + runners = r[0]['runners'] + raise '[Odds] - cannot identify prices' unless runners + + rs = runners.first + raise '[Odds] - cannot identify prices' unless rs && rs['ex'] && rs['ex']['availableToBack'] + + imports = [] + rs['ex']['availableToBack'].each do |ex| + imports << BetfairRunnerOdd.new(betfair_event_runner_id: runner.id, odds: ex['price'], total_matched: 0, total_available: ex['size'], bet_type: 'back') + end + rs['ex']['availableToLay'].each do |ex| + imports << BetfairRunnerOdd.new(betfair_event_runner_id: runner.id, odds: ex['price'], total_matched: 0, total_available: ex['size'], bet_type: 'lay') + end + + BetfairRunnerOdd.import(imports, on_duplicate_key_ignore: true) + end + + private + + def batch_event_runners(batch) + body = { maxResults: 1000 - (10 * (batch.size - 1)), filter: { eventIds: batch }, marketProjection: ['EVENT', 'RUNNER_DESCRIPTION'] } + markets = self.class.post("#{API_BETTING_ENDPOINT}/listMarketCatalogue/", { headers: @connection.api_headers, body: body.to_json }) + import = [] + markets.each do |market| + market_fragment = { event_id: market['event']['id'], market_id: market['marketId'], market_name: market['marketName'] } + runners = market['runners'] + runners&.each do |runner| + rec = { selection_id: runner['selectionId'] || runner['runnerName'], selection_name: runner['runnerName'] }.merge(market_fragment) + import << BetfairEventRunner.new(rec) + end + end + import_result = BetfairEventRunner.import(import, on_duplicate_key_ignore: true) + puts "#{import_result.ids.size} runners added" + end + end + end +end + diff --git a/portal/app/lib/services/bet_outcome_service.rb b/portal/app/lib/services/bet_outcome_service.rb new file mode 100644 index 0000000..2457424 --- /dev/null +++ b/portal/app/lib/services/bet_outcome_service.rb @@ -0,0 +1,6 @@ +module Services + class BetOutcomeService + def bet_outcome(bet_id) + end + end +end diff --git a/portal/app/mailers/application_mailer.rb b/portal/app/mailers/application_mailer.rb new file mode 100644 index 0000000..a163f53 --- /dev/null +++ b/portal/app/mailers/application_mailer.rb @@ -0,0 +1,44 @@ +class ApplicationMailer < ActionMailer::Base + default from: 'info@helpbuild.co' + helper MailerStyleHelper + layout 'mailer' + + def try_delivering(_options = {}) + yield + true + rescue EOFError, + IOError, + Errno::ECONNRESET, + Errno::ECONNABORTED, + Errno::EPIPE, + Errno::ETIMEDOUT, + Net::SMTPAuthenticationError, + Net::SMTPServerBusy, + Net::SMTPSyntaxError, + Net::SMTPUnknownError, + OpenSSL::SSL::SSLError => e + ExceptionHub.notify(e) + false + end + + private + + def deliver_mail(email, subject, reply_to = nil, to_name = '') + addresses = set_addresses_and_user(email: email, to_name: to_name) + reply_to = addresses[:from] if reply_to.nil? + try_delivering do + I18n.with_locale('en') do + mail(reply_to: reply_to, from: addresses[:from], to: addresses[:to], subject: "[Helpbuild] | #{subject}") + end + end + end + + def set_addresses_and_user(email: '', from_name: 'Helpbuild', to_name: '', from_email: nil) + addresses = {} + from_email ||= Rails.configuration.action_mailer[:default_options][:from] + addresses[:from] = %(#{from_name} <#{from_email}>) + addresses[:reply_to] = %(#{from_name} <#{from_email}>) + addresses[:to] = %("#{to_name}" <#{email}>) + addresses + end +end diff --git a/portal/app/mailers/project_mailer.rb b/portal/app/mailers/project_mailer.rb new file mode 100644 index 0000000..e3da75a --- /dev/null +++ b/portal/app/mailers/project_mailer.rb @@ -0,0 +1,76 @@ +class ProjectMailer < ApplicationMailer + layout 'project_mailer' + layout false, only: :send_project_invitation + + def helper_join_request(builders, helper) + subject = I18n.t('project_join_request', project: helper.project.title) + @project_helper = helper + @project = helper.project + builders.each do |b| + @user = b.user + deliver_mail(@user.email, subject, reply_to = nil) + end + end + + def notify_helper_for_join_request(helper) + return if helper.builder? + subject = I18n.t('project_join_request_response', project: helper.project.title) + + @user = helper.user + @project = helper.project + @status = helper.status.titlecase + + deliver_mail(@user.email, subject, reply_to = nil) + end + + def ask_request_to_helpers(helper, ask) + @user = helper.user + @project_helper = helper + @ask = ask + subject = I18n.t('ask_request_to_helpers', project: helper.project.title) + @project = helper.project + deliver_mail(helper.user.email, subject, reply_to = nil) + end + + def notify_builder_ask_status(ask, status, project_helper) + @builder = ask.project_helper.user + @ask = ask + @status = status + @project_helper = project_helper + @project = ask.project + subject = I18n.t('ask_status_to_builder_subject', project: @project.title, ask: ask.title, status: status) + deliver_mail(@builder.email, subject, reply_to = nil) + end + + def notify_helper_ask_status(ask, status, project_helper) + # @builder = ask.project_helper.user + # @ask = ask + # @status = status + # @project_helper = project_helper + # @project = ask.project + # subject = I18n.t('ask_status_to_builder_subject', project: @project.title, ask: ask.title, status: status) + # deliver_mail(project_helper.user.email, subject, reply_to = nil) + end + + def notify_announcement(project_helper, announcement) + @user = project_helper.user + @announcement = announcement + @project = project_helper.project + subject = I18n.t('announcement_notification', project: announcement.project.title) + deliver_mail(project_helper.user.email, subject, reply_to = nil) + end + + def notify_monthly_announcement(project) + emails = project.project_builders.map {|pb| pb.user.email } + subject = I18n.t('monthly_announcement_check', project: project.title) + @project = project + + deliver_mail(emails.join(','), subject, reply_to = nil) + end + + def send_project_invitation(email, project) + @project = project + subject = I18n.t('project_invitation_subject', project: project.title, builder_name: project.user.first_name) + deliver_mail(email, subject, reply_to = nil) + end +end diff --git a/portal/app/mailers/user_mailer.rb b/portal/app/mailers/user_mailer.rb new file mode 100644 index 0000000..8768340 --- /dev/null +++ b/portal/app/mailers/user_mailer.rb @@ -0,0 +1,21 @@ +class UserMailer < ApplicationMailer + def welcome_email user + @user = user + subject = I18n.t('welcome_email_subject') + deliver_mail(user.email, subject, reply_to = nil, user.fullname) + end + + def notify_token_purchase(user, tokens, amount, balance) + @user = user + subject = I18n.t('token_purchase_confirmation_subject') + @body = I18n.t('token_purchase_confirmation_body', tokens: tokens, amount: amount, balance: balance.to_i) + deliver_mail(user.email, subject, reply_to = nil, user.fullname) + end + + def notify_hourly_unread_activities(user_id, activities_hash) + @user = User.find_by(id: user_id) + @activities_hash = activities_hash + + deliver_mail(@user.email, '[Helpbuild] Activity Digest Mail', reply_to = nil, @user.fullname) + end +end diff --git a/portal/app/models/account.rb b/portal/app/models/account.rb new file mode 100644 index 0000000..5c89805 --- /dev/null +++ b/portal/app/models/account.rb @@ -0,0 +1,15 @@ +# == Schema Information +# +# Table name: accounts +# +# id :uuid not null, primary key +# name :string +# contact_id :uuid not null +# subdomain :string not null +# style :text +# size :bigint default(10000) +# created_at :datetime not null +# updated_at :datetime not null +# +class Account < ApplicationRecord +end diff --git a/portal/app/models/application_record.rb b/portal/app/models/application_record.rb new file mode 100644 index 0000000..10a4cba --- /dev/null +++ b/portal/app/models/application_record.rb @@ -0,0 +1,3 @@ +class ApplicationRecord < ActiveRecord::Base + self.abstract_class = true +end diff --git a/portal/app/models/bet.rb b/portal/app/models/bet.rb new file mode 100644 index 0000000..68beeb9 --- /dev/null +++ b/portal/app/models/bet.rb @@ -0,0 +1,82 @@ +# == Schema Information +# +# Table name: bets +# +# id :uuid not null, primary key +# tip_provider_id :string not null +# tip_provider_bet_id :string not null +# tip_provider_percent :float not null +# exchange_id :string not null +# exchange_event_name :string not null +# exchange_event_id :string +# tip_provider_odds :string +# expected_value :float +# exchange_bet_id :string +# stake :float default(0.0), not null +# outcome :string default("processing"), not null +# created_at :datetime not null +# updated_at :datetime not null +# exchange_account_id :string +# tip_provider_event_id :string +# exchange_odds :string +# team1 :string not null +# team2 :string +# tip_provider_market_details :jsonb +# exchange_market_details :jsonb +# log :jsonb +# original_json :jsonb +# executed_odds :string +# placement_attempts :integer default(0) +# period :string +# +class Bet < ApplicationRecord + enum outcome: { processing: 'processing', won: 'won', lost: 'lost', open: 'open', expired: 'expired', skipped: 'skipped', ignored: 'ignored', errored: 'errored', voided: 'voided', cancelled: 'cancelled'} + scope :placed_bets, -> { where('outcome in (?)', %w[won lost open voided cancelled]) } + scope :retryable_bets, -> { where("outcome in ('expired', 'skipped')") } + + default_scope { where(exchange_account_id: ENV['EXCHANGE_ACCOUNT']) } + + belongs_to :exchange_account + include Loggable + def json_payload + { + id: id, + exchange: exchange_id, + exchange_event_name: exchange_event_name, + created_at: updated_at, + exchange_odds: exchange_odds, + tip_provider_odds: tip_provider_odds, + expected_value: expected_value&.round(1), + stake: stake, + outcome: outcome, + market_identifier: tip_provider_market_details.to_s, + exchange_market_details: exchange_market_details.to_s, + bet_placement_type: bet_placement_type, + executed_odds: executed_odds, + ev_percent: tip_provider_percent.round(2), + event_scheduled_time: original_json['started_at'] ? Time.at(original_json['started_at']) : "", + log: show_log + } + end + + def self.calculate_expected_value(stake, odds) + (stake * odds) - stake + end + + private + + def placed_bet? + open? || lost? || won? || voided? + end + + def bet_placement_type + if placed_bet? + return 'Simulated' unless exchange_bet_id + + return "Placed [#{exchange_bet_id}]" + end + '' + end + + +end diff --git a/portal/app/models/betfair_event.rb b/portal/app/models/betfair_event.rb new file mode 100644 index 0000000..88c10aa --- /dev/null +++ b/portal/app/models/betfair_event.rb @@ -0,0 +1,5 @@ +class BetfairEvent < ApplicationRecord + has_many :betfair_event_runners, dependent: :delete_all, primary_key: :event_id + + enum status: { open: 'open', closed: 'closed' } +end diff --git a/portal/app/models/betfair_event_runner.rb b/portal/app/models/betfair_event_runner.rb new file mode 100644 index 0000000..ac2f8c4 --- /dev/null +++ b/portal/app/models/betfair_event_runner.rb @@ -0,0 +1,6 @@ +class BetfairEventRunner < ApplicationRecord + belongs_to :betfair_event, foreign_key: :event_id + has_many :betfair_runner_odds, dependent: :delete_all + + scope :runners_for_open_events, -> { joins("INNER JOIN betfair_events ON betfair_event_runners.event_id = betfair_events.event_id").where(betfair_events: { status: 'open' }) } +end diff --git a/portal/app/models/betfair_runner_odd.rb b/portal/app/models/betfair_runner_odd.rb new file mode 100644 index 0000000..8e06eb9 --- /dev/null +++ b/portal/app/models/betfair_runner_odd.rb @@ -0,0 +1,4 @@ +class BetfairRunnerOdd < ApplicationRecord + belongs_to :betfair_event_runner + enum bet_type: {back: 'back', lay: 'lay'} +end diff --git a/portal/app/models/concerns/latest.rb b/portal/app/models/concerns/latest.rb new file mode 100644 index 0000000..ad44179 --- /dev/null +++ b/portal/app/models/concerns/latest.rb @@ -0,0 +1,6 @@ +module Latest + extend ActiveSupport::Concern + included do + scope :latest, -> { order(created_at: :desc).first } + end +end diff --git a/portal/app/models/exchange_account.rb b/portal/app/models/exchange_account.rb new file mode 100644 index 0000000..986cbb4 --- /dev/null +++ b/portal/app/models/exchange_account.rb @@ -0,0 +1,218 @@ +# == Schema Information +# +# Table name: exchange_accounts +# +# id :string not null, primary key +# exchange_id :string not null +# exchange_name :string not null +# login_uid :string not null +# login_pass :string not null +# apikey :string not null +# contact_name :string not null +# contact_email :string not null +# account_balance :float default(10000.0) +# account_balance_last_checked :datetime +# ssl_pem :text +# allow_multiple_bets_per_event :boolean default(FALSE) +# created_at :datetime not null +# updated_at :datetime not null +# log :jsonb +# betting_enabled :boolean default(FALSE) +# last_session_token :string +# last_session_token_saved_at :datetime +# status :string default("inactive"), not null +# stake_strategy :jsonb +# last_log_time :datetime +# +class ExchangeAccount < ApplicationRecord + has_many :bets + has_many :source_subscriptions, dependent: :delete_all + has_many :tip_sources, through: :source_subscriptions + has_many :subscription_runs, through: :source_subscriptions + enum status: { active: 'active', inactive: 'inactive'} + + DEFAULT_STRATEGY = + { + max_bankroll_per_bet: 0.2, + stake_sizing: 'MINIMUM', #[MINIMUM,FIXED, HYBRID, PROPORTIONAL] + min_odds_to_bet: 1.1, + max_odds_to_bet: 3.0, + min_ev: 2, + max_ev: 7, + fixed_stake_size: 3.0, + odds_margin: 0.1, + kelly_multiplier: 0.3, + }.freeze + + DEFAULT_HYBRID_STRATEGY = + { + max_bets: 10, + action_on_max_losses: 'REBASE', #[REBASE] + pause_in_minutes: 0, + rebase_status: :complete, + current_stake: 0.0, + last_account_balance: nil, + account_balance_checked_at: nil, + max_losses: 0.1 + }.freeze + + def json_payload + { + id: id, + exchange_name: exchange_name, + exchange_id: exchange_id, + contact_name: contact_name, + contact_email: contact_email, + account_balance: account_balance, + is_active: active?, + can_bet: can_bet?, + stake_strategy_name: stake_strategy_description, + log: subscription_runs.empty? ? nil : last_run.log, + last_log_time: subscription_runs.empty? ? updated_at : last_run.created_at, + stake_strategy_config: current_stake_strategy, + source_types: tip_sources.empty? ? 'None' : tip_sources.pluck(:source_type).join(' , ') + } + end + + def last_run + subscription_runs&.latest + end + + def self.mounted_account + find_by_id(ENV['EXCHANGE_ACCOUNT']) + end + + def self.mounted_account_payload + return {} unless mounted_account + + mounted_account.json_payload + end + + def optimal_stake(bet, exchange_minimum, stake_available) + return exchange_minimum if minimum_stake? + + x = [stake_by_strategy(bet).round(1), stake_available].min + + [x, exchange_minimum].max + end + + # # account_balance will ultimately come from accounts model + def actual_account_balance + return account_balance if betting_enabled + + simulated_account_balance + end + + def simulated_account_balance + account_balance + total_won - (total_lost + total_open) + end + + def total_won(subset = my_bets) + subset.won.sum(:expected_value).round(1) + end + + def total_lost(subset = my_bets) + subset.lost.sum(:stake).round(1) + end + + def total_open(subset = my_bets) + subset.open.sum(:stake).round(1) + end + + def can_bet? + return false unless betting_enabled + + actual_account_balance > total_open + end + + def my_bets + Bet.unscoped.where(exchange_account_id: id) + end + + def current_stake_strategy + if stake_strategy.blank? + update(stake_strategy: DEFAULT_STRATEGY) + end + @current_stake_strategy ||= stake_strategy.with_indifferent_access + end + + def minimum_stake? + stake_strategy_description == 'MINIMUM' + end + + def stake_strategy_description + return current_stake_strategy[:stake_sizing] unless current_stake_strategy[:stake_sizing].blank? + + 'MINIMUM' + end + + def stake_by_strategy(bet) + return classic_kelly_stake_sizing(bet) if current_stake_strategy[:stake_sizing] == 'KELLY' + return proportional_stake if current_stake_strategy[:stake_sizing] == 'PROPORTIONAL' + return current_stake_strategy[:fixed_stake_size] if current_stake_strategy[:stake_sizing] == 'FIXED' + return hybrid_stake_sizing if current_stake_strategy[:stake_sizing] == 'HYBRID' + + 0 + end + + # def kelly_stake_sizing(bet) + # proportional = proportional_stake + # multiplier = current_stake_strategy[:kelly_multiplier] || 0.3 + # kelly_factor = bet.tip_provider_percent * multiplier + # stake = kelly_factor * proportional + # + # return 0 if stake.negative? + # + # [stake, proportional].min + # end + + def classic_kelly_stake_sizing(bet) + multiplier = current_stake_strategy[:kelly_multiplier] || 0.3 + max_per_bet = proportional_stake + kelly(odds: bet.executed_odds.to_f, probs: bet.tip_provider_percent, balance: actual_account_balance, multiplier: multiplier, per_bet_max: max_per_bet) + end + + def kelly(odds:, probs:, balance: , multiplier:, per_bet_max:) + kelly_stake_percent = (((odds * probs) -1) / (odds - 1 ) * multiplier)/100 + stake = kelly_stake_percent * balance + + return 0 if stake.negative? + + [per_bet_max, stake].min + + end + + private + + def hybrid_stake_sizing + hybrid_strategy = current_stake_strategy[:hybrid] || DEFAULT_HYBRID_STRATEGY.clone + last_based_account_balance = hybrid_strategy[:last_account_balance] + last_based_account_balance ||= rebase_hybrid_strategy + + max_losses = hybrid_strategy[:max_losses] + max_allowable_losses = last_based_account_balance * max_losses + + difference = actual_account_balance - last_based_account_balance + if difference.abs >= max_allowable_losses + rebase_hybrid_strategy + end + current_stake_strategy[:hybrid][:current_stake] + # raise "[Bet sizing] Betting on '#{id}' paused due to stake management rules" + end + + def rebase_hybrid_strategy + puts 'Rebasing hybrid stake because account balances have gained/loss at least the max' + hybrid = current_stake_strategy[:hybrid] || DEFAULT_HYBRID_STRATEGY.clone(freeze: false) + hybrid[:last_account_balance] = actual_account_balance + hybrid[:current_stake] = ((actual_account_balance * hybrid[:max_losses]) / hybrid[:max_bets]).round(2) + hybrid[:account_balance_checked_at] = Time.now + current_stake_strategy[:hybrid] = hybrid + update(stake_strategy: current_stake_strategy) + hybrid[:current_stake] + end + + def proportional_stake + (current_stake_strategy[:max_bankroll_per_bet] * actual_account_balance) + end + +end diff --git a/portal/app/models/loggable.rb b/portal/app/models/loggable.rb new file mode 100644 index 0000000..5fb4034 --- /dev/null +++ b/portal/app/models/loggable.rb @@ -0,0 +1,20 @@ +module Loggable + extend ActiveSupport::Concern + + def set_log(s) + log_this(s, false) + end + + def log_this(s, append = true) + x = append ? log || [] : [] + x << "[#{Time.now}]: #{s}" + update(log: x) + end + + def show_log + x = log || [] + return 'Nothing logged' if x.length.zero? + + x.join(',') + end +end diff --git a/portal/app/models/source_subscription.rb b/portal/app/models/source_subscription.rb new file mode 100644 index 0000000..96af948 --- /dev/null +++ b/portal/app/models/source_subscription.rb @@ -0,0 +1,17 @@ +# == Schema Information +# +# Table name: source_subscriptions +# +# id :uuid not null, primary key +# exchange_account_id :string +# tip_source_id :uuid +# created_at :datetime not null +# updated_at :datetime not null +# +class SourceSubscription < ApplicationRecord + belongs_to :tip_source, dependent: :delete + belongs_to :exchange_account + + has_many :subscription_runs + has_many :tip_source_data, through: :tip_source +end diff --git a/portal/app/models/subscription_run.rb b/portal/app/models/subscription_run.rb new file mode 100644 index 0000000..c0b5024 --- /dev/null +++ b/portal/app/models/subscription_run.rb @@ -0,0 +1,17 @@ +# == Schema Information +# +# Table name: subscription_runs +# +# id :uuid not null, primary key +# source_subscription_id :uuid +# tip_source_data_id :uuid +# log :jsonb +# created_at :datetime not null +# updated_at :datetime not null +# +class SubscriptionRun < ApplicationRecord + include Loggable + include Latest + belongs_to :tip_source_data + belongs_to :source_subscription +end diff --git a/portal/app/models/tip_source.rb b/portal/app/models/tip_source.rb new file mode 100644 index 0000000..3dc7cd4 --- /dev/null +++ b/portal/app/models/tip_source.rb @@ -0,0 +1,22 @@ +# == Schema Information +# +# Table name: tip_sources +# +# id :uuid not null, primary key +# tipster_account_id :string +# description :string +# source_type :string +# active :boolean default(FALSE) +# filters :jsonb +# created_at :datetime not null +# updated_at :datetime not null +# +class TipSource < ApplicationRecord + belongs_to :tipster_account, dependent: :delete + has_many :source_subscriptions, dependent: :delete_all + has_many :tip_source_data, class_name: 'TipSourceData' + has_many :subscription_runs, through: :source_subscriptions + + scope :active_sources, -> { where(active: true)} + +end diff --git a/portal/app/models/tip_source_data.rb b/portal/app/models/tip_source_data.rb new file mode 100644 index 0000000..5058151 --- /dev/null +++ b/portal/app/models/tip_source_data.rb @@ -0,0 +1,15 @@ +# == Schema Information +# +# Table name: tip_source_data +# +# id :uuid not null, primary key +# tip_source_id :uuid +# data :jsonb +# created_at :datetime not null +# updated_at :datetime not null +# +class TipSourceData < ApplicationRecord + include Latest + belongs_to :tip_source, dependent: :delete + has_many :source_subscriptions +end diff --git a/portal/app/models/tipster_account.rb b/portal/app/models/tipster_account.rb new file mode 100644 index 0000000..3664fd8 --- /dev/null +++ b/portal/app/models/tipster_account.rb @@ -0,0 +1,18 @@ +# == Schema Information +# +# Table name: tipster_accounts +# +# id :string not null, primary key +# tipster_name :string not null +# apikey :string not null +# filter_ids :string not null +# contact_name :string not null +# contact_email :string not null +# created_at :datetime not null +# updated_at :datetime not null +# +class TipsterAccount < ApplicationRecord + has_many :tip_sources + has_many :tip_source_data, through: :tip_sources, class_name: 'TipSourceData' + has_many :tip_source_data_processing_runs, through: :tip_source_data +end diff --git a/portal/app/models/user.rb b/portal/app/models/user.rb new file mode 100644 index 0000000..f5df25e --- /dev/null +++ b/portal/app/models/user.rb @@ -0,0 +1,82 @@ +# == Schema Information +# +# Table name: users +# +# id :uuid not null, primary key +# first_name :string +# last_name :string +# email :string default(""), not null +# encrypted_password :string default(""), not null +# reset_password_token :string +# reset_password_sent_at :datetime +# remember_created_at :datetime +# created_at :datetime not null +# updated_at :datetime not null +# uid :string +# provider :string +# +class User < ApplicationRecord + # Include default devise modules. Others available are: + # :confirmable, :lockable, :timeoutable, :trackable and + devise :database_authenticatable, :registerable, :omniauthable + + + def name + "#{first_name} #{last_name}" + end + + def self.find_or_create_from_auth_hash(auth) + user = where(email: auth.info.email).first + + unless user.present? + user = where(provider: auth.provider, uid: auth.uid).first_or_initialize + user.email = auth.info.email + user.password = Devise.friendly_token.first(8) + end + + unless user.provider.present? + user.provider = auth.provider + user.uid = auth.uid + end + + user.first_name = auth.info.first_name + user.last_name = auth.info.last_name + + user.save! + + user + end + + def fullname + "#{first_name} #{last_name}" + end + + def confirmation_token + if read_attribute(:confirmation_token).nil? + self.confirmation_token = generate_confirmation_token + save! + end + read_attribute(:confirmation_token) + end + + def json_payload + { + id: id, + email: email, + first_name: first_name, + last_name: last_name, + } + end + + def admin? + %w[mike@wizewerx.com].include?(email) + end + + private + + def generate_confirmation_token + token = Devise.friendly_token(10) + token = Devise.friendly_token(10) while User.where(confirmation_token: token).count.positive? + self.confirmation_token = token + end +end diff --git a/portal/app/views/layouts/application.html.erb b/portal/app/views/layouts/application.html.erb new file mode 100644 index 0000000..b84b5e8 --- /dev/null +++ b/portal/app/views/layouts/application.html.erb @@ -0,0 +1,19 @@ + + + + betbeast : taking a bit out of arbitrage + " rel="shortcut icon"> + + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + <%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %> + <%= stylesheet_pack_tag 'application', 'data-turbolinks-track': 'reload' %> + + + +
<%= yield %>
+ + + + diff --git a/portal/app/views/pages/index.html.erb b/portal/app/views/pages/index.html.erb new file mode 100644 index 0000000..e69de29 diff --git a/portal/app/views/user_mailer/notify_hourly_unread_activities.html.erb b/portal/app/views/user_mailer/notify_hourly_unread_activities.html.erb new file mode 100644 index 0000000..4ec2f2c --- /dev/null +++ b/portal/app/views/user_mailer/notify_hourly_unread_activities.html.erb @@ -0,0 +1,11 @@ +<% @activities_hash.except(:all_activities).each do |k, activities| %> + <% if k.starts_with?('project_asks.') %> +
+

Activities

+ + <% activities.each do |activity| %> + <%= render partial: 'partials/project_ask_digest', locals: { activity: activity } %> + <% end %> +
+ <% end %> +<% end %> diff --git a/portal/babel.config.js b/portal/babel.config.js new file mode 100644 index 0000000..1b36dca --- /dev/null +++ b/portal/babel.config.js @@ -0,0 +1,83 @@ +module.exports = function(api) { + const validEnv = ['development', 'test', 'production']; + const currentEnv = api.env(); + const isDevelopmentEnv = api.env('development'); + const isProductionEnv = api.env('production'); + const isTestEnv = api.env('test'); + + if (!validEnv.includes(currentEnv)) { + throw new Error( + `${'Please specify a valid `NODE_ENV` or ' + + '`BABEL_ENV` environment variables. Valid values are "development", ' + + '"test", and "production". Instead, received: '}${JSON.stringify( + currentEnv + )}.` + ); + } + + return { + presets: [ + isTestEnv && [ + '@babel/preset-env', + { + targets: { + node: 'current', + }, + modules: 'commonjs', + }, + '@babel/preset-react', + ], + (isProductionEnv || isDevelopmentEnv) && [ + '@babel/preset-env', + { + forceAllTransforms: true, + useBuiltIns: 'entry', + corejs: 3, + modules: false, + exclude: ['transform-typeof-symbol'], + }, + ], + [ + '@babel/preset-react', + { + development: isDevelopmentEnv || isTestEnv, + useBuiltIns: true, + }, + ], + ].filter(Boolean), + plugins: [ + 'babel-plugin-macros', + '@babel/plugin-syntax-dynamic-import', + isTestEnv && 'babel-plugin-dynamic-import-node', + '@babel/plugin-transform-destructuring', + ['@babel/plugin-proposal-decorators', { legacy: true }], + ['@babel/plugin-proposal-class-properties', { loose: false }], + [ + '@babel/plugin-proposal-object-rest-spread', + { + useBuiltIns: true, + }, + ], + [ + '@babel/plugin-transform-runtime', + { + helpers: false, + regenerator: true, + corejs: false, + }, + ], + [ + '@babel/plugin-transform-regenerator', + { + async: false, + }, + ], + isProductionEnv && [ + 'babel-plugin-transform-react-remove-prop-types', + { + removeImport: true, + }, + ], + ].filter(Boolean), + }; +}; diff --git a/portal/bin/bundle b/portal/bin/bundle new file mode 100755 index 0000000..b9a7b0f --- /dev/null +++ b/portal/bin/bundle @@ -0,0 +1,118 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'bundle' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +require 'rubygems' + +m = Module.new do + module_function + + def invoked_as_script? + File.expand_path($0) == File.expand_path(__FILE__) + end + + def env_var_version + ENV['BUNDLER_VERSION'] + end + + def cli_arg_version + return unless invoked_as_script? # don't want to hijack other binstubs + return unless 'update'.start_with?(ARGV.first || ' ') # must be running `bundle update` + + bundler_version = nil + update_index = nil + ARGV.each_with_index do |a, i| + bundler_version = a if update_index && update_index.succ == i && a =~ Gem::Version::ANCHORED_VERSION_PATTERN + next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/ + + bundler_version = Regexp.last_match(1) + update_index = i + end + bundler_version + end + + def gemfile + gemfile = ENV['BUNDLE_GEMFILE'] + return gemfile if gemfile && !gemfile.empty? + + File.expand_path('../Gemfile', __dir__) + end + + def lockfile + lockfile = + case File.basename(gemfile) + when 'gems.rb' then gemfile.sub(/\.rb$/, gemfile) + else "#{gemfile}.lock" + end + File.expand_path(lockfile) + end + + def lockfile_version + return unless File.file?(lockfile) + + lockfile_contents = File.read(lockfile) + return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/ + + Regexp.last_match(1) + end + + def bundler_version + @bundler_version ||= + env_var_version || cli_arg_version || + lockfile_version + end + + def bundler_requirement + return "#{Gem::Requirement.default}.a" unless bundler_version + + bundler_gem_version = Gem::Version.new(bundler_version) + + requirement = bundler_gem_version.approximate_recommendation + + return requirement unless Gem::Version.new(Gem::VERSION) < Gem::Version.new('2.7.0') + + requirement += '.a' if bundler_gem_version.prerelease? + + requirement + end + + def load_bundler! + ENV['BUNDLE_GEMFILE'] ||= gemfile + + activate_bundler + end + + def activate_bundler + gem_error = activation_error_handling do + gem 'bundler', bundler_requirement + end + return if gem_error.nil? + + require_error = activation_error_handling do + require 'bundler/version' + end + if require_error.nil? && Gem::Requirement.new(bundler_requirement).satisfied_by?(Gem::Version.new(Bundler::VERSION)) + return + end + + 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}'`" + exit 42 + end + + def activation_error_handling + yield + nil + rescue StandardError, LoadError => e + e + end +end + +m.load_bundler! + +load Gem.bin_path('bundler', 'bundle') if m.invoked_as_script? diff --git a/portal/bin/rails b/portal/bin/rails new file mode 100755 index 0000000..7a8ff81 --- /dev/null +++ b/portal/bin/rails @@ -0,0 +1,9 @@ +#!/usr/bin/env ruby +begin + load File.expand_path('spring', __dir__) +rescue LoadError => e + raise unless e.message.include?('spring') +end +APP_PATH = File.expand_path('../config/application', __dir__) +require_relative '../config/boot' +require 'rails/commands' diff --git a/portal/bin/rake b/portal/bin/rake new file mode 100755 index 0000000..0ba8c48 --- /dev/null +++ b/portal/bin/rake @@ -0,0 +1,9 @@ +#!/usr/bin/env ruby +begin + load File.expand_path('spring', __dir__) +rescue LoadError => e + raise unless e.message.include?('spring') +end +require_relative '../config/boot' +require 'rake' +Rake.application.run diff --git a/portal/bin/setup b/portal/bin/setup new file mode 100755 index 0000000..33a3949 --- /dev/null +++ b/portal/bin/setup @@ -0,0 +1,35 @@ +#!/usr/bin/env ruby +require 'fileutils' + +# path to your application root. +APP_ROOT = File.expand_path('..', __dir__) + +def system!(*args) + system(*args) || abort("\n== Command #{args} failed ==") +end + +FileUtils.chdir APP_ROOT do + # This script is a way to setup or update your development environment automatically. + # This script is idempotent, so that you can run it at anytime and get an expectable outcome. + # Add necessary setup steps to this file. + + puts '== Installing dependencies ==' + system! 'gem install bundler --conservative' + system('bundle check') || system!('bundle install') + + # Install JavaScript dependencies + system('bin/yarn') + + puts "\n== Copying sample files ==" + FileUtils.cp 'config/database.yml', 'config/database.yml' unless File.exist?('config/database.yml') + + puts "\n== Preparing database ==" + system! 'bin/rails db:create' + system! 'bin/rails db:migrate' + + puts "\n== Removing old logs and tempfiles ==" + system! 'bin/rails log:clear tmp:clear' + + puts "\n== Restarting application server ==" + system! 'bin/rails restart' +end diff --git a/portal/bin/spring b/portal/bin/spring new file mode 100755 index 0000000..d89ee49 --- /dev/null +++ b/portal/bin/spring @@ -0,0 +1,17 @@ +#!/usr/bin/env ruby + +# This file loads Spring without using Bundler, in order to be fast. +# It gets overwritten when you run the `spring binstub` command. + +unless defined?(Spring) + require 'rubygems' + require 'bundler' + + lockfile = Bundler::LockfileParser.new(Bundler.default_lockfile.read) + spring = lockfile.specs.detect { |spec| spec.name == 'spring' } + if spring + Gem.use_paths Gem.dir, Bundler.bundle_path.to_s, *Gem.path + gem 'spring', spring.version + require 'spring/binstub' + end +end diff --git a/portal/bin/webpack b/portal/bin/webpack new file mode 100755 index 0000000..d922a7d --- /dev/null +++ b/portal/bin/webpack @@ -0,0 +1,18 @@ +#!/usr/bin/env ruby + +ENV['RAILS_ENV'] ||= ENV['RACK_ENV'] || 'development' +ENV['NODE_ENV'] ||= 'development' + +require 'pathname' +ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', + Pathname.new(__FILE__).realpath) + +require 'bundler/setup' + +require 'webpacker' +require 'webpacker/webpack_runner' + +APP_ROOT = File.expand_path('..', __dir__) +Dir.chdir(APP_ROOT) do + Webpacker::WebpackRunner.run(ARGV) +end diff --git a/portal/bin/webpack-dev-server b/portal/bin/webpack-dev-server new file mode 100755 index 0000000..4e5f40d --- /dev/null +++ b/portal/bin/webpack-dev-server @@ -0,0 +1,18 @@ +#!/usr/bin/env ruby + +ENV['RAILS_ENV'] ||= ENV['RACK_ENV'] || 'development' +ENV['NODE_ENV'] ||= 'development' + +require 'pathname' +ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', + Pathname.new(__FILE__).realpath) + +require 'bundler/setup' + +require 'webpacker' +require 'webpacker/dev_server_runner' + +APP_ROOT = File.expand_path('..', __dir__) +Dir.chdir(APP_ROOT) do + Webpacker::DevServerRunner.run(ARGV) +end diff --git a/portal/bin/yarn b/portal/bin/yarn new file mode 100755 index 0000000..d3627c3 --- /dev/null +++ b/portal/bin/yarn @@ -0,0 +1,9 @@ +#!/usr/bin/env ruby +APP_ROOT = File.expand_path('..', __dir__) +Dir.chdir(APP_ROOT) do + exec 'yarnpkg', *ARGV +rescue Errno::ENOENT + warn 'Yarn executable was not detected in the system.' + warn 'Download Yarn at https://yarnpkg.com/en/docs/install' + exit 1 +end diff --git a/portal/client/packs/application.js b/portal/client/packs/application.js new file mode 100644 index 0000000..5e0fd42 --- /dev/null +++ b/portal/client/packs/application.js @@ -0,0 +1,24 @@ +import React from 'react'; +import { render } from 'react-dom'; +import { createRoot } from 'react-dom/client'; + +import { library } from '@fortawesome/fontawesome-svg-core'; +import { fab } from '@fortawesome/free-brands-svg-icons'; +import { fas } from '@fortawesome/free-solid-svg-icons'; +import { far } from '@fortawesome/free-regular-svg-icons'; +import TimeAgo from 'javascript-time-ago'; +import en from 'javascript-time-ago/locale/en.json'; + +import '../src/assets/style'; + +import EntryPoint from '../src/entry_point'; + +library.add(fas, fab, far); + +TimeAgo.addDefaultLocale(en); + +document.addEventListener('DOMContentLoaded', () => { + const container = document.getElementById('app'); + const root = createRoot(container); + root.render(); +}); diff --git a/portal/client/src/assets/images.js b/portal/client/src/assets/images.js new file mode 100644 index 0000000..e295d71 --- /dev/null +++ b/portal/client/src/assets/images.js @@ -0,0 +1,5 @@ +import MAIN_BACKGROUND_IMG from './images/main-bg.jpg'; +import LOGO from './images/logo.png'; +import LINKEDIN from './images/linkedin.png'; + +export { MAIN_BACKGROUND_IMG, LINKEDIN, LOGO }; diff --git a/portal/client/src/assets/images/iconmonstr-arrow-65-240.png b/portal/client/src/assets/images/iconmonstr-arrow-65-240.png new file mode 100644 index 0000000..54986b8 Binary files /dev/null and b/portal/client/src/assets/images/iconmonstr-arrow-65-240.png differ diff --git a/portal/client/src/assets/images/linkedin.png b/portal/client/src/assets/images/linkedin.png new file mode 100644 index 0000000..7298870 Binary files /dev/null and b/portal/client/src/assets/images/linkedin.png differ diff --git a/portal/client/src/assets/images/logo.png b/portal/client/src/assets/images/logo.png new file mode 100644 index 0000000..61964c6 Binary files /dev/null and b/portal/client/src/assets/images/logo.png differ diff --git a/portal/client/src/assets/images/logo.svg b/portal/client/src/assets/images/logo.svg new file mode 100644 index 0000000..8bd246b --- /dev/null +++ b/portal/client/src/assets/images/logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/portal/client/src/assets/images/main-bg.jpg b/portal/client/src/assets/images/main-bg.jpg new file mode 100644 index 0000000..2b41ef0 Binary files /dev/null and b/portal/client/src/assets/images/main-bg.jpg differ diff --git a/portal/client/src/assets/style.js b/portal/client/src/assets/style.js new file mode 100644 index 0000000..414a48c --- /dev/null +++ b/portal/client/src/assets/style.js @@ -0,0 +1,9 @@ +import 'bootstrap/dist/css/bootstrap.min.css'; +import 'react-datetime/css/react-datetime.css'; +import 'react-date-range/dist/styles.css'; +import 'react-date-range/dist/theme/default.css'; +import 'react-input-range/lib/css/index.css'; +import 'react-toastify/dist/ReactToastify.css'; +import 'react-confirm-alert/src/react-confirm-alert.css'; + +import './stylesheets/style.scss'; diff --git a/portal/client/src/assets/stylesheets/style.scss b/portal/client/src/assets/stylesheets/style.scss new file mode 100644 index 0000000..b9f6d8f --- /dev/null +++ b/portal/client/src/assets/stylesheets/style.scss @@ -0,0 +1,1210 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@200;300;400;500;600;700&display=swap'); + +$background-color: #7D04B6; +$primary-color: #A200A6; + +.btn-primary { + background: #57B8D7; + border-color: #57B8D7; +} + +.btn-xs { + padding: .25rem .5rem; + font-size: .775rem; + line-height: 1.2; + border-radius: .2rem; + + .btn-block { + display: block; + width: 100% + } + + .btn-block + .btn-block { + margin-top: .5rem + } +} + +@mixin position($position, $top, $right, $bottom, $left) { + position: $position; + top: $top; + left: $left; + right: $right; + bottom: $bottom; +} + +body { + font-family: 'Inter', sans-serif !important; +} + +section.landing-page { + display: flex; + justify-content: center; + align-items: center; +} + +.form-control, .form-control:disabled { + padding: 10px 15px; + height: auto; + font-size: 14px; + background: #E9F6F9; + border: none; +} + +.input-range__slider { + background: #57b8d7; + border: 1px solid #57b8d7; +} + +.input-range__label--value { + font-weight: bold; + font-size: 13px; + color: black; +} + +.input-range__track--active { + background: #57b8d7; +} + +.form-group { + .input-range { + margin-top: 12px; + } + + label { + margin-bottom: 15px; + } +} + +.btn-theme-primary { + background: $primary-color; + border-color: $primary-color; + font-weight: 500; +} + +.btn-normal-primary { + font-weight: 500; + background: #0072b1; + border-color: #0072b1; + color: white; +} + +.authenticated-layout { + height: 100vh; + + .sidebar { + background: #363F48; + width: 300px; + z-index: 1; + padding: 30px; + overflow-y: auto; + height: 100%; + overflow-x: hidden; + + .logo { + margin-bottom: 30px; + } + } + + .main-content { + padding: 15px 15px 15px 315px; + background: #F3F3F3; + height: 100%; + overflow-y: auto; + } +} + +.main-sidebar { + padding-left: 100px; +} + +.main-sidebar { + a { + color: #eee; + font-size: 12px; + padding: 5px 10px; + display: inline-block; + width: 100%; + border-radius: 5px 0px 0px 5px; + + &.active { + background: #a200a6; + pointer-events: none; + } + } +} + +.common-popover .popover-body { + padding: 20px; +} + +.common-popover .popover { + border: none; + max-width: initial; + box-shadow: 2px 2px 15px rgba(0, 0, 0, 0.1); + border-radius: 15px; +} + +.sidebar-links ul { + padding: 0; + list-style: none; +} + +.sidebar-links ul li a { + padding: 12px 30px; + font-size: 15px; + display: inline-block; + color: #17a2b8; + font-weight: 300; + width: 100%; + text-decoration: none !important; +} + +.sidebar-links ul { + margin: 0 -30px; +} + +.sidebar-links ul li a svg { + color: #57B8D7; + width: 30px !important; + margin-right: 10px; + font-size: 18px; +} + +.separator { + border-top: 1px solid #4D5A68; + margin-bottom: 10px; + margin-top: 10px; +} + +.project-selector { + margin-bottom: 20px; +} + +header .navbar { + background: #222C36; + height: 73px; +} + +.no-max-width .popover { + max-width: initial; +} + +main { + padding: 30px; + background: #EAECF1; +} + +.card, .card-body { + border-radius: 15px; + border: none; +} + +.ask-card { + padding: 20px; + border: 1px solid #EDEDED; + border-radius: 15px; + cursor: pointer +} + +.ask-header h3 { + font-size: 16px; + font-weight: 600; + margin: 0 15px 0 0; +} + +.ask-header { + display: flex; + align-items: center; + margin-bottom: 7px; + flex-wrap: wrap; +} + +.ask-header p { + margin: 0; +} + +.primary-badge { + padding: 3px 10px; + font-size: 10px; + background: #57b8d7; + color: #fff; + border-radius: 30px; + margin: 0 5px 0 0; +} + +.secondary-badge { + padding: 3px 10px; + font-size: 10px; + background: #DBE9EE; + color: #819AA1; + border-radius: 30px; + margin: 0; +} + +.role-builder { + //background: #a200a6;color: #fff; + // added for completeness - builder role is a primary badge +} + +.role-helper { + background: #a200a6; + color: #fff; +} + +.ask-body p { + color: #686868; + font-weight: 200; + font-size: 14px; + margin: 0 0 10px; +} + +.acceptance-criteria label { + font-weight: 100; + font-size: 14px; + font-style: italic; + color: #686868; +} + +.acceptance-criteria svg { + margin-left: 5px; + color: #57b8d7; +} + +.ask-footer { + display: flex; + justify-content: space-between; + font-size: 14px; + border-top: 1px solid #ededed; + border-bottom: 1px solid #ededed; +} + +.ask-footer > label { + margin: 0; +} + +.ask-footer-left > label { + margin: 0; + padding: 9px 15px; + border-right: 1px solid #ededed; + font-size: 11px; +} + +.ask-footer-right .btn { + font-size: 14px; + font-weight: 500; + border-left: 1px solid #ededed; + border-radius: 0; + padding: 6px 15px; + height: 35px; + text-decoration: none !important; +} + +.ask-footer-left label:not(:first-child) svg { + margin-right: 10px; +} + +.ask-footer-left label svg { + font-size: 14px; + color: #57b8d7; +} + +.ask-footer-right .btn:first-child { + border: none; +} + +.common-tabs { + padding: 0 20px; + border: 1px solid #ededed; + border-left: none; + border-right: none; + margin-bottom: 20px; +} + +.common-tabs .btn { + background: none !important; + border: 2px solid rgba(0, 0, 0, 0) !important; + color: #686868 !important; + font-size: 11px; + box-shadow: none !important; +} + +.common-tabs .btn svg { + margin-right: 6px; + color: #57b8d7; +} + +.common-tabs .btn.active { + color: #4e555b !important; + border-bottom: 2px solid #57b8d7 !important; + border-radius: 0 !important; +} + +.text-dark { + color: #434343 !important; +} + +.text-success { + color: #66D2AB !important; +} + +.text-danger { + color: #E46D6D !important; +} + +.project-tabs { + display: flex; + flex-direction: column; +} + +.project-tabs .btn { + text-align: left; + color: #686868; + margin-bottom: 20px; + box-shadow: none !important; + + &.active { + color: #000000; + font-weight: bold; + } +} + +.add-project-footer { + display: flex; +} + +.project-form { + min-height: 400px; +} + +.file-upload { + display: flex; + margin-top: 15px; + justify-content: space-between; + align-items: center; +} + +.file-upload span { + color: #57b8d7; + text-decoration: underline; + font-size: 12px; +} + +span.help-text { + color: #57b8d7; + font-size: 12px; + text-decoration: underline; + padding: 0; + cursor: pointer; +} + +.form-group > label { + text-transform: capitalize; +} + +.terms-box { + max-height: 500px; + overflow-y: auto; + margin-bottom: 20px; + background: #f3f3f3; + padding: 20px; + border-radius: 10px; + font-size: 14px; +} + +.terms-box h4 { + font-size: 18px; +} + +.project-stats { + display: flex; + height: 39px; + background: #14212E; + color: #fff; + padding: 0px 20px; + border-radius: 0 30px 30px 0; + text-transform: capitalize; + font-size: 14px; +} + +.selector-group { + display: flex; +} + +.project-status { + margin-right: 15px; + display: flex; + align-items: center; + border-right: 1px solid #4D5A68; + padding-right: 20px; +} + +.project-stats > div:last-child { + display: flex; + align-items: center; +} + + +.user-info-dropdown { + display: flex; + align-items: center; +} + +.wallets-balance { + background: #14212e; + color: #fff; + font-size: 12px; + padding: 7px 15px 5px 5px; + border-radius: 15px; + text-transform: capitalize; +} + +.wallets-balance svg { + color: #57b8d7; +} + +.user-name { + margin-right: 10px; + font-size: 10px; + font-weight: bold; +} + +.radio-btns { + display: flex; +} + +.radio-btns > label { + flex: 1; + border: 1px solid #d5d5d5; + text-align: center; + font-size: 12px; + position: relative; +} + +.radio-btns > label:first-child { + border-radius: 5px 0 0 5px; + border-right: none; +} + +.radio-btns > label:last-child { + border-radius: 0 5px 5px 0; +} + +.radio-btns > label span { + width: 100%; + padding: 7px 15px; + display: inline-block; +} + +.radio-btns > label input:checked ~ span { + background: #17a2b8; + color: #fff; +} + +.radio-btns > label input { + position: absolute; + left: 0; + right: 0; + margin: 0; + top: 0; + bottom: 0; + width: 100%; + height: 100%; + visibility: hidden; +} + +.messages { + display: flex; +} + +.message-users { + flex: 0 0 200px; + display: flex; + flex-direction: column; + border-right: 1px solid #ededed; +} + +.message-users .btn { + background: none; + border: none; + color: #212529; + text-align: left; + border-bottom: 1px solid #ededed; + border-radius: 0; +} + +.ask-more-details .common-tabs { + margin: 0; +} + +.ask-more-details .messages .btn { + font-size: 12px; +} + +.ask-footer-right .dropdown-toggle { + background: none; + color: #333; + border-left: 1px solid #ededed !important; +} + +.ask-status { + border: none !important; + height: auto; + margin: 0 0 0 12px !important; + padding: 2px 25px !important; + line-height: 21px; + border-radius: 30px; + font-weight: 500; + font-size: 12px; +} + +.share-project { + .btn { + color: #57b8d7 + } + + svg { + font-size: 24px; + } + + + .ask-helpers .ask-status { + position: absolute; + right: -25px; + font-size: 12px; + top: 10px; + } +} + +.card-body { + padding: 25px; +} + +.project-details { + h3 { + font-size: 22px; + font-weight: 600; + margin-bottom: 20px; + line-height: 33px; + } + + h1 { + font-size: 28px; + font-weight: 600; + margin-bottom: 20px; + } + + p { + color: #686868; + font-size: 14px; + margin-bottom: 20px; + font-weight: 300; + line-height: 27px; + } +} + +.project-size h3 { + font-size: 14px; + font-weight: 700; + margin: 0; + padding: 10px 0; +} + +.project-size { + display: flex; + border-top: 1px solid #ededed; + border-bottom: 1px solid #ededed; + margin-bottom: 10px; +} + +.project-size ul { + margin: 0; + display: flex; + font-size: 11px; +} + +.project-size ul li { + padding-left: 15px; + padding-right: 15px; + border-left: 1px solid rgba(196, 196, 196, 0.3); + //margin-left: 15px; + display: flex; + align-items: center; +} + +.project-size ul li strong { + margin-left: 6px; +} + +//.table thead th {border: none;background: #f3f3f3;font-weight: 400;font-size: 12px;} + +.table tbody tr:first-child td { + border: none; +} + +.table tbody td { + font-size: 14px; +} + +.height-100 { + height: 100%; +} + +.custom-checkbox-btn { + padding: 0; +} + +.custom-checkbox-btn .form-check-label { + position: relative; + width: 100%; + text-align: center; + text-transform: capitalize; + padding: 10px 40px; + border: 1px solid #57B8D7; + border-radius: 7px; + margin-bottom: 15px; + font-weight: 600; + font-size: 14px; +} + +.custom-checkbox-btn .form-check-label input { + margin: 0; + position: absolute; + left: 0; + right: 0; + width: 100%; + top: 0; + bottom: 0; + height: 100%; + opacity: 0; +} + +.project-card-group { + height: 100%; + display: flex; + flex-direction: column; +} + +.project-card-group .card:nth-child(2) { + flex: 1; +} + +.custom-checkbox-btn.checked .form-check-label { + background: #85C96D; + color: #fff; + border-color: #85C96D; + position: relative +} + +.custom-checkbox-btn.checked .form-check-label svg { + position: absolute; + left: 15px; + top: 13px +} + +.join-project-form .card-body { + display: flex; + flex: 1; + flex-direction: column; +} + +.join-project-form .card-body .project-details { + display: flex; + flex-direction: column; + flex: 1; +} + +@media (min-width: 1600px) { + .container-xl { + max-width: 1540px; + } +} + +.rdw-editor-wrapper { + padding: 7px; + border: 1px solid #eee; + background: #ffffff; + border-radius: 6px; + margin-bottom: 10px; +} + +.rdw-editor-toolbar { + padding: 0; + border: none; + border-bottom: 1px solid #eee; + border-radius: 0; +} + +.project-details .project-reason * { + font-family: 'Inter', sans-serif !important; + font-size: 16px; +} + +.project-details .project-reason p { + margin-bottom: 1em ; +} + + +.project-details .project-reason strong { + font-weight: 600; +} + +.project-reason-video { + min-width: 400px; + + .plyr--video { + flex: 0 0 40%; + width: 100%; + margin-right: 25px; + background: #000; + border-radius: 20px; + } +} + +.common-tabs-content { + padding: 25px; +} + +.ask-more-details + .ask-footer { + margin: 10px -15px -15px; +} + +.ask-card .ask-header { + flex-wrap: wrap; +} + +.ask-card .ask-header h3 { + width: 100%; + margin-bottom: 10px; +} + +.ask-card .ask-footer { + margin-top: 15px; +} + +.ask-card .ask-status { + position: absolute; + right: -15px; + top: 15px; +} + +.ask-card .ask-header { + padding-right: 105px; +} + +.ask-more-details .messages .btn.active { + background: none !important; + color: #57b8d7; + font-weight: 600; + border-color: #57b8d7; + border-width: 2px; + box-shadow: none !important; +} + +.conversation h4 { + flex: 1; + justify-content: space-between; + padding: 8px 15px; + font-size: 18px; + border-bottom: 1px solid #ededed; + align-items: center; + color: #2a2a2a; + font-weight: 200; + margin: 0; +} + +.conversation h4 .btn { + padding: 4px 10px; + background: none; + color: #57b8d7; + border: none; +} + +.messages-list { + height: 330px; + overflow-y: auto; + padding: 15px; +} + +.messages-list .message { + background: #e9f6f9; + border-radius: 10px; + margin-bottom: 15px; + padding: 15px; + font-size: 14px; +} + +.msg-footer { + font-size: 11px; + display: flex; + margin-top: 15px; +} + +.msg-footer > strong { + margin-right: 10px; +} + +form.message-form { + display: flex; + border-top: 1px solid #ededed; +} + +form.message-form .btn { + background: none; + border: none; + padding: 10px 25px; + color: #57b8d7; + font-size: 14px !important; + font-weight: 500; +} + +form.message-form .form-control { + background: none; + border-right: 1px solid #ededed; + border-radius: 0; +} + +.messages-area { + min-height: 436px; +} + +.messages-area { + min-height: 436px; + display: flex; + align-items: center; + justify-content: center; + + h4 { + font-size: 18px; + } +} + +.conversation { + flex: 1; +} + +.messages-list .message { + text-align: left; + max-width: 80%; +} + +.messages-list .message.is-you { + text-align: right; + margin-left: auto; +} + +.messages-list .message.is-you .msg-footer { + justify-content: end; +} + +.ask-helpers .card .ask-status { + position: absolute; + right: -15px; + top: 15px; +} + +.project-status-dash { + margin-bottom: 15px; + + .ask-status { + margin: 0 !important; + } +} + +.project-dash { + cursor: pointer; +} + +.ask-more-details .ask-tabs {display: flex;flex-direction: column;} + +.ask-more-details .common-tabs-content {flex: 1;padding: 0 25px;} + +.ask-more-details {display: flex;} + +.ask-more-details .nav {display: flex;flex-direction: column;margin-right: 15px;} + +.btn-info { + background: #17a2b8; + border-color: #17a2b8; +} + +.ask-more-details .tab-content { + flex: 1; +} + +.messages { + border: 1px solid #ededed; + border-radius: 10px; +} + +.record-badges .badge { + margin-right: 7px; +} + +.ask-header p { + font-weight: 100; + color: #000; +} + +.DraftEditor-editorContainer > * { + color: #333333; +} + +.project-details .project-reason span {color: #686868 !important;} + +.bootstrap-layout { + height: 100vh; + overflow: auto; + display: flex; +} +.bl-sidebar { + flex: 0 0 80px; + max-width: 80px; + transition: all ease-in-out 200ms; +} +.bl-content { + flex: 1 1 100%; +} + +.bl-sidebar-toggler {flex: 0 0 40px; background: none !important;box-shadow: none !important;border: none;width: 40px;display: flex;flex-direction: column;height: 40px;margin-top: auto;margin-bottom: auto;padding: 5px 7px;display: flex;justify-content: center;} + +.bl-header {display: flex;background: #222c36;padding-left: 15px;} + +.bl-sidebar-toggler span {height: 2px;width: 100%;background: #fff;position: relative;transition: all ease-in-out 400ms; transform: rotate(0deg); top: 0} + +.bl-sidebar-toggler span:nth-child(1) {top: -7px;} + +.bl-sidebar-toggler span:nth-child(3) {top: 7px;} + +.bl-sidebar-toggler.opened span:nth-child(1) {transform: rotate(45deg);top: 2px;} + +.bl-sidebar-toggler.opened span:nth-child(3) {transform: rotate(-45deg);top: -2px;} + +.bl-sidebar-toggler.opened span:nth-child(2) {opacity: 0} + +.sidebar-opened .bl-sidebar { + flex: 0 0 300px; + max-width: 300px; +} + +.sidebar-opened { + .bl-content { + max-width: calc(100% - 300px); + } +} + +.sidebar-closed { + .bl-content { + max-width: calc(100% - 80px); + } +} +.sidebar-closed { + header .navbar { + height: 84px; + } +} + +.authenticated-layout .sidebar-closed .sidebar {padding: 18px;} + +.authenticated-layout .sidebar-closed .sidebar .logo {margin: 0 0 20px;} + +.authenticated-layout .sidebar-closed .sidebar .primary-links {padding: 0 7px;} + +.authenticated-layout .sidebar-closed .sidebar-links ul li a svg {margin-right: 25px;} + +.bl-header header { + flex: 1; +} + +.more-info-toggle { + background: none !important; + border: none !important; + padding: 0 18px 0 15px; + box-shadow: none !important; +} + +.bl-content {display: flex;flex-direction: column} + +.bl-content main {flex: 1;overflow-y: auto;} + +@media screen and (max-width: 530px) { + .authenticated-layout .sidebar-closed .sidebar { + padding: 8px; + } + .sidebar-closed .bl-sidebar { + flex: 0 0 60px; + max-width: 60px; + } + .authenticated-layout .sidebar-closed .sidebar .logo { + margin: 0 0 8px; + } + .sidebar-closed { + .bl-content { + max-width: calc(100% - 60px); + } + header .navbar { + height: 62px; + } + } +} + +.popover-inner .project-stats {height: auto;border-radius: 0;flex-direction: column;padding: 20px;} + +.popover-inner .project-stats .project-status {border: none;margin: 0;display: flex;justify-content: center;border-bottom: 0.5px solid rgb(255 255 255 / 14%);padding-bottom: 10px;} + +.navbar-nav .dropdown-menu { + position: absolute; +} + +.navbar-nav .dropdown-menu .user-info-dropdown {flex-direction: column;padding: 15px;min-width: 270px;border-bottom: 1px solid #e4e4e4;margin-bottom: 7px;align-items: flex-start;} + +.navbar-nav .dropdown-menu .user-info-dropdown .user-name {margin-bottom: 7px;} + +.navbar-nav .dropdown-menu .user-info-dropdown .wallets-balance {color: #000;background: #f3f3f3;} + +.selector-group {min-width: 350px;} + +.selector-group > div:first-child {flex: 1;} + +.sidebar-opened .bl-content { + transition: all ease-in-out 400ms; +} +.project-form .project-public-view { + .col-lg-3 {flex: 0 0 33.33%;max-width: 33.33%;} + + .row {flex-wrap: nowrap;} + + .col-lg-9 {flex: 1 !important;max-width: calc(100% - 33.33%);} +} + +div:not(.project-form) .project-public-view { + .col-lg-3 {flex: 0 0 400px;max-width: 400px;} + + .row {flex-wrap: nowrap;} + + .col-lg-9 {flex: 1 !important;max-width: calc(100% - 400px);} +} + +@media screen and (max-width: 1550px) { + .add-new-project .p-5.card { + padding: 0 !important; + } + .project-form .project-reason-video {width: 100%;padding: 0;margin: 0 0 20px;} + .project-form { + .project-size {flex-direction: column;border: none;} + + .project-size ul {flex-direction: column;margin: 10px 0 0;} + + .project-size {margin: 0;} + } + + div:not(.project-form) .project-public-view { + .project-size {flex-direction: column;border: none;} + + .project-size ul {flex-direction: column;margin: 10px 0 0;} + } +} + +@media screen and (max-width: 1420px) { + div:not(.project-form) .project-public-view { + .project-details .project-reason-video { + float: none; + width: 100%; + padding: 0; + margin: 0 0 30px; + min-width: auto; + } + } +} + +@media screen and (max-width: 1199px) { + .filtered-lists .row { + flex-direction: column; + } + .bl-content .project-public-view .row {flex-direction: column;} + + .bl-content .project-public-view .row > div {max-width: 100%;flex: 1 1 100%;} + + div:not(.project-form) .project-public-view .row > .col-lg-3 { + margin-top: 30px; + } +} + +@media screen and (max-width: 991px) { + .selector-group {min-width: auto} +} + +@media screen and (max-width: 767px) { + header .navbar {flex: 1;} + + header .navbar .navbar-nav:first-child {flex: 1;margin-right: 10px;} + + .selector-group {min-width: auto} + header .navbar {flex: 1;} + + header .navbar .navbar-nav:first-child {flex: 1;margin-right: 10px;} + + .sidebar-opened .bl-sidebar { + flex: 0 0 80px; + max-width: 80px; + } + .sidebar-opened .bl-content { + max-width: calc(100% - 80px); + } + .sidebar-width { + width: 240px; + } + .authenticated-layout .sidebar { + position: fixed; + transition: all ease-in-out 400ms; + } + .authenticated-layout .sidebar-closed .sidebar { + width: 80px; + } + .sidebar-opened .bl-sidebar-toggler { + position: fixed; + left: 310px; + z-index: 99; + background: #363F48 !important; + top: 5px; + } +} + +@media screen and (max-width: 450px) { + .bl-header { + padding-left: 7px; + } + header .navbar { + padding: 0 7px; + } +} + +@media screen and (max-width: 530px) { + .sidebar-opened .bl-sidebar { + flex: 0 0 60px; + max-width: 60px; + } + .sidebar-opened .bl-content { + max-width: calc(100% - 60px); + } + .authenticated-layout .sidebar-closed .sidebar { + width: 60px; + } +} + +#exchangeAccounts { + font-size: 0.75rem !important; +} + +.form-control:disabled { + opacity: 0.5; +} + +.dt-range-pick { + display: flex; + background: #e9f6f9; + border-radius: 4px; + + .form-control { + opacity: 1; + } +} diff --git a/portal/client/src/channels/consumer.js b/portal/client/src/channels/consumer.js new file mode 100644 index 0000000..9dc0ced --- /dev/null +++ b/portal/client/src/channels/consumer.js @@ -0,0 +1,6 @@ +// Action Cable provides the framework to deal with WebSockets in Rails. +// You can generate new channels where WebSocket features live using the `rails generate channel` command. + +import { createConsumer } from '@rails/actioncable'; + +export default createConsumer(); diff --git a/portal/client/src/data/axiosClient.js b/portal/client/src/data/axiosClient.js new file mode 100644 index 0000000..60d715d --- /dev/null +++ b/portal/client/src/data/axiosClient.js @@ -0,0 +1,15 @@ +import axios from 'axios'; + +const axiosObj = () => { + const instance = axios.create({ + headers: { + 'cache-control': 'no-cache', + 'Access-Control-Allow-Origin': '*', + 'X-CSRF-Token': document.querySelector('meta[name=csrf-token]').content, + }, + }); + + return instance; +}; + +export default axiosObj(); diff --git a/portal/client/src/data/concerns/filterable.js b/portal/client/src/data/concerns/filterable.js new file mode 100644 index 0000000..9172cf0 --- /dev/null +++ b/portal/client/src/data/concerns/filterable.js @@ -0,0 +1,19 @@ +import {action, observable} from 'mobx'; + +import Pagination from '../entities/pagination'; + +const Filterable = { + pagination: null, + + initializeFilterable: action(function(params) { + this.pagination = new Pagination( + { + page: 1, + params, + }, + this + ); + }), +}; + +export default Filterable; diff --git a/portal/client/src/data/concerns/notfications.js b/portal/client/src/data/concerns/notfications.js new file mode 100644 index 0000000..64754b7 --- /dev/null +++ b/portal/client/src/data/concerns/notfications.js @@ -0,0 +1,8 @@ +import { toast } from 'react-toastify'; + +const notification = { + notifySuccess: message => toast(message, { type: 'success' }), + notifyError: message => toast(message, { type: 'error' }), +}; + +export default notification; diff --git a/portal/client/src/data/entities/base_entity.js b/portal/client/src/data/entities/base_entity.js new file mode 100644 index 0000000..d4d7511 --- /dev/null +++ b/portal/client/src/data/entities/base_entity.js @@ -0,0 +1,67 @@ +import { + action, + computed, + extendObservable, + flow, + makeObservable, + observable, +} from 'mobx'; +import { camelCase, isEmpty, map } from 'lodash'; + +import client from '../axiosClient'; +import notification from '../concerns/notfications'; + +class BaseEntity { + /* eslint-disable */ + store; + client; + @observable dirty = false; + @observable json = false; + /* eslint-enable */ + + constructor(value, store) { + makeObservable(this); + + this.store = store; + this.client = client; + this.json = value; + + extendObservable(this, notification); + } + + @computed + get currentUser() { + return this.store.rootStore.userStore.currentUser; + } + + @action + initialize(params) { + this.update(params); + } + + @action + update(params, updateServer) { + map( + Object.keys(params), + function(k) { + this[camelCase(k)] = params[k]; + }.bind(this) + ); + + if (updateServer) this.updateServer(params); + } + + @action + destroy() { + if (isEmpty(this.store)) return; + + this.store.records.splice(this.store.records.indexOf(this), 1); + } + + @flow + *updateServer(params) { + yield this.client.put(this.updateUrl, params); + } +} + +export default BaseEntity; diff --git a/portal/client/src/data/entities/bet.js b/portal/client/src/data/entities/bet.js new file mode 100644 index 0000000..a1cf501 --- /dev/null +++ b/portal/client/src/data/entities/bet.js @@ -0,0 +1,37 @@ +import { action, computed, makeObservable, observable } from 'mobx'; +import BaseEntity from './base_entity'; + +class Bet extends BaseEntity { + /* eslint-disable */ + id; + outcome; + exchange; + exchangeEventName; + exchangeOdds; + tipProviderOdds; + stake; + expectedValue; + marketIdentifier; + exchangeMarketDetails; + betPlacementType; + executedOdds; + evPercent; + eventScheduledTime; + /* eslint-enable */ + + constructor(value, store) { + super(value, store); + + makeObservable(this); + + this.handleConstruction(value); + } + + @action + handleConstruction(value) { + const val = { ...value }; + this.initialize(val); + } +} + +export default Bet; diff --git a/portal/client/src/data/entities/exchange_account.js b/portal/client/src/data/entities/exchange_account.js new file mode 100644 index 0000000..73826ba --- /dev/null +++ b/portal/client/src/data/entities/exchange_account.js @@ -0,0 +1,48 @@ +import { action, computed, flow, makeObservable, observable } from 'mobx'; +import BaseEntity from './base_entity'; +import client from '../axiosClient'; + +class ExchangeAccount extends BaseEntity { + /* eslint-disable */ + id; + exchangeName; + exchangeId; + contactEmail; + contactName; + stakeStrategyName; + sourceTypes; + stakeStrategyConfig; + stakeStrategy; + lastLogTime; + @observable isActive; + @observable canBet; + log; + + @observable accountBalance; + /* eslint-enable */ + + constructor(value, store) { + super(value, store); + + makeObservable(this); + + this.handleConstruction(value); + } + + @action + handleConstruction(value) { + const val = { ...value }; + this.initialize(val); + } + + @flow + *updateExchangeAccount(params) { + this.update(params); + yield client.put('/api/v1/exchange_account.json', { + exchange_account_id: this.id, + ...params, + }); + } +} + +export default ExchangeAccount; diff --git a/portal/client/src/data/entities/pagination.js b/portal/client/src/data/entities/pagination.js new file mode 100644 index 0000000..e8f1668 --- /dev/null +++ b/portal/client/src/data/entities/pagination.js @@ -0,0 +1,58 @@ +import { action, computed, makeObservable, observable } from 'mobx'; +import { isNull } from 'lodash'; + +import BaseEntity from './base_entity'; + +class Pagination extends BaseEntity { + /* eslint-disable */ + @observable count = 0; + @observable page = 1; + @observable pages = null; + @observable prev = null; + @observable next = null; + @observable last = null; + @observable from = null; + @observable to = null; + @observable params = {}; + @observable filterType = 'fetch'; + initialParams = {}; + /* eslint-enable */ + + constructor(value, store) { + super(value, store); + + makeObservable(this); + + this.initialize(value); + this.initialParams = value.params; + } + + @action + fetch() { + this.store.fetch({ ...this.params, page: this.page }); + } + + @action + updateParams(params) { + this.update({ params: { ...this.params, ...params } }); + } + + @action + gotoPage(page) { + this.update({ page }); + this.fetch(); + } + + @action + resetPageAndFetch() { + this.gotoPage(1); + } + + @action + reset() { + this.update({ page: 1, params: this.initialParams }); + this.fetch(); + } +} + +export default Pagination; diff --git a/portal/client/src/data/entities/user.js b/portal/client/src/data/entities/user.js new file mode 100644 index 0000000..7c25cf5 --- /dev/null +++ b/portal/client/src/data/entities/user.js @@ -0,0 +1,51 @@ +import { action, computed, makeObservable, observable } from 'mobx'; +import BaseEntity from './base_entity'; + +class User extends BaseEntity { + /* eslint-disable */ + id; + email; + accountId; + accountName; + @observable status; + @observable firstName; + @observable lastName; + @observable strKey; + @observable fullname; + @observable onboarded = []; + @observable environment = 'production'; + /* eslint-enable */ + + constructor(value, store) { + super(value, store); + + makeObservable(this); + + this.handleConstruction(value); + } + + updateUrl = () => `/api/v1/users/${this.id}.json`; + + @computed + get fullName() { + return `${this.firstName} ${this.lastName}`; + } + + @computed + get asSelectOption() { + return { label: this.fullName, value: this.id }; + } + + @action + logout = () => { + window.location.href = '/public'; + }; + + @action + handleConstruction(value) { + const val = { ...value }; + this.initialize(val); + } +} + +export default User; diff --git a/portal/client/src/data/index.js b/portal/client/src/data/index.js new file mode 100644 index 0000000..bef90dc --- /dev/null +++ b/portal/client/src/data/index.js @@ -0,0 +1,15 @@ +import { makeAutoObservable } from 'mobx'; + +import AppStore from './stores/app_store'; +import UserStore from './stores/user_store'; +import BetStore from './stores/bet_store'; + +export default class RootStore { + constructor() { + this.appStore = new AppStore(this); + this.userStore = new UserStore(this); + this.betStore = new BetStore(this); + + makeAutoObservable(this); + } +} diff --git a/portal/client/src/data/provider.js b/portal/client/src/data/provider.js new file mode 100644 index 0000000..7a83ec7 --- /dev/null +++ b/portal/client/src/data/provider.js @@ -0,0 +1,4 @@ +import React from 'react'; + +export const StoresContext = React.createContext(null); +export const StoreProvider = StoresContext.Provider; diff --git a/portal/client/src/data/store.js b/portal/client/src/data/store.js new file mode 100644 index 0000000..c95d1b2 --- /dev/null +++ b/portal/client/src/data/store.js @@ -0,0 +1,7 @@ +import React from 'react'; + +import { StoresContext } from './provider'; + +const useStore = () => React.useContext(StoresContext); + +export default useStore; diff --git a/portal/client/src/data/stores/app_store.js b/portal/client/src/data/stores/app_store.js new file mode 100644 index 0000000..873c0e3 --- /dev/null +++ b/portal/client/src/data/stores/app_store.js @@ -0,0 +1,126 @@ +import { action, flow, makeObservable, observable } from 'mobx'; +import BaseStore from './base_store'; +import client from '../axiosClient'; +import ExchangeAccount from '../entities/exchange_account'; +import consumer from '../../channels/consumer'; + +class AppStore extends BaseStore { + @observable sidebarOpened = true; + + @observable exchangeAccount; + + @observable currentExchangeAccountId; + + @observable totalWinAmount = 0; + + @observable totalLostAmount = 0; + + @observable totalRiskedAmount = 0; + + @observable totalTips = 0; + + @observable totalTipsSkipped = 0; + + @observable totalTipsProcessing = 0; + + @observable totalTipsExpired = 0; + + @observable totalTipsIgnored = 0; + + @observable totalTipsVoided = 0; + + @observable totalPlacedBets = 0; + + @observable totalPlacedBetsWon = 0; + + @observable totalPlacedBetsLost = 0; + + @observable totalPlacedBetsOpen = 0; + + @observable averageOddsWon = 0; + + @observable averageOddsLost = 0; + + @observable averageOdds = 0; + + + @observable appInitialised = false; + + @observable autoRefreshResults = true; + + @observable runningSince = null; + + @observable exchangeAccounts = []; + + constructor(store) { + super(store, null); + this.consumer = consumer; + makeObservable(this); + this.fetchConfigFromServer(); + } + + @action + asPercentOfTotal(val) { + if (val === 0 || this.totalPlacedBets === 0) return '0%'; + return `${Math.round((val / this.totalPlacedBets) * 100)}%`; + } + + @flow + *fetchConfigFromServer() { + const response = yield client.get('/api/v1/app/configuration.json'); + if (response.data.success) { + this.update(response.data.config); + } + } + + @flow + *fetchSummaryFiguresFromServer() { + const response = yield client.get('/api/v1/app/summary.json', { + params: { + exchange_account_id: + this.currentExchangeAccountId || this.exchangeAccount?.id || '', + }, + }); + if (response.data.success) { + this.updateSummary(response.data.summary); + } + } + + updateSummary(summary) { + console.log('Refreshing dashboard'); + this.update(summary); + const ea = new ExchangeAccount(summary.exchange_account); + this.update({ + exchangeAccount: ea, + appInitialised: true, + currentExchangeAccountId: ea.id, + }); + } + + refresh() { + console.log('Refreshing filtered results'); + this.rootStore.betStore.fetch(this.rootStore.betStore.params); + return null; + } + + startListeningToAccountChannel() { + const obj = this; + this.consumer.subscriptions.create( + { channel: 'ExchangeAccountChannel', id: obj.exchangeAccount.id }, + { + connected() { + console.log('Connected to ExchangeAccountChannel'); + }, + disconnected() { + console.log('Disconnected from ExchangeAccountChannel'); + }, + received(data) { + console.log('Cable received'); + return null; + }, + } + ); + } +} + +export default AppStore; diff --git a/portal/client/src/data/stores/base_store.js b/portal/client/src/data/stores/base_store.js new file mode 100644 index 0000000..f9d7672 --- /dev/null +++ b/portal/client/src/data/stores/base_store.js @@ -0,0 +1,86 @@ +import { + action, + computed, + extendObservable, + makeObservable, + observable, +} from 'mobx'; +import { + camelCase, + filter, + find, + includes, + isEmpty, + map, + uniqBy, +} from 'lodash'; +import notification from '../concerns/notfications'; +import client from '../axiosClient'; + +class BaseStore { + /* eslint-disable */ + rootStore; + client; + Entity; + @observable records = []; + @observable fetching = false; + @observable fetched = false; + /* eslint-enable */ + + constructor(store, entity) { + makeObservable(this); + + this.rootStore = store; + this.client = client; + this.Entity = entity; + + extendObservable(this, notification); + } + + getById = id => this.getByParams({ id }); + + getByParams = params => find(this.records, params); + + getMultipleByParams = params => filter(this.records, params); + + getMultipleById = ids => filter(this.records, r => includes(ids, r.id)); + + @computed + get filteredRecords() { + return this.records; + } + + @computed + get hasRecords() { + return !isEmpty(this.records); + } + + @action + initialize(params) { + this.update(params); + } + + @action + update(params) { + map( + Object.keys(params), + function(k) { + this[camelCase(k)] = params[k]; + }.bind(this) + ); + } + + @action + addRecord(record) { + const obj = new this.Entity(record, this); + + this.records = uniqBy( + [...this.records, new this.Entity(record, this)], + 'id' + ); + + return obj; + } +} + +export default BaseStore; diff --git a/portal/client/src/data/stores/bet_store.js b/portal/client/src/data/stores/bet_store.js new file mode 100644 index 0000000..f1d3b26 --- /dev/null +++ b/portal/client/src/data/stores/bet_store.js @@ -0,0 +1,70 @@ +import { + extendObservable, + flow, + makeObservable, + observable, + override, +} from 'mobx'; + +import { map, orderBy } from 'lodash'; +import BaseStore from './base_store'; +import Bet from '../entities/bet'; +import Filterable from '../concerns/filterable'; +import Pagination from '../entities/pagination'; + +class BetStore extends BaseStore { + @observable params = { + event_name: '', + outcome: '', + created_at: { from: null, to: null }, + }; + + + constructor(store) { + super(store, Bet); + + extendObservable(this, Filterable); + + this.initializeFilterable(this.params); + makeObservable(this); + } + + @override + get filteredRecords() { + return orderBy(this.records, ['createdAt'], ['desc']); + } + + @flow + *fetch(pms) { + this.params = pms; + const response = yield this.client.get('/api/v1/bets.json', { + params: { + ...this.params, + exchange_account_id: this.rootStore.appStore.currentExchangeAccountId, + }, + }); + + if (response.data.summary) { + this.rootStore.appStore.updateSummary(response.data.summary); + } + + if (response.data.bets) { + this.update({ + records: map(response.data.bets, bet => this.addRecord(bet)), + }); + } + if (response.data.pagy) { + const p = new Pagination({ ...response.data.pagy }, this); + this.pagination.count = p.count; + this.pagination.prev = p.prev; + this.pagination.next = p.next; + this.pagination.last = p.last; + this.pagination.from = p.from; + this.pagination.to = p.to; + this.pagination.page = p.page; + this.pagination.pages = p.pages; + } + } +} + +export default BetStore; diff --git a/portal/client/src/data/stores/user_store.js b/portal/client/src/data/stores/user_store.js new file mode 100644 index 0000000..a1f336f --- /dev/null +++ b/portal/client/src/data/stores/user_store.js @@ -0,0 +1,36 @@ +import { computed, flow, makeObservable, observable } from 'mobx'; + +import { isEmpty } from 'lodash'; +import BaseStore from './base_store'; +import User from '../entities/user'; + +class UserStore extends BaseStore { + /* eslint-disable */ + @observable currentUser = {}; + @observable fetchingCurrentUser = true; + /* eslint-enable */ + + constructor(store) { + super(store, User); + + makeObservable(this); + } + + @computed + get userSignedIn() { + return !isEmpty(this.currentUser); + } + + @flow + *fetchCurrentUser() { + const response = yield this.client.get('/api/v1/users/auth.json'); + + if (response.data.success) { + this.update({ currentUser: new User(response.data.user, this) }); + } + + this.fetchingCurrentUser = false; + } +} + +export default UserStore; diff --git a/portal/client/src/entry_point/index.js b/portal/client/src/entry_point/index.js new file mode 100644 index 0000000..1fb4b9e --- /dev/null +++ b/portal/client/src/entry_point/index.js @@ -0,0 +1,22 @@ +import React from 'react'; + +import { BrowserRouter, Switch } from 'react-router-dom'; +import { ToastContainer } from 'react-toastify'; +import { StoreProvider } from '../data/provider'; +import RootStore from '../data'; +import AppRoutes from '../routes'; + +const EntryPoint = () => ( + + +
+ + + + +
+
+
+); + +export default EntryPoint; diff --git a/portal/client/src/helpers/filter_helpers.js b/portal/client/src/helpers/filter_helpers.js new file mode 100644 index 0000000..950bf1d --- /dev/null +++ b/portal/client/src/helpers/filter_helpers.js @@ -0,0 +1,66 @@ +import React from 'react'; + +import { + DateRange, + InputRangeTag, + ReactSelectTag, + SearchInput, + SelectTag, +} from '../views/shared/form_components'; + +export const renderFilter = (filter, handleChange) => { + switch (filter.type) { + case 'search': + return ; + + case 'select': + return ; + + case 'date-range': + return ; + + case 'input-range': + return ; + + case 'react-select': + return ; + + default: + return null; + } +}; + +export const betFilters = [ + { + name: 'outcome', + type: 'react-select', + placeholder: 'Select...', + label: 'Outcome', + isMulti: true, + options: [ + { label: 'All', value: '' }, + { label: 'Open', value: 'open' }, + { label: 'Lost', value: 'lost' }, + { label: 'Won', value: 'won' }, + { label: 'Processing', value: 'processing' }, + { label: 'Expired', value: 'expired' }, + { label: 'Skipped', value: 'skipped' }, + { label: 'Ignored', value: 'ignored' }, + { label: 'Errored', value: 'errored' }, + { label: 'Voided', value: 'voided' }, + { label: 'Cancelled', value: 'cancelled' }, + ], + }, + { + name: 'created_at', + type: 'date-range', + placeholder: 'Select Date', + label: 'Created Date', + }, + { + name: 'event_name', + type: 'search', + placeholder: 'Search...', + label: 'Event Name', + }, +]; diff --git a/portal/client/src/helpers/shared_helpers.js b/portal/client/src/helpers/shared_helpers.js new file mode 100644 index 0000000..ebd175b --- /dev/null +++ b/portal/client/src/helpers/shared_helpers.js @@ -0,0 +1,75 @@ +import { useEffect, useState } from 'react'; + +export const toSentence = arr => { + if (arr.length > 1) + return `${arr.slice(0, arr.length - 1).join(', ')}, and ${arr.slice(-1)}`; + + return arr[0]; +}; + +export const amountFormatter = number => { + const unitlist = ['', 'K', 'M', 'G']; + let num = number || 0; + + const sign = Math.sign(num); + let unit = 0; + while (Math.abs(num) > 1000) { + unit += 1; + num = Math.floor(Math.abs(num) / 100) / 10; + } + + return sign * num + unitlist[unit]; +}; + +export const useWindowSize = () => { + // Initialize state with undefined width/height so server and client renders match + // Learn more here: https://joshwcomeau.com/react/the-perils-of-rehydration/ + const [windowSize, setWindowSize] = useState({ + width: undefined, + height: undefined, + }); + useEffect(() => { + // Handler to call on window resize + function handleResize() { + // Set window width/height to state + setWindowSize({ + width: window.innerWidth, + height: window.innerHeight, + }); + } + // Add event listener + window.addEventListener('resize', handleResize); + // Call handler right away so state gets updated with initial window size + handleResize(); + // Remove event listener on cleanup + return () => window.removeEventListener('resize', handleResize); + }, []); // Empty array ensures that effect is only run on mount + return windowSize; +}; + +export const basicEditorToolbar = [ + ['bold', 'italic', 'underline'], // ['strike'] toggled buttons + ['blockquote', 'code-block'], + ['link', 'image'], + ['emoji'], + // [{ header: 1 }, { header: 2 }], // custom button values + [{ list: 'ordered' }, { list: 'bullet' }], + // [{ script: 'sub' }, { script: 'super' }], // superscript/subscript + // [{ indent: '-1' }, { indent: '+1' }], // outdent/indent + // [{ direction: 'rtl' }], // text direction + + // [{ size: ['small', false, 'large', 'huge'] }], // custom dropdown + [{ header: [1, 2, 3, 4, 5, 6, false] }], + + [{ color: [] }, { background: [] }], // dropdown with defaults from theme + [{ font: [] }], + [{ align: [] }], + + // ['clean'], +]; + +export const commentEditorToolbar = [ + ['bold', 'italic', 'underline'], + [{ font: [] }], + ['emoji'], +]; diff --git a/portal/client/src/helpers/sidebar_helpers.js b/portal/client/src/helpers/sidebar_helpers.js new file mode 100644 index 0000000..115370d --- /dev/null +++ b/portal/client/src/helpers/sidebar_helpers.js @@ -0,0 +1,61 @@ +export const primaryLinks = [ + { + linkName: 'dashboard', + iconName: 'tachometer-alt', + }, +]; + +const controlStyle = { + paddingLeft: 15, + backgroundColor: '#6D7E8F', + color: '#fff', + boxShadow: 'none', + border: 'none', +}; + +const commonStyles = { + placeholder: provided => ({ + ...provided, + color: '#ffffff', + }), + input: (provided, st) => ({ + ...provided, + color: '#fff', + }), + singleValue: provided => ({ + ...provided, + color: '#ffffff', + }), + option: (provided, state) => ({ + ...provided, + backgroundColor: state.isSelected ? '#57B8D7' : '#1f252b', + color: '#ffffff', + paddingLeft: 20, + fontSize: 14, + }), + menu: provided => ({ + ...provided, + backgroundColor: '#1f252b', + borderRadius: 15, + overflow: 'hidden', + padding: 0, + }), +}; + +export const customStylesAlt = { + ...commonStyles, + control: (provided, st) => ({ + ...provided, + ...controlStyle, + borderRadius: '30px 0px 0px 30px', + }), +}; + +export const customStyles = { + ...commonStyles, + control: (provided, st) => ({ + ...provided, + ...controlStyle, + borderRadius: 30, + }), +}; diff --git a/portal/client/src/routes/AuthenticatedRoute.jsx b/portal/client/src/routes/AuthenticatedRoute.jsx new file mode 100644 index 0000000..aa1ee0f --- /dev/null +++ b/portal/client/src/routes/AuthenticatedRoute.jsx @@ -0,0 +1,41 @@ +import React from 'react'; + +import { Route } from 'react-router-dom'; +import PropTypes from 'prop-types'; +import { observer } from 'mobx-react'; + +import useStore from '../data/store'; + +export const KEYCLOAK_AUTH_URL = '/users/auth/keycloakopenid'; + +const AuthenticatedRoute = ({ component, path }) => { + const { userStore } = useStore(); + + const Component = component; + + const HandleAuth = () => { + localStorage.setItem( + 'redirectURL', + `${window.location.pathname}${window.location.search}` + ); + + window.location.href = KEYCLOAK_AUTH_URL; + + return null; + }; + + return ( + + ); +}; + +AuthenticatedRoute.propTypes = { + component: PropTypes.func, + path: PropTypes.string, +}; + +export default observer(AuthenticatedRoute); diff --git a/portal/client/src/routes/index.jsx b/portal/client/src/routes/index.jsx new file mode 100644 index 0000000..8157dac --- /dev/null +++ b/portal/client/src/routes/index.jsx @@ -0,0 +1,36 @@ +import React, { useEffect } from 'react'; + +import { Route } from 'react-router-dom'; +import { isEmpty, map } from 'lodash'; +import { observer } from 'mobx-react'; + +import AuthenticatedRoute from './AuthenticatedRoute'; + +import * as allRoutes from './routes'; +import useStore from '../data/store'; + +const AppRoutes = () => { + const { userStore, appStore } = useStore(); + + useEffect(() => { + userStore.fetchCurrentUser().then(() => { + console.log('Fetched user'); + if (userStore.userSignedIn) appStore.startListeningToAccountChannel(); + }); + }, []); + + if (userStore.fetchingCurrentUser) return null; + + return ( + <> + {map(allRoutes.normalRoutes, (r, i) => ( + + ))} + {map(allRoutes.protectedRoutes, (r, i) => ( + + ))} + + ); +}; + +export default observer(AppRoutes); diff --git a/portal/client/src/routes/routes.js b/portal/client/src/routes/routes.js new file mode 100644 index 0000000..7f65bea --- /dev/null +++ b/portal/client/src/routes/routes.js @@ -0,0 +1,5 @@ +import Dashboard from '../views/dashboard'; +import Public from '../views/public'; + +export const normalRoutes = [{ route: '/', component: Public }]; +export const protectedRoutes = [{ route: '/dashboard', component: Dashboard }]; diff --git a/portal/client/src/t.js b/portal/client/src/t.js new file mode 100644 index 0000000..b9c6445 --- /dev/null +++ b/portal/client/src/t.js @@ -0,0 +1,44 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import { isObject, map } from 'lodash'; + +import en from '../../config/locales/en.yml'; + +const checkForDynamicValues = data => { + let updatedData = data; + + if (isObject(data)) { + map(Object.keys(updatedData), k => { + updatedData[k] = checkForDynamicValues(updatedData[k]); + }); + + return updatedData; + } + + updatedData ||= ''; + updatedData = updatedData.replaceAll('%', ''); + updatedData = updatedData.replaceAll('{', '{{'); + updatedData = updatedData.replaceAll('}', '}}'); + + return updatedData; +}; + +const createLocaleData = (data, key) => ({ + translation: checkForDynamicValues(data[key]), +}); + +const resources = { + en: createLocaleData(en, 'en'), +}; + +i18n + .use(initReactI18next) // passes i18n down to react-i18next + .init({ + resources, + lng: 'en', + interpolation: { + escapeValue: false, // react already safes from xss + }, + }); + +export default i18n.t; diff --git a/portal/client/src/utils/Notifier.js b/portal/client/src/utils/Notifier.js new file mode 100644 index 0000000..6c4853d --- /dev/null +++ b/portal/client/src/utils/Notifier.js @@ -0,0 +1,18 @@ +import { store } from 'react-notifications-component'; + +const Notifier = (type, title, message) => + store.addNotification({ + title, + message, + type, + insert: 'top', + container: 'top-right', + animationIn: ['animate__animated', 'animate__fadeIn'], + animationOut: ['animate__animated', 'animate__fadeOut'], + dismiss: { + duration: 5000, + onScreen: true, + }, + }); + +export default Notifier; diff --git a/portal/client/src/utils/needfulMethods.js b/portal/client/src/utils/needfulMethods.js new file mode 100644 index 0000000..52ca112 --- /dev/null +++ b/portal/client/src/utils/needfulMethods.js @@ -0,0 +1,2 @@ +export const getInitials = u => `${u.firstName[0]}${u.lastName[0]}`; +export const getFullName = u => `${u.firstName} ${u.lastName}`; diff --git a/portal/client/src/views/bets/_bets_list.js b/portal/client/src/views/bets/_bets_list.js new file mode 100644 index 0000000..7e22285 --- /dev/null +++ b/portal/client/src/views/bets/_bets_list.js @@ -0,0 +1,84 @@ +import { isEmpty, map } from 'lodash'; +import React from 'react'; +import PropTypes from 'prop-types'; +import { observer } from 'mobx-react'; +import ReactTimeAgo from 'react-time-ago'; +import { Table } from 'reactstrap'; +import moment from 'moment'; + +const BetsList = ({ records }) => { + if (isEmpty(records)) + return

No bets

; + + return ( + + + + + + + + + + + + + + + + + + {map(records, (record, i) => ( + + + + + + + + + + + + + + ))} + +
EventTip Market / Exchange Market InfoTip EVTip OddsExchange OddsExecuted OddsStakeProjected ProfitOutcomeUpdatedType
+ {record.exchangeEventName} +
+ {!isEmpty(record.eventScheduledTime) && ( + <> + + {moment(new Date(record.eventScheduledTime)).format( + 'DD.MMM.YYYY HH:mm:ss' + )} + +
+ + )} + + + {record.exchange}|{record.id} + + +
+ {' '} + {record.marketIdentifier}
+ + {record.exchangeMarketDetails} + +
{record.evPercent} {record.tipProviderOdds} {record.exchangeOdds} {record.executedOdds} {record.stake} {record.expectedValue} + + {record.outcome}{' '} + + + + {record.betPlacementType}
+ ); +}; +BetsList.propTypes = { + records: PropTypes.instanceOf(Array).isRequired, +}; + +export default observer(BetsList); diff --git a/portal/client/src/views/bets/index.js b/portal/client/src/views/bets/index.js new file mode 100644 index 0000000..9b78f0a --- /dev/null +++ b/portal/client/src/views/bets/index.js @@ -0,0 +1,39 @@ +import React, { useEffect, useState } from 'react'; + +import { observer } from 'mobx-react'; +import { Card, CardBody } from 'reactstrap'; + +import PropTypes from 'prop-types'; +import FilteredList from '../modules/filtered_list'; +import useStore from '../../data/store'; +import { betFilters } from '../../helpers/filter_helpers'; +import BetsList from './_bets_list'; + +const BetsIndex = ({ match }) => { + const { betStore } = useStore(); + + useEffect(() => { + if (betStore.fetched) { + console.log('Bets fetched'); + } + }, [betStore.fetched]); + + return ( + + + + + + ); +}; + +BetsIndex.propTypes = { + match: PropTypes.instanceOf(Object).isRequired, +}; + +export default observer(BetsIndex); diff --git a/portal/client/src/views/dashboard/index.js b/portal/client/src/views/dashboard/index.js new file mode 100644 index 0000000..880323a --- /dev/null +++ b/portal/client/src/views/dashboard/index.js @@ -0,0 +1,33 @@ +import React, { useEffect } from 'react'; + +import { Card, CardBody, Col, Row } from 'reactstrap'; +import { observer } from 'mobx-react'; +import ReactTimeAgo from 'react-time-ago'; +import { map } from 'lodash'; +import PublicLayout from '../layouts/public_layout'; +import BetsIndex from '../bets'; +import useStore from '../../data/store'; +import CountdownRefresh from '../modules/countdown_refresh'; +import { SelectTag } from '../shared/form_components'; +import AuthenticatedLayout from '../layouts/authenticate'; + +const Dashboard = () => { + const { appStore } = useStore(); + const renderBody = () => ( + <> + {!appStore.appInitialised &&
Refreshing...
} + {appStore.appInitialised && ( +
+ + + + + +
+ )} + + ); + return {renderBody()}; +}; + +export default observer(Dashboard); diff --git a/portal/client/src/views/layouts/authenticate/_header.js b/portal/client/src/views/layouts/authenticate/_header.js new file mode 100644 index 0000000..de66540 --- /dev/null +++ b/portal/client/src/views/layouts/authenticate/_header.js @@ -0,0 +1,96 @@ +import React from 'react'; + +import { observer } from 'mobx-react'; +import { + Navbar, + Nav, + UncontrolledDropdown, + DropdownToggle, + DropdownMenu, + DropdownItem, + Button, + Row, + Col, +} from 'reactstrap'; +import { Initial } from 'react-initial'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { useHistory } from 'react-router-dom'; +import { isEmpty } from 'lodash'; +import useStore from '../../../data/store'; +import { useWindowSize } from '../../../helpers/shared_helpers'; + +const _header = () => { + const { appStore } = useStore(); + + return ( +
+ +
+ Results Dashboard +
+
+ +
+ Total Risked: £{appStore.totalRiskedAmount} +
+
Total Won: £{appStore.totalWinAmount}
+
Total Lost: £{appStore.totalLostAmount}
+
+ + Total Profit:{' '} + = 0 + ? 'text-success' + : 'text-danger' + } + > + £ + {Math.round(appStore.totalWinAmount - appStore.totalLostAmount)} + + +
+ + +
Total Tips: {appStore.totalTips}
+
Total Tips Executed: {appStore.totalPlacedBets}
+
Total Tips Skipped: {appStore.totalTipsSkipped}
+ + +
+ Total Bets Placed: {appStore.totalPlacedBets} +
+
Total Tips Expired: {appStore.totalTipsExpired}
+
Total Tips Ignored: {appStore.totalTipsIgnored}
+
Total Tips Voided: {appStore.totalTipsVoided}
+ + +
Total Bets Open: {appStore.totalPlacedBetsOpen}
+
+ Total Bets Won: {appStore.totalPlacedBetsWon} + + {' '} + ({appStore.asPercentOfTotal(appStore.totalPlacedBetsWon)}) + +
+
+ Average Odds Won at: {appStore.averageOddsWon} +
+
+ Total Bets Lost: {appStore.totalPlacedBetsLost} + + {' '} + ({appStore.asPercentOfTotal(appStore.totalPlacedBetsLost)}) + +
+
+ Average Odds Lost at: {appStore.averageOddsLost} +
+ +
+
+
+ ); +}; + +export default observer(_header); diff --git a/portal/client/src/views/layouts/authenticate/_sidebar.js b/portal/client/src/views/layouts/authenticate/_sidebar.js new file mode 100644 index 0000000..46f5e03 --- /dev/null +++ b/portal/client/src/views/layouts/authenticate/_sidebar.js @@ -0,0 +1,151 @@ +import React, { useEffect } from 'react'; + +import { Link } from 'react-router-dom'; +import { observer } from 'mobx-react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import ReactTimeAgo from 'react-time-ago'; +import { map } from 'lodash'; +import moment from 'moment'; +import { Form, FormGroup, Input, Label } from 'reactstrap'; +import LOGO from '../../../assets/images/logo.svg'; +import useStore from '../../../data/store'; +import { SelectTag } from '../../shared/form_components'; +import CountdownRefresh from '../../modules/countdown_refresh'; +import EditableHash from '../../modules/editable_hash'; +import Toggler from '../../modules/toggler.js'; + +const _sidebar = () => { + const { appStore } = useStore(); + + useEffect(() => { + appStore.refresh(); + }, []); + + const changeExchangeAccount = e => { + appStore.update({ currentExchangeAccountId: e.target.value }); + appStore.refresh(); + }; + + const exchangeAccountsAsOptions = () => ({ + name: 'exchangeAccounts', + value: appStore.currentExchangeAccountId, + type: 'select', + placeholder: 'Select...', + label: 'Available Accounts', + options: map(appStore.exchangeAccounts, r => ({ label: r, value: r })), + }); + + return ( + <> + {!appStore.appInitialised &&
Refreshing...
} + {appStore.appInitialised && ( +
+
+
+ + logo + +
+
+
+
+
+
Available Accounts:
+ +
+
+ { + appStore.exchangeAccount.updateExchangeAccount({ + isActive: !appStore.exchangeAccount.isActive, + }); + }} + /> +
+
+ { + appStore.exchangeAccount.updateExchangeAccount({ + canBet: !appStore.exchangeAccount.canBet, + }); + }} + /> +
+
+ +
+
+
+
+ Account Balance: £{appStore.exchangeAccount.accountBalance} +
+
+ Running since:{' '} + +
+
+ Feed type: {appStore.exchangeAccount.sourceTypes} +
+
+ Stake Strategy: {appStore.exchangeAccount.stakeStrategyName} +
+
+ Strategy Config: + { + alert('this'); + }} + /> +
+
+
+ + Last log was at{' '} + {moment( + new Date(appStore.exchangeAccount.lastLogTime) + ).format('DD.MMM.YYYY HH:mm:ss')} + +
+ + {appStore.exchangeAccount.log} + +
+
+
+
+ +
+
+ +
+
+
+ )} + ; + + ); +}; + +export default observer(_sidebar); diff --git a/portal/client/src/views/layouts/authenticate/index.js b/portal/client/src/views/layouts/authenticate/index.js new file mode 100644 index 0000000..787f8fe --- /dev/null +++ b/portal/client/src/views/layouts/authenticate/index.js @@ -0,0 +1,44 @@ +import React from 'react'; + +import { observer } from 'mobx-react'; +import PropTypes from 'prop-types'; + +import { Button } from 'reactstrap'; +import classnames from 'classnames'; +import Header from './_header'; +import Sidebar from './_sidebar'; +import useStore from '../../../data/store'; + +const AuthenticatedLayout = ({ children, loading }) => { + const { appStore } = useStore(); + + return ( +
+
+
+ +
+
+
+
{loading ? 'Loading' : children}
+
+
+
+ ); +}; + +AuthenticatedLayout.defaultProps = { + loading: false, +}; + +AuthenticatedLayout.propTypes = { + children: PropTypes.node, + loading: PropTypes.bool, +}; + +export default observer(AuthenticatedLayout); diff --git a/portal/client/src/views/layouts/public_layout.js b/portal/client/src/views/layouts/public_layout.js new file mode 100644 index 0000000..6914a1c --- /dev/null +++ b/portal/client/src/views/layouts/public_layout.js @@ -0,0 +1,40 @@ +import React from 'react'; + +import { observer } from 'mobx-react'; +import PropTypes from 'prop-types'; + +import { Container, Navbar, NavbarBrand } from 'reactstrap'; +import LOGO from '../../assets/images/logo.svg'; +import useStore from '../../data/store'; + +const PublicLayout = ({ children }) => { + const { userStore } = useStore(); + return ( +
+ + + logo + + {userStore.userSignedIn && ( + + )} + + + {children} + +
+ ); +}; + +PublicLayout.propTypes = { + children: PropTypes.node, +}; + +export default observer(PublicLayout); diff --git a/portal/client/src/views/modules/countdown_refresh/index.js b/portal/client/src/views/modules/countdown_refresh/index.js new file mode 100644 index 0000000..0639eab --- /dev/null +++ b/portal/client/src/views/modules/countdown_refresh/index.js @@ -0,0 +1,54 @@ +import React, { useEffect, useState } from 'react'; + +import { observer } from 'mobx-react'; +import { Form, FormGroup, Input, Label } from 'reactstrap'; +import useStore from '../../../data/store'; +import Toggler from '../toggler.js'; + +const CountdownRefresh = () => { + const { appStore } = useStore(); + const refreshTime = 1; + const [countdownToNextRefresh, setCountdownToNextRefresh] = useState( + refreshTime + ); + const [enableCountdown, setEnableCountdown] = useState( + appStore.autoRefreshResults + ); + const [intervalId, setIntervalId] = useState(); + let counter = refreshTime; + const startCountdown = () => { + setIntervalId( + setInterval(() => { + counter -= 1; + if (counter === 0) { + counter = refreshTime; + appStore.refresh(); + } + setCountdownToNextRefresh(counter); + }, 1000) + ); + }; + + useEffect(() => { + if (enableCountdown) { + startCountdown(); + } + }, [enableCountdown]); + + const handleChange = () => { + setEnableCountdown(!enableCountdown); + console.log('Stopping counter refresh'); + clearInterval(intervalId); + }; + + return ( + + ); +}; + +export default observer(CountdownRefresh); diff --git a/portal/client/src/views/modules/editable_hash/index.js b/portal/client/src/views/modules/editable_hash/index.js new file mode 100644 index 0000000..5b7cdf6 --- /dev/null +++ b/portal/client/src/views/modules/editable_hash/index.js @@ -0,0 +1,67 @@ +import React, { useEffect } from 'react'; + +import { observer } from 'mobx-react'; + +import { forEach, includes, isEmpty, isNull } from 'lodash'; +import { Button } from 'reactstrap'; +import PropTypes from 'prop-types'; + +const EditableHash = ({ source, updateFunction }) => { + const editableValues = []; + // + // const editableValues = [ + // 'max_ev', + // 'min_ev', + // 'odds_margin', + // 'max_odds_to_bet', + // 'min_odds_to_bet', + // 'stake_sizing', + // 'max_bankroll_per_bet', + // ]; + + const showOnlyEditable = false; + const divOfValues = hash => { + if (Object.keys(hash).length === 0 || typeof hash === 'string') { + return hash; + } + const x = []; + // forEach(Object.keys(hash), key => { + // if (includes(editableValues, key)){ + // const v = divOfValues(hash[key]) + // const vEntry = + // x.push( + //
{key}: {vEntry}
+ // ); + // } + // }); + + forEach(Object.keys(hash), key => { + const v = divOfValues(hash[key]); + const vEntry = includes(editableValues, key) ? ( + + ) : ( + v + ); + const hideThis = showOnlyEditable && !includes(editableValues, key) + + if (!hideThis) { + x.push( +
+ {key}: {vEntry} +
+ ); + } + }); + return
{x}
; + }; + return ( +
{divOfValues(source)}
+ ); +}; + +EditableHash.propTypes = { + source: PropTypes.instanceOf(Object).isRequired, + updateFunction: PropTypes.func.isRequired, +}; + +export default observer(EditableHash); diff --git a/portal/client/src/views/modules/filtered_list/_filters.js b/portal/client/src/views/modules/filtered_list/_filters.js new file mode 100644 index 0000000..c374ee6 --- /dev/null +++ b/portal/client/src/views/modules/filtered_list/_filters.js @@ -0,0 +1,55 @@ +import React, { Fragment, useState } from 'react'; + +import PropTypes from 'prop-types'; +import { isBoolean, map } from 'lodash'; +import { Col, FormGroup, Label, Row } from 'reactstrap'; +import { observer } from 'mobx-react'; + +import { renderFilter } from '../../../helpers/filter_helpers'; + +const Filters = ({ pagination, filters }) => { + const [timeout, setTimeOut] = useState(null); + + const handleChange = e => { + let timer = null; + + setTimeOut(clearTimeout(timeout)); + + pagination.update({ + params: { ...pagination.params, [e.target.name]: e.target.value }, + }); + + timer = setTimeout(() => { + pagination.resetPageAndFetch(); + }, 1000); + + setTimeOut(timer); + }; + + return ( + + {map(filters, (filter, i) => ( + + {isBoolean(filter.show) && !filter.show ? null : ( + + + + {renderFilter( + { ...filter, value: pagination.params[filter.name] }, + handleChange + )} + + + )} + + ))} + + ); +}; + +Filters.propTypes = { + pagination: PropTypes.instanceOf(Object).isRequired, + filters: PropTypes.instanceOf(Array).isRequired, +}; + +export default observer(Filters); diff --git a/portal/client/src/views/modules/filtered_list/index.js b/portal/client/src/views/modules/filtered_list/index.js new file mode 100644 index 0000000..b48b128 --- /dev/null +++ b/portal/client/src/views/modules/filtered_list/index.js @@ -0,0 +1,111 @@ +import React, { useEffect } from 'react'; + +import { observer } from 'mobx-react'; + +import { isEmpty, isNull } from 'lodash'; +import { Button } from 'reactstrap'; +import PropTypes from 'prop-types'; +import Filters from './_filters'; + +const FilteredList = ({ + store, + listComponent, + gridComponent, + filters, + changeTrigger, + listClassName, + recordClassName, +}) => { + const ListComponent = listComponent; + // const Component = gridComponent; + + useEffect(() => { + store.pagination.fetch(); + }, [changeTrigger]); + + return ( +
+ + + {isEmpty(store.filteredRecords) && ( + <> +
+ Nothing here. + + )} + {!isEmpty(store.filteredRecords) && ( +
+
+
+ +
+
+
+ )} +
+ ); +}; + +FilteredList.defaultProps = { + listClassName: '', + recordClassName: '', +}; + +FilteredList.propTypes = { + store: PropTypes.instanceOf(Object).isRequired, + listComponent: PropTypes.func.isRequired, + gridComponent: PropTypes.func, + filters: PropTypes.instanceOf(Array).isRequired, + changeTrigger: PropTypes.string.isRequired, + listClassName: PropTypes.string, + recordClassName: PropTypes.string, +}; + +export default observer(FilteredList); diff --git a/portal/client/src/views/modules/toggler.js/index.js b/portal/client/src/views/modules/toggler.js/index.js new file mode 100644 index 0000000..bca7e69 --- /dev/null +++ b/portal/client/src/views/modules/toggler.js/index.js @@ -0,0 +1,58 @@ +import React from 'react'; + +import { observer } from 'mobx-react'; +import { Form, FormGroup, Input, Label } from 'reactstrap'; +import PropTypes from 'prop-types'; + +const Toggler = ({ + prompt, + disabled, + state, + onText, + offText, + onChangeHandler, +}) => { + const handleChange = () => { + if (prompt) { + // eslint-disable-next-line no-restricted-globals + if (confirm('Are you sure?') === false) return false; + } + onChangeHandler(); + }; + + return ( +
+ + + + +
+ ); +}; + +Toggler.defaultProps = { + disabled: false, + prompt: false, +}; + +Toggler.propTypes = { + disabled: PropTypes.bool, + prompt: PropTypes.bool, + state: PropTypes.bool.isRequired, + onText: PropTypes.string.isRequired, + offText: PropTypes.string.isRequired, + onChangeHandler: PropTypes.func.isRequired, +}; +export default observer(Toggler); diff --git a/portal/client/src/views/public/index.js b/portal/client/src/views/public/index.js new file mode 100644 index 0000000..ee2c1ba --- /dev/null +++ b/portal/client/src/views/public/index.js @@ -0,0 +1,47 @@ +import React from 'react'; + +import { Button } from 'reactstrap'; +import { observer } from 'mobx-react'; +import { Link } from 'react-router-dom'; +import useStore from '../../data/store'; +import { KEYCLOAK_AUTH_URL } from '../../routes/AuthenticatedRoute'; +import PublicLayout from '../layouts/public_layout'; + +const Public = () => { + const { userStore } = useStore(); + + const handleSignInSignUp = () => { + localStorage.setItem( + 'redirectURL', + `${window.location.pathname}${window.location.search}` + ); + + window.location.href = KEYCLOAK_AUTH_URL; + }; + + const handleSignOut = () => { + window.location.href = '/sign_out'; + }; + + const renderBody = () => ( +
+
Welcome to BetBeast.
+
+ {userStore.userSignedIn && ( +
+ + Go to Dashboard + + +
+ )} + {!userStore.userSignedIn && ( + + )} +
+
+ ); + return {renderBody()}; +}; + +export default observer(Public); diff --git a/portal/client/src/views/shared/form_components.js b/portal/client/src/views/shared/form_components.js new file mode 100644 index 0000000..b1cbdbb --- /dev/null +++ b/portal/client/src/views/shared/form_components.js @@ -0,0 +1,182 @@ +import React, { useState } from 'react'; + +import { Button, Input, Popover, PopoverBody } from 'reactstrap'; +import PropTypes from 'prop-types'; +import { find, filter, isDate, isObject, map, includes } from 'lodash'; +import { DateRangePicker } from 'react-date-range'; +import InputRange from 'react-input-range'; +import Select from 'react-select'; +import moment from 'moment'; + +export const SearchInput = ({ input, handleChange }) => ( + +); + +export const SelectTag = ({ input, handleChange }) => ( + + {map(input.options, (opt, i) => ( + + ))} + +); + +export const DateRange = ({ input, handleChange }) => { + const [isOpen, setIsOpen] = useState(false); + + const togglePopover = () => setIsOpen(!isOpen); + + const dateLabel = () => { + if (!isDate(input.value.from) && !isDate(input.value.to)) + return input.placeholder; + + const startDate = isDate(input.value.from) + ? moment(input.value.from).format('MMM/DD/YYYY') + : 'NA'; + + const endDate = isDate(input.value.to) + ? moment(input.value.to).format('MMM/DD/YYYY') + : 'NA'; + + return `${startDate} - ${endDate}`; + }; + + return ( +
+ + {isDate(input.value.from) && ( + + )} + + + + handleChange({ + target: { + name: input.name, + value: { + from: d[input.name].startDate, + to: d[input.name].endDate, + }, + }, + }) + } + /> + + +
+ ); +}; + +export const InputRangeTag = ({ input, handleChange }) => ( + + handleChange({ + target: { + name: input.name, + value, + }, + }) + } + /> +); + +export const ReactSelectTag = ({ input, handleChange }) => ( +