diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 63ff4bab..6bc00264 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -56,8 +56,8 @@ jobs: - name: Install Erlang & Elixir uses: erlef/setup-beam@v1 with: - otp-version: '25.2.3' - elixir-version: '1.14.2' + otp-version: '26.0.2' + elixir-version: '1.15.2' - name: Install system dependencies run: sudo apt-get update && sudo apt-get install wkhtmltopdf - name: Cache Mix diff --git a/client/src/components/Account/Account.jsx b/client/src/components/Account/Account.jsx index 62d433c3..8334b5a8 100644 --- a/client/src/components/Account/Account.jsx +++ b/client/src/components/Account/Account.jsx @@ -1,11 +1,18 @@ -import { Box } from "@mui/material"; +import { Box, Grid } from "@mui/material"; import OneTimeCode from "../OneTimeCode/OneTimeCode"; +import ScoretakingTokens from "../ScoretakingTokens/ScoretakingTokens"; function Account() { - // It's pretty boring right now, but there's more to come. return ( - + + + + + + + + ); } diff --git a/client/src/components/CompetitionSearch/CompetitionSearch.jsx b/client/src/components/CompetitionSearch/CompetitionSearch.jsx index 66e83e54..05b927fb 100644 --- a/client/src/components/CompetitionSearch/CompetitionSearch.jsx +++ b/client/src/components/CompetitionSearch/CompetitionSearch.jsx @@ -15,7 +15,7 @@ const COMPETITIONS = gql` const DEBOUNCE_MS = 250; -function CompetitionSearch({ onChange, TextFieldProps = {} }) { +function CompetitionSearch({ value = null, onChange, TextFieldProps = {} }) { const [search, setSearch] = useState(""); const debouncedSearch = useDebounce(search, DEBOUNCE_MS); @@ -31,9 +31,9 @@ function CompetitionSearch({ onChange, TextFieldProps = {} }) { } } - function handleChange(event, user, reason) { + function handleChange(event, competition, reason) { if (reason === "selectOption") { - onChange(user); + onChange(competition); } } @@ -45,8 +45,10 @@ function CompetitionSearch({ onChange, TextFieldProps = {} }) { getOptionLabel={(competition) => competition.name} loading={loading} onInputChange={handleInputChange} - value={null} + value={value} onChange={handleChange} + forcePopupIcon={false} + disableClearable={true} renderInput={(params) => } /> ); diff --git a/client/src/components/ScoretakingTokens/ScoretakingTokenDialog.jsx b/client/src/components/ScoretakingTokens/ScoretakingTokenDialog.jsx new file mode 100644 index 00000000..655cf7dc --- /dev/null +++ b/client/src/components/ScoretakingTokens/ScoretakingTokenDialog.jsx @@ -0,0 +1,36 @@ +import { + Grid, + Typography, + Dialog, + DialogContent, + DialogActions, + Button, +} from "@mui/material"; + +function ScoretakingTokenDialog({ token, open, onClose }) { + return ( + + + + + + Copy and save the token, you won't be able to see it again. + + + + + {token} + + + + + + + + + ); +} + +export default ScoretakingTokenDialog; diff --git a/client/src/components/ScoretakingTokens/ScoretakingTokens.jsx b/client/src/components/ScoretakingTokens/ScoretakingTokens.jsx new file mode 100644 index 00000000..fdc70869 --- /dev/null +++ b/client/src/components/ScoretakingTokens/ScoretakingTokens.jsx @@ -0,0 +1,128 @@ +import { useState } from "react"; +import { gql, useMutation, useQuery } from "@apollo/client"; +import { Button, Grid, Link, Typography } from "@mui/material"; + +import useApolloErrorHandler from "../../hooks/useApolloErrorHandler"; +import CompetitionSearch from "../CompetitionSearch/CompetitionSearch"; +import Loading from "../Loading/Loading"; +import Error from "../Error/Error"; +import ScoretakingTokensTable from "../ScoretakingTokensTable/ScoretakingTokensTable"; +import ScoretakingTokenDialog from "./ScoretakingTokenDialog"; + +const ACTIVE_SCORETAKING_TOKENS_QUERY = gql` + query ActiveScoretakingTokensQuery { + activeScoretakingTokens { + id + insertedAt + competition { + id + name + } + } + } +`; + +const GENERATE_SCORETAKING_TOKEN = gql` + mutation GenerateScoretakingToken($input: GenerateScoretakingTokenInput!) { + generateScoretakingToken(input: $input) { + token + } + } +`; + +function ScoretakingTokens() { + const apolloErrorHandler = useApolloErrorHandler(); + + const [competition, setCompetition] = useState(null); + const [dialogOpen, setDialogOpen] = useState(false); + + const { data, loading, error } = useQuery(ACTIVE_SCORETAKING_TOKENS_QUERY); + + const [ + generateScoretakingToken, + { data: generatedTokenData, loading: mutationLoading }, + ] = useMutation(GENERATE_SCORETAKING_TOKEN, { + variables: { + input: { + competitionId: competition && competition.id, + }, + }, + onError: apolloErrorHandler, + onCompleted: () => { + setCompetition(null); + setDialogOpen(true); + }, + refetchQueries: [ACTIVE_SCORETAKING_TOKENS_QUERY], + }); + + if (loading && !data) return ; + if (error) return ; + + const { activeScoretakingTokens } = data; + + const token = generatedTokenData + ? generatedTokenData.generateScoretakingToken.token + : null; + + return ( + <> + + + + Scoretaking tokens + + + Generate a personal token for a specific competition. You can use + this token programmatically enter results from an external software + (such as a dedicated scoretaking device). For API specifics check + out{" "} + + this page + + . + + + + setCompetition(competition)} + value={competition} + TextFieldProps={{ + placeholder: "Competition", + size: "small", + style: { width: 350 }, + }} + /> + + + + + + + Active tokens + + + + + setDialogOpen(false)} + /> + + ); +} + +export default ScoretakingTokens; diff --git a/client/src/components/ScoretakingTokensTable/ScoretakingTokensTable.jsx b/client/src/components/ScoretakingTokensTable/ScoretakingTokensTable.jsx new file mode 100644 index 00000000..3d252ae1 --- /dev/null +++ b/client/src/components/ScoretakingTokensTable/ScoretakingTokensTable.jsx @@ -0,0 +1,97 @@ +import { gql, useMutation } from "@apollo/client"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableRow, + TableContainer, + Paper, + IconButton, +} from "@mui/material"; +import DeleteIcon from "@mui/icons-material/Delete"; +import TimeAgo from "react-timeago"; +import { parseISO } from "date-fns"; +import { useConfirm } from "material-ui-confirm"; + +import { orderBy } from "../../lib/utils"; +import useApolloErrorHandler from "../../hooks/useApolloErrorHandler"; + +const DELETE_SCORETAKING_TOKEN_MUTATION = gql` + mutation DeleteScoretakingToken($input: DeleteScoretakingTokenInput!) { + deleteScoretakingToken(input: $input) { + scoretakingToken { + id + } + } + } +`; + +function ScoretakingTokensTable({ + scoretakingTokens, + activeScoretakingTokensQuery, +}) { + const confirm = useConfirm(); + const apolloErrorHandler = useApolloErrorHandler(); + + const [deleteScoretakingToken, { loading }] = useMutation( + DELETE_SCORETAKING_TOKEN_MUTATION, + { + onError: apolloErrorHandler, + refetchQueries: [activeScoretakingTokensQuery], + } + ); + + function handleDelete(scoretakingToken) { + confirm({ + description: ` + This will remove the scoretaking token for ${scoretakingToken.competition.name}. + `, + }).then(() => { + deleteScoretakingToken({ + variables: { input: { id: scoretakingToken.id } }, + }); + }); + } + + const sortedScoretakingTokens = orderBy( + scoretakingTokens, + (token) => [token.insertedAt], + ["desc"] + ); + + return ( + + + + + Competition + Created + + + + + {sortedScoretakingTokens.map((scoretakingToken) => ( + + {scoretakingToken.competition.name} + + + + + handleDelete(scoretakingToken)} + size="small" + disabled={loading} + > + + + + + ))} + +
+
+ ); +} + +export default ScoretakingTokensTable; diff --git a/lib/wca_live/accounts.ex b/lib/wca_live/accounts.ex index 95e41c95..36e00f16 100644 --- a/lib/wca_live/accounts.ex +++ b/lib/wca_live/accounts.ex @@ -6,7 +6,7 @@ defmodule WcaLive.Accounts do import Ecto.Query, warn: false alias WcaLive.Repo - alias WcaLive.Accounts.{User, AccessToken, OneTimeCode, UserToken} + alias WcaLive.Accounts.{User, AccessToken, OneTimeCode, UserToken, ScoretakingToken} alias WcaLive.Wca @doc """ @@ -152,4 +152,41 @@ defmodule WcaLive.Accounts do end end end + + @doc """ + Generates a new scoretaking token. + """ + def generate_scoretaking_token(user, competition) do + scoretaking_token = ScoretakingToken.build_scoretaking_token(user, competition) + Repo.insert!(scoretaking_token) + end + + @doc """ + Gets the user with the given token. + """ + def get_user_and_competition_by_scoretaking_token(token) do + Repo.one(ScoretakingToken.verify_token_query(token)) + end + + @doc """ + Gets scoretaking token by id. + """ + def get_scoretaking_token!(id) do + Repo.get!(ScoretakingToken, id) + end + + @doc """ + Deletes scoretaking token. + """ + def delete_scoretaking_token(scoretaking_token) do + Repo.delete!(scoretaking_token) + :ok + end + + @doc """ + Lists all active scoretaking tokens for the given user. + """ + def list_active_scoretaking_tokens(user) do + Repo.all(ScoretakingToken.active_tokens_for_user_query(user)) + end end diff --git a/lib/wca_live/accounts/one_time_code.ex b/lib/wca_live/accounts/one_time_code.ex index e583c5e2..3382701c 100644 --- a/lib/wca_live/accounts/one_time_code.ex +++ b/lib/wca_live/accounts/one_time_code.ex @@ -24,9 +24,6 @@ defmodule WcaLive.Accounts.OneTimeCode do # Configure OTCs to expire in 2 minutes. @ttl_sec 2 * 60 - @required_fields [:code, :expires_at] - @optional_fields [] - schema "one_time_codes" do field :code, :string field :expires_at, :utc_datetime @@ -36,12 +33,6 @@ defmodule WcaLive.Accounts.OneTimeCode do timestamps(updated_at: false) end - def changeset(otc, attrs) do - otc - |> cast(attrs, @required_fields ++ @optional_fields) - |> validate_required(@required_fields) - end - @doc """ Returns the changeset for a new OTC with randomly generated code and a short expiration time. diff --git a/lib/wca_live/accounts/scoretaking_token.ex b/lib/wca_live/accounts/scoretaking_token.ex new file mode 100644 index 00000000..0029267a --- /dev/null +++ b/lib/wca_live/accounts/scoretaking_token.ex @@ -0,0 +1,88 @@ +defmodule WcaLive.Accounts.ScoretakingToken do + @moduledoc """ + A competition-scoped token for scoretaking API calls from external + systems. + """ + + use WcaLive.Schema + + import Ecto.Query + + alias WcaLive.{Accounts, Competitions} + alias WcaLive.Accounts.ScoretakingToken + + @rand_size 32 + + @validity_in_days 7 + + schema "scoretaking_tokens" do + # We only store the token hash, as we would do with password + field :token, :string, virtual: true + field :token_hash, :binary + + belongs_to :user, Accounts.User + belongs_to :competition, Competitions.Competition + + timestamps(updated_at: false) + end + + @doc """ + Computes hash for the given token. + """ + @spec token_hash(String.t()) :: binary() + def token_hash(token) do + :crypto.hash(:sha256, token) + end + + @doc """ + Generates a new competition-scoped token for the given user. + """ + @spec build_scoretaking_token(%Accounts.User{}, %Competitions.Competition{}) :: + {String.t(), %__MODULE__{}} + def build_scoretaking_token(user, competition) do + token = :crypto.strong_rand_bytes(@rand_size) |> Base.url_encode64(padding: false) + + %ScoretakingToken{ + token: token, + token_hash: token_hash(token), + user_id: user.id, + competition_id: competition.id + } + end + + @doc """ + Returns query that verifies the token and returns the user and + competition, if any. + + The token is valid if it matches the value in the database and it + has not expired. + """ + @spec verify_token_query(String.t()) :: Ecto.Query.t() + def verify_token_query(token) do + from(token in token_query(token), + join: user in assoc(token, :user), + join: competition in assoc(token, :competition), + where: token.inserted_at > ago(@validity_in_days, "day"), + select: {user, competition} + ) + end + + @doc """ + Returns query to lookup token struct for the given token value. + """ + @spec token_query(String.t()) :: Ecto.Query.t() + def token_query(token) do + token_hash = token_hash(token) + from(ScoretakingToken, where: [token_hash: ^token_hash]) + end + + @doc """ + Returns query to lookup all active tokens for the given user. + """ + @spec active_tokens_for_user_query(%Accounts.User{}) :: Ecto.Query.t() + def active_tokens_for_user_query(user) do + from(token in ScoretakingToken, + where: token.user_id == ^user.id and token.inserted_at > ago(@validity_in_days, "day") + ) + end +end diff --git a/lib/wca_live/scoretaking.ex b/lib/wca_live/scoretaking.ex index 9ccf1b47..ee8c0251 100644 --- a/lib/wca_live/scoretaking.ex +++ b/lib/wca_live/scoretaking.ex @@ -32,7 +32,7 @@ defmodule WcaLive.Scoretaking do end @doc """ - Finds a specific round of the given event and competition. + Finds round by id. Raises an error if no round is found. """ @@ -40,7 +40,7 @@ defmodule WcaLive.Scoretaking do def fetch_round(id), do: Repo.fetch(Round, id) @doc """ - Finds + Finds round given event id and number. """ @spec get_round_by_event_and_number!(term(), String.t(), pos_integer()) :: %Round{} def get_round_by_event_and_number!(competition_id, event_id, round_number) do @@ -93,6 +93,20 @@ defmodule WcaLive.Scoretaking do @spec fetch_result(term()) :: {:ok, %Result{}} | {:error, Ecto.Queryable.t()} def fetch_result(id), do: Repo.fetch(Result, id) + @doc """ + Finds round given event id and number. + """ + @spec get_result_by_registrant_id!(term(), pos_integer()) :: %Result{} + def get_result_by_registrant_id!(round_id, registrant_id) do + Repo.one!( + from round in Round, + join: result in assoc(round, :results), + join: person in assoc(result, :person), + where: round.id == ^round_id and person.registrant_id == ^registrant_id, + select: result + ) + end + @doc """ Updates attempts for a batch of results. @@ -100,7 +114,7 @@ defmodule WcaLive.Scoretaking do ranking, records and advancing based on the new state. """ @spec enter_results( - %Result{}, + %Round{}, list(%{id: term(), attempts: list(map()), entered_at: DateTime.t()}), %User{} ) :: {:ok, %Round{}} | {:error, Ecto.Changeset.t()} @@ -138,6 +152,40 @@ defmodule WcaLive.Scoretaking do end end + @doc """ + Updates a single attempt of the given result. + + Wraps `enter_results/3`. + """ + def enter_result_attempt(round, result, attempt_number, attempt_result, user) do + format = Format.get_by_id!(round.format_id) + + attempt_attrs = Enum.map(result.attempts, &Map.from_struct/1) + + padded_attempt_attrs = + attempt_attrs ++ + List.duplicate(%{result: 0}, format.number_of_attempts - length(result.attempts)) + + padded_attempt_attrs = + put_in(padded_attempt_attrs, [Access.at!(attempt_number - 1), :result], attempt_result) + + attempt_attrs = + padded_attempt_attrs + |> Enum.reverse() + |> Enum.drop_while(&(&1.result == 0)) + |> Enum.reverse() + + results_attrs = [ + %{ + id: result.id, + attempts: attempt_attrs, + entered_at: DateTime.utc_now() + } + ] + + enter_results(round, results_attrs, user) + end + # Updates attributes (`ranking`, `advancing` and record tags) of the given round results. @spec process_round_after_results_change(%Round{}) :: Ecto.Multi.t() defp process_round_after_results_change(round) do diff --git a/lib/wca_live/scoretaking/attempt.ex b/lib/wca_live/scoretaking/attempt.ex index 3e821232..11aee131 100644 --- a/lib/wca_live/scoretaking/attempt.ex +++ b/lib/wca_live/scoretaking/attempt.ex @@ -19,5 +19,12 @@ defmodule WcaLive.Scoretaking.Attempt do attempt |> cast(attrs, @required_fields ++ @optional_fields) |> validate_required(@required_fields) + |> validate_change(:result, fn :result, result -> + if result < -2 do + [result: "is out of the expected value range"] + else + [] + end + end) end end diff --git a/lib/wca_live_web/controllers/competition_controller.ex b/lib/wca_live_web/controllers/competition_controller.ex index 920415f6..e7ff2132 100644 --- a/lib/wca_live_web/controllers/competition_controller.ex +++ b/lib/wca_live_web/controllers/competition_controller.ex @@ -1,7 +1,7 @@ defmodule WcaLiveWeb.CompetitionController do use WcaLiveWeb, :controller - alias WcaLive.Competitions + alias WcaLive.{Competitions, Scoretaking} def show_wcif(conn, params) do case Competitions.fetch_competition(params["id"]) do @@ -25,4 +25,84 @@ defmodule WcaLiveWeb.CompetitionController do |> json(%{error: "not found"}) end end + + def enter_attempt(conn, params) do + case params do + %{ + "competitionWcaId" => competition_wca_id, + "eventId" => event_id, + "roundNumber" => round_number, + "registrantId" => registrant_id, + "attemptNumber" => attempt_number, + "attemptResult" => attempt_result + } -> + competition = Competitions.get_competition_by_wca_id!(competition_wca_id) + round = Scoretaking.get_round_by_event_and_number!(competition.id, event_id, round_number) + result = Scoretaking.get_result_by_registrant_id!(round.id, registrant_id) + + case authorize_scoretaking_token(conn, competition) do + {:ok, user} -> + case Scoretaking.enter_result_attempt( + round, + result, + attempt_number, + attempt_result, + user + ) do + {:ok, round} -> + # Publish event to trigger subscription updates + Absinthe.Subscription.publish(WcaLiveWeb.Endpoint, %{round: round}, + round_updated: round.id + ) + + conn + |> put_status(200) + |> json(%{}) + + {:error, changeset} -> + conn + |> put_status(422) + |> json(%{errors: WcaLiveWeb.Helpers.changeset_to_error_messages(changeset)}) + end + + {:error, message} -> + conn + |> put_status(401) + |> json(%{error: message}) + end + + _ -> + conn + |> put_status(400) + |> json(%{error: "invalid payload"}) + end + end + + defp authorize_scoretaking_token(conn, competition) do + case get_req_header(conn, "authorization") do + ["Bearer " <> token] -> + case WcaLive.Accounts.get_user_and_competition_by_scoretaking_token(token) do + {user, token_competition} -> + cond do + competition.id != token_competition.id -> + {:error, "the provided token does not grant access to this competition"} + + not WcaLive.Scoretaking.Access.can_scoretake_competition?(user, competition) -> + {:error, "the token user no longer have access to this competition"} + + true -> + {:ok, user} + end + + nil -> + {:error, "the provided token is not valid"} + end + + [_] -> + {:error, "invalid token format"} + + _ -> + {:error, "no authorization token provided"} + end + end end diff --git a/lib/wca_live_web/helpers.ex b/lib/wca_live_web/helpers.ex new file mode 100644 index 00000000..8d52f750 --- /dev/null +++ b/lib/wca_live_web/helpers.ex @@ -0,0 +1,38 @@ +defmodule WcaLiveWeb.Helpers do + def changeset_to_error_messages(changeset) do + changeset + |> Ecto.Changeset.traverse_errors(fn {message, opts} -> + # Interpolate opts into message. + Enum.reduce(opts, message, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", to_string(value)) + end) + end) + |> Enum.flat_map(fn {field, messages} -> + format_field_error_messages(field, messages) + end) + end + + defp format_field_error_messages(field, messages) do + Enum.flat_map(messages, fn message -> + format_field_error_message(field, message) + end) + end + + defp format_field_error_message(field, message) when is_binary(message) do + ["#{friendly_field_name(field)} #{message}"] + end + + defp format_field_error_message(_field, %{} = message) do + # Errors may come from nested associated structures, + # so a message could be a map + Enum.flat_map(message, fn {field, messages} -> + format_field_error_messages(field, messages) + end) + end + + defp friendly_field_name(field) do + field + |> to_string() + |> String.replace("_", " ") + end +end diff --git a/lib/wca_live_web/resolvers/accounts.ex b/lib/wca_live_web/resolvers/accounts.ex index 23a93de0..a1969c6a 100644 --- a/lib/wca_live_web/resolvers/accounts.ex +++ b/lib/wca_live_web/resolvers/accounts.ex @@ -12,6 +12,12 @@ defmodule WcaLiveWeb.Resolvers.Accounts do def current_user(_parent, _args, _resolution), do: {:ok, nil} + def active_scoretaking_tokens(_parent, _args, %{context: %{current_user: current_user}}) do + {:ok, Accounts.list_active_scoretaking_tokens(current_user)} + end + + def active_scoretaking_tokens(_parent, _args, _resolution), do: {:ok, []} + def list_users(_parent, args, _resolution) do {:ok, Accounts.list_users(args)} end diff --git a/lib/wca_live_web/resolvers/accounts_mutation.ex b/lib/wca_live_web/resolvers/accounts_mutation.ex index 96b452bb..773533c0 100644 --- a/lib/wca_live_web/resolvers/accounts_mutation.ex +++ b/lib/wca_live_web/resolvers/accounts_mutation.ex @@ -1,5 +1,6 @@ defmodule WcaLiveWeb.Resolvers.AccountsMutation do alias WcaLive.Accounts + alias WcaLive.Competitions def generate_one_time_code(_parent, _args, %{context: %{current_user: current_user}}) do with {:ok, otc} <- Accounts.generate_one_time_code(current_user) do @@ -8,4 +9,37 @@ defmodule WcaLiveWeb.Resolvers.AccountsMutation do end def generate_one_time_code(_parent, _args, _resolution), do: {:error, "not authenticated"} + + def generate_scoretaking_token(_parent, %{input: input}, %{ + context: %{current_user: current_user} + }) do + competition = Competitions.get_competition(input.competition_id) + + can_scoretake? = + WcaLive.Scoretaking.Access.can_scoretake_competition?(current_user, competition) + + if can_scoretake? do + scoretaking_token = Accounts.generate_scoretaking_token(current_user, competition) + {:ok, %{token: scoretaking_token.token, scoretaking_token: scoretaking_token}} + else + {:error, "you do not have scoretaking access for this competition"} + end + end + + def generate_scoretaking_token(_parent, _args, _resolution), do: {:error, "not authenticated"} + + def delete_scoretaking_token(_parent, %{input: input}, %{ + context: %{current_user: current_user} + }) do + scoretaking_token = Accounts.get_scoretaking_token!(input.id) + + if scoretaking_token.user_id == current_user.id do + Accounts.delete_scoretaking_token(scoretaking_token) + {:ok, %{scoretaking_token: scoretaking_token}} + else + {:error, "not authorized"} + end + end + + def delete_scoretaking_token(_parent, _args, _resolution), do: {:error, "not authenticated"} end diff --git a/lib/wca_live_web/router.ex b/lib/wca_live_web/router.ex index 0ab94163..e867bac2 100644 --- a/lib/wca_live_web/router.ex +++ b/lib/wca_live_web/router.ex @@ -47,6 +47,9 @@ defmodule WcaLiveWeb.Router do pipe_through :api get "/competitions/:id/wcif", CompetitionController, :show_wcif + + # Public scoretaking endpoints + post "/enter-attempt", CompetitionController, :enter_attempt end scope "/api" do diff --git a/lib/wca_live_web/schema/accounts_mutation_types.ex b/lib/wca_live_web/schema/accounts_mutation_types.ex index aa69f344..00947f04 100644 --- a/lib/wca_live_web/schema/accounts_mutation_types.ex +++ b/lib/wca_live_web/schema/accounts_mutation_types.ex @@ -7,6 +7,26 @@ defmodule WcaLiveWeb.Schema.AccountsMutationTypes do field :generate_one_time_code, non_null(:generate_one_time_code_payload) do resolve &Resolvers.AccountsMutation.generate_one_time_code/3 end + + field :generate_scoretaking_token, non_null(:generate_scoretaking_token_payload) do + arg :input, non_null(:generate_scoretaking_token_input) + resolve &Resolvers.AccountsMutation.generate_scoretaking_token/3 + end + + field :delete_scoretaking_token, non_null(:delete_scoretaking_token_payload) do + arg :input, non_null(:delete_scoretaking_token_input) + resolve &Resolvers.AccountsMutation.delete_scoretaking_token/3 + end + end + + # Inputs + + input_object :generate_scoretaking_token_input do + field :competition_id, non_null(:id) + end + + input_object :delete_scoretaking_token_input do + field :id, non_null(:id) end # Payloads @@ -14,4 +34,13 @@ defmodule WcaLiveWeb.Schema.AccountsMutationTypes do object :generate_one_time_code_payload do field :one_time_code, :one_time_code end + + object :generate_scoretaking_token_payload do + field :token, :string + field :scoretaking_token, :scoretaking_token + end + + object :delete_scoretaking_token_payload do + field :scoretaking_token, :scoretaking_token + end end diff --git a/lib/wca_live_web/schema/accounts_types.ex b/lib/wca_live_web/schema/accounts_types.ex index 5341c13a..3780b6e5 100644 --- a/lib/wca_live_web/schema/accounts_types.ex +++ b/lib/wca_live_web/schema/accounts_types.ex @@ -9,6 +9,10 @@ defmodule WcaLiveWeb.Schema.AccountsTypes do resolve &Resolvers.Accounts.current_user/3 end + field :active_scoretaking_tokens, non_null(list_of(non_null(:scoretaking_token))) do + resolve &Resolvers.Accounts.active_scoretaking_tokens/3 + end + field :users, non_null(list_of(non_null(:user))) do arg :filter, :string resolve &Resolvers.Accounts.list_users/3 @@ -42,4 +46,14 @@ defmodule WcaLiveWeb.Schema.AccountsTypes do field :expires_at, non_null(:datetime) field :inserted_at, non_null(:datetime) end + + @desc "A competition-scoped token for scoretaking API calls from external systems." + object :scoretaking_token do + field :id, non_null(:id) + field :inserted_at, non_null(:datetime) + + field :competition, non_null(:competition) do + resolve dataloader(:db) + end + end end diff --git a/lib/wca_live_web/schema/middleware/handle_errors.ex b/lib/wca_live_web/schema/middleware/handle_errors.ex index 6425568f..329ab465 100644 --- a/lib/wca_live_web/schema/middleware/handle_errors.ex +++ b/lib/wca_live_web/schema/middleware/handle_errors.ex @@ -11,48 +11,15 @@ defmodule WcaLiveWeb.Schema.Middleware.HandleErrors do %{resolution | errors: Enum.flat_map(resolution.errors, &handle_error/1)} end - defp handle_error(%Ecto.Changeset{} = changeset) do - changeset - |> Ecto.Changeset.traverse_errors(fn {message, opts} -> - # Interpolate opts into message. - Enum.reduce(opts, message, fn {key, value}, acc -> - String.replace(acc, "%{#{key}}", to_string(value)) - end) - end) - |> Enum.flat_map(fn {field, messages} -> - format_field_error_messages(field, messages) - end) + def handle_error(%Ecto.Changeset{} = changeset) do + WcaLiveWeb.Helpers.changeset_to_error_messages(changeset) end - defp handle_error(%Ecto.Query{} = query) do + def handle_error(%Ecto.Query{} = query) do %{from: %{source: {_, queryable}}} = query schema = queryable |> Module.split() |> List.last() ["#{String.downcase(schema)} not found"] end - defp handle_error(error), do: [error] - - defp format_field_error_messages(field, messages) do - Enum.flat_map(messages, fn message -> - format_field_error_message(field, message) - end) - end - - defp format_field_error_message(field, message) when is_binary(message) do - ["#{friendly_field_name(field)} #{message}"] - end - - defp format_field_error_message(_field, %{} = message) do - # Errors may come from nested associated structures, - # so a message could be a map - Enum.flat_map(message, fn {field, messages} -> - format_field_error_messages(field, messages) - end) - end - - defp friendly_field_name(field) do - field - |> to_string() - |> String.replace("_", " ") - end + def handle_error(error), do: [error] end diff --git a/priv/repo/migrations/20230924155518_create_scoretaking_tokens.exs b/priv/repo/migrations/20230924155518_create_scoretaking_tokens.exs new file mode 100644 index 00000000..dbc3b0e0 --- /dev/null +++ b/priv/repo/migrations/20230924155518_create_scoretaking_tokens.exs @@ -0,0 +1,17 @@ +defmodule WcaLive.Repo.Migrations.CreateScoretakingTokens do + use Ecto.Migration + + def change do + create table(:scoretaking_tokens) do + add :token_hash, :binary, null: false + add :user_id, references(:users, on_delete: :delete_all), null: false + add :competition_id, references(:competitions, on_delete: :delete_all), null: false + + timestamps(updated_at: false) + end + + create index(:scoretaking_tokens, [:user_id]) + create index(:scoretaking_tokens, [:competition_id]) + create unique_index(:scoretaking_tokens, [:token_hash]) + end +end diff --git a/test/support/factory.ex b/test/support/factory.ex index f025d7c4..689514c0 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -33,6 +33,15 @@ defmodule WcaLive.Factory do } end + def scoretaking_token_factory do + %Accounts.ScoretakingToken{ + token: "token", + token_hash: Accounts.ScoretakingToken.token_hash("token"), + user: build(:user), + competition: build(:competition) + } + end + def competition_factory do %Competitions.Competition{ wca_id: sequence(:wca_id, &"Competition#{&1}2020"), diff --git a/test/wca_live/scoretaking_test.exs b/test/wca_live/scoretaking_test.exs index e03ed87d..d6fc1a3a 100644 --- a/test/wca_live/scoretaking_test.exs +++ b/test/wca_live/scoretaking_test.exs @@ -334,6 +334,26 @@ defmodule WcaLive.ScoretakingTest do assert {:ok, _round} = Scoretaking.enter_results(round, attrs, user) end + test "enter_result_attempt/5 adds skipped attempts when necessary" do + user = insert(:user) + round = insert(:round, cutoff: nil, format_id: "a") + result = insert(:result, round: round, attempts: []) + + assert {:ok, _round} = Scoretaking.enter_result_attempt(round, result, 2, 1000, user) + result = Repo.reload(result) + assert [0, 1000] == Enum.map(result.attempts, & &1.result) + end + + test "enter_result_attempt/5 trims skipped attempts" do + user = insert(:user) + round = insert(:round, cutoff: nil, format_id: "a") + result = insert(:result, round: round, attempts: build_list(3, :attempt, result: 1000)) + + assert {:ok, _round} = Scoretaking.enter_result_attempt(round, result, 3, 0, user) + result = Repo.reload(result) + assert [1000, 1000] == Enum.map(result.attempts, & &1.result) + end + test "open_round/1 given the first round adds everyone who registered for the event" do competition = insert(:competition) ce333 = insert(:competition_event, competition: competition, event_id: "333") @@ -558,7 +578,9 @@ defmodule WcaLive.ScoretakingTest do [next_qualifying_person] = setup.get_people_by_rank.(5) assert {:ok, updated} = - Scoretaking.remove_person_from_round(second_person, setup.second_round, replace: true) + Scoretaking.remove_person_from_round(second_person, setup.second_round, + replace: true + ) results = updated |> Ecto.assoc(:results) |> Repo.all() assert Enum.any?(results, &(&1.person_id == next_qualifying_person.id)) diff --git a/test/wca_live_web/controllers/competition_controller_test.exs b/test/wca_live_web/controllers/competition_controller_test.exs index 58166b28..cf8c7a32 100644 --- a/test/wca_live_web/controllers/competition_controller_test.exs +++ b/test/wca_live_web/controllers/competition_controller_test.exs @@ -3,6 +3,8 @@ defmodule WcaLiveWeb.CompetitionControllerTest do import WcaLive.Factory + alias WcaLive.Repo + describe "show_wcif" do @tag :signed_in test "returns WCIF when authorized", %{conn: conn, current_user: current_user} do @@ -34,4 +36,176 @@ defmodule WcaLiveWeb.CompetitionControllerTest do assert body == %{"error" => "access denied"} end end + + describe "enter_attempt" do + test "returns error when no token is given", %{conn: conn} do + competition = insert(:competition) + competition_event = insert(:competition_event, competition: competition) + round = insert(:round, competition_event: competition_event) + result = insert(:result, round: round, attempts: []) + + body = %{ + "competitionWcaId" => competition.wca_id, + "eventId" => "333", + "roundNumber" => 1, + "registrantId" => result.person.registrant_id, + "attemptNumber" => 1, + "attemptResult" => 1000 + } + + conn = post(conn, "/api/enter-attempt", body) + + assert %{"error" => "no authorization token provided"} = json_response(conn, 401) + end + + test "returns error when non-existent token is given", %{conn: conn} do + competition = insert(:competition) + competition_event = insert(:competition_event, competition: competition) + round = insert(:round, competition_event: competition_event) + result = insert(:result, round: round, attempts: []) + + body = %{ + "competitionWcaId" => competition.wca_id, + "eventId" => "333", + "roundNumber" => 1, + "registrantId" => result.person.registrant_id, + "attemptNumber" => 1, + "attemptResult" => 1000 + } + + conn = + conn + |> put_req_header("authorization", "Bearer nonexistent") + |> post("/api/enter-attempt", body) + + assert %{"error" => "the provided token is not valid"} = json_response(conn, 401) + end + + @tag :signed_in + test "returns error when the token is for a different competition", + %{conn: conn, current_user: current_user} do + competition = insert(:competition) + competition_event = insert(:competition_event, competition: competition) + round = insert(:round, competition_event: competition_event) + result = insert(:result, round: round, attempts: []) + + scoretaking_token = insert(:scoretaking_token, user: current_user) + + body = %{ + "competitionWcaId" => competition.wca_id, + "eventId" => "333", + "roundNumber" => 1, + "registrantId" => result.person.registrant_id, + "attemptNumber" => 1, + "attemptResult" => 1000 + } + + conn = + conn + |> put_req_header("authorization", "Bearer #{scoretaking_token.token}") + |> post("/api/enter-attempt", body) + + assert %{"error" => "the provided token does not grant access to this competition"} = + json_response(conn, 401) + end + + @tag :signed_in + test "returns error when the user does not have access to the competition", + %{conn: conn, current_user: current_user} do + competition = insert(:competition) + competition_event = insert(:competition_event, competition: competition) + round = insert(:round, competition_event: competition_event) + result = insert(:result, round: round, attempts: []) + + scoretaking_token = insert(:scoretaking_token, competition: competition, user: current_user) + + body = %{ + "competitionWcaId" => competition.wca_id, + "eventId" => "333", + "roundNumber" => 1, + "registrantId" => result.person.registrant_id, + "attemptNumber" => 1, + "attemptResult" => 1000 + } + + conn = + conn + |> put_req_header("authorization", "Bearer #{scoretaking_token.token}") + |> post("/api/enter-attempt", body) + + assert %{"error" => "the token user no longer have access to this competition"} = + json_response(conn, 401) + end + + test "returns error on incomplete payload", %{conn: conn} do + competition = insert(:competition) + + body = %{ + "competitionWcaId" => competition.wca_id + } + + conn = post(conn, "/api/enter-attempt", body) + + assert %{"error" => "invalid payload"} = json_response(conn, 400) + end + + @tag :signed_in + test "updates result", %{conn: conn, current_user: current_user} do + competition = insert(:competition) + insert(:staff_member, competition: competition, user: current_user, roles: ["delegate"]) + competition_event = insert(:competition_event, competition: competition) + round = insert(:round, competition_event: competition_event) + result = insert(:result, round: round, attempts: []) + + scoretaking_token = insert(:scoretaking_token, competition: competition, user: current_user) + + body = %{ + "competitionWcaId" => competition.wca_id, + "eventId" => "333", + "roundNumber" => 1, + "registrantId" => result.person.registrant_id, + "attemptNumber" => 1, + "attemptResult" => 1000 + } + + conn = + conn + |> put_req_header("authorization", "Bearer #{scoretaking_token.token}") + |> post("/api/enter-attempt", body) + + json_response(conn, 200) + + result = Repo.reload(result) + assert current_user.id == result.entered_by_id + assert [1000] == Enum.map(result.attempts, & &1.result) + end + + @tag :signed_in + test "returns errors on invalid update", %{conn: conn, current_user: current_user} do + competition = insert(:competition) + insert(:staff_member, competition: competition, user: current_user, roles: ["delegate"]) + competition_event = insert(:competition_event, competition: competition) + round = insert(:round, competition_event: competition_event) + result = insert(:result, round: round, attempts: []) + + scoretaking_token = insert(:scoretaking_token, competition: competition, user: current_user) + + body = %{ + "competitionWcaId" => competition.wca_id, + "eventId" => "333", + "roundNumber" => 1, + "registrantId" => result.person.registrant_id, + "attemptNumber" => 1, + "attemptResult" => nil + } + + conn = + conn + |> put_req_header("authorization", "Bearer #{scoretaking_token.token}") + |> post("/api/enter-attempt", body) + + assert %{"errors" => ["result can't be blank"]} = + json_response(conn, 422) + end + end end diff --git a/test/wca_live_web/schema/accounts_mutation_types_test.exs b/test/wca_live_web/schema/accounts_mutation_types_test.exs index 2a8566ff..e0e0a255 100644 --- a/test/wca_live_web/schema/accounts_mutation_types_test.exs +++ b/test/wca_live_web/schema/accounts_mutation_types_test.exs @@ -1,6 +1,8 @@ defmodule WcaLiveWeb.Schema.AccountsMutationTypesTest do use WcaLiveWeb.ConnCase + import WcaLive.Factory + describe "mutation: generate one time code" do @generate_otc_mutation """ mutation GenerateOneTimeCode { @@ -28,4 +30,76 @@ defmodule WcaLiveWeb.Schema.AccountsMutationTypesTest do assert %{"data" => %{"generateOneTimeCode" => %{"oneTimeCode" => %{"code" => _}}}} = body end end + + describe "mutation: generate scoretaking token" do + @generate_token_mutation """ + mutation GenerateScoretakingToken($input: GenerateScoretakingTokenInput!) { + generateScoretakingToken(input: $input) { + token + scoretakingToken { + id + } + } + } + """ + + test "returns an error when not authenticated", %{conn: conn} do + competition = insert(:competition) + + input = %{"competitionId" => competition.id} + + conn = + post(conn, "/api", %{ + "query" => @generate_token_mutation, + "variables" => %{"input" => input} + }) + + body = json_response(conn, 200) + assert %{"errors" => [%{"message" => "not authenticated"}]} = body + end + + @tag :signed_in + test "returns an error when the user is not authorized to scoretake the given competition", + %{conn: conn} do + competition = insert(:competition) + + input = %{"competitionId" => competition.id} + + conn = + post(conn, "/api", %{ + "query" => @generate_token_mutation, + "variables" => %{"input" => input} + }) + + body = json_response(conn, 200) + + assert %{ + "errors" => [ + %{"message" => "you do not have scoretaking access for this competition"} + ] + } = body + end + + @tag :signed_in + test "returns the generated token", %{conn: conn, current_user: current_user} do + competition = insert(:competition) + insert(:staff_member, competition: competition, user: current_user, roles: ["delegate"]) + + input = %{"competitionId" => competition.id} + + conn = + post(conn, "/api", %{ + "query" => @generate_token_mutation, + "variables" => %{"input" => input} + }) + + body = json_response(conn, 200) + + assert %{ + "data" => %{ + "generateScoretakingToken" => %{"token" => _, "scoretakingToken" => %{"id" => _}} + } + } = body + end + end end