├── tests ├── test_helper.exs ├── channel_case.ex ├── conn_case.ex └── data_case.ex ├── priv └── repo │ ├── migrations │ ├── .formatter.exs │ ├── 20200927191955_users.exs │ ├── 20200927192022_posts.exs │ └── 20200927192047_chats.exs │ └── seeds.exs ├── lib ├── repo.ex ├── graphql │ ├── dataloader.ex │ ├── middleware │ │ └── authMiddleware.ex │ ├── schema.ex │ ├── context.ex │ ├── resolvers │ │ ├── user.ex │ │ ├── chat.ex │ │ └── post.ex │ └── typedefs │ │ ├── user.ex │ │ ├── chat.ex │ │ └── post.ex ├── schema │ ├── post_like.ex │ ├── chat_membership.ex │ ├── post_comment.ex │ ├── chat_message.ex │ ├── chat.ex │ ├── post.ex │ └── user.ex ├── guardian.ex ├── router.ex ├── api.ex ├── uploads │ ├── file.ex │ └── image.ex ├── application.ex ├── endpoint.ex ├── channels │ └── user_socket.ex └── telemetry.ex ├── .formatter.exs ├── .env-sample ├── Dockerfile ├── config ├── test.exs ├── prod.secret.exs ├── dev.exs ├── config.exs └── prod.exs ├── docker-compose.yml ├── .gitignore ├── README.md ├── mix.exs └── mix.lock /tests/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | Ecto.Adapters.SQL.Sandbox.mode(API.Repo, :manual) 3 | -------------------------------------------------------------------------------- /priv/repo/migrations/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto_sql], 3 | inputs: ["*.exs"] 4 | ] 5 | -------------------------------------------------------------------------------- /lib/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule API.Repo do 2 | use Ecto.Repo, 3 | otp_app: :api, 4 | adapter: Ecto.Adapters.Postgres, 5 | migration_timestamps: [type: :timestamptz] 6 | end 7 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto, :phoenix], 3 | inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"], 4 | subdirectories: ["priv/*/migrations"] 5 | ] 6 | -------------------------------------------------------------------------------- /.env-sample: -------------------------------------------------------------------------------- 1 | DB_USER="" 2 | DB_PASSWORD="" 3 | DB_URL= 4 | 5 | BACKBLAZE_access_key="" 6 | BACKBLAZE_secret_key="" 7 | BACKBLAZE_S3_BUCKET="" 8 | BACKBLAZE_ASSET_HOST="" 9 | 10 | IMGPROXY_KEY="" 11 | IMGPROXY_SALT="" -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM elixir:latest 2 | 3 | # Create app directory and copy the Elixir projects into it 4 | RUN mkdir /app 5 | COPY . /app 6 | WORKDIR /app 7 | 8 | RUN mix local.hex --force 9 | RUN mix local.rebar --force 10 | 11 | CMD mix deps.get && mix deps.compile && mix ecto.setup && mix start 12 | 13 | -------------------------------------------------------------------------------- /lib/graphql/dataloader.ex: -------------------------------------------------------------------------------- 1 | defmodule API.Graphql.Data do 2 | def data() do 3 | Dataloader.Ecto.new(API.Repo, query: &query/2) 4 | end 5 | 6 | def loader() do 7 | Dataloader.new() 8 | |> Dataloader.add_source(:db, data()) 9 | end 10 | 11 | def query(queryable, _params) do 12 | queryable 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/graphql/middleware/authMiddleware.ex: -------------------------------------------------------------------------------- 1 | defmodule API.Graphql.AuthMiddleware do 2 | @behaviour Absinthe.Middleware 3 | 4 | def call(resolution, _config) do 5 | case resolution.context do 6 | %{current_user: _} -> 7 | resolution 8 | 9 | _ -> 10 | resolution 11 | |> Absinthe.Resolution.put_result({:error, "unauthenticated"}) 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /priv/repo/seeds.exs: -------------------------------------------------------------------------------- 1 | # Script for populating the database. You can run it as: 2 | # 3 | # mix run priv/repo/seeds.exs 4 | # 5 | # Inside the script, you can read and write to any of your 6 | # repositories directly: 7 | # 8 | # API.Repo.insert!(%API.SomeSchema{}) 9 | # 10 | # We recommend using the bang functions (`insert!`, `update!` 11 | # and so on) as they will fail if something goes wrong. 12 | -------------------------------------------------------------------------------- /lib/schema/post_like.ex: -------------------------------------------------------------------------------- 1 | defmodule API.PostLike do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | @timestamps_opts [type: :utc_datetime] 6 | schema "post_likes" do 7 | field(:creator_id, :id) 8 | timestamps() 9 | 10 | belongs_to(:post, API.Post) 11 | end 12 | 13 | def changeset(post_like, args) do 14 | post_like |> cast(args, [:creator_id, :post_id]) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/schema/chat_membership.ex: -------------------------------------------------------------------------------- 1 | defmodule API.ChatMembership do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | alias API.{User, Chat} 6 | 7 | @timestamps_opts [type: :utc_datetime] 8 | schema "chat_memberships" do 9 | timestamps() 10 | belongs_to(:user, User, foreign_key: :member_id) 11 | belongs_to(:chat, Chat) 12 | end 13 | 14 | def changeset(chat, args) do 15 | chat 16 | |> cast(args, [:chat_id, :member_id]) 17 | |> validate_required([:chat_id, :member_id]) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200927191955_users.exs: -------------------------------------------------------------------------------- 1 | defmodule API.Repo.Migrations.Users do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table("users") do 6 | add(:email, :string, null: false) 7 | add(:password, :string, null: false) 8 | add(:name, :string, null: false, size: 50) 9 | add(:avatar, :string) 10 | add(:type, :string, null: false) 11 | timestamps(type: :timestamptz) 12 | add(:archived_at, :timestamptz, default: nil) 13 | end 14 | 15 | create(unique_index("users", [:email])) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/guardian.ex: -------------------------------------------------------------------------------- 1 | defmodule API.Guardian do 2 | use Guardian, otp_app: :api 3 | alias API.{Repo, User} 4 | 5 | def subject_for_token(resource, _claims) do 6 | {:ok, to_string(resource.id)} 7 | end 8 | 9 | def subject_for_token() do 10 | {:error, "Not authorized"} 11 | end 12 | 13 | def resource_from_claims(claims) do 14 | id = claims["sub"] 15 | {:ok, Repo.get!(User, id)} 16 | rescue 17 | Ecto.NoResultsError -> {:error, "Not found"} 18 | end 19 | 20 | def resource_from_claims() do 21 | {:error, "Not authorized"} 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/schema/post_comment.ex: -------------------------------------------------------------------------------- 1 | defmodule API.PostComment do 2 | use Ecto.Schema 3 | 4 | import Ecto.Changeset 5 | 6 | alias API.{User, Post} 7 | @timestamps_opts [type: :utc_datetime] 8 | schema "post_comments" do 9 | field(:content, :string) 10 | timestamps() 11 | 12 | belongs_to(:post, Post) 13 | belongs_to(:user, User, foreign_key: :creator_id) 14 | end 15 | 16 | def changeset(comment, args) do 17 | comment 18 | |> cast(args, [:content, :creator_id, :post_id]) 19 | |> validate_required([:content, :creator_id, :post_id]) 20 | |> validate_length(:content, min: 1) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/schema/chat_message.ex: -------------------------------------------------------------------------------- 1 | defmodule API.ChatMessage do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | alias API.{Chat, User} 6 | 7 | @timestamps_opts [type: :utc_datetime] 8 | 9 | schema "chat_messages" do 10 | field(:content, :string) 11 | field(:chat_index, :string, virtual: true) 12 | timestamps() 13 | belongs_to(:chat, Chat) 14 | belongs_to(:user, User, foreign_key: :creator_id) 15 | end 16 | 17 | def changeset(message, args) do 18 | message 19 | |> cast(args, [:chat_id, :creator_id, :content]) 20 | |> validate_required([:chat_id, :creator_id, :content]) 21 | |> validate_length(:content, min: 1) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # Configure your database 4 | # 5 | # The MIX_TEST_PARTITION environment variable can be used 6 | # to provide built-in test partitioning in CI environment. 7 | # Run `mix help test` for more information. 8 | config :api, API.Repo, 9 | username: "postgres", 10 | password: "postgres", 11 | database: "api_test#{System.get_env("MIX_TEST_PARTITION")}", 12 | hostname: "localhost", 13 | pool: Ecto.Adapters.SQL.Sandbox 14 | 15 | # We don't run a server during test. If one is required, 16 | # you can enable the server option below. 17 | config :api, API.Endpoint, 18 | http: [port: 4002], 19 | server: false 20 | 21 | # Print only warnings and errors during test 22 | config :logger, level: :warn 23 | -------------------------------------------------------------------------------- /lib/schema/chat.ex: -------------------------------------------------------------------------------- 1 | defmodule API.Chat do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | alias API.{Repo, User, ChatMembership, ChatMessage} 6 | 7 | @timestamps_opts [type: :utc_datetime] 8 | 9 | schema "chats" do 10 | field(:index, :string) 11 | field(:creator_id, :id) 12 | field(:member_ids, {:array, :map}, virtual: true) 13 | timestamps() 14 | 15 | has_many(:chat_messages, ChatMessage) 16 | has_many(:chat_memberships, ChatMembership) 17 | end 18 | 19 | def changeset(chat, args) do 20 | chat 21 | |> cast(args, [ 22 | :index, 23 | :creator_id, 24 | ]) 25 | |> validate_required([:index, :creator_id]) 26 | |> validate_length(:member_ids, min: 2) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/router.ex: -------------------------------------------------------------------------------- 1 | defmodule API.Router do 2 | use API, :router 3 | 4 | pipeline :api do 5 | plug :accepts, ["json"] 6 | plug(API.Graphql.Schema.Context) 7 | end 8 | 9 | # Todo: Add authentication for livedashboard 10 | import Phoenix.LiveDashboard.Router 11 | 12 | scope "/dashboard" do 13 | pipe_through([:fetch_session, :protect_from_forgery, :api]) 14 | live_dashboard("/", metrics: API.Telemetry) 15 | end 16 | 17 | scope "/" do 18 | pipe_through([:api]) 19 | 20 | forward("/graphiql", Absinthe.Plug.GraphiQL, 21 | schema: API.Graphql.Schema, 22 | socket: API.UserSocket, 23 | pipeline: {ApolloTracing.Pipeline, :plug}, 24 | interface: :playground 25 | ) 26 | 27 | forward("/api/v1", Absinthe.Plug, schema: API.Graphql.Schema) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/graphql/schema.ex: -------------------------------------------------------------------------------- 1 | defmodule API.Graphql.Schema do 2 | use Absinthe.Schema 3 | use ApolloTracing 4 | 5 | import_types(Absinthe.Plug.Types) 6 | import_types(Absinthe.Type.Custom) 7 | 8 | import_types(API.Graphql.User) 9 | import_types(API.Graphql.Post) 10 | import_types(API.Graphql.Chat) 11 | 12 | query do 13 | import_fields(:user_queries) 14 | import_fields(:post_queries) 15 | import_fields(:chat_queries) 16 | end 17 | 18 | mutation do 19 | import_fields(:user_mutations) 20 | import_fields(:post_mutations) 21 | import_fields(:chat_mutations) 22 | end 23 | 24 | subscription do 25 | import_fields(:chat_subscriptions) 26 | import_fields(:post_subscriptions) 27 | end 28 | 29 | def plugins() do 30 | [Absinthe.Middleware.Dataloader | Absinthe.Plugin.defaults()] 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/api.ex: -------------------------------------------------------------------------------- 1 | defmodule API do 2 | @moduledoc """ 3 | Api keeps the contexts that define your domain 4 | and business logic. 5 | 6 | Contexts are also responsible for managing your data, regardless 7 | if it comes from the database, an external API or others. 8 | 9 | Do NOT define functions inside the quoted expressions 10 | below. Instead, define any helper function in modules 11 | and import those modules here. 12 | 13 | """ 14 | 15 | def router do 16 | quote do 17 | use Phoenix.Router 18 | 19 | import Plug.Conn 20 | import Phoenix.Controller 21 | end 22 | end 23 | 24 | def channel do 25 | quote do 26 | use Phoenix.Channel 27 | end 28 | end 29 | 30 | @doc """ 31 | When used, dispatch to the appropriate controller/view/etc. 32 | """ 33 | defmacro __using__(which) when is_atom(which) do 34 | apply(__MODULE__, which, []) 35 | end 36 | 37 | end 38 | -------------------------------------------------------------------------------- /lib/schema/post.ex: -------------------------------------------------------------------------------- 1 | defmodule API.Post do 2 | use Ecto.Schema 3 | 4 | import Ecto.Changeset 5 | 6 | alias API.{User, PostLike, PostComment} 7 | 8 | @timestamps_opts [type: :utc_datetime] 9 | schema "posts" do 10 | field(:content, :string) 11 | field(:likes_count, :integer, default: 0) 12 | field(:comments_count, :integer, default: 0) 13 | field(:creator, :map, virtual: true) 14 | field(:liked_by_me, :boolean, virtual: true) 15 | timestamps() 16 | field(:archived_at, :utc_datetime, default: nil) 17 | belongs_to(:user, User, foreign_key: :creator_id) 18 | has_many(:post_likes, PostLike) 19 | has_many(:post_comments, PostComment) 20 | end 21 | 22 | def changeset(post, args) do 23 | post 24 | |> cast(args, [:content, :creator_id, :likes_count, :comments_count]) 25 | |> validate_required([:content, :creator_id]) 26 | |> validate_length(:content, min: 1) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200927192022_posts.exs: -------------------------------------------------------------------------------- 1 | defmodule API.Repo.Migrations.Posts do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table("posts") do 6 | add(:content, :text, null: false) 7 | add(:creator_id, references(:users), null: false) 8 | add(:likes_count, :integer, default: 0) 9 | add(:comments_count, :integer, default: 0) 10 | timestamps(type: :timestamptz) 11 | add(:archived_at, :timestamptz, default: nil) 12 | end 13 | 14 | create table("post_comments") do 15 | add(:content, :string, null: false) 16 | add(:post_id, references(:posts), null: false) 17 | add(:creator_id, references(:users), null: false) 18 | timestamps(type: :timestamptz) 19 | end 20 | 21 | create table("post_likes") do 22 | add(:post_id, references(:posts), null: false) 23 | add(:creator_id, references(:users), null: false) 24 | timestamps(type: :timestamptz) 25 | end 26 | 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/graphql/context.ex: -------------------------------------------------------------------------------- 1 | defmodule API.Graphql.Schema.Context do 2 | @behaviour Plug 3 | import Plug.Conn 4 | 5 | alias API.{Repo, User, Guardian, Graphql} 6 | 7 | def init(opts), do: opts 8 | 9 | def call(conn, _) do 10 | context = build_context(conn) 11 | Absinthe.Plug.put_options(conn, context: context) 12 | end 13 | 14 | defp build_context(conn) do 15 | with ["Bearer " <> token] <- get_req_header(conn, "authorization"), 16 | {:ok, current_user} <- authorize(token) do 17 | %{current_user: User.map_user_avatar_url(current_user), loader: Graphql.Data.loader()} 18 | else 19 | _ -> %{} 20 | end 21 | end 22 | 23 | defp authorize(token) do 24 | with {:ok, claims} <- Guardian.decode_and_verify(token), 25 | user <- Repo.get(User, claims["sub"]) do 26 | case user do 27 | nil -> {:error, "error"} 28 | _ -> {:ok, user} 29 | end 30 | else 31 | _ -> {:error, "error"} 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.6" 2 | services: 3 | db: 4 | environment: 5 | PGDATA: /var/lib/postgresql/data/pgdata 6 | POSTGRES_PASSWORD: ${DB_PASSWORD} 7 | POSTGRES_USER: ${DB_USER} 8 | image: "postgres:11-alpine" 9 | restart: always 10 | volumes: 11 | - "pgdata:/var/lib/postgresql/data" 12 | api: 13 | build: . 14 | depends_on: 15 | - db 16 | environment: 17 | MIX_ENV: dev 18 | env_file: 19 | - .env 20 | ports: 21 | - '4000:4000' 22 | volumes: 23 | - .:/app 24 | imgproxy: 25 | image: darthsim/imgproxy:v2.14.1 26 | restart: always 27 | environment: 28 | IMGPROXY_QUALITY: 90 # default is 80. 29 | IMGPROXY_USE_S3: "true" 30 | IMGPROXY_KEY: ${IMGPROXY_KEY} 31 | IMGPROXY_SALT: ${IMGPROXY_SALT} 32 | AWS_ACCESS_KEY_ID: ${BACKBLAZE_ACCESS_KEY} 33 | AWS_SECRET_ACCESS_KEY: ${BACKBLAZE_SECRET_KEY} 34 | AWS_REGION: us-west-002 35 | ports: 36 | - "8082:8080" 37 | volumes: 38 | pgdata: 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | api-*.tar 24 | 25 | # If NPM crashes, it generates a log, let's ignore it too. 26 | npm-debug.log 27 | 28 | # The directory NPM downloads your dependencies sources to. 29 | /assets/node_modules/ 30 | 31 | # Since we are building assets from assets/, 32 | # we ignore priv/static. You may want to comment 33 | # this depending on your deployment strategy. 34 | /priv/static/ 35 | 36 | .elixir_ls 37 | .vscode 38 | 39 | .env -------------------------------------------------------------------------------- /priv/repo/migrations/20200927192047_chats.exs: -------------------------------------------------------------------------------- 1 | defmodule API.Repo.Migrations.Chats do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table("chats") do 6 | add(:index, :string, null: false) 7 | add(:creator_id, references(:users), null: false) 8 | add(:class_id, references(:classes)) 9 | timestamps(type: :timestamptz) 10 | add(:archived_at, :timestamptz, default: nil) 11 | end 12 | 13 | create(unique_index("chats", [:index])) 14 | 15 | create table("chat_messages") do 16 | add(:content, :text, null: false) 17 | add(:chat_id, references(:chats), null: false) 18 | add(:creator_id, references(:users), null: false) 19 | add(:media, :text) 20 | timestamps(type: :timestamptz) 21 | end 22 | 23 | create table("chat_memberships") do 24 | add(:chat_id, references(:chats), null: false) 25 | add(:member_id, references(:users), null: false) 26 | # pending, accepted, declined 27 | add(:status, :string, null: false, default: "accepted") 28 | timestamps(type: :timestamptz) 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/uploads/file.ex: -------------------------------------------------------------------------------- 1 | defmodule API.Uploads.File do 2 | use Arc.Definition 3 | use Arc.Ecto.Definition 4 | 5 | @acl :authenticated_read 6 | @versions [:original] 7 | @extension_whitelist ~w(.mp4, .mkv, .mp3, .doc, .docx, .ppt, .pptx, .pdf, .xls, .xlsx) 8 | 9 | def validate({file, _}) do 10 | file_extension = file.file_name |> Path.extname() |> String.downcase() 11 | Enum.member?(@extension_whitelist, file_extension) 12 | end 13 | 14 | def filename(version, {file, scope}) do 15 | file_name = Path.basename(file.file_name, Path.extname(file.file_name)) 16 | "#{scope.id}_#{version}_#{file_name}" 17 | end 18 | 19 | # To make the destination file the same as the version: 20 | def filename(version, _), do: version 21 | 22 | # Specify custom headers for s3 objects 23 | # Available options are [:cache_control, :content_disposition, 24 | # :content_encoding, :content_length, :content_type, 25 | # :expect, :expires, :storage_class, :website_redirect_location] 26 | # 27 | def s3_object_headers(version, {file, scope}) do 28 | [content_type: MIME.from_path(file.file_name)] 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/application.ex: -------------------------------------------------------------------------------- 1 | defmodule API.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | def start(_type, _args) do 9 | children = [ 10 | # Start the Ecto repository 11 | API.Repo, 12 | # Start the Telemetry supervisor 13 | API.Telemetry, 14 | # Start the PubSub system 15 | {Phoenix.PubSub, name: API.PubSub}, 16 | # Start the Endpoint (http/https) 17 | API.Endpoint, 18 | {Absinthe.Subscription, API.Endpoint} 19 | # Start a worker by calling: API.Worker.start_link(arg) 20 | # {API.Worker, arg} 21 | ] 22 | 23 | # See https://hexdocs.pm/elixir/Supervisor.html 24 | # for other strategies and supported options 25 | opts = [strategy: :one_for_one, name: API.Supervisor] 26 | Supervisor.start_link(children, opts) 27 | end 28 | 29 | # Update the endpoint configuration 30 | # whenever the application is updated. 31 | def config_change(changed, _new, removed) do 32 | API.Endpoint.config_change(changed, removed) 33 | :ok 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /tests/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule API.ChannelCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | channel tests. 5 | 6 | Such tests rely on `Phoenix.ChannelTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | we enable the SQL sandbox, so changes done to the database 12 | are reverted at the end of every test. If you are using 13 | PostgreSQL, you can even run database tests asynchronously 14 | by setting `use API.ChannelCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | using do 21 | quote do 22 | # Import conveniences for testing with channels 23 | import Phoenix.ChannelTest 24 | import API.ChannelCase 25 | 26 | # The default endpoint for testing 27 | @endpoint API.Endpoint 28 | end 29 | end 30 | 31 | setup tags do 32 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(API.Repo) 33 | 34 | unless tags[:async] do 35 | Ecto.Adapters.SQL.Sandbox.mode(API.Repo, {:shared, self()}) 36 | end 37 | 38 | :ok 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /tests/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule API.ConnCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | tests that require setting up a connection. 5 | 6 | Such tests rely on `Phoenix.ConnTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | we enable the SQL sandbox, so changes done to the database 12 | are reverted at the end of every test. If you are using 13 | PostgreSQL, you can even run database tests asynchronously 14 | by setting `use API.ConnCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | using do 21 | quote do 22 | # Import conveniences for testing with connections 23 | import Plug.Conn 24 | import Phoenix.ConnTest 25 | import API.ConnCase 26 | 27 | alias API.Router.Helpers, as: Routes 28 | 29 | # The default endpoint for testing 30 | @endpoint API.Endpoint 31 | end 32 | end 33 | 34 | setup tags do 35 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(API.Repo) 36 | 37 | unless tags[:async] do 38 | Ecto.Adapters.SQL.Sandbox.mode(API.Repo, {:shared, self()}) 39 | end 40 | 41 | {:ok, conn: Phoenix.ConnTest.build_conn()} 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /config/prod.secret.exs: -------------------------------------------------------------------------------- 1 | # In this file, we load production configuration and secrets 2 | # from environment variables. You can also hardcode secrets, 3 | # although such is generally not recommended and you have to 4 | # remember to add this file to your .gitignore. 5 | use Mix.Config 6 | 7 | database_url = 8 | System.get_env("DATABASE_URL") || 9 | raise """ 10 | environment variable DATABASE_URL is missing. 11 | For example: ecto://USER:PASS@HOST/DATABASE 12 | """ 13 | 14 | config :api, API.Repo, 15 | # ssl: true, 16 | url: database_url, 17 | pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10") 18 | 19 | secret_key_base = 20 | System.get_env("SECRET_KEY_BASE") || 21 | raise """ 22 | environment variable SECRET_KEY_BASE is missing. 23 | You can generate one by calling: mix phx.gen.secret 24 | """ 25 | 26 | config :api, API.Endpoint, 27 | http: [ 28 | port: String.to_integer(System.get_env("PORT") || "4000"), 29 | transport_options: [socket_opts: [:inet6]] 30 | ], 31 | secret_key_base: secret_key_base 32 | 33 | # ## Using releases (Elixir v1.9+) 34 | # 35 | # If you are doing OTP releases, you need to instruct Phoenix 36 | # to start each relevant endpoint: 37 | # 38 | # config :api, API.Endpoint, server: true 39 | # 40 | # Then you can assemble a release by calling `mix release`. 41 | # See `mix help release` for more information. 42 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # Configure your database 4 | config :api, API.Repo, 5 | url: System.get_env("DB_URL"), 6 | show_sensitive_data_on_connection_error: true, 7 | pool_size: 10 8 | 9 | config :api, API.Endpoint, 10 | http: [port: 4000], 11 | debug_errors: true, 12 | code_reloader: true, 13 | check_origin: false 14 | 15 | # ## SSL Support 16 | # 17 | # In order to use HTTPS in development, a self-signed 18 | # certificate can be generated by running the following 19 | # Mix task: 20 | # 21 | # mix phx.gen.cert 22 | # 23 | # Note that this task requires Erlang/OTP 20 or later. 24 | # Run `mix help phx.gen.cert` for more information. 25 | # 26 | # The `http:` config above can be replaced with: 27 | # 28 | # https: [ 29 | # port: 4001, 30 | # cipher_suite: :strong, 31 | # keyfile: "priv/cert/selfsigned_key.pem", 32 | # certfile: "priv/cert/selfsigned.pem" 33 | # ], 34 | # 35 | # If desired, both `http:` and `https:` keys can be 36 | # configured to run both http and https servers on 37 | # different ports. 38 | 39 | # Do not include metadata nor timestamps in development logs 40 | config :logger, :console, format: "[$level] $message\n" 41 | 42 | # Set a higher stacktrace during development. Avoid configuring such 43 | # in production as building large stacktraces may be expensive. 44 | config :phoenix, :stacktrace_depth, 20 45 | 46 | # Initialize plugs at runtime for faster development compilation 47 | config :phoenix, :plug_init_mode, :runtime 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Phoenix Framework - API only Phoenix boilerplate. 2 | 3 | ## What's in it ? 4 | 5 | - Full Graphql support using [`absinthe`](https://absinthe-graphql.org/) 6 | - Authentication using [`Guardian`](https://github.com/ueberauth/guardian) 7 | - S3 uploads and fetching and thumbailing images using arc,arc_ecto, ex_aws and ex_aws_s3. 8 | 9 | ### Todo 10 | - Add tests 11 | - At present there are 3 models/schemas in this bolerplate. Users, Posts, and Chats. Graphql resolvers needs some refinements. Basically I need to setup assocations a bit better so that I can just preload these easily. 12 | - Possibly integrate dataloader queries too. 13 | - Improve the way thumbnailing is done. Maybe check ImageProxy library for that ? 14 | 15 | ### To start your Phoenix server: 16 | 17 | - Run `docker-compose build` and `docker-compose run` 18 | 19 | * Access graphql playround at [`localhost:4000/graphiql`](http://localhost:4000/graphiql) from your browser to see the graphql playground in action 20 | * Access Phoenix live dashboard at [`localhost:4000/dashboard`](http://localhost:4000/dashboard) 21 | 22 | 23 | * To run in production? Please [check the official deployment guides](https://hexdocs.pm/phoenix/deployment.html). 24 | 25 | ### Learn more 26 | 27 | - Official website: https://www.phoenixframework.org/ 28 | - Guides: https://hexdocs.pm/phoenix/overview.html 29 | - Docs: https://hexdocs.pm/phoenix 30 | - Forum: https://elixirforum.com/c/phoenix-forum 31 | - Source: https://github.com/phoenixframework/phoenix 32 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | 7 | # General application configuration 8 | use Mix.Config 9 | 10 | config :api, 11 | namespace: API, 12 | ecto_repos: [API.Repo] 13 | 14 | # Configures the endpoint 15 | config :api, API.Endpoint, 16 | url: [host: "localhost"], 17 | secret_key_base: "P5+l8mwHTQ9z/eN0gtsrHNoDhNxXmlER5zJ9SQp9Ak0MFj7cmEX0hwYIbCdnWcHc", 18 | pubsub_server: API.PubSub, 19 | live_view: [signing_salt: "Y7hRAEjK"] 20 | 21 | # Configures Elixir's Logger 22 | config :logger, :console, 23 | format: "$time $metadata[$level] $message\n", 24 | metadata: [:request_id] 25 | 26 | # Use Jason for JSON parsing in Phoenix 27 | config :phoenix, :json_library, Jason 28 | 29 | config :arc, 30 | storage: Arc.Storage.S3, 31 | bucket: {:system, "BACKBLAZE_S3_BUCKET"}, 32 | version_timeout: 30_000 33 | 34 | config :ex_aws, 35 | debug_requests: true, 36 | json_codec: Jason, 37 | access_key_id: {:system, "BACKBLAZE_ACCESS_KEY"}, 38 | secret_access_key: {:system, "BACKBLAZE_SECRET_KEY"}, 39 | region: "us-west-002" 40 | 41 | config :ex_aws, :s3, 42 | scheme: "https://", 43 | host: "s3.us-west-002.backblazeb2.com", 44 | region: "us-west-002" 45 | 46 | # Import environment specific config. This must remain at the bottom 47 | # of this file so it overrides the configuration defined above. 48 | import_config "#{Mix.env()}.exs" 49 | -------------------------------------------------------------------------------- /lib/uploads/image.ex: -------------------------------------------------------------------------------- 1 | # Todo: instead of this, refactor the Uploads.File to process 2 | # thumbnailing of images and leave other file types as it is. 3 | 4 | defmodule API.Uploads.Image do 5 | use Arc.Definition 6 | use Arc.Ecto.Definition 7 | 8 | @acl :authenticated_read 9 | @versions [:original, :thumb] 10 | @extension_whitelist ~w(.jpg .jpeg .gif .png .svg) 11 | 12 | def validate({file, _}) do 13 | file_extension = get_file_name(file) 14 | Enum.member?(@extension_whitelist, file_extension) 15 | end 16 | 17 | def transform(:thumb, {file, scope}) do 18 | is_gif = get_file_name(file) == ".gif" 19 | is_svg = get_file_name(file) == ".svg" 20 | 21 | if !is_gif and !is_svg do 22 | {:convert, "-thumbnail 100x100^ -gravity center -extent 100x100 -format png", :png} 23 | else 24 | :noaction 25 | end 26 | end 27 | 28 | def filename(version, {file, scope}) do 29 | file_name = Path.basename(file.file_name, Path.extname(file.file_name)) 30 | "#{scope.id}_#{version}_#{file_name}" 31 | end 32 | 33 | # To make the destination file the same as the version: 34 | def filename(version, _), do: version 35 | 36 | def default_url(:thumb) do 37 | "https://placehold.it/100x100" 38 | end 39 | 40 | # Specify custom headers for s3 objects 41 | # Available options are [:cache_control, :content_disposition, 42 | # :content_encoding, :content_length, :content_type, 43 | # :expect, :expires, :storage_class, :website_redirect_location] 44 | # 45 | def s3_object_headers(version, {file, scope}) do 46 | [content_type: MIME.from_path(file.file_name)] 47 | end 48 | 49 | defp get_file_name(file) do 50 | file.file_name |> Path.extname() |> String.downcase() 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /tests/data_case.ex: -------------------------------------------------------------------------------- 1 | defmodule API.DataCase do 2 | @moduledoc """ 3 | This module defines the setup for tests requiring 4 | access to the application's data layer. 5 | 6 | You may define functions here to be used as helpers in 7 | your tests. 8 | 9 | Finally, if the test case interacts with the database, 10 | we enable the SQL sandbox, so changes done to the database 11 | are reverted at the end of every test. If you are using 12 | PostgreSQL, you can even run database tests asynchronously 13 | by setting `use API.DataCase, async: true`, although 14 | this option is not recommended for other databases. 15 | """ 16 | 17 | use ExUnit.CaseTemplate 18 | 19 | using do 20 | quote do 21 | alias API.Repo 22 | 23 | import Ecto 24 | import Ecto.Changeset 25 | import Ecto.Query 26 | import API.DataCase 27 | end 28 | end 29 | 30 | setup tags do 31 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(API.Repo) 32 | 33 | unless tags[:async] do 34 | Ecto.Adapters.SQL.Sandbox.mode(API.Repo, {:shared, self()}) 35 | end 36 | 37 | :ok 38 | end 39 | 40 | @doc """ 41 | A helper that transforms changeset errors into a map of messages. 42 | 43 | assert {:error, changeset} = Accounts.create_user(%{password: "short"}) 44 | assert "password is too short" in errors_on(changeset).password 45 | assert %{password: ["password is too short"]} = errors_on(changeset) 46 | 47 | """ 48 | def errors_on(changeset) do 49 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> 50 | Regex.replace(~r"%{(\w+)}", message, fn _, key -> 51 | opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() 52 | end) 53 | end) 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule API.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :api 3 | use Absinthe.Phoenix.Endpoint 4 | 5 | # The session will be stored in the cookie and signed, 6 | # this means its contents can be read but not tampered with. 7 | # Set :encryption_salt if you would also like to encrypt it. 8 | @session_options [ 9 | store: :cookie, 10 | key: "_api_key", 11 | signing_salt: "mM70nmCq" 12 | ] 13 | 14 | 15 | socket("/socket", API.UserSocket, 16 | websocket: true, 17 | longpoll: false 18 | ) 19 | 20 | # For pheonix live dashboard 21 | socket("/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]]) 22 | 23 | # Code reloading can be explicitly enabled under the 24 | # :code_reloader configuration of your endpoint. 25 | if code_reloading? do 26 | plug(Phoenix.CodeReloader) 27 | plug(Phoenix.Ecto.CheckRepoStatus, otp_app: :api) 28 | end 29 | 30 | plug(Phoenix.LiveDashboard.RequestLogger, 31 | param_key: "request_logger", 32 | cookie_key: "request_logger" 33 | ) 34 | 35 | plug(Plug.RequestId) 36 | plug(Plug.Telemetry, event_prefix: [:phoenix, :endpoint]) 37 | 38 | plug(Plug.Parsers, 39 | parsers: [:urlencoded, :multipart, :json, Absinthe.Plug.Parser], 40 | pass: ["*/*"], 41 | json_decoder: Phoenix.json_library() 42 | ) 43 | 44 | plug(Plug.MethodOverride) 45 | plug(Plug.Head) 46 | plug(Plug.Session, @session_options) 47 | 48 | plug(Corsica, 49 | origins: "http://localhost:3000", 50 | log: [rejected: :error, invalid: :warn, accepted: :debug], 51 | # Todo: make sure this is safe. Doing it now to prevent cors error with preflight requests from frontend 52 | allow_headers: :all, 53 | allow_credentials: true 54 | ) 55 | 56 | plug(API.Router) 57 | end 58 | -------------------------------------------------------------------------------- /lib/channels/user_socket.ex: -------------------------------------------------------------------------------- 1 | defmodule API.UserSocket do 2 | use Phoenix.Socket 3 | 4 | use Absinthe.Phoenix.Socket, 5 | schema: API.Graphql.Schema 6 | 7 | alias API.{Repo, User, Guardian} 8 | 9 | ## Channels 10 | # channel "room:*", API.RoomChannel 11 | 12 | # Socket params are passed from the client and can 13 | # be used to verify and authenticate a user. After 14 | # verification, you can put default assigns into 15 | # the socket that will be set for all channels, ie 16 | # 17 | # {:ok, assign(socket, :user_id, verified_user_id)} 18 | # 19 | # To deny connection, return `:error`. 20 | # 21 | # See `Phoenix.Token` documentation for examples in 22 | # performing token verification on connect. 23 | @impl true 24 | def connect(params, socket, _connect_info) do 25 | with "Bearer " <> token <- params["token"], 26 | {:ok, user} <- authorize(token) do 27 | socket = Absinthe.Phoenix.Socket.put_options(socket, context: %{current_user: user}) 28 | {:ok, socket} 29 | else 30 | _ -> :error 31 | end 32 | end 33 | 34 | defp authorize(token) do 35 | with {:ok, claims} <- Guardian.decode_and_verify(token), 36 | user <- Repo.get(User, claims["sub"]) do 37 | {:ok, user} 38 | else 39 | _ -> {:error, "error"} 40 | end 41 | end 42 | 43 | # Socket id's are topics that allow you to identify all sockets for a given user: 44 | # 45 | # def id(socket), do: "user_socket:#{socket.assigns.user_id}" 46 | # 47 | # Would allow you to broadcast a "disconnect" event and terminate 48 | # all active sockets and channels for a given user: 49 | # 50 | # APIWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{}) 51 | # 52 | # Returning `nil` makes this socket anonymous. 53 | @impl true 54 | def id(_socket), do: nil 55 | end 56 | -------------------------------------------------------------------------------- /lib/schema/user.ex: -------------------------------------------------------------------------------- 1 | defmodule API.User do 2 | use Ecto.Schema 3 | use Arc.Ecto.Schema 4 | 5 | import Ecto.Changeset 6 | 7 | alias Argon2 8 | 9 | alias API.{School, Post, PostComment, ChatMembership, Uploads.Image} 10 | 11 | @timestamps_opts [type: :utc_datetime] 12 | schema "users" do 13 | field(:email, :string) 14 | field(:password, :string) 15 | field(:name, :string) 16 | field(:avatar, Image.Type) 17 | timestamps() 18 | 19 | has_many(:posts, Post, foreign_key: :creator_id) 20 | has_many(:post_comments, PostComment, foreign_key: :creator_id) 21 | has_many(:chat_memberships, ChatMembership, foreign_key: :member_id) 22 | end 23 | 24 | def update_changeset(user, args) do 25 | user 26 | |> cast(args, [:id, :email, :name]) 27 | |> cast_attachments(args, [:avatar]) 28 | |> validate_required([:id]) 29 | # Todo: need better email validation 30 | |> validate_format(:email, ~r/@/) 31 | |> unique_constraint(:email) 32 | end 33 | 34 | def signup_changeset(user, args) do 35 | user 36 | |> cast(args, [:email, :password, :name]) 37 | |> cast_attachments(args, [:avatar]) 38 | |> validate_required([:name, :email, :password]) 39 | # Todo: need better email validation 40 | |> validate_format(:email, ~r/@/) 41 | |> unique_constraint(:email) 42 | |> validate_length(:password, min: 5, max: 20) 43 | |> put_password_hash() 44 | end 45 | 46 | defp put_password_hash(changeset) do 47 | case changeset do 48 | %Ecto.Changeset{valid?: true, changes: %{password: password}} -> 49 | Ecto.Changeset.put_change(changeset, :password, Argon2.hash_pwd_salt(password)) 50 | 51 | _ -> 52 | changeset 53 | end 54 | end 55 | 56 | def map_user_avatar_url(user) do 57 | if user.avatar do 58 | avatar_url = Image.url({user.avatar, user}, :thumb, signed: true) 59 | Map.merge(user, %{avatar_url: avatar_url}) 60 | else 61 | user 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule API.Telemetry do 2 | use Supervisor 3 | import Telemetry.Metrics 4 | 5 | def start_link(arg) do 6 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__) 7 | end 8 | 9 | @impl true 10 | def init(_arg) do 11 | children = [ 12 | # Telemetry poller will execute the given period measurements 13 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics 14 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} 15 | # Add reporters as children of your supervision tree. 16 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} 17 | ] 18 | 19 | Supervisor.init(children, strategy: :one_for_one) 20 | end 21 | 22 | def metrics do 23 | [ 24 | # Phoenix Metrics 25 | summary("phoenix.endpoint.stop.duration", 26 | unit: {:native, :millisecond} 27 | ), 28 | summary("phoenix.router_dispatch.stop.duration", 29 | tags: [:route], 30 | unit: {:native, :millisecond} 31 | ), 32 | 33 | # Database Metrics 34 | summary("api.repo.query.total_time", unit: {:native, :millisecond}), 35 | summary("api.repo.query.decode_time", unit: {:native, :millisecond}), 36 | summary("api.repo.query.query_time", unit: {:native, :millisecond}), 37 | summary("api.repo.query.queue_time", unit: {:native, :millisecond}), 38 | summary("api.repo.query.idle_time", unit: {:native, :millisecond}), 39 | 40 | # VM Metrics 41 | summary("vm.memory.total", unit: {:byte, :kilobyte}), 42 | summary("vm.total_run_queue_lengths.total"), 43 | summary("vm.total_run_queue_lengths.cpu"), 44 | summary("vm.total_run_queue_lengths.io") 45 | ] 46 | end 47 | 48 | defp periodic_measurements do 49 | [ 50 | # A module, function and arguments to be invoked periodically. 51 | # This function must call :telemetry.execute/3 and a metric must be added above. 52 | # {API, :count_users, []} 53 | ] 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/graphql/resolvers/user.ex: -------------------------------------------------------------------------------- 1 | defmodule API.Graphql.Resolvers.User do 2 | alias Argon2 3 | alias API.{Repo, User, Guardian} 4 | 5 | def user(_, args, _) do 6 | user_by_id = Repo.get(User, args.id) 7 | 8 | if user_by_id do 9 | {:ok, User.map_user_avatar_url(user_by_id)} 10 | else 11 | {:error, "error finding user by id"} 12 | end 13 | end 14 | 15 | # Todo: paginate 16 | def users(_, _args, _) do 17 | all_users = Repo.all(User) 18 | {:ok, all_users} 19 | end 20 | 21 | def me(_, _, %{context: %{current_user: current_user}}) do 22 | {:ok, current_user} 23 | end 24 | 25 | def update(_, args, _) do 26 | import Ecto.Changeset 27 | 28 | case Repo.get_by(User, id: args.id) do 29 | nil -> 30 | {:error, "cant find the user by id"} 31 | 32 | user -> 33 | case change(User.update_changeset(user, args)) |> Repo.update() do 34 | {:ok, user} -> 35 | {:ok, User.map_user_avatar_url(user)} 36 | 37 | {:error, _} -> 38 | {:error, "error updating user"} 39 | end 40 | end 41 | end 42 | 43 | def login(_parent, args, _context) do 44 | case Repo.get_by!(User, email: String.downcase(args.email)) do 45 | nil -> 46 | "no user account exists with that email" 47 | 48 | user -> 49 | if Argon2.check_pass(user, args.password, hash_key: :password) do 50 | {:ok, token, _full_claims} = Guardian.encode_and_sign(user) 51 | userWithToken = %{user: User.map_user_avatar_url(user), token: token} 52 | {:ok, userWithToken} 53 | else 54 | {:error, "invalid credentials"} 55 | end 56 | end 57 | end 58 | 59 | def signup(_root, args, _context) do 60 | new_user = User.signup_changeset(%User{}, args) |> Repo.insert!() 61 | {:ok, token, _full_claims} = Guardian.encode_and_sign(new_user) 62 | userWithToken = %{user: User.map_user_avatar_url(new_user), token: token} 63 | {:ok, userWithToken} 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # For production, don't forget to configure the url host 4 | # to something meaningful, Phoenix uses this information 5 | # when generating URLs. 6 | # 7 | # Note we also include the path to a cache manifest 8 | # containing the digested version of static files. This 9 | # manifest is generated by the `mix phx.digest` task, 10 | # which you should run after static files are built and 11 | # before starting your production server. 12 | config :api, API.Endpoint, 13 | url: [host: "example.com", port: 80], 14 | cache_static_manifest: "priv/static/cache_manifest.json" 15 | 16 | # Do not print debug messages in production 17 | config :logger, level: :info 18 | 19 | # ## SSL Support 20 | # 21 | # To get SSL working, you will need to add the `https` key 22 | # to the previous section and set your `:url` port to 443: 23 | # 24 | # config :api, API.Endpoint, 25 | # ... 26 | # url: [host: "example.com", port: 443], 27 | # https: [ 28 | # port: 443, 29 | # cipher_suite: :strong, 30 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 31 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH"), 32 | # transport_options: [socket_opts: [:inet6]] 33 | # ] 34 | # 35 | # The `cipher_suite` is set to `:strong` to support only the 36 | # latest and more secure SSL ciphers. This means old browsers 37 | # and clients may not be supported. You can set it to 38 | # `:compatible` for wider support. 39 | # 40 | # `:keyfile` and `:certfile` expect an absolute path to the key 41 | # and cert in disk or a relative path inside priv, for example 42 | # "priv/ssl/server.key". For all supported SSL configuration 43 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 44 | # 45 | # We also recommend setting `force_ssl` in your endpoint, ensuring 46 | # no data is ever sent via http, always redirecting to https: 47 | # 48 | # config :api, API.Endpoint, 49 | # force_ssl: [hsts: true] 50 | # 51 | # Check `Plug.SSL` for all available options in `force_ssl`. 52 | 53 | # Finally import the config/prod.secret.exs which loads secrets 54 | # and configuration from environment variables. 55 | import_config "prod.secret.exs" 56 | -------------------------------------------------------------------------------- /lib/graphql/typedefs/user.ex: -------------------------------------------------------------------------------- 1 | defmodule API.Graphql.User do 2 | use Absinthe.Schema.Notation 3 | 4 | alias API.Graphql.Resolvers.User 5 | 6 | @desc "user" 7 | object :user do 8 | field(:id, non_null(:id)) 9 | field(:email, non_null(:string)) 10 | field(:password, :string) 11 | field(:name, non_null(:string)) 12 | field(:avatar_url, :string) 13 | field(:updated_at, :datetime) 14 | field(:inserted_at, :datetime) 15 | end 16 | 17 | @desc "user with auth token" 18 | object :userWithToken do 19 | field(:user, non_null(:user)) 20 | field(:token, non_null(:string)) 21 | end 22 | 23 | # start of queries 24 | @desc "user related queries" 25 | object :user_queries do 26 | @desc "get logged in user" 27 | field :me, type: :user do 28 | middleware(API.Graphql.AuthMiddleware) 29 | resolve(&User.me/3) 30 | end 31 | 32 | # Todo: pagination 33 | @desc "Get all the users" 34 | field :users, type: non_null(list_of(:user)) do 35 | middleware(API.Graphql.AuthMiddleware) 36 | resolve(&User.users/3) 37 | end 38 | 39 | @desc "Get a user by id" 40 | field :user, type: :user do 41 | arg(:id, non_null(:id)) 42 | middleware(API.Graphql.AuthMiddleware) 43 | resolve(&User.user/3) 44 | end 45 | end 46 | 47 | # end of queries 48 | 49 | # start of mutations 50 | @desc "user related mutations" 51 | object :user_mutations do 52 | @desc "login" 53 | field :login, type: non_null(:userWithToken) do 54 | arg(:email, non_null(:string)) 55 | arg(:password, non_null(:string)) 56 | resolve(&User.login/3) 57 | end 58 | 59 | @desc "signup" 60 | field :signup, type: non_null(:userWithToken) do 61 | arg(:email, non_null(:string)) 62 | arg(:password, non_null(:string)) 63 | arg(:name, non_null(:string)) 64 | arg(:avatar, :upload) 65 | resolve(&User.signup/3) 66 | end 67 | 68 | @desc "update user" 69 | field :update_user, type: non_null(:user) do 70 | arg(:id, non_null(:id)) 71 | arg(:email, :string) 72 | arg(:name, :string) 73 | arg(:avatar, :upload) 74 | middleware(API.Graphql.AuthMiddleware) 75 | resolve(&User.update/3) 76 | end 77 | end 78 | 79 | # end of mutations 80 | end 81 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule API.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :api, 7 | version: "0.1.0", 8 | elixir: "~> 1.7", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | compilers: [:phoenix] ++ Mix.compilers(), 11 | start_permanent: Mix.env() == :prod, 12 | aliases: aliases(), 13 | deps: deps() 14 | ] 15 | end 16 | 17 | # Configuration for the OTP application. 18 | # 19 | # Type `mix help compile.app` for more information. 20 | def application do 21 | [ 22 | mod: {API.Application, []}, 23 | extra_applications: [:logger, :runtime_tools] 24 | ] 25 | end 26 | 27 | # Specifies which paths to compile per environment. 28 | defp elixirc_paths(:test), do: ["lib", "test/support"] 29 | defp elixirc_paths(_), do: ["lib"] 30 | 31 | # Specifies your project dependencies. 32 | # 33 | # Type `mix help deps` for examples and options. 34 | defp deps do 35 | [ 36 | {:phoenix, "~> 1.5.5"}, 37 | {:phoenix_ecto, "~> 4.1"}, 38 | {:ecto_sql, "~> 3.4"}, 39 | {:postgrex, ">= 0.0.0"}, 40 | {:phoenix_live_dashboard, "~> 0.2"}, 41 | {:telemetry_metrics, "~> 0.4"}, 42 | {:telemetry_poller, "~> 0.4"}, 43 | {:jason, "~> 1.0"}, 44 | {:plug_cowboy, "~> 2.0"}, 45 | {:guardian, "~> 2.1.1"}, 46 | {:argon2_elixir, "~> 2.0"}, 47 | {:absinthe, "~> 1.5.3"}, 48 | {:absinthe_plug, "~> 1.5"}, 49 | {:absinthe_phoenix, "~> 2.0"}, 50 | {:corsica, "~> 1.0"}, 51 | {:dataloader, "~> 1.0.7"}, 52 | {:uuid, "~> 1.1"}, 53 | {:apollo_tracing, "~> 0.4.0"}, 54 | {:arc, "~> 0.11.0"}, 55 | {:arc_ecto, "~> 0.11.3"}, 56 | {:ex_aws, git: "https://github.com/factsfinder/ex_aws.git", tag: "2.14", override: true}, 57 | {:ex_aws_s3, 58 | git: "https://github.com/factsfinder/ex_aws_s3.git", tag: "2.0.3", override: true}, 59 | {:sweet_xml, "~> 0.6"}, 60 | {:hackney, "~> 1.16.0"} 61 | ] 62 | end 63 | 64 | # Aliases are shortcuts or tasks specific to the current project. 65 | # For example, to install project dependencies and perform other setup tasks, run: 66 | # 67 | # $ mix setup 68 | # 69 | # See the documentation for `Mix` for more info on aliases. 70 | defp aliases do 71 | [ 72 | start: ["phx.server"], 73 | setup: ["deps.get", "ecto.setup"], 74 | "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], 75 | "ecto.reset": ["ecto.drop", "ecto.setup"], 76 | test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"] 77 | ] 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/graphql/typedefs/chat.ex: -------------------------------------------------------------------------------- 1 | defmodule API.Graphql.Chat do 2 | use Absinthe.Schema.Notation 3 | 4 | alias API.Graphql.Resolvers.Chat 5 | require Logger; 6 | @desc "Chat" 7 | object :chat do 8 | field(:id, non_null(:id)) 9 | field(:index, non_null(:string)) 10 | field(:creator_id, non_null(:id)) 11 | field(:members, non_null(list_of(:user))) 12 | field(:messages, list_of(:chat_message)) 13 | field(:updated_at, :datetime) 14 | field(:inserted_at, :datetime) 15 | end 16 | 17 | @desc "Chat Message" 18 | object :chat_message do 19 | field(:id, non_null(:id)) 20 | field(:chat_id, non_null(:id)) 21 | field(:content, non_null(:string)) 22 | field(:creator_id, non_null(:id)) 23 | field(:updated_at, :datetime) 24 | field(:inserted_at, :datetime) 25 | end 26 | 27 | # start of chat subscriptions 28 | @desc "chat related subscriptions" 29 | object :chat_subscriptions do 30 | field :new_message, :chat_message do 31 | arg(:chat_index, non_null(:string)) 32 | 33 | config(fn args, _context -> 34 | {:ok, topic: args.chat_index} 35 | end) 36 | 37 | trigger(:send_chat_message, topic: fn msg -> 38 | msg.chat_index 39 | end 40 | ) 41 | end 42 | end 43 | 44 | # end of chat subscriptions 45 | 46 | # start of chat queries 47 | @desc "chat related queries" 48 | object :chat_queries do 49 | field :chat, type: :chat do 50 | arg(:id, non_null(:id)) 51 | middleware(API.Graphql.AuthMiddleware) 52 | resolve(&Chat.chat/3) 53 | end 54 | 55 | field :my_chats, type: list_of(:chat) do 56 | arg(:page, non_null(:integer)) 57 | middleware(API.Graphql.AuthMiddleware) 58 | resolve(&Chat.myChats/3) 59 | end 60 | end 61 | 62 | # end of chat queries 63 | 64 | # start of chat mutations 65 | @desc "chat related mutations" 66 | object :chat_mutations do 67 | field :create_chat, type: non_null(:chat) do 68 | arg(:creator_id, non_null(:id)) 69 | arg(:member_ids, non_null(list_of(:id))) 70 | middleware(API.Graphql.AuthMiddleware) 71 | resolve(&Chat.createChat/3) 72 | end 73 | 74 | field :send_chat_message, type: non_null(:chat_message) do 75 | arg(:content, non_null(:string)) 76 | arg(:chat_id, non_null(:id)) 77 | arg(:creator_id, non_null(:id)) 78 | arg(:chat_index, non_null(:string)) 79 | middleware(API.Graphql.AuthMiddleware) 80 | resolve(&Chat.sendMessage/3) 81 | end 82 | 83 | field :add_chat_member, type: non_null(:chat) do 84 | arg(:chat_id, non_null(:id)) 85 | arg(:member_id, non_null(:id)) 86 | middleware(API.Graphql.AuthMiddleware) 87 | resolve(&Chat.addMember/3) 88 | end 89 | 90 | field :get_chat_messages, type: list_of(:chat_message) do 91 | arg(:chat_id, non_null(:id)) 92 | arg(:page, non_null(:integer)) 93 | middleware(API.Graphql.AuthMiddleware) 94 | resolve(&Chat.getMessages/3) 95 | end 96 | end 97 | 98 | # end of chat mutations 99 | end 100 | -------------------------------------------------------------------------------- /lib/graphql/typedefs/post.ex: -------------------------------------------------------------------------------- 1 | defmodule API.Graphql.Post do 2 | use Absinthe.Schema.Notation 3 | 4 | alias API.Graphql.Resolvers.Post, as: PostResolver 5 | 6 | require Logger 7 | 8 | @desc "Post" 9 | object :post do 10 | field(:id, non_null(:id)) 11 | field(:content, non_null(:string)) 12 | field(:creator, non_null(:user)) 13 | field(:recent_comments, list_of(:post_comment)) 14 | field(:likes_count, non_null(:integer)) 15 | field(:comments_count, non_null(:integer)) 16 | field(:liked_by_me, :boolean) 17 | field(:updated_at, :datetime) 18 | field(:inserted_at, :datetime) 19 | end 20 | 21 | @desc "Post Comment" 22 | object :post_comment do 23 | field(:id, non_null(:id)) 24 | field(:creator, non_null(:user)) 25 | field(:content, non_null(:string)) 26 | field(:post_id, non_null(:id)) 27 | field(:updated_at, :datetime) 28 | field(:inserted_at, :datetime) 29 | end 30 | 31 | @desc "post related queries" 32 | object :post_queries do 33 | @desc "Get all posts" 34 | field :posts, type: non_null(list_of(:post)) do 35 | arg(:type, non_null(:string)) 36 | arg(:page, non_null(:integer)) 37 | middleware(API.Graphql.AuthMiddleware) 38 | resolve(&PostResolver.getPosts/3) 39 | end 40 | 41 | @desc "get post comments" 42 | field :comments, type: non_null(list_of(:post_comment)) do 43 | arg(:post_id, non_null(:id)) 44 | arg(:page, non_null(:integer)) 45 | middleware(API.Graphql.AuthMiddleware) 46 | resolve(&PostResolver.getPostComments/3) 47 | end 48 | end 49 | 50 | @desc "post related mutations" 51 | object :post_mutations do 52 | @desc "create a new post" 53 | field :create_post, type: :post do 54 | arg(:content, non_null(:string)) 55 | middleware(API.Graphql.AuthMiddleware) 56 | resolve(&PostResolver.createPost/3) 57 | end 58 | 59 | @desc "update post" 60 | field :update_post, type: :post do 61 | arg(:id, non_null(:id)) 62 | arg(:content, :string) 63 | arg(:likes_count, :integer) 64 | middleware(API.Graphql.AuthMiddleware) 65 | resolve(&PostResolver.updatePost/3) 66 | end 67 | 68 | @desc "toggle post like" 69 | field :toggle_post_like, type: :boolean do 70 | arg(:post_id, non_null(:id)) 71 | middleware(API.Graphql.AuthMiddleware) 72 | resolve(&PostResolver.togglePostLike/3) 73 | end 74 | 75 | @desc "delete a post by id" 76 | field :delete_post, type: :boolean do 77 | arg(:id, non_null(:id)) 78 | middleware(API.Graphql.AuthMiddleware) 79 | resolve(&PostResolver.deletePost/3) 80 | end 81 | 82 | @desc "create a new comment" 83 | field :create_comment, type: :post_comment do 84 | arg(:post_id, non_null(:id)) 85 | arg(:content, non_null(:string)) 86 | middleware(API.Graphql.AuthMiddleware) 87 | resolve(&PostResolver.createComment/3) 88 | end 89 | end 90 | 91 | @desc "post related subscriptions" 92 | object :post_subscriptions do 93 | field :post_added, :post do 94 | arg(:name, non_null(:string)) 95 | 96 | config(fn args, _ -> 97 | {:ok, topic: args.name} 98 | end) 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /lib/graphql/resolvers/chat.ex: -------------------------------------------------------------------------------- 1 | defmodule API.Graphql.Resolvers.Chat do 2 | alias API.{Repo, Chat, ChatMembership, ChatMessage, User} 3 | import Ecto.Query 4 | 5 | require Logger 6 | 7 | def chat(_, args, _) do 8 | case Repo.get(Chat, args.id) do 9 | nil -> {:error, "error getting chat by id"} 10 | res -> {:ok, res} 11 | end 12 | end 13 | 14 | def myChats(_, args, %{context: %{current_user: current_user, loader: loader}}) do 15 | query = 16 | from(c in Chat, 17 | left_join: cm in ChatMembership, 18 | on: cm.member_id == ^current_user.id and cm.chat_id == c.id, 19 | limit: 10, 20 | offset: 10 * ^args.page 21 | ) 22 | 23 | chats = Repo.all(query) 24 | 25 | # Todo: fix n+1 issue while fetching members 26 | chats = 27 | Enum.map(chats, fn c -> 28 | member_ids = String.split(c.index, "-") 29 | 30 | members = 31 | Enum.map(member_ids, fn id -> 32 | Repo.get(User, id) 33 | end) 34 | 35 | messages = 36 | Repo.all( 37 | from(cm in ChatMessage, 38 | where: cm.chat_id == ^c.id, 39 | order_by: [desc: cm.inserted_at], 40 | limit: 10 41 | ) 42 | ) 43 | 44 | Map.merge(c, %{members: members, messages: messages}) 45 | end) 46 | 47 | {:ok, chats} 48 | end 49 | 50 | def classChats(_, _, _) do 51 | end 52 | 53 | def createChat(_, args, _) do 54 | creator_id_exists_in_member_ids = 55 | Enum.any?(args.member_ids, fn id -> 56 | id == args.creator_id 57 | end) 58 | 59 | if creator_id_exists_in_member_ids do 60 | index = Enum.uniq(args.member_ids) |> Enum.sort() |> Enum.join("-") 61 | existing_chat = Repo.get_by(Chat, index: index) 62 | 63 | if existing_chat do 64 | {:ok, existing_chat} 65 | else 66 | Repo.transaction(fn -> 67 | case(Chat.changeset(%Chat{}, Map.merge(args, %{index: index})) |> Repo.insert()) do 68 | {:ok, new_chat} -> 69 | Enum.each(args.member_ids, fn id -> 70 | ChatMembership.changeset( 71 | %ChatMembership{}, 72 | %{chat_id: new_chat.id, member_id: id} 73 | ) 74 | |> Repo.insert() 75 | end) 76 | 77 | mapped_chat = 78 | Map.merge(new_chat, %{ 79 | messages: [], 80 | members: 81 | Enum.map(args.member_ids, fn id -> 82 | Repo.get(User, id) 83 | end) 84 | }) 85 | 86 | mapped_chat 87 | 88 | {:error, _} -> 89 | Repo.rollback("error creating a new chat") 90 | end 91 | end) 92 | end 93 | else 94 | {:error, "creator id should exist in member ids array"} 95 | end 96 | end 97 | 98 | def sendMessage(_, args, _) do 99 | case ChatMessage.changeset(%ChatMessage{}, args) |> Repo.insert() do 100 | {:ok, message} -> {:ok, Map.merge(message, %{chat_index: args.chat_index})} 101 | {:error, _} -> {:error, "error sending chat message"} 102 | end 103 | end 104 | 105 | def addMember(_, _, _) do 106 | end 107 | 108 | def getMessages(_, args, _) do 109 | query = 110 | from(m in ChatMessage, 111 | where: m.chat_id == ^args.chat_id, 112 | order_by: [desc: m.inserted_at], 113 | limit: 10, 114 | offset: 10 * ^args.page 115 | ) 116 | 117 | messages = Repo.all(query) 118 | 119 | if messages do 120 | {:ok, messages} 121 | else 122 | {:error, "error fetching messages"} 123 | end 124 | end 125 | end 126 | -------------------------------------------------------------------------------- /lib/graphql/resolvers/post.ex: -------------------------------------------------------------------------------- 1 | defmodule API.Graphql.Resolvers.Post do 2 | require Logger 3 | alias API.{Repo, Post, PostComment, PostLike, User} 4 | 5 | import Ecto.Query 6 | import Ecto.Changeset 7 | 8 | def createPost(_, args, %{context: %{current_user: current_user}}) do 9 | args_with_creator_id = Map.merge(args, %{creator_id: current_user.id}) 10 | 11 | case Post.changeset(%Post{}, args_with_creator_id) |> Repo.insert() do 12 | {:ok, new_post} -> 13 | {:ok, 14 | Map.merge(new_post, %{ 15 | creator: current_user, 16 | recent_comments: [], 17 | liked_by_me: false 18 | })} 19 | 20 | {:error, _} -> 21 | {:error, "Error creating a new post"} 22 | end 23 | end 24 | 25 | def updatePost(_, args, %{context: %{current_user: current_user}}) do 26 | post = Repo.get_by(Post, id: args.id, creator_id: current_user.id) 27 | 28 | case Post.changeset(post, args).change() |> Repo.update() do 29 | {:ok, res} -> {:ok, res} 30 | {:error, _} -> {:error, "error updating the post"} 31 | end 32 | end 33 | 34 | def togglePostLike(_, args, %{context: %{current_user: current_user}}) do 35 | args_with_creator_id = Map.merge(args, %{creator_id: current_user.id}) 36 | Repo.transaction(fn -> 37 | case Repo.get_by(PostLike, creator_id: current_user.id, post_id: args.post_id) do 38 | nil -> 39 | case Repo.insert(PostLike.changeset(%PostLike{}, args_with_creator_id)) do 40 | {:ok, _} -> 41 | case updateLikesCount(args.post_id, true) do 42 | {:ok, _} -> true 43 | {:error, _} -> 44 | Repo.rollback("error updating post like -> rolling back.") 45 | end 46 | 47 | {:error, _} -> 48 | Repo.rollback("error updating post like -> rolling back.") 49 | end 50 | 51 | post_like -> 52 | case Repo.delete(post_like) do 53 | {:ok, _} -> 54 | case updateLikesCount(args.post_id, false) do 55 | {:ok, _} -> true 56 | {:error, _} -> 57 | Repo.rollback("error updating post like -> rolling back.") 58 | end 59 | 60 | {:error, _} -> 61 | Repo.rollback("error updating post like -> rolling back.") 62 | end 63 | end 64 | end) 65 | 66 | end 67 | 68 | defp updateLikesCount(post_id, isInc) do 69 | post = Repo.get(Post, post_id) 70 | case post do 71 | nil -> 72 | {:error, "error updating post likes count"} 73 | post -> 74 | new_likes_count = if isInc, do: post.likes_count + 1, else: post.likes_count - 1 75 | updated = Repo.update(change(post, likes_count: new_likes_count)) 76 | case updated do 77 | {:ok, _} -> {:ok, true} 78 | {:error, _} -> {:error, "error updating the post likes count"} 79 | end 80 | end 81 | end 82 | 83 | def createComment(_, args, %{context: %{current_user: current_user}}) do 84 | args_with_creator_id = Map.merge(args, %{creator_id: current_user.id}) 85 | Repo.transaction(fn -> 86 | case PostComment.changeset(%PostComment{}, args_with_creator_id) |> Repo.insert() do 87 | {:ok, new_comment} -> 88 | case updateCommentsCount(args.post_id, true) do 89 | {:ok, _} -> Map.merge(new_comment, %{creator: current_user}) 90 | {:error, _} -> Repo.rollback("error updating comments count so rolling back created comment...!") 91 | end 92 | {:error, _} -> 93 | Repo.rollback("error creating a new comment -> rolling back.") 94 | end 95 | end) 96 | end 97 | 98 | # Note: isInc will be false when we are deleting a comment 99 | defp updateCommentsCount(post_id, isInc) do 100 | post = Repo.get(Post, post_id) 101 | case post do 102 | nil -> 103 | {:error, "error updating post comments count"} 104 | _ -> 105 | new_comments_count = if isInc, do: post.comments_count + 1, else: post.comments_count - 1 106 | updated = Repo.update(change(post, comments_count: new_comments_count)) 107 | 108 | case updated do 109 | {:ok, _} -> {:ok, true} 110 | {:error, _} -> {:error, "error updating the post comments count"} 111 | end 112 | end 113 | end 114 | 115 | 116 | def getPosts(_, %{page: page, type: type}, %{ 117 | context: %{current_user: current_user} 118 | }) do 119 | 120 | query = 121 | from(post in Post, 122 | join: post_creator in assoc(post, :user), 123 | left_join: likes in assoc(post, :post_likes), 124 | left_join: comments in assoc(post, :post_comments), 125 | left_join: comment_creator in assoc(comments, :user), 126 | preload: [user: post_creator, post_comments: {comments, user: comment_creator}], 127 | where: post.posted_to == ^type and is_nil(post.archived_at), 128 | order_by: [desc: post.inserted_at], 129 | offset: 10 * (^page - 1), 130 | limit: 10, 131 | select_merge: %{ 132 | liked_by_me: likes.post_id == post.id and likes.creator_id == ^current_user.id 133 | } 134 | ) 135 | 136 | posts = 137 | Repo.all(query) 138 | |> Enum.map(fn post -> 139 | post_creator = 140 | if post.creator_id == current_user.id, 141 | do: current_user, 142 | else: User.map_user_avatar_url(post.user) 143 | 144 | recent_comments = 145 | post.post_comments 146 | |> Enum.map(fn comment -> 147 | comment_creator = 148 | if comment.creator_id == current_user.id, 149 | do: current_user, 150 | else: User.map_user_avatar_url(comment.user) 151 | 152 | Map.merge(comment, %{ 153 | creator: comment_creator 154 | }) 155 | end) 156 | 157 | Map.merge(post, %{ 158 | creator: post_creator, 159 | recent_comments: recent_comments 160 | }) 161 | end) 162 | 163 | {:ok, posts} 164 | end 165 | 166 | # for threaded comments in future, read this: https://hexdocs.pm/ecto/Ecto.Schema.html#has_many/3 167 | def getPostComments(_, %{post_id: post_id, page: page}, %{ 168 | context: %{current_user: current_user} 169 | }) do 170 | query = 171 | from(c in PostComment, 172 | where: c.post_id == ^post_id, 173 | offset: 5 * (^page - 1), 174 | limit: 5 175 | ) 176 | 177 | post_comments = 178 | Repo.all(query) 179 | |> Enum.map(fn comment -> 180 | creator = 181 | if comment.creator_id == current_user.id, 182 | do: current_user, 183 | else: User.map_user_avatar_url(Repo.get(User, comment.creator_id)) 184 | 185 | Map.merge(comment, %{creator: creator}) 186 | end) 187 | 188 | {:ok, post_comments} 189 | end 190 | 191 | def deletePost(_, %{id: id}, _) do 192 | from(p in Post, 193 | where: p.id == ^id 194 | ) 195 | |> Repo.update_all(set: [archived_at: DateTime.utc_now()]) 196 | 197 | {:ok, true} 198 | end 199 | end 200 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "absinthe": {:hex, :absinthe, "1.5.3", "d255e6d825e63abd9ff22b6d2423540526c9d699f46b712aa76f4b9c06116ff9", [:mix], [{:dataloader, "~> 1.0.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "69a170f3a8630b2ca489367bc2aeeabd84e15cbd1e86fe8741b05885fda32a2e"}, 3 | "absinthe_phoenix": {:hex, :absinthe_phoenix, "2.0.0", "01c6a90af0ca12ee08d0fb93e23f9890d75bb6d3027f49ee4383bc03058ef5c3", [:mix], [{:absinthe, "~> 1.5.0", [hex: :absinthe, repo: "hexpm", optional: false]}, {:absinthe_plug, "~> 1.5.0", [hex: :absinthe_plug, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.5", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.13", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}], "hexpm", "7ffbfe9fb82a14cafb78885cc2cef4f9d454bbbe2c95eec12b5463f5a20d1020"}, 4 | "absinthe_plug": {:hex, :absinthe_plug, "1.5.0", "018ef544cf577339018d1f482404b4bed762e1b530c78be9de4bbb88a6f3a805", [:mix], [{:absinthe, "~> 1.5.0", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.2 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "4c160f4ce9a1233a4219a42de946e4e05d0e8733537cd5d8d20e7d4ef8d4b7c7"}, 5 | "apollo_tracing": {:hex, :apollo_tracing, "0.4.4", "de945527568e3377409377f5ed39b2f57a02adfa0ff19c33b232d201f45b83a1", [:mix], [{:absinthe, "~> 1.4", [hex: :absinthe, repo: "hexpm", optional: false]}, {:absinthe_plug, "~> 1.4", [hex: :absinthe_plug, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "9b6203b72ae582363fb5c9cabae6ed828276f2c54ee553fb11b9d5ab147db438"}, 6 | "arc": {:hex, :arc, "0.11.0", "ac7a0cc03035317b6fef9fe94c97d7d9bd183a3e7ce1606aa0c175cfa8d1ba6d", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:ex_aws_s3, "~> 2.0", [hex: :ex_aws_s3, repo: "hexpm", optional: true]}, {:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:poison, "~> 2.2 or ~> 3.1", [hex: :poison, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.6", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "e91a8bd676fca716f6e46275ae81fb96c0bbc7a9d5b96cac511ae190588eddd0"}, 7 | "arc_ecto": {:hex, :arc_ecto, "0.11.3", "52f278330fe3a29472ce5d9682514ca09eaed4b33453cbaedb5241a491464f7d", [:mix], [{:arc, "~> 0.11.0", [hex: :arc, repo: "hexpm", optional: false]}, {:ecto, ">= 2.1.0", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "24beed35003707434a778caece7d71e46e911d46d1e82e7787345264fc8e96d0"}, 8 | "argon2_elixir": {:hex, :argon2_elixir, "2.3.0", "e251bdafd69308e8c1263e111600e6d68bd44f23d2cccbe43fcb1a417a76bc8e", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "28ccb63bff213aecec1f7f3dde9648418b031f822499973281d8f494b9d5a3b3"}, 9 | "certifi": {:hex, :certifi, "2.5.2", "b7cfeae9d2ed395695dd8201c57a2d019c0c43ecaf8b8bcb9320b40d6662f340", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "3b3b5f36493004ac3455966991eaf6e768ce9884693d9968055aeeeb1e575040"}, 10 | "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, 11 | "comeonin": {:hex, :comeonin, "5.3.1", "7fe612b739c78c9c1a75186ef2d322ce4d25032d119823269d0aa1e2f1e20025", [:mix], [], "hexpm", "d6222483060c17f0977fad1b7401ef0c5863c985a64352755f366aee3799c245"}, 12 | "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm", "4a0850c9be22a43af9920a71ab17c051f5f7d45c209e40269a1938832510e4d9"}, 13 | "corsica": {:hex, :corsica, "1.1.3", "5f1de40bc9285753aa03afbdd10c364dac79b2ddbf2ba9c5c9c47b397ec06f40", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "8156b3a14a114a346262871333a931a1766b2597b56bf994fcfcb65443a348ad"}, 14 | "cowboy": {:hex, :cowboy, "2.8.0", "f3dc62e35797ecd9ac1b50db74611193c29815401e53bac9a5c0577bd7bc667d", [:rebar3], [{:cowlib, "~> 2.9.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "4643e4fba74ac96d4d152c75803de6fad0b3fa5df354c71afdd6cbeeb15fac8a"}, 15 | "cowlib": {:hex, :cowlib, "2.9.1", "61a6c7c50cf07fdd24b2f45b89500bb93b6686579b069a89f88cb211e1125c78", [:rebar3], [], "hexpm", "e4175dc240a70d996156160891e1c62238ede1729e45740bdd38064dad476170"}, 16 | "dataloader": {:hex, :dataloader, "1.0.8", "114294362db98a613f231589246aa5b0ce847412e8e75c4c94f31f204d272cbf", [:mix], [{:ecto, ">= 3.4.3 and < 4.0.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "eaf3c2aa2bc9dbd2f1e960561d616b7f593396c4754185b75904f6d66c82a667"}, 17 | "db_connection": {:hex, :db_connection, "2.2.2", "3bbca41b199e1598245b716248964926303b5d4609ff065125ce98bcd368939e", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm", "642af240d8a8affb93b4ba5a6fcd2bbcbdc327e1a524b825d383711536f8070c"}, 18 | "decimal": {:hex, :decimal, "1.9.0", "83e8daf59631d632b171faabafb4a9f4242c514b0a06ba3df493951c08f64d07", [:mix], [], "hexpm", "b1f2343568eed6928f3e751cf2dffde95bfaa19dd95d09e8a9ea92ccfd6f7d85"}, 19 | "ecto": {:hex, :ecto, "3.4.6", "08f7afad3257d6eb8613309af31037e16c36808dfda5a3cd0cb4e9738db030e4", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6f13a9e2a62e75c2dcfc7207bfc65645ab387af8360db4c89fee8b5a4bf3f70b"}, 20 | "ecto_sql": {:hex, :ecto_sql, "3.4.5", "30161f81b167d561a9a2df4329c10ae05ff36eca7ccc84628f2c8b9fa1e43323", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0 or ~> 0.4.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.0", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "31990c6a3579b36a3c0841d34a94c275e727de8b84f58509da5f1b2032c98ac2"}, 21 | "elixir_make": {:hex, :elixir_make, "0.6.1", "8faa29a5597faba999aeeb72bbb9c91694ef8068f0131192fb199f98d32994ef", [:mix], [], "hexpm", "35d33270680f8d839a4003c3e9f43afb595310a592405a00afc12de4c7f55a18"}, 22 | "ex_aws": {:git, "https://github.com/factsfinder/ex_aws.git", "bdcf975a1c0e3878d8ed62aeb93897e6fae271a4", [tag: "2.14"]}, 23 | "ex_aws_s3": {:git, "https://github.com/factsfinder/ex_aws_s3.git", "cd520cf1ef41a33e76b2781a2bcf7b5e26db189e", [tag: "2.0.3"]}, 24 | "faker": {:hex, :faker, "0.15.0", "7b91646b97aef21f4b514367ce95a177c9871fcf301336b33e931d2519343bce", [:mix], [], "hexpm", "73ce103e4dca83a147198bdf40d78b5840be520c7bd15ee5b59b48550654b932"}, 25 | "gettext": {:hex, :gettext, "0.18.2", "7df3ea191bb56c0309c00a783334b288d08a879f53a7014341284635850a6e55", [:mix], [], "hexpm", "f9f537b13d4fdd30f3039d33cb80144c3aa1f8d9698e47d7bcbcc8df93b1f5c5"}, 26 | "guardian": {:hex, :guardian, "2.1.1", "1f02b349f6ba765647cc834036a8d76fa4bd65605342fe3a031df3c99d0d411a", [:mix], [{:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "189b87ba7ce6b40d6ba029138098b96ffc4ae78f229f5b39539b9141af8bf0f8"}, 27 | "hackney": {:hex, :hackney, "1.16.0", "5096ac8e823e3a441477b2d187e30dd3fff1a82991a806b2003845ce72ce2d84", [:rebar3], [{:certifi, "2.5.2", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.1", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.0", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.6", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "3bf0bebbd5d3092a3543b783bf065165fa5d3ad4b899b836810e513064134e18"}, 28 | "idna": {:hex, :idna, "6.0.1", "1d038fb2e7668ce41fbf681d2c45902e52b3cb9e9c77b55334353b222c2ee50c", [:rebar3], [{:unicode_util_compat, "0.5.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a02c8a1c4fd601215bb0b0324c8a6986749f807ce35f25449ec9e69758708122"}, 29 | "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, 30 | "jose": {:hex, :jose, "1.10.1", "16d8e460dae7203c6d1efa3f277e25b5af8b659febfc2f2eb4bacf87f128b80a", [:mix, :rebar3], [], "hexpm", "3c7ddc8a9394b92891db7c2771da94bf819834a1a4c92e30857b7d582e2f8257"}, 31 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 32 | "mime": {:hex, :mime, "1.4.0", "5066f14944b470286146047d2f73518cf5cca82f8e4815cf35d196b58cf07c47", [:mix], [], "hexpm", "75fa42c4228ea9a23f70f123c74ba7cece6a03b1fd474fe13f6a7a85c6ea4ff6"}, 33 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 34 | "nimble_parsec": {:hex, :nimble_parsec, "0.6.0", "32111b3bf39137144abd7ba1cce0914533b2d16ef35e8abc5ec8be6122944263", [:mix], [], "hexpm", "27eac315a94909d4dc68bc07a4a83e06c8379237c5ea528a9acff4ca1c873c52"}, 35 | "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"}, 36 | "phoenix": {:hex, :phoenix, "1.5.5", "9a5a197edc1828c5f138a8ef10524dfecc43e36ab435c14578b1e9b4bd98858c", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.13", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.1.2 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b10eaf86ad026eafad2ee3dd336f0fb1c95a3711789855d913244e270bde463b"}, 37 | "phoenix_ecto": {:hex, :phoenix_ecto, "4.2.1", "13f124cf0a3ce0f1948cf24654c7b9f2347169ff75c1123f44674afee6af3b03", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 2.15", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "478a1bae899cac0a6e02be1deec7e2944b7754c04e7d4107fc5a517f877743c0"}, 38 | "phoenix_html": {:hex, :phoenix_html, "2.14.2", "b8a3899a72050f3f48a36430da507dd99caf0ac2d06c77529b1646964f3d563e", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "58061c8dfd25da5df1ea0ca47c972f161beb6c875cd293917045b92ffe1bf617"}, 39 | "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.2.8", "636e1443dbce6769a4d6c69c5659c554eb572604540d86eef544de15e9a86f3a", [:mix], [{:phoenix_html, "~> 2.14.1 or ~> 2.15", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.14.3", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.4.0 or ~> 0.5.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "4545f9db40c4d267f15b083f09a4e581143ddc16d861e1374fc4e531b472bd08"}, 40 | "phoenix_live_view": {:hex, :phoenix_live_view, "0.14.7", "e05ca2e57974bb99eb54fed88b04754a622e54cf7e832db3c868bd06e0b99ff2", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.3", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 0.5", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "899224a704221ab0019200da61019dea699763e12daa24d69edd79bc228fe5a5"}, 41 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.0.0", "a1ae76717bb168cdeb10ec9d92d1480fec99e3080f011402c0a2d68d47395ffb", [:mix], [], "hexpm", "c52d948c4f261577b9c6fa804be91884b381a7f8f18450c5045975435350f771"}, 42 | "plug": {:hex, :plug, "1.10.4", "41eba7d1a2d671faaf531fa867645bd5a3dce0957d8e2a3f398ccff7d2ef017f", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ad1e233fe73d2eec56616568d260777b67f53148a999dc2d048f4eb9778fe4a0"}, 43 | "plug_cowboy": {:hex, :plug_cowboy, "2.3.0", "149a50e05cb73c12aad6506a371cd75750c0b19a32f81866e1a323dda9e0e99d", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "bc595a1870cef13f9c1e03df56d96804db7f702175e4ccacdb8fc75c02a7b97e"}, 44 | "plug_crypto": {:hex, :plug_crypto, "1.1.2", "bdd187572cc26dbd95b87136290425f2b580a116d3fb1f564216918c9730d227", [:mix], [], "hexpm", "6b8b608f895b6ffcfad49c37c7883e8df98ae19c6a28113b02aa1e9c5b22d6b5"}, 45 | "postgrex": {:hex, :postgrex, "0.15.6", "a464c72010a56e3214fe2b99c1a76faab4c2bb0255cabdef30dea763a3569aa2", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "f99268325ac8f66ffd6c4964faab9e70fbf721234ab2ad238c00f9530b8cdd55"}, 46 | "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm", "451d8527787df716d99dc36162fca05934915db0b6141bbdac2ea8d3c7afc7d7"}, 47 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, 48 | "sweet_xml": {:hex, :sweet_xml, "0.6.6", "fc3e91ec5dd7c787b6195757fbcf0abc670cee1e4172687b45183032221b66b8", [:mix], [], "hexpm", "2e1ec458f892ffa81f9f8386e3f35a1af6db7a7a37748a64478f13163a1f3573"}, 49 | "telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"}, 50 | "telemetry_metrics": {:hex, :telemetry_metrics, "0.5.0", "1b796e74add83abf844e808564275dfb342bcc930b04c7577ab780e262b0d998", [:mix], [{:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "31225e6ce7a37a421a0a96ec55244386aec1c190b22578bd245188a4a33298fd"}, 51 | "telemetry_poller": {:hex, :telemetry_poller, "0.5.1", "21071cc2e536810bac5628b935521ff3e28f0303e770951158c73eaaa01e962a", [:rebar3], [{:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4cab72069210bc6e7a080cec9afffad1b33370149ed5d379b81c7c5f0c663fd4"}, 52 | "timex": {:hex, :timex, "3.6.2", "845cdeb6119e2fef10751c0b247b6c59d86d78554c83f78db612e3290f819bc2", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5 or ~> 1.0.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "26030b46199d02a590be61c2394b37ea25a3664c02fafbeca0b24c972025d47a"}, 53 | "tzdata": {:hex, :tzdata, "1.0.3", "73470ad29dde46e350c60a66e6b360d3b99d2d18b74c4c349dbebbc27a09a3eb", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "a6e1ee7003c4d04ecbd21dd3ec690d4c6662db5d3bbdd7262d53cdf5e7c746c1"}, 54 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.5.0", "8516502659002cec19e244ebd90d312183064be95025a319a6c7e89f4bccd65b", [:rebar3], [], "hexpm", "d48d002e15f5cc105a696cf2f1bbb3fc72b4b770a184d8420c8db20da2674b38"}, 55 | "uuid": {:hex, :uuid, "1.1.8", "e22fc04499de0de3ed1116b770c7737779f226ceefa0badb3592e64d5cfb4eb9", [:mix], [], "hexpm", "c790593b4c3b601f5dc2378baae7efaf5b3d73c4c6456ba85759905be792f2ac"}, 56 | } 57 | --------------------------------------------------------------------------------