diff --git a/.credo.exs b/.credo.exs deleted file mode 100644 index 4cb262308..000000000 --- a/.credo.exs +++ /dev/null @@ -1,216 +0,0 @@ -# This file contains the configuration for Credo and you are probably reading -# this after creating it with `mix credo.gen.config`. -# -# If you find anything wrong or unclear in this file, please report an -# issue on GitHub: https://github.com/rrrene/credo/issues -# -%{ - # - # You can have as many configs as you like in the `configs:` field. - configs: [ - %{ - # - # Run any config using `mix credo -C `. If no config name is given - # "default" is used. - # - name: "default", - # - # These are the files included in the analysis: - files: %{ - # - # You can give explicit globs or simply directories. - # In the latter case `**/*.{ex,exs}` will be used. - # - included: [ - "lib/", - "src/", - "test/", - "web/", - "apps/*/lib/", - "apps/*/src/", - "apps/*/test/", - "apps/*/web/" - ], - excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] - }, - # - # Load and configure plugins here: - # - plugins: [], - # - # If you create your own checks, you must specify the source files for - # them here, so they can be loaded by Credo before running the analysis. - # - requires: [], - # - # If you want to enforce a style guide and need a more traditional linting - # experience, you can change `strict` to `true` below: - # - strict: false, - # - # To modify the timeout for parsing files, change this value: - # - parse_timeout: 5000, - # - # If you want to use uncolored output by default, you can change `color` - # to `false` below: - # - color: true, - # - # You can customize the parameters of any check by adding a second element - # to the tuple. - # - # To disable a check put `false` as second element: - # - # {Credo.Check.Design.DuplicatedCode, false} - # - checks: %{ - enabled: [ - # - ## Consistency Checks - # - {Credo.Check.Consistency.ExceptionNames, []}, - {Credo.Check.Consistency.LineEndings, []}, - {Credo.Check.Consistency.ParameterPatternMatching, []}, - {Credo.Check.Consistency.SpaceAroundOperators, []}, - {Credo.Check.Consistency.SpaceInParentheses, []}, - {Credo.Check.Consistency.TabsOrSpaces, []}, - - # - ## Design Checks - # - # You can customize the priority of any check - # Priority values are: `low, normal, high, higher` - # - {Credo.Check.Design.AliasUsage, [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, - {Credo.Check.Design.TagFIXME, []}, - # You can also customize the exit_status of each check. - # If you don't want TODO comments to cause `mix credo` to fail, just - # set this value to 0 (zero). - # - {Credo.Check.Design.TagTODO, [exit_status: 0]}, - - # - ## Readability Checks - # - {Credo.Check.Readability.AliasOrder, []}, - {Credo.Check.Readability.FunctionNames, []}, - {Credo.Check.Readability.LargeNumbers, []}, - {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, - {Credo.Check.Readability.ModuleAttributeNames, []}, - # {Credo.Check.Readability.ModuleDoc, []}, - {Credo.Check.Readability.ModuleNames, []}, - {Credo.Check.Readability.ParenthesesInCondition, []}, - {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, - {Credo.Check.Readability.PipeIntoAnonymousFunctions, []}, - {Credo.Check.Readability.PredicateFunctionNames, []}, - {Credo.Check.Readability.PreferImplicitTry, []}, - {Credo.Check.Readability.RedundantBlankLines, []}, - {Credo.Check.Readability.Semicolons, []}, - {Credo.Check.Readability.SpaceAfterCommas, []}, - {Credo.Check.Readability.StringSigils, []}, - {Credo.Check.Readability.TrailingBlankLine, []}, - {Credo.Check.Readability.TrailingWhiteSpace, []}, - {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, - {Credo.Check.Readability.VariableNames, []}, - {Credo.Check.Readability.WithSingleClause, []}, - - # - ## Refactoring Opportunities - # - {Credo.Check.Refactor.Apply, []}, - {Credo.Check.Refactor.CondStatements, []}, - {Credo.Check.Refactor.CyclomaticComplexity, []}, - {Credo.Check.Refactor.FilterCount, []}, - {Credo.Check.Refactor.FilterFilter, []}, - {Credo.Check.Refactor.FunctionArity, []}, - {Credo.Check.Refactor.LongQuoteBlocks, []}, - {Credo.Check.Refactor.MapJoin, []}, - {Credo.Check.Refactor.MatchInCondition, []}, - {Credo.Check.Refactor.NegatedConditionsInUnless, []}, - {Credo.Check.Refactor.NegatedConditionsWithElse, []}, - {Credo.Check.Refactor.Nesting, []}, - {Credo.Check.Refactor.RedundantWithClauseResult, []}, - {Credo.Check.Refactor.RejectReject, []}, - {Credo.Check.Refactor.UnlessWithElse, []}, - {Credo.Check.Refactor.WithClauses, []}, - - # - ## Warnings - # - {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, - {Credo.Check.Warning.BoolOperationOnSameValues, []}, - {Credo.Check.Warning.Dbg, []}, - {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, - {Credo.Check.Warning.IExPry, []}, - {Credo.Check.Warning.IoInspect, []}, - {Credo.Check.Warning.MissedMetadataKeyInLoggerConfig, []}, - {Credo.Check.Warning.OperationOnSameValues, []}, - {Credo.Check.Warning.OperationWithConstantResult, []}, - {Credo.Check.Warning.RaiseInsideRescue, []}, - {Credo.Check.Warning.SpecWithStruct, []}, - {Credo.Check.Warning.UnsafeExec, []}, - {Credo.Check.Warning.UnusedEnumOperation, []}, - {Credo.Check.Warning.UnusedFileOperation, []}, - {Credo.Check.Warning.UnusedKeywordOperation, []}, - {Credo.Check.Warning.UnusedListOperation, []}, - {Credo.Check.Warning.UnusedPathOperation, []}, - {Credo.Check.Warning.UnusedRegexOperation, []}, - {Credo.Check.Warning.UnusedStringOperation, []}, - {Credo.Check.Warning.UnusedTupleOperation, []} - # {Credo.Check.Warning.WrongTestFileExtension, []} - ], - disabled: [ - # - # Checks scheduled for next check update (opt-in for now) - {Credo.Check.Refactor.UtcNowTruncate, []}, - - # - # Controversial and experimental checks (opt-in, just move the check to `:enabled` - # and be sure to use `mix credo --strict` to see low priority checks) - # - {Credo.Check.Consistency.MultiAliasImportRequireUse, []}, - {Credo.Check.Consistency.UnusedVariableNames, []}, - {Credo.Check.Design.DuplicatedCode, []}, - {Credo.Check.Design.SkipTestWithoutComment, []}, - {Credo.Check.Readability.AliasAs, []}, - {Credo.Check.Readability.BlockPipe, []}, - {Credo.Check.Readability.ImplTrue, []}, - {Credo.Check.Readability.MultiAlias, []}, - {Credo.Check.Readability.NestedFunctionCalls, []}, - {Credo.Check.Readability.OneArityFunctionInPipe, []}, - {Credo.Check.Readability.OnePipePerLine, []}, - {Credo.Check.Readability.SeparateAliasRequire, []}, - {Credo.Check.Readability.SingleFunctionToBlockPipe, []}, - {Credo.Check.Readability.SinglePipe, []}, - {Credo.Check.Readability.Specs, []}, - {Credo.Check.Readability.StrictModuleLayout, []}, - {Credo.Check.Readability.WithCustomTaggedTuple, []}, - {Credo.Check.Refactor.ABCSize, []}, - {Credo.Check.Refactor.AppendSingleItem, []}, - {Credo.Check.Refactor.DoubleBooleanNegation, []}, - {Credo.Check.Refactor.FilterReject, []}, - {Credo.Check.Refactor.IoPuts, []}, - {Credo.Check.Refactor.MapMap, []}, - {Credo.Check.Refactor.ModuleDependencies, []}, - {Credo.Check.Refactor.NegatedIsNil, []}, - {Credo.Check.Refactor.PassAsyncInTestCases, []}, - {Credo.Check.Refactor.PipeChainStart, []}, - {Credo.Check.Refactor.RejectFilter, []}, - {Credo.Check.Refactor.VariableRebinding, []}, - {Credo.Check.Warning.LazyLogging, []}, - {Credo.Check.Warning.LeakyEnvironment, []}, - {Credo.Check.Warning.MapGetUnsafePass, []}, - {Credo.Check.Warning.MixEnv, []}, - {Credo.Check.Warning.UnsafeToAtom, []} - - # {Credo.Check.Refactor.MapInto, []}, - - # - # Custom checks can be created using `mix credo.gen.check`. - # - ] - } - } - ] -} diff --git a/.cursor/mcp.json b/.cursor/mcp.json deleted file mode 100644 index 22db8a19a..000000000 --- a/.cursor/mcp.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "mcpServers": { - "tidewave": { - "command": "/usr/local/bin/mcp-proxy", - "args": ["http://localhost:4000/tidewave/mcp"] - } - } -} diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs deleted file mode 100644 index a6dba8f00..000000000 --- a/.dialyzer_ignore.exs +++ /dev/null @@ -1,9 +0,0 @@ -[ - ~r/Mix.Task behaviour is not available/, - {"lib/algora/contracts/contracts.ex", :pattern_match}, - # ExUnit is not available in the PLT when running dialyzer with MIX_ENV=dev - {"test/support/conn_case.ex", :unknown_function}, - {"test/support/data_case.ex", :unknown_function}, - # Money.Ecto.Composite.Type.t/0 is defined but not exported for dialyzer - {"lib/algora/jobs/schemas/job_posting.ex", :unknown_type} -] diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index afa2c515a..000000000 --- a/.dockerignore +++ /dev/null @@ -1,37 +0,0 @@ -.dockerignore -# there are valid reasons to keep the .git, namely so that you can get the -# current commit hash -#.git -.log -tmp - -# Mix artifacts -_build -deps -*.ez -releases - -# Generate on crash by the VM -erl_crash.dump - -# Static artifacts -node_modules - -# Env files -.env -.env*.local -.env.dev -.env.prod -.env.staging - -# Local files -/tmp -/.local -/priv/cache -*.patch -/.fly -/xref* -/priv/domain_blacklist.txt -/priv/plts -/priv/db -/priv/puppeteer diff --git a/.env.example b/.env.example deleted file mode 100644 index 8f0cc6d91..000000000 --- a/.env.example +++ /dev/null @@ -1,44 +0,0 @@ -PORT=4000 -LOG_LEVEL=info - -DATABASE_URL="postgresql://postgres:postgres@localhost:15432/algora_dev" -TEST_DATABASE_URL="postgresql://postgres:postgres@localhost:15432/algora_test" - -GITHUB_CLIENT_ID="" -GITHUB_CLIENT_SECRET="" -GITHUB_APP_HANDLE="" -GITHUB_APP_ID="" -GITHUB_WEBHOOK_SECRET="" -GITHUB_PRIVATE_KEY="" -GITHUB_OAUTH_STATE_TTL=600 -GITHUB_OAUTH_STATE_SALT="github-oauth-state" - -STRIPE_PUBLISHABLE_KEY="" -STRIPE_SECRET_KEY="" -STRIPE_WEBHOOK_SECRET="" - -AWS_ENDPOINT_URL_S3="" -AWS_REGION="" -AWS_ACCESS_KEY_ID="" -AWS_SECRET_ACCESS_KEY="" -BUCKET_NAME="" - -APPSIGNAL_OTP_APP="algora" -APPSIGNAL_PUSH_API_KEY="00000000-0000-0000-0000-000000000000" -APPSIGNAL_APP_NAME="AlgoraConsole" -APPSIGNAL_APP_ENV="dev" - -SENDGRID_API_KEY="SG.x.x" - -LOGIN_CODE_TTL=600 -LOGIN_CODE_SALT="algora-login-code" -LOCAL_STORE_TTL=3600 -LOCAL_STORE_SALT="algora-local-store" - -CLOUDFLARE_TUNNEL="" -ASSETS_URL="" -INGEST_URL="" -INGEST_STATIC_URL="" -INGEST_TOKEN="" -PLAUSIBLE_EMBED_URL="" -DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/1234567890/x" \ No newline at end of file diff --git a/.formatter.exs b/.formatter.exs deleted file mode 100644 index d487aae7d..000000000 --- a/.formatter.exs +++ /dev/null @@ -1,6 +0,0 @@ -[ - import_deps: [:ecto, :ecto_sql, :phoenix], - subdirectories: ["priv/*/migrations", "config"], - plugins: [Phoenix.LiveView.HTMLFormatter, Styler], - inputs: ["*.{heex,ex,exs}", "{scripts,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"] -] diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml deleted file mode 100644 index dc609b99e..000000000 --- a/.github/workflows/elixir.yml +++ /dev/null @@ -1,107 +0,0 @@ -name: Elixir CI - -on: - push: - branches: ["main"] - pull_request: - branches: ["main"] - -env: - MIX_ENV: test - TEST_DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/postgres" - -permissions: - contents: read - -jobs: - test: - services: - db: - image: postgres:12 - ports: ["5432:5432"] - env: - POSTGRES_PASSWORD: postgres - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - - runs-on: ubuntu-latest - name: Test on OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} - strategy: - matrix: - otp: ["27.2"] - elixir: ["1.18.1"] - steps: - - name: Set up Elixir - uses: erlef/setup-beam@v1 - with: - otp-version: ${{matrix.otp}} - elixir-version: ${{matrix.elixir}} - - - name: Checkout code - uses: actions/checkout@v3 - - - name: Cache deps - id: cache-deps - uses: actions/cache@v3 - env: - cache-name: cache-elixir-deps - with: - path: deps - key: ${{ runner.os }}-mix-${{ env.cache-name }}-${{ hashFiles('**/mix.lock') }} - restore-keys: | - ${{ runner.os }}-mix-${{ env.cache-name }}- - - - name: Cache compiled build - id: cache-build - uses: actions/cache@v3 - env: - cache-name: cache-compiled-build - with: - path: _build - key: ${{ runner.os }}-mix-${{ env.cache-name }}-${{ hashFiles('**/mix.lock') }} - restore-keys: | - ${{ runner.os }}-mix-${{ env.cache-name }}- - ${{ runner.os }}-mix- - - - name: Install dependencies - run: mix deps.get - - - name: Compile without warnings - run: mix compile --warnings-as-errors - - # - name: Check formatting - # run: mix format --check-formatted - - - name: Run tests - run: mix test - - - name: Restore PLT cache - id: plt_cache - uses: actions/cache/restore@v3 - with: - key: | - plt-${{ runner.os }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}-${{ hashFiles('**/mix.lock') }} - restore-keys: | - plt-${{ runner.os }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}- - path: | - priv/plts - - - name: Create PLTs - if: steps.plt_cache.outputs.cache-hit != 'true' - run: MIX_ENV=dev mix dialyzer --plt - - - name: Save PLT cache - id: plt_cache_save - uses: actions/cache/save@v3 - if: steps.plt_cache.outputs.cache-hit != 'true' - with: - key: | - plt-${{ runner.os }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}-${{ hashFiles('**/mix.lock') }} - path: | - priv/plts - - - name: Run dialyzer - run: MIX_ENV=dev mix dialyzer --format github --format dialyxir diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 29ae5a54f..000000000 --- a/.gitignore +++ /dev/null @@ -1,67 +0,0 @@ -# The directory Mix will write compiled artifacts to. -/_build/ - -# If you run "mix test --cover", coverage assets end up here. -/cover/ - -# The directory Mix downloads your dependencies sources to. -/deps/ - -# Where 3rd-party dependencies like ExDoc output generated docs. -/doc/ - -# Ignore .fetch files in case you like to edit your project deps locally. -/.fetch - -# If the VM crashes, it generates a dump, let's ignore it too. -erl_crash.dump - -# Also ignore archive artifacts (built via "mix archive.build"). -*.ez - -# Temporary files, for example, from tests. -/tmp/ - -# Ignore package tarball (built via "mix hex.build"). -algora-*.tar - -# Ignore assets that are produced by build tools. -/priv/static/assets/ -/priv/puppeteer - -# Ignore digested assets cache. -/priv/static/cache_manifest.json - -# Ignore svelte compiled files -/priv/svelte/ - -# In case you use Node.js/npm, you want to ignore these. -npm-debug.log -/assets/node_modules/ - -# Ignore env files -.env -.env*.local -.env.dev -.env.prod -.env.staging - -# Ignore local files -/tmp -/.local -/priv/cache -*.patch -/.fly -/xref* -/priv/domain_blacklist.txt -/priv/plts -/priv/db -/priv/github -/priv/migration -/priv/dev -/lib/algora_cloud* -/test/algora_cloud* -CLAUDE.md -/.claude/agents -/.cursor/settings.json -/.playwright-mcp \ No newline at end of file diff --git a/.iex.exs b/.iex.exs deleted file mode 100644 index 0bd959786..000000000 --- a/.iex.exs +++ /dev/null @@ -1,47 +0,0 @@ -import Ecto.Changeset -import Ecto.Query -import Money.Sigil - -alias Algora.Accounts -alias Algora.Accounts.Identity -alias Algora.Accounts.User -alias Algora.Analytics -alias Algora.Bounties -alias Algora.Bounties.Bounty -alias Algora.Bounties.Claim -alias Algora.Bounties.Tip -alias Algora.Contracts -alias Algora.Contracts.Contract -alias Algora.Contracts.Timesheet -alias Algora.Github -alias Algora.Jobs -alias Algora.Jobs.JobApplication -alias Algora.Jobs.JobPosting -alias Algora.Organizations -alias Algora.Organizations.Member -alias Algora.Payments -alias Algora.Payments.Account -alias Algora.Payments.Customer -alias Algora.Payments.PaymentMethod -alias Algora.Payments.Transaction -alias Algora.Repo -alias Algora.Settings -alias Algora.Util -alias Algora.Workspace -alias Algora.Workspace.Installation -alias Algora.Workspace.Repository -alias Algora.Workspace.Ticket - -IEx.configure(inspect: [charlists: :as_lists, limit: :infinity], auto_reload: true) - -defmodule Helpers do - @moduledoc false - def get_abstract_code(module) do - f = ~c"./_build/dev/lib/algora/ebin/#{module}.beam" - result = :beam_lib.chunks(f, [:abstract_code]) - {:ok, {_, [{:abstract_code, {_, ac}}]}} = result - code = :erl_prettypr.format(:erl_syntax.form_list(ac)) - IO.puts(code) - File.write!(~c".local/#{module}.erl", code) - end -end diff --git a/.tool-versions b/.tool-versions deleted file mode 100644 index cce92ceb1..000000000 --- a/.tool-versions +++ /dev/null @@ -1,4 +0,0 @@ -erlang 27.2 -elixir 1.18.1-otp-27 -pnpm 8.15.9 -nodejs 22.16.0 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index df123cdff..000000000 --- a/Dockerfile +++ /dev/null @@ -1,112 +0,0 @@ -# Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian -# instead of Alpine to avoid DNS resolution issues in production. -# -# https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu -# https://hub.docker.com/_/ubuntu?tab=tags -# -# This file is based on these images: -# -# - https://hub.docker.com/r/hexpm/elixir/tags - for the build image -# - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20240130-slim - for the release image -# - https://pkgs.org/ - resource for finding needed packages -# - Ex: hexpm/elixir:1.18.1-erlang-27.2-debian-bookworm-20241223-slim -# -ARG ELIXIR_VERSION=1.18.1 -ARG OTP_VERSION=27.2 -ARG DEBIAN_VERSION=bookworm-20241223-slim -ARG NODE_VERSION=23-bookworm-slim - -ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}" -ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}" -ARG NODE_IMAGE="node:${NODE_VERSION}" - -FROM ${NODE_IMAGE} as node_stage - -ENV PUPPETEER_CACHE_DIR="/app/puppeteer" - -RUN npx --yes puppeteer browsers install chrome@134.0.6998.35 - -FROM ${BUILDER_IMAGE} as builder - -# install build dependencies -RUN apt-get update -y && apt-get install -y build-essential git \ - && apt-get clean && rm -f /var/lib/apt/lists/*_* - -# prepare build dir -WORKDIR /app - -# install hex + rebar -RUN mix local.hex --force && \ - mix local.rebar --force - -# set build ENV -ENV MIX_ENV="prod" - -# install mix dependencies -COPY mix.exs mix.lock ./ -RUN mix deps.get --only $MIX_ENV -RUN mkdir config - -# copy compile-time config files before we compile dependencies -# to ensure any relevant config change will trigger the dependencies -# to be re-compiled. -COPY config/config.exs config/${MIX_ENV}.exs config/ -RUN mix deps.compile - -COPY priv priv - -COPY lib lib - -COPY assets assets - -COPY --from=node_stage /usr/local/bin /usr/local/bin - -# compile assets -RUN mix assets.deploy - -# Compile the release -RUN mix compile - -# Changes to config/runtime.exs don't require recompiling the code -COPY config/runtime.exs config/ - -COPY rel rel -RUN mix release - -# start a new build stage so that the final image will only contain -# the compiled release and other runtime necessities -FROM ${RUNNER_IMAGE} - -# Copy the puppeteer cache from the node stage -COPY --from=node_stage --chown=nobody:root /app/puppeteer /app/puppeteer - -COPY --from=node_stage /usr/local/bin /usr/local/bin - -RUN apt-get update -y && \ - apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates fonts-liberation fonts-noto-color-emoji fonts-noto-cjk fonts-dejavu fonts-freefont-ttf libasound2 libatk-bridge2.0-0 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgbm1 libgcc1 libglib2.0-0 libgtk-3-0 libnspr4 libnss3 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 lsb-release wget xdg-utils \ - && apt-get clean && rm -f /var/lib/apt/lists/*_* - -# Set the locale -RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen - -ENV LANG en_US.UTF-8 -ENV LANGUAGE en_US:en -ENV LC_ALL en_US.UTF-8 - -WORKDIR "/app" -RUN chown nobody /app - -# set runner ENV -ENV MIX_ENV="prod" - -# Copy the final release from the build stage -COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/algora ./ - -USER nobody - -# If using an environment that doesn't automatically reap zombie processes, it is -# advised to add an init process such as tini via `apt-get install` -# above and adding an entrypoint. See https://github.com/krallin/tini for details -# ENTRYPOINT ["/tini", "--"] - -CMD ["/app/bin/server"] \ No newline at end of file diff --git a/LICENSE b/LICENSE deleted file mode 100644 index cd4244941..000000000 --- a/LICENSE +++ /dev/null @@ -1,663 +0,0 @@ -Copyright (c) 2024-present Algora, PBC. - - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -our General Public Licenses are intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. - - A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. - - The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. - - An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing under -this license. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU Affero General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Remote Network Interaction; Use with the GNU General Public License. - - Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your version -supports such interaction) an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source -from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source -shall include the Corresponding Source for any work covered by version 3 -of the GNU General Public License that is incorporated pursuant to the -following paragraph. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the work with which it is combined will remain governed by version -3 of the GNU General Public License. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU Affero General Public License from time to time. Such new versions -will be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU Affero General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU Affero General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU Affero General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published - by the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If your software can interact with users remotely through a computer -network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its -interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different -solutions will be better for different programs; see section 13 for the -specific requirements. - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU AGPL, see -. diff --git a/Makefile b/Makefile deleted file mode 100644 index ec8c542af..000000000 --- a/Makefile +++ /dev/null @@ -1,22 +0,0 @@ -.PHONY: help install server watch postgres postgres-rm psql - -help: - @perl -nle'print $& if m{^[a-zA-Z_-]+:.*?## .*$$}' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - -install: ## Run the initial setup - mix setup - -server: ## Start the web server - iex -S mix phx.server - -watch: ## Recompile on file changes - find lib/ | entr mix compile - -postgres: ## Start a container with latest postgres - docker run --detach -e POSTGRES_PASSWORD="postgres" -p 15432:5432 --name algora_db --volume=algora_db:/var/lib/postgresql/data postgres:latest - -postgres-rm: ## Stop and remove the postgres container - docker stop algora_db && docker rm algora_db - -psql: ## Connect to postgres - docker exec -it algora_db psql -U postgres -d algora_dev \ No newline at end of file diff --git a/README.md b/README.md deleted file mode 100644 index 752f15e08..000000000 --- a/README.md +++ /dev/null @@ -1,269 +0,0 @@ - -

- - Homepage - - -

Algora

- -

- Hire the top 1% open source engineers. -
- Learn more » -
-
- Website - · - Twitter - · - YouTube - · - Issues -

- -

- Checks - License - Hacker News -

- -Algora connects **companies** and **developers** for full-time and contract work. - -This codebase consists of the following: - -- a **web app** to publish & manage SWE jobs, contracts & bounties -- a **GitHub app** to create bounties & reward tips on issues and PRs -- a **payment processor** to handle payouts, compliance & 1099s - -OSS communities and closed source teams can **self-host** or join **[Algora.io](https://algora.io)** to accomplish the following: - -| Use with your | Benefit | -| ------------------------- | ------------------------------------------------- | -| **open source community** | reward open source contributors & maintainers | -| **contractors** | manage work and complete outcome based payments | -| **job candidates** | collaborate on paid projects for interviews | -| **teammates** | run an internal bounty program for fun and profit | - -**[Algora.io](https://algora.io)**, hosted by Algora Public Benefit Corporation, extends functionalities including: - -- developers' top OSS contributions are automatically displayed on their Algora profiles -- companies' job applicants are automatically screened & ranked for OSS contributions -- companies and developers are automatically matched for full-time & contract work based on tech/budget/location preferences - -**[Algora.io](https://algora.io)** is a complete solution for sourcing, screening, interviewing & onboarding engineers to your team. - -| Hiring process | Benefit | -| ---------------- | ------------------------------------------------ | -| **sourcing** | publish jobs to 50K+ developers, access matches | -| **screening** | auto screen job applicants for OSS contributions | -| **interviewing** | trial your candidates using bounties & contracts | -| **onboarding** | contribute-first hires are productive on day 1 | - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- User Dashboard - - Organization Dashboard -
- Global Payments - - Pending Payments -
- Bounty Board - - Crowdfund -
- User Profile - - Embed Profile -
- Create Custom Bounty - - View Custom Bounty -
- Create Tip Step 1 - - Create Tip Step 2 -
- - - -## Roadmap & community requests - -- Profile Embed - - Embeddable profile for GitHub and personal websites - - One-click bounty and contract sharing -- Apply Embed - - One-click apply embed for careers pages -- New payment/payout options - - Alipay, Wise, crypto etc. -- Localization of platform & matches -- New workflow integrations - - GitLab, Linear, Plane, Cursor etc. -- New clients - - Mobile, desktop, CLI -- Crowdfunding enhancements - - - -## Getting Started - -### Prerequisites - -The easiest way to get up and running is to [install](https://docs.docker.com/get-docker/) and use Docker for running Postgres. - -Make sure Docker, Elixir, Erlang and Node.js are all installed on your development machine. You can install Elixir and Erlang/OTP with [ASDF](https://asdf-vm.com/) from the project root as follows: - -1. [Install ASDF](https://asdf-vm.com/guide/getting-started.html) -2. `asdf plugin add erlang` -3. `asdf plugin add elixir` -4. `asdf plugin add pnpm` -5. `asdf plugin add nodejs` -6. `asdf install` - -We also recommend using [direnv](https://github.com/direnv/direnv) to load environment variables and [entr](https://github.com/eradman/entr) to watch for file changes. - -### Setting up the project - -1. Clone the repo and go to the project folder - - ```sh - git clone git@github.com:algora-io/algora.git && cd algora - ``` - -2. Initialize and load `.env` - - ```sh - cp .env.example .env && direnv allow .env - ``` - -3. Start a container with latest postgres - - ```sh - make postgres - ``` - -4. Install and setup dependencies - - ```sh - make install - ``` - -5. Start the web server inside IEx - - ```sh - make server - ``` - -6. (Optional) Watch for file changes and auto reload IEx shell in a separate terminal - - ```sh - make watch - ``` - -### Setting up external services - -Some features of Algora rely on external services. If you're not planning on using these features, feel free to skip setting them up. - -#### GitHub - -[Register new GitHub app](https://github.com/settings/apps/new) and set - -- Homepage URL: http://localhost:4000 -- Callback URL: http://localhost:4000/callbacks/github/oauth -- Setup URL: http://localhost:4000/callbacks/github/installation -- Redirect on update: Yes -- Webhook URL: https://[your-public-proxy]/webhooks/github (e.g. ngrok, Cloudflare Tunnel) -- Secret: [generate new random string] -- Permissions: - - Read & write issues - - Read & write pull requests - - Read account email address -- Events: issues, pull requests, issue comment, pull request review, pull request review comment - -Once you have obtained your client ID and secret, add them to your `.env` file and run `direnv allow .env`. - -```env -GITHUB_CLIENT_ID="" -GITHUB_CLIENT_SECRET="" -GITHUB_APP_HANDLE="" -GITHUB_APP_ID="" -GITHUB_WEBHOOK_SECRET="" -GITHUB_PRIVATE_KEY="" -``` - -#### Stripe - -[Create new Stripe account](https://dashboard.stripe.com/register) to obtain your secrets and add them to your `.env` file. - -```env -STRIPE_PUBLISHABLE_KEY="" -STRIPE_SECRET_KEY="" -STRIPE_WEBHOOK_SECRET="" -``` - -#### Object Storage - -To host static assets, set up a public bucket on your preferred S3-compatible storage service and add the following credentials to your `.env` file: - -```env -AWS_ENDPOINT_URL_S3="" -AWS_REGION="" -AWS_ACCESS_KEY_ID="" -AWS_SECRET_ACCESS_KEY="" -BUCKET_NAME="" -``` - -#### Tunnel - -To receive webhooks from GitHub or Stripe on your local machine, you'll need a way to expose your local server to the internet. The easiest way is to use a service like [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) or [ngrok](https://ngrok.com/). - -If you'd like to utilize our Cloudflare Tunnel [GenServer](https://github.com/algora-io/algora/blob/main/lib/algora/integrations/tunnel.ex) to automatically run a tunnel when you start the app, you'll need to set up a named tunnel on your own domain: - -```sh -cloudflared tunnel login -cloudflared tunnel create local -cloudflared tunnel route dns local http://local.yourdomain.com -``` - -And then add it to your `.env` file: - -```env -CLOUDFLARE_TUNNEL="local" -``` - -If you're using another service, make sure to start the tunnel manually in another terminal. diff --git a/assets/build.js b/assets/build.js deleted file mode 100644 index 7861b95b0..000000000 --- a/assets/build.js +++ /dev/null @@ -1,82 +0,0 @@ -const esbuild = require("esbuild"); -const sveltePlugin = require("esbuild-svelte"); -const importGlobPlugin = require("esbuild-plugin-import-glob").default; -const sveltePreprocess = require("svelte-preprocess"); - -const args = process.argv.slice(2); -const watch = args.includes("--watch"); -const deploy = args.includes("--deploy"); - -let optsClient = { - entryPoints: ["js/app.ts"], - bundle: true, - minify: deploy, - target: "es2017", - conditions: ["svelte", "browser"], - outdir: "../priv/static/assets", - logLevel: "info", - sourcemap: watch ? "inline" : false, - tsconfig: "./tsconfig.json", - plugins: [ - importGlobPlugin(), - sveltePlugin({ - preprocess: sveltePreprocess(), - compilerOptions: { dev: !deploy, hydratable: true, css: "injected" }, - }), - ], -}; - -let optsServer = { - entryPoints: ["js/server.js"], - platform: "node", - bundle: true, - minify: false, - target: "node19.6.1", - conditions: ["svelte"], - outdir: "../priv/svelte", - logLevel: "info", - sourcemap: watch ? "inline" : false, - tsconfig: "./tsconfig.json", - plugins: [ - importGlobPlugin(), - sveltePlugin({ - preprocess: sveltePreprocess(), - compilerOptions: { dev: !deploy, hydratable: true, generate: "ssr" }, - }), - ], -}; - -let optsPuppeteer = { - entryPoints: ["js/puppeteer-img.js"], - platform: "node", - bundle: true, - minify: true, - target: "node19.6.1", - conditions: [], - outdir: "../priv/puppeteer", - logLevel: "info", - sourcemap: watch ? "inline" : false, - tsconfig: "./tsconfig.json", - plugins: [], -}; - -if (watch) { - esbuild - .context(optsClient) - .then((ctx) => ctx.watch()) - .catch((_error) => process.exit(1)); - - esbuild - .context(optsServer) - .then((ctx) => ctx.watch()) - .catch((_error) => process.exit(1)); - - esbuild - .context(optsPuppeteer) - .then((ctx) => ctx.watch()) - .catch((_error) => process.exit(1)); -} else { - esbuild.build(optsClient); - esbuild.build(optsServer); - esbuild.build(optsPuppeteer); -} diff --git a/assets/css/app.css b/assets/css/app.css deleted file mode 100644 index e18f8c5f8..000000000 --- a/assets/css/app.css +++ /dev/null @@ -1,628 +0,0 @@ -/* This file is for your main application CSS */ -@import "tailwindcss/base"; -@import "tailwindcss/components"; -@import "tailwindcss/utilities"; - -.font-display { - font-family: "Space Grotesk", sans-serif; - font-optical-sizing: auto; - font-style: normal; -} - -.font-mono { - font-family: "Inconsolata", monospace; - font-optical-sizing: auto; - font-style: normal; - font-variation-settings: "wdth" 100; -} - -/* animations */ -.fade-in-scale { - animation: 0.2s ease-in 0s normal forwards 1 fade-in-scale-keys; -} - -.fade-out-scale { - animation: 0.2s ease-out 0s normal forwards 1 fade-out-scale-keys; -} - -.fade-in { - animation: 0.2s ease-out 0s normal forwards 1 fade-in-keys; -} -.fade-out { - animation: 0.2s ease-out 0s normal forwards 1 fade-out-keys; -} - -@keyframes fade-in-scale-keys { - 0% { - scale: 0.95; - opacity: 0; - } - 100% { - scale: 1; - opacity: 1; - } -} - -@keyframes fade-out-scale-keys { - 0% { - scale: 1; - opacity: 1; - } - 100% { - scale: 0.95; - opacity: 0; - } -} - -@keyframes fade-in-keys { - 0% { - opacity: 0; - } - 100% { - opacity: 1; - } -} - -@keyframes fade-out-keys { - 0% { - opacity: 1; - } - 100% { - opacity: 0; - } -} - -@keyframes slide-in-right-keys { - 100% { - transform: translateX(0%); - } -} - -/* Alerts and form errors used by phx.new */ -.alert { - padding: 15px; - margin-bottom: 20px; - border: 1px solid transparent; - border-radius: 4px; -} -.alert-info { - @apply text-indigo-300 bg-indigo-950/50 border-indigo-500; -} -.alert-warning { - @apply text-yellow-300 bg-yellow-950/50 border-yellow-500; -} -.alert-danger { - @apply text-red-300 bg-red-950/50 border-red-500; -} -.alert p { - margin-bottom: 0; -} -.alert:empty { - display: none; -} -.invalid-feedback { - display: inline-block; -} - -/* LiveView specific classes for your customization */ -.phx-no-feedback.invalid-feedback, -.phx-no-feedback .invalid-feedback { - display: none; -} - -.phx-click-loading { - opacity: 0.5; - transition: opacity 1s ease-out; -} - -/* .phx-loading { - cursor: wait; -} */ - -.phx-modal { - opacity: 1 !important; - position: fixed; - z-index: 1; - left: 0; - top: 0; - width: 100%; - height: 100%; - overflow: auto; - background-color: rgb(255, 255, 255); - background-color: rgba(255, 255, 255, 0.4); -} - -.phx-modal-content { - background-color: #000; - margin: 15vh auto; - padding: 20px; - border: 1px solid #94a3b8; - width: 80%; -} - -.phx-modal-close { - color: #64748b; - float: right; - font-size: 28px; - font-weight: bold; -} - -.phx-modal-close:hover, -.phx-modal-close:focus { - color: black; - text-decoration: none; - cursor: pointer; -} - -body { - @apply bg-background text-foreground; -} - -::selection { - @apply bg-primary text-primary-foreground; -} - -input:-webkit-autofill, -input:-webkit-autofill:hover, -input:-webkit-autofill:focus, -input:-webkit-autofill:active { - -webkit-box-shadow: 0 0 0 30px hsl(var(--background)) inset !important; - -webkit-text-fill-color: hsl(var(--foreground)) !important; -} - -@layer base { - :root { - --background: 255 0% 95%; - --foreground: 255 0% 0%; - --card: 255 0% 90%; - --card-foreground: 255 0% 10%; - --popover: 255 0% 95%; - --popover-foreground: 255 95% 0%; - --primary: 161 93.5% 30.4%; - --primary-foreground: 0 0% 100%; - --secondary: 255 10% 70%; - --secondary-foreground: 0 0% 0%; - --muted: 217 10% 85%; - --muted-foreground: 255 0% 35%; - --accent: 217 10% 80%; - --accent-foreground: 255 0% 10%; - --destructive: 0 84% 60%; - --destructive-foreground: 0 75% 15%; - --destructive-50: 0 75% 15%; - --destructive-100: 0 63% 31%; - --destructive-200: 0 70% 35%; - --destructive-300: 0 74% 42%; - --destructive-400: 0 72% 51%; - --destructive-500: 0 84% 60%; - --destructive-600: 0 91% 71%; - --destructive-700: 0 94% 82%; - --destructive-800: 0 96% 89%; - --destructive-900: 0 93% 94%; - --destructive-950: 0 86% 97%; - --success: 160 84% 39%; - --success-foreground: 166 91% 9%; - --success-50: 166 91% 9%; - --success-100: 164 86% 16%; - --success-200: 163 88% 20%; - --success-300: 163 94% 24%; - --success-400: 161 94% 30%; - --success-500: 160 84% 39%; - --success-600: 158 64% 52%; - --success-700: 156 72% 67%; - --success-800: 152 76% 80%; - --success-900: 149 80% 90%; - --success-950: 152 81% 96%; - --warning: 38 92% 50%; - --warning-foreground: 21 92% 14%; - --warning-50: 21 92% 14%; - --warning-100: 22 78% 26%; - --warning-200: 23 83% 31%; - --warning-300: 26 90% 37%; - --warning-400: 32 95% 44%; - --warning-500: 38 92% 50%; - --warning-600: 43 96% 56%; - --warning-700: 46 97% 65%; - --warning-800: 48 97% 77%; - --warning-900: 48 96% 89%; - --warning-950: 48 100% 96%; - --border: 255 20% 50%; - --input: 255 20% 18%; - --ring: 161 93.5% 30.4%; - --radius: 0.5rem; - } - .dark { - --background: 240 10% 3.9%; - --foreground: 0 0% 98%; - --card: 240 10% 3.9%; - --card-foreground: 0 0% 98%; - --popover: 240 10% 3.9%; - --popover-foreground: 0 0% 98%; - --primary: 158 64% 52%; - --primary-foreground: 0 0% 100%; - --secondary: 240 3.7% 15.9%; - --secondary-foreground: 0 0% 98%; - --muted: 240 3.7% 15.9%; - --muted-foreground: 240 5% 64.9%; - --accent: 240 3.7% 15.9%; - --accent-foreground: 0 0% 98%; - --destructive: 0 91% 71%; - --destructive-foreground: 0 86% 97%; - --destructive-50: 0 86% 97%; - --destructive-100: 0 93% 94%; - --destructive-200: 0 96% 89%; - --destructive-300: 0 94% 82%; - --destructive-400: 0 91% 71%; - --destructive-500: 0 84% 60%; - --destructive-600: 0 72% 51%; - --destructive-700: 0 74% 42%; - --destructive-800: 0 70% 35%; - --destructive-900: 0 63% 31%; - --destructive-950: 0 75% 15%; - --success: 158 64% 52%; - --success-foreground: 152 81% 96%; - --success-50: 152 81% 96%; - --success-100: 149 80% 90%; - --success-200: 152 76% 80%; - --success-300: 156 72% 67%; - --success-400: 158 64% 52%; - --success-500: 160 84% 39%; - --success-600: 161 94% 30%; - --success-700: 163 94% 24%; - --success-800: 163 88% 20%; - --success-900: 164 86% 16%; - --success-950: 166 91% 9%; - --warning: 43 96% 56%; - --warning-foreground: 48 100% 96%; - --warning-50: 48 100% 96%; - --warning-100: 48 96% 89%; - --warning-200: 48 97% 77%; - --warning-300: 46 97% 65%; - --warning-400: 43 96% 56%; - --warning-500: 38 92% 50%; - --warning-600: 32 95% 44%; - --warning-700: 26 90% 37%; - --warning-800: 23 83% 31%; - --warning-900: 22 78% 26%; - --warning-950: 21 92% 14%; - --border: 255 20% 18%; - --input: 240 5% 26%; - --ring: 161 93.5% 30.4%; - --radius: 0.5rem; - } - - * { - @apply border-border; - } - - input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not( - [type="file"] - ):not([type="submit"]):not([type="button"]):not([type="reset"]), - textarea, - select { - @apply rounded-lg border-input focus:outline-none focus:ring-1 focus:border-ring focus:ring-ring; - } - - input[type="checkbox"], - input[type="radio"] { - @apply rounded text-emerald-600 focus:outline-none focus:ring-1 focus:border-ring focus:ring-ring; - } - - body { - @apply font-sans antialiased bg-background text-foreground; - } - - :not(.scrollbar-thin) { - &::-webkit-scrollbar { - width: 10px; - height: 10px; - } - - &::-webkit-scrollbar-track { - background: hsl(var(--background)); - } - - &::-webkit-scrollbar-thumb { - background: hsl(var(--muted)); - border-radius: 5px; - } - - &::-webkit-scrollbar-thumb:hover { - background: hsl(var(--muted)); - } - } - - .scrollbar-thin::-webkit-scrollbar { - width: 0.25em; - height: 0.25em; - } -} - -emoji-picker { - border-radius: theme("borderRadius.lg"); - border: 1px solid theme("colors.border"); - background: theme("colors.background"); - box-shadow: theme("boxShadow.lg"); -} - -/* Home onboarding: keep overflow scroll but hide scrollbar UI */ -.onboarding-form-overlay-scroll { - -ms-overflow-style: none; - scrollbar-width: none; -} - -.onboarding-form-overlay-scroll::-webkit-scrollbar { - display: none; -} - -.onboarding-heart-wrap { - --heart-dim-x: 32px; - --heart-dim-y: 28px; - --heart-curve-height: 12px; - perspective: 200px; - filter: drop-shadow(0px 6px 10px rgb(16 185 129 / 0.3)); -} - -.onboarding-heart { - @apply bg-emerald-800; - position: relative; - height: var(--heart-dim-y); - width: var(--heart-dim-x); - overflow: hidden; - clip-path: url(#onboarding-heart-clip-path); - transition: transform 280ms ease; -} - -.onboarding-heart.is-pumping { - transform: translateZ(16px) scale(1.06); -} - -.onboarding-heart-tank { - @apply bg-emerald-400; - position: absolute; - bottom: 0; - width: var(--heart-dim-x); - z-index: 5; - transition: height 400ms ease; -} - -.onboarding-heart-curve { - position: absolute; - width: var(--heart-dim-x); - height: var(--heart-curve-height); - z-index: 6; - transition: bottom 400ms ease; -} - -.onboarding-heart-curve use { - animation: onboarding-heart-wave 2s cubic-bezier(0.55, 0.5, 0.45, 0.5) - infinite; -} - -.onboarding-heart-curve use:nth-child(1) { - animation-duration: 3s; -} - -.onboarding-heart-curve use:nth-child(2) { - animation-duration: 4s; -} - -.onboarding-heart-curve use:nth-child(3) { - animation-duration: 2s; -} - -.onboarding-heart-clip-defs { - position: absolute; - width: 0; - height: 0; -} - -#onboarding-heart-clip-path path { - transform: translate(0.11px, 0.03px); -} - -@keyframes onboarding-heart-wave { - 0% { - transform: translateX(-90px); - } - - 100% { - transform: translateX(85px); - } -} - -.onboarding-heart-wrap.is-goal-celebrate { - animation: onboarding-heart-goal-celebrate 0.9s - cubic-bezier(0.34, 1.56, 0.64, 1) both; -} - -@keyframes onboarding-heart-goal-celebrate { - 0% { - filter: drop-shadow(0 6px 10px rgb(16 185 129 / 0.35)); - transform: scale(1); - } - - 35% { - filter: drop-shadow(0 0 28px rgb(52 211 153 / 0.95)); - transform: scale(1.18) translateY(-2px); - } - - 100% { - filter: drop-shadow(0 6px 10px rgb(16 185 129 / 0.35)); - transform: scale(1); - } -} - -.home-onboarding-sparks { - position: absolute; - inset: 0 0 35% 0; - pointer-events: none; - overflow: visible; -} - -.home-onboarding-sparks span { - position: absolute; - bottom: 20%; - width: 5px; - height: 5px; - border-radius: 9999px; - background: rgb(52 211 153 / 0.95); - box-shadow: 0 0 10px rgb(16 185 129 / 0.8); - opacity: 0; - animation: home-onboarding-spark-rise 1.15s ease-out forwards; -} - -.home-onboarding-sparks span:nth-child(1) { - left: 12%; - animation-delay: 0.05s; -} - -.home-onboarding-sparks span:nth-child(2) { - left: 28%; - animation-delay: 0.12s; -} - -.home-onboarding-sparks span:nth-child(3) { - left: 44%; - animation-delay: 0.08s; -} - -.home-onboarding-sparks span:nth-child(4) { - left: 58%; - animation-delay: 0.16s; -} - -.home-onboarding-sparks span:nth-child(5) { - left: 72%; - animation-delay: 0.1s; -} - -.home-onboarding-sparks span:nth-child(6) { - left: 86%; - animation-delay: 0.14s; -} - -.home-onboarding-sparks span:nth-child(7) { - left: 34%; - bottom: 35%; - width: 4px; - height: 4px; - animation-delay: 0.2s; -} - -.home-onboarding-sparks span:nth-child(8) { - left: 66%; - bottom: 32%; - width: 4px; - height: 4px; - animation-delay: 0.18s; -} - -@keyframes home-onboarding-spark-rise { - 0% { - opacity: 0; - transform: translateY(0) scale(0); - } - - 25% { - opacity: 1; - transform: translateY(-8px) scale(1); - } - - 100% { - opacity: 0; - transform: translateY(-36px) scale(0.45); - } -} - -/* Full-card swipe: softer emerald / red rim glow on exit; opacity + shadow + brightness only */ -.home-candidate-stage { - position: relative; - border-radius: 1rem; - will-change: opacity, box-shadow, filter; -} - -.home-candidate-card-wrap.is-hidden { - display: none; -} - -.home-candidate-card-wrap.is-active { - display: block; -} - -.home-candidate-exit.home-candidate-exit--like { - animation: home-candidate-exit-like 900ms cubic-bezier(0.33, 0, 0.2, 1) - forwards; -} - -.home-candidate-exit.home-candidate-exit--skip { - animation: home-candidate-exit-skip 900ms cubic-bezier(0.33, 0, 0.22, 1) - forwards; -} - -.home-candidate-enter { - animation: home-candidate-enter 280ms cubic-bezier(0.22, 0.88, 0.32, 1) - forwards; -} - -.home-candidate-between { - opacity: 0; - pointer-events: none; -} - -@keyframes home-candidate-exit-like { - 0% { - opacity: 1; - filter: brightness(1); - box-shadow: none; - } - - 42% { - opacity: 1; - filter: brightness(1.025); - box-shadow: - 0 0 40px hsl(var(--success-500) / 0.18), - inset 0 0 0 3px hsl(var(--success-400) / 0.48); - } - - 100% { - opacity: 0; - filter: brightness(1); - box-shadow: - 0 0 48px hsl(var(--success-500) / 0.04), - inset 0 0 0 1px hsl(var(--success-400) / 0); - } -} - -@keyframes home-candidate-exit-skip { - 0% { - opacity: 1; - filter: brightness(1); - box-shadow: none; - } - - 38% { - opacity: 1; - filter: brightness(0.97); - box-shadow: - 0 0 40px rgb(239 68 68 / 0.2), - inset 0 0 0 3px rgb(248 113 113 / 0.46); - } - - 100% { - opacity: 0; - filter: brightness(1); - box-shadow: - 0 0 48px rgb(239 68 68 / 0.035), - inset 0 0 0 1px rgb(248 113 113 / 0); - } -} - -@keyframes home-candidate-enter { - from { - opacity: 0; - } - - to { - opacity: 1; - } -} diff --git a/assets/js/app.ts b/assets/js/app.ts deleted file mode 100644 index a41504e0d..000000000 --- a/assets/js/app.ts +++ /dev/null @@ -1,1596 +0,0 @@ -import "phoenix_html"; -import { Socket } from "phoenix"; -import { LiveSocket, type ViewHook } from "phoenix_live_view"; -import topbar from "../vendor/topbar"; -import { getHooks } from "live_svelte"; -import * as Components from "../svelte/**/*.svelte"; -import posthog from "posthog-js"; -import "emoji-picker-element"; - -// TODO: add eslint & biome -// TODO: enable strict mode -// TODO: eliminate anys - -interface PhxEvent extends Event { - target: Element; - detail: Record; -} - -type PhxEventKey = `js:${string}` | `phx:${string}`; - -declare global { - interface Window { - liveSocket: LiveSocket; - addEventListener( - type: K, - listener: ( - this: Window, - ev: K extends keyof WindowEventMap ? WindowEventMap[K] : PhxEvent, - ) => any, - options?: boolean | AddEventListenerOptions | undefined, - ): void; - } -} - -let isVisible = (el) => - !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length > 0); - -let execJS = (selector, attr) => { - document - .querySelectorAll(selector) - .forEach((el) => liveSocket.execJS(el, el.getAttribute(attr))); -}; - -// Set up the home page candidate-section IntersectionObserver outside LiveView's -// hook lifecycle so the like/skip dock + hero hide work even before the -// `live` connect completes (e.g. on slow connections / latency sim). -function initHomeTinderSection() { - if ((window as any).__homeTinderInit) return; - const section = document.getElementById("candidate-section"); - if (!section) return; - (window as any).__homeTinderInit = true; - - const buttons = document.getElementById("tinder-buttons"); - const navbar = document.getElementById("home-top-navbar"); - const hero = document.getElementById("home-hero-section"); - if (!buttons) return; - - const onboardingActive = () => - new URLSearchParams(window.location.search).has("go") || - section.getAttribute("data-onboarding-started") === "true"; - - const showTinderButtons = () => { - buttons.classList.remove("opacity-0", "pointer-events-none"); - buttons.classList.add("opacity-100"); - }; - - const hideHero = () => { - if (!hero) return; - hero.style.transition = "opacity 400ms ease-out"; - hero.style.opacity = "0"; - hero.style.pointerEvents = "none"; - window.setTimeout(() => { - hero.style.display = "none"; - }, 420); - }; - - if (onboardingActive()) showTinderButtons(); - - initHomeTinderButtons(buttons); - - const observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - showTinderButtons(); - hideHero(); - navbar?.classList.remove("max-h-40", "opacity-100"); - navbar?.classList.add("max-h-0", "opacity-0", "pointer-events-none"); - } else if (!onboardingActive()) { - // navbar?.classList.remove("max-h-0", "opacity-0", "pointer-events-none"); - // navbar?.classList.add("max-h-40", "opacity-100"); - } - }); - }, - { threshold: 0.95 }, - ); - observer.observe(section); -} - -function initHomeTinderButtons(dock: HTMLElement) { - if ((dock as any).__inited) return; - (dock as any).__inited = true; - - const state = { - likedIds: [] as string[], - dislikedIds: [] as string[], - currentIndex: 0, - swipeLock: false, - formShown: false, - }; - - const getStack = (): HTMLElement | null => - document.getElementById("home-candidate-stack"); - - const initialStack = getStack(); - const total = initialStack - ? Number((initialStack as HTMLElement).dataset.total || "0") - : 0; - const totalCandidates = Number.isNaN(total) ? 0 : total; - - const goalAttr = Number(dock.getAttribute("data-like-goal") || "3"); - const likeGoal = Number.isNaN(goalAttr) || goalAttr <= 0 ? 3 : goalAttr; - - const exitMs = Number(dock.getAttribute("data-swipe-exit-ms") || "900"); - const gapMs = Number(dock.getAttribute("data-swipe-gap-ms") || "80"); - const enterMs = Number(dock.getAttribute("data-swipe-enter-ms") || "280"); - - const findActiveCard = (): HTMLElement | null => { - const stack = getStack(); - if (!stack) return null; - return stack.querySelector( - ".home-candidate-card-wrap.is-active", - ) as HTMLElement | null; - }; - - const findCardByIndex = (idx: number): HTMLElement | null => { - const stack = getStack(); - if (!stack) return null; - return stack.querySelector( - `.home-candidate-card-wrap[data-candidate-index="${idx}"]`, - ) as HTMLElement | null; - }; - - const syncDisabled = () => { - const blocked = state.likedIds.length >= likeGoal; - for (const node of dock.querySelectorAll("[data-home-swipe]")) { - if (node instanceof HTMLButtonElement) { - node.disabled = blocked || state.swipeLock; - } - } - }; - - const updateHeart = () => { - const clamped = Math.min(Math.max(state.likedIds.length, 0), likeGoal); - const fillPct = Math.trunc((clamped / likeGoal) * 100); - const curveBottomPx = Math.max(-10, Math.trunc(fillPct * 0.24) - 10); - const tank = dock.querySelector(".onboarding-heart-tank"); - const curve = dock.querySelector(".onboarding-heart-curve"); - if (tank instanceof HTMLElement) tank.style.height = `${fillPct}%`; - if (curve instanceof HTMLElement) curve.style.bottom = `${curveBottomPx}px`; - }; - - const pumpHeart = () => { - const heart = dock.querySelector(".onboarding-heart"); - if (!(heart instanceof HTMLElement)) return; - heart.classList.remove("is-pumping"); - void heart.offsetWidth; - heart.classList.add("is-pumping"); - setTimeout(() => heart.classList.remove("is-pumping"), 320); - }; - - const celebrateGoal = () => { - const wrap = dock.querySelector(".onboarding-heart-wrap"); - if (!(wrap instanceof HTMLElement)) return; - wrap.classList.remove("is-goal-celebrate"); - void wrap.offsetWidth; - wrap.classList.add("is-goal-celebrate"); - setTimeout(() => wrap.classList.remove("is-goal-celebrate"), 920); - }; - - const writeHiddenInputs = () => { - const liked = document.getElementById( - "onboarding-liked-ids", - ) as HTMLInputElement | null; - const disliked = document.getElementById( - "onboarding-disliked-ids", - ) as HTMLInputElement | null; - if (liked) liked.value = JSON.stringify(state.likedIds); - if (disliked) disliked.value = JSON.stringify(state.dislikedIds); - }; - - const revealForm = () => { - if (state.formShown) return; - state.formShown = true; - (window as any).__homeFormRevealed = true; - - const fade = document.getElementById("home-candidate-fade"); - if (fade) { - fade.classList.remove("opacity-100"); - fade.classList.add("opacity-0", "pointer-events-none"); - } - - const overlay = document.getElementById("home-onboarding-form-overlay"); - const inner = document.getElementById("home-onboarding-form-inner"); - if (overlay) { - overlay.classList.remove("opacity-0", "pointer-events-none"); - overlay.classList.add("opacity-100"); - } - if (inner) { - inner.classList.remove("opacity-0", "translate-y-6", "scale-[0.97]"); - inner.classList.add("opacity-100", "translate-y-0", "scale-100"); - } - - dock.classList.remove("opacity-100"); - dock.classList.add("opacity-0", "pointer-events-none"); - - const submitDock = document.getElementById("onboarding-form-submit-dock"); - if (submitDock) { - submitDock.classList.remove("opacity-0", "pointer-events-none"); - submitDock.classList.add("opacity-100"); - } - - writeHiddenInputs(); - - const form = document.getElementById("onboarding-candidates-form"); - const submitBtn = document.querySelector( - "#onboarding-form-submit-dock button[type=submit]", - ); - if (form && submitBtn) { - const resetSubmitBtn = () => { - const iconSend = - submitBtn.querySelector("[data-submit-icon]"); - const iconLoader = submitBtn.querySelector( - "[data-loading-icon]", - ); - submitBtn.disabled = false; - submitBtn.classList.remove("opacity-50", "cursor-not-allowed"); - if (iconSend) iconSend.style.display = ""; - if (iconLoader) iconLoader.style.display = "none"; - }; - - form.addEventListener("submit", () => { - const iconSend = - submitBtn.querySelector("[data-submit-icon]"); - const iconLoader = submitBtn.querySelector( - "[data-loading-icon]", - ); - submitBtn.disabled = true; - submitBtn.classList.add("opacity-50", "cursor-not-allowed"); - if (iconSend) iconSend.style.display = "none"; - if (iconLoader) iconLoader.style.display = ""; - // Watch for LiveView to remove phx-submit-loading class from the form, - // which signals the server has responded (works for both success and error). - const observer = new MutationObserver(() => { - if (!form.classList.contains("phx-submit-loading")) { - resetSubmitBtn(); - observer.disconnect(); - } - }); - observer.observe(form, { - attributes: true, - attributeFilter: ["class"], - }); - }); - } - }; - - const advanceTo = (nextIdx: number) => { - const next = findCardByIndex(nextIdx); - if (!next) return; - stripHomeCandidateSwipeClasses(next); - next.classList.remove("is-hidden"); - next.classList.add("is-active", "home-candidate-between"); - void next.offsetWidth; - window.setTimeout(() => { - next.classList.remove("home-candidate-between"); - void next.offsetWidth; - next.classList.add("home-candidate-enter"); - window.setTimeout(() => { - next.classList.remove("home-candidate-enter"); - }, enterMs); - }, gapMs); - }; - - const runSwipe = (action: "like" | "skip") => { - if (state.swipeLock) return; - if (state.likedIds.length >= likeGoal) return; - - const active = findActiveCard(); - if (!active) { - if (state.likedIds.length >= likeGoal || state.formShown) revealForm(); - return; - } - - const userId = active.dataset.candidateUserId || null; - - if (action === "like" && userId) { - state.likedIds.push(userId); - updateHeart(); - pumpHeart(); - if (state.likedIds.length >= likeGoal) celebrateGoal(); - } else if (action === "skip" && userId) { - state.dislikedIds.push(userId); - } - - state.swipeLock = true; - syncDisabled(); - - const exitKind = - action === "like" - ? "home-candidate-exit--like" - : "home-candidate-exit--skip"; - stripHomeCandidateSwipeClasses(active); - void active.offsetWidth; - active.classList.add("home-candidate-exit", exitKind); - - const fromIdx = Number(active.dataset.candidateIndex || "NaN"); - const nextIdx = fromIdx + 1; - const isLastCard = nextIdx >= totalCandidates; - const reachedGoal = state.likedIds.length >= likeGoal; - - window.setTimeout(() => { - stripHomeCandidateSwipeClasses(active); - active.classList.remove("is-active"); - active.classList.add("is-hidden"); - state.currentIndex = nextIdx; - - if (reachedGoal || isLastCard) { - state.swipeLock = false; - syncDisabled(); - revealForm(); - return; - } - - advanceTo(nextIdx); - state.swipeLock = false; - syncDisabled(); - }, exitMs); - }; - - dock.addEventListener("click", (e: MouseEvent) => { - const raw = (e.target as Element | null)?.closest("[data-home-swipe]"); - if (!(raw instanceof HTMLButtonElement) || raw.disabled) return; - const action = raw.getAttribute("data-home-swipe"); - if (action !== "like" && action !== "skip") return; - e.preventDefault(); - runSwipe(action); - }); - - document.addEventListener( - "submit", - (e) => { - const form = e.target as HTMLElement | null; - if (form && (form as HTMLElement).id === "onboarding-candidates-form") { - writeHiddenInputs(); - } - }, - true, - ); - - updateHeart(); - syncDisabled(); -} - -if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", initHomeTinderSection); -} else { - initHomeTinderSection(); -} - -function stripHomeCandidateSwipeClasses(el: HTMLElement) { - el.classList.remove( - "home-candidate-exit", - "home-candidate-exit--like", - "home-candidate-exit--skip", - "home-candidate-between", - "home-candidate-enter", - ); -} - -const Hooks = { - Capture: { - mounted() { - const token = this.el.getAttribute("data-token"); - if (!token) return; - - posthog.init(token, { api_host: this.el.getAttribute("data-host") }); - - const email = this.el.getAttribute("data-email"); - if (!email) return; - - posthog.identify(email, { email }); - }, - }, - ScrollToEnd: { - mounted() { - requestAnimationFrame(() => { - this.el.scrollLeft = this.el.scrollWidth; - }); - }, - updated() { - requestAnimationFrame(() => { - this.el.scrollLeft = this.el.scrollWidth; - }); - }, - }, - Flash: { - mounted() { - let hide = () => - liveSocket.execJS(this.el, this.el.getAttribute("phx-click")); - this.timer = setTimeout(() => hide(), 5000); - this.el.addEventListener("phx:hide-start", () => - clearTimeout(this.timer), - ); - this.el.addEventListener("mouseover", () => { - clearTimeout(this.timer); - this.timer = setTimeout(() => hide(), 5000); - }); - }, - destroyed() { - clearTimeout(this.timer); - }, - }, - Menu: { - getAttr(name) { - let val = this.el.getAttribute(name); - if (val === null) { - throw new Error(`no ${name} attribute configured for menu`); - } - return val; - }, - reset() { - this.enabled = false; - this.activeClass = this.getAttr("data-active-class"); - this.deactivate(this.menuItems()); - this.activeItem = null; - window.removeEventListener("keydown", this.handleKeyDown); - }, - destroyed() { - this.reset(); - }, - mounted() { - this.menuItemsContainer = document.querySelector( - `[aria-labelledby="${this.el.id}"]`, - ); - this.reset(); - this.handleKeyDown = (e) => this.onKeyDown(e); - this.el.addEventListener("keydown", (e) => { - if ( - (e.key === "Enter" || e.key === " ") && - e.currentTarget.isSameNode(this.el) - ) { - this.enabled = true; - } - }); - this.el.addEventListener("click", (e) => { - if (!e.currentTarget.isSameNode(this.el)) { - return; - } - - window.addEventListener("keydown", this.handleKeyDown); - // disable if button clicked and click was not a keyboard event - if (this.enabled) { - window.requestAnimationFrame(() => this.activate(0)); - } - }); - this.menuItemsContainer.addEventListener("phx:hide-start", () => - this.reset(), - ); - }, - activate(index, fallbackIndex) { - let menuItems = this.menuItems(); - this.activeItem = menuItems[index] || menuItems[fallbackIndex]; - this.activeItem.classList.add(this.activeClass); - this.activeItem.focus(); - }, - deactivate(items) { - items.forEach((item) => item.classList.remove(this.activeClass)); - }, - menuItems() { - return Array.from( - this.menuItemsContainer.querySelectorAll("[role=menuitem]"), - ); - }, - onKeyDown(e) { - if (e.key === "Escape") { - document.body.click(); - this.el.focus(); - this.reset(); - } else if (e.key === "Enter" && !this.activeItem) { - this.activate(0); - } else if (e.key === "Enter") { - this.activeItem.click(); - } - if (e.key === "ArrowDown") { - e.preventDefault(); - let menuItems = this.menuItems(); - this.deactivate(menuItems); - this.activate(menuItems.indexOf(this.activeItem) + 1, 0); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - let menuItems = this.menuItems(); - this.deactivate(menuItems); - this.activate( - menuItems.indexOf(this.activeItem) - 1, - menuItems.length - 1, - ); - } else if (e.key === "Tab") { - e.preventDefault(); - } - }, - }, - PWAInstallPrompt: { - mounted() { - let deferredPrompt: any; - const installPrompt = document.getElementById("pwa-install-prompt"); - const installButton = document.getElementById("pwa-install-button"); - const closeButton = document.getElementById("pwa-close-button"); - const instructionsMobile = document.getElementById( - "pwa-instructions-mobile", - ); - if ( - !installPrompt || - !installButton || - !closeButton || - !instructionsMobile || - localStorage.getItem("pwaPromptShown") - ) { - return; - } - - const scrollHeight = - (document.documentElement.scrollHeight || document.body.scrollHeight) - - document.documentElement.clientHeight; - - const isMobile = - /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( - navigator.userAgent, - ); - - let promptShown = false; - - const showPrompt = () => { - if (!promptShown) { - installPrompt.classList.remove("hidden"); - if (isMobile) { - instructionsMobile.classList.remove("hidden"); - installButton.classList.add("hidden"); - } else { - installButton.classList.remove("hidden"); - instructionsMobile.classList.add("hidden"); - } - promptShown = true; - } - }; - - window.addEventListener( - "scroll", - () => { - const scrollPos = - document.documentElement.scrollTop || document.body.scrollTop; - - if (scrollPos > Math.min(500, scrollHeight / 2) && deferredPrompt) { - showPrompt(); - } - }, - { passive: true }, - ); - - window.addEventListener("beforeinstallprompt", (e) => { - e.preventDefault(); - deferredPrompt = e; - }); - - installButton.addEventListener("click", async () => { - if (deferredPrompt) { - deferredPrompt.prompt(); - deferredPrompt = null; - } - installPrompt.classList.add("hidden"); - localStorage.setItem("pwaPromptShown", "true"); - }); - - closeButton.addEventListener("click", () => { - installPrompt.classList.add("hidden"); - localStorage.setItem("pwaPromptShown", "true"); - }); - - window.addEventListener("appinstalled", () => { - installPrompt.classList.add("hidden"); - deferredPrompt = null; - localStorage.setItem("pwaPromptShown", "true"); - }); - }, - }, - NavBar: { - mounted() { - const offset = 16; - this.isOpaque = false; - - const onScroll = () => { - if (!this.isOpaque && window.scrollY > offset) { - this.isOpaque = true; - this.el.classList.add("bg-gray-950"); - this.el.classList.remove("bg-transparent"); - } else if (this.isOpaque && window.scrollY <= offset) { - this.isOpaque = false; - this.el.classList.add("bg-transparent"); - this.el.classList.remove("bg-gray-950"); - } - }; - - window.addEventListener("scroll", onScroll, { passive: true }); - }, - }, - CopyToClipboard: { - value() { - return this.el.dataset.value; - }, - - mounted() { - this.el.addEventListener("click", () => { - navigator.clipboard.writeText(this.value()); - }); - }, - }, - ScrollToBottom: { - mounted() { - this.el.classList.add("js-scroll"); - this.el.scrollTop = this.el.scrollHeight; - this.handleEvent("scroll-to-bottom", () => { - this.el.scrollTop = this.el.scrollHeight; - }); - }, - updated() { - this.el.scrollTop = this.el.scrollHeight; - }, - }, - AnimatedTooltip: { - mounted() { - const springConfig = { stiffness: 100, damping: 5 }; - let hoveredTooltip: HTMLElement | null = null; - let currentX = 0; - - const handleMouseEnter = (event: MouseEvent) => { - const target = event.currentTarget as HTMLElement; - const tooltip = target.querySelector("[data-tooltip]") as HTMLElement; - if (tooltip) { - hoveredTooltip = tooltip; - tooltip.classList.remove("hidden"); - tooltip.style.opacity = "1"; - tooltip.style.transform = "translateY(0) scale(1)"; - } - }; - - const handleMouseLeave = (event: MouseEvent) => { - const target = event.currentTarget as HTMLElement; - const tooltip = target.querySelector("[data-tooltip]") as HTMLElement; - if (tooltip) { - tooltip.classList.add("hidden"); - tooltip.style.opacity = "0"; - tooltip.style.transform = "translateY(20px) scale(0.6)"; - hoveredTooltip = null; - } - }; - - const handleMouseMove = (event: MouseEvent) => { - if (!hoveredTooltip) return; - - const target = event.currentTarget as HTMLElement; - const halfWidth = target.offsetWidth / 2; - currentX = event.offsetX - halfWidth; - - // Calculate rotation and translation based on mouse position - const rotateRange = [-45, 45]; - const translateRange = [-50, 50]; - const progress = (currentX + 100) / 200; // Normalize to 0-1 - - const rotation = - rotateRange[0] + (rotateRange[1] - rotateRange[0]) * progress; - const translation = - translateRange[0] + - (translateRange[1] - translateRange[0]) * progress; - - hoveredTooltip.style.transform = `translateX(${translation}px) rotate(${rotation}deg)`; - }; - - // Set up event listeners for all tooltip items - this.el.querySelectorAll("[data-tooltip-trigger]").forEach((trigger) => { - trigger.addEventListener("mouseenter", handleMouseEnter); - trigger.addEventListener("mouseleave", handleMouseLeave); - trigger.addEventListener("mousemove", handleMouseMove); - }); - }, - - destroyed() { - // Clean up event listeners if needed - this.el.querySelectorAll("[data-tooltip-trigger]").forEach((trigger) => { - trigger.removeEventListener("mouseenter", this.handleMouseEnter); - trigger.removeEventListener("mouseleave", this.handleMouseLeave); - trigger.removeEventListener("mousemove", this.handleMouseMove); - }); - }, - }, - DeriveHandle: { - mounted() { - const handleInput = document.querySelector("[data-handle-target]"); - let shouldDerive = true; - - // Listen for manual edits to the handle field - handleInput?.addEventListener("input", () => { - shouldDerive = false; - }); - - // Listen for changes to the name field - this.el.addEventListener("input", (e) => { - if (!shouldDerive) return; - - const handle = e.target.value - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); - - if (handleInput) { - (handleInput as HTMLInputElement).value = handle; - // Trigger the blur event to update the server state - handleInput.dispatchEvent(new Event("blur")); - } - }); - }, - }, - ClearInput: { - mounted() { - this.handleEvent("clear-input", ({ selector }) => { - document.querySelector(selector).value = ""; - }); - }, - }, - ChatInput: { - mounted() { - const el = this.el as HTMLTextAreaElement; - const resize = () => { - el.style.height = "auto"; - el.style.height = Math.min(el.scrollHeight, 200) + "px"; - }; - el.addEventListener("input", resize); - - el.addEventListener("keydown", (e: KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - const form = el.closest("form"); - if (form) form.requestSubmit(); - } - }); - - this.handleEvent("clear-input", ({ selector }: { selector: string }) => { - const target = document.querySelector(selector) as HTMLTextAreaElement; - if (target) { - target.value = ""; - target.style.height = "auto"; - } - }); - }, - }, - DeriveDomain: { - mounted() { - const domainInput = (this.el.closest("form") || document).querySelector( - "[data-domain-source]", - ); - let shouldDerive = true; - - // Listen for manual edits to the domain field - domainInput?.addEventListener("input", () => { - shouldDerive = false; - }); - - // Listen for changes to the email field - this.el.addEventListener("input", (e) => { - if (!shouldDerive) return; - - const email = (e.target as HTMLInputElement).value; - const domain = email.split("@")[1] || ""; - - if (domainInput) { - (domainInput as HTMLInputElement).value = domain; - // Trigger the change event to update the server state - domainInput.dispatchEvent(new Event("change")); - } - }); - }, - }, - - EmojiPicker: { - mounted() { - const button = this.el; - const container = document.getElementById("emoji-picker-container"); - const input = document.getElementById( - "message-input", - ) as HTMLInputElement; - const picker = container?.querySelector("emoji-picker"); - let isVisible = false; - - // Toggle picker visibility - button.addEventListener("click", () => { - isVisible = !isVisible; - if (isVisible) { - container?.classList.remove("hidden"); - } else { - container?.classList.add("hidden"); - } - }); - - // Handle emoji selection - picker?.addEventListener("emoji-click", (event: any) => { - const emoji = event.detail.unicode; - const cursorPosition = input.selectionStart || 0; - - // Insert emoji at cursor position - const currentValue = input.value; - input.value = - currentValue.slice(0, cursorPosition) + - emoji + - currentValue.slice(cursorPosition); - - // Move cursor after emoji - input.setSelectionRange( - cursorPosition + emoji.length, - cursorPosition + emoji.length, - ); - - // Hide picker after selection - container?.classList.add("hidden"); - isVisible = false; - - // Focus back on input - input.focus(); - }); - - // Close picker when clicking outside - document.addEventListener("click", (event) => { - if ( - !container?.contains(event.target as Node) && - !button.contains(event.target as Node) - ) { - container?.classList.add("hidden"); - isVisible = false; - } - }); - }, - }, - InfiniteScroll: { - mounted() { - this.setupObserver(); - }, - - updated() { - // Disconnect previous observer before creating a new one - if (this.observer) { - this.observer.disconnect(); - } - this.setupObserver(); - }, - - setupObserver() { - this.observer = new IntersectionObserver( - (entries) => { - const entry = entries[0]; - if (entry.isIntersecting) { - this.pushEvent("load_more"); - } - }, - { - root: null, // viewport - rootMargin: "0px 0px 400px 0px", // trigger when indicator is 400px from viewport - threshold: 0.1, - }, - ); - - // Look for the indicator inside this.el rather than document-wide - const loadMoreIndicator = this.el.querySelector( - "[data-load-more-indicator]", - ); - if (loadMoreIndicator) { - this.observer.observe(loadMoreIndicator); - } - }, - - destroyed() { - if (this.observer) { - this.observer.disconnect(); - } - }, - }, - AvatarImage: { - mounted() { - this.handleError = () => { - this.errored = true; - this.el.style.display = "none"; - }; - this.el.addEventListener("error", this.handleError); - }, - updated() { - if (this.errored) { - this.el.style.display = "none"; - } - }, - destroyed() { - this.el.removeEventListener("error", this.handleError); - }, - }, - LocalStateStore: { - getStorage() { - const storage = this.el.getAttribute("data-storage"); - return storage === "localStorage" ? localStorage : sessionStorage; - }, - - mounted() { - this.storage = this.getStorage(); - this.handleEvent("store", (obj) => this.store(obj)); - this.handleEvent("clear", (obj) => this.clear(obj)); - this.handleEvent("restore", (obj) => this.restore(obj)); - }, - - store(obj) { - this.storage.setItem(obj.key, obj.data); - }, - - restore(obj) { - const data = this.storage.getItem(obj.key); - this.pushEvent(obj.event, data); - }, - - clear(obj) { - this.storage.removeItem(obj.key); - }, - }, - CtrlEnterSubmit: { - mounted() { - this.el.addEventListener("keydown", (e) => { - if (e.key == "Enter" && e.ctrlKey) { - this.el.form.dispatchEvent( - new Event("submit", { bubbles: true, cancelable: true }), - ); - } - }); - }, - }, - EnterSubmit: { - mounted() { - this.el.addEventListener("keydown", (e) => { - if (e.key == "Enter") { - this.el.form.dispatchEvent( - new Event("submit", { bubbles: true, cancelable: true }), - ); - } - }); - }, - }, - ExpandableText: { - mounted() { - const button = document.querySelector(`#${this.el.dataset.expandId}`); - - // Check if content is truncated - const isTruncated = this.el.scrollHeight > this.el.clientHeight; - - if (isTruncated && button) { - button.classList.remove("hidden"); - } - }, - }, - - ExpandableTextButton: { - mounted() { - this.el.addEventListener("click", () => { - const content = document.querySelector( - `#${this.el.dataset.contentId}`, - ); - if (!content) return; - - const className = content.dataset.class; - - if (content.classList.contains(className)) { - // Expand - content.classList.remove(className); - this.el.classList.add("hidden"); - } else { - // Collapse - content.classList.add(className); - this.el.classList.remove("hidden"); - } - }); - }, - }, - ScrollToTop: { - mounted() { - this.el.addEventListener("click", () => { - window.scrollTo({ top: 0, behavior: "smooth" }); - }); - }, - }, - CompensationStrengthIndicator: { - mounted() { - const input = this.el.querySelector("input[type='text']"); - const strengthBar = this.el.querySelector("[data-strength-bar]"); - const strengthLabel = this.el.querySelector("[data-strength-label]"); - - if (!input || !strengthBar || !strengthLabel) return; - - const minAmount = 50000; - - const expandShorthand = (value: string): string => { - const trimmed = value.trim().toLowerCase(); - - // Handle 'k' for thousands (e.g., "100k" -> "100000") - if (trimmed.endsWith("k")) { - const number = parseFloat(trimmed.slice(0, -1)); - if (!isNaN(number)) { - return Math.floor(number * 1000).toString(); - } - } - - // Handle 'm' for millions (e.g., "1m" -> "1000000") - if (trimmed.endsWith("m")) { - const number = parseFloat(trimmed.slice(0, -1)); - if (!isNaN(number)) { - return Math.floor(number * 1000000).toString(); - } - } - - // Return just the digits if no shorthand - return value.replace(/[^0-9]/g, ""); - }; - - const formatWithCommas = (value: string): string => { - // First expand any shorthand notation - const expanded = expandShorthand(value); - // Add commas for thousands separators - return expanded.replace(/\B(?=(\d{3})+(?!\d))/g, ","); - }; - - const updateStrength = () => { - const value = expandShorthand(input.value); - const amount = parseInt(value) || 0; - - let strength = 0; - let label = ""; - let color = "bg-gray-200"; - - if (amount >= 500000) { - strength = 99; - label = "Big D Energy"; - color = "bg-emerald-500"; - } else if (amount >= 400000) { - strength = 90; - label = "Baller Status"; - color = "bg-emerald-500"; - } else if (amount >= 300000) { - strength = 80; - label = "High Roller"; - color = "bg-emerald-500"; - } else if (amount >= 200000) { - strength = 70; - label = "Big League"; - color = "bg-emerald-500"; - } else if (amount >= 150000) { - strength = 60; - label = "Major League"; - color = "bg-emerald-500"; - } else if (amount >= 100000) { - strength = 50; - label = "Six Figures"; - color = "bg-emerald-500"; - } else if (amount >= 75000) { - strength = 40; - label = "Solid Pay"; - color = "bg-emerald-500"; - } else if (amount >= minAmount) { - strength = 30; - label = "Decent"; - color = "bg-emerald-500"; - } - - // Update strength bar - strengthBar.style.width = `${strength}%`; - strengthBar.className = `h-2 rounded-full transition-all duration-300 ${color}`; - - // Show/hide the entire indicator section - const indicatorSection = strengthBar.closest(".mt-2"); - if (amount >= minAmount) { - indicatorSection.style.display = "block"; - } else { - indicatorSection.style.display = "none"; - } - - // Update label - strengthLabel.textContent = label; - strengthLabel.className = `text-sm font-medium transition-colors duration-300 ${ - strength >= 80 - ? "text-emerald-500" - : strength >= 60 - ? "text-emerald-500" - : strength >= 40 - ? "text-emerald-500" - : strength >= 20 - ? "text-emerald-500" - : "text-gray-600" - }`; - }; - - const handleInput = (e: Event) => { - const target = e.target as HTMLInputElement; - const cursorPosition = target.selectionStart || 0; - const oldValue = target.value; - - // Check if user just typed 'k' or 'm' to trigger expansion - const shouldExpand = - oldValue.toLowerCase().endsWith("k") || - oldValue.toLowerCase().endsWith("m"); - - let formattedValue: string; - let newCursorPosition = cursorPosition; - - if (shouldExpand) { - // Expand shorthand and format with commas - formattedValue = formatWithCommas(oldValue); - // Place cursor at the end after expansion - newCursorPosition = formattedValue.length; - } else { - // Just format with commas, preserving user input - const digitsOnly = oldValue.replace(/[^0-9]/g, ""); - formattedValue = digitsOnly.replace(/\B(?=(\d{3})+(?!\d))/g, ","); - - // Adjust cursor position to account for added/removed commas - const oldCommas = (oldValue.match(/,/g) || []).length; - const newCommas = (formattedValue.match(/,/g) || []).length; - newCursorPosition = cursorPosition + (newCommas - oldCommas); - } - - // Only update if the value changed to prevent cursor jumping - if (oldValue !== formattedValue) { - target.value = formattedValue; - - // Set cursor position after the DOM updates - setTimeout(() => { - target.setSelectionRange(newCursorPosition, newCursorPosition); - }, 0); - } - - // updateStrength(); - }; - - input.addEventListener("input", handleInput); - // input.addEventListener("keyup", updateStrength); - - // Initial formatting and update - if (input.value) { - input.value = formatWithCommas(input.value); - } - // updateStrength(); - }, - }, - CandidatesScroll: { - mounted() { - this.handleEvent("scroll_to_candidate", ({ index }) => { - const candidateElement = document.querySelector(`#candidate-${index}`); - if (candidateElement) { - candidateElement.scrollIntoView({ - behavior: "instant", - block: "start", - inline: "nearest", - }); - } - }); - - // Observe candidate elements to update current index based on scroll position - // this.setupScrollObserver(); - }, - - // setupScrollObserver() { - // if (this.observer) { - // this.observer.disconnect(); - // } - - // this.observer = new IntersectionObserver( - // (entries) => { - // entries.forEach((entry) => { - // if (entry.isIntersecting && entry.intersectionRatio > 0.5) { - // const candidateId = entry.target.id; - // const index = candidateId.split("-")[1]; - // if (index !== undefined) { - // // Update the sidebar selection without triggering a scroll - // this.pushEvent("update_current_index_silent", { - // index: parseInt(index), - // }); - // } - // } - // }); - // }, - // { - // root: null, - // rootMargin: "-20% 0px -20% 0px", - // threshold: [0.5], - // } - // ); - - // // Observe all candidate elements - // document.querySelectorAll('[id^="candidate-"]').forEach((el) => { - // this.observer.observe(el); - // }); - // }, - - // updated() { - // // Re-setup observer when candidates are updated - // this.setupScrollObserver(); - // }, - - // destroyed() { - // if (this.observer) { - // this.observer.disconnect(); - // } - // }, - }, - LoadFromHash: { - mounted() { - const hash = window.location.hash.substring(1); - if (hash) { - try { - const data = JSON.parse(decodeURIComponent(atob(hash))); - this.pushEvent("hash_load_success", data); - } catch (error) { - this.pushEvent("hash_load_failure", { error: error.message }); - } - } - - this.handleEvent("close-window", ({ delay }) => { - setTimeout(() => { - window.close(); - }, delay || 0); - }); - }, - }, - LazyLoadImage: { - mounted() { - const container = this.el; - const img = container.querySelector("img"); - - if (!img) return; - - const dataSrc = img.getAttribute("data-src"); - if (!dataSrc) return; - - // Find the parent link element to listen for hover - const trigger = container.closest(".group"); - if (!trigger) return; - - let hasLoaded = false; - - const loadImage = () => { - console.log("loading image", dataSrc); - if (hasLoaded) return; - hasLoaded = true; - img.src = dataSrc; - img.classList.remove("invisible"); - }; - - trigger.addEventListener("mouseenter", loadImage); - }, - }, - CandidateCarousel: { - mounted() { - // Get all carousel items - const items = this.el.querySelectorAll("[data-carousel-item]"); - if (!items || items.length === 0) return; - - let currentIndex = 0; - - // Set up interval to rotate items every 5 seconds - this.interval = setInterval(() => { - const currentItem = items[currentIndex] as HTMLElement; - const nextIndex = (currentIndex + 1) % items.length; - const nextItem = items[nextIndex] as HTMLElement; - - // Fade out current item - currentItem.classList.remove("opacity-100"); - currentItem.classList.add("opacity-0"); - - // After fade out completes, show next item - setTimeout(() => { - // Position current item absolutely so it doesn't take up space - if (!currentItem.classList.contains("absolute")) { - currentItem.classList.add("absolute", "inset-0"); - } - - // Remove absolute positioning from next item so it's in normal flow - if (nextItem.classList.contains("absolute")) { - nextItem.classList.remove("absolute", "inset-0"); - } - - // Fade in next item - nextItem.classList.remove("opacity-0"); - nextItem.classList.add("opacity-100"); - - currentIndex = nextIndex; - }, 500); // Match the transition-opacity duration-500 from the CSS - }, 5000); - }, - destroyed() { - if (this.interval) { - clearInterval(this.interval); - } - }, - }, - TinderSection: { - mounted() { - // Observer/visibility setup runs in initHomeTinderSection() at script load - // (independent of LiveView connect, so dock appears even with high latency). - // Hook stays for `updated()` to react to data-onboarding-started changes. - // - // If the form was revealed before the socket connected, restore that state - // now so the server's form_revealed: false patch doesn't hide it. - if ((window as any).__homeFormRevealed === true) { - const fade = document.getElementById("home-candidate-fade"); - if (fade) { - fade.classList.remove("opacity-100"); - fade.classList.add("opacity-0", "pointer-events-none"); - } - const overlay = document.getElementById("home-onboarding-form-overlay"); - const inner = document.getElementById("home-onboarding-form-inner"); - if (overlay) { - overlay.classList.remove("opacity-0", "pointer-events-none"); - overlay.classList.add("opacity-100"); - } - if (inner) { - inner.classList.remove("opacity-0", "translate-y-6", "scale-[0.97]"); - inner.classList.add("opacity-100", "translate-y-0", "scale-100"); - } - const tinderButtons = document.getElementById("tinder-buttons"); - if (tinderButtons) { - tinderButtons.classList.remove("opacity-100"); - tinderButtons.classList.add("opacity-0", "pointer-events-none"); - } - const submitDock = document.getElementById( - "onboarding-form-submit-dock", - ); - if (submitDock) { - submitDock.classList.remove("opacity-0", "pointer-events-none"); - submitDock.classList.add("opacity-100"); - } - this.pushEvent("reveal_form", {}); - } - }, - updated() { - const onboardingActive = - new URLSearchParams(window.location.search).has("go") || - this.el.getAttribute("data-onboarding-started") === "true"; - - const buttons = document.getElementById("tinder-buttons"); - if (onboardingActive) { - if (buttons) { - buttons.classList.remove("opacity-0", "pointer-events-none"); - buttons.classList.add("opacity-100"); - } - this.onboardingSent = true; - return; - } - - this.onboardingSent = false; - }, - destroyed() {}, - }, - TinderButtons: { - // All click/state logic now lives in initHomeTinderButtons() which runs at - // script load (independent of LV connect), so swipes work pre-mount. - mounted() {}, - destroyed() {}, - }, -} satisfies Record & Record>; - -// Accessible focus handling -let Focus = { - focusMain() { - let target = - document.querySelector("main h1") || - document.querySelector("main"); - if (target) { - let origTabIndex = target.tabIndex; - target.tabIndex = -1; - target.focus(); - target.tabIndex = origTabIndex; - } - }, - // Subject to the W3C Software License at https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document - isFocusable(el) { - if ( - el.tabIndex > 0 || - (el.tabIndex === 0 && el.getAttribute("tabIndex") !== null) - ) { - return true; - } - if (el.disabled) { - return false; - } - - switch (el.nodeName) { - case "A": - return !!el.href && el.rel !== "ignore"; - case "INPUT": - return el.type != "hidden" && el.type !== "file"; - case "BUTTON": - case "SELECT": - case "TEXTAREA": - return true; - default: - return false; - } - }, - // Subject to the W3C Software License at https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document - attemptFocus(el) { - if (!el) { - return; - } - if (!this.isFocusable(el)) { - return false; - } - try { - el.focus(); - } catch (e) {} - - return document.activeElement === el; - }, - // Subject to the W3C Software License at https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document - focusFirstDescendant(el) { - for (let i = 0; i < el.childNodes.length; i++) { - let child = el.childNodes[i]; - if (this.attemptFocus(child) || this.focusFirstDescendant(child)) { - return true; - } - } - return false; - }, - // Subject to the W3C Software License at https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document - focusLastDescendant(element) { - for (let i = element.childNodes.length - 1; i >= 0; i--) { - let child = element.childNodes[i]; - if (this.attemptFocus(child) || this.focusLastDescendant(child)) { - return true; - } - } - return false; - }, - AutoFocus: { - mounted() { - this.el.focus(); - }, - }, -}; - -let csrfToken = document - .querySelector("meta[name='csrf-token']")! - .getAttribute("content"); -let liveSocket = new LiveSocket("/live", Socket, { - ...{ disconnectedTimeout: 3000 }, - hooks: { ...Hooks, ...getHooks(Components) }, - params: { - _csrf_token: csrfToken, - timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, - }, - dom: { - onNodeAdded(node) { - if (node instanceof HTMLElement && node.autofocus) { - node.focus(); - } - return node; - }, - }, -}); - -let routeUpdated = () => { - // TODO: uncomment - // Focus.focusMain(); -}; - -let topBarScheduled = undefined; - -// Show progress bar on live navigation and form submits -topbar.config({ - barColors: { 0: "rgba(5, 150, 105, 1)" }, - shadowColor: "rgba(0, 0, 0, .3)", -}); -window.addEventListener("phx:page-loading-start", (info) => { - if (topBarScheduled || window.location.search.includes("screenshot")) { - return; - } - topBarScheduled = setTimeout(() => topbar.show(), 500); -}); -window.addEventListener("phx:page-loading-stop", (info) => { - clearTimeout(topBarScheduled); - topBarScheduled = undefined; - topbar.hide(); -}); - -// Accessible routing -window.addEventListener("phx:page-loading-stop", routeUpdated); - -window.addEventListener("phx:js-exec", ({ detail }) => { - document.querySelectorAll(detail.to).forEach((el) => { - liveSocket.execJS(el, el.getAttribute(detail.attr)); - }); -}); - -window.addEventListener("js:exec", (e) => - e.target[e.detail.call](...e.detail.args), -); -window.addEventListener("js:focus", (e) => { - let parent = document.querySelector(e.detail.parent); - if (parent && isVisible(parent)) { - (e.target as any).focus(); - } -}); -window.addEventListener("js:focus-closest", (e) => { - let el = e.target; - let sibling = el.nextElementSibling; - while (sibling) { - if (isVisible(sibling) && Focus.attemptFocus(sibling)) { - return; - } - sibling = sibling.nextElementSibling; - } - sibling = el.previousElementSibling; - while (sibling) { - if (isVisible(sibling) && Focus.attemptFocus(sibling)) { - return; - } - sibling = sibling.previousElementSibling; - } - Focus.attemptFocus((el as any).parent) || Focus.focusMain(); -}); -window.addEventListener("phx:remove-el", (e) => - document.getElementById(e.detail.id)?.remove(), -); - -// connect if there are any LiveViews on the page -liveSocket.getSocket().onOpen(() => execJS("#connection-status", "js-hide")); -liveSocket.getSocket().onError(() => execJS("#connection-status", "js-show")); -liveSocket.connect(); - -// expose liveSocket on window for web console debug logs and latency simulation: -// >> liveSocket.enableDebug() -// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session -// >> liveSocket.disableLatencySim() -window.liveSocket = liveSocket; - -// Allows to execute JS commands from the server -window.addEventListener("phx:js-exec", ({ detail }) => { - document.querySelectorAll(detail.to).forEach((el) => { - liveSocket.execJS(el, el.getAttribute(detail.attr)); - }); -}); - -window.addEventListener("phx:open_popup", (e: CustomEvent) => { - const url = e.detail.url; - if (!url) return; - - const width = e.detail.width || 600; - const height = e.detail.height || 600; - const left = e.detail.left || window.screen.width / 2 - width / 2; - const top = e.detail.top || window.screen.height / 2 - height / 2; - - const newWindow = window.open( - url, - "oauth", - `width=${width},height=${height},left=${left},top=${top},toolbar=0,scrollbars=1,status=1`, - ); - - if (window.focus && newWindow) { - newWindow.focus(); - } -}); - -// Add event listener for storing session values -window.addEventListener("phx:store-session", (event) => { - const token = document - .querySelector('meta[name="csrf-token"]') - .getAttribute("content"); - - fetch("/api/store_session", { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-CSRF-Token": token, - }, - body: JSON.stringify(event.detail), - }); -}); - -export default Hooks; diff --git a/assets/js/puppeteer-img.js b/assets/js/puppeteer-img.js deleted file mode 100644 index 1b76a74f3..000000000 --- a/assets/js/puppeteer-img.js +++ /dev/null @@ -1,126 +0,0 @@ -import puppeteer from "puppeteer"; - -function parseArgs() { - const args = process.argv.slice(2); - const options = { - type: "png", - path: null, - width: "800", - height: "600", - scaleFactor: "1", - x: null, - y: null, - clipWidth: null, - clipHeight: null, - }; - - for (let i = 0; i < args.length; i++) { - let arg = args[i]; - let value = null; - - [arg, value] = arg.split("="); - - switch (arg) { - case "-t": - case "--type": - options.type = value; - break; - case "-p": - case "--path": - options.path = value; - break; - case "-w": - case "--width": - options.width = value; - break; - case "-h": - case "--height": - options.height = value; - break; - case "-s": - case "--scale-factor": - options.scaleFactor = value; - break; - case "-x": - case "--x": - options.x = value; - break; - case "-y": - case "--y": - options.y = value; - break; - case "--clip-width": - options.clipWidth = value; - break; - case "--clip-height": - options.clipHeight = value; - break; - } - } - - // URL is the first non-option argument - options.url = args.find((arg) => !arg.startsWith("-")); - return options; -} - -function _validateInteger(value) { - const parsed = parseInt(value); - if (value && !parsed) { - process.stderr.write("Number values must be valid integer"); - return null; - } - return parsed; -} - -(async () => { - const options = parseArgs(); - let screenshotOptions = {}; - let viewportOptions = {}; - - if (!options.url) { - process.stderr.write("URL required"); - return; - } - - viewportOptions.width = _validateInteger(options.width) || 800; - viewportOptions.height = _validateInteger(options.height) || 600; - viewportOptions.deviceScaleFactor = - _validateInteger(options.scaleFactor) || 1; - screenshotOptions.type = ["jpeg", "png"].includes(options.type) - ? options.type - : "png"; - screenshotOptions.path = options.path || `./image.${screenshotOptions.type}`; - const clipParams = { - x: options.x, - y: options.y, - width: options.clipWidth, - height: options.clipHeight, - }; - const hasClipParams = Object.values(clipParams).every((val) => val !== null); - - if (hasClipParams) { - screenshotOptions.clip = {}; - for (const [key, value] of Object.entries(clipParams)) { - screenshotOptions.clip[key] = _validateInteger(value); - } - } - - const browser = await puppeteer.launch({ - devtools: false, - args: ["--no-sandbox", "--disable-setuid-sandbox", "--single-process"], - ignoreHTTPSErrors: true, - }); - - try { - const page = await browser.newPage(); - await page.setViewport(viewportOptions); - await page.goto(options.url, { waitUntil: "networkidle2" }); - await page.focus("body"); - await page.screenshot(screenshotOptions); - await page.close(); - } catch (e) { - process.stderr.write(e.message); - } finally { - await browser.close(); - } -})(); diff --git a/assets/js/server.js b/assets/js/server.js deleted file mode 100644 index 9157f7c73..000000000 --- a/assets/js/server.js +++ /dev/null @@ -1,4 +0,0 @@ -import * as Components from "../svelte/**/*.svelte" -import {getRender} from "live_svelte" - -export const render = getRender(Components) diff --git a/assets/package.json b/assets/package.json deleted file mode 100644 index b7e77ebc2..000000000 --- a/assets/package.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "name": "@algora/console", - "version": "0.0.1", - "description": "Algora is a developer tool & community simplifying bounties, hiring & open source sustainability.", - "main": "app.ts", - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" - }, - "keywords": [], - "author": "Algora PBC", - "private": true, - "devDependencies": { - "@types/node": "^22.10.2", - "@types/phoenix": "^1.6.4", - "@types/phoenix_live_view": "^0.18.4", - "esbuild": "^0.25.5", - "esbuild-plugin-import-glob": "^0.1.1", - "esbuild-svelte": "^0.9.3", - "puppeteer": "^24.4.0", - "svelte": "^4.2.19", - "svelte-preprocess": "^6.0.3", - "tailwindcss-animate": "^1.0.7", - "typescript": "^5.7.2" - }, - "dependencies": { - "clsx": "^2.1.1", - "emoji-picker-element": "^1.25.0", - "framer-motion": "^11.11.10", - "live_svelte": "file:../deps/live_svelte", - "phoenix": "file:../deps/phoenix", - "phoenix_html": "file:../deps/phoenix_html", - "phoenix_live_view": "file:../deps/phoenix_live_view", - "posthog-js": "^1.234.8", - "tailwind-merge": "^3.2.0" - } -} diff --git a/assets/pnpm-lock.yaml b/assets/pnpm-lock.yaml deleted file mode 100644 index 88760e716..000000000 --- a/assets/pnpm-lock.yaml +++ /dev/null @@ -1,1461 +0,0 @@ -lockfileVersion: '6.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -dependencies: - clsx: - specifier: ^2.1.1 - version: 2.1.1 - emoji-picker-element: - specifier: ^1.25.0 - version: 1.25.0 - framer-motion: - specifier: ^11.11.10 - version: 11.11.10 - live_svelte: - specifier: file:../deps/live_svelte - version: file:../deps/live_svelte - phoenix: - specifier: file:../deps/phoenix - version: file:../deps/phoenix - phoenix_html: - specifier: file:../deps/phoenix_html - version: file:../deps/phoenix_html - phoenix_live_view: - specifier: file:../deps/phoenix_live_view - version: file:../deps/phoenix_live_view - posthog-js: - specifier: ^1.234.8 - version: 1.234.8 - tailwind-merge: - specifier: ^3.2.0 - version: 3.2.0 - -devDependencies: - '@types/node': - specifier: ^22.10.2 - version: 22.10.2 - '@types/phoenix': - specifier: ^1.6.4 - version: 1.6.4 - '@types/phoenix_live_view': - specifier: ^0.18.4 - version: 0.18.4 - esbuild: - specifier: ^0.25.5 - version: 0.25.5 - esbuild-plugin-import-glob: - specifier: ^0.1.1 - version: 0.1.1 - esbuild-svelte: - specifier: ^0.9.3 - version: 0.9.3(esbuild@0.25.5)(svelte@4.2.19) - puppeteer: - specifier: ^24.4.0 - version: 24.4.0(typescript@5.7.2) - svelte: - specifier: ^4.2.19 - version: 4.2.19 - svelte-preprocess: - specifier: ^6.0.3 - version: 6.0.3(svelte@4.2.19)(typescript@5.7.2) - tailwindcss-animate: - specifier: ^1.0.7 - version: 1.0.7 - typescript: - specifier: ^5.7.2 - version: 5.7.2 - -packages: - - /@ampproject/remapping@2.3.0: - resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} - engines: {node: '>=6.0.0'} - dependencies: - '@jridgewell/gen-mapping': 0.3.8 - '@jridgewell/trace-mapping': 0.3.25 - dev: true - - /@babel/code-frame@7.27.1: - resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/helper-validator-identifier': 7.27.1 - js-tokens: 4.0.0 - picocolors: 1.1.1 - dev: true - - /@babel/helper-validator-identifier@7.27.1: - resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} - engines: {node: '>=6.9.0'} - dev: true - - /@esbuild/aix-ppc64@0.25.5: - resolution: {integrity: sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - requiresBuild: true - dev: true - optional: true - - /@esbuild/android-arm64@0.25.5: - resolution: {integrity: sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - requiresBuild: true - dev: true - optional: true - - /@esbuild/android-arm@0.25.5: - resolution: {integrity: sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - requiresBuild: true - dev: true - optional: true - - /@esbuild/android-x64@0.25.5: - resolution: {integrity: sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - requiresBuild: true - dev: true - optional: true - - /@esbuild/darwin-arm64@0.25.5: - resolution: {integrity: sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - requiresBuild: true - dev: true - optional: true - - /@esbuild/darwin-x64@0.25.5: - resolution: {integrity: sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - requiresBuild: true - dev: true - optional: true - - /@esbuild/freebsd-arm64@0.25.5: - resolution: {integrity: sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - requiresBuild: true - dev: true - optional: true - - /@esbuild/freebsd-x64@0.25.5: - resolution: {integrity: sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-arm64@0.25.5: - resolution: {integrity: sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-arm@0.25.5: - resolution: {integrity: sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-ia32@0.25.5: - resolution: {integrity: sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-loong64@0.25.5: - resolution: {integrity: sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-mips64el@0.25.5: - resolution: {integrity: sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-ppc64@0.25.5: - resolution: {integrity: sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-riscv64@0.25.5: - resolution: {integrity: sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-s390x@0.25.5: - resolution: {integrity: sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/linux-x64@0.25.5: - resolution: {integrity: sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /@esbuild/netbsd-arm64@0.25.5: - resolution: {integrity: sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - requiresBuild: true - dev: true - optional: true - - /@esbuild/netbsd-x64@0.25.5: - resolution: {integrity: sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - requiresBuild: true - dev: true - optional: true - - /@esbuild/openbsd-arm64@0.25.5: - resolution: {integrity: sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - requiresBuild: true - dev: true - optional: true - - /@esbuild/openbsd-x64@0.25.5: - resolution: {integrity: sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - requiresBuild: true - dev: true - optional: true - - /@esbuild/sunos-x64@0.25.5: - resolution: {integrity: sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - requiresBuild: true - dev: true - optional: true - - /@esbuild/win32-arm64@0.25.5: - resolution: {integrity: sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - requiresBuild: true - dev: true - optional: true - - /@esbuild/win32-ia32@0.25.5: - resolution: {integrity: sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - requiresBuild: true - dev: true - optional: true - - /@esbuild/win32-x64@0.25.5: - resolution: {integrity: sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - requiresBuild: true - dev: true - optional: true - - /@jridgewell/gen-mapping@0.3.8: - resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} - engines: {node: '>=6.0.0'} - dependencies: - '@jridgewell/set-array': 1.2.1 - '@jridgewell/sourcemap-codec': 1.5.0 - '@jridgewell/trace-mapping': 0.3.25 - dev: true - - /@jridgewell/resolve-uri@3.1.2: - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - dev: true - - /@jridgewell/set-array@1.2.1: - resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} - engines: {node: '>=6.0.0'} - dev: true - - /@jridgewell/sourcemap-codec@1.5.0: - resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} - dev: true - - /@jridgewell/trace-mapping@0.3.25: - resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.0 - dev: true - - /@nodelib/fs.scandir@2.1.5: - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - dev: true - - /@nodelib/fs.stat@2.0.5: - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} - dev: true - - /@nodelib/fs.walk@1.2.8: - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.19.1 - dev: true - - /@puppeteer/browsers@2.8.0: - resolution: {integrity: sha512-yTwt2KWRmCQAfhvbCRjebaSX8pV1//I0Y3g+A7f/eS7gf0l4eRJoUCvcYdVtboeU4CTOZQuqYbZNS8aBYb8ROQ==} - engines: {node: '>=18'} - hasBin: true - dependencies: - debug: 4.4.1 - extract-zip: 2.0.1 - progress: 2.0.3 - proxy-agent: 6.5.0 - semver: 7.7.2 - tar-fs: 3.0.9 - yargs: 17.7.2 - transitivePeerDependencies: - - bare-buffer - - supports-color - dev: true - - /@tootallnate/quickjs-emscripten@0.23.0: - resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} - dev: true - - /@types/estree@1.0.6: - resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} - dev: true - - /@types/node@22.10.2: - resolution: {integrity: sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==} - dependencies: - undici-types: 6.20.0 - dev: true - - /@types/phoenix@1.6.4: - resolution: {integrity: sha512-B34A7uot1Cv0XtaHRYDATltAdKx0BvVKNgYNqE4WjtPUa4VQJM7kxeXcVKaH+KS+kCmZ+6w+QaUdcljiheiBJA==} - dev: true - - /@types/phoenix_live_view@0.18.4: - resolution: {integrity: sha512-9mq6zRZfCtY8f4Kiu9ca0YodlNoL5kPfz6AO8AMIylftoT59re5+l5BxPISt/NJXyj1S/gRJmwpHgyVsyEk8cg==} - dependencies: - '@types/phoenix': 1.6.4 - dev: true - - /@types/yauzl@2.10.3: - resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} - requiresBuild: true - dependencies: - '@types/node': 22.10.2 - dev: true - optional: true - - /acorn@8.14.0: - resolution: {integrity: sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==} - engines: {node: '>=0.4.0'} - hasBin: true - dev: true - - /agent-base@7.1.3: - resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==} - engines: {node: '>= 14'} - dev: true - - /ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - dev: true - - /ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - dependencies: - color-convert: 2.0.1 - dev: true - - /argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - dev: true - - /aria-query@5.3.2: - resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} - engines: {node: '>= 0.4'} - dev: true - - /ast-types@0.13.4: - resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} - engines: {node: '>=4'} - dependencies: - tslib: 2.8.0 - dev: true - - /axobject-query@4.1.0: - resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} - engines: {node: '>= 0.4'} - dev: true - - /b4a@1.6.7: - resolution: {integrity: sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==} - dev: true - - /bare-events@2.5.4: - resolution: {integrity: sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==} - requiresBuild: true - dev: true - optional: true - - /bare-fs@4.1.5: - resolution: {integrity: sha512-1zccWBMypln0jEE05LzZt+V/8y8AQsQQqxtklqaIyg5nu6OAYFhZxPXinJTSG+kU5qyNmeLgcn9AW7eHiCHVLA==} - engines: {bare: '>=1.16.0'} - requiresBuild: true - peerDependencies: - bare-buffer: '*' - peerDependenciesMeta: - bare-buffer: - optional: true - dependencies: - bare-events: 2.5.4 - bare-path: 3.0.0 - bare-stream: 2.6.5(bare-events@2.5.4) - dev: true - optional: true - - /bare-os@3.6.1: - resolution: {integrity: sha512-uaIjxokhFidJP+bmmvKSgiMzj2sV5GPHaZVAIktcxcpCyBFFWO+YlikVAdhmUo2vYFvFhOXIAlldqV29L8126g==} - engines: {bare: '>=1.14.0'} - requiresBuild: true - dev: true - optional: true - - /bare-path@3.0.0: - resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==} - requiresBuild: true - dependencies: - bare-os: 3.6.1 - dev: true - optional: true - - /bare-stream@2.6.5(bare-events@2.5.4): - resolution: {integrity: sha512-jSmxKJNJmHySi6hC42zlZnq00rga4jjxcgNZjY9N5WlOe/iOoGRtdwGsHzQv2RlH2KOYMwGUXhf2zXd32BA9RA==} - requiresBuild: true - peerDependencies: - bare-buffer: '*' - bare-events: '*' - peerDependenciesMeta: - bare-buffer: - optional: true - bare-events: - optional: true - dependencies: - bare-events: 2.5.4 - streamx: 2.22.1 - dev: true - optional: true - - /basic-ftp@5.0.5: - resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==} - engines: {node: '>=10.0.0'} - dev: true - - /braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} - dependencies: - fill-range: 7.1.1 - dev: true - - /buffer-crc32@0.2.13: - resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} - dev: true - - /callsites@3.1.0: - resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} - engines: {node: '>=6'} - dev: true - - /chromium-bidi@2.1.2(devtools-protocol@0.0.1413902): - resolution: {integrity: sha512-vtRWBK2uImo5/W2oG6/cDkkHSm+2t6VHgnj+Rcwhb0pP74OoUb4GipyRX/T/y39gYQPhioP0DPShn+A7P6CHNw==} - peerDependencies: - devtools-protocol: '*' - dependencies: - devtools-protocol: 0.0.1413902 - mitt: 3.0.1 - zod: 3.25.64 - dev: true - - /cliui@8.0.1: - resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} - engines: {node: '>=12'} - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 7.0.0 - dev: true - - /clsx@2.1.1: - resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} - engines: {node: '>=6'} - dev: false - - /code-red@1.0.4: - resolution: {integrity: sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==} - dependencies: - '@jridgewell/sourcemap-codec': 1.5.0 - '@types/estree': 1.0.6 - acorn: 8.14.0 - estree-walker: 3.0.3 - periscopic: 3.1.0 - dev: true - - /color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - dependencies: - color-name: 1.1.4 - dev: true - - /color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - dev: true - - /core-js@3.41.0: - resolution: {integrity: sha512-SJ4/EHwS36QMJd6h/Rg+GyR4A5xE0FSI3eZ+iBVpfqf1x0eTSg1smWLHrA+2jQThZSh97fmSgFSU8B61nxosxA==} - requiresBuild: true - dev: false - - /cosmiconfig@9.0.0(typescript@5.7.2): - resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} - engines: {node: '>=14'} - peerDependencies: - typescript: '>=4.9.5' - peerDependenciesMeta: - typescript: - optional: true - dependencies: - env-paths: 2.2.1 - import-fresh: 3.3.1 - js-yaml: 4.1.0 - parse-json: 5.2.0 - typescript: 5.7.2 - dev: true - - /css-tree@2.3.1: - resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} - engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} - dependencies: - mdn-data: 2.0.30 - source-map-js: 1.2.1 - dev: true - - /data-uri-to-buffer@6.0.2: - resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} - engines: {node: '>= 14'} - dev: true - - /debug@4.4.1: - resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - dependencies: - ms: 2.1.3 - dev: true - - /degenerator@5.0.1: - resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==} - engines: {node: '>= 14'} - dependencies: - ast-types: 0.13.4 - escodegen: 2.1.0 - esprima: 4.0.1 - dev: true - - /devtools-protocol@0.0.1413902: - resolution: {integrity: sha512-yRtvFD8Oyk7C9Os3GmnFZLu53yAfsnyw1s+mLmHHUK0GQEc9zthHWvS1r67Zqzm5t7v56PILHIVZ7kmFMaL2yQ==} - dev: true - - /emoji-picker-element@1.25.0: - resolution: {integrity: sha512-UcUMxqIuneLCsEJ5KpqTD1xaHZyUpg6Oa7uCVe5AMXXpsW3C2TNegbNLXj2/rlbyr6qVMf7lXTFyzvFEarOIUg==} - dev: false - - /emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - dev: true - - /end-of-stream@1.4.4: - resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} - dependencies: - once: 1.4.0 - dev: true - - /env-paths@2.2.1: - resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} - engines: {node: '>=6'} - dev: true - - /error-ex@1.3.2: - resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} - dependencies: - is-arrayish: 0.2.1 - dev: true - - /esbuild-plugin-import-glob@0.1.1: - resolution: {integrity: sha512-yAFH+9AoIcsQkODSx0KUPRv1FeJUN6Tef8vkPQMcuVkc2vXYneYKsHhOiFS/yIsg5bQ70HHtAlXVA1uTjgoJXg==} - dependencies: - fast-glob: 3.3.3 - dev: true - - /esbuild-svelte@0.9.3(esbuild@0.25.5)(svelte@4.2.19): - resolution: {integrity: sha512-CgEcGY1r/d16+aggec3czoFBEBaYIrFOnMxpsO6fWNaNEqHregPN5DLAPZDqrL7rXDNplW+WMu8s3GMq9FqgJA==} - engines: {node: '>=18'} - peerDependencies: - esbuild: '>=0.17.0' - svelte: '>=4.2.1 <6' - dependencies: - '@jridgewell/trace-mapping': 0.3.25 - esbuild: 0.25.5 - svelte: 4.2.19 - dev: true - - /esbuild@0.25.5: - resolution: {integrity: sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==} - engines: {node: '>=18'} - hasBin: true - requiresBuild: true - optionalDependencies: - '@esbuild/aix-ppc64': 0.25.5 - '@esbuild/android-arm': 0.25.5 - '@esbuild/android-arm64': 0.25.5 - '@esbuild/android-x64': 0.25.5 - '@esbuild/darwin-arm64': 0.25.5 - '@esbuild/darwin-x64': 0.25.5 - '@esbuild/freebsd-arm64': 0.25.5 - '@esbuild/freebsd-x64': 0.25.5 - '@esbuild/linux-arm': 0.25.5 - '@esbuild/linux-arm64': 0.25.5 - '@esbuild/linux-ia32': 0.25.5 - '@esbuild/linux-loong64': 0.25.5 - '@esbuild/linux-mips64el': 0.25.5 - '@esbuild/linux-ppc64': 0.25.5 - '@esbuild/linux-riscv64': 0.25.5 - '@esbuild/linux-s390x': 0.25.5 - '@esbuild/linux-x64': 0.25.5 - '@esbuild/netbsd-arm64': 0.25.5 - '@esbuild/netbsd-x64': 0.25.5 - '@esbuild/openbsd-arm64': 0.25.5 - '@esbuild/openbsd-x64': 0.25.5 - '@esbuild/sunos-x64': 0.25.5 - '@esbuild/win32-arm64': 0.25.5 - '@esbuild/win32-ia32': 0.25.5 - '@esbuild/win32-x64': 0.25.5 - dev: true - - /escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - dev: true - - /escodegen@2.1.0: - resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} - engines: {node: '>=6.0'} - hasBin: true - dependencies: - esprima: 4.0.1 - estraverse: 5.3.0 - esutils: 2.0.3 - optionalDependencies: - source-map: 0.6.1 - dev: true - - /esprima@4.0.1: - resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} - engines: {node: '>=4'} - hasBin: true - dev: true - - /estraverse@5.3.0: - resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} - engines: {node: '>=4.0'} - dev: true - - /estree-walker@3.0.3: - resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - dependencies: - '@types/estree': 1.0.6 - dev: true - - /esutils@2.0.3: - resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} - engines: {node: '>=0.10.0'} - dev: true - - /extract-zip@2.0.1: - resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} - engines: {node: '>= 10.17.0'} - hasBin: true - dependencies: - debug: 4.4.1 - get-stream: 5.2.0 - yauzl: 2.10.0 - optionalDependencies: - '@types/yauzl': 2.10.3 - transitivePeerDependencies: - - supports-color - dev: true - - /fast-fifo@1.3.2: - resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} - dev: true - - /fast-glob@3.3.3: - resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} - engines: {node: '>=8.6.0'} - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.8 - dev: true - - /fastq@1.19.1: - resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} - dependencies: - reusify: 1.1.0 - dev: true - - /fd-slicer@1.1.0: - resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} - dependencies: - pend: 1.2.0 - dev: true - - /fflate@0.4.8: - resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==} - dev: false - - /fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - dependencies: - to-regex-range: 5.0.1 - dev: true - - /framer-motion@11.11.10: - resolution: {integrity: sha512-061Bt1jL/vIm+diYIiA4dP/Yld7vD47ROextS7ESBW5hr4wQFhxB5D5T5zAc3c/5me3cOa+iO5LqhA38WDln/A==} - peerDependencies: - '@emotion/is-prop-valid': '*' - react: ^18.0.0 - react-dom: ^18.0.0 - peerDependenciesMeta: - '@emotion/is-prop-valid': - optional: true - react: - optional: true - react-dom: - optional: true - dependencies: - tslib: 2.8.0 - dev: false - - /get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} - dev: true - - /get-stream@5.2.0: - resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} - engines: {node: '>=8'} - dependencies: - pump: 3.0.3 - dev: true - - /get-uri@6.0.4: - resolution: {integrity: sha512-E1b1lFFLvLgak2whF2xDBcOy6NLVGZBqqjJjsIhvopKfWWEi64pLVTWWehV8KlLerZkfNTA95sTe2OdJKm1OzQ==} - engines: {node: '>= 14'} - dependencies: - basic-ftp: 5.0.5 - data-uri-to-buffer: 6.0.2 - debug: 4.4.1 - transitivePeerDependencies: - - supports-color - dev: true - - /glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} - dependencies: - is-glob: 4.0.3 - dev: true - - /http-proxy-agent@7.0.2: - resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} - engines: {node: '>= 14'} - dependencies: - agent-base: 7.1.3 - debug: 4.4.1 - transitivePeerDependencies: - - supports-color - dev: true - - /https-proxy-agent@7.0.6: - resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} - engines: {node: '>= 14'} - dependencies: - agent-base: 7.1.3 - debug: 4.4.1 - transitivePeerDependencies: - - supports-color - dev: true - - /import-fresh@3.3.1: - resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} - engines: {node: '>=6'} - dependencies: - parent-module: 1.0.1 - resolve-from: 4.0.0 - dev: true - - /ip-address@9.0.5: - resolution: {integrity: sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==} - engines: {node: '>= 12'} - dependencies: - jsbn: 1.1.0 - sprintf-js: 1.1.3 - dev: true - - /is-arrayish@0.2.1: - resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} - dev: true - - /is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - dev: true - - /is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - dev: true - - /is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - dependencies: - is-extglob: 2.1.1 - dev: true - - /is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - dev: true - - /is-reference@3.0.3: - resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} - dependencies: - '@types/estree': 1.0.6 - dev: true - - /js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - dev: true - - /js-yaml@4.1.0: - resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} - hasBin: true - dependencies: - argparse: 2.0.1 - dev: true - - /jsbn@1.1.0: - resolution: {integrity: sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==} - dev: true - - /json-parse-even-better-errors@2.3.1: - resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} - dev: true - - /lines-and-columns@1.2.4: - resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - dev: true - - /locate-character@3.0.0: - resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} - dev: true - - /lru-cache@7.18.3: - resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} - engines: {node: '>=12'} - dev: true - - /magic-string@0.30.17: - resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} - dependencies: - '@jridgewell/sourcemap-codec': 1.5.0 - dev: true - - /mdn-data@2.0.30: - resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} - dev: true - - /merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} - dev: true - - /micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} - dependencies: - braces: 3.0.3 - picomatch: 2.3.1 - dev: true - - /mitt@3.0.1: - resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} - dev: true - - /morphdom@2.7.5: - resolution: {integrity: sha512-z6bfWFMra7kBqDjQGHud1LSXtq5JJC060viEkQFMBX6baIecpkNr2Ywrn2OQfWP3rXiNFQRPoFjD8/TvJcWcDg==} - dev: false - - /ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - dev: true - - /netmask@2.0.2: - resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==} - engines: {node: '>= 0.4.0'} - dev: true - - /once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - dependencies: - wrappy: 1.0.2 - dev: true - - /pac-proxy-agent@7.2.0: - resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==} - engines: {node: '>= 14'} - dependencies: - '@tootallnate/quickjs-emscripten': 0.23.0 - agent-base: 7.1.3 - debug: 4.4.1 - get-uri: 6.0.4 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - pac-resolver: 7.0.1 - socks-proxy-agent: 8.0.5 - transitivePeerDependencies: - - supports-color - dev: true - - /pac-resolver@7.0.1: - resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} - engines: {node: '>= 14'} - dependencies: - degenerator: 5.0.1 - netmask: 2.0.2 - dev: true - - /parent-module@1.0.1: - resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} - engines: {node: '>=6'} - dependencies: - callsites: 3.1.0 - dev: true - - /parse-json@5.2.0: - resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} - engines: {node: '>=8'} - dependencies: - '@babel/code-frame': 7.27.1 - error-ex: 1.3.2 - json-parse-even-better-errors: 2.3.1 - lines-and-columns: 1.2.4 - dev: true - - /pend@1.2.0: - resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} - dev: true - - /periscopic@3.1.0: - resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==} - dependencies: - '@types/estree': 1.0.6 - estree-walker: 3.0.3 - is-reference: 3.0.3 - dev: true - - /picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - dev: true - - /picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} - engines: {node: '>=8.6'} - dev: true - - /posthog-js@1.234.8: - resolution: {integrity: sha512-25E1HMPeyqtb+YumT6JeL6ppfoDyh9d1LLEgnFQVotyjn1SpvjrRpqscPyQjAtyFkq+upDyHksFAvZmdHPd7wg==} - peerDependencies: - '@rrweb/types': 2.0.0-alpha.17 - rrweb-snapshot: 2.0.0-alpha.17 - peerDependenciesMeta: - '@rrweb/types': - optional: true - rrweb-snapshot: - optional: true - dependencies: - core-js: 3.41.0 - fflate: 0.4.8 - preact: 10.26.4 - web-vitals: 4.2.4 - dev: false - - /preact@10.26.4: - resolution: {integrity: sha512-KJhO7LBFTjP71d83trW+Ilnjbo+ySsaAgCfXOXUlmGzJ4ygYPWmysm77yg4emwfmoz3b22yvH5IsVFHbhUaH5w==} - dev: false - - /progress@2.0.3: - resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} - engines: {node: '>=0.4.0'} - dev: true - - /proxy-agent@6.5.0: - resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==} - engines: {node: '>= 14'} - dependencies: - agent-base: 7.1.3 - debug: 4.4.1 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - lru-cache: 7.18.3 - pac-proxy-agent: 7.2.0 - proxy-from-env: 1.1.0 - socks-proxy-agent: 8.0.5 - transitivePeerDependencies: - - supports-color - dev: true - - /proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - dev: true - - /pump@3.0.3: - resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} - dependencies: - end-of-stream: 1.4.4 - once: 1.4.0 - dev: true - - /puppeteer-core@24.4.0: - resolution: {integrity: sha512-eFw66gCnWo0X8Hyf9KxxJtms7a61NJVMiSaWfItsFPzFBsjsWdmcNlBdsA1WVwln6neoHhsG+uTVesKmTREn/g==} - engines: {node: '>=18'} - dependencies: - '@puppeteer/browsers': 2.8.0 - chromium-bidi: 2.1.2(devtools-protocol@0.0.1413902) - debug: 4.4.1 - devtools-protocol: 0.0.1413902 - typed-query-selector: 2.12.0 - ws: 8.18.2 - transitivePeerDependencies: - - bare-buffer - - bufferutil - - supports-color - - utf-8-validate - dev: true - - /puppeteer@24.4.0(typescript@5.7.2): - resolution: {integrity: sha512-E4JhJzjS8AAI+6N/b+Utwarhz6zWl3+MR725fal+s3UlOlX2eWdsvYYU+Q5bXMjs9eZEGkNQroLkn7j11s2k1Q==} - engines: {node: '>=18'} - hasBin: true - requiresBuild: true - dependencies: - '@puppeteer/browsers': 2.8.0 - chromium-bidi: 2.1.2(devtools-protocol@0.0.1413902) - cosmiconfig: 9.0.0(typescript@5.7.2) - devtools-protocol: 0.0.1413902 - puppeteer-core: 24.4.0 - typed-query-selector: 2.12.0 - transitivePeerDependencies: - - bare-buffer - - bufferutil - - supports-color - - typescript - - utf-8-validate - dev: true - - /queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - dev: true - - /require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.0'} - dev: true - - /resolve-from@4.0.0: - resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} - engines: {node: '>=4'} - dev: true - - /reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - dev: true - - /run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - dependencies: - queue-microtask: 1.2.3 - dev: true - - /semver@7.7.2: - resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} - engines: {node: '>=10'} - hasBin: true - dev: true - - /smart-buffer@4.2.0: - resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} - engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} - dev: true - - /socks-proxy-agent@8.0.5: - resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} - engines: {node: '>= 14'} - dependencies: - agent-base: 7.1.3 - debug: 4.4.1 - socks: 2.8.5 - transitivePeerDependencies: - - supports-color - dev: true - - /socks@2.8.5: - resolution: {integrity: sha512-iF+tNDQla22geJdTyJB1wM/qrX9DMRwWrciEPwWLPRWAUEM8sQiyxgckLxWT1f7+9VabJS0jTGGr4QgBuvi6Ww==} - engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} - dependencies: - ip-address: 9.0.5 - smart-buffer: 4.2.0 - dev: true - - /source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - dev: true - - /source-map@0.6.1: - resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} - engines: {node: '>=0.10.0'} - requiresBuild: true - dev: true - optional: true - - /sprintf-js@1.1.3: - resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} - dev: true - - /streamx@2.22.1: - resolution: {integrity: sha512-znKXEBxfatz2GBNK02kRnCXjV+AA4kjZIUxeWSr3UGirZMJfTE9uiwKHobnbgxWyL/JWro8tTq+vOqAK1/qbSA==} - dependencies: - fast-fifo: 1.3.2 - text-decoder: 1.2.3 - optionalDependencies: - bare-events: 2.5.4 - dev: true - - /string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - dev: true - - /strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - dependencies: - ansi-regex: 5.0.1 - dev: true - - /svelte-preprocess@6.0.3(svelte@4.2.19)(typescript@5.7.2): - resolution: {integrity: sha512-PLG2k05qHdhmRG7zR/dyo5qKvakhm8IJ+hD2eFRQmMLHp7X3eJnjeupUtvuRpbNiF31RjVw45W+abDwHEmP5OA==} - engines: {node: '>= 18.0.0'} - requiresBuild: true - peerDependencies: - '@babel/core': ^7.10.2 - coffeescript: ^2.5.1 - less: ^3.11.3 || ^4.0.0 - postcss: ^7 || ^8 - postcss-load-config: '>=3' - pug: ^3.0.0 - sass: ^1.26.8 - stylus: '>=0.55' - sugarss: ^2.0.0 || ^3.0.0 || ^4.0.0 - svelte: ^4.0.0 || ^5.0.0-next.100 || ^5.0.0 - typescript: ^5.0.0 - peerDependenciesMeta: - '@babel/core': - optional: true - coffeescript: - optional: true - less: - optional: true - postcss: - optional: true - postcss-load-config: - optional: true - pug: - optional: true - sass: - optional: true - stylus: - optional: true - sugarss: - optional: true - typescript: - optional: true - dependencies: - svelte: 4.2.19 - typescript: 5.7.2 - dev: true - - /svelte@4.2.19: - resolution: {integrity: sha512-IY1rnGr6izd10B0A8LqsBfmlT5OILVuZ7XsI0vdGPEvuonFV7NYEUK4dAkm9Zg2q0Um92kYjTpS1CAP3Nh/KWw==} - engines: {node: '>=16'} - dependencies: - '@ampproject/remapping': 2.3.0 - '@jridgewell/sourcemap-codec': 1.5.0 - '@jridgewell/trace-mapping': 0.3.25 - '@types/estree': 1.0.6 - acorn: 8.14.0 - aria-query: 5.3.2 - axobject-query: 4.1.0 - code-red: 1.0.4 - css-tree: 2.3.1 - estree-walker: 3.0.3 - is-reference: 3.0.3 - locate-character: 3.0.0 - magic-string: 0.30.17 - periscopic: 3.1.0 - dev: true - - /tailwind-merge@3.2.0: - resolution: {integrity: sha512-FQT/OVqCD+7edmmJpsgCsY820RTD5AkBryuG5IUqR5YQZSdj5xlH5nLgH7YPths7WsLPSpSBNneJdM8aS8aeFA==} - dev: false - - /tailwindcss-animate@1.0.7: - resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==} - peerDependencies: - tailwindcss: '>=3.0.0 || insiders' - dev: true - - /tar-fs@3.0.9: - resolution: {integrity: sha512-XF4w9Xp+ZQgifKakjZYmFdkLoSWd34VGKcsTCwlNWM7QG3ZbaxnTsaBwnjFZqHRf/rROxaR8rXnbtwdvaDI+lA==} - dependencies: - pump: 3.0.3 - tar-stream: 3.1.7 - optionalDependencies: - bare-fs: 4.1.5 - bare-path: 3.0.0 - transitivePeerDependencies: - - bare-buffer - dev: true - - /tar-stream@3.1.7: - resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} - dependencies: - b4a: 1.6.7 - fast-fifo: 1.3.2 - streamx: 2.22.1 - dev: true - - /text-decoder@1.2.3: - resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==} - dependencies: - b4a: 1.6.7 - dev: true - - /to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - dependencies: - is-number: 7.0.0 - dev: true - - /tslib@2.8.0: - resolution: {integrity: sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==} - - /typed-query-selector@2.12.0: - resolution: {integrity: sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==} - dev: true - - /typescript@5.7.2: - resolution: {integrity: sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==} - engines: {node: '>=14.17'} - hasBin: true - dev: true - - /undici-types@6.20.0: - resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} - dev: true - - /web-vitals@4.2.4: - resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==} - dev: false - - /wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - dev: true - - /wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - dev: true - - /ws@8.18.2: - resolution: {integrity: sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - dev: true - - /y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} - dev: true - - /yargs-parser@21.1.1: - resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} - engines: {node: '>=12'} - dev: true - - /yargs@17.7.2: - resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} - engines: {node: '>=12'} - dependencies: - cliui: 8.0.1 - escalade: 3.2.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - string-width: 4.2.3 - y18n: 5.0.8 - yargs-parser: 21.1.1 - dev: true - - /yauzl@2.10.0: - resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} - dependencies: - buffer-crc32: 0.2.13 - fd-slicer: 1.1.0 - dev: true - - /zod@3.25.64: - resolution: {integrity: sha512-hbP9FpSZf7pkS7hRVUrOjhwKJNyampPgtXKc3AN6DsWtoHsg2Sb4SQaS4Tcay380zSwd2VPo9G9180emBACp5g==} - dev: true - - file:../deps/live_svelte: - resolution: {directory: ../deps/live_svelte, type: directory} - name: live_svelte - dev: false - - file:../deps/phoenix: - resolution: {directory: ../deps/phoenix, type: directory} - name: phoenix - dev: false - - file:../deps/phoenix_html: - resolution: {directory: ../deps/phoenix_html, type: directory} - name: phoenix_html - dev: false - - file:../deps/phoenix_live_view: - resolution: {directory: ../deps/phoenix_live_view, type: directory} - name: phoenix_live_view - dependencies: - morphdom: 2.7.5 - dev: false diff --git a/assets/svelte/TechStack.svelte b/assets/svelte/TechStack.svelte deleted file mode 100644 index 1b9d55adc..000000000 --- a/assets/svelte/TechStack.svelte +++ /dev/null @@ -1,92 +0,0 @@ - - -
- - - - - {#if tech?.length} -
- {#each tech as tech} -
- {tech} - -
- {/each} -
- {/if} -
diff --git a/assets/svelte/Timezone.svelte b/assets/svelte/Timezone.svelte deleted file mode 100644 index 04057ad51..000000000 --- a/assets/svelte/Timezone.svelte +++ /dev/null @@ -1,13 +0,0 @@ - - - diff --git a/assets/tailwind.config.js b/assets/tailwind.config.js deleted file mode 100644 index 8a0cc4fa9..000000000 --- a/assets/tailwind.config.js +++ /dev/null @@ -1,244 +0,0 @@ -// See the Tailwind configuration guide for advanced usage -// https://tailwindcss.com/docs/configuration - -const plugin = require("tailwindcss/plugin"); -const { fontFamily } = require("tailwindcss/defaultTheme"); -const path = require("path"); -const fs = require("fs"); -const colors = require("tailwindcss/colors"); - -module.exports = { - content: [ - "./js/**/*.js", - "./js/**/*.ts", - "../lib/*_web.ex", - "../lib/*_web/**/*.*ex", - "../lib/*_cloud/**/*.*ex", - "./svelte/**/*.svelte", - "../priv/content/**/*.md", - ], - theme: { - container: { - center: true, - padding: "2rem", - screens: { - "2xl": "1400px", - }, - }, - extend: { - colors: { - gray: colors.zinc, - border: "hsl(var(--border))", - input: "hsl(var(--input))", - ring: "hsl(var(--ring))", - background: "hsl(var(--background))", - foreground: "hsl(var(--foreground))", - primary: { - DEFAULT: "hsl(var(--primary))", - foreground: "hsl(var(--primary-foreground))", - }, - secondary: { - DEFAULT: "hsl(var(--secondary))", - foreground: "hsl(var(--secondary-foreground))", - }, - destructive: { - DEFAULT: "hsl(var(--destructive))", - foreground: "hsl(var(--destructive-foreground))", - 50: "hsl(var(--destructive-50))", - 100: "hsl(var(--destructive-100))", - 200: "hsl(var(--destructive-200))", - 300: "hsl(var(--destructive-300))", - 400: "hsl(var(--destructive-400))", - 500: "hsl(var(--destructive-500))", - 600: "hsl(var(--destructive-600))", - 700: "hsl(var(--destructive-700))", - 800: "hsl(var(--destructive-800))", - 900: "hsl(var(--destructive-900))", - 950: "hsl(var(--destructive-950))", - }, - success: { - DEFAULT: "hsl(var(--success))", - foreground: "hsl(var(--success-foreground))", - 50: "hsl(var(--success-50))", - 100: "hsl(var(--success-100))", - 200: "hsl(var(--success-200))", - 300: "hsl(var(--success-300))", - 400: "hsl(var(--success-400))", - 500: "hsl(var(--success-500))", - 600: "hsl(var(--success-600))", - 700: "hsl(var(--success-700))", - 800: "hsl(var(--success-800))", - 900: "hsl(var(--success-900))", - 950: "hsl(var(--success-950))", - }, - warning: { - DEFAULT: "hsl(var(--warning))", - foreground: "hsl(var(--warning-foreground))", - 50: "hsl(var(--warning-50))", - 100: "hsl(var(--warning-100))", - 200: "hsl(var(--warning-200))", - 300: "hsl(var(--warning-300))", - 400: "hsl(var(--warning-400))", - 500: "hsl(var(--warning-500))", - 600: "hsl(var(--warning-600))", - 700: "hsl(var(--warning-700))", - 800: "hsl(var(--warning-800))", - 900: "hsl(var(--warning-900))", - 950: "hsl(var(--warning-950))", - }, - muted: { - DEFAULT: "hsl(var(--muted))", - foreground: "hsl(var(--muted-foreground))", - }, - accent: { - DEFAULT: "hsl(var(--accent))", - foreground: "hsl(var(--accent-foreground))", - }, - popover: { - DEFAULT: "hsl(var(--popover))", - foreground: "hsl(var(--popover-foreground))", - }, - card: { - DEFAULT: "hsl(var(--card))", - foreground: "hsl(var(--card-foreground))", - }, - }, - borderRadius: { - lg: `var(--radius)`, - md: `calc(var(--radius) - 2px)`, - sm: "calc(var(--radius) - 4px)", - }, - keyframes: { - wiggle: { - "0%, 100%": { transform: "rotate(-7deg)" }, - "50%": { transform: "rotate(7deg)" }, - }, - "accordion-down": { - from: { height: 0 }, - to: { height: "var(--radix-accordion-content-height)" }, - }, - "accordion-up": { - from: { height: "var(--radix-accordion-content-height)" }, - to: { height: 0 }, - }, - rotate: { - "0%": { transform: "rotate(0deg) scale(10)" }, - "100%": { transform: "rotate(-360deg) scale(10)" }, - }, - "onboarding-line-in": { - from: { opacity: "0", transform: "translateY(12px)" }, - to: { opacity: "1", transform: "translateY(0)" }, - }, - "onboarding-success-icon": { - "0%": { opacity: "0", transform: "scale(0.5)" }, - "65%": { opacity: "1", transform: "scale(1.1)" }, - "100%": { opacity: "1", transform: "scale(1)" }, - }, - "onboarding-orb-breathe": { - "0%, 100%": { opacity: "0.72" }, - "50%": { opacity: "1" }, - }, - }, - animation: { - "accordion-down": "accordion-down 0.2s ease-out", - "accordion-up": "accordion-up 0.2s ease-out", - rotate: "rotate 10s linear infinite", - "onboarding-line-in": - "onboarding-line-in 0.5s cubic-bezier(0.2, 0.8, 0.2, 1) both", - "onboarding-success-icon": - "onboarding-success-icon 0.65s cubic-bezier(0.34, 1.56, 0.64, 1) both", - "onboarding-orb-breathe": - "onboarding-orb-breathe 5s ease-in-out infinite", - }, - typography: { - DEFAULT: { - css: { - "code::before": false, - "code::after": false, - "blockquote p:first-of-type::before": false, - "blockquote p:last-of-type::after": false, - }, - }, - }, - }, - }, - plugins: [ - require("@tailwindcss/typography"), - require("tailwindcss-animate"), - require("@tailwindcss/forms"), - // Allows prefixing tailwind classes with LiveView classes to add rules - // only when LiveView classes are applied, for example: - // - //
- // - plugin(({ addVariant }) => - addVariant("phx-no-feedback", [".phx-no-feedback&", ".phx-no-feedback &"]) - ), - plugin(({ addVariant }) => - addVariant("phx-click-loading", [ - ".phx-click-loading&", - ".phx-click-loading &", - ]) - ), - plugin(({ addVariant }) => - addVariant("phx-submit-loading", [ - ".phx-submit-loading&", - ".phx-submit-loading &", - ]) - ), - plugin(({ addVariant }) => - addVariant("phx-change-loading", [ - ".phx-change-loading&", - ".phx-change-loading &", - ]) - ), - plugin(({ addVariant }) => - addVariant("phx-keydown-loading", [ - ".phx-keydown-loading&", - ".phx-keydown-loading &", - ]) - ), - // Embeds Tabler Icons (https://tabler.io/icons) into your app.css bundle - // See your `CoreComponents.icon/1` for more information. - // - plugin(function ({ matchComponents, theme }) { - const iconsDir = path.join(__dirname, "../deps/tabler_icons/icons"); - const values = {}; - const icons = [ - ["", "/outline"], - ["-filled", "/filled"], - ]; - icons.forEach(([suffix, dir]) => { - fs.readdirSync(path.join(iconsDir, dir)).forEach((file) => { - const name = path.basename(file, ".svg") + suffix; - values[name] = { name, fullPath: path.join(iconsDir, dir, file) }; - }); - }); - matchComponents( - { - tabler: ({ name, fullPath }) => { - const content = fs - .readFileSync(fullPath) - .toString() - .replace(/\r?\n|\r/g, "") - .replace(/width="[^"]*"/, "") - .replace(/height="[^"]*"/, ""); - - return { - [`--tabler-${name}`]: `url('data:image/svg+xml;utf8,${content}')`, - "-webkit-mask": `var(--tabler-${name})`, - mask: `var(--tabler-${name})`, - "mask-repeat": "no-repeat", - "background-color": "currentColor", - "vertical-align": "middle", - display: "inline-block", - width: theme("spacing.6"), - height: theme("spacing.6"), - }; - }, - }, - { values } - ); - }), - ], -}; diff --git a/assets/tsconfig.json b/assets/tsconfig.json deleted file mode 100644 index 00f664932..000000000 --- a/assets/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "verbatimModuleSyntax": true, - "types": ["node"], - "baseUrl": ".", - "paths": { - "$lib": ["svelte"], - "$lib/*": ["svelte/*"] - } - }, - "include": ["js/**/*", "svelte/**/*", "vendor/**/*"] -} diff --git a/assets/vendor/topbar.js b/assets/vendor/topbar.js deleted file mode 100644 index 41957274d..000000000 --- a/assets/vendor/topbar.js +++ /dev/null @@ -1,165 +0,0 @@ -/** - * @license MIT - * topbar 2.0.0, 2023-02-04 - * https://buunguyen.github.io/topbar - * Copyright (c) 2021 Buu Nguyen - */ -(function (window, document) { - "use strict"; - - // https://gist.github.com/paulirish/1579671 - (function () { - var lastTime = 0; - var vendors = ["ms", "moz", "webkit", "o"]; - for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { - window.requestAnimationFrame = - window[vendors[x] + "RequestAnimationFrame"]; - window.cancelAnimationFrame = - window[vendors[x] + "CancelAnimationFrame"] || - window[vendors[x] + "CancelRequestAnimationFrame"]; - } - if (!window.requestAnimationFrame) - window.requestAnimationFrame = function (callback, element) { - var currTime = new Date().getTime(); - var timeToCall = Math.max(0, 16 - (currTime - lastTime)); - var id = window.setTimeout(function () { - callback(currTime + timeToCall); - }, timeToCall); - lastTime = currTime + timeToCall; - return id; - }; - if (!window.cancelAnimationFrame) - window.cancelAnimationFrame = function (id) { - clearTimeout(id); - }; - })(); - - var canvas, - currentProgress, - showing, - progressTimerId = null, - fadeTimerId = null, - delayTimerId = null, - addEvent = function (elem, type, handler) { - if (elem.addEventListener) elem.addEventListener(type, handler, false); - else if (elem.attachEvent) elem.attachEvent("on" + type, handler); - else elem["on" + type] = handler; - }, - options = { - autoRun: true, - barThickness: 3, - barColors: { - 0: "rgba(26, 188, 156, .9)", - ".25": "rgba(52, 152, 219, .9)", - ".50": "rgba(241, 196, 15, .9)", - ".75": "rgba(230, 126, 34, .9)", - "1.0": "rgba(211, 84, 0, .9)", - }, - shadowBlur: 10, - shadowColor: "rgba(0, 0, 0, .6)", - className: null, - }, - repaint = function () { - canvas.width = window.innerWidth; - canvas.height = options.barThickness * 5; // need space for shadow - - var ctx = canvas.getContext("2d"); - ctx.shadowBlur = options.shadowBlur; - ctx.shadowColor = options.shadowColor; - - var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0); - for (var stop in options.barColors) - lineGradient.addColorStop(stop, options.barColors[stop]); - ctx.lineWidth = options.barThickness; - ctx.beginPath(); - ctx.moveTo(0, options.barThickness / 2); - ctx.lineTo( - Math.ceil(currentProgress * canvas.width), - options.barThickness / 2 - ); - ctx.strokeStyle = lineGradient; - ctx.stroke(); - }, - createCanvas = function () { - canvas = document.createElement("canvas"); - var style = canvas.style; - style.position = "fixed"; - style.top = style.left = style.right = style.margin = style.padding = 0; - style.zIndex = 100001; - style.display = "none"; - if (options.className) canvas.classList.add(options.className); - document.body.appendChild(canvas); - addEvent(window, "resize", repaint); - }, - topbar = { - config: function (opts) { - for (var key in opts) - if (options.hasOwnProperty(key)) options[key] = opts[key]; - }, - show: function (delay) { - if (showing) return; - if (delay) { - if (delayTimerId) return; - delayTimerId = setTimeout(() => topbar.show(), delay); - } else { - showing = true; - if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId); - if (!canvas) createCanvas(); - canvas.style.opacity = 1; - canvas.style.display = "block"; - topbar.progress(0); - if (options.autoRun) { - (function loop() { - progressTimerId = window.requestAnimationFrame(loop); - topbar.progress( - "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2) - ); - })(); - } - } - }, - progress: function (to) { - if (typeof to === "undefined") return currentProgress; - if (typeof to === "string") { - to = - (to.indexOf("+") >= 0 || to.indexOf("-") >= 0 - ? currentProgress - : 0) + parseFloat(to); - } - currentProgress = to > 1 ? 1 : to; - repaint(); - return currentProgress; - }, - hide: function () { - clearTimeout(delayTimerId); - delayTimerId = null; - if (!showing) return; - showing = false; - if (progressTimerId != null) { - window.cancelAnimationFrame(progressTimerId); - progressTimerId = null; - } - (function loop() { - if (topbar.progress("+.1") >= 1) { - canvas.style.opacity -= 0.05; - if (canvas.style.opacity <= 0.05) { - canvas.style.display = "none"; - fadeTimerId = null; - return; - } - } - fadeTimerId = window.requestAnimationFrame(loop); - })(); - }, - }; - - if (typeof module === "object" && typeof module.exports === "object") { - module.exports = topbar; - } else if (typeof define === "function" && define.amd) { - define(function () { - return topbar; - }); - } else { - this.topbar = topbar; - } -}.call(this, window, document)); diff --git a/config/.formatter.exs b/config/.formatter.exs deleted file mode 100644 index f0118697c..000000000 --- a/config/.formatter.exs +++ /dev/null @@ -1,3 +0,0 @@ -[ - inputs: ["*.exs"] -] diff --git a/config/config.exs b/config/config.exs deleted file mode 100644 index 930b36167..000000000 --- a/config/config.exs +++ /dev/null @@ -1,160 +0,0 @@ -# This file is responsible for configuring your application -# and its dependencies with the aid of the Config module. -# -# This configuration file is loaded before any dependency and -# is restricted to this project. - -# General application configuration -import Config - -config :algora, - title: "Algora", - description: "Algora connects companies and engineers for full-time and contract work", - ecto_repos: [Algora.Repo], - generators: [timestamp_type: :utc_datetime_usec], - redirects: [ - {"/tv", "https://algora.io"}, - {"/docs/bounties/payments", "/docs/payments"}, - {"/sdk", "https://github.com/algora-io/sdk"}, - {"/healthcare", "https://blog.algora.io/post/healthcare"}, - {"/podcast", "https://www.youtube.com/@algora-io/podcasts"}, - {"/create/org", "/onboarding/org"}, - {"/solve", "/onboarding/dev"}, - {"/onboarding/solver", "/onboarding/dev"}, - {"/:org/contract/:id", "/:org/contracts/:id"}, - {"/org/*path", "/*path"}, - {"/@/:handle", "/:handle/profile"}, - {"/challenges/limbo", "/challenges/turso"}, - {"/challenges/primeintellect-ai", "/challenges/primeintellect"} - ] - -# Configures the endpoint -config :algora, AlgoraWeb.Endpoint, - url: [host: "localhost"], - adapter: Phoenix.Endpoint.Cowboy2Adapter, - render_errors: [ - formats: [html: AlgoraWeb.ErrorHTML, json: AlgoraWeb.ErrorJSON], - layout: false - ], - pubsub_server: Algora.PubSub, - live_view: [signing_salt: "lTPawhId"] - -config :algora, Oban, - notifier: Oban.Notifiers.PG, - repo: Algora.Repo, - queues: [ - default: [limit: 1, dispatch_cooldown: 5], - background: [limit: 5, dispatch_cooldown: 50], - internal: [limit: 1, dispatch_cooldown: 100], - internal_par: [limit: 5, dispatch_cooldown: 100] - ] - -# Configures the mailer -# -# By default it uses the "Local" adapter which stores the emails -# locally. You can see the emails in your browser, at "/dev/mailbox". -# -# For production it's recommended to configure a different adapter -# at the `config/runtime.exs`. -config :algora, Algora.Mailer, adapter: Swoosh.Adapters.Local - -# Configure tailwind (the version is required) -config :tailwind, - version: "3.4.0", - algora: [ - args: ~w( - --config=tailwind.config.js - --input=css/app.css - --output=../priv/static/assets/app.css - ), - cd: Path.expand("../assets", __DIR__) - ] - -# Configures Elixir's Logger -config :logger, :console, - format: "[$level] $message $metadata\n", - level: String.to_atom(System.get_env("LOG_LEVEL") || "debug"), - metadata: [:mfa, :file, :line, :request_id, :user_id] - -# Use Jason for JSON parsing in Phoenix -config :phoenix, :json_library, Jason - -config :nanoid, - size: 16, - alphabet: "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" - -config :ex_money, - default_cldr_backend: Algora.Cldr - -config :ex_cldr, - default_locale: "en", - default_backend: Algora.Cldr - -config :tails, - color_classes: [ - "primary", - "primary-foreground", - "secondary", - "secondary-foreground", - "destructive", - "destructive-foreground", - "destructive-50", - "destructive-100", - "destructive-200", - "destructive-300", - "destructive-400", - "destructive-500", - "destructive-600", - "destructive-700", - "destructive-800", - "destructive-900", - "destructive-950", - "success", - "success-foreground", - "success-50", - "success-100", - "success-200", - "success-300", - "success-400", - "success-500", - "success-600", - "success-700", - "success-800", - "success-900", - "success-950", - "warning", - "warning-foreground", - "warning-50", - "warning-100", - "warning-200", - "warning-300", - "warning-400", - "warning-500", - "warning-600", - "warning-700", - "warning-800", - "warning-900", - "warning-950", - "muted", - "muted-foreground", - "accent", - "accent-foreground", - "popover", - "popover-foreground", - "card", - "card-foreground", - "border", - "input", - "ring", - "background", - "foreground", - "gray" - ] - -config :elixir, :time_zone_database, Tzdata.TimeZoneDatabase - -config :reverse_proxy_plug, :http_client, ReverseProxyPlug.HTTPClient.Adapters.HTTPoison - -# Import environment specific config. This must remain at the bottom -# of this file so it overrides the configuration defined above. -import_config "#{config_env()}.exs" diff --git a/config/dev.exs b/config/dev.exs deleted file mode 100644 index 93f6d5590..000000000 --- a/config/dev.exs +++ /dev/null @@ -1,168 +0,0 @@ -import Config - -config :algora, :github, - client_id: System.get_env("GITHUB_CLIENT_ID"), - client_secret: System.get_env("GITHUB_CLIENT_SECRET"), - app_handle: System.get_env("GITHUB_APP_HANDLE"), - app_id: System.get_env("GITHUB_APP_ID"), - webhook_secret: System.get_env("GITHUB_WEBHOOK_SECRET"), - private_key: System.get_env("GITHUB_PRIVATE_KEY"), - pat: System.get_env("GITHUB_PAT"), - pat_enabled: System.get_env("GITHUB_PAT_ENABLED", "false") == "true", - bot_handle: System.get_env("GITHUB_BOT_HANDLE"), - oauth_state_ttl: String.to_integer(System.get_env("GITHUB_OAUTH_STATE_TTL", "600")), - oauth_state_salt: System.get_env("GITHUB_OAUTH_STATE_SALT", "github-oauth-state") - -config :stripity_stripe, - api_key: System.get_env("STRIPE_SECRET_KEY"), - api_version: "2022-11-15" - -config :algora, :stripe, - secret_key: System.get_env("STRIPE_SECRET_KEY"), - publishable_key: System.get_env("STRIPE_PUBLISHABLE_KEY"), - webhook_secret: System.get_env("STRIPE_WEBHOOK_SECRET"), - test_customer_id: System.get_env("STRIPE_TEST_CUSTOMER_ID"), - test_account_id: System.get_env("STRIPE_TEST_ACCOUNT_ID") - -# Configure your database -config :algora, Algora.Repo, - url: System.get_env("DATABASE_URL"), - stacktrace: true, - show_sensitive_data_on_connection_error: true, - pool_size: 10, - migration_primary_key: [type: :string], - migration_timestamps: [type: :utc_datetime_usec] - -config :ex_aws, - json_codec: Jason, - access_key_id: System.get_env("AWS_ACCESS_KEY_ID"), - secret_access_key: System.get_env("AWS_SECRET_ACCESS_KEY") - -config :ex_aws, :s3, - scheme: "https://", - host: if(url = System.get_env("AWS_ENDPOINT_URL_S3"), do: URI.parse(url).host), - region: System.get_env("AWS_REGION") - -# For development, we disable any cache and enable -# debugging and code reloading. -# -# The watchers configuration can be used to run external -# watchers to your application. For example, we can use it -# to bundle .js and .css sources. -config :algora, AlgoraWeb.Endpoint, - # Binding to loopback ipv4 address prevents access from other machines. - # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. - http: [ip: {127, 0, 0, 1}, port: String.to_integer(System.get_env("PORT") || "4000")], - check_origin: - (case "ALLOWED_ORIGINS" - |> System.get_env("") - |> String.split(",") - |> Enum.map(&String.trim/1) do - [""] -> false - origins -> origins - end), - code_reloader: true, - debug_errors: true, - secret_key_base: "WYiQUy5kdwRSeANJjW+5ddL155PmOJ64xCQePobCN45nqhDMdGfc3NnpTy/0TtYF", - watchers: [ - node: ["build.js", "--watch", cd: Path.expand("../assets", __DIR__)], - # esbuild: {Esbuild, :install_and_run, [:algora, ~w(--sourcemap=inline --watch)]}, - tailwind: {Tailwind, :install_and_run, [:algora, ~w(--watch)]} - ] - -# ## SSL Support -# -# In order to use HTTPS in development, a self-signed -# certificate can be generated by running the following -# Mix task: -# -# mix phx.gen.cert -# -# Run `mix help phx.gen.cert` for more information. -# -# The `http:` config above can be replaced with: -# -# https: [ -# port: 4001, -# cipher_suite: :strong, -# keyfile: "priv/cert/selfsigned_key.pem", -# certfile: "priv/cert/selfsigned.pem" -# ], -# -# If desired, both `http:` and `https:` keys can be -# configured to run both http and https servers on -# different ports. - -# Watch static and templates for browser reloading. -config :algora, AlgoraWeb.Endpoint, - live_reload: [ - patterns: [ - ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$", - ~r"priv/gettext/.*(po)$", - ~r"lib/algora_web/(controllers|components)/.*(ex|heex)$" - ], - notify: [ - live_view: [ - ~r"lib/algora_web/live/.*(ex|heex)$", - ~r"lib/algora_cloud/live/.*(ex|heex)$" - ] - ] - ] - -# Enable dev routes for dashboard and mailbox -config :algora, dev_routes: true - -# Configures Elixir's Logger -config :logger, :console, - format: "[$level] $message $metadata\n", - level: String.to_atom(System.get_env("LOG_LEVEL") || "debug"), - metadata: [:mfa, :file, :line, :request_id, :user_id] - -# Set a higher stacktrace during development. Avoid configuring such -# in production as building large stacktraces may be expensive. -config :phoenix, :stacktrace_depth, 20 - -# Initialize plugs at runtime for faster development compilation -config :phoenix, :plug_init_mode, :runtime - -# Include HEEx debug annotations as HTML comments in rendered markup -config :phoenix_live_view, :debug_heex_annotations, true - -# Disable swoosh api client as it is only required for production adapters. -config :swoosh, :api_client, false - -# Path to install SaladUI components -config :salad_ui, components_path: Path.join(File.cwd!(), "lib/algora_web/components/ui") - -config :hound, - browser: "chrome", - driver: "chrome_driver", - host: "localhost", - port: System.get_env("CHROMEDRIVER_PORT") - -config :algora, - bucket_name: System.get_env("BUCKET_NAME"), - cloudflare_tunnel: System.get_env("CLOUDFLARE_TUNNEL"), - auto_start_pollers: System.get_env("AUTO_START_POLLERS") == "true" - -config :algora, :discord, webhook_url: System.get_env("DISCORD_WEBHOOK_URL") - -config :algora, :login_code, - ttl: String.to_integer(System.get_env("LOGIN_CODE_TTL", "3600")), - salt: System.get_env("LOGIN_CODE_SALT", "algora-login-code") - -config :algora, :local_store, - ttl: String.to_integer(System.get_env("LOCAL_STORE_TTL", "3600")), - salt: System.get_env("LOCAL_STORE_SALT", "algora-local-store") - -config :algora, - canonical_host: System.get_env("CANONICAL_HOST"), - plausible_embed_url: System.get_env("PLAUSIBLE_EMBED_URL"), - posthog_project_id: System.get_env("POSTHOG_PROJECT_ID"), - assets_url: System.get_env("ASSETS_URL"), - storage_url: System.get_env("STORAGE_URL"), - ingest_url: System.get_env("INGEST_URL"), - ingest_static_url: System.get_env("INGEST_STATIC_URL"), - ingest_token: System.get_env("INGEST_TOKEN") - -config :algora, AlgoraWeb.OGImageController, max_age: 600 diff --git a/config/prod.exs b/config/prod.exs deleted file mode 100644 index 1b91c38a3..000000000 --- a/config/prod.exs +++ /dev/null @@ -1,24 +0,0 @@ -import Config - -# Note we also include the path to a cache manifest -# containing the digested version of static files. This -# manifest is generated by the `mix assets.deploy` task, -# which you should run after static files are built and -# before starting your production server. -config :algora, AlgoraWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json" - -# Configures Swoosh API Client -config :swoosh, api_client: Swoosh.ApiClient.Finch, finch_name: Algora.Finch - -# Disable Swoosh Local Memory Storage -config :swoosh, local: false - -config :logger, - format: {LogfmtEx, :format}, - level: String.to_atom(System.get_env("LOG_LEVEL") || "info"), - metadata: :all - -config :algora, AlgoraWeb.OGImageController, max_age: 600 - -# Runtime production configuration, including reading -# of environment variables, is done on config/runtime.exs. diff --git a/config/runtime.exs b/config/runtime.exs deleted file mode 100644 index 2e7206af3..000000000 --- a/config/runtime.exs +++ /dev/null @@ -1,174 +0,0 @@ -import Config - -# config/runtime.exs is executed for all environments, including -# during releases. It is executed after compilation and before the -# system starts, so it is typically used to load production configuration -# and secrets from environment variables or elsewhere. Do not define -# any compile-time configuration in here, as it won't be applied. -# The block below contains prod specific runtime configuration. - -# ## Using releases -# -# If you use `mix release`, you need to explicitly enable the server -# by passing the PHX_SERVER=true when you start it: -# -# PHX_SERVER=true bin/algora start -# -# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server` -# script that automatically sets the env var above. -if System.get_env("PHX_SERVER") do - config :algora, AlgoraWeb.Endpoint, server: true -end - -if config_env() == :prod do - config :algora, :github, - client_id: System.fetch_env!("GITHUB_CLIENT_ID"), - client_secret: System.fetch_env!("GITHUB_CLIENT_SECRET"), - app_handle: System.fetch_env!("GITHUB_APP_HANDLE"), - app_id: System.fetch_env!("GITHUB_APP_ID"), - webhook_secret: System.fetch_env!("GITHUB_WEBHOOK_SECRET"), - private_key: System.fetch_env!("GITHUB_PRIVATE_KEY"), - pat: System.get_env("GITHUB_PAT"), - pat_enabled: System.get_env("GITHUB_PAT_ENABLED", "false") == "true", - bot_handle: System.get_env("GITHUB_BOT_HANDLE"), - oauth_state_ttl: String.to_integer(System.get_env("GITHUB_OAUTH_STATE_TTL", "600")), - oauth_state_salt: System.fetch_env!("GITHUB_OAUTH_STATE_SALT") - - config :stripity_stripe, - api_key: System.fetch_env!("STRIPE_SECRET_KEY"), - api_version: "2022-11-15" - - config :algora, :stripe, - secret_key: System.fetch_env!("STRIPE_SECRET_KEY"), - publishable_key: System.fetch_env!("STRIPE_PUBLISHABLE_KEY"), - webhook_secret: System.fetch_env!("STRIPE_WEBHOOK_SECRET") - - database_url = - System.get_env("DATABASE_URL") || - raise """ - environment variable DATABASE_URL is missing. - For example: ecto://USER:PASS@HOST/DATABASE - """ - - maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: [] - - config :algora, Algora.Repo, - # ssl: true, - url: database_url, - pool_size: String.to_integer(System.get_env("POOL_SIZE") || "20"), - socket_options: maybe_ipv6, - migration_primary_key: [type: :string], - migration_timestamps: [type: :utc_datetime_usec] - - config :ex_aws, - json_codec: Jason, - access_key_id: System.fetch_env!("AWS_ACCESS_KEY_ID"), - secret_access_key: System.fetch_env!("AWS_SECRET_ACCESS_KEY") - - config :ex_aws, :s3, - scheme: "https://", - host: URI.parse(System.fetch_env!("AWS_ENDPOINT_URL_S3")).host, - region: System.fetch_env!("AWS_REGION") - - # The secret key base is used to sign/encrypt cookies and other secrets. - # A default value is used in config/dev.exs and config/test.exs but you - # want to use a different value for prod and you most likely don't want - # to check this value into version control, so we use an environment - # variable instead. - secret_key_base = - System.get_env("SECRET_KEY_BASE") || - raise """ - environment variable SECRET_KEY_BASE is missing. - You can generate one by calling: mix phx.gen.secret - """ - - host = System.get_env("PHX_HOST") || "example.com" - port = String.to_integer(System.get_env("PORT") || "4000") - - config :algora, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") - - config :algora, AlgoraWeb.Endpoint, - url: [host: host, port: 443, scheme: "https"], - http: [ - # Enable IPv6 and bind on all interfaces. - # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. - # See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0 - # for details about using IPv6 vs IPv4 and loopback vs public addresses. - ip: {0, 0, 0, 0, 0, 0, 0, 0}, - port: port - ], - check_origin: - (case "ALLOWED_ORIGINS" - |> System.get_env("") - |> String.split(",") - |> Enum.map(&String.trim/1) do - [""] -> true - origins -> origins - end), - secret_key_base: secret_key_base - - # ## SSL Support - # - # To get SSL working, you will need to add the `https` key - # to your endpoint configuration: - # - # config :algora, AlgoraWeb.Endpoint, - # https: [ - # ..., - # port: 443, - # cipher_suite: :strong, - # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), - # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") - # ] - # - # The `cipher_suite` is set to `:strong` to support only the - # latest and more secure SSL ciphers. This means old browsers - # and clients may not be supported. You can set it to - # `:compatible` for wider support. - # - # `:keyfile` and `:certfile` expect an absolute path to the key - # and cert in disk or a relative path inside priv, for example - # "priv/ssl/server.key". For all supported SSL configuration - # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 - # - # We also recommend setting `force_ssl` in your config/prod.exs, - # ensuring no data is ever sent via http, always redirecting to https: - # - # config :algora, AlgoraWeb.Endpoint, - # force_ssl: [hsts: true] - # - # Check `Plug.SSL` for all available options in `force_ssl`. - - config :algora, Algora.Mailer, - adapter: Swoosh.Adapters.Sendgrid, - api_key: System.get_env("SENDGRID_API_KEY") - - config :swoosh, api_client: Swoosh.ApiClient.Finch, finch_name: Algora.Finch - - config :algora, - bucket_name: System.fetch_env!("BUCKET_NAME"), - auto_start_pollers: System.get_env("AUTO_START_POLLERS") == "true" - - config :algora, :discord, webhook_url: System.get_env("DISCORD_WEBHOOK_URL") - - config :algora, :login_code, - ttl: String.to_integer(System.get_env("LOGIN_CODE_TTL", "3600")), - salt: System.fetch_env!("LOGIN_CODE_SALT") - - config :algora, :local_store, - ttl: String.to_integer(System.get_env("LOCAL_STORE_TTL", "3600")), - salt: System.fetch_env!("LOCAL_STORE_SALT") - - config :algora, - canonical_host: System.get_env("CANONICAL_HOST"), - plausible_embed_url: System.get_env("PLAUSIBLE_EMBED_URL"), - posthog_project_id: System.get_env("POSTHOG_PROJECT_ID"), - assets_url: System.get_env("ASSETS_URL"), - storage_url: System.get_env("STORAGE_URL"), - ingest_url: System.get_env("INGEST_URL"), - ingest_static_url: System.get_env("INGEST_STATIC_URL"), - ingest_token: System.get_env("INGEST_TOKEN") - - config :chromic_pdf, - chrome_executable: "/app/puppeteer/chrome/linux-134.0.6998.35/chrome-linux64/chrome" -end diff --git a/config/test.exs b/config/test.exs deleted file mode 100644 index b3d77b961..000000000 --- a/config/test.exs +++ /dev/null @@ -1,75 +0,0 @@ -import Config - -# Configure your database -# -# The MIX_TEST_PARTITION environment variable can be used -# to provide built-in test partitioning in CI environment. -# Run `mix help test` for more information. -config :algora, Algora.Repo, - url: System.get_env("TEST_DATABASE_URL"), - pool: Ecto.Adapters.SQL.Sandbox, - pool_size: System.schedulers_online() * 2, - migration_primary_key: [type: :string], - migration_timestamps: [type: :utc_datetime_usec] - -# We don't run a server during test. If one is required, -# you can enable the server option below. -config :algora, AlgoraWeb.Endpoint, - http: [ip: {127, 0, 0, 1}, port: 4002], - secret_key_base: "M+VvXlmVxm5bl+xdXcImlpFP7Kob6M/sYK4SoaPgF0Spteix9NWw7WimjBQolY6V", - server: false - -config :algora, Oban, queues: false, plugins: false - -# In test we don't send emails. -config :algora, Algora.Mailer, adapter: Swoosh.Adapters.Test - -# Disable swoosh api client as it is only required for production adapters. -config :swoosh, :api_client, false - -# Print only warnings and errors during test -config :logger, level: :warning - -# Initialize plugs at runtime for faster test compilation -config :phoenix, :plug_init_mode, :runtime - -config :algora, :github, - client_id: System.get_env("GITHUB_CLIENT_ID"), - client_secret: System.get_env("GITHUB_CLIENT_SECRET"), - app_handle: System.get_env("GITHUB_APP_HANDLE"), - app_id: System.get_env("GITHUB_APP_ID"), - webhook_secret: System.get_env("GITHUB_WEBHOOK_SECRET"), - private_key: System.get_env("GITHUB_PRIVATE_KEY"), - pat: System.get_env("GITHUB_PAT"), - pat_enabled: System.get_env("GITHUB_PAT_ENABLED", "false") == "true", - oauth_state_ttl: String.to_integer(System.get_env("GITHUB_OAUTH_STATE_TTL", "600")), - oauth_state_salt: System.get_env("GITHUB_OAUTH_STATE_SALT", "github-oauth-state") - -config :algora, :stripe_client, Algora.Support.StripeMock -config :algora, :github_client, Algora.Support.GithubMock - -config :algora, - cloudflare_tunnel: System.get_env("CLOUDFLARE_TUNNEL"), - auto_start_pollers: System.get_env("AUTO_START_POLLERS") == "true" - -config :algora, :stripe, - test_customer_id: System.get_env("STRIPE_TEST_CUSTOMER_ID"), - test_account_id: System.get_env("STRIPE_TEST_ACCOUNT_ID") - -config :algora, :login_code, - ttl: String.to_integer(System.get_env("LOGIN_CODE_TTL", "3600")), - salt: System.get_env("LOGIN_CODE_SALT", "algora-login-code") - -config :algora, :local_store, - ttl: String.to_integer(System.get_env("LOCAL_STORE_TTL", "3600")), - salt: System.get_env("LOCAL_STORE_SALT", "algora-local-store") - -config :algora, :discord, webhook_url: System.get_env("DISCORD_WEBHOOK_URL") - -config :algora, - plausible_embed_url: System.get_env("PLAUSIBLE_EMBED_URL"), - assets_url: System.get_env("ASSETS_URL"), - storage_url: System.get_env("STORAGE_URL"), - ingest_url: System.get_env("INGEST_URL"), - ingest_static_url: System.get_env("INGEST_STATIC_URL"), - ingest_token: System.get_env("INGEST_TOKEN") diff --git a/coveralls.json b/coveralls.json deleted file mode 100644 index 61853132b..000000000 --- a/coveralls.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "skip_files": [ - "lib/algora/integrations", - "lib/algora/shared/*", - "lib/algora/admin/admin.ex", - "lib/algora_web/components", - "lib/algora_web", - "lib/mix/tasks", - "priv/", - "test/support" - ], - "default_stop_words": [ - "defmodule", - "defrecord", - "defimpl", - "def.+(.+\/\/.+).+do", - "typed_schema", - "use .+" - ], - - "custom_stop_words": [ - ], - - "coverage_options": { - "treat_no_relevant_lines_as_covered": true, - "output_dir": "cover/", - "html_filter_full_covered": true - } -} diff --git a/fiex b/fiex deleted file mode 100755 index f3dbef9e8..000000000 --- a/fiex +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -set -e - -fly ssh console --pty --select -C "/app/bin/algora remote" \ No newline at end of file diff --git a/fly.toml b/fly.toml deleted file mode 100644 index fec7efffe..000000000 --- a/fly.toml +++ /dev/null @@ -1,40 +0,0 @@ -# fly.toml app configuration file generated for algora on 2025-04-02T14:15:15+03:00 -# -# See https://fly.io/docs/reference/configuration/ for information about how to use this file. -# - -app = 'algora' -primary_region = 'iad' -kill_signal = 'SIGTERM' - -[build] - -[deploy] - strategy = "bluegreen" - release_command = '/app/bin/migrate' - -[env] - PHX_HOST = 'algora.io' - PORT = '4000' - -[http_service] - internal_port = 4000 - force_https = true - auto_stop_machines = 'suspend' - auto_start_machines = true - min_machines_running = 1 - processes = ['app'] - -[http_service.concurrency] - type = 'connections' - hard_limit = 1000 - soft_limit = 100 - -[[http_service.checks]] - grace_period = "10s" - interval = "15s" - timeout = "2s" - method = "GET" - path = "/health" - [http_service.checks.headers] - X-Forwarded-Proto = "https" \ No newline at end of file diff --git a/lib/algora.ex b/lib/algora.ex deleted file mode 100644 index bc2711b76..000000000 --- a/lib/algora.ex +++ /dev/null @@ -1,23 +0,0 @@ -defmodule Algora do - @moduledoc """ - Algora keeps the contexts that define your domain - and business logic. - - Contexts are also responsible for managing your data, regardless - if it comes from the database, an external API or others. - """ - - @doc """ - Looks up `Application` config or raises if keyspace is not configured. - """ - def config([main_key | rest] = keyspace) when is_list(keyspace) do - main = Application.fetch_env!(:algora, main_key) - - Enum.reduce(rest, main, fn next_key, current -> - case Keyword.fetch(current, next_key) do - {:ok, val} -> val - :error -> raise ArgumentError, "no config found under #{inspect(keyspace)}" - end - end) - end -end diff --git a/lib/algora/accounts/accounts.ex b/lib/algora/accounts/accounts.ex deleted file mode 100644 index 6ceefe945..000000000 --- a/lib/algora/accounts/accounts.ex +++ /dev/null @@ -1,921 +0,0 @@ -defmodule Algora.Accounts do - @moduledoc false - import Ecto.Changeset - import Ecto.Query - - alias Algora.Accounts.Identity - alias Algora.Accounts.User - alias Algora.Accounts.UserMedia - alias Algora.Bounties.Bounty - alias Algora.Contracts.Contract - alias Algora.Github - alias Algora.Organizations - alias Algora.Organizations.Member - alias Algora.Payments.Transaction - alias Algora.Repo - alias Algora.Workspace.Contributor - alias Algora.Workspace.Installation - alias Algora.Workspace.Repository - alias Algora.Workspace.Ticket - alias Swoosh.Email - - require Algora.SQL - require Logger - - def base_query, do: User - - @type criterion :: - {:id, binary()} - | {:ids, [binary()]} - | {:org_id, binary()} - | {:limit, non_neg_integer()} - | {:handle, String.t()} - | {:handles, [String.t()]} - | {:provider_logins, [String.t()]} - | {:earnings_gt, Money.t()} - | {:sort_by_country, String.t()} - | {:sort_by_tech_stack, [String.t()]} - - @spec apply_criteria(Ecto.Queryable.t(), [criterion()]) :: Ecto.Queryable.t() - defp apply_criteria(query, criteria) do - Enum.reduce(criteria, query, fn - {:id, id}, query -> - from([b] in query, where: b.id == ^id) - - {:ids, ids}, query -> - from([b] in query, where: b.id in ^ids) - - {:limit, :infinity}, query -> - query - - {:limit, limit}, query -> - from([b] in query, limit: ^limit) - - {:handle, handle}, query -> - from([b] in query, where: b.handle == ^handle) - - {:handles, handles}, query -> - from([b] in query, where: b.handle in ^handles) - - {:provider_logins, logins}, query -> - from([b] in query, where: b.provider_login in ^logins) - - {:earnings_gt, min_amount}, query -> - from([b, earnings: e] in query, - where: - fragment( - "?::money_with_currency >= (?, ?)::money_with_currency", - e.total_earned, - ^to_string(min_amount.currency), - ^min_amount.amount - ) - ) - - {:sort_by_country, country}, query -> - from([b] in query, - order_by: [fragment("CASE WHEN ? = ? THEN 0 ELSE 1 END", b.country, ^country)] - ) - - {:sort_by_tech_stack, tech_stack}, query -> - from([b] in query, - order_by: [ - fragment( - "array_length(ARRAY(SELECT UNNEST(?::citext[]) INTERSECT SELECT UNNEST(?::citext[])), 1) DESC NULLS LAST", - b.tech_stack, - ^tech_stack - ) - ] - ) - - _, query -> - query - end) - end - - def list_developers_with(base_query, criteria \\ []) do - criteria = Keyword.merge([limit: 10], criteria) - - base_users = - base_query - |> where([u], u.type == :individual) - |> select([b], b.id) - - filter_org_id = - if org_id = criteria[:org_id], - do: dynamic([linked_transaction: ltx, repository: r], ltx.user_id == ^org_id or r.user_id == ^org_id), - else: true - - earnings_query = - from tx in Transaction, - where: tx.type == :credit and tx.status == :succeeded, - left_join: ltx in assoc(tx, :linked_transaction), - as: :linked_transaction, - left_join: b in assoc(tx, :bounty), - left_join: t in assoc(b, :ticket), - left_join: r in assoc(t, :repository), - as: :repository, - where: ^filter_org_id, - group_by: tx.user_id, - select: %{ - user_id: tx.user_id, - total_earned: sum(tx.net_amount) - } - - transactions_query = - from t in Transaction, - where: t.type == :credit and t.status == :succeeded, - group_by: t.user_id, - select: %{ - user_id: t.user_id, - transactions_count: count(t.id) - } - - projects_query = - from tx in Transaction, - where: tx.type == :credit and tx.status == :succeeded, - left_join: bounty in assoc(tx, :bounty), - left_join: tip in assoc(tx, :tip), - join: t in Ticket, - on: t.id == bounty.ticket_id or t.id == tip.ticket_id, - left_join: r in assoc(t, :repository), - group_by: tx.user_id, - select: %{ - user_id: tx.user_id, - projects_count: count(fragment("DISTINCT ?", r.user_id)) - } - - User - |> join(:inner, [u], b in subquery(base_users), as: :base, on: u.id == b.id) - |> join(:left, [u], e in subquery(earnings_query), as: :earnings, on: e.user_id == u.id) - |> join(:left, [u], t in subquery(transactions_query), as: :transactions, on: t.user_id == u.id) - |> join(:left, [u], p in subquery(projects_query), as: :projects, on: p.user_id == u.id) - |> apply_criteria(criteria) - |> order_by([earnings: e], desc_nulls_last: e.total_earned) - |> order_by([u], desc: u.id) - |> select_merge([u, earnings: e, transactions: t, projects: p], %{ - total_earned: Algora.SQL.money_or_zero(e.total_earned), - transactions_count: coalesce(t.transactions_count, 0), - contributed_projects_count: coalesce(p.projects_count, 0) - }) - |> Repo.all() - |> Enum.map(&User.after_load/1) - end - - def list_developers(criteria \\ []) do - list_developers_with(base_query(), criteria) - end - - def list_contributed_projects(user, opts \\ []) do - # order_by = - # if tech_stack = opts[:tech_stack] do - # dynamic([tx, r: r], fragment("? && ?::citext[]", r.tech_stack, ^tech_stack)) - # else - # true - # end - - Repo.all( - from tx in Transaction, - where: tx.type == :credit, - where: tx.status == :succeeded, - where: tx.user_id == ^user.id, - join: ltx in assoc(tx, :linked_transaction), - left_join: bounty in assoc(tx, :bounty), - left_join: tip in assoc(tx, :tip), - join: t in Ticket, - on: t.id == bounty.ticket_id or t.id == tip.ticket_id, - left_join: r in assoc(t, :repository), - as: :r, - left_join: ro in User, - on: fragment("? = (case when ? is null then ? else ? end)", ro.id, r.user_id, ltx.user_id, r.user_id), - # order_by: ^[desc: order_by], - order_by: [desc: sum(tx.net_amount)], - group_by: [ro.id], - select: {ro, sum(tx.net_amount)}, - limit: ^opts[:limit] - ) - end - - @spec fetch_developer(binary()) :: {:ok, User.t()} | {:error, :not_found} - def fetch_developer(id) do - case list_developers(id: id, limit: 1) do - [developer] -> {:ok, developer} - _ -> {:error, :not_found} - end - end - - @spec fetch_developer_by([criterion()]) :: {:ok, User.t()} | {:error, :not_found} - def fetch_developer_by(criteria) do - criteria = Keyword.put(criteria, :limit, 1) - - case list_developers(criteria) do - [developer] -> {:ok, developer} - _ -> {:error, :not_found} - end - end - - def list_featured_developers(_country \\ nil) do - case Algora.Settings.get_featured_developers() do - handles when is_list(handles) and handles != [] -> - list_developers(handles: handles) - - _ -> - list_developers(limit: 5) - end - end - - def get_users_map(user_ids) when is_list(user_ids) do - Repo.all(from u in User, where: u.id in ^user_ids, select: {u.id, u}) - end - - def update_settings(%User{} = user, attrs) do - user |> User.settings_changeset(attrs) |> Repo.update() - end - - def update_job_preferences(%User{} = user, attrs) do - user |> User.job_preferences_changeset(attrs) |> Repo.update() - end - - def update_hiring_preferences(%User{} = user, attrs) do - user |> User.hiring_changeset(attrs) |> Repo.update() - end - - def update_resume(%User{} = user, resume) do - user |> User.resume_changeset(%{resume: resume}) |> Repo.update() - end - - ## Database getters - - @doc """ - Gets a user by email. - - ## Examples - - iex> get_user_by_email("foo@example.com") - %User{} - - iex> get_user_by_email("unknown@example.com") - nil - - """ - def get_user_by_email(email) when is_binary(email) do - Repo.get_by(User, email: email) - end - - @doc """ - Gets a single user. - - Raises `Ecto.NoResultsError` if the User does not exist. - - ## Examples - - iex> get_user!(123) - %User{} - - iex> get_user!(456) - ** (Ecto.NoResultsError) - - """ - def get_user!(id), do: Repo.get!(User, id) - - def get_user(id), do: Repo.get(User, id) - - def get_user_by(fields), do: Repo.get_by(User, fields) - - def get_user_by!(fields), do: Repo.get_by!(User, fields) - - @doc """ - Gets a user by checking email across multiple fields. - - Checks the following fields in this order: - - user.provider_meta["email"] - - user.internal_email - - user.email - - Returns the first user found matching any of these email fields. - - ## Examples - - iex> list_users_by_any_email("user@example.com") - %User{} - - iex> list_users_by_any_email("unknown@example.com") - nil - - """ - def list_users_by_any_email(email) when is_binary(email) do - Repo.all( - from u in User, - where: fragment("COALESCE(?, ?, ?->>'email') = ?", u.internal_email, u.email, u.provider_meta, ^email) - ) - end - - @spec fetch_user_by(clauses :: Keyword.t() | map()) :: - {:ok, User.t()} | {:error, :not_found} - def fetch_user_by(clauses) do - Repo.fetch_by(User, clauses) - end - - ## User registration - - @doc """ - Registers a user from their GitHub information. - """ - def register_github_user(current_user, primary_email, info, emails, token) do - matching_users = - Repo.all( - from u in User, - where: u.email == ^primary_email or (u.provider == "github" and u.provider_id == ^to_string(info["id"])) - ) - - primary_user = - case {current_user, matching_users} do - {current_user, _} when not is_nil(current_user) -> current_user - {_, [user]} -> user - {_, users} -> Enum.find(users, &(&1.provider == "github" and &1.provider_id == to_string(info["id"]))) - end - - case primary_user do - nil -> create_user(info, primary_email, emails, token) - user -> update_user(user, info, primary_email, emails, token) - end - end - - def create_user(info, primary_email, emails, token) do - nil - |> User.github_registration_changeset(info, primary_email, emails, token) - |> Repo.insert(returning: true) - end - - def update_user(user, info, primary_email, emails, token) do - github_user = Repo.get_by(User, provider: "github", provider_id: to_string(info["id"])) - email_user = Repo.get_by(User, email: primary_email) - - Repo.tx(fn -> - Repo.delete_all(from(i in Identity, where: i.provider == "github" and i.provider_id == ^to_string(info["id"]))) - - with true <- github_user && github_user.id != user.id, - {:ok, github_user} <- - github_user |> change(provider: nil, provider_id: nil, provider_login: nil) |> Repo.update(), - {:ok, _} <- - Repo.insert_activity(github_user, %{ - type: :user_migrated, - meta: %{provider: "github", provider_id: to_string(info["id"])}, - changes: %{from: %{provider: "github", provider_id: to_string(info["id"])}, to: %{}}, - notify_users: [] - }) do - migrate_user(github_user.id, user.id) - else - {:error, reason} -> - Logger.error("Failed to migrate user: #{inspect(reason)}") - - _ -> - :ok - end - - with true <- email_user && email_user.id != user.id, - {:ok, email_user} <- email_user |> change(email: nil) |> Repo.update(), - {:ok, _} <- - Repo.insert_activity(email_user, %{ - type: :user_migrated, - meta: %{email: primary_email}, - changes: %{from: %{email: primary_email}, to: %{}}, - notify_users: [] - }) do - migrate_user(email_user.id, user.id) - else - {:error, reason} -> - Logger.error("Failed to migrate user: #{inspect(reason)}") - - _ -> - :ok - end - - identity_changeset = Identity.github_registration_changeset(user, info, primary_email, emails, token) - user_changeset = User.github_registration_changeset(user, info, primary_email, emails, token) - - with {:ok, _} <- Repo.insert(identity_changeset), - {:ok, user} <- Repo.update(user_changeset) do - {:ok, user} - else - {:error, reason} -> - Logger.error("Failed to update user: #{inspect(reason)}") - {:error, reason} - end - end) - end - - def migrate_user(old_user_id, new_user_id) do - # TODO: enqueue job - Repo.update_all( - from(r in Repository, where: r.user_id == ^old_user_id), - set: [user_id: new_user_id] - ) - - Repo.update_all( - from(m in Member, - where: m.user_id == ^old_user_id, - where: fragment("not exists (select 1 from members where user_id = ? and org_id = ?)", ^new_user_id, m.org_id) - ), - set: [user_id: new_user_id] - ) - - Repo.update_all( - from(m in Member, - where: m.user_id == ^new_user_id, - where: - fragment( - "exists (select 1 from members where user_id = ? and org_id = ? and role = 'admin')", - ^old_user_id, - m.org_id - ) - ), - set: [role: "admin"] - ) - - Repo.update_all( - from(c in Contributor, where: c.user_id == ^old_user_id), - set: [user_id: new_user_id] - ) - - Repo.update_all( - from(c in Contract, where: c.contractor_id == ^old_user_id), - set: [contractor_id: new_user_id] - ) - - Repo.update_all( - from(i in Installation, where: i.owner_id == ^old_user_id), - set: [owner_id: new_user_id] - ) - - Repo.update_all( - from(i in Installation, where: i.provider_user_id == ^old_user_id), - set: [provider_user_id: new_user_id] - ) - - Repo.update_all( - from(i in Installation, where: i.connected_user_id == ^old_user_id), - set: [connected_user_id: new_user_id] - ) - end - - def register_org(params) do - params |> User.org_registration_changeset() |> Repo.insert(returning: true) - end - - def auto_join_orgs(user) do - domain = user.email |> String.split("@") |> List.last() - - if domain in ["github.com"] do - [] - else - orgs = - Repo.all( - from o in User, - left_join: m in Member, - on: m.org_id == o.id and m.user_id == ^user.id, - where: o.domain == ^domain and is_nil(m.id) - ) - - Enum.each(orgs, fn org -> - case Organizations.create_member(org, user, :mod) do - {:ok, _member} -> - Algora.Activities.alert("#{user.email} joined #{org.name}", :info) - - {:error, _reason} -> - Algora.Activities.alert("#{user.email} failed to join #{org.name}", :error) - end - end) - - if org = List.first(orgs) do - update_settings(user, %{last_context: org.handle}) - end - - orgs - end - end - - def get_or_register_user(email, attr \\ %{}) do - res = - case get_user_by_email(email) do - nil -> attr |> Map.put(:email, email) |> register_org() - user -> {:ok, user} - end - - with {:ok, user} <- res do - auto_join_orgs(user) - res - end - end - - # def get_user_by_provider_email(provider, email) when provider in [:github] do - # query = - # from(u in User, - # join: i in assoc(u, :identities), - # where: - # i.provider == ^to_string(provider) and - # fragment("lower(?)", u.email) == ^String.downcase(email) - # ) - - # Repo.one(query) - # end - - def get_user_by_provider_id(provider, id) when provider in [:github] do - query = - from(u in User, - left_join: i in Identity, - on: i.provider == "github" and u.provider_id == ^to_string(id), - where: u.provider == "github" and u.provider_id == ^to_string(id), - select: {u, i} - ) - - Repo.one(query) - end - - def get_user_by_handle(handle) do - query = - from(u in User, - where: u.handle == ^handle, - select: u - ) - - Repo.one(query) - end - - def get_access_token(%User{} = user) do - case Repo.one(from(i in Identity, where: i.user_id == ^user.id and i.provider == "github")) do - %Identity{provider_token: token} -> {:ok, token} - _ -> {:error, :not_found} - end - end - - def has_fresh_token?(nil), do: false - - def has_fresh_token?(%User{} = user) do - # TODO: use refresh tokens and check expiration - case get_access_token(user) do - {:ok, token} -> - case Github.get_user(token, user.provider_id) do - {:ok, _} -> true - _ -> false - end - - _ -> - false - end - end - - def get_random_access_tokens(n) when is_integer(n) and n > 0 do - case Identity - |> where([i], i.provider == "github" and not is_nil(i.provider_token)) - |> order_by(fragment("RANDOM()")) - |> limit(^n) - |> select([i], i.provider_token) - |> Repo.all() do - [""] -> [] - tokens -> tokens - end - end - - def last_context(nil), do: "nil" - - def last_context(%User{last_context: nil} = user) do - contexts = get_contexts(user) - - last_debit_query = - from(t in Transaction, - join: u in assoc(t, :user), - where: t.type == :debit, - where: u.id in ^Enum.map(contexts, & &1.id), - order_by: [desc: t.succeeded_at], - limit: 1, - select_merge: %{user: u} - ) - - last_bounty_query = - from(b in Bounty, - join: c in assoc(b, :creator), - where: c.id in ^Enum.map(contexts, & &1.id), - order_by: [desc: b.inserted_at], - limit: 1, - select_merge: %{creator: c} - ) - - new_context = - cond do - last_debit = Repo.one(last_debit_query) -> last_debit.user.handle - last_bounty = Repo.one(last_bounty_query) -> last_bounty.owner.handle - true -> default_context() - end - - update_settings(user, %{last_context: new_context}) - - new_context - end - - def last_context(%User{last_context: last_context}), do: last_context - - def get_last_context_user(nil), do: nil - - def get_last_context_user(%User{} = user) do - case last_context(user) do - "personal" -> - user - - "preview/" <> ctx -> - case String.split(ctx, "/") do - [id, _repo_owner, _repo_name] -> get_user(id) - _ -> nil - end - - "repo/" <> _repo_full_name -> - user - - last_context -> - get_user_by_handle(last_context) - end - end - - def ensure_org_context(%User{} = user) do - case get_last_context_user(user) do - %User{type: :organization} = ctx -> - {:ok, ctx} - - _ -> - orgs = Organizations.get_user_orgs(user) - - new_context = if orgs == [], do: user, else: List.first(orgs) - - update_settings(user, %{last_context: new_context.handle}) - end - end - - def default_context, do: "personal" - - def set_context(%User{} = user, "personal") do - update_settings(user, %{last_context: "personal"}) - end - - def set_context(%User{} = user, "preview/" <> id) do - context = - case Repo.get(User, id) do - nil -> - nil - - user -> - case user.last_context do - "repo/" <> repo_full_name -> "preview/#{id}/#{repo_full_name}" - _ -> nil - end - end - - case context do - nil -> {:error, :not_found} - context -> update_settings(user, %{last_context: context}) - end - end - - def set_context(%User{} = user, context) do - if context == user.handle do - update_settings(user, %{last_context: context}) - else - membership = - Repo.one( - from(m in Member, - join: o in assoc(m, :org), - where: m.user_id == ^user.id and o.handle == ^context - ) - ) - - if membership || user.is_admin do - update_settings(user, %{last_context: context}) - else - {:error, :unauthorized} - end - end - end - - def get_contexts(nil), do: [] - - def get_contexts(%User{} = user) do - [user | Organizations.get_user_orgs(user)] - end - - # TODO: fetch from db - def list_community(tech_stack) do - community_file = :algora |> :code.priv_dir() |> Path.join("dev/community/#{tech_stack}.json") - - with true <- File.exists?(community_file), - {:ok, contents} <- File.read(community_file), - {:ok, community} <- Jason.decode(contents) do - community - else - _ -> [] - end - end - - # TODO: remove hardcoded techs - def list_techs do - tech_order = [ - "TypeScript", - "Rust", - "Scala", - "Python", - "Go", - "C++", - "Java", - "Swift", - "PHP", - "Elixir", - "Haskell", - "Ruby" - ] - - :algora - |> :code.priv_dir() - |> Path.join("dev/community") - |> File.ls!() - |> Enum.filter(&String.ends_with?(&1, ".json")) - |> Enum.map(&String.trim_trailing(&1, ".json")) - |> Enum.sort_by(fn tech -> Enum.find_index(tech_order, &(&1 == tech)) || 999 end) - end - - def deliver_totp_signup_email(email, code) do - email = - Email.new() - |> Email.to(email) - |> Email.from({"Algora", "info@algora.io"}) - |> Email.subject("#{code} - Your Algora verification code") - |> Email.text_body(""" - Hi there, - - To verify your identity, enter the 6-digit code in the original window: - - #{code} - - If you didn't request this code, you can safely ignore this email. - - -------------------------------------------------------------------------------- - - For correspondence, please email the Algora founders at ioannis@algora.io and zafer@algora.io - - © #{Date.utc_today().year} Algora PBC. - """) - - Algora.Mailer.deliver(email) - end - - def deliver_totp_login_email(user, code) do - email = - Email.new() - |> Email.to({user.display_name, user.email}) - |> Email.from({"Algora", "info@algora.io"}) - |> Email.subject("#{code} - Algora Sign-in Verification") - |> Email.text_body(""" - Hello #{user.display_name}, - - To complete the sign-in process; enter the 6-digit code in the original window: - - #{code} - - If you didn't request this link, you can safely ignore this email. - - -------------------------------------------------------------------------------- - - For correspondence, please email the Algora founders at ioannis@algora.io and zafer@algora.io - - © #{Date.utc_today().year} Algora PBC. - """) - - Algora.Mailer.deliver(email) - end - - def list_user_media(%User{} = user) do - Repo.all(from m in UserMedia, where: m.user_id == ^user.id) - end - - def youtube_url?(url) do - String.contains?(url, "youtube.com") or String.contains?(url, "youtu.be") - end - - def canonicalize_youtube_url(url) do - cond do - # Handle youtu.be URLs - String.contains?(url, "youtu.be/") -> - id = url |> String.split("youtu.be/") |> List.last() |> String.split("?") |> List.first() - "https://youtube.com/embed/#{id}" - - # Handle youtube.com/watch?v= URLs - String.contains?(url, "youtube.com/watch?v=") -> - id = url |> String.split("v=") |> List.last() |> String.split("&") |> List.first() - "https://youtube.com/embed/#{id}" - - # Handle youtube.com/embed/ URLs - String.contains?(url, "youtube.com/embed/") -> - "https://youtube.com/embed/#{url |> String.split("embed/") |> List.last()}" - - true -> - url - end - end - - def create_user_media(%User{} = user, attrs) do - if youtube_url?(attrs["url"]) do - %UserMedia{} - |> UserMedia.changeset( - attrs - |> Map.put("user_id", user.id) - |> Map.put("original_url", attrs["url"]) - |> Map.update!("url", &canonicalize_youtube_url/1) - ) - |> Repo.insert() - else - with {:ok, %{body: body, headers: headers, status: status}} when status in 200..299 <- - fetch_media(attrs["url"]), - object_path = media_object_path(user.id, body), - {:ok, _} <- - Algora.S3.upload(body, object_path, - content_type: extract_content_type(headers), - cache_control: "public, max-age=31536000, immutable" - ) do - s3_url = Path.join(Algora.S3.bucket_url(), object_path) - - %UserMedia{} - |> UserMedia.changeset( - attrs - |> Map.put("user_id", user.id) - |> Map.put("original_url", attrs["url"]) - |> Map.put("url", s3_url) - ) - |> Repo.insert() - else - {:ok, %{status: status}} -> - Logger.error("Failed to process media: #{inspect(status)}") - {:error, status} - - error -> - Logger.error("Failed to process media: #{inspect(error)}") - {:error, :media_processing_failed} - end - end - end - - def fetch_or_create_user_media(%User{} = user, attrs) do - # TODO: persist and compare against original URL - case Repo.fetch_by(UserMedia, user_id: user.id, original_url: attrs["url"]) do - {:ok, media} -> {:ok, media} - _ -> create_user_media(user, attrs) - end - end - - defp media_object_path(user_id, body) do - hash = :md5 |> :crypto.hash(body) |> Base.encode16(case: :lower) - Path.join(["media", to_string(user_id), hash]) - end - - def fetch_media(url, redirect_count \\ 0) do - if redirect_count > 3 do - {:error, :too_many_redirects} - else - case :get |> Finch.build(url) |> Finch.request(Algora.Finch) do - {:ok, %{status: status, headers: headers}} when status in 300..399 -> - case List.keyfind(headers, "location", 0) do - {_, location} -> fetch_media(location, redirect_count + 1) - nil -> {:error, :missing_redirect_location} - end - - {:ok, %{status: status}} when status in 400..499 -> - new_url = url |> URI.parse() |> Map.put(:query, nil) |> to_string() - - if new_url == url do - {:error, status} - else - fetch_media(new_url, redirect_count + 1) - end - - other -> - other - end - end - end - - def extract_content_type(headers) do - case List.keyfind(headers, "content-type", 0) do - {_, content_type} -> content_type - nil -> "application/octet-stream" - end - end - - def delete_user_media(%UserMedia{} = media) do - Repo.delete(media) - end - - def admins_last_active do - Repo.one( - from u in User, - where: u.is_admin == true, - order_by: [desc: u.last_active_at], - select: u.last_active_at, - limit: 1 - ) - end -end diff --git a/lib/algora/accounts/schemas/company.ex b/lib/algora/accounts/schemas/company.ex deleted file mode 100644 index e8ceee596..000000000 --- a/lib/algora/accounts/schemas/company.ex +++ /dev/null @@ -1,19 +0,0 @@ -defmodule Algora.Accounts.Company do - @moduledoc false - use Algora.Schema - - typed_schema "companies" do - field :name, :string - field :logo_url, :string - field :linkedin_id, :string - timestamps() - end - - def changeset(company, attrs) do - company - |> cast(attrs, [:name, :logo_url, :linkedin_id]) - |> validate_required([:name]) - |> generate_id() - |> unique_constraint(:name) - end -end diff --git a/lib/algora/accounts/schemas/follow.ex b/lib/algora/accounts/schemas/follow.ex deleted file mode 100644 index 0ce9fff6c..000000000 --- a/lib/algora/accounts/schemas/follow.ex +++ /dev/null @@ -1,38 +0,0 @@ -defmodule Algora.Accounts.Follow do - @moduledoc false - use Algora.Schema - - alias Algora.Accounts.User - - typed_schema "follows" do - belongs_to :follower, User, foreign_key: :follower_id, type: :string - belongs_to :followed, User, foreign_key: :followed_id, type: :string - - field :provider, :string, default: "github" - field :provider_created_at, :utc_datetime_usec - - timestamps() - end - - def changeset(follow, attrs) do - follow - |> cast(attrs, [:follower_id, :followed_id, :provider, :provider_created_at]) - |> validate_required([:follower_id, :followed_id, :provider]) - |> generate_id() - |> unique_constraint([:follower_id, :followed_id], name: :follows_follower_id_followed_id_index) - |> foreign_key_constraint(:follower_id) - |> foreign_key_constraint(:followed_id) - |> validate_not_self_follow() - end - - defp validate_not_self_follow(changeset) do - follower_id = get_field(changeset, :follower_id) - followed_id = get_field(changeset, :followed_id) - - if follower_id && followed_id && follower_id == followed_id do - add_error(changeset, :followed_id, "cannot follow yourself") - else - changeset - end - end -end diff --git a/lib/algora/accounts/schemas/identity.ex b/lib/algora/accounts/schemas/identity.ex deleted file mode 100644 index e6ad6c465..000000000 --- a/lib/algora/accounts/schemas/identity.ex +++ /dev/null @@ -1,76 +0,0 @@ -defmodule Algora.Accounts.Identity do - @moduledoc false - use Algora.Schema - - alias Algora.Accounts.Identity - alias Algora.Accounts.User - alias Algora.Activities.Activity - - @derive {Inspect, except: [:provider_token, :provider_meta]} - typed_schema "identities" do - field :provider, :string - field :provider_token, :string - field :provider_email, :string - field :provider_login, :string - field :provider_name, :string, virtual: true - field :provider_id, :string - field :provider_meta, :map - - belongs_to :user, User - - has_many :activities, {"identity_activities", Activity}, foreign_key: :assoc_id - - timestamps() - end - - @doc """ - A user changeset for github registration. - """ - def github_registration_changeset(nil, info, primary_email, emails, token) do - params = %{ - "provider_token" => token, - "provider_id" => to_string(info["id"]), - "provider_login" => info["login"], - "provider_name" => info["name"] || info["login"], - "provider_email" => primary_email - } - - %Identity{provider: "github", provider_meta: %{"user" => info, "emails" => emails}} - |> cast(params, [ - :provider_token, - :provider_email, - :provider_login, - :provider_name, - :provider_id - ]) - |> generate_id() - |> validate_required([:provider_token, :provider_email, :provider_name, :provider_id]) - |> validate_length(:provider_meta, max: 10_000) - end - - def github_registration_changeset(user, info, primary_email, emails, token) do - params = %{ - "provider_token" => token, - "provider_id" => to_string(info["id"]), - "provider_login" => info["login"], - "provider_name" => info["name"] || info["login"], - "provider_email" => primary_email - } - - %Identity{ - provider: "github", - provider_meta: %{"user" => info, "emails" => emails}, - user_id: user.id - } - |> cast(params, [ - :provider_token, - :provider_email, - :provider_login, - :provider_name, - :provider_id - ]) - |> generate_id() - |> validate_required([:provider_token, :provider_email, :provider_name, :provider_id]) - |> validate_length(:provider_meta, max: 10_000) - end -end diff --git a/lib/algora/accounts/schemas/user.ex b/lib/algora/accounts/schemas/user.ex deleted file mode 100644 index 009af3082..000000000 --- a/lib/algora/accounts/schemas/user.ex +++ /dev/null @@ -1,640 +0,0 @@ -defmodule Algora.Accounts.User do - @moduledoc false - use Algora.Schema - - alias Algora.Accounts.Follow - alias Algora.Accounts.Identity - alias Algora.Accounts.User - alias Algora.Accounts.UserMedia - alias Algora.Activities.Activity - alias Algora.Bounties.Bounty - alias Algora.Bounties.Tip - alias Algora.Contracts.Contract - alias Algora.MoneyUtils - alias Algora.Organizations.Member - alias Algora.Types.Money - alias Algora.Util - alias Algora.Workspace.Installation - alias AlgoraWeb.Endpoint - - @derive {Inspect, except: [:provider_meta]} - typed_schema "users" do - field :provider, :string - field :provider_id, :string - field :provider_login, :string - field :provider_meta, :map, default: %{} - - field :type, Ecto.Enum, values: [:individual, :organization, :bot], default: :individual - field :email, :string - field :internal_email, :string - field :internal_notes, :string - field :name, :string - field :display_name, :string - field :handle, :string - field :last_context, :string - field :bio, :string - field :avatar_url, :string - field :location, :string - field :country, :string - field :timezone, :string - field :stargazers_count, :integer, default: 0 - field :followers_count, :integer, default: 0 - field :following_count, :integer, default: 0 - field :domain, :string - field :tech_stack, {:array, :string}, default: [] - field :discovery_tech_stack, {:array, :string}, default: [] - field :categories, {:array, :string}, default: [] - field :featured, :boolean, default: false - field :priority, :integer, default: 0 - field :fee_pct, :integer, default: 9 - field :fee_pct_prev, :integer, default: 9 - field :subscription_price, Money - field :seeded, :boolean, default: false - field :activated, :boolean, default: false - field :max_open_attempts, :integer, default: 3 - field :manual_assignment, :boolean, default: false - field :is_admin, :boolean, default: false - field :contract_signed, :boolean, default: false - field :last_active_at, :utc_datetime_usec - field :last_job_match_email_at, :utc_datetime_usec - field :last_dm_date, :utc_datetime_usec - field :candidate_notes, :string - field :dm_thread_url, :string - field :contribution_scores, :map, default: %{} - - field :seeking_bounties, :boolean, default: false - field :seeking_contracts, :boolean, default: false - field :seeking_jobs, :boolean, default: false - field :open_to_new_role, :boolean, default: true - field :hiring, :boolean, default: false - field :hiring_subscription, Ecto.Enum, values: [:inactive, :trial, :active], default: :inactive - field :hiring_keywords, :string - field :candidates_require_login, :boolean, default: true - field :candidates_require_confirmation, :boolean, default: false - - field :hourly_rate_min, Money - field :hourly_rate_max, Money - field :hours_per_week, :integer - field :min_compensation, Money - field :willing_to_relocate, :boolean, default: false - field :us_work_authorization, :boolean, default: false - field :preferences, :string - - field :refer_to_company, :boolean, default: false - field :company_domain, :string - field :friends_recommendations, :boolean, default: false - field :friends_github_handles, :string - field :opt_out_algora, :boolean, default: false - - field :total_earned, Money, virtual: true - field :transactions_count, :integer, virtual: true - field :contributed_projects_count, :integer, virtual: true - - field :need_avatar, :boolean, default: false - - field :bounty_mode, Ecto.Enum, - values: [:community, :exclusive, :public], - default: :community - - field :website_url, :string - field :twitter_url, :string - field :github_url, :string - field :youtube_url, :string - field :twitch_url, :string - field :discord_url, :string - field :slack_url, :string - field :linkedin_url, :string - field :linkedin_meta, :map, default: %{} - field :linkedin_profile, :map - field :linkedin_profile_raw, :string - field :google_scholar_url, :string - field :employment_info, :map, default: %{} - - field :og_title, :string - field :og_image_url, :string - - field :login_token, :string, virtual: true - field :signup_token, :string, virtual: true - - field :billing_name, :string - field :billing_address, :string - field :jurisdiction, :string - field :entity_type, :string - field :executive_name, :string - field :executive_role, :string - field :executive_email, :string - field :recruiting_contract_id, :string - - field :system_bio, :string - field :system_bio_meta, :map, default: %{} - field :system_tags, {:array, :string}, default: [] - field :readme, :string - - field :location_meta, :map - field :location_iso_lvl4, :string - field :grad_year, :integer - - field :email_recipients, {:array, :map}, default: [] - field :language_contributions_synced, :boolean, default: false - field :repo_contributions_synced, :boolean, default: false - field :linkedin_url_attempted, :boolean, default: false - field :linkedin_meta_attempted, :boolean, default: false - field :readme_attempted, :boolean, default: false - - # Work arrangement preferences - field :open_to_remote, :boolean, default: false - field :open_to_hybrid, :boolean, default: false - field :open_to_onsite, :boolean, default: false - - # Relocation preferences - field :open_to_relocate_sf, :boolean, default: false - field :open_to_relocate_ny, :boolean, default: false - field :open_to_relocate_country, :boolean, default: false - field :open_to_relocate_world, :boolean, default: false - field :open_to_relocate_local, :boolean, default: false - - # Commitment preferences - field :open_to_fulltime, :boolean, default: false - field :open_to_contract, :boolean, default: false - - # Track preferences - field :open_to_ic, :boolean, default: false - field :open_to_manager, :boolean, default: false - - # Work authorization - field :work_auth_us, :boolean, default: false - field :work_auth_eu, :boolean, default: false - - # Citizenship - field :us_citizen, :boolean - - # Security clearance - field :security_clearance, Ecto.Enum, values: [:active, :eligible, :not_eligible] - - # Resume - field :resume_url, :string - field :resume, :string - - # Phone number - field :phone_number, :string - - # Earliest start date - field :earliest_start_date, :date - - # Poaching targets - stores repos and companies to poach from - field :poaching_targets, :string - - # Customer stage tracking - field :stage, Ecto.Enum, - values: [:inbound, :opening, :onboarding, :campaigning, :interviewing, :none], - default: :none - - # Import tracking - identifies the source that imported this user - field :import_source, :string - - # Company funding information (for organizations) - field :latest_funding_round, :string - field :amount_raised, Money - field :company_valuation, Money - - field :company_pitch, :string - field :algora_note, :string - - # Ashby integration fields - field :ashby_api_key, :string - field :ashby_source_id, :string - field :ashby_user_id, :string - - has_many :identities, Identity - has_many :memberships, Member, foreign_key: :user_id - has_many :members, Member, foreign_key: :org_id - has_many :owned_bounties, Bounty, foreign_key: :owner_id - has_many :created_bounties, Bounty, foreign_key: :creator_id - has_many :owned_tips, Tip, foreign_key: :owner_id - has_many :created_tips, Tip, foreign_key: :creator_id - has_many :received_tips, Tip, foreign_key: :recipient_id - has_many :attempts, Algora.Bounties.Attempt - has_many :claims, Algora.Bounties.Claim - has_many :repositories, Algora.Workspace.Repository - has_many :transactions, Algora.Payments.Transaction, foreign_key: :user_id - has_many :owned_installations, Installation, foreign_key: :owner_id - has_many :connected_installations, Installation, foreign_key: :connected_user_id - has_many :contractor_contracts, Contract, foreign_key: :contractor_id - has_many :client_contracts, Contract, foreign_key: :client_id - has_many :activities, {"user_activities", Activity}, foreign_key: :assoc_id - - has_one :customer, Algora.Payments.Customer, foreign_key: :user_id - has_one :heatmap, Algora.Workspace.UserHeatmap, foreign_key: :user_id - - has_many :media, UserMedia - has_many :job_matches, Algora.Matches.JobMatch, foreign_key: :user_id - - has_many :following_relationships, Follow, foreign_key: :follower_id - has_many :following, through: [:following_relationships, :followed] - has_many :follower_relationships, Follow, foreign_key: :followed_id - has_many :followers, through: [:follower_relationships, :follower] - - timestamps() - end - - def after_load({:ok, struct}), do: {:ok, after_load(struct)} - def after_load({:error, _} = result), do: result - def after_load(nil), do: nil - - def after_load(struct) do - Enum.reduce([:total_earned], struct, &MoneyUtils.ensure_money_field(&2, &1)) - end - - def org_registration_changeset(params) do - %User{} - |> cast(params, [:email, :display_name, :type]) - |> generate_id() - |> validate_required([:email]) - |> validate_unique_email() - end - - @doc """ - A user changeset for github registration. - """ - def github_registration_changeset(nil, info, primary_email, emails, token) do - identity_changeset = - Identity.github_registration_changeset(nil, info, primary_email, emails, token) - - if identity_changeset.valid? do - params = %{ - "handle" => info["login"], - "email" => primary_email, - "display_name" => info["name"], - "bio" => info["bio"], - "location" => info["location"], - "avatar_url" => info["avatar_url"], - "website_url" => info["blog"], - "github_url" => info["html_url"], - "provider" => "github", - "provider_id" => to_string(info["id"]), - "provider_login" => info["login"], - "provider_meta" => info - } - - %User{} - |> cast(params, [ - :handle, - :email, - :display_name, - :bio, - :location, - :avatar_url, - :website_url, - :github_url, - :provider, - :provider_id, - :provider_login, - :provider_meta - ]) - |> generate_id() - |> validate_required([:email, :handle]) - |> validate_handle() - |> validate_unique_email() - |> unique_constraint(:email) - |> unique_constraint(:handle) - |> put_assoc(:identities, [identity_changeset]) - else - %User{} - |> change() - |> Map.put(:valid?, false) - |> put_assoc(:identities, [identity_changeset]) - end - end - - def github_registration_changeset(%User{} = user, info, primary_email, emails, token) do - identity_changeset = - Identity.github_registration_changeset(user, info, primary_email, emails, token) - - if identity_changeset.valid? do - params = - %{ - "handle" => user.handle || Algora.Organizations.ensure_unique_handle(info["login"]), - "email" => user.email || primary_email, - "display_name" => user.display_name || info["name"], - "bio" => user.bio || info["bio"], - "location" => user.location || info["location"], - "avatar_url" => user.avatar_url || info["avatar_url"], - "website_url" => user.website_url || info["blog"], - "github_url" => user.github_url || info["html_url"], - "provider" => "github", - "provider_id" => to_string(info["id"]), - "provider_login" => info["login"], - "provider_meta" => info - } - - params = - if is_nil(user.provider_id) do - Map.put(params, "display_name", info["name"]) - else - params - end - - user - |> cast(params, [ - :handle, - :email, - :display_name, - :bio, - :location, - :avatar_url, - :website_url, - :github_url, - :provider, - :provider_id, - :provider_login, - :provider_meta - ]) - |> generate_id() - |> validate_required([:email, :handle]) - |> validate_handle() - |> validate_unique_email() - |> unique_constraint(:email) - |> unique_constraint(:handle) - else - user - |> change() - |> Map.put(:valid?, false) - end - end - - def user_registration_changeset(params) do - %User{} - |> cast(params, [:handle, :email]) - |> generate_id() - |> validate_required([:email, :handle]) - |> validate_handle() - |> validate_unique_email() - |> unique_constraint(:email) - |> unique_constraint(:handle) - end - - def org_registration_changeset(org, params) do - org - |> cast(params, [ - :email, - :display_name, - :website_url, - :location, - :bio, - :avatar_url, - :handle, - :domain, - :tech_stack, - :categories, - :hourly_rate_min, - :hourly_rate_max, - :hours_per_week, - :last_context - ]) - |> generate_id() - |> validate_required([:type, :handle, :email]) - |> validate_unique_email() - |> unique_constraint(:handle) - |> unique_constraint(:email) - end - - def settings_changeset(%User{} = user, params) do - user - |> cast(params, [ - :handle, - :display_name, - :last_context, - :need_avatar, - :website_url, - :bio, - :country, - :location, - :timezone, - :tech_stack, - :seeking_contracts, - :seeking_bounties, - :seeking_jobs, - :hourly_rate_min, - :hours_per_week, - :preferences, - :ashby_api_key, - :ashby_source_id, - :ashby_user_id - ]) - |> validate_required([:handle]) - |> validate_handle() - |> validate_timezone() - end - - def login_changeset(%User{} = user, params) do - cast(user, params, [:email, :login_token]) - end - - def signup_changeset(%User{} = user, params) do - cast(user, params, [:email, :signup_token]) - end - - def validate_email(changeset) do - changeset - |> validate_required([:email]) - |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces") - |> validate_length(:email, max: 160) - end - - def validate_unique_email(changeset) do - changeset - |> validate_email() - |> unsafe_validate_unique(:email, Algora.Repo) - |> unique_constraint(:email) - end - - def validate_handle(changeset) do - reserved_words = - ~w(personal org admin support help security team staff official auth tip home dashboard bounties community user payment claims orgs projects jobs leaderboard onboarding pricing developers companies contracts blog docs open hiring sdk api repo go preview tv podcast) - - changeset - |> validate_format(:handle, ~r/^[a-zA-Z0-9_-]{2,32}$/) - |> validate_exclusion(:handle, reserved_words, message: "is reserved") - |> unsafe_validate_unique(:handle, Algora.Repo) - |> unique_constraint(:handle) - end - - def get_domain(%{"type" => type}) when type != "Organization", do: nil - - def get_domain(%{"email" => email}) when is_binary(email) do - domain = email |> String.split("@") |> List.last() |> Util.to_domain() - - if not Algora.Crawler.blacklisted?(domain), do: domain - end - - def get_domain(%{"blog" => url}) when is_binary(url) do - domain = - with url when not is_nil(url) <- Util.normalize_url(url), - %URI{host: host} when is_binary(host) and host != "" <- URI.parse(url) do - Util.to_domain(host) - else - _ -> nil - end - - if not Algora.Crawler.blacklisted?(domain), do: domain - end - - def get_domain(_meta), do: nil - - def github_changeset(user \\ %User{}, meta) do - params = %{ - provider_id: to_string(meta["id"]), - provider_login: meta["login"], - type: type_from_provider(:github, meta["type"]), - display_name: meta["name"] || meta["login"], - bio: meta["bio"], - location: meta["location"], - avatar_url: meta["avatar_url"], - website_url: Util.normalize_url(meta["blog"]), - github_url: meta["html_url"], - domain: get_domain(meta), - provider: "github", - provider_meta: meta - } - - user - |> cast( - params, - [ - :provider, - :provider_meta, - :provider_id, - :provider_login, - :type, - :display_name, - :bio, - :location, - :avatar_url, - :website_url, - :github_url, - :domain - ] - ) - |> generate_id() - |> validate_required([:provider_id, :provider_login, :type]) - |> unique_constraint([:provider, :provider_id]) - end - - def is_admin_changeset(user, is_admin) do - cast(user, %{is_admin: is_admin}, [:is_admin]) - end - - def job_preferences_changeset(%User{} = user, params) do - user - |> cast(params, [ - :min_compensation, - :willing_to_relocate, - :us_work_authorization, - :linkedin_url, - :google_scholar_url, - :twitter_url, - :youtube_url, - :website_url, - :location, - :preferences, - :display_name, - :internal_email, - :internal_notes, - :candidate_notes, - :refer_to_company, - :company_domain, - :friends_recommendations, - :friends_github_handles, - :opt_out_algora, - :email_recipients, - :employment_info, - :open_to_remote, - :open_to_hybrid, - :open_to_onsite, - :open_to_relocate_sf, - :open_to_relocate_ny, - :open_to_relocate_country, - :open_to_relocate_world, - :open_to_relocate_local, - :open_to_fulltime, - :open_to_contract, - :open_to_ic, - :open_to_manager, - :open_to_new_role, - :work_auth_us, - :work_auth_eu, - :us_citizen, - :security_clearance, - :phone_number, - :resume_url, - :earliest_start_date - ]) - |> validate_url(:linkedin_url) - |> validate_url(:google_scholar_url) - |> validate_url(:twitter_url) - |> validate_url(:youtube_url) - |> validate_url(:website_url) - |> validate_url(:resume_url) - end - - def hiring_changeset(%User{} = user, params) do - cast(user, params, [ - :preferences, - :executive_name, - :executive_role, - :executive_email, - :billing_name, - :billing_address, - :jurisdiction, - :entity_type, - :hiring_keywords, - :poaching_targets, - :ashby_api_key, - :ashby_source_id, - :ashby_user_id - ]) - end - - def admin_pipeline_changeset(%User{} = user, params) do - cast(user, params, [ - :open_to_new_role, - :last_job_match_email_at, - :last_dm_date, - :candidate_notes, - :dm_thread_url, - :linkedin_url, - :employment_info, - :internal_email, - :stage - ]) - end - - defp validate_url(changeset, field) do - validate_format(changeset, field, ~r/^https?:\/\/.*/, message: "must be a valid URL") - end - - def validate_timezone(changeset) do - validate_inclusion(changeset, :timezone, Tzdata.zone_list()) - end - - def type_from_provider(:github, "Bot"), do: :bot - def type_from_provider(:github, "Organization"), do: :organization - def type_from_provider(:github, _), do: :individual - - def resume_changeset(%User{} = user, params) do - cast(user, params, [:resume]) - end - - def handle(%{handle: handle}) when is_binary(handle), do: handle - def handle(%{provider_login: handle}), do: handle - - def url(%{handle: handle}) when is_binary(handle), do: "#{Endpoint.url()}/#{handle}" - def url(%{provider_login: handle}), do: "https://github.com/#{handle}" - - @doc """ - Returns the primary email for a user (internal_email > email > provider_meta["email"]) - """ - def primary_email(%User{} = user) do - user.internal_email || user.email || get_in(user.provider_meta, ["email"]) - end -end diff --git a/lib/algora/accounts/schemas/user_media.ex b/lib/algora/accounts/schemas/user_media.ex deleted file mode 100644 index 163339e44..000000000 --- a/lib/algora/accounts/schemas/user_media.ex +++ /dev/null @@ -1,23 +0,0 @@ -defmodule Algora.Accounts.UserMedia do - @moduledoc false - use Algora.Schema - - alias Algora.Accounts.User - - typed_schema "user_media" do - field :url, :string - field :original_url, :string - - belongs_to :user, User - - timestamps() - end - - def changeset(user_media, attrs) do - user_media - |> cast(attrs, [:url, :original_url, :user_id]) - |> validate_required([:url, :user_id]) - |> generate_id() - |> foreign_key_constraint(:user_id) - end -end diff --git a/lib/algora/activities/activities.ex b/lib/algora/activities/activities.ex deleted file mode 100644 index 69ee56c6a..000000000 --- a/lib/algora/activities/activities.ex +++ /dev/null @@ -1,441 +0,0 @@ -defmodule Algora.Activities do - @moduledoc false - import Ecto.Query - - alias Algora.Accounts.Identity - alias Algora.Accounts.User - alias Algora.Activities.Activity - alias Algora.Activities.DiscordViews - alias Algora.Activities.Router - alias Algora.Activities.SendDiscord - alias Algora.Activities.SendEmail - alias Algora.Activities.Views - alias Algora.Bounties.Bounty - alias Algora.Repo - - require Logger - - @schema_from_table %{ - identity_activities: Identity, - user_activities: User, - attempt_activities: Algora.Bounties.Attempt, - bonus_activities: Algora.Bounties.Bonus, - bounty_activities: Bounty, - claim_activities: Algora.Bounties.Claim, - tip_activities: Algora.Bounties.Tip, - message_activities: Algora.Chat.Message, - thread_activities: Algora.Chat.Thread, - contract_activities: Algora.Contracts.Contract, - timesheet_activities: Algora.Contracts.Timesheet, - account_activities: Algora.Payments.Account, - customer_activities: Algora.Payments.Customer, - payment_method_activities: Algora.Payments.PaymentMethod, - platform_transaction_activities: Algora.Payments.PlatformTransaction, - transaction_activities: Algora.Payments.Transaction, - review_activities: Algora.Reviews.Project, - installation_activities: Algora.Workplace.Installation, - ticket_activities: Algora.Workspace.Ticket, - repository_activities: Algora.Workspace.Repository - } - - @table_from_user_relation %{ - # attempts: "attempt_activities", - claims: "claim_activities", - client_contracts: "contract_activities", - connected_installations: "installation_activities", - contractor_contracts: "contract_activities", - created_bounties: "bounty_activities", - # owned_bounties: "bounty_activities", - created_tips: "tip_activities", - # owned_tips: "tip_activities", - received_tips: "tip_activities", - identities: "identity_activities", - owned_installations: "installation_activities", - repositories: "repository_activities", - transactions: "transaction_activities" - } - - @table_from_schema Map.new(@schema_from_table, &{elem(&1, 1), elem(&1, 0)}) - @tables Map.keys(@schema_from_table) - @user_attributes Map.keys(@table_from_user_relation) - - def schema_from_table(name) when is_binary(name), do: name |> String.to_atom() |> schema_from_table() - - def schema_from_table(name) when is_atom(name) do - Map.fetch!(@schema_from_table, name) - end - - def table_from_schema(name) when is_binary(name), do: name |> String.to_atom() |> table_from_schema() - - def table_from_schema(name) when is_atom(name) do - Map.fetch!(@table_from_schema, name) - end - - def table_from_user_relation(table) do - Map.fetch!(@table_from_user_relation, table) - end - - def tables, do: @tables - def user_attributes, do: @user_attributes - - def base_query do - [head | tail] = @tables - query = head |> to_string() |> base_query() - - Enum.reduce(tail, query, fn table_path, acc -> - new_query = base_query(table_path) - union_all(new_query, ^acc) - end) - end - - def base_query(table_name) when is_atom(table_name) do - table_name |> to_string() |> base_query() - end - - def base_query(table_name) when is_binary(table_name) do - base = from(e in {table_name, Activity}) - - from(u in subquery(base), - select_merge: %{ - id: u.id, - type: u.type, - assoc_id: u.assoc_id, - assoc_name: ^table_name - } - ) - end - - def base_query_for_user(user_id) do - [head | tail] = @user_attributes - first_query = base_query_for_user(user_id, head) - - Enum.reduce(tail, first_query, fn relation_name, acc -> - new_query = base_query_for_user(user_id, relation_name) - union_all(new_query, ^acc) - end) - end - - def base_query_for_user(user_id, relation_name) do - table_name = table_from_user_relation(relation_name) - - from u in User, - where: u.id == ^user_id, - join: c in assoc(u, ^relation_name), - join: a in assoc(c, :activities), - select: %{ - id: a.id, - type: a.type, - assoc_id: a.assoc_id, - assoc_name: ^table_name, - inserted_at: a.inserted_at - } - end - - def all(table_name) when is_binary(table_name) do - table_name - |> base_query() - |> order_by(desc: :inserted_at) - |> Repo.all() - end - - def all(target) when is_map(target) do - target - |> Ecto.assoc(:activities) - |> order_by(desc: :inserted_at) - |> Repo.all() - end - - def all do - base_query() - |> order_by(fragment("inserted_at DESC")) - |> limit(40) - |> all_with_assoc() - end - - def all_for_user(user_id) do - user_id - |> base_query_for_user() - |> order_by(fragment("inserted_at DESC")) - |> limit(40) - |> all_with_assoc() - end - - def insert(target, activity) do - target - |> Activity.build_activity(activity) - |> Repo.insert() - end - - def all_with_assoc(query) do - activities = Repo.all(query) - source = Dataloader.Ecto.new(Repo) - dataloader = Dataloader.add_source(Dataloader.new(), :db, source) - - loader = - activities - |> Enum.reduce(dataloader, fn activity, loader -> - schema = schema_from_table(activity.assoc_name) - Dataloader.load(loader, :db, schema, activity.assoc_id) - end) - |> Dataloader.run() - - Enum.map(activities, fn activity -> - schema = schema_from_table(activity.assoc_name) - assoc = Dataloader.get(loader, :db, schema, activity.assoc_id) - Map.put(activity, :assoc, assoc) - end) - end - - def get(table, id) do - assoc_query = - from t in schema_from_table(table), - where: parent_as(:activity).assoc_id == t.id - - query = - from a in table, - as: :activity, - where: a.id == ^id, - inner_lateral_join: t in subquery(assoc_query), - on: true, - select: %{ - id: a.id, - type: a.type, - assoc_id: a.assoc_id, - assoc_name: ^table, - assoc: t, - notify_users: a.notify_users, - visibility: a.visibility, - template: a.template, - meta: a.meta, - changes: a.changes, - trace_id: a.trace_id, - previous_event_id: a.previous_event_id, - inserted_at: a.inserted_at, - updated_at: a.updated_at - } - - struct(Activity, Repo.one(query)) - end - - def get_with_preloaded_assoc(table, id) do - schema = schema_from_table(table) - - with %{assoc_id: assoc_id} = activity <- get(table, id), - assoc when is_map(assoc) <- get_preloaded_assoc(schema, assoc_id) do - Map.put(activity, :assoc, assoc) - end - end - - def get_preloaded_assoc(schema, assoc_id) do - query = - if Kernel.function_exported?(schema, :preload, 1) do - schema.preload(assoc_id) - else - from a in schema, where: a.id == ^assoc_id - end - - Repo.one(query) - end - - def assoc_url(table, id) do - table |> get(id) |> Router.route() - end - - def subscribe do - Phoenix.PubSub.subscribe(Algora.PubSub, "activities") - end - - def subscribe(schema) when is_atom(schema) do - schema |> schema_from_table() |> subscribe() - end - - def subscribe_table(table) when is_binary(table) do - Phoenix.PubSub.subscribe(Algora.PubSub, "activity:table:#{table}") - end - - def subscribe_user(user_id) when is_binary(user_id) do - Phoenix.PubSub.subscribe(Algora.PubSub, "activity:users:#{user_id}") - end - - def broadcast(%{notify_users: []}), do: [] - - def broadcast(%{notify_users: user_ids} = activity) do - :ok = Phoenix.PubSub.broadcast(Algora.PubSub, "activities", activity) - :ok = Phoenix.PubSub.broadcast(Algora.PubSub, "activity:table:#{activity.assoc_name}", activity) - - users_query = - from u in User, - where: u.id in ^user_ids, - select: u - - users_query - |> Repo.all() - |> Enum.reduce([], fn user, not_online -> - # TODO setup notification preferences - :ok = Phoenix.PubSub.broadcast(Algora.PubSub, "activity:users:#{user.id}", activity) - [user | not_online] - end) - end - - def notify_users(activity, users_to_notify) do - email_jobs = - if users_to_notify == [] do - [] - else - title = Views.render(activity, :title) - body = Views.render(activity, :txt) - - Enum.reduce(users_to_notify, [], fn - %{name: display_name, email: email, id: id}, acc -> - changeset = - SendEmail.changeset(%{ - title: title, - body: body, - user_id: id, - activity_id: activity.id, - activity_type: activity.type, - activity_table: activity.assoc_name, - name: display_name, - email: email - }) - - [changeset | acc] - - _user, acc -> - acc - end) - end - - discord_job = - if discord_payload = DiscordViews.render(activity) do - [ - SendDiscord.changeset(%{ - url: Algora.config([:discord, :webhook_url]), - payload: discord_payload - }) - ] - else - [] - end - - Oban.insert_all(email_jobs ++ discord_job) - end - - def redirect_url_for_activity(activity) do - slug = - activity.assoc_name - |> to_string() - |> String.replace("_activities", "") - - "a/#{slug}/#{activity.id}" - end - - def external_url(activity) do - path = redirect_url_for_activity(activity) - "#{AlgoraWeb.Endpoint.url()}/#{path}" - end - - def activity_type_to_name(type) do - type - |> to_string() - |> String.split("_") - |> Enum.map_join(" ", &String.capitalize(&1)) - end - - def alert(message, severity \\ :error) - - def alert(message, :error = severity) do - Logger.error(message) - - %{ - url: Algora.config([:discord, :webhook_url]), - payload: %{ - embeds: [ - %{ - color: color(severity), - title: severity |> to_string() |> String.capitalize(), - description: message, - timestamp: DateTime.utc_now() - } - ] - } - } - |> SendDiscord.changeset() - |> Oban.insert() - end - - def alert(message, :critical = severity) do - Logger.error(message) - - email_job = - SendEmail.changeset(%{ - title: "#{message}", - body: message, - name: "Algora alert", - email: "info@algora.io" - }) - - discord_job = - SendDiscord.changeset(%{ - url: Algora.Settings.get("discord_webhook_url")["critical"] || Algora.config([:discord, :webhook_url]), - payload: %{ - embeds: [ - %{color: color(severity), title: "Alert", description: message, timestamp: DateTime.utc_now()} - ] - } - }) - - Oban.insert_all([email_job, discord_job]) - end - - def alert(message, :inbound = severity) do - Logger.info(message) - - email_job = - SendEmail.changeset(%{ - title: "#{message}", - body: message, - name: "Inbound", - email: "info@algora.io" - }) - - discord_job = - SendDiscord.changeset(%{ - url: Algora.Settings.get("discord_webhook_url")["inbound"] || Algora.config([:discord, :webhook_url]), - payload: %{ - embeds: [ - %{color: color(severity), title: "Inbound", description: message, timestamp: DateTime.utc_now()} - ] - } - }) - - Oban.insert_all([email_job, discord_job]) - end - - def alert(message, severity) do - Logger.info(message) - - %{ - url: Algora.config([:discord, :webhook_url]), - payload: %{ - embeds: [ - %{ - color: color(severity), - title: severity |> to_string() |> String.capitalize(), - description: message, - timestamp: DateTime.utc_now() - } - ] - } - } - |> SendDiscord.changeset() - |> Oban.insert() - end - - def color(:inbound), do: 0x10B981 - def color(:critical), do: 0xEF4444 - def color(:error), do: 0xEF4444 - def color(:debug), do: 0x64748B - def color(:info), do: 0xF59E0B - def color(_), do: 0xF59E0B -end diff --git a/lib/algora/activities/discord_views.ex b/lib/algora/activities/discord_views.ex deleted file mode 100644 index d43dc2a65..000000000 --- a/lib/algora/activities/discord_views.ex +++ /dev/null @@ -1,86 +0,0 @@ -defmodule Algora.Activities.DiscordViews do - @moduledoc false - alias Algora.Repo - - def render(%{type: type} = activity) when is_binary(type) do - render(%{activity | type: String.to_existing_atom(type)}) - end - - def render(%{type: :bounty_posted, assoc: bounty}) do - bounty = Repo.preload(bounty, [:owner, :creator, ticket: [repository: :user]]) - - %{ - embeds: [ - %{ - color: 0x6366F1, - title: "#{bounty.amount} bounty!", - author: %{ - name: bounty.ticket.repository.user.provider_login, - icon_url: bounty.ticket.repository.user.avatar_url, - url: - "https://github.com/#{bounty.ticket.repository.user.provider_login}/#{bounty.ticket.repository.name}/issues/#{bounty.ticket.number}" - }, - footer: %{ - text: bounty.creator.name, - icon_url: bounty.creator.avatar_url - }, - thumbnail: %{url: bounty.owner.avatar_url}, - fields: [ - %{ - name: "Sponsor", - value: bounty.owner.name, - inline: false - }, - %{ - name: "Ticket", - value: "#{bounty.ticket.repository.name}##{bounty.ticket.number}: #{bounty.ticket.title}", - inline: false - } - ], - url: - "https://github.com/#{bounty.ticket.repository.user.provider_login}/#{bounty.ticket.repository.name}/issues/#{bounty.ticket.number}", - timestamp: bounty.inserted_at - } - ] - } - end - - def render(%{type: :transaction_succeeded, assoc: tx}) do - tx = Repo.preload(tx, [:user, linked_transaction: [:user]]) - - %{ - embeds: [ - %{ - color: 0x6366F1, - title: "#{tx.net_amount} paid!", - author: %{ - name: tx.linked_transaction.user.name, - icon_url: tx.linked_transaction.user.avatar_url, - url: "#{AlgoraWeb.Endpoint.url()}/#{tx.linked_transaction.user.handle}" - }, - footer: %{ - text: tx.user.name, - icon_url: tx.user.avatar_url - }, - thumbnail: %{url: tx.user.avatar_url}, - fields: [ - %{ - name: "Sender", - value: tx.linked_transaction.user.name, - inline: false - }, - %{ - name: "Recipient", - value: tx.user.name, - inline: false - } - ], - url: "#{AlgoraWeb.Endpoint.url()}/#{tx.linked_transaction.user.handle}", - timestamp: tx.succeeded_at - } - ] - } - end - - def render(_activity), do: nil -end diff --git a/lib/algora/activities/jobs/notifier.ex b/lib/algora/activities/jobs/notifier.ex deleted file mode 100644 index b96e58ea5..000000000 --- a/lib/algora/activities/jobs/notifier.ex +++ /dev/null @@ -1,38 +0,0 @@ -defmodule Algora.Activities.Notifier do - @moduledoc false - use Oban.Worker, - queue: :background, - max_attempts: 1 - - alias Algora.Activities - - # unique: [period: 30] - - def changeset(activity, target) do - case Activities.table_from_schema(target.__meta__.schema) do - nil -> - :error - - table when is_atom(table) -> - new(%{activity_id: activity.id, target_id: target.id, table_name: table}) - end - end - - @impl Oban.Worker - def perform(%Oban.Job{args: args}) do - case args do - %{ - "activity_id" => activity_id, - "table_name" => table - } - when is_binary(table) -> - activity = Activities.get_with_preloaded_assoc(table, activity_id) - users_to_notify = Activities.broadcast(activity) - Activities.notify_users(activity, users_to_notify) - :ok - - _args -> - :error - end - end -end diff --git a/lib/algora/activities/jobs/send_discord.ex b/lib/algora/activities/jobs/send_discord.ex deleted file mode 100644 index d8e27969b..000000000 --- a/lib/algora/activities/jobs/send_discord.ex +++ /dev/null @@ -1,17 +0,0 @@ -defmodule Algora.Activities.SendDiscord do - @moduledoc false - use Oban.Worker, - queue: :background, - tags: ["discord", "activities"] - - require Logger - - def changeset(attrs) do - new(attrs) - end - - @impl Oban.Worker - def perform(%Oban.Job{args: %{"url" => url, "payload" => payload}}) do - Algora.Discord.send_message(url, payload) - end -end diff --git a/lib/algora/activities/jobs/send_email.ex b/lib/algora/activities/jobs/send_email.ex deleted file mode 100644 index c7fb84981..000000000 --- a/lib/algora/activities/jobs/send_email.ex +++ /dev/null @@ -1,36 +0,0 @@ -defmodule Algora.Activities.SendEmail do - @moduledoc false - use Oban.Worker, - queue: :background, - max_attempts: 1, - tags: ["email", "activities"] - - alias Swoosh.Email - - @from_name "Algora" - @from_email "info@algora.io" - - # unique: [period: 30] - - def changeset(attrs) do - new(attrs) - end - - @impl Oban.Worker - def perform(%Oban.Job{args: args}) do - case args do - %{"email" => email, "name" => name, "title" => subject, "body" => body} -> - email = - Email.new() - |> Email.to({name, email}) - |> Email.from({@from_name, @from_email}) - |> Email.subject(subject) - |> Email.text_body(body) - - Algora.Mailer.deliver(email) - - _args -> - :discard - end - end -end diff --git a/lib/algora/activities/router.ex b/lib/algora/activities/router.ex deleted file mode 100644 index 351ad778c..000000000 --- a/lib/algora/activities/router.ex +++ /dev/null @@ -1,12 +0,0 @@ -defmodule Algora.Activities.Router do - alias Algora.Accounts.Identity - alias Algora.Bounties.Bounty - - def route(%{assoc: %Bounty{owner: user}}), do: {:ok, "/#{user.handle}/bounties"} - - def route(%{assoc: %Identity{user: user}}), do: {:ok, "/#{user.handle}"} - - def route(_activity) do - {:error, :not_found} - end -end diff --git a/lib/algora/activities/schemas/activity.ex b/lib/algora/activities/schemas/activity.ex deleted file mode 100644 index 088fd986f..000000000 --- a/lib/algora/activities/schemas/activity.ex +++ /dev/null @@ -1,98 +0,0 @@ -defmodule Algora.Activities.Activity do - @moduledoc false - use Algora.Schema - - require Protocol - - @activity_types ~w{ - contract_prepaid - contract_created - contract_renewed - identity_created - user_migrated - user_online - bounty_posted - bounty_repriced - claim_submitted - claim_approved - transaction_succeeded - }a - - typed_schema "activities" do - field :assoc_id, :string - field :type, Ecto.Enum, values: @activity_types - field :visibility, Ecto.Enum, values: [:public, :private, :internal], default: :internal - field :template, :string - field :meta, :map, default: %{} - field :changes, :map, default: %{} - field :trace_id, :string - field :notify_users, {:array, :string}, default: [] - field :assoc_name, :string, virtual: true - field :assoc, :map, virtual: true - - belongs_to :user, Algora.Accounts.User - belongs_to :previous_event, __MODULE__ - - timestamps() - end - - def types, do: @activity_types - - @doc false - def changeset(activity, attrs) do - activity - |> cast(attrs, [ - :type, - :visibility, - :template, - :meta, - :changes, - :trace_id, - :user_id, - :previous_event_id, - :notify_users - ]) - |> validate_required([:type]) - |> foreign_key_constraint(:assoc_id) - |> foreign_key_constraint(:user_id) - |> foreign_key_constraint(:previous_event_id) - |> generate_id() - end - - def build_activity(target, %{meta: %struct{}} = activity) when struct in [Stripe.Error] do - build_activity(target, %{activity | meta: Algora.Util.normalize_struct(struct)}) - end - - def build_activity(target, activity) do - target - |> Ecto.build_assoc(:activities) - |> changeset(activity) - end - - def put_activity(target, activity) do - put_activity(change(target), target, activity) - end - - def put_activiies(target, activities) do - put_activities(change(target), target, activities) - end - - def put_activity(changeset, target, activity) do - put_activities(changeset, target, [activity]) - end - - def put_activities(%Ecto.Changeset{changes: changes} = changeset, target, activities) do - put_assoc( - changeset, - :activities, - Enum.map(activities, fn activity -> - build_activity(target, put_changes(activity, changes)) - end) - ) - end - - defp put_changes(activity, changes) do - changes = Map.delete(changes, :activities) - Map.put(activity, :changes, changes) - end -end diff --git a/lib/algora/activities/views.ex b/lib/algora/activities/views.ex deleted file mode 100644 index f1f2bba53..000000000 --- a/lib/algora/activities/views.ex +++ /dev/null @@ -1,179 +0,0 @@ -defmodule Algora.Activities.Views do - @moduledoc false - alias Algora.Repo - - require Logger - - def render(%{type: type} = activity, template) when is_binary(type) do - render(%{activity | type: String.to_existing_atom(type)}, template) - end - - def render(%{type: :identity_created}, :title) do - "An identity has been linked on Algora" - end - - def render(%{type: :identity_created, assoc: identity} = activity, :txt) do - """ - An identity from #{identity.provider} has been linked on algora. - - #{Algora.Activities.external_url(activity)} - """ - end - - def render(%{type: :bounty_posted, assoc: bounty}, :title) do - bounty = Repo.preload(bounty, :creator) - "#{bounty.amount} bounty posted by #{bounty.creator.name}" - end - - def render(%{type: :bounty_posted, assoc: bounty} = activity, :txt) do - bounty = Repo.preload(bounty, :creator) - - """ - A new bounty has been posted by #{bounty.creator.name} - - #{Algora.Activities.external_url(activity)} - """ - end - - def render(%{type: :bounty_repriced, assoc: _bounty}, :title) do - "Reward updated for a bounty posted to Algora" - end - - def render(%{type: :bounty_repriced, assoc: bounty} = activity, :txt) do - bounty = Repo.preload(bounty, ticket: :repository) - - """ - A Bounty for #{bounty.ticket.repository.name} had it's reward updated to #{bounty.amount} - - #{Algora.Activities.external_url(activity)} - """ - end - - def render(%{type: :claim_approved, assoc: _claim}, :title) do - "A claim has been approved on Algora" - end - - def render(%{type: :claim_approved, assoc: claim} = activity, :txt) do - claim = Repo.preload(claim, :target) - - """ - A claim for the issue "#{claim.target.title}" was accepted. - - #{claim.url} - - #{Algora.Activities.external_url(activity)} - """ - end - - def render(%{type: :claim_submitted, assoc: _claim}, :title) do - "A claim has been submitted on Algora" - end - - def render(%{type: :claim_submitted, assoc: claim} = activity, :txt) do - claim = Repo.preload(claim, :target) - - """ - A claim for the issue "#{claim.target.title}" was submitted. - - #{claim.url} - - #{Algora.Activities.external_url(activity)} - """ - end - - def render(%{type: :contract_created, assoc: _contract}, :title) do - "A contract has been created on Algora" - end - - def render(%{type: :contract_created, assoc: contract} = activity, :txt) do - contract = Repo.preload(contract, [:client, :contractor]) - - """ - A contract between #{contract.client.name} and #{contract.contractor.name} has been created. - - #{Algora.Activities.external_url(activity)} - """ - end - - def render(%{type: :contract_prepaid, assoc: _contract}, :title) do - "A contract has been prepaid on Algora" - end - - def render(%{type: :contract_prepaid, assoc: contract} = activity, :txt) do - contract = Repo.preload(contract, :client) - - """ - A contract for "#{contract.client.name}" has been prepaid. - - #{Algora.Activities.external_url(activity)} - """ - end - - def render(%{type: :contract_renewed, assoc: _contract}, :title) do - "A contract has been renewed on Algora" - end - - def render(%{type: :contract_renewed, assoc: contract} = activity, :txt) do - contract = Repo.preload(contract, [:client, :contractor]) - - """ - A contract between "#{contract.client.name}" and "#{contract.contractor.name}" has been renewed. - - #{Algora.Activities.external_url(activity)} - """ - end - - def render(%{type: :transaction_succeeded, assoc: tx} = activity, template) do - tx = Repo.preload(tx, [:user, linked_transaction: [:user]]) - activity = %{activity | assoc: tx} - - case tx do - %{linked_transaction: nil} -> - Logger.error("Unknown transaction type: #{inspect(tx)}") - raise "Unknown transaction type: #{inspect(tx)}" - - %{bounty_id: bounty_id} when not is_nil(bounty_id) -> - render_transaction_succeeded(activity, template, :bounty) - - %{tip_id: tip_id} when not is_nil(tip_id) -> - render_transaction_succeeded(activity, template, :tip) - - %{contract_id: contract_id} when not is_nil(contract_id) -> - render_transaction_succeeded(activity, template, :contract) - - _ -> - Logger.error("Unknown transaction type: #{inspect(tx)}") - raise "Unknown transaction type: #{inspect(tx)}" - end - end - - defp render_transaction_succeeded(%{assoc: tx}, :title, :bounty) do - "🎉 #{tx.net_amount} bounty awarded by #{tx.linked_transaction.user.name}" - end - - defp render_transaction_succeeded(%{assoc: tx}, :title, :tip) do - "💸 #{tx.net_amount} tip received from #{tx.linked_transaction.user.name}" - end - - defp render_transaction_succeeded(%{assoc: tx}, :title, :contract) do - "💰 #{tx.net_amount} contract paid by #{tx.linked_transaction.user.name}" - end - - defp render_transaction_succeeded(%{assoc: tx}, :txt, :bounty) do - """ - Congratulations, you've been awarded a #{tx.net_amount} bounty by #{tx.linked_transaction.user.name}! - """ - end - - defp render_transaction_succeeded(%{assoc: tx}, :txt, :tip) do - """ - Congratulations, you've been awarded a #{tx.net_amount} tip by #{tx.linked_transaction.user.name}! - """ - end - - defp render_transaction_succeeded(%{assoc: tx}, :txt, :contract) do - """ - Congratulations, you've been awarded a #{tx.net_amount} contract by #{tx.linked_transaction.user.name}! - """ - end -end diff --git a/lib/algora/analytics/analytics.ex b/lib/algora/analytics/analytics.ex deleted file mode 100644 index 2e5fc5224..000000000 --- a/lib/algora/analytics/analytics.ex +++ /dev/null @@ -1,153 +0,0 @@ -defmodule Algora.Analytics do - @moduledoc false - import Ecto.Query - - alias Algora.Accounts.User - alias Algora.Bounties.Bounty - alias Algora.Repo - - require Algora.SQL - - # TODO - # - # active org: org who triggered a GMV event in given period - # GMV events: bounty.created, bounty.rewarded, contract.payment_escrowed, contract.payment_released etc. - # successful contract: currently active or paid contract - # avg time to fill: time from published to accepted, avg over all successful contracts (excl. renewals) - - def get_company_analytics(period \\ "30d", from \\ DateTime.utc_now()) do - days = period |> String.replace("d", "") |> String.to_integer() - period_start = DateTime.add(from, -days * 24 * 3600) - previous_period_start = DateTime.add(period_start, -days * 24 * 3600) - - orgs_query = - from u in User, - where: u.type == :organization, - where: u.featured, - select: %{ - count_all: count(u.id), - count_current: u.id |> count() |> filter(u.inserted_at <= ^from and u.inserted_at >= ^period_start), - count_previous: - u.id |> count() |> filter(u.inserted_at <= ^period_start and u.inserted_at >= ^previous_period_start), - active_all: u.id |> count() |> filter(u.seeded and u.activated), - active_current: - u.id - |> count() - |> filter(u.inserted_at <= ^from and u.inserted_at >= ^period_start), - active_previous: - u.id - |> count() - |> filter(u.inserted_at <= ^period_start and u.inserted_at >= ^previous_period_start) - } - - contracts_query = - from b in Bounty, - where: b.inserted_at >= ^previous_period_start, - select: %{ - count_current: b.id |> count() |> filter(b.inserted_at < ^from and b.inserted_at >= ^period_start), - count_previous: - b.id |> count() |> filter(b.inserted_at < ^period_start and b.inserted_at >= ^previous_period_start), - success_current: - b.id - |> count() - |> filter(b.inserted_at < ^from and b.inserted_at >= ^period_start and b.status == :paid), - success_previous: - b.id - |> count() - |> filter( - b.inserted_at < ^period_start and b.inserted_at >= ^previous_period_start and - b.status == :paid - ) - } - - companies_query = - from u in User, - where: u.inserted_at >= ^period_start, - where: u.type == :organization, - where: u.featured, - left_join: b in Bounty, - on: b.owner_id == u.id, - distinct: [u.id], - group_by: [u.id, b.id], - order_by: [desc: b.inserted_at], - select: %{ - id: u.id, - name: u.name, - handle: u.handle, - joined_at: u.inserted_at, - total_bounties: b.id |> count() |> filter(b.inserted_at >= ^period_start), - successful_bounties: b.id |> count() |> filter(b.status == :paid and b.inserted_at >= ^period_start), - last_active_at: u.updated_at, - avatar_url: u.avatar_url - } - - Ecto.Multi.new() - |> Ecto.Multi.one(:orgs, orgs_query) - |> Ecto.Multi.one(:contracts, contracts_query) - |> Ecto.Multi.all(:companies, companies_query) - |> Repo.transaction() - |> case do - {:ok, resp} -> - %{ - orgs: orgs, - contracts: contracts, - companies: companies - } = resp - - current_success_rate = calculate_success_rate(contracts.success_current, contracts.count_current) - previous_success_rate = calculate_success_rate(contracts.success_previous, contracts.count_previous) - - {:ok, - %{ - total_companies: orgs.count_all, - current_companies: orgs.count_current, - companies_change: orgs.count_current - orgs.count_previous, - companies_trend: calculate_trend(orgs.count_current, orgs.count_previous), - active_companies: orgs.active_all, - active_current: orgs.active_current, - active_change: orgs.active_current - orgs.active_previous, - active_trend: calculate_trend(orgs.active_current, orgs.active_previous), - # TODO track time when contract is filled - # - # in open contracts (contracts w/o contractor_id) we track :published_at - # in filled contracts (contracts w/ contractor_id) we track both :published_at (inherited) and :accepted_at - avg_time_to_fill: 0.0, - time_to_fill_change: -0.0, - time_to_fill_trend: :down, - bounty_success_rate: current_success_rate, - previous_bounty_success_rate: previous_success_rate, - success_rate_change: current_success_rate - previous_success_rate, - success_rate_trend: calculate_trend(current_success_rate, previous_success_rate), - companies: - Enum.map(companies, fn company -> - Map.merge(company, %{ - success_rate: calculate_success_rate(company.successful_bounties, company.total_bounties), - status: if(company.successful_bounties > 0, do: :active, else: :inactive) - }) - end) - }} - - {:error, reason} -> - {:error, reason} - end - end - - def get_funnel_data(_period \\ "30d", _from \\ DateTime.utc_now()) do - # Mock funnel data - %{ - registered: 100, - card_saved: 75, - contract_started: 50, - released_renewed: 35, - released_only: 10, - disputed: 5 - } - end - - defp calculate_success_rate(successful, total) when successful == 0 or total == 0, do: 0.0 - defp calculate_success_rate(successful, total), do: Float.ceil(successful / total * 100, 0) - - defp calculate_trend(a, b) when a > b, do: :up - defp calculate_trend(a, b) when a < b, do: :down - defp calculate_trend(a, b) when a == b, do: :same -end diff --git a/lib/algora/analytics/metrics.ex b/lib/algora/analytics/metrics.ex deleted file mode 100644 index 101f8d2b6..000000000 --- a/lib/algora/analytics/metrics.ex +++ /dev/null @@ -1,158 +0,0 @@ -defmodule Algora.Analytics.Metrics do - @moduledoc false - - import Ecto.Query - - alias Algora.Accounts.User - alias Algora.Repo - - @type interval :: :daily | :weekly | :monthly - @type period_metrics :: %{ - org_signups: non_neg_integer(), - org_returns: non_neg_integer(), - dev_signups: non_neg_integer(), - dev_returns: non_neg_integer() - } - - @doc """ - Returns user metrics for the last n periods with the given interval. - Organizations are users who are members of an org where their handle differs from the org handle. - Developers are all other users. - """ - @spec get_user_metrics(pos_integer(), interval()) :: [{DateTime.t(), period_metrics()}] - def get_user_metrics(n_periods, interval) do - period_start = period_start_date(n_periods, interval) - interval_str = interval_to_string(interval) - - # Generate periods using SQL - periods_query = - from( - p in fragment( - """ - SELECT generate_series( - date_trunc(?, ?::timestamp), - date_trunc(?, now()), - (?||' '||?)::interval - ) as period_start - """, - ^interval_str, - ^period_start, - ^interval_str, - ^"1", - ^interval_str - ), - select: %{period_start: fragment("period_start")} - ) - - # Base query for all users with org membership info - base_query = - from u in User, - where: not is_nil(u.handle), - select: %{ - inserted_at: fragment("date_trunc(?, ?)", ^interval_str, u.inserted_at), - is_org: - fragment( - """ - EXISTS (SELECT 1 - FROM members m - INNER JOIN users o ON m.org_id = o.id - WHERE m.user_id = ? AND o.id != m.user_id - ) - """, - u.id - ) - } - - # Get signups per period - signups = - from q in subquery(base_query), - right_join: p in subquery(periods_query), - on: q.inserted_at == p.period_start, - group_by: p.period_start, - select: { - p.period_start, - %{ - org_signups: coalesce(count(fragment("CASE WHEN ? IS TRUE THEN 1 END", q.is_org)), 0), - dev_signups: coalesce(count(fragment("CASE WHEN ? IS NOT TRUE THEN 1 END", q.is_org)), 0) - } - } - - # Get returns per period using user_activities - returns = - from u in User, - inner_join: ua in "user_activities", - on: ua.assoc_id == u.id and ua.type == "user_online", - where: not is_nil(u.handle), - right_join: p in subquery(periods_query), - on: fragment("date_trunc(?, ?)", ^interval_str, ua.inserted_at) == p.period_start, - group_by: p.period_start, - select: { - p.period_start, - %{ - org_returns: - coalesce( - count( - fragment( - """ - DISTINCT CASE WHEN EXISTS ( - SELECT 1 FROM members m - INNER JOIN users o ON m.org_id = o.id - WHERE m.user_id = ? AND o.id != m.user_id - ) THEN ? END - """, - u.id, - u.id - ) - ), - 0 - ), - dev_returns: - coalesce( - count( - fragment( - """ - DISTINCT CASE WHEN NOT EXISTS ( - SELECT 1 FROM members m - INNER JOIN users o ON m.org_id = o.id - WHERE m.user_id = ? AND o.id != m.user_id - ) THEN ? END - """, - u.id, - u.id - ) - ), - 0 - ) - } - } - - # Combine results - signups = signups |> Repo.all() |> Map.new() - returns = returns |> Repo.all() |> Map.new() - - # Merge metrics - periods = Repo.all(periods_query) - - periods - |> Enum.map(fn %{period_start: date} -> - signup_metrics = Map.get(signups, date, %{org_signups: 0, dev_signups: 0}) - return_metrics = Map.get(returns, date, %{org_returns: 0, dev_returns: 0}) - {date, Map.merge(signup_metrics, return_metrics)} - end) - |> Enum.sort_by(&elem(&1, 0), {:desc, DateTime}) - end - - def period_start_date(n_periods, interval) do - now = DateTime.utc_now() - - case interval do - :daily -> DateTime.add(now, -n_periods, :day) - :weekly -> DateTime.add(now, -n_periods * 7, :day) - :monthly -> DateTime.add(now, -n_periods * 30, :day) - end - end - - defp interval_to_string(:daily), do: "day" - defp interval_to_string(:weekly), do: "week" - defp interval_to_string(:monthly), do: "month" -end diff --git a/lib/algora/analytics/schemas/company_analytics.ex b/lib/algora/analytics/schemas/company_analytics.ex deleted file mode 100644 index 0111ea56e..000000000 --- a/lib/algora/analytics/schemas/company_analytics.ex +++ /dev/null @@ -1,39 +0,0 @@ -defmodule Algora.Analytics.CompanyAnalytics do - @moduledoc false - use Algora.Schema - - typed_schema "company_analytics" do - belongs_to :organization, Algora.Accounts.User - - # Registration & Onboarding - field :joined_at, :utc_datetime_usec - field :card_saved_at, :utc_datetime_usec - field :last_active_at, :utc_datetime_usec - field :visit_count, :integer, default: 0 - - # Job Status - field :job_status, Ecto.Enum, values: [:null, :pending, :created, :published] - field :job_created_at, :utc_datetime_usec - field :job_published_at, :utc_datetime_usec - - # Contract Metrics - field :total_contracts, :integer, default: 0 - field :active_contracts, :integer, default: 0 - field :prepaid_contracts, :integer, default: 0 - field :released_contracts, :integer, default: 0 - field :renewed_contracts, :integer, default: 0 - field :disputed_contracts, :integer, default: 0 - - # Time to Fill - field :first_contract_created_at, :utc_datetime_usec - field :first_contract_filled_at, :utc_datetime_usec - - # Aggregate Contract Metrics - field :total_matches, :integer, default: 0 - field :total_impressions, :integer, default: 0 - field :unique_impressions, :integer, default: 0 - field :total_clicks, :integer, default: 0 - - timestamps() - end -end diff --git a/lib/algora/application.ex b/lib/algora/application.ex deleted file mode 100644 index 67d3fcdbf..000000000 --- a/lib/algora/application.ex +++ /dev/null @@ -1,71 +0,0 @@ -defmodule Algora.Application do - # See https://hexdocs.pm/elixir/Application.html - # for more information on OTP Applications - @moduledoc false - - use Application - - @impl true - def start(_type, _args) do - :ok = Appsignal.Logger.Handler.add("phoenix") - :ok = Appsignal.Phoenix.LiveView.attach() - - children = - [ - {NodeJS.Supervisor, [path: LiveSvelte.SSR.NodeJS.server_path(), pool_size: 4]}, - AlgoraWeb.Telemetry, - Algora.Repo, - {Oban, - :algora - |> Application.fetch_env!(Oban) - |> Keyword.put(:plugins, [ - {Oban.Plugins.Cron, - timezone: "America/Los_Angeles", - crontab: - [{"0 3 * * *", Algora.Bounties.Jobs.SyncOpenBounties}] ++ - if(Code.ensure_loaded?(AlgoraCloud.Workers.SyncCandidates), - do: [{"0 * * * *", AlgoraCloud.Workers.SyncCandidates}], - else: [] - )} - ])}, - {DNSCluster, query: Application.get_env(:algora, :dns_cluster_query) || :ignore}, - {Phoenix.PubSub, name: Algora.PubSub}, - # Start the Finch HTTP client for sending emails - {Finch, name: Algora.Finch}, - # Start ChromicPDF for contract PDF generation - {ChromicPDF, Application.get_all_env(:chromic_pdf)}, - # Task supervisor for background jobs - {Task.Supervisor, name: Algora.TaskSupervisor}, - Algora.Github.TokenPool, - Algora.Github.Poller.RootSupervisor, - Algora.ScreenshotQueue, - Algora.RateLimit, - AlgoraWeb.Data.HomeCache, - # Start to serve requests, typically the last entry - AlgoraWeb.Endpoint, - Algora.Stargazer, - TwMerge.Cache, - UAInspector.Supervisor - ] ++ Algora.Cloud.start() - - children = - case Application.get_env(:algora, :cloudflare_tunnel) do - nil -> children - "" -> children - tunnel -> children ++ [{Algora.Tunnel, tunnel}] - end - - # See https://hexdocs.pm/elixir/Supervisor.html - # for other strategies and supported options - opts = [strategy: :one_for_one, name: Algora.Supervisor] - Supervisor.start_link(children, opts) - end - - # Tell Phoenix to update the endpoint configuration - # whenever the application is updated. - @impl true - def config_change(changed, _new, removed) do - AlgoraWeb.Endpoint.config_change(changed, removed) - :ok - end -end diff --git a/lib/algora/bot_templates/bot_templates.ex b/lib/algora/bot_templates/bot_templates.ex deleted file mode 100644 index 1a9fe9540..000000000 --- a/lib/algora/bot_templates/bot_templates.ex +++ /dev/null @@ -1,77 +0,0 @@ -defmodule Algora.BotTemplates do - @moduledoc false - - alias Algora.BotTemplates.BotTemplate - alias Algora.Repo - - def get_default_template(:bounty_created) do - """ - ${PRIZE_POOL} - ### Steps to solve: - 1. **Start working**: Comment `/attempt #${ISSUE_NUMBER}` with your implementation plan - 2. **Submit work**: Create a pull request including `/claim #${ISSUE_NUMBER}` in the PR body to claim the bounty - 3. **Receive payment**: 100% of the bounty is received 2-5 days post-reward. [Make sure you are eligible for payouts](https://algora.io/docs/payments#supported-countries-regions) - - ### ❗ Important guidelines: - - To claim a bounty, you need to **provide a short demo video** of your changes in your pull request - - If anything is unclear, **ask for clarification** before starting as this will help avoid potential rework - - Low quality AI PRs will not receive review and will be closed - - Do not ask to be assigned unless you've contributed before - - Thank you for contributing to ${REPO_FULL_NAME}! - ${ATTEMPTS} - """ - end - - def get_default_template(_type), do: raise("Not implemented") - - def placeholders(:bounty_created, user) do - %{ - "PRIZE_POOL" => "## 💎 $1,000 bounty [• #{user.name}](#{AlgoraWeb.Endpoint.url()}/#{user.handle})", - "ISSUE_NUMBER" => "100", - "REPO_FULL_NAME" => "#{user.provider_login || user.handle}/repo", - "ATTEMPTS" => """ - | Attempt | Started (UTC) | Solution | Actions | - | --- | --- | --- | --- | - | 🟢 [@jsmith](https://github.com/jsmith) | #{Calendar.strftime(DateTime.utc_now(), "%b %d, %Y, %I:%M:%S %p")} | [#101](https://github.com/#{user.provider_login || user.handle}/repo/pull/101) | [Reward](#{AlgoraWeb.Endpoint.url()}/claims/:id) | - """, - "FUND_URL" => AlgoraWeb.Endpoint.url(), - "TWEET_URL" => - "https://twitter.com/intent/tweet?related=algoraio&text=%241%2C000+bounty%21+%F0%9F%92%8E+https%3A%2F%2Fgithub.com%2F#{user.provider_login || user.handle}%2Frepo%2Fissues%2F100", - "ADDITIONAL_OPPORTUNITIES" => "" - } - end - - def placeholders(_type, _user), do: raise("Not implemented") - - def available_variables(:bounty_created) do - [ - "PRIZE_POOL", - "ISSUE_NUMBER", - "REPO_FULL_NAME", - "ATTEMPTS", - "FUND_URL", - "TWEET_URL" - ] - end - - def get_template(org_id, type) do - Repo.get_by(BotTemplate, user_id: org_id, type: type, active: true) - end - - def save_template(org_id, type, template) do - params = %{ - user_id: org_id, - type: type, - template: template, - active: true - } - - %BotTemplate{} - |> BotTemplate.changeset(params) - |> Repo.insert( - on_conflict: [set: [template: template, active: true]], - conflict_target: [:user_id, :type] - ) - end -end diff --git a/lib/algora/bot_templates/schemas/bot_template.ex b/lib/algora/bot_templates/schemas/bot_template.ex deleted file mode 100644 index 5828844fb..000000000 --- a/lib/algora/bot_templates/schemas/bot_template.ex +++ /dev/null @@ -1,32 +0,0 @@ -defmodule Algora.BotTemplates.BotTemplate do - @moduledoc false - use Algora.Schema - - import Ecto.Changeset - - @types [ - :multiple_attempts_detected, - :attempt_rejected, - :bounty_created, - :claim_submitted, - :bounty_awarded - ] - - typed_schema "bot_templates" do - field :template, :string, null: false - field :type, Ecto.Enum, values: @types, null: false - field :active, :boolean, null: false, default: true - belongs_to :user, Algora.Accounts.User, null: false - - timestamps() - end - - def changeset(bot_template, attrs) do - bot_template - |> cast(attrs, [:template, :type, :active, :user_id]) - |> validate_required([:template, :type, :user_id]) - |> generate_id() - |> foreign_key_constraint(:user_id) - |> unique_constraint([:user_id, :type]) - end -end diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex deleted file mode 100644 index 6e3054570..000000000 --- a/lib/algora/bounties/bounties.ex +++ /dev/null @@ -1,1660 +0,0 @@ -defmodule Algora.Bounties do - @moduledoc false - import Ecto.Changeset - import Ecto.Query - - alias Algora.Accounts.User - alias Algora.BotTemplates - alias Algora.BotTemplates.BotTemplate - alias Algora.Bounties.Attempt - alias Algora.Bounties.Bounty - alias Algora.Bounties.Claim - alias Algora.Bounties.Jobs - alias Algora.Bounties.LineItem - alias Algora.Bounties.Tip - alias Algora.Github - alias Algora.Organizations.Member - alias Algora.Payments - alias Algora.Payments.Transaction - alias Algora.PSP - alias Algora.Repo - alias Algora.Util - alias Algora.Workspace - alias Algora.Workspace.Installation - alias Algora.Workspace.Ticket - - require Logger - - def base_query, do: Bounty - - @type criterion :: - {:id, String.t()} - | {:limit, non_neg_integer() | :infinity} - | {:ticket_id, String.t()} - | {:owner_id, String.t()} - | {:owner_handles, [String.t()]} - | {:status, :open | :paid} - | {:tech_stack, [String.t()]} - | {:before, %{inserted_at: DateTime.t(), id: String.t()}} - | {:amount_gt, Money.t()} - | {:current_user, User.t()} - - def broadcast do - Phoenix.PubSub.broadcast(Algora.PubSub, "bounties:all", :bounties_updated) - end - - def subscribe do - Phoenix.PubSub.subscribe(Algora.PubSub, "bounties:all") - end - - @spec do_create_bounty(%{ - creator: User.t(), - owner: User.t(), - amount: Money.t(), - ticket: Ticket.t(), - visibility: Bounty.visibility(), - shared_with: [String.t()], - hours_per_week: integer() | nil, - hourly_rate: Money.t() | nil, - contract_type: Bounty.contract_type() | nil - }) :: - {:ok, Bounty.t()} | {:error, atom()} - defp do_create_bounty(%{creator: creator, owner: owner, amount: amount, ticket: ticket} = params) do - if owner.provider_login in Algora.Settings.get_blocked_users() or - creator.provider_login in Algora.Settings.get_blocked_users() do - raise "blocked" - end - - changeset = - Bounty.changeset(%Bounty{}, %{ - amount: amount, - ticket_id: ticket.id, - owner_id: owner.id, - creator_id: creator.id, - visibility: params[:visibility] || owner.bounty_mode, - shared_with: params[:shared_with] || [], - hours_per_week: params[:hours_per_week], - hourly_rate: params[:hourly_rate], - contract_type: params[:contract_type] - }) - - changeset - |> Repo.insert_with_activity(%{ - type: :bounty_posted, - notify_users: [] - }) - |> case do - {:ok, bounty} -> - {:ok, bounty} - - {:error, %{errors: [ticket_id: {_, [constraint: :unique, constraint_name: _]}]}} -> - {:error, :already_exists} - - {:error, _changeset} = error -> - error - end - end - - @type strategy :: :create | :set | :increase - - @spec strategy_to_action(Bounty.t() | nil, strategy() | nil) :: {:ok, strategy()} | {:error, atom()} - defp strategy_to_action(bounty, strategy) do - case {bounty, strategy} do - {_, nil} -> strategy_to_action(bounty, :increase) - {nil, _} -> {:ok, :create} - {_existing, :create} -> {:error, :already_exists} - {_existing, strategy} -> {:ok, strategy} - end - end - - def create_bounty(_params, opts \\ []) - - @spec create_bounty( - %{ - creator: User.t(), - owner: User.t(), - amount: Money.t(), - ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()} - }, - opts :: [ - strategy: strategy(), - installation_id: integer(), - command_id: integer(), - command_source: :ticket | :comment, - visibility: Bounty.visibility() | nil, - shared_with: [String.t()] | nil, - hourly_rate: Money.t() | nil, - hours_per_week: integer() | nil, - contract_type: Bounty.contract_type() | nil - ] - ) :: - {:ok, Bounty.t()} | {:error, atom()} - def create_bounty( - %{ - creator: creator, - owner: owner, - amount: amount, - ticket_ref: %{owner: repo_owner, repo: repo_name, number: number} = ticket_ref - }, - opts - ) do - command_id = opts[:command_id] - shared_with = opts[:shared_with] || [] - - Repo.tx(fn -> - with {:ok, %{installation_id: installation_id, token: token}} <- - Workspace.resolve_installation_and_token(opts[:installation_id], repo_owner, creator), - {:ok, ticket} <- Workspace.ensure_ticket(token, repo_owner, repo_name, number), - existing = Repo.get_by(Bounty, owner_id: owner.id, ticket_id: ticket.id), - {:ok, strategy} <- strategy_to_action(existing, opts[:strategy]), - {:ok, bounty} <- - (case strategy do - :create -> - do_create_bounty(%{ - creator: creator, - owner: owner, - amount: amount, - ticket: ticket, - visibility: opts[:visibility], - shared_with: shared_with, - hourly_rate: opts[:hourly_rate], - hours_per_week: opts[:hours_per_week], - contract_type: opts[:contract_type] - }) - - :set -> - existing - |> Bounty.changeset(%{ - amount: amount, - visibility: opts[:visibility] || existing.visibility, - shared_with: shared_with - }) - # |> Activity.put_activity(%Bounty{}, %{type: :bounty_updated, notify_users: []}) - |> Repo.update() - - :increase -> - existing - |> Bounty.changeset(%{ - amount: Money.add!(existing.amount, amount), - visibility: opts[:visibility] || existing.visibility, - shared_with: shared_with - }) - # |> Activity.put_activity(%Bounty{}, %{type: :bounty_updated, notify_users: []}) - |> Repo.update() - end), - {:ok, _job} <- - notify_bounty(%{owner: owner, bounty: bounty, ticket_ref: ticket_ref}, - installation_id: installation_id, - command_id: command_id, - command_source: opts[:command_source] - ) do - broadcast() - {:ok, bounty} - else - {:error, _reason} = error -> - Algora.Activities.alert("Error creating bounty: #{inspect(error)}", :error) - error - end - end) - end - - @spec create_bounty( - %{ - creator: User.t(), - owner: User.t(), - amount: Money.t(), - title: String.t(), - description: String.t() - }, - opts :: [ - strategy: strategy(), - visibility: Bounty.visibility() | nil, - shared_with: [String.t()] | nil, - hours_per_week: integer() | nil, - hourly_rate: Money.t() | nil, - contract_type: Bounty.contract_type() | nil - ] - ) :: - {:ok, Bounty.t()} | {:error, atom()} - def create_bounty(%{creator: creator, owner: owner, amount: amount, title: title, description: description}, opts) do - shared_with = opts[:shared_with] || [] - - Repo.tx(fn -> - with {:ok, ticket} <- - %Ticket{type: :issue} - |> Ticket.changeset(%{title: title, description: description}) - |> Repo.insert(), - {:ok, bounty} <- - do_create_bounty(%{ - creator: creator, - owner: owner, - amount: amount, - ticket: ticket, - visibility: opts[:visibility], - shared_with: shared_with, - hours_per_week: opts[:hours_per_week], - hourly_rate: opts[:hourly_rate], - contract_type: opts[:contract_type] - }), - {:ok, _job} <- notify_bounty(%{owner: owner, bounty: bounty}) do - broadcast() - {:ok, bounty} - else - {:error, _reason} = error -> - Algora.Activities.alert("Error creating bounty: #{inspect(error)}", :error) - error - end - end) - end - - defp claim_to_solution(claim) do - %{ - type: :claim, - started_at: claim.inserted_at, - user: claim.user, - group_id: claim.group_id, - solution_id: "claim-#{claim.group_id}", - indicator: "🟢", - solution: "##{claim.source.number}" - } - end - - defp attempt_to_solution(attempt) do - %{ - type: :attempt, - started_at: attempt.inserted_at, - user: attempt.user, - group_id: attempt.id, - solution_id: "attempt-#{attempt.id}", - indicator: get_attempt_emoji(attempt), - solution: "WIP" - } - end - - @spec get_response_body( - bounties :: list(Bounty.t()), - ticket_ref :: %{owner: String.t(), repo: String.t(), number: integer()}, - attempts :: list(Attempt.t()), - claims :: list(Claim.t()) - ) :: String.t() - def get_response_body(bounties, ticket_ref, attempts, claims) do - custom_template = - Repo.one( - from bt in BotTemplate, - where: bt.type == :bounty_created, - where: bt.active == true, - join: u in assoc(bt, :user), - join: r in assoc(u, :repositories), - join: t in assoc(r, :tickets), - where: t.id == ^List.first(bounties).ticket_id - ) - - prize_pool = format_prize_pool(bounties) - attempts_table = format_attempts_table(attempts, claims) - - template = - if custom_template do - custom_template.template - else - BotTemplates.get_default_template(:bounty_created) - end - - template - |> String.replace("${PRIZE_POOL}", prize_pool) - |> String.replace("${ISSUE_NUMBER}", to_string(ticket_ref[:number])) - |> String.replace("${REPO_FULL_NAME}", "#{ticket_ref[:owner]}/#{ticket_ref[:repo]}") - |> String.replace("${ATTEMPTS}", attempts_table) - |> String.replace("${FUND_URL}", AlgoraWeb.Endpoint.url()) - |> String.replace("${TWEET_URL}", generate_tweet_url(bounties, ticket_ref)) - |> String.replace("${ADDITIONAL_OPPORTUNITIES}", "") - |> String.trim() - end - - defp generate_tweet_url(bounties, ticket_ref) do - total_amount = Enum.reduce(bounties, Money.new(0, :USD), &Money.add!(&2, &1.amount)) - - text = - "#{Money.to_string!(total_amount, no_fraction_if_integer: true)} bounty! 💎 https://github.com/#{ticket_ref[:owner]}/#{ticket_ref[:repo]}/issues/#{ticket_ref[:number]}" - - uri = URI.parse("https://twitter.com/intent/tweet") - - query = - URI.encode_query(%{ - "text" => text, - "related" => "algoraio" - }) - - URI.to_string(%{uri | query: query}) - end - - defp format_prize_pool(bounties) do - Enum.map_join(bounties, "\n", fn bounty -> - "## 💎 #{Money.to_string!(bounty.amount, no_fraction_if_integer: true)} bounty [• #{bounty.owner.name}](#{User.url(bounty.owner)})" - end) - end - - defp format_attempts_table(attempts, claims) do - solutions = - [] - |> Enum.concat(Enum.map(claims, &claim_to_solution/1)) - |> Enum.concat(Enum.map(attempts, &attempt_to_solution/1)) - |> Enum.group_by(& &1.user.id) - |> Enum.map(fn {_user_id, solutions} -> - started_at = Enum.min_by(solutions, & &1.started_at).started_at - solution = Enum.find(solutions, &(&1.type == :claim)) || List.first(solutions) - %{solution | started_at: started_at} - end) - |> Enum.group_by(& &1.solution_id) - |> Enum.sort_by(fn {_solution_id, solutions} -> Enum.min_by(solutions, & &1.started_at).started_at end) - |> Enum.map(fn {_solution_id, solutions} -> - primary_solution = Enum.min_by(solutions, & &1.started_at) - timestamp = Calendar.strftime(primary_solution.started_at, "%b %d, %Y, %I:%M:%S %p") - - users = - solutions - |> Enum.sort_by(& &1.started_at) - |> Enum.map(&"@#{&1.user.provider_login}") - |> Util.format_name_list() - - actions = - if primary_solution.type == :claim do - "[Reward](#{AlgoraWeb.Endpoint.url()}/claims/#{primary_solution.group_id})" - else - "" - end - - "| #{primary_solution.indicator} #{users} | #{timestamp} | #{primary_solution.solution} | #{actions} |" - end) - - if solutions == [] do - "" - else - """ - - | Attempt | Started (UTC) | Solution | Actions | - | --- | --- | --- | --- | - #{Enum.join(solutions, "\n")} - """ - end - end - - def refresh_bounty_response(token, ticket_ref, ticket) do - bounties = list_bounties(ticket_id: ticket.id) - attempts = list_attempts_for_ticket(ticket.id) - claims = list_claims([ticket.id]) - body = get_response_body(bounties, ticket_ref, attempts, claims) - - Workspace.refresh_command_response(%{ - token: token, - ticket_ref: ticket_ref, - ticket: ticket, - body: body, - command_type: :bounty - }) - end - - def try_refresh_bounty_response(token, ticket_ref, ticket) do - case refresh_bounty_response(token, ticket_ref, ticket) do - {:ok, response} -> - {:ok, response} - - {:error, _} -> - Logger.warning( - "Failed to refresh bounty response for #{ticket_ref[:owner]}/#{ticket_ref[:repo]}##{ticket_ref[:number]}" - ) - - {:ok, nil} - end - end - - def notify_bounty(bounty, opts \\ []) - - @spec notify_bounty( - %{ - owner: User.t(), - bounty: Bounty.t(), - ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()} - }, - opts :: [installation_id: integer(), command_id: integer(), command_source: :ticket | :comment] - ) :: - {:ok, Oban.Job.t()} | {:error, atom()} - def notify_bounty(%{owner: owner, bounty: bounty, ticket_ref: ticket_ref}, opts) do - %{ - owner_login: owner.provider_login, - amount: Money.to_string!(bounty.amount, no_fraction_if_integer: true), - ticket_ref: %{owner: ticket_ref.owner, repo: ticket_ref.repo, number: ticket_ref.number}, - installation_id: opts[:installation_id], - command_id: opts[:command_id], - command_source: opts[:command_source], - bounty_id: bounty.id, - visibility: bounty.visibility, - shared_with: bounty.shared_with - } - |> Jobs.NotifyBounty.new() - |> Oban.insert() - end - - @spec notify_bounty(%{owner: User.t(), bounty: Bounty.t()}, opts :: []) :: - {:ok, nil} | {:error, atom()} - def notify_bounty(%{owner: owner, bounty: bounty}, _opts) do - Algora.Activities.alert( - "New contract offer: #{AlgoraWeb.Endpoint.url()}/#{owner.handle}/contracts/#{bounty.id}", - :critical - ) - - {:ok, nil} - end - - @spec do_claim_bounty(%{ - provider_login: String.t(), - token: String.t(), - target: Ticket.t(), - source: Ticket.t(), - group_id: String.t() | nil, - group_share: Decimal.t(), - status: Claim.status(), - type: Claim.type() - }) :: - {:ok, Claim.t()} | {:error, atom()} - defp do_claim_bounty(%{ - provider_login: provider_login, - token: token, - target: target, - source: source, - group_id: group_id, - group_share: group_share, - status: status, - type: type - }) do - case Workspace.ensure_user(token, provider_login) do - {:ok, user} -> - activity_attrs = %{type: :claim_submitted, notify_users: []} - - claim_attrs = %{ - target_id: target.id, - source_id: source.id, - user_id: user.id, - type: type, - status: status, - url: source.url, - group_id: group_id, - group_share: group_share - } - - # Try to find existing claim - existing_claim = - Repo.get_by(Claim, - target_id: target.id, - source_id: source.id, - user_id: user.id - ) - - case existing_claim do - nil -> - # Create new claim - %Claim{} - |> Claim.changeset(claim_attrs) - |> Repo.insert_with_activity(activity_attrs) - - claim -> - claim - |> Claim.changeset(claim_attrs) - |> Repo.update_with_activity(activity_attrs) - end - - {:error, _reason} = error -> - error - end - end - - @spec do_claim_bounties(%{ - provider_logins: [String.t()], - token: String.t(), - target: Ticket.t(), - source: Ticket.t(), - status: Claim.status(), - type: Claim.type() - }) :: - {:ok, [Claim.t()]} | {:error, atom()} - defp do_claim_bounties(%{ - provider_logins: provider_logins, - token: token, - target: target, - source: source, - status: status, - type: type - }) do - Enum.reduce_while(provider_logins, {:ok, []}, fn provider_login, {:ok, acc} -> - group_id = - case List.last(acc) do - nil -> nil - primary_claim -> primary_claim.group_id - end - - case do_claim_bounty(%{ - provider_login: provider_login, - token: token, - target: target, - source: source, - status: status, - type: type, - group_id: group_id, - group_share: Decimal.div(1, length(provider_logins)) - }) do - {:ok, claim} -> {:cont, {:ok, [claim | acc]}} - error -> {:halt, error} - end - end) - end - - @spec claim_bounty( - %{ - user: User.t(), - coauthor_provider_logins: [String.t()], - target_ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()}, - source_ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()}, - status: Claim.status(), - type: Claim.type() - }, - opts :: [installation_id: integer()] - ) :: - {:ok, [Claim.t()]} | {:error, atom()} - def claim_bounty( - %{ - user: user, - coauthor_provider_logins: coauthor_provider_logins, - target_ticket_ref: %{owner: target_repo_owner, repo: target_repo_name, number: target_number}, - source_ticket_ref: %{owner: source_repo_owner, repo: source_repo_name, number: source_number}, - status: status, - type: type - }, - opts \\ [] - ) do - Repo.tx(fn -> - with {:ok, %{installation_id: installation_id, token: token}} <- - Workspace.resolve_installation_and_token(opts[:installation_id], source_repo_owner, user), - {:ok, target} <- Workspace.ensure_ticket(token, target_repo_owner, target_repo_name, target_number), - {:ok, source} <- Workspace.ensure_ticket(token, source_repo_owner, source_repo_name, source_number) do - # Get all active claims for this PR - active_claims = get_active_claims(source.id) - requested_participants = [user.provider_login | coauthor_provider_logins] - - cond do - # Case 1: Target changed - cancel all claims and create new ones - target_changed?(active_claims, target.id) -> - with :ok <- cancel_all_claims(active_claims) do - create_new_claims(token, source, target, requested_participants, status, type, installation_id) - end - - # Case 3: Participants changed - cancel old claims and create new ones - participants_changed?(active_claims, requested_participants) -> - with :ok <- cancel_all_claims(active_claims) do - create_new_claims(token, source, target, requested_participants, status, type, installation_id) - end - - # Case 4: No existing claims - create new ones - Enum.empty?(active_claims) -> - create_new_claims(token, source, target, requested_participants, status, type, installation_id) - - # Case 5: No changes needed - true -> - {:ok, active_claims} - end - else - {:error, _reason} = error -> error - end - end) - end - - def get_active_claims(source_id) do - Repo.all( - from c in Claim, - where: c.source_id == ^source_id, - where: c.status == :pending, - preload: [:user] - ) - end - - defp target_changed?(claims, target_id) do - Enum.any?(claims, fn claim -> claim.target_id != target_id end) - end - - defp participants_changed?(claims, requested_participants) do - current_participants = - claims - |> Enum.map(& &1.user.provider_login) - |> Enum.sort() - - requested_participants - |> Enum.sort() - |> Kernel.!=(current_participants) - end - - def cancel_all_claims(claims) do - Enum.reduce_while(claims, :ok, fn claim, :ok -> - case claim - |> Claim.changeset(%{status: :cancelled, group_share: Decimal.new(0)}) - |> Repo.update() do - {:ok, _} -> {:cont, :ok} - error -> error - end - end) - end - - defp create_new_claims(token, source, target, participants, status, type, installation_id) do - with {:ok, [claim | _] = claims} <- - do_claim_bounties(%{ - provider_logins: participants, - token: token, - target: target, - source: source, - status: status, - type: type - }), - {:ok, _job} <- notify_claim(%{claim: claim}, installation_id: installation_id) do - broadcast() - {:ok, claims} - end - end - - @spec notify_claim( - %{claim: Claim.t()}, - opts :: [installation_id: integer()] - ) :: - {:ok, Oban.Job.t()} | {:error, atom()} - def notify_claim(%{claim: claim}, opts \\ []) do - %{claim_group_id: claim.group_id, installation_id: opts[:installation_id]} - |> Jobs.NotifyClaim.new() - |> Oban.insert() - end - - @spec build_tip_intent( - %{ - recipient: String.t() | nil, - amount: Money.t() | nil, - ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()} - }, - opts :: [installation_id: integer()] - ) :: - Oban.Job.changeset() - def build_tip_intent( - %{recipient: recipient, amount: amount, ticket_ref: %{owner: owner, repo: repo, number: number}}, - opts \\ [] - ) do - body = - cond do - recipient == nil -> - "Please specify a recipient to tip (e.g. `/tip $#{Money.to_decimal(amount)} @jsmith`)" - - amount == nil -> - "Please specify an amount to tip (e.g. `/tip $100 @#{recipient}`)" - - true -> - installation = - case opts[:installation_id] do - nil -> nil - installation_id -> Repo.get_by(Installation, provider: "github", provider_id: to_string(installation_id)) - end - - query = - URI.encode_query( - amount: Money.to_decimal(amount), - recipient: recipient, - owner: owner, - repo: repo, - number: number, - org_id: if(installation, do: installation.connected_user_id) - ) - - url = AlgoraWeb.Endpoint.url() <> "/tip" <> "?" <> query - - "Please visit [Algora](#{url}) to complete your tip via Stripe." - end - - Jobs.NotifyTipIntent.new(%{ - body: body, - ticket_ref: %{owner: owner, repo: repo, number: number}, - installation_id: opts[:installation_id] - }) - end - - @spec create_tip_intent( - params :: %{ - recipient: String.t() | nil, - amount: Money.t() | nil, - ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()} - }, - opts :: [installation_id: integer()] - ) :: - {:ok, Oban.Job.t()} | {:error, atom()} - def create_tip_intent(params, opts \\ []) do - params - |> build_tip_intent(opts) - |> Oban.insert() - end - - @spec create_tip( - %{creator: User.t(), owner: User.t(), recipient: User.t(), amount: Money.t()}, - opts :: [ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()}, installation_id: integer()] - ) :: - {:ok, String.t()} | {:error, atom()} - def create_tip(%{creator: creator, owner: owner, recipient: recipient, amount: amount}, opts \\ []) do - Repo.tx(fn -> - case do_create_tip(%{creator: creator, owner: owner, recipient: recipient, amount: amount}, opts) do - {:ok, tip} -> - create_payment_session( - %{owner: owner, amount: amount, description: "Tip payment for OSS contributions"}, - ticket_ref: opts[:ticket_ref], - tip_id: tip.id, - recipient: recipient - ) - - {:error, reason} -> - Algora.Activities.alert("Error creating tip: #{inspect(reason)}", :error) - {:error, reason} - end - end) - end - - @spec do_create_tip( - %{creator: User.t(), owner: User.t(), recipient: User.t(), amount: Money.t()}, - opts :: [ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()}, installation_id: integer()] - ) :: - {:ok, Tip.t()} | {:error, atom()} - def do_create_tip(%{creator: creator, owner: owner, recipient: recipient, amount: amount}, opts \\ []) do - if owner.provider_login in Algora.Settings.get_blocked_users() or - creator.provider_login in Algora.Settings.get_blocked_users() do - raise "blocked" - end - - ticket_res = - if ticket_ref = opts[:ticket_ref] do - with {:ok, %{token: token}} <- - Workspace.resolve_installation_and_token(opts[:installation_id], ticket_ref[:owner], creator) do - Workspace.ensure_ticket(token, ticket_ref[:owner], ticket_ref[:repo], ticket_ref[:number]) - end - else - {:ok, nil} - end - - with {:ok, ticket} <- ticket_res do - %Tip{} - |> Tip.changeset(%{ - amount: amount, - owner_id: owner.id, - creator_id: creator.id, - recipient_id: recipient.id, - ticket_id: if(ticket, do: ticket.id) - }) - |> Repo.insert() - end - end - - @spec reward_bounty( - %{ - owner: User.t(), - amount: Money.t(), - bounty: Bounty.t(), - claims: [Claim.t()] - }, - opts :: [ - ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()}, - recipient: User.t(), - success_url: String.t(), - cancel_url: String.t() - ] - ) :: - {:ok, String.t()} | {:error, atom()} - def reward_bounty(%{owner: owner, amount: amount, bounty: bounty, claims: claims}, opts \\ []) do - create_payment_session( - %{owner: owner, amount: amount, description: "Bounty payment for OSS contributions"}, - ticket_ref: opts[:ticket_ref], - bounty: bounty, - claims: claims, - recipient: opts[:recipient], - success_url: opts[:success_url], - cancel_url: opts[:cancel_url] - ) - end - - @spec authorize_payment( - %{ - owner: User.t(), - amount: Money.t(), - bounty: Bounty.t(), - claims: [Claim.t()] - }, - opts :: [ - ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()}, - recipient: User.t(), - success_url: String.t(), - cancel_url: String.t() - ] - ) :: - {:ok, String.t()} | {:error, atom()} - def authorize_payment(%{owner: owner, amount: amount, bounty: bounty, claims: claims}, opts \\ []) do - create_payment_session( - %{owner: owner, amount: amount, description: "Bounty payment for OSS contributions"}, - ticket_ref: opts[:ticket_ref], - bounty: bounty, - claims: claims, - recipient: opts[:recipient], - capture_method: :manual, - success_url: opts[:success_url], - cancel_url: opts[:cancel_url] - ) - end - - @spec generate_line_items( - %{owner: User.t(), amount: Money.t()}, - opts :: [ - bounty: Bounty.t(), - ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()}, - claims: [Claim.t()], - recipient: User.t() - ] - ) :: - [LineItem.t()] - def generate_line_items(%{owner: owner, amount: amount}, opts \\ []) do - bounty = opts[:bounty] - ticket_ref = opts[:ticket_ref] - recipient = opts[:recipient] - claims = opts[:claims] || [] - - description = if(ticket_ref, do: "#{ticket_ref[:repo]}##{ticket_ref[:number]}") - - platform_fee_pct = - if bounty && Date.before?(bounty.inserted_at, ~D[2025-04-16]) && is_nil(bounty.contract_type) do - Decimal.div(owner.fee_pct_prev, 100) - else - Decimal.div(owner.fee_pct, 100) - end - - transaction_fee_pct = Payments.get_transaction_fee_pct() - - case opts[:bounty] do - %{contract_type: :marketplace} -> - [ - %LineItem{ - amount: amount, - title: "Contract payment - @#{recipient.provider_login}", - description: "(includes all platform and payment processing fees)", - image: recipient.avatar_url, - type: :payout - } - ] - - _ -> - if recipient do - [ - %LineItem{ - amount: amount, - title: "Payment to @#{recipient.provider_login}", - description: description, - image: recipient.avatar_url, - type: :payout - } - ] - else - Enum.map(claims, fn claim -> - %LineItem{ - # TODO: ensure shares are normalized - amount: Money.mult!(amount, claim.group_share), - title: "Payment to @#{claim.user.provider_login}", - description: description, - image: claim.user.avatar_url, - type: :payout - } - end) - end ++ - [ - %LineItem{ - amount: Money.mult!(amount, platform_fee_pct), - title: "Algora platform fee (#{Util.format_pct(platform_fee_pct)})", - type: :fee - }, - %LineItem{ - amount: Money.mult!(amount, transaction_fee_pct), - title: "Transaction fee (#{Util.format_pct(transaction_fee_pct)})", - type: :fee - } - ] - end - end - - def final_contract_amount(:marketplace, amount), do: amount - - def final_contract_amount(:bring_your_own, amount), do: Money.mult!(amount, Decimal.new("1.13")) - - @spec create_payment_session( - %{owner: User.t(), amount: Money.t(), description: String.t()}, - opts :: [ - ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()}, - tip_id: String.t(), - bounty: Bounty.t(), - claims: [Claim.t()], - recipient: User.t(), - capture_method: :automatic | :automatic_async | :manual, - success_url: String.t(), - cancel_url: String.t() - ] - ) :: - {:ok, String.t()} | {:error, atom()} - def create_payment_session(%{owner: owner, amount: amount, description: description}, opts \\ []) do - if owner.provider_login in Algora.Settings.get_blocked_users() do - raise "blocked" - end - - tx_group_id = Nanoid.generate() - - line_items = - generate_line_items(%{owner: owner, amount: amount}, - ticket_ref: opts[:ticket_ref], - recipient: opts[:recipient], - claims: opts[:claims], - bounty: opts[:bounty] - ) - - payment_intent_data = %{ - description: description, - metadata: %{"version" => Payments.metadata_version(), "group_id" => tx_group_id} - } - - {payment_intent_data, session_opts} = - if capture_method = opts[:capture_method] do - {Map.put(payment_intent_data, :capture_method, capture_method), - [success_url: opts[:success_url], cancel_url: opts[:cancel_url]]} - else - {payment_intent_data, []} - end - - gross_amount = LineItem.gross_amount(line_items) - - bounty_id = if bounty = opts[:bounty], do: bounty.id - - Repo.tx(fn -> - with {:ok, _charge} <- - initialize_charge(%{ - id: Nanoid.generate(), - user_id: owner.id, - bounty_id: bounty_id, - gross_amount: gross_amount, - net_amount: amount, - total_fee: Money.sub!(gross_amount, amount), - line_items: line_items, - group_id: tx_group_id, - idempotency_key: "session-#{Nanoid.generate()}" - }), - {:ok, _transactions} <- - create_transaction_pairs(%{ - claims: opts[:claims] || [], - tip_id: opts[:tip_id], - recipient_id: if(opts[:recipient], do: opts[:recipient].id), - bounty_id: bounty_id, - claim_id: nil, - amount: amount, - creator_id: owner.id, - group_id: tx_group_id - }), - {:ok, session} <- - Payments.create_stripe_session( - owner, - Enum.map(line_items, &LineItem.to_stripe/1), - payment_intent_data, - session_opts - ) do - {:ok, session.url} - end - end) - end - - @spec create_invoice( - %{owner: User.t(), amount: Money.t(), idempotency_key: String.t()}, - opts :: [ - ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()}, - tip_id: String.t(), - bounty: Bounty.t(), - claims: [Claim.t()], - recipient: User.t() - ] - ) :: - {:ok, PSP.invoice()} | {:error, atom()} - def create_invoice(%{owner: owner, amount: amount, idempotency_key: idempotency_key}, opts \\ []) do - tx_group_id = Nanoid.generate() - - line_items = - generate_line_items(%{owner: owner, amount: amount}, - ticket_ref: opts[:ticket_ref], - recipient: opts[:recipient], - claims: opts[:claims], - bounty: opts[:bounty] - ) - - gross_amount = LineItem.gross_amount(line_items) - - bounty_id = if bounty = opts[:bounty], do: bounty.id - - Repo.tx(fn -> - with {:ok, _charge} <- - initialize_charge(%{ - id: Nanoid.generate(), - user_id: owner.id, - bounty_id: bounty_id, - gross_amount: gross_amount, - net_amount: amount, - total_fee: Money.sub!(gross_amount, amount), - line_items: line_items, - group_id: tx_group_id, - idempotency_key: idempotency_key - }), - {:ok, _transactions} <- - create_transaction_pairs(%{ - claims: opts[:claims] || [], - tip_id: opts[:tip_id], - recipient_id: if(opts[:recipient], do: opts[:recipient].id), - bounty_id: bounty_id, - claim_id: nil, - amount: amount, - creator_id: owner.id, - group_id: tx_group_id - }), - {:ok, customer} <- Payments.fetch_or_create_customer(owner), - {:ok, invoice} <- - PSP.Invoice.create( - %{ - auto_advance: false, - customer: customer.provider_id, - metadata: %{"version" => Payments.metadata_version(), "group_id" => tx_group_id} - }, - %{idempotency_key: idempotency_key} - ), - {:ok, _line_items} <- - line_items - |> Enum.map(&LineItem.to_invoice_item(&1, invoice, customer)) - |> Enum.with_index() - |> Enum.reduce_while({:ok, []}, fn {params, index}, {:ok, acc} -> - case PSP.Invoiceitem.create(params, %{idempotency_key: "#{idempotency_key}-#{index}"}) do - {:ok, item} -> {:cont, {:ok, [item | acc]}} - {:error, error} -> {:halt, {:error, error}} - end - end) do - {:ok, invoice} - end - end) - end - - defp initialize_charge( - %{ - id: id, - user_id: user_id, - gross_amount: gross_amount, - net_amount: net_amount, - total_fee: total_fee, - line_items: line_items, - group_id: group_id, - idempotency_key: idempotency_key - } = params - ) do - %Transaction{} - |> change(%{ - id: id, - provider: "stripe", - type: :charge, - status: :initialized, - user_id: user_id, - bounty_id: params[:bounty_id], - gross_amount: gross_amount, - net_amount: net_amount, - total_fee: total_fee, - line_items: Util.normalize_struct(line_items), - group_id: group_id, - idempotency_key: idempotency_key - }) - |> Algora.Validations.validate_positive(:gross_amount) - |> Algora.Validations.validate_positive(:net_amount) - |> foreign_key_constraint(:user_id) - |> unique_constraint([:idempotency_key]) - |> Repo.insert() - end - - defp initialize_debit(%{ - id: id, - tip_id: tip_id, - bounty_id: bounty_id, - claim_id: claim_id, - amount: amount, - user_id: user_id, - linked_transaction_id: linked_transaction_id, - group_id: group_id - }) do - %Transaction{} - |> change(%{ - id: id, - provider: "stripe", - type: :debit, - status: :initialized, - tip_id: tip_id, - bounty_id: bounty_id, - claim_id: claim_id, - user_id: user_id, - gross_amount: amount, - net_amount: amount, - total_fee: Money.zero(:USD), - linked_transaction_id: linked_transaction_id, - group_id: group_id - }) - |> Algora.Validations.validate_positive(:gross_amount) - |> Algora.Validations.validate_positive(:net_amount) - |> foreign_key_constraint(:user_id) - |> foreign_key_constraint(:tip_id) - |> foreign_key_constraint(:bounty_id) - |> foreign_key_constraint(:claim_id) - |> Repo.insert() - end - - defp initialize_credit(%{ - id: id, - tip_id: tip_id, - bounty_id: bounty_id, - claim_id: claim_id, - amount: amount, - user_id: user_id, - linked_transaction_id: linked_transaction_id, - group_id: group_id - }) do - %Transaction{} - |> change(%{ - id: id, - provider: "stripe", - type: :credit, - status: :initialized, - tip_id: tip_id, - bounty_id: bounty_id, - claim_id: claim_id, - user_id: user_id, - gross_amount: amount, - net_amount: amount, - total_fee: Money.zero(:USD), - linked_transaction_id: linked_transaction_id, - group_id: group_id - }) - |> Algora.Validations.validate_positive(:gross_amount) - |> Algora.Validations.validate_positive(:net_amount) - |> foreign_key_constraint(:user_id) - |> foreign_key_constraint(:tip_id) - |> foreign_key_constraint(:bounty_id) - |> foreign_key_constraint(:claim_id) - |> Repo.insert() - end - - @spec apply_criteria(Ecto.Queryable.t(), [criterion()]) :: Ecto.Queryable.t() - defp apply_criteria(query, criteria) do - Enum.reduce(criteria, query, fn - {:id, id}, query -> - from([b] in query, where: b.id == ^id) - - {:limit, :infinity}, query -> - query - - {:limit, limit}, query -> - from([b] in query, limit: ^limit) - - {:ticket_id, ticket_id}, query -> - from([b] in query, where: b.ticket_id == ^ticket_id) - - {:owner_id, owner_id}, query -> - from([b, r: r] in query, where: b.owner_id == ^owner_id or r.user_id == ^owner_id) - - {:owner_handle, owner_handle}, query -> - from([b, o: o, ro: ro] in query, where: o.handle == ^owner_handle or ro.handle == ^owner_handle) - - {:owner_handles, owner_handles}, query -> - from([b, o: o, ro: ro] in query, where: o.handle in ^owner_handles or ro.handle in ^owner_handles) - - {:status, status}, query -> - query = where(query, [b], b.status == ^status) - - case status do - :open -> - query = where(query, [t: t], t.state == :open) - - query = - case criteria[:current_user] do - nil -> - where(query, [b], b.visibility != :exclusive) - - user -> - where( - query, - [b], - b.visibility != :exclusive or - fragment( - "? && ARRAY[?, ?, ?]::citext[]", - b.shared_with, - ^user.id, - ^user.email, - ^to_string(user.provider_id) - ) or - fragment( - "EXISTS (SELECT 1 FROM members m WHERE m.user_id = ? AND m.org_id = ? AND m.role = ANY(?))", - ^user.id, - b.owner_id, - ^["admin", "mod"] - ) - ) - end - - query = - case criteria[:owner_id] || criteria[:owner_handle] do - nil -> - where(query, [b, o: o], (b.visibility == :public and o.featured == true) or b.visibility == :exclusive) - - _org_id -> - query - end - - query - - _ -> - query - end - - {:before, %{inserted_at: inserted_at, id: id}}, query -> - from([b] in query, - where: {b.inserted_at, b.id} < {^inserted_at, ^id} - ) - - {:tech_stack, []}, query -> - query - - {:tech_stack, tech_stack}, query -> - from([b, r: r] in query, - where: b.visibility == :exclusive or fragment("? && ?::citext[]", r.tech_stack, ^tech_stack) - ) - - {:amount_gt, min_amount}, query -> - from([b] in query, - where: - b.visibility == :exclusive or - fragment( - "?::money_with_currency >= (?, ?)::money_with_currency", - b.amount, - ^to_string(min_amount.currency), - ^min_amount.amount - ) - ) - - _, query -> - query - end) - end - - def list_contracts_query(base_query, criteria \\ []) do - criteria = Keyword.merge([order: :date, limit: 10], criteria) - - base_bounties = select(base_query, [b], b.id) - - query = - from(b in Bounty) - |> join(:inner, [b], bb in subquery(base_bounties), on: b.id == bb.id) - |> join(:inner, [b], t in assoc(b, :ticket), as: :t) - |> join(:inner, [b], o in assoc(b, :owner), as: :o) - |> where([b], not is_nil(b.amount)) - |> where([b], b.status != :cancelled) - |> where([b], not is_nil(b.contract_type)) - - if criteria[:org_id] do - where(query, [b], b.owner_id == ^criteria[:org_id]) - else - query - end - end - - def list_contracts(criteria \\ []) do - base_query() - |> list_contracts_query(criteria) - |> select_merge([b, o: o, t: t], %{ - owner: o, - ticket: t - }) - |> Repo.all() - end - - def list_bounties_query(base_query, criteria \\ []) do - criteria = Keyword.merge([order: :date, limit: 10], criteria) - - base_bounties = select(base_query, [b], b.id) - - from(b in Bounty) - |> join(:inner, [b], bb in subquery(base_bounties), on: b.id == bb.id) - |> join(:left, [b], t in assoc(b, :ticket), as: :t) - |> join(:inner, [b], o in assoc(b, :owner), as: :o) - |> join(:left, [t: t], r in assoc(t, :repository), as: :r) - |> join(:left, [r: r], ro in assoc(r, :user), as: :ro) - |> where([b], not is_nil(b.amount)) - |> where([b], b.status != :cancelled) - |> apply_criteria(criteria) - end - - def list_bounties_with(base_query, criteria \\ []) do - base_query - |> list_bounties_query(criteria) - # TODO: sort by b.paid_at if criteria[:status] == :paid - |> order_by([b], desc: b.inserted_at, desc: b.id) - |> select([b, o: o, t: t, ro: ro, r: r], %{ - id: b.id, - inserted_at: b.inserted_at, - amount: b.amount, - status: b.status, - owner: %{ - id: o.id, - inserted_at: o.inserted_at, - name: o.name, - handle: o.handle, - provider_login: o.provider_login, - avatar_url: o.avatar_url, - tech_stack: o.tech_stack - }, - ticket_id: t.id, - ticket: %{ - id: t.id, - title: t.title, - number: t.number, - url: t.url, - description: t.description - }, - repository: %{ - id: r.id, - name: r.name, - owner: %{ - id: ro.id, - name: ro.name, - handle: ro.handle, - provider_login: ro.provider_login, - avatar_url: ro.avatar_url - } - } - }) - |> Repo.all() - end - - def list_tech(criteria \\ []) do - base_query() - |> list_bounties_query(Keyword.put(criteria, :limit, :infinity)) - |> where([b, r: r], not is_nil(r.tech_stack)) - |> join(:cross_lateral, [b, r: r], tech in fragment("SELECT UNNEST(?) as tech", r.tech_stack), as: :tech) - |> group_by([b, r: r, tech: tech], fragment("tech")) - |> select([b, r: r, tech: tech], {fragment("tech"), count(fragment("tech"))}) - |> order_by([b, r: r, tech: tech], desc: count(fragment("tech"))) - |> Repo.all() - end - - @spec list_claims(list(String.t())) :: [Claim.t()] - def list_claims(ticket_ids) do - Repo.all( - from c in Claim, - join: t in assoc(c, :target), - join: user in assoc(c, :user), - left_join: s in assoc(c, :source), - where: t.id in ^ticket_ids, - where: c.status != :cancelled, - select_merge: %{user: user, source: s} - ) - end - - def list_bounties(criteria \\ []) do - list_bounties_with(base_query(), criteria) - end - - def fetch_stats(opts) do - zero_money = Money.zero(:USD, no_fraction_if_integer: true) - - open_bounties_query = - from b in Bounty, - join: t in assoc(b, :ticket), - left_join: r in assoc(t, :repository), - where: b.owner_id == ^opts[:org_id] or r.user_id == ^opts[:org_id], - where: b.status == :open, - where: b.status != :cancelled, - where: not is_nil(b.amount), - where: t.state == :open - - open_bounties_query = - case(opts[:current_user]) do - nil -> - where(open_bounties_query, [b], b.visibility != :exclusive) - - user -> - where( - open_bounties_query, - [b], - b.visibility != :exclusive or - fragment( - "? && ARRAY[?, ?, ?]::citext[]", - b.shared_with, - ^user.id, - ^user.email, - ^to_string(user.provider_id) - ) or - fragment( - "EXISTS (SELECT 1 FROM members m WHERE m.user_id = ? AND m.org_id = ? AND m.role = ANY(?))", - ^user.id, - b.owner_id, - ^["admin", "mod"] - ) - ) - end - - rewards_query = - from tx in Transaction, - where: tx.type == :credit, - where: tx.status == :succeeded, - join: ltx in assoc(tx, :linked_transaction), - left_join: b in assoc(tx, :bounty), - as: :b, - left_join: t in assoc(b, :ticket), - left_join: r in assoc(t, :repository), - where: ltx.type == :debit, - where: ltx.status == :succeeded, - where: ltx.user_id == ^opts[:org_id] or r.user_id == ^opts[:org_id] - - rewarded_bounties_query = - rewards_query - |> where([t], not is_nil(t.bounty_id)) - |> distinct([:user_id, :bounty_id]) - - rewarded_tips_query = - rewards_query - |> where([t], not is_nil(t.tip_id)) - |> distinct([:user_id, :tip_id]) - - rewarded_bonuses_query = - rewards_query - |> where([t, b: b], t.net_amount > b.amount) - |> distinct([:user_id, :bounty_id]) - - rewarded_contracts_query = - rewards_query - |> where([t], not is_nil(t.contract_id)) - |> distinct([:user_id, :contract_id]) - - rewarded_users_query = - rewards_query - |> distinct(true) - |> select([:user_id]) - - rewarded_users_diff_query = - from t in rewarded_users_query, - where: t.succeeded_at >= fragment("NOW() - INTERVAL '1 month'"), - except_all: ^from(t in rewarded_users_query, where: t.succeeded_at < fragment("NOW() - INTERVAL '1 month'")) - - members_query = Member.filter_by_org_id(Member, opts[:org_id]) - open_bounties = Repo.aggregate(open_bounties_query, :count, :id) - open_bounties_amount = Repo.aggregate(open_bounties_query, :sum, :amount) || zero_money - total_awarded_amount = Repo.aggregate(rewards_query, :sum, :net_amount) || zero_money - rewarded_bounties_count = Repo.aggregate(rewarded_bounties_query, :count, :id) - rewarded_tips_count = Repo.aggregate(rewarded_tips_query, :count, :id) - rewarded_bonuses_count = Repo.aggregate(rewarded_bonuses_query, :count, :id) - rewarded_contracts_count = Repo.aggregate(rewarded_contracts_query, :count, :id) - solvers_diff = Repo.aggregate(rewarded_users_diff_query, :count, :user_id) - solvers_count = Repo.aggregate(rewarded_users_query, :count, :user_id) - members_count = Repo.aggregate(members_query, :count, :id) - - %{ - open_bounties_amount: open_bounties_amount, - open_bounties_count: open_bounties, - total_awarded_amount: total_awarded_amount, - rewarded_bounties_count: rewarded_bounties_count, - rewarded_tips_count: rewarded_tips_count + rewarded_bonuses_count, - rewarded_contracts_count: rewarded_contracts_count, - solvers_count: solvers_count, - solvers_diff: solvers_diff, - members_count: members_count - } - end - - # Helper function to create transaction pairs - defp create_transaction_pairs(%{amount: amount, claims: claims} = params) when length(claims) > 0 do - Enum.reduce_while(claims, {:ok, []}, fn claim, {:ok, acc} -> - params - |> Map.put(:claim_id, claim.id) - |> Map.put(:recipient_id, claim.user.id) - |> Map.put(:amount, Money.mult!(amount, claim.group_share)) - |> create_single_transaction_pair() - |> case do - {:ok, transactions} -> {:cont, {:ok, transactions ++ acc}} - error -> {:halt, error} - end - end) - end - - defp create_transaction_pairs(params) do - create_single_transaction_pair(params) - end - - defp create_single_transaction_pair(params) do - debit_id = Nanoid.generate() - credit_id = Nanoid.generate() - - with {:ok, debit} <- - initialize_debit(%{ - id: debit_id, - tip_id: params.tip_id, - bounty_id: params.bounty_id, - claim_id: params.claim_id, - amount: params.amount, - user_id: params.creator_id, - linked_transaction_id: credit_id, - group_id: params.group_id - }), - {:ok, credit} <- - initialize_credit(%{ - id: credit_id, - tip_id: params.tip_id, - bounty_id: params.bounty_id, - claim_id: params[:claim_id], - amount: params.amount, - user_id: params[:recipient_id], - linked_transaction_id: debit_id, - group_id: params.group_id - }) do - {:ok, [debit, credit]} - end - end - - @spec create_attempt(%{ticket: Ticket.t(), user: User.t()}) :: - {:ok, Attempt.t()} | {:error, Ecto.Changeset.t()} - def create_attempt(%{ticket: ticket, user: user}) do - %Attempt{} - |> Attempt.changeset(%{ - ticket_id: ticket.id, - user_id: user.id - }) - |> Repo.insert() - end - - @spec get_or_create_attempt(%{ticket: Ticket.t(), user: User.t()}) :: - {:ok, Attempt.t()} | {:error, Ecto.Changeset.t()} - def get_or_create_attempt(%{ticket: ticket, user: user}) do - case Repo.fetch_by(Attempt, ticket_id: ticket.id, user_id: user.id) do - {:ok, attempt} -> {:ok, attempt} - {:error, _reason} -> create_attempt(%{ticket: ticket, user: user}) - end - end - - @spec list_attempts_for_ticket(String.t()) :: [Attempt.t()] - def list_attempts_for_ticket(ticket_id) do - Repo.all( - from(a in Attempt, - join: u in assoc(a, :user), - where: a.ticket_id == ^ticket_id, - order_by: [desc: a.inserted_at], - select_merge: %{ - user: u - } - ) - ) - end - - def get_attempt_emoji(%Attempt{status: :inactive}), do: "🔴" - def get_attempt_emoji(%Attempt{warnings_count: count}) when count > 0, do: "🟡" - def get_attempt_emoji(%Attempt{status: :active}), do: "🟢" - - @spec delete_bounty_full(Bounty.t()) :: {:ok, Bounty.t()} | {:error, atom()} - def delete_bounty_full(%Bounty{} = bounty) do - bounty = Repo.preload(bounty, [:owner, ticket: [repository: :user]]) - owner = bounty.ticket.repository.user.provider_login - repo = bounty.ticket.repository.name - number = bounty.ticket.number - delete_bounty_full(owner, repo, number) - end - - @spec delete_bounty_full(String.t(), String.t(), integer()) :: {:ok, [Bounty.t()]} | {:error, atom()} - def delete_bounty_full(owner, repo, number) do - installation_id = Workspace.get_installation_id_by_owner(owner) - - with {:ok, token} <- Github.get_installation_token(installation_id), - {:ok, ticket} <- Workspace.ensure_ticket(token, owner, repo, number), - :ok <- - (case Workspace.fetch_command_response(ticket.id, :bounty) do - {:ok, cr} -> - with {:ok, _} <- Github.delete_issue_comment(token, owner, repo, cr.provider_response_id), - {:ok, _} <- Workspace.delete_command_response(cr.id) do - :ok - else - {:error, _} -> :ok - end - - {:error, :not_found} -> - :ok - end), - :ok <- Workspace.remove_existing_amount_labels(token, owner, repo, number), - {:ok, _} <- - (case Github.remove_label_from_issue(token, owner, repo, number, "💎 Bounty") do - {:ok, result} -> {:ok, result} - {:error, _} -> {:ok, :skipped} - end) do - from(b in Bounty, where: b.ticket_id == ^ticket.id) - |> Repo.all() - |> Enum.reduce_while({:ok, []}, fn bounty, {:ok, acc} -> - case delete_bounty(bounty) do - {:ok, b} -> {:cont, {:ok, [b | acc]}} - error -> {:halt, error} - end - end) - end - end - - @spec delete_bounty(Bounty.t()) :: {:ok, Bounty.t()} | {:error, Ecto.Changeset.t()} - def delete_bounty(%Bounty{} = bounty) do - Repo.tx(fn -> - with {:ok, updated_bounty} <- - bounty - |> change(%{status: :cancelled}) - |> Repo.update() do - broadcast() - {:ok, updated_bounty} - end - end) - end -end diff --git a/lib/algora/bounties/jobs/notify_bounty.ex b/lib/algora/bounties/jobs/notify_bounty.ex deleted file mode 100644 index 2071c4af4..000000000 --- a/lib/algora/bounties/jobs/notify_bounty.ex +++ /dev/null @@ -1,104 +0,0 @@ -defmodule Algora.Bounties.Jobs.NotifyBounty do - @moduledoc false - use Oban.Worker, - queue: :default, - max_attempts: 1 - - alias Algora.Bounties - alias Algora.Github - alias Algora.Workspace - - require Logger - - @impl Oban.Worker - def perform(%Oban.Job{args: %{"bounty_id" => bounty_id, "visibility" => "exclusive", "shared_with" => shared_with}}) do - Algora.Activities.alert("Notify exclusive bounty #{bounty_id} to #{inspect(shared_with)}") - end - - def perform(%Oban.Job{ - args: %{ - "owner_login" => owner_login, - "amount" => amount, - "ticket_ref" => ticket_ref, - "installation_id" => nil, - "command_id" => command_id, - "command_source" => command_source - } - }) do - if owner_login in Algora.Settings.get_blocked_users() do - :discard - else - ticket_ref = %{ - owner: ticket_ref["owner"], - repo: ticket_ref["repo"], - number: ticket_ref["number"] - } - - body = """ - 💎 **#{owner_login}** is offering a **#{amount}** bounty for this issue. View and reward the bounty at `#{AlgoraWeb.Endpoint.host()}/#{ticket_ref.owner}/#{ticket_ref.repo}/issues/#{ticket_ref.number}` - - 👉 Got a pull request resolving this? Claim the bounty by commenting `/claim ##{ticket_ref.number}` in your PR and joining `#{AlgoraWeb.Endpoint.host()}` - """ - - if Github.pat_enabled() do - with {:ok, comment} <- - Github.create_issue_comment(Github.pat(), ticket_ref.owner, ticket_ref.repo, ticket_ref.number, body), - {:ok, ticket} <- - Workspace.ensure_ticket(Github.pat(), ticket_ref.owner, ticket_ref.repo, ticket_ref.number) do - Workspace.create_command_response(%{ - comment: comment, - command_source: command_source, - command_id: command_id, - ticket_id: ticket.id - }) - end - else - Logger.info(""" - Github.create_issue_comment(Github.pat(), "#{ticket_ref.owner}", "#{ticket_ref.repo}", #{ticket_ref.number}, - \"\"\" - #{body} - \"\"\") - """) - end - end - end - - @impl Oban.Worker - def perform(%Oban.Job{ - args: %{ - "amount" => amount, - "ticket_ref" => ticket_ref, - "installation_id" => installation_id, - "command_id" => command_id, - "command_source" => command_source - } - }) do - ticket_ref = %{ - owner: ticket_ref["owner"], - repo: ticket_ref["repo"], - number: ticket_ref["number"] - } - - with {:ok, token} <- Github.get_installation_token(installation_id), - {:ok, ticket} <- - Workspace.ensure_ticket(token, ticket_ref.owner, ticket_ref.repo, ticket_ref.number), - bounties when bounties != [] <- Bounties.list_bounties(ticket_id: ticket.id), - {:ok, _} <- Github.add_labels(token, ticket_ref.owner, ticket_ref.repo, ticket_ref.number, ["💎 Bounty"]), - :ok <- Workspace.remove_existing_amount_labels(token, ticket_ref.owner, ticket_ref.repo, ticket_ref.number), - :ok <- - Workspace.add_amount_label(token, ticket_ref.owner, ticket_ref.repo, ticket_ref.number, Money.parse(amount)) do - attempts = Bounties.list_attempts_for_ticket(ticket.id) - claims = Bounties.list_claims([ticket.id]) - - Workspace.ensure_command_response(%{ - token: token, - ticket_ref: ticket_ref, - command_id: command_id, - command_type: :bounty, - command_source: command_source, - ticket: ticket, - body: Bounties.get_response_body(bounties, ticket_ref, attempts, claims) - }) - end - end -end diff --git a/lib/algora/bounties/jobs/notify_claim.ex b/lib/algora/bounties/jobs/notify_claim.ex deleted file mode 100644 index 0dabde3b0..000000000 --- a/lib/algora/bounties/jobs/notify_claim.ex +++ /dev/null @@ -1,48 +0,0 @@ -defmodule Algora.Bounties.Jobs.NotifyClaim do - @moduledoc false - use Oban.Worker, queue: :default - - import Ecto.Query - - alias Algora.Bounties.Claim - alias Algora.Github - alias Algora.Repo - - require Logger - - @impl Oban.Worker - def perform(%Oban.Job{args: %{"claim_group_id" => _claim_group_id, "installation_id" => nil}}) do - :ok - end - - @impl Oban.Worker - def perform(%Oban.Job{args: %{"claim_group_id" => claim_group_id, "installation_id" => installation_id}}) do - with {:ok, token} <- Github.get_installation_token(installation_id), - claims = - from(c in Claim, - where: c.group_id == ^claim_group_id, - order_by: [asc: c.inserted_at] - ) - |> Repo.all() - |> Repo.preload([:user, source: [repository: [:user]], target: [repository: [:user]]]), - {:ok, _} <- maybe_add_labels(token, claims) do - :ok - end - end - - defp maybe_add_labels(token, claims) do - primary_claim = List.first(claims) - - if primary_claim.source do - Github.add_labels( - token, - primary_claim.source.repository.user.provider_login, - primary_claim.source.repository.name, - primary_claim.source.number, - ["🙋 Bounty claim"] - ) - else - {:ok, nil} - end - end -end diff --git a/lib/algora/bounties/jobs/notify_tip_intent.ex b/lib/algora/bounties/jobs/notify_tip_intent.ex deleted file mode 100644 index 42cf00251..000000000 --- a/lib/algora/bounties/jobs/notify_tip_intent.ex +++ /dev/null @@ -1,29 +0,0 @@ -defmodule Algora.Bounties.Jobs.NotifyTipIntent do - @moduledoc false - use Oban.Worker, queue: :default - - alias Algora.Github - - @impl Oban.Worker - def perform(%Oban.Job{args: %{"body" => body, "ticket_ref" => ticket_ref, "installation_id" => nil}}) do - Github.try_without_installation(&Github.create_issue_comment/5, [ - ticket_ref["owner"], - ticket_ref["repo"], - ticket_ref["number"], - body - ]) - end - - @impl Oban.Worker - def perform(%Oban.Job{args: %{"body" => body, "ticket_ref" => ticket_ref, "installation_id" => installation_id}}) do - with {:ok, token} <- Github.get_installation_token(installation_id) do - Github.create_issue_comment( - token, - ticket_ref["owner"], - ticket_ref["repo"], - ticket_ref["number"], - body - ) - end - end -end diff --git a/lib/algora/bounties/jobs/notify_transfer.ex b/lib/algora/bounties/jobs/notify_transfer.ex deleted file mode 100644 index 6bb86bd73..000000000 --- a/lib/algora/bounties/jobs/notify_transfer.ex +++ /dev/null @@ -1,78 +0,0 @@ -defmodule Algora.Bounties.Jobs.NotifyTransfer do - @moduledoc false - use Oban.Worker, queue: :default - - import Ecto.Query - - alias Algora.Bounties.Ticket - alias Algora.Github - alias Algora.Payments.Transaction - alias Algora.Repo - alias Algora.Workspace.Installation - alias Algora.Workspace.Ticket - - require Logger - - @impl Oban.Worker - def perform(%Oban.Job{args: %{"transfer_id" => transfer_id}}) do - with {:ok, ticket} <- - Repo.fetch_one( - from t in Ticket, - left_join: bounty in assoc(t, :bounties), - left_join: tip in assoc(t, :tips), - left_join: tx in Transaction, - on: tx.bounty_id == bounty.id or tx.tip_id == tip.id, - join: repo in assoc(t, :repository), - join: user in assoc(repo, :user), - where: tx.id == ^transfer_id, - select_merge: %{ - repository: %{repo | user: user} - } - ), - ticket_ref = %{ - owner: ticket.repository.user.provider_login, - repo: ticket.repository.name, - number: ticket.number - }, - {:ok, transfer_tx} <- - Repo.fetch_one( - from tx in Transaction, - join: user in assoc(tx, :user), - where: tx.type == :transfer, - where: tx.id == ^transfer_id, - select_merge: %{user: user} - ), - {:ok, debit_tx} <- - Repo.fetch_one( - from tx in Transaction, - join: user in assoc(tx, :user), - where: tx.type == :debit, - where: tx.group_id == ^transfer_tx.group_id, - select_merge: %{user: user}, - limit: 1 - ) do - installation = Repo.get_by(Installation, provider_user_id: ticket.repository.user.id) - - body = - "🎉🎈 @#{transfer_tx.user.provider_login} has been awarded **#{transfer_tx.net_amount}** by **#{debit_tx.user.name}**! 🎈🎊" - - do_perform(ticket_ref, body, installation) - end - end - - defp do_perform(ticket_ref, body, nil) do - Github.try_without_installation(&Github.create_issue_comment/5, [ - ticket_ref.owner, - ticket_ref.repo, - ticket_ref.number, - body - ]) - end - - defp do_perform(ticket_ref, body, installation) do - with {:ok, token} <- Github.get_installation_token(installation.provider_id), - {:ok, _} <- Github.add_labels(token, ticket_ref.owner, ticket_ref.repo, ticket_ref.number, ["💰 Rewarded"]) do - Github.create_issue_comment(token, ticket_ref.owner, ticket_ref.repo, ticket_ref.number, body) - end - end -end diff --git a/lib/algora/bounties/jobs/prompt_payout_connect.ex b/lib/algora/bounties/jobs/prompt_payout_connect.ex deleted file mode 100644 index 403f60a30..000000000 --- a/lib/algora/bounties/jobs/prompt_payout_connect.ex +++ /dev/null @@ -1,91 +0,0 @@ -defmodule Algora.Bounties.Jobs.PromptPayoutConnect do - @moduledoc false - use Oban.Worker, queue: :default - - import Ecto.Query - - alias Algora.Bounties.Ticket - alias Algora.Github - alias Algora.Payments.Transaction - alias Algora.Repo - alias Algora.Workspace.Installation - alias Algora.Workspace.Ticket - - require Logger - - @impl Oban.Worker - def perform(%Oban.Job{args: %{"credit_id" => credit_id}}) do - with {:ok, ticket} <- - Repo.fetch_one( - from t in Ticket, - left_join: bounty in assoc(t, :bounties), - left_join: tip in assoc(t, :tips), - left_join: tx in Transaction, - on: tx.bounty_id == bounty.id or tx.tip_id == tip.id, - join: repo in assoc(t, :repository), - join: user in assoc(repo, :user), - where: tx.id == ^credit_id, - select_merge: %{ - repository: %{repo | user: user} - } - ), - ticket_ref = %{ - owner: ticket.repository.user.provider_login, - repo: ticket.repository.name, - number: ticket.number - }, - {:ok, credit_tx} <- - Repo.fetch_one( - from tx in Transaction, - join: user in assoc(tx, :user), - where: tx.type == :credit, - where: tx.id == ^credit_id, - select_merge: %{user: user} - ), - {:ok, debit_tx} <- - Repo.fetch_one( - from tx in Transaction, - join: user in assoc(tx, :user), - where: tx.type == :debit, - where: tx.group_id == ^credit_tx.group_id, - select_merge: %{user: user}, - limit: 1 - ) do - installation = Repo.get_by(Installation, provider_user_id: ticket.repository.user.id) - - reward_type = - cond do - credit_tx.tip_id -> "tip" - credit_tx.bounty_id -> "bounty" - credit_tx.contract_id -> "contract" - true -> raise "Unknown transaction type" - end - - body = - "@#{credit_tx.user.provider_login}: You've been awarded a **#{credit_tx.net_amount}** by **#{debit_tx.user.name}**! 👉 [Complete your Algora onboarding](#{AlgoraWeb.Endpoint.url()}/onboarding/dev) to collect the #{reward_type}." - - do_perform(ticket_ref, body, installation) - end - end - - defp do_perform(ticket_ref, body, nil) do - Github.try_without_installation(&Github.create_issue_comment/5, [ - ticket_ref.owner, - ticket_ref.repo, - ticket_ref.number, - body - ]) - end - - defp do_perform(ticket_ref, body, installation) do - with {:ok, token} <- Github.get_installation_token(installation.provider_id) do - Github.create_issue_comment( - token, - ticket_ref.owner, - ticket_ref.repo, - ticket_ref.number, - body - ) - end - end -end diff --git a/lib/algora/bounties/jobs/sync_open_bounties.ex b/lib/algora/bounties/jobs/sync_open_bounties.ex deleted file mode 100644 index bf2c99eed..000000000 --- a/lib/algora/bounties/jobs/sync_open_bounties.ex +++ /dev/null @@ -1,39 +0,0 @@ -defmodule Algora.Bounties.Jobs.SyncOpenBounties do - @moduledoc false - use Oban.Worker, - queue: :internal, - max_attempts: 3 - - alias Algora.Bounties - alias Algora.Bounties.Jobs.SyncTicket - - @page_size 100 - - @impl Oban.Worker - def perform(%Oban.Job{}) do - enqueue_page(nil) - end - - defp enqueue_page(cursor) do - criteria = [status: :open, limit: @page_size] ++ if(cursor, do: [before: cursor], else: []) - bounties = Bounties.list_bounties(criteria) - - jobs = - Enum.map(bounties, fn bounty -> - SyncTicket.new(%{ - owner_login: bounty.repository.owner.provider_login, - repo_name: bounty.repository.name, - number: bounty.ticket.number - }) - end) - - Oban.insert_all(jobs) - - if length(bounties) == @page_size do - last = List.last(bounties) - enqueue_page(%{inserted_at: last.inserted_at, id: last.id}) - else - :ok - end - end -end diff --git a/lib/algora/bounties/jobs/sync_ticket.ex b/lib/algora/bounties/jobs/sync_ticket.ex deleted file mode 100644 index 7dc8adeb6..000000000 --- a/lib/algora/bounties/jobs/sync_ticket.ex +++ /dev/null @@ -1,25 +0,0 @@ -defmodule Algora.Bounties.Jobs.SyncTicket do - @moduledoc false - use Oban.Worker, - queue: :internal, - max_attempts: 3 - - alias Algora.Github - alias Algora.Workspace - - require Logger - - @impl Oban.Worker - def perform(%Oban.Job{args: %{"owner_login" => owner_login, "repo_name" => repo_name, "number" => number}}) do - token = Github.TokenPool.get_token() - - case Workspace.update_ticket_from_github(token, owner_login, repo_name, number) do - {:ok, _ticket} -> - :ok - - {:error, reason} -> - Logger.error("Failed to sync ticket #{owner_login}/#{repo_name}##{number}: #{inspect(reason)}") - {:error, reason} - end - end -end diff --git a/lib/algora/bounties/schemas/attempt.ex b/lib/algora/bounties/schemas/attempt.ex deleted file mode 100644 index ece24c526..000000000 --- a/lib/algora/bounties/schemas/attempt.ex +++ /dev/null @@ -1,28 +0,0 @@ -defmodule Algora.Bounties.Attempt do - @moduledoc false - use Algora.Schema - - alias Algora.Activities.Activity - - typed_schema "attempts" do - field :status, Ecto.Enum, values: [:active, :inactive], default: :active, null: false - field :warnings_count, :integer, default: 0, null: false - - belongs_to :ticket, Algora.Workspace.Ticket, null: false - belongs_to :user, Algora.Accounts.User, null: false - - has_many :activities, {"attempt_activities", Activity}, foreign_key: :assoc_id - - timestamps() - end - - def changeset(attempt, attrs) do - attempt - |> cast(attrs, [:ticket_id, :user_id]) - |> generate_id() - |> validate_required([:ticket_id, :user_id]) - |> unique_constraint([:ticket_id, :user_id]) - |> foreign_key_constraint(:ticket_id) - |> foreign_key_constraint(:user_id) - end -end diff --git a/lib/algora/bounties/schemas/bonus.ex b/lib/algora/bounties/schemas/bonus.ex deleted file mode 100644 index 475712366..000000000 --- a/lib/algora/bounties/schemas/bonus.ex +++ /dev/null @@ -1,21 +0,0 @@ -defmodule Algora.Bounties.Bonus do - @moduledoc false - use Algora.Schema - - alias Algora.Activities.Activity - - typed_schema "bonuses" do - belongs_to :bounty, Algora.Bounties.Bounty - belongs_to :user, Algora.Accounts.User - - has_many :activities, {"bonus_activities", Activity}, foreign_key: :assoc_id - - timestamps() - end - - def changeset(bonus, attrs) do - bonus - |> cast(attrs, [:bounty_id, :user_id]) - |> validate_required([:bounty_id, :user_id]) - end -end diff --git a/lib/algora/bounties/schemas/bounty.ex b/lib/algora/bounties/schemas/bounty.ex deleted file mode 100644 index 45468a4fb..000000000 --- a/lib/algora/bounties/schemas/bounty.ex +++ /dev/null @@ -1,133 +0,0 @@ -defmodule Algora.Bounties.Bounty do - @moduledoc false - use Algora.Schema - - alias Algora.Accounts.User - alias Algora.Bounties.Bounty - alias Algora.Types.Money - - @type visibility :: :community | :exclusive | :public - @type contract_type :: :bring_your_own | :marketplace - - typed_schema "bounties" do - field :amount, Money - field :status, Ecto.Enum, values: [:open, :cancelled, :paid] - field :number, :integer, default: 0 - field :autopay_disabled, :boolean, default: false - field :visibility, Ecto.Enum, values: [:community, :exclusive, :public], null: false, default: :community - field :contract_type, Ecto.Enum, values: [:bring_your_own, :marketplace] - field :shared_with, {:array, :string}, null: false, default: [] - field :deadline, :utc_datetime_usec - field :hours_per_week, :integer - field :hourly_rate, Money - - belongs_to :ticket, Algora.Workspace.Ticket - belongs_to :owner, User - belongs_to :creator, User - has_many :transactions, Algora.Payments.Transaction - has_many :activities, {"bounty_activities", Algora.Activities.Activity}, foreign_key: :assoc_id - - timestamps() - end - - def preload(id) do - from a in __MODULE__, - preload: [:ticket, :owner, :creator], - where: a.id == ^id - end - - def changeset(bounty, attrs) do - bounty - |> cast(attrs, [ - :amount, - :ticket_id, - :owner_id, - :creator_id, - :visibility, - :shared_with, - :hours_per_week, - :hourly_rate, - :contract_type, - :status - ]) - |> validate_required([:amount, :ticket_id, :owner_id, :creator_id]) - |> generate_id() - |> foreign_key_constraint(:ticket) - |> foreign_key_constraint(:owner) - |> foreign_key_constraint(:creator) - |> unique_constraint([:ticket_id, :owner_id, :number]) - |> Algora.Validations.validate_money_positive(:amount) - end - - def settings_changeset(bounty, attrs) do - bounty - |> cast(attrs, [:visibility, :shared_with, :deadline]) - |> Algora.Validations.validate_date_in_future(:deadline) - |> validate_required([:visibility, :shared_with]) - end - - def url(%{repository: %{name: name, owner: %{login: login}}, ticket: %{provider: "github", number: number}}) do - "https://github.com/#{login}/#{name}/issues/#{number}" - end - - def url(%{id: id, owner: owner, repository: %{name: nil}}) do - "/#{owner.handle}/bounties/#{id}" - end - - def url(%{ticket: %{url: url}}) do - url - end - - def path(%{repository: %{name: nil}}) do - nil - end - - def path(%{repository: %{name: name}, ticket: %{number: number}}) do - "#{name}##{number}" - end - - # DEPRECATED - def path(%{ticket: %{provider: "github", url: url}}) do - Algora.Util.path_from_url(url) - end - - def path(_bounty), do: nil - - def full_path(%{repository: %{name: name, owner: %{login: login}}, ticket: %{number: number}}) do - "#{login}/#{name}##{number}" - end - - def full_path(%{ticket: %{provider: "github", url: url}}) do - url - |> URI.parse() - |> then(& &1.path) - |> String.replace(~r/\/(issues|pull|discussions)\//, "#") - end - - def order_by_most_recent(query \\ Bounty) do - from(b in query, order_by: [desc: b.inserted_at]) - end - - def limit(query \\ Bounty, limit) do - from(b in query, limit: ^limit) - end - - def filter_by_tech_stack(query, []), do: query - def filter_by_tech_stack(query, nil), do: query - - def filter_by_tech_stack(query, tech_stack) do - lowercase_tech_stack = Enum.map(tech_stack, &String.downcase/1) - - from b in query, - join: o in assoc(b, :owner), - where: fragment("ARRAY(SELECT LOWER(unnest(?))) && ?", o.tech_stack, ^lowercase_tech_stack) - end - - def create_changeset(bounty, attrs) do - bounty - |> cast(attrs, [:amount]) - |> cast_assoc(:ticket) - |> validate_required([:amount]) - |> validate_number(:amount, greater_than: 0) - end -end diff --git a/lib/algora/bounties/schemas/claim.ex b/lib/algora/bounties/schemas/claim.ex deleted file mode 100644 index d27d1fb0c..000000000 --- a/lib/algora/bounties/schemas/claim.ex +++ /dev/null @@ -1,61 +0,0 @@ -defmodule Algora.Bounties.Claim do - @moduledoc false - use Algora.Schema - - alias Algora.Activities.Activity - alias Algora.Workspace.Ticket - - @type status :: :pending | :approved | :cancelled - @type type :: :pull_request | :review | :video | :design | :article - - typed_schema "claims" do - field :status, Ecto.Enum, values: [:pending, :approved, :cancelled], null: false - field :type, Ecto.Enum, values: [:pull_request, :review, :video, :design, :article] - field :url, :string, null: false - field :group_id, :string, null: false - field :group_share, :decimal, null: false, default: 1.0 - - belongs_to :source, Ticket - belongs_to :target, Ticket, null: false - belongs_to :user, Algora.Accounts.User, null: false - has_many :transactions, Algora.Payments.Transaction - - has_many :activities, {"claim_activities", Activity}, foreign_key: :assoc_id - timestamps() - end - - def changeset(claim, attrs) do - claim - |> cast(attrs, [:source_id, :target_id, :user_id, :status, :type, :url, :group_id, :group_share]) - |> validate_required([:target_id, :user_id, :status, :type, :url]) - |> generate_id() - |> put_group_id() - |> foreign_key_constraint(:source_id) - |> foreign_key_constraint(:target_id) - |> foreign_key_constraint(:user_id) - |> unique_constraint([:user_id, :group_id]) - |> unique_constraint([:user_id, :source_id, :target_id]) - end - - def preload(id) do - from a in __MODULE__, - preload: [:source, :target, :user], - where: a.id == ^id - end - - def put_group_id(changeset) do - case get_field(changeset, :group_id) do - nil -> put_change(changeset, :group_id, get_field(changeset, :id)) - _existing -> changeset - end - end - - def type_label(:pull_request), do: "a pull request" - def type_label(:review), do: "a review" - def type_label(:video), do: "a video" - def type_label(:design), do: "a design" - def type_label(:article), do: "an article" - def type_label(nil), do: "a URL" - - def reward_url(claim), do: "#{AlgoraWeb.Endpoint.url()}/claims/#{claim.group_id}" -end diff --git a/lib/algora/bounties/schemas/line_item.ex b/lib/algora/bounties/schemas/line_item.ex deleted file mode 100644 index cf2a1354b..000000000 --- a/lib/algora/bounties/schemas/line_item.ex +++ /dev/null @@ -1,54 +0,0 @@ -defmodule Algora.Bounties.LineItem do - @moduledoc false - use Algora.Schema - - alias Algora.MoneyUtils - - @primary_key false - typed_embedded_schema do - field :amount, Algora.Types.Money - field :title, :string - field :description, :string - field :image, :string - field :type, Ecto.Enum, values: [:payout, :fee] - end - - def to_stripe(line_item) do - %{ - price_data: %{ - unit_amount: MoneyUtils.to_minor_units(line_item.amount), - currency: to_string(line_item.amount.currency), - product_data: - Map.reject( - %{ - name: line_item.title, - description: line_item.description, - images: if(line_item.image, do: [line_item.image]) - }, - fn {_, v} -> is_nil(v) end - ) - }, - quantity: 1 - } - end - - def to_invoice_item(line_item, invoice, customer) do - %{ - invoice: invoice.id, - customer: customer.provider_id, - amount: MoneyUtils.to_minor_units(line_item.amount), - currency: to_string(line_item.amount.currency), - description: if(line_item.description, do: line_item.title <> " - " <> line_item.description, else: line_item.title) - } - end - - def gross_amount(line_items) do - Enum.reduce(line_items, Money.zero(:USD), fn item, acc -> Money.add!(acc, item.amount) end) - end - - def total_fee(line_items) do - Enum.reduce(line_items, Money.zero(:USD), fn item, acc -> - if item.type == :fee, do: Money.add!(acc, item.amount), else: acc - end) - end -end diff --git a/lib/algora/bounties/schemas/tip.ex b/lib/algora/bounties/schemas/tip.ex deleted file mode 100644 index 46c3e3224..000000000 --- a/lib/algora/bounties/schemas/tip.ex +++ /dev/null @@ -1,40 +0,0 @@ -defmodule Algora.Bounties.Tip do - @moduledoc false - use Algora.Schema - - alias Algora.Accounts.User - alias Algora.Activities.Activity - - typed_schema "tips" do - field :amount, Algora.Types.Money - field :status, Ecto.Enum, values: [:open, :cancelled, :paid], default: :open, null: false - - belongs_to :ticket, Algora.Workspace.Ticket - belongs_to :owner, User - belongs_to :creator, User - belongs_to :recipient, User - has_many :transactions, Algora.Payments.Transaction - - has_many :activities, {"tip_activities", Activity}, foreign_key: :assoc_id - - timestamps() - end - - def changeset(tip, attrs) do - tip - |> cast(attrs, [:amount, :ticket_id, :owner_id, :creator_id, :recipient_id]) - |> validate_required([:amount, :owner_id, :creator_id, :recipient_id]) - |> generate_id() - |> foreign_key_constraint(:ticket) - |> foreign_key_constraint(:owner) - |> foreign_key_constraint(:creator) - |> foreign_key_constraint(:recipient) - |> Algora.Validations.validate_money_positive(:amount) - end - - def preload(id) do - from a in __MODULE__, - preload: [:ticket, :owner, :creator, :recipient], - where: a.id == ^id - end -end diff --git a/lib/algora/chat/chat.ex b/lib/algora/chat/chat.ex deleted file mode 100644 index 4521d6227..000000000 --- a/lib/algora/chat/chat.ex +++ /dev/null @@ -1,193 +0,0 @@ -defmodule Algora.Chat do - @moduledoc false - import Ecto.Query - - alias Algora.Accounts.User - alias Algora.Chat.Message - alias Algora.Chat.Participant - alias Algora.Chat.Thread - alias Algora.Repo - - defmodule MessageCreated do - @moduledoc false - defstruct message: nil, participant: nil - end - - def broadcast(%MessageCreated{} = event) do - Phoenix.PubSub.broadcast(Algora.PubSub, "chat:thread:#{event.message.thread_id}", event) - end - - def subscribe(thread_id) do - Phoenix.PubSub.subscribe(Algora.PubSub, "chat:thread:#{thread_id}") - end - - def create_direct_thread(user_1, user_2) do - Repo.transaction(fn -> - {:ok, thread} = - %Thread{} - |> Thread.changeset(%{title: "#{User.handle(user_1)} <> #{User.handle(user_2)}"}) - |> Repo.insert() - - for user <- [user_1, user_2] do - %Participant{} - |> Participant.changeset(%{ - thread_id: thread.id, - user_id: user.id, - last_read_at: DateTime.utc_now() - }) - |> Repo.insert!() - end - - thread - end) - end - - def create_admin_thread(user, admins) do - Repo.transaction(fn -> - {:ok, thread} = - %Thread{} - |> Thread.changeset(%{title: "Chat with Algora founders"}) - |> Repo.insert() - - participants = Enum.uniq_by([user | admins], & &1.id) - - for u <- participants do - %Participant{} - |> Participant.changeset(%{ - thread_id: thread.id, - user_id: u.id, - last_read_at: DateTime.utc_now() - }) - |> Repo.insert!() - end - - thread - end) - end - - defp ensure_participant(thread_id, user_id) do - case Repo.fetch_by(Participant, thread_id: thread_id, user_id: user_id) do - {:ok, participant} -> - {:ok, participant} - - {:error, _} -> - %Participant{} - |> Participant.changeset(%{ - thread_id: thread_id, - user_id: user_id, - last_read_at: DateTime.utc_now() - }) - |> Repo.insert() - end - end - - defp insert_message(thread_id, sender_id, content) do - %Message{} - |> Message.changeset(%{ - thread_id: thread_id, - sender_id: sender_id, - content: content - }) - |> Repo.insert() - end - - def send_message(thread_id, sender_id, content) do - with {:ok, participant} <- ensure_participant(thread_id, sender_id), - {:ok, message} <- insert_message(thread_id, sender_id, content) do - message = Repo.preload(message, :sender) - - broadcast(%MessageCreated{ - message: message, - participant: Repo.preload(participant, :user) - }) - - {:ok, message} - end - end - - def list_messages(thread_id, limit \\ 50) do - Message - |> where(thread_id: ^thread_id) - |> order_by(asc: :inserted_at) - |> limit(^limit) - |> Repo.all() - end - - def get_thread(thread_id) do - Repo.get(Thread, thread_id) - end - - # TODO: filter by user_id - def list_threads(_user_id) do - last_message_query = - from m in Message, - select: %{ - thread_id: m.thread_id, - last_message_at: max(m.inserted_at) - }, - group_by: m.thread_id - - Thread - |> join(:left, [t], lm in subquery(last_message_query), on: t.id == lm.thread_id) - |> order_by([t, lm], desc: lm.last_message_at) - |> preload(participants: :user) - |> Repo.all() - end - - def list_participants(thread_id) do - Participant - |> where(thread_id: ^thread_id) - |> Repo.all() - end - - def mark_as_read(thread_id, user_id) do - Participant - |> where(thread_id: ^thread_id, user_id: ^user_id) - |> Repo.update_all(set: [last_read_at: DateTime.utc_now()]) - end - - def get_thread_for_users(users) do - participants = Enum.uniq_by(users, & &1.id) - - Thread - |> join(:inner, [t], p in Participant, on: p.thread_id == t.id) - |> where([t, p], p.user_id in ^Enum.map(participants, & &1.id)) - |> group_by([t], t.id) - |> having([t, p], count(p.id) == ^length(participants)) - |> limit(1) - |> Repo.one() - end - - def get_or_create_thread(contract) do - case get_thread_for_users([contract.client, contract.contractor]) do - nil -> create_direct_thread(contract.client, contract.contractor) - thread -> {:ok, thread} - end - end - - def get_or_create_thread!(contract) do - {:ok, thread} = get_or_create_thread(contract) - thread - end - - def get_or_create_admin_thread(current_user) do - admins = Repo.all(from u in User, where: u.is_admin == true) - - case get_thread_for_users([current_user] ++ admins) do - nil -> create_admin_thread(current_user, admins) - thread -> {:ok, thread} - end - end - - def get_or_create_bounty_thread(bounty) do - case Repo.fetch_by(Thread, bounty_id: bounty.id) do - {:ok, thread} -> - {:ok, thread} - - {:error, _} -> - %Thread{} - |> Thread.changeset(%{title: "Contributor chat", bounty_id: bounty.id}) - |> Repo.insert() - end - end -end diff --git a/lib/algora/chat/schemas/message.ex b/lib/algora/chat/schemas/message.ex deleted file mode 100644 index be3c1bde6..000000000 --- a/lib/algora/chat/schemas/message.ex +++ /dev/null @@ -1,24 +0,0 @@ -defmodule Algora.Chat.Message do - @moduledoc false - use Algora.Schema - - alias Algora.Activities.Activity - - typed_schema "messages" do - field :content, :string - - belongs_to :thread, Algora.Chat.Thread - belongs_to :sender, Algora.Accounts.User - - has_many :activities, {"message_activities", Activity}, foreign_key: :assoc_id - - timestamps() - end - - def changeset(message, attrs) do - message - |> cast(attrs, [:content, :thread_id, :sender_id]) - |> validate_required([:content, :thread_id, :sender_id]) - |> generate_id() - end -end diff --git a/lib/algora/chat/schemas/participant.ex b/lib/algora/chat/schemas/participant.ex deleted file mode 100644 index 33211598b..000000000 --- a/lib/algora/chat/schemas/participant.ex +++ /dev/null @@ -1,22 +0,0 @@ -defmodule Algora.Chat.Participant do - @moduledoc false - use Algora.Schema - - typed_schema "chat_participants" do - field :last_read_at, :utc_datetime_usec - - belongs_to :thread, Algora.Chat.Thread - belongs_to :user, Algora.Accounts.User - - timestamps() - end - - def changeset(participant, attrs) do - participant - |> cast(attrs, [:last_read_at, :thread_id, :user_id]) - |> validate_required([:last_read_at, :thread_id, :user_id]) - |> foreign_key_constraint(:thread_id) - |> foreign_key_constraint(:user_id) - |> generate_id() - end -end diff --git a/lib/algora/chat/schemas/thread.ex b/lib/algora/chat/schemas/thread.ex deleted file mode 100644 index 0f2da6242..000000000 --- a/lib/algora/chat/schemas/thread.ex +++ /dev/null @@ -1,24 +0,0 @@ -defmodule Algora.Chat.Thread do - @moduledoc false - use Algora.Schema - - alias Algora.Activities.Activity - - typed_schema "threads" do - field :title, :string - field :bounty_id, :string - has_many :messages, Algora.Chat.Message - has_many :participants, Algora.Chat.Participant - has_many :activities, {"thread_activities", Activity}, foreign_key: :assoc_id - - timestamps() - end - - def changeset(thread, attrs) do - thread - |> cast(attrs, [:title, :bounty_id]) - |> validate_required([:title]) - |> generate_id() - |> unique_constraint(:bounty_id) - end -end diff --git a/lib/algora/cldr.ex b/lib/algora/cldr.ex deleted file mode 100644 index a886df4bd..000000000 --- a/lib/algora/cldr.ex +++ /dev/null @@ -1,7 +0,0 @@ -defmodule Algora.Cldr do - @moduledoc false - use Cldr, - locales: ["en", "de"], - default_locale: "en", - providers: [Cldr.Number, Money] -end diff --git a/lib/algora/cloud.ex b/lib/algora/cloud.ex deleted file mode 100644 index 87ec69b98..000000000 --- a/lib/algora/cloud.ex +++ /dev/null @@ -1,134 +0,0 @@ -defmodule Algora.Cloud do - @moduledoc false - - def top_contributions(github_handles) do - call(AlgoraCloud, :top_contributions, [github_handles], []) - end - - def list_top_matches(opts \\ []) do - call(AlgoraCloud, :list_top_matches, [opts], []) - end - - def list_top_stargazers(opts \\ []) do - call(AlgoraCloud, :list_top_stargazers, [opts], []) - end - - def truncate_matches(org, matches) do - call(AlgoraCloud, :truncate_matches, [org, matches], matches) - end - - def count_matches(job) do - call(AlgoraCloud, :count_matches, [job], 0) - end - - def list_heatmaps(user_ids) do - call(AlgoraCloud.Profiles, :list_heatmaps, [user_ids], []) - end - - def list_language_contributions_batch(user_ids) do - call(AlgoraCloud.LanguageContributions, :list_language_contributions_batch, [user_ids], []) - end - - def sync_heatmap_by(opts \\ []) do - call(AlgoraCloud.Profiles, :sync_heatmap_by, [opts], {:ok, nil}) - end - - def count_top_matches(opts \\ []) do - call(AlgoraCloud, :count_top_matches, [opts], 0) - end - - def get_contribution_score(job, user, contributions_map) do - call(AlgoraCloud, :get_contribution_score, [job, user, contributions_map], {0, 0}) - end - - def get_job_offer(assigns) do - call(AlgoraCloud.JobLive, :offer, [assigns], nil) - end - - def notify_match(attrs) do - # call(AlgoraCloud.Talent.Jobs.SendJobMatchEmail, :send, [attrs]) - match = Algora.Repo.get_by(Algora.Matches.JobMatch, user_id: attrs.user_id, job_posting_id: attrs.job_posting_id) - call(AlgoraCloud.EmailScheduler, :schedule_email, [:job_drip, match.id], {:ok, :skipped}) - end - - def notify_candidate_like(_attrs) do - :ok - # call(AlgoraCloud.Talent.Jobs.SendCandidateLikeEmail, :send, [attrs]) - end - - def notify_company_like(_match_id) do - :ok - # call(AlgoraCloud.EmailScheduler, :schedule_email, [:company_like, match_id]) - end - - def create_admin_task(attrs) do - call(AlgoraCloud.AdminTasks, :create_admin_task, [attrs], {:ok, nil}) - end - - def create_welcome_task(attrs) do - call(AlgoraCloud.AdminTasks, :create_welcome_task, [attrs], {:ok, nil}) - end - - def create_origin_event(event, attrs) do - call(AlgoraCloud.Events, :create_origin_event, [event, attrs], {:ok, nil}) - end - - def presigned do - call(AlgoraCloud.Constants, :presigned, [], []) - end - - def candidate_card(assigns) do - import Phoenix.Component - - fallback = ~H""" - - """ - - call(AlgoraCloud.Components.CandidateCard, :candidate_card, [assigns], fallback) - end - - def start do - call(AlgoraCloud, :start, [], []) - end - - def token! do - call(AlgoraCloud, :token!, [], nil) - end - - def token do - call(AlgoraCloud, :token, [], nil) - end - - def filter_featured_txs(transactions) do - call(AlgoraCloud, :filter_featured_txs, [transactions], transactions) - end - - def ats_event_ids do - call(AlgoraCloud, :ats_event_ids, [], []) - end - - def label_ats_event(event) do - call(AlgoraCloud, :label_ats_event, [event], nil) - end - - defp call(module, function, args, fallback) do - if :code.which(module) == :non_existing do - fallback - else - apply(module, function, args) - end - end - - defmacro use_if_available(quoted_module, opts \\ []) do - module = Macro.expand(quoted_module, __CALLER__) - - if Code.ensure_loaded?(module) do - quote do - use unquote(quoted_module), unquote(opts) - end - end - end -end diff --git a/lib/algora/content.ex b/lib/algora/content.ex deleted file mode 100644 index 254394432..000000000 --- a/lib/algora/content.ex +++ /dev/null @@ -1,100 +0,0 @@ -defmodule Algora.Content do - @moduledoc """ - Handles markdown content with frontmatter for blog posts, changelogs, docs, etc. - """ - - alias Algora.Markdown - - defstruct [:slug, :title, :date, :tags, :authors, :content, :path] - - defp base_path, do: Path.join([:code.priv_dir(:algora), "content"]) - - def load_content(directory, slug) do - with {:ok, content} <- [base_path(), directory, "#{slug}.md"] |> Path.join() |> File.read(), - [frontmatter, markdown] <- content |> String.split("---\n", parts: 3) |> Enum.drop(1), - {:ok, parsed_frontmatter} <- YamlElixir.read_from_string(frontmatter) do - {:ok, - %__MODULE__{ - slug: slug, - title: parsed_frontmatter["title"], - date: parsed_frontmatter["date"], - tags: parsed_frontmatter["tags"], - authors: parsed_frontmatter["authors"], - content: Markdown.render_unsafe(markdown) - }} - end - end - - def list_content(directory) do - [base_path(), directory] - |> Path.join() - |> File.ls!() - |> Enum.filter(&String.ends_with?(&1, ".md")) - |> Enum.map(fn filename -> - slug = String.replace(filename, ".md", "") - {:ok, content} = load_content(directory, slug) - content - end) - |> Enum.sort_by(& &1.date, :desc) - end - - def list_content_rec(directory) do - list_content_rec_helper([base_path(), directory], directory) - end - - defp list_content_rec_helper(path, root_dir) do - case File.ls(Path.join(path)) do - {:ok, entries} -> - entries - |> Enum.reduce(%{files: [], dirs: %{}}, fn entry, acc -> - full_path = Path.join(path ++ [entry]) - - cond do - File.dir?(full_path) -> - nested_content = list_content_rec_helper(path ++ [entry], root_dir) - put_in(acc, [:dirs, entry], nested_content) - - String.ends_with?(entry, ".md") -> - # Get the path relative to base_path - relative_path = - full_path - |> Path.relative_to(base_path()) - |> Path.rootname(".md") - - path_segments = - relative_path - |> Path.split() - |> Enum.drop(1) - - directory = Path.dirname(relative_path) - slug = Path.basename(relative_path) - - case load_content(directory, slug) do - {:ok, content} -> - content_with_path = Map.put(content, :path, path_segments) - Map.update!(acc, :files, &[content_with_path | &1]) - - _ -> - acc - end - - true -> - acc - end - end) - |> Map.update!(:files, &Enum.sort_by(&1, fn file -> file.date end, :desc)) - - {:error, _} -> - %{files: [], dirs: %{}} - end - end - - def format_date(date_string) when is_binary(date_string) do - case Date.from_iso8601(date_string) do - {:ok, date} -> Calendar.strftime(date, "%B %d, %Y") - _ -> date_string - end - end - - def format_date(_), do: "" -end diff --git a/lib/algora/contracts/contracts.ex b/lib/algora/contracts/contracts.ex deleted file mode 100644 index 8a554ad28..000000000 --- a/lib/algora/contracts/contracts.ex +++ /dev/null @@ -1,734 +0,0 @@ -defmodule Algora.Contracts do - @moduledoc false - import Ecto.Changeset - import Ecto.Query - - alias Algora.Activities - alias Algora.Contracts.Contract - alias Algora.Contracts.Timesheet - alias Algora.FeeTier - alias Algora.MoneyUtils - alias Algora.Payments - alias Algora.Payments.Account - alias Algora.Payments.Transaction - alias Algora.PSP.Invoice - alias Algora.Repo - alias Algora.Util - - require Algora.SQL - - @type payment_status :: - {:paid, Contract.t()} - | {:pending_release, Contract.t()} - | {:pending_payment, Contract.t()} - | {:pending_timesheet, Contract.t()} - - @type criterion :: - {:id, binary()} - | {:client_id, binary()} - | {:contractor_id, binary()} - | {:original_contract_id, binary()} - | {:open?, true} - | {:active_or_paid?, true} - | {:original?, true} - | {:status, Contract.status() | {:in, [Contract.status()]}} - | {:after, non_neg_integer()} - | {:before, non_neg_integer()} - | {:order, :asc | :desc} - | {:limit, non_neg_integer()} - | {:tech_stack, [String.t()]} - - def create_contract(attrs) do - case %Contract{} |> Contract.changeset(attrs) |> Repo.insert() do - {:ok, contract} -> - Activities.alert("Contract created: #{contract.id}", :info) - {:ok, contract} - - {:error, error} -> - Activities.alert("Error creating contract: #{inspect(error)}", :error) - {:error, error} - end - end - - @spec get_payment_status(Contract.t()) :: payment_status() - def get_payment_status(contract) do - cond do - is_nil(contract.timesheet) -> {:pending_timesheet, contract} - Money.positive?(contract.amount_credited) -> {:paid, contract} - true -> {:pending_release, contract} - end - end - - def calculate_fee_data(contract) do - contract = Repo.preload(contract, :client) - - # TODO: implement sliding scale for expert contracts - # fee_tiers = FeeTier.all(:expert) - # total_paid = Payments.get_total_paid(contract.client_id, contract.contractor_id) - # progress: FeeTier.calculate_progress(total_paid) - # current_fee = FeeTier.calculate_fee_percentage(total_paid) - - fee_tiers = FeeTier.all(:community) - total_paid = Money.zero(:USD) - progress = Decimal.new(0) - current_fee = Decimal.div(contract.client.fee_pct, 100) - - %{ - total_paid: total_paid, - fee_tiers: fee_tiers, - current_fee: current_fee, - transaction_fee: Payments.get_transaction_fee_pct(), - total_fee: Decimal.add(current_fee, Payments.get_transaction_fee_pct()), - progress: progress - } - end - - def calculate_weekly_amount(contract) do - Money.mult!(contract.hourly_rate, contract.hours_per_week) - end - - def calculate_monthly_amount(contract) do - contract - |> calculate_weekly_amount() - |> Money.mult!(4) - end - - def build_contract_timeline(contract_chain) do - contract_chain - |> Enum.with_index() - |> Enum.flat_map(&build_contract_period/1) - |> Enum.reject(&is_nil/1) - |> Enum.sort_by(& &1.date, {:desc, DateTime}) - end - - defp build_contract_period({contract, index}) do - List.flatten([ - build_initial_prepayment(contract), - build_contract_renewal(contract, index), - build_timesheet_submission(contract), - build_payment_releases(contract) - ]) - end - - defp build_initial_prepayment(contract) do - contract.transactions - |> Enum.filter(&(&1.type == :charge)) - |> Enum.map( - &%{ - type: :prepayment, - description: "Prepayment: #{Money.to_string!(&1.net_amount)}", - date: &1.inserted_at, - amount: &1.net_amount - } - ) - end - - defp build_contract_renewal(contract, index) do - if index != 0 do - %{ - type: :renewal, - description: "Contract renewed for another period", - date: contract.inserted_at, - amount: nil - } - end - end - - defp build_timesheet_submission(contract) do - if contract.timesheet do - %{ - type: :timesheet, - description: "Timesheet submitted for #{contract.timesheet.hours_worked} hours", - date: contract.timesheet.inserted_at, - amount: calculate_transfer_amount(contract) - } - end - end - - defp build_payment_releases(contract) do - contract.transactions - |> Enum.filter(&(&1.type == :transfer)) - |> Enum.sort_by(& &1.inserted_at) - |> Enum.map( - &%{ - type: :release, - description: "Payment released: #{Money.to_string!(&1.net_amount)}", - date: &1.inserted_at, - amount: &1.net_amount - } - ) - end - - def get_timesheet(id), do: Repo.get(Timesheet, id) - def get_timesheet!(id), do: Repo.get!(Timesheet, id) - - defp maybe_initialize_charge(%{line_items: []}), do: {:ok, nil} - - defp maybe_initialize_charge(%{ - contract: contract, - gross_amount: gross_amount, - net_amount: net_amount, - total_fee: total_fee, - line_items: line_items - }) do - %Transaction{} - |> change(%{ - id: Nanoid.generate(), - provider: "stripe", - type: :charge, - status: :initialized, - contract_id: contract.id, - original_contract_id: contract.original_contract_id, - timesheet_id: contract.timesheet && contract.timesheet.id, - user_id: contract.client_id, - gross_amount: gross_amount, - net_amount: net_amount, - total_fee: total_fee, - line_items: line_items - }) - |> Algora.Validations.validate_positive(:gross_amount) - |> Algora.Validations.validate_positive(:net_amount) - |> foreign_key_constraint(:original_contract_id) - |> foreign_key_constraint(:contract_id) - |> foreign_key_constraint(:timesheet_id) - |> foreign_key_constraint(:user_id) - |> Repo.insert() - end - - defp initialize_debit(%{id: id, contract: contract, amount: amount, linked_transaction_id: linked_transaction_id}) do - %Transaction{} - |> change(%{ - id: id, - provider: "stripe", - type: :debit, - status: :initialized, - contract_id: contract.id, - original_contract_id: contract.original_contract_id, - timesheet_id: contract.timesheet.id, - user_id: contract.client_id, - gross_amount: amount, - net_amount: amount, - total_fee: Money.zero(:USD), - linked_transaction_id: linked_transaction_id - }) - |> Algora.Validations.validate_positive(:gross_amount) - |> Algora.Validations.validate_positive(:net_amount) - |> foreign_key_constraint(:original_contract_id) - |> foreign_key_constraint(:contract_id) - |> foreign_key_constraint(:timesheet_id) - |> foreign_key_constraint(:user_id) - |> Repo.insert() - end - - defp initialize_credit(%{id: id, contract: contract, amount: amount, linked_transaction_id: linked_transaction_id}) do - %Transaction{} - |> change(%{ - id: id, - provider: "stripe", - gross_amount: amount, - net_amount: amount, - total_fee: Money.zero(:USD), - type: :credit, - status: :initialized, - contract_id: contract.id, - original_contract_id: contract.original_contract_id, - timesheet_id: contract.timesheet.id, - user_id: contract.contractor_id, - linked_transaction_id: linked_transaction_id - }) - |> Algora.Validations.validate_positive(:gross_amount) - |> Algora.Validations.validate_positive(:net_amount) - |> foreign_key_constraint(:original_contract_id) - |> foreign_key_constraint(:contract_id) - |> foreign_key_constraint(:timesheet_id) - |> foreign_key_constraint(:user_id) - |> Repo.insert() - end - - defp initialize_transfer(%{contract: contract, amount: amount}) do - %Transaction{} - |> change(%{ - id: Nanoid.generate(), - provider: "stripe", - gross_amount: amount, - net_amount: amount, - total_fee: Money.zero(:USD), - type: :transfer, - status: :initialized, - contract_id: contract.id, - original_contract_id: contract.original_contract_id, - timesheet_id: contract.timesheet.id, - user_id: contract.contractor_id - }) - |> Algora.Validations.validate_positive(:gross_amount) - |> Algora.Validations.validate_positive(:net_amount) - |> foreign_key_constraint(:original_contract_id) - |> foreign_key_constraint(:contract_id) - |> foreign_key_constraint(:timesheet_id) - |> foreign_key_constraint(:user_id) - |> Repo.insert() - end - - defp initialize_prepayment_transaction(contract) do - fee_data = calculate_fee_data(contract) - - net_charge_amount = Money.mult!(contract.hourly_rate, contract.hours_per_week) - platform_fee = Money.mult!(net_charge_amount, fee_data.current_fee) - transaction_fee = Money.mult!(net_charge_amount, fee_data.transaction_fee) - total_fee = Money.add!(platform_fee, transaction_fee) - gross_charge_amount = Money.add!(net_charge_amount, total_fee) - - line_items = [ - %{ - amount: net_charge_amount, - description: - "Prepayment for upcoming period - #{contract.hours_per_week} hours @ #{Money.to_string!(contract.hourly_rate)}/hr" - }, - %{ - amount: platform_fee, - description: "Algora platform fee (#{Util.format_pct(fee_data.current_fee)})" - }, - %{ - amount: transaction_fee, - description: "Transaction fee (#{Util.format_pct(fee_data.transaction_fee)})" - } - ] - - with {:ok, charge} <- - maybe_initialize_charge(%{ - contract: contract, - line_items: line_items, - gross_amount: gross_charge_amount, - net_amount: net_charge_amount, - total_fee: total_fee - }) do - {:ok, %{charge: charge}} - end - end - - defp initialize_release_transactions(contract, renew) do - balance = Contract.balance(contract) - fee_data = calculate_fee_data(contract) - - transfer_amount = calculate_transfer_amount(contract) - - new_prepayment = - if renew, - do: Money.mult!(contract.hourly_rate, contract.hours_per_week), - else: Money.zero(:USD) - - net_charge_amount = Money.sub!(Money.add!(transfer_amount, new_prepayment), balance) - platform_fee = Money.mult!(net_charge_amount, fee_data.current_fee) - transaction_fee = Money.mult!(net_charge_amount, fee_data.transaction_fee) - total_fee = Money.add!(platform_fee, transaction_fee) - gross_charge_amount = Money.add!(net_charge_amount, total_fee) - - line_items = - if Money.positive?(net_charge_amount) do - [ - build_transfer_line_item(contract, transfer_amount), - build_balance_line_item(balance), - build_prepayment_line_item(contract, new_prepayment), - build_platform_fee_line_item(platform_fee, fee_data), - build_transaction_fee_line_item(transaction_fee, fee_data) - ] - |> Enum.reject(&is_nil/1) - |> Enum.reject(&Money.zero?(&1.amount)) - else - [] - end - - debit_id = Nanoid.generate() - credit_id = Nanoid.generate() - - charge_params = %{ - contract: contract, - line_items: line_items, - gross_amount: gross_charge_amount, - net_amount: net_charge_amount, - total_fee: total_fee - } - - debit_params = %{ - id: debit_id, - linked_transaction_id: credit_id, - contract: contract, - amount: transfer_amount - } - - credit_params = %{ - id: credit_id, - linked_transaction_id: debit_id, - contract: contract, - amount: transfer_amount - } - - Repo.tx(fn -> - with {:ok, charge} <- maybe_initialize_charge(charge_params), - {:ok, debit} <- initialize_debit(debit_params), - {:ok, credit} <- initialize_credit(credit_params), - {:ok, transfer} <- initialize_transfer(%{contract: contract, amount: transfer_amount}) do - {:ok, %{charge: charge, debit: debit, credit: credit, transfer: transfer}} - end - end) - end - - def release_contract(contract, renew \\ false) do - with {:ok, txs} <- initialize_release_transactions(contract, renew), - {:ok, invoice} <- maybe_generate_invoice(contract, txs.charge), - {:ok, _invoice} <- maybe_pay_invoice(contract, invoice, txs) do - {:ok, txs} - end - end - - def release_and_renew_contract(contract) do - with {:ok, txs} <- release_contract(contract, true), - {:ok, new_contract} <- renew_contract(contract) do - {:ok, {txs, new_contract}} - end - end - - def prepay_contract(contract) do - with {:ok, txs} <- initialize_prepayment_transaction(contract), - {:ok, invoice} <- maybe_generate_invoice(contract, txs.charge), - {:ok, _invoice} <- maybe_pay_invoice(contract, invoice, txs) do - Activities.insert(contract, %{type: :contract_prepaid}) - - {:ok, txs} - else - error -> - Activities.insert(contract, %{ - type: :contract_prepayment_failed - }) - - error - end - end - - defp maybe_generate_invoice(_contract, nil), do: {:ok, nil} - - defp maybe_generate_invoice(contract, charge) do - # TODO: add metadata to invoice %{"version" => Payments.metadata_version(), "group_id" => tx_group_id} - invoice_params = %{auto_advance: false, customer: contract.client.customer.provider_id} - - with {:ok, invoice} <- Invoice.create(invoice_params, %{idempotency_key: "contract-#{contract.id}"}), - {:ok, _line_items} <- create_line_items(contract, invoice, charge.line_items) do - {:ok, invoice} - end - end - - defp build_transfer_line_item(contract, amount) do - %{ - amount: amount, - description: - "Payment for completed work - #{contract.timesheet.hours_worked} hours @ #{Money.to_string!(contract.hourly_rate)}/hr" - } - end - - defp build_balance_line_item(balance) do - %{ - amount: Money.negate!(balance), - description: "Less: Previously prepaid amount" - } - end - - defp build_prepayment_line_item(contract, amount) do - if Money.positive?(amount) do - %{ - amount: amount, - description: - "Prepayment for upcoming period - #{contract.hours_per_week} hours @ #{Money.to_string!(contract.hourly_rate)}/hr" - } - end - end - - defp build_platform_fee_line_item(fee, fee_data) do - %{ - amount: fee, - description: "Algora platform fee (#{Util.format_pct(fee_data.current_fee)})" - } - end - - defp build_transaction_fee_line_item(fee, fee_data) do - %{ - amount: fee, - description: "Transaction fee (#{Util.format_pct(fee_data.transaction_fee)})" - } - end - - defp create_line_items(contract, invoice, line_items) do - line_items - |> Enum.with_index() - |> Enum.reduce_while({:ok, []}, fn {line_item, index}, {:ok, acc} -> - case Algora.PSP.Invoiceitem.create( - %{ - invoice: invoice.id, - customer: contract.client.customer.provider_id, - amount: MoneyUtils.to_minor_units(line_item.amount), - currency: to_string(line_item.amount.currency), - description: line_item.description - }, - %{idempotency_key: "contract-#{contract.id}-#{index}"} - ) do - {:ok, item} -> {:cont, {:ok, [item | acc]}} - {:error, error} -> {:halt, {:error, error}} - end - end) - end - - defp maybe_pay_invoice(contract, nil, txs), do: release_funds(contract, nil, txs) - - defp maybe_pay_invoice(contract, invoice, txs) do - pm_id = contract.client.customer.default_payment_method.provider_id - - case Invoice.pay(invoice.id, %{off_session: true, payment_method: pm_id}) do - {:ok, stripe_invoice} -> - if stripe_invoice.paid, do: release_funds(contract, stripe_invoice, txs) - {:ok, stripe_invoice} - - {:error, error} -> - update_transaction_status(txs.charge, {:error, error}) - {:error, error} - end - end - - defp release_funds(contract, metadata, txs) do - if txs[:debit], do: update_transaction_status(txs.debit, metadata, :succeeded) - if txs[:credit], do: update_transaction_status(txs.credit, metadata, :succeeded) - if txs[:transfer], do: transfer_funds(contract, txs.transfer) - {:ok, :ok} - end - - # TODO: do we need to lock the transactions here? - defp transfer_funds(contract, %Transaction{type: :transfer} = transaction) when transaction.status != :succeeded do - with {:ok, account} <- Repo.fetch_by(Account, user_id: transaction.user_id), - {:ok, stripe_transfer} <- - Algora.PSP.Transfer.create( - %{ - amount: MoneyUtils.to_minor_units(transaction.net_amount), - currency: to_string(transaction.net_amount.currency), - destination: account.provider_id - }, - %{idempotency_key: transaction.id} - ) do - update_transaction_status(transaction, stripe_transfer, :succeeded) - mark_contract_as_paid(contract) - {:ok, stripe_transfer} - else - {:error, error} -> - update_transaction_status(transaction, {:error, error}) - Activities.insert(contract, %{type: :contract_prepayment_failed}) - {:error, error} - end - end - - defp update_transaction_status(transaction, {:error, error}) do - transaction - |> change(%{ - provider_meta: Util.normalize_struct(%{error: error}), - status: :failed - }) - |> Repo.update() - end - - defp update_transaction_status(transaction, nil, status) do - transaction - |> change(%{ - status: status, - succeeded_at: if(status == :succeeded, do: DateTime.utc_now()) - }) - |> Repo.update() - end - - defp update_transaction_status(transaction, record, status) do - transaction - |> change(%{ - provider_id: record.id, - provider_meta: Util.normalize_struct(record), - status: status, - succeeded_at: if(status == :succeeded, do: DateTime.utc_now()) - }) - |> Repo.update() - end - - defp mark_contract_as_paid(contract) do - change(contract, %{status: :paid}) - end - - defp renew_contract(contract) do - %Contract{} - |> change(%{ - id: Nanoid.generate(), - status: :active, - start_date: contract.end_date, - end_date: DateTime.add(contract.end_date, 7, :day), - sequence_number: contract.sequence_number + 1, - original_contract_id: contract.original_contract_id, - client_id: contract.client_id, - contractor_id: contract.contractor_id, - hourly_rate: contract.hourly_rate, - hours_per_week: contract.hours_per_week - }) - |> Repo.insert_with_activity(%{ - type: :contract_renewed, - notify_users: [] - }) - end - - def calculate_transfer_amount(contract) do - Money.mult!(contract.hourly_rate, contract.timesheet.hours_worked) - end - - def fetch_contract(criteria) when is_list(criteria) do - case list_contract_chain(criteria) do - [contract] -> {:ok, contract} - [] -> {:error, :not_found} - _ -> {:error, :multiple_contracts} - end - end - - def fetch_contract(id) do - fetch_contract(id: id) - end - - def fetch_last_contract(id) do - fetch_contract(original_contract_id: id, limit: 1, order: :desc) - end - - def list_contracts(criteria \\ []) do - list_contract_chain(criteria) - end - - # TODO: rename - def list_contract_chain(criteria \\ []) do - criteria = Keyword.merge([order: :desc, limit: 50], criteria) - - transaction_amounts = - Transaction - |> maybe_filter_txs_by_contract_id(criteria) - |> maybe_filter_txs_by_original_contract_id(criteria) - |> group_by([t], t.contract_id) - |> select([t], %{ - contract_id: t.contract_id, - amount_credited: Algora.SQL.sum_by_type(t, "credit"), - amount_debited: Algora.SQL.sum_by_type(t, "debit") - }) - - transaction_totals = - Transaction - |> maybe_filter_txs_by_original_contract_id(criteria) - |> group_by([t], t.original_contract_id) - |> select([t], %{ - original_contract_id: t.original_contract_id, - total_charged: Algora.SQL.sum_by_type(t, "charge"), - total_credited: Algora.SQL.sum_by_type(t, "credit"), - total_debited: Algora.SQL.sum_by_type(t, "debit"), - total_deposited: Algora.SQL.sum_by_type(t, "deposit"), - total_transferred: Algora.SQL.sum_by_type(t, "transfer"), - total_withdrawn: Algora.SQL.sum_by_type(t, "withdrawal") - }) - - base_contracts = Contract |> apply_criteria(criteria) |> select([c], c.id) - - from(c in Contract) - |> join(:inner, [c], bc in subquery(base_contracts), on: c.id == bc.id) - |> join(:inner, [c], cl in assoc(c, :client), as: :cl) - |> join(:left, [c], ct in assoc(c, :contractor), as: :ct) - |> join(:left, [c, cl: cl], cu in assoc(cl, :customer), as: :cu) - |> join(:left, [c, cu: cu], dpm in assoc(cu, :default_payment_method), as: :dpm) - |> join(:left, [c], ts in assoc(c, :timesheet), as: :ts) - |> join(:left, [c], txs in assoc(c, :transactions), as: :txs) - |> join(:left, [c], ta in subquery(transaction_amounts), - on: ta.contract_id == c.id, - as: :ta - ) - |> join(:left, [c], tt in subquery(transaction_totals), - on: tt.original_contract_id == c.original_contract_id, - as: :tt - ) - |> join(:left, [c], act in assoc(c, :activities), as: :act) - |> select_merge([ta: ta, tt: tt], %{ - amount_credited: Algora.SQL.money_or_zero(ta.amount_credited), - amount_debited: Algora.SQL.money_or_zero(ta.amount_debited), - total_charged: Algora.SQL.money_or_zero(tt.total_charged), - total_credited: Algora.SQL.money_or_zero(tt.total_credited), - total_debited: Algora.SQL.money_or_zero(tt.total_debited), - total_deposited: Algora.SQL.money_or_zero(tt.total_deposited), - total_transferred: Algora.SQL.money_or_zero(tt.total_transferred), - total_withdrawn: Algora.SQL.money_or_zero(tt.total_withdrawn) - }) - |> preload([ts: ts, txs: txs, cl: cl, ct: ct, cu: cu, dpm: dpm, act: act], - timesheet: ts, - transactions: txs, - client: {cl, customer: {cu, default_payment_method: dpm}}, - contractor: ct, - activities: act - ) - |> Repo.all() - |> Enum.map(&Contract.after_load/1) - end - - @spec apply_criteria(Ecto.Queryable.t(), [criterion()]) :: Ecto.Queryable.t() - defp apply_criteria(query, criteria) do - Enum.reduce(criteria, query, fn - {:id, id}, query -> - from([c] in query, where: c.id == ^id) - - {:contractor_id, contractor_id}, query -> - from([c] in query, where: c.contractor_id == ^contractor_id) - - {:client_id, client_id}, query -> - from([c] in query, where: c.client_id == ^client_id) - - {:original_contract_id, original_contract_id}, query -> - from([c] in query, where: c.original_contract_id == ^original_contract_id) - - {:open?, true}, query -> - from([c] in query, where: is_nil(c.contractor_id)) - - {:active_or_paid?, true}, query -> - from([c] in query, where: c.status in [:active, :paid]) - - {:original?, true}, query -> - from([c] in query, where: c.id == c.original_contract_id) - - {:status, {:in, statuses}}, query -> - from([c] in query, where: c.status in ^statuses) - - {:status, status}, query -> - from([c] in query, where: c.status == ^status) - - {:after, sequence_number}, query -> - from([c] in query, where: c.sequence_number > ^sequence_number) - - {:before, sequence_number}, query -> - from([c] in query, where: c.sequence_number < ^sequence_number) - - {:order, :asc}, query -> - from([c] in query, order_by: [asc: c.sequence_number]) - - {:order, :desc}, query -> - from([c] in query, order_by: [desc: c.sequence_number]) - - {:limit, limit}, query -> - from([c] in query, limit: ^limit) - - _, query -> - query - end) - end - - defp maybe_filter_txs_by_contract_id(query, criteria) do - case Keyword.get(criteria, :id) do - nil -> query - id -> from([t] in query, where: t.contract_id == ^id) - end - end - - defp maybe_filter_txs_by_original_contract_id(query, criteria) do - case Keyword.get(criteria, :original_contract_id) do - nil -> query - id -> from([t] in query, where: t.original_contract_id == ^id) - end - end -end diff --git a/lib/algora/contracts/schemas/contract.ex b/lib/algora/contracts/schemas/contract.ex deleted file mode 100644 index 592f54180..000000000 --- a/lib/algora/contracts/schemas/contract.ex +++ /dev/null @@ -1,106 +0,0 @@ -defmodule Algora.Contracts.Contract do - @moduledoc false - use Algora.Schema - - alias Algora.Accounts.User - alias Algora.Activities.Activity - alias Algora.Contracts.Contract - alias Algora.MoneyUtils - alias Algora.Validations - - @type status :: :draft | :active | :paid | :cancelled | :disputed - - typed_schema "contracts" do - field :status, Ecto.Enum, values: [:draft, :active, :paid, :cancelled, :disputed] - field :sequence_number, :integer, default: 1 - field :hourly_rate, Algora.Types.Money - field :hourly_rate_min, Algora.Types.Money - field :hourly_rate_max, Algora.Types.Money - field :hours_per_week, :integer - field :start_date, :utc_datetime_usec - field :end_date, :utc_datetime_usec - - field :amount_credited, Algora.Types.Money, virtual: true - field :amount_debited, Algora.Types.Money, virtual: true - - field :total_charged, Algora.Types.Money, virtual: true - field :total_credited, Algora.Types.Money, virtual: true - field :total_debited, Algora.Types.Money, virtual: true - field :total_deposited, Algora.Types.Money, virtual: true - field :total_transferred, Algora.Types.Money, virtual: true - field :total_withdrawn, Algora.Types.Money, virtual: true - - belongs_to :original_contract, Contract - has_many :renewals, Contract, foreign_key: :original_contract_id - - belongs_to :client, User - belongs_to :contractor, User - - has_many :transactions, Algora.Payments.Transaction - has_many :reviews, Algora.Reviews.Review - has_one :timesheet, Algora.Contracts.Timesheet - - has_many :activities, {"contract_activities", Activity}, foreign_key: :assoc_id - - timestamps() - end - - def after_load({:ok, struct}), do: {:ok, after_load(struct)} - def after_load({:error, _} = result), do: result - def after_load(nil), do: nil - - def after_load(struct) do - Enum.reduce( - [ - :amount_credited, - :amount_debited, - :total_charged, - :total_credited, - :total_debited, - :total_deposited, - :total_transferred, - :total_withdrawn - ], - struct, - &MoneyUtils.ensure_money_field(&2, &1) - ) - end - - def balance(contract) do - :USD - |> Money.zero() - |> Money.add!(contract.total_charged) - |> Money.add!(contract.total_deposited) - |> Money.sub!(contract.total_debited) - |> Money.sub!(contract.total_withdrawn) - end - - def changeset(contract, attrs) do - contract - |> cast(attrs, [ - :status, - :sequence_number, - :hourly_rate, - :hours_per_week, - :start_date, - :end_date, - :original_contract_id, - :client_id, - :contractor_id - ]) - |> validate_required([:status, :hourly_rate, :hours_per_week, :client_id]) - |> validate_number(:hours_per_week, greater_than: 0) - |> Validations.validate_money_positive(:hourly_rate) - |> foreign_key_constraint(:client_id) - |> foreign_key_constraint(:contractor_id) - |> generate_id() - |> put_original_contract_id() - end - - def put_original_contract_id(changeset) do - case get_field(changeset, :original_contract_id) do - nil -> put_change(changeset, :original_contract_id, get_field(changeset, :id)) - _existing -> changeset - end - end -end diff --git a/lib/algora/contracts/schemas/timesheet.ex b/lib/algora/contracts/schemas/timesheet.ex deleted file mode 100644 index df1894185..000000000 --- a/lib/algora/contracts/schemas/timesheet.ex +++ /dev/null @@ -1,25 +0,0 @@ -defmodule Algora.Contracts.Timesheet do - @moduledoc false - use Algora.Schema - - alias Algora.Activities.Activity - - typed_schema "timesheets" do - field :hours_worked, :integer - field :description, :string - - belongs_to :contract, Algora.Contracts.Contract - has_many :transactions, Algora.Payments.Transaction - - has_many :activities, {"timesheet_activities", Activity}, foreign_key: :assoc_id - - timestamps() - end - - def changeset(timesheet, attrs) do - timesheet - |> cast(attrs, [:hours_worked, :description]) - |> validate_required([:hours_worked]) - |> generate_id() - end -end diff --git a/lib/algora/forms.ex b/lib/algora/forms.ex deleted file mode 100644 index b19ff68f5..000000000 --- a/lib/algora/forms.ex +++ /dev/null @@ -1,12 +0,0 @@ -defmodule Algora.Forms do - @moduledoc false - - alias Algora.Forms.FormSubmission - alias Algora.Repo - - def submit(form, attrs) do - %FormSubmission{} - |> FormSubmission.changeset(Map.merge(attrs, %{form: form})) - |> Repo.insert() - end -end diff --git a/lib/algora/forms/schemas/form_submission.ex b/lib/algora/forms/schemas/form_submission.ex deleted file mode 100644 index 199105a85..000000000 --- a/lib/algora/forms/schemas/form_submission.ex +++ /dev/null @@ -1,19 +0,0 @@ -defmodule Algora.Forms.FormSubmission do - @moduledoc false - use Algora.Schema - - typed_schema "form_submissions" do - field :form, :string, null: false - field :email, :string - field :payload, :map, null: false, default: %{} - - timestamps() - end - - def changeset(form_submission, attrs) do - form_submission - |> cast(attrs, [:form, :email, :payload]) - |> generate_id() - |> validate_required([:form]) - end -end diff --git a/lib/algora/integrations/crawler.ex b/lib/algora/integrations/crawler.ex deleted file mode 100644 index 058ccb035..000000000 --- a/lib/algora/integrations/crawler.ex +++ /dev/null @@ -1,445 +0,0 @@ -defmodule Algora.Crawler do - @moduledoc false - alias Algora.Util - - require Logger - - @user_agent "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" - @headers [{"User-Agent", @user_agent}] - @max_redirects 5 - @max_retries 3 - @retry_delay to_timeout(second: 1) - @blacklist_filename "domain_blacklist.txt" - - def blacklisted?(nil), do: true - def blacklisted?(""), do: true - - def blacklisted?(domain) do - :algora - |> :code.priv_dir() - |> Path.join(@blacklist_filename) - |> File.stream!() - |> Stream.map(&String.trim/1) - |> Enum.member?(domain) - end - - def fetch_site_metadata(nil), do: {:error, :blacklisted_domain} - def fetch_site_metadata(domain), do: fetch_site_metadata("https://#{domain}", 0, 0) - - def fetch_site_metadata(url, redirect_count, retry_count) do - request = Finch.build(:get, url, @headers) - - case Finch.request(request, Algora.Finch) do - {:ok, response} -> - case handle_response(response, url, redirect_count) do - {:ok, body} -> - case Floki.parse_document(body) do - {:ok, html_tree} -> - metadata = %{ - og_title: find_title(html_tree), - og_description: find_description(html_tree), - og_image_url: find_og_image(html_tree), - favicon_url: find_logo(html_tree, url), - socials: find_social_links(html_tree, url) - } - - # Enhance metadata with GitHub info if available - metadata = - case get_github_info(url, metadata.socials[:github]) do - {:ok, github_info} -> Map.merge(metadata, github_info) - _ -> update_in(metadata, [:socials, :github], fn _ -> nil end) - end - - metadata - |> update_in([:socials, :twitter], fn twitter_url -> - case get_in(metadata, [:twitter_username]) do - nil -> twitter_url - username -> "https://x.com/#{username}" - end - end) - |> Map.delete(:twitter_username) - |> then(&{:ok, &1}) - - error -> - Logger.error("Failed to parse HTML from #{url}: #{inspect(error)}") - {:error, :parse_failed} - end - - {:redirect, new_url} -> - fetch_site_metadata(new_url, redirect_count + 1, retry_count) - - {:error, reason} -> - Logger.error("Failed to fetch metadata from #{url}: #{inspect(reason)}") - {:error, reason} - end - - error -> - Logger.error("Failed to fetch metadata from #{url}: #{inspect(error)}") - - if retry_count < @max_retries do - Process.sleep(@retry_delay) - fetch_site_metadata(url, redirect_count, retry_count + 1) - else - {:error, :request_failed} - end - end - end - - def fetch_user_metadata(email, opts \\ []) do - domain = get_email_domain(email) - gravatar_url = Util.get_gravatar_url(email, opts) - - case fetch_site_metadata(domain) do - {:ok, metadata} -> - %{avatar_url: gravatar_url, org: metadata} - - {:error, _reason} -> - %{avatar_url: gravatar_url, org: nil} - end - end - - defp handle_response(%Finch.Response{status: status, headers: headers, body: _body}, url, redirect_count) - when status in [301, 302, 303, 307, 308] do - if redirect_count >= @max_redirects do - {:error, :too_many_redirects} - else - case List.keyfind(headers, "location", 0) do - {_, location} -> {:redirect, make_absolute_url(location, url)} - nil -> {:error, :missing_redirect_location} - end - end - end - - defp handle_response(%Finch.Response{status: 200, body: body}, _url, _redirect_count) do - {:ok, body} - end - - defp handle_response(%Finch.Response{status: status}, _url, _redirect_count) do - {:error, "Unexpected status code: #{status}"} - end - - defp find_og_image(html_tree) do - # Try multiple OG image meta tags - og_tags = [ - ~s|meta[property="og:image"]|, - ~s|meta[property="og:image:url"]|, - ~s|meta[property="og:image:secure_url"]|, - ~s|meta[name="twitter:image"]| - ] - - Enum.find_value(og_tags, fn selector -> - html_tree - |> Floki.find(selector) - |> get_content_or_nil() - end) - end - - defp find_title(html_tree) do - # Try meta title first, then fallback to HTML title tag - meta_title = - html_tree - |> Floki.find(~s|meta[property="og:title"]|) - |> get_content_or_nil() - |> maybe_trim() - - html_title = - html_tree - |> Floki.find("title") - |> Floki.text() - |> maybe_trim() - - meta_title || html_title - end - - defp find_description(html_tree) do - # Try multiple description meta tags - description_tags = [ - ~s|meta[property="og:description"]|, - ~s|meta[name="description"]|, - ~s|meta[name="twitter:description"]| - ] - - Enum.find_value(description_tags, fn selector -> - html_tree - |> Floki.find(selector) - |> get_content_or_nil() - |> maybe_trim() - end) - end - - defp find_logo(html_tree, base_url) do - # First find all icon links and parse their sizes - icons_with_sizes = - html_tree - |> Floki.find("link[rel~=icon], link[rel~=apple-touch-icon]") - |> Enum.map(fn element -> - { - element, - element |> Floki.attribute("sizes") |> List.first() |> get_size_in_pixels() - } - end) - |> Enum.sort_by(fn {_, size} -> size end, :desc) - - # Then try logo images - logo_selectors = [ - ~s|link[rel="logo"]|, - ~s|img[alt*="logo" i]|, - ~s|img[src*="logo" i]| - ] - - logo_url = - case icons_with_sizes do - # If we found icons with sizes, use the largest one - [{element, _} | _] -> - get_logo_url([element]) - - # Otherwise try the logo selectors - [] -> - Enum.find_value(logo_selectors, fn selector -> - html_tree - |> Floki.find(selector) - |> get_logo_url() - end) - end - - case logo_url do - nil -> nil - url -> make_absolute_url(url, base_url) - end - end - - defp get_size_in_pixels(nil), do: 0 - - defp get_size_in_pixels(sizes) do - sizes - |> String.split(" ") - |> Enum.map(fn size -> - case String.split(size, "x") do - [width, _height] -> String.to_integer(width) - _ -> 0 - end - end) - |> Enum.max(fn -> 0 end) - end - - defp get_content_or_nil([]), do: nil - - defp get_content_or_nil([element | _]) do - element - |> Floki.attribute("content") - |> List.first() - end - - defp get_logo_url([]), do: nil - - defp get_logo_url([element | _]) do - element - |> Floki.attribute("href") - |> List.first() - |> case do - nil -> element |> Floki.attribute("src") |> List.first() - href -> href - end - end - - defp make_absolute_url(nil, _base_url), do: nil - - defp make_absolute_url(url, base_url) do - uri = URI.parse(url) - base_uri = URI.parse(base_url) - - case uri do - %URI{host: nil, scheme: nil} -> - # Relative URL - if String.starts_with?(url, "/") do - "#{base_uri.scheme}://#{base_uri.host}#{url}" - else - Path.join([base_uri.scheme <> "://" <> base_uri.host, url]) - end - - _ -> - url - end - end - - defp maybe_trim(nil), do: nil - - defp maybe_trim(string) do - string - |> String.trim() - |> String.split() - |> Enum.join(" ") - end - - defp find_social_links(html_tree, url) do - %{ - twitter: find_social_url(html_tree, :twitter, url), - discord: find_social_url(html_tree, :discord, url), - github: find_social_url(html_tree, :github, url), - instagram: find_social_url(html_tree, :instagram, url), - youtube: find_social_url(html_tree, :youtube, url), - producthunt: find_social_url(html_tree, :producthunt, url), - hackernews: find_social_url(html_tree, :hackernews, url), - slack: find_social_url(html_tree, :slack, url), - linkedin: find_social_url(html_tree, :linkedin, url) - } - |> Enum.reject(fn {_k, v} -> is_nil(v) end) - |> Map.new() - end - - @social_selectors %{ - twitter: [ - ~s|meta[name="twitter:url"]|, - ~s|meta[name="twitter:site"]|, - ~s|a[href*="twitter.com"]|, - ~s|a[href*="x.com"]|, - ~s|a[aria-label*="Twitter" i], a:has([aria-label*="Twitter" i])| - ], - discord: [ - ~s|a[href*="discord.gg"]|, - ~s|a[href*="discord.com/invite"]|, - ~s|a[aria-label*="Discord" i], a:has([aria-label*="Discord" i])|, - ~s|a[href*="discord"]| - ], - github: [ - ~s|a[href*="github.com"]|, - ~s|a[aria-label*="GitHub" i], a:has([aria-label*="GitHub" i])|, - ~s|a[href*="github"]| - ], - instagram: [ - ~s|a[href*="instagram.com"]|, - ~s|a[aria-label*="Instagram" i], a:has([aria-label*="Instagram" i])| - ], - youtube: [ - ~s|a[href*="youtube.com"]|, - ~s|a[href*="youtu.be"]|, - ~s|a[aria-label*="YouTube" i], a:has([aria-label*="YouTube" i])| - ], - producthunt: [ - ~s|a[href*="producthunt.com"]|, - ~s|a[aria-label*="Product Hunt" i], a:has([aria-label*="Product Hunt" i])|, - ~s|a[aria-label*="ProductHunt" i], a:has([aria-label*="ProductHunt" i])| - ], - hackernews: [ - ~s|a[href*="news.ycombinator.com"]|, - ~s|a[href*="ycombinator.com"]|, - ~s|a[aria-label*="Hacker News" i], a:has([aria-label*="Hacker News" i])|, - ~s|a[aria-label*="HackerNews" i], a:has([aria-label*="HackerNews" i])| - ], - slack: [ - ~s|a[href*="slack.com/join"]|, - ~s|a[href*="slack.com/shared_invite"]|, - ~s|a[aria-label*="Slack" i], a:has([aria-label*="Slack" i])| - ], - linkedin: [ - ~s|a[href*="linkedin.com"]|, - ~s|a[aria-label*="LinkedIn" i], a:has([aria-label*="LinkedIn" i])| - ] - } - - defp find_social_url(html_tree, platform, base_url) do - selectors = @social_selectors[platform] - - Enum.find_value(selectors, fn selector -> - elements = Floki.find(html_tree, selector) - - url = - case platform do - :twitter -> - handle_twitter_url(elements) - - _ -> - get_href_or_nil(elements) - end - - if url do - make_absolute_url(url, base_url) - end - end) - end - - defp handle_twitter_url([]), do: nil - - defp handle_twitter_url([element | _]) do - content = get_content_or_nil([element]) - href = get_href_or_nil([element]) - - cond do - content && String.starts_with?(content, "@") -> - "https://twitter.com/#{String.trim_leading(content, "@")}" - - href -> - href - - true -> - nil - end - end - - defp get_href_or_nil([]), do: nil - - defp get_href_or_nil([element | _]) do - element - |> Floki.attribute("href") - |> List.first() - end - - defp get_email_domain(email) do - [_, domain] = String.split(email, "@") - if not blacklisted?(domain), do: domain - end - - defp get_github_info(_website_url, nil), do: {:error, :no_github_url} - - defp get_github_info(website_url, github_url) do - case extract_github_handle(github_url) do - nil -> - {:error, :invalid_github_url} - - handle -> - request = Finch.build(:get, "https://api.github.com/users/#{handle}", @headers) - - case Finch.request(request, Algora.Finch) do - {:ok, %Finch.Response{status: 200, body: body}} -> - case Jason.decode(body) do - {:ok, data} -> - host = website_url |> URI.parse() |> Map.get(:host) |> String.split(".") |> Enum.at(-2) - - if Util.normalized_strings_match?(data["login"], host) do - {:ok, - %{ - email: data["email"], - avatar_url: data["avatar_url"], - bio: data["bio"], - handle: data["login"], - website_url: data["blog"], - display_name: data["name"], - twitter_username: data["twitter_username"] - }} - else - {:error, :mismatch} - end - - _ -> - {:error, :json_decode_failed} - end - - _ -> - {:error, :github_api_failed} - end - end - end - - defp extract_github_handle(url) do - case URI.parse(url) do - %URI{host: "github.com", path: path} when is_binary(path) -> - path - |> String.trim("/") - |> String.split("/") - |> List.first() - - _ -> - nil - end - end -end diff --git a/lib/algora/integrations/discord/client.ex b/lib/algora/integrations/discord/client.ex deleted file mode 100644 index 09e71a10d..000000000 --- a/lib/algora/integrations/discord/client.ex +++ /dev/null @@ -1,26 +0,0 @@ -defmodule Algora.Discord.Client do - @moduledoc false - - require Logger - - def post(nil, _data), do: {:ok, nil} - def post(url, data), do: do_post(url, data) - - defp do_post(url, data) do - headers = [{"Content-Type", "application/json"}] - - with {:ok, encoded_body} <- Jason.encode(data), - request = Finch.build("POST", url, headers, encoded_body), - {:ok, %Finch.Response{status: status}} when status < 300 <- Finch.request(request, Algora.Finch) do - {:ok, status} - else - {:ok, %Finch.Response{status: status}} -> - Logger.error("Discord API error: #{inspect(status)}") - {:error, status} - - error -> - Logger.error("Discord API error: #{inspect(error)}") - error - end - end -end diff --git a/lib/algora/integrations/discord/discord.ex b/lib/algora/integrations/discord/discord.ex deleted file mode 100644 index 9ce94ddfa..000000000 --- a/lib/algora/integrations/discord/discord.ex +++ /dev/null @@ -1,59 +0,0 @@ -defmodule Algora.Discord do - @moduledoc """ - Discord integration for Algora. - - Provides functionality to send messages to Discord channels. - """ - - alias Algora.Discord.Client - - require Logger - - @doc """ - Sends a message to a Discord channel. - - ## Parameters - - * `content` - The message content (optional) - * `embeds` - List of embeds to include in the message (optional) - - ## Examples - - iex> Discord.send_message("https://discord.com/api/webhooks/1234567890/abcdefg", %{content: "Hello, world!"}) - {:ok, response} - - iex> Discord.send_message("https://discord.com/api/webhooks/1234567890/abcdefg", %{ - ...> embeds: [ - ...> %{ - ...> color: 0x6366f1, - ...> title: "New Bounty Created", - ...> author: %{ - ...> name: "Organization Name", - ...> icon_url: "https://example.com/avatar.png", - ...> url: "https://example.com/org" - ...> }, - ...> url: "https://github.com/repo/issues/1", - ...> timestamp: DateTime.utc_now() |> DateTime.to_iso8601() - ...> } - ...> ] - ...> }) - {:ok, response} - """ - @spec send_message(String.t(), map()) :: {:ok, map() | nil} | {:error, any()} - def send_message(url, input) do - input = - Map.merge( - %{username: "Algora.io", avatar_url: "https://algora.io/asset/storage/v1/object/public/images/logo-256px.png"}, - input - ) - - case Client.post(url, input) do - {:ok, response} -> - {:ok, response} - - {:error, reason} = error -> - Logger.error("Could not send Discord message: #{inspect(reason)}, input: #{inspect(input)}") - error - end - end -end diff --git a/lib/algora/integrations/github/behaviour.ex b/lib/algora/integrations/github/behaviour.ex deleted file mode 100644 index 544d8015f..000000000 --- a/lib/algora/integrations/github/behaviour.ex +++ /dev/null @@ -1,43 +0,0 @@ -defmodule Algora.Github.Behaviour do - @moduledoc false - - @type token :: String.t() - - @callback get_delivery(String.t()) :: {:ok, map()} | {:error, String.t()} - @callback list_deliveries(keyword()) :: {:ok, [map()]} | {:error, String.t()} - @callback redeliver(String.t()) :: {:ok, map()} | {:error, String.t()} - @callback get_issue(token(), String.t(), String.t(), integer()) :: {:ok, map()} | {:error, String.t()} - @callback get_repository(token(), String.t(), String.t()) :: {:ok, map()} | {:error, String.t()} - @callback get_repository(token(), integer()) :: {:ok, map()} | {:error, String.t()} - @callback get_pull_request(token(), String.t(), String.t(), integer()) :: {:ok, map()} | {:error, String.t()} - @callback get_current_user(token()) :: {:ok, map()} | {:error, String.t()} - @callback get_current_user_emails(token()) :: {:ok, [map()]} | {:error, String.t()} - @callback get_user(token(), integer()) :: {:ok, map()} | {:error, String.t()} - @callback get_user_by_username(token(), String.t()) :: {:ok, map()} | {:error, String.t()} - @callback get_repository_permissions(token(), String.t(), String.t(), String.t()) :: {:ok, map()} | {:error, String.t()} - @callback list_installations(token(), integer()) :: {:ok, map()} | {:error, String.t()} - @callback find_installation(token(), integer(), integer()) :: {:ok, map()} | {:error, String.t()} - @callback get_installation_token(integer()) :: {:ok, map()} | {:error, String.t()} - @callback get_installation(integer()) :: {:ok, map()} | {:error, String.t()} - @callback list_installation_repos(token()) :: {:ok, [map()]} | {:error, String.t()} - @callback create_issue_comment(token(), String.t(), String.t(), integer(), String.t()) :: - {:ok, map()} | {:error, String.t()} - @callback update_issue_comment(token(), String.t(), String.t(), integer(), String.t()) :: - {:ok, map()} | {:error, String.t()} - @callback delete_issue_comment(token(), String.t(), String.t(), integer()) :: - {:ok, map()} | {:error, String.t()} - @callback list_user_repositories(token(), String.t(), keyword()) :: {:ok, [map()]} | {:error, String.t()} - @callback list_repository_events(token(), String.t(), String.t(), keyword()) :: {:ok, [map()]} | {:error, String.t()} - @callback list_repository_comments(token(), String.t(), String.t(), keyword()) :: {:ok, [map()]} | {:error, String.t()} - @callback list_repository_languages(token(), String.t(), String.t()) :: {:ok, [map()]} | {:error, String.t()} - @callback list_repository_contributors(token(), String.t(), String.t()) :: {:ok, [map()]} | {:error, String.t()} - @callback add_labels(token(), String.t(), String.t(), integer(), [String.t()]) :: {:ok, [map()]} | {:error, String.t()} - @callback list_labels(token(), String.t(), String.t(), integer()) :: {:ok, [map()]} | {:error, String.t()} - @callback create_label(token(), String.t(), String.t(), map()) :: {:ok, map()} | {:error, String.t()} - @callback get_label(token(), String.t(), String.t(), String.t()) :: {:ok, map()} | {:error, String.t()} - @callback remove_label(token(), String.t(), String.t(), String.t()) :: {:ok, map()} | {:error, String.t()} - @callback remove_label_from_issue(token(), String.t(), String.t(), integer(), String.t()) :: - {:ok, map()} | {:error, String.t()} - @callback list_user_followers(token(), String.t(), keyword()) :: {:ok, [map()]} | {:error, String.t()} - @callback list_user_following(token(), String.t(), keyword()) :: {:ok, [map()]} | {:error, String.t()} -end diff --git a/lib/algora/integrations/github/client.ex b/lib/algora/integrations/github/client.ex deleted file mode 100644 index e9450fc09..000000000 --- a/lib/algora/integrations/github/client.ex +++ /dev/null @@ -1,363 +0,0 @@ -defmodule Algora.Github.Client do - @moduledoc false - @behaviour Algora.Github.Behaviour - - alias Algora.Github.Crypto - - require Logger - - @type token :: String.t() - - def http(host, method, path, headers, body) do - do_http_request(host, method, path, headers, body) - end - - defp do_http_request(host, method, path, headers, body) do - url = "https://#{host}#{path}" - headers = [{"Content-Type", "application/json"} | headers] - - with {:ok, encoded_body} <- Jason.encode(body), - request = Finch.build(method, url, headers, encoded_body), - {:ok, %Finch.Response{body: body}} <- request_with_follow_redirects(request), - {:ok, decoded_body} <- Jason.decode(body) do - maybe_handle_error(decoded_body) - end - end - - defp request_with_follow_redirects(request) do - case Finch.request(request, Algora.Finch) do - {:ok, %Finch.Response{status: status, headers: headers}} when status in [301, 302, 307] -> - case List.keyfind(headers, "location", 0) do - {"location", location} -> - request_with_follow_redirects(Finch.build(request.method, location, request.headers, request.body)) - - nil -> - {:error, "Redirect response missing location header"} - end - - res -> - res - end - end - - defp maybe_handle_error(%{"message" => "Repository access blocked"} = body) do - {:error, body} - end - - defp maybe_handle_error(%{"message" => message, "status" => status} = body) do - case Integer.parse(status) do - {code, _} when code >= 400 -> {:error, "#{code} #{message}"} - _ -> {:ok, body} - end - end - - defp maybe_handle_error(body), do: {:ok, body} - - def run_cached(path, fun) do - case read_from_cache(path) do - :not_found -> - Logger.warning("❌ Cache miss for #{path}") - write_to_cache!(fun.(), path) - - res -> - res - end - end - - defp get_cache_path(path), do: Path.join([:code.priv_dir(:algora), "github", path <> ".bin"]) - - defp maybe_retry({:ok, %{"message" => "Moved Permanently"}}), do: :not_found - defp maybe_retry({:ok, data}), do: {:ok, data} - defp maybe_retry({:error, "404 Not Found"}), do: {:error, "404 Not Found"} - defp maybe_retry(_error), do: :not_found - - def read_from_cache(path) do - cache_path = get_cache_path(path) - - if File.exists?(cache_path) do - case File.read(cache_path) do - {:ok, content} -> - content - |> Plug.Crypto.non_executable_binary_to_term([:safe]) - |> maybe_retry() - - {:error, _} -> - :not_found - end - else - :not_found - end - end - - defp write_to_cache!(data, path) do - cache_path = get_cache_path(path) - File.mkdir_p!(Path.dirname(cache_path)) - File.write!(cache_path, :erlang.term_to_binary(data)) - data - end - - def fetch_with_headers(access_token, path) do - url = "https://api.github.com#{path}" - headers = [ - {"Content-Type", "application/json"}, - {"accept", "application/vnd.github.v3+json"} - | if(access_token, do: [{"Authorization", "Bearer #{access_token}"}], else: []) - ] - - request = Finch.build("GET", url, headers, nil) - - with {:ok, %Finch.Response{body: body, headers: resp_headers}} <- - Finch.request(request, Algora.Finch), - {:ok, decoded} <- Jason.decode(body) do - {:ok, decoded, resp_headers} - end - end - - def fetch(access_token, url, method \\ "GET", body \\ nil) - - def fetch(access_token, "https://api.github.com" <> path, method, body), do: fetch(access_token, path, method, body) - - def fetch(access_token, path, method, body) do - http( - "api.github.com", - method, - path, - [{"accept", "application/vnd.github.v3+json"}] ++ - if(access_token, do: [{"Authorization", "Bearer #{access_token}"}], else: []), - body - ) - end - - def fetch_with_jwt(path, method \\ "GET", body \\ nil) do - with {:ok, jwt, _claims} <- Crypto.generate_jwt() do - fetch(jwt, path, method, body) - end - end - - defp build_query(opts), do: if(opts == [], do: "", else: "?" <> URI.encode_query(opts)) - - @impl true - def get_delivery(delivery_id) do - fetch_with_jwt("/app/hook/deliveries/#{delivery_id}") - end - - @impl true - def list_deliveries(opts \\ []) do - fetch_with_jwt("/app/hook/deliveries#{build_query(opts)}") - end - - @impl true - def redeliver(delivery_id) do - fetch_with_jwt("/app/hook/deliveries/#{delivery_id}/attempts", "POST") - end - - @impl true - def get_issue(access_token, owner, repo, number) do - fetch(access_token, "/repos/#{owner}/#{repo}/issues/#{number}") - end - - @impl true - def get_repository(access_token, owner, repo) do - fetch(access_token, "/repos/#{owner}/#{repo}") - end - - @impl true - def get_repository(access_token, id) do - fetch(access_token, "/repositories/#{id}") - end - - @impl true - def get_pull_request(access_token, owner, repo, number) do - fetch(access_token, "/repos/#{owner}/#{repo}/pulls/#{number}") - end - - @impl true - def get_current_user(access_token) do - fetch(access_token, "/user") - end - - @impl true - def get_current_user_emails(access_token) do - fetch(access_token, "/user/emails") - end - - @impl true - def get_user(access_token, id) do - fetch(access_token, "/user/#{id}") - end - - @impl true - def get_user_by_username(access_token, username) do - fetch(access_token, "/users/#{username}") - end - - @impl true - def get_repository_permissions(access_token, owner, repo, username) do - fetch(access_token, "/repos/#{owner}/#{repo}/collaborators/#{username}/permission") - end - - @impl true - def list_installations(token, page \\ 1) do - fetch(token, "/user/installations?page=#{page}") - end - - @impl true - def find_installation(token, installation_id, page \\ 1) do - case list_installations(token, page) do - {:ok, %{"installations" => installations}} -> - find_installation_in_list(token, installation_id, installations, page) - - {:error, reason} -> - Logger.error("❌ Failed to find installation #{installation_id}: #{inspect(reason)}") - {:error, reason} - end - end - - defp find_installation_in_list(token, installation_id, installations, page) do - case Enum.find(installations, fn i -> i["id"] == installation_id end) do - nil -> find_installation(token, installation_id, page + 1) - installation -> {:ok, installation} - end - end - - @impl true - def get_installation_token(installation_id) do - with {:ok, %{"token" => token}} <- fetch_with_jwt("/app/installations/#{installation_id}/access_tokens", "POST") do - {:ok, token} - end - end - - @impl true - def get_installation(installation_id) do - with {:ok, %{"token" => token}} <- fetch_with_jwt("/app/installations/#{installation_id}") do - {:ok, token} - end - end - - @impl true - def list_installation_repos(access_token) do - with {:ok, %{"repositories" => repos}} <- - fetch(access_token, "/installation/repositories", "GET") do - {:ok, repos} - end - end - - @impl true - def create_issue_comment(access_token, owner, repo, number, body) do - fetch( - access_token, - "/repos/#{owner}/#{repo}/issues/#{number}/comments", - "POST", - %{body: body} - ) - end - - @impl true - def update_issue_comment(access_token, owner, repo, comment_id, body) do - fetch( - access_token, - "/repos/#{owner}/#{repo}/issues/comments/#{comment_id}", - "PATCH", - %{body: body} - ) - end - - @impl true - def delete_issue_comment(access_token, owner, repo, comment_id) do - access_token - |> fetch( - "/repos/#{owner}/#{repo}/issues/comments/#{comment_id}", - "DELETE" - ) - |> case do - {:error, %Jason.DecodeError{position: 0, token: nil, data: ""}} -> - {:ok, nil} - - res -> - res - end - end - - @impl true - def list_user_repositories(access_token, username, opts \\ []) do - fetch(access_token, "/users/#{username}/repos#{build_query(opts)}") - end - - @impl true - def list_repository_events(access_token, owner, repo, opts \\ []) do - fetch(access_token, "/repos/#{owner}/#{repo}/events#{build_query(opts)}") - end - - @impl true - def list_repository_comments(access_token, owner, repo, opts \\ []) do - fetch(access_token, "/repos/#{owner}/#{repo}/issues/comments#{build_query(opts)}") - end - - @impl true - def list_repository_languages(access_token, owner, repo) do - fetch(access_token, "/repos/#{owner}/#{repo}/languages") - end - - @impl true - def list_repository_contributors(access_token, owner, repo) do - fetch(access_token, "/repos/#{owner}/#{repo}/contributors") - end - - @impl true - def add_labels(access_token, owner, repo, number, labels) do - fetch(access_token, "/repos/#{owner}/#{repo}/issues/#{number}/labels", "POST", %{ - labels: labels - }) - end - - @impl true - def list_labels(access_token, owner, repo, number) do - fetch(access_token, "/repos/#{owner}/#{repo}/issues/#{number}/labels") - end - - @impl true - def create_label(access_token, owner, repo, label) do - fetch(access_token, "/repos/#{owner}/#{repo}/labels", "POST", label) - end - - @impl true - def get_label(access_token, owner, repo, label) do - fetch(access_token, "/repos/#{owner}/#{repo}/labels/#{label}") - end - - @impl true - def remove_label(access_token, owner, repo, label) do - access_token - |> fetch("/repos/#{owner}/#{repo}/labels/#{label}", "DELETE") - |> case do - {:error, %Jason.DecodeError{position: 0, token: nil, data: ""}} -> - {:ok, nil} - - res -> - res - end - end - - @impl true - def remove_label_from_issue(access_token, owner, repo, number, label) do - access_token - |> fetch("/repos/#{owner}/#{repo}/issues/#{number}/labels/#{URI.encode(label)}", "DELETE") - |> case do - {:error, %Jason.DecodeError{position: 0, token: nil, data: ""}} -> - {:ok, nil} - - res -> - res - end - end - - @impl true - def list_user_followers(access_token, username, opts \\ []) do - fetch(access_token, "/users/#{username}/followers#{build_query(opts)}") - end - - @impl true - def list_user_following(access_token, username, opts \\ []) do - fetch(access_token, "/users/#{username}/following#{build_query(opts)}") - end -end diff --git a/lib/algora/integrations/github/command.ex b/lib/algora/integrations/github/command.ex deleted file mode 100644 index 62dad77f4..000000000 --- a/lib/algora/integrations/github/command.ex +++ /dev/null @@ -1,114 +0,0 @@ -defmodule Algora.Github.Command do - @moduledoc false - import Algora.Parser.Combinator - import NimbleParsec - - defmodule Helper do - @moduledoc false - @usage %{ - bounty: "/bounty ", - tip: "/tip @username or /tip @username ", - claim: "/claim (e.g. #123, repo#123, owner/repo#123, or full GitHub URL)", - split: "/split @username", - attempt: "/attempt (e.g. #123, repo#123, owner/repo#123, or full GitHub URL)", - bonus: "/bonus " - } - - def command do - choice([ - bounty_command(), - tip_command(), - claim_command(), - split_command(), - attempt_command(), - bonus_command() - ]) - end - - def bounty_command do - "/bounty" - |> string() - |> ignore() - |> concat(ignore(whitespace())) - |> concat(amount()) - |> tag(:bounty) - |> label(@usage.bounty) - end - - def bonus_command do - "/bonus" - |> string() - |> ignore() - |> concat(ignore(whitespace())) - |> concat(amount()) - |> tag(:bonus) - |> label(@usage.bonus) - end - - def tip_command do - "/tip" - |> string() - |> ignore() - |> concat(ignore(whitespace())) - |> choice([ - amount() |> concat(ignore(whitespace())) |> concat(recipient()), - recipient() |> concat(ignore(whitespace())) |> concat(amount()), - amount(), - recipient() - ]) - |> tag(:tip) - |> label(@usage.tip) - end - - def split_command do - "/split" - |> string() - |> ignore() - |> concat(ignore(whitespace())) - |> concat(recipient()) - |> tag(:split) - |> label(@usage.split) - end - - def claim_command do - "/claim" - |> string() - |> ignore() - |> concat(ignore(whitespace())) - |> concat(ticket_ref()) - |> tag(:claim) - |> label(@usage.claim) - end - - def attempt_command do - "/attempt" - |> string() - |> ignore() - |> concat(ignore(whitespace())) - |> concat(ticket_ref()) - |> tag(:attempt) - |> label(@usage.attempt) - end - - def commands do - repeat( - choice([ - ignore(utf8_string([not: ?/], min: 1)), - command(), - ignore(string("/")) - ]) - ) - end - end - - defparsec(:parse_raw, Helper.commands()) - - def parse(nil), do: {:ok, []} - - def parse(input) when is_binary(input) do - case parse_raw(input) do - {:ok, parsed, _, _, _, _} -> {:ok, Enum.reject(parsed, &is_nil/1)} - {:error, reason, _, _, _, _} -> {:error, reason} - end - end -end diff --git a/lib/algora/integrations/github/crypto.ex b/lib/algora/integrations/github/crypto.ex deleted file mode 100644 index 983c54c2e..000000000 --- a/lib/algora/integrations/github/crypto.ex +++ /dev/null @@ -1,24 +0,0 @@ -defmodule Algora.Github.Crypto do - @moduledoc false - alias Algora.Github - - @doc """ - Generates a JWT (JSON Web Token) for GitHub App authentication. - - ## Returns - - `{:ok, jwt, claims}` on success, `{:error, reason}` on failure - """ - @spec generate_jwt() :: {:ok, String.t(), map()} | {:error, any()} - def generate_jwt do - payload = %{ - "iat" => System.system_time(:second), - "exp" => System.system_time(:second) + 600, - "iss" => Github.client_id() - } - - signer = Joken.Signer.create("RS256", %{"pem" => Github.private_key()}) - - Joken.generate_and_sign(%{}, payload, signer) - end -end diff --git a/lib/algora/integrations/github/github.ex b/lib/algora/integrations/github/github.ex deleted file mode 100644 index a2ce1f753..000000000 --- a/lib/algora/integrations/github/github.ex +++ /dev/null @@ -1,173 +0,0 @@ -defmodule Algora.Github do - @moduledoc false - @behaviour Algora.Github.Behaviour - - require Logger - - @type token :: String.t() - - def client_id, do: Algora.config([:github, :client_id]) - def secret, do: Algora.config([:github, :client_secret]) - def app_handle, do: Algora.config([:github, :app_handle]) - def app_id, do: Algora.config([:github, :app_id]) - def webhook_secret, do: Algora.config([:github, :webhook_secret]) - def private_key, do: [:github, :private_key] |> Algora.config() |> String.replace("\\n", "\n") - def pat, do: Algora.config([:github, :pat]) - def pat_enabled, do: Algora.config([:github, :pat_enabled]) - def bot_handle, do: Algora.config([:github, :bot_handle]) - - def install_url_base, do: "https://github.com/apps/#{app_handle()}/installations" - def install_url_new, do: "#{install_url_base()}/new" - def install_url_select_target, do: "#{install_url_base()}/select_target" - - defp oauth_state_ttl, do: Algora.config([:github, :oauth_state_ttl]) - defp oauth_state_salt, do: Algora.config([:github, :oauth_state_salt]) - - def generate_oauth_state(data) do - Phoenix.Token.sign(AlgoraWeb.Endpoint, oauth_state_salt(), data, max_age: oauth_state_ttl()) - end - - def verify_oauth_state(token) do - Phoenix.Token.verify(AlgoraWeb.Endpoint, oauth_state_salt(), token, max_age: oauth_state_ttl()) - end - - def authorize_url(data \\ nil) do - redirect_uri = "#{AlgoraWeb.Endpoint.url()}/callbacks/github/oauth" - - query = - URI.encode_query( - client_id: client_id(), - state: generate_oauth_state(data), - redirect_uri: redirect_uri - ) - - "https://github.com/login/oauth/authorize?#{query}" - end - - defp client, do: Application.get_env(:algora, :github_client, Algora.Github.Client) - - def try_without_installation(function, args) do - if pat_enabled() do - apply(function, [pat() | args]) - else - {_, module} = Function.info(function, :module) - {_, name} = Function.info(function, :name) - function_name = String.trim_leading("#{module}.#{name}", "Elixir.") - - formatted_args = - Enum.map_join(args, ", ", fn - arg when is_binary(arg) -> "\"#{arg}\"" - arg -> "#{arg}" - end) - - Logger.warning(""" - App installation not found and GITHUB_PAT_ENABLED is false, skipping Github call: - #{function_name}(#{formatted_args}) - """) - end - end - - @impl true - def get_delivery(delivery_id), do: client().get_delivery(delivery_id) - - @impl true - def list_deliveries(opts \\ []), do: client().list_deliveries(opts) - - @impl true - def redeliver(delivery_id), do: client().redeliver(delivery_id) - - @impl true - def get_repository(token, owner, repo), do: client().get_repository(token, owner, repo) - - @impl true - def get_repository(token, id), do: client().get_repository(token, id) - - @impl true - def get_issue(token, owner, repo, number), do: client().get_issue(token, owner, repo, number) - - @impl true - def get_pull_request(token, owner, repo, number), do: client().get_pull_request(token, owner, repo, number) - - @impl true - def get_current_user(token), do: client().get_current_user(token) - - @impl true - def get_current_user_emails(token), do: client().get_current_user_emails(token) - - @impl true - def get_user(token, id), do: client().get_user(token, id) - - @impl true - def get_user_by_username(token, username), do: client().get_user_by_username(token, username) - - @impl true - def get_repository_permissions(token, owner, repo, username), - do: client().get_repository_permissions(token, owner, repo, username) - - @impl true - def list_installations(token, page \\ 1), do: client().list_installations(token, page) - - @impl true - def find_installation(token, installation_id, page \\ 1), do: client().find_installation(token, installation_id, page) - - @impl true - def get_installation_token(installation_id), do: client().get_installation_token(installation_id) - - @impl true - def get_installation(installation_id), do: client().get_installation(installation_id) - @impl true - def list_installation_repos(token), do: client().list_installation_repos(token) - - @impl true - def create_issue_comment(token, owner, repo, number, body), - do: client().create_issue_comment(token, owner, repo, number, body) - - @impl true - def update_issue_comment(token, owner, repo, comment_id, body), - do: client().update_issue_comment(token, owner, repo, comment_id, body) - - @impl true - def delete_issue_comment(token, owner, repo, comment_id), - do: client().delete_issue_comment(token, owner, repo, comment_id) - - @impl true - def list_user_repositories(token, username, opts \\ []), do: client().list_user_repositories(token, username, opts) - - @impl true - def list_repository_events(token, owner, repo, opts \\ []), - do: client().list_repository_events(token, owner, repo, opts) - - @impl true - def list_repository_comments(token, owner, repo, opts \\ []), - do: client().list_repository_comments(token, owner, repo, opts) - - @impl true - def list_repository_languages(token, owner, repo), do: client().list_repository_languages(token, owner, repo) - @impl true - def list_repository_contributors(token, owner, repo), do: client().list_repository_contributors(token, owner, repo) - - @impl true - def add_labels(token, owner, repo, number, labels), do: client().add_labels(token, owner, repo, number, labels) - - @impl true - def list_labels(token, owner, repo, number), do: client().list_labels(token, owner, repo, number) - - @impl true - def create_label(token, owner, repo, label), do: client().create_label(token, owner, repo, label) - - @impl true - def get_label(token, owner, repo, label), do: client().get_label(token, owner, repo, label) - - @impl true - def remove_label(token, owner, repo, label), do: client().remove_label(token, owner, repo, label) - - @impl true - def remove_label_from_issue(token, owner, repo, number, label), - do: client().remove_label_from_issue(token, owner, repo, number, label) - - @impl true - def list_user_followers(token, username, opts \\ []), do: client().list_user_followers(token, username, opts) - - @impl true - def list_user_following(token, username, opts \\ []), do: client().list_user_following(token, username, opts) -end diff --git a/lib/algora/integrations/github/oauth.ex b/lib/algora/integrations/github/oauth.ex deleted file mode 100644 index b399da563..000000000 --- a/lib/algora/integrations/github/oauth.ex +++ /dev/null @@ -1,66 +0,0 @@ -defmodule Algora.Github.OAuth do - @moduledoc false - alias Algora.Github - - require Logger - - def exchange_access_token(opts) do - code = Keyword.fetch!(opts, :code) - state = Keyword.fetch!(opts, :state) - - state - |> fetch_exchange_response(code) - |> fetch_user_info() - |> fetch_emails() - end - - defp fetch_exchange_response(state, code) do - query = [ - state: state, - code: code, - client_id: Github.client_id(), - client_secret: Github.secret() - ] - - url = "https://github.com/login/oauth/access_token?#{URI.encode_query(query)}" - headers = [{"Content-Type", "application/json"}, {"accept", "application/json"}] - request = Finch.build("POST", url, headers) - - with {:ok, %Finch.Response{body: body}} <- Finch.request(request, Algora.Finch), - {:ok, %{"access_token" => token}} <- Jason.decode(body) do - {:ok, token} - else - {:ok, %{"error" => error}} -> - Logger.error("failed GitHub exchange #{inspect(error)}") - {:error, error} - - {:error, _reason} = err -> - err - end - end - - defp fetch_user_info({:error, _reason} = error), do: error - - defp fetch_user_info({:ok, token}) do - case Github.get_current_user(token) do - {:ok, info} -> {:ok, %{info: info, token: token}} - {:error, _reason} = err -> err - end - end - - defp fetch_emails({:error, _} = err), do: err - - defp fetch_emails({:ok, user}) do - case Github.get_current_user_emails(user.token) do - {:ok, emails} -> - {:ok, Map.merge(user, %{primary_email: primary_email(emails), emails: emails})} - - {:error, _reason} = err -> - err - end - end - - defp primary_email(emails) do - Enum.find(emails, fn email -> email["primary"] end)["email"] || Enum.at(emails, 0) - end -end diff --git a/lib/algora/integrations/github/poller/deliveries.ex b/lib/algora/integrations/github/poller/deliveries.ex deleted file mode 100644 index 8c26ded94..000000000 --- a/lib/algora/integrations/github/poller/deliveries.ex +++ /dev/null @@ -1,174 +0,0 @@ -defmodule Algora.Github.Poller.Deliveries do - @moduledoc false - use GenServer - - import Ecto.Query, warn: false - - alias Algora.Github - alias Algora.Repo - alias Algora.Sync - - require Logger - - @per_page 100 - @poll_interval to_timeout(second: 10) - - # Client API - def start_link(opts) do - GenServer.start_link(__MODULE__, opts) - end - - def pause(pid) do - GenServer.cast(pid, :pause) - end - - def resume(pid) do - GenServer.cast(pid, :resume) - end - - # Server callbacks - @impl true - def init(opts) do - provider = Keyword.fetch!(opts, :provider) - - {:ok, - %{ - provider: provider, - cursor: nil, - paused: not Algora.config([:auto_start_pollers]) - }, {:continue, :setup}} - end - - @impl true - def handle_continue(:setup, state) do - {:ok, cursor} = get_or_create_cursor(state.provider, "deliveries") - schedule_poll() - - {:noreply, %{state | cursor: cursor}} - end - - @impl true - def handle_info(:poll, %{paused: true} = state) do - {:noreply, state} - end - - @impl true - def handle_info(:poll, state) do - {:ok, new_state} = poll(state) - schedule_poll() - {:noreply, new_state} - end - - @impl true - def handle_cast(:pause, state) do - {:noreply, %{state | paused: true}} - end - - @impl true - def handle_cast(:resume, %{paused: true} = state) do - schedule_poll() - {:noreply, %{state | paused: false}} - end - - @impl true - def handle_cast(:resume, state) do - {:noreply, state} - end - - @impl true - def handle_call(:get_provider, _from, state) do - {:reply, state.provider, state} - end - - @impl true - def handle_call(:is_paused, _from, state) do - {:reply, state.paused, state} - end - - defp schedule_poll do - Process.send_after(self(), :poll, @poll_interval) - end - - def poll(state) do - with {:ok, deliveries} <- fetch_deliveries(state), - {:ok, updated_cursor} <- process_batch(deliveries, state) do - {:ok, %{state | cursor: updated_cursor}} - else - {:error, reason} -> - Logger.error("Failed to fetch deliveries: #{inspect(reason)}") - {:ok, state} - end - end - - defp process_batch([], state), do: {:ok, state.cursor} - - defp process_batch(deliveries, state) do - Repo.tx(fn -> - with :ok <- process_deliveries(deliveries, state) do - update_last_polled(state.cursor, List.first(deliveries)) - end - end) - end - - defp process_deliveries(deliveries, state) do - Enum.reduce_while(deliveries, :ok, fn delivery, _acc -> - case process_delivery(delivery, state) do - {:ok, _} -> {:cont, :ok} - error -> {:halt, error} - end - end) - end - - defp fetch_deliveries(_state) do - # TODO: paginate via the next and previous page cursors in the link header - Github.list_deliveries(per_page: @per_page) - end - - defp get_or_create_cursor(provider, resource) do - case Sync.get_sync_cursor(provider, resource) do - nil -> - Sync.create_sync_cursor(%{provider: provider, resource: resource, timestamp: DateTime.utc_now()}) - - sync_cursor -> - {:ok, sync_cursor} - end - end - - defp update_last_polled(sync_cursor, %{"delivered_at" => timestamp}) do - with {:ok, timestamp, _} <- DateTime.from_iso8601(timestamp), - {:ok, cursor} <- - Sync.update_sync_cursor(sync_cursor, %{ - timestamp: timestamp, - last_polled_at: DateTime.utc_now() - }) do - {:ok, cursor} - else - {:error, reason} -> Logger.error("Failed to update sync cursor: #{inspect(reason)}") - end - end - - defp process_delivery(delivery, state) do - case DateTime.from_iso8601(delivery["delivered_at"]) do - {:ok, delivered_at, _} -> - skip_reason = - cond do - not DateTime.after?(delivered_at, state.cursor.timestamp) -> :already_processed - delivery["status_code"] < 400 -> :status_ok - true -> nil - end - - if skip_reason do - {:ok, nil} - else - %{delivery: delivery} - |> Github.Poller.DeliveryConsumer.new() - |> Oban.insert() - end - - {:error, reason} -> - Logger.error("Failed to parse delivery: #{inspect(delivery)}. Reason: #{inspect(reason)}") - - {:ok, nil} - end - end -end diff --git a/lib/algora/integrations/github/poller/delivery_consumer.ex b/lib/algora/integrations/github/poller/delivery_consumer.ex deleted file mode 100644 index 335498102..000000000 --- a/lib/algora/integrations/github/poller/delivery_consumer.ex +++ /dev/null @@ -1,31 +0,0 @@ -defmodule Algora.Github.Poller.DeliveryConsumer do - @moduledoc false - use Oban.Worker, queue: :background - - import Ecto.Query - - alias Algora.Github - alias Algora.Repo - - require Logger - - @max_attempts 3 - - @impl Oban.Worker - def perform(%Oban.Job{args: %{"delivery" => delivery} = _args}) do - attempts_count = - Repo.one( - from(j in "oban_jobs", - where: fragment("(args->>'delivery')::jsonb->>'guid' = ?", ^delivery["guid"]), - select: count(j.id) - ) - ) || 0 - - if attempts_count <= @max_attempts do - Github.redeliver(delivery["id"]) - else - Algora.Activities.alert("Max attempts reached for delivery #{delivery["id"]}", :error) - :discard - end - end -end diff --git a/lib/algora/integrations/github/poller/delivery_supervisor.ex b/lib/algora/integrations/github/poller/delivery_supervisor.ex deleted file mode 100644 index e2a110439..000000000 --- a/lib/algora/integrations/github/poller/delivery_supervisor.ex +++ /dev/null @@ -1,85 +0,0 @@ -defmodule Algora.Github.Poller.DeliverySupervisor do - @moduledoc false - use DynamicSupervisor - - alias Algora.Github.Poller.Deliveries, as: DeliveryPoller - alias Algora.Sync - - require Logger - - # Client API - def start_link(init_arg) do - DynamicSupervisor.start_link(__MODULE__, init_arg, name: __MODULE__) - end - - @impl DynamicSupervisor - def init(_init_arg) do - DynamicSupervisor.init(strategy: :one_for_one) - end - - def start_children do - Sync.list_cursors() - |> Task.async_stream( - fn cursor -> add_provider(cursor.provider) end, - max_concurrency: 100, - ordered: false - ) - |> Stream.run() - - :ok - end - - def add_provider(provider \\ "github", opts \\ []) do - DynamicSupervisor.start_child(__MODULE__, {DeliveryPoller, [provider: provider] ++ opts}) - end - - def terminate_child(provider) do - case find_child(provider) do - {_id, pid, _type, _modules} -> DynamicSupervisor.terminate_child(__MODULE__, pid) - nil -> {:error, :not_found} - end - end - - def remove_provider(provider \\ "github") do - with :ok <- terminate_child(provider), - {:ok, _cursor} <- Sync.delete_sync_cursor(provider, "deliveries") do - :ok - end - end - - def find_child(provider) do - Enum.find(which_children(), fn {_, pid, _, _} -> - GenServer.call(pid, :get_provider) == provider - end) - end - - def pause(provider) do - provider - |> find_child() - |> case do - {_, pid, _, _} -> DeliveryPoller.pause(pid) - nil -> {:error, :not_found} - end - end - - def resume(provider) do - provider - |> find_child() - |> case do - {_, pid, _, _} -> DeliveryPoller.resume(pid) - nil -> {:error, :not_found} - end - end - - def pause_all do - Enum.each(which_children(), fn {_, pid, _, _} -> DeliveryPoller.pause(pid) end) - end - - def resume_all do - Enum.each(which_children(), fn {_, pid, _, _} -> DeliveryPoller.resume(pid) end) - end - - def which_children do - DynamicSupervisor.which_children(__MODULE__) - end -end diff --git a/lib/algora/integrations/github/poller/root_supervisor.ex b/lib/algora/integrations/github/poller/root_supervisor.ex deleted file mode 100644 index 4b6100523..000000000 --- a/lib/algora/integrations/github/poller/root_supervisor.ex +++ /dev/null @@ -1,31 +0,0 @@ -defmodule Algora.Github.Poller.RootSupervisor do - @moduledoc false - use Supervisor - - alias Algora.Github.Poller.DeliverySupervisor - alias Algora.Github.Poller.SearchSupervisor - - def start_link(init_arg) do - Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__) - end - - @impl true - def init(_init_arg) do - children = [ - SearchSupervisor, - DeliverySupervisor, - Supervisor.child_spec( - {Task, &SearchSupervisor.start_children/0}, - id: :search_supervisor, - restart: :transient - ), - Supervisor.child_spec( - {Task, &DeliverySupervisor.start_children/0}, - id: :delivery_supervisor, - restart: :transient - ) - ] - - Supervisor.init(children, strategy: :rest_for_one) - end -end diff --git a/lib/algora/integrations/github/poller/search.ex b/lib/algora/integrations/github/poller/search.ex deleted file mode 100644 index b14672a00..000000000 --- a/lib/algora/integrations/github/poller/search.ex +++ /dev/null @@ -1,281 +0,0 @@ -defmodule Algora.Github.Poller.Search do - @moduledoc false - use GenServer - - import Ecto.Query, warn: false - - alias Algora.Cloud - alias Algora.Github - alias Algora.Github.Command - alias Algora.Parser - alias Algora.Repo - alias Algora.Search - alias Algora.Util - alias Algora.Workspace - - require Logger - - @per_page 10 - @poll_interval to_timeout(second: 3) - - # Client API - def start_link(opts) do - GenServer.start_link(__MODULE__, opts) - end - - def pause(pid) do - GenServer.cast(pid, :pause) - end - - def resume(pid) do - GenServer.cast(pid, :resume) - end - - # Server callbacks - @impl true - def init(opts) do - provider = Keyword.fetch!(opts, :provider) - - {:ok, - %{ - provider: provider, - cursor: nil, - paused: not Algora.config([:auto_start_pollers]) - }, {:continue, :setup}} - end - - @impl true - def handle_continue(:setup, state) do - {:ok, cursor} = get_or_create_cursor() - schedule_poll() - - {:noreply, %{state | cursor: cursor}} - end - - @impl true - def handle_info(:poll, %{paused: true} = state) do - {:noreply, state} - end - - @impl true - def handle_info(:poll, state) do - {:ok, new_state} = poll(state) - schedule_poll() - {:noreply, new_state} - end - - @impl true - def handle_cast(:pause, state) do - {:noreply, %{state | paused: true}} - end - - @impl true - def handle_cast(:resume, %{paused: true} = state) do - schedule_poll() - {:noreply, %{state | paused: false}} - end - - @impl true - def handle_cast(:resume, state) do - {:noreply, state} - end - - @impl true - def handle_call(:get_provider, _from, state) do - {:reply, state.provider, state} - end - - @impl true - def handle_call(:is_paused, _from, state) do - {:reply, state.paused, state} - end - - defp schedule_poll do - Process.send_after(self(), :poll, @poll_interval) - end - - def poll(state) do - with {:ok, tickets} <- fetch_tickets(state), - if(length(tickets) > 0, do: Logger.debug("Processing #{length(tickets)} tickets")), - {:ok, updated_cursor} <- process_batch(tickets, state) do - {:ok, %{state | cursor: updated_cursor}} - else - {:error, reason} -> - Logger.error("Failed to fetch tickets: #{inspect(reason)}") - {:ok, state} - end - end - - defp process_batch([], state), do: {:ok, state.cursor} - - defp process_batch(tickets, state) do - Repo.tx(fn -> - with :ok <- process_tickets(tickets, state) do - timestamps = - tickets - |> Enum.flat_map(fn ticket -> ticket["comments"]["nodes"] end) - |> Enum.flat_map(fn comment -> - case DateTime.from_iso8601(comment["updatedAt"]) do - {:ok, updated_at, _} -> [updated_at] - _ -> [] - end - end) - - fallback_timestamp = DateTime.truncate(DateTime.utc_now(), :second) - - timestamp = - case timestamps do - [] -> fallback_timestamp - timestamps -> Enum.max(timestamps) - end - - if DateTime.after?(timestamp, state.cursor.timestamp) do - update_last_polled(state.cursor, timestamp) - else - update_last_polled(state.cursor, fallback_timestamp) - end - end - end) - end - - defp process_tickets(tickets, state) do - Enum.reduce_while(tickets, :ok, fn ticket, _acc -> - case process_ticket(ticket, state) do - {:ok, _} -> {:cont, :ok} - error -> {:halt, error} - end - end) - end - - defp fetch_tickets(state) do - case search("bounty", since: DateTime.to_iso8601(state.cursor.timestamp), per_page: @per_page) do - {:ok, %{"data" => %{"search" => %{"nodes" => tickets}}}} -> - {:ok, tickets} - - _ -> - {:error, :no_tickets_found} - end - end - - defp get_or_create_cursor do - case Search.get_search_cursor("github") do - nil -> - Search.create_search_cursor(%{provider: "github", timestamp: DateTime.utc_now()}) - - search_cursor -> - {:ok, search_cursor} - end - end - - defp update_last_polled(search_cursor, timestamp) do - case Search.update_search_cursor(search_cursor, %{ - timestamp: timestamp, - last_polled_at: DateTime.utc_now() - }) do - {:ok, cursor} -> {:ok, cursor} - {:error, reason} -> Logger.error("Failed to update search cursor: #{inspect(reason)}") - end - end - - defp process_ticket(%{"updatedAt" => updated_at, "url" => url} = ticket, state) do - with {:ok, updated_at, _} <- DateTime.from_iso8601(updated_at), - {:ok, [ticket_ref: ticket_ref], _, _, _, _} <- Parser.full_ticket_ref(url) do - Logger.info("Latency: #{DateTime.diff(DateTime.utc_now(), updated_at, :second)}s") - - installation_token = - if installation_id = Workspace.get_installation_id_by_owner(ticket_ref[:owner]) do - case Github.get_installation_token(installation_id) do - {:ok, token} -> token - _error -> nil - end - end - - ticket["comments"]["nodes"] - |> Enum.reject(fn comment -> - already_processed? = - case DateTime.from_iso8601(comment["updatedAt"]) do - {:ok, comment_updated_at, _} -> - not DateTime.after?(comment_updated_at, state.cursor.timestamp) - - {:error, _} -> - true - end - - bot? = comment["author"]["login"] == Github.bot_handle() - - blocked? = comment["author"]["login"] in Algora.Settings.get_blocked_users() - - not is_nil(installation_token) or bot? or already_processed? or blocked? - end) - |> Enum.flat_map(fn comment -> - case Command.parse(comment["body"]) do - {:ok, [command | _]} -> [{comment, command}] - _ -> [] - end - end) - |> Enum.reduce_while(:ok, fn {comment, command}, _acc -> - res = - %{ - comment: comment, - command: Util.term_to_base64(command), - ticket_ref: Util.term_to_base64(ticket_ref) - } - |> Github.Poller.SearchConsumer.new() - |> Oban.insert() - - case res do - {:ok, _job} -> {:cont, :ok} - error -> {:halt, error} - end - end) - else - {:error, reason} -> - Logger.error("Failed to parse commands from ticket: #{inspect(ticket)}. Reason: #{inspect(reason)}") - - :ok - end - end - - defp search(q, opts) do - per_page = opts[:per_page] || @per_page - - search_query = - if since = opts[:since] do - "#{q} in:comment is:issue sort:updated-asc updated:>#{since}" - else - "#{q} in:comment is:issue sort:updated-asc" - end - - query = """ - query issues($search_query: String!) { - search(first: #{per_page}, type: ISSUE, query: $search_query) { - issueCount - pageInfo { - hasNextPage - } - nodes { - __typename - ... on Issue { - url - updatedAt - comments(first: 3, orderBy: {field: UPDATED_AT, direction: DESC}) { - nodes { - updatedAt - databaseId - author { - login - } - body - url - } - } - } - } - } - } - """ - - body = %{query: query, variables: %{search_query: search_query}} - Github.Client.fetch(Cloud.token!(), "/graphql", "POST", body) - end -end diff --git a/lib/algora/integrations/github/poller/search_consumer.ex b/lib/algora/integrations/github/poller/search_consumer.ex deleted file mode 100644 index ea55c527c..000000000 --- a/lib/algora/integrations/github/poller/search_consumer.ex +++ /dev/null @@ -1,87 +0,0 @@ -defmodule Algora.Github.Poller.SearchConsumer do - @moduledoc false - use Oban.Worker, queue: :background - - alias Algora.Accounts - alias Algora.Bounties - alias Algora.Util - - require Logger - - @impl Oban.Worker - def perform(%Oban.Job{ - args: %{"comment" => comment, "command" => encoded_command, "ticket_ref" => encoded_ticket_ref} = _args - }) do - command = Util.base64_to_term!(encoded_command) - ticket_ref = Util.base64_to_term!(encoded_ticket_ref) - - if ticket_ref[:owner] in Algora.Settings.get_blocked_users() or - comment["author"]["login"] in Algora.Settings.get_blocked_users() do - raise "blocked" - end - - run_command(command, ticket_ref, comment) - end - - defp run_command({:tip, args}, ticket_ref, _comment) do - Algora.Activities.alert("Creating global tip intent for #{inspect(args[:amount])}: #{inspect(ticket_ref)}", :info) - - Bounties.create_tip_intent(%{ - recipient: args[:recipient], - amount: args[:amount], - ticket_ref: %{ - owner: ticket_ref[:owner], - repo: ticket_ref[:repo], - number: ticket_ref[:number] - } - }) - end - - defp run_command({:bounty, args}, ticket_ref, comment) do - case Accounts.fetch_user_by( - provider: "github", - provider_login: to_string(comment["author"]["login"]) - ) do - {:ok, user} -> - strategy = :set - # strategy = - # case Repo.get_by(CommandResponse, - # provider: "github", - # provider_command_id: to_string(comment["databaseId"]), - # command_source: :comment - # ) do - # nil -> :increase - # _ -> :set - # end - - Algora.Activities.alert("Creating global bounty for #{inspect(args[:amount])}: #{inspect(ticket_ref)}", :info) - - Bounties.create_bounty( - %{ - creator: user, - owner: user, - amount: args[:amount], - ticket_ref: %{ - owner: ticket_ref[:owner], - repo: ticket_ref[:repo], - number: ticket_ref[:number] - } - }, - command_id: comment["databaseId"], - command_source: :comment, - strategy: strategy - ) - - {:error, _reason} = error -> - Logger.error("Failed to create bounty: #{inspect(error)}") - error - end - end - - defp run_command(command, ticket_ref, comment) do - Algora.Activities.alert( - "Received unknown command: #{inspect(command)}. Ticket ref: #{inspect(ticket_ref)}. URL: #{comment["url"]}", - :error - ) - end -end diff --git a/lib/algora/integrations/github/poller/search_supervisor.ex b/lib/algora/integrations/github/poller/search_supervisor.ex deleted file mode 100644 index 25d27a6b4..000000000 --- a/lib/algora/integrations/github/poller/search_supervisor.ex +++ /dev/null @@ -1,85 +0,0 @@ -defmodule Algora.Github.Poller.SearchSupervisor do - @moduledoc false - use DynamicSupervisor - - alias Algora.Github.Poller.Search, as: SearchPoller - alias Algora.Search - - require Logger - - # Client API - def start_link(init_arg) do - DynamicSupervisor.start_link(__MODULE__, init_arg, name: __MODULE__) - end - - @impl DynamicSupervisor - def init(_init_arg) do - DynamicSupervisor.init(strategy: :one_for_one) - end - - def start_children do - Search.list_cursors() - |> Task.async_stream( - fn cursor -> add_provider(cursor.provider) end, - max_concurrency: 100, - ordered: false - ) - |> Stream.run() - - :ok - end - - def add_provider(provider \\ "github", opts \\ []) do - DynamicSupervisor.start_child(__MODULE__, {SearchPoller, [provider: provider] ++ opts}) - end - - def terminate_child(provider) do - case find_child(provider) do - {_id, pid, _type, _modules} -> DynamicSupervisor.terminate_child(__MODULE__, pid) - nil -> {:error, :not_found} - end - end - - def remove_provider(provider \\ "github") do - with :ok <- terminate_child(provider), - {:ok, _cursor} <- Search.delete_search_cursor(provider) do - :ok - end - end - - def find_child(provider) do - Enum.find(which_children(), fn {_, pid, _, _} -> - GenServer.call(pid, :get_provider) == provider - end) - end - - def pause(provider) do - provider - |> find_child() - |> case do - {_, pid, _, _} -> SearchPoller.pause(pid) - nil -> {:error, :not_found} - end - end - - def resume(provider) do - provider - |> find_child() - |> case do - {_, pid, _, _} -> SearchPoller.resume(pid) - nil -> {:error, :not_found} - end - end - - def pause_all do - Enum.each(which_children(), fn {_, pid, _, _} -> SearchPoller.pause(pid) end) - end - - def resume_all do - Enum.each(which_children(), fn {_, pid, _, _} -> SearchPoller.resume(pid) end) - end - - def which_children do - DynamicSupervisor.which_children(__MODULE__) - end -end diff --git a/lib/algora/integrations/github/stargazer.ex b/lib/algora/integrations/github/stargazer.ex deleted file mode 100644 index 7c32e80a7..000000000 --- a/lib/algora/integrations/github/stargazer.ex +++ /dev/null @@ -1,47 +0,0 @@ -defmodule Algora.Stargazer do - @moduledoc false - use GenServer - - alias Algora.Github - alias AlgoraWeb.Constants - - require Logger - - @poll_interval to_timeout(minute: 10) - - def start_link(cmd) do - GenServer.start_link(__MODULE__, cmd, name: __MODULE__) - end - - @impl true - def init(cmd) do - {:ok, schedule_fetch(%{count: nil}, cmd, 0)} - end - - @impl true - def handle_info(cmd, state) do - count = fetch_count() || state.count - {:noreply, schedule_fetch(%{state | count: count}, cmd)} - end - - defp schedule_fetch(state, cmd, after_ms \\ @poll_interval) do - Process.send_after(self(), cmd, after_ms) - state - end - - def fetch_count do - case Github.Client.fetch(nil, Constants.get(:github_repo_api_url)) do - {:ok, %{"stargazers_count" => count}} -> count - _ -> nil - end - end - - def count do - GenServer.call(__MODULE__, :get_count) - end - - @impl true - def handle_call(:get_count, _from, state) do - {:reply, state.count, state} - end -end diff --git a/lib/algora/integrations/github/token_pool.ex b/lib/algora/integrations/github/token_pool.ex deleted file mode 100644 index 68c15a570..000000000 --- a/lib/algora/integrations/github/token_pool.ex +++ /dev/null @@ -1,68 +0,0 @@ -defmodule Algora.Github.TokenPool do - @moduledoc false - use GenServer - - alias Algora.Accounts - alias Algora.Github - - require Logger - - @pool_size 100 - - # Client API - def start_link(_opts) do - GenServer.start_link(__MODULE__, :ok, name: __MODULE__) - end - - def get_token do - case maybe_get_token() do - token when is_binary(token) -> token - _ -> get_token() - end - end - - def maybe_get_token do - GenServer.call(__MODULE__, :maybe_get_token, 10_000) - end - - def refresh_tokens do - GenServer.cast(__MODULE__, :refresh_tokens) - end - - # Server callbacks - @impl true - def init(:ok) do - {:ok, %{tokens: nil, current_token_index: nil}, {:continue, :setup}} - end - - @impl true - def handle_continue(:setup, state) do - tokens = Accounts.get_random_access_tokens(@pool_size) - {:noreply, %{state | tokens: tokens, current_token_index: 0}} - end - - @impl true - def handle_call(:maybe_get_token, _from, %{current_token_index: index, tokens: tokens} = state) do - token = Enum.at(tokens, index) - - if token == nil do - {:reply, Github.pat(), state} - else - next_index = rem(index + 1, length(tokens)) - if next_index == 0, do: refresh_tokens() - - case Github.get_current_user(token) do - {:ok, _} -> - {:reply, token, %{state | current_token_index: next_index}} - - _ -> - {:reply, nil, %{state | current_token_index: next_index}} - end - end - end - - @impl true - def handle_cast(:refresh_tokens, state) do - {:noreply, %{state | tokens: Accounts.get_random_access_tokens(@pool_size), current_token_index: 0}} - end -end diff --git a/lib/algora/integrations/github/webhook.ex b/lib/algora/integrations/github/webhook.ex deleted file mode 100644 index a6e233f85..000000000 --- a/lib/algora/integrations/github/webhook.ex +++ /dev/null @@ -1,99 +0,0 @@ -defmodule Algora.Github.Webhook do - @moduledoc false - require Logger - - @enforce_keys [ - # Webhook headers - :hook_id, - :event, - :delivery, - :signature, - :signature_256, - :user_agent, - :installation_target_type, - :installation_target_id, - # Webhook payload - :payload, - # Convenience fields - :event_action, - :body, - :author - ] - - defstruct @enforce_keys - - def new(conn) do - secret = Algora.Github.webhook_secret() - - with {:ok, headers} <- parse_headers(conn), - {:ok, payload, conn} = Plug.Conn.read_body(conn), - {:ok, _} <- verify_signature(headers.signature_256, payload, secret), - {:ok, webhook} <- build_webhook(headers, payload) do - {:ok, webhook, conn} - end - end - - defp build_webhook(headers, payload) do - payload = Jason.decode!(payload) - - params = - headers - |> Map.put(:payload, payload) - |> Map.put(:event_action, headers[:event] <> "." <> payload["action"]) - |> Map.put(:body, get_body(headers[:event], payload)) - |> Map.put(:author, get_author(headers[:event], payload)) - - {:ok, struct!(__MODULE__, params)} - rescue - error -> - {:error, error} - end - - defp parse_headers(conn) do - required_headers = [ - {"x-github-hook-id", :hook_id}, - {"x-github-event", :event}, - {"x-github-delivery", :delivery}, - {"x-hub-signature", :signature}, - {"x-hub-signature-256", :signature_256}, - {"user-agent", :user_agent}, - {"x-github-hook-installation-target-type", :installation_target_type}, - {"x-github-hook-installation-target-id", :installation_target_id} - ] - - headers = Enum.map(required_headers, fn {header, key} -> {key, get_header(conn, header)} end) - - case Enum.find(headers, fn {_, value} -> is_nil(value) end) do - {_key, nil} -> {:error, :missing_header} - nil -> {:ok, Map.new(headers)} - end - end - - def verify_signature(signature, payload, secret) do - sig = generate_signature(payload, secret) - - if Plug.Crypto.secure_compare("sha256=" <> sig, signature) do - {:ok, nil} - else - {:error, :signature_mismatch} - end - end - - defp generate_signature(payload, secret) do - :hmac |> :crypto.mac(:sha256, secret, payload) |> Base.encode16(case: :lower) - end - - defp get_header(conn, header) do - conn |> Plug.Conn.get_req_header(header) |> List.first() - end - - def entity_key("issues"), do: "issue" - def entity_key("issue_comment"), do: "comment" - def entity_key("pull_request"), do: "pull_request" - def entity_key("pull_request_review"), do: "review" - def entity_key("pull_request_review_comment"), do: "comment" - def entity_key(_event), do: nil - - defp get_author(event, payload), do: get_in(payload, ["#{entity_key(event)}", "user"]) - defp get_body(event, payload), do: get_in(payload, ["#{entity_key(event)}", "body"]) -end diff --git a/lib/algora/integrations/tunnel.ex b/lib/algora/integrations/tunnel.ex deleted file mode 100644 index 8dce9dfb6..000000000 --- a/lib/algora/integrations/tunnel.ex +++ /dev/null @@ -1,57 +0,0 @@ -defmodule Algora.Tunnel do - @moduledoc false - use GenServer - - require Logger - - def start_link(arg) do - GenServer.start_link(__MODULE__, arg, name: __MODULE__) - end - - @impl true - def init(name) do - {:ok, %{name: name}, {:continue, :start_tunnel}} - end - - @impl true - def handle_continue(:start_tunnel, %{name: name} = state) do - start_tunnel(name) - {:noreply, state} - end - - @impl true - def handle_info({port, {:data, {:eol, line}}}, %{port: port} = state) do - Logger.debug("CLOUDFLARE #{line}") - {:noreply, state} - end - - @impl true - def handle_info({port, {:exit_status, status}}, %{port: port} = state) do - Logger.error("Tunnel process exited with status #{status}") - {:stop, :tunnel_failed, state} - end - - defp start_tunnel(name) do - http_config = Application.get_env(:algora, AlgoraWeb.Endpoint)[:http] - host = "#{:inet.ntoa(http_config[:ip])}:#{http_config[:port]}" - port = open_port("cloudflared", ["tunnel", "run", "--url", "http://#{host}", name]) - Logger.info("Running Cloudflare tunnel at #{host} (#{name})") - GenServer.cast(self(), {:store_port, port}) - end - - defp open_port(cmd, args) do - Port.open({:spawn_executable, System.find_executable(cmd)}, [ - :binary, - :exit_status, - {:line, 1024}, - :use_stdio, - :stderr_to_stdout, - args: args - ]) - end - - @impl true - def handle_cast({:store_port, port}, state) do - {:noreply, Map.put(state, :port, port)} - end -end diff --git a/lib/algora/interviews/interviews.ex b/lib/algora/interviews/interviews.ex deleted file mode 100644 index bf73ea82e..000000000 --- a/lib/algora/interviews/interviews.ex +++ /dev/null @@ -1,146 +0,0 @@ -defmodule Algora.Interviews do - @moduledoc """ - The Interviews context. - """ - - import Ecto.Query, warn: false - - alias Algora.Interviews.JobInterview - alias Algora.Repo - - require Logger - - @doc """ - Returns the list of job interviews. - - ## Examples - - iex> list_job_interviews() - [%JobInterview{}, ...] - - """ - def list_job_interviews do - Repo.all(JobInterview) - end - - @doc """ - Returns the list of job interviews grouped by organization. - Groups by the job posting's user (which represents the organization). - - ## Examples - - iex> list_job_interviews_by_org() - %{org_id => [%JobInterview{}, ...]} - - """ - def list_job_interviews_by_org do - JobInterview - |> join(:inner, [ji], jp in assoc(ji, :job_posting)) - |> join(:inner, [ji, jp], org in assoc(jp, :user)) - |> preload([ji, jp, org], job_posting: {jp, user: org}) - |> preload(:user) - |> Repo.all() - |> Enum.group_by(fn interview -> interview.job_posting.user_id end) - end - - @doc """ - Gets a single job interview. - - Raises `Ecto.NoResultsError` if the Job interview does not exist. - - ## Examples - - iex> get_job_interview!(123) - %JobInterview{} - - iex> get_job_interview!(456) - ** (Ecto.NoResultsError) - - """ - def get_job_interview!(id), do: Repo.get!(JobInterview, id) - - @doc """ - Creates a job interview. - - ## Examples - - iex> create_job_interview(%{field: value}) - {:ok, %JobInterview{}} - - iex> create_job_interview(%{field: bad_value}) - {:error, %Ecto.Changeset{}} - - """ - def create_job_interview(attrs \\ %{}) do - case %JobInterview{} - |> JobInterview.changeset(attrs) - |> Repo.insert() do - {:ok, interview} -> - {:ok, interview} - - {:error, changeset} -> - {:error, changeset} - end - end - - @doc """ - Updates a job interview. - - ## Examples - - iex> update_job_interview(job_interview, %{field: new_value}) - {:ok, %JobInterview{}} - - iex> update_job_interview(job_interview, %{field: bad_value}) - {:error, %Ecto.Changeset{}} - - """ - def update_job_interview(%JobInterview{} = job_interview, attrs) do - job_interview - |> JobInterview.changeset(attrs) - |> Repo.update() - end - - @doc """ - Deletes a job interview. - - ## Examples - - iex> delete_job_interview(job_interview) - {:ok, %JobInterview{}} - - iex> delete_job_interview(job_interview) - {:error, %Ecto.Changeset{}} - - """ - def delete_job_interview(%JobInterview{} = job_interview) do - Repo.delete(job_interview) - end - - @doc """ - Returns an `%Ecto.Changeset{}` for tracking job interview changes. - - ## Examples - - iex> change_job_interview(job_interview) - %Ecto.Changeset{data: %JobInterview{}} - - """ - def change_job_interview(%JobInterview{} = job_interview, attrs \\ %{}) do - JobInterview.changeset(job_interview, attrs) - end - - @doc """ - Counts total interviews for given organization IDs. - """ - def count_interviews_for_orgs(org_ids) when is_list(org_ids) do - Repo.aggregate(from(ji in JobInterview, where: ji.org_id in ^org_ids and ji.status != :initial), :count, :id) - end - - @doc """ - Counts total hires (passed interviews) for given organization IDs. - """ - def count_hires_for_orgs(org_ids) when is_list(org_ids) do - Repo.aggregate(from(ji in JobInterview, where: ji.org_id in ^org_ids and ji.status == :passed), :count, :id) - end -end diff --git a/lib/algora/interviews/schemas/job_interview.ex b/lib/algora/interviews/schemas/job_interview.ex deleted file mode 100644 index 48c437d77..000000000 --- a/lib/algora/interviews/schemas/job_interview.ex +++ /dev/null @@ -1,77 +0,0 @@ -defmodule Algora.Interviews.JobInterview do - @moduledoc false - use Algora.Schema - - import Ecto.Changeset - - alias Algora.Accounts.User - - @interview_statuses [:initial, :ongoing, :passed, :failed, :withdrawn] - - typed_schema "job_interviews" do - field :status, Ecto.Enum, values: @interview_statuses - - field :notes, :string - field :scheduled_at, :utc_datetime_usec - field :completed_at, :utc_datetime_usec - field :company_feedback, :string - field :candidate_feedback, :string - field :company_feedback_token, :string - field :candidate_feedback_token, :string - field :willing_to_relocate, :boolean - field :open_to_inperson, :boolean - field :work_auth_us, :boolean - field :resume_url, :string - field :earliest_start_date, :date - field :favorite_thing, :string - field :custom_question_answer, :string - - belongs_to :user, User - belongs_to :job_posting, Algora.Jobs.JobPosting - belongs_to :org, User - - timestamps() - end - - def changeset(job_interview, attrs) do - job_interview - |> cast(attrs, [ - :user_id, - :job_posting_id, - :org_id, - :status, - :notes, - :scheduled_at, - :completed_at, - :company_feedback, - :candidate_feedback, - :company_feedback_token, - :candidate_feedback_token, - :willing_to_relocate, - :open_to_inperson, - :work_auth_us, - :resume_url, - :earliest_start_date, - :favorite_thing, - :custom_question_answer - ]) - |> validate_required([:user_id, :job_posting_id, :org_id, :status]) - |> validate_inclusion(:status, @interview_statuses) - |> foreign_key_constraint(:user_id) - |> foreign_key_constraint(:job_posting_id) - |> foreign_key_constraint(:org_id) - |> unique_constraint([:user_id, :org_id]) - |> generate_id() - |> maybe_generate_feedback_tokens() - end - - defp maybe_generate_feedback_tokens(changeset) do - if get_field(changeset, :id) && !get_field(changeset, :company_feedback_token) do - changeset - |> put_change(:company_feedback_token, Nanoid.generate(6)) - |> put_change(:candidate_feedback_token, Nanoid.generate(6)) - else - changeset - end - end -end diff --git a/lib/algora/jobs/jobs.ex b/lib/algora/jobs/jobs.ex deleted file mode 100644 index 447c7f8a0..000000000 --- a/lib/algora/jobs/jobs.ex +++ /dev/null @@ -1,237 +0,0 @@ -defmodule Algora.Jobs do - @moduledoc false - - import Ecto.Changeset - import Ecto.Query - - alias Algora.Accounts.User - alias Algora.Bounties.LineItem - alias Algora.Interviews.JobInterview - alias Algora.Jobs.JobApplication - alias Algora.Jobs.JobPosting - alias Algora.Matches.JobMatch - alias Algora.Payments - alias Algora.Payments.Transaction - alias Algora.Repo - alias Algora.Util - - require Logger - - def list_jobs(opts \\ []) do - query = - JobPosting - |> maybe_filter_by_ids(opts[:ids]) - |> maybe_filter_by_status(opts) - |> join(:inner, [j], u in User, on: u.id == j.user_id) - |> maybe_filter_by_user(opts) - |> maybe_filter_by_handle(opts[:handle]) - |> maybe_filter_by_tech_stack(opts[:tech_stack]) - |> join(:left, [j], i in JobInterview, on: i.job_posting_id == j.id and i.status not in [:initial]) - |> join(:left, [j], m in JobMatch, on: m.job_posting_id == j.id and m.status not in [:pending, :discarded]) - |> group_by([j, u, i, m], [u.contract_signed, j.id, j.inserted_at]) - |> order_by([j, u, i, m], - desc: u.contract_signed, - desc_nulls_last: max(i.inserted_at), - desc: j.inserted_at - ) - |> maybe_filter_by_outbound(opts[:outbound_only]) - |> maybe_limit(opts[:limit]) - - query = - if opts[:remove_dripped] do - where( - query, - [j, u], - u.contract_signed or fragment("not exists (select 1 from drips where drips.org_id = ?)", u.id) - ) - else - query - end - - query = - if opts[:dripped_only] do - where( - query, - [j, u], - not u.contract_signed and fragment("exists (select 1 from drips where drips.org_id = ?)", u.id) - ) - else - query - end - - query - |> Repo.all() - |> apply_preloads(opts) - end - - def count_jobs(opts \\ []) do - JobPosting - |> maybe_filter_by_status(opts[:status]) - |> maybe_filter_by_user(opts) - |> join(:inner, [j], u in User, on: u.id == j.user_id) - |> maybe_filter_by_tech_stack(opts[:tech_stack]) - |> Repo.aggregate(:count) - end - - def create_job_posting(attrs) do - %JobPosting{} - |> JobPosting.changeset(attrs) - |> Repo.insert() - end - - defp maybe_filter_by_user(query, opts) do - cond do - opts[:user_ids] -> - where(query, [j], j.user_id in ^opts[:user_ids] and j.status in [:active, :processing]) - - opts[:user_id] -> - where(query, [j], j.user_id == ^opts[:user_id] and j.status in [:active, :processing]) - - opts[:handles] && opts[:handles] != [] -> - where(query, [j, u], u.handle in ^opts[:handles] and j.status in [:active, :processing]) - - is_nil(opts[:user_id]) and is_nil(opts[:handles]) and is_nil(opts[:handle]) -> - where(query, [j, u], j.status in [:active]) - - true -> - query - end - end - - defp maybe_filter_by_handle(query, nil), do: query - - defp maybe_filter_by_handle(query, handle) do - where(query, [j, u], u.handle == ^handle) - end - - defp maybe_filter_by_ids(query, nil), do: query - - defp maybe_filter_by_ids(query, ids) do - where(query, [j], j.id in ^ids) - end - - defp maybe_filter_by_status(query, opts) do - cond do - opts[:status] == :all -> where(query, [j], j.status in [:active, :processing]) - opts[:user_ids] -> where(query, [j], j.status in [:active, :processing]) - opts[:user_id] -> where(query, [j], j.status in [:active, :processing]) - opts[:handles] && opts[:handles] != [] -> where(query, [j, u], j.status in [:active, :processing]) - opts[:handle] -> where(query, [j, u], j.status in [:active, :processing]) - opts[:ids] -> where(query, [j, u], j.status in [:active, :processing]) - true -> where(query, [j], j.status in [:active]) - end - end - - defp maybe_filter_by_tech_stack(query, nil), do: query - defp maybe_filter_by_tech_stack(query, []), do: query - - defp maybe_filter_by_tech_stack(query, tech_stack) do - where(query, [j], fragment("? && ?", j.tech_stack, ^tech_stack)) - end - - defp maybe_filter_by_outbound(query, nil), do: query - defp maybe_filter_by_outbound(query, false), do: query - defp maybe_filter_by_outbound(query, true), do: where(query, [j], j.outbound == true) - - defp maybe_limit(query, nil), do: query - defp maybe_limit(query, limit), do: limit(query, ^limit) - - defp apply_preloads(jobs, opts) do - preloads = [:user | opts[:preload] || []] - Repo.preload(jobs, preloads) - end - - @spec create_payment_session(User.t() | nil, JobPosting.t(), Money.t()) :: - {:ok, String.t()} | {:error, atom()} - def create_payment_session(user, job_posting, amount) do - line_items = [ - %LineItem{ - amount: amount, - title: "Algora Annual Subscription", - description: "Hiring services annual package" - }, - %LineItem{ - amount: Money.mult!(amount, Decimal.new("0.04")), - title: "Processing fee (4%)" - } - ] - - gross_amount = LineItem.gross_amount(line_items) - group_id = Nanoid.generate() - - job_posting = Repo.preload(job_posting, :user) - - Repo.tx(fn -> - with {:ok, _charge} <- - %Transaction{} - |> change(%{ - id: Nanoid.generate(), - provider: "stripe", - type: :charge, - status: :initialized, - user_id: if(user, do: user.id), - job_id: job_posting.id, - gross_amount: gross_amount, - net_amount: gross_amount, - total_fee: Money.zero(:USD), - line_items: Util.normalize_struct(line_items), - group_id: group_id, - idempotency_key: "session-#{Nanoid.generate()}" - }) - |> Algora.Validations.validate_positive(:gross_amount) - |> Algora.Validations.validate_positive(:net_amount) - |> foreign_key_constraint(:user_id) - |> unique_constraint([:idempotency_key]) - |> Repo.insert(), - {:ok, session} <- - Payments.create_stripe_session( - user, - Enum.map(line_items, &LineItem.to_stripe/1), - %{ - description: "Job posting - #{job_posting.company_name}", - metadata: %{"version" => Payments.metadata_version(), "group_id" => group_id} - }, - success_url: - "#{AlgoraWeb.Endpoint.url()}/#{job_posting.user.handle}/jobs/#{job_posting.id}/applicants?status=paid", - cancel_url: "#{AlgoraWeb.Endpoint.url()}/#{job_posting.user.handle}/jobs/#{job_posting.id}/applicants" - ) do - {:ok, session.url} - end - end) - end - - def create_application(job_id, user, attrs \\ %{}) do - %JobApplication{job_id: job_id, user_id: user.id} - |> JobApplication.changeset(attrs) - |> Repo.insert() - end - - def ensure_application(job_id, user, attrs \\ %{}) do - case JobApplication |> where([a], a.job_id == ^job_id and a.user_id == ^user.id) |> Repo.one() do - nil -> create_application(job_id, user, attrs) - application -> {:ok, application} - end - end - - def list_user_applications(user) do - JobApplication - |> where([a], a.user_id == ^user.id) - |> select([a], a.job_id) - |> Repo.all() - |> MapSet.new() - end - - def get_job_posting(id) do - case JobPosting |> Repo.get(id) |> Repo.preload(:user) do - nil -> {:error, :not_found} - job -> {:ok, job} - end - end - - def list_job_applications(job) do - JobApplication - |> where([a], a.job_id == ^job.id) - |> preload(:user) - |> Repo.all() - end -end diff --git a/lib/algora/jobs/schemas/job_application.ex b/lib/algora/jobs/schemas/job_application.ex deleted file mode 100644 index 6b3558b28..000000000 --- a/lib/algora/jobs/schemas/job_application.ex +++ /dev/null @@ -1,26 +0,0 @@ -defmodule Algora.Jobs.JobApplication do - @moduledoc false - use Algora.Schema - - alias Algora.Accounts.User - alias Algora.Jobs.JobPosting - - typed_schema "job_applications" do - field :status, Ecto.Enum, values: [:pending], null: false, default: :pending - field :imported_at, :utc_datetime_usec - belongs_to :job, JobPosting, null: false - belongs_to :user, User, null: false - - timestamps() - end - - def changeset(job_application, attrs) do - job_application - |> cast(attrs, [:status, :job_id, :user_id, :imported_at]) - |> generate_id() - |> validate_required([:status, :job_id, :user_id]) - |> unique_constraint([:job_id, :user_id]) - |> foreign_key_constraint(:job_id) - |> foreign_key_constraint(:user_id) - end -end diff --git a/lib/algora/jobs/schemas/job_posting.ex b/lib/algora/jobs/schemas/job_posting.ex deleted file mode 100644 index 2f39d4e25..000000000 --- a/lib/algora/jobs/schemas/job_posting.ex +++ /dev/null @@ -1,107 +0,0 @@ -defmodule Algora.Jobs.JobPosting do - @moduledoc false - use Algora.Schema - - alias Algora.Accounts.User - alias Money.Ecto.Composite.Type - - typed_schema "job_postings" do - field :title, :string - field :description, :string - field :tech_stack, {:array, :string}, default: [] - field :url, :string - field :company_name, :string - field :company_url, :string - field :email, :string - field :status, Ecto.Enum, values: [:initialized, :processing, :active, :expired], null: false, default: :initialized - field :expires_at, :utc_datetime_usec - # e.g. "SF Bay Area (Remote)" - field :location, :string - # e.g. ["US", "CA", "BR"] - field :countries, {:array, :string}, default: [] - # e.g. ["LATAM", "NA"] - field :regions, {:array, :string}, default: [] - field :compensation, :string - field :min_compensation, Type - field :max_compensation, Type - field :seniority, :string - field :system_tags, {:array, :string}, default: [] - field :primary_tech, :string - field :primary_tag, :string - field :full_description, :string - field :team, :string - field :provider, :string - field :provider_id, :string - - field :location_meta, :map - field :location_iso_lvl4, :string - field :location_types, {:array, Ecto.Enum}, values: [:remote, :hybrid, :onsite] - field :locations, {:array, :string}, default: [] - field :states, {:array, :string}, default: [] - - field :require_security_clearance, :boolean, default: false - field :require_us_citizenship, :boolean, default: false - field :outbound, :boolean, default: true - - field :custom_question, :string - - # Equity compensation details - # Percentage-based equity (e.g., 0.25 for 0.25%) - field :min_equity_pct, :decimal - field :max_equity_pct, :decimal - # Money-based equity (actual dollar value) - field :min_equity, Type - field :max_equity, Type - - belongs_to :user, User, null: false - has_many :interviews, Algora.Interviews.JobInterview, foreign_key: :job_posting_id - has_many :matches, Algora.Matches.JobMatch, foreign_key: :job_posting_id - - timestamps() - end - - def changeset(job_posting, attrs) do - job_posting - |> cast(attrs, [ - :title, - :description, - :tech_stack, - :url, - :company_name, - :company_url, - :email, - :status, - :expires_at, - :user_id, - :location, - :compensation, - :seniority, - :countries, - :regions, - :system_tags, - :location_meta, - :location_iso_lvl4, - :primary_tech, - :primary_tag, - :full_description, - :team, - :provider, - :provider_id, - :location_types, - :locations, - :min_compensation, - :max_compensation, - :states, - :min_equity_pct, - :max_equity_pct, - :min_equity, - :max_equity, - :require_security_clearance, - :require_us_citizenship, - :custom_question - ]) - |> generate_id() - |> validate_required([:url]) - |> foreign_key_constraint(:user) - end -end diff --git a/lib/algora/mailer.ex b/lib/algora/mailer.ex deleted file mode 100644 index ac8d7fe9f..000000000 --- a/lib/algora/mailer.ex +++ /dev/null @@ -1,133 +0,0 @@ -defmodule Algora.Mailer do - @moduledoc false - use Swoosh.Mailer, otp_app: :algora - - require Logger - - def deliver_with_logging(mail) do - case deliver(mail) do - {:ok, _} -> - {:ok, mail} - - {:error, reason} -> - Logger.error(""" - Email delivery failed: - - Subject: #{mail.subject} - To: #{inspect(mail.to)} - Reason: #{inspect(reason, pretty: true)} - """) - - {:error, reason} - end - end - - def html_template(template_params, opts \\ []) do - """ - - - - - - - - - #{preheader_section(opts[:preheader])} -
- - - - -
- #{html_sections(template_params)} -
-
- - - """ - end - - def text_template(template_params) do - """ - ============================== - - #{text_sections(template_params)} - - ============================== - """ - end - - defp text_sections(template_params) do - template_params - |> Enum.map(fn {type, value} -> text_section(type, value) end) - |> Enum.intersperse("\n\n") - end - - defp text_section(:cta, %{href: href, src: src}) do - ~s|#{href}\n\n#{src}| - end - - defp text_section(_, value) do - value - end - - defp html_sections(template_params) do - for {type, value} <- template_params, - do: html_section(type, value) - end - - defp html_section(:markdown, value) do - html = Algora.Markdown.render_unsafe(value) - - ~s""" - - - - -
- #{html} -
- """ - end - - defp html_section(type, value) do - ~s|

| <> - html_section_by_type(type, value) <> ~s|

| - end - - defp html_section_by_type(:cta, %{href: href, src: src}) do - ~s|| <> - ~s|| <> - ~s|| - end - - defp html_section_by_type(:url, value) do - ~s|| <> - value <> ~s|| - end - - defp html_section_by_type(:img, value) do - ~s|| - end - - defp html_section_by_type(_, text) do - text - end - - defp preheader_section(nil), do: "" - - defp preheader_section(preheader), - do: """ - -
- ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ ͏ ‌     ­ -
- """ -end diff --git a/lib/algora/matches/matches.ex b/lib/algora/matches/matches.ex deleted file mode 100644 index 575caade5..000000000 --- a/lib/algora/matches/matches.ex +++ /dev/null @@ -1,321 +0,0 @@ -defmodule Algora.Matches do - @moduledoc false - - import Ecto.Changeset - import Ecto.Query, warn: false - - alias Algora.Jobs.JobPosting - alias Algora.Matches.JobMatch - alias Algora.Repo - - require Logger - - def list_job_matches(opts \\ []) do - order_by_clause = opts[:order_by] || [asc: :updated_at] - - JobMatch - |> filter_by_job_posting_id(opts[:job_posting_id]) - |> filter_by_user_id(opts[:user_id]) - |> filter_by_org_id(opts[:org_id]) - |> filter_by_status(opts[:status]) - |> filter_by_is_draft(opts[:include_drafts]) - |> join(:inner, [m], j in assoc(m, :job_posting), as: :j) - |> order_by(^order_by_clause) - |> maybe_preload(opts[:preload]) - |> Repo.all() - end - - def list_job_matches_with_assocs do - JobMatch - |> where([jm], not is_nil(jm.provider_application_id)) - |> preload([:user, job_posting: :user]) - |> order_by([jm], desc: jm.inserted_at) - |> limit(500) - |> Repo.all() - end - - def get_job_match!(id) do - JobMatch - |> preload([:user, :job_posting]) - |> Repo.get!(id) - end - - def get_job_match_by_id(id) do - JobMatch - |> preload([:user, :job_posting]) - |> Repo.get(id) - end - - def get_job_match(user_id, job_posting_id) do - JobMatch - |> where([m], m.user_id == ^user_id and m.job_posting_id == ^job_posting_id) - |> preload([:user, :job_posting]) - |> Repo.one() - end - - def create_job_match(attrs \\ %{}) do - %JobMatch{} - |> JobMatch.changeset(attrs) - |> Repo.insert() - end - - def upsert_job_match(attrs) do - case create_job_match(attrs) do - {:ok, match} -> - if confirmed?(match) do - Algora.Cloud.notify_match(attrs) - end - - {:ok, match} - - {:error, _changeset} -> - match = Repo.get_by(JobMatch, user_id: attrs.user_id, job_posting_id: attrs.job_posting_id) - - case match |> change(%{status: attrs.status}) |> Repo.update() do - {:ok, updated_match} = result -> - if not confirmed?(match) and confirmed?(updated_match) do - Algora.Cloud.notify_match(attrs) - end - - result - - error -> - error - end - end - end - - defp confirmed?(%{status: status}) when status in [:approved, :highlighted], do: true - defp confirmed?(_match), do: false - - def fetch_job_matches(job_posting_id) do - job = Repo.get!(JobPosting, job_posting_id) - - existing_matches = - Repo.all( - from(m in JobMatch, - where: m.job_posting_id == ^job_posting_id, - where: m.status in [:highlighted, :approved] - ) - ) - - discarded_matches = - Repo.all( - from(m in JobMatch, - where: m.job_posting_id == ^job_posting_id, - where: m.status == :discarded - ) - ) - - ids_not = Enum.map(existing_matches, & &1.user_id) ++ Enum.map(discarded_matches, & &1.user_id) - - opts = [ - ids_not: ids_not, - tech_stack: job.tech_stack, - by_language_contributions: true, - not_discarded: true, - system_tags: job.system_tags - ] - - location_iso_lvl4 = - if job.location_iso_lvl4 && job.countries && - Enum.any?(job.countries, &String.starts_with?(job.location_iso_lvl4, &1)) do - job.location_iso_lvl4 - end - - m1 = - if location_iso_lvl4 do - opts - |> Keyword.put(:limit, 6) - |> Keyword.put(:location_iso_lvl4, location_iso_lvl4) - |> Algora.Cloud.list_top_matches() - else - [] - end - - m2 = - opts - |> Keyword.put(:limit, max(6 - length(m1), 3)) - |> Keyword.put(:location_iso_lvl4_not, location_iso_lvl4) - |> Keyword.put(:countries, job.countries) - |> Algora.Cloud.list_top_matches() - - m3 = - opts - |> Keyword.put(:limit, 3) - |> Keyword.put(:sort_by, [{"regions", job.regions}]) - |> Algora.Cloud.list_top_matches() - - matches = m1 ++ m2 ++ m3 - - matches - |> Enum.uniq_by(& &1.user_id) - |> Algora.Settings.load_matches_2() - end - - def create_job_matches(job_posting_id) do - job_posting_id - |> fetch_job_matches() - |> Enum.map(fn match -> match.user.id end) - |> then(&create_job_matches(job_posting_id, &1)) - end - - def create_job_matches(job_posting_id, user_ids) do - matches = - Enum.map(user_ids, fn user_id -> - %{ - id: Nanoid.generate(), - user_id: user_id, - job_posting_id: job_posting_id, - inserted_at: DateTime.truncate(DateTime.utc_now(), :microsecond), - updated_at: DateTime.truncate(DateTime.utc_now(), :microsecond) - } - end) - - Repo.tx(fn -> - # Delete existing matches for this job posting - Repo.delete_all( - from(m in JobMatch, - where: m.job_posting_id == ^job_posting_id, - where: m.status not in [:highlighted, :approved, :discarded] - ) - ) - - # Insert new matches - case Repo.insert_all(JobMatch, matches, on_conflict: :nothing) do - {0, _} -> {:error, "No matches created"} - {count, _} -> {:ok, count} - end - end) - end - - def update_job_match(%JobMatch{} = job_match, attrs) do - changeset = JobMatch.changeset(job_match, attrs) - - case Repo.update(changeset) do - {:ok, updated_job_match} -> - # Check if approval timestamps were just set and enqueue emails - enqueue_like_emails_if_needed(job_match, updated_job_match) - {:ok, updated_job_match} - - error -> - error - end - end - - defp enqueue_like_emails_if_needed(old_match, new_match) do - # Check if company_approved_at was just set (wasn't set before, but is now) - if is_nil(old_match.company_approved_at) && - is_nil(old_match.candidate_approved_at) && - not is_nil(new_match.company_approved_at) do - Algora.Cloud.notify_company_like(new_match.id) - end - - # Check if candidate_approved_at was just set (wasn't set before, but is now) - if is_nil(old_match.candidate_approved_at) && - is_nil(old_match.company_approved_at) && - not is_nil(new_match.candidate_approved_at) do - Algora.Cloud.notify_candidate_like(new_match.id) - end - end - - def update_job_match_status(match_id, status) do - case Repo.get(JobMatch, match_id) do - nil -> {:error, :not_found} - job_match -> update_job_match(job_match, %{status: status}) - end - end - - def delete_job_match(%JobMatch{} = job_match) do - Repo.delete(job_match) - end - - def change_job_match(%JobMatch{} = job_match, attrs \\ %{}) do - JobMatch.changeset(job_match, attrs) - end - - def list_user_approved_matches(user_id) do - JobMatch - |> filter_by_user_id(user_id) - |> filter_by_status([:automatched, :approved, :highlighted]) - |> join(:inner, [m], j in assoc(m, :job_posting), as: :j) - |> order_by([m], - asc: m.candidate_approved_at, - asc: m.candidate_bookmarked_at, - desc: m.candidate_discarded_at, - # asc: fragment("CASE WHEN ? = 'highlighted' THEN 0 WHEN ? = 'approved' THEN 1 ELSE 2 END", m.status, m.status), - desc: m.inserted_at - ) - |> preload(job_posting: :user) - |> Repo.all() - end - - # Private helper functions - defp filter_by_job_posting_id(query, nil), do: query - - defp filter_by_job_posting_id(query, job_posting_id) do - where(query, [m], m.job_posting_id == ^job_posting_id) - end - - defp filter_by_user_id(query, nil), do: query - - defp filter_by_user_id(query, user_id) do - where(query, [m], m.user_id == ^user_id) - end - - defp filter_by_org_id(query, nil), do: query - - defp filter_by_org_id(query, org_id) do - query - |> where([m, j], j.user_id == ^org_id) - |> where([m, j], is_nil(m.company_discarded_at)) - end - - defp filter_by_status(query, nil), do: query - - defp filter_by_status(query, status) when is_list(status) do - where(query, [m], m.status in ^status) - end - - defp filter_by_status(query, status) do - where(query, [m], m.status == ^status) - end - - defp filter_by_is_draft(query, true), do: query - - defp filter_by_is_draft(query, _) do - where(query, [m], m.is_draft == false) - end - - defp maybe_preload(query, nil), do: query - - defp maybe_preload(query, preload_list) do - preload(query, ^preload_list) - end - - @doc """ - Check if a match is considered "new" (within the last 7 days and not yet approved/bookmarked by company). - """ - def is_match_new?(match) do - is_nil(match.company_approved_at) && - is_nil(match.company_bookmarked_at) && - is_match_recent?(match) - end - - def is_match_recent?(match) do - timestamp = match.dripped_at || match.inserted_at - one_week_ago = DateTime.add(DateTime.utc_now(), -7, :day) - DateTime.after?(timestamp, one_week_ago) - end - - def get_latest_match_with_notes(user_id) do - Repo.one( - from(m in JobMatch, - where: m.user_id == ^user_id, - where: not is_nil(m.notes) and m.notes != "", - order_by: [desc: m.updated_at], - limit: 1 - ) - ) - end -end diff --git a/lib/algora/matches/schemas/job_match.ex b/lib/algora/matches/schemas/job_match.ex deleted file mode 100644 index 46ca5d8f4..000000000 --- a/lib/algora/matches/schemas/job_match.ex +++ /dev/null @@ -1,103 +0,0 @@ -defmodule Algora.Matches.JobMatch do - @moduledoc false - use Algora.Schema - - import Ecto.Changeset - - typed_schema "job_matches" do - field :status, Ecto.Enum, - values: [:pending, :discarded, :automatched, :dripped, :approved, :highlighted], - default: :pending - - field :score, :decimal - field :notes, :string - field :company_approved_at, :utc_datetime_usec - field :company_bookmarked_at, :utc_datetime_usec - field :company_discarded_at, :utc_datetime_usec - field :candidate_approved_at, :utc_datetime_usec - field :candidate_bookmarked_at, :utc_datetime_usec - field :candidate_discarded_at, :utc_datetime_usec - field :custom_sort_order, :integer - field :anonymize, :boolean, default: true - field :company_notes, :string - field :dripped_at, :utc_datetime_usec - field :locked, :boolean, default: false - field :is_draft, :boolean, default: false - field :dropped, :boolean, default: false - field :eval, :map - field :provider_candidate_id, :string - field :provider_application_id, :string - field :provider_candidate_meta, :map, default: %{} - field :provider_application_meta, :map, default: %{} - field :provider_feedback_meta, :map, default: %{} - field :provider_interviews_meta, :map, default: %{} - field :provider_notes_meta, :map, default: %{} - - belongs_to :user, Algora.Accounts.User - belongs_to :job_posting, Algora.Jobs.JobPosting - - timestamps() - end - - def changeset(job_match, attrs) do - job_match - |> cast(attrs, [ - :user_id, - :job_posting_id, - :status, - :score, - :notes, - :company_approved_at, - :company_bookmarked_at, - :company_discarded_at, - :candidate_approved_at, - :candidate_bookmarked_at, - :candidate_discarded_at, - :custom_sort_order, - :anonymize, - :company_notes, - :dripped_at, - :locked, - :is_draft, - :dropped, - :eval, - :provider_candidate_id, - :provider_application_id, - :provider_candidate_meta, - :provider_application_meta, - :provider_feedback_meta, - :provider_interviews_meta, - :provider_notes_meta - ]) - |> then(fn cs -> - Enum.reduce([:provider_candidate_id, :provider_application_id], cs, fn field, acc -> - case Map.fetch(attrs, field) do - {:ok, ""} -> force_change(acc, field, "") - _ -> acc - end - end) - end) - |> validate_required([:user_id, :job_posting_id]) - |> validate_inclusion(:status, [:pending, :discarded, :automatched, :dripped, :approved, :highlighted]) - |> foreign_key_constraint(:user_id) - |> foreign_key_constraint(:job_posting_id) - |> unique_constraint([:user_id, :job_posting_id]) - |> generate_id() - end - - def get_application_history(match) do - get_in(match.provider_application_meta, ["applicationHistory"]) || [] - end - - def get_application_feedback(match) do - get_in(match.provider_feedback_meta, ["feedbacks"]) || [] - end - - def get_interview_schedules(match) do - get_in(match.provider_interviews_meta, ["schedules"]) || [] - end - - def get_candidate_notes(match) do - get_in(match.provider_notes_meta, ["notes"]) || [] - end -end diff --git a/lib/algora/notifier.ex b/lib/algora/notifier.ex deleted file mode 100644 index dd625b41f..000000000 --- a/lib/algora/notifier.ex +++ /dev/null @@ -1,14 +0,0 @@ -defmodule Algora.Notifier do - @moduledoc false - def notify_welcome_org(_user, _org) do - :ok - end - - def notify_welcome_developer(_user) do - :ok - end - - def notify_stripe_account_link_error(_user, _error) do - :ok - end -end diff --git a/lib/algora/oban_repo.ex b/lib/algora/oban_repo.ex deleted file mode 100644 index 04035a160..000000000 --- a/lib/algora/oban_repo.ex +++ /dev/null @@ -1,5 +0,0 @@ -defmodule Algora.ObanRepo do - use Ecto.Repo, - adapter: Ecto.Adapters.Postgres, - otp_app: :algora -end diff --git a/lib/algora/organizations/organizations.ex b/lib/algora/organizations/organizations.ex deleted file mode 100644 index a06b885f5..000000000 --- a/lib/algora/organizations/organizations.ex +++ /dev/null @@ -1,412 +0,0 @@ -defmodule Algora.Organizations do - @moduledoc false - import Ecto.Changeset - import Ecto.Query - - alias Algora.Accounts.User - alias Algora.Organizations.Member - alias Algora.Organizations.Org - alias Algora.Repo - alias Algora.Workspace - - def create_organization(params) do - %User{type: :organization} - |> Org.changeset(params) - |> Repo.insert() - end - - def update_organization(org, params) do - org - |> Org.changeset(params) - |> Repo.update() - end - - def create_member(org, user, role) do - %Member{} - |> Member.changeset(%{role: role, org_id: org.id, user_id: user.id}) - |> Repo.insert() - end - - def onboard_organization(params) do - Algora.Activities.alert("New organization: #{inspect(params)}", :critical) - - user = Repo.get_by(User, email: params.user.email) - - domain = params.user.email |> String.split("@") |> List.last() - - org = - case user do - nil -> - Repo.one(from o in User, where: o.domain == ^domain, limit: 1) - - user -> - Repo.one( - from o in User, - left_join: m in assoc(o, :members), - left_join: u in assoc(m, :user), - where: - o.domain == ^domain or - (o.handle in ^generate_unique_org_handle_candidates(params.organization.handle) and u.id == ^user.id), - limit: 1 - ) - end - - org_handle = - case org do - nil -> ensure_unique_org_handle(params.organization.handle) - org -> org.handle - end - - Repo.tx(fn -> - {:ok, user} = - case Repo.get_by(User, email: params.user.email) do - nil -> - handle = ensure_unique_handle(params.user.handle) - - %User{type: :individual, last_context: org_handle} - |> User.org_registration_changeset(Map.put(params.user, :handle, handle)) - |> Repo.insert() - - existing_user -> - existing_user - |> User.org_registration_changeset(Map.delete(params.user, :handle)) - |> put_change(:last_context, org_handle) - |> Repo.update() - end - - {:ok, org} = - case org do - nil -> - %User{type: :organization} - |> Org.changeset(Map.put(params.organization, :handle, org_handle)) - |> Repo.insert() - - existing_org -> - updated_params = - params.organization - |> Map.take([:hiring, :categories, :tech_stack]) - |> Map.update(:hiring, existing_org.hiring, &(existing_org.hiring || &1)) - |> Map.update(:categories, existing_org.categories, &Enum.uniq(existing_org.categories ++ &1)) - |> Map.update(:tech_stack, existing_org.tech_stack, &Enum.uniq(existing_org.tech_stack ++ &1)) - - existing_org - |> Org.changeset(updated_params) - |> Repo.update() - end - - {:ok, member} = - case Repo.get_by(Member, user_id: user.id, org_id: org.id) do - nil -> - %Member{} - |> Member.changeset(Map.merge(params.member, %{user_id: user.id, org_id: org.id})) - |> Repo.insert() - - existing_member -> - existing_member - |> Member.changeset(Map.merge(params.member, %{user_id: user.id, org_id: org.id})) - |> Repo.update() - end - - {:ok, %{org: org, user: user, member: member}} - end) - end - - def parse_site_metadata(domain, metadata, opts \\ %{}) do - org_name = - case get_in(metadata, [:display_name]) do - nil -> - domain - |> String.split(".") - |> List.first() - |> String.capitalize() - - name -> - name - end - - org_handle = - case get_in(metadata, [:handle]) do - nil -> - domain - |> String.split(".") - |> List.first() - |> String.downcase() - - handle -> - handle - end - - Map.merge( - %{ - display_name: org_name, - bio: get_in(metadata, [:bio]) || get_in(metadata, [:og_description]) || get_in(metadata, [:og_title]), - avatar_url: get_in(metadata, [:avatar_url]) || get_in(metadata, [:favicon_url]), - handle: org_handle, - domain: domain, - og_title: get_in(metadata, [:og_title]), - og_image_url: get_in(metadata, [:og_image_url]), - website_url: get_in(metadata, [:website_url]), - twitter_url: get_in(metadata, [:socials, :twitter]), - github_url: get_in(metadata, [:socials, :github]), - youtube_url: get_in(metadata, [:socials, :youtube]), - twitch_url: get_in(metadata, [:socials, :twitch]), - discord_url: get_in(metadata, [:socials, :discord]), - slack_url: get_in(metadata, [:socials, :slack]), - linkedin_url: get_in(metadata, [:socials, :linkedin]) - }, - opts - ) - end - - def onboard_organization_from_domain(domain, opts \\ %{}) do - # Use provided metadata or fetch it - result = - case Map.get(opts, :metadata) do - nil -> - case Algora.Crawler.fetch_site_metadata(domain) do - {:ok, metadata} -> {:ok, parse_site_metadata(domain, metadata, opts)} - {:error, reason} -> {:error, reason} - end - - metadata -> - {:ok, metadata} - end - - case result do - {:ok, metadata} -> - org = Repo.one(from o in User, where: o.domain == ^domain, limit: 1) - - org_handle = - case org do - nil -> ensure_unique_org_handle(metadata.handle) - org -> org.handle - end - - case org do - nil -> - %User{type: :organization} - |> Org.changeset(Map.put(metadata, :handle, org_handle)) - |> Repo.insert() - - existing_org -> - metadata = - if existing_org.handle do - Map.delete(metadata, :handle) - else - metadata - end - - existing_org - |> Org.changeset(metadata) - |> Repo.update() - end - - {:error, error} -> - {:error, error} - end - end - - def generate_handle_from_email(email) do - email - |> String.split("@") - |> List.first() - |> String.split("+") - |> List.first() - |> String.replace(~r/[^a-zA-Z0-9]/, "") - |> String.downcase() - end - - def ensure_unique_handle(base_handle) do - 0 - |> Stream.iterate(&(&1 + 1)) - |> Enum.reduce_while(base_handle, fn i, _handle -> {:halt, increment_handle(base_handle, i)} end) - end - - defp generate_unique_org_handle_candidates(base_handle) do - suffixes = ["hq", "team", "app", "labs", "co"] - prefixes = ["get", "try", "join", "go"] - - List.flatten( - [base_handle] ++ - Enum.map(suffixes, &"#{base_handle}#{&1}") ++ - Enum.map(prefixes, &"#{&1}#{base_handle}") - ) - end - - def ensure_unique_org_handle(base_handle) do - case try_candidates(base_handle) do - nil -> increment_handle(base_handle, 1) - handle -> handle - end - end - - defp try_candidates(base_handle) do - candidates = generate_unique_org_handle_candidates(base_handle) - - Enum.reduce_while(candidates, nil, fn candidate, _acc -> - case Repo.get_by(User, handle: candidate) do - nil -> {:halt, candidate} - _user -> {:cont, nil} - end - end) - end - - defp increment_handle(base_handle, n) do - candidate = - case n do - 0 -> base_handle - n when n <= 42 -> "#{base_handle}#{n}" - _ -> raise "Too many attempts to generate unique handle" - end - - case Repo.get_by(User, handle: candidate) do - nil -> candidate - _user -> increment_handle(base_handle, n + 1) - end - end - - def get_org_by(fields), do: Repo.get_by(User, fields) - def get_org_by!(fields), do: Repo.get_by!(User, fields) - - def get_org_by_handle(handle), do: get_org_by(handle: handle) - def get_org_by_handle!(handle), do: get_org_by!(handle: handle) - - def get_org(id), do: Repo.get(User, id) - def get_org!(id), do: Repo.get!(User, id) - - @spec fetch_org_by(clauses :: Keyword.t() | map()) :: - {:ok, User.t()} | {:error, :not_found} - def fetch_org_by(clauses) do - Repo.fetch_by(User, clauses) - end - - @type criterion :: - {:limit, non_neg_integer()} - | {:before, %{priority: integer(), stargazers_count: integer(), id: String.t()}} - - @spec apply_criteria(Ecto.Queryable.t(), [criterion()]) :: Ecto.Queryable.t() - defp apply_criteria(query, criteria) do - Enum.reduce(criteria, query, fn - {:limit, limit}, query -> - from([u] in query, limit: ^limit) - - {:before, %{priority: priority, stargazers_count: stargazers_count, id: id}}, query -> - from([u] in query, - where: {u.priority, u.stargazers_count, u.id} < {^priority, ^stargazers_count, ^id} - ) - - _, query -> - query - end) - end - - def list_orgs(opts) do - orgs_with_open_bounties = - from b in Algora.Bounties.Bounty, - where: b.status == :open, - select: b.owner_id - - orgs_with_transactions = - from tx in Algora.Payments.Transaction, - where: tx.status == :succeeded, - where: tx.type == :debit, - select: tx.user_id - - User - |> where([u], u.type == :organization) - |> where([u], not is_nil(u.handle)) - |> where([u], u.featured == true) - |> where([u], u.id in subquery(orgs_with_open_bounties) or u.id in subquery(orgs_with_transactions)) - |> order_by([u], desc: u.priority, desc: u.stargazers_count, desc: u.id) - |> apply_criteria(opts) - |> Repo.all() - end - - def get_user_orgs(%User{} = user) do - Repo.all( - from o in User, - join: m in assoc(o, :members), - where: m.user_id == ^user.id and m.org_id == o.id - ) - end - - def list_org_members(org) do - Repo.all( - from m in Member, - join: u in assoc(m, :user), - where: m.org_id == ^org.id and m.user_id == u.id, - select_merge: %{ - user: u - }, - order_by: [ - fragment( - "CASE WHEN ? = 'admin' THEN 0 WHEN ? = 'mod' THEN 1 WHEN ? = 'expert' THEN 2 ELSE 3 END", - m.role, - m.role, - m.role - ), - asc: m.inserted_at, - asc: m.id - ] - ) - end - - def fetch_member(org_id, user_id) do - Repo.fetch_by(Member, org_id: org_id, user_id: user_id) - end - - def list_org_contractors(org) do - Repo.all( - from u in User, - distinct: true, - join: c in assoc(u, :contractor_contracts), - where: c.client_id == ^org.id and c.contractor_id == u.id - ) - end - - def init_preview(repo_owner, repo_name) do - token = Algora.Cloud.token() - - with {:ok, repo} <- Workspace.ensure_repository(token, repo_owner, repo_name), - {:ok, owner} <- Workspace.ensure_user(token, repo_owner), - {:ok, _contributors} <- Workspace.ensure_contributors(token, repo), - {:ok, tech_stack} <- Workspace.ensure_repo_tech_stack(token, repo) do - Repo.tx(fn _ -> - with {:ok, org} <- - Repo.insert(%User{ - type: :organization, - id: Nanoid.generate(), - display_name: owner.name, - avatar_url: owner.avatar_url, - last_context: "repo/#{repo_owner}/#{repo_name}", - tech_stack: tech_stack - }), - {:ok, user} <- - Repo.insert(%User{ - type: :individual, - id: Nanoid.generate(), - display_name: "You", - last_context: "preview/#{org.id}/#{repo_owner}/#{repo_name}", - tech_stack: tech_stack - }), - {:ok, member} <- - Repo.insert(%Member{ - id: Nanoid.generate(), - org_id: org.id, - user_id: user.id, - role: :admin - }) do - {:ok, %{org: org, user: user, member: member}} - end - end) - else - {:error, error} -> - Algora.Activities.alert("Error initializing preview for #{repo_owner}/#{repo_name}: #{inspect(error)}", :error) - {:error, error} - end - rescue - error -> - Algora.Activities.alert("Error initializing preview for #{repo_owner}/#{repo_name}: #{inspect(error)}", :error) - {:error, error} - end -end diff --git a/lib/algora/organizations/schemas/member.ex b/lib/algora/organizations/schemas/member.ex deleted file mode 100644 index 00551d210..000000000 --- a/lib/algora/organizations/schemas/member.ex +++ /dev/null @@ -1,43 +0,0 @@ -defmodule Algora.Organizations.Member do - @moduledoc false - use Algora.Schema - - alias Algora.Accounts.User - - @roles [:admin, :mod, :expert] - - typed_schema "members" do - field :role, Ecto.Enum, values: @roles - - belongs_to :org, User - belongs_to :user, User - - timestamps() - end - - def roles, do: @roles - - def changeset(member, params) do - member - |> cast(params, [:role, :org_id, :user_id]) - |> validate_required([:role, :org_id, :user_id]) - |> foreign_key_constraint(:org_id) - |> foreign_key_constraint(:user_id) - |> unique_constraint([:org_id, :user_id]) - |> generate_id() - end - - def filter_by_org_id(query, nil), do: query - - def filter_by_org_id(query, org_id) do - from m in query, - join: o in assoc(m, :org), - where: o.id == ^org_id - end - - def can_create_bounty?(role), do: role in [:admin, :mod] - - def can_create_contract?(role), do: role in [:admin, :mod] - - def can_view_matches?(org, role), do: org.contract_signed && role in [:admin, :mod] -end diff --git a/lib/algora/organizations/schemas/org.ex b/lib/algora/organizations/schemas/org.ex deleted file mode 100644 index 11d856848..000000000 --- a/lib/algora/organizations/schemas/org.ex +++ /dev/null @@ -1,43 +0,0 @@ -defmodule Algora.Organizations.Org do - @moduledoc false - use Algora.Schema - - alias Algora.Accounts.User - - def changeset(org, params) do - org - |> cast(params, [ - :handle, - :display_name, - :domain, - :bio, - :avatar_url, - :location, - :stargazers_count, - :tech_stack, - :categories, - :hiring, - :featured, - :priority, - :fee_pct, - :seeded, - :activated, - :max_open_attempts, - :manual_assignment, - :bounty_mode, - :og_title, - :og_image_url, - :website_url, - :twitter_url, - :github_url, - :youtube_url, - :twitch_url, - :discord_url, - :slack_url, - :linkedin_url - ]) - |> generate_id() - |> validate_required([:type, :handle]) - |> User.validate_handle() - end -end diff --git a/lib/algora/payments/errors.ex b/lib/algora/payments/errors.ex deleted file mode 100644 index 88fa98372..000000000 --- a/lib/algora/payments/errors.ex +++ /dev/null @@ -1,14 +0,0 @@ -defmodule Algora.Payments.StripeAccountLinkError do - @moduledoc false - defexception [:message] -end - -defmodule Algora.Payments.StripeAccountCreateError do - @moduledoc false - defexception [:message] -end - -defmodule Algora.Payments.StripeAccountDeleteError do - @moduledoc false - defexception [:message] -end diff --git a/lib/algora/payments/jobs/execute_pending_transfers.ex b/lib/algora/payments/jobs/execute_pending_transfers.ex deleted file mode 100644 index 39eedb236..000000000 --- a/lib/algora/payments/jobs/execute_pending_transfers.ex +++ /dev/null @@ -1,13 +0,0 @@ -defmodule Algora.Payments.Jobs.ExecutePendingTransfer do - @moduledoc false - use Oban.Worker, - queue: :default, - unique: [period: :infinity] - - alias Algora.Payments - - @impl Oban.Worker - def perform(%Oban.Job{args: %{"credit_id" => credit_id}}) do - Payments.execute_pending_transfer(credit_id) - end -end diff --git a/lib/algora/payments/payments.ex b/lib/algora/payments/payments.ex deleted file mode 100644 index 0d4f8abe1..000000000 --- a/lib/algora/payments/payments.ex +++ /dev/null @@ -1,902 +0,0 @@ -defmodule Algora.Payments do - @moduledoc false - import Ecto.Changeset - import Ecto.Query - - alias Algora.Accounts - alias Algora.Accounts.User - alias Algora.Bounties.Bounty - alias Algora.Bounties.Claim - alias Algora.Bounties.Jobs.PromptPayoutConnect - alias Algora.Bounties.Tip - alias Algora.Jobs.JobPosting - alias Algora.MoneyUtils - alias Algora.Payments.Account - alias Algora.Payments.Customer - alias Algora.Payments.Jobs - alias Algora.Payments.PaymentMethod - alias Algora.Payments.Transaction - alias Algora.PSP - alias Algora.Repo - alias Algora.Util - alias Algora.Workspace.Ticket - - require Logger - - def metadata_version, do: "2" - - def broadcast do - Phoenix.PubSub.broadcast(Algora.PubSub, "payments:all", :payments_updated) - end - - def subscribe do - Phoenix.PubSub.subscribe(Algora.PubSub, "payments:all") - end - - @spec create_stripe_session( - user :: User.t() | nil, - line_items :: [PSP.Session.line_item_data()], - payment_intent_data :: PSP.Session.payment_intent_data(), - opts :: [ - {:success_url, String.t()}, - {:cancel_url, String.t()} - ] - ) :: - {:ok, PSP.session()} | {:error, PSP.error()} - def create_stripe_session(user, line_items, payment_intent_data, opts \\ []) - - def create_stripe_session(nil, line_items, payment_intent_data, opts) do - opts = %{ - mode: "payment", - billing_address_collection: "required", - line_items: line_items, - success_url: opts[:success_url] || "#{AlgoraWeb.Endpoint.url()}/payment/success", - cancel_url: opts[:cancel_url] || "#{AlgoraWeb.Endpoint.url()}/payment/canceled", - payment_intent_data: payment_intent_data - } - - opts = - if payment_intent_data[:capture_method] == :manual do - opts - else - Map.put(opts, :invoice_creation, %{enabled: true}) - end - - PSP.Session.create(opts) - end - - def create_stripe_session(user, line_items, payment_intent_data, opts) do - with {:ok, customer} <- fetch_or_create_customer(user) do - opts = %{ - mode: "payment", - customer: customer.provider_id, - billing_address_collection: "required", - line_items: line_items, - success_url: opts[:success_url] || "#{AlgoraWeb.Endpoint.url()}/payment/success", - cancel_url: opts[:cancel_url] || "#{AlgoraWeb.Endpoint.url()}/payment/canceled", - payment_intent_data: payment_intent_data - } - - opts = - if payment_intent_data[:capture_method] == :manual do - opts - else - Map.put(opts, :invoice_creation, %{enabled: true}) - end - - PSP.Session.create(opts) - end - end - - def get_transaction_fee_pct, do: Decimal.new("0.04") - - def get_provider_fee_from_balance_transaction(txn) do - case Money.from_integer(txn.fee, txn.currency) do - %Money{} = amount -> amount - _ -> nil - end - end - - def get_provider_fee_from_invoice(%{charge: %{balance_transaction: txn}}) when not is_nil(txn) do - get_provider_fee_from_balance_transaction(txn) - end - - def get_provider_fee_from_invoice(%{id: id}) do - case PSP.Invoice.retrieve(id, expand: ["charge.balance_transaction"]) do - {:ok, invoice} -> - get_provider_fee_from_balance_transaction(invoice.charge.balance_transaction) - - _ -> - nil - end - end - - # TODO: This is not used anymore - def get_provider_fee_from_payment_intent(pi) do - with [ch] <- pi.charges.data, - {:ok, txn} <- PSP.BalanceTransaction.retrieve(ch.balance_transaction) do - get_provider_fee_from_balance_transaction(txn) - else - _ -> nil - end - end - - def get_customer_by(fields), do: Repo.get_by(Customer, fields) - - def fetch_customer_by(fields), do: Repo.fetch_by(Customer, fields) - - @spec fetch_default_payment_method(user_id :: String.t()) :: - {:ok, PaymentMethod.t()} | {:error, :not_found} - def fetch_default_payment_method(user_id) do - Repo.fetch_one( - from(pm in PaymentMethod, - join: c in assoc(pm, :customer), - where: c.user_id == ^user_id and pm.is_default == true - ) - ) - end - - @spec has_default_payment_method?(user_id :: String.t()) :: boolean() - def has_default_payment_method?(user_id) do - Repo.exists?( - from(pm in PaymentMethod, - join: c in assoc(pm, :customer), - where: c.user_id == ^user_id and pm.is_default == true - ) - ) - end - - def get_total_paid(client_id, contractor_id) do - Transaction - |> join(:inner, [t], lt in Transaction, - as: :lt, - on: t.linked_transaction_id == lt.id - ) - |> where([t], t.user_id == ^client_id) - |> where([lt: lt], lt.user_id == ^contractor_id) - |> where([t], t.type == :debit) - |> where([lt: lt], lt.type == :credit) - |> where([t], t.status == :succeeded) - |> select([t], sum(t.net_amount)) - |> limit(1) - |> Repo.one() - |> case do - nil -> Money.zero(:USD) - amount -> amount - end - end - - def get_max_paid_to_single_contractor(client_id) do - Transaction - |> join(:inner, [t], lt in Transaction, - as: :lt, - on: t.linked_transaction_id == lt.id - ) - |> where([t], t.user_id == ^client_id) - |> where([t], t.type == :debit) - |> where([lt: lt], lt.type == :credit) - |> where([t], t.status == :succeeded) - |> group_by([lt: lt], lt.user_id) - |> select([t, lt: lt], {lt.user_id, sum(t.net_amount)}) - |> order_by([t], desc: sum(t.net_amount)) - |> limit(1) - |> Repo.one() - |> case do - nil -> {nil, Money.zero(:USD)} - {user_id, amount} -> {user_id, amount} - end - end - - def list_transactions(criteria \\ []) do - criteria - |> Enum.reduce(Transaction, fn {key, value}, query -> - case value do - v when is_list(v) -> where(query, [t], field(t, ^key) in ^v) - v -> where(query, [t], field(t, ^key) == ^v) - end - end) - |> preload(linked_transaction: :user) - |> order_by([t], desc: t.inserted_at) - |> Repo.all() - end - - @spec fetch_or_create_customer(user :: User.t()) :: - {:ok, Customer.t()} | {:error, Ecto.Changeset.t()} | {:error, PSP.error()} - def fetch_or_create_customer(user) do - case fetch_customer_by(user_id: user.id) do - {:ok, customer} -> {:ok, customer} - {:error, :not_found} -> create_customer(user) - end - end - - @spec create_customer(user :: User.t()) :: - {:ok, Customer.t()} | {:error, Ecto.Changeset.t()} | {:error, PSP.error()} - def create_customer(user) do - with {:ok, stripe_customer} <- PSP.Customer.create(%{name: user.name}) do - %Customer{} - |> Customer.changeset(%{ - provider: "stripe", - provider_id: stripe_customer.id, - provider_meta: Util.normalize_struct(stripe_customer), - user_id: user.id, - name: user.name - }) - |> Repo.insert() - end - end - - @spec create_payment_method(customer :: Customer.t(), payment_method :: PSP.payment_method()) :: - {:ok, PaymentMethod.t()} | {:error, Ecto.Changeset.t()} - def create_payment_method(customer, payment_method) do - %PaymentMethod{} - |> PaymentMethod.changeset(%{ - provider: "stripe", - provider_id: payment_method.id, - provider_meta: Util.normalize_struct(payment_method), - provider_customer_id: customer.provider_id, - customer_id: customer.id, - is_default: true - }) - |> Repo.insert() - end - - @spec create_stripe_setup_session(customer :: Customer.t(), success_url :: String.t(), cancel_url :: String.t()) :: - {:ok, PSP.session()} | {:error, PSP.error()} - def create_stripe_setup_session(customer, success_url, cancel_url) do - PSP.Session.create(%{ - mode: "setup", - billing_address_collection: "required", - payment_method_types: ["card"], - success_url: success_url, - cancel_url: cancel_url, - customer: customer.provider_id - }) - end - - @spec fetch_or_create_account(user :: User.t(), country :: String.t()) :: - {:ok, Account.t()} | {:error, Ecto.Changeset.t()} - def fetch_or_create_account(user, country) do - case fetch_account(user) do - {:ok, account} -> {:ok, account} - {:error, :not_found} -> create_account(user, country) - end - end - - @spec fetch_account(user :: User.t()) :: - {:ok, Account.t()} | {:error, :not_found} - def fetch_account(user) do - Repo.fetch_by(Account, user_id: user.id) - end - - @spec get_account(user :: User.t()) :: Account.t() | nil - def get_account(user) do - Repo.get_by(Account, user_id: user.id) - end - - @spec create_account(user :: User.t(), country :: String.t()) :: - {:ok, Account.t()} | {:error, Ecto.Changeset.t()} - def create_account(user, country) do - type = PSP.ConnectCountries.account_type(country) - - with {:ok, stripe_account} <- create_stripe_account(%{country: country, type: type}) do - attrs = %{ - provider: "stripe", - provider_id: stripe_account.id, - provider_meta: Util.normalize_struct(stripe_account), - type: type, - user_id: user.id, - country: country - } - - %Account{} - |> Account.changeset(attrs) - |> Repo.insert() - end - end - - @spec create_stripe_account(attrs :: map()) :: - {:ok, PSP.account()} | {:error, PSP.error()} - defp create_stripe_account(%{country: country, type: type}) do - case PSP.Account.create(%{country: country, type: to_string(type)}) do - {:ok, account} -> {:ok, account} - {:error, _reason} -> PSP.Account.create(%{type: to_string(type)}) - end - end - - @spec create_account_link(account :: Account.t(), base_url :: String.t()) :: - {:ok, PSP.account_link()} | {:error, PSP.error()} - def create_account_link(account, base_url) do - PSP.AccountLink.create(%{ - account: account.provider_id, - refresh_url: "#{base_url}/callbacks/stripe/refresh", - return_url: "#{base_url}/callbacks/stripe/return", - type: "account_onboarding" - }) - end - - @spec create_login_link(account :: Account.t()) :: - {:ok, PSP.login_link()} | {:error, PSP.error()} - def create_login_link(account) do - PSP.LoginLink.create(account.provider_id) - end - - @spec update_account(account :: Account.t(), stripe_account :: PSP.account()) :: - {:ok, Account.t()} | {:error, Ecto.Changeset.t()} - def update_account(account, stripe_account) do - account - |> Account.changeset(%{ - provider: "stripe", - provider_id: stripe_account.id, - provider_meta: Util.normalize_struct(stripe_account), - charges_enabled: stripe_account.charges_enabled, - payouts_enabled: stripe_account.payouts_enabled, - payout_interval: stripe_account.settings.payouts.schedule.interval, - payout_speed: stripe_account.settings.payouts.schedule.delay_days, - default_currency: stripe_account.default_currency, - details_submitted: stripe_account.details_submitted, - country: stripe_account.country, - service_agreement: get_service_agreement(stripe_account) - }) - |> Repo.update() - end - - @spec refresh_stripe_account(user :: User.t()) :: - {:ok, Account.t()} | {:error, Ecto.Changeset.t()} | {:error, :not_found} | {:error, PSP.error()} - def refresh_stripe_account(user) do - with {:ok, account} <- fetch_account(user), - {:ok, stripe_account} <- PSP.Account.retrieve(account.provider_id), - {:ok, updated_account} <- update_account(account, stripe_account) do - user = Accounts.get_user(account.user_id) - - if user && stripe_account.payouts_enabled do - Accounts.update_settings(user, %{country: stripe_account.country}) - enqueue_pending_transfers(account.user_id) - end - - {:ok, updated_account} - end - end - - @spec get_service_agreement(account :: PSP.account()) :: String.t() - defp get_service_agreement(%{tos_acceptance: %{service_agreement: agreement}} = _account) when not is_nil(agreement) do - agreement - end - - @spec get_service_agreement(account :: PSP.account()) :: String.t() - defp get_service_agreement(%{capabilities: capabilities}) do - if is_nil(capabilities[:card_payments]), do: "recipient", else: "full" - end - - @spec delete_account(account :: Account.t()) :: {:ok, Account.t()} | {:error, Ecto.Changeset.t()} - def delete_account(account) do - with {:ok, _stripe_account} <- PSP.Account.delete(account.provider_id) do - Repo.delete(account) - end - end - - @spec execute_pending_transfer(credit_id :: String.t()) :: - {:ok, PSP.transfer()} | {:error, :not_found} | {:error, :duplicate_transfer_attempt} - def execute_pending_transfer(credit_id) do - with {:ok, credit} <- Repo.fetch_by(Transaction, id: credit_id, type: :credit, status: :succeeded) do - case fetch_active_account(credit.user_id) do - {:ok, account} -> - with {:ok, transaction} <- fetch_or_create_transfer(credit), - {:ok, transfer} <- execute_transfer(transaction, account) do - broadcast() - {:ok, transfer} - else - error -> - Logger.error("Failed to execute transfer: #{inspect(error)}") - error - end - - _ -> - Logger.error("Attempted to execute transfer to inactive account") - {:error, :no_active_account} - end - end - end - - def list_payable_credits(user_id) do - Repo.all( - from(cr in Transaction, - left_join: tr in Transaction, - on: - tr.user_id == cr.user_id and tr.group_id == cr.group_id and tr.type == :transfer and - tr.status in [:initialized, :processing, :succeeded], - where: cr.user_id == ^user_id, - where: cr.type == :credit, - where: cr.status == :succeeded, - where: is_nil(tr.id) - ) - ) - end - - def list_hosted_transactions(user_id, opts \\ []) do - query = - from tx in Transaction, - where: tx.type == :credit, - where: not is_nil(tx.succeeded_at), - join: ltx in assoc(tx, :linked_transaction), - join: recipient in assoc(tx, :user), - left_join: bounty in assoc(tx, :bounty), - left_join: tip in assoc(tx, :tip), - join: t in Ticket, - on: t.id == bounty.ticket_id or t.id == tip.ticket_id, - left_join: r in assoc(t, :repository), - where: ltx.user_id == ^user_id or r.user_id == ^user_id, - left_join: o in assoc(r, :user), - select: %{transaction: tx, recipient: recipient, ticket: %{t | repository: %{r | user: o}}}, - order_by: [desc: tx.succeeded_at] - - query = - if cursor = opts[:before] do - from [tx] in query, - where: {tx.succeeded_at, tx.id} < {^cursor.succeeded_at, ^cursor.id} - else - query - end - - query = from q in query, limit: ^opts[:limit] - - query - |> Repo.all() - |> Algora.Cloud.filter_featured_txs() - end - - def list_received_transactions(user_id, opts \\ []) do - query = - from tx in Transaction, - where: tx.user_id == ^user_id, - where: tx.type == :credit, - where: not is_nil(tx.succeeded_at), - join: ltx in assoc(tx, :linked_transaction), - join: sender in assoc(ltx, :user), - left_join: bounty in assoc(tx, :bounty), - left_join: tip in assoc(tx, :tip), - join: t in Ticket, - on: t.id == bounty.ticket_id or t.id == tip.ticket_id, - left_join: r in assoc(t, :repository), - left_join: o in assoc(r, :user), - select: %{transaction: tx, sender: sender, ticket: %{t | repository: %{r | user: o}}}, - order_by: [desc: tx.succeeded_at] - - query = - if cursor = opts[:before] do - from [tx] in query, - where: {tx.succeeded_at, tx.id} < {^cursor.succeeded_at, ^cursor.id} - else - query - end - - query = from q in query, limit: ^opts[:limit] - - query - |> Repo.all() - |> Algora.Cloud.filter_featured_txs() - end - - @spec enqueue_pending_transfers(user_id :: String.t()) :: {:ok, nil} | {:error, term()} - def enqueue_pending_transfers(user_id) do - Repo.tx(fn -> - with {:ok, _account} <- fetch_active_account(user_id), - credits = list_payable_credits(user_id), - :ok <- - Enum.reduce_while(credits, :ok, fn credit, :ok -> - case %{credit_id: credit.id} - |> Jobs.ExecutePendingTransfer.new() - |> Oban.insert() do - {:ok, _job} -> {:cont, :ok} - error -> {:halt, error} - end - end) do - {:ok, nil} - else - {:error, reason} -> - Logger.error("Failed to execute pending transfers: #{inspect(reason)}") - {:error, reason} - end - end) - end - - @spec fetch_active_account(user_id :: String.t()) :: {:ok, Account.t()} | {:error, :no_active_account} - def fetch_active_account(user_id) do - case Repo.fetch_by(Account, user_id: user_id, provider: "stripe", payouts_enabled: true) do - {:ok, account} -> {:ok, account} - {:error, :not_found} -> {:error, :no_active_account} - end - end - - def fetch_or_create_transfer(%Transaction{} = credit) do - idempotency_key = "credit_#{credit.id}" - - case Repo.get_by(Transaction, idempotency_key: idempotency_key) do - nil -> - %Transaction{} - |> change(%{ - id: Nanoid.generate(), - provider: "stripe", - type: :transfer, - status: :initialized, - tip_id: credit.tip_id, - bounty_id: credit.bounty_id, - contract_id: credit.contract_id, - claim_id: credit.claim_id, - user_id: credit.user_id, - gross_amount: credit.net_amount, - net_amount: credit.net_amount, - total_fee: Money.zero(:USD), - group_id: credit.group_id, - idempotency_key: idempotency_key - }) - |> Algora.Validations.validate_positive(:gross_amount) - |> Algora.Validations.validate_positive(:net_amount) - |> unique_constraint(:idempotency_key) - |> foreign_key_constraint(:user_id) - |> foreign_key_constraint(:tip_id) - |> foreign_key_constraint(:bounty_id) - |> foreign_key_constraint(:contract_id) - |> foreign_key_constraint(:claim_id) - |> Repo.insert() - - transfer -> - {:ok, transfer} - end - end - - def execute_transfer(%Transaction{} = transaction, account) do - charge = Repo.get_by(Transaction, type: :charge, status: :succeeded, group_id: transaction.group_id) - - transfer_params = - %{ - amount: MoneyUtils.to_minor_units(transaction.net_amount), - currency: MoneyUtils.to_stripe_currency(transaction.net_amount), - destination: account.provider_id, - metadata: %{"version" => metadata_version()} - } - |> Map.merge(if transaction.group_id, do: %{transfer_group: transaction.group_id}, else: %{}) - |> Map.merge(if charge && charge.provider_id, do: %{source_transaction: charge.provider_id}, else: %{}) - - case PSP.Transfer.create(transfer_params, %{idempotency_key: transaction.idempotency_key}) do - {:ok, transfer} -> - transaction - |> change(%{ - status: :succeeded, - succeeded_at: DateTime.utc_now(), - provider_id: transfer.id, - provider_transfer_id: transfer.id, - provider_meta: Util.normalize_struct(transfer) - }) - |> Repo.update() - - {:ok, transfer} - - {:error, error} -> - transaction - |> change(%{status: :failed}) - |> Repo.update() - - {:error, error} - end - end - - def process_charge(%Stripe.Event{type: "charge.succeeded", data: %{object: %Stripe.Charge{}}}, group_id) - when not is_binary(group_id) do - {:error, :invalid_group_id} - end - - def process_charge( - "charge.succeeded", - %Stripe.Charge{id: charge_id, captured: false, payment_intent: payment_intent_id}, - group_id - ) do - Repo.tx(fn -> - Repo.update_all(from(t in Transaction, where: t.group_id == ^group_id, where: t.type == :charge), - set: [ - status: :requires_capture, - provider: "stripe", - provider_id: charge_id, - provider_charge_id: charge_id, - provider_payment_intent_id: payment_intent_id - ] - ) - - broadcast() - {:ok, nil} - end) - end - - def process_charge( - "charge.captured", - %Stripe.Charge{id: charge_id, captured: true, payment_intent: payment_intent_id}, - group_id - ) do - Repo.tx(fn -> - Repo.update_all(from(t in Transaction, where: t.group_id == ^group_id, where: t.type == :charge), - set: [ - status: :succeeded, - provider: "stripe", - provider_id: charge_id, - provider_charge_id: charge_id, - provider_payment_intent_id: payment_intent_id - ] - ) - - Repo.update_all(from(t in Transaction, where: t.group_id == ^group_id, where: t.type != :charge), - set: [ - status: :requires_release, - provider: "stripe", - provider_id: charge_id, - provider_charge_id: charge_id, - provider_payment_intent_id: payment_intent_id - ] - ) - - broadcast() - {:ok, nil} - end) - end - - def process_charge( - "charge.succeeded", - %Stripe.Charge{id: charge_id, captured: true, payment_intent: payment_intent_id}, - group_id - ) do - Repo.tx(fn -> - {_, txs} = - Repo.update_all(from(t in Transaction, where: t.group_id == ^group_id, select: t), - set: [ - status: :processing, - provider: "stripe", - provider_id: charge_id, - provider_charge_id: charge_id, - provider_payment_intent_id: payment_intent_id - ] - ) - - bounty_ids = txs |> Enum.map(& &1.bounty_id) |> Enum.reject(&is_nil/1) |> Enum.uniq() - - bounties = - from(b in Bounty, - where: b.id in ^bounty_ids, - join: u in assoc(b, :owner), - join: t in assoc(b, :ticket), - select: %{b | ticket: t, owner: u} - ) - |> Repo.all() - |> Map.new(&{&1.id, &1}) - - {auto_bounty_ids, manual_bounty_ids} = - Enum.split_with(bounty_ids, fn id -> - bounty = bounties[id] - bounty && bounty.contract_type != :marketplace - end) - - tip_ids = txs |> Enum.map(& &1.tip_id) |> Enum.reject(&is_nil/1) |> Enum.uniq() - claim_ids = txs |> Enum.map(& &1.claim_id) |> Enum.reject(&is_nil/1) |> Enum.uniq() - job_ids = txs |> Enum.map(& &1.job_id) |> Enum.reject(&is_nil/1) |> Enum.uniq() - - Repo.update_all(from(b in Bounty, where: b.id in ^auto_bounty_ids), set: [status: :paid]) - Repo.update_all(from(t in Tip, where: t.id in ^tip_ids), set: [status: :paid]) - # TODO: add and use a new "paid" status for claims - Repo.update_all(from(c in Claim, where: c.id in ^claim_ids), set: [status: :approved]) - - {_, job_postings} = - Repo.update_all(from(j in JobPosting, where: j.id in ^job_ids, select: j), set: [status: :active]) - - job_postings = Repo.preload(job_postings, :user) - - Repo.update_all(from(u in User, where: u.id in ^Enum.map(job_postings, & &1.user_id)), - set: [hiring_subscription: :active] - ) - - for job <- job_postings do - Algora.Activities.alert("Job payment received! #{job.company_name} #{job.email} #{job.url}", :critical) - end - - auto_txs = - Enum.filter(txs, fn tx -> - bounty = bounties[tx.bounty_id] - - manual? = tx.bounty_id in manual_bounty_ids - - if tx.type == :credit and manual? do - Algora.Activities.alert( - "Contract payment received. URL: #{AlgoraWeb.Endpoint.url()}/#{bounty.owner.handle}/contracts/#{bounty.id}", - :info - ) - end - - tx.type != :credit or not manual? - end) - - Repo.update_all( - from(t in Transaction, where: t.group_id == ^group_id and t.id in ^Enum.map(auto_txs, & &1.id), select: t), - set: [status: :succeeded, succeeded_at: DateTime.utc_now()] - ) - - activities_result = - auto_txs - |> Enum.filter(&(&1.type == :credit)) - |> Enum.reduce_while(:ok, fn tx, :ok -> - case Repo.insert_activity(tx, %{type: :transaction_succeeded, notify_users: [tx.user_id]}) do - {:ok, _} -> {:cont, :ok} - error -> {:halt, error} - end - end) - - jobs_result = - auto_txs - |> Enum.filter(&(&1.type == :credit)) - |> Enum.reduce_while(:ok, fn credit, :ok -> - case fetch_active_account(credit.user_id) do - {:ok, _account} -> - case %{credit_id: credit.id} - |> Jobs.ExecutePendingTransfer.new() - |> Oban.insert() do - {:ok, _job} -> {:cont, :ok} - error -> {:halt, error} - end - - {:error, :no_active_account} -> - case %{credit_id: credit.id} - |> PromptPayoutConnect.new() - |> Oban.insert() do - {:ok, _job} -> {:cont, :ok} - error -> {:halt, error} - end - end - end) - - with txs when txs != [] <- txs, - :ok <- activities_result, - :ok <- jobs_result do - broadcast() - {:ok, nil} - else - {:error, reason} -> - Logger.error("Failed to update transactions: #{inspect(reason)}") - {:error, :failed_to_update_transactions} - - error -> - Logger.error("Failed to update transactions: #{inspect(error)}") - {:error, :failed_to_update_transactions} - end - end) - end - - def process_release( - %Stripe.Charge{id: charge_id, captured: true, payment_intent: payment_intent_id}, - group_id, - amount, - recipient - ) do - Repo.tx(fn -> - tx = Repo.get_by(Transaction, group_id: group_id, type: :charge, status: :succeeded) - - user = Repo.get_by(User, id: tx.user_id) - bounty = Repo.get_by(Bounty, id: tx.bounty_id) - - Algora.Activities.alert( - "Release #{amount} escrow to #{recipient.handle} for #{AlgoraWeb.Endpoint.url()}/#{user.handle}/contracts/#{bounty.id}", - :critical - ) - - debit_id = Nanoid.generate() - credit_id = Nanoid.generate() - - with {:ok, debit0} <- Repo.fetch_by(Transaction, group_id: group_id, type: :debit, status: :requires_release), - {:ok, _} <- - debit0 - |> change(%{ - net_amount: Money.sub!(debit0.net_amount, amount), - gross_amount: Money.sub!(debit0.gross_amount, amount) - }) - |> Repo.update(), - {:ok, credit0} <- Repo.fetch_by(Transaction, group_id: group_id, type: :credit, status: :requires_release), - {:ok, _} <- - credit0 - |> change(%{ - net_amount: Money.add!(credit0.net_amount, amount), - gross_amount: Money.add!(credit0.gross_amount, amount) - }) - |> Repo.update(), - {:ok, _debit} <- - Repo.insert(%Transaction{ - id: debit_id, - provider: "stripe", - provider_id: charge_id, - provider_charge_id: charge_id, - provider_payment_intent_id: payment_intent_id, - type: :debit, - status: :succeeded, - succeeded_at: DateTime.utc_now(), - bounty_id: tx.bounty_id, - user_id: tx.user_id, - gross_amount: amount, - net_amount: amount, - total_fee: Money.zero(:USD), - linked_transaction_id: credit_id, - group_id: group_id - }), - {:ok, _credit} <- - Repo.insert(%Transaction{ - id: credit_id, - provider: "stripe", - provider_id: charge_id, - provider_charge_id: charge_id, - provider_payment_intent_id: payment_intent_id, - type: :credit, - status: :initialized, - succeeded_at: DateTime.utc_now(), - bounty_id: tx.bounty_id, - user_id: recipient.id, - gross_amount: amount, - net_amount: amount, - total_fee: Money.zero(:USD), - linked_transaction_id: debit_id, - group_id: group_id - }) do - {:ok, nil} - end - end) - end - - def list_featured_transactions do - tx_query = - from(tx in Transaction, - where: tx.type == :credit, - where: not is_nil(tx.succeeded_at), - join: u in assoc(tx, :user), - left_join: b in assoc(tx, :bounty), - left_join: tip in assoc(tx, :tip), - join: t in Ticket, - on: t.id == b.ticket_id or t.id == tip.ticket_id, - left_join: r in assoc(t, :repository), - left_join: o in assoc(r, :user), - join: ltx in assoc(tx, :linked_transaction), - join: ltx_user in assoc(ltx, :user), - select: %{ - id: tx.id, - succeeded_at: tx.succeeded_at, - net_amount: tx.net_amount, - bounty_id: b.id, - tip_id: tip.id, - user: u, - ticket: %{t | repository: %{r | user: o}}, - linked_transaction: %{ltx | user: ltx_user} - } - ) - - # case Algora.Settings.get_featured_transactions() do - # ids when is_list(ids) and ids != [] -> - # where(tx_query, [tx], tx.id in ^ids) - # _ -> - tx_query = - tx_query - |> where([tx], tx.succeeded_at > ago(2, "week")) - |> order_by([tx], desc: tx.net_amount) - |> limit(10) - - # end - - transactions = - tx_query - |> Repo.all() - |> Algora.Cloud.filter_featured_txs() - |> Enum.reduce(%{}, fn tx, acc -> - # Group transactions by bounty_id when repository is null and bounty_id exists - if tx.bounty_id && !tx.ticket.repository do - Map.update(acc, tx.bounty_id, tx, fn existing -> - %{existing | net_amount: Money.add!(existing.net_amount, tx.net_amount)} - end) - else - # Keep other transactions as is - Map.put(acc, tx.id, tx) - end - end) - |> Map.values() - - Enum.sort_by(transactions, & &1.succeeded_at, {:desc, DateTime}) - end -end diff --git a/lib/algora/payments/schemas/account.ex b/lib/algora/payments/schemas/account.ex deleted file mode 100644 index 7f0c8c0e4..000000000 --- a/lib/algora/payments/schemas/account.ex +++ /dev/null @@ -1,66 +0,0 @@ -defmodule Algora.Payments.Account do - @moduledoc false - use Algora.Schema - - alias Algora.Activities.Activity - - @derive {Inspect, except: [:provider_meta]} - typed_schema "accounts" do - field :provider, :string, null: false - field :provider_id, :string, null: false - field :provider_meta, :map - - field :name, :string - field :details_submitted, :boolean, default: false, null: false - field :charges_enabled, :boolean, default: false, null: false - field :payouts_enabled, :boolean, default: false, null: false - field :payout_interval, :string - field :payout_speed, :integer - field :default_currency, :string - field :service_agreement, :string - field :country, :string - field :type, Ecto.Enum, values: [:standard, :express], null: false - field :stale, :boolean, default: false, null: false - - belongs_to :user, Algora.Accounts.User, null: false - - has_many :activities, {"account_activities", Activity}, foreign_key: :assoc_id - timestamps() - end - - def changeset(account, attrs) do - account - |> cast(attrs, [ - :provider, - :provider_id, - :provider_meta, - :details_submitted, - :charges_enabled, - :payouts_enabled, - :payout_interval, - :payout_speed, - :default_currency, - :service_agreement, - :country, - :type, - :stale, - :user_id - ]) - |> validate_required([ - :provider, - :provider_id, - :provider_meta, - :details_submitted, - :charges_enabled, - :payouts_enabled, - :country, - :type, - :stale, - :user_id - ]) - |> validate_inclusion(:type, [:standard, :express]) - |> validate_inclusion(:country, Algora.PSP.ConnectCountries.list_codes()) - |> foreign_key_constraint(:user_id) - |> generate_id() - end -end diff --git a/lib/algora/payments/schemas/customer.ex b/lib/algora/payments/schemas/customer.ex deleted file mode 100644 index f1f6d0dfe..000000000 --- a/lib/algora/payments/schemas/customer.ex +++ /dev/null @@ -1,34 +0,0 @@ -defmodule Algora.Payments.Customer do - @moduledoc false - use Algora.Schema - - alias Algora.Activities.Activity - - @derive {Inspect, except: [:provider_meta]} - typed_schema "customers" do - field :provider, :string - field :provider_id, :string - field :provider_meta, :map - - field :name, :string - - belongs_to :user, Algora.Accounts.User - - has_one :default_payment_method, Algora.Payments.PaymentMethod, - foreign_key: :customer_id, - where: [is_default: true] - - has_many :activities, {"customer_activities", Activity}, foreign_key: :assoc_id - - timestamps() - end - - def changeset(customer, attrs) do - customer - |> cast(attrs, [:user_id, :provider, :provider_id, :provider_meta, :name]) - |> generate_id() - |> validate_required([:user_id, :provider, :provider_id, :provider_meta, :name]) - |> unique_constraint(:user_id) - |> foreign_key_constraint(:user_id) - end -end diff --git a/lib/algora/payments/schemas/payment_method.ex b/lib/algora/payments/schemas/payment_method.ex deleted file mode 100644 index 474b9e70a..000000000 --- a/lib/algora/payments/schemas/payment_method.ex +++ /dev/null @@ -1,28 +0,0 @@ -defmodule Algora.Payments.PaymentMethod do - @moduledoc false - use Algora.Schema - - alias Algora.Activities.Activity - - typed_schema "payment_methods" do - field :provider, :string - field :provider_id, :string - field :provider_meta, :map - field :provider_customer_id, :string - field :is_default, :boolean, default: true - - belongs_to :customer, Algora.Payments.Customer - - has_many :activities, {"platform_transaction_activities", Activity}, foreign_key: :assoc_id - - timestamps() - end - - def changeset(payment_method, attrs) do - payment_method - |> cast(attrs, [:provider, :provider_id, :provider_meta, :provider_customer_id, :is_default, :customer_id]) - |> generate_id() - |> validate_required([:provider, :provider_id, :provider_meta, :provider_customer_id, :is_default, :customer_id]) - |> foreign_key_constraint(:customer_id) - end -end diff --git a/lib/algora/payments/schemas/platform_transaction.ex b/lib/algora/payments/schemas/platform_transaction.ex deleted file mode 100644 index 0edda0f8b..000000000 --- a/lib/algora/payments/schemas/platform_transaction.ex +++ /dev/null @@ -1,27 +0,0 @@ -defmodule Algora.Payments.PlatformTransaction do - @moduledoc false - use Algora.Schema - - alias Algora.Activities.Activity - - @derive {Inspect, except: [:provider_meta]} - typed_schema "platform_transactions" do - field :provider, :string - field :provider_id, :string - field :provider_meta, :map - - field :succeeded_at, :utc_datetime_usec - field :amount, Algora.Types.Money - field :type, :string - field :reporting_category, :string - - has_many :activities, {"platform_transaction_activities", Activity}, foreign_key: :assoc_id - timestamps() - end - - def changeset(transaction, attrs) do - transaction - |> cast(attrs, [:provider, :provider_id, :provider_meta, :amount]) - |> validate_required([:provider, :provider_id, :provider_meta, :amount]) - end -end diff --git a/lib/algora/payments/schemas/transaction.ex b/lib/algora/payments/schemas/transaction.ex deleted file mode 100644 index 7d2e08902..000000000 --- a/lib/algora/payments/schemas/transaction.ex +++ /dev/null @@ -1,91 +0,0 @@ -defmodule Algora.Payments.Transaction do - @moduledoc false - use Algora.Schema - - alias Algora.Activities.Activity - alias Algora.Contracts.Contract - alias Algora.Types.Money - - @transaction_types [:charge, :transfer, :reversal, :debit, :credit, :deposit, :withdrawal] - @transaction_statuses [:initialized, :processing, :requires_capture, :requires_release, :succeeded, :failed, :canceled] - - @derive {Inspect, except: [:provider_meta]} - typed_schema "transactions" do - field :provider, :string - field :provider_id, :string - field :provider_charge_id, :string - field :provider_payment_intent_id, :string - field :provider_transfer_id, :string - field :provider_invoice_id, :string - field :provider_balance_transaction_id, :string - field :provider_meta, :map - - field :gross_amount, Money - field :net_amount, Money - field :total_fee, Money - field :provider_fee, Money - field :line_items, {:array, :map} - - field :type, Ecto.Enum, values: @transaction_types - field :status, Ecto.Enum, values: @transaction_statuses - field :succeeded_at, :utc_datetime_usec - field :reversed_at, :utc_datetime_usec - field :group_id, :string - field :idempotency_key, :string - - belongs_to :timesheet, Algora.Contracts.Timesheet - belongs_to :contract, Contract - belongs_to :original_contract, Contract - belongs_to :user, Algora.Accounts.User - belongs_to :claim, Algora.Bounties.Claim - belongs_to :bounty, Algora.Bounties.Bounty - belongs_to :tip, Algora.Bounties.Tip - belongs_to :job, Algora.Jobs.JobPosting - belongs_to :linked_transaction, Algora.Payments.Transaction - - has_many :activities, {"transaction_activities", Activity}, foreign_key: :assoc_id - - timestamps() - end - - def changeset(transaction, attrs) do - transaction - |> cast(attrs, [ - :id, - :provider, - :provider_id, - :provider_meta, - :provider_invoice_id, - :gross_amount, - :net_amount, - :total_fee, - :type, - :status, - :timesheet_id, - :contract_id, - :original_contract_id, - :user_id, - :succeeded_at, - :idempotency_key - ]) - |> validate_required([ - :id, - :provider, - :provider_id, - :provider_meta, - :gross_amount, - :net_amount, - :total_fee, - :type, - :status, - :contract_id, - :original_contract_id, - :user_id - ]) - |> unique_constraint([:idempotency_key]) - |> foreign_key_constraint(:user_id) - |> foreign_key_constraint(:contract_id) - |> foreign_key_constraint(:original_contract_id) - |> foreign_key_constraint(:timesheet_id) - end -end diff --git a/lib/algora/psp/connect_countries.ex b/lib/algora/psp/connect_countries.ex deleted file mode 100644 index d5e4760b3..000000000 --- a/lib/algora/psp/connect_countries.ex +++ /dev/null @@ -1,399 +0,0 @@ -defmodule Algora.PSP.ConnectCountries do - @moduledoc false - - @spec list() :: [{String.t(), String.t()}] - def list, - do: [ - {"Albania", "AL"}, - {"Algeria", "DZ"}, - {"Angola", "AO"}, - {"Antigua and Barbuda", "AG"}, - {"Argentina", "AR"}, - {"Armenia", "AM"}, - {"Australia", "AU"}, - {"Austria", "AT"}, - {"Azerbaijan", "AZ"}, - {"Bahamas", "BS"}, - {"Bahrain", "BH"}, - {"Bangladesh", "BD"}, - {"Belgium", "BE"}, - {"Benin", "BJ"}, - {"Bhutan", "BT"}, - {"Bolivia", "BO"}, - {"Bosnia and Herzegovina", "BA"}, - {"Botswana", "BW"}, - {"Brunei", "BN"}, - {"Bulgaria", "BG"}, - {"Cambodia", "KH"}, - {"Canada", "CA"}, - {"Chile", "CL"}, - {"Colombia", "CO"}, - {"Costa Rica", "CR"}, - {"Croatia", "HR"}, - {"Cyprus", "CY"}, - {"Czech Republic", "CZ"}, - {"Denmark", "DK"}, - {"Dominican Republic", "DO"}, - {"Ecuador", "EC"}, - {"Egypt", "EG"}, - {"El Salvador", "SV"}, - {"Estonia", "EE"}, - {"Ethiopia", "ET"}, - {"Finland", "FI"}, - {"France", "FR"}, - {"Gabon", "GA"}, - {"Gambia", "GM"}, - {"Germany", "DE"}, - {"Ghana", "GH"}, - {"Gibraltar", "GI"}, - {"Greece", "GR"}, - {"Guatemala", "GT"}, - {"Guyana", "GY"}, - {"Hong Kong", "HK"}, - {"Hungary", "HU"}, - {"Iceland", "IS"}, - {"India", "IN"}, - {"Indonesia", "ID"}, - {"Ireland", "IE"}, - {"Israel", "IL"}, - {"Italy", "IT"}, - {"Ivory Coast", "CI"}, - {"Jamaica", "JM"}, - {"Japan", "JP"}, - {"Jordan", "JO"}, - {"Kazakhstan", "KZ"}, - {"Kenya", "KE"}, - {"Kuwait", "KW"}, - {"Laos", "LA"}, - {"Latvia", "LV"}, - {"Liechtenstein", "LI"}, - {"Lithuania", "LT"}, - {"Luxembourg", "LU"}, - {"Macao", "MO"}, - {"Macedonia", "MK"}, - {"Madagascar", "MG"}, - {"Malaysia", "MY"}, - {"Malta", "MT"}, - {"Mauritius", "MU"}, - {"Mexico", "MX"}, - {"Moldova", "MD"}, - {"Monaco", "MC"}, - {"Mongolia", "MN"}, - {"Morocco", "MA"}, - {"Mozambique", "MZ"}, - {"Namibia", "NA"}, - {"Netherlands", "NL"}, - {"New Zealand", "NZ"}, - {"Nigeria", "NG"}, - {"Norway", "NO"}, - {"Oman", "OM"}, - {"Pakistan", "PK"}, - {"Panama", "PA"}, - {"Paraguay", "PY"}, - {"Peru", "PE"}, - {"Philippines", "PH"}, - {"Poland", "PL"}, - {"Portugal", "PT"}, - {"Qatar", "QA"}, - {"Romania", "RO"}, - {"Rwanda", "RW"}, - {"Saint Lucia", "LC"}, - {"San Marino", "SM"}, - {"Saudi Arabia", "SA"}, - {"Senegal", "SN"}, - {"Serbia", "RS"}, - {"Singapore", "SG"}, - {"Slovakia", "SK"}, - {"Slovenia", "SI"}, - {"South Africa", "ZA"}, - {"South Korea", "KR"}, - {"Spain", "ES"}, - {"Sri Lanka", "LK"}, - {"Sweden", "SE"}, - {"Switzerland", "CH"}, - {"Taiwan", "TW"}, - {"Tanzania", "TZ"}, - {"Thailand", "TH"}, - {"Trinidad and Tobago", "TT"}, - {"Tunisia", "TN"}, - {"Turkey", "TR"}, - {"United Arab Emirates", "AE"}, - {"United Kingdom", "GB"}, - {"United States", "US"}, - {"Uruguay", "UY"}, - {"Uzbekistan", "UZ"}, - {"Vietnam", "VN"} - ] - - def count, do: length(list()) - - @spec from_code(String.t()) :: String.t() - def from_code(code) do - case Enum.find(list(), &(elem(&1, 1) == code)) do - nil -> code - {name, _} -> name - end - end - - def abbr_from_code("US"), do: "US" - - def abbr_from_code(code) do - case Enum.find(list(), &(elem(&1, 1) == code)) do - nil -> code - {name, _} -> name - end - end - - @spec list_codes() :: [String.t()] - def list_codes, do: Enum.map(list(), &elem(&1, 1)) - - @spec account_type(String.t()) :: :standard | :express - def account_type("BR"), do: :standard - def account_type(_), do: :express - - @spec regions() :: %{String.t() => [String.t()]} - def regions do - %{ - "LATAM" => [ - "BR", - "MX", - "CO", - "AR", - "PE", - "VE", - "CL", - "GT", - "EC", - "BO", - "HT", - "DO", - "HN", - "CU", - "PY", - "NI", - "SV", - "CR", - "PA", - "UY", - "JM", - "TT", - "GY", - "SR", - "BZ", - "BS", - "BB", - "LC", - "GD", - "VC", - "AG", - "DM", - "KN" - ], - "APAC" => [ - "AU", - "BD", - "BN", - "BT", - "HK", - "ID", - "IN", - "JP", - "KH", - "KR", - "LA", - "LK", - "MO", - "MM", - "MN", - "MY", - "NZ", - "PH", - "PK", - "SG", - "TH", - "TW", - "VN" - ], - "EMEA" => [ - "AE", - "AL", - "AM", - "AO", - "AT", - "AZ", - "BA", - "BE", - "BG", - "BH", - "BJ", - "BW", - "CH", - "CI", - "CY", - "CZ", - "DE", - "DK", - "DZ", - "EE", - "EG", - "ES", - "ET", - "FI", - "FR", - "GA", - "GB", - "GH", - "GI", - "GM", - "GR", - "HR", - "HU", - "IE", - "IL", - "IS", - "IT", - "JO", - "KE", - "KW", - "KZ", - "LI", - "LT", - "LU", - "LV", - "MA", - "MC", - "MD", - "MG", - "MK", - "MT", - "MU", - "MZ", - "NA", - "NG", - "NL", - "NO", - "OM", - "PL", - "PT", - "QA", - "RO", - "RS", - "RW", - "SA", - "SE", - "SI", - "SK", - "SM", - "SN", - "TN", - "TR", - "TZ", - "ZA" - ], - "AMERICAS" => [ - "US", - "CA", - "BR", - "MX", - "CO", - "AR", - "PE", - "VE", - "CL", - "GT", - "EC", - "BO", - "HT", - "DO", - "HN", - "CU", - "PY", - "NI", - "SV", - "CR", - "PA", - "UY", - "JM", - "TT", - "GY", - "SR", - "BZ", - "BS", - "BB", - "LC", - "GD", - "VC", - "AG", - "DM", - "KN" - ], - "NA" => [ - "US", - "CA" - ], - "EU" => [ - "AE", - "AL", - "AM", - "AT", - "AZ", - "BA", - "BE", - "BG", - "BH", - "CH", - "CY", - "CZ", - "DE", - "DK", - "EE", - "ES", - "FI", - "FR", - "GB", - "GI", - "GR", - "HR", - "HU", - "IE", - "IS", - "IT", - "JO", - "KW", - "KZ", - "LI", - "LT", - "LU", - "LV", - "MC", - "MD", - "ME", - "MK", - "MT", - "NL", - "NO", - "OM", - "PL", - "PT", - "QA", - "RO", - "RS", - "SA", - "SE", - "SI", - "SK", - "SM", - "TN", - "TR", - "XK" - ] - } - end - - def get_countries(region) do - case regions()[region] do - nil -> [] - countries -> countries - end - end -end diff --git a/lib/algora/psp/psp.ex b/lib/algora/psp/psp.ex deleted file mode 100644 index aeb5071ca..000000000 --- a/lib/algora/psp/psp.ex +++ /dev/null @@ -1,195 +0,0 @@ -defmodule Algora.PSP do - @moduledoc """ - Payment Service Provider (PSP) interface module. - - This module serves as an abstraction layer for payment service provider interactions. - Currently, it implements Stripe as the default payment processor, but is designed - to be extensible for supporting multiple payment providers in the future (e.g., PayPal). - - The module provides a unified interface for common payment operations such as: - - Invoice management - - Payment processing - - Transfer operations - - Checkout sessions - - Payment method handling - - Setup intents - - Each submodule corresponds to a specific payment service functionality and delegates - to the configured payment provider client (currently Stripe). - """ - - def client(module) do - :algora - |> Application.get_env(:stripe_client, Stripe) - |> Module.concat(module |> Module.split() |> List.last()) - end - - @type error :: Stripe.Error.t() - - @type metadata :: %{ - optional(String.t()) => String.t() - } - - @type invoice :: Algora.PSP.Invoice.t() - defmodule Invoice do - @moduledoc false - @type t :: Stripe.Invoice.t() - - @spec create(params, options) :: {:ok, t} | {:error, Algora.PSP.error()} - when params: - %{ - optional(:auto_advance) => boolean, - :metadata => Algora.PSP.metadata(), - :customer => Stripe.id() | Stripe.Customer.t() - } - | %{}, - options: %{ - :idempotency_key => String.t() - } - def create(params, opts), do: Algora.PSP.client(__MODULE__).create(params, Keyword.new(opts)) - - @spec pay(Stripe.id() | t, params) :: {:ok, t} | {:error, Algora.PSP.error()} - when params: - %{ - optional(:off_session) => boolean, - optional(:payment_method) => String.t() - } - | %{} - def pay(invoice_id, params), do: Algora.PSP.client(__MODULE__).pay(invoice_id, params) - - def retrieve(id), do: Algora.PSP.client(__MODULE__).retrieve(id) - def retrieve(id, opts), do: Algora.PSP.client(__MODULE__).retrieve(id, Keyword.new(opts)) - end - - @type invoiceitem :: Algora.PSP.Invoiceitem.t() - defmodule Invoiceitem do - @moduledoc false - @type t :: Stripe.Invoiceitem.t() - - @spec create(params, options) :: {:ok, t} | {:error, Algora.PSP.error()} - when params: - %{ - optional(:amount) => integer, - :currency => String.t(), - :customer => Stripe.id() | Stripe.Customer.t(), - optional(:description) => String.t(), - optional(:invoice) => Stripe.id() | Stripe.Invoice.t() - } - | %{}, - options: %{ - :idempotency_key => String.t() - } - def create(params, opts), do: Algora.PSP.client(__MODULE__).create(params, Keyword.new(opts)) - end - - @type transfer :: Algora.PSP.Transfer.t() - defmodule Transfer do - @moduledoc false - @type t :: Stripe.Transfer.t() - - @spec create(params, options) :: {:ok, t} | {:error, Algora.PSP.error()} - when params: %{ - :amount => pos_integer, - :currency => String.t(), - :destination => String.t(), - optional(:metadata) => Algora.PSP.metadata(), - optional(:source_transaction) => String.t(), - optional(:transfer_group) => String.t(), - optional(:description) => String.t(), - optional(:source_type) => String.t() - }, - options: %{ - :idempotency_key => String.t() - } - def create(params, opts), do: Algora.PSP.client(__MODULE__).create(params, Keyword.new(opts)) - end - - @type session :: Algora.PSP.Session.t() - defmodule Session do - @moduledoc false - - @type t :: Stripe.Session.t() - @type line_item_data :: Stripe.Session.line_item_data() - @type payment_intent_data :: Stripe.Session.payment_intent_data() - - def create(params), do: Algora.PSP.client(__MODULE__).create(params) - end - - @type payment_method :: Algora.PSP.PaymentMethod.t() - defmodule PaymentMethod do - @moduledoc false - - @type t :: Stripe.PaymentMethod.t() - def attach(params), do: Algora.PSP.client(__MODULE__).attach(params) - def retrieve(id), do: Algora.PSP.client(__MODULE__).retrieve(id) - end - - @type payment_intent :: Algora.PSP.PaymentIntent.t() - defmodule PaymentIntent do - @moduledoc false - - @type t :: Stripe.PaymentIntent.t() - def create(params), do: Algora.PSP.client(__MODULE__).create(params) - def capture(id, params \\ %{}), do: Algora.PSP.client(__MODULE__).capture(id, params) - end - - @type charge :: Algora.PSP.Charge.t() - defmodule Charge do - @moduledoc false - - @type t :: Stripe.Charge.t() - def retrieve(id), do: Algora.PSP.client(__MODULE__).retrieve(id) - def retrieve(id, params), do: Algora.PSP.client(__MODULE__).retrieve(id, params) - end - - @type setup_intent :: Algora.PSP.SetupIntent.t() - defmodule SetupIntent do - @moduledoc false - - @type t :: Stripe.SetupIntent.t() - def retrieve(id, params), do: Algora.PSP.client(__MODULE__).retrieve(id, params) - end - - @type customer :: Algora.PSP.Customer.t() - defmodule Customer do - @moduledoc false - - @type t :: Stripe.Customer.t() - def retrieve(id), do: Algora.PSP.client(__MODULE__).retrieve(id) - def create(params), do: Algora.PSP.client(__MODULE__).create(params) - end - - @type account :: Algora.PSP.Account.t() - defmodule Account do - @moduledoc false - - @type t :: Stripe.Account.t() - def retrieve(id), do: Algora.PSP.client(__MODULE__).retrieve(id) - def create(params), do: Algora.PSP.client(__MODULE__).create(params) - def delete(id), do: Algora.PSP.client(__MODULE__).delete(id) - end - - @type account_link :: Algora.PSP.AccountLink.t() - defmodule AccountLink do - @moduledoc false - - @type t :: Stripe.AccountLink.t() - def create(params), do: Algora.PSP.client(__MODULE__).create(params) - end - - @type login_link :: Algora.PSP.LoginLink.t() - defmodule LoginLink do - @moduledoc false - - @type t :: Stripe.LoginLink.t() - def create(id, params \\ %{}), do: Algora.PSP.client(__MODULE__).create(id, params) - end - - @type balance_transaction :: Algora.PSP.BalanceTransaction.t() - defmodule BalanceTransaction do - @moduledoc false - - @type t :: Stripe.BalanceTransaction.t() - def retrieve(id), do: Algora.PSP.client(__MODULE__).retrieve(id) - end -end diff --git a/lib/algora/rate_limit.ex b/lib/algora/rate_limit.ex deleted file mode 100644 index 65b952ee2..000000000 --- a/lib/algora/rate_limit.ex +++ /dev/null @@ -1,4 +0,0 @@ -defmodule Algora.RateLimit do - @moduledoc false - use Hammer, backend: :ets -end diff --git a/lib/algora/release.ex b/lib/algora/release.ex deleted file mode 100644 index c4cfbf3cd..000000000 --- a/lib/algora/release.ex +++ /dev/null @@ -1,28 +0,0 @@ -defmodule Algora.Release do - @moduledoc """ - Used for executing DB release tasks when run in production without Mix - installed. - """ - @app :algora - - def migrate do - load_app() - - for repo <- repos() do - {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) - end - end - - def rollback(repo, version) do - load_app() - {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) - end - - defp repos do - Application.fetch_env!(@app, :ecto_repos) - end - - defp load_app do - Application.load(@app) - end -end diff --git a/lib/algora/repo.ex b/lib/algora/repo.ex deleted file mode 100644 index c8612efa7..000000000 --- a/lib/algora/repo.ex +++ /dev/null @@ -1,136 +0,0 @@ -defmodule Algora.Repo do - use Ecto.Repo, - otp_app: :algora, - adapter: Ecto.Adapters.Postgres - - alias Algora.Activities.Activity - alias Algora.Activities.Notifier - - require Ecto.Query - - @spec fetch_one(Ecto.Queryable.t(), Keyword.t()) :: - {:ok, struct()} | {:error, :not_found} - def fetch_one(queryable, opts \\ []) do - case all(queryable, opts) do - [record] -> {:ok, record} - _none_or_multiple_records -> {:error, :not_found} - end - end - - @spec fetch(Ecto.Queryable.t(), term(), Keyword.t()) :: - {:ok, struct()} | {:error, :not_found} - def fetch(queryable, id, opts \\ []) do - schema = - case queryable do - schema when is_atom(schema) -> - schema - - queryable -> - {_, schema} = queryable.from.source - schema - end - - [pk] = schema.__schema__(:primary_key) - - query = - Ecto.Query.from(q in queryable, - where: field(q, ^pk) == ^id - ) - - fetch_one(query, opts) - end - - @spec fetch_by(Ecto.Queryable.t(), Keyword.t() | map(), Keyword.t()) :: - {:ok, struct()} | {:error, :not_found} - def fetch_by(queryable, clauses, opts \\ []) do - query = - Enum.reduce(clauses, queryable, fn {k, v}, queryable -> - Ecto.Query.from(q in queryable, - where: field(q, ^k) == ^v - ) - end) - - fetch_one(query, opts) - end - - @spec tx(fun(), Keyword.t()) :: term() - def tx(fun, opts \\ []) do - transaction( - fn repo -> - result = - case Function.info(fun, :arity) do - {:arity, 0} -> fun.() - {:arity, 1} -> fun.(repo) - end - - case result do - :ok -> nil - {:ok, result} -> result - {:error, reason} -> repo.rollback(reason) - error -> repo.rollback(error) - end - end, - opts - ) - end - - @spec insert_with_activity(Ecto.Changeset.t(), map()) :: - {:ok, struct()} | {:error, Ecto.Changeset.t()} - def insert_with_activity(changeset, activity) do - Ecto.Multi.new() - |> Ecto.Multi.insert(:target, changeset) - |> with_activity(activity) - |> transaction() - |> extract_target() - end - - @spec update_with_activity(Ecto.Changeset.t(), map()) :: - {:ok, struct()} | {:error, Ecto.Changeset.t()} - def update_with_activity(changeset, activity) do - Ecto.Multi.new() - |> Ecto.Multi.update(:target, changeset) - |> with_activity(activity) - |> transaction() - |> extract_target() - end - - @spec delete_with_activity(Ecto.Changeset.t(), map()) :: - {:ok, struct()} | {:error, Ecto.Changeset.t()} - def delete_with_activity(changeset, activity) do - Ecto.Multi.new() - |> Ecto.Multi.delete(:target, changeset) - |> with_activity(activity) - |> transaction() - |> extract_target() - end - - @spec with_activity(Ecto.Multi.t(), map()) :: Ecto.Multi.t() - def with_activity(multi, activity) do - multi - |> Ecto.Multi.insert(:activity, fn %{target: target} -> - Activity.build_activity(target, Map.put(activity, :id, target.id)) - end) - |> Oban.insert(:notification, fn %{activity: activity, target: target} -> - Notifier.changeset(activity, target) - end) - end - - def insert_activity(target, activity) do - activity = Map.put(activity, :id, target.id) - - with {:ok, activity} <- Algora.Repo.insert(Activity.build_activity(target, activity)), - {:ok, notification} <- Oban.insert(Notifier.changeset(activity, target)) do - {:ok, %{activity: activity, notification: notification}} - end - end - - defp extract_target(response) do - case response do - {:ok, %{target: target}} -> - {:ok, target} - - {:error, :target, target, _extra} -> - {:error, target} - end - end -end diff --git a/lib/algora/reviews/reviews.ex b/lib/algora/reviews/reviews.ex deleted file mode 100644 index 13f89069d..000000000 --- a/lib/algora/reviews/reviews.ex +++ /dev/null @@ -1,82 +0,0 @@ -defmodule Algora.Reviews do - @moduledoc false - import Ecto.Query - - alias Algora.Repo - alias Algora.Reviews.Review - - def create_review(attrs) do - %Review{} - |> Review.changeset(attrs) - |> Repo.insert() - end - - def base_query, do: Review - - defp apply_criteria(query, criteria) do - Enum.reduce(criteria, query, fn - {:id, id}, query -> - from([r] in query, where: r.id == ^id) - - {:reviewee_id, reviewee_id}, query -> - from([r] in query, where: r.reviewee_id == ^reviewee_id) - - {:limit, limit}, query -> - from([r] in query, limit: ^limit) - - _, query -> - query - end) - end - - def list_reviews_with(base_query, criteria \\ []) do - base_reviews = - base_query - |> apply_criteria(criteria) - |> select([r], r.id) - - from(r in Review) - |> join(:inner, [r], rr in subquery(base_reviews), on: r.id == rr.id) - |> join(:inner, [r], o in assoc(r, :organization), as: :o) - |> join(:inner, [r], rr in assoc(r, :reviewer), as: :rr) - |> join(:inner, [r], re in assoc(r, :reviewee), as: :re) - |> select([r, o: o, rr: rr, re: re], %{ - id: r.id, - inserted_at: r.inserted_at, - rating: r.rating, - content: r.content, - organization: %{ - id: o.id, - handle: o.handle, - name: o.name, - avatar_url: o.avatar_url - }, - reviewer: %{ - id: rr.id, - handle: rr.handle, - name: rr.name, - avatar_url: rr.avatar_url - }, - reviewee: %{ - id: re.id, - handle: re.handle, - name: re.name, - avatar_url: re.avatar_url - } - }) - |> Repo.all() - end - - def list_reviews(criteria \\ []) do - list_reviews_with(base_query(), criteria) - end - - def get_top_reviews_for_users(user_ids) when is_list(user_ids) do - base_query() - |> where([r], r.reviewee_id in ^user_ids) - |> distinct([r], r.reviewee_id) - |> order_by([r], desc: r.rating, desc: r.inserted_at) - |> list_reviews_with() - |> Map.new(fn review -> {review.reviewee.id, review} end) - end -end diff --git a/lib/algora/reviews/schemas/review.ex b/lib/algora/reviews/schemas/review.ex deleted file mode 100644 index 557cd7fc2..000000000 --- a/lib/algora/reviews/schemas/review.ex +++ /dev/null @@ -1,37 +0,0 @@ -defmodule Algora.Reviews.Review do - @moduledoc false - use Algora.Schema - - alias Algora.Accounts.User - alias Algora.Activities.Activity - - typed_schema "reviews" do - field :rating, :integer - field :content, :string - field :visibility, Ecto.Enum, values: [:public, :private] - - belongs_to :contract, Algora.Contracts.Contract - belongs_to :bounty, Algora.Bounties.Bounty - belongs_to :organization, User - belongs_to :reviewer, User - belongs_to :reviewee, User - - has_many :activities, {"review_activities", Activity}, foreign_key: :assoc_id - - timestamps() - end - - def changeset(review, attrs) do - review - |> cast(attrs, [:rating, :content, :visibility, :contract_id, :reviewer_id, :reviewee_id, :organization_id]) - |> validate_required([:rating, :content, :contract_id, :reviewer_id, :reviewee_id, :organization_id]) - |> validate_number(:rating, - greater_than_or_equal_to: min_rating(), - less_than_or_equal_to: max_rating() - ) - |> generate_id() - end - - def min_rating, do: 1 - def max_rating, do: 5 -end diff --git a/lib/algora/s3.ex b/lib/algora/s3.ex deleted file mode 100644 index 94bf1a170..000000000 --- a/lib/algora/s3.ex +++ /dev/null @@ -1,15 +0,0 @@ -defmodule Algora.S3 do - @moduledoc false - - def bucket_name, do: Algora.config([:bucket_name]) - - def bucket_url, do: "#{AlgoraWeb.Endpoint.url()}/storage" - - def bucket_url(path), do: Path.join(bucket_url(), path) - - def upload(body, object, opts \\ []) do - bucket_name() - |> ExAws.S3.put_object(object, body, opts) - |> ExAws.request([]) - end -end diff --git a/lib/algora/schema.ex b/lib/algora/schema.ex deleted file mode 100644 index 83662a42c..000000000 --- a/lib/algora/schema.ex +++ /dev/null @@ -1,22 +0,0 @@ -defmodule Algora.Schema do - @moduledoc false - defmacro __using__(_) do - quote do - use TypedEctoSchema - - import Ecto.Changeset - import Ecto.Query - - @primary_key {:id, :string, autogenerate: false} - @timestamps_opts [type: :utc_datetime_usec] - @foreign_key_type :string - - def generate_id(changeset) do - case get_field(changeset, :id) do - nil -> put_change(changeset, :id, Nanoid.generate()) - _existing -> changeset - end - end - end - end -end diff --git a/lib/algora/screenshot_queue.ex b/lib/algora/screenshot_queue.ex deleted file mode 100644 index d690089a2..000000000 --- a/lib/algora/screenshot_queue.ex +++ /dev/null @@ -1,167 +0,0 @@ -defmodule Algora.ScreenshotQueue do - @moduledoc false - use GenServer - - require Logger - - @timeout 30_000 - @max_concurrent_tasks 3 - @max_queue_size 100 - - def start_link(_) do - GenServer.start_link(__MODULE__, :ok, name: __MODULE__) - end - - def generate_image(url, opts \\ []) do - start_time = System.monotonic_time() - result = GenServer.call(__MODULE__, {:generate_image, url, opts}, opts[:timeout] || @timeout) - end_time = System.monotonic_time() - - :telemetry.execute([:algora, :screenshot_queue, :generate], %{duration: end_time - start_time}) - - result - end - - def queue_size do - GenServer.call(__MODULE__, :queue_size) - end - - def clear_queue do - GenServer.call(__MODULE__, :clear_queue) - end - - @impl true - def init(:ok) do - {:ok, %{active_tasks: %{}, queue: :queue.new(), waiting: %{}}} - end - - @impl true - def handle_call({:generate_image, url, opts}, from, state) do - path = Keyword.get(opts, :path, "no path") - Logger.info("Screenshot request: url=#{url} path=#{path}") - - active_count = map_size(state.active_tasks) - :telemetry.execute([:algora, :screenshot_queue], %{length: :queue.len(state.queue)}) - :telemetry.execute([:algora, :screenshot_queue], %{active_count: active_count}) - - if active_count < @max_concurrent_tasks do - {_task, new_state} = start_task(url, opts, from, state) - {:noreply, new_state} - else - queue = :queue.in({url, opts, from}, state.queue) - queue = enforce_max_queue_size(queue) - {:noreply, %{state | queue: queue}} - end - end - - @impl true - def handle_call(:queue_size, _from, state) do - {:reply, %{queue: :queue.len(state.queue), active: map_size(state.active_tasks)}, state} - end - - @impl true - def handle_call(:clear_queue, _from, state) do - dropped_count = :queue.len(state.queue) - Logger.info("Clearing screenshot queue, dropping #{dropped_count} items") - {:reply, {:ok, dropped_count}, %{state | queue: :queue.new()}} - end - - @impl true - def handle_info({ref, result}, state) when is_reference(ref) do - {from, new_active_tasks} = Map.pop(state.active_tasks, ref) - if is_tuple(from), do: GenServer.reply(from, result) - - case :queue.out(state.queue) do - {{:value, {url, opts, next_from}}, new_queue} -> - {_task, new_state} = start_task(url, opts, next_from, %{state | active_tasks: new_active_tasks, queue: new_queue}) - {:noreply, new_state} - - {:empty, _} -> - {:noreply, %{state | active_tasks: new_active_tasks}} - end - end - - @impl true - def handle_info({:DOWN, ref, :process, _pid, _reason}, state) do - {_from, new_active_tasks} = Map.pop(state.active_tasks, ref) - {:noreply, %{state | active_tasks: new_active_tasks}} - end - - defp start_task(url, opts, from, state) do - task = - Task.async(fn -> - try_generate_image(url, opts, 3) - end) - - {task, %{state | active_tasks: Map.put(state.active_tasks, task.ref, from)}} - end - - defp enforce_max_queue_size(queue) do - queue_len = :queue.len(queue) - - if queue_len > @max_queue_size do - dropped = queue_len - @max_queue_size - Logger.warning("Screenshot queue exceeded max size (#{queue_len}), dropping #{dropped} oldest items") - drop_oldest(queue, dropped) - else - queue - end - end - - defp drop_oldest(queue, 0), do: queue - - defp drop_oldest(queue, count) do - case :queue.out(queue) do - {{:value, _}, new_queue} -> drop_oldest(new_queue, count - 1) - {:empty, queue} -> queue - end - end - - defp try_generate_image(url, opts, attempts_left) when attempts_left > 0 do - puppeteer_path = Path.join([:code.priv_dir(:algora), "puppeteer", "puppeteer-img.js"]) - - Logger.info("Running puppeteer command: #{Enum.join([puppeteer_path] ++ build_opts(url, opts), " ")}") - - case System.cmd("node", [puppeteer_path] ++ build_opts(url, opts)) do - {cmd_response, 0} -> - {:ok, cmd_response} - - {_, 127} = res -> - Logger.warning("Puppeteer command failed with #{inspect(res)}") - {:error, :invalid_exec_path} - - res -> - Logger.warning("Puppeteer command failed with #{inspect(res)}, attempts left: #{attempts_left - 1}") - try_generate_image(url, opts, attempts_left - 1) - end - rescue - e in ErlangError -> - %ErlangError{original: error} = e - - case error do - :enoent -> - Logger.warning("Puppeteer command failed with :enoent") - {:error, :invalid_exec_path} - - _ -> - Logger.warning("Puppeteer command failed with #{inspect(error)}, attempts left: #{attempts_left - 1}") - try_generate_image(url, opts, attempts_left - 1) - end - - error -> - Logger.warning("Puppeteer command failed with #{inspect(error)}, attempts left: #{attempts_left - 1}") - try_generate_image(url, opts, attempts_left - 1) - end - - defp try_generate_image(_url, _opts, 0) do - {:error, :max_retries_exceeded} - end - - defp build_opts(url, options) do - options - |> Keyword.take([:type, :path, :width, :height, :scale_factor]) - |> Enum.reduce([url], fn {key, value}, result -> - result ++ ["--#{key}=#{value}"] - end) - end -end diff --git a/lib/algora/search/schemas/search_cursor.ex b/lib/algora/search/schemas/search_cursor.ex deleted file mode 100644 index 034551a36..000000000 --- a/lib/algora/search/schemas/search_cursor.ex +++ /dev/null @@ -1,23 +0,0 @@ -defmodule Algora.Search.SearchCursor do - @moduledoc false - use Algora.Schema - - import Ecto.Changeset - - typed_schema "search_cursors" do - field :provider, :string - field :timestamp, :utc_datetime_usec - field :last_polled_at, :utc_datetime_usec - - timestamps() - end - - @doc false - def changeset(search_cursor, attrs) do - search_cursor - |> cast(attrs, [:provider, :timestamp, :last_polled_at]) - |> generate_id() - |> validate_required([:provider, :timestamp]) - |> unique_constraint([:provider]) - end -end diff --git a/lib/algora/search/search.ex b/lib/algora/search/search.ex deleted file mode 100644 index 663ac6e29..000000000 --- a/lib/algora/search/search.ex +++ /dev/null @@ -1,34 +0,0 @@ -defmodule Algora.Search do - @moduledoc false - import Ecto.Query, warn: false - - alias Algora.Repo - alias Algora.Search.SearchCursor - - def get_search_cursor(provider) do - Repo.get_by(SearchCursor, provider: provider) - end - - def delete_search_cursor(provider) do - case get_search_cursor(provider) do - nil -> {:error, :cursor_not_found} - cursor -> Repo.delete(cursor) - end - end - - def create_search_cursor(attrs \\ %{}) do - %SearchCursor{} - |> SearchCursor.changeset(attrs) - |> Repo.insert() - end - - def update_search_cursor(%SearchCursor{} = search_cursor, attrs) do - search_cursor - |> SearchCursor.changeset(attrs) - |> Repo.update() - end - - def list_cursors do - Repo.all(from(p in SearchCursor)) - end -end diff --git a/lib/algora/settings/settings.ex b/lib/algora/settings/settings.ex deleted file mode 100644 index f3cd68092..000000000 --- a/lib/algora/settings/settings.ex +++ /dev/null @@ -1,427 +0,0 @@ -defmodule Algora.Settings do - @moduledoc false - use Ecto.Schema - - alias Algora.Accounts - alias Algora.Repo - - @primary_key {:key, :string, []} - schema "settings" do - field :value, :map - timestamps() - end - - def get(key) do - case Repo.get(__MODULE__, key) do - nil -> nil - config -> config.value - end - end - - def set(key, value) do - %__MODULE__{} - |> Ecto.Changeset.cast(%{key: key, value: value}, [:key, :value]) - |> Ecto.Changeset.validate_required([:key, :value]) - |> Repo.insert(on_conflict: {:replace, [:value]}, conflict_target: :key) - end - - def set!(key, value) do - case set(key, value) do - {:ok, _} -> :ok - {:error, reason} -> raise "Failed to set #{key} to #{value}: #{reason}" - end - end - - def delete(key) do - case Repo.get(__MODULE__, key) do - nil -> {:ok, nil} - config -> Repo.delete(config) - end - end - - def get_featured_developers do - case get("featured_developers") do - %{"handles" => handles} when is_list(handles) -> handles - _ -> nil - end - end - - def set_featured_developers(handles) when is_list(handles) do - set("featured_developers", %{"handles" => handles}) - end - - def get_featured_orgs do - case get("featured_orgs") do - %{"handles" => handles} when is_list(handles) -> handles - _ -> [] - end - end - - def set_featured_orgs(handles) when is_list(handles) do - set("featured_orgs", %{"handles" => handles}) - end - - def get_featured_collabs do - case get("featured_collabs") do - %{"handles" => handles} when is_list(handles) -> handles - _ -> nil - end - end - - def set_featured_collabs(handles) when is_list(handles) do - set("featured_collabs", %{"handles" => handles}) - end - - def set_user_profile(handle, profile) do - set("user_profile:#{handle}", profile) - end - - def get_user_profile(handle) do - get("user_profile:#{handle}") - end - - def get_org_matches(org) do - if get_user_profile(org.handle) do - [] - else - case get("org_matches:#{org.handle}") do - %{"matches" => matches} when is_list(matches) -> - load_matches(matches) - - _ -> - if tech_stack = List.first(org.tech_stack) do - get_tech_matches(tech_stack) - else - [] - end - end - end - end - - def set_org_matches(org_handle, matches) when is_binary(org_handle) and is_list(matches) do - set("org_matches:#{org_handle}", %{"matches" => matches}) - end - - def get_job_matches(job, opts \\ []) do - opts = Keyword.put_new(opts, :limit, 1000) - - case get("job_matches:#{job.id}") do - %{"matches_2" => matches} when is_list(matches) -> - matches - |> Enum.map(fn %{"user_id" => id} -> %{user_id: id} end) - |> load_matches_2() - - %{"matches" => matches} when is_list(matches) -> - matches - |> load_matches() - |> Enum.take(opts[:limit]) - - _ -> - matches = - [ - tech_stack: job.tech_stack, - email_required: false, - sort_by: - case get_job_criteria(job) do - criteria when map_size(criteria) > 0 -> criteria - _ -> [{"solver", true}] - end - ] - |> Keyword.merge(opts) - |> Algora.Cloud.list_top_matches() - - # Cache the raw matches for future calls - _count = get_job_matches_count(job, opts) - set_job_matches_2(job.id, matches) - - load_matches_2(matches) - end - end - - def set_job_matches_count(job_id, count) when is_binary(job_id) and is_integer(count) do - set("job_matches_count:#{job_id}", %{"count" => count}) - end - - def get_job_matches_count(job, opts \\ []) do - case get("job_matches_count:#{job.id}") do - %{"count" => count} when is_integer(count) -> - count - - _ -> - count = - case get("job_matches:#{job.id}") do - %{"matches" => matches} when is_list(matches) -> - length(matches) - - _ -> - [ - tech_stack: job.tech_stack, - email_required: false, - sort_by: - case get_job_criteria(job) do - criteria when map_size(criteria) > 0 -> criteria - _ -> [{"solver", true}] - end - ] - |> Keyword.merge(opts) - |> Algora.Cloud.count_top_matches() - end - - set_job_matches_count(job.id, count) - count - end - end - - def get_top_stargazers(job) do - [ - job: job, - tech_stack: job.tech_stack, - limit: 50, - sort_by: get_job_criteria(job) - ] - |> Algora.Cloud.list_top_stargazers() - |> load_matches_2() - end - - def set_job_criteria(job_id, criteria) when is_binary(job_id) and is_map(criteria) do - set("job_criteria:#{job_id}", %{"criteria" => criteria}) - end - - def get_job_criteria(job) do - cond do - job.countries != [] -> - %{"countries" => job.countries} - - job.regions != [] -> - %{"regions" => job.regions} - - true -> - case get("job_criteria:#{job.id}") do - %{"criteria" => criteria} when is_map(criteria) -> criteria - _ -> %{} - end - end - end - - def set_job_matches(job_id, matches) when is_binary(job_id) and is_list(matches) do - set("job_matches:#{job_id}", %{"matches" => matches}) - end - - def set_job_matches_2(job_id, matches) when is_binary(job_id) and is_list(matches) do - set("job_matches:#{job_id}", %{"matches_2" => matches}) - end - - def get_tech_matches(tech) do - case get("tech_matches:#{String.downcase(tech)}") do - %{"matches" => matches} when is_list(matches) -> load_matches(matches) - _ -> [] - end - end - - def set_tech_matches(tech, matches) when is_binary(tech) and is_list(matches) do - set("tech_matches:#{String.downcase(tech)}", %{"matches" => matches}) - end - - def load_matches(matches) do - user_map = - [handles: Enum.map(matches, & &1["handle"]), limit: :infinity] - |> Accounts.list_developers() - |> Enum.filter(& &1.provider_login) - |> Map.new(fn user -> {user.handle, user} end) - - Enum.flat_map(matches, fn match -> - if user = Map.get(user_map, match["handle"]) do - # TODO: N+1 - profile = get_user_profile(user.handle) - projects = Accounts.list_contributed_projects(user, limit: 2) - avatar_url = profile["avatar_url"] || user.avatar_url - hourly_rate = match["hourly_rate"] || profile["hourly_rate"] - hours_per_week = match["hours_per_week"] || profile["hours_per_week"] || user.hours_per_week - - [ - %{ - user: %{user | avatar_url: avatar_url}, - projects: projects, - badge_variant: match["badge_variant"], - badge_text: match["badge_text"], - hourly_rate: if(hourly_rate, do: Money.new(:USD, hourly_rate, no_fraction_if_integer: true)), - hours_per_week: hours_per_week - } - ] - else - [] - end - end) - end - - def load_matches_2(matches) do - user_map = - [ids: Enum.map(matches, & &1[:user_id]), limit: :infinity] - |> Accounts.list_developers() - |> Enum.filter(& &1.provider_login) - |> Map.new(fn user -> {user.id, user} end) - - Enum.flat_map(matches, fn match -> - if user = Map.get(user_map, match[:user_id]) do - [%{user: user, contribution_score: match["contribution_score"]}] - else - [] - end - end) - end - - def get_blocked_users do - case get("blocked_users") do - %{"handles" => handles} when is_list(handles) -> handles - _ -> [] - end - end - - def set_blocked_users(handles) when is_list(handles) do - set("blocked_users", %{"handles" => handles}) - end - - def get_featured_transactions do - case get("featured_transactions") do - %{"ids" => ids} when is_list(ids) -> ids - _ -> nil - end - end - - def set_featured_transactions(ids) when is_list(ids) do - set("featured_transactions", %{"ids" => ids}) - end - - def get_featured_talent_for_location(state) when is_binary(state) do - case get("featured_talent_by_location") do - %{} = locations -> - case Map.get(locations, state) do - ids when is_list(ids) -> ids - _ -> [] - end - - _ -> - [] - end - end - - def set_featured_talent_for_location(state, ids) when is_binary(state) and is_list(ids) do - locations = - case get("featured_talent_by_location") do - %{} = m -> m - _ -> %{} - end - - set("featured_talent_by_location", Map.put(locations, state, ids)) - end - - def add_featured_talent_for_location(state, user_id) when is_binary(state) and is_binary(user_id) do - ids = get_featured_talent_for_location(state) - if user_id in ids, do: {:ok, ids}, else: set_featured_talent_for_location(state, ids ++ [user_id]) - end - - def remove_featured_talent_for_location(state, user_id) when is_binary(state) and is_binary(user_id) do - ids = get_featured_talent_for_location(state) - set_featured_talent_for_location(state, Enum.reject(ids, &(&1 == user_id))) - end - - def get_home_carousel_candidate_ids do - case get("home_carousel_candidates") do - %{"ids" => ids} when is_list(ids) -> ids - _ -> nil - end - end - - def set_home_carousel_candidate_ids(ids) when is_list(ids) do - set("home_carousel_candidates", %{"ids" => ids}) - end - - def get_wire_details do - case get("wire_details") do - %{"details" => details} when is_map(details) -> details - _ -> nil - end - end - - def set_wire_details(details) when is_map(details) do - set("wire_details", %{"details" => details}) - end - - def get_subscription_price do - case get("subscription") do - %{"price" => %{"amount" => _amount, "currency" => _currency} = price} -> - Algora.MoneyUtils.deserialize(price) - - _ -> - nil - end - end - - def set_subscription_price(price) do - set("subscription", %{"price" => Algora.MoneyUtils.serialize(price)}) - end - - def get_campaign_timestamp do - case get("campaign_timestamp") do - %{"timestamp" => timestamp} when is_binary(timestamp) -> timestamp - _ -> nil - end - end - - def set_campaign_timestamp(timestamp) when is_binary(timestamp) do - set("campaign_timestamp", %{"timestamp" => timestamp}) - end - - def update_campaign_timestamp do - timestamp = format_timestamp(DateTime.utc_now()) - set_campaign_timestamp(timestamp) - end - - def get_org_members(org_handle) when is_binary(org_handle) do - case get("org_members:#{org_handle}") do - %{"members" => members} when is_list(members) -> members - _ -> [] - end - end - - def set_org_members(org_handle, members) when is_binary(org_handle) and is_list(members) do - set("org_members:#{org_handle}", %{"members" => members}) - end - - def get_pipeline_candidates(org_handle) when is_binary(org_handle) do - case get("pipeline_candidates:#{org_handle}") do - %{"ids" => ids} when is_list(ids) -> ids - _ -> nil - end - end - - def set_pipeline_candidates(org_handle, ids) when is_binary(org_handle) and is_list(ids) do - set("pipeline_candidates:#{org_handle}", %{"ids" => ids}) - end - - @doc """ - Gets portfolio company IDs for a given host handle. - Returns a list of organization IDs or an empty list if not set. - """ - def get_portfolio_companies(host_handle) when is_binary(host_handle) do - case get("portfolio_companies:#{host_handle}") do - %{"org_ids" => org_ids} when is_list(org_ids) -> org_ids - _ -> [] - end - end - - @doc """ - Sets portfolio company IDs for a given host handle. - """ - def set_portfolio_companies(host_handle, org_ids) when is_binary(host_handle) and is_list(org_ids) do - set("portfolio_companies:#{host_handle}", %{"org_ids" => org_ids}) - end - - defp format_timestamp(datetime) do - datetime - |> DateTime.to_string() - |> String.replace(~r/\D/, "-") - |> String.replace(~r/-+/, "-") - |> String.trim_trailing("-") - end -end diff --git a/lib/algora/shared/color.ex b/lib/algora/shared/color.ex deleted file mode 100644 index 0afacd3fe..000000000 --- a/lib/algora/shared/color.ex +++ /dev/null @@ -1,206 +0,0 @@ -defmodule Algora.Color do - @moduledoc false - @doc """ - Converts HSL string to hex color code. - - ## Examples - - iex> Algora.Color.hsl_to_hex("217deg 91% 60%") - "#3CAFF6" - """ - def hsl_to_hex(hsl) when is_binary(hsl) do - hsl - |> String.replace("deg", "") - |> String.split(~r/[,\s]+/, trim: true) - |> Enum.map(&String.trim/1) - |> Enum.map(fn str -> - str - |> String.replace("%", "") - |> Float.parse() - |> then(fn {num, _} -> num end) - end) - |> then(fn [h, s, l] -> hsl_to_hex(h, s, l) end) - end - - @doc """ - Converts HSL values to hex color code. - - ## Examples - - iex> Algora.Color.hsl_to_hex(217, 91, 60) - "#3CAFF6" - """ - def hsl_to_hex(h, s, l) when is_number(h) and is_number(s) and is_number(l) do - h - |> Chameleon.HSL.new(s, l) - |> Chameleon.convert(Chameleon.Hex) - |> then(&("#" <> &1.hex)) - end - - @doc """ - Converts HSL string to hex and finds nearest Tailwind color. - - ## Examples - - iex> Algora.Color.from_hsl("217deg 91% 60%") - %{hex: "#3CAFF6", tailwind: "sky-400"} - """ - def from_hsl(hsl) do - hex = hsl_to_hex(hsl) - nearest = find_nearest_tailwind_color(hex) - %{hex: hex, tailwind: nearest} - end - - @doc """ - Converts HSL values to hex and finds nearest Tailwind color. - - ## Examples - - iex> Algora.Color.from_hsl(217, 91, 60) - %{hex: "#3CAFF6", tailwind: "sky-400"} - """ - def from_hsl(h, s, l) do - hex = hsl_to_hex(h, s, l) - nearest = find_nearest_tailwind_color(hex) - %{hex: hex, tailwind: nearest} - end - - @doc """ - Converts a map of color names and hex values to CSS custom properties (variables). - - ## Examples - - iex> Algora.Color.to_css_vars(%{ - ...> "primary": "#3b82f6", - ...> "secondary": "#6366f1" - ...> }) - --primary: 217deg 91% 60%; - --secondary: 239deg 84% 67%; - :ok - """ - def to_css_vars(hex_map) when is_map(hex_map) do - hex_map - |> Enum.map_join("\n", fn {name, hex} -> - {h, s, l} = - hex - |> String.replace_prefix("#", "") - |> Chameleon.Hex.new() - |> Chameleon.convert(Chameleon.HSL) - |> then(fn %{h: h, s: s, l: l} -> {h, s, l} end) - - "--#{name}: #{round(h)}deg #{round(s)}% #{round(l)}%;" - end) - |> IO.puts() - end - - @doc """ - Converts CSS variable string to map of colors with hex and nearest Tailwind color. - - ## Examples - - iex> Algora.Color.from_css_vars(\"\"\" - ...> --primary: 217deg 91% 60%; - ...> --secondary: 239deg 84% 67%; - ...> \"\"\") - %{ - "primary" => %{hex: "#3CAFF6", tailwind: "sky-400"}, - "secondary" => %{hex: "#64EFF2", tailwind: "cyan-300"} - } - """ - def from_css_vars(css_string) when is_binary(css_string) do - css_string - |> String.split("\n", trim: true) - |> Enum.map(&parse_css_var/1) - |> Enum.reject(&is_nil/1) - |> Map.new() - end - - @doc """ - Finds the nearest Tailwind color name for a given hex color. - - ## Examples - - iex> Algora.Color.find_nearest_tailwind_color("#3B82F6") - "blue-500" - """ - def find_nearest_tailwind_color(hex) do - hex_rgb = hex |> String.replace_prefix("#", "") |> Chameleon.Hex.new() |> Chameleon.convert(Chameleon.RGB) - - tailwind_colors() - |> Enum.min_by(fn {_name, color} -> - color_rgb = color |> String.replace_prefix("#", "") |> Chameleon.Hex.new() |> Chameleon.convert(Chameleon.RGB) - color_distance(hex_rgb, color_rgb) - end) - |> elem(0) - end - - defp color_distance(rgb1, rgb2) do - :math.sqrt( - :math.pow(rgb1.r - rgb2.r, 2) + - :math.pow(rgb1.g - rgb2.g, 2) + - :math.pow(rgb1.b - rgb2.b, 2) - ) - end - - defp parse_css_var(line) do - case Regex.run(~r/\s*--([^:]+):\s*([^;]+);/, line) do - [_, var_name, hsl] -> {var_name, from_hsl(String.trim(hsl))} - _ -> nil - end - end - - defp parse_color_line(line) do - cond do - # Match nested color objects like "slate: {" - Regex.match?(~r/^\s*['"]?([^'"]+)['"]?:\s*{/, line) -> - [_, color_name] = Regex.run(~r/^\s*['"]?([^'"]+)['"]?:\s*{/, line) - {color_name, :start_object} - - # Match color-shade pairs like "500: '#123456'" - Regex.match?(~r/^\s*['"]?(\d+)['"]?:\s*['"]#([^'"]+)['"]/, line) -> - [_, shade, hex] = Regex.run(~r/^\s*['"]?(\d+)['"]?:\s*['"]#([^'"]+)['"]/, line) - {:shade, shade, "##{hex}"} - - # Match simple color pairs like "black: '#000'" - Regex.match?(~r/^\s*['"]?([^'"]+)['"]?:\s*['"]#([^'"]+)['"]/, line) -> - [_, name, hex] = Regex.run(~r/^\s*['"]?([^'"]+)['"]?:\s*['"]#([^'"]+)['"]/, line) - {name, "##{hex}"} - - true -> - nil - end - end - - def tailwind_colors(regex \\ nil) do - colors_path = "assets/node_modules/tailwindcss/lib/public/colors.js" - - case File.read(colors_path) do - {:ok, content} -> - colors = - content - |> String.split("\n") - |> Enum.filter(&String.contains?(&1, ":")) - |> Enum.reduce({%{}, nil}, fn line, {acc, current_color} -> - case parse_color_line(line) do - {color_name, :start_object} -> - {acc, color_name} - - {:shade, shade, hex} when is_binary(current_color) -> - {Map.put(acc, "#{current_color}-#{shade}", hex), current_color} - - {name, hex} -> - {Map.put(acc, name, hex), current_color} - - nil -> - {acc, current_color} - end - end) - |> elem(0) - - Map.filter(colors, fn {key, _} -> if is_nil(regex), do: true, else: Regex.match?(regex, key) end) - - {:error, _} -> - raise "Could not find Tailwind colors at #{colors_path}. Ensure tailwindcss is installed in your assets." - end - end -end diff --git a/lib/algora/shared/country_emojis.ex b/lib/algora/shared/country_emojis.ex deleted file mode 100644 index e1f6fdb28..000000000 --- a/lib/algora/shared/country_emojis.ex +++ /dev/null @@ -1,267 +0,0 @@ -defmodule Algora.Misc.CountryEmojis do - @moduledoc false - @country_emojis %{ - "AC" => "🇦🇨", - "AD" => "🇦🇩", - "AE" => "🇦🇪", - "AF" => "🇦🇫", - "AG" => "🇦🇬", - "AI" => "🇦🇮", - "AL" => "🇦🇱", - "AM" => "🇦🇲", - "AO" => "🇦🇴", - "AQ" => "🇦🇶", - "AR" => "🇦🇷", - "AS" => "🇦🇸", - "AT" => "🇦🇹", - "AU" => "🇦🇺", - "AW" => "🇦🇼", - "AX" => "🇦🇽", - "AZ" => "🇦🇿", - "BA" => "🇧🇦", - "BB" => "🇧🇧", - "BD" => "🇧🇩", - "BE" => "🇧🇪", - "BF" => "🇧🇫", - "BG" => "🇧🇬", - "BH" => "🇧🇭", - "BI" => "🇧🇮", - "BJ" => "🇧🇯", - "BL" => "🇧🇱", - "BM" => "🇧🇲", - "BN" => "🇧🇳", - "BO" => "🇧🇴", - "BQ" => "🇧🇶", - "BR" => "🇧🇷", - "BS" => "🇧🇸", - "BT" => "🇧🇹", - "BV" => "🇧🇻", - "BW" => "🇧🇼", - "BY" => "🇧🇾", - "BZ" => "🇧🇿", - "CA" => "🇨🇦", - "CC" => "🇨🇨", - "CD" => "🇨🇩", - "CF" => "🇨🇫", - "CG" => "🇨🇬", - "CH" => "🇨🇭", - "CI" => "🇨🇮", - "CK" => "🇨🇰", - "CL" => "🇨🇱", - "CM" => "🇨🇲", - "CN" => "🇨🇳", - "CO" => "🇨🇴", - "CP" => "🇨🇵", - "CR" => "🇨🇷", - "CU" => "🇨🇺", - "CV" => "🇨🇻", - "CW" => "🇨🇼", - "CX" => "🇨🇽", - "CY" => "🇨🇾", - "CZ" => "🇨🇿", - "DE" => "🇩🇪", - "DG" => "🇩🇬", - "DJ" => "🇩🇯", - "DK" => "🇩🇰", - "DM" => "🇩🇲", - "DO" => "🇩🇴", - "DZ" => "🇩🇿", - "EA" => "🇪🇦", - "EC" => "🇪🇨", - "EE" => "🇪🇪", - "EG" => "🇪🇬", - "EH" => "🇪🇭", - "ER" => "🇪🇷", - "ES" => "🇪🇸", - "ET" => "🇪🇹", - "EU" => "🇪🇺", - "FI" => "🇫🇮", - "FJ" => "🇫🇯", - "FK" => "🇫🇰", - "FM" => "🇫🇲", - "FO" => "🇫🇴", - "FR" => "🇫🇷", - "GA" => "🇬🇦", - "GB" => "🇬🇧", - "GD" => "🇬🇩", - "GE" => "🇬🇪", - "GF" => "🇬🇫", - "GG" => "🇬🇬", - "GH" => "🇬🇭", - "GI" => "🇬🇮", - "GL" => "🇬🇱", - "GM" => "🇬🇲", - "GN" => "🇬🇳", - "GP" => "🇬🇵", - "GQ" => "🇬🇶", - "GR" => "🇬🇷", - "GS" => "🇬🇸", - "GT" => "🇬🇹", - "GU" => "🇬🇺", - "GW" => "🇬🇼", - "GY" => "🇬🇾", - "HK" => "🇭🇰", - "HM" => "🇭🇲", - "HN" => "🇭🇳", - "HR" => "🇭🇷", - "HT" => "🇭🇹", - "HU" => "🇭🇺", - "IC" => "🇮🇨", - "ID" => "🇮🇩", - "IE" => "🇮🇪", - "IL" => "🇮🇱", - "IM" => "🇮🇲", - "IN" => "🇮🇳", - "IO" => "🇮🇴", - "IQ" => "🇮🇶", - "IR" => "🇮🇷", - "IS" => "🇮🇸", - "IT" => "🇮🇹", - "JE" => "🇯🇪", - "JM" => "🇯🇲", - "JO" => "🇯🇴", - "JP" => "🇯🇵", - "KE" => "🇰🇪", - "KG" => "🇰🇬", - "KH" => "🇰🇭", - "KI" => "🇰🇮", - "KM" => "🇰🇲", - "KN" => "🇰🇳", - "KP" => "🇰🇵", - "KR" => "🇰🇷", - "KW" => "🇰🇼", - "KY" => "🇰🇾", - "KZ" => "🇰🇿", - "LA" => "🇱🇦", - "LB" => "🇱🇧", - "LC" => "🇱🇨", - "LI" => "🇱🇮", - "LK" => "🇱🇰", - "LR" => "🇱🇷", - "LS" => "🇱🇸", - "LT" => "🇱🇹", - "LU" => "🇱🇺", - "LV" => "🇱🇻", - "LY" => "🇱🇾", - "MA" => "🇲🇦", - "MC" => "🇲🇨", - "MD" => "🇲🇩", - "ME" => "🇲🇪", - "MF" => "🇲🇫", - "MG" => "🇲🇬", - "MH" => "🇲🇭", - "MK" => "🇲🇰", - "ML" => "🇲🇱", - "MM" => "🇲🇲", - "MN" => "🇲🇳", - "MO" => "🇲🇴", - "MP" => "🇲🇵", - "MQ" => "🇲🇶", - "MR" => "🇲🇷", - "MS" => "🇲🇸", - "MT" => "🇲🇹", - "MU" => "🇲🇺", - "MV" => "🇲🇻", - "MW" => "🇲🇼", - "MX" => "🇲🇽", - "MY" => "🇲🇾", - "MZ" => "🇲🇿", - "NA" => "🇳🇦", - "NC" => "🇳🇨", - "NE" => "🇳🇪", - "NF" => "🇳🇫", - "NG" => "🇳🇬", - "NI" => "🇳🇮", - "NL" => "🇳🇱", - "NO" => "🇳🇴", - "NP" => "🇳🇵", - "NR" => "🇳🇷", - "NU" => "🇳🇺", - "NZ" => "🇳🇿", - "OM" => "🇴🇲", - "PA" => "🇵🇦", - "PE" => "🇵🇪", - "PF" => "🇵🇫", - "PG" => "🇵🇬", - "PH" => "🇵🇭", - "PK" => "🇵🇰", - "PL" => "🇵🇱", - "PM" => "🇵🇲", - "PN" => "🇵🇳", - "PR" => "🇵🇷", - "PS" => "🇵🇸", - "PT" => "🇵🇹", - "PW" => "🇵🇼", - "PY" => "🇵🇾", - "QA" => "🇶🇦", - "RE" => "🇷🇪", - "RO" => "🇷🇴", - "RS" => "🇷🇸", - "RU" => "🇷🇺", - "RW" => "🇷🇼", - "SA" => "🇸🇦", - "SB" => "🇸🇧", - "SC" => "🇸🇨", - "SD" => "🇸🇩", - "SE" => "🇸🇪", - "SG" => "🇸🇬", - "SH" => "🇸🇭", - "SI" => "🇸🇮", - "SJ" => "🇸🇯", - "SK" => "🇸🇰", - "SL" => "🇸🇱", - "SM" => "🇸🇲", - "SN" => "🇸🇳", - "SO" => "🇸🇴", - "SR" => "🇸🇷", - "SS" => "🇸🇸", - "ST" => "🇸🇹", - "SV" => "🇸🇻", - "SX" => "🇸🇽", - "SY" => "🇸🇾", - "SZ" => "🇸🇿", - "TA" => "🇹🇦", - "TC" => "🇹🇨", - "TD" => "🇹🇩", - "TF" => "🇹🇫", - "TG" => "🇹🇬", - "TH" => "🇹🇭", - "TJ" => "🇹🇯", - "TK" => "🇹🇰", - "TL" => "🇹🇱", - "TM" => "🇹🇲", - "TN" => "🇹🇳", - "TO" => "🇹🇴", - "TR" => "🇹🇷", - "TT" => "🇹🇹", - "TV" => "🇹🇻", - "TW" => "🇹🇼", - "TZ" => "🇹🇿", - "UA" => "🇺🇦", - "UG" => "🇺🇬", - "UM" => "🇺🇲", - "UN" => "🇺🇳", - "US" => "🇺🇸", - "UY" => "🇺🇾", - "UZ" => "🇺🇿", - "VA" => "🇻🇦", - "VC" => "🇻🇨", - "VE" => "🇻🇪", - "VG" => "🇻🇬", - "VI" => "🇻🇮", - "VN" => "🇻🇳", - "VU" => "🇻🇺", - "WF" => "🇼🇫", - "WS" => "🇼🇸", - "XK" => "🇽🇰", - "YE" => "🇾🇪", - "YT" => "🇾🇹", - "ZA" => "🇿🇦", - "ZM" => "🇿🇲", - "ZW" => "🇿🇼" - } - - def get(country_code, default \\ "🌎") do - Map.get(@country_emojis, country_code, default) - end -end diff --git a/lib/algora/shared/fee_tier.ex b/lib/algora/shared/fee_tier.ex deleted file mode 100644 index 9808d967a..000000000 --- a/lib/algora/shared/fee_tier.ex +++ /dev/null @@ -1,106 +0,0 @@ -defmodule Algora.FeeTier do - @moduledoc """ - Defines the fee tiers and helper functions for calculating fees based on payment volume. - """ - - @community_tiers [ - %{ - threshold: Money.new!(0, :USD, no_fraction_if_integer: true), - fee: Decimal.new("0.09"), - progress: Decimal.new("0.00") - } - ] - - @expert_tiers [ - %{ - threshold: Money.new!(0, :USD, no_fraction_if_integer: true), - fee: Decimal.new("0.19"), - progress: Decimal.new("0.00") - }, - %{ - threshold: Money.new!(3_000, :USD, no_fraction_if_integer: true), - fee: Decimal.new("0.15"), - progress: Decimal.new("0.33") - }, - %{ - threshold: Money.new!(5_000, :USD, no_fraction_if_integer: true), - fee: Decimal.new("0.10"), - progress: Decimal.new("0.66") - }, - %{ - threshold: Money.new!(15_000, :USD, no_fraction_if_integer: true), - fee: Decimal.new("0.05"), - progress: Decimal.new("1.00") - } - ] - - def all(:community), do: @community_tiers - def all(:expert), do: @expert_tiers - - def calculate_fee_percentage(total_paid) do - # Find the highest tier where total_paid is greater than or equal to the threshold - tier = - @expert_tiers - |> Enum.reverse() - |> Enum.find(List.first(@expert_tiers), &threshold_met?(total_paid, &1)) - - tier.fee - end - - def calculate_progress(total_paid) do - tier = find_current_tier(total_paid) - prev_tier = find_previous_tier(tier) - next_threshold = if tier, do: tier.threshold, else: List.last(@expert_tiers).threshold - - case {prev_tier, tier} do - {nil, _tier} -> - # First tier - calculate progress towards first threshold - percentage_of(total_paid, next_threshold) - - {_prev_tier, nil} -> - # Beyond last tier - Decimal.new("1.00") - - {prev_tier, _tier} -> - # Between tiers - calculate progress between thresholds - base_progress = prev_tier.progress - - segment_progress = - percentage_of( - Money.sub!(total_paid, prev_tier.threshold), - Money.sub!(next_threshold, prev_tier.threshold) - ) - - Decimal.add(base_progress, segment_progress) - end - end - - defp find_current_tier(total_paid) do - Enum.find(@expert_tiers, &(Money.compare!(total_paid, &1.threshold) == :lt)) - end - - defp find_previous_tier(nil), do: List.last(@expert_tiers) - - defp find_previous_tier(current_tier) do - index = Enum.find_index(@expert_tiers, &(&1 == current_tier)) - if index > 0, do: Enum.at(@expert_tiers, index - 1) - end - - defp percentage_of(amount, total) do - Decimal.div( - Money.to_decimal(amount), - Money.to_decimal(total) - ) - end - - @doc """ - Returns true if the total paid amount has met or exceeded a tier's threshold. - """ - def threshold_met?(total_paid, tier) do - Money.compare!(total_paid, tier.threshold) != :lt - end - - def first_threshold_met?(total_paid) do - threshold_met?(total_paid, Enum.at(@expert_tiers, 1)) - end -end diff --git a/lib/algora/shared/markdown.ex b/lib/algora/shared/markdown.ex deleted file mode 100644 index 41e3ef767..000000000 --- a/lib/algora/shared/markdown.ex +++ /dev/null @@ -1,60 +0,0 @@ -defmodule Algora.Markdown do - @moduledoc false - - require Logger - - @default_opts [ - extension: [ - strikethrough: true, - tagfilter: true, - table: true, - autolink: true, - tasklist: true, - footnotes: true, - shortcodes: true - ], - parse: [ - smart: true, - relaxed_tasklist_matching: true, - relaxed_autolinks: true - ], - render: [ - github_pre_lang: true, - unsafe_: true - ], - features: [ - sanitize: MDEx.default_sanitize_options(), - # TODO: sanitize and syntax_highlight_theme are currently incompatible - # since sanitization strips out the syntax highlighting classes - syntax_highlight_theme: "neovim_dark" - ] - ] - - def render(_md_or_doc, _opts \\ []) - - def render(nil, _opts), do: nil - - def render(md_or_doc, opts) do - case MDEx.to_html(md_or_doc, Keyword.merge(@default_opts, opts)) do - {:ok, html} -> - html - - {:error, error} -> - Logger.error("Error converting markdown to html: #{inspect(error)}") - if is_binary(md_or_doc), do: md_or_doc, else: inspect(md_or_doc) - end - end - - def render_unsafe(md_or_doc, opts \\ []) do - default_opts = update_in(@default_opts, [:features, :sanitize], fn _ -> false end) - - case MDEx.to_html(md_or_doc, Keyword.merge(default_opts, opts)) do - {:ok, html} -> - html - - {:error, error} -> - Logger.error("Error converting markdown to html: #{inspect(error)}") - if is_binary(md_or_doc), do: md_or_doc, else: inspect(md_or_doc) - end - end -end diff --git a/lib/algora/shared/money_utils.ex b/lib/algora/shared/money_utils.ex deleted file mode 100644 index bdc5101ac..000000000 --- a/lib/algora/shared/money_utils.ex +++ /dev/null @@ -1,63 +0,0 @@ -defmodule Algora.MoneyUtils do - @moduledoc false - def fmt_precise(money), do: Money.to_string(money, no_fraction_if_integer: false) - def fmt_precise!(money), do: Money.to_string!(money, no_fraction_if_integer: false) - - def fmt_compact(nil), do: nil - - def fmt_compact(money) do - amount = Algora.Util.format_number_compact(money.amount) - - case money.currency do - :USD -> - "$" <> amount - - :EUR -> - "€" <> amount - - :GBP -> - "£" <> amount - - _ -> - to_string(money.currency) <> " " <> amount - end - end - - @spec split_evenly(Money.t(), non_neg_integer()) :: [Money.t()] - def split_evenly(_money, 0), do: [] - - def split_evenly(money, parts) do - {dividend, _remainder} = Money.split(money, parts) - [dividend | split_evenly(Money.sub!(money, dividend), parts - 1)] - end - - @spec to_minor_units(Money.t()) :: integer() - def to_minor_units(money) do - {_, amount_int, _, _} = Money.to_integer_exp(money) - amount_int - end - - @spec to_stripe_currency(Money.t()) :: String.t() - def to_stripe_currency(money) do - money.currency |> to_string() |> String.downcase() - end - - # TODO: Find a way to make this obsolete - # Why does ecto return {currency, amount} instead of Money.t()? - def ensure_money_field(struct, field) do - case Map.get(struct, field) do - {currency, amount} -> Map.put(struct, field, Money.new!(currency, amount, no_fraction_if_integer: true)) - _ -> struct - end - end - - def serialize(money) do - %{currency: money.currency, amount: money.amount} - end - - def deserialize(%{"currency" => currency, "amount" => amount}) do - Money.new!(currency, amount, no_fraction_if_integer: true) - end - - def deserialize(_), do: nil -end diff --git a/lib/algora/shared/parser.ex b/lib/algora/shared/parser.ex deleted file mode 100644 index c8f05bf2d..000000000 --- a/lib/algora/shared/parser.ex +++ /dev/null @@ -1,148 +0,0 @@ -defmodule Algora.Parser do - @moduledoc false - import NimbleParsec - - defmodule Combinator do - @moduledoc false - def whitespace, do: ascii_string([?\s, ?\t], min: 1) - def digits, do: ascii_string([?0..?9], min: 1) - def word_chars, do: ascii_string([not: ?/, not: ?\s, not: ?\t, not: ?\n, not: ?\r], min: 1) - def non_separator_chars, do: ascii_string([not: ?#, not: ?/, not: ?\s, not: ?\t, not: ?\n, not: ?\r], min: 1) - def integer, do: reduce(digits(), {__MODULE__, :to_integer, []}) - - def amount do - "$" - |> string() - |> optional() - |> ignore() - |> concat(ascii_string([?0..?9, ?., ?,], min: 1)) - |> post_traverse({__MODULE__, :to_money, []}) - |> unwrap_and_tag(:amount) - |> label("amount (e.g. 1000 or 1,000.00)") - end - - def recipient do - "@" - |> string() - |> ignore() - |> concat(word_chars()) - |> unwrap_and_tag(:recipient) - |> label("username starting with @") - end - - def ticket_ref do - [ - simple_issue_ref(), - repo_issue_ref(), - full_repo_issue_ref(), - github_url_ref() - ] - |> choice() - |> tag(:ticket_ref) - |> label("issue reference (e.g. #123, repo#123, owner/repo#123, or GitHub URL)") - end - - def full_ticket_ref do - [full_repo_issue_ref(), github_url_ref()] - |> choice() - |> tag(:ticket_ref) - |> label("issue reference (e.g. owner/repo#123 or GitHub URL)") - end - - def simple_issue_ref do - # Format: #123 - "#" - |> string() - |> ignore() - |> optional() - |> concat(unwrap_and_tag(integer(), :number)) - end - - def repo_issue_ref do - # Format: repo#123 - empty() - |> concat(unwrap_and_tag(non_separator_chars(), :repo)) - |> ignore(string("#")) - |> concat(unwrap_and_tag(integer(), :number)) - end - - def full_repo_issue_ref do - # Format: owner/repo#123 - empty() - |> concat(unwrap_and_tag(non_separator_chars(), :owner)) - |> ignore(string("/")) - |> concat(unwrap_and_tag(non_separator_chars(), :repo)) - |> ignore(string("#")) - |> concat(unwrap_and_tag(integer(), :number)) - end - - def github_url_ref do - # Format: https://github.com/owner/repo/(issues|pull|discussions)/123 - [string("https://"), string("http://"), empty()] - |> choice() - |> ignore() - |> ignore(string("github.com/")) - |> concat(unwrap_and_tag(non_separator_chars(), :owner)) - |> ignore(string("/")) - |> concat(unwrap_and_tag(non_separator_chars(), :repo)) - |> ignore(string("/")) - |> concat( - [string("issues"), string("pull"), string("discussions")] - |> choice() - |> unwrap_and_tag(:type) - ) - |> ignore(string("/")) - |> concat(unwrap_and_tag(integer(), :number)) - end - - # Helper functions - def to_money(rest, args, context, _line, _offset) do - delimiters = [",", "."] - - amount_string = Enum.join(args) - - amount_string = - Enum.reduce(delimiters, amount_string, fn delimiter, acc -> - String.trim(acc, delimiter) - end) - - last_delimiter_pos = - amount_string - |> String.reverse() - |> String.split("") - |> Enum.find_index(&(&1 in delimiters)) - - locale = - cond do - # No delimiter found - treat as whole number - is_nil(last_delimiter_pos) -> - :en - - # If last segment is 3 digits, it's a thousands separator - last_delimiter_pos == 4 -> - case String.at(amount_string, -last_delimiter_pos) do - "." -> :de - "," -> :en - end - - # Otherwise, it's a decimal separator - true -> - case String.at(amount_string, -last_delimiter_pos) do - "," -> :de - "." -> :en - end - end - - case Money.new(:USD, amount_string, locale: locale) do - {:error, _reason} -> {:error, "Invalid amount: \"#{amount_string}\""} - amount -> {rest, [amount], context} - end - end - - def to_integer(segments) when is_list(segments) do - segments |> Enum.join() |> String.to_integer() - end - end - - defparsec(:full_ticket_ref, Combinator.full_ticket_ref()) -end diff --git a/lib/algora/shared/regions.ex b/lib/algora/shared/regions.ex deleted file mode 100644 index 466c5b980..000000000 --- a/lib/algora/shared/regions.ex +++ /dev/null @@ -1,112 +0,0 @@ -defmodule Algora.Misc.Regions do - @moduledoc false - @regions %{ - "APAC" => [ - "AU", - "BD", - "CN", - "HK", - "ID", - "IN", - "JP", - "KR", - "LK", - "MN", - "MY", - "NP", - "NZ", - "PH", - "PK", - "SG", - "TH", - "TW", - "VN" - ], - "EMEA" => [ - "AE", - "AM", - "AT", - "AZ", - "BA", - "BE", - "BG", - "BH", - "BJ", - "CH", - "CI", - "CY", - "CZ", - "DE", - "DK", - "DZ", - "EE", - "EG", - "ES", - "ET", - "FI", - "FR", - "GA", - "GB", - "GH", - "GR", - "HR", - "HU", - "IE", - "IL", - "IT", - "KE", - "KZ", - "LT", - "LU", - "LV", - "MA", - "MD", - "MG", - "MT", - "NG", - "NL", - "NO", - "PL", - "PT", - "RO", - "RS", - "RW", - "SA", - "SE", - "SI", - "SK", - "SN", - "TN", - "TR", - "UZ", - "ZA" - ], - "AMERICAS" => [ - "AR", - "BR", - "CA", - "CL", - "CO", - "DO", - "EC", - "GT", - "MX", - "PE", - "US", - "UY" - ] - } - - def get_region(country_code) when is_binary(country_code) do - country_code = String.upcase(country_code) - - cond do - country_code in @regions["APAC"] -> "APAC" - country_code in @regions["EMEA"] -> "EMEA" - country_code in @regions["AMERICAS"] -> "AMERICAS" - true -> "OTHER" - end - end - - def all_regions, do: Map.keys(@regions) -end diff --git a/lib/algora/shared/sql.ex b/lib/algora/shared/sql.ex deleted file mode 100644 index aedc2f444..000000000 --- a/lib/algora/shared/sql.ex +++ /dev/null @@ -1,24 +0,0 @@ -defmodule Algora.SQL do - @moduledoc """ - SQL helper functions and macros for common database operations. - """ - - defmacro money_or_zero(value) do - quote do - coalesce(unquote(value), fragment("('USD', 0)::money_with_currency")) - end - end - - defmacro sum_by_type(t, type) do - quote do - sum( - fragment( - "CASE WHEN ? = ? THEN ? ELSE ('USD', 0)::money_with_currency END", - unquote(t).type, - unquote(type), - unquote(t).net_amount - ) - ) - end - end -end diff --git a/lib/algora/shared/time.ex b/lib/algora/shared/time.ex deleted file mode 100644 index ba3b930c8..000000000 --- a/lib/algora/shared/time.ex +++ /dev/null @@ -1,52 +0,0 @@ -defmodule Algora.Time do - @moduledoc false - @doc """ - Converts an IANA timezone to a friendly display format with proper offset handling. - Examples: - "America/New_York" -> "(GMT-05:00) New York" - "Europe/Paris" -> "(GMT+01:00) Paris" - """ - def friendly_timezone(nil), do: nil - - def friendly_timezone(timezone) do - datetime = DateTime.now!(timezone) - offset_hours = div(datetime.utc_offset, 3600) - offset_mins = div(rem(datetime.utc_offset, 3600), 60) - offset_string = format_offset(offset_hours, offset_mins) - city = format_city(timezone) - "(#{offset_string}) #{city}" - end - - defp format_offset(hours, mins) do - sign = if hours >= 0, do: "+", else: "-" - "GMT#{sign}#{pad_number(abs(hours))}:#{pad_number(abs(mins))}" - end - - defp pad_number(num), do: String.pad_leading("#{num}", 2, "0") - - defp format_city(timezone) do - timezone - |> String.split("/") - |> List.last() - |> String.replace("_", " ") - |> String.replace(~r/^(?:GMT|UTC|Universal|Zulu|WET|UCT)$/, "UTC") - end - - @doc """ - Returns a sorted, deduplicated list of all timezones in friendly format. - Sorts by UTC offset first, then alphabetically by city name. - """ - def list_friendly_timezones do - Tzdata.zone_list() - |> Enum.reject(&String.starts_with?(&1, "Etc/GMT")) - |> Enum.map(fn zone -> {zone, DateTime.now!(zone)} end) - |> Enum.sort_by(fn {zone, dt} -> - { - dt.utc_offset, - zone |> String.split("/") |> List.last() |> String.downcase() - } - end) - |> Enum.map(fn {zone, _} -> {friendly_timezone(zone), zone} end) - |> Enum.uniq_by(fn {zone, _} -> zone end) - end -end diff --git a/lib/algora/shared/types/money.ex b/lib/algora/shared/types/money.ex deleted file mode 100644 index f282f0898..000000000 --- a/lib/algora/shared/types/money.ex +++ /dev/null @@ -1,25 +0,0 @@ -defmodule Algora.Types.Money do - @moduledoc false - - use Ecto.ParameterizedType - - alias Money.Ecto.Composite.Type - - @type t :: Money.t() - - defdelegate type(params), to: Type - defdelegate cast_type(opts), to: Type - defdelegate load(tuple, loader, params), to: Type - defdelegate dump(money, dumper, params), to: Type - defdelegate cast(money), to: Type - defdelegate cast(money, params), to: Type - defdelegate embed_as(term), to: Type - defdelegate embed_as(term, params), to: Type - defdelegate equal?(money1, money2), to: Type - - def init(opts) do - opts - |> Type.init() - |> Keyword.put(:no_fraction_if_integer, true) - end -end diff --git a/lib/algora/shared/types/usd.ex b/lib/algora/shared/types/usd.ex deleted file mode 100644 index 54a520221..000000000 --- a/lib/algora/shared/types/usd.ex +++ /dev/null @@ -1,31 +0,0 @@ -defmodule Algora.Types.USD do - @moduledoc false - use Ecto.Type - - alias Money.Ecto.Composite.Type - - @impl true - def type, do: :money_with_currency - - @impl true - def cast(string) when is_binary(string) do - string = string |> String.replace("$", "") |> String.trim() - - case Money.new(:USD, string) do - %Money{} = money -> {:ok, money} - {:error, _reason} -> :error - end - end - - def cast(money) when is_struct(money, Money) do - {:ok, money} - end - - def cast(_), do: :error - - @impl true - def dump(money), do: Type.dump(money) - - @impl true - def load(money), do: Type.load(money) -end diff --git a/lib/algora/shared/util.ex b/lib/algora/shared/util.ex deleted file mode 100644 index 33eee8139..000000000 --- a/lib/algora/shared/util.ex +++ /dev/null @@ -1,462 +0,0 @@ -defmodule Algora.Util do - @moduledoc false - def to_csv(data, output_path \\ nil) do - rows = Enum.to_list(data) - - # Collect all unique keys across all entries - all_keys = - rows - |> Enum.flat_map(&Map.keys/1) - |> Enum.uniq() - |> Enum.sort() - - # Normalize each row to have all fields - normalized_rows = - Enum.map(rows, fn row -> - Enum.map(all_keys, fn key -> - case Map.get(row, key) do - list when is_list(list) -> Enum.join(list, ", ") - value -> value - end - end) - end) - - csv = - [all_keys | normalized_rows] - |> CSV.encode() - |> Enum.join("") - - if output_path do - File.write!(output_path, csv) - else - csv - end - end - - def random_string do - binary = << - System.system_time(:nanosecond)::64, - :erlang.phash2({node(), self()})::16, - :erlang.unique_integer()::16 - >> - - binary - |> Base.url_encode64() - |> String.replace(["/", "+"], "-") - end - - def random_int(n \\ 1_000_000) do - :rand.uniform(n) - end - - def term_to_base64(term) do - term - |> :erlang.term_to_binary() - |> Base.encode64() - end - - def base64_to_term!(base64) do - base64 - |> Base.decode64!() - |> Plug.Crypto.non_executable_binary_to_term([:safe]) - end - - def format_number_compact(number) when is_struct(number, Decimal) do - number - |> Decimal.to_float() - |> format_number_compact() - end - - def format_number_compact(number) when is_number(number) do - n = trunc(number) - - case n do - n when n >= 1_000_000_000 -> - "#{(n / 1_000_000_000) |> Float.round(1) |> trim_trailing_zero()}B" - - n when n >= 1_000_000 -> - "#{(n / 1_000_000) |> Float.round(1) |> trim_trailing_zero()}M" - - n when n >= 100_000 -> - "#{round(n / 1_000)}k" - - n when n >= 1_000 -> - "#{(n / 1_000) |> Float.round(1) |> trim_trailing_zero()}k" - - n -> - to_string(n) - end - end - - def format_number_compact(number) do - number - end - - defp trim_trailing_zero(number) do - number - |> Float.to_string() - |> String.replace(~r/\.0+$/, "") - end - - def time_ago(datetime) do - now = NaiveDateTime.utc_now() - diff = NaiveDateTime.diff(now, datetime, :second) - - cond do - diff < 60 -> - "just now" - - diff < 3600 -> - count = div(diff, 60) - unit = Gettext.ngettext(AlgoraWeb.Gettext, "minute", "minutes", count) - "#{count} #{unit} ago" - - diff < 86_400 -> - count = div(diff, 3600) - unit = Gettext.ngettext(AlgoraWeb.Gettext, "hour", "hours", count) - "#{count} #{unit} ago" - - diff < 604_800 -> - count = div(diff, 86_400) - unit = Gettext.ngettext(AlgoraWeb.Gettext, "day", "days", count) - "#{count} #{unit} ago" - - diff < 2_592_000 -> - count = div(diff, 604_800) - unit = Gettext.ngettext(AlgoraWeb.Gettext, "week", "weeks", count) - "#{count} #{unit} ago" - - true -> - count = div(diff, 2_592_000) - unit = Gettext.ngettext(AlgoraWeb.Gettext, "month", "months", count) - "#{count} #{unit} ago" - end - end - - def time_ago_compact(datetime) do - now = NaiveDateTime.utc_now() - diff = NaiveDateTime.diff(now, datetime, :second) - - cond do - diff < 60 -> - "now" - - diff < 3600 -> - count = div(diff, 60) - "#{count}m" - - diff < 86_400 -> - count = div(diff, 3600) - "#{count}h" - - diff < 604_800 -> - count = div(diff, 86_400) - "#{count}d" - - diff < 2_592_000 -> - count = div(diff, 604_800) - "#{count}wk" - - true -> - count = div(diff, 2_592_000) - "#{count}mo" - end - end - - def relative_time(datetime) do - case time_ago_compact(datetime) do - "now" -> "just now" - t -> "#{t} ago" - end - end - - def timestamp(date, nil) do - Calendar.strftime(date, "%Y-%m-%d %I:%M %p UTC") - end - - def timestamp(date, timezone) do - date |> DateTime.shift_zone!(timezone) |> Calendar.strftime("%Y-%m-%d %I:%M %p") - end - - def to_date!(nil), do: nil - - def to_date!(date) do - case DateTime.from_iso8601(date) do - {:ok, datetime, _offset} -> - %{datetime | microsecond: {elem(datetime.microsecond, 0), 6}} - - {:error, _reason} = error -> - error - end - end - - def format_pct(percentage, opts \\ []) do - pct = percentage |> Decimal.mult(100) |> Decimal.normalize() - - pct = - if opts[:precision] do - Decimal.round(pct, opts[:precision]) - else - pct - end - - pct |> Decimal.to_string(:normal) |> Kernel.<>("%") - end - - def normalize_struct(%Money{} = money) do - %{ - amount: Decimal.to_string(money.amount), - currency: money.currency - } - end - - def normalize_struct(struct) when is_struct(struct) do - struct - |> Map.from_struct() - |> normalize_struct() - end - - def normalize_struct(map) when is_map(map) do - Map.new(map, fn {k, v} -> {k, normalize_struct(v)} end) - end - - def normalize_struct(list) when is_list(list) do - Enum.map(list, &normalize_struct/1) - end - - def normalize_struct(tuple) when is_tuple(tuple) do - tuple |> Tuple.to_list() |> normalize_struct() - end - - def normalize_struct(value), do: value - - def format_name_list([x]), do: x - def format_name_list([x1, x2]), do: "#{x1} and #{x2}" - def format_name_list([x1, x2, x3]), do: "#{x1}, #{x2} and #{x3}" - def format_name_list([x1, x2 | xs]), do: "#{x1}, #{x2} and #{length(xs)} others" - def format_name_list(_), do: nil - - def initials(str, length \\ 2) - def initials(nil, _length), do: "" - def initials(str, length), do: str |> String.slice(0, length) |> String.upcase() - - # TODO: Implement this for all countries - def locale_from_country_code("gr"), do: "el" - def locale_from_country_code(country_code), do: country_code - - def parse_github_url(url) do - case Regex.run(~r{(?:github\.com/)?([^/\s]+)/([^/\s]+)}, url) do - [_, owner, repo] -> {:ok, {owner, repo}} - _ -> {:error, "Must be a valid GitHub repository URL (e.g. github.com/owner/repo) or owner/repo format"} - end - end - - def path_from_url(url) do - url - |> URI.parse() - |> then(& &1.path) - |> String.replace(~r/^\/[^\/]+\//, "") - |> String.replace(~r/\/(issues|pull|discussions)\//, "#") - end - - def normalize_url(nil), do: nil - - def normalize_url(url) when is_binary(url) do - url = String.trim(url) - - # Add https:// if no scheme present - url = if String.contains?(url, "://"), do: url, else: "https://" <> url - - case URI.parse(url) do - %URI{scheme: scheme, host: host} when not is_nil(scheme) and scheme != "" and not is_nil(host) and host != "" -> url - _ -> nil - end - end - - def to_domain(nil), do: nil - - def to_domain(url) do - url - |> String.trim_leading("https://") - |> String.trim_leading("http://") - |> String.trim_leading("www.") - end - - @doc """ - Extracts the root domain from a hostname, removing subdomains. - - Examples: - iex> extract_root_domain("www.example.com") - "example.com" - - iex> extract_root_domain("app.dexory.com") - "dexory.com" - - iex> extract_root_domain("example.com") - "example.com" - """ - def extract_root_domain(nil), do: nil - - def extract_root_domain(host) when is_binary(host) do - host - |> String.trim() - |> String.downcase() - |> then(fn h -> - # Split by dots - parts = String.split(h, ".") - - # If we have more than 2 parts, take the last 2 (domain + TLD) - # For most cases, this works (www.example.com -> example.com) - # For edge cases like .co.uk, we'd need a public suffix list - case length(parts) do - n when n > 2 -> - # Take last 2 parts - parts - |> Enum.take(-2) - |> Enum.join(".") - - _ -> - # Already a root domain or invalid - h - end - end) - end - - @doc """ - Extracts the root domain from a URL, removing scheme, path, and subdomains. - - Examples: - iex> url_to_root_domain("https://duckduckgo.com/careers") - "duckduckgo.com" - - iex> url_to_root_domain("http://bsky.social/about/join") - "bsky.social" - - iex> url_to_root_domain("jobs.ongoody.com/") - "ongoody.com" - - iex> url_to_root_domain("https://foxglove.dev/") - "foxglove.dev" - """ - def url_to_root_domain(nil), do: nil - - def url_to_root_domain(url) when is_binary(url) do - # Add scheme if missing for proper URI parsing - url_with_scheme = if String.contains?(url, "://"), do: url, else: "https://#{url}" - - url_with_scheme - |> URI.parse() - |> then(fn uri -> uri.host end) - |> extract_root_domain() - end - - def get_gravatar_url(email, opts \\ []) do - default = Keyword.get(opts, :default, "") - size = Keyword.get(opts, :size, 460) - - email - |> String.trim() - |> String.downcase() - |> remove_plus_suffix() - |> then(&:crypto.hash(:sha256, &1)) - |> Base.encode16(case: :lower) - |> build_gravatar_url(default, size) - end - - defp remove_plus_suffix(email) do - [local_part, domain] = String.split(email, "@") - base_local_part = local_part |> String.split("+") |> List.first() - base_local_part <> "@" <> domain - end - - defp build_gravatar_url(hash, default, size) do - query = - URI.encode_query(%{ - "d" => default, - "s" => Integer.to_string(size) - }) - - "https://www.gravatar.com/avatar/#{hash}?#{query}&d=identicon" - end - - def normalized_strings_match?(s1, s2) do - s1 = s1 |> String.downcase() |> String.trim() |> String.replace(~r/[^a-zA-Z0-9]+/, "") - s2 = s2 |> String.downcase() |> String.trim() |> String.replace(~r/[^a-zA-Z0-9]+/, "") - - String.contains?(s1, s2) or String.contains?(s2, s1) - end - - def next_occurrence_of_time(datetime) do - now = DateTime.utc_now() - - if DateTime.after?(datetime, now) do - datetime - else - %{hour: hour, minute: minute, second: second, microsecond: microsecond} = datetime - - now - |> DateTime.truncate(:second) - |> Map.put(:hour, hour) - |> Map.put(:minute, minute) - |> Map.put(:second, second) - |> Map.put(:microsecond, microsecond) - |> then(fn target_time -> - if DateTime.after?(target_time, now) do - target_time - else - DateTime.add(target_time, 24 * 60 * 60, :second) - end - end) - end - end - - def random_datetime(opts \\ []) do - now = DateTime.utc_now() - from = Keyword.get(opts, :from, DateTime.add(now, -365, :day)) - to = Keyword.get(opts, :to, now) - - from_unix = DateTime.to_unix(from) - to_unix = DateTime.to_unix(to) - - from_unix..to_unix - |> Enum.random() - |> DateTime.from_unix!() - end - - def compact_org_name(org_name) do - org_name - # Remove YC batch strings like "YC S24", "YC W23", etc. - |> String.replace(~r/\s*\(?YC\s+[a-z]\d{2}\)?\s*/i, "") - # Remove common company suffixes - |> String.replace( - ~r/,?\s+(PBC\.?|Public Benefit Corporation|Corporation|Corp\.?|,?\s*Inc\.?|Labs|Technologies|Industries|Research)\s*$/i, - "" - ) - |> String.trim() - end - - def remove_non_ascii(string) do - String.replace(string, ~r/[^a-zA-Z]/, "") - end - - def to_local_string(nil), do: nil - - def to_local_string(datetime) do - datetime - |> DateTime.shift_zone!("Europe/Athens", Tzdata.TimeZoneDatabase) - |> Calendar.strftime("%Y-%m-%d %H:%M:%S") - end - - def slugify(text, replace \\ "-") - - def slugify(text, replace) when is_binary(text) do - text - |> String.downcase() - |> String.trim() - |> String.normalize(:nfd) - |> String.replace(~r/[^a-z0-9\s-]/u, " ") - |> String.replace(~r/[\s-]+/, replace, global: true) - end - - def slugify(_, _), do: "" -end diff --git a/lib/algora/shared/validations.ex b/lib/algora/shared/validations.ex deleted file mode 100644 index 35ec18d63..000000000 --- a/lib/algora/shared/validations.ex +++ /dev/null @@ -1,79 +0,0 @@ -defmodule Algora.Validations do - @moduledoc false - import Ecto.Changeset - - def validate_greater_than(changeset, field, value) do - validate_change(changeset, field, fn _, field_value -> - if Money.compare(field_value, value) == :gt do - [] - else - [{field, "must be greater than #{Money.to_string!(value)}"}] - end - end) - end - - def validate_positive(changeset, field) do - validate_greater_than(changeset, field, Money.zero(:USD)) - end - - def validate_money_positive(changeset, field) do - with amount when not is_nil(amount) <- get_change(changeset, field), - true <- Money.positive?(amount) do - put_change(changeset, field, amount) - else - false -> add_error(changeset, field, "must be positive") - _ -> changeset - end - end - - def validate_ticket_ref(changeset, field, embed_field \\ nil) do - with url when not is_nil(url) <- get_change(changeset, field), - {:ok, [ticket_ref: ticket_ref], _, _, _, _} <- Algora.Parser.full_ticket_ref(url) do - if embed_field do - put_embed(changeset, embed_field, ticket_ref) - else - changeset - end - else - {:error, error, _, _, _, _} -> add_error(changeset, field, error) - _ -> changeset - end - end - - def validate_github_handle(changeset, field, embed_field \\ nil) do - case get_change(changeset, field) do - handle when not is_nil(handle) -> - # Check if user is already embedded with matching provider_login - existing_user = embed_field && get_field(changeset, embed_field) - - if existing_user && existing_user.provider_login == handle do - changeset - else - case Algora.Workspace.ensure_user(Algora.Cloud.token!(), handle) do - {:ok, user} -> - if embed_field do - put_embed(changeset, embed_field, user) - else - changeset - end - - {:error, error, _, _, _, _} -> - add_error(changeset, field, error) - end - end - - _ -> - changeset - end - end - - def validate_date_in_future(changeset, field) do - validate_change(changeset, field, fn _, date -> - if date && Date.before?(date, DateTime.utc_now()) do - [{field, "must be in the future"}] - else - [] - end - end) - end -end diff --git a/lib/algora/signal.ex b/lib/algora/signal.ex deleted file mode 100644 index 12003b84f..000000000 --- a/lib/algora/signal.ex +++ /dev/null @@ -1,27 +0,0 @@ -defmodule Algora.Signal do - @moduledoc false - @provider Appsignal - - def send_error(%Ecto.Changeset{} = error, exception) do - send_error(error, exception, []) - end - - def send_error(%Stripe.Error{} = error, exception) do - send_error(error, exception, []) - end - - def send_error(%Stripe.Error{} = error, exception, stacktrace) do - case error do - %{extra: %{raw_error: %{"message" => message}}} -> - send_error(%{exception | message: message}, stacktrace) - - _error -> - send_error(%{exception | message: "Unknown Stripe error"}, stacktrace) - end - - :ok - end - - defdelegate send_error(exception, stacktrace), to: @provider - defdelegate send_error(kind, reason, stacktrace), to: @provider -end diff --git a/lib/algora/sync/schemas/sync_cursor.ex b/lib/algora/sync/schemas/sync_cursor.ex deleted file mode 100644 index 1ef990768..000000000 --- a/lib/algora/sync/schemas/sync_cursor.ex +++ /dev/null @@ -1,24 +0,0 @@ -defmodule Algora.Sync.SyncCursor do - @moduledoc false - use Algora.Schema - - import Ecto.Changeset - - typed_schema "sync_cursors" do - field :provider, :string - field :resource, :string - field :timestamp, :utc_datetime_usec - field :last_polled_at, :utc_datetime_usec - - timestamps() - end - - @doc false - def changeset(search_cursor, attrs) do - search_cursor - |> cast(attrs, [:provider, :resource, :timestamp, :last_polled_at]) - |> generate_id() - |> validate_required([:provider, :resource, :timestamp]) - |> unique_constraint([:provider, :resource]) - end -end diff --git a/lib/algora/sync/search.ex b/lib/algora/sync/search.ex deleted file mode 100644 index ece0c1850..000000000 --- a/lib/algora/sync/search.ex +++ /dev/null @@ -1,34 +0,0 @@ -defmodule Algora.Sync do - @moduledoc false - import Ecto.Query, warn: false - - alias Algora.Repo - alias Algora.Sync.SyncCursor - - def get_sync_cursor(provider, resource) do - Repo.get_by(SyncCursor, provider: provider, resource: resource) - end - - def delete_sync_cursor(provider, resource) do - case get_sync_cursor(provider, resource) do - nil -> {:error, :cursor_not_found} - cursor -> Repo.delete(cursor) - end - end - - def create_sync_cursor(attrs \\ %{}) do - %SyncCursor{} - |> SyncCursor.changeset(attrs) - |> Repo.insert() - end - - def update_sync_cursor(%SyncCursor{} = sync_cursor, attrs) do - sync_cursor - |> SyncCursor.changeset(attrs) - |> Repo.update() - end - - def list_cursors do - Repo.all(from(p in SyncCursor)) - end -end diff --git a/lib/algora/workspace/invalid_repo_cache.ex b/lib/algora/workspace/invalid_repo_cache.ex deleted file mode 100644 index 3e5cdc20a..000000000 --- a/lib/algora/workspace/invalid_repo_cache.ex +++ /dev/null @@ -1,38 +0,0 @@ -defmodule Algora.Workspace.InvalidRepoCache do - @moduledoc false - use Algora.Schema - - import Ecto.Changeset - import Ecto.Query - - alias Algora.Repo - - typed_schema "invalid_repos" do - field :owner, :string - field :name, :string - - timestamps() - end - - def changeset(invalid_repo, attrs) do - invalid_repo - |> cast(attrs, [:owner, :name]) - |> validate_required([:owner, :name]) - |> unique_constraint([:owner, :name]) - end - - def cache_invalid_repo(owner, name) do - %__MODULE__{} - |> changeset(%{owner: owner, name: name}) - |> generate_id() - |> Repo.insert() - end - - def invalid_repo?(owner, name) do - query = - from r in __MODULE__, - where: r.owner == ^owner and r.name == ^name - - Repo.exists?(query) - end -end diff --git a/lib/algora/workspace/jobs/fetch_top_contributions.ex b/lib/algora/workspace/jobs/fetch_top_contributions.ex deleted file mode 100644 index fe666a57c..000000000 --- a/lib/algora/workspace/jobs/fetch_top_contributions.ex +++ /dev/null @@ -1,16 +0,0 @@ -defmodule Algora.Workspace.Jobs.FetchTopContributions do - @moduledoc false - use Oban.Worker, - queue: :internal, - max_attempts: 2, - unique: [period: 30 * 24 * 60 * 60, fields: [:args]] - - alias Algora.Github - - @impl Oban.Worker - def perform(%Oban.Job{args: %{"provider_logins" => provider_logins}}) do - Algora.Workspace.fetch_top_contributions_async(Github.TokenPool.get_token(), provider_logins) - end - - def timeout(_), do: to_timeout(second: 30) -end diff --git a/lib/algora/workspace/jobs/import_contributor.ex b/lib/algora/workspace/jobs/import_contributor.ex deleted file mode 100644 index 5fb9e3cae..000000000 --- a/lib/algora/workspace/jobs/import_contributor.ex +++ /dev/null @@ -1,27 +0,0 @@ -defmodule Algora.Workspace.Jobs.ImportContributor do - @moduledoc false - use Oban.Worker, - queue: :internal, - max_attempts: 3 - - alias Algora.Github - - @impl Oban.Worker - def perform(%Oban.Job{args: %{"contributors_data" => contributors_data, "repo_id" => repo_id}}) do - token = Github.TokenPool.get_token() - provider_logins = Enum.map(contributors_data, & &1["provider_login"]) - - with {:ok, users} <- Algora.Workspace.fetch_top_contributions_async(token, provider_logins) do - contributions_map = - Map.new(contributors_data, fn contributor -> {contributor["provider_login"], contributor["contributions"]} end) - - for user <- users do - contributions = Map.get(contributions_map, user.provider_login, 0) - {:ok, _} = Algora.Workspace.upsert_contributor(user.id, repo_id, contributions) - {:ok, _} = Algora.Workspace.upsert_user_contribution(user.id, repo_id, contributions) - end - - :ok - end - end -end diff --git a/lib/algora/workspace/jobs/import_stargazer.ex b/lib/algora/workspace/jobs/import_stargazer.ex deleted file mode 100644 index 42d07faba..000000000 --- a/lib/algora/workspace/jobs/import_stargazer.ex +++ /dev/null @@ -1,53 +0,0 @@ -defmodule Algora.Workspace.Jobs.ImportStargazer do - @moduledoc false - use Oban.Worker, - queue: :internal, - max_attempts: 3 - - alias Algora.Github - alias Algora.Repo - alias Algora.Workspace.Stargazer - - @impl Oban.Worker - def perform(%Oban.Job{args: %{"provider_logins" => provider_logins, "repo_id" => repo_id}}) do - with {:ok, users} <- Algora.Workspace.fetch_top_contributions_async(Github.TokenPool.get_token(), provider_logins) do - {_count, _} = - Repo.insert_all( - Stargazer, - Enum.map(users, fn user -> - %{ - id: Nanoid.generate(), - inserted_at: DateTime.utc_now(), - updated_at: DateTime.utc_now(), - user_id: user.id, - repository_id: repo_id - } - end), - on_conflict: :nothing - ) - - :ok - end - end - - def perform(%Oban.Job{args: %{"provider_login" => provider_login, "repo_id" => repo_id}}) do - with {:ok, users} <- Algora.Workspace.fetch_top_contributions_async(Github.TokenPool.get_token(), [provider_login]) do - {_count, _} = - Repo.insert_all( - Stargazer, - Enum.map(users, fn user -> - %{ - id: Nanoid.generate(), - inserted_at: DateTime.utc_now(), - updated_at: DateTime.utc_now(), - user_id: user.id, - repository_id: repo_id - } - end), - on_conflict: :nothing - ) - - :ok - end - end -end diff --git a/lib/algora/workspace/jobs/process_contributors.ex b/lib/algora/workspace/jobs/process_contributors.ex deleted file mode 100644 index b171f547b..000000000 --- a/lib/algora/workspace/jobs/process_contributors.ex +++ /dev/null @@ -1,40 +0,0 @@ -defmodule Algora.Workspace.Jobs.ProcessContributors do - @moduledoc false - use Oban.Worker, - queue: :internal_par, - max_attempts: 3 - - alias Algora.Workspace.Jobs.ImportContributor - - require Logger - - @impl Oban.Worker - def perform(%Oban.Job{args: %{"contributors_data" => contributors_data, "repo_id" => repo_id}}) do - jobs = - contributors_data - |> Enum.reduce([], fn contributor_data, acc -> - id = contributor_data["id"] - contributions = contributor_data["contributions"] - - case Algora.Repo.fetch(Algora.Accounts.User, id) do - {:ok, user} -> - [%{provider_login: user.provider_login, contributions: contributions} | acc] - - {:error, reason} -> - Logger.error("Failed to fetch user #{id}: #{reason}") - acc - end - end) - |> Enum.chunk_every(10) - |> Enum.map(fn contributors -> ImportContributor.new(%{contributors_data: contributors, repo_id: repo_id}) end) - |> Oban.insert_all() - - case jobs do - [] -> - Logger.warning("No contributors to process for #{repo_id}") - - _ -> - :ok - end - end -end diff --git a/lib/algora/workspace/jobs/process_stargazers.ex b/lib/algora/workspace/jobs/process_stargazers.ex deleted file mode 100644 index 44bb78611..000000000 --- a/lib/algora/workspace/jobs/process_stargazers.ex +++ /dev/null @@ -1,54 +0,0 @@ -defmodule Algora.Workspace.Jobs.ProcessStargazers do - @moduledoc false - use Oban.Worker, - queue: :internal_par, - max_attempts: 3 - - import Ecto.Query - - alias Algora.Github - alias Algora.Repo - alias Algora.Workspace - alias Algora.Workspace.Jobs.ImportStargazer - alias Algora.Workspace.Stargazer - - require Logger - - @impl Oban.Worker - def perform(%Oban.Job{args: %{"provider_logins" => provider_logins, "repo_id" => repo_id}}) do - token = Github.TokenPool.get_token() - - jobs = - provider_logins - |> Enum.reduce([], fn provider_login, acc -> - case Workspace.ensure_user(token, provider_login) do - {:ok, user} -> - if user.provider_meta["followers"] < 10 do - Logger.warning("User #{provider_login} has less than 10 followers") - acc - else - if Repo.exists?(from s in Stargazer, where: s.user_id == ^user.id and s.repository_id == ^repo_id) do - acc - else - [user.provider_login | acc] - end - end - - {:error, reason} -> - Logger.error("Failed to fetch user #{provider_login}: #{reason}") - acc - end - end) - |> Enum.chunk_every(10) - |> Enum.map(fn logins -> ImportStargazer.new(%{provider_logins: logins, repo_id: repo_id}) end) - |> Oban.insert_all() - - case jobs do - [] -> - Logger.warning("No stargazers to process for #{repo_id}") - - _ -> - :ok - end - end -end diff --git a/lib/algora/workspace/jobs/sync_contribution.ex b/lib/algora/workspace/jobs/sync_contribution.ex deleted file mode 100644 index 77ca1e7e2..000000000 --- a/lib/algora/workspace/jobs/sync_contribution.ex +++ /dev/null @@ -1,23 +0,0 @@ -defmodule Algora.Workspace.Jobs.SyncContribution do - @moduledoc false - use Oban.Worker, - queue: :internal_par, - max_attempts: 2 - - alias Algora.Github - alias Algora.Workspace - - @impl Oban.Worker - def perform(%Oban.Job{ - args: %{"user_id" => user_id, "repo_full_name" => repo_full_name, "contribution_count" => contribution_count} - }) do - token = Github.TokenPool.get_token() - - with [repo_owner, repo_name] <- String.split(repo_full_name, "/"), - {:ok, repo} <- Workspace.ensure_repository(token, repo_owner, repo_name), - {:ok, _tech_stack} <- Workspace.ensure_repo_tech_stack(token, repo), - {:ok, _contribution} <- Workspace.upsert_user_contribution(user_id, repo.id, contribution_count) do - :ok - end - end -end diff --git a/lib/algora/workspace/jobs/sync_user.ex b/lib/algora/workspace/jobs/sync_user.ex deleted file mode 100644 index cdd44350f..000000000 --- a/lib/algora/workspace/jobs/sync_user.ex +++ /dev/null @@ -1,46 +0,0 @@ -defmodule Algora.Workspace.Jobs.SyncUser do - @moduledoc false - use Oban.Worker, - queue: :internal_par, - max_attempts: 3 - - import Ecto.Changeset - - alias Algora.Github - alias Algora.Repo - alias Algora.Workspace - - @impl Oban.Worker - def perform(%Oban.Job{args: %{"provider_id" => provider_id}}) do - token = Github.TokenPool.get_token() - - with {:ok, data} <- Github.get_user(token, provider_id), - {:ok, user} <- Workspace.ensure_user_by_provider_id(token, data["id"]) do - user - |> change(%{ - display_name: data["name"], - location: data["location"], - provider_meta: data, - provider_login: data["login"] - }) - |> Repo.update() - end - end - - @impl Oban.Worker - def perform(%Oban.Job{args: %{"provider_login" => provider_login}}) do - token = Github.TokenPool.get_token() - - with {:ok, data} <- Github.get_user_by_username(token, provider_login), - {:ok, user} <- Workspace.ensure_user_by_provider_id(token, data["id"]) do - user - |> change(%{ - display_name: data["name"], - location: data["location"], - provider_meta: data, - provider_login: data["login"] - }) - |> Repo.update() - end - end -end diff --git a/lib/algora/workspace/jobs/update_repository_og_image.ex b/lib/algora/workspace/jobs/update_repository_og_image.ex deleted file mode 100644 index b6dd956f2..000000000 --- a/lib/algora/workspace/jobs/update_repository_og_image.ex +++ /dev/null @@ -1,50 +0,0 @@ -defmodule Algora.Workspace.Jobs.UpdateRepositoryOgImage do - @moduledoc false - use Oban.Worker, queue: :internal - - alias Algora.Repo - alias Algora.Workspace.Repository - - require Logger - - @impl Oban.Worker - def perform(%Oban.Job{args: %{"repository_id" => repository_id}}) do - repository = - Repository - |> Repo.get(repository_id) - |> Repo.preload(:user) - - case repository do - nil -> {:error, :not_found} - repository -> update_og_image(repository) - end - end - - defp update_og_image(repository) do - repo_owner = repository.user.provider_login - repo_name = repository.name - object = "repositories/#{repo_owner}/#{repo_name}/og.png" - - req = Finch.build(:get, repository.og_image_url) - - with {:ok, %Finch.Response{body: body}} <- Finch.request(req, Algora.Finch), - {:ok, _} <- Algora.S3.upload(body, object, content_type: "image/png"), - url = Algora.S3.bucket_url() <> "/" <> object, - {:ok, updated_repository} <- update_repository_url(repository, url) do - {:ok, updated_repository} - else - error -> - Logger.error("Failed to fetch/upload image for #{repo_owner}/#{repo_name}: #{inspect(error)}") - error - end - end - - defp update_repository_url(repository, url) do - repository - |> Ecto.Changeset.change(%{ - og_image_url: url, - og_image_updated_at: DateTime.utc_now() - }) - |> Repo.update() - end -end diff --git a/lib/algora/workspace/schemas/candidate_note.ex b/lib/algora/workspace/schemas/candidate_note.ex deleted file mode 100644 index 4484d47ed..000000000 --- a/lib/algora/workspace/schemas/candidate_note.ex +++ /dev/null @@ -1,32 +0,0 @@ -defmodule Algora.Workspace.CandidateNote do - @moduledoc """ - Schema for storing candidate notes/highlights. - """ - use Algora.Schema - - alias Algora.Accounts.User - alias Algora.Jobs.JobPosting - alias Algora.Workspace.CandidateNote - - typed_schema "candidate_notes" do - field :notes, {:array, :string}, null: false - - belongs_to :user, User, null: false - belongs_to :job, JobPosting - - timestamps() - end - - @doc """ - Changeset for creating or updating candidate notes. - """ - def changeset(%CandidateNote{} = note, attrs) do - note - |> cast(attrs, [:user_id, :job_id, :notes]) - |> validate_required([:user_id, :notes]) - |> generate_id() - |> foreign_key_constraint(:user_id) - |> foreign_key_constraint(:job_id) - |> unique_constraint([:user_id, :job_id]) - end -end diff --git a/lib/algora/workspace/schemas/command_response.ex b/lib/algora/workspace/schemas/command_response.ex deleted file mode 100644 index 25192b4de..000000000 --- a/lib/algora/workspace/schemas/command_response.ex +++ /dev/null @@ -1,44 +0,0 @@ -defmodule Algora.Workspace.CommandResponse do - @moduledoc """ - Schema for tracking command comments and their corresponding bot responses. - This allows updating existing bot responses instead of creating new ones. - """ - use Algora.Schema - - typed_schema "command_responses" do - field :provider, :string, null: false - field :provider_meta, :map - field :provider_command_id, :string - field :provider_response_id, :string, null: false - field :command_source, Ecto.Enum, values: [:ticket, :comment], null: false - field :command_type, Ecto.Enum, values: [:bounty, :attempt, :claim], null: false - - belongs_to :ticket, Algora.Workspace.Ticket, null: false - - timestamps() - end - - def changeset(command_response, attrs) do - command_response - |> cast(attrs, [ - :provider, - :provider_meta, - :provider_command_id, - :provider_response_id, - :command_source, - :command_type, - :ticket_id - ]) - |> validate_required([ - :provider, - :provider_meta, - :provider_response_id, - :command_source, - :command_type, - :ticket_id - ]) - |> generate_id() - |> foreign_key_constraint(:ticket_id) - |> unique_constraint([:provider, :provider_command_id]) - end -end diff --git a/lib/algora/workspace/schemas/contributor.ex b/lib/algora/workspace/schemas/contributor.ex deleted file mode 100644 index 6f8ebaccf..000000000 --- a/lib/algora/workspace/schemas/contributor.ex +++ /dev/null @@ -1,51 +0,0 @@ -defmodule Algora.Workspace.Contributor do - @moduledoc false - use Algora.Schema - - alias Algora.Accounts.User - alias Algora.Workspace.Repository - - typed_schema "contributors" do - field :contributions, :integer, default: 0 - - belongs_to :repository, Repository - belongs_to :user, User - - timestamps() - end - - def github_user_changeset(meta) do - params = %{ - provider_id: to_string(meta["id"]), - provider_login: meta["login"], - type: User.type_from_provider(:github, meta["type"]), - display_name: meta["login"], - avatar_url: meta["avatar_url"], - github_url: meta["html_url"] - } - - %User{provider: "github", provider_meta: meta} - |> cast(params, [:provider_id, :provider_login, :type, :display_name, :avatar_url, :github_url]) - |> generate_id() - |> validate_required([:provider_id, :provider_login, :type]) - |> unique_constraint([:provider, :provider_id]) - end - - def changeset(contributor, params) do - contributor - |> cast(params, [:contributions, :repository_id, :user_id]) - |> validate_required([:repository_id, :user_id]) - |> foreign_key_constraint(:repository_id) - |> foreign_key_constraint(:user_id) - |> unique_constraint([:repository_id, :user_id]) - |> generate_id() - end - - def filter_by_repository_id(query, nil), do: query - - def filter_by_repository_id(query, repository_id) do - from c in query, - join: r in assoc(c, :repository), - where: r.id == ^repository_id - end -end diff --git a/lib/algora/workspace/schemas/installation.ex b/lib/algora/workspace/schemas/installation.ex deleted file mode 100644 index 0de288978..000000000 --- a/lib/algora/workspace/schemas/installation.ex +++ /dev/null @@ -1,52 +0,0 @@ -defmodule Algora.Workspace.Installation do - @moduledoc false - use Algora.Schema - - alias Algora.Accounts.User - alias Algora.Activities.Activity - - @derive {Inspect, except: [:provider_meta]} - typed_schema "installations" do - field :provider, :string, null: false - field :provider_id, :string, null: false - field :provider_meta, :map - - field :repository_selection, :string - - belongs_to :owner, User - belongs_to :connected_user, User, null: false - # TODO: make non-nullable after migration - belongs_to :provider_user, User - - has_many :activities, {"installation_activities", Activity}, foreign_key: :assoc_id - timestamps() - end - - def github_changeset(installation, user, provider_user, org, data) do - params = %{ - owner_id: user.id, - connected_user_id: org.id, - repository_selection: data["repository_selection"], - provider_id: to_string(data["id"]), - provider_user_id: to_string(provider_user.id), - provider_meta: data - } - - installation - |> cast(params, [ - :owner_id, - :connected_user_id, - :repository_selection, - :provider_id, - :provider_user_id, - :provider_meta - ]) - |> validate_required([:owner_id, :connected_user_id, :provider_id, :provider_user_id, :provider_meta]) - |> generate_id() - |> foreign_key_constraint(:owner_id) - |> foreign_key_constraint(:connected_user_id) - |> unique_constraint([:provider, :provider_id]) - |> put_change(:provider, "github") - |> put_change(:provider_meta, data) - end -end diff --git a/lib/algora/workspace/schemas/repository.ex b/lib/algora/workspace/schemas/repository.ex deleted file mode 100644 index 36bdde709..000000000 --- a/lib/algora/workspace/schemas/repository.ex +++ /dev/null @@ -1,70 +0,0 @@ -defmodule Algora.Workspace.Repository do - @moduledoc false - use Algora.Schema - - alias Algora.Activities.Activity - alias Algora.Workspace.Repository - - @derive {Inspect, except: [:provider_meta]} - typed_schema "repositories" do - field :provider, :string, null: false - field :provider_id, :string, null: false - field :provider_meta, :map, null: false - - field :name, :string, null: false - field :url, :string, null: false - field :description, :string - field :tech_stack, {:array, :string}, null: false, default: [] - field :topics, {:array, :string}, null: false, default: [] - field :og_image_url, :string, null: false - field :og_image_updated_at, :utc_datetime_usec - field :stargazers_count, :integer, null: false, default: 0 - field :contributors_count, :integer - field :downloads_count, :integer - field :dependents_count, :integer - - has_many :tickets, Algora.Workspace.Ticket - has_many :activities, {"repository_activities", Activity}, foreign_key: :assoc_id - belongs_to :user, Algora.Accounts.User, null: false - - timestamps() - end - - defp og_image_base_url, do: "https://opengraph.githubassets.com" - - def has_default_og_image?(%Repository{} = repository), - do: String.starts_with?(repository.og_image_url, og_image_base_url()) - - def default_og_image_url(repo_owner, repo_name), do: "#{og_image_base_url()}/0/#{repo_owner}/#{repo_name}" - - def github_changeset(meta, user) do - params = %{ - provider_id: to_string(meta["id"]), - name: meta["name"], - description: meta["description"], - og_image_url: default_og_image_url(meta["owner"]["login"], meta["name"]), - og_image_updated_at: DateTime.utc_now(), - url: meta["html_url"], - stargazers_count: meta["stargazers_count"], - topics: meta["topics"] || [], - user_id: user.id - } - - %Repository{provider: "github", provider_meta: meta} - |> cast(params, [ - :provider_id, - :name, - :url, - :description, - :og_image_url, - :og_image_updated_at, - :stargazers_count, - :topics, - :user_id - ]) - |> generate_id() - |> validate_required([:provider_id, :name, :url, :user_id]) - |> foreign_key_constraint(:user_id) - |> unique_constraint([:provider, :provider_id]) - end -end diff --git a/lib/algora/workspace/schemas/stargazer.ex b/lib/algora/workspace/schemas/stargazer.ex deleted file mode 100644 index dd4ad03c2..000000000 --- a/lib/algora/workspace/schemas/stargazer.ex +++ /dev/null @@ -1,32 +0,0 @@ -defmodule Algora.Workspace.Stargazer do - @moduledoc false - use Algora.Schema - - alias Algora.Accounts.User - alias Algora.Workspace.Repository - - typed_schema "stargazers" do - belongs_to :repository, Repository - belongs_to :user, User - - timestamps() - end - - def changeset(stargazer, params) do - stargazer - |> cast(params, [:repository_id, :user_id]) - |> validate_required([:repository_id, :user_id]) - |> foreign_key_constraint(:repository_id) - |> foreign_key_constraint(:user_id) - |> unique_constraint([:repository_id, :user_id]) - |> generate_id() - end - - def filter_by_repository_id(query, nil), do: query - - def filter_by_repository_id(query, repository_id) do - from c in query, - join: r in assoc(c, :repository), - where: r.id == ^repository_id - end -end diff --git a/lib/algora/workspace/schemas/ticket.ex b/lib/algora/workspace/schemas/ticket.ex deleted file mode 100644 index 1bcf848d3..000000000 --- a/lib/algora/workspace/schemas/ticket.ex +++ /dev/null @@ -1,76 +0,0 @@ -defmodule Algora.Workspace.Ticket do - @moduledoc false - use Algora.Schema - - alias Algora.Activities.Activity - alias Algora.Workspace.Ticket - - @derive {Inspect, except: [:provider_meta]} - typed_schema "tickets" do - field :provider, :string - field :provider_id, :string - field :provider_meta, :map - - field :type, Ecto.Enum, values: [:issue, :pull_request] - field :title, :string - field :description, :string - field :number, :integer - field :url, :string - field :state, Ecto.Enum, values: [:open, :closed], default: :open - field :closed_at, :utc_datetime_usec - field :merged_at, :utc_datetime_usec - - belongs_to :repository, Algora.Workspace.Repository - has_many :bounties, Algora.Bounties.Bounty - has_many :tips, Algora.Bounties.Tip - - has_many :activities, {"ticket_activities", Activity}, foreign_key: :assoc_id - - timestamps() - end - - def changeset(ticket, params) do - ticket - |> cast(params, [:title, :description, :url]) - |> validate_required([:title]) - |> generate_id() - end - - def github_changeset(ticket \\ %Ticket{}, meta, repo) do - params = %{ - provider: "github", - provider_id: to_string(meta["id"]), - provider_meta: meta, - type: if(meta["pull_request"], do: :pull_request, else: :issue), - title: meta["title"], - description: meta["body"], - number: meta["number"], - url: meta["html_url"], - repository_id: repo.id, - state: meta["state"], - closed_at: meta["closed_at"], - merged_at: get_in(meta, ["pull_request", "merged_at"]) - } - - ticket - |> cast(params, [ - :provider, - :provider_id, - :provider_meta, - :type, - :title, - :description, - :number, - :url, - :repository_id, - :state, - :closed_at, - :merged_at - ]) - |> generate_id() - |> validate_required([:provider_id, :type, :title, :number, :url, :repository_id, :state]) - - # TODO: Reenable this after migration is complete. - # |> unique_constraint([:provider, :provider_id]) - end -end diff --git a/lib/algora/workspace/schemas/user_contribution.ex b/lib/algora/workspace/schemas/user_contribution.ex deleted file mode 100644 index 1786776e6..000000000 --- a/lib/algora/workspace/schemas/user_contribution.ex +++ /dev/null @@ -1,35 +0,0 @@ -defmodule Algora.Workspace.UserContribution do - @moduledoc """ - Schema for tracking user contributions to repositories. - """ - use Algora.Schema - - alias Algora.Accounts.User - alias Algora.Workspace.Repository - alias Algora.Workspace.UserContribution - - typed_schema "user_contributions" do - field :contribution_count, :integer, null: false, default: 0 - field :last_fetched_at, :utc_datetime_usec, null: false - field :status, Ecto.Enum, values: [:initial, :highlighted, :hidden], default: :initial - - belongs_to :user, User, null: false - belongs_to :repository, Repository, null: false - - timestamps() - end - - @doc """ - Changeset for creating or updating a user contribution. - """ - def changeset(%UserContribution{} = contribution, attrs) do - contribution - |> cast(attrs, [:user_id, :repository_id, :contribution_count, :last_fetched_at, :status]) - |> validate_required([:user_id, :repository_id, :contribution_count, :last_fetched_at]) - |> validate_inclusion(:status, [:initial, :highlighted, :hidden]) - |> generate_id() - |> foreign_key_constraint(:user_id) - |> foreign_key_constraint(:repository_id) - |> unique_constraint([:user_id, :repository_id]) - end -end diff --git a/lib/algora/workspace/schemas/user_heatmap.ex b/lib/algora/workspace/schemas/user_heatmap.ex deleted file mode 100644 index 5707fc288..000000000 --- a/lib/algora/workspace/schemas/user_heatmap.ex +++ /dev/null @@ -1,29 +0,0 @@ -defmodule Algora.Workspace.UserHeatmap do - @moduledoc """ - Schema for tracking user heatmaps. - """ - use Algora.Schema - - alias Algora.Accounts.User - alias Algora.Workspace.UserHeatmap - - typed_schema "user_heatmaps" do - field :data, :map, null: false - - belongs_to :user, User, null: false - - timestamps() - end - - @doc """ - Changeset for creating or updating a user heatmap. - """ - def changeset(%UserHeatmap{} = heatmap, attrs) do - heatmap - |> cast(attrs, [:user_id, :data]) - |> validate_required([:user_id, :data]) - |> generate_id() - |> foreign_key_constraint(:user_id) - |> unique_constraint(:user_id) - end -end diff --git a/lib/algora/workspace/workspace.ex b/lib/algora/workspace/workspace.ex deleted file mode 100644 index 838538eff..000000000 --- a/lib/algora/workspace/workspace.ex +++ /dev/null @@ -1,1231 +0,0 @@ -defmodule Algora.Workspace do - @moduledoc false - import Ecto.Changeset - import Ecto.Query - - alias Algora.Accounts - alias Algora.Accounts.User - alias Algora.Github - alias Algora.Github.Client - alias Algora.Organizations.Member - alias Algora.Repo - alias Algora.Util - alias Algora.Workspace.CommandResponse - alias Algora.Workspace.Contributor - alias Algora.Workspace.Installation - alias Algora.Workspace.Jobs - alias Algora.Workspace.Repository - alias Algora.Workspace.Stargazer - alias Algora.Workspace.Ticket - alias Algora.Workspace.UserContribution - - require Logger - - @type ticket_type :: :issue | :pull_request - @type command_type :: :bounty | :attempt | :claim - @type command_source :: :ticket | :comment - - @doc """ - Resolves a GitHub installation token for interacting with repositories. - - This function attempts to obtain a valid GitHub access token through three methods in order: - 1. If an installation_id is provided directly, it gets an installation token via the GitHub Apps API - 2. If no installation_id is provided, it attempts to look up an installation_id by the repo owner - 3. If no installation is found, it falls back to using the personal access token of the fallback user - - ## Parameters - * `installation_id` - Optional GitHub App installation ID - * `repo_owner` - The GitHub username/org that owns the repository - * `fallback_user` - The user whose personal access token will be used if no installation token is available - - ## Returns - * `{:ok, %{installation_id: integer() | nil, token: String.t()}}` - Successfully obtained token - * `{:error, atom()}` - Failed to obtain token - - ## Examples - # Using provided installation ID - iex> resolve_installation_and_token(12345, "octocat", user) - {:ok, %{installation_id: 12345, token: "ghs_xxx..."}} - - # Looking up installation ID by owner - iex> resolve_installation_and_token(nil, "octocat", user) - {:ok, %{installation_id: 67890, token: "ghs_xxx..."}} - - # Falling back to user's personal access token - iex> resolve_installation_and_token(nil, "octocat", user) - {:ok, %{installation_id: nil, token: "ghp_xxx..."}} - """ - @spec resolve_installation_and_token(integer() | nil, String.t(), User.t()) :: - {:ok, %{installation_id: integer() | nil, token: String.t()}} | {:error, atom()} - def resolve_installation_and_token(installation_id, repo_owner, fallback_user) do - with id when not is_nil(id) <- installation_id || get_installation_id_by_owner(repo_owner), - {:ok, token} <- Github.get_installation_token(id) do - {:ok, %{installation_id: id, token: token}} - else - _ -> - case Accounts.get_access_token(fallback_user) do - {:ok, token} -> {:ok, %{installation_id: nil, token: token}} - error -> error - end - end - end - - @spec get_installation_id_by_owner(String.t()) :: integer() | nil - def get_installation_id_by_owner(repo_owner) do - installation = - Repo.one( - from i in Installation, - join: u in User, - on: u.id == i.provider_user_id and u.provider == i.provider, - where: u.provider == "github" and u.provider_login == ^repo_owner - ) - - if installation, do: installation.provider_id - end - - def ensure_ticket(token, owner, repo, number) do - case get_ticket(owner, repo, number) do - %Ticket{} = ticket -> {:ok, ticket} - nil -> create_ticket_from_github(token, owner, repo, number) - end - end - - def get_ticket(owner, repo, number) do - Repo.one( - from(t in Ticket, - join: r in assoc(t, :repository), - join: u in assoc(r, :user), - where: t.provider == "github", - where: t.number == ^number, - where: r.name == ^repo, - where: u.provider_login == ^owner, - preload: [repository: {r, user: u}], - order_by: [asc: t.inserted_at], - limit: 1 - ) - ) - end - - def create_ticket_from_github(token, owner, repo, number) do - with {:ok, issue} <- Github.get_issue(token, owner, repo, number), - {:ok, repository} <- ensure_repository(token, owner, repo) do - issue - |> Ticket.github_changeset(repository) - |> Repo.insert() - end - end - - def update_ticket_from_github(token, owner, repo, number) do - with ticket when not is_nil(ticket) <- get_ticket(owner, repo, number), - {:ok, issue} <- Github.get_issue(token, owner, repo, number), - {:ok, repository} <- ensure_repository(token, owner, repo) do - ticket - |> Ticket.github_changeset(issue, repository) - |> Repo.update() - end - end - - def ensure_repository(token, owner, repo, opts \\ []) do - if opts[:force] do - create_repository_from_github(token, owner, repo, force: true) - else - repository_query = - from(r in Repository, - join: u in assoc(r, :user), - where: r.provider == "github", - where: r.name == ^repo, - where: u.provider_login == ^owner, - preload: [user: u] - ) - - case Repo.one(repository_query) do - %Repository{} = repository -> {:ok, repository} - nil -> create_repository_from_github(token, owner, repo) - end - end - end - - def ensure_maintainers(token, owner, repo, opts \\ []) do - opts = Keyword.merge([limit: 10], opts) - - contributors = - if opts[:force] do - fetch_and_persist_maintainers(token, owner, repo) - else - case list_maintainers(owner, repo) do - [] -> fetch_and_persist_maintainers(token, owner, repo) - contributors -> contributors - end - end - - Enum.take(contributors, opts[:limit]) - end - - defp fetch_and_persist_maintainers(token, owner, repo) do - with {:ok, repository} <- ensure_repository(token, owner, repo) do - api_contributors = fetch_maintainers(token, owner, repo) - Enum.each(api_contributors, &upsert_contributor_from_github(repository, &1)) - list_maintainers(owner, repo) - end - end - - defp upsert_contributor_from_github(repository, contributor) do - with {:ok, user} <- ensure_user_by_contributor(contributor) do - %Contributor{} - |> Contributor.changeset(%{ - contributions: contributor["contributions"], - repository_id: repository.id, - user_id: user.id - }) - |> Repo.insert( - on_conflict: {:replace, [:contributions, :updated_at]}, - conflict_target: [:repository_id, :user_id] - ) - end - end - - def fetch_maintainers(token, owner, repo) do - path = "/repos/#{owner}/#{repo}/contributors?per_page=100&page=1" - - case Client.fetch(token, path) do - {:ok, contributors} when is_list(contributors) -> - contributors - - {:ok, _} -> - [] - - {:error, reason} -> - Logger.error("Failed to fetch contributors for #{owner}/#{repo}: #{inspect(reason)}") - [] - end - end - - def fetch_contributors_count(token, owner, repo) do - path = "/repos/#{owner}/#{repo}/contributors?per_page=1&anon=1" - - case Client.fetch_with_headers(token, path) do - {:ok, _, headers} -> - link = List.keyfind(headers, "link", 0) - - count = - case link do - {"link", value} -> - case Regex.run(~r/page=(\d+)>; rel="last"/, value) do - [_, n] -> String.to_integer(n) - _ -> 1 - end - - nil -> - 1 - end - - {:ok, count} - - {:error, reason} -> - Logger.error("Failed to fetch contributors count for #{owner}/#{repo}: #{inspect(reason)}") - {:error, reason} - end - end - - def fetch_downloads_count(token, owner, repo) do - fetch_downloads_count_page(token, owner, repo, 1, 0) - end - - defp fetch_downloads_count_page(token, owner, repo, page, acc) do - path = "/repos/#{owner}/#{repo}/releases?per_page=100&page=#{page}" - - case Client.fetch(token, path) do - {:ok, releases} when is_list(releases) and releases != [] -> - total = - Enum.reduce(releases, acc, fn release, sum -> - assets_total = - Enum.reduce(release["assets"] || [], 0, fn asset, s -> s + (asset["download_count"] || 0) end) - - sum + assets_total - end) - - if length(releases) == 100 do - fetch_downloads_count_page(token, owner, repo, page + 1, total) - else - {:ok, total} - end - - {:ok, _} -> - {:ok, acc} - - {:error, reason} -> - Logger.error("Failed to fetch downloads for #{owner}/#{repo}: #{inspect(reason)}") - {:error, reason} - end - end - - def fetch_dependents_count(owner, repo) do - url = "https://github.com/#{owner}/#{repo}/network/dependents" - headers = [{"User-Agent", "Mozilla/5.0"}] - request = Finch.build("GET", url, headers, nil) - - with {:ok, %Finch.Response{body: body}} <- Finch.request(request, Algora.Finch), - [_, count_str] <- - Regex.run( - ~r/dependent_type=REPOSITORY">\s*]*>.*?<\/svg>\s*([\d,]+)\s*Repositories/s, - body - ) do - {:ok, count_str |> String.replace(",", "") |> String.to_integer()} - else - nil -> - {:error, "count not found in page"} - - {:error, reason} -> - Logger.error("Failed to fetch dependents for #{owner}/#{repo}: #{inspect(reason)}") - {:error, reason} - end - end - - def update_repository_counts(%Repository{} = repository, attrs) do - repository - |> cast(attrs, [:contributors_count, :downloads_count, :dependents_count]) - |> Repo.update() - end - - def maybe_schedule_og_image_update(%Repository{} = repository) do - one_day_ago = DateTime.add(DateTime.utc_now(), -1, :day) - - needs_update? = - Repository.has_default_og_image?(repository) || - (repository.og_image_updated_at && - DateTime.before?(repository.og_image_updated_at, one_day_ago)) - - if needs_update? do - %{repository_id: repository.id} - |> Jobs.UpdateRepositoryOgImage.new() - |> Oban.insert() - end - - :ok - end - - def create_repository_from_github(token, owner, repo, opts \\ []) do - insert_opts = - if opts[:force] do - [ - on_conflict: {:replace, [:name, :description, :url, :stargazers_count, :topics, :updated_at, :user_id]}, - conflict_target: [:provider, :provider_id] - ] - else - [] - end - - with {:ok, repository} <- Github.get_repository(token, owner, repo), - {:ok, user} <- ensure_user_by_repo(token, repository, owner), - {:ok, user} <- sync_user(user, repository, owner, repo), - {:ok, repo} <- repository |> Repository.github_changeset(user) |> Repo.insert(insert_opts) do - {:ok, %{repo | user: user}} - else - {:error, - %Ecto.Changeset{ - errors: [ - provider: {_, [constraint: :unique, constraint_name: "repositories_provider_provider_id_index"]} - ] - } = changeset} -> - Repo.fetch_one( - from r in Repository, - where: r.provider == "github", - where: r.provider_id == ^changeset.changes.provider_id, - join: u in assoc(r, :user), - preload: [user: u] - ) - - {:error, _reason} = error -> - error - end - end - - def ensure_user_by_repo(token, repository, owner) do - case Repo.get_by(User, provider: "github", provider_id: to_string(repository["owner"]["id"])) do - %User{} = user -> - {:ok, user} - - nil -> - if repository["owner"]["login"] != owner do - Logger.warning("might need to rename #{owner} -> #{repository["owner"]["login"]}") - end - - ensure_user(token, repository["owner"]["login"]) - end - end - - def ensure_user(token, owner, reload \\ false) do - case Repo.get_by(User, provider: "github", provider_login: owner) do - %User{} = user -> - cond do - reload -> - with {:ok, user_data} <- Github.get_user(token, user.provider_id) do - user - |> User.github_changeset(user_data) - |> Repo.update() - end - - is_nil(user.provider_meta["followers"]) -> - with {:ok, user_data} <- Github.get_user_by_username(token, owner) do - user - |> User.github_changeset(user_data) - |> Repo.update() - end - - true -> - {:ok, user} - end - - nil -> - create_user_from_github(token, owner) - end - end - - def ensure_user_by_provider_id(token, provider_id) do - case Repo.get_by(User, provider: "github", provider_id: to_string(provider_id)) do - %User{} = user -> - if is_nil(user.provider_meta["followers"]) do - with {:ok, user_data} <- Github.get_user(token, provider_id) do - user - |> User.github_changeset(user_data) - |> Repo.update() - end - else - {:ok, user} - end - - nil -> - create_user_from_github_by_id(token, provider_id) - end - end - - def sync_user(user, repository, owner, repo) do - github_user = repository["owner"] - - if github_user["login"] == user.provider_login and not is_nil(user.provider_id) do - {:ok, user} - else - if github_user["login"] != user.provider_login do - Logger.warning( - "renaming #{user.provider_login} -> #{github_user["login"]} (reason: #{owner}/#{repo} moved to #{repository["full_name"]})" - ) - end - - res = - user - |> change(%{ - provider_id: to_string(github_user["id"]), - provider_login: github_user["login"], - provider_meta: Util.normalize_struct(github_user) - }) - |> unique_constraint([:provider, :provider_id]) - |> Repo.update() - - case res do - {:ok, user} -> - {:ok, user} - - error -> - Logger.error("#{owner}/#{repo} | failed to remap #{user.provider_login} -> #{github_user["login"]}") - - error - end - end - end - - def create_user_from_github(token, owner) do - with {:ok, user_data} <- Github.get_user_by_username(token, owner) do - user_data - |> User.github_changeset() - |> Repo.insert() - end - end - - def create_user_from_github_by_id(token, id) do - with {:ok, user_data} <- Github.get_user(token, id) do - user_data - |> User.github_changeset() - |> Repo.insert() - end - end - - def list_installation_repos_by(clauses) do - with {:ok, user} <- Repo.fetch_by(User, clauses), - {:ok, installation} <- Repo.fetch_by(Installation, connected_user_id: user.id), - {:ok, token} <- Github.get_installation_token(installation.provider_id), - {:ok, repos} <- Github.list_installation_repos(token) do - Enum.map(repos, & &1["full_name"]) - end - end - - def create_installation(user, provider_user, org, data) do - %Installation{} - |> Installation.github_changeset(user, provider_user, org, data) - |> Repo.insert() - end - - def update_installation(installation, user, provider_user, org, data) do - installation - |> Installation.github_changeset(user, provider_user, org, data) - |> Repo.update() - end - - def upsert_installation(installation, user, org, provider_user) do - case get_installation_by_provider_id("github", installation["id"]) do - nil -> - case get_installation_by_provider_user_id("github", provider_user.id) do - nil -> - create_installation(user, provider_user, org, installation) - - existing_installation -> - update_installation(existing_installation, user, provider_user, org, installation) - end - - existing_installation -> - update_installation(existing_installation, user, provider_user, org, installation) - end - end - - @spec fetch_installation_by(clauses :: Keyword.t() | map()) :: - {:ok, Installation.t()} | {:error, :not_found} - def fetch_installation_by(clauses) do - Repo.fetch_by(Installation, clauses) - end - - def get_installation_by(fields), do: Repo.get_by(Installation, fields) - def get_installation_by!(fields), do: Repo.get_by!(Installation, fields) - - @type provider_id :: String.t() | integer() - @type provider_user_id :: String.t() | integer() - - @spec get_installation_by_provider_user_id(String.t(), provider_user_id()) :: Installation.t() | nil - def get_installation_by_provider_user_id(provider, provider_user_id), - do: get_installation_by(provider: provider, provider_user_id: to_string(provider_user_id)) - - @spec get_installation_by_provider_id(String.t(), provider_id()) :: Installation.t() | nil - def get_installation_by_provider_id(provider, provider_id), - do: get_installation_by(provider: provider, provider_id: to_string(provider_id)) - - @spec get_installation_by_provider_id!(String.t(), provider_id()) :: Installation.t() - def get_installation_by_provider_id!(provider, provider_id), - do: get_installation_by!(provider: provider, provider_id: to_string(provider_id)) - - def get_installation(id), do: Repo.get(Installation, id) - def get_installation!(id), do: Repo.get!(Installation, id) - - def list_installations_by(fields), - do: - Repo.all( - from(i in Installation, - where: ^fields, - join: connected_user in assoc(i, :connected_user), - join: provider_user in assoc(i, :provider_user), - select_merge: %{connected_user: connected_user, provider_user: provider_user} - ) - ) - - def fetch_command_response(ticket_id, command_type) do - Repo.fetch_one( - from cr in CommandResponse, - where: cr.ticket_id == ^ticket_id, - where: cr.command_type == ^command_type - ) - end - - def delete_command_response(id), do: Repo.delete(Repo.get(CommandResponse, id)) - - @spec ensure_command_response(%{ - token: String.t(), - ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()}, - command_id: integer(), - command_type: command_type(), - command_source: command_source(), - ticket: Ticket.t(), - body: String.t() - }) :: {:ok, CommandResponse.t()} | {:error, any()} - def ensure_command_response(%{ - token: token, - ticket_ref: ticket_ref, - command_id: command_id, - command_type: command_type, - command_source: command_source, - ticket: ticket, - body: body - }) do - case refresh_command_response(%{ - token: token, - ticket_ref: ticket_ref, - ticket: ticket, - body: body, - command_type: command_type - }) do - {:ok, response} -> - {:ok, response} - - {:error, :command_response_not_found} -> - post_response(token, ticket_ref, command_id, command_source, ticket, body) - - {:error, {:comment_not_found, response_id}} -> - with {:ok, _} <- delete_command_response(response_id) do - post_response(token, ticket_ref, command_id, command_source, ticket, body) - end - - {:error, reason} -> - {:error, reason} - end - end - - @spec refresh_command_response(%{ - token: String.t(), - command_type: command_type(), - ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()}, - ticket: Ticket.t(), - body: String.t() - }) :: {:ok, CommandResponse.t()} | {:error, any()} - def refresh_command_response(%{ - token: token, - command_type: command_type, - ticket_ref: ticket_ref, - ticket: ticket, - body: body - }) do - case fetch_command_response(ticket.id, command_type) do - {:ok, response} -> - case Github.update_issue_comment( - token, - ticket_ref[:owner], - ticket_ref[:repo], - response.provider_response_id, - body - ) do - {:ok, comment} -> - try_update_command_response(response, comment) - - # TODO: don't rely on string matching - {:error, "404 Not Found"} -> - Logger.error("Github comment for command response #{response.id} not found") - {:error, {:comment_not_found, response.id}} - - {:error, reason} -> - Logger.error("Failed to update command response #{response.id}: #{inspect(reason)}") - {:error, reason} - end - - {:error, reason} -> - Logger.warning("Failed to refresh command response #{ticket.id}: #{inspect(reason)}") - {:error, :command_response_not_found} - end - end - - defp post_response(token, ticket_ref, command_id, command_source, ticket, body) do - with {:ok, comment} <- - Github.create_issue_comment( - token, - ticket_ref[:owner], - ticket_ref[:repo], - ticket_ref[:number], - body - ) do - create_command_response(%{ - comment: comment, - command_source: command_source, - command_id: command_id, - ticket_id: ticket.id - }) - end - end - - @spec create_command_response(%{ - comment: map(), - command_source: command_source(), - command_id: integer(), - ticket_id: integer() - }) :: {:ok, CommandResponse.t()} | {:error, any()} - def create_command_response(%{ - comment: comment, - command_source: command_source, - command_id: command_id, - ticket_id: ticket_id - }) do - %CommandResponse{} - |> CommandResponse.changeset(%{ - provider: "github", - provider_meta: Util.normalize_struct(comment), - provider_command_id: to_string(command_id), - provider_response_id: to_string(comment["id"]), - command_source: command_source, - command_type: :bounty, - ticket_id: ticket_id - }) - |> Repo.insert() - end - - defp try_update_command_response(command_response, body) do - case command_response - |> CommandResponse.changeset(%{provider_meta: Util.normalize_struct(body)}) - |> Repo.update() do - {:ok, command_response} -> - {:ok, command_response} - - {:error, reason} -> - Logger.error("Failed to update command response #{command_response.id}: #{inspect(reason)}") - - {:ok, command_response} - end - end - - def ensure_repo_tech_stack(_token, %{tech_stack: tech_stack}) when tech_stack != [] do - {:ok, tech_stack} - end - - def ensure_repo_tech_stack(token, repository) do - with {:ok, languages} <- Github.list_repository_languages(token, repository.user.provider_login, repository.name) do - top_languages = - languages - |> Enum.sort_by(fn {_lang, count} -> count end, :desc) - |> Enum.take(3) - |> Enum.map(fn {lang, _count} -> lang end) - - Repo.update_all(from(r in Repository, where: r.id == ^repository.id), set: [tech_stack: top_languages]) - - {:ok, top_languages} - end - rescue - error -> {:error, error} - end - - def ensure_contributors(token, repository) do - case list_repository_contributors(repository.user.provider_login, repository.name) do - [] -> - with {:ok, contributors} <- - Github.list_repository_contributors(token, repository.user.provider_login, repository.name) do - Repo.tx(fn -> - Enum.reduce_while(contributors, {:ok, []}, fn contributor, {:ok, acc} -> - case create_contributor_from_github(repository, contributor) do - {:ok, created} -> {:cont, {:ok, [created | acc]}} - error -> {:halt, error} - end - end) - end) - end - - contributors -> - {:ok, contributors} - end - end - - defp ensure_user_by_contributor(contributor) do - case Repo.get_by(User, provider: "github", provider_id: to_string(contributor["id"])) do - %User{} = user -> - {:ok, user} - - nil -> - contributor - |> Contributor.github_user_changeset() - |> Repo.insert() - end - end - - def create_contributor_from_github(repository, contributor) do - with {:ok, user} <- ensure_user_by_contributor(contributor) do - %Contributor{} - |> Contributor.changeset(%{ - contributions: contributor["contributions"], - repository_id: repository.id, - user_id: user.id - }) - |> Repo.insert() - end - end - - def list_repository_contributors(repo_owner, repo_name) do - Repo.all( - from(c in Contributor, - join: r in assoc(c, :repository), - where: r.provider == "github", - where: r.name == ^repo_name, - join: ro in assoc(r, :user), - where: ro.provider_login == ^repo_owner, - join: u in assoc(c, :user), - where: u.type != :bot, - where: not ilike(u.provider_login, "%bot"), - left_join: m in Member, - on: m.user_id == u.id and m.org_id == r.user_id, - where: is_nil(m.id), - select_merge: %{user: u}, - order_by: [desc: c.contributions, asc: c.inserted_at, asc: c.id] - ) - ) - end - - def list_maintainers(repo_owner, repo_name) do - Repo.all( - from(c in Contributor, - join: r in assoc(c, :repository), - where: r.provider == "github", - where: r.name == ^repo_name, - join: ro in assoc(r, :user), - where: ro.provider_login == ^repo_owner, - join: u in assoc(c, :user), - where: u.type != :bot, - select_merge: %{user: u}, - order_by: [desc: c.contributions, asc: c.inserted_at, asc: c.id] - ) - ) - end - - def list_contributors(repo_owner) do - Repo.all( - from(c in Contributor, - join: r in assoc(c, :repository), - where: r.provider == "github", - join: ro in assoc(r, :user), - where: ro.provider_login == ^repo_owner, - join: u in assoc(c, :user), - where: u.type != :bot, - where: not ilike(u.provider_login, "%bot"), - left_join: m in Member, - on: m.user_id == u.id and m.org_id == r.user_id, - where: is_nil(m.id), - distinct: [c.user_id], - select_merge: %{user: u}, - order_by: [desc: c.contributions, asc: c.inserted_at, asc: c.id], - limit: 50 - ) - ) - end - - def stargazers_query(id) do - from(s in Stargazer, - join: r in assoc(s, :repository), - where: r.provider == "github", - join: ro in assoc(r, :user), - where: ro.id == ^id, - join: u in assoc(s, :user), - where: u.type != :bot, - where: not ilike(u.provider_login, "%bot"), - left_join: m in Member, - on: m.user_id == u.id and m.org_id == r.user_id, - where: is_nil(m.id), - distinct: [s.user_id] - ) - end - - def list_stargazers(id) do - id - |> stargazers_query() - |> select_merge([s, r, ro, u], %{user: u}) - |> limit(100) - |> order_by([s], desc: s.inserted_at, asc: s.id) - |> Repo.all() - end - - def count_stargazers(id) do - id |> stargazers_query() |> Repo.aggregate(:count) - end - - def fetch_contributor(repository_id, user_id) do - Repo.fetch_by(Contributor, repository_id: repository_id, user_id: user_id) - end - - def get_or_create_label(token, owner, repo, name, color) do - case Github.get_label(token, owner, repo, name) do - {:ok, _} -> {:ok, name} - {:error, _reason} -> Github.create_label(token, owner, repo, %{"name" => name, "color" => color}) - end - end - - def add_amount_label(token, owner, repo, number, amount) do - label = "$#{amount |> Money.to_decimal() |> Util.format_number_compact()}" - - with {:ok, _} <- get_or_create_label(token, owner, repo, label, "34d399"), - {:ok, _} <- Github.add_labels(token, owner, repo, number, [label]) do - :ok - else - {:error, reason} -> - Logger.error("Failed to add label #{label} to #{owner}/#{repo}##{number}: #{inspect(reason)}") - - :error - end - end - - def remove_amount_label(token, owner, repo, number, amount) do - label = "$#{amount |> Money.to_decimal() |> Util.format_number_compact()}" - - case Client.fetch(token, "/repos/#{owner}/#{repo}/issues/#{number}/labels/#{label}", "DELETE") do - # TODO: properly parse DELETE responses in Github.Client - {:error, %Jason.DecodeError{data: ""}} -> - :ok - - {:error, reason} -> - Logger.error("Failed to remove label #{label} from #{owner}/#{repo}##{number}: #{inspect(reason)}") - - :error - end - end - - @spec list_user_contributions(list(String.t()), Keyword.t()) :: {:ok, list(map())} | {:error, term()} - def list_user_contributions(ids, opts \\ []) do - tech_stack = opts[:tech_stack] || [] - - strict_tech_stack = opts[:strict_tech_stack] - - query = - from uc in UserContribution, - where: uc.user_id in ^ids, - join: u in assoc(uc, :user), - join: r in assoc(uc, :repository), - where: r.tech_stack != [], - join: repo_owner in assoc(r, :user), - # where: fragment("? && ?::citext[]", r.tech_stack, ^(opts[:tech_stack] || [])), - where: - not (ilike(r.name, "%awesome%") or - ilike(r.name, "%algorithms%") or - ilike(r.name, "%exercises%") or - ilike(r.name, "%tutorials%") or - r.name == "DefinitelyTyped" or - r.name == "developer-roadmap" or - r.name == "freeCodeCamp" or - r.name == "hiring-without-whiteboards" or - r.name == "papers-we-love"), - where: - not (ilike(repo_owner.provider_login, "%algorithms%") or - ilike(repo_owner.provider_login, "%firstcontributions%") or - repo_owner.provider_login == "up-for-grabs"), - order_by: [ - desc: fragment("CASE WHEN ? && ?::citext[] THEN 1 ELSE 0 END", r.tech_stack, ^tech_stack), - desc: r.stargazers_count - ], - select: %UserContribution{ - contribution_count: uc.contribution_count, - user: map(u, [:id, :provider_login]), - status: uc.status, - repository: %{ - id: r.id, - name: r.name, - stargazers_count: r.stargazers_count, - tech_stack: r.tech_stack, - user: map(repo_owner, [:id, :provider_login, :type, :name, :avatar_url, :stargazers_count]) - } - } - - query = - if opts[:display_all] do - query - else - where(query, [uc, u, r, repo_owner], repo_owner.type == :organization or r.stargazers_count > 200) - end - - query = - if opts[:exclude_personal] do - where(query, [uc, u, r, repo_owner], repo_owner.type == :organization) - else - query - end - - query = - if strict_tech_stack do - where(query, [uc, u, r, repo_owner], fragment("? && ?::citext[]", r.tech_stack, ^tech_stack)) - else - query - end - - query = - case opts[:limit] do - nil -> query - limit -> limit(query, ^limit) - end - - Repo.all(query) - end - - @spec count_user_contributions(list(String.t()), Keyword.t()) :: {:ok, list(map())} | {:error, term()} - def count_user_contributions(ids, opts \\ []) do - query = - from uc in UserContribution, - join: u in assoc(uc, :user), - join: r in assoc(uc, :repository), - join: repo_owner in assoc(r, :user), - where: u.id in ^ids, - where: not ilike(r.name, "%awesome%"), - where: not ilike(r.name, "%algorithms%"), - where: not ilike(repo_owner.provider_login, "%algorithms%"), - where: repo_owner.type == :organization or r.stargazers_count > 200, - # where: fragment("? && ?::citext[]", r.tech_stack, ^(opts[:tech_stack] || [])), - order_by: [ - desc: fragment("CASE WHEN ? && ?::citext[] THEN 1 ELSE 0 END", r.tech_stack, ^(opts[:tech_stack] || [])), - desc: r.stargazers_count - ], - select_merge: %{user: u, repository: %{r | user: repo_owner}} - - Repo.aggregate(query, :sum, :contribution_count) || 0 - end - - @spec upsert_contributor(String.t(), String.t(), integer()) :: - {:ok, Contributor.t()} | {:error, Ecto.Changeset.t()} - def upsert_contributor(user_id, repository_id, contribution_count) do - attrs = %{ - user_id: user_id, - repository_id: repository_id, - contribution_count: contribution_count, - last_fetched_at: DateTime.utc_now() - } - - case Repo.get_by(Contributor, user_id: user_id, repository_id: repository_id) do - nil -> - %Contributor{} |> Contributor.changeset(attrs) |> Repo.insert() - - contributor -> - if contributor.contributions < contribution_count do - contributor - |> Contributor.changeset(attrs) - |> Repo.update() - else - {:ok, contributor} - end - end - end - - @spec upsert_user_contribution(String.t(), String.t(), integer()) :: - {:ok, UserContribution.t()} | {:error, Ecto.Changeset.t()} - def upsert_user_contribution(user_id, repository_id, contribution_count) do - attrs = %{ - user_id: user_id, - repository_id: repository_id, - contribution_count: contribution_count, - last_fetched_at: DateTime.utc_now() - } - - case Repo.get_by(UserContribution, user_id: user_id, repository_id: repository_id) do - nil -> - %UserContribution{} |> UserContribution.changeset(attrs) |> Repo.insert() - - contribution -> - if contribution.contribution_count < contribution_count do - contribution - |> UserContribution.changeset(attrs) - |> Repo.update() - else - {:ok, contribution} - end - end - end - - def fetch_top_contributions(token, provider_logins, opts \\ []) when is_list(provider_logins) do - users_with_contributions = - if opts[:refresh] do - [] - else - get_users_with_contributions(provider_logins) - end - - users_with_contributions_logins = Enum.map(users_with_contributions, & &1.provider_login) - - users_without_contributions = provider_logins -- users_with_contributions_logins - - cloud_result = - if Enum.empty?(users_without_contributions) do - {:ok, []} - else - Algora.Cloud.top_contributions(users_without_contributions) - end - - with {:ok, cloud_contributions} <- cloud_result, - {:ok, users} <- ensure_users(token, provider_logins), - :ok <- add_contributions(token, users, cloud_contributions) do - # Always mark users as synced after fetching from Cloud API - mark_users_as_synced(users_without_contributions, users) - {:ok, users} - else - {:error, reason} -> - Logger.error("Failed to fetch contributions for #{inspect(provider_logins)}: #{inspect(reason)}") - {:error, reason} - end - end - - def fetch_top_contributions_async(token, provider_logins) when is_list(provider_logins) do - users_with_contributions = get_users_with_contributions(provider_logins) - users_with_contributions_logins = Enum.map(users_with_contributions, & &1.provider_login) - - users_without_contributions = provider_logins -- users_with_contributions_logins - - cloud_result = - if Enum.empty?(users_without_contributions) do - {:ok, []} - else - Algora.Cloud.top_contributions(users_without_contributions) - end - - with {:ok, cloud_contributions} <- cloud_result, - {:ok, users} <- ensure_users(token, provider_logins), - :ok <- add_contributions_async(token, users, cloud_contributions) do - # Always mark users as synced after fetching from Cloud API - mark_users_as_synced(users_without_contributions, users) - {:ok, users} - else - {:error, reason} -> - Logger.error("Failed to fetch contributions for #{inspect(provider_logins)}: #{inspect(reason)}") - {:error, reason} - end - end - - defp add_contributions_async(_token, users, contributions) do - users_map = Enum.group_by(users, & &1.provider_login) - - results = - Enum.flat_map(contributions, fn contribution -> - case users_map[contribution.provider_login] do - [user] -> - %{ - "user_id" => user.id, - "repo_full_name" => contribution.repo_name, - "contribution_count" => contribution.contribution_count - } - |> Jobs.SyncContribution.new() - |> Oban.insert() - |> then(&[&1]) - - _ -> - [] - end - end) - - if results == [] or Enum.any?(results, &match?({:ok, _}, &1)) do - :ok - else - Logger.error("Failed to add contributions: #{inspect(results)}") - {:error, :failed} - end - end - - defp add_contributions(token, users, contributions) do - users_map = Enum.group_by(users, & &1.provider_login) - - results = - Enum.map(contributions, fn contribution -> - case users_map[contribution.provider_login] do - [user] -> - add_contribution(%{ - token: token, - user_id: user.id, - repo_full_name: contribution.repo_name, - contribution_count: contribution.contribution_count - }) - - _ -> - {:error, :user_not_found} - end - end) - - if results == [] or Enum.any?(results, fn result -> result == :ok end), do: :ok, else: {:error, :failed} - end - - defp add_contribution(%{ - token: token, - user_id: user_id, - repo_full_name: repo_full_name, - contribution_count: contribution_count - }) do - with [repo_owner, repo_name] <- String.split(repo_full_name, "/"), - {:ok, repo} <- ensure_repository(token, repo_owner, repo_name), - {:ok, _tech_stack} <- ensure_repo_tech_stack(token, repo), - {:ok, _contribution} <- upsert_user_contribution(user_id, repo.id, contribution_count) do - :ok - end - end - - def ensure_users(token, provider_logins) do - provider_logins - |> Enum.map(&ensure_user(token, &1)) - |> Enum.reduce_while({:ok, []}, fn - {:ok, user}, {:ok, users} -> - {:cont, {:ok, [user | users]}} - - {:error, reason}, acc -> - Logger.error("Something went wrong fetching user #{inspect(reason)}") - {:cont, acc} - # {:halt, {:error, reason}} - end) - end - - @spec get_users_with_contributions(list(String.t())) :: list(User.t()) - defp get_users_with_contributions(provider_logins) do - # Get users who either have contributions OR have been marked as synced - users_with_contributions = - Repo.all( - from u in User, - join: uc in UserContribution, - on: uc.user_id == u.id, - where: u.provider == "github", - where: u.provider_login in ^provider_logins, - distinct: u.id, - select: u - ) - - users_marked_synced = - Repo.all( - from u in User, - where: u.provider == "github", - where: u.provider_login in ^provider_logins, - where: u.repo_contributions_synced == true, - select: u - ) - - # Combine and deduplicate by id - Enum.uniq_by(users_with_contributions ++ users_marked_synced, & &1.id) - end - - @spec mark_users_as_synced(list(String.t()), list(User.t())) :: :ok - defp mark_users_as_synced(users_without_contributions, users) do - if not Enum.empty?(users_without_contributions) do - Logger.info("Marking users as repo_contributions_synced: #{inspect(users_without_contributions)}") - - # Update all users that were queried from Cloud API to mark them as synced - users_map = Enum.group_by(users, & &1.provider_login) - - Enum.each(users_without_contributions, fn provider_login -> - case users_map[provider_login] do - [user] -> - user - |> Ecto.Changeset.change(%{repo_contributions_synced: true}) - |> Repo.update() - - _ -> - Logger.warning("User not found for marking as synced: #{provider_login}") - end - end) - end - - :ok - end - - def get_amount_labels(token, owner, repo, number) do - case Github.list_labels(token, owner, repo, number) do - {:ok, labels} -> - amount_labels = - labels - |> Enum.filter(fn label -> String.match?(label["name"], ~r/^\$\d+[.,]?\d*$/) end) - |> Enum.map(fn label -> label["name"] end) - - {:ok, amount_labels} - - {:error, reason} -> - {:error, reason} - end - end - - def remove_existing_amount_labels(token, owner, repo, number) do - case get_amount_labels(token, owner, repo, number) do - {:ok, amount_labels} -> - Enum.each(amount_labels, fn label_name -> - Github.remove_label_from_issue(token, owner, repo, number, label_name) - end) - - :ok - - {:error, reason} -> - Logger.warning("Failed to list labels for #{owner}/#{repo}##{number}: #{inspect(reason)}") - :ok - end - end -end diff --git a/lib/algora_web.ex b/lib/algora_web.ex deleted file mode 100644 index be9c2c519..000000000 --- a/lib/algora_web.ex +++ /dev/null @@ -1,123 +0,0 @@ -defmodule AlgoraWeb do - @moduledoc """ - The entrypoint for defining your web interface, such - as controllers, components, channels, and so on. - - This can be used in your application as: - - use AlgoraWeb, :controller - use AlgoraWeb, :html - - The definitions below will be executed for every controller, - component, etc, so keep them short and clean, focused - on imports, uses and aliases. - - Do NOT define functions inside the quoted expressions - below. Instead, define additional modules and import - those modules here. - """ - - def static_paths, do: ~w(assets fonts images videos favicon.ico robots.txt) - - def router do - quote do - use Phoenix.Router, helpers: false - - import Phoenix.Controller - import Phoenix.LiveView.Router - - # Import common connection and controller functions to use in pipelines - import Plug.Conn - end - end - - def channel do - quote do - use Phoenix.Channel - end - end - - def controller do - quote do - use Phoenix.Controller, - formats: [:html, :json], - layouts: [html: AlgoraWeb.Layouts] - - use Gettext, backend: AlgoraWeb.Gettext - - import Plug.Conn - - unquote(verified_routes()) - end - end - - def live_view do - quote do - use Phoenix.LiveView, - layout: {AlgoraWeb.Layouts, :app} - - unquote(html_helpers()) - end - end - - def live_component do - quote do - use Phoenix.LiveComponent - - unquote(html_helpers()) - end - end - - def html do - quote do - use Phoenix.Component - - # Import convenience functions from controllers - import Phoenix.Controller, - only: [get_csrf_token: 0, view_module: 1, view_template: 1] - - # Include general helpers for rendering HTML - unquote(html_helpers()) - end - end - - defp html_helpers do - quote do - use Gettext, backend: AlgoraWeb.Gettext - - # Core UI components and translation - import AlgoraWeb.CoreComponents - - # Enable rendering Svelte components - import LiveSvelte - - # HTML escaping functionality - import Phoenix.HTML - - # class helpers - import Tails, only: [classes: 1] - - # Shortcut for generating JS commands - alias Phoenix.LiveView.JS - - # Routes generation with the ~p sigil - unquote(verified_routes()) - end - end - - def verified_routes do - quote do - use Phoenix.VerifiedRoutes, - endpoint: AlgoraWeb.Endpoint, - router: AlgoraWeb.Router, - statics: AlgoraWeb.static_paths() - end - end - - @doc """ - When used, dispatch to the appropriate controller/view/etc. - """ - defmacro __using__(which) when is_atom(which) do - apply(__MODULE__, which, []) - end -end diff --git a/lib/algora_web/components/achievement.ex b/lib/algora_web/components/achievement.ex deleted file mode 100644 index 0b465c3ed..000000000 --- a/lib/algora_web/components/achievement.ex +++ /dev/null @@ -1,96 +0,0 @@ -defmodule AlgoraWeb.Components.Achievement do - @moduledoc false - use AlgoraWeb.Component - - import AlgoraWeb.CoreComponents - - def link_attrs(path) when is_binary(path), do: [navigate: path] - def link_attrs(opts) when is_list(opts), do: opts - def link_attrs(_opts), do: [] - - def achievement(%{achievement: %{status: :completed}} = assigns) do - ~H""" -
-
- <.icon name="tabler-circle-check-filled" class="h-5 w-5" /> -
- - {@achievement.name} - -
- """ - end - - def achievement(%{achievement: %{status: :upcoming}} = assigns) do - ~H""" - <.maybe_link {link_attrs(@achievement.path)}> -
-
-
-
-
- - {@achievement.name} - -
- - """ - end - - def achievement(%{achievement: %{status: :current}} = assigns) do - ~H""" - <.maybe_link {link_attrs(@achievement.path)}> -
- - - {@achievement.name} - -
- - """ - end -end diff --git a/lib/algora_web/components/activity.ex b/lib/algora_web/components/activity.ex deleted file mode 100644 index a0e57bf83..000000000 --- a/lib/algora_web/components/activity.ex +++ /dev/null @@ -1,121 +0,0 @@ -defmodule AlgoraWeb.Components.Activity do - @moduledoc false - use AlgoraWeb.Component - - import AlgoraWeb.CoreComponents - - alias Algora.Activities - - attr :activities, :list, required: true - attr :id, :string, required: true - - def activities_timeline(assigns) do - ~H""" -
-
-
- <.activity_card activity={activity} /> -
-
-
- """ - end - - attr :activity, :map, required: true - - def activity_card(assigns) do - ~H""" - <.link - href={Activities.redirect_url_for_activity(assigns[:activity])} - class="flex flex-grow items-center gap-4 mt-4 mb-4 p-4 pb-4 border-b w-full last:border-none first:border-t first:mt-0 first:pt-4" - tabindex="-1" - phx-mounted={ - JS.transition( - {"first:ease-in duration-500", "first:opacity-0 first:p-0 first:h-0", "first:opacity-100"}, - time: 500 - ) - } - > -
- <.icon name={activity_icon(to_string(@activity.type))} class="h-5 w-5" /> -
-
-
- <.activity_name type={assigns.activity.type} /> -
-
- {Calendar.strftime(assigns.activity.inserted_at, "%b %d, %Y, %H:%M:%S")} -
-
- - """ - end - - attr :type, :atom, required: true - - def activity_name(%{type: type} = assigns) do - assigns = assign(assigns, :name, Activities.activity_type_to_name(type)) - - ~H""" -
{@name}
- """ - end - - attr :id, :string, required: true - attr :class, :string, default: nil - attr :activities, :list, required: true - - def dropdown_activities(assigns) do - ~H""" -
-
- -
- -
- """ - end - - defp activity_icon(_type) do - "tabler-file-check" - end - - defp activity_background_class(_type) do - "bg-primary/20" - end -end diff --git a/lib/algora_web/components/autopay.ex b/lib/algora_web/components/autopay.ex deleted file mode 100644 index 656d4287b..000000000 --- a/lib/algora_web/components/autopay.ex +++ /dev/null @@ -1,95 +0,0 @@ -defmodule AlgoraWeb.Components.Autopay do - @moduledoc false - use AlgoraWeb.Component - - def autopay(assigns) do - ~H""" -
-
-
- -

- Merged pull request -

-
-
-
-
- -

- Charged saved payment method -

-
-
-
-
- - - - - - -

- Transferring funds to contributor -

-
-
-
- """ - end -end diff --git a/lib/algora_web/components/banner.ex b/lib/algora_web/components/banner.ex deleted file mode 100644 index 6cd319340..000000000 --- a/lib/algora_web/components/banner.ex +++ /dev/null @@ -1,48 +0,0 @@ -defmodule AlgoraWeb.Components.Banner do - @moduledoc false - use AlgoraWeb.Component - use AlgoraWeb, :verified_routes - - import AlgoraWeb.CoreComponents - - alias AlgoraWeb.Constants - - def banner(assigns) do - ~H""" -
-

- <.link - href={Constants.get(:github_repo_url)} - rel="noopener" - target="_blank" - class="font-semibold" - > - 🎉 Algora is now open source!Give us a star - <.icon - name="tabler-arrow-right" - class="size-4 group-hover:translate-x-1.5 transition-transform" - /> - -

-
- <%!-- --%> -
-
- """ - end -end diff --git a/lib/algora_web/components/bounties.ex b/lib/algora_web/components/bounties.ex deleted file mode 100644 index 2d6e6adb2..000000000 --- a/lib/algora_web/components/bounties.ex +++ /dev/null @@ -1,47 +0,0 @@ -defmodule AlgoraWeb.Components.Bounties do - @moduledoc false - use AlgoraWeb.Component - - import AlgoraWeb.CoreComponents - - alias Algora.Accounts.User - alias Algora.Bounties.Bounty - - def bounties(assigns) do - ~H""" -
-
    - <%= for bounty <- @bounties do %> - <.link href={Bounty.url(bounty)} class="block whitespace-nowrap hover:bg-muted/50"> -
  • -
    - <.avatar class="h-8 w-8"> - <.avatar_image src={bounty.repository.owner.avatar_url || bounty.owner.avatar_url} /> - <.avatar_fallback> - {Algora.Util.initials(User.handle(bounty.repository.owner || bounty.owner))} - - -
    - -
    -
    - - {bounty.repository.owner.name || bounty.owner.name} - - - #{bounty.ticket.number} - - - {Money.to_string!(bounty.amount)} - - {bounty.ticket.title} -
    -
    -
  • - - <% end %> -
-
- """ - end -end diff --git a/lib/algora_web/components/component.ex b/lib/algora_web/components/component.ex deleted file mode 100644 index 19e194d12..000000000 --- a/lib/algora_web/components/component.ex +++ /dev/null @@ -1,24 +0,0 @@ -defmodule AlgoraWeb.Component do - @moduledoc """ - The entrypoint for defining UI components. - - This can be used in your components as: - - use AlgoraWeb - - Do NOT define functions inside the quoted expressions - below. Instead, define additional modules and import - those modules here. - """ - - defmacro __using__(_) do - quote do - use Phoenix.Component - - import AlgoraWeb.ComponentHelpers - import Tails, only: [classes: 1] - - alias Phoenix.LiveView.JS - end - end -end diff --git a/lib/algora_web/components/core_components.ex b/lib/algora_web/components/core_components.ex deleted file mode 100644 index d4d5f4e1e..000000000 --- a/lib/algora_web/components/core_components.ex +++ /dev/null @@ -1,1448 +0,0 @@ -defmodule AlgoraWeb.CoreComponents do - @moduledoc """ - Provides core UI components. - - The components in this module use Tailwind CSS, a utility-first CSS framework. - See the [Tailwind CSS documentation](https://tailwindcss.com) to learn how to - customize the generated components in this module. - - Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage. - """ - use AlgoraWeb.Component - use AlgoraWeb, :verified_routes - use Gettext, backend: AlgoraWeb.Gettext - - alias AlgoraWeb.Components.UI.Accordion - alias AlgoraWeb.Components.UI.Alert - alias AlgoraWeb.Components.UI.Avatar - alias AlgoraWeb.Components.UI.Card - alias AlgoraWeb.Components.UI.Dialog - alias AlgoraWeb.Components.UI.Drawer - alias AlgoraWeb.Components.UI.DropdownMenu - alias AlgoraWeb.Components.UI.HoverCard - alias AlgoraWeb.Components.UI.Menu - alias AlgoraWeb.Components.UI.Multiline - alias AlgoraWeb.Components.UI.Popover - alias AlgoraWeb.Components.UI.RadioGroup - alias AlgoraWeb.Components.UI.Select - alias AlgoraWeb.Components.UI.Sheet - alias AlgoraWeb.Components.UI.Tabs - alias AlgoraWeb.Components.UI.ToggleGroup - alias AlgoraWeb.Components.UI.Tooltip - alias Phoenix.HTML.Form - alias Phoenix.HTML.FormField - alias Phoenix.LiveView.JS - - slot :inner_block - - def connection_status(assigns) do - ~H""" - - """ - end - - attr :class, :string, default: nil - - def logo(assigns) do - ~H""" - <.link navigate="/" aria-label="Algora"> - - - """ - end - - attr :class, :string, default: nil - - def wordmark(assigns) do - ~H""" - <.link navigate="/" aria-label="Algora"> - - - """ - end - - @doc """ - Returns a button triggered dropdown with aria keyboard and focus supporrt. - - Accepts the follow slots: - - * `:id` - The id to uniquely identify this dropdown - * `:img` - The optional img to show beside the button title - * `:title` - The button title - * `:subtitle` - The button subtitle - - ## Examples - - <.dropdown id={@id}> - <:img src={@current_user.avatar_url} alt={@current_user.handle}/> - <:title><%= @current_user.name %> - <:subtitle>@<%= @current_user.handle %> - - <:link navigate={~p"/"}>Dashboard - <:link navigate={~p"/user/settings"}Settings - - """ - attr :id, :string, required: true - attr :class, :string, default: nil - attr :border, :boolean, default: false - - slot :img do - attr :src, :string - attr :alt, :string - end - - slot :title - slot :subtitle - - slot :link do - attr :navigate, :string - attr :href, :string - attr :patch, :string - attr :method, :any - end - - def dropdown(assigns) do - ~H""" - -
-
- -
- -
- """ - end - - def context_selector(assigns) do - ~H""" - <.dropdown id="dashboard-dropdown" class="min-w-[12rem]"> - <:img src={@current_context.avatar_url} alt={@current_context.handle} /> - <:title>{@current_context.name} - <:subtitle :if={@current_context.handle}> - @{@current_context.handle} - - <:link :if={@current_user.handle} href={~p"/set_context/personal"}> -
-
- <.avatar class="mr-3 size-10"> - <.avatar_image src={@current_user.avatar_url} /> - <.avatar_fallback> - {Algora.Util.initials(@current_user.name)} - - -
- <.icon name="tabler-code" class="size-5 text-foreground" /> -
-
-
-
{@current_user.name}
-
- @{@current_user.handle} -
-
-
- - <:link - :for={ctx <- @all_contexts |> Enum.filter(&(&1.id != @current_user.id))} - :if={@current_context.id != ctx.id} - href={ - cond do - ctx.id == @current_user.id -> ~p"/set_context/personal" - not is_nil(ctx.handle) -> ~p"/set_context/#{ctx.handle}" - true -> ~p"/set_context/preview?id=#{ctx.id}" - end - } - > -
- <.avatar class="mr-3 size-10"> - <.avatar_image src={ctx.avatar_url} /> - <.avatar_fallback> - {Algora.Util.initials(ctx.name)} - - -
-
{ctx.name}
-
@{ctx.handle}
-
-
- - <:link :if={@current_user.is_admin} href={~p"/admin"}> -
-
- <.icon name="tabler-adjustments-alt" class="size-6" /> -
-
Admin
-
- - <:link :if={@current_user.id == @current_context.id} href={~p"/user/transactions"}> -
-
- <.icon name="tabler-wallet" class="size-6" /> -
-
Payouts
-
- - <:link href={~p"/auth/logout"}> -
-
- <.icon name="tabler-logout" class="size-6" /> -
-
- {if is_nil(@current_user.handle), do: "Exit preview", else: "Logout"} -
-
- - - """ - end - - @doc """ - Returns a button triggered dropdown with aria keyboard and focus supporrt. - - Accepts the follow slots: - - * `:id` - The id to uniquely identify this dropdown - * `:img` - The optional img to show beside the button title - - ## Examples - - <.dropdown id={@id}> - <:img src={@current_user.avatar_url} alt={@current_user.handle}/> - - <:link navigate={~p"/"}>Dashboard - <:link navigate={~p"/user/settings"}Settings - - """ - attr :id, :string, required: true - - slot :img do - attr :src, :string - attr :alt, :string - end - - slot :link do - attr :navigate, :string - attr :href, :string - attr :method, :any - end - - def simple_dropdown(assigns) do - ~H""" - -
-
- -
- -
- """ - end - - def show_mobile_sidebar(js \\ %JS{}) do - js - |> JS.show(to: "#mobile-sidebar-container", transition: "fade-in") - |> JS.show( - to: "#mobile-sidebar", - display: "flex", - time: 300, - transition: {"transition ease-in-out duration-300 transform", "-translate-x-full", "translate-x-0"} - ) - |> JS.hide(to: "#show-mobile-sidebar", transition: "fade-out") - |> JS.dispatch("js:exec", to: "#hide-mobile-sidebar", detail: %{call: "focus", args: []}) - end - - def hide_mobile_sidebar(js \\ %JS{}) do - js - |> JS.hide(to: "#mobile-sidebar-container", transition: "fade-out") - |> JS.hide( - to: "#mobile-sidebar", - time: 300, - transition: {"transition ease-in-out duration-300 transform", "translate-x-0", "-translate-x-full"} - ) - |> JS.show(to: "#show-mobile-sidebar", transition: "fade-in") - |> JS.dispatch("js:exec", to: "#show-mobile-sidebar", detail: %{call: "focus", args: []}) - end - - def show_dropdown(to) do - [ - to: to, - transition: {"transition ease-out duration-120", "transform opacity-0 scale-95", "transform opacity-100 scale-100"} - ] - |> JS.show() - |> JS.set_attribute({"aria-expanded", "true"}, to: to) - end - - def hide_dropdown(to) do - [ - to: to, - transition: {"transition ease-in duration-120", "transform opacity-100 scale-100", "transform opacity-0 scale-95"} - ] - |> JS.hide() - |> JS.remove_attribute("aria-expanded", to: to) - end - - @doc """ - Renders a modal. - - ## Examples - - <.modal id="confirm-modal"> - Are you sure? - <:confirm>OK - <:cancel>Cancel - - - JS commands may be passed to the `:on_cancel` and `on_confirm` attributes - for the caller to react to each button press, for example: - - <.modal id="confirm" on_confirm={JS.push("delete")} on_cancel={JS.navigate(~p"/posts")}> - Are you sure you? - <:confirm>OK - <:cancel>Cancel - - """ - attr :id, :string, required: true - attr :show, :boolean, default: false - attr :on_cancel, JS, default: %JS{} - attr :on_confirm, JS, default: %JS{} - - slot :inner_block, required: true - slot :title - slot :subtitle - slot :confirm - slot :cancel - - def modal(assigns) do - ~H""" -