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 (
+
+ );
+}
+
+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